EntryDetailViewModel.kt

14.4 KB · raw

1package com.peachpasswords.ui.vault23import android.content.Context4import com.peachpasswords.shared.PeachClipboard5import androidx.lifecycle.SavedStateHandle6import androidx.lifecycle.ViewModel7import androidx.lifecycle.viewModelScope8import com.peachpasswords.license.LicenseRepository9import com.peachpasswords.ui.PeachNav10import com.peachpasswords.ui.vault.entryTapDestination11import com.peachpasswords.vault.Canary12import com.peachpasswords.vault.LastViewedEntryStore13import com.peachpasswords.vault.TOTPGenerator14import com.peachpasswords.vault.VaultEntry15import com.peachpasswords.vault.VaultLockTimer16import com.peachpasswords.vault.VaultRepository17import dagger.hilt.android.lifecycle.HiltViewModel18import dagger.hilt.android.qualifiers.ApplicationContext19import kotlinx.coroutines.flow.MutableStateFlow20import kotlinx.coroutines.flow.SharingStarted21import kotlinx.coroutines.flow.StateFlow22import kotlinx.coroutines.flow.asStateFlow23import kotlinx.coroutines.flow.map24import kotlinx.coroutines.flow.stateIn25import kotlinx.coroutines.Dispatchers26import kotlinx.coroutines.launch27import kotlinx.coroutines.withContext28import okhttp3.MediaType.Companion.toMediaType29import okhttp3.OkHttpClient30import okhttp3.Request31import okhttp3.RequestBody.Companion.toRequestBody32import javax.inject.Inject3334@HiltViewModel35class EntryDetailViewModel @Inject constructor(36    private val vault: VaultRepository,37    @ApplicationContext private val appContext: Context,38    private val lastViewed: LastViewedEntryStore,39    private val license: LicenseRepository,40    private val lockTimer: VaultLockTimer,41    savedStateHandle: SavedStateHandle,42) : ViewModel() {43    private val id: String = savedStateHandle.get<String>("id").orEmpty()4445    /** Lock the vault directly from the entry-deck landing surface (C1: this46     * screen has no "back" when it's the post-unlock root, so it needs its47     * own lock affordance rather than relying on a parent screen's). */48    fun lock() {49        lockTimer.cancel()50        vault.lock()51    }5253    init {54        // Remember this as the entry to reopen on the next unlock (brief Fix 3).55        // Only record a real, live entry so a bad deep-link id never becomes the target.56        if (id.isNotBlank() && vault.getEntry(id) != null) lastViewed.set(id)57    }5859    val entry: StateFlow<VaultEntry?> =60        vault.entries.map { list -> list.firstOrNull { it.id == id } }61            .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), vault.getEntry(id))6263    val activeEntries: StateFlow<List<VaultEntry>> =64        // Notes are not a destination of the Entry view (NotesMode is the65        // notes surface). Filtering once here means the prev/next buttons,66        // the index lookup and the initial entry picker all share the same67        // view of "what is navigable" — mirroring the browser's68        // `nonNoteEntries` memo so a user can never get stuck inside a note.69        vault.entries70            .map { it.filter(::isNavigable) }71            .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), vault.getActiveEntries().filter(::isNavigable))7273    /** Prior identities, classified by @ and ranked by frequency for inline editing. */74    val usernameSuggestions: StateFlow<List<IdentitySuggestion>> =75        vault.entries76            .map { identitySuggestionCounts(it, IdentityField.Username) }77            .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList())7879    val emailSuggestions: StateFlow<List<IdentitySuggestion>> =80        vault.entries81            .map { identitySuggestionCounts(it, IdentityField.Email) }82            .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList())8384    val allTags: StateFlow<List<String>> =85        vault.entries86            .map { vault.getDistinctTags() }87            .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList())8889    private val _isPro = MutableStateFlow(license.isProActive())90    val isPro: StateFlow<Boolean> = _isPro.asStateFlow()9192    private val _revealed = MutableStateFlow(false)93    val revealed: StateFlow<Boolean> = _revealed.asStateFlow()9495    private val _copiedField = MutableStateFlow<String?>(null)96    val copiedField: StateFlow<String?> = _copiedField.asStateFlow()9798    private val _canaryMessage = MutableStateFlow<String?>(null)99    val canaryMessage: StateFlow<String?> = _canaryMessage.asStateFlow()100101    private val _canaryTesting = MutableStateFlow(false)102    val canaryTesting: StateFlow<Boolean> = _canaryTesting.asStateFlow()103104    private val canaryClient by lazy { OkHttpClient() }105106    /** Reveal the password and auto-mask after 10 seconds (matches the browser). */107    fun reveal() {108        _revealed.value = true109        viewModelScope.launch {110            kotlinx.coroutines.delay(10_000)111            _revealed.value = false112        }113    }114115    fun mask() { _revealed.value = false }116117    private val _cvcRevealed = MutableStateFlow(false)118    val cvcRevealed: StateFlow<Boolean> = _cvcRevealed.asStateFlow()119120    /** Reveal the CVC and auto-mask after 10s — same policy as the password reveal. */121    fun revealCvc() {122        _cvcRevealed.value = true123        viewModelScope.launch {124            kotlinx.coroutines.delay(10_000)125            _cvcRevealed.value = false126        }127    }128129    fun maskCvc() { _cvcRevealed.value = false }130131    fun copy(field: String, value: String?) {132        val v = value ?: return133        PeachClipboard.copy(appContext, "peach", v)134        viewModelScope.launch {135            _copiedField.value = field136            kotlinx.coroutines.delay(1200)137            if (_copiedField.value == field) _copiedField.value = null138        }139    }140141    /** Copy the live TOTP code (never the seed). */142    fun copyTotp() {143        val totp = entry.value?.totp ?: return144        copy("totp", TOTPGenerator.generateCode(totp))145    }146147    fun adjacentDestination(offset: Int): PeachNav? {148        val list = activeEntries.value149        if (list.isEmpty()) return null150        val currentIndex = list.indexOfFirst { it.id == id }151        if (currentIndex < 0) return null152        val nextIndex = (currentIndex + offset + list.size) % list.size153        return entryTapDestination(list[nextIndex])154    }155156    private fun isNavigable(entry: VaultEntry): Boolean = entry.deletedAt == null && entry.type != "note"157158    /**159     * Apply a generated password via updateEntry — VaultRepository captures the160     * prior password into history automatically, so we only pass the new value.161     */162    fun applyGeneratedPassword(newPassword: String, onDone: () -> Unit = {}) {163        val current = entry.value ?: return164        if (newPassword.isEmpty() || newPassword == current.password) { onDone(); return }165        viewModelScope.launch {166            runCatching { vault.updateEntry(current.copy(password = newPassword)) }167            onDone()168        }169    }170171    /**172     * Persist a single field's inline edit via a full-entry updateEntry patch173     * (which handles password-history capture + timestamps). No-op if entry gone.174     */175    fun updateField(field: String, value: String) {176        val current = entry.value ?: return177        val patch = when (field) {178            "title" -> current.copy(title = value.trim().ifBlank { current.title })179            "url" -> current.copy(url = value.ifBlank { null })180            "username" -> current.copy(username = value.ifBlank { null })181            "email" -> current.copy(email = value.ifBlank { null })182            "password" -> current.copy(password = value.ifBlank { null })183            "notes" -> current.copy(notes = value.ifBlank { null })184            "totp" -> current.copy(totp = value.ifBlank { null })185            else -> return186        }187        viewModelScope.launch {188            runCatching { vault.updateEntry(patch) }189        }190    }191192    fun updateAdditionalUrl(index: Int, value: String) {193        val current = entry.value ?: return194        val urls = current.additionalUrls.orEmpty().toMutableList()195        if (index !in urls.indices) return196        urls[index] = value197        val patch = current.copy(198            additionalUrls = urls.map { it.trim() }.filter { it.isNotBlank() }.ifEmpty { null },199        )200        viewModelScope.launch {201            runCatching { vault.updateEntry(patch) }202        }203    }204205    fun addAdditionalUrl(value: String) {206        val current = entry.value ?: return207        val next = (current.additionalUrls.orEmpty() + value)208            .map { it.trim() }209            .filter { it.isNotBlank() }210            .distinct()211            .ifEmpty { null }212        viewModelScope.launch {213            runCatching { vault.updateEntry(current.copy(additionalUrls = next)) }214        }215    }216217    fun addTag(raw: String) {218        val current = entry.value ?: return219        val tag = normalizeTag(raw)220        if (tag.isEmpty()) return221        val next = (current.tags.orEmpty() + tag).distinct().take(10)222        viewModelScope.launch {223            runCatching { vault.updateEntry(current.copy(tags = next.ifEmpty { null })) }224        }225    }226227    fun removeTag(tag: String) {228        val current = entry.value ?: return229        val next = current.tags.orEmpty().filterNot { it == tag }230        viewModelScope.launch {231            runCatching { vault.updateEntry(current.copy(tags = next.ifEmpty { null })) }232        }233    }234235    fun updateCard(cardNickname: String, cardNumber: String, cardholderName: String, expiry: String, cvc: String) {236        val current = entry.value ?: return237        val digits = cardNumber.filter { it.isDigit() }238        val expDigits = expiry.filter { it.isDigit() }239        val network = CardFieldUtils.detectNetwork(digits).takeIf { it != "unknown" }240        viewModelScope.launch {241            runCatching {242                vault.updateEntry(243                    current.copy(244                        cardNickname = cardNickname.ifBlank { null },245                        cardNumber = digits.ifBlank { null },246                        cardholderName = cardholderName.ifBlank { null },247                        expMonth = expDigits.take(2).ifBlank { null },248                        expYear = expDigits.drop(2).take(4).ifBlank { null },249                        cvc = cvc.ifBlank { null },250                        cardNetwork = network,251                    ),252                )253            }254        }255    }256257    fun saveCanary(enabled: Boolean, webhookUrl: String, label: String) {258        val current = entry.value ?: return259        val url = webhookUrl.trim()260        val nextLabel = label.trim().ifBlank { null }261        val nextCanary = if (enabled) {262            if (current.canary == null && !license.isProActive()) {263                _canaryMessage.value = "Canary entries require Peach Pro"264                return265            }266            if (!isAllowedCanaryWebhookUrl(url)) {267                _canaryMessage.value = "enter a public HTTPS webhook URL"268                return269            }270            Canary(271                webhookUrl = url,272                label = nextLabel,273                lastPinged = current.canary?.lastPinged,274                pingCount = current.canary?.pingCount,275            )276        } else {277            null278        }279280        viewModelScope.launch {281            runCatching { vault.updateEntry(current.copy(canary = nextCanary)) }282                .onSuccess { _canaryMessage.value = if (nextCanary == null) "canary disabled" else "canary saved" }283                .onFailure { _canaryMessage.value = it.message ?: "could not save canary" }284        }285    }286287    fun testCanary(webhookUrl: String) {288        val current = entry.value ?: return289        val url = webhookUrl.trim()290        if (!isAllowedCanaryWebhookUrl(url)) {291            _canaryMessage.value = "enter a public HTTPS webhook URL"292            return293        }294        viewModelScope.launch {295            _canaryTesting.value = true296            _canaryMessage.value = null297            val result = runCatching {298                withContext(Dispatchers.IO) {299                    val body = """300                        {"event":"peach_canary_test","entry":${jsonString(current.title)},"trigger":"test","timestamp":${jsonString(java.time.Instant.now().toString())},"source":"peach-android"}301                    """.trimIndent().toRequestBody("application/json".toMediaType())302                    val request = Request.Builder().url(url).post(body).build()303                    canaryClient.newCall(request).execute().use { it.isSuccessful }304                }305            }.getOrDefault(false)306            _canaryMessage.value = if (result) "sent" else "failed - check URL"307            _canaryTesting.value = false308        }309    }310311    /**312     * Restore a past password as the current one. updateEntry captures the313     * now-outgoing password into history, so this is a normal password swap.314     */315    fun restorePassword(entryId: String, oldPassword: String) {316        viewModelScope.launch {317            val current = vault.getEntry(entryId) ?: return@launch318            if (current.password == oldPassword) return@launch319            runCatching { vault.updateEntry(current.copy(password = oldPassword)) }320        }321    }322323    fun delete(onDone: () -> Unit) {324        viewModelScope.launch {325            runCatching { vault.deleteEntry(id) }326            onDone()327        }328    }329}330331internal fun isAllowedCanaryWebhookUrl(value: String): Boolean {332    return try {333        val uri = java.net.URI(value)334        val host = uri.host?.lowercase() ?: return false335        if (uri.scheme != "https") return false336        if (host == "localhost" || host.endsWith(".localhost")) return false337        if (isPrivateIpv4(host) || isPrivateIpv6(host)) return false338        true339    } catch (_: Exception) {340        false341    }342}343344private fun isPrivateIpv4(host: String): Boolean {345    val parts = host.split(".")346    if (parts.size != 4) return false347    val octets = parts.map { it.toIntOrNull() ?: return false }348    if (octets.any { it !in 0..255 }) return false349    return octets[0] == 10 ||350        (octets[0] == 172 && octets[1] in 16..31) ||351        (octets[0] == 192 && octets[1] == 168) ||352        octets[0] == 127 ||353        (octets[0] == 169 && octets[1] == 254)354}355356private fun isPrivateIpv6(host: String): Boolean {357    val normalized = host.trim('[', ']').lowercase()358    return normalized == "::1" ||359        normalized.startsWith("fc") ||360        normalized.startsWith("fd") ||361        normalized.startsWith("fe80:")362}363364private fun jsonString(value: String): String =365    "\"" + value366        .replace("\\", "\\\\")367        .replace("\"", "\\\"")368        .replace("\n", "\\n")369        .replace("\r", "\\r") + "\""