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 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 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 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 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 159160161162 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 172173174175 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 312313314315 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") + "\""