1package com.peachpasswords.ui.lock23import android.content.Context4import androidx.biometric.BiometricPrompt5import androidx.fragment.app.FragmentActivity6import androidx.lifecycle.ViewModel7import androidx.lifecycle.viewModelScope8import com.peachpasswords.sync.ForegroundSyncCoordinator9import com.peachpasswords.sync.PeachSyncService10import com.peachpasswords.ui.PeachNav11import com.peachpasswords.ui.vault.entryTapDestination12import com.peachpasswords.vault.CreateVaultResult13import com.peachpasswords.ui.security.scorePassword14import com.peachpasswords.vault.DuressAction15import com.peachpasswords.vault.DuressConfigStore16import com.peachpasswords.vault.LastViewedEntryStore17import com.peachpasswords.vault.UnlockResult18import com.peachpasswords.vault.VaultCrypto19import com.peachpasswords.vault.VaultEntry20import com.peachpasswords.vault.VaultLockTimer21import com.peachpasswords.vault.VaultRepository22import dagger.hilt.android.lifecycle.HiltViewModel23import dagger.hilt.android.qualifiers.ApplicationContext24import kotlinx.coroutines.Dispatchers25import kotlinx.coroutines.async26import kotlinx.coroutines.flow.MutableStateFlow27import kotlinx.coroutines.flow.StateFlow28import kotlinx.coroutines.flow.asStateFlow29import kotlinx.coroutines.flow.update30import kotlinx.coroutines.launch31import kotlinx.coroutines.withContext32import javax.inject.Inject3334data class LockUiState(35 val hasVault: Boolean = false,36 val loading: Boolean = false,37 val error: String? = null,38 val unlocked: Boolean = false,39 40 val created: Boolean = false,41 42 val shakeNonce: Int = 0,43 44 val duressDestroyed: Boolean = false,45)4647const val MIN_PASSWORD_LENGTH = 12484950const val MIN_CREATE_STRENGTH = 251525354555657internal fun createValidationError(password: String, confirm: String, score: Int): String? = when {58 password.length < MIN_PASSWORD_LENGTH -> "password must be at least $MIN_PASSWORD_LENGTH characters"59 score < MIN_CREATE_STRENGTH -> "Password too weak — try a longer or more varied password"60 password != confirm -> "passwords do not match"61 else -> null62}636465internal fun canSubmitCreateVault(password: String, confirm: String, score: Int): Boolean =66 createValidationError(password, confirm, score) == null676869707172737475internal fun postUnlockDestination(76 lastViewedId: String?,77 resolve: (String) -> VaultEntry?,78 firstActive: VaultEntry?,79): PeachNav {80 val target = lastViewedId?.let(resolve) ?: firstActive ?: return PeachNav.VaultList81 return entryTapDestination(target)82}8384@HiltViewModel85class LockViewModel @Inject constructor(86 private val vault: VaultRepository,87 private val duress: DuressConfigStore,88 private val lockTimer: VaultLockTimer,89 private val lastViewed: LastViewedEntryStore,90 private val foregroundSync: ForegroundSyncCoordinator,91 @ApplicationContext private val appContext: Context,92) : ViewModel() {93 private val _state = MutableStateFlow(LockUiState(hasVault = vault.hasVault()))94 val state: StateFlow<LockUiState> = _state.asStateFlow()9596 97 fun postUnlockDestination(): PeachNav =98 postUnlockDestination(lastViewed.get(), vault::getEntry, vault.getActiveEntries().firstOrNull())99100 fun unlock(password: String) {101 if (password.isBlank() || _state.value.loading) return102 _state.update { it.copy(loading = true, error = null) }103 viewModelScope.launch {104 105 106 107 108 109 110 val (duressUnlock, slotA) = withContext(Dispatchers.Default) {111 val duressDeferred = async { if (duress.isConfigured()) vault.tryUnlockDuress(password) else null }112 val slotADeferred = async { vault.tryUnlockSlotA(password) }113 duressDeferred.await() to slotADeferred.await()114 }115 when {116 117 duressUnlock != null -> {118 slotA?.let(vault::discardSlotAUnlock)119 handleDuress(duressUnlock)120 }121 slotA != null -> {122 vault.commitSlotAUnlock(slotA)123 lockTimer.start()124 PeachSyncService.start(appContext)125 foregroundSync.triggerForegroundSync()126 _state.update { it.copy(loading = false, unlocked = true) }127 }128 else -> _state.update {129 it.copy(loading = false, error = "incorrect password", shakeNonce = it.shakeNonce + 1)130 }131 }132 }133 }134135 136137138139140 private suspend fun handleDuress(duressUnlock: VaultRepository.DuressUnlock) {141 when (duress.action()) {142 DuressAction.DECOY -> when (vault.commitDuressUnlock(duressUnlock)) {143 is UnlockResult.Success -> { lockTimer.start(); _state.update { it.copy(loading = false, unlocked = true) } }144 else -> _state.update {145 it.copy(loading = false, error = "incorrect password", shakeNonce = it.shakeNonce + 1)146 }147 }148 DuressAction.DESTROY -> {149 vault.discardDuressUnlock(duressUnlock)150 vault.destroyVault()151 duress.clear()152 _state.update { it.copy(loading = false, hasVault = false, duressDestroyed = true) }153 }154 }155 }156157 fun create(password: String, confirm: String) {158 if (_state.value.loading) return159 createValidationError(password, confirm, scorePassword(password))?.let { msg ->160 return _state.update { it.copy(error = msg) }161 }162 _state.update { it.copy(loading = true, error = null) }163 viewModelScope.launch {164 when (val r = vault.createVault(password)) {165 is CreateVaultResult.Success -> { lockTimer.start(); _state.update { it.copy(loading = false, unlocked = true, created = true) } }166 is CreateVaultResult.Error -> _state.update { it.copy(loading = false, error = r.reason) }167 }168 }169 }170171 fun clearError() = _state.update { it.copy(error = null) }172173 174175176177178 fun canTryBiometric(): Boolean =179 vault.hasVault() &&180 Biometrics.strongAvailable(appContext) &&181 BiometricKeyStore.hasWrappedKey(appContext)182183 184185186187188 fun biometricUnlock(activity: FragmentActivity) {189 if (_state.value.loading) return190 val cipher = BiometricKeyStore.decryptCipher(appContext) ?: return191 Biometrics.authenticateCrypto(192 activity = activity,193 title = "Unlock Peach",194 subtitle = "Use your biometric to unlock your vault",195 crypto = BiometricPrompt.CryptoObject(cipher),196 onSuccess = { result ->197 val used = result.cryptoObject?.cipher ?: return@authenticateCrypto198 val key = BiometricKeyStore.unwrap(appContext, used) ?: return@authenticateCrypto199 viewModelScope.launch {200 val r = vault.unlockWithKey(key)201 VaultCrypto.zero(key)202 when (r) {203 is UnlockResult.Success -> {204 lockTimer.start()205 PeachSyncService.start(appContext)206 foregroundSync.triggerForegroundSync()207 _state.update { it.copy(unlocked = true) }208 }209 else -> _state.update { it.copy(error = "biometric unlock failed", shakeNonce = it.shakeNonce + 1) }210 }211 }212 },213 )214 }215}