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 pinEnabled: Boolean = false,38 val pinPreferred: Boolean = false,39 val isPro: Boolean = false,40 val loading: Boolean = false,41 val error: String? = null,42 val unlocked: Boolean = false,43 44 val created: Boolean = false,45 46 val shakeNonce: Int = 0,47 48 val duressDestroyed: Boolean = false,49)5051const val MIN_PASSWORD_LENGTH = 12525354const val MIN_CREATE_STRENGTH = 255565758596061internal fun createValidationError(password: String, confirm: String, score: Int): String? = when {62 password.length < MIN_PASSWORD_LENGTH -> "password must be at least $MIN_PASSWORD_LENGTH characters"63 score < MIN_CREATE_STRENGTH -> "Password too weak — try a longer or more varied password"64 password != confirm -> "passwords do not match"65 else -> null66}676869internal fun canSubmitCreateVault(password: String, confirm: String, score: Int): Boolean =70 createValidationError(password, confirm, score) == null717273747576777879internal fun postUnlockDestination(80 lastViewedId: String?,81 resolve: (String) -> VaultEntry?,82 firstActive: VaultEntry?,83): PeachNav {84 val target = lastViewedId?.let(resolve) ?: firstActive ?: return PeachNav.VaultList85 return entryTapDestination(target)86}8788@HiltViewModel89class LockViewModel @Inject constructor(90 private val vault: VaultRepository,91 license: LicenseRepository,92 private val duress: DuressConfigStore,93 private val lockTimer: VaultLockTimer,94 private val lastViewed: LastViewedEntryStore,95 private val foregroundSync: ForegroundSyncCoordinator,96 private val syncFreshStart: SyncFreshStartResetter,97 private val pinUnlock: com.peachpasswords.vault.PinUnlockStore,98 @ApplicationContext private val appContext: Context,99) : ViewModel() {100 private val _state = MutableStateFlow(101 LockUiState(102 hasVault = vault.hasVault(),103 pinEnabled = pinUnlock.isConfigured(),104 pinPreferred = pinUnlock.isPinPreferred(),105 isPro = license.isProActive(),106 ),107 )108 val state: StateFlow<LockUiState> = _state.asStateFlow()109110 111 fun postUnlockDestination(): PeachNav =112 postUnlockDestination(lastViewed.get(), vault::getEntry, vault.getActiveEntries().firstOrNull())113114 115116117118119120121122123 private class UnlockCandidates(private val vault: VaultRepository) : AutoCloseable {124 private val lock = Any()125 private var closed = false126 private var slotA: VaultRepository.SlotAUnlock? = null127 private var duress: VaultRepository.DuressUnlock? = null128129 fun registerSlotA(candidate: VaultRepository.SlotAUnlock) {130 val discard = synchronized(lock) {131 if (closed) true else {132 slotA?.let(vault::discardSlotAUnlock)133 slotA = candidate134 false135 }136 }137 if (discard) vault.discardSlotAUnlock(candidate)138 }139140 fun registerDuress(candidate: VaultRepository.DuressUnlock) {141 val discard = synchronized(lock) {142 if (closed) true else {143 duress?.let(vault::discardDuressUnlock)144 duress = candidate145 false146 }147 }148 if (discard) vault.discardDuressUnlock(candidate)149 }150151 fun takeSlotA(): VaultRepository.SlotAUnlock? = synchronized(lock) {152 slotA.also { slotA = null }153 }154155 fun takeDuress(): VaultRepository.DuressUnlock? = synchronized(lock) {156 duress.also { duress = null }157 }158159 override fun close() {160 val candidates = synchronized(lock) {161 if (closed) return162 closed = true163 val result = slotA to duress164 slotA = null165 duress = null166 result167 }168 candidates.first?.let(vault::discardSlotAUnlock)169 candidates.second?.let(vault::discardDuressUnlock)170 }171 }172173 fun unlock(password: String) {174 if (password.isBlank() || _state.value.loading) return175 _state.update { it.copy(loading = true, error = null) }176 viewModelScope.launch {177 val candidates = UnlockCandidates(vault)178 try {179 180 181 182 withContext(Dispatchers.Default) {183 kotlinx.coroutines.coroutineScope {184 val duressDeferred = async {185 vault.tryUnlockDuress(password)?.also(candidates::registerDuress)186 }187 val slotADeferred = async {188 vault.tryUnlockSlotA(password)?.also(candidates::registerSlotA)189 }190 191 192 duressDeferred.await()193 slotADeferred.await()194 }195 }196197 when (val duressUnlock = candidates.takeDuress()) {198 199 200 null -> when (val slotAUnlock = candidates.takeSlotA()) {201 null -> _state.update {202 it.copy(loading = false, error = "incorrect password", shakeNonce = it.shakeNonce + 1)203 }204 else -> try {205 when (val result = vault.commitSlotAUnlock(slotAUnlock)) {206 is UnlockResult.Success -> {207 lockTimer.start()208 vault.clearUiGate()209 foregroundSync.triggerForegroundSync()210 pinUnlock.markPasswordUsed()211 pinUnlock.replenishAttemptGates()212 _state.update { it.copy(loading = false, unlocked = true, pinPreferred = false) }213 }214 is UnlockResult.Error -> _state.update {215 it.copy(loading = false, error = result.reason, shakeNonce = it.shakeNonce + 1)216 }217 is UnlockResult.WrongPassword -> _state.update {218 it.copy(loading = false, error = "incorrect password", shakeNonce = it.shakeNonce + 1)219 }220 }221 } finally {222 223 224 225 vault.discardSlotAUnlock(slotAUnlock)226 }227 }228 else -> {229 candidates.takeSlotA()?.let(vault::discardSlotAUnlock)230 try {231 handleDuress(duressUnlock)232 } finally {233 vault.discardDuressUnlock(duressUnlock)234 }235 }236 }237 } finally {238 239 240 241 candidates.close()242 }243 }244 }245246 fun unlockWithPin(pin: String) {247 if (pin.isBlank() || _state.value.loading) return248 _state.update { it.copy(loading = true, error = null) }249 viewModelScope.launch {250 val key = withContext(Dispatchers.Default) {251 pinUnlock.unlockVaultKey(pin)252 }253 if (key == null) {254 _state.update {255 it.copy(256 loading = false,257 error = if (pinUnlock.areAttemptsExhausted()) {258 "PIN locked — use master password"259 } else {260 "incorrect PIN"261 },262 shakeNonce = it.shakeNonce + 1,263 )264 }265 return@launch266 }267 try {268 when (val result = vault.unlockWithKey(key)) {269 is UnlockResult.Success -> {270 lockTimer.start()271 vault.clearUiGate()272 foregroundSync.triggerForegroundSync()273 pinUnlock.markPinUsed()274 _state.update { it.copy(loading = false, unlocked = true, pinPreferred = true) }275 }276 else -> _state.update {277 it.copy(loading = false, error = "incorrect PIN", shakeNonce = it.shakeNonce + 1)278 }279 }280 } finally {281 VaultCrypto.zero(key)282 }283 }284 }285286 287288289290291 private suspend fun handleDuress(duressUnlock: VaultRepository.DuressUnlock) {292 when (duress.action()) {293 DuressAction.DECOY -> when (vault.commitDuressUnlock(duressUnlock)) {294 is UnlockResult.Success -> {295 296 297 foregroundSync.stopForDecoySession()298 lockTimer.start()299 vault.clearUiGate()300 _state.update { it.copy(loading = false, unlocked = true) }301 }302 else -> _state.update {303 it.copy(loading = false, error = "incorrect password", shakeNonce = it.shakeNonce + 1)304 }305 }306 DuressAction.DESTROY -> {307 vault.discardDuressUnlock(duressUnlock)308 val destroyed = vault.destroyVault()309 if (!destroyed) {310 _state.update { it.copy(loading = false, error = "destroy_failed") }311 return@handleDuress312 }313 syncFreshStart.reset()314 duress.clear()315 _state.update { it.copy(loading = false, hasVault = false, duressDestroyed = true) }316 }317 }318 }319320 fun create(password: String, confirm: String) {321 if (_state.value.loading) return322 createValidationError(password, confirm, scorePassword(password))?.let { msg ->323 return _state.update { it.copy(error = msg) }324 }325 _state.update { it.copy(loading = true, error = null) }326 viewModelScope.launch {327 when (val r = vault.createVault(password)) {328 is CreateVaultResult.Success -> { lockTimer.start(); _state.update { it.copy(loading = false, unlocked = true, created = true) } }329 is CreateVaultResult.Error -> _state.update { it.copy(loading = false, error = r.reason) }330 }331 }332 }333334 fun clearError() = _state.update { it.copy(error = null) }335336 337338339340341 fun canTryBiometric(): Boolean =342 vault.hasVault() &&343 Biometrics.strongAvailable(appContext) &&344 BiometricKeyStore.hasWrappedKey(appContext)345346 347348349350351 fun biometricUnlock(activity: FragmentActivity) {352 if (_state.value.loading) return353 354 if (vault.isUnlocked.value) {355 Biometrics.authenticate(356 activity = activity,357 title = "Unlock Peach",358 subtitle = "Confirm your identity to access your vault",359 onSuccess = {360 vault.clearUiGate()361 lockTimer.poke()362 foregroundSync.triggerForegroundSync()363 _state.update { it.copy(unlocked = true) }364 },365 )366 return367 }368 val cipher = BiometricKeyStore.decryptCipher(appContext) ?: return369 Biometrics.authenticateCrypto(370 activity = activity,371 title = "Unlock Peach",372 subtitle = "Use your biometric to unlock your vault",373 crypto = BiometricPrompt.CryptoObject(cipher),374 onSuccess = { result ->375 val used = result.cryptoObject?.cipher ?: return@authenticateCrypto376 viewModelScope.launch {377 378 379 380 var key: ByteArray? = null381 val r = try {382 key = BiometricKeyStore.unwrap(appContext, used)383 if (key == null) return@launch384 vault.unlockWithKey(key!!)385 } finally {386 key?.let(VaultCrypto::zero)387 }388 when (r) {389 is UnlockResult.Success -> {390 lockTimer.start()391 vault.clearUiGate()392 foregroundSync.triggerForegroundSync()393 _state.update { it.copy(unlocked = true) }394 }395 else -> _state.update { it.copy(error = "biometric unlock failed", shakeNonce = it.shakeNonce + 1) }396 }397 }398 },399 )400 }401}