dfa6c3b5a925
feat: save passwords to Peach from the Credential Manager save sheet (v2.12.0)
- time
- 2026-08-04 21:57 UTC · 2 months ago
- machine
- koh2
- parent
- 960b4a5c107a
↓ peach-android-dfa6c3b5.tar.gzatlas at this save
files changed3 files
VERSION+1 −1app/src/main/java/com/peachpasswords/credentials/PasswordCredentialSelectionActivity.kt+87 −5app/src/main/java/com/peachpasswords/passkeys/PeachCredentialProviderService.kt+51 −2app/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 )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 }