df73b22b6f1b

fix: Add browser-style canary controls (v2.3.17)

time
2026-06-26 01:32 UTC · 4 months ago
machine
koh2
parent
f9ec7f8479b4
4 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 −0
app/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 {

view file

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),

view file

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

view file

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 }

view file