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 45 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 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 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 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 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 135136137138 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 148149150151 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 288289290291 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") + "\""