dfa6c3b5a925

feat: save passwords to Peach from the Credential Manager save sheet (v2.12.0)

onBeginCreateCredentialRequest now handles BeginCreatePasswordCredentialRequest alongside passkey registrations: Peach appears in the system 'save password' flow on Android 14+ (and backported devices via Play services). - passwordCreateEntry: reads the id/password from the request's candidateQueryData (androidx's stable wire keys; the Kotlin constants are @RestrictTo-internal) and offers a 'Save password to Peach' CreateEntry; not Pro-gated — saving is core, like the autofill save flow - PasswordCredentialSelectionActivity gains MODE_CREATE: builds an AutofillSaveDraft from the framework-verified caller (web origin, or app label for native callers) and stores via the existing planAutofillSave add/update planner, then answers CreatePasswordResponse; fails closed with no caller info or on store failure - create path split out of the passkey branch; passkey create behavior unchanged - autofill/passkeys/credentials unit suites green

time
2026-08-04 21:57 UTC · 2 months ago
machine
koh2
parent
960b4a5c107a
3 files VERSION+1 −1app/src/main/java/com/peachpasswords/credentials/PasswordCredentialSelectionActivity.kt+87 −5app/src/main/java/com/peachpasswords/passkeys/PeachCredentialProviderService.kt+51 −2
VERSION+1 −1
@@ -1 +1 @@1-2.11.01+2.12.0

view file

app/src/main/java/com/peachpasswords/credentials/PasswordCredentialSelectionActivity.kt+87 −5
@@ -5,15 +5,21 @@55 import android.content.Intent66 import android.os.Build77 import android.os.Bundle8+import androidx.credentials.CreatePasswordResponse89 import androidx.credentials.GetCredentialResponse910 import androidx.credentials.PasswordCredential11+import androidx.credentials.exceptions.CreateCredentialUnsupportedException1012 import androidx.credentials.exceptions.GetCredentialUnsupportedException13+import androidx.credentials.provider.CallingAppInfo1114 import androidx.credentials.provider.PendingIntentHandler1215 import androidx.fragment.app.FragmentActivity1316 import androidx.lifecycle.lifecycleScope1417 import com.peachpasswords.BuildConfig18+import com.peachpasswords.autofill.AutofillSaveDraft19+import com.peachpasswords.autofill.AutofillSavePlan1520 import com.peachpasswords.autofill.appLabel1621 import com.peachpasswords.autofill.appLinkDomains22+import com.peachpasswords.autofill.planAutofillSave1723 import com.peachpasswords.passkeys.PasskeyCallerOrigin1824 import com.peachpasswords.shared.extractDomain1925 import com.peachpasswords.ui.components.QaFlags@@ -24,14 +30,16 @@2430 import kotlinx.coroutines.launch2531 2632 /**27- * Completes a Credential Manager password selection. The vault was already28- * unlocked when the row was offered (the provider never emits rows while33+ * Completes Credential Manager password selections. The vault was already34+ * unlocked when rows were offered (the provider never emits rows while2935  * locked), and the tap is the release boundary — the same posture as autofill3036  * (PEACH-AND-01-002 risk acceptance).3137  *32- * Defense in depth: the framework-verified caller is re-checked against the33- * entry before release, so a stale or replayed pending intent cannot pull a34- * credential for a different caller. Fails closed on any doubt.38+ * Get mode releases the credential; defense in depth re-checks the39+ * framework-verified caller against the entry first, so a stale or replayed40+ * pending intent cannot pull a credential for a different caller. Create mode41+ * stores the new credential via the same add/update planner as the autofill42+ * save flow (planAutofillSave). Both fail closed on any doubt.3543  */3644 @AndroidEntryPoint3745 class PasswordCredentialSelectionActivity : FragmentActivity() {@@ -66,6 +74,10 @@6674             )6775 6876     private suspend fun completeSelection() {77+        if (intent.getStringExtra(EXTRA_MODE) == MODE_CREATE) {78+            completeCreate()79+            return80+        }6981         val result = Intent()7082         val entryId = intent.getStringExtra(EXTRA_ENTRY_ID)7183         val entry = entryId?.let(vault::getEntry)@@ -112,8 +124,64 @@112124         finish()113125     }114126 127+    private suspend fun completeCreate() {128+        val result = Intent()129+        val id = intent.getStringExtra(EXTRA_CREATE_ID)130+        val password = intent.getStringExtra(EXTRA_CREATE_PASSWORD)131+        val providerRequest = runCatching {132+            PendingIntentHandler.retrieveProviderCreateCredentialRequest(intent)133+        }.getOrNull()134+        val draft = buildSaveDraft(providerRequest?.callingAppInfo, id, password)135+        if (draft == null) {136+            PendingIntentHandler.setCreateCredentialException(137+                result,138+                CreateCredentialUnsupportedException("Peach cannot save this password."),139+            )140+        } else {141+            val saved = runCatching {142+                when (val plan = planAutofillSave(vault.getEntries(), draft)) {143+                    is AutofillSavePlan.Add -> vault.addEntry(plan.entry)144+                    is AutofillSavePlan.Update -> vault.updateEntry(plan.entry)145+                }146+            }.isSuccess147+            if (saved) {148+                PendingIntentHandler.setCreateCredentialResponse(result, CreatePasswordResponse())149+            } else {150+                PendingIntentHandler.setCreateCredentialException(151+                    result,152+                    CreateCredentialUnsupportedException("Peach could not save this password."),153+                )154+            }155+        }156+        setResult(RESULT_OK, result)157+        finish()158+    }159+160+    /**161+     * The save draft for a CM create request. Web callers save against their162+     * verified origin; native callers save against their display label — the163+     * same origin model the autofill save flow uses. No caller info -> null:164+     * a credential with no attributable origin would be unmatchable filler.165+     */166+    private fun buildSaveDraft(info: CallingAppInfo?, id: String?, password: String?): AutofillSaveDraft? {167+        if (info == null || id.isNullOrBlank() || password.isNullOrEmpty()) return null168+        val identity = id.trim()169+        val identityIsEmail = identity.contains("@")170+        val allowlist = PasskeyCallerOrigin.privilegedAllowlist(this)171+        val webOrigin = runCatching { info.getOrigin(allowlist) }.getOrNull()172+        if (!webOrigin.isNullOrBlank()) {173+            return AutofillSaveDraft(webOrigin, true, identity, identityIsEmail, password, null)174+        }175+        val title = appLabel(this, info.packageName) ?: return null176+        return AutofillSaveDraft(title, false, identity, identityIsEmail, password, null)177+    }178+115179     companion object {116180         private const val EXTRA_ENTRY_ID = "com.peachpasswords.credentials.EXTRA_ENTRY_ID"181+        private const val EXTRA_MODE = "com.peachpasswords.credentials.EXTRA_MODE"182+        private const val MODE_CREATE = "create"183+        private const val EXTRA_CREATE_ID = "com.peachpasswords.credentials.EXTRA_CREATE_ID"184+        private const val EXTRA_CREATE_PASSWORD = "com.peachpasswords.credentials.EXTRA_CREATE_PASSWORD"117185 118186         fun pendingIntent(context: Context, entryId: String): PendingIntent {119187             val intent = Intent(context, PasswordCredentialSelectionActivity::class.java)@@ -122,6 +190,20 @@122190             return PendingIntent.getActivity(123191                 context,124192                 "cm-password:$entryId".hashCode(),193+                intent,194+                PendingIntent.FLAG_UPDATE_CURRENT or mutableFlag,195+            )196+        }197+198+        fun createPendingIntent(context: Context, id: String, password: String): PendingIntent {199+            val intent = Intent(context, PasswordCredentialSelectionActivity::class.java)200+                .putExtra(EXTRA_MODE, MODE_CREATE)201+                .putExtra(EXTRA_CREATE_ID, id)202+                .putExtra(EXTRA_CREATE_PASSWORD, password)203+            val mutableFlag = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) PendingIntent.FLAG_MUTABLE else 0204+            return PendingIntent.getActivity(205+                context,206+                "cm-password-create:$id".hashCode(),125207                 intent,126208                 PendingIntent.FLAG_UPDATE_CURRENT or mutableFlag,127209             )

view file

app/src/main/java/com/peachpasswords/passkeys/PeachCredentialProviderService.kt+51 −2
@@ -5,6 +5,7 @@55 import android.graphics.drawable.Icon66 import androidx.credentials.provider.BeginCreateCredentialRequest77 import androidx.credentials.provider.BeginCreateCredentialResponse8+import androidx.credentials.provider.BeginCreatePasswordCredentialRequest89 import androidx.credentials.provider.BeginCreatePublicKeyCredentialRequest910 import androidx.credentials.provider.BeginGetCredentialRequest1011 import androidx.credentials.provider.BeginGetCredentialResponse@@ -173,8 +174,56 @@173174         cancellationSignal: CancellationSignal,174175         callback: OutcomeReceiver<BeginCreateCredentialResponse, CreateCredentialException>,175176     ) {176-        val publicKeyRequest = request as? BeginCreatePublicKeyCredentialRequest177-        if (publicKeyRequest == null || !vault.settings.value.passkeyProviderEnabled || !vault.isUnlocked.value) {177+        if (!vault.isUnlocked.value) {178+            callback.onResult(BeginCreateCredentialResponse())179+            return180+        }181+        when (request) {182+            is BeginCreatePasswordCredentialRequest -> passwordCreateEntry(request, callback)183+            is BeginCreatePublicKeyCredentialRequest -> passkeyCreateEntry(request, callback)184+            else -> callback.onResult(BeginCreateCredentialResponse())185+        }186+    }187+188+    /**189+     * "Save password to Peach" in the system save sheet. Not Pro-gated and not190+     * toggle-gated — saving is a core capability, exactly like the autofill191+     * save flow. The actual add/update decision happens at tap time in192+     * PasswordCredentialSelectionActivity via planAutofillSave.193+     */194+    private fun passwordCreateEntry(195+        request: BeginCreatePasswordCredentialRequest,196+        callback: OutcomeReceiver<BeginCreateCredentialResponse, CreateCredentialException>,197+    ) {198+        // androidx.credentials.CreatePasswordRequest's BUNDLE_KEY_* constants199+        // are @RestrictTo-internal in Kotlin but are the stable wire keys the200+        // library serializes create requests with — read them directly.201+        val id = request.candidateQueryData.getString("androidx.credentials.BUNDLE_KEY_ID")202+        val password = request.candidateQueryData.getString("androidx.credentials.BUNDLE_KEY_PASSWORD")203+        if (id.isNullOrBlank() || password.isNullOrBlank()) {204+            callback.onResult(BeginCreateCredentialResponse())205+            return206+        }207+        val entries = vault.getEntries()208+        val entry = CreateEntry(209+            accountName = "Peach",210+            pendingIntent = PasswordCredentialSelectionActivity.createPendingIntent(this, id, password),211+            description = "Save password to Peach",212+            lastUsedTime = Instant.now(),213+            icon = Icon.createWithResource(this, R.drawable.ic_peach_mark),214+            passwordCredentialCount = entries.count { !it.password.isNullOrBlank() },215+            publicKeyCredentialCount = entries.count { it.passkey != null },216+            totalCredentialCount = entries.size,217+            isAutoSelectAllowed = false,218+        )219+        callback.onResult(BeginCreateCredentialResponse(createEntries = listOf(entry)))220+    }221+222+    private fun passkeyCreateEntry(223+        publicKeyRequest: BeginCreatePublicKeyCredentialRequest,224+        callback: OutcomeReceiver<BeginCreateCredentialResponse, CreateCredentialException>,225+    ) {226+        if (!vault.settings.value.passkeyProviderEnabled) {178227             callback.onResult(BeginCreateCredentialResponse())179228             return180229         }

view file