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 464748 fun lock() {49 lockTimer.cancel()50 vault.lock()51 }5253 init {54 55 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 65 66 67 68 69 vault.entries70 .map { it.filter(::isNavigable) }71 .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), vault.getActiveEntries().filter(::isNavigable))7273 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 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 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 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 154155156157 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 167168169170 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 307308309310 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") + "\""