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 vault.entries.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), vault.getActiveEntries())6566 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 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 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 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 145146147148 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 158159160161 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 298299300301 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") + "\""