package dev.privacyllc.period.lock import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel import dev.privacyllc.period.core.datastore.UserPreferencesRepository import dev.privacyllc.period.core.security.AppLockRepository import dev.privacyllc.period.core.security.UnlockResult import dev.privacyllc.period.notifications.NotificationActionHandler import kotlinx.coroutines.CoroutineExceptionHandler import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import javax.inject.Inject /** What the gate should show. */ sealed interface LockState { /** The stores have not answered yet. Renders nothing — see [AppLockGate]. */ data object Unknown : LockState data object Locked : LockState data object Unlocked : LockState } /** What the lock screen should say right now. */ data class LockScreenState( val checking: Boolean = false, val wrong: Boolean = false, val waitMillis: Long = 0L, val keyUnavailable: Boolean = false, val biometricOffered: Boolean = false, ) /** * The gate's state, and the one place a PIN is offered. * * ## The lock is on exactly when a PIN exists * * There is no separate "app lock enabled" flag, deliberately. Two records of one * fact eventually disagree, and both ways of disagreeing are bad: a lock with no * PIN can never be opened, and a PIN with the lock off protects nothing. * `UserPreferences.biometricLockEnabled` survives as what its name says — whether * a fingerprint may be used *instead of* the PIN — and it is meaningless without * one, which is why it is combined with `hasPin` rather than read alone. */ @HiltViewModel class AppLockViewModel @Inject constructor( private val lock: AppLockRepository, private val preferences: UserPreferencesRepository, private val controller: AppLockController, private val notificationActions: NotificationActionHandler, ) : ViewModel() { /** Non-null while a notification action is waiting to be applied. */ val pendingNotificationAction: StateFlow = controller.pendingNotificationAction /** * Apply a parked notification action, now that somebody has authenticated. * * Called only from the unlocked branch of the gate. Tapping "Not yet" on a * reminder writes to the health record, and that button sits on the phone's * own lock screen where anybody can reach it — so with an app lock on, the * write waits for the unlock. A session that never unlocks never applies it. */ fun deliverPendingNotificationAction() { val action = controller.takeNotificationAction() ?: return viewModelScope.launch(handler) { notificationActions.handle(action) } } private val _screen = MutableStateFlow(LockScreenState()) val screen: StateFlow = _screen.asStateFlow() /** * `Unknown` until both stores have answered. * * The initial value must not be `Unlocked` — that would flash the Today * screen, with a forecast on it, before the lock had a chance to close. */ val state: StateFlow = combine(lock.hasPin, controller.unlocked, ::lockStateOf) .stateIn(viewModelScope, SharingStarted.Eagerly, LockState.Unknown) /** True only when a fingerprint may stand in for the PIN, which needs a PIN to stand in for. */ val biometricAllowed: StateFlow = combine( preferences.preferences.map { it.biometricLockEnabled }, lock.hasPin, ) { enabled, hasPin -> enabled && hasPin } .stateIn(viewModelScope, SharingStarted.Eagerly, false) private val handler = CoroutineExceptionHandler { _, _ -> // Never logged: `app` is in modulesSeeingHealthData and an exception on // this path can carry key material. A failure here reads as "not now". _screen.value = _screen.value.copy(checking = false, keyUnavailable = true) } fun submit(pin: CharArray) { if (_screen.value.checking) return _screen.value = _screen.value.copy(checking = true, wrong = false) viewModelScope.launch(handler) { when (val outcome = lock.check(pin)) { is UnlockResult.Unlocked -> { _screen.value = LockScreenState() controller.unlock() } is UnlockResult.Wrong -> _screen.value = LockScreenState(wrong = true, waitMillis = outcome.waitMillis) is UnlockResult.TooSoon -> _screen.value = LockScreenState(waitMillis = outcome.waitMillis) is UnlockResult.KeyUnavailable -> _screen.value = LockScreenState(keyUnavailable = true) is UnlockResult.NoPin -> { // The lock was turned off in another window; nothing to check. _screen.value = LockScreenState() controller.unlock() } } pin.fill('\u0000') } } /** Called after the biometric prompt succeeds. The PIN is not involved. */ fun unlockFromBiometric() { _screen.value = LockScreenState() controller.unlock() } /** * Close the lock again, on leaving the app. * * The screen state is reset with it: a half-typed PIN and a "that is not the * PIN" message must not still be on screen when the app is reopened, which * would tell whoever opens it next that somebody was recently guessing. */ fun relock() { controller.lock() _screen.value = LockScreenState() } /** True while the system biometric prompt is up; the gate must not re-lock under it. */ val authInProgress: Boolean get() = controller.authInProgress fun beginBiometricAuth() { controller.authInProgress = true } fun endBiometricAuth() { controller.authInProgress = false } /** * Turn the fingerprint offer off, because the hardware says it cannot work. * * Only for `ERROR_NO_BIOMETRICS` and `ERROR_HW_NOT_PRESENT` — no enrolment * left, or no sensor. A transient failure must not switch a setting the user * chose; they would have to go and find it again to turn it back on. */ fun disableBiometric() { viewModelScope.launch(handler) { preferences.setBiometricLockEnabled(false) } } fun refreshLockout() { viewModelScope.launch(handler) { val wait = lock.lockoutRemainingMillis() _screen.value = _screen.value.copy(waitMillis = wait) } } } /** * The whole rule, in one place: locked exactly when a PIN exists and this * session has not opened it. * * Extracted from the gate's `combine` so a test can assert against the rule the * app actually uses rather than a restatement of it. That matters here more than * usual — the thing worth proving about setting a PIN is that this function * never returns [LockState.Locked] while it happens, and a copy of the rule in * the test would prove that about the copy. */ internal fun lockStateOf(hasPin: Boolean, unlocked: Boolean): LockState = when { !hasPin -> LockState.Unlocked unlocked -> LockState.Unlocked else -> LockState.Locked }