Privacy-Period-Tracker/app/src/main/kotlin/dev/privacyllc/period/lock/AppLockViewModel.kt

181 lines
6.9 KiB
Kotlin

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<String?> = 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<LockScreenState> = _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<LockState> = combine(
lock.hasPin,
controller.unlocked,
) { hasPin, unlocked ->
when {
!hasPin -> LockState.Unlocked
unlocked -> LockState.Unlocked
else -> LockState.Locked
}
}.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<Boolean> = 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)
}
}
}