EntryDetailViewModel.kt

13.5 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        vault.entries.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), vault.getActiveEntries())6566    /** Distinct prior username/email values for the identity-suggest dropdown. */67    val identitySuggestions: StateFlow<List<String>> =68        vault.entries69            .map { vault.getDistinctFieldValues("username").take(4) }70            .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList())7172    val allTags: StateFlow<List<String>> =73        vault.entries74            .map { vault.getDistinctTags() }75            .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList())7677    private val _isPro = MutableStateFlow(license.isProActive())78    val isPro: StateFlow<Boolean> = _isPro.asStateFlow()7980    private val _revealed = MutableStateFlow(false)81    val revealed: StateFlow<Boolean> = _revealed.asStateFlow()8283    private val _copiedField = MutableStateFlow<String?>(null)84    val copiedField: StateFlow<String?> = _copiedField.asStateFlow()8586    private val _canaryMessage = MutableStateFlow<String?>(null)87    val canaryMessage: StateFlow<String?> = _canaryMessage.asStateFlow()8889    private val _canaryTesting = MutableStateFlow(false)90    val canaryTesting: StateFlow<Boolean> = _canaryTesting.asStateFlow()9192    private val canaryClient by lazy { OkHttpClient() }9394    /** Reveal the password and auto-mask after 10 seconds (matches the browser). */95    fun reveal() {96        _revealed.value = true97        viewModelScope.launch {98            kotlinx.coroutines.delay(10_000)99            _revealed.value = false100        }101    }102103    fun mask() { _revealed.value = false }104105    private val _cvcRevealed = MutableStateFlow(false)106    val cvcRevealed: StateFlow<Boolean> = _cvcRevealed.asStateFlow()107108    /** Reveal the CVC and auto-mask after 10s — same policy as the password reveal. */109    fun revealCvc() {110        _cvcRevealed.value = true111        viewModelScope.launch {112            kotlinx.coroutines.delay(10_000)113            _cvcRevealed.value = false114        }115    }116117    fun maskCvc() { _cvcRevealed.value = false }118119    fun copy(field: String, value: String?) {120        val v = value ?: return121        PeachClipboard.copy(appContext, "peach", v)122        viewModelScope.launch {123            _copiedField.value = field124            kotlinx.coroutines.delay(1200)125            if (_copiedField.value == field) _copiedField.value = null126        }127    }128129    /** Copy the live TOTP code (never the seed). */130    fun copyTotp() {131        val totp = entry.value?.totp ?: return132        copy("totp", TOTPGenerator.generateCode(totp))133    }134135    fun adjacentDestination(offset: Int): PeachNav? {136        val list = activeEntries.value137        if (list.isEmpty()) return null138        val currentIndex = list.indexOfFirst { it.id == id }139        if (currentIndex < 0) return null140        val nextIndex = (currentIndex + offset + list.size) % list.size141        return entryTapDestination(list[nextIndex])142    }143144    /**145     * Apply a generated password via updateEntry — VaultRepository captures the146     * prior password into history automatically, so we only pass the new value.147     */148    fun applyGeneratedPassword(newPassword: String, onDone: () -> Unit = {}) {149        val current = entry.value ?: return150        if (newPassword.isEmpty() || newPassword == current.password) { onDone(); return }151        viewModelScope.launch {152            runCatching { vault.updateEntry(current.copy(password = newPassword)) }153            onDone()154        }155    }156157    /**158     * Persist a single field's inline edit via a full-entry updateEntry patch159     * (which handles password-history capture + timestamps). No-op if entry gone.160     */161    fun updateField(field: String, value: String) {162        val current = entry.value ?: return163        val patch = when (field) {164            "title" -> current.copy(title = value.trim().ifBlank { current.title })165            "url" -> current.copy(url = value.ifBlank { null })166            "username" -> current.copy(username = value.ifBlank { null })167            "email" -> current.copy(email = value.ifBlank { null })168            "password" -> current.copy(password = value.ifBlank { null })169            "notes" -> current.copy(notes = value.ifBlank { null })170            "totp" -> current.copy(totp = value.ifBlank { null })171            else -> return172        }173        viewModelScope.launch {174            runCatching { vault.updateEntry(patch) }175        }176    }177178    fun updateAdditionalUrl(index: Int, value: String) {179        val current = entry.value ?: return180        val urls = current.additionalUrls.orEmpty().toMutableList()181        if (index !in urls.indices) return182        urls[index] = value183        val patch = current.copy(184            additionalUrls = urls.map { it.trim() }.filter { it.isNotBlank() }.ifEmpty { null },185        )186        viewModelScope.launch {187            runCatching { vault.updateEntry(patch) }188        }189    }190191    fun addAdditionalUrl(value: String) {192        val current = entry.value ?: return193        val next = (current.additionalUrls.orEmpty() + value)194            .map { it.trim() }195            .filter { it.isNotBlank() }196            .distinct()197            .ifEmpty { null }198        viewModelScope.launch {199            runCatching { vault.updateEntry(current.copy(additionalUrls = next)) }200        }201    }202203    fun addTag(raw: String) {204        val current = entry.value ?: return205        val tag = normalizeTag(raw)206        if (tag.isEmpty()) return207        val next = (current.tags.orEmpty() + tag).distinct().take(10)208        viewModelScope.launch {209            runCatching { vault.updateEntry(current.copy(tags = next.ifEmpty { null })) }210        }211    }212213    fun removeTag(tag: String) {214        val current = entry.value ?: return215        val next = current.tags.orEmpty().filterNot { it == tag }216        viewModelScope.launch {217            runCatching { vault.updateEntry(current.copy(tags = next.ifEmpty { null })) }218        }219    }220221    fun updateCard(cardNickname: String, cardNumber: String, cardholderName: String, expiry: String, cvc: String) {222        val current = entry.value ?: return223        val digits = cardNumber.filter { it.isDigit() }224        val expDigits = expiry.filter { it.isDigit() }225        val network = CardFieldUtils.detectNetwork(digits).takeIf { it != "unknown" }226        viewModelScope.launch {227            runCatching {228                vault.updateEntry(229                    current.copy(230                        cardNickname = cardNickname.ifBlank { null },231                        cardNumber = digits.ifBlank { null },232                        cardholderName = cardholderName.ifBlank { null },233                        expMonth = expDigits.take(2).ifBlank { null },234                        expYear = expDigits.drop(2).take(4).ifBlank { null },235                        cvc = cvc.ifBlank { null },236                        cardNetwork = network,237                    ),238                )239            }240        }241    }242243    fun saveCanary(enabled: Boolean, webhookUrl: String, label: String) {244        val current = entry.value ?: return245        val url = webhookUrl.trim()246        val nextLabel = label.trim().ifBlank { null }247        val nextCanary = if (enabled) {248            if (current.canary == null && !license.isProActive()) {249                _canaryMessage.value = "Canary entries require Peach Pro"250                return251            }252            if (!isAllowedCanaryWebhookUrl(url)) {253                _canaryMessage.value = "enter a public HTTPS webhook URL"254                return255            }256            Canary(257                webhookUrl = url,258                label = nextLabel,259                lastPinged = current.canary?.lastPinged,260                pingCount = current.canary?.pingCount,261            )262        } else {263            null264        }265266        viewModelScope.launch {267            runCatching { vault.updateEntry(current.copy(canary = nextCanary)) }268                .onSuccess { _canaryMessage.value = if (nextCanary == null) "canary disabled" else "canary saved" }269                .onFailure { _canaryMessage.value = it.message ?: "could not save canary" }270        }271    }272273    fun testCanary(webhookUrl: String) {274        val current = entry.value ?: return275        val url = webhookUrl.trim()276        if (!isAllowedCanaryWebhookUrl(url)) {277            _canaryMessage.value = "enter a public HTTPS webhook URL"278            return279        }280        viewModelScope.launch {281            _canaryTesting.value = true282            _canaryMessage.value = null283            val result = runCatching {284                withContext(Dispatchers.IO) {285                    val body = """286                        {"event":"peach_canary_test","entry":${jsonString(current.title)},"trigger":"test","timestamp":${jsonString(java.time.Instant.now().toString())},"source":"peach-android"}287                    """.trimIndent().toRequestBody("application/json".toMediaType())288                    val request = Request.Builder().url(url).post(body).build()289                    canaryClient.newCall(request).execute().use { it.isSuccessful }290                }291            }.getOrDefault(false)292            _canaryMessage.value = if (result) "sent" else "failed - check URL"293            _canaryTesting.value = false294        }295    }296297    /**298     * Restore a past password as the current one. updateEntry captures the299     * now-outgoing password into history, so this is a normal password swap.300     */301    fun restorePassword(entryId: String, oldPassword: String) {302        viewModelScope.launch {303            val current = vault.getEntry(entryId) ?: return@launch304            if (current.password == oldPassword) return@launch305            runCatching { vault.updateEntry(current.copy(password = oldPassword)) }306        }307    }308309    fun delete(onDone: () -> Unit) {310        viewModelScope.launch {311            runCatching { vault.deleteEntry(id) }312            onDone()313        }314    }315}316317internal fun isAllowedCanaryWebhookUrl(value: String): Boolean {318    return try {319        val uri = java.net.URI(value)320        val host = uri.host?.lowercase() ?: return false321        if (uri.scheme != "https") return false322        if (host == "localhost" || host.endsWith(".localhost")) return false323        if (isPrivateIpv4(host) || isPrivateIpv6(host)) return false324        true325    } catch (_: Exception) {326        false327    }328}329330private fun isPrivateIpv4(host: String): Boolean {331    val parts = host.split(".")332    if (parts.size != 4) return false333    val octets = parts.map { it.toIntOrNull() ?: return false }334    if (octets.any { it !in 0..255 }) return false335    return octets[0] == 10 ||336        (octets[0] == 172 && octets[1] in 16..31) ||337        (octets[0] == 192 && octets[1] == 168) ||338        octets[0] == 127 ||339        (octets[0] == 169 && octets[1] == 254)340}341342private fun isPrivateIpv6(host: String): Boolean {343    val normalized = host.trim('[', ']').lowercase()344    return normalized == "::1" ||345        normalized.startsWith("fc") ||346        normalized.startsWith("fd") ||347        normalized.startsWith("fe80:")348}349350private fun jsonString(value: String): String =351    "\"" + value352        .replace("\\", "\\\\")353        .replace("\"", "\\\"")354        .replace("\n", "\\n")355        .replace("\r", "\\r") + "\""