df73b22b6f1b
fix: Add browser-style canary controls (v2.3.17)
- time
- 2026-06-26 01:32 UTC · 4 months ago
- machine
- koh2
- parent
- f9ec7f8479b4
↓ peach-android-df73b22b.tar.gzatlas at this save
files changed4 files
app/build.gradle+2 −2app/src/main/java/com/peachpasswords/ui/vault/EntryDetailScreen.kt+95 −16app/src/main/java/com/peachpasswords/ui/vault/EntryDetailViewModel.kt+114 −0app/src/test/java/com/peachpasswords/ui/vault/EntryDetailParityTest.kt+9 −0app/build.gradle+2 −2
@@ -16,8 +16,8 @@1616 applicationId "com.peachpasswords"1717 minSdk 261818 targetSdk 3519- versionCode 23160020- versionName "2.3.16"19+ versionCode 23170020+ versionName "2.3.17"2121 2222 testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"2323 vectorDrawables {app/src/main/java/com/peachpasswords/ui/vault/EntryDetailScreen.kt+95 −16
@@ -102,9 +102,12 @@102102 val activeEntries by vm.activeEntries.collectAsState()103103 val identitySuggestions by vm.identitySuggestions.collectAsState()104104 val allTags by vm.allTags.collectAsState()105+ val isPro by vm.isPro.collectAsState()105106 val cvcRevealed by vm.cvcRevealed.collectAsState()106107 var cardRevealed by remember { mutableStateOf(false) }107108 val copiedField by vm.copiedField.collectAsState()109+ val canaryMessage by vm.canaryMessage.collectAsState()110+ val canaryTesting by vm.canaryTesting.collectAsState()108111 val context = LocalContext.current109112 val accent = LocalPeachAccent.current110113 var confirmingDelete by remember { mutableStateOf(false) }@@ -332,12 +335,16 @@332335 onAddTag = vm::addTag,333336 onRemoveTag = vm::removeTag,334337 )335- e.canary?.let { canary ->338+ if (!e.isNote() && !e.isCard()) {336339 CanaryDetailSection(337- label = canary.label,338- webhookUrl = canary.webhookUrl,339- lastPinged = canary.lastPinged,340- pingCount = canary.pingCount,340+ entryId = e.id,341+ modifiedAt = e.modifiedAt,342+ canary = e.canary,343+ isPro = isPro,344+ message = canaryMessage,345+ testing = canaryTesting,346+ onSave = vm::saveCanary,347+ onTest = vm::testCanary,341348 )342349 }343350 @@ -468,24 +475,96 @@468475 }469476 470477 @Composable471-private fun CanaryDetailSection(label: String?, webhookUrl: String, lastPinged: Long?, pingCount: Int?) {478+private fun CanaryDetailSection(479+ entryId: String,480+ modifiedAt: Long,481+ canary: com.peachpasswords.vault.Canary?,482+ isPro: Boolean,483+ message: String?,484+ testing: Boolean,485+ onSave: (enabled: Boolean, webhookUrl: String, label: String) -> Unit,486+ onTest: (webhookUrl: String) -> Unit,487+) {488+ var enabled by remember(entryId, modifiedAt, canary?.webhookUrl) { mutableStateOf(canary != null) }489+ var webhookUrl by remember(entryId, modifiedAt, canary?.webhookUrl) { mutableStateOf(canary?.webhookUrl.orEmpty()) }490+ var label by remember(entryId, modifiedAt, canary?.label) { mutableStateOf(canary?.label.orEmpty()) }491+ val gated = !isPro && canary == null472492 FieldShell("canary alert") {473493 Column(Modifier.weight(1f)) {494+ Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp)) {495+ Text(496+ label.takeIf { it.isNotBlank() } ?: if (enabled) "Enabled" else "Off",497+ color = peachColors.T0,498+ style = PeachType.body,499+ modifier = Modifier.weight(1f),500+ )501+ if (gated) {502+ Text("PRO", color = LocalPeachAccent.current, style = PeachType.labelMono)503+ }504+ Text(505+ if (enabled) "on" else "off",506+ color = if (enabled) LocalPeachAccent.current else peachColors.T2,507+ style = PeachType.labelMono,508+ modifier = Modifier.clip(RoundedCornerShape(6.dp)).clickable {509+ if (gated) {510+ onSave(true, webhookUrl, label)511+ } else {512+ val next = !enabled513+ enabled = next514+ if (!next) onSave(false, webhookUrl, label)515+ }516+ }.padding(horizontal = 6.dp, vertical = 4.dp),517+ )518+ }474519 Text(475- label?.takeIf { it.isNotBlank() } ?: "Enabled",476- color = peachColors.T0,477- style = PeachType.body,478- )479- Text(480- webhookUrl,520+ "Fires a silent webhook when this entry's password or username is copied.",481521 color = peachColors.T2,482522 style = PeachType.labelMono,483- maxLines = 1,484- overflow = TextOverflow.Ellipsis,523+ modifier = Modifier.padding(top = 3.dp),485524 )486- if (lastPinged != null || pingCount != null) {525+ if (enabled) {526+ Spacer(Modifier.height(8.dp))527+ PeachField(528+ label = "webhook url",529+ value = webhookUrl,530+ onChange = { webhookUrl = it },531+ )487532 Text(488- canaryStatusLabel(lastPinged, pingCount),533+ "When triggered, the entry title is sent to this URL. Use an endpoint you control.",534+ color = peachColors.T2,535+ style = PeachType.labelMono,536+ )537+ PeachField(538+ label = "label",539+ value = label,540+ onChange = { label = it },541+ mono = false,542+ )543+ Row(horizontalArrangement = Arrangement.spacedBy(12.dp), verticalAlignment = Alignment.CenterVertically) {544+ Text(545+ "save",546+ color = LocalPeachAccent.current,547+ style = PeachType.labelMono,548+ modifier = Modifier.clip(RoundedCornerShape(6.dp))549+ .clickable { onSave(true, webhookUrl, label) }550+ .padding(vertical = 4.dp),551+ )552+ Text(553+ if (testing) "sending..." else "test ping",554+ color = if (webhookUrl.isBlank() || testing) peachColors.T2 else LocalPeachAccent.current,555+ style = PeachType.labelMono,556+ modifier = Modifier.clip(RoundedCornerShape(6.dp))557+ .clickable(enabled = webhookUrl.isNotBlank() && !testing) { onTest(webhookUrl) }558+ .padding(vertical = 4.dp),559+ )560+ }561+ }562+ message?.let {563+ Text(it, color = peachColors.T2, style = PeachType.labelMono, modifier = Modifier.padding(top = 4.dp))564+ }565+ if (canary?.lastPinged != null || canary?.pingCount != null) {566+ Text(567+ canaryStatusLabel(canary?.lastPinged, canary?.pingCount),489568 color = peachColors.T2,490569 style = PeachType.labelMono,491570 modifier = Modifier.padding(top = 3.dp),app/src/main/java/com/peachpasswords/ui/vault/EntryDetailViewModel.kt+114 −0
@@ -5,8 +5,10 @@55 import androidx.lifecycle.SavedStateHandle66 import androidx.lifecycle.ViewModel77 import androidx.lifecycle.viewModelScope8+import com.peachpasswords.license.LicenseRepository89 import com.peachpasswords.ui.PeachNav910 import com.peachpasswords.ui.vault.entryTapDestination11+import com.peachpasswords.vault.Canary1012 import com.peachpasswords.vault.LastViewedEntryStore1113 import com.peachpasswords.vault.TOTPGenerator1214 import com.peachpasswords.vault.VaultEntry@@ -19,7 +21,13 @@1921 import kotlinx.coroutines.flow.asStateFlow2022 import kotlinx.coroutines.flow.map2123 import kotlinx.coroutines.flow.stateIn24+import kotlinx.coroutines.Dispatchers2225 import kotlinx.coroutines.launch26+import kotlinx.coroutines.withContext27+import okhttp3.MediaType.Companion.toMediaType28+import okhttp3.OkHttpClient29+import okhttp3.Request30+import okhttp3.RequestBody.Companion.toRequestBody2331 import javax.inject.Inject2432 2533 @HiltViewModel@@ -27,6 +35,7 @@2735 private val vault: VaultRepository,2836 @ApplicationContext private val appContext: Context,2937 private val lastViewed: LastViewedEntryStore,38+ private val license: LicenseRepository,3039 savedStateHandle: SavedStateHandle,3140 ) : ViewModel() {3241 private val id: String = savedStateHandle.get<String>("id").orEmpty()@@ -55,12 +64,23 @@5564 .map { vault.getDistinctTags() }5665 .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList())5766 67+ private val _isPro = MutableStateFlow(license.isProActive())68+ val isPro: StateFlow<Boolean> = _isPro.asStateFlow()69+5870 private val _revealed = MutableStateFlow(false)5971 val revealed: StateFlow<Boolean> = _revealed.asStateFlow()6072 6173 private val _copiedField = MutableStateFlow<String?>(null)6274 val copiedField: StateFlow<String?> = _copiedField.asStateFlow()6375 76+ private val _canaryMessage = MutableStateFlow<String?>(null)77+ val canaryMessage: StateFlow<String?> = _canaryMessage.asStateFlow()78+79+ private val _canaryTesting = MutableStateFlow(false)80+ val canaryTesting: StateFlow<Boolean> = _canaryTesting.asStateFlow()81+82+ private val canaryClient by lazy { OkHttpClient() }83+6484 /** Reveal the password and auto-mask after 10 seconds (matches the browser). */6585 fun reveal() {6686 _revealed.value = true@@ -210,6 +230,60 @@210230 }211231 }212232 233+ 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+ }255+256+ 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+ }262+263+ 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+ }286+213287 /**214288 * Restore a past password as the current one. updateEntry captures the215289 * now-outgoing password into history, so this is a normal password swap.@@ -229,3 +303,43 @@229303 }230304 }231305 }306+307+internal 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+}319+320+private 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+}331+332+private 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+}339+340+private fun jsonString(value: String): String =341+ "\"" + value342+ .replace("\\", "\\\\")343+ .replace("\"", "\\\"")344+ .replace("\n", "\\n")345+ .replace("\r", "\\r") + "\""app/src/test/java/com/peachpasswords/ui/vault/EntryDetailParityTest.kt+9 −0
@@ -31,4 +31,13 @@3131 assertEquals("0 total alerts", canaryStatusLabel(null, null))3232 assertEquals("1 total alert", canaryStatusLabel(null, 1))3333 }34+35+ @Test fun canary_webhook_validation_matches_browser_public_https_rule() {36+ assertTrue(isAllowedCanaryWebhookUrl("https://hooks.example.com/peach"))37+ assertFalse(isAllowedCanaryWebhookUrl("http://hooks.example.com/peach"))38+ assertFalse(isAllowedCanaryWebhookUrl("https://localhost/peach"))39+ assertFalse(isAllowedCanaryWebhookUrl("https://127.0.0.1/peach"))40+ assertFalse(isAllowedCanaryWebhookUrl("https://192.168.1.50/peach"))41+ assertFalse(isAllowedCanaryWebhookUrl("not a url"))42+ }3443 }