1package com.peachpasswords.ui.lock23import android.content.Context4import androidx.biometric.BiometricPrompt5import androidx.fragment.app.FragmentActivity6import androidx.lifecycle.ViewModel7import androidx.lifecycle.viewModelScope8import com.peachpasswords.ui.PeachNav9import com.peachpasswords.ui.vault.entryTapDestination10import com.peachpasswords.vault.CreateVaultResult11import com.peachpasswords.ui.security.scorePassword12import com.peachpasswords.vault.DuressAction13import com.peachpasswords.vault.DuressConfigStore14import com.peachpasswords.vault.LastViewedEntryStore15import com.peachpasswords.vault.UnlockResult16import com.peachpasswords.vault.VaultCrypto17import com.peachpasswords.vault.VaultEntry18import com.peachpasswords.vault.VaultLockTimer19import com.peachpasswords.vault.VaultRepository20import dagger.hilt.android.lifecycle.HiltViewModel21import dagger.hilt.android.qualifiers.ApplicationContext22import kotlinx.coroutines.Dispatchers23import kotlinx.coroutines.async24import kotlinx.coroutines.flow.MutableStateFlow25import kotlinx.coroutines.flow.StateFlow26import kotlinx.coroutines.flow.asStateFlow27import kotlinx.coroutines.flow.update28import kotlinx.coroutines.launch29import kotlinx.coroutines.withContext30import javax.inject.Inject3132data class LockUiState(33 val hasVault: Boolean = false,34 val loading: Boolean = false,35 val error: String? = null,36 val unlocked: Boolean = false,37 38 val created: Boolean = false,39 40 val shakeNonce: Int = 0,41 42 val duressDestroyed: Boolean = false,43)4445const val MIN_PASSWORD_LENGTH = 12464748const val MIN_CREATE_STRENGTH = 249505152535455internal fun createValidationError(password: String, confirm: String, score: Int): String? = when {56 password.length < MIN_PASSWORD_LENGTH -> "password must be at least $MIN_PASSWORD_LENGTH characters"57 score < MIN_CREATE_STRENGTH -> "Password too weak — try a longer or more varied password"58 password != confirm -> "passwords do not match"59 else -> null60}616263internal fun canSubmitCreateVault(password: String, confirm: String, score: Int): Boolean =64 createValidationError(password, confirm, score) == null656667686970717273internal fun postUnlockDestination(74 lastViewedId: String?,75 resolve: (String) -> VaultEntry?,76 firstActive: VaultEntry?,77): PeachNav {78 val target = lastViewedId?.let(resolve) ?: firstActive ?: return PeachNav.VaultList79 return entryTapDestination(target)80}8182@HiltViewModel83class LockViewModel @Inject constructor(84 private val vault: VaultRepository,85 private val duress: DuressConfigStore,86 private val lockTimer: VaultLockTimer,87 private val lastViewed: LastViewedEntryStore,88 @ApplicationContext private val appContext: Context,89) : ViewModel() {90 private val _state = MutableStateFlow(LockUiState(hasVault = vault.hasVault()))91 val state: StateFlow<LockUiState> = _state.asStateFlow()9293 94 fun postUnlockDestination(): PeachNav =95 postUnlockDestination(lastViewed.get(), vault::getEntry, vault.getActiveEntries().firstOrNull())9697 fun unlock(password: String) {98 if (password.isBlank() || _state.value.loading) return99 _state.update { it.copy(loading = true, error = null) }100 viewModelScope.launch {101 102 103 104 105 106 107 val (duressUnlock, slotA) = withContext(Dispatchers.Default) {108 val duressDeferred = async { if (duress.isConfigured()) vault.tryUnlockDuress(password) else null }109 val slotADeferred = async { vault.tryUnlockSlotA(password) }110 duressDeferred.await() to slotADeferred.await()111 }112 when {113 114 duressUnlock != null -> {115 slotA?.let(vault::discardSlotAUnlock)116 handleDuress(duressUnlock)117 }118 slotA != null -> {119 vault.commitSlotAUnlock(slotA)120 lockTimer.start()121 _state.update { it.copy(loading = false, unlocked = true) }122 }123 else -> _state.update {124 it.copy(loading = false, error = "incorrect password", shakeNonce = it.shakeNonce + 1)125 }126 }127 }128 }129130 131132133134135 private suspend fun handleDuress(duressUnlock: VaultRepository.DuressUnlock) {136 when (duress.action()) {137 DuressAction.DECOY -> when (vault.commitDuressUnlock(duressUnlock)) {138 is UnlockResult.Success -> { lockTimer.start(); _state.update { it.copy(loading = false, unlocked = true) } }139 else -> _state.update {140 it.copy(loading = false, error = "incorrect password", shakeNonce = it.shakeNonce + 1)141 }142 }143 DuressAction.DESTROY -> {144 vault.discardDuressUnlock(duressUnlock)145 vault.destroyVault()146 duress.clear()147 _state.update { it.copy(loading = false, hasVault = false, duressDestroyed = true) }148 }149 }150 }151152 fun create(password: String, confirm: String) {153 if (_state.value.loading) return154 createValidationError(password, confirm, scorePassword(password))?.let { msg ->155 return _state.update { it.copy(error = msg) }156 }157 _state.update { it.copy(loading = true, error = null) }158 viewModelScope.launch {159 when (val r = vault.createVault(password)) {160 is CreateVaultResult.Success -> { lockTimer.start(); _state.update { it.copy(loading = false, unlocked = true, created = true) } }161 is CreateVaultResult.Error -> _state.update { it.copy(loading = false, error = r.reason) }162 }163 }164 }165166 fun clearError() = _state.update { it.copy(error = null) }167168 169170171172173 fun canTryBiometric(): Boolean =174 vault.hasVault() &&175 Biometrics.strongAvailable(appContext) &&176 BiometricKeyStore.hasWrappedKey(appContext)177178 179180181182183 fun biometricUnlock(activity: FragmentActivity) {184 if (_state.value.loading) return185 val cipher = BiometricKeyStore.decryptCipher(appContext) ?: return186 Biometrics.authenticateCrypto(187 activity = activity,188 title = "Unlock Peach",189 subtitle = "Use your biometric to unlock your vault",190 crypto = BiometricPrompt.CryptoObject(cipher),191 onSuccess = { result ->192 val used = result.cryptoObject?.cipher ?: return@authenticateCrypto193 val key = BiometricKeyStore.unwrap(appContext, used) ?: return@authenticateCrypto194 viewModelScope.launch {195 val r = vault.unlockWithKey(key)196 VaultCrypto.zero(key)197 when (r) {198 is UnlockResult.Success -> { lockTimer.start(); _state.update { it.copy(unlocked = true) } }199 else -> _state.update { it.copy(error = "biometric unlock failed", shakeNonce = it.shakeNonce + 1) }200 }201 }202 },203 )204 }205}