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(' |