1package com.peachpasswords.ui.lock23import android.content.Context4import androidx.biometric.BiometricPrompt5import androidx.fragment.app.FragmentActivity6import androidx.lifecycle.ViewModel7import androidx.lifecycle.viewModelScope8import com.peachpasswords.license.LicenseRepository9import com.peachpasswords.sync.ForegroundSyncCoordinator10import com.peachpasswords.sync.SyncFreshStartResetter11import com.peachpasswords.ui.PeachNav12import com.peachpasswords.ui.vault.entryTapDestination13import com.peachpasswords.vault.CreateVaultResult14import com.peachpasswords.ui.security.scorePassword15import com.peachpasswords.vault.DuressAction16import com.peachpasswords.vault.DuressConfigStore17import com.peachpasswords.vault.LastViewedEntryStore18import com.peachpasswords.vault.UnlockResult19import com.peachpasswords.vault.VaultCrypto20import com.peachpasswords.vault.VaultEntry21import com.peachpasswords.vault.VaultLockTimer22import com.peachpasswords.vault.VaultRepository23import dagger.hilt.android.lifecycle.HiltViewModel24import dagger.hilt.android.qualifiers.ApplicationContext25import kotlinx.coroutines.Dispatchers26import kotlinx.coroutines.async27import kotlinx.coroutines.flow.MutableStateFlow28import kotlinx.coroutines.flow.StateFlow29import kotlinx.coroutines.flow.asStateFlow30import kotlinx.coroutines.flow.update31import kotlinx.coroutines.launch32import kotlinx.coroutines.withContext33import javax.inject.Inject3435data class LockUiState(36 val hasVault: Boolean = false,37 val isPro: Boolean = false,38 val loading: Boolean = false,39 val error: String? = null,40 val unlocked: Boolean = false,41 42 val created: Boolean = false,43 44 val shakeNonce: Int = 0,45 46 val duressDestroyed: Boolean = false,47)4849const val MIN_PASSWORD_LENGTH = 12505152const val MIN_CREATE_STRENGTH = 253545556575859internal fun createValidationError(password: String, confirm: String, score: Int): String? = when {60 password.length < MIN_PASSWORD_LENGTH -> "password must be at least $MIN_PASSWORD_LENGTH characters"61 score < MIN_CREATE_STRENGTH -> "Password too weak — try a longer or more varied password"62 password != confirm -> "passwords do not match"63 else -> null64}656667internal fun canSubmitCreateVault(password: String, confirm: String, score: Int): Boolean =68 createValidationError(password, confirm, score) == null697071727374757677internal fun postUnlockDestination(78 lastViewedId: String?,79 resolve: (String) -> VaultEntry?,80 firstActive: VaultEntry?,81): PeachNav {82 val target = lastViewedId?.let(resolve) ?: firstActive ?: return PeachNav.VaultList83 return entryTapDestination(target)84}8586@HiltViewModel87class LockViewModel @Inject constructor(88 private val vault: VaultRepository,89 license: LicenseRepository,90 private val duress: DuressConfigStore,91 private val lockTimer: VaultLockTimer,92 private val lastViewed: LastViewedEntryStore,93 private val foregroundSync: ForegroundSyncCoordinator,94 private val syncFreshStart: SyncFreshStartResetter,95 @ApplicationContext private val appContext: Context,96) : ViewModel() {97 private val _state = MutableStateFlow(98 LockUiState(hasVault = vault.hasVault(), isPro = license.isProActive()),99 )100 val state: StateFlow<LockUiState> = _state.asStateFlow()101102 103 fun postUnlockDestination(): PeachNav =104 postUnlockDestination(lastViewed.get(), vault::getEntry, vault.getActiveEntries().firstOrNull())105106 107108109110111112113114115 private class UnlockCandidates(private val vault: VaultRepository) : AutoCloseable {116 private val lock = Any()117 private var closed = false118 private var slotA: VaultRepository.SlotAUnlock? = null119 private var duress: VaultRepository.DuressUnlock? = null120121 fun registerSlotA(candidate: VaultRepository.SlotAUnlock) {122 val discard = synchronized(lock) {123 if (closed) true else {124 slotA?.let(vault::discardSlotAUnlock)125 slotA = candidate126 false127 }128 }129 if (discard) vault.discardSlotAUnlock(candidate)130 }131132 fun registerDuress(candidate: VaultRepository.DuressUnlock) {133 val discard = synchronized(lock) {134 if (closed) true else {135 duress?.let(vault::discardDuressUnlock)136 duress = candidate137 false138 }139 }140 if (discard) vault.discardDuressUnlock(candidate)141 }142143 fun takeSlotA(): VaultRepository.SlotAUnlock? = synchronized(lock) {144 slotA.also { slotA = null }145 }146147 fun takeDuress(): VaultRepository.DuressUnlock? = synchronized(lock) {148 duress.also { duress = null }149 }150151 override fun close() {152 val candidates = synchronized(lock) {153 if (closed) return154 closed = true155 val result = slotA to duress156 slotA = null157 duress = null158 result159 }160 candidates.first?.let(vault::discardSlotAUnlock)161 candidates.second?.let(vault::discardDuressUnlock)162 }163 }164165 fun unlock(password: String) {166 if (password.isBlank() || _state.value.loading) return167 _state.update { it.copy(loading = true, error = null) }168 viewModelScope.launch {169 val candidates = UnlockCandidates(vault)170 try {171 172 173 174 withContext(Dispatchers.Default) {175 kotlinx.coroutines.coroutineScope {176 val duressDeferred = async {177 if (duress.isConfigured()) {178 vault.tryUnlockDuress(password)?.also(candidates::registerDuress)179 } else null180 }181 val slotADeferred = async {182 vault.tryUnlockSlotA(password)?.also(candidates::registerSlotA)183 }184 185 186 duressDeferred.await()187 slotADeferred.await()188 }189 }190191 when (val duressUnlock = candidates.takeDuress()) {192 193 194 null -> when (val slotAUnlock = candidates.takeSlotA()) {195 null -> _state.update {196 it.copy(loading = false, error = "incorrect password", shakeNonce = it.shakeNonce + 1)197 }198 else -> try {199 when (val result = vault.commitSlotAUnlock(slotAUnlock)) {200 is UnlockResult.Success -> {201 lockTimer.start()202 vault.clearUiGate()203 foregroundSync.triggerForegroundSync()204 _state.update { it.copy(loading = false, unlocked = true) }205 }206 is UnlockResult.Error -> _state.update {207 it.copy(loading = false, error = result.reason, shakeNonce = it.shakeNonce + 1)208 }209 is UnlockResult.WrongPassword -> _state.update {210 it.copy(loading = false, error = "incorrect password", shakeNonce = it.shakeNonce + 1)211 }212 }213 } finally {214 215 216 217 vault.discardSlotAUnlock(slotAUnlock)218 }219 }220 else -> {221 candidates.takeSlotA()?.let(vault::discardSlotAUnlock)222 try {223 handleDuress(duressUnlock)224 } finally {225 vault.discardDuressUnlock(duressUnlock)226 }227 }228 }229 } finally {230 231 232 233 candidates.close()234 }235 }236 }237238 239240241242243 private suspend fun handleDuress(duressUnlock: VaultRepository.DuressUnlock) {244 when (duress.action()) {245 DuressAction.DECOY -> when (vault.commitDuressUnlock(duressUnlock)) {246 is UnlockResult.Success -> { lockTimer.start(); vault.clearUiGate(); _state.update { it.copy(loading = false, unlocked = true) } }247 else -> _state.update {248 it.copy(loading = false, error = "incorrect password", shakeNonce = it.shakeNonce + 1)249 }250 }251 DuressAction.DESTROY -> {252 vault.discardDuressUnlock(duressUnlock)253 vault.destroyVault()254 syncFreshStart.reset()255 duress.clear()256 _state.update { it.copy(loading = false, hasVault = false, duressDestroyed = true) }257 }258 }259 }260261 fun create(password: String, confirm: String) {262 if (_state.value.loading) return263 createValidationError(password, confirm, scorePassword(password))?.let { msg ->264 return _state.update { it.copy(error = msg) }265 }266 _state.update { it.copy(loading = true, error = null) }267 viewModelScope.launch {268 when (val r = vault.createVault(password)) {269 is CreateVaultResult.Success -> { lockTimer.start(); _state.update { it.copy(loading = false, unlocked = true, created = true) } }270 is CreateVaultResult.Error -> _state.update { it.copy(loading = false, error = r.reason) }271 }272 }273 }274275 fun clearError() = _state.update { it.copy(error = null) }276277 278279280281282 fun canTryBiometric(): Boolean =283 vault.hasVault() &&284 Biometrics.strongAvailable(appContext) &&285 BiometricKeyStore.hasWrappedKey(appContext)286287 288289290291292 fun biometricUnlock(activity: FragmentActivity) {293 if (_state.value.loading) return294 295 if (vault.isUnlocked.value) {296 Biometrics.authenticate(297 activity = activity,298 title = "Unlock Peach",299 subtitle = "Confirm your identity to access your vault",300 onSuccess = {301 vault.clearUiGate()302 lockTimer.poke()303 foregroundSync.triggerForegroundSync()304 _state.update { it.copy(unlocked = true) }305 },306 )307 return308 }309 val cipher = BiometricKeyStore.decryptCipher(appContext) ?: return310 Biometrics.authenticateCrypto(311 activity = activity,312 title = "Unlock Peach",313 subtitle = "Use your biometric to unlock your vault",314 crypto = BiometricPrompt.CryptoObject(cipher),315 onSuccess = { result ->316 val used = result.cryptoObject?.cipher ?: return@authenticateCrypto317 viewModelScope.launch {318 319 320 321 var key: ByteArray? = null322 val r = try {323 key = BiometricKeyStore.unwrap(appContext, used)324 if (key == null) return@launch325 vault.unlockWithKey(key!!)326 } finally {327 key?.let(VaultCrypto::zero)328 }329 when (r) {330 is UnlockResult.Success -> {331 lockTimer.start()332 vault.clearUiGate()333 foregroundSync.triggerForegroundSync()334 _state.update { it.copy(unlocked = true) }335 }336 else -> _state.update { it.copy(error = "biometric unlock failed", shakeNonce = it.shakeNonce + 1) }337 }338 }339 },340 )341 }342}