181 lines
6.9 KiB
Kotlin
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)
|
|
}
|
|
}
|
|
}
|