EntryDetailViewModel.kt

13.2 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.VaultRepository16import dagger.hilt.android.lifecycle.HiltViewModel17import dagger.hilt.android.qualifiers.ApplicationContext18import kotlinx.coroutines.flow.MutableStateFlow19import kotlinx.coroutines.flow.SharingStarted20import kotlinx.coroutines.flow.StateFlow21import kotlinx.coroutines.flow.asStateFlow22import kotlinx.coroutines.flow.map23import kotlinx.coroutines.flow.stateIn24import kotlinx.coroutines.Dispatchers25import kotlinx.coroutines.launch26import kotlinx.coroutines.withContext27import okhttp3.MediaType.Companion.toMediaType28import okhttp3.OkHttpClient29import okhttp3.Request30import okhttp3.RequestBody.Companion.toRequestBody31import javax.inject.Inject3233@HiltViewModel34class EntryDetailViewModel @Inject constructor(35    private val vault: VaultRepository,36    @ApplicationContext private val appContext: Context,37    private val lastViewed: LastViewedEntryStore,38    private val license: LicenseRepository,39    savedStateHandle: SavedStateHandle,40) : ViewModel() {41    private val id: String = savedStateHandle.get<String>("id").orEmpty()4243    init {44        // Remember this as the entry to reopen on the next unlock (brief Fix 3).45        // Only record a real, live entry so a bad deep-link id never becomes the target.46        if (id.isNotBlank() && vault.getEntry(id) != null) lastViewed.set(id)47    }4849    val entry: StateFlow<VaultEntry?> =50        vault.entries.map { list -> list.firstOrNull { it.id == id } }51            .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), vault.getEntry(id))5253    val activeEntries: StateFlow<List<VaultEntry>> =54        vault.entries.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), vault.getActiveEntries())5556    /** Distinct prior username/email values for the identity-suggest dropdown. */57    val identitySuggestions: StateFlow<List<String>> =58        vault.entries59            .map { vault.getDistinctFieldValues("username").take(4) }60            .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList())6162    val allTags: StateFlow<List<String>> =63        vault.entries64            .map { vault.getDistinctTags() }65            .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList())6667    private val _isPro = MutableStateFlow(license.isProActive())68    val isPro: StateFlow<Boolean> = _isPro.asStateFlow()6970    private val _revealed = MutableStateFlow(false)71    val revealed: StateFlow<Boolean> = _revealed.asStateFlow()7273    private val _copiedField = MutableStateFlow<String?>(null)74    val copiedField: StateFlow<String?> = _copiedField.asStateFlow()7576    private val _canaryMessage = MutableStateFlow<String?>(null)77    val canaryMessage: StateFlow<String?> = _canaryMessage.asStateFlow()7879    private val _canaryTesting = MutableStateFlow(false)80    val canaryTesting: StateFlow<Boolean> = _canaryTesting.asStateFlow()8182    private val canaryClient by lazy { OkHttpClient() }8384    /** Reveal the password and auto-mask after 10 seconds (matches the browser). */85    fun reveal() {86        _revealed.value = true87        viewModelScope.launch {88            kotlinx.coroutines.delay(10_000)89            _revealed.value = false90        }91    }9293    fun mask() { _revealed.value = false }9495    private val _cvcRevealed = MutableStateFlow(false)96    val cvcRevealed: StateFlow<Boolean> = _cvcRevealed.asStateFlow()9798    /** Reveal the CVC and auto-mask after 10s — same policy as the password reveal. */99    fun revealCvc() {100        _cvcRevealed.value = true101        viewModelScope.launch {102            kotlinx.coroutines.delay(10_000)103            _cvcRevealed.value = false104        }105    }106107    fun maskCvc() { _cvcRevealed.value = false }108109    fun copy(field: String, value: String?) {110        val v = value ?: return111        PeachClipboard.copy(appContext, "peach", v)112        viewModelScope.launch {113            _copiedField.value = field114            kotlinx.coroutines.delay(1200)115            if (_copiedField.value == field) _copiedField.value = null116        }117    }118119    /** Copy the live TOTP code (never the seed). */120    fun copyTotp() {121        val totp = entry.value?.totp ?: return122        copy("totp", TOTPGenerator.generateCode(totp))123    }124125    fun adjacentDestination(offset: Int): PeachNav? {126        val list = activeEntries.value127        if (list.isEmpty()) return null128        val currentIndex = list.indexOfFirst { it.id == id }129        if (currentIndex < 0) return null130        val nextIndex = (currentIndex + offset + list.size) % list.size131        return entryTapDestination(list[nextIndex])132    }133134    /**135     * Apply a generated password via updateEntry — VaultRepository captures the136     * prior password into history automatically, so we only pass the new value.137     */138    fun applyGeneratedPassword(newPassword: String, onDone: () -> Unit = {}) {139        val current = entry.value ?: return140        if (newPassword.isEmpty() || newPassword == current.password) { onDone(); return }141        viewModelScope.launch {142            runCatching { vault.updateEntry(current.copy(password = newPassword)) }143            onDone()144        }145    }146147    /**148     * Persist a single field's inline edit via a full-entry updateEntry patch149     * (which handles password-history capture + timestamps). No-op if entry gone.150     */151    fun updateField(field: String, value: String) {152        val current = entry.value ?: return153        val patch = when (field) {154            "title" -> current.copy(title = value.trim().ifBlank { current.title })155            "url" -> current.copy(url = value.ifBlank { null })156            "username" -> current.copy(username = value.ifBlank { null })157            "email" -> current.copy(email = value.ifBlank { null })158            "password" -> current.copy(password = value.ifBlank { null })159            "notes" -> current.copy(notes = value.ifBlank { null })160            "totp" -> current.copy(totp = value.ifBlank { null })161            else -> return162        }163        viewModelScope.launch {164            runCatching { vault.updateEntry(patch) }165        }166    }167168    fun updateAdditionalUrl(index: Int, value: String) {169        val current = entry.value ?: return170        val urls = current.additionalUrls.orEmpty().toMutableList()171        if (index !in urls.indices) return172        urls[index] = value173        val patch = current.copy(174            additionalUrls = urls.map { it.trim() }.filter { it.isNotBlank() }.ifEmpty { null },175        )176        viewModelScope.launch {177            runCatching { vault.updateEntry(patch) }178        }179    }180181    fun addAdditionalUrl(value: String) {182        val current = entry.value ?: return183        val next = (current.additionalUrls.orEmpty() + value)184            .map { it.trim() }185            .filter { it.isNotBlank() }186            .distinct()187            .ifEmpty { null }188        viewModelScope.launch {189            runCatching { vault.updateEntry(current.copy(additionalUrls = next)) }190        }191    }192193    fun addTag(raw: String) {194        val current = entry.value ?: return195        val tag = normalizeTag(raw)196        if (tag.isEmpty()) return197        val next = (current.tags.orEmpty() + tag).distinct().take(10)198        viewModelScope.launch {199            runCatching { vault.updateEntry(current.copy(tags = next.ifEmpty { null })) }200        }201    }202203    fun removeTag(tag: String) {204        val current = entry.value ?: return205        val next = current.tags.orEmpty().filterNot { it == tag }206        viewModelScope.launch {207            runCatching { vault.updateEntry(current.copy(tags = next.ifEmpty { null })) }208        }209    }210211    fun updateCard(cardNickname: String, cardNumber: String, cardholderName: String, expiry: String, cvc: String) {212        val current = entry.value ?: return213        val digits = cardNumber.filter { it.isDigit() }214        val expDigits = expiry.filter { it.isDigit() }215        val network = CardFieldUtils.detectNetwork(digits).takeIf { it != "unknown" }216        viewModelScope.launch {217            runCatching {218                vault.updateEntry(219                    current.copy(220                        cardNickname = cardNickname.ifBlank { null },221                        cardNumber = digits.ifBlank { null },222                        cardholderName = cardholderName.ifBlank { null },223                        expMonth = expDigits.take(2).ifBlank { null },224                        expYear = expDigits.drop(2).take(4).ifBlank { null },225                        cvc = cvc.ifBlank { null },226                        cardNetwork = network,227                    ),228                )229            }230        }231    }232233    fun saveCanary(enabled: Boolean, webhookUrl: String, label: String) {234        val current = entry.value ?: return235        val url = webhookUrl.trim()236        val nextLabel = label.trim().ifBlank { null }237        val nextCanary = if (enabled) {238            if (current.canary == null && !license.isProActive()) {239                _canaryMessage.value = "Canary entries require Peach Pro"240                return241            }242            if (!isAllowedCanaryWebhookUrl(url)) {243                _canaryMessage.value = "enter a public HTTPS webhook URL"244                return245            }246            Canary(247                webhookUrl = url,248                label = nextLabel,249                lastPinged = current.canary?.lastPinged,250                pingCount = current.canary?.pingCount,251            )252        } else {253            null254        }255256        viewModelScope.launch {257            runCatching { vault.updateEntry(current.copy(canary = nextCanary)) }258                .onSuccess { _canaryMessage.value = if (nextCanary == null) "canary disabled" else "canary saved" }259                .onFailure { _canaryMessage.value = it.message ?: "could not save canary" }260        }261    }262263    fun testCanary(webhookUrl: String) {264        val current = entry.value ?: return265        val url = webhookUrl.trim()266        if (!isAllowedCanaryWebhookUrl(url)) {267            _canaryMessage.value = "enter a public HTTPS webhook URL"268            return269        }270        viewModelScope.launch {271            _canaryTesting.value = true272            _canaryMessage.value = null273            val result = runCatching {274                withContext(Dispatchers.IO) {275                    val body = """276                        {"event":"peach_canary_test","entry":${jsonString(current.title)},"trigger":"test","timestamp":${jsonString(java.time.Instant.now().toString())},"source":"peach-android"}277                    """.trimIndent().toRequestBody("application/json".toMediaType())278                    val request = Request.Builder().url(url).post(body).build()279                    canaryClient.newCall(request).execute().use { it.isSuccessful }280                }281            }.getOrDefault(false)282            _canaryMessage.value = if (result) "sent" else "failed - check URL"283            _canaryTesting.value = false284        }285    }286287    /**288     * Restore a past password as the current one. updateEntry captures the289     * now-outgoing password into history, so this is a normal password swap.290     */291    fun restorePassword(entryId: String, oldPassword: String) {292        viewModelScope.launch {293            val current = vault.getEntry(entryId) ?: return@launch294            if (current.password == oldPassword) return@launch295            runCatching { vault.updateEntry(current.copy(password = oldPassword)) }296        }297    }298299    fun delete(onDone: () -> Unit) {300        viewModelScope.launch {301            runCatching { vault.deleteEntry(id) }302            onDone()303        }304    }305}306307internal fun isAllowedCanaryWebhookUrl(value: String): Boolean {308    return try {309        val uri = java.net.URI(value)310        val host = uri.host?.lowercase() ?: return false311        if (uri.scheme != "https") return false312        if (host == "localhost" || host.endsWith(".localhost")) return false313        if (isPrivateIpv4(host) || isPrivateIpv6(host)) return false314        true315    } catch (_: Exception) {316        false317    }318}319320private fun isPrivateIpv4(host: String): Boolean {321    val parts = host.split(".")322    if (parts.size != 4) return false323    val octets = parts.map { it.toIntOrNull() ?: return false }324    if (octets.any { it !in 0..255 }) return false325    return octets[0] == 10 ||326        (octets[0] == 172 && octets[1] in 16..31) ||327        (octets[0] == 192 && octets[1] == 168) ||328        octets[0] == 127 ||329        (octets[0] == 169 && octets[1] == 254)330}331332private fun isPrivateIpv6(host: String): Boolean {333    val normalized = host.trim('[', ']').lowercase()334    return normalized == "::1" ||335        normalized.startsWith("fc") ||336        normalized.startsWith("fd") ||337        normalized.startsWith("fe80:")338}339340private fun jsonString(value: String): String =341    "\"" + value342        .replace("\\", "\\\\")343        .replace("\"", "\\\"")344        .replace("\n", "\\n")345        .replace("\r", "\\r") + "\""