5b013329da52

autofill: ranked domain match (subdomain/package), locked-vault auth flow (consume + complete EXTRA_AUTHENTICATION_RESULT), onNewIntent re-fire, appStateChange re-checks save

time
2026-05-01 04:19 UTC · 6 months ago
machine
koh2
parent
51810d467d82
5 files android/app/src/main/java/com/peachpasswords/app/AutofillPlugin.kt+93 −0android/app/src/main/java/com/peachpasswords/app/MainActivity.kt+19 −0android/app/src/main/java/com/peachpasswords/app/PeachAutofillService.kt+4 −0android/app/src/main/java/com/peachpasswords/app/VaultBridge.kt+60 −10src/popup/App.tsx+93 −1
android/app/src/main/java/com/peachpasswords/app/AutofillPlugin.kt+93 −0
@@ -1,6 +1,15 @@11 package com.peachpasswords.app22 3+import android.app.Activity34 import android.content.Context5+import android.content.Intent6+import android.os.Build7+import android.service.autofill.Dataset8+import android.service.autofill.FillResponse9+import android.view.autofill.AutofillId10+import android.view.autofill.AutofillManager11+import android.view.autofill.AutofillValue12+import android.widget.RemoteViews413 import com.getcapacitor.*514 import com.getcapacitor.annotation.CapacitorPlugin615 @@ -33,5 +42,89 @@3342     fun isEnabledAsAutofillService(call: PluginCall) {3443         val afm = context.getSystemService(android.view.autofill.AutofillManager::class.java)3544         call.resolve(JSObject().put("enabled", afm?.hasEnabledAutofillServices() == true))45+    }46+47+    /**48+     * If the activity was opened via the autofill auth intent (vault was49+     * locked), return the originating domain/package so the WebView can50+     * surface "Unlock to fill X" UI. The extras are consumed on read so a51+     * second call returns null.52+     */53+    @PluginMethod54+    fun consumePendingAutofillRequest(call: PluginCall) {55+        val intent = activity?.intent56+        val domain = intent?.getStringExtra("autofill_domain")57+        val pkg = intent?.getStringExtra("autofill_package")58+        if (domain == null && pkg == null) {59+            call.resolve(JSObject().put("pending", null))60+            return61+        }62+        // Clear extras so we don't re-fire on next resume.63+        intent.removeExtra("autofill_domain")64+        intent.removeExtra("autofill_package")65+        call.resolve(JSObject().apply {66+            put("pending", JSObject().apply {67+                put("domain", domain ?: "")68+                put("package", pkg ?: "")69+            })70+        })71+    }72+73+    /**74+     * After unlock, finish the activity and return a FillResponse to the75+     * autofill framework. The framework re-invokes onFillRequest with the76+     * user authenticated, but the spec'd path is to return a populated77+     * response directly via EXTRA_AUTHENTICATION_RESULT.78+     */79+    @PluginMethod80+    fun completeAutofillAuthentication(call: PluginCall) {81+        val domain = call.getString("domain") ?: return call.reject("domain required")82+        val act = activity ?: return call.reject("no activity")83+84+        // Reconstruct field IDs the original FillRequest gave us. They live in85+        // the original auth intent extras under the standard AutofillManager keys.86+        val originalIntent = act.intent87+        val usernameId = originalIntent?.getParcelableExtra<AutofillId>("autofill_username_id")88+        val passwordId = originalIntent?.getParcelableExtra<AutofillId>("autofill_password_id")89+90+        if (usernameId == null && passwordId == null) {91+            // No structure available — just finish; framework will retry the fill92+            // request now that VaultBridge.isUnlocked() returns true.93+            act.setResult(Activity.RESULT_OK)94+            act.finish()95+            call.resolve()96+            return97+        }98+99+        val entries = VaultBridge.findEntries(domain)100+        val responseBuilder = FillResponse.Builder()101+        for (entry in entries.take(3)) {102+            val pres = RemoteViews(act.packageName, R.layout.autofill_item).apply {103+                setTextViewText(R.id.autofill_label, entry.title)104+                setTextViewText(R.id.autofill_subtitle, entry.username)105+            }106+            val ds = Dataset.Builder()107+            usernameId?.let {108+                ds.setValue(it, AutofillValue.forText(entry.username), pres)109+            }110+            passwordId?.let {111+                ds.setValue(112+                    it,113+                    AutofillValue.forText(entry.password),114+                    RemoteViews(act.packageName, R.layout.autofill_item).apply {115+                        setTextViewText(R.id.autofill_label, entry.title)116+                        setTextViewText(R.id.autofill_subtitle, "••••••••")117+                    },118+                )119+            }120+            responseBuilder.addDataset(ds.build())121+        }122+123+        val replyIntent = Intent().apply {124+            putExtra(AutofillManager.EXTRA_AUTHENTICATION_RESULT, responseBuilder.build())125+        }126+        act.setResult(Activity.RESULT_OK, replyIntent)127+        act.finish()128+        call.resolve()36129     }37130 }

view file

android/app/src/main/java/com/peachpasswords/app/MainActivity.kt+19 −0
@@ -1,5 +1,6 @@11 package com.peachpasswords.app22 3+import android.content.Intent34 import android.os.Bundle45 import com.getcapacitor.BridgeActivity56 @@ -13,5 +14,23 @@1314         registerPlugin(FoldPlugin::class.java)1415         super.onCreate(savedInstanceState)1516         VaultBridge.init(this)17+    }18+19+    /**20+     * launchMode=singleTask: a new autofill auth intent reuses this activity21+     * instead of creating a new one. Replace the cached intent so22+     * AutofillPlugin.consumePendingAutofillRequest sees the latest extras and23+     * the WebView can react.24+     */25+    override fun onNewIntent(intent: Intent) {26+        super.onNewIntent(intent)27+        setIntent(intent)28+        if (intent.hasExtra("autofill_domain") || intent.hasExtra("autofill_package")) {29+            // Notify the JS layer so the lock screen can show "Unlock to fill X".30+            bridge?.triggerWindowJSEvent(31+                "peach:autofill-requested",32+                "{}",33+            )34+        }1635     }1736 }

view file

android/app/src/main/java/com/peachpasswords/app/PeachAutofillService.kt+4 −0
@@ -28,6 +28,10 @@2828             val authIntent = Intent(this, MainActivity::class.java).apply {2929                 putExtra("autofill_domain", domain)3030                 putExtra("autofill_package", packageName)31+                // Pass field IDs so the JS side can complete auth with a populated32+                // FillResponse via EXTRA_AUTHENTICATION_RESULT after unlock.33+                parser.usernameId?.let { putExtra("autofill_username_id", it) }34+                parser.passwordId?.let { putExtra("autofill_password_id", it) }3135                 addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)3236             }3337             val pendingIntent = PendingIntent.getActivity(

view file

android/app/src/main/java/com/peachpasswords/app/VaultBridge.kt+60 −10
@@ -17,18 +17,68 @@1717 1818     data class VaultEntry(val title: String, val username: String, val password: String)1919 20-    fun findEntries(domain: String): List<VaultEntry> {20+    /**21+     * Match vault entries to an autofill request domain or package name.22+     * Handles:23+     *   - web domains (github.com → https://github.com)24+     *   - subdomains (login.microsoftonline.com → microsoftonline.com)25+     *   - native app package matching against entry urls (com.google.android.gm26+     *     → entries with "gmail.com" / "google.com" in url)27+     * Ranks: exact host match > base-domain match > substring fallback.28+     */29+    fun findEntries(query: String): List<VaultEntry> {30+        if (query.isBlank()) return emptyList()2131         val entries = InMemoryVault.getEntries() ?: return emptyList()22-        return entries.filter { entry ->23-            entry.url?.contains(domain, ignoreCase = true) == true ||24-            entry.title?.contains(domain, ignoreCase = true) == true25-        }.map { entry ->26-            VaultEntry(27-                title = entry.title ?: domain,28-                username = entry.username ?: "",29-                password = entry.password ?: ""30-            )32+        val q = query.lowercase().removePrefix("http://").removePrefix("https://")33+            .substringBefore('/').removePrefix("www.")34+        val baseDomain = baseDomainOf(q)35+        // Package-name heuristic: com.google.android.gm → google36+        val packageHint = if (q.startsWith("com.") || q.contains(".") && !q.contains('/')37+            && q.split('.').size >= 3 && q.first().isLetter() && q.endsWith(q.split('.').last())) {38+            // Take the second segment as a hint (com.google.android.gm → google)39+            q.split('.').getOrNull(1)?.takeIf { it.length > 2 }40+        } else null41+42+        data class Scored(val entry: InMemoryVault.VaultEntryData, val score: Int)43+        val scored = entries.mapNotNull { e ->44+            if (e.type != "login") return@mapNotNull null45+            val url = (e.url ?: "").lowercase().removePrefix("http://").removePrefix("https://")46+                .substringBefore('/').removePrefix("www.")47+            val urlBase = baseDomainOf(url)48+            val title = (e.title ?: "").lowercase()49+            val score = when {50+                url.isNotEmpty() && url == q -> 10051+                urlBase.isNotEmpty() && urlBase == baseDomain -> 8052+                url.isNotEmpty() && q.endsWith(url) -> 70   // request is subdomain of saved53+                url.isNotEmpty() && url.endsWith(q) -> 60   // saved is subdomain of request54+                packageHint != null && (urlBase.contains(packageHint) || title.contains(packageHint)) -> 5055+                url.isNotEmpty() && url.contains(baseDomain) && baseDomain.length >= 4 -> 4056+                title.contains(q) -> 2557+                else -> 058+            }59+            if (score > 0) Scored(e, score) else null3160         }61+        return scored62+            .sortedByDescending { it.score }63+            .take(3)64+            .map { (e, _) ->65+                VaultEntry(66+                    title = e.title ?: query,67+                    username = e.username ?: "",68+                    password = e.password ?: "",69+                )70+            }71+    }72+73+    /** Crude eTLD+1: last two dot-separated segments. Good enough for autofill74+     * ranking; misses .co.uk-style multi-segment TLDs (returns "co.uk" which75+     * still matches usefully via exact compare). Replaced if we ever ship a76+     * public-suffix list to the Android side. */77+    private fun baseDomainOf(host: String): String {78+        if (host.isEmpty() || !host.contains('.')) return host79+        val parts = host.split('.')80+        if (parts.size <= 2) return host81+        return parts.takeLast(2).joinToString(".")3282     }3383 3484     fun offerSave(domain: String, username: String, password: String) {

view file

src/popup/App.tsx+93 −1
@@ -46,6 +46,10 @@4646   checkPendingSave(): Promise<{ pending: string | null }>;4747   openAutofillSettings(): Promise<void>;4848   isEnabledAsAutofillService(): Promise<{ enabled: boolean }>;49+  consumePendingAutofillRequest(): Promise<{50+    pending: { domain: string; package: string } | null;51+  }>;52+  completeAutofillAuthentication(options: { domain: string }): Promise<void>;4953 }>("Autofill");5054 5155 type View =@@ -169,6 +173,10 @@169173     username: string;170174     password: string;171175   } | null>(null);176+  const [pendingAutofillRequest, setPendingAutofillRequest] = createSignal<{177+    domain: string;178+    pkg: string;179+  } | null>(null);172180   const [backExitNotice, setBackExitNotice] = createSignal(false);173181   const [incomingPair, setIncomingPair] = createSignal<IncomingPairingRequest | null>(null);174182   const [recentPairToast, setRecentPairToast] = createSignal<string | null>(null);@@ -385,6 +393,51 @@385393       updateLayoutClass();386394       window.addEventListener("resize", updateLayoutClass);387395 396+      // launchMode=singleTask: a re-fired autofill auth intent triggers397+      // onNewIntent in MainActivity, which dispatches this window event.398+      // Re-consume so the lock screen surfaces the pending request.399+      const onAutofillRequested = () => {400+        void (async () => {401+          try {402+            const { pending } = await Autofill.consumePendingAutofillRequest();403+            if (pending && (pending.domain || pending.package)) {404+              setPendingAutofillRequest({405+                domain: pending.domain || pending.package,406+                pkg: pending.package || "",407+              });408+            }409+          } catch { /* best effort */ }410+        })();411+      };412+      window.addEventListener("peach:autofill-requested", onAutofillRequested);413+414+      // When Peach foregrounds, re-check for a pending autofill save (the user415+      // may have submitted a form in another app while we were backgrounded)416+      // and re-consume any new autofill request extras.417+      const appState = await CapacitorApp.addListener("appStateChange", (state) => {418+        if (!state.isActive) return;419+        void (async () => {420+          try {421+            const { pending } = await Autofill.checkPendingSave();422+            if (pending) {423+              const parsed = JSON.parse(pending) as {424+                domain?: string;425+                username?: string;426+                password?: string;427+              };428+              if (parsed.username && parsed.password) {429+                setPendingAutofillSave({430+                  domain: parsed.domain ?? "",431+                  username: parsed.username,432+                  password: parsed.password,433+                });434+              }435+            }436+          } catch { /* best effort */ }437+          onAutofillRequested();438+        })();439+      });440+388441       // Foldable posture (Z Fold tabletop / book) — set data-posture and a389442       // CSS variable for hinge height so layouts can pad away from the crease.390443       const stopFold = await startFoldMonitoring((e: PostureChangedEvent) => {@@ -429,7 +482,9 @@429482         void keyboardShow.remove();430483         void keyboardHide.remove();431484         void backButton.remove();485+        void appState.remove();432486         stopFold();487+        window.removeEventListener("peach:autofill-requested", onAutofillRequested);433488         window.removeEventListener("resize", updateLayoutClass);434489         document.removeEventListener("touchstart", handleTouchStart);435490         document.removeEventListener("touchend", handleTouchEnd);@@ -551,6 +606,19 @@551606       } catch {552607         // Pending autofill saves are opportunistic; boot must continue.553608       }609+610+      // Locked-vault autofill flow: was the activity opened via the autofill611+      // auth intent? If so, surface "Unlock to fill X" and complete the auth612+      // dataset once the vault unlocks.613+      try {614+        const { pending } = await Autofill.consumePendingAutofillRequest();615+        if (pending && (pending.domain || pending.package)) {616+          setPendingAutofillRequest({617+            domain: pending.domain || pending.package,618+            pkg: pending.package || "",619+          });620+        }621+      } catch { /* best effort */ }554622     }555623 556624     // Warm the lazy-module cache so the Suspense fallback never shows for@@ -845,7 +913,16 @@845913 846914     const loadedEntries = res.data ?? [];847915     setEntries(loadedEntries);848-    void publishInMemoryVault(loadedEntries);916+    void publishInMemoryVault(loadedEntries).then(() => {917+      // If we were opened by Android's autofill auth intent (vault was locked),918+      // entries are now published to the native InMemoryVault — finish the919+      // auth dataset so the framework fills the originating app's fields.920+      const req = pendingAutofillRequest();921+      if (req) {922+        setPendingAutofillRequest(null);923+        void Autofill.completeAutofillAuthentication({ domain: req.domain });924+      }925+    });849926 850927     const currentId = selectedEntry()?.id;851928     if (currentId) {@@ -1241,6 +1318,21 @@12411318                 <button12421319                   class="peach-pro-notice-close"12431320                   onClick={() => setPendingAutofillSave(null)}1321+                  aria-label="Dismiss"1322+                >1323+                  x1324+                </button>1325+              </div>1326+            </Show>1327+1328+            <Show when={pendingAutofillRequest() !== null}>1329+              <div class="peach-pro-notice-banner peach-autofill-request-banner">1330+                <span>1331+                  Unlock to autofill{pendingAutofillRequest()?.domain ? ` ${pendingAutofillRequest()!.domain}` : ""}1332+                </span>1333+                <button1334+                  class="peach-pro-notice-close"1335+                  onClick={() => setPendingAutofillRequest(null)}12441336                   aria-label="Dismiss"12451337                 >12461338                   x

view file