From c0a899c1cecfb9f845ebacee415510a63807542c Mon Sep 17 00:00:00 2001 From: Thomas Date: Fri, 13 Feb 2026 23:28:43 +0100 Subject: [PATCH 1/5] feat(applock): add API module with public contracts --- feature/applock/api/build.gradle.kts | 16 +++ .../api/AppLockAuthenticatorFactory.kt | 13 +++ .../feature/applock/api/AppLockGate.kt | 38 +++++++ .../applock/api/AppLockSettingsNavigation.kt | 8 ++ .../applock/api/AppLockAuthenticator.kt | 16 +++ .../feature/applock/api/AppLockConfig.kt | 25 +++++ .../feature/applock/api/AppLockCoordinator.kt | 98 +++++++++++++++++++ .../feature/applock/api/AppLockError.kt | 54 ++++++++++ .../feature/applock/api/AppLockResult.kt | 11 +++ .../feature/applock/api/AppLockState.kt | 86 ++++++++++++++++ settings.gradle.kts | 4 + 11 files changed, 369 insertions(+) create mode 100644 feature/applock/api/build.gradle.kts create mode 100644 feature/applock/api/src/androidMain/kotlin/net/thunderbird/feature/applock/api/AppLockAuthenticatorFactory.kt create mode 100644 feature/applock/api/src/androidMain/kotlin/net/thunderbird/feature/applock/api/AppLockGate.kt create mode 100644 feature/applock/api/src/androidMain/kotlin/net/thunderbird/feature/applock/api/AppLockSettingsNavigation.kt create mode 100644 feature/applock/api/src/commonMain/kotlin/net/thunderbird/feature/applock/api/AppLockAuthenticator.kt create mode 100644 feature/applock/api/src/commonMain/kotlin/net/thunderbird/feature/applock/api/AppLockConfig.kt create mode 100644 feature/applock/api/src/commonMain/kotlin/net/thunderbird/feature/applock/api/AppLockCoordinator.kt create mode 100644 feature/applock/api/src/commonMain/kotlin/net/thunderbird/feature/applock/api/AppLockError.kt create mode 100644 feature/applock/api/src/commonMain/kotlin/net/thunderbird/feature/applock/api/AppLockResult.kt create mode 100644 feature/applock/api/src/commonMain/kotlin/net/thunderbird/feature/applock/api/AppLockState.kt diff --git a/feature/applock/api/build.gradle.kts b/feature/applock/api/build.gradle.kts new file mode 100644 index 00000000000..89877b2ccb8 --- /dev/null +++ b/feature/applock/api/build.gradle.kts @@ -0,0 +1,16 @@ +plugins { + id(ThunderbirdPlugins.Library.kmp) +} + +kotlin { + androidLibrary { + namespace = "net.thunderbird.feature.applock.api" + withHostTest {} + } + sourceSets { + commonMain.dependencies { + api(projects.core.outcome) + api(libs.kotlinx.coroutines.core) + } + } +} diff --git a/feature/applock/api/src/androidMain/kotlin/net/thunderbird/feature/applock/api/AppLockAuthenticatorFactory.kt b/feature/applock/api/src/androidMain/kotlin/net/thunderbird/feature/applock/api/AppLockAuthenticatorFactory.kt new file mode 100644 index 00000000000..bfba3072022 --- /dev/null +++ b/feature/applock/api/src/androidMain/kotlin/net/thunderbird/feature/applock/api/AppLockAuthenticatorFactory.kt @@ -0,0 +1,13 @@ +package net.thunderbird.feature.applock.api + +import androidx.fragment.app.FragmentActivity + +/** + * Factory for creating [AppLockAuthenticator] instances bound to a specific activity. + * + * This allows modules that depend on the API to create authenticators without + * depending on the concrete implementation (e.g., BiometricAuthenticator). + */ +fun interface AppLockAuthenticatorFactory { + fun create(activity: FragmentActivity): AppLockAuthenticator +} diff --git a/feature/applock/api/src/androidMain/kotlin/net/thunderbird/feature/applock/api/AppLockGate.kt b/feature/applock/api/src/androidMain/kotlin/net/thunderbird/feature/applock/api/AppLockGate.kt new file mode 100644 index 00000000000..cd820fabd2c --- /dev/null +++ b/feature/applock/api/src/androidMain/kotlin/net/thunderbird/feature/applock/api/AppLockGate.kt @@ -0,0 +1,38 @@ +package net.thunderbird.feature.applock.api + +import androidx.fragment.app.FragmentActivity +import androidx.lifecycle.DefaultLifecycleObserver + +/** + * Lifecycle-aware component that handles app lock UI and authentication. + * + * Add this observer to an Activity's lifecycle to automatically: + * - Show/hide a lock overlay when the app is locked + * - Trigger biometric authentication when needed + * - Handle authentication results + * + * Usage: + * ``` + * class MyActivity : AppCompatActivity() { + * private val appLockGate: AppLockGate by inject { parametersOf(this) } + * + * override fun onCreate(savedInstanceState: Bundle?) { + * super.onCreate(savedInstanceState) + * lifecycle.addObserver(appLockGate) + * } + * } + * ``` + */ +interface AppLockGate : DefaultLifecycleObserver { + /** + * Factory for creating [AppLockGate] instances bound to a specific activity. + */ + interface Factory { + /** + * Create an [AppLockGate] for the given activity. + * + * @param activity The FragmentActivity to bind to (needed for BiometricPrompt) + */ + fun create(activity: FragmentActivity): AppLockGate + } +} diff --git a/feature/applock/api/src/androidMain/kotlin/net/thunderbird/feature/applock/api/AppLockSettingsNavigation.kt b/feature/applock/api/src/androidMain/kotlin/net/thunderbird/feature/applock/api/AppLockSettingsNavigation.kt new file mode 100644 index 00000000000..e865a1eedde --- /dev/null +++ b/feature/applock/api/src/androidMain/kotlin/net/thunderbird/feature/applock/api/AppLockSettingsNavigation.kt @@ -0,0 +1,8 @@ +package net.thunderbird.feature.applock.api + +import android.content.Context +import android.content.Intent + +fun interface AppLockSettingsNavigation { + fun createIntent(context: Context): Intent +} diff --git a/feature/applock/api/src/commonMain/kotlin/net/thunderbird/feature/applock/api/AppLockAuthenticator.kt b/feature/applock/api/src/commonMain/kotlin/net/thunderbird/feature/applock/api/AppLockAuthenticator.kt new file mode 100644 index 00000000000..73a32d3c297 --- /dev/null +++ b/feature/applock/api/src/commonMain/kotlin/net/thunderbird/feature/applock/api/AppLockAuthenticator.kt @@ -0,0 +1,16 @@ +package net.thunderbird.feature.applock.api + +/** + * Functional interface for authenticating a user. + * + * This abstraction allows for different authentication implementations + * (biometric, device credential, etc.) and testing with fakes. + */ +fun interface AppLockAuthenticator { + /** + * Authenticate the user. + * + * @return An [AppLockResult] representing the outcome. + */ + suspend fun authenticate(): AppLockResult +} diff --git a/feature/applock/api/src/commonMain/kotlin/net/thunderbird/feature/applock/api/AppLockConfig.kt b/feature/applock/api/src/commonMain/kotlin/net/thunderbird/feature/applock/api/AppLockConfig.kt new file mode 100644 index 00000000000..2becf43402a --- /dev/null +++ b/feature/applock/api/src/commonMain/kotlin/net/thunderbird/feature/applock/api/AppLockConfig.kt @@ -0,0 +1,25 @@ +package net.thunderbird.feature.applock.api + +/** + * Configuration settings for app lock. + * + * @property isEnabled Whether biometric/device authentication is enabled. + * @property timeoutMillis Timeout in milliseconds after which re-authentication is required + * when the app returns from background. Use 0 for immediate re-authentication. + */ +data class AppLockConfig( + val isEnabled: Boolean = DEFAULT_ENABLED, + val timeoutMillis: Long = DEFAULT_TIMEOUT_MILLIS, +) { + companion object { + /** + * Default: App lock is disabled. + */ + const val DEFAULT_ENABLED = false + + /** + * Default timeout: 0 (immediate re-authentication required when returning from background). + */ + const val DEFAULT_TIMEOUT_MILLIS = 0L + } +} diff --git a/feature/applock/api/src/commonMain/kotlin/net/thunderbird/feature/applock/api/AppLockCoordinator.kt b/feature/applock/api/src/commonMain/kotlin/net/thunderbird/feature/applock/api/AppLockCoordinator.kt new file mode 100644 index 00000000000..93dfd13f354 --- /dev/null +++ b/feature/applock/api/src/commonMain/kotlin/net/thunderbird/feature/applock/api/AppLockCoordinator.kt @@ -0,0 +1,98 @@ +package net.thunderbird.feature.applock.api + +import kotlinx.coroutines.flow.StateFlow + +/** + * Coordinates app lock flow and orchestration. + * + * This is the main public API for the app lock feature. Other modules should + * only interact with app lock through this interface. + * + * Uses a pull model: Activities observe [state] and call [ensureUnlocked] when + * they need to ensure the app is unlocked. No effect bus is used for prompting. + * + * **Threading contract:** All methods must be called on the main thread. + * State mutations are not thread-safe. Callers must not invoke methods from + * background threads or coroutine dispatchers other than [Dispatchers.Main]. + */ +interface AppLockCoordinator { + /** + * Observable app lock state for UI rendering. + */ + val state: StateFlow + + /** + * Current app lock configuration. + */ + val config: AppLockConfig + + /** + * Whether app lock is currently enabled in settings. + */ + val isEnabled: Boolean + get() = config.isEnabled + + /** + * Whether authentication (biometric or device credential) is available on this device. + */ + val isAuthenticationAvailable: Boolean + + /** + * Notify that the app came to foreground. + */ + fun onAppForegrounded() + + /** + * Notify that the app went to background. + */ + fun onAppBackgrounded() + + /** + * Notify that the screen turned off. Immediately locks the app if enabled. + */ + fun onScreenOff() + + /** + * Lock the app immediately. + */ + fun lockNow() + + /** + * Request unlock. + * + * Call this from Activity.onResume() when state is not Unlocked/Disabled. + * Transitions Locked/Failed → Unlocking if not already unlocking. + * + * @return true if unlock was initiated or already unlocked/disabled, + * false if already unlocking (caller should wait, not show duplicate prompt) + */ + fun ensureUnlocked(): Boolean + + /** + * Update app lock configuration. + */ + fun onSettingsChanged(config: AppLockConfig) + + /** + * Authenticate using the provided authenticator. + * Call this when state is [AppLockState.Unlocking]. + */ + suspend fun authenticate(authenticator: AppLockAuthenticator): AppLockResult + + /** + * Authenticate and enable app lock in a single operation. + * + * Unlike [onSettingsChanged], this authenticates *before* persisting the config change. + * On success, config is persisted with `isEnabled = true` and state transitions to Unlocked. + * On failure, no config or state change occurs. + * + * @return [AppLockResult] indicating success or the authentication error. + */ + suspend fun requestEnable(authenticator: AppLockAuthenticator): AppLockResult + + /** + * Re-check authentication availability after returning from device settings. + * Transitions Unavailable -> Locked if auth is now available. + */ + fun refreshAvailability() +} diff --git a/feature/applock/api/src/commonMain/kotlin/net/thunderbird/feature/applock/api/AppLockError.kt b/feature/applock/api/src/commonMain/kotlin/net/thunderbird/feature/applock/api/AppLockError.kt new file mode 100644 index 00000000000..757a47a46da --- /dev/null +++ b/feature/applock/api/src/commonMain/kotlin/net/thunderbird/feature/applock/api/AppLockError.kt @@ -0,0 +1,54 @@ +package net.thunderbird.feature.applock.api + +/** + * Authentication error types that can occur during the authentication process. + */ +sealed interface AppLockError { + /** + * Device authentication is not available on this device. + */ + data object NotAvailable : AppLockError + + /** + * User has not enrolled any biometric credentials or device credentials. + */ + data object NotEnrolled : AppLockError + + /** + * Authentication attempt failed. + */ + data object Failed : AppLockError + + /** + * User explicitly canceled the authentication dialog. + */ + data object Canceled : AppLockError + + /** + * Authentication was interrupted by the system (e.g., app went to background, + * configuration change, or fragment lifecycle). Should retry silently. + */ + data object Interrupted : AppLockError + + /** + * Too many failed attempts, user is locked out. + * + * @property durationSeconds The duration of the lockout: + * - `> 0`: Temporary lockout with known duration in seconds + * - `== 0`: Temporary lockout with unknown duration + * - `< 0`: Permanent lockout (user must unlock device with PIN/pattern/password to reset) + */ + data class Lockout(val durationSeconds: Int) : AppLockError { + companion object { + const val DURATION_UNKNOWN = 0 + const val DURATION_PERMANENT = -1 + } + } + + /** + * Unable to start the authentication system. + * + * @property message A description of why authentication could not start. + */ + data class UnableToStart(val message: String) : AppLockError +} diff --git a/feature/applock/api/src/commonMain/kotlin/net/thunderbird/feature/applock/api/AppLockResult.kt b/feature/applock/api/src/commonMain/kotlin/net/thunderbird/feature/applock/api/AppLockResult.kt new file mode 100644 index 00000000000..40c491920a4 --- /dev/null +++ b/feature/applock/api/src/commonMain/kotlin/net/thunderbird/feature/applock/api/AppLockResult.kt @@ -0,0 +1,11 @@ +package net.thunderbird.feature.applock.api + +import net.thunderbird.core.outcome.Outcome + +/** + * Type alias for the result of an authentication operation. + * + * Returns [Outcome.Success] with [Unit] on successful authentication, + * or [Outcome.Failure] with an [AppLockError] on failure. + */ +typealias AppLockResult = Outcome diff --git a/feature/applock/api/src/commonMain/kotlin/net/thunderbird/feature/applock/api/AppLockState.kt b/feature/applock/api/src/commonMain/kotlin/net/thunderbird/feature/applock/api/AppLockState.kt new file mode 100644 index 00000000000..ac6518a773b --- /dev/null +++ b/feature/applock/api/src/commonMain/kotlin/net/thunderbird/feature/applock/api/AppLockState.kt @@ -0,0 +1,86 @@ +package net.thunderbird.feature.applock.api + +/** + * Unified state for the app lock feature. + */ +sealed interface AppLockState { + /** + * App lock is disabled by user preference - no authentication required. + */ + data object Disabled : AppLockState + + /** + * App lock is enabled but authentication is unavailable on this device. + * Access is blocked (fail-closed). User should be shown guidance to restore + * authentication availability, plus an explicit option to close the app. + * + * @property reason Why authentication is unavailable. + */ + data class Unavailable(val reason: UnavailableReason) : AppLockState + + /** + * App lock is enabled and authentication is required. + */ + data object Locked : AppLockState + + /** + * Authentication is currently in progress. + * + * @property attemptId Internal identifier for correlating auth results. + */ + data class Unlocking( + val attemptId: Long, + ) : AppLockState + + /** + * User has successfully authenticated. + * + * @property lastHiddenAtElapsedMillis Elapsed realtime when app went to background, or null if visible. + */ + data class Unlocked( + val lastHiddenAtElapsedMillis: Long? = null, + ) : AppLockState + + /** + * Authentication failed with an error. + * + * @property error The error from the failed authentication attempt. + */ + data class Failed(val error: AppLockError) : AppLockState +} + +/** + * Reason why authentication is unavailable on this device. + */ +enum class UnavailableReason { + /** + * Device does not have biometric or credential hardware. + */ + NO_HARDWARE, + + /** + * User has not enrolled any biometrics or device credentials. + */ + NOT_ENROLLED, + + /** + * Authentication hardware is temporarily unavailable. + * Usually resolves without user setup changes. + */ + TEMPORARILY_UNAVAILABLE, + + /** + * Authentication is unavailable for an unknown reason. + */ + UNKNOWN, +} + +/** + * Check if the app is unlocked (authenticated or lock disabled by user). + * + * Note: [AppLockState.Unavailable] is NOT considered unlocked - it blocks access + * because lock was enabled but authentication became unavailable. + */ +fun AppLockState.isUnlocked(): Boolean { + return this is AppLockState.Unlocked || this is AppLockState.Disabled +} diff --git a/settings.gradle.kts b/settings.gradle.kts index 63b1a4cdd07..077247af7eb 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -61,6 +61,10 @@ include( ":feature:launcher", ) +include( + ":feature:applock:api", +) + include( ":feature:account:api", ":feature:account:avatar:api", From 0c69bcbecd7faa28b03debd876eca1d4f99f6621 Mon Sep 17 00:00:00 2001 From: Thomas Date: Fri, 13 Feb 2026 23:29:59 +0100 Subject: [PATCH 2/5] feat(applock): add domain layer with coordinator and authentication --- .../applock/impl/data/AppLockConfigStore.kt | 42 +++ .../applock/impl/data/AppLockPreferences.kt | 11 + .../impl/domain/AppLockAvailability.kt | 16 + .../impl/domain/AppLockConfigRepository.kt | 12 + .../impl/domain/AppLockLifecycleHandler.kt | 20 ++ .../impl/domain/BiometricAuthenticator.kt | 145 ++++++++ .../domain/BiometricAuthenticatorFactory.kt | 16 + .../impl/domain/DefaultAppLockCoordinator.kt | 315 ++++++++++++++++++ .../domain/DefaultAppLockLifecycleHandler.kt | 54 +++ .../DefaultBiometricAvailabilityChecker.kt | 25 ++ 10 files changed, 656 insertions(+) create mode 100644 feature/applock/impl/src/main/kotlin/net/thunderbird/feature/applock/impl/data/AppLockConfigStore.kt create mode 100644 feature/applock/impl/src/main/kotlin/net/thunderbird/feature/applock/impl/data/AppLockPreferences.kt create mode 100644 feature/applock/impl/src/main/kotlin/net/thunderbird/feature/applock/impl/domain/AppLockAvailability.kt create mode 100644 feature/applock/impl/src/main/kotlin/net/thunderbird/feature/applock/impl/domain/AppLockConfigRepository.kt create mode 100644 feature/applock/impl/src/main/kotlin/net/thunderbird/feature/applock/impl/domain/AppLockLifecycleHandler.kt create mode 100644 feature/applock/impl/src/main/kotlin/net/thunderbird/feature/applock/impl/domain/BiometricAuthenticator.kt create mode 100644 feature/applock/impl/src/main/kotlin/net/thunderbird/feature/applock/impl/domain/BiometricAuthenticatorFactory.kt create mode 100644 feature/applock/impl/src/main/kotlin/net/thunderbird/feature/applock/impl/domain/DefaultAppLockCoordinator.kt create mode 100644 feature/applock/impl/src/main/kotlin/net/thunderbird/feature/applock/impl/domain/DefaultAppLockLifecycleHandler.kt create mode 100644 feature/applock/impl/src/main/kotlin/net/thunderbird/feature/applock/impl/domain/DefaultBiometricAvailabilityChecker.kt diff --git a/feature/applock/impl/src/main/kotlin/net/thunderbird/feature/applock/impl/data/AppLockConfigStore.kt b/feature/applock/impl/src/main/kotlin/net/thunderbird/feature/applock/impl/data/AppLockConfigStore.kt new file mode 100644 index 00000000000..11a278dfd64 --- /dev/null +++ b/feature/applock/impl/src/main/kotlin/net/thunderbird/feature/applock/impl/data/AppLockConfigStore.kt @@ -0,0 +1,42 @@ +package net.thunderbird.feature.applock.impl.data + +import android.content.Context +import androidx.core.content.edit +import net.thunderbird.feature.applock.api.AppLockConfig +import net.thunderbird.feature.applock.impl.domain.AppLockConfigRepository + +/** + * Storage for authentication state and configuration. + * + * Stores: + * - Authentication enabled/disabled state + * - Timeout configuration + * + * @param context Application context for creating preferences. + */ +internal class AppLockConfigStore(context: Context) : AppLockConfigRepository { + private val preferences = context.getSharedPreferences( + AppLockPreferences.PREFS_FILE_NAME, + Context.MODE_PRIVATE, + ) + + override fun getConfig(): AppLockConfig { + return AppLockConfig( + isEnabled = preferences.getBoolean( + AppLockPreferences.KEY_ENABLED, + AppLockConfig.DEFAULT_ENABLED, + ), + timeoutMillis = preferences.getLong( + AppLockPreferences.KEY_TIMEOUT_MILLIS, + AppLockConfig.DEFAULT_TIMEOUT_MILLIS, + ), + ) + } + + override fun setConfig(config: AppLockConfig) { + preferences.edit { + putBoolean(AppLockPreferences.KEY_ENABLED, config.isEnabled) + putLong(AppLockPreferences.KEY_TIMEOUT_MILLIS, config.timeoutMillis) + } + } +} diff --git a/feature/applock/impl/src/main/kotlin/net/thunderbird/feature/applock/impl/data/AppLockPreferences.kt b/feature/applock/impl/src/main/kotlin/net/thunderbird/feature/applock/impl/data/AppLockPreferences.kt new file mode 100644 index 00000000000..92d1f1f0743 --- /dev/null +++ b/feature/applock/impl/src/main/kotlin/net/thunderbird/feature/applock/impl/data/AppLockPreferences.kt @@ -0,0 +1,11 @@ +package net.thunderbird.feature.applock.impl.data + +/** + * Preference keys for authentication settings. + */ +internal object AppLockPreferences { + const val PREFS_FILE_NAME = "applock_preferences" + + const val KEY_ENABLED = "applock_enabled" + const val KEY_TIMEOUT_MILLIS = "applock_timeout_millis" +} diff --git a/feature/applock/impl/src/main/kotlin/net/thunderbird/feature/applock/impl/domain/AppLockAvailability.kt b/feature/applock/impl/src/main/kotlin/net/thunderbird/feature/applock/impl/domain/AppLockAvailability.kt new file mode 100644 index 00000000000..d327a005329 --- /dev/null +++ b/feature/applock/impl/src/main/kotlin/net/thunderbird/feature/applock/impl/domain/AppLockAvailability.kt @@ -0,0 +1,16 @@ +package net.thunderbird.feature.applock.impl.domain + +import net.thunderbird.feature.applock.api.UnavailableReason + +/** + * Checks whether authentication is available on this device. + */ +internal interface AppLockAvailability { + fun isAuthenticationAvailable(): Boolean + + /** + * Returns the reason why authentication is unavailable. + * Only valid to call when [isAuthenticationAvailable] returns false. + */ + fun getUnavailableReason(): UnavailableReason +} diff --git a/feature/applock/impl/src/main/kotlin/net/thunderbird/feature/applock/impl/domain/AppLockConfigRepository.kt b/feature/applock/impl/src/main/kotlin/net/thunderbird/feature/applock/impl/domain/AppLockConfigRepository.kt new file mode 100644 index 00000000000..0cc27fb053f --- /dev/null +++ b/feature/applock/impl/src/main/kotlin/net/thunderbird/feature/applock/impl/domain/AppLockConfigRepository.kt @@ -0,0 +1,12 @@ +package net.thunderbird.feature.applock.impl.domain + +import net.thunderbird.feature.applock.api.AppLockConfig + +/** + * Storage access for app lock configuration. + */ +internal interface AppLockConfigRepository { + fun getConfig(): AppLockConfig + + fun setConfig(config: AppLockConfig) +} diff --git a/feature/applock/impl/src/main/kotlin/net/thunderbird/feature/applock/impl/domain/AppLockLifecycleHandler.kt b/feature/applock/impl/src/main/kotlin/net/thunderbird/feature/applock/impl/domain/AppLockLifecycleHandler.kt new file mode 100644 index 00000000000..17e701d9040 --- /dev/null +++ b/feature/applock/impl/src/main/kotlin/net/thunderbird/feature/applock/impl/domain/AppLockLifecycleHandler.kt @@ -0,0 +1,20 @@ +package net.thunderbird.feature.applock.impl.domain + +import androidx.lifecycle.DefaultLifecycleObserver + +/** + * Handles registration of lifecycle observers for app lock. + * Abstracted for testability. + */ +internal interface AppLockLifecycleHandler { + /** + * Register the observer for app lifecycle and screen-off events. + * The [onScreenOff] callback is invoked when screen turns off. + */ + fun register(observer: DefaultLifecycleObserver, onScreenOff: () -> Unit) + + /** + * Unregister the lifecycle observer and screen-off receiver. + */ + fun unregister() +} diff --git a/feature/applock/impl/src/main/kotlin/net/thunderbird/feature/applock/impl/domain/BiometricAuthenticator.kt b/feature/applock/impl/src/main/kotlin/net/thunderbird/feature/applock/impl/domain/BiometricAuthenticator.kt new file mode 100644 index 00000000000..8ad76abc35c --- /dev/null +++ b/feature/applock/impl/src/main/kotlin/net/thunderbird/feature/applock/impl/domain/BiometricAuthenticator.kt @@ -0,0 +1,145 @@ +package net.thunderbird.feature.applock.impl.domain + +import android.os.Build +import androidx.biometric.BiometricManager +import androidx.biometric.BiometricPrompt +import androidx.core.content.ContextCompat +import androidx.fragment.app.FragmentActivity +import kotlin.coroutines.resume +import kotlinx.coroutines.suspendCancellableCoroutine +import net.thunderbird.core.outcome.Outcome +import net.thunderbird.feature.applock.api.AppLockAuthenticator +import net.thunderbird.feature.applock.api.AppLockError +import net.thunderbird.feature.applock.api.AppLockResult + +/** + * Returns the bitmask of allowed authenticator types for BiometricPrompt and availability checks. + * + * On API 30+ (R): allows strong biometrics, weak biometrics, and device credentials. + * On API < 30: omits BIOMETRIC_STRONG because combining it with DEVICE_CREDENTIAL + * is unreliable on older platforms. BIOMETRIC_WEAK is functionally equivalent since + * app lock does not use CryptoObject. + */ +internal fun allowedAuthenticators(): Int { + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + BiometricManager.Authenticators.BIOMETRIC_STRONG or + BiometricManager.Authenticators.BIOMETRIC_WEAK or + BiometricManager.Authenticators.DEVICE_CREDENTIAL + } else { + BiometricManager.Authenticators.BIOMETRIC_WEAK or + BiometricManager.Authenticators.DEVICE_CREDENTIAL + } +} + +/** + * An [AppLockAuthenticator] implementation that uses Android's BiometricPrompt API. + * + * Supports biometric authentication (fingerprint, face, iris) with automatic + * fallback to device credentials (PIN, pattern, password). + * + * Policy note: device credentials are allowed even when no biometrics are enrolled; + * the availability check uses the same allowed authenticators mask. + * + * Note: This class must be used within a [FragmentActivity] due to BiometricPrompt requirements. + * + * @param activity The FragmentActivity context for the BiometricPrompt. + * @param title The title displayed on the biometric prompt. + * @param subtitle The subtitle displayed on the biometric prompt. + */ +internal class BiometricAuthenticator( + private val activity: FragmentActivity, + private val title: String, + private val subtitle: String, +) : AppLockAuthenticator { + + @Suppress("TooGenericExceptionCaught") + override suspend fun authenticate(): AppLockResult = suspendCancellableCoroutine { continuation -> + val authenticationCallback = object : BiometricPrompt.AuthenticationCallback() { + override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) { + if (continuation.isActive) { + continuation.resume(Outcome.Success(Unit)) + } + } + + override fun onAuthenticationError(errorCode: Int, errString: CharSequence) { + if (continuation.isActive) { + val error = mapErrorCode(errorCode, errString.toString()) + continuation.resume(Outcome.Failure(error)) + } + } + + override fun onAuthenticationFailed() { + // Called on failed attempt but prompt stays open for retry + // No action needed as user can retry or cancel + } + } + + val promptInfo = BiometricPrompt.PromptInfo.Builder() + .setTitle(title) + .setSubtitle(subtitle) + .setAllowedAuthenticators(allowedAuthenticators()) + .build() + + val executor = ContextCompat.getMainExecutor(activity) + + val prompt = BiometricPrompt(activity, executor, authenticationCallback) + + try { + prompt.authenticate(promptInfo) + } catch (e: Exception) { + if (continuation.isActive) { + continuation.resume( + Outcome.Failure(AppLockError.UnableToStart(e.message ?: "Unknown error")), + ) + } + } + + continuation.invokeOnCancellation { + prompt.cancelAuthentication() + } + } + + companion object { + /** + * Check if biometric or device credential authentication is available. + * + * @param biometricManager The BiometricManager to check availability. + * @return `true` if authentication is available, `false` otherwise. + */ + fun isAvailable(biometricManager: BiometricManager): Boolean { + return biometricManager.canAuthenticate(allowedAuthenticators()) == BiometricManager.BIOMETRIC_SUCCESS + } + } +} + +internal fun mapErrorCode(errorCode: Int, errString: String): AppLockError { + return when (errorCode) { + BiometricPrompt.ERROR_HW_NOT_PRESENT, + BiometricPrompt.ERROR_HW_UNAVAILABLE, + -> AppLockError.NotAvailable + + BiometricPrompt.ERROR_NO_BIOMETRICS, + BiometricPrompt.ERROR_NO_DEVICE_CREDENTIAL, + -> AppLockError.NotEnrolled + + BiometricPrompt.ERROR_USER_CANCELED, + BiometricPrompt.ERROR_NEGATIVE_BUTTON, + -> AppLockError.Canceled + + BiometricPrompt.ERROR_CANCELED, + -> AppLockError.Interrupted + + BiometricPrompt.ERROR_LOCKOUT -> AppLockError.Lockout(durationSeconds = AppLockError.Lockout.DURATION_UNKNOWN) + BiometricPrompt.ERROR_LOCKOUT_PERMANENT -> AppLockError.Lockout( + durationSeconds = AppLockError.Lockout.DURATION_PERMANENT, + ) + + BiometricPrompt.ERROR_TIMEOUT, + BiometricPrompt.ERROR_UNABLE_TO_PROCESS, + BiometricPrompt.ERROR_NO_SPACE, + BiometricPrompt.ERROR_VENDOR, + -> AppLockError.Failed + + else -> AppLockError.UnableToStart(errString) + } +} diff --git a/feature/applock/impl/src/main/kotlin/net/thunderbird/feature/applock/impl/domain/BiometricAuthenticatorFactory.kt b/feature/applock/impl/src/main/kotlin/net/thunderbird/feature/applock/impl/domain/BiometricAuthenticatorFactory.kt new file mode 100644 index 00000000000..84d6e85d3c1 --- /dev/null +++ b/feature/applock/impl/src/main/kotlin/net/thunderbird/feature/applock/impl/domain/BiometricAuthenticatorFactory.kt @@ -0,0 +1,16 @@ +package net.thunderbird.feature.applock.impl.domain + +import androidx.fragment.app.FragmentActivity +import net.thunderbird.feature.applock.api.AppLockAuthenticator +import net.thunderbird.feature.applock.api.AppLockAuthenticatorFactory +import net.thunderbird.feature.applock.impl.R + +internal class BiometricAuthenticatorFactory : AppLockAuthenticatorFactory { + override fun create(activity: FragmentActivity): AppLockAuthenticator { + return BiometricAuthenticator( + activity = activity, + title = activity.getString(R.string.applock_prompt_title), + subtitle = activity.getString(R.string.applock_prompt_subtitle), + ) + } +} diff --git a/feature/applock/impl/src/main/kotlin/net/thunderbird/feature/applock/impl/domain/DefaultAppLockCoordinator.kt b/feature/applock/impl/src/main/kotlin/net/thunderbird/feature/applock/impl/domain/DefaultAppLockCoordinator.kt new file mode 100644 index 00000000000..33845eac6cd --- /dev/null +++ b/feature/applock/impl/src/main/kotlin/net/thunderbird/feature/applock/impl/domain/DefaultAppLockCoordinator.kt @@ -0,0 +1,315 @@ +package net.thunderbird.feature.applock.impl.domain + +import android.os.Looper +import android.os.SystemClock +import androidx.annotation.MainThread +import androidx.lifecycle.DefaultLifecycleObserver +import androidx.lifecycle.LifecycleOwner +import kotlin.coroutines.cancellation.CancellationException +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import net.thunderbird.core.outcome.Outcome +import net.thunderbird.feature.applock.api.AppLockAuthenticator +import net.thunderbird.feature.applock.api.AppLockConfig +import net.thunderbird.feature.applock.api.AppLockCoordinator +import net.thunderbird.feature.applock.api.AppLockError +import net.thunderbird.feature.applock.api.AppLockResult +import net.thunderbird.feature.applock.api.AppLockState + +/** + * Coordinates app lock flow: settings, availability, state, and authentication. + * + * Uses a pull model where UI explicitly calls [ensureUnlocked] to trigger authentication. + * No effect bus is used - activities observe [state] and show prompts when appropriate. + * + * State is managed in-memory and not persisted. Process death always requires + * re-authentication when app lock is enabled. The timeout only applies to + * background-to-foreground transitions within the same process. + * + * Registers itself with ProcessLifecycleOwner to track app foreground/background state, + * and listens for screen-off broadcasts to lock immediately. + */ +@Suppress("TooManyFunctions") +internal class DefaultAppLockCoordinator( + private val configRepository: AppLockConfigRepository, + private val availability: AppLockAvailability, + lifecycleHandler: AppLockLifecycleHandler? = null, + private val clock: () -> Long = { SystemClock.elapsedRealtime() }, + private val mainThreadCheck: () -> Unit = ::defaultMainThreadCheck, +) : AppLockCoordinator, DefaultLifecycleObserver { + + private val _state = MutableStateFlow(AppLockState.Disabled) + override val state: StateFlow = _state.asStateFlow() + + private var nextAttemptId: Long = 0L + private var isAuthenticating = false + + override val config: AppLockConfig + get() = configRepository.getConfig() + + override val isAuthenticationAvailable: Boolean + get() = availability.isAuthenticationAvailable() + + init { + // Initialize state based on current config (cold start) + val currentConfig = config + val biometricAvailable = isAuthenticationAvailable + _state.value = computeInitialState(currentConfig, biometricAvailable) + + // Register lifecycle observer (null in tests) + lifecycleHandler?.register(this, ::onScreenOff) + } + + // DefaultLifecycleObserver callbacks + override fun onStart(owner: LifecycleOwner) { + onAppForegrounded() + } + + override fun onStop(owner: LifecycleOwner) { + onAppBackgrounded() + } + + @MainThread + override fun onAppForegrounded() { + val currentConfig = config + val biometricAvailable = isAuthenticationAvailable + + // If disabled by user preference, set state to Disabled + if (!currentConfig.isEnabled) { + _state.value = AppLockState.Disabled + return + } + + // If enabled but auth unavailable, block access with Unavailable state + if (!biometricAvailable) { + _state.value = AppLockState.Unavailable(availability.getUnavailableReason()) + return + } + + // Evaluate timeout for Unlocked state + when (val current = _state.value) { + is AppLockState.Unlocked -> { + val lastHiddenAt = current.lastHiddenAtElapsedMillis + if (lastHiddenAt != null && isTimeoutExceeded(lastHiddenAt, currentConfig.timeoutMillis)) { + _state.value = AppLockState.Locked + } else { + // Clear the hidden timestamp since we're back in foreground + _state.value = current.copy( + lastHiddenAtElapsedMillis = null, + ) + } + } + AppLockState.Disabled, is AppLockState.Unavailable -> { + // Was disabled/unavailable, now enabled and available - require auth + _state.value = AppLockState.Locked + } + // Locked, Unlocking, Failed - keep current state, UI will call ensureUnlocked + AppLockState.Locked, is AppLockState.Unlocking, is AppLockState.Failed -> Unit + } + } + + @MainThread + override fun onAppBackgrounded() { + when (val current = _state.value) { + is AppLockState.Unlocked -> { + _state.value = current.copy(lastHiddenAtElapsedMillis = clock()) + } + is AppLockState.Unlocking, is AppLockState.Failed -> { + // Cancel unlock attempt or clear failure when backgrounded + // This allows retry on next foreground + _state.value = AppLockState.Locked + } + AppLockState.Disabled, AppLockState.Locked, is AppLockState.Unavailable -> Unit + } + } + + @MainThread + override fun onScreenOff() { + val currentConfig = config + if (currentConfig.isEnabled && isAuthenticationAvailable) { + when (_state.value) { + is AppLockState.Unlocked, is AppLockState.Unlocking -> { + _state.value = AppLockState.Locked + } + AppLockState.Disabled, AppLockState.Locked, is AppLockState.Failed, is AppLockState.Unavailable -> Unit + } + } + } + + @MainThread + override fun lockNow() { + val currentConfig = config + if (currentConfig.isEnabled && isAuthenticationAvailable) { + _state.value = AppLockState.Locked + } + } + + @MainThread + override fun ensureUnlocked(): Boolean { + return when (_state.value) { + AppLockState.Disabled, is AppLockState.Unlocked -> { + // Already unlocked + true + } + is AppLockState.Unlocking -> { + // Already unlocking - caller should not show duplicate prompt + false + } + is AppLockState.Unavailable -> { + // Auth unavailable - cannot unlock, UI should show guidance + false + } + AppLockState.Locked, is AppLockState.Failed -> { + // Transition to Unlocking + _state.value = AppLockState.Unlocking(nextAttemptId++) + true + } + } + } + + @MainThread + override fun onSettingsChanged(config: AppLockConfig) { + // Reject enabling when authentication is unavailable to prevent trapping the user + if (config.isEnabled && !isAuthenticationAvailable) { + return + } + + configRepository.setConfig(config) + + if (!config.isEnabled) { + _state.value = AppLockState.Disabled + } else { + // Lock was enabled and auth is available - require auth + when (_state.value) { + AppLockState.Disabled, is AppLockState.Unavailable -> { + _state.value = AppLockState.Locked + } + // Unlocking/Failed/Locked/Unlocked: keep as-is. In practice this branch is + // unreachable because the settings screen is behind the lock overlay, so the + // user cannot toggle app lock while locked out. + AppLockState.Locked, is AppLockState.Unlocking, is AppLockState.Failed, is AppLockState.Unlocked -> Unit + } + } + } + + @MainThread + override fun refreshAvailability() { + val currentConfig = config + val biometricAvailable = isAuthenticationAvailable + + when (_state.value) { + is AppLockState.Unavailable -> { + if (biometricAvailable && currentConfig.isEnabled) { + _state.value = AppLockState.Locked + } else if (!currentConfig.isEnabled) { + _state.value = AppLockState.Disabled + } + } + AppLockState.Disabled, AppLockState.Locked, + is AppLockState.Unlocking, is AppLockState.Unlocked, is AppLockState.Failed, + -> Unit + } + } + + @Suppress("ReturnCount") + override suspend fun requestEnable(authenticator: AppLockAuthenticator): AppLockResult { + mainThreadCheck() + + if (!isAuthenticationAvailable) { + return Outcome.Failure(AppLockError.NotAvailable) + } + + if (isAuthenticating) { + return Outcome.Failure(AppLockError.UnableToStart("Authentication already in progress")) + } + + isAuthenticating = true + try { + val result = safeAuthenticate(authenticator) + + if (result is Outcome.Success) { + val currentConfig = config + configRepository.setConfig(currentConfig.copy(isEnabled = true)) + _state.value = AppLockState.Unlocked(lastHiddenAtElapsedMillis = null) + } + + return result + } finally { + isAuthenticating = false + } + } + + @Suppress("ReturnCount") + override suspend fun authenticate(authenticator: AppLockAuthenticator): AppLockResult { + mainThreadCheck() + + // Single-flight: reject if already authenticating + if (isAuthenticating) { + return Outcome.Failure(AppLockError.UnableToStart("Authentication already in progress")) + } + + isAuthenticating = true + try { + val unlocking = _state.value as? AppLockState.Unlocking + ?: return Outcome.Failure(AppLockError.UnableToStart("Not in Unlocking state")) + + val result = safeAuthenticate(authenticator) + + // Only apply result if attemptId still matches (guards against stale results) + if ((_state.value as? AppLockState.Unlocking)?.attemptId == unlocking.attemptId) { + _state.value = resolveAuthResult(result) + } + + return result + } finally { + isAuthenticating = false + } + } + + private fun resolveAuthResult(result: AppLockResult): AppLockState { + return when (result) { + is Outcome.Success -> AppLockState.Unlocked(lastHiddenAtElapsedMillis = null) + is Outcome.Failure -> { + // System interruptions (rotation, backgrounding) go back to Locked + if (result.error is AppLockError.Interrupted) { + AppLockState.Locked + } else { + AppLockState.Failed(result.error) + } + } + } + } + + @Suppress("TooGenericExceptionCaught") + private suspend fun safeAuthenticate(authenticator: AppLockAuthenticator): AppLockResult { + return try { + authenticator.authenticate() + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + Outcome.Failure(AppLockError.UnableToStart(e.message ?: "Unknown error")) + } + } + + private fun computeInitialState(config: AppLockConfig, biometricAvailable: Boolean): AppLockState { + return if (!config.isEnabled) { + AppLockState.Disabled + } else if (!biometricAvailable) { + AppLockState.Unavailable(availability.getUnavailableReason()) + } else { + AppLockState.Locked + } + } + + private fun isTimeoutExceeded(lastHiddenAtMillis: Long, timeoutMillis: Long): Boolean { + val elapsed = clock() - lastHiddenAtMillis + return elapsed >= timeoutMillis + } +} + +private fun defaultMainThreadCheck() { + check(Looper.myLooper() == Looper.getMainLooper()) { + "AppLockCoordinator methods must be called on the main thread" + } +} diff --git a/feature/applock/impl/src/main/kotlin/net/thunderbird/feature/applock/impl/domain/DefaultAppLockLifecycleHandler.kt b/feature/applock/impl/src/main/kotlin/net/thunderbird/feature/applock/impl/domain/DefaultAppLockLifecycleHandler.kt new file mode 100644 index 00000000000..86f7460d2e7 --- /dev/null +++ b/feature/applock/impl/src/main/kotlin/net/thunderbird/feature/applock/impl/domain/DefaultAppLockLifecycleHandler.kt @@ -0,0 +1,54 @@ +package net.thunderbird.feature.applock.impl.domain + +import android.app.Application +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import androidx.core.content.ContextCompat +import androidx.lifecycle.DefaultLifecycleObserver +import androidx.lifecycle.ProcessLifecycleOwner + +/** + * Default implementation that registers with ProcessLifecycleOwner + * and listens for screen-off broadcasts. + */ +internal class DefaultAppLockLifecycleHandler( + private val application: Application, +) : AppLockLifecycleHandler { + + private var screenOffReceiver: BroadcastReceiver? = null + private var lifecycleObserver: DefaultLifecycleObserver? = null + + override fun register(observer: DefaultLifecycleObserver, onScreenOff: () -> Unit) { + // Clean up any previous registration to make this idempotent + unregister() + + lifecycleObserver = observer + ProcessLifecycleOwner.get().lifecycle.addObserver(observer) + + val receiver = object : BroadcastReceiver() { + override fun onReceive(context: Context?, intent: Intent?) { + if (intent?.action == Intent.ACTION_SCREEN_OFF) { + onScreenOff() + } + } + } + screenOffReceiver = receiver + + ContextCompat.registerReceiver( + application, + receiver, + IntentFilter(Intent.ACTION_SCREEN_OFF), + ContextCompat.RECEIVER_NOT_EXPORTED, + ) + } + + override fun unregister() { + screenOffReceiver?.let { application.unregisterReceiver(it) } + screenOffReceiver = null + + lifecycleObserver?.let { ProcessLifecycleOwner.get().lifecycle.removeObserver(it) } + lifecycleObserver = null + } +} diff --git a/feature/applock/impl/src/main/kotlin/net/thunderbird/feature/applock/impl/domain/DefaultBiometricAvailabilityChecker.kt b/feature/applock/impl/src/main/kotlin/net/thunderbird/feature/applock/impl/domain/DefaultBiometricAvailabilityChecker.kt new file mode 100644 index 00000000000..0803a1cdec9 --- /dev/null +++ b/feature/applock/impl/src/main/kotlin/net/thunderbird/feature/applock/impl/domain/DefaultBiometricAvailabilityChecker.kt @@ -0,0 +1,25 @@ +package net.thunderbird.feature.applock.impl.domain + +import androidx.biometric.BiometricManager +import net.thunderbird.feature.applock.api.UnavailableReason + +/** + * Default implementation using Android's BiometricManager. + */ +internal class DefaultBiometricAvailabilityChecker( + private val biometricManager: BiometricManager, +) : AppLockAvailability { + + override fun isAuthenticationAvailable(): Boolean { + return BiometricAuthenticator.isAvailable(biometricManager) + } + + override fun getUnavailableReason(): UnavailableReason { + return when (biometricManager.canAuthenticate(allowedAuthenticators())) { + BiometricManager.BIOMETRIC_ERROR_NONE_ENROLLED -> UnavailableReason.NOT_ENROLLED + BiometricManager.BIOMETRIC_ERROR_NO_HARDWARE -> UnavailableReason.NO_HARDWARE + BiometricManager.BIOMETRIC_ERROR_HW_UNAVAILABLE -> UnavailableReason.TEMPORARILY_UNAVAILABLE + else -> UnavailableReason.UNKNOWN + } + } +} From 564a93822ab3f5c554ea569283512df07dbfa00f Mon Sep 17 00:00:00 2001 From: Thomas Date: Fri, 13 Feb 2026 23:31:34 +0100 Subject: [PATCH 3/5] feat(applock): add UI layer with gate overlays and settings --- feature/applock/impl/build.gradle.kts | 39 ++ .../applock/impl/src/main/AndroidManifest.xml | 8 + .../applock/impl/FeatureAppLockModule.kt | 74 ++++ .../applock/impl/ui/AppLockOverlayContent.kt | 113 +++++ .../applock/impl/ui/DefaultAppLockGate.kt | 390 ++++++++++++++++++ .../ui/settings/AppLockSettingsActivity.kt | 34 ++ .../ui/settings/AppLockSettingsContent.kt | 187 +++++++++ .../ui/settings/AppLockSettingsContract.kt | 35 ++ .../impl/ui/settings/AppLockSettingsScreen.kt | 75 ++++ .../ui/settings/AppLockSettingsViewModel.kt | 64 +++ .../DefaultAppLockSettingsNavigation.kt | 11 + .../impl/src/main/res/values/strings.xml | 42 ++ settings.gradle.kts | 1 + 13 files changed, 1073 insertions(+) create mode 100644 feature/applock/impl/build.gradle.kts create mode 100644 feature/applock/impl/src/main/AndroidManifest.xml create mode 100644 feature/applock/impl/src/main/kotlin/net/thunderbird/feature/applock/impl/FeatureAppLockModule.kt create mode 100644 feature/applock/impl/src/main/kotlin/net/thunderbird/feature/applock/impl/ui/AppLockOverlayContent.kt create mode 100644 feature/applock/impl/src/main/kotlin/net/thunderbird/feature/applock/impl/ui/DefaultAppLockGate.kt create mode 100644 feature/applock/impl/src/main/kotlin/net/thunderbird/feature/applock/impl/ui/settings/AppLockSettingsActivity.kt create mode 100644 feature/applock/impl/src/main/kotlin/net/thunderbird/feature/applock/impl/ui/settings/AppLockSettingsContent.kt create mode 100644 feature/applock/impl/src/main/kotlin/net/thunderbird/feature/applock/impl/ui/settings/AppLockSettingsContract.kt create mode 100644 feature/applock/impl/src/main/kotlin/net/thunderbird/feature/applock/impl/ui/settings/AppLockSettingsScreen.kt create mode 100644 feature/applock/impl/src/main/kotlin/net/thunderbird/feature/applock/impl/ui/settings/AppLockSettingsViewModel.kt create mode 100644 feature/applock/impl/src/main/kotlin/net/thunderbird/feature/applock/impl/ui/settings/DefaultAppLockSettingsNavigation.kt create mode 100644 feature/applock/impl/src/main/res/values/strings.xml diff --git a/feature/applock/impl/build.gradle.kts b/feature/applock/impl/build.gradle.kts new file mode 100644 index 00000000000..cb8bbfd03bf --- /dev/null +++ b/feature/applock/impl/build.gradle.kts @@ -0,0 +1,39 @@ +plugins { + id(ThunderbirdPlugins.Library.androidCompose) +} + +android { + namespace = "net.thunderbird.feature.applock.impl" + resourcePrefix = "applock_" + + testOptions { + unitTests { + isIncludeAndroidResources = true + } + } +} + +dependencies { + api(projects.feature.applock.api) + + implementation(projects.core.outcome) + implementation(projects.core.common) + implementation(projects.core.ui.compose.common) + implementation(projects.core.ui.compose.designsystem) + implementation(projects.core.ui.compose.theme2.common) + implementation(projects.core.ui.theme.api) + + implementation(libs.androidx.activity.compose) + implementation(libs.androidx.biometric) + implementation(libs.androidx.compose.material3) + implementation(libs.androidx.lifecycle.process) + implementation(libs.androidx.lifecycle.runtime.ktx) + implementation(libs.kotlinx.coroutines.core) + implementation(libs.koin.android) + + testImplementation(projects.core.testing) + testImplementation(projects.core.android.testing) + testImplementation(projects.core.ui.compose.testing) + testImplementation(projects.core.ui.compose.theme2.k9mail) + testImplementation(libs.androidx.test.core) +} diff --git a/feature/applock/impl/src/main/AndroidManifest.xml b/feature/applock/impl/src/main/AndroidManifest.xml new file mode 100644 index 00000000000..314b74b1ba9 --- /dev/null +++ b/feature/applock/impl/src/main/AndroidManifest.xml @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/feature/applock/impl/src/main/kotlin/net/thunderbird/feature/applock/impl/FeatureAppLockModule.kt b/feature/applock/impl/src/main/kotlin/net/thunderbird/feature/applock/impl/FeatureAppLockModule.kt new file mode 100644 index 00000000000..723fa4b2af9 --- /dev/null +++ b/feature/applock/impl/src/main/kotlin/net/thunderbird/feature/applock/impl/FeatureAppLockModule.kt @@ -0,0 +1,74 @@ +package net.thunderbird.feature.applock.impl + +import androidx.biometric.BiometricManager +import net.thunderbird.core.ui.theme.api.FeatureThemeProvider +import net.thunderbird.feature.applock.api.AppLockAuthenticatorFactory +import net.thunderbird.feature.applock.api.AppLockCoordinator +import net.thunderbird.feature.applock.api.AppLockGate +import net.thunderbird.feature.applock.api.AppLockSettingsNavigation +import net.thunderbird.feature.applock.impl.data.AppLockConfigStore +import net.thunderbird.feature.applock.impl.domain.BiometricAuthenticatorFactory +import net.thunderbird.feature.applock.impl.domain.DefaultAppLockCoordinator +import net.thunderbird.feature.applock.impl.domain.DefaultAppLockLifecycleHandler +import net.thunderbird.feature.applock.impl.domain.DefaultBiometricAvailabilityChecker +import net.thunderbird.feature.applock.impl.ui.DefaultAppLockGateFactory +import net.thunderbird.feature.applock.impl.ui.settings.AppLockSettingsViewModel +import net.thunderbird.feature.applock.impl.ui.settings.DefaultAppLockSettingsNavigation +import org.koin.android.ext.koin.androidApplication +import org.koin.android.ext.koin.androidContext +import org.koin.core.module.Module +import org.koin.core.module.dsl.viewModel +import org.koin.dsl.module + +/** + * Koin DI module for the app lock feature. + * + * Public API: + * - [AppLockCoordinator] - Main entry point for app lock functionality + * - [AppLockGate.Factory] - Factory to create lifecycle-aware app lock handlers + */ +val featureAppLockModule: Module = module { + // Internal components + single { + AppLockConfigStore( + context = androidContext(), + ) + } + + single { + DefaultBiometricAvailabilityChecker( + biometricManager = BiometricManager.from(androidApplication()), + ) + } + + single { + DefaultAppLockLifecycleHandler( + application = androidApplication(), + ) + } + + single { + DefaultAppLockCoordinator( + configRepository = get(), + availability = get(), + lifecycleHandler = get(), + ) + } + + // Public API - only this is exposed for injection by other modules + single { get() } + single { BiometricAuthenticatorFactory() } + + // App lock gate factory for activities + single { + DefaultAppLockGateFactory( + coordinator = get(), + authenticatorFactory = get(), + themeProvider = get(), + ) + } + + // Settings UI + viewModel { AppLockSettingsViewModel(coordinator = get()) } + single { DefaultAppLockSettingsNavigation() } +} diff --git a/feature/applock/impl/src/main/kotlin/net/thunderbird/feature/applock/impl/ui/AppLockOverlayContent.kt b/feature/applock/impl/src/main/kotlin/net/thunderbird/feature/applock/impl/ui/AppLockOverlayContent.kt new file mode 100644 index 00000000000..1a852ad64a2 --- /dev/null +++ b/feature/applock/impl/src/main/kotlin/net/thunderbird/feature/applock/impl/ui/AppLockOverlayContent.kt @@ -0,0 +1,113 @@ +package net.thunderbird.feature.applock.impl.ui + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import app.k9mail.core.ui.compose.designsystem.atom.Surface +import app.k9mail.core.ui.compose.designsystem.atom.button.ButtonFilled +import app.k9mail.core.ui.compose.designsystem.atom.button.ButtonOutlined +import app.k9mail.core.ui.compose.designsystem.atom.text.TextBodyMedium +import app.k9mail.core.ui.compose.designsystem.atom.text.TextHeadlineSmall +import app.k9mail.core.ui.compose.theme2.MainTheme +import net.thunderbird.core.ui.compose.designsystem.atom.icon.Icon +import net.thunderbird.core.ui.compose.designsystem.atom.icon.Icons +import net.thunderbird.feature.applock.impl.R + +@Composable +internal fun AppLockFailedOverlay( + errorMessage: String, + onRetryClick: () -> Unit, + onCloseClick: () -> Unit, +) { + Surface(modifier = Modifier.fillMaxSize()) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = MainTheme.spacings.quadruple), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + Icon( + imageVector = Icons.Outlined.ErrorOutline, + modifier = Modifier.size(48.dp), + tint = MainTheme.colors.error, + ) + Spacer(modifier = Modifier.height(MainTheme.spacings.double)) + TextHeadlineSmall( + text = stringResource(R.string.applock_error_title), + textAlign = TextAlign.Center, + ) + Spacer(modifier = Modifier.height(MainTheme.spacings.default)) + TextBodyMedium( + text = errorMessage, + textAlign = TextAlign.Center, + ) + Spacer(modifier = Modifier.height(MainTheme.spacings.triple)) + ButtonFilled( + text = stringResource(R.string.applock_button_unlock), + onClick = onRetryClick, + ) + Spacer(modifier = Modifier.height(MainTheme.spacings.default)) + ButtonOutlined( + text = stringResource(R.string.applock_button_close_app), + onClick = onCloseClick, + ) + } + } +} + +@Composable +internal fun AppLockUnavailableOverlay( + hintMessage: String, + actionButtonText: String?, + onActionClick: (() -> Unit)?, + onCloseClick: () -> Unit, +) { + Surface(modifier = Modifier.fillMaxSize()) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = MainTheme.spacings.quadruple), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + Icon( + imageVector = Icons.Outlined.Warning, + modifier = Modifier.size(48.dp), + tint = MainTheme.colors.warning, + ) + Spacer(modifier = Modifier.height(MainTheme.spacings.double)) + TextHeadlineSmall( + text = stringResource(R.string.applock_requirements_title), + textAlign = TextAlign.Center, + ) + Spacer(modifier = Modifier.height(MainTheme.spacings.default)) + TextBodyMedium( + text = hintMessage, + textAlign = TextAlign.Center, + ) + Spacer(modifier = Modifier.height(MainTheme.spacings.triple)) + if (actionButtonText != null && onActionClick != null) { + ButtonFilled( + text = actionButtonText, + onClick = onActionClick, + ) + Spacer(modifier = Modifier.height(MainTheme.spacings.default)) + } + ButtonOutlined( + text = stringResource(R.string.applock_button_close_app), + onClick = onCloseClick, + ) + } + } +} diff --git a/feature/applock/impl/src/main/kotlin/net/thunderbird/feature/applock/impl/ui/DefaultAppLockGate.kt b/feature/applock/impl/src/main/kotlin/net/thunderbird/feature/applock/impl/ui/DefaultAppLockGate.kt new file mode 100644 index 00000000000..6d6b1798113 --- /dev/null +++ b/feature/applock/impl/src/main/kotlin/net/thunderbird/feature/applock/impl/ui/DefaultAppLockGate.kt @@ -0,0 +1,390 @@ +package net.thunderbird.feature.applock.impl.ui + +import android.content.Intent +import android.graphics.Color +import android.net.Uri +import android.provider.Settings +import android.util.TypedValue +import android.view.View +import android.view.ViewGroup +import androidx.activity.compose.BackHandler +import androidx.compose.runtime.Composable +import androidx.compose.ui.platform.ComposeView +import androidx.fragment.app.FragmentActivity +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.lifecycleScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.launch +import net.thunderbird.core.outcome.Outcome +import net.thunderbird.core.ui.theme.api.FeatureThemeProvider +import net.thunderbird.feature.applock.api.AppLockAuthenticatorFactory +import net.thunderbird.feature.applock.api.AppLockCoordinator +import net.thunderbird.feature.applock.api.AppLockError +import net.thunderbird.feature.applock.api.AppLockGate +import net.thunderbird.feature.applock.api.AppLockState +import net.thunderbird.feature.applock.api.UnavailableReason +import net.thunderbird.feature.applock.api.isUnlocked +import net.thunderbird.feature.applock.impl.R + +private const val OVERLAY_TAG_PLAIN = "applock_overlay_plain" +private const val OVERLAY_TAG_CONTENT = "applock_overlay_content" + +/** + * Default implementation of [AppLockGate] that handles lock overlay and biometric authentication. + * + * This class observes the app lock coordinator state and: + * - Shows/hides a lock overlay based on lock state + * - Triggers biometric authentication when the activity resumes in a locked state + * - Handles authentication results (success finishes normally, cancel closes app) + */ +@Suppress("TooManyFunctions") +internal class DefaultAppLockGate( + private val activity: FragmentActivity, + private val coordinator: AppLockCoordinator, + private val authenticatorFactory: AppLockAuthenticatorFactory, + private val themeProvider: FeatureThemeProvider, +) : AppLockGate { + + private sealed interface ContentOverlayState { + data class Failed(val error: AppLockError) : ContentOverlayState + data class Unavailable(val reason: UnavailableReason) : ContentOverlayState + } + + private var lockOverlay: View? = null + private var currentContentOverlayState: ContentOverlayState? = null + private var lastAttemptId: Long? = null + private var stateObserverJob: Job? = null + private var authenticationJob: Job? = null + private var isResumed: Boolean = false + + override fun onStart(owner: LifecycleOwner) { + // Start observing state changes to update overlay and trigger auth if needed + stateObserverJob = activity.lifecycleScope.launch { + coordinator.state.collect { state -> + // Update overlay based on state + when { + state.isUnlocked() -> hideLockOverlay() + state is AppLockState.Failed -> showFailedOverlay(state.error) + state is AppLockState.Unavailable -> showUnavailableOverlay(state.reason) + else -> showLockOverlay() + } + + // Trigger authentication if activity is resumed and we're in a locked state + if (isResumed) { + triggerAuthenticationIfNeeded() + } + } + } + } + + override fun onResume(owner: LifecycleOwner) { + isResumed = true + + // Refresh availability in case user set up authentication in Settings + if (coordinator.state.value is AppLockState.Unavailable) { + coordinator.refreshAvailability() + } + + triggerAuthenticationIfNeeded() + + // Hide privacy overlay if still unlocked (for quick pause/resume) + // Done after triggerAuthenticationIfNeeded to avoid ordering flicker + if (coordinator.state.value.isUnlocked()) { + hideLockOverlay() + } + } + + override fun onPause(owner: LifecycleOwner) { + isResumed = false + + // Show privacy overlay to obscure content in task switcher. + // Skip if a content overlay (Failed/Unavailable) is already visible — those overlays + // already hide app content, and replacing them with the plain overlay would discard the + // actionable UI. StateFlow won't re-emit the same state on resume, so the content + // overlay would never be restored, leaving users stuck behind a non-interactive screen. + if (coordinator.config.isEnabled && lockOverlay?.tag != OVERLAY_TAG_CONTENT) { + showLockOverlay() + } + + // Do NOT cancel authenticationJob here or in onStop. BiometricPrompt with device + // credentials (PIN/pattern/password) launches a system activity that causes both onPause + // and onStop on the host activity. Cancelling the auth would discard the result when the + // user successfully authenticates on that screen. The auth job is scoped to lifecycleScope + // and is cleaned up automatically on DESTROYED. + } + + override fun onStop(owner: LifecycleOwner) { + stateObserverJob?.cancel() + stateObserverJob = null + lastAttemptId = null // Allow relaunch on next start + } + + override fun onDestroy(owner: LifecycleOwner) { + hideLockOverlay() + lastAttemptId = null + } + + private fun triggerAuthenticationIfNeeded() { + when (val state = coordinator.state.value) { + is AppLockState.Unlocking -> { + val attemptId = state.attemptId + if (attemptId != lastAttemptId) { + lastAttemptId = attemptId + launchAuthentication() + } + } + AppLockState.Locked -> { + // Request unlock - coordinator will transition to Unlocking + if (coordinator.ensureUnlocked()) { + val newState = coordinator.state.value + if (newState is AppLockState.Unlocking) { + lastAttemptId = newState.attemptId + launchAuthentication() + } + } + } + is AppLockState.Failed -> { + // Don't auto-retry on failure to prevent infinite prompt loop. + // User can close app and reopen to retry. Overlay remains visible. + } + is AppLockState.Unavailable -> { + // Auth unavailable - show guidance overlay, no auth to trigger + } + AppLockState.Disabled, is AppLockState.Unlocked -> { + // Nothing to do + } + } + } + + private fun launchAuthentication() { + // Don't launch if already in progress + if (authenticationJob?.isActive == true) return + + // Don't launch if another activity already started authentication + if (coordinator.state.value !is AppLockState.Unlocking) return + + val authenticator = authenticatorFactory.create(activity) + + authenticationJob = activity.lifecycleScope.launch { + try { + val result = coordinator.authenticate(authenticator) + // UnableToStart is expected in multi-window when another activity is already + // authenticating. Clear lastAttemptId so the state observer can retry when + // the other activity's auth completes and the coordinator state changes. + if (result is Outcome.Failure && + result.error is AppLockError.UnableToStart && + coordinator.state.value is AppLockState.Unlocking + ) { + lastAttemptId = null + } + } finally { + authenticationJob = null + } + } + } + + private fun showLockOverlay() { + // Already showing plain lock overlay + if (lockOverlay?.tag == OVERLAY_TAG_PLAIN) { + currentContentOverlayState = null + return + } + + // Remove any existing overlay (e.g., failed overlay) + hideLockOverlay() + + val contentView = activity.findViewById(android.R.id.content) ?: return + + // Use a plain View instead of ComposeView for synchronous rendering. + // This minimizes the timing gap where the task switcher could capture + // actual content before the overlay renders. + val overlay = View(activity).apply { + layoutParams = ViewGroup.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.MATCH_PARENT, + ) + tag = OVERLAY_TAG_PLAIN + isFocusable = true + isClickable = true + setBackgroundColor(resolveWindowBackgroundColor()) + } + + contentView.addView(overlay) + lockOverlay = overlay + currentContentOverlayState = null + } + + 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 + } + } + + private fun showFailedOverlay(error: AppLockError) { + showContentOverlay(ContentOverlayState.Failed(error)) { + AppLockFailedOverlay( + errorMessage = getErrorMessage(error), + onRetryClick = ::onRetryClicked, + onCloseClick = { activity.finishAffinity() }, + ) + } + } + + private fun showUnavailableOverlay(reason: UnavailableReason) { + val actionButtonText = when (reason) { + UnavailableReason.NOT_ENROLLED -> activity.getString(R.string.applock_button_open_settings) + UnavailableReason.TEMPORARILY_UNAVAILABLE, + UnavailableReason.UNKNOWN, + -> activity.getString(R.string.applock_button_try_again) + UnavailableReason.NO_HARDWARE -> null + } + + val onActionClick: (() -> Unit)? = when (reason) { + UnavailableReason.NOT_ENROLLED -> ::openSecuritySettings + UnavailableReason.TEMPORARILY_UNAVAILABLE, + UnavailableReason.UNKNOWN, + -> ::onUnavailableRetryClicked + UnavailableReason.NO_HARDWARE -> null + } + + showContentOverlay(ContentOverlayState.Unavailable(reason)) { + AppLockUnavailableOverlay( + hintMessage = getUnavailableHint(reason), + actionButtonText = actionButtonText, + onActionClick = onActionClick, + onCloseClick = { activity.finishAffinity() }, + ) + } + } + + private fun showContentOverlay(state: ContentOverlayState, content: @Composable () -> Unit) { + // Avoid removing and re-adding when showing the exact same content. + if (lockOverlay?.tag == OVERLAY_TAG_CONTENT && currentContentOverlayState == state) return + + hideLockOverlay() + + val contentView = activity.findViewById(android.R.id.content) ?: return + + val overlay = ComposeView(activity).apply { + layoutParams = ViewGroup.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.MATCH_PARENT, + ) + tag = OVERLAY_TAG_CONTENT + isFocusable = true + isClickable = true + setContent { + BackHandler { activity.finishAffinity() } + themeProvider.WithTheme { + content() + } + } + } + + contentView.addView(overlay) + lockOverlay = overlay + currentContentOverlayState = state + } + + private fun getUnavailableHint(reason: UnavailableReason): String { + return when (reason) { + UnavailableReason.NO_HARDWARE -> activity.getString(R.string.applock_error_not_available) + UnavailableReason.NOT_ENROLLED -> activity.getString(R.string.applock_requirements_hint) + UnavailableReason.TEMPORARILY_UNAVAILABLE -> { + activity.getString(R.string.applock_error_temporarily_unavailable) + } + UnavailableReason.UNKNOWN -> activity.getString(R.string.applock_error_unknown_unavailable) + } + } + + private fun openSecuritySettings() { + listOf( + Intent(Settings.ACTION_SECURITY_SETTINGS), + Intent(Settings.ACTION_SETTINGS), + Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS, Uri.fromParts("package", activity.packageName, null)), + ).firstOrNull { it.resolveActivity(activity.packageManager) != null } + ?.let { activity.startActivity(it) } + } + + private fun onRetryClicked() { + // Just request unlock - the state collector will observe the transition + // to Unlocking and trigger authentication via triggerAuthenticationIfNeeded() + coordinator.ensureUnlocked() + } + + private fun onUnavailableRetryClicked() { + coordinator.refreshAvailability() + if (coordinator.state.value == AppLockState.Locked) { + coordinator.ensureUnlocked() + } + } + + private fun getErrorMessage(error: AppLockError): String { + return when (error) { + is AppLockError.NotAvailable -> activity.getString(R.string.applock_error_not_available) + is AppLockError.NotEnrolled -> activity.getString(R.string.applock_error_not_enrolled) + is AppLockError.Failed -> activity.getString(R.string.applock_error_failed) + is AppLockError.Canceled -> activity.getString(R.string.applock_error_canceled) + is AppLockError.Interrupted -> activity.getString(R.string.applock_error_failed) + is AppLockError.Lockout -> { + when { + error.durationSeconds == AppLockError.Lockout.DURATION_PERMANENT -> { + activity.getString(R.string.applock_error_lockout_permanent) + } + error.durationSeconds > 0 -> { + // Temporary lockout with known duration + activity.resources.getQuantityString( + R.plurals.applock_error_lockout, + error.durationSeconds, + error.durationSeconds, + ) + } + else -> { + // Temporary lockout with unknown duration + activity.getString(R.string.applock_error_lockout_unknown) + } + } + } + is AppLockError.UnableToStart -> { + activity.getString(R.string.applock_error_unable_to_start, error.message) + } + } + } + + private fun hideLockOverlay() { + lockOverlay?.let { overlay -> + (overlay.parent as? ViewGroup)?.removeView(overlay) + lockOverlay = null + } + currentContentOverlayState = null + } +} + +/** + * Factory for creating [DefaultAppLockGate] instances. + */ +internal class DefaultAppLockGateFactory( + private val coordinator: AppLockCoordinator, + private val authenticatorFactory: AppLockAuthenticatorFactory, + private val themeProvider: FeatureThemeProvider, +) : AppLockGate.Factory { + override fun create(activity: FragmentActivity): AppLockGate { + return DefaultAppLockGate( + activity = activity, + coordinator = coordinator, + authenticatorFactory = authenticatorFactory, + themeProvider = themeProvider, + ) + } +} diff --git a/feature/applock/impl/src/main/kotlin/net/thunderbird/feature/applock/impl/ui/settings/AppLockSettingsActivity.kt b/feature/applock/impl/src/main/kotlin/net/thunderbird/feature/applock/impl/ui/settings/AppLockSettingsActivity.kt new file mode 100644 index 00000000000..84a7f8ed475 --- /dev/null +++ b/feature/applock/impl/src/main/kotlin/net/thunderbird/feature/applock/impl/ui/settings/AppLockSettingsActivity.kt @@ -0,0 +1,34 @@ +package net.thunderbird.feature.applock.impl.ui.settings + +import android.content.Context +import android.content.Intent +import android.os.Bundle +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import androidx.fragment.app.FragmentActivity +import net.thunderbird.core.ui.theme.api.FeatureThemeProvider +import org.koin.android.ext.android.inject + +internal class AppLockSettingsActivity : FragmentActivity() { + + private val themeProvider: FeatureThemeProvider by inject() + + override fun onCreate(savedInstanceState: Bundle?) { + enableEdgeToEdge() + super.onCreate(savedInstanceState) + + setContent { + themeProvider.WithTheme { + AppLockSettingsScreen( + onBack = { finish() }, + ) + } + } + } + + companion object { + fun createIntent(context: Context): Intent { + return Intent(context, AppLockSettingsActivity::class.java) + } + } +} diff --git a/feature/applock/impl/src/main/kotlin/net/thunderbird/feature/applock/impl/ui/settings/AppLockSettingsContent.kt b/feature/applock/impl/src/main/kotlin/net/thunderbird/feature/applock/impl/ui/settings/AppLockSettingsContent.kt new file mode 100644 index 00000000000..39b4096f3a2 --- /dev/null +++ b/feature/applock/impl/src/main/kotlin/net/thunderbird/feature/applock/impl/ui/settings/AppLockSettingsContent.kt @@ -0,0 +1,187 @@ +package net.thunderbird.feature.applock.impl.ui.settings + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +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.res.pluralStringResource +import androidx.compose.ui.res.stringResource +import app.k9mail.core.ui.compose.designsystem.atom.Checkbox +import app.k9mail.core.ui.compose.designsystem.atom.button.RadioButton +import app.k9mail.core.ui.compose.designsystem.atom.text.TextBodyMedium +import app.k9mail.core.ui.compose.designsystem.atom.text.TextTitleMedium +import app.k9mail.core.ui.compose.designsystem.organism.AlertDialog +import app.k9mail.core.ui.compose.theme2.MainTheme +import kotlinx.collections.immutable.ImmutableList +import net.thunderbird.feature.applock.impl.R +import net.thunderbird.feature.applock.impl.ui.settings.AppLockSettingsContract.Event +import net.thunderbird.feature.applock.impl.ui.settings.AppLockSettingsContract.State + +@Composable +internal fun AppLockSettingsContent( + state: State, + onEvent: (Event) -> Unit, + contentPadding: PaddingValues, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier + .fillMaxSize() + .padding(contentPadding), + ) { + AppLockEnableRow( + isEnabled = state.isEnabled, + isAvailable = state.isAuthenticationAvailable, + onEnableChanged = { onEvent(Event.OnEnableChanged(it)) }, + ) + + AppLockTimeoutRow( + timeoutMinutes = state.timeoutMinutes, + timeoutOptions = state.timeoutOptions, + onTimeoutChanged = { onEvent(Event.OnTimeoutChanged(it)) }, + isEnabled = state.isEnabled, + ) + } +} + +@Composable +private fun AppLockEnableRow( + isEnabled: Boolean, + isAvailable: Boolean, + onEnableChanged: (Boolean) -> Unit, +) { + val contentColor = if (isAvailable) { + MainTheme.colors.onSurface + } else { + MainTheme.colors.onSurface.copy(alpha = 0.38f) + } + val secondaryColor = if (isAvailable) { + MainTheme.colors.onSurfaceVariant + } else { + MainTheme.colors.onSurfaceVariant.copy(alpha = 0.38f) + } + + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .clickable(enabled = isAvailable) { onEnableChanged(!isEnabled) } + .padding(horizontal = MainTheme.spacings.double, vertical = MainTheme.spacings.oneHalf), + ) { + Column(modifier = Modifier.weight(1f)) { + TextTitleMedium( + text = stringResource(R.string.applock_settings_title), + color = contentColor, + ) + TextBodyMedium( + text = if (isAvailable) { + stringResource(R.string.applock_settings_summary) + } else { + stringResource(R.string.applock_settings_biometric_not_available) + }, + color = secondaryColor, + ) + } + Checkbox( + checked = isEnabled, + onCheckedChange = onEnableChanged, + enabled = isAvailable, + ) + } +} + +@Composable +private fun AppLockTimeoutRow( + timeoutMinutes: Int, + timeoutOptions: ImmutableList, + onTimeoutChanged: (Int) -> Unit, + isEnabled: Boolean, +) { + var showDialog by remember { mutableStateOf(false) } + val contentColor = if (isEnabled) { + MainTheme.colors.onSurface + } else { + MainTheme.colors.onSurface.copy(alpha = 0.38f) + } + val secondaryColor = if (isEnabled) { + MainTheme.colors.onSurfaceVariant + } else { + MainTheme.colors.onSurfaceVariant.copy(alpha = 0.38f) + } + + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .clickable(enabled = isEnabled) { showDialog = true } + .padding(horizontal = MainTheme.spacings.double, vertical = MainTheme.spacings.oneHalf), + ) { + Column(modifier = Modifier.weight(1f)) { + TextTitleMedium( + text = stringResource(R.string.applock_settings_timeout_title), + color = contentColor, + ) + TextBodyMedium( + text = formatTimeout(timeoutMinutes), + color = secondaryColor, + ) + } + } + + if (showDialog) { + TimeoutSelectionDialog( + timeoutMinutes = timeoutMinutes, + timeoutOptions = timeoutOptions, + onTimeoutSelected = { minutes -> + onTimeoutChanged(minutes) + showDialog = false + }, + onDismiss = { showDialog = false }, + ) + } +} + +@Composable +private fun TimeoutSelectionDialog( + timeoutMinutes: Int, + timeoutOptions: ImmutableList, + onTimeoutSelected: (Int) -> Unit, + onDismiss: () -> Unit, +) { + AlertDialog( + title = stringResource(R.string.applock_settings_timeout_title), + confirmText = stringResource(android.R.string.cancel), + onConfirmClick = onDismiss, + onDismissRequest = onDismiss, + ) { + Column { + timeoutOptions.forEach { minutes -> + RadioButton( + selected = minutes == timeoutMinutes, + label = formatTimeout(minutes), + onClick = { onTimeoutSelected(minutes) }, + modifier = Modifier.fillMaxWidth(), + ) + } + } + } +} + +@Composable +private fun formatTimeout(minutes: Int): String { + return when (minutes) { + 0 -> stringResource(R.string.applock_settings_timeout_immediately) + 1 -> stringResource(R.string.applock_settings_timeout_1_minute) + else -> pluralStringResource(R.plurals.applock_settings_timeout_n_minutes, minutes, minutes) + } +} diff --git a/feature/applock/impl/src/main/kotlin/net/thunderbird/feature/applock/impl/ui/settings/AppLockSettingsContract.kt b/feature/applock/impl/src/main/kotlin/net/thunderbird/feature/applock/impl/ui/settings/AppLockSettingsContract.kt new file mode 100644 index 00000000000..cc90460594b --- /dev/null +++ b/feature/applock/impl/src/main/kotlin/net/thunderbird/feature/applock/impl/ui/settings/AppLockSettingsContract.kt @@ -0,0 +1,35 @@ +package net.thunderbird.feature.applock.impl.ui.settings + +import app.k9mail.core.ui.compose.common.mvi.UnidirectionalViewModel +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf +import net.thunderbird.feature.applock.api.AppLockAuthenticator + +internal interface AppLockSettingsContract { + + interface ViewModel : UnidirectionalViewModel + + data class State( + val isEnabled: Boolean = false, + val isAuthenticationAvailable: Boolean = false, + val timeoutMinutes: Int = 0, + val timeoutOptions: ImmutableList = DEFAULT_TIMEOUT_OPTIONS, + ) { + companion object { + val DEFAULT_TIMEOUT_OPTIONS = persistentListOf(0, 1, 3, 5) + } + } + + sealed interface Event { + data class OnEnableChanged(val enabled: Boolean) : Event + data class OnTimeoutChanged(val minutes: Int) : Event + data class OnAuthenticatorReady(val authenticator: AppLockAuthenticator) : Event + data object OnResume : Event + data object OnBackPressed : Event + } + + sealed interface Effect { + data object NavigateBack : Effect + data object RequestAuthentication : Effect + } +} diff --git a/feature/applock/impl/src/main/kotlin/net/thunderbird/feature/applock/impl/ui/settings/AppLockSettingsScreen.kt b/feature/applock/impl/src/main/kotlin/net/thunderbird/feature/applock/impl/ui/settings/AppLockSettingsScreen.kt new file mode 100644 index 00000000000..b225296bf8f --- /dev/null +++ b/feature/applock/impl/src/main/kotlin/net/thunderbird/feature/applock/impl/ui/settings/AppLockSettingsScreen.kt @@ -0,0 +1,75 @@ +package net.thunderbird.feature.applock.impl.ui.settings + +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.navigationBars +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.material3.Scaffold +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.fragment.app.FragmentActivity +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver +import androidx.lifecycle.compose.LocalLifecycleOwner +import app.k9mail.core.ui.compose.common.mvi.observe +import app.k9mail.core.ui.compose.designsystem.organism.TopAppBarWithBackButton +import net.thunderbird.feature.applock.api.AppLockAuthenticatorFactory +import net.thunderbird.feature.applock.impl.R +import net.thunderbird.feature.applock.impl.ui.settings.AppLockSettingsContract.Effect +import net.thunderbird.feature.applock.impl.ui.settings.AppLockSettingsContract.Event +import org.koin.androidx.compose.koinViewModel +import org.koin.compose.koinInject + +@Composable +internal fun AppLockSettingsScreen( + onBack: () -> Unit, + viewModel: AppLockSettingsContract.ViewModel = koinViewModel(), + authenticatorFactory: AppLockAuthenticatorFactory = koinInject(), +) { + val context = LocalContext.current + + val (state, dispatch) = viewModel.observe { effect -> + when (effect) { + Effect.NavigateBack -> onBack() + Effect.RequestAuthentication -> { + val activity = context as? FragmentActivity ?: return@observe + val authenticator = authenticatorFactory.create(activity) + viewModel.event(Event.OnAuthenticatorReady(authenticator)) + } + } + } + + val lifecycleOwner = LocalLifecycleOwner.current + DisposableEffect(lifecycleOwner) { + val observer = LifecycleEventObserver { _, event -> + if (event == Lifecycle.Event.ON_RESUME) { + dispatch(Event.OnResume) + } + } + lifecycleOwner.lifecycle.addObserver(observer) + onDispose { lifecycleOwner.lifecycle.removeObserver(observer) } + } + + BackHandler { + dispatch(Event.OnBackPressed) + } + + Scaffold( + topBar = { + TopAppBarWithBackButton( + title = stringResource(R.string.applock_settings_screen_title), + onBackClick = { dispatch(Event.OnBackPressed) }, + ) + }, + modifier = Modifier.windowInsetsPadding(WindowInsets.navigationBars), + ) { innerPadding -> + AppLockSettingsContent( + state = state.value, + onEvent = { dispatch(it) }, + contentPadding = innerPadding, + ) + } +} diff --git a/feature/applock/impl/src/main/kotlin/net/thunderbird/feature/applock/impl/ui/settings/AppLockSettingsViewModel.kt b/feature/applock/impl/src/main/kotlin/net/thunderbird/feature/applock/impl/ui/settings/AppLockSettingsViewModel.kt new file mode 100644 index 00000000000..1da289cccf8 --- /dev/null +++ b/feature/applock/impl/src/main/kotlin/net/thunderbird/feature/applock/impl/ui/settings/AppLockSettingsViewModel.kt @@ -0,0 +1,64 @@ +package net.thunderbird.feature.applock.impl.ui.settings + +import androidx.lifecycle.viewModelScope +import app.k9mail.core.ui.compose.common.mvi.BaseViewModel +import kotlinx.coroutines.launch +import net.thunderbird.core.outcome.Outcome +import net.thunderbird.feature.applock.api.AppLockCoordinator +import net.thunderbird.feature.applock.impl.ui.settings.AppLockSettingsContract.Effect +import net.thunderbird.feature.applock.impl.ui.settings.AppLockSettingsContract.Event +import net.thunderbird.feature.applock.impl.ui.settings.AppLockSettingsContract.State + +private const val MILLIS_PER_MINUTE = 60_000L + +internal class AppLockSettingsViewModel( + private val coordinator: AppLockCoordinator, +) : BaseViewModel( + initialState = State( + isEnabled = coordinator.config.isEnabled, + isAuthenticationAvailable = coordinator.isAuthenticationAvailable, + timeoutMinutes = (coordinator.config.timeoutMillis / MILLIS_PER_MINUTE).toInt(), + ), +), + AppLockSettingsContract.ViewModel { + + override fun event(event: Event) { + when (event) { + is Event.OnEnableChanged -> handleEnableChanged(event.enabled) + is Event.OnTimeoutChanged -> handleTimeoutChanged(event.minutes) + is Event.OnAuthenticatorReady -> handleAuthenticatorReady(event) + Event.OnResume -> handleResume() + Event.OnBackPressed -> emitEffect(Effect.NavigateBack) + } + } + + private fun handleEnableChanged(enabled: Boolean) { + if (enabled) { + emitEffect(Effect.RequestAuthentication) + } else { + val currentConfig = coordinator.config + coordinator.onSettingsChanged(currentConfig.copy(isEnabled = false)) + updateState { it.copy(isEnabled = false) } + } + } + + private fun handleAuthenticatorReady(event: Event.OnAuthenticatorReady) { + viewModelScope.launch { + val result = coordinator.requestEnable(event.authenticator) + if (result is Outcome.Success) { + updateState { it.copy(isEnabled = true) } + } + } + } + + private fun handleResume() { + updateState { it.copy(isAuthenticationAvailable = coordinator.isAuthenticationAvailable) } + } + + private fun handleTimeoutChanged(minutes: Int) { + val currentConfig = coordinator.config + val timeoutMillis = minutes * MILLIS_PER_MINUTE + coordinator.onSettingsChanged(currentConfig.copy(timeoutMillis = timeoutMillis)) + updateState { it.copy(timeoutMinutes = minutes) } + } +} diff --git a/feature/applock/impl/src/main/kotlin/net/thunderbird/feature/applock/impl/ui/settings/DefaultAppLockSettingsNavigation.kt b/feature/applock/impl/src/main/kotlin/net/thunderbird/feature/applock/impl/ui/settings/DefaultAppLockSettingsNavigation.kt new file mode 100644 index 00000000000..95487b8d6e7 --- /dev/null +++ b/feature/applock/impl/src/main/kotlin/net/thunderbird/feature/applock/impl/ui/settings/DefaultAppLockSettingsNavigation.kt @@ -0,0 +1,11 @@ +package net.thunderbird.feature.applock.impl.ui.settings + +import android.content.Context +import android.content.Intent +import net.thunderbird.feature.applock.api.AppLockSettingsNavigation + +internal class DefaultAppLockSettingsNavigation : AppLockSettingsNavigation { + override fun createIntent(context: Context): Intent { + return AppLockSettingsActivity.createIntent(context) + } +} diff --git a/feature/applock/impl/src/main/res/values/strings.xml b/feature/applock/impl/src/main/res/values/strings.xml new file mode 100644 index 00000000000..691baf7c226 --- /dev/null +++ b/feature/applock/impl/src/main/res/values/strings.xml @@ -0,0 +1,42 @@ + + + Unlock + + + Unlock app + Authenticate to access your email + + + Unlock Failed + Device authentication is not available on this device. App lock stays active, so this app cannot be opened here. + No biometric credentials or screen lock set up. Please set up a screen lock in device settings. + Authentication failed. Please try again. + Authentication was canceled. + + Too many failed attempts. Please try again in %d second. + Too many failed attempts. Please try again in %d seconds. + + Too many failed attempts. Please try again later. + Too many failed attempts. Please unlock your device using your PIN, pattern, or password. + Unable to start authentication: %s + Device authentication is temporarily unavailable. Please try again. + Unable to determine device authentication status. Please try again later. + App Lock Unavailable + Set up a screen lock (PIN, pattern, or password) in device settings to continue. + Open Settings + Try again + Close App + + + Security + App lock + Require device authentication to open the app. This does not encrypt data stored on the device. If authentication becomes unavailable, access remains blocked until it is restored. + Device authentication is not available. Activate device lock to enable app lock. + Lock timeout + Immediately + 1 minute + + %d minute + %d minutes + + diff --git a/settings.gradle.kts b/settings.gradle.kts index 077247af7eb..e79834e3f81 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -63,6 +63,7 @@ include( include( ":feature:applock:api", + ":feature:applock:impl", ) include( From ea945941c4a12c81708190d689ed8953a1c2b5bd Mon Sep 17 00:00:00 2001 From: Thomas Date: Fri, 13 Feb 2026 23:32:15 +0100 Subject: [PATCH 4/5] feat(applock): integrate with app-common and legacy settings --- app-common/build.gradle.kts | 9 +++++++ .../thunderbird/app/common/BaseApplication.kt | 9 +++++++ .../common/feature/AppCommonFeatureModule.kt | 2 ++ .../AppLockActivityLifecycleCallbacks.kt | 27 +++++++++++++++++++ legacy/ui/legacy/build.gradle.kts | 1 + .../general/GeneralSettingsFragment.kt | 13 +++++++++ .../ui/legacy/src/main/res/values/strings.xml | 1 + .../src/main/res/xml/general_settings.xml | 7 +++++ 8 files changed, 69 insertions(+) create mode 100644 app-common/src/main/kotlin/net/thunderbird/app/common/feature/applock/AppLockActivityLifecycleCallbacks.kt diff --git a/app-common/build.gradle.kts b/app-common/build.gradle.kts index 7d713c68051..57f878fe451 100644 --- a/app-common/build.gradle.kts +++ b/app-common/build.gradle.kts @@ -8,6 +8,12 @@ android { buildFeatures { buildConfig = true } + + testOptions { + unitTests { + isIncludeAndroidResources = true + } + } } dependencies { @@ -42,6 +48,8 @@ dependencies { implementation(projects.feature.account.avatar.impl) implementation(projects.feature.account.settings.api) implementation(projects.feature.account.setup) + implementation(projects.feature.applock.api) + implementation(projects.feature.applock.impl) implementation(projects.feature.mail.account.api) implementation(projects.feature.mail.message.composer) implementation(projects.feature.migration.provider) @@ -64,6 +72,7 @@ dependencies { testImplementation(projects.feature.account.fake) testImplementation(projects.core.testing) + testImplementation(projects.core.android.testing) } codeCoverage { diff --git a/app-common/src/main/kotlin/net/thunderbird/app/common/BaseApplication.kt b/app-common/src/main/kotlin/net/thunderbird/app/common/BaseApplication.kt index 1872ec753d4..40f6f895bf0 100644 --- a/app-common/src/main/kotlin/net/thunderbird/app/common/BaseApplication.kt +++ b/app-common/src/main/kotlin/net/thunderbird/app/common/BaseApplication.kt @@ -23,11 +23,14 @@ import kotlinx.coroutines.flow.drop import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach import net.thunderbird.app.common.feature.LoggerLifecycleObserver +import net.thunderbird.app.common.feature.applock.AppLockActivityLifecycleCallbacks import net.thunderbird.core.common.exception.ExceptionHandler import net.thunderbird.core.logging.Logger import net.thunderbird.core.logging.file.FileLogSink import net.thunderbird.core.logging.legacy.Log import net.thunderbird.core.ui.theme.manager.ThemeManager +import net.thunderbird.feature.applock.api.AppLockGate +import org.koin.android.ext.android.getKoin import org.koin.android.ext.android.inject import org.koin.core.module.Module import org.koin.core.qualifier.named @@ -75,6 +78,12 @@ abstract class BaseApplication : Application(), WorkManagerConfiguration.Provide Thread.setDefaultUncaughtExceptionHandler(ExceptionHandler(originalHandler)) ProcessLifecycleOwner.get().lifecycle.addObserver(LoggerLifecycleObserver(syncDebugFileLogSink)) + + registerActivityLifecycleCallbacks( + AppLockActivityLifecycleCallbacks( + gateFactory = getKoin().getOrNull(AppLockGate.Factory::class), + ), + ) } abstract fun provideAppModule(): Module diff --git a/app-common/src/main/kotlin/net/thunderbird/app/common/feature/AppCommonFeatureModule.kt b/app-common/src/main/kotlin/net/thunderbird/app/common/feature/AppCommonFeatureModule.kt index bd75505a654..696119d60c1 100644 --- a/app-common/src/main/kotlin/net/thunderbird/app/common/feature/AppCommonFeatureModule.kt +++ b/app-common/src/main/kotlin/net/thunderbird/app/common/feature/AppCommonFeatureModule.kt @@ -4,6 +4,7 @@ import app.k9mail.feature.launcher.FeatureLauncherExternalContract import app.k9mail.feature.launcher.di.featureLauncherModule import net.thunderbird.app.common.feature.mail.appCommonFeatureMailModule import net.thunderbird.feature.account.avatar.di.featureAccountAvatarModule +import net.thunderbird.feature.applock.impl.featureAppLockModule import net.thunderbird.feature.mail.message.composer.inject.featureMessageComposerModule import net.thunderbird.feature.mail.message.reader.impl.inject.featureMessageReaderModule import net.thunderbird.feature.navigation.drawer.api.NavigationDrawerExternalContract @@ -14,6 +15,7 @@ import org.koin.dsl.module internal val appCommonFeatureModule = module { includes(appCommonFeatureMailModule) includes(featureAccountAvatarModule) + includes(featureAppLockModule) includes(featureLauncherModule) includes(featureNotificationModule) includes(featureMessageComposerModule) diff --git a/app-common/src/main/kotlin/net/thunderbird/app/common/feature/applock/AppLockActivityLifecycleCallbacks.kt b/app-common/src/main/kotlin/net/thunderbird/app/common/feature/applock/AppLockActivityLifecycleCallbacks.kt new file mode 100644 index 00000000000..221d536cfb3 --- /dev/null +++ b/app-common/src/main/kotlin/net/thunderbird/app/common/feature/applock/AppLockActivityLifecycleCallbacks.kt @@ -0,0 +1,27 @@ +package net.thunderbird.app.common.feature.applock + +import android.app.Activity +import android.app.Application.ActivityLifecycleCallbacks +import android.os.Bundle +import androidx.fragment.app.FragmentActivity +import net.thunderbird.feature.applock.api.AppLockGate + +internal class AppLockActivityLifecycleCallbacks( + private val gateFactory: AppLockGate.Factory?, +) : ActivityLifecycleCallbacks { + + override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) { + if (gateFactory == null) return + if (activity !is FragmentActivity) return + + val gate = gateFactory.create(activity) + activity.lifecycle.addObserver(gate) + } + + override fun onActivityStarted(activity: Activity) = Unit + override fun onActivityResumed(activity: Activity) = Unit + override fun onActivityPaused(activity: Activity) = Unit + override fun onActivityStopped(activity: Activity) = Unit + override fun onActivitySaveInstanceState(activity: Activity, outState: Bundle) = Unit + override fun onActivityDestroyed(activity: Activity) = Unit +} diff --git a/legacy/ui/legacy/build.gradle.kts b/legacy/ui/legacy/build.gradle.kts index 697ef5cc7cc..feab5bd7e73 100644 --- a/legacy/ui/legacy/build.gradle.kts +++ b/legacy/ui/legacy/build.gradle.kts @@ -33,6 +33,7 @@ dependencies { implementation(projects.feature.search.implLegacy) implementation(projects.feature.settings.import) implementation(projects.feature.telemetry.api) + implementation(projects.feature.applock.api) implementation(projects.feature.mail.message.list.api) implementation(projects.feature.mail.message.composer) implementation(projects.feature.mail.message.export.api) diff --git a/legacy/ui/legacy/src/main/java/com/fsck/k9/ui/settings/general/GeneralSettingsFragment.kt b/legacy/ui/legacy/src/main/java/com/fsck/k9/ui/settings/general/GeneralSettingsFragment.kt index 241e1378dcc..33ca3c8b9ce 100644 --- a/legacy/ui/legacy/src/main/java/com/fsck/k9/ui/settings/general/GeneralSettingsFragment.kt +++ b/legacy/ui/legacy/src/main/java/com/fsck/k9/ui/settings/general/GeneralSettingsFragment.kt @@ -27,15 +27,18 @@ import java.util.Calendar import java.util.Locale import net.thunderbird.core.featureflag.FeatureFlagProvider import net.thunderbird.core.featureflag.toFeatureFlagKey +import net.thunderbird.feature.applock.api.AppLockSettingsNavigation import org.koin.android.ext.android.inject import org.koin.androidx.viewmodel.ext.android.viewModel +@Suppress("TooManyFunctions") class GeneralSettingsFragment : PreferenceFragmentCompat() { private val viewModel: GeneralSettingsViewModel by viewModel() private val dataStore: GeneralSettingsDataStore by inject() private val telemetryManager: TelemetryManager by inject() private val featureFlagProvider: FeatureFlagProvider by inject() private val jobManager: K9JobManager by inject() + private val appLockSettingsNavigation: AppLockSettingsNavigation by inject() private var rootKey: String? = null private var currentUiState: GeneralSettingsUiState? = null @@ -112,6 +115,7 @@ class GeneralSettingsFragment : PreferenceFragmentCompat() { } initializeDataCollection() + initializeSecurityPreferences() viewModel.uiState.observe(this) { uiState -> updateUiState(uiState) @@ -171,6 +175,14 @@ class GeneralSettingsFragment : PreferenceFragmentCompat() { } } + private fun initializeSecurityPreferences() { + findPreference(PREFERENCE_SECURITY)?.onPreferenceClickListener = + Preference.OnPreferenceClickListener { + startActivity(appLockSettingsNavigation.createIntent(requireContext())) + true + } + } + private fun updateUiState(uiState: GeneralSettingsUiState) { val oldUiState = currentUiState currentUiState = uiState @@ -220,6 +232,7 @@ class GeneralSettingsFragment : PreferenceFragmentCompat() { companion object { private const val PREFERENCE_SCREEN_DEBUGGING = "debug_preferences" private const val PREFERENCE_DATA_COLLECTION = "data_collection" + private const val PREFERENCE_SECURITY = "security_preferences" const val DEFAULT_SYNC_FILENAME = "thunderbird-sync-logs" fun create(rootKey: String? = null) = GeneralSettingsFragment().withArguments(ARG_PREFERENCE_ROOT to rootKey) diff --git a/legacy/ui/legacy/src/main/res/values/strings.xml b/legacy/ui/legacy/src/main/res/values/strings.xml index cb4f756e2de..801b20928dc 100644 --- a/legacy/ui/legacy/src/main/res/values/strings.xml +++ b/legacy/ui/legacy/src/main/res/values/strings.xml @@ -635,6 +635,7 @@ Global Debugging Privacy + Security Network Interaction Account list diff --git a/legacy/ui/legacy/src/main/res/xml/general_settings.xml b/legacy/ui/legacy/src/main/res/xml/general_settings.xml index a1eee65d9eb..b6c3474211a 100644 --- a/legacy/ui/legacy/src/main/res/xml/general_settings.xml +++ b/legacy/ui/legacy/src/main/res/xml/general_settings.xml @@ -522,6 +522,13 @@ + + Date: Fri, 13 Feb 2026 23:35:39 +0100 Subject: [PATCH 5/5] test(applock): add comprehensive test suite --- .../AppLockActivityLifecycleCallbacksTest.kt | 59 ++ .../impl/data/AppLockConfigStoreTest.kt | 66 ++ .../domain/DefaultAppLockCoordinatorTest.kt | 685 ++++++++++++++++++ .../impl/domain/FakeAppLockCoordinator.kt | 169 +++++ .../applock/impl/domain/FakeAuthenticator.kt | 26 + .../applock/impl/domain/MapErrorCodeTest.kt | 110 +++ .../impl/ui/AppLockFailedOverlayTest.kt | 94 +++ .../impl/ui/AppLockUnavailableOverlayTest.kt | 112 +++ .../applock/impl/ui/DefaultAppLockGateTest.kt | 599 +++++++++++++++ .../settings/AppLockSettingsViewModelTest.kt | 121 ++++ .../src/test/java/com/fsck/k9/TestApp.kt | 2 + 11 files changed, 2043 insertions(+) create mode 100644 app-common/src/test/kotlin/net/thunderbird/app/common/feature/applock/AppLockActivityLifecycleCallbacksTest.kt create mode 100644 feature/applock/impl/src/test/kotlin/net/thunderbird/feature/applock/impl/data/AppLockConfigStoreTest.kt create mode 100644 feature/applock/impl/src/test/kotlin/net/thunderbird/feature/applock/impl/domain/DefaultAppLockCoordinatorTest.kt create mode 100644 feature/applock/impl/src/test/kotlin/net/thunderbird/feature/applock/impl/domain/FakeAppLockCoordinator.kt create mode 100644 feature/applock/impl/src/test/kotlin/net/thunderbird/feature/applock/impl/domain/FakeAuthenticator.kt create mode 100644 feature/applock/impl/src/test/kotlin/net/thunderbird/feature/applock/impl/domain/MapErrorCodeTest.kt create mode 100644 feature/applock/impl/src/test/kotlin/net/thunderbird/feature/applock/impl/ui/AppLockFailedOverlayTest.kt create mode 100644 feature/applock/impl/src/test/kotlin/net/thunderbird/feature/applock/impl/ui/AppLockUnavailableOverlayTest.kt create mode 100644 feature/applock/impl/src/test/kotlin/net/thunderbird/feature/applock/impl/ui/DefaultAppLockGateTest.kt create mode 100644 feature/applock/impl/src/test/kotlin/net/thunderbird/feature/applock/impl/ui/settings/AppLockSettingsViewModelTest.kt diff --git a/app-common/src/test/kotlin/net/thunderbird/app/common/feature/applock/AppLockActivityLifecycleCallbacksTest.kt b/app-common/src/test/kotlin/net/thunderbird/app/common/feature/applock/AppLockActivityLifecycleCallbacksTest.kt new file mode 100644 index 00000000000..bc7a19b3d85 --- /dev/null +++ b/app-common/src/test/kotlin/net/thunderbird/app/common/feature/applock/AppLockActivityLifecycleCallbacksTest.kt @@ -0,0 +1,59 @@ +package net.thunderbird.app.common.feature.applock + +import android.app.Activity +import android.os.Build +import androidx.fragment.app.FragmentActivity +import assertk.assertThat +import assertk.assertions.isEmpty +import assertk.assertions.isEqualTo +import assertk.assertions.single +import net.thunderbird.core.android.testing.RobolectricTest +import net.thunderbird.feature.applock.api.AppLockGate +import org.junit.Test +import org.robolectric.Robolectric +import org.robolectric.annotation.Config + +@Config(sdk = [Build.VERSION_CODES.TIRAMISU]) +class AppLockActivityLifecycleCallbacksTest : RobolectricTest() { + + @Test + fun `should not crash when factory is null`() { + val testSubject = AppLockActivityLifecycleCallbacks(gateFactory = null) + val activity = Robolectric.buildActivity(FragmentActivity::class.java).create().get() + + testSubject.onActivityCreated(activity, null) + } + + @Test + fun `should create gate for FragmentActivity`() { + val fakeFactory = FakeAppLockGateFactory() + val testSubject = AppLockActivityLifecycleCallbacks(gateFactory = fakeFactory) + val activity = Robolectric.buildActivity(FragmentActivity::class.java).create().get() + + testSubject.onActivityCreated(activity, null) + + assertThat(fakeFactory.createdActivities).single().isEqualTo(activity) + } + + @Test + fun `should not create gate for plain Activity`() { + val fakeFactory = FakeAppLockGateFactory() + val testSubject = AppLockActivityLifecycleCallbacks(gateFactory = fakeFactory) + val activity = Robolectric.buildActivity(Activity::class.java).create().get() + + testSubject.onActivityCreated(activity, null) + + assertThat(fakeFactory.createdActivities).isEmpty() + } + + private class FakeAppLockGateFactory : AppLockGate.Factory { + val createdActivities = mutableListOf() + + override fun create(activity: FragmentActivity): AppLockGate { + createdActivities.add(activity) + return FakeAppLockGate() + } + } + + private class FakeAppLockGate : AppLockGate +} diff --git a/feature/applock/impl/src/test/kotlin/net/thunderbird/feature/applock/impl/data/AppLockConfigStoreTest.kt b/feature/applock/impl/src/test/kotlin/net/thunderbird/feature/applock/impl/data/AppLockConfigStoreTest.kt new file mode 100644 index 00000000000..f28d2637823 --- /dev/null +++ b/feature/applock/impl/src/test/kotlin/net/thunderbird/feature/applock/impl/data/AppLockConfigStoreTest.kt @@ -0,0 +1,66 @@ +package net.thunderbird.feature.applock.impl.data + +import android.os.Build +import androidx.test.core.app.ApplicationProvider +import assertk.assertThat +import assertk.assertions.isEqualTo +import assertk.assertions.isFalse +import assertk.assertions.isTrue +import net.thunderbird.feature.applock.api.AppLockConfig +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [Build.VERSION_CODES.TIRAMISU]) +class AppLockConfigStoreTest { + + private val testSubject = AppLockConfigStore(ApplicationProvider.getApplicationContext()) + + @Test + fun `should return default config when nothing is stored`() { + val config = testSubject.getConfig() + + assertThat(config.isEnabled).isFalse() + assertThat(config.timeoutMillis).isEqualTo(AppLockConfig.DEFAULT_TIMEOUT_MILLIS) + } + + @Test + fun `should persist and retrieve enabled state`() { + testSubject.setConfig(AppLockConfig(isEnabled = true)) + + val config = testSubject.getConfig() + + assertThat(config.isEnabled).isTrue() + } + + @Test + fun `should persist and retrieve timeout`() { + testSubject.setConfig(AppLockConfig(timeoutMillis = 60_000L)) + + val config = testSubject.getConfig() + + assertThat(config.timeoutMillis).isEqualTo(60_000L) + } + + @Test + fun `should persist and retrieve full config`() { + val expected = AppLockConfig(isEnabled = true, timeoutMillis = 120_000L) + + testSubject.setConfig(expected) + + assertThat(testSubject.getConfig()).isEqualTo(expected) + } + + @Test + fun `should overwrite previous config`() { + testSubject.setConfig(AppLockConfig(isEnabled = true, timeoutMillis = 60_000L)) + testSubject.setConfig(AppLockConfig(isEnabled = false, timeoutMillis = 30_000L)) + + val config = testSubject.getConfig() + + assertThat(config.isEnabled).isFalse() + assertThat(config.timeoutMillis).isEqualTo(30_000L) + } +} diff --git a/feature/applock/impl/src/test/kotlin/net/thunderbird/feature/applock/impl/domain/DefaultAppLockCoordinatorTest.kt b/feature/applock/impl/src/test/kotlin/net/thunderbird/feature/applock/impl/domain/DefaultAppLockCoordinatorTest.kt new file mode 100644 index 00000000000..645ab2696a8 --- /dev/null +++ b/feature/applock/impl/src/test/kotlin/net/thunderbird/feature/applock/impl/domain/DefaultAppLockCoordinatorTest.kt @@ -0,0 +1,685 @@ +package net.thunderbird.feature.applock.impl.domain + +import assertk.assertThat +import assertk.assertions.isEqualTo +import assertk.assertions.isFalse +import assertk.assertions.isInstanceOf +import assertk.assertions.isTrue +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.runTest +import net.thunderbird.core.outcome.Outcome +import net.thunderbird.feature.applock.api.AppLockAuthenticator +import net.thunderbird.feature.applock.api.AppLockConfig +import net.thunderbird.feature.applock.api.AppLockError +import net.thunderbird.feature.applock.api.AppLockResult +import net.thunderbird.feature.applock.api.AppLockState +import net.thunderbird.feature.applock.api.UnavailableReason +import net.thunderbird.feature.applock.api.isUnlocked +import org.junit.Test + +class DefaultAppLockCoordinatorTest { + + @Test + fun `should require auth on cold start when enabled`() = runTest { + val testSubject = createTestSubject( + config = AppLockConfig(isEnabled = true), + ) + + assertThat(testSubject.state.value).isEqualTo(AppLockState.Locked) + } + + @Test + fun `should return Unavailable on cold start when enabled but auth unavailable`() = runTest { + val testSubject = createTestSubject( + config = AppLockConfig(isEnabled = true), + biometricAvailable = false, + unavailableReason = UnavailableReason.NOT_ENROLLED, + ) + + assertThat(testSubject.state.value).isEqualTo(AppLockState.Unavailable(UnavailableReason.NOT_ENROLLED)) + } + + @Test + fun `should do nothing on foreground when feature is disabled`() = runTest { + val testSubject = createTestSubject( + config = AppLockConfig(isEnabled = false), + ) + + testSubject.onAppForegrounded() + + assertThat(testSubject.state.value).isEqualTo(AppLockState.Disabled) + } + + @Test + fun `should transition to Unavailable on foreground when auth is unavailable`() = runTest { + val testSubject = createTestSubject( + config = AppLockConfig(isEnabled = true), + biometricAvailable = false, + unavailableReason = UnavailableReason.NO_HARDWARE, + ) + + testSubject.onAppForegrounded() + + assertThat(testSubject.state.value).isEqualTo(AppLockState.Unavailable(UnavailableReason.NO_HARDWARE)) + } + + @Test + fun `should keep Locked state on foreground when pull model requires ensureUnlocked`() = runTest { + val testSubject = createTestSubject( + config = AppLockConfig(isEnabled = true), + ) + + testSubject.onAppForegrounded() + + // In pull model, onAppForegrounded does NOT auto-transition to Unlocking + assertThat(testSubject.state.value).isEqualTo(AppLockState.Locked) + } + + @Test + fun `should transition Locked to Unlocking when ensureUnlocked called`() = runTest { + val testSubject = createTestSubject( + config = AppLockConfig(isEnabled = true), + ) + + val result = testSubject.ensureUnlocked() + + assertThat(result).isTrue() + assertThat(testSubject.state.value).isInstanceOf() + } + + @Test + fun `should return false when ensureUnlocked called and already Unlocking`() = runTest { + val testSubject = createTestSubject( + config = AppLockConfig(isEnabled = true), + ) + + testSubject.ensureUnlocked() + assertThat(testSubject.state.value).isInstanceOf() + + // Second call should return false (already unlocking) + val result = testSubject.ensureUnlocked() + + assertThat(result).isFalse() + } + + @Test + fun `should return true when ensureUnlocked called and already Unlocked`() = runTest { + val testSubject = createTestSubject( + config = AppLockConfig(isEnabled = true), + ) + + testSubject.ensureUnlocked() + testSubject.authenticate(FakeAuthenticator.success()) + + val result = testSubject.ensureUnlocked() + + assertThat(result).isTrue() + assertThat(testSubject.state.value).isInstanceOf() + } + + @Test + fun `should transition Failed to Unlocking when ensureUnlocked called`() = runTest { + val testSubject = createTestSubject( + config = AppLockConfig(isEnabled = true), + ) + + testSubject.ensureUnlocked() + testSubject.authenticate(FakeAuthenticator.failure(AppLockError.Failed)) + assertThat(testSubject.state.value).isEqualTo(AppLockState.Failed(AppLockError.Failed)) + + val result = testSubject.ensureUnlocked() + + assertThat(result).isTrue() + assertThat(testSubject.state.value).isInstanceOf() + } + + @Test + fun `should lock on foreground when timeout exceeded since background`() = runTest { + var now = 100_000L + val testSubject = createTestSubject( + config = AppLockConfig(isEnabled = true, timeoutMillis = 60_000L), + clock = { now }, + ) + + // Unlock + testSubject.ensureUnlocked() + testSubject.authenticate(FakeAuthenticator.success()) + assertThat(testSubject.state.value).isInstanceOf() + + // Go to background and advance time past timeout + testSubject.onAppBackgrounded() + now += 120_000L + + testSubject.onAppForegrounded() + + assertThat(testSubject.state.value).isEqualTo(AppLockState.Locked) + } + + @Test + fun `should stay Unlocked on foreground when timeout not exceeded since background`() = runTest { + var now = 100_000L + val testSubject = createTestSubject( + config = AppLockConfig(isEnabled = true, timeoutMillis = 60_000L), + clock = { now }, + ) + + // Unlock + testSubject.ensureUnlocked() + testSubject.authenticate(FakeAuthenticator.success()) + assertThat(testSubject.state.value).isInstanceOf() + + // Go to background but don't advance time past timeout + testSubject.onAppBackgrounded() + now += 30_000L + + testSubject.onAppForegrounded() + + assertThat(testSubject.state.value).isInstanceOf() + } + + @Test + fun `should lock immediately on foreground when timeout is zero`() = runTest { + var now = 100_000L + val testSubject = createTestSubject( + config = AppLockConfig(isEnabled = true, timeoutMillis = 0L), + clock = { now }, + ) + + // Unlock + testSubject.ensureUnlocked() + testSubject.authenticate(FakeAuthenticator.success()) + assertThat(testSubject.state.value).isInstanceOf() + + // Go to background and advance time minimally + testSubject.onAppBackgrounded() + now += 1L + + testSubject.onAppForegrounded() + + assertThat(testSubject.state.value).isEqualTo(AppLockState.Locked) + } + + @Test + fun `should cancel Unlocking state when backgrounded`() = runTest { + val testSubject = createTestSubject( + config = AppLockConfig(isEnabled = true), + ) + + testSubject.ensureUnlocked() + assertThat(testSubject.state.value).isInstanceOf() + + testSubject.onAppBackgrounded() + + assertThat(testSubject.state.value).isEqualTo(AppLockState.Locked) + } + + @Test + fun `should lock when screen off and Unlocked`() = runTest { + val testSubject = createTestSubject( + config = AppLockConfig(isEnabled = true), + ) + + testSubject.ensureUnlocked() + testSubject.authenticate(FakeAuthenticator.success()) + assertThat(testSubject.state.value).isInstanceOf() + + testSubject.onScreenOff() + + assertThat(testSubject.state.value).isEqualTo(AppLockState.Locked) + } + + @Test + fun `should lock when screen off and Unlocking`() = runTest { + val testSubject = createTestSubject( + config = AppLockConfig(isEnabled = true), + ) + + testSubject.ensureUnlocked() + assertThat(testSubject.state.value).isInstanceOf() + + testSubject.onScreenOff() + + assertThat(testSubject.state.value).isEqualTo(AppLockState.Locked) + } + + @Test + fun `should do nothing when screen off and Disabled`() = runTest { + val testSubject = createTestSubject( + config = AppLockConfig(isEnabled = false), + ) + + testSubject.onScreenOff() + + assertThat(testSubject.state.value).isEqualTo(AppLockState.Disabled) + } + + @Test + fun `should transition to Locked when lockNow called`() = runTest { + val testSubject = createTestSubject( + config = AppLockConfig(isEnabled = true), + ) + + testSubject.ensureUnlocked() + testSubject.authenticate(FakeAuthenticator.success()) + assertThat(testSubject.state.value).isInstanceOf() + + testSubject.lockNow() + + assertThat(testSubject.state.value).isEqualTo(AppLockState.Locked) + } + + @Test + fun `should transition to Locked when settings changed to enabled`() = runTest { + val testSubject = createTestSubject( + config = AppLockConfig(isEnabled = false), + ) + + testSubject.onSettingsChanged(AppLockConfig(isEnabled = true)) + + assertThat(testSubject.state.value).isEqualTo(AppLockState.Locked) + } + + @Test + fun `should update state when authentication succeeds`() = runTest { + val testSubject = createTestSubject( + config = AppLockConfig(isEnabled = true), + ) + + testSubject.ensureUnlocked() + assertThat(testSubject.state.value).isInstanceOf() + + val result = testSubject.authenticate(FakeAuthenticator.success()) + + assertThat(result).isEqualTo(Outcome.Success(Unit)) + assertThat(testSubject.state.value).isInstanceOf() + } + + @Test + fun `should update state when authentication fails`() = runTest { + val testSubject = createTestSubject( + config = AppLockConfig(isEnabled = true), + ) + + testSubject.ensureUnlocked() + assertThat(testSubject.state.value).isInstanceOf() + + val result = testSubject.authenticate(FakeAuthenticator.failure(AppLockError.Failed)) + + assertThat(result).isEqualTo(Outcome.Failure(AppLockError.Failed)) + assertThat(testSubject.state.value).isEqualTo(AppLockState.Failed(AppLockError.Failed)) + } + + @Test + fun `should return error when authenticate called and not in Unlocking state`() = runTest { + val testSubject = createTestSubject( + config = AppLockConfig(isEnabled = true), + ) + + // Don't call ensureUnlocked - state is Locked, not Unlocking + assertThat(testSubject.state.value).isEqualTo(AppLockState.Locked) + + val result = testSubject.authenticate(FakeAuthenticator.success()) + + assertThat(result).isEqualTo(Outcome.Failure(AppLockError.UnableToStart("Not in Unlocking state"))) + } + + @Test + fun `should transition to Locked when authentication interrupted`() = runTest { + val testSubject = createTestSubject( + config = AppLockConfig(isEnabled = true), + ) + + testSubject.ensureUnlocked() + val result = testSubject.authenticate(FakeAuthenticator.failure(AppLockError.Interrupted)) + + assertThat(result).isEqualTo(Outcome.Failure(AppLockError.Interrupted)) + assertThat(testSubject.state.value).isEqualTo(AppLockState.Locked) + } + + @Test + fun `should return true for isEnabled when feature is enabled`() = runTest { + val testSubject = createTestSubject( + config = AppLockConfig(isEnabled = true), + ) + + assertThat(testSubject.isEnabled).isTrue() + } + + @Test + fun `should return false for isEnabled when feature is disabled`() = runTest { + val testSubject = createTestSubject( + config = AppLockConfig(isEnabled = false), + ) + + assertThat(testSubject.isEnabled).isFalse() + } + + @Test + fun `should disable lock when settings changed to disabled`() = runTest { + val testSubject = createTestSubject( + config = AppLockConfig(isEnabled = true), + ) + assertThat(testSubject.state.value).isEqualTo(AppLockState.Locked) + + testSubject.onSettingsChanged(AppLockConfig(isEnabled = false)) + + assertThat(testSubject.state.value).isEqualTo(AppLockState.Disabled) + } + + @Test + fun `should allow successful authentication when ensureUnlocked called after failure`() = runTest { + val testSubject = createTestSubject( + config = AppLockConfig(isEnabled = true), + ) + + testSubject.ensureUnlocked() + testSubject.authenticate(FakeAuthenticator.failure(AppLockError.Failed)) + assertThat(testSubject.state.value).isEqualTo(AppLockState.Failed(AppLockError.Failed)) + + // ensureUnlocked transitions Failed -> Unlocking + testSubject.ensureUnlocked() + assertThat(testSubject.state.value).isInstanceOf() + + val result = testSubject.authenticate(FakeAuthenticator.success()) + + assertThat(result).isEqualTo(Outcome.Success(Unit)) + assertThat(testSubject.state.value).isInstanceOf() + } + + @Test + fun `should return false when ensureUnlocked called and Unavailable`() = runTest { + val testSubject = createTestSubject( + config = AppLockConfig(isEnabled = true), + biometricAvailable = false, + unavailableReason = UnavailableReason.NOT_ENROLLED, + ) + assertThat(testSubject.state.value).isInstanceOf() + + val result = testSubject.ensureUnlocked() + + assertThat(result).isFalse() + assertThat(testSubject.state.value).isInstanceOf() + } + + @Test + fun `should return false for isUnlocked when state is Unavailable`() = runTest { + val state = AppLockState.Unavailable(UnavailableReason.NOT_ENROLLED) + + assertThat(state.isUnlocked()).isFalse() + } + + @Test + fun `should reject concurrent authenticate calls`() = runTest { + val testSubject = createTestSubject( + config = AppLockConfig(isEnabled = true), + ) + + testSubject.ensureUnlocked() + + // First call - use a suspending authenticator + val suspendingAuthenticator = SuspendingAuthenticator() + val firstJob = launch { + testSubject.authenticate(suspendingAuthenticator) + } + + // Wait for first call to start + suspendingAuthenticator.awaitStarted() + + // Second concurrent call should be rejected + val result = testSubject.authenticate(FakeAuthenticator.success()) + + assertThat(result).isEqualTo( + Outcome.Failure(AppLockError.UnableToStart("Authentication already in progress")), + ) + + // Complete first call + suspendingAuthenticator.complete(Outcome.Success(Unit)) + firstJob.join() + } + + @Test + fun `should transition Unavailable to Locked when refreshAvailability finds auth available`() = runTest { + val availability = FakeAppLockAvailability(available = false, reason = UnavailableReason.NOT_ENROLLED) + val testSubject = createTestSubjectWithAvailability( + config = AppLockConfig(isEnabled = true), + availability = availability, + ) + + assertThat(testSubject.state.value).isEqualTo(AppLockState.Unavailable(UnavailableReason.NOT_ENROLLED)) + + // User sets up authentication in device settings + availability.setAvailable(true) + testSubject.refreshAvailability() + + assertThat(testSubject.state.value).isEqualTo(AppLockState.Locked) + } + + @Test + fun `should do nothing when refreshAvailability called and not in Unavailable state`() = runTest { + val testSubject = createTestSubject( + config = AppLockConfig(isEnabled = true), + ) + + assertThat(testSubject.state.value).isEqualTo(AppLockState.Locked) + + testSubject.refreshAvailability() + + // State should remain Locked + assertThat(testSubject.state.value).isEqualTo(AppLockState.Locked) + } + + @Test + fun `should transition to Disabled when refreshAvailability called and lock is disabled`() = runTest { + val configRepository = FakeAppLockConfigRepository(AppLockConfig(isEnabled = true)) + val availability = FakeAppLockAvailability(available = false, reason = UnavailableReason.NOT_ENROLLED) + val testSubject = DefaultAppLockCoordinator( + configRepository = configRepository, + availability = availability, + mainThreadCheck = {}, + ) + + assertThat(testSubject.state.value).isEqualTo(AppLockState.Unavailable(UnavailableReason.NOT_ENROLLED)) + + // User disabled app lock while in unavailable state + configRepository.setConfig(AppLockConfig(isEnabled = false)) + testSubject.refreshAvailability() + + assertThat(testSubject.state.value).isEqualTo(AppLockState.Disabled) + } + + @Test + fun `should authenticate and enable when requestEnable succeeds`() = runTest { + val testSubject = createTestSubject( + config = AppLockConfig(isEnabled = false), + ) + assertThat(testSubject.state.value).isEqualTo(AppLockState.Disabled) + + val result = testSubject.requestEnable(FakeAuthenticator.success()) + + assertThat(result).isEqualTo(Outcome.Success(Unit)) + assertThat(testSubject.config.isEnabled).isTrue() + assertThat(testSubject.state.value).isInstanceOf() + } + + @Test + fun `should not enable when requestEnable authentication fails`() = runTest { + val testSubject = createTestSubject( + config = AppLockConfig(isEnabled = false), + ) + assertThat(testSubject.state.value).isEqualTo(AppLockState.Disabled) + + val result = testSubject.requestEnable(FakeAuthenticator.failure(AppLockError.Canceled)) + + assertThat(result).isEqualTo(Outcome.Failure(AppLockError.Canceled)) + assertThat(testSubject.config.isEnabled).isFalse() + assertThat(testSubject.state.value).isEqualTo(AppLockState.Disabled) + } + + @Test + fun `should reject requestEnable when auth unavailable`() = runTest { + val testSubject = createTestSubject( + config = AppLockConfig(isEnabled = false), + biometricAvailable = false, + ) + + val result = testSubject.requestEnable(FakeAuthenticator.success()) + + assertThat(result).isEqualTo(Outcome.Failure(AppLockError.NotAvailable)) + assertThat(testSubject.config.isEnabled).isFalse() + } + + @Test + fun `should reject concurrent requestEnable calls`() = runTest { + val testSubject = createTestSubject( + config = AppLockConfig(isEnabled = false), + ) + + val suspendingAuthenticator = SuspendingAuthenticator() + val firstJob = launch { + testSubject.requestEnable(suspendingAuthenticator) + } + + suspendingAuthenticator.awaitStarted() + + val result = testSubject.requestEnable(FakeAuthenticator.success()) + + assertThat(result).isEqualTo( + Outcome.Failure(AppLockError.UnableToStart("Authentication already in progress")), + ) + + suspendingAuthenticator.complete(Outcome.Success(Unit)) + firstJob.join() + } + + @Test + fun `should transition Failed to Locked when backgrounded`() = runTest { + val testSubject = createTestSubject( + config = AppLockConfig(isEnabled = true), + ) + + testSubject.ensureUnlocked() + testSubject.authenticate(FakeAuthenticator.failure(AppLockError.Failed)) + assertThat(testSubject.state.value).isEqualTo(AppLockState.Failed(AppLockError.Failed)) + + testSubject.onAppBackgrounded() + + assertThat(testSubject.state.value).isEqualTo(AppLockState.Locked) + } + + @Test + fun `should require re-auth when backgrounded during credential activity`() = runTest { + val testSubject = createTestSubject( + config = AppLockConfig(isEnabled = true), + ) + + // Start unlock flow + testSubject.ensureUnlocked() + assertThat(testSubject.state.value).isInstanceOf() + + // Simulate: auth is in progress (e.g., credential activity launched) + val suspendingAuthenticator = SuspendingAuthenticator() + val authJob = launch { + testSubject.authenticate(suspendingAuthenticator) + } + suspendingAuthenticator.awaitStarted() + + // App goes to background (e.g., user switches away) — coordinator resets to Locked + testSubject.onAppBackgrounded() + assertThat(testSubject.state.value).isEqualTo(AppLockState.Locked) + + // The credential activity completes successfully, but coordinator already moved to Locked + suspendingAuthenticator.complete(Outcome.Success(Unit)) + authJob.join() + + // State remains Locked — the successful auth result is discarded because the coordinator + // was no longer in Unlocking state. This is intentional: backgrounding invalidates any + // in-flight authentication, requiring the user to re-authenticate on next foreground. + assertThat(testSubject.state.value).isEqualTo(AppLockState.Locked) + } + + @Test + fun `should reject enabling when settings changed and auth unavailable`() = runTest { + val availability = FakeAppLockAvailability(available = true) + val testSubject = createTestSubjectWithAvailability( + config = AppLockConfig(isEnabled = false), + availability = availability, + ) + assertThat(testSubject.state.value).isEqualTo(AppLockState.Disabled) + + // Make auth unavailable then try to enable + availability.setAvailable(false) + testSubject.onSettingsChanged(AppLockConfig(isEnabled = true)) + + // State should remain Disabled - enabling was rejected + assertThat(testSubject.state.value).isEqualTo(AppLockState.Disabled) + // Config should not be persisted + assertThat(testSubject.config.isEnabled).isFalse() + } + + private class SuspendingAuthenticator : AppLockAuthenticator { + private val started = kotlinx.coroutines.CompletableDeferred() + private val result = kotlinx.coroutines.CompletableDeferred() + + suspend fun awaitStarted() = started.await() + fun complete(value: AppLockResult) = result.complete(value) + + override suspend fun authenticate(): AppLockResult { + started.complete(Unit) + return result.await() + } + } + + private fun createTestSubject( + config: AppLockConfig, + biometricAvailable: Boolean = true, + unavailableReason: UnavailableReason = UnavailableReason.NO_HARDWARE, + clock: () -> Long = { System.currentTimeMillis() }, + ): DefaultAppLockCoordinator { + val configRepository = FakeAppLockConfigRepository(config) + val availability = FakeAppLockAvailability(available = biometricAvailable, reason = unavailableReason) + + return DefaultAppLockCoordinator( + configRepository = configRepository, + availability = availability, + clock = clock, + mainThreadCheck = {}, + ) + } + + private class FakeAppLockConfigRepository( + private var config: AppLockConfig, + ) : AppLockConfigRepository { + override fun getConfig(): AppLockConfig = config + + override fun setConfig(config: AppLockConfig) { + this.config = config + } + } + + private class FakeAppLockAvailability( + private var available: Boolean, + private val reason: UnavailableReason = UnavailableReason.NO_HARDWARE, + ) : AppLockAvailability { + fun setAvailable(available: Boolean) { + this.available = available + } + + override fun isAuthenticationAvailable(): Boolean = available + override fun getUnavailableReason(): UnavailableReason = reason + } + + private fun createTestSubjectWithAvailability( + config: AppLockConfig, + availability: FakeAppLockAvailability, + clock: () -> Long = { System.currentTimeMillis() }, + ): DefaultAppLockCoordinator { + val configRepository = FakeAppLockConfigRepository(config) + + return DefaultAppLockCoordinator( + configRepository = configRepository, + availability = availability, + clock = clock, + mainThreadCheck = {}, + ) + } +} diff --git a/feature/applock/impl/src/test/kotlin/net/thunderbird/feature/applock/impl/domain/FakeAppLockCoordinator.kt b/feature/applock/impl/src/test/kotlin/net/thunderbird/feature/applock/impl/domain/FakeAppLockCoordinator.kt new file mode 100644 index 00000000000..30a0e25c428 --- /dev/null +++ b/feature/applock/impl/src/test/kotlin/net/thunderbird/feature/applock/impl/domain/FakeAppLockCoordinator.kt @@ -0,0 +1,169 @@ +package net.thunderbird.feature.applock.impl.domain + +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import net.thunderbird.core.outcome.Outcome +import net.thunderbird.feature.applock.api.AppLockAuthenticator +import net.thunderbird.feature.applock.api.AppLockConfig +import net.thunderbird.feature.applock.api.AppLockCoordinator +import net.thunderbird.feature.applock.api.AppLockError +import net.thunderbird.feature.applock.api.AppLockResult +import net.thunderbird.feature.applock.api.AppLockState + +/** + * Fake implementation of [AppLockCoordinator] for testing. + */ +internal class FakeAppLockCoordinator( + private var authResult: AppLockResult = Outcome.Success(Unit), +) : AppLockCoordinator { + + private val _state = MutableStateFlow(AppLockState.Disabled) + override val state: StateFlow = _state.asStateFlow() + + private var _config = AppLockConfig() + override val config: AppLockConfig + get() = _config + + override var isAuthenticationAvailable: Boolean = true + + var onAppForegroundedCallCount = 0 + private set + + var onAppBackgroundedCallCount = 0 + private set + + var onScreenOffCallCount = 0 + private set + + var lockNowCallCount = 0 + private set + + var ensureUnlockedCallCount = 0 + private set + + var authenticateCallCount = 0 + private set + + var refreshAvailabilityCallCount = 0 + private set + + var lastSettings: AppLockConfig? = null + private set + + private var authDeferred: CompletableDeferred? = null + private var nextAttemptId = 0L + private var stateAfterRefresh: AppLockState? = null + private var isAuthenticating = false + + /** + * Makes [authenticate] suspend until [completeAuthenticate] is called. + */ + fun suspendOnAuthenticate() { + authDeferred = CompletableDeferred() + } + + fun completeAuthenticate(result: AppLockResult) { + authDeferred?.complete(result) + } + + override fun onAppForegrounded() { + onAppForegroundedCallCount++ + } + + override fun onAppBackgrounded() { + onAppBackgroundedCallCount++ + } + + override fun onScreenOff() { + onScreenOffCallCount++ + if (_state.value is AppLockState.Unlocked || _state.value is AppLockState.Unlocking) { + _state.value = AppLockState.Locked + } + } + + override fun lockNow() { + lockNowCallCount++ + _state.value = AppLockState.Locked + } + + override fun ensureUnlocked(): Boolean { + ensureUnlockedCallCount++ + + return when (_state.value) { + AppLockState.Disabled, is AppLockState.Unlocked -> true + is AppLockState.Unlocking -> false + is AppLockState.Unavailable -> false + AppLockState.Locked, is AppLockState.Failed -> { + _state.value = AppLockState.Unlocking(attemptId = nextAttemptId++) + true + } + } + } + + override fun onSettingsChanged(config: AppLockConfig) { + lastSettings = config + _config = config + } + + override fun refreshAvailability() { + refreshAvailabilityCallCount++ + stateAfterRefresh?.let { _state.value = it } + } + + override suspend fun requestEnable(authenticator: AppLockAuthenticator): AppLockResult { + val result = authenticator.authenticate() + if (result is Outcome.Success) { + _config = _config.copy(isEnabled = true) + _state.value = AppLockState.Unlocked() + } + return result + } + + @Suppress("ReturnCount") + override suspend fun authenticate(authenticator: AppLockAuthenticator): AppLockResult { + if (isAuthenticating) { + return Outcome.Failure(AppLockError.UnableToStart("Authentication already in progress")) + } + + authenticateCallCount++ + _state.value as? AppLockState.Unlocking + ?: return Outcome.Failure(AppLockError.UnableToStart("Not in Unlocking state")) + + isAuthenticating = true + try { + val result = authDeferred?.await() ?: authResult + _state.value = when (result) { + is Outcome.Success -> AppLockState.Unlocked() + is Outcome.Failure -> AppLockState.Failed(result.error) + } + return result + } finally { + isAuthenticating = false + } + } + + fun setAuthResult(result: AppLockResult) { + authResult = result + } + + fun setState(state: AppLockState) { + _state.value = state + } + + fun setStateAfterRefresh(state: AppLockState?) { + stateAfterRefresh = state + } + + fun setConfigEnabled(enabled: Boolean) { + _config = _config.copy(isEnabled = enabled) + } + + companion object { + fun alwaysSucceeds(): FakeAppLockCoordinator = FakeAppLockCoordinator() + + fun alwaysFails(error: AppLockError = AppLockError.Failed): FakeAppLockCoordinator = + FakeAppLockCoordinator(authResult = Outcome.Failure(error)) + } +} diff --git a/feature/applock/impl/src/test/kotlin/net/thunderbird/feature/applock/impl/domain/FakeAuthenticator.kt b/feature/applock/impl/src/test/kotlin/net/thunderbird/feature/applock/impl/domain/FakeAuthenticator.kt new file mode 100644 index 00000000000..437b531064c --- /dev/null +++ b/feature/applock/impl/src/test/kotlin/net/thunderbird/feature/applock/impl/domain/FakeAuthenticator.kt @@ -0,0 +1,26 @@ +package net.thunderbird.feature.applock.impl.domain + +import net.thunderbird.core.outcome.Outcome +import net.thunderbird.feature.applock.api.AppLockAuthenticator +import net.thunderbird.feature.applock.api.AppLockError +import net.thunderbird.feature.applock.api.AppLockResult + +/** + * Fake implementation of [AppLockAuthenticator] for testing. + */ +internal class FakeAuthenticator( + private val result: AppLockResult = Outcome.Success(Unit), +) : AppLockAuthenticator { + var authenticateCallCount = 0 + private set + + override suspend fun authenticate(): AppLockResult { + authenticateCallCount++ + return result + } + + companion object { + fun success(): FakeAuthenticator = FakeAuthenticator(Outcome.Success(Unit)) + fun failure(error: AppLockError): FakeAuthenticator = FakeAuthenticator(Outcome.Failure(error)) + } +} diff --git a/feature/applock/impl/src/test/kotlin/net/thunderbird/feature/applock/impl/domain/MapErrorCodeTest.kt b/feature/applock/impl/src/test/kotlin/net/thunderbird/feature/applock/impl/domain/MapErrorCodeTest.kt new file mode 100644 index 00000000000..58e042056c3 --- /dev/null +++ b/feature/applock/impl/src/test/kotlin/net/thunderbird/feature/applock/impl/domain/MapErrorCodeTest.kt @@ -0,0 +1,110 @@ +package net.thunderbird.feature.applock.impl.domain + +import androidx.biometric.BiometricPrompt +import assertk.assertThat +import assertk.assertions.isEqualTo +import assertk.assertions.isInstanceOf +import net.thunderbird.feature.applock.api.AppLockError +import org.junit.Test + +class MapErrorCodeTest { + + @Test + fun `should map ERROR_HW_NOT_PRESENT to NotAvailable`() { + val result = mapErrorCode(BiometricPrompt.ERROR_HW_NOT_PRESENT, "") + + assertThat(result).isEqualTo(AppLockError.NotAvailable) + } + + @Test + fun `should map ERROR_HW_UNAVAILABLE to NotAvailable`() { + val result = mapErrorCode(BiometricPrompt.ERROR_HW_UNAVAILABLE, "") + + assertThat(result).isEqualTo(AppLockError.NotAvailable) + } + + @Test + fun `should map ERROR_NO_BIOMETRICS to NotEnrolled`() { + val result = mapErrorCode(BiometricPrompt.ERROR_NO_BIOMETRICS, "") + + assertThat(result).isEqualTo(AppLockError.NotEnrolled) + } + + @Test + fun `should map ERROR_NO_DEVICE_CREDENTIAL to NotEnrolled`() { + val result = mapErrorCode(BiometricPrompt.ERROR_NO_DEVICE_CREDENTIAL, "") + + assertThat(result).isEqualTo(AppLockError.NotEnrolled) + } + + @Test + fun `should map ERROR_USER_CANCELED to Canceled`() { + val result = mapErrorCode(BiometricPrompt.ERROR_USER_CANCELED, "") + + assertThat(result).isEqualTo(AppLockError.Canceled) + } + + @Test + fun `should map ERROR_NEGATIVE_BUTTON to Canceled`() { + val result = mapErrorCode(BiometricPrompt.ERROR_NEGATIVE_BUTTON, "") + + assertThat(result).isEqualTo(AppLockError.Canceled) + } + + @Test + fun `should map ERROR_CANCELED to Interrupted`() { + val result = mapErrorCode(BiometricPrompt.ERROR_CANCELED, "") + + assertThat(result).isEqualTo(AppLockError.Interrupted) + } + + @Test + fun `should map ERROR_LOCKOUT to temporary Lockout`() { + val result = mapErrorCode(BiometricPrompt.ERROR_LOCKOUT, "") + + assertThat(result).isEqualTo(AppLockError.Lockout(durationSeconds = 0)) + } + + @Test + fun `should map ERROR_LOCKOUT_PERMANENT to permanent Lockout`() { + val result = mapErrorCode(BiometricPrompt.ERROR_LOCKOUT_PERMANENT, "") + + assertThat(result).isEqualTo(AppLockError.Lockout(durationSeconds = -1)) + } + + @Test + fun `should map ERROR_TIMEOUT to Failed`() { + val result = mapErrorCode(BiometricPrompt.ERROR_TIMEOUT, "") + + assertThat(result).isEqualTo(AppLockError.Failed) + } + + @Test + fun `should map ERROR_UNABLE_TO_PROCESS to Failed`() { + val result = mapErrorCode(BiometricPrompt.ERROR_UNABLE_TO_PROCESS, "") + + assertThat(result).isEqualTo(AppLockError.Failed) + } + + @Test + fun `should map ERROR_NO_SPACE to Failed`() { + val result = mapErrorCode(BiometricPrompt.ERROR_NO_SPACE, "") + + assertThat(result).isEqualTo(AppLockError.Failed) + } + + @Test + fun `should map ERROR_VENDOR to Failed`() { + val result = mapErrorCode(BiometricPrompt.ERROR_VENDOR, "") + + assertThat(result).isEqualTo(AppLockError.Failed) + } + + @Test + fun `should map unknown error code to UnableToStart with error string`() { + val result = mapErrorCode(9999, "Some unknown error") + + assertThat(result).isInstanceOf() + assertThat((result as AppLockError.UnableToStart).message).isEqualTo("Some unknown error") + } +} diff --git a/feature/applock/impl/src/test/kotlin/net/thunderbird/feature/applock/impl/ui/AppLockFailedOverlayTest.kt b/feature/applock/impl/src/test/kotlin/net/thunderbird/feature/applock/impl/ui/AppLockFailedOverlayTest.kt new file mode 100644 index 00000000000..980ac2bbee7 --- /dev/null +++ b/feature/applock/impl/src/test/kotlin/net/thunderbird/feature/applock/impl/ui/AppLockFailedOverlayTest.kt @@ -0,0 +1,94 @@ +package net.thunderbird.feature.applock.impl.ui + +import androidx.compose.ui.test.performClick +import app.k9mail.core.ui.compose.testing.ComposeTest +import app.k9mail.core.ui.compose.testing.onNodeWithText +import app.k9mail.core.ui.compose.testing.setContentWithTheme +import assertk.assertThat +import assertk.assertions.isEqualTo +import net.thunderbird.feature.applock.impl.R +import org.junit.Test + +class AppLockFailedOverlayTest : ComposeTest() { + + @Test + fun `should show error message when generic failure`() { + val errorMessage = getString(R.string.applock_error_failed) + + setContentWithTheme { + AppLockFailedOverlay( + errorMessage = errorMessage, + onRetryClick = {}, + onCloseClick = {}, + ) + } + + onNodeWithText(errorMessage).assertExists() + } + + @Test + fun `should show permanent lockout message`() { + val errorMessage = getString(R.string.applock_error_lockout_permanent) + + setContentWithTheme { + AppLockFailedOverlay( + errorMessage = errorMessage, + onRetryClick = {}, + onCloseClick = {}, + ) + } + + onNodeWithText(errorMessage, substring = true).assertExists() + } + + @Test + fun `should show temporary lockout message with duration`() { + val durationSeconds = 30 + val errorMessage = org.robolectric.RuntimeEnvironment.getApplication().resources + .getQuantityString(R.plurals.applock_error_lockout, durationSeconds, durationSeconds) + + setContentWithTheme { + AppLockFailedOverlay( + errorMessage = errorMessage, + onRetryClick = {}, + onCloseClick = {}, + ) + } + + onNodeWithText("30", substring = true).assertExists() + } + + @Test + fun `should trigger onRetryClick callback when retry button clicked`() { + var retryClickCount = 0 + + setContentWithTheme { + AppLockFailedOverlay( + errorMessage = getString(R.string.applock_error_failed), + onRetryClick = { retryClickCount++ }, + onCloseClick = {}, + ) + } + + onNodeWithText(R.string.applock_button_unlock).performClick() + + assertThat(retryClickCount).isEqualTo(1) + } + + @Test + fun `should trigger onCloseClick callback when close button clicked`() { + var closeClickCount = 0 + + setContentWithTheme { + AppLockFailedOverlay( + errorMessage = getString(R.string.applock_error_failed), + onRetryClick = {}, + onCloseClick = { closeClickCount++ }, + ) + } + + onNodeWithText(R.string.applock_button_close_app).performClick() + + assertThat(closeClickCount).isEqualTo(1) + } +} diff --git a/feature/applock/impl/src/test/kotlin/net/thunderbird/feature/applock/impl/ui/AppLockUnavailableOverlayTest.kt b/feature/applock/impl/src/test/kotlin/net/thunderbird/feature/applock/impl/ui/AppLockUnavailableOverlayTest.kt new file mode 100644 index 00000000000..85fe019618e --- /dev/null +++ b/feature/applock/impl/src/test/kotlin/net/thunderbird/feature/applock/impl/ui/AppLockUnavailableOverlayTest.kt @@ -0,0 +1,112 @@ +package net.thunderbird.feature.applock.impl.ui + +import androidx.compose.ui.test.performClick +import app.k9mail.core.ui.compose.testing.ComposeTest +import app.k9mail.core.ui.compose.testing.onNodeWithText +import app.k9mail.core.ui.compose.testing.setContentWithTheme +import assertk.assertThat +import assertk.assertions.isEqualTo +import net.thunderbird.feature.applock.impl.R +import org.junit.Test + +class AppLockUnavailableOverlayTest : ComposeTest() { + + @Test + fun `should show hint when temporarily unavailable`() { + val hintMessage = getString(R.string.applock_error_temporarily_unavailable) + + setContentWithTheme { + AppLockUnavailableOverlay( + hintMessage = hintMessage, + actionButtonText = getString(R.string.applock_button_try_again), + onActionClick = {}, + onCloseClick = {}, + ) + } + + onNodeWithText(hintMessage, substring = true).assertExists() + } + + @Test + fun `should show hint when unknown unavailable`() { + val hintMessage = getString(R.string.applock_error_unknown_unavailable) + + setContentWithTheme { + AppLockUnavailableOverlay( + hintMessage = hintMessage, + actionButtonText = getString(R.string.applock_button_try_again), + onActionClick = {}, + onCloseClick = {}, + ) + } + + onNodeWithText(hintMessage, substring = true).assertExists() + } + + @Test + fun `should show close app button and no action button when no hardware`() { + val hintMessage = getString(R.string.applock_error_not_available) + + setContentWithTheme { + AppLockUnavailableOverlay( + hintMessage = hintMessage, + actionButtonText = null, + onActionClick = null, + onCloseClick = {}, + ) + } + + onNodeWithText(R.string.applock_button_close_app).assertExists() + onNodeWithText(R.string.applock_button_try_again).assertDoesNotExist() + } + + @Test + fun `should show try again action button when temporarily unavailable`() { + setContentWithTheme { + AppLockUnavailableOverlay( + hintMessage = getString(R.string.applock_error_temporarily_unavailable), + actionButtonText = getString(R.string.applock_button_try_again), + onActionClick = {}, + onCloseClick = {}, + ) + } + + onNodeWithText(R.string.applock_button_try_again).assertExists() + } + + @Test + fun `should trigger onActionClick callback when action button clicked`() { + var actionClickCount = 0 + + setContentWithTheme { + AppLockUnavailableOverlay( + hintMessage = getString(R.string.applock_error_temporarily_unavailable), + actionButtonText = getString(R.string.applock_button_try_again), + onActionClick = { actionClickCount++ }, + onCloseClick = {}, + ) + } + + onNodeWithText(R.string.applock_button_try_again).performClick() + + assertThat(actionClickCount).isEqualTo(1) + } + + @Test + fun `should trigger onCloseClick callback when close button clicked`() { + var closeClickCount = 0 + + setContentWithTheme { + AppLockUnavailableOverlay( + hintMessage = getString(R.string.applock_error_not_available), + actionButtonText = null, + onActionClick = null, + onCloseClick = { closeClickCount++ }, + ) + } + + onNodeWithText(R.string.applock_button_close_app).performClick() + + assertThat(closeClickCount).isEqualTo(1) + } +} diff --git a/feature/applock/impl/src/test/kotlin/net/thunderbird/feature/applock/impl/ui/DefaultAppLockGateTest.kt b/feature/applock/impl/src/test/kotlin/net/thunderbird/feature/applock/impl/ui/DefaultAppLockGateTest.kt new file mode 100644 index 00000000000..65289a9786d --- /dev/null +++ b/feature/applock/impl/src/test/kotlin/net/thunderbird/feature/applock/impl/ui/DefaultAppLockGateTest.kt @@ -0,0 +1,599 @@ +package net.thunderbird.feature.applock.impl.ui + +import android.os.Build +import android.os.Bundle +import android.os.Looper +import android.view.ViewGroup +import android.widget.FrameLayout +import androidx.compose.runtime.Composable +import androidx.fragment.app.FragmentActivity +import assertk.assertThat +import assertk.assertions.isEqualTo +import assertk.assertions.isFalse +import assertk.assertions.isInstanceOf +import assertk.assertions.isNotNull +import assertk.assertions.isNull +import assertk.assertions.isTrue +import net.thunderbird.core.outcome.Outcome +import net.thunderbird.core.ui.theme.api.FeatureThemeProvider +import net.thunderbird.feature.applock.api.AppLockAuthenticator +import net.thunderbird.feature.applock.api.AppLockAuthenticatorFactory +import net.thunderbird.feature.applock.api.AppLockError +import net.thunderbird.feature.applock.api.AppLockResult +import net.thunderbird.feature.applock.api.AppLockState +import net.thunderbird.feature.applock.api.UnavailableReason +import net.thunderbird.feature.applock.impl.domain.FakeAppLockCoordinator +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.Robolectric +import org.robolectric.RobolectricTestRunner +import org.robolectric.Shadows.shadowOf +import org.robolectric.android.controller.ActivityController +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [Build.VERSION_CODES.TIRAMISU]) +class DefaultAppLockGateTest { + + private lateinit var coordinator: FakeAppLockCoordinator + private lateinit var testSubject: DefaultAppLockGate + + private val authenticatorFactory = AppLockAuthenticatorFactory { _ -> + object : AppLockAuthenticator { + override suspend fun authenticate(): AppLockResult = Outcome.Success(Unit) + } + } + + private val themeProvider = object : FeatureThemeProvider { + @Composable + override fun WithTheme(content: @Composable () -> Unit) = content() + + @Composable + override fun WithTheme(darkTheme: Boolean, content: @Composable () -> Unit) = content() + } + + @Before + fun setUp() { + coordinator = FakeAppLockCoordinator() + } + + private fun launchActivity(state: AppLockState): ActivityController { + coordinator.setState(state) + val controller = Robolectric.buildActivity(TestActivity::class.java) + controller.create() + val activity = controller.get() + testSubject = DefaultAppLockGate(activity, coordinator, authenticatorFactory, themeProvider) + activity.lifecycle.addObserver(testSubject) + controller.start().resume() + shadowOf(Looper.getMainLooper()).idle() + return controller + } + + @Test + fun `should show plain overlay when state is Locked`() { + coordinator.suspendOnAuthenticate() + + val controller = launchActivity(AppLockState.Locked) + val activity = controller.get() + + val overlay = findOverlay(activity) + assertThat(overlay).isNotNull() + assertThat(overlay!!.tag).isEqualTo("applock_overlay_plain") + } + + @Test + fun `should show content overlay when state is Failed`() { + val controller = launchActivity(AppLockState.Failed(AppLockError.Failed)) + val activity = controller.get() + + val overlay = findOverlay(activity) + assertThat(overlay).isNotNull() + assertThat(overlay!!.tag).isEqualTo("applock_overlay_content") + } + + @Test + fun `should show content overlay when permanent lockout`() { + val controller = launchActivity(AppLockState.Failed(AppLockError.Lockout(durationSeconds = -1))) + val activity = controller.get() + + val overlay = findOverlay(activity) + assertThat(overlay).isNotNull() + assertThat(overlay!!.tag).isEqualTo("applock_overlay_content") + } + + @Test + fun `should show content overlay when temporary lockout`() { + val controller = launchActivity(AppLockState.Failed(AppLockError.Lockout(durationSeconds = 30))) + val activity = controller.get() + + val overlay = findOverlay(activity) + assertThat(overlay).isNotNull() + assertThat(overlay!!.tag).isEqualTo("applock_overlay_content") + } + + @Test + fun `should replace content overlay when state changes from Failed to Unavailable`() { + val controller = launchActivity(AppLockState.Failed(AppLockError.Failed)) + val activity = controller.get() + + val firstOverlay = findOverlay(activity) + assertThat(firstOverlay).isNotNull() + assertThat(firstOverlay!!.tag).isEqualTo("applock_overlay_content") + + coordinator.setState(AppLockState.Unavailable(UnavailableReason.NOT_ENROLLED)) + shadowOf(Looper.getMainLooper()).idle() + + val secondOverlay = findOverlay(activity) + assertThat(secondOverlay).isNotNull() + assertThat(secondOverlay!!.tag).isEqualTo("applock_overlay_content") + assertThat(secondOverlay === firstOverlay).isFalse() + } + + @Test + fun `should replace content overlay when unavailable reason changes`() { + val controller = launchActivity(AppLockState.Unavailable(UnavailableReason.NOT_ENROLLED)) + val activity = controller.get() + + val firstOverlay = findOverlay(activity) + assertThat(firstOverlay).isNotNull() + assertThat(firstOverlay!!.tag).isEqualTo("applock_overlay_content") + + coordinator.setState(AppLockState.Unavailable(UnavailableReason.NO_HARDWARE)) + shadowOf(Looper.getMainLooper()).idle() + + val secondOverlay = findOverlay(activity) + assertThat(secondOverlay).isNotNull() + assertThat(secondOverlay!!.tag).isEqualTo("applock_overlay_content") + assertThat(secondOverlay === firstOverlay).isFalse() + } + + @Test + fun `should replace content overlay when failed error changes`() { + val controller = launchActivity(AppLockState.Failed(AppLockError.Failed)) + val activity = controller.get() + + val firstOverlay = findOverlay(activity) + assertThat(firstOverlay).isNotNull() + assertThat(firstOverlay!!.tag).isEqualTo("applock_overlay_content") + + coordinator.setState(AppLockState.Failed(AppLockError.Canceled)) + shadowOf(Looper.getMainLooper()).idle() + + val secondOverlay = findOverlay(activity) + assertThat(secondOverlay).isNotNull() + assertThat(secondOverlay!!.tag).isEqualTo("applock_overlay_content") + assertThat(secondOverlay === firstOverlay).isFalse() + } + + @Test + fun `should replace failed overlay with plain overlay when state changes to Locked`() { + coordinator.suspendOnAuthenticate() + + val controller = launchActivity(AppLockState.Failed(AppLockError.Failed)) + val activity = controller.get() + + var overlay = findOverlay(activity) + assertThat(overlay).isNotNull() + assertThat(overlay!!.tag).isEqualTo("applock_overlay_content") + + coordinator.setState(AppLockState.Locked) + shadowOf(Looper.getMainLooper()).idle() + + overlay = findOverlay(activity) + assertThat(overlay).isNotNull() + assertThat(overlay!!.tag).isEqualTo("applock_overlay_plain") + } + + @Test + fun `should hide overlay when state becomes Unlocked`() { + coordinator.suspendOnAuthenticate() + + val controller = launchActivity(AppLockState.Locked) + val activity = controller.get() + + assertThat(findOverlay(activity)).isNotNull() + + coordinator.setState(AppLockState.Unlocked()) + shadowOf(Looper.getMainLooper()).idle() + + assertThat(findOverlay(activity)).isNull() + } + + @Test + fun `should hide overlay when state becomes Disabled`() { + coordinator.suspendOnAuthenticate() + + val controller = launchActivity(AppLockState.Locked) + val activity = controller.get() + + assertThat(findOverlay(activity)).isNotNull() + + coordinator.setState(AppLockState.Disabled) + shadowOf(Looper.getMainLooper()).idle() + + assertThat(findOverlay(activity)).isNull() + } + + @Test + fun `should not relaunch auth on pause-resume when Unlocking`() { + coordinator.suspendOnAuthenticate() + + val controller = launchActivity(AppLockState.Locked) + shadowOf(Looper.getMainLooper()).idle() + + val initialAuthCount = coordinator.authenticateCallCount + + controller.pause() + shadowOf(Looper.getMainLooper()).idle() + controller.resume() + shadowOf(Looper.getMainLooper()).idle() + + assertThat(coordinator.authenticateCallCount).isEqualTo(initialAuthCount) + } + + @Test + fun `should not relaunch auth on stop-start when auth job is still active`() { + coordinator.suspendOnAuthenticate() + + val controller = launchActivity(AppLockState.Locked) + shadowOf(Looper.getMainLooper()).idle() + + val initialAuthCount = coordinator.authenticateCallCount + + // Auth job is still suspended (active). Stop-start clears lastAttemptId, + // but the active-job guard in launchAuthentication() prevents a duplicate launch. + controller.pause() + shadowOf(Looper.getMainLooper()).idle() + controller.stop() + shadowOf(Looper.getMainLooper()).idle() + controller.start().resume() + shadowOf(Looper.getMainLooper()).idle() + + assertThat(coordinator.authenticateCallCount).isEqualTo(initialAuthCount) + } + + @Test + fun `should survive credential flow across pause-stop-start-resume`() { + coordinator.suspendOnAuthenticate() + + val controller = launchActivity(AppLockState.Locked) + shadowOf(Looper.getMainLooper()).idle() + + assertThat(coordinator.state.value).isInstanceOf() + + // Simulate device-credential activity obscuring the host (causes both onPause and onStop) + controller.pause() + shadowOf(Looper.getMainLooper()).idle() + controller.stop() + shadowOf(Looper.getMainLooper()).idle() + + // User completes PIN entry on the system credential screen + coordinator.completeAuthenticate(Outcome.Success(Unit)) + shadowOf(Looper.getMainLooper()).idle() + + // Host activity returns to foreground + controller.start().resume() + shadowOf(Looper.getMainLooper()).idle() + + assertThat(coordinator.state.value).isInstanceOf() + assertThat(findOverlay(controller.get())).isNull() + } + + @Test + fun `should relaunch auth after stop-start when previous auth already completed`() { + // Auth completes immediately (fails) + coordinator.setAuthResult(Outcome.Failure(AppLockError.Canceled)) + + val controller = launchActivity(AppLockState.Locked) + shadowOf(Looper.getMainLooper()).idle() + + // Auth ran and failed — state is now Failed, authenticationJob is null + assertThat(coordinator.state.value).isInstanceOf() + val authCountAfterFailure = coordinator.authenticateCallCount + + // Simulate re-lock (e.g., coordinator transitions back to Unlocking for retry) + coordinator.suspendOnAuthenticate() + coordinator.setState(AppLockState.Unlocking(attemptId = 99)) + shadowOf(Looper.getMainLooper()).idle() + + // stop-start cycle clears lastAttemptId + controller.pause() + shadowOf(Looper.getMainLooper()).idle() + controller.stop() + shadowOf(Looper.getMainLooper()).idle() + controller.start().resume() + shadowOf(Looper.getMainLooper()).idle() + + // Auth should relaunch because authenticationJob is not active + assertThat(coordinator.authenticateCallCount).isEqualTo(authCountAfterFailure + 1) + } + + @Test + fun `should allow activity B to retry auth after activity A auth fails`() { + // Activity A starts auth that will fail + coordinator.setAuthResult(Outcome.Failure(AppLockError.Failed)) + + val controllerA = launchActivity(AppLockState.Locked) + shadowOf(Looper.getMainLooper()).idle() + + // Auth failed on activity A + assertThat(coordinator.state.value).isInstanceOf() + + // Simulate retry: coordinator transitions back to Unlocking + coordinator.setAuthResult(Outcome.Success(Unit)) + coordinator.setState(AppLockState.Unlocking(attemptId = 50)) + shadowOf(Looper.getMainLooper()).idle() + + // Activity B starts and picks up the retry + val controllerB = Robolectric.buildActivity(TestActivity::class.java) + controllerB.create() + val activityB = controllerB.get() + val gateB = DefaultAppLockGate(activityB, coordinator, authenticatorFactory, themeProvider) + activityB.lifecycle.addObserver(gateB) + controllerB.start().resume() + shadowOf(Looper.getMainLooper()).idle() + + // Activity B's auth succeeds + assertThat(coordinator.state.value).isInstanceOf() + assertThat(findOverlay(activityB)).isNull() + } + + @Test + fun `should not trigger duplicate auth on pause-resume when auth already completed`() { + val controller = launchActivity(AppLockState.Locked) + shadowOf(Looper.getMainLooper()).idle() + + val authCountAfterUnlock = coordinator.authenticateCallCount + + assertThat(coordinator.state.value).isInstanceOf() + + controller.pause() + shadowOf(Looper.getMainLooper()).idle() + controller.resume() + shadowOf(Looper.getMainLooper()).idle() + + assertThat(coordinator.authenticateCallCount).isEqualTo(authCountAfterUnlock) + } + + @Test + fun `should show content overlay when temporarily unavailable`() { + val controller = launchActivity(AppLockState.Unavailable(UnavailableReason.TEMPORARILY_UNAVAILABLE)) + val activity = controller.get() + + val overlay = findOverlay(activity) + assertThat(overlay).isNotNull() + assertThat(overlay!!.tag).isEqualTo("applock_overlay_content") + } + + @Test + fun `should show content overlay when unknown unavailable`() { + val controller = launchActivity(AppLockState.Unavailable(UnavailableReason.UNKNOWN)) + val activity = controller.get() + + val overlay = findOverlay(activity) + assertThat(overlay).isNotNull() + assertThat(overlay!!.tag).isEqualTo("applock_overlay_content") + } + + @Test + fun `should show content overlay when no hardware unavailable`() { + val controller = launchActivity(AppLockState.Unavailable(UnavailableReason.NO_HARDWARE)) + val activity = controller.get() + + val overlay = findOverlay(activity) + assertThat(overlay).isNotNull() + assertThat(overlay!!.tag).isEqualTo("applock_overlay_content") + } + + @Test + fun `should show privacy overlay when paused and app lock is enabled`() { + coordinator.setConfigEnabled(true) + val controller = launchActivity(AppLockState.Unlocked()) + val activity = controller.get() + + assertThat(findOverlay(activity)).isNull() + + controller.pause() + shadowOf(Looper.getMainLooper()).idle() + + assertThat(findOverlay(activity)).isNotNull() + } + + @Test + fun `should not show privacy overlay when paused and app lock is disabled`() { + coordinator.setConfigEnabled(false) + val controller = launchActivity(AppLockState.Disabled) + val activity = controller.get() + + controller.pause() + shadowOf(Looper.getMainLooper()).idle() + + assertThat(findOverlay(activity)).isNull() + } + + @Test + fun `should hide privacy overlay when resumed while still unlocked`() { + coordinator.setConfigEnabled(true) + val controller = launchActivity(AppLockState.Unlocked()) + val activity = controller.get() + + controller.pause() + shadowOf(Looper.getMainLooper()).idle() + assertThat(findOverlay(activity)).isNotNull() + + controller.resume() + shadowOf(Looper.getMainLooper()).idle() + assertThat(findOverlay(activity)).isNull() + } + + @Test + fun `should preserve content overlay on pause when state is Failed`() { + coordinator.setConfigEnabled(true) + val controller = launchActivity(AppLockState.Failed(AppLockError.Failed)) + val activity = controller.get() + + val overlayBeforePause = findOverlay(activity) + assertThat(overlayBeforePause).isNotNull() + assertThat(overlayBeforePause!!.tag).isEqualTo("applock_overlay_content") + + controller.pause() + shadowOf(Looper.getMainLooper()).idle() + + val overlayAfterPause = findOverlay(activity) + assertThat(overlayAfterPause).isNotNull() + assertThat(overlayAfterPause!!.tag).isEqualTo("applock_overlay_content") + assertThat(overlayAfterPause === overlayBeforePause).isTrue() + } + + @Test + fun `should preserve content overlay on pause when state is Unavailable`() { + coordinator.setConfigEnabled(true) + val controller = launchActivity(AppLockState.Unavailable(UnavailableReason.NOT_ENROLLED)) + val activity = controller.get() + + val overlayBeforePause = findOverlay(activity) + assertThat(overlayBeforePause).isNotNull() + assertThat(overlayBeforePause!!.tag).isEqualTo("applock_overlay_content") + + controller.pause() + shadowOf(Looper.getMainLooper()).idle() + + val overlayAfterPause = findOverlay(activity) + assertThat(overlayAfterPause).isNotNull() + assertThat(overlayAfterPause!!.tag).isEqualTo("applock_overlay_content") + assertThat(overlayAfterPause === overlayBeforePause).isTrue() + } + + @Test + fun `should show overlay and relaunch auth after activity recreation when Unlocking`() { + coordinator.suspendOnAuthenticate() + + val controller = launchActivity(AppLockState.Locked) + shadowOf(Looper.getMainLooper()).idle() + + assertThat(coordinator.state.value).isInstanceOf() + val authCountBeforeRecreate = coordinator.authenticateCallCount + + controller.pause() + shadowOf(Looper.getMainLooper()).idle() + controller.stop() + shadowOf(Looper.getMainLooper()).idle() + controller.destroy() + shadowOf(Looper.getMainLooper()).idle() + + coordinator.suspendOnAuthenticate() + + val newController = Robolectric.buildActivity(TestActivity::class.java) + newController.create() + val newActivity = newController.get() + val newGate = DefaultAppLockGate(newActivity, coordinator, authenticatorFactory, themeProvider) + newActivity.lifecycle.addObserver(newGate) + newController.start().resume() + shadowOf(Looper.getMainLooper()).idle() + + assertThat(findOverlay(newActivity)).isNotNull() + assertThat(coordinator.authenticateCallCount).isEqualTo(authCountBeforeRecreate + 1) + } + + @Test + fun `should hide activity B overlay when activity A unlocks`() { + coordinator.suspendOnAuthenticate() + + val controllerA = launchActivity(AppLockState.Locked) + val activityA = controllerA.get() + shadowOf(Looper.getMainLooper()).idle() + + val controllerB = Robolectric.buildActivity(TestActivity::class.java) + controllerB.create() + val activityB = controllerB.get() + val gateB = DefaultAppLockGate(activityB, coordinator, authenticatorFactory, themeProvider) + activityB.lifecycle.addObserver(gateB) + controllerB.start().resume() + shadowOf(Looper.getMainLooper()).idle() + + assertThat(findOverlay(activityA)).isNotNull() + assertThat(findOverlay(activityB)).isNotNull() + + coordinator.completeAuthenticate(Outcome.Success(Unit)) + shadowOf(Looper.getMainLooper()).idle() + + assertThat(findOverlay(activityA)).isNull() + assertThat(findOverlay(activityB)).isNull() + } + + @Test + fun `should not show duplicate auth prompt when second activity starts`() { + coordinator.suspendOnAuthenticate() + + val controllerA = launchActivity(AppLockState.Locked) + shadowOf(Looper.getMainLooper()).idle() + + val authCountAfterA = coordinator.authenticateCallCount + + val controllerB = Robolectric.buildActivity(TestActivity::class.java) + controllerB.create() + val activityB = controllerB.get() + val gateB = DefaultAppLockGate(activityB, coordinator, authenticatorFactory, themeProvider) + activityB.lifecycle.addObserver(gateB) + controllerB.start().resume() + shadowOf(Looper.getMainLooper()).idle() + + assertThat(coordinator.authenticateCallCount).isEqualTo(authCountAfterA) + } + + @Test + fun `should close app on back press when Failed overlay is shown`() { + val controller = launchActivity(AppLockState.Failed(AppLockError.Failed)) + val activity = controller.get() + + activity.onBackPressedDispatcher.onBackPressed() + + assertThat(activity.isFinishing).isTrue() + } + + @Test + fun `should close app on back press when Unavailable overlay is shown`() { + val controller = launchActivity(AppLockState.Unavailable(UnavailableReason.NOT_ENROLLED)) + val activity = controller.get() + + activity.onBackPressedDispatcher.onBackPressed() + + assertThat(activity.isFinishing).isTrue() + } + + @Test + fun `should not show privacy overlay when paused while Unlocking`() { + coordinator.setConfigEnabled(true) + coordinator.suspendOnAuthenticate() + val controller = launchActivity(AppLockState.Locked) + val activity = controller.get() + + assertThat(coordinator.state.value).isInstanceOf() + assertThat(findOverlay(activity)).isNotNull() + + controller.pause() + shadowOf(Looper.getMainLooper()).idle() + + assertThat(findOverlay(activity)).isNotNull() + } + + private fun findOverlay(activity: FragmentActivity): android.view.View? { + val contentView = activity.findViewById(android.R.id.content) + for (i in contentView.childCount - 1 downTo 0) { + val child = contentView.getChildAt(i) + val tag = child.tag + if (tag == "applock_overlay_plain" || tag == "applock_overlay_content") { + return child + } + } + return null + } + + class TestActivity : FragmentActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContentView(FrameLayout(this)) + } + } +} diff --git a/feature/applock/impl/src/test/kotlin/net/thunderbird/feature/applock/impl/ui/settings/AppLockSettingsViewModelTest.kt b/feature/applock/impl/src/test/kotlin/net/thunderbird/feature/applock/impl/ui/settings/AppLockSettingsViewModelTest.kt new file mode 100644 index 00000000000..e6f4df88294 --- /dev/null +++ b/feature/applock/impl/src/test/kotlin/net/thunderbird/feature/applock/impl/ui/settings/AppLockSettingsViewModelTest.kt @@ -0,0 +1,121 @@ +package net.thunderbird.feature.applock.impl.ui.settings + +import app.cash.turbine.test +import assertk.assertThat +import assertk.assertions.isEqualTo +import assertk.assertions.isFalse +import assertk.assertions.isInstanceOf +import assertk.assertions.isTrue +import kotlinx.coroutines.test.runTest +import net.thunderbird.core.outcome.Outcome +import net.thunderbird.core.testing.coroutines.MainDispatcherRule +import net.thunderbird.feature.applock.api.AppLockConfig +import net.thunderbird.feature.applock.impl.domain.FakeAppLockCoordinator +import net.thunderbird.feature.applock.impl.ui.settings.AppLockSettingsContract.Effect +import net.thunderbird.feature.applock.impl.ui.settings.AppLockSettingsContract.Event +import org.junit.Rule +import org.junit.Test + +class AppLockSettingsViewModelTest { + + @get:Rule + val mainDispatcherRule = MainDispatcherRule() + + @Test + fun `should initialize state from coordinator config`() { + val coordinator = FakeAppLockCoordinator().apply { + onSettingsChanged(AppLockConfig(isEnabled = true, timeoutMillis = 180_000L)) + } + + val testSubject = AppLockSettingsViewModel(coordinator = coordinator) + + assertThat(testSubject.state.value.isEnabled).isTrue() + assertThat(testSubject.state.value.timeoutMinutes).isEqualTo(3) + assertThat(testSubject.state.value.isAuthenticationAvailable).isTrue() + } + + @Test + fun `should emit RequestAuthentication effect when enabling`() = runTest { + val coordinator = FakeAppLockCoordinator() + val testSubject = AppLockSettingsViewModel(coordinator = coordinator) + + testSubject.effect.test { + testSubject.event(Event.OnEnableChanged(true)) + + assertThat(awaitItem()).isInstanceOf() + } + } + + @Test + fun `should update state and coordinator when disabling`() { + val coordinator = FakeAppLockCoordinator().apply { + onSettingsChanged(AppLockConfig(isEnabled = true)) + } + val testSubject = AppLockSettingsViewModel(coordinator = coordinator) + + testSubject.event(Event.OnEnableChanged(false)) + + assertThat(testSubject.state.value.isEnabled).isFalse() + assertThat(coordinator.config.isEnabled).isFalse() + } + + @Test + fun `should update state on successful authentication`() = runTest { + val coordinator = FakeAppLockCoordinator() + val testSubject = AppLockSettingsViewModel(coordinator = coordinator) + val fakeAuthenticator = { Outcome.success(Unit) } + + testSubject.event(Event.OnAuthenticatorReady(fakeAuthenticator)) + + assertThat(testSubject.state.value.isEnabled).isTrue() + } + + @Test + fun `should not update state on failed authentication`() = runTest { + val coordinator = FakeAppLockCoordinator() + val testSubject = AppLockSettingsViewModel(coordinator = coordinator) + val fakeAuthenticator = { Outcome.failure(net.thunderbird.feature.applock.api.AppLockError.Failed) } + + testSubject.event(Event.OnAuthenticatorReady(fakeAuthenticator)) + + assertThat(testSubject.state.value.isEnabled).isFalse() + } + + @Test + fun `should update state and coordinator when timeout changed`() { + val coordinator = FakeAppLockCoordinator() + val testSubject = AppLockSettingsViewModel(coordinator = coordinator) + + testSubject.event(Event.OnTimeoutChanged(5)) + + assertThat(testSubject.state.value.timeoutMinutes).isEqualTo(5) + assertThat(coordinator.config.timeoutMillis).isEqualTo(300_000L) + } + + @Test + fun `should refresh availability on resume`() { + val coordinator = FakeAppLockCoordinator().apply { + isAuthenticationAvailable = false + } + val testSubject = AppLockSettingsViewModel(coordinator = coordinator) + + assertThat(testSubject.state.value.isAuthenticationAvailable).isFalse() + + coordinator.isAuthenticationAvailable = true + testSubject.event(Event.OnResume) + + assertThat(testSubject.state.value.isAuthenticationAvailable).isTrue() + } + + @Test + fun `should emit NavigateBack effect when back pressed`() = runTest { + val coordinator = FakeAppLockCoordinator() + val testSubject = AppLockSettingsViewModel(coordinator = coordinator) + + testSubject.effect.test { + testSubject.event(Event.OnBackPressed) + + assertThat(awaitItem()).isInstanceOf() + } + } +} diff --git a/legacy/ui/legacy/src/test/java/com/fsck/k9/TestApp.kt b/legacy/ui/legacy/src/test/java/com/fsck/k9/TestApp.kt index 7ab67cfc5e2..bfe61e1cd88 100644 --- a/legacy/ui/legacy/src/test/java/com/fsck/k9/TestApp.kt +++ b/legacy/ui/legacy/src/test/java/com/fsck/k9/TestApp.kt @@ -23,6 +23,7 @@ import net.thunderbird.core.logging.legacy.Log import net.thunderbird.core.logging.testing.TestLogLevelManager import net.thunderbird.core.logging.testing.TestLogger import net.thunderbird.core.preference.storage.StoragePersister +import net.thunderbird.feature.applock.api.AppLockCoordinator import net.thunderbird.feature.mail.message.reader.api.css.CssClassNameProvider import net.thunderbird.feature.mail.message.reader.api.css.CssStyleProvider import net.thunderbird.feature.mail.message.reader.api.css.CssVariableNameProvider @@ -89,6 +90,7 @@ val testModule = module { single { mock() } single { mock() } single { FakePlatformConfigProvider() } + single { mock() } single { mock() } single { mock() } factoryListOf()