c592f947ec77

feat: Add pending Pro passkey notice (v2.3.50)

- Gate Credential Provider passkey begin requests behind Peach Pro - Record non-Pro background passkey attempts as pending Pro notices - Render a dismissible vault-list Pro notice with Settings routing

time
2026-06-26 14:14 UTC · 4 months ago
machine
koh2
parent
6c48f1b25b6e
6 files app/build.gradle+2 −2app/src/main/java/com/peachpasswords/license/ProNoticeStore.kt+53 −0app/src/main/java/com/peachpasswords/passkeys/PeachCredentialProviderService.kt+15 −0app/src/main/java/com/peachpasswords/ui/vault/VaultListScreen.kt+49 −0app/src/main/java/com/peachpasswords/ui/vault/VaultListViewModel.kt+8 −0app/src/test/java/com/peachpasswords/license/ProNoticeStoreTest.kt+17 −0
app/build.gradle+2 −2
@@ -16,8 +16,8 @@1616         applicationId "com.peachpasswords"1717         minSdk 261818         targetSdk 3519-        versionCode 23490020-        versionName "2.3.49"19+        versionCode 23500020+        versionName "2.3.50"2121 2222         testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"2323         vectorDrawables {

view file

app/src/main/java/com/peachpasswords/license/ProNoticeStore.kt+53 −0
@@ -0,0 +1,53 @@1+package com.peachpasswords.license2+3+import android.content.Context4+import dagger.hilt.android.qualifiers.ApplicationContext5+import kotlinx.coroutines.flow.MutableStateFlow6+import kotlinx.coroutines.flow.StateFlow7+import kotlinx.coroutines.flow.asStateFlow8+import javax.inject.Inject9+import javax.inject.Singleton10+11+data class PendingProNotice(12+    val feature: String,13+    val triggeredAt: Long,14+)15+16+@Singleton17+class ProNoticeStore @Inject constructor(18+    @ApplicationContext context: Context,19+) {20+    private val prefs = context.getSharedPreferences(PREFS_FILE, Context.MODE_PRIVATE)21+    private val _notice = MutableStateFlow(load())22+    val notice: StateFlow<PendingProNotice?> = _notice.asStateFlow()23+24+    fun record(feature: ProFeature, nowMs: Long = System.currentTimeMillis()) {25+        val next = PendingProNotice(feature.key, nowMs)26+        prefs.edit()27+            .putString(KEY_FEATURE, next.feature)28+            .putLong(KEY_TRIGGERED_AT, next.triggeredAt)29+            .apply()30+        _notice.value = next31+    }32+33+    fun dismiss() {34+        prefs.edit().remove(KEY_FEATURE).remove(KEY_TRIGGERED_AT).apply()35+        _notice.value = null36+    }37+38+    private fun load(): PendingProNotice? {39+        val feature = prefs.getString(KEY_FEATURE, null)?.takeIf { it.isNotBlank() } ?: return null40+        val triggeredAt = prefs.getLong(KEY_TRIGGERED_AT, 0L).takeIf { it > 0L } ?: return null41+        return PendingProNotice(feature, triggeredAt)42+    }43+44+    private companion object {45+        const val PREFS_FILE = "peach_pro_notice"46+        const val KEY_FEATURE = "feature"47+        const val KEY_TRIGGERED_AT = "triggered_at"48+    }49+}50+51+fun proNoticeText(notice: PendingProNotice): String =52+    if (notice.feature == ProFeature.Sync.key) "Sync requires Peach Pro"53+    else "A Pro feature requires Peach Pro"

view file

app/src/main/java/com/peachpasswords/passkeys/PeachCredentialProviderService.kt+15 −0
@@ -20,6 +20,9 @@2020 import androidx.credentials.exceptions.GetCredentialException2121 import androidx.annotation.RequiresApi2222 import com.peachpasswords.R23+import com.peachpasswords.license.LicenseRepository24+import com.peachpasswords.license.ProFeature25+import com.peachpasswords.license.ProNoticeStore2326 import com.peachpasswords.vault.VaultRepository2427 import dagger.hilt.android.AndroidEntryPoint2528 import java.time.Instant@@ -42,6 +45,8 @@4245 class PeachCredentialProviderService : CredentialProviderService() {4346 4447     @Inject lateinit var vault: VaultRepository48+    @Inject lateinit var license: LicenseRepository49+    @Inject lateinit var proNoticeStore: ProNoticeStore4550 4651     override fun onBeginGetCredentialRequest(4752         request: BeginGetCredentialRequest,@@ -52,6 +57,11 @@5257             callback.onResult(BeginGetCredentialResponse())5358             return5459         }60+        if (!license.hasFeature(ProFeature.Passkeys)) {61+            proNoticeStore.record(ProFeature.Passkeys)62+            callback.onResult(BeginGetCredentialResponse())63+            return64+        }5565         if (!vault.isUnlocked.value) {5666             callback.onResult(5767                 BeginGetCredentialResponse(@@ -101,6 +111,11 @@101111     ) {102112         val publicKeyRequest = request as? BeginCreatePublicKeyCredentialRequest103113         if (publicKeyRequest == null || !vault.settings.value.passkeyProviderEnabled || !vault.isUnlocked.value) {114+            callback.onResult(BeginCreateCredentialResponse())115+            return116+        }117+        if (!license.hasFeature(ProFeature.Passkeys)) {118+            proNoticeStore.record(ProFeature.Passkeys)104119             callback.onResult(BeginCreateCredentialResponse())105120             return106121         }

view file

app/src/main/java/com/peachpasswords/ui/vault/VaultListScreen.kt+49 −0
@@ -43,6 +43,7 @@4343 import androidx.compose.material.icons.rounded.Search4444 import androidx.compose.material.icons.rounded.Settings4545 import androidx.compose.material3.Icon46+import androidx.compose.material3.TextButton4647 import androidx.compose.material3.Snackbar4748 import androidx.compose.material3.SnackbarDuration4849 import androidx.compose.material3.SnackbarHost@@ -82,6 +83,8 @@8283 import androidx.compose.ui.unit.sp8384 import androidx.hilt.navigation.compose.hiltViewModel8485 import androidx.compose.runtime.collectAsState86+import com.peachpasswords.license.PendingProNotice87+import com.peachpasswords.license.proNoticeText8588 import com.peachpasswords.sync.SyncAlert8689 import com.peachpasswords.haptics.PeachHaptics8790 import com.peachpasswords.sync.syncAlertMessage@@ -117,6 +120,7 @@117120     val selectedIds by vm.selectedIds.collectAsState()118121     val undoEvent by vm.undoEvent.collectAsState()119122     val syncAlerts by vm.syncAlerts.collectAsState()123+    val proNotice by vm.proNotice.collectAsState()120124     val snackbarHostState = remember { SnackbarHostState() }121125     var menuEntry by remember { mutableStateOf<VaultEntry?>(null) }122126     val view = LocalView.current@@ -156,6 +160,14 @@156160                 alerts = syncAlerts,157161                 onDismiss = vm::dismissSyncAlert,158162             )163+            ProNoticeBanner(164+                notice = proNotice,165+                onLearnMore = {166+                    vm.dismissProNotice()167+                    onNavigate(PeachNav.Settings)168+                },169+                onDismiss = vm::dismissProNotice,170+            )159171             SearchBar(160172                 value = search,161173                 onChange = vm::setSearch,@@ -234,6 +246,43 @@234246             onEdit = { onNavigate(PeachNav.EntryDetail(e.id)) },235247             onMoveToTrash = { vm.softDelete(e) },236248             onDismiss = { menuEntry = null },249+        )250+    }251+}252+253+@Composable254+private fun ProNoticeBanner(255+    notice: PendingProNotice?,256+    onLearnMore: () -> Unit,257+    onDismiss: () -> Unit,258+) {259+    if (notice == null) return260+    Row(261+        modifier = Modifier262+            .fillMaxWidth()263+            .background(LocalPeachAccent.current.copy(alpha = 0.10f))264+            .border(0.5.dp, LocalPeachAccent.current.copy(alpha = 0.24f))265+            .padding(start = 16.dp, top = 4.dp, end = 8.dp, bottom = 4.dp),266+        verticalAlignment = Alignment.CenterVertically,267+    ) {268+        Text(269+            text = proNoticeText(notice),270+            color = peachColors.T1,271+            style = PeachType.labelMono.copy(fontSize = 10.sp),272+            modifier = Modifier.weight(1f),273+        )274+        TextButton(onClick = onLearnMore) {275+            Text("Learn more", color = LocalPeachAccent.current, style = PeachType.labelMono.copy(fontSize = 10.sp))276+        }277+        Icon(278+            Icons.Rounded.Close,279+            contentDescription = "Dismiss Pro notice",280+            tint = peachColors.T2,281+            modifier = Modifier282+                .size(28.dp)283+                .clip(CircleShape)284+                .clickable { onDismiss() }285+                .padding(7.dp),237286         )238287     }239288 }

view file

app/src/main/java/com/peachpasswords/ui/vault/VaultListViewModel.kt+8 −0
@@ -4,6 +4,8 @@44 import com.peachpasswords.shared.PeachClipboard55 import androidx.lifecycle.ViewModel66 import androidx.lifecycle.viewModelScope7+import com.peachpasswords.license.PendingProNotice8+import com.peachpasswords.license.ProNoticeStore79 import com.peachpasswords.sync.SyncAlert810 import com.peachpasswords.sync.SyncAlertStore911 import com.peachpasswords.vault.TOTPGenerator@@ -35,6 +37,7 @@3537     private val vault: VaultRepository,3638     private val lockTimer: VaultLockTimer,3739     private val syncAlertStore: SyncAlertStore,40+    private val proNoticeStore: ProNoticeStore,3841     @ApplicationContext private val appContext: Context,3942 ) : ViewModel() {4043 @@ -55,6 +58,7 @@5558 5659     val isUnlocked: StateFlow<Boolean> = vault.isUnlocked5760     val syncAlerts: StateFlow<List<SyncAlert>> = syncAlertStore.alerts61+    val proNotice: StateFlow<PendingProNotice?> = proNoticeStore.notice5862 5963     /** Live, filtered + sorted active entries. */6064     val entries: StateFlow<List<VaultEntry>> =@@ -84,6 +88,10 @@8488 8589     fun dismissSyncAlert(alert: SyncAlert) {8690         syncAlertStore.dismiss(alert)91+    }92+93+    fun dismissProNotice() {94+        proNoticeStore.dismiss()8795     }8896 8997     // ── Multi-select ────────────────────────────────────────────────────────

view file

app/src/test/java/com/peachpasswords/license/ProNoticeStoreTest.kt+17 −0
@@ -0,0 +1,17 @@1+package com.peachpasswords.license2+3+import org.junit.Assert.assertEquals4+import org.junit.Test5+6+class ProNoticeStoreTest {7+    @Test fun pro_notice_copy_matches_browser_feature_labels() {8+        assertEquals(9+            "Sync requires Peach Pro",10+            proNoticeText(PendingProNotice(ProFeature.Sync.key, triggeredAt = 1L)),11+        )12+        assertEquals(13+            "A Pro feature requires Peach Pro",14+            proNoticeText(PendingProNotice(ProFeature.Passkeys.key, triggeredAt = 1L)),15+        )16+    }17+}

view file