EntryDetailViewModel.kt

14.1 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    /** Distinct prior username/email values for the identity-suggest dropdown. */74    val identitySuggestions: StateFlow<List<String>> =75        vault.entries76            .map { vault.getDistinctFieldValues("username").take(4) }77            .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList())7879    val allTags: StateFlow<List<String>> =80        vault.entries81            .map { vault.getDistinctTags() }82            .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList())8384    private val _isPro = MutableStateFlow(license.isProActive())85    val isPro: StateFlow<Boolean> = _isPro.asStateFlow()8687    private val _revealed = MutableStateFlow(false)88    val revealed: StateFlow<Boolean> = _revealed.asStateFlow()8990    private val _copiedField = MutableStateFlow<String?>(null)91    val copiedField: StateFlow<String?> = _copiedField.asStateFlow()9293    private val _canaryMessage = MutableStateFlow<String?>(null)94    val canaryMessage: StateFlow<String?> = _canaryMessage.asStateFlow()9596    private val _canaryTesting = MutableStateFlow(false)97    val canaryTesting: StateFlow<Boolean> = _canaryTesting.asStateFlow()9899    private val canaryClient by lazy { OkHttpClient() }100101    /** Reveal the password and auto-mask after 10 seconds (matches the browser). */102    fun reveal() {103        _revealed.value = true104        viewModelScope.launch {105            kotlinx.coroutines.delay(10_000)106            _revealed.value = false107        }108    }109110    fun mask() { _revealed.value = false }111112    private val _cvcRevealed = MutableStateFlow(false)113    val cvcRevealed: StateFlow<Boolean> = _cvcRevealed.asStateFlow()114115    /** Reveal the CVC and auto-mask after 10s — same policy as the password reveal. */116    fun revealCvc() {117        _cvcRevealed.value = true118        viewModelScope.launch {119            kotlinx.coroutines.delay(10_000)120            _cvcRevealed.value = false121        }122    }123124    fun maskCvc() { _cvcRevealed.value = false }125126    fun copy(field: String, value: String?) {127        val v = value ?: return128        PeachClipboard.copy(appContext, "peach", v)129        viewModelScope.launch {130            _copiedField.value = field131            kotlinx.coroutines.delay(1200)132            if (_copiedField.value == field) _copiedField.value = null133        }134    }135136    /** Copy the live TOTP code (never the seed). */137    fun copyTotp() {138        val totp = entry.value?.totp ?: return139        copy("totp", TOTPGenerator.generateCode(totp))140    }141142    fun adjacentDestination(offset: Int): PeachNav? {143        val list = activeEntries.value144        if (list.isEmpty()) return null145        val currentIndex = list.indexOfFirst { it.id == id }146        if (currentIndex < 0) return null147        val nextIndex = (currentIndex + offset + list.size) % list.size148        return entryTapDestination(list[nextIndex])149    }150151    private fun isNavigable(entry: VaultEntry): Boolean = entry.deletedAt == null && entry.type != "note"152153    /**154     * Apply a generated password via updateEntry — VaultRepository captures the155     * prior password into history automatically, so we only pass the new value.156     */157    fun applyGeneratedPassword(newPassword: String, onDone: () -> Unit = {}) {158        val current = entry.value ?: return159        if (newPassword.isEmpty() || newPassword == current.password) { onDone(); return }160        viewModelScope.launch {161            runCatching { vault.updateEntry(current.copy(password = newPassword)) }162            onDone()163        }164    }165166    /**167     * Persist a single field's inline edit via a full-entry updateEntry patch168     * (which handles password-history capture + timestamps). No-op if entry gone.169     */170    fun updateField(field: String, value: String) {171        val current = entry.value ?: return172        val patch = when (field) {173            "title" -> current.copy(title = value.trim().ifBlank { current.title })174            "url" -> current.copy(url = value.ifBlank { null })175            "username" -> current.copy(username = value.ifBlank { null })176            "email" -> current.copy(email = value.ifBlank { null })177            "password" -> current.copy(password = value.ifBlank { null })178            "notes" -> current.copy(notes = value.ifBlank { null })179            "totp" -> current.copy(totp = value.ifBlank { null })180            else -> return181        }182        viewModelScope.launch {183            runCatching { vault.updateEntry(patch) }184        }185    }186187    fun updateAdditionalUrl(index: Int, value: String) {188        val current = entry.value ?: return189        val urls = current.additionalUrls.orEmpty().toMutableList()190        if (index !in urls.indices) return191        urls[index] = value192        val patch = current.copy(193            additionalUrls = urls.map { it.trim() }.filter { it.isNotBlank() }.ifEmpty { null },194        )195        viewModelScope.launch {196            runCatching { vault.updateEntry(patch) }197        }198    }199200    fun addAdditionalUrl(value: String) {201        val current = entry.value ?: return202        val next = (current.additionalUrls.orEmpty() + value)203            .map { it.trim() }204            .filter { it.isNotBlank() }205            .distinct()206            .ifEmpty { null }207        viewModelScope.launch {208            runCatching { vault.updateEntry(current.copy(additionalUrls = next)) }209        }210    }211212    fun addTag(raw: String) {213        val current = entry.value ?: return214        val tag = normalizeTag(raw)215        if (tag.isEmpty()) return216        val next = (current.tags.orEmpty() + tag).distinct().take(10)217        viewModelScope.launch {218            runCatching { vault.updateEntry(current.copy(tags = next.ifEmpty { null })) }219        }220    }221222    fun removeTag(tag: String) {223        val current = entry.value ?: return224        val next = current.tags.orEmpty().filterNot { it == tag }225        viewModelScope.launch {226            runCatching { vault.updateEntry(current.copy(tags = next.ifEmpty { null })) }227        }228    }229230    fun updateCard(cardNickname: String, cardNumber: String, cardholderName: String, expiry: String, cvc: String) {231        val current = entry.value ?: return232        val digits = cardNumber.filter { it.isDigit() }233        val expDigits = expiry.filter { it.isDigit() }234        val network = CardFieldUtils.detectNetwork(digits).takeIf { it != "unknown" }235        viewModelScope.launch {236            runCatching {237                vault.updateEntry(238                    current.copy(239                        cardNickname = cardNickname.ifBlank { null },240                        cardNumber = digits.ifBlank { null },241                        cardholderName = cardholderName.ifBlank { null },242                        expMonth = expDigits.take(2).ifBlank { null },243                        expYear = expDigits.drop(2).take(4).ifBlank { null },244                        cvc = cvc.ifBlank { null },245                        cardNetwork = network,246                    ),247                )248            }249        }250    }251252    fun saveCanary(enabled: Boolean, webhookUrl: String, label: String) {253        val current = entry.value ?: return254        val url = webhookUrl.trim()255        val nextLabel = label.trim().ifBlank { null }256        val nextCanary = if (enabled) {257            if (current.canary == null && !license.isProActive()) {258                _canaryMessage.value = "Canary entries require Peach Pro"259                return260            }261            if (!isAllowedCanaryWebhookUrl(url)) {262                _canaryMessage.value = "enter a public HTTPS webhook URL"263                return264            }265            Canary(266                webhookUrl = url,267                label = nextLabel,268                lastPinged = current.canary?.lastPinged,269                pingCount = current.canary?.pingCount,270            )271        } else {272            null273        }274275        viewModelScope.launch {276            runCatching { vault.updateEntry(current.copy(canary = nextCanary)) }277                .onSuccess { _canaryMessage.value = if (nextCanary == null) "canary disabled" else "canary saved" }278                .onFailure { _canaryMessage.value = it.message ?: "could not save canary" }279        }280    }281282    fun testCanary(webhookUrl: String) {283        val current = entry.value ?: return284        val url = webhookUrl.trim()285        if (!isAllowedCanaryWebhookUrl(url)) {286            _canaryMessage.value = "enter a public HTTPS webhook URL"287            return288        }289        viewModelScope.launch {290            _canaryTesting.value = true291            _canaryMessage.value = null292            val result = runCatching {293                withContext(Dispatchers.IO) {294                    val body = """295                        {"event":"peach_canary_test","entry":${jsonString(current.title)},"trigger":"test","timestamp":${jsonString(java.time.Instant.now().toString())},"source":"peach-android"}296                    """.trimIndent().toRequestBody("application/json".toMediaType())297                    val request = Request.Builder().url(url).post(body).build()298                    canaryClient.newCall(request).execute().use { it.isSuccessful }299                }300            }.getOrDefault(false)301            _canaryMessage.value = if (result) "sent" else "failed - check URL"302            _canaryTesting.value = false303        }304    }305306    /**307     * Restore a past password as the current one. updateEntry captures the308     * now-outgoing password into history, so this is a normal password swap.309     */310    fun restorePassword(entryId: String, oldPassword: String) {311        viewModelScope.launch {312            val current = vault.getEntry(entryId) ?: return@launch313            if (current.password == oldPassword) return@launch314            runCatching { vault.updateEntry(current.copy(password = oldPassword)) }315        }316    }317318    fun delete(onDone: () -> Unit) {319        viewModelScope.launch {320            runCatching { vault.deleteEntry(id) }321            onDone()322        }323    }324}325326internal fun isAllowedCanaryWebhookUrl(value: String): Boolean {327    return try {328        val uri = java.net.URI(value)329        val host = uri.host?.lowercase() ?: return false330        if (uri.scheme != "https") return false331        if (host == "localhost" || host.endsWith(".localhost")) return false332        if (isPrivateIpv4(host) || isPrivateIpv6(host)) return false333        true334    } catch (_: Exception) {335        false336    }337}338339private fun isPrivateIpv4(host: String): Boolean {340    val parts = host.split(".")341    if (parts.size != 4) return false342    val octets = parts.map { it.toIntOrNull() ?: return false }343    if (octets.any { it !in 0..255 }) return false344    return octets[0] == 10 ||345        (octets[0] == 172 && octets[1] in 16..31) ||346        (octets[0] == 192 && octets[1] == 168) ||347        octets[0] == 127 ||348        (octets[0] == 169 && octets[1] == 254)349}350351private fun isPrivateIpv6(host: String): Boolean {352    val normalized = host.trim('[', ']').lowercase()353    return normalized == "::1" ||354        normalized.startsWith("fc") ||355        normalized.startsWith("fd") ||356        normalized.startsWith("fe80:")357}358359private fun jsonString(value: String): String =360    "\"" + value361        .replace("\\", "\\\\")362        .replace("\"", "\\\"")363        .replace("\n", "\\n")364        .replace("\r", "\\r") + "\""