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
↓ peach-browser-5b013329.tar.gzatlas at this save
files changed5 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 −1android/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 }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 }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(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) {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