improve app link banner handling

This commit is contained in:
Fabian Freund
2026-08-09 05:19:35 +02:00
parent 599fb997f1
commit 54e277be72
14 changed files with 3334 additions and 4620 deletions
@@ -134,7 +134,12 @@ class GeckoAppLinksApiImpl(
try {
val components = GlobalComponents.components
val list = components
?.let { pendingStoreFor(it).getPending(owner).map(PendingAppLinkRequest::toPigeon) }
?.let {
val store = pendingStoreFor(it)
store.getPending(owner).map { request ->
request.toPigeon(store.expiresInMs(request))
}
}
?: emptyList()
callback(Result.success(list))
} catch (e: Exception) {
@@ -56,6 +56,7 @@ class NativeAppLinkPromptFeature(
private val sessionUseCases: SessionUseCases,
) : LifecycleAwareFeature {
private var dialog: AlertDialog? = null
private var shownRequestId: Long? = null
private val mainHandler = Handler(Looper.getMainLooper())
override fun start() {
@@ -70,15 +71,33 @@ class NativeAppLinkPromptFeature(
dialog?.setOnDismissListener(null)
dialog?.dismiss()
dialog = null
shownRequestId = null
}
/**
* A new pending request may have been created for this tab (interceptor, engine thread) after
* [start] already queried. Re-check on the main thread; [showNext] is idempotent (a no-op while a
* dialog is up or when nothing pends).
* The tab's pending requests changed (interceptor created one on an engine thread after [start]
* already queried, or the navigation middleware invalidated one). Re-check on the main thread;
* [showNext] is idempotent (a no-op while a live dialog is up or when nothing pends).
*/
fun onPromptAvailable() {
mainHandler.post { showNext() }
mainHandler.post {
dismissStaleDialog()
showNext()
}
}
/**
* Drop a dialog whose request has since been invalidated — otherwise it stays on screen as a
* dud whose Open button consumes nothing. Not a user dismissal: nothing is suppressed.
*/
private fun dismissStaleDialog() {
val shown = shownRequestId ?: return
if (store.peek(shown) != null) return
dialog?.setOnCancelListener(null)
dialog?.setOnDismissListener(null)
dialog?.dismiss()
dialog = null
shownRequestId = null
}
private fun showNext() {
@@ -107,6 +126,14 @@ class NativeAppLinkPromptFeature(
}
.setOnDismissListener { dialog = null }
.show()
shownRequestId = request.requestId
// Expiry in the store is lazy, so nothing would take this dialog down when the request
// lapses — its buttons would consume nothing. Retire it on its own deadline.
mainHandler.postDelayed(
{ dismissStaleDialog() },
store.expiresInMs(request).coerceAtLeast(MIN_EXPIRY_TICK_MS),
)
}
private fun resolveOpen(request: PendingAppLinkRequest) {
@@ -142,6 +169,12 @@ class NativeAppLinkPromptFeature(
private fun afterResolve() {
dialog = null
shownRequestId = null
showNext()
}
private companion object {
/** Never schedule a zero-delay expiry tick; a lapsed request would reschedule in a spin. */
const val MIN_EXPIRY_TICK_MS = 250L
}
}
@@ -51,7 +51,7 @@ data class PendingAppLinkRequest(
val scopeKey: String,
val createdAt: Long,
) {
fun toPigeon(): AppLinkPromptRequest = AppLinkPromptRequest(
fun toPigeon(expiresInMs: Long): AppLinkPromptRequest = AppLinkPromptRequest(
requestId = requestId,
owner = owner,
tabId = tabId,
@@ -72,6 +72,7 @@ data class PendingAppLinkRequest(
engineSupportsScheme = engineSupportsScheme,
scopeKey = scopeKey,
),
expiresInMs = expiresInMs,
)
}
@@ -120,10 +121,32 @@ object PendingAppLinkStores {
* Query + consume: requests stay until resolved, invalidated, or expired. The store
* never holds its lock across a side effect — [consume] returns the request and the
* caller performs launch/fallback after the lock is released.
*
* **A request's lifetime is deliberately not derived from navigation.** Three attempts to infer
* "the user has left the page this prompt belongs to" from the [mozilla.components.browser.state.store.BrowserStore]
* action stream all failed the same way, because every available signal is *per document* while a
* single user-visible navigation spans several:
* - comparing the committed URL's host to the request's anchor killed a banner on its own redirect
* chain (`youtu.be` → `youtube.com`, shortener → destination) and killed a modal on the commit of
* the very load it had interrupted — leaving a denied navigation with no dialog to un-stall it;
* - counting load starts ([mozilla.components.browser.state.action.ContentAction.UpdateLoadingStateAction])
* dismissed banners seconds in, because each redirected document starts its own load;
* - settling on idle only moved that to the first `onPageStop`, which multi-document pages reach
* long before the user is done with them.
*
* So navigation is not consulted at all. A request ends when the user answers it, when its tab
* closes, when a newer banner for the same tab replaces it, or when it expires ([BANNER_EXPIRY_MS]
* for the passive banner, [REQUEST_EXPIRY_MS] for a modal that is holding a navigation). The
* residual risk — a banner outliving the page it was raised on — is bounded by that expiry and is
* strictly safer than the alternatives: a lingering banner still names its target and still opens
* exactly that link, whereas the invalidation heuristics produced prompts that silently did
* nothing. Do not reintroduce URL- or load-state-derived invalidation without a signal that is
* per *navigation* rather than per document.
*/
class PendingAppLinkStore(
private val clock: MonotonicClock = MonotonicClock.SYSTEM,
private val requestExpiryMs: Long = REQUEST_EXPIRY_MS,
private val bannerExpiryMs: Long = BANNER_EXPIRY_MS,
private val suppressionExpiryMs: Long = SUPPRESSION_EXPIRY_MS,
private val dedupeWindowMs: Long = DEDUPE_WINDOW_MS,
private val fallbackReentryMs: Long = FALLBACK_REENTRY_MS,
@@ -180,11 +203,26 @@ class PendingAppLinkStore(
scopeKey = input.scopeKey,
createdAt = clock.elapsedRealtime(),
)
// At most one live banner per tab: the surface renders one anyway, and a second
// app-link site visited in the same tab should replace the offer, not stack behind it.
if (request.urlClass == AppLinkUrlClass.BANNER) {
requests.values.removeAll {
it.tabId == request.tabId && it.urlClass == AppLinkUrlClass.BANNER
}
}
requests[request.requestId] = request
return request
}
}
/**
* How long [request] has left before [sweepExpiredLocked] drops it. Handed to the surface so it
* can retire the prompt on time — expiry is lazy (it only runs on query/consume), so a prompt
* left on screen past its deadline would still render buttons that resolve to `stale`.
*/
fun expiresInMs(request: PendingAppLinkRequest): Long =
(expiryFor(request) - (clock.elapsedRealtime() - request.createdAt)).coerceAtLeast(0L)
/** Non-consuming query of live requests for [owner]. */
fun getPending(owner: AppLinkPromptOwner): List<PendingAppLinkRequest> {
synchronized(lock) {
@@ -212,80 +250,23 @@ class PendingAppLinkStore(
synchronized(lock) { requests.remove(requestId) }
}
/** Invalidate every pending request for a tab (tab close / replacement). */
fun invalidateTab(tabId: String) {
/**
* Invalidate every pending request for a tab (tab close / replacement).
*
* @return the owners that had a request removed, so the caller can tell those surfaces to
* re-query instead of leaving a dead prompt on screen.
*/
fun invalidateTab(tabId: String): Set<AppLinkPromptOwner> {
synchronized(lock) {
val owners = requests.values
.filter { it.tabId == tabId }
.mapTo(mutableSetOf()) { it.owner }
requests.values.removeAll { it.tabId == tabId }
suppression.keys.removeAll { it.startsWith("$tabId\u0000") }
return owners
}
}
/**
* A committed top-level navigation in [tabId]. A request whose own page committed
* stays alive (that commit is the page the prompt sits on); a commit to a
* *different site* invalidates the tab's pending requests (§2.6).
*
* Matching is by **normalised host**, not exact URL: the initial load a banner
* rides on almost always commits at a redirected/normalised URL (`www`, trailing
* slash, tracking params) that never equals the intercepted URL, so an exact-URL
* check would invalidate every banner on its own page load. The anchor is the
* target host for a banner (the page it loads) and the source host for a modal
* (the page it is shown over, since the modal's own navigation was denied). When
* no host can be derived, the request is kept and left to expiry/tab-close.
*/
fun onCommittedNavigation(tabId: String, committedUrl: String) {
val committedHost = siteKey(committedUrl)
synchronized(lock) {
val removed = mutableListOf<Long>()
requests.values.removeAll { request ->
if (request.tabId != tabId) return@removeAll false
val anchorHost = siteKey(if (request.isModal) request.sourceUrl else request.url)
val invalidate = anchorHost != null && committedHost != null && anchorHost != committedHost
if (invalidate) removed.add(request.requestId)
invalidate
}
if (removed.isNotEmpty()) {
logger.info(
"onCommittedNavigation tab=$tabId committedHost=$committedHost invalidated=$removed",
)
}
}
}
/** Normalised, subdomain-stripped host for same-site comparison; null if underivable. */
private fun siteKey(url: String?): String? {
val rawHost = extractHost(url) ?: return null
val normalized = AppLinkHostNormalizer.normalizeHost(rawHost) ?: return null
return stripCommonSubDomains(normalized)
}
private fun extractHost(url: String?): String? {
if (url.isNullOrEmpty()) return null
val schemeSep = url.indexOf("://")
if (schemeSep < 0) return null
val afterScheme = url.substring(schemeSep + 3)
val end = afterScheme.indexOfFirst { it == '/' || it == '?' || it == '#' }
var authority = if (end >= 0) afterScheme.substring(0, end) else afterScheme
val at = authority.lastIndexOf('@')
if (at >= 0) authority = authority.substring(at + 1)
// Preserve a bracketed IPv6 literal; AppLinkHostNormalizer canonicalises it.
if (authority.startsWith("[")) {
val close = authority.indexOf(']')
return if (close >= 0) authority.substring(0, close + 1) else null
}
val colon = authority.lastIndexOf(':')
if (colon >= 0) authority = authority.substring(0, colon)
return authority.ifEmpty { null }
}
private fun stripCommonSubDomains(host: String): String = when {
host.startsWith("www.") -> host.removePrefix("www.")
host.startsWith("m.") -> host.removePrefix("m.")
host.startsWith("mobile.") -> host.removePrefix("mobile.")
host.startsWith("maps.") -> host.removePrefix("maps.")
else -> host
}
// ---- Suppression (§2.6) ----
fun recordSuppression(tabId: String, fingerprint: String) {
@@ -326,15 +307,24 @@ class PendingAppLinkStore(
}
}
/**
* A banner is a passive offer sitting over a page the user keeps reading, so it is bounded by
* time rather than by navigation (see the class KDoc). A modal blocks a navigation until it is
* answered, so it keeps the long window.
*/
private fun expiryFor(request: PendingAppLinkRequest): Long =
if (request.urlClass == AppLinkUrlClass.BANNER) bannerExpiryMs else requestExpiryMs
private fun sweepExpiredLocked() {
val now = clock.elapsedRealtime()
requests.values.removeAll { now > it.createdAt + requestExpiryMs }
requests.values.removeAll { now > it.createdAt + expiryFor(it) }
suppression.values.removeAll { now > it }
fallbackReentry.values.removeAll { now > it }
}
companion object {
const val REQUEST_EXPIRY_MS = 10 * 60 * 1000L
const val BANNER_EXPIRY_MS = 90 * 1000L
const val SUPPRESSION_EXPIRY_MS = 10 * 60 * 1000L
const val DEDUPE_WINDOW_MS = 2000L
const val FALLBACK_REENTRY_MS = 10 * 1000L
@@ -6,9 +6,12 @@
package eu.weblibre.flutter_mozilla_components.middleware
import eu.weblibre.flutter_mozilla_components.GlobalComponents
import eu.weblibre.flutter_mozilla_components.applinks.NativeAppLinkPromptNotifier
import eu.weblibre.flutter_mozilla_components.applinks.PendingAppLinkStore
import eu.weblibre.flutter_mozilla_components.ext.EventSequence
import eu.weblibre.flutter_mozilla_components.pigeons.AppLinkPromptOwner
import mozilla.components.browser.state.action.BrowserAction
import mozilla.components.browser.state.action.ContentAction
import mozilla.components.browser.state.action.CustomTabListAction
import mozilla.components.browser.state.action.EngineAction
import mozilla.components.browser.state.action.TabListAction
@@ -17,17 +20,18 @@ import mozilla.components.lib.state.Middleware
import mozilla.components.lib.state.Store
/**
* Observes the [BrowserStore] and drives [PendingAppLinkStore] invalidation and
* suppression clearing (APP_LINKS_OWN_IMPLEMENTATION_PLAN.md §2.6):
* Observes the [BrowserStore] and drives [PendingAppLinkStore] tab teardown and suppression
* clearing (APP_LINKS_OWN_IMPLEMENTATION_PLAN.md §2.6):
*
* - a committed top-level navigation whose URL is not a request's own target
* invalidates that request (a banner-class request's target committing keeps it
* alive — that commit is the page the banner sits on);
* - tab close / Custom Tab removal invalidates the tab's pending requests and
* suppression;
* - a new user-initiated/direct navigation (omnibar, bookmark, typed URL — which
* dispatch a `LoadUrlAction`) clears the tab's suppression. In-page redirects
* do not dispatch these actions, so the redirect-loop defence stays intact.
* - tab close / Custom Tab removal invalidates the tab's pending requests and suppression, and
* tells the owning surface to re-query so no dead prompt is left on screen;
* - a new user-initiated/direct navigation (omnibar, bookmark, typed URL — which dispatch a
* `LoadUrlAction`) clears the tab's suppression. In-page redirects do not dispatch these
* actions, so the redirect-loop defence stays intact.
*
* It deliberately does **not** invalidate prompts on navigation: see the [PendingAppLinkStore]
* KDoc for the three per-document signals that were tried and why none of them can express
* "the user left this page".
*/
class AppLinkNavigationMiddleware(
private val store: PendingAppLinkStore,
@@ -38,13 +42,7 @@ class AppLinkNavigationMiddleware(
action: BrowserAction,
) {
when (action) {
is ContentAction.UpdateUrlAction -> {
// A committed top-level navigation.
this.store.onCommittedNavigation(action.sessionId, action.url)
}
is EngineAction.LoadUrlAction -> {
// App-initiated (direct) navigation — clears suppression.
this.store.clearSuppressionForTab(action.tabId)
}
@@ -53,15 +51,17 @@ class AppLinkNavigationMiddleware(
}
is TabListAction.RemoveTabAction -> {
this.store.invalidateTab(action.tabId)
notifyInvalidated(action.tabId, this.store.invalidateTab(action.tabId))
}
is TabListAction.RemoveTabsAction -> {
action.tabIds.forEach(this.store::invalidateTab)
action.tabIds.forEach { tabId ->
notifyInvalidated(tabId, this.store.invalidateTab(tabId))
}
}
is CustomTabListAction.RemoveCustomTabAction -> {
this.store.invalidateTab(action.tabId)
notifyInvalidated(action.tabId, this.store.invalidateTab(action.tabId))
}
else -> {}
@@ -69,4 +69,22 @@ class AppLinkNavigationMiddleware(
next(action)
}
/**
* Nudge each affected surface to re-query the pending store. The event is the same
* "prompts changed" signal the interceptor sends on creation — the query is authoritative, so
* a lost event only delays the cleanup to the next resume.
*/
private fun notifyInvalidated(tabId: String, owners: Set<AppLinkPromptOwner>) {
for (owner in owners) {
when (owner) {
AppLinkPromptOwner.NATIVE_EXTERNAL ->
NativeAppLinkPromptNotifier.notifyPromptAvailable(tabId)
AppLinkPromptOwner.FLUTTER_BROWSER ->
GlobalComponents.appLinkEvents
?.onAppLinkPromptAvailable(EventSequence.next(), owner) { _ -> }
}
}
}
}
@@ -5929,7 +5929,14 @@ data class AppLinkPromptRequest (
* unsupported-scheme prompt.
*/
val isModal: Boolean,
val target: AppLinkTarget
val target: AppLinkTarget,
/**
* Milliseconds until the native store drops this request, measured at query
* time. The surface showing it must stop offering it by then: resolving an
* expired request is a no-op, so a prompt left on screen past this becomes a
* button that silently does nothing.
*/
val expiresInMs: Long
)
{
companion object {
@@ -5945,7 +5952,8 @@ data class AppLinkPromptRequest (
val canRemember = pigeonVar_list[8] as Boolean
val isModal = pigeonVar_list[9] as Boolean
val target = pigeonVar_list[10] as AppLinkTarget
return AppLinkPromptRequest(requestId, owner, tabId, contextId, sourceUrl, isPrivate, isWallet, isProtectedContext, canRemember, isModal, target)
val expiresInMs = pigeonVar_list[11] as Long
return AppLinkPromptRequest(requestId, owner, tabId, contextId, sourceUrl, isPrivate, isWallet, isProtectedContext, canRemember, isModal, target, expiresInMs)
}
}
fun toList(): List<Any?> {
@@ -5961,6 +5969,7 @@ data class AppLinkPromptRequest (
canRemember,
isModal,
target,
expiresInMs,
)
}
override fun equals(other: Any?): Boolean {
@@ -5971,7 +5980,7 @@ data class AppLinkPromptRequest (
return true
}
val other = other as AppLinkPromptRequest
return GeckoPigeonUtils.deepEquals(this.requestId, other.requestId) && GeckoPigeonUtils.deepEquals(this.owner, other.owner) && GeckoPigeonUtils.deepEquals(this.tabId, other.tabId) && GeckoPigeonUtils.deepEquals(this.contextId, other.contextId) && GeckoPigeonUtils.deepEquals(this.sourceUrl, other.sourceUrl) && GeckoPigeonUtils.deepEquals(this.isPrivate, other.isPrivate) && GeckoPigeonUtils.deepEquals(this.isWallet, other.isWallet) && GeckoPigeonUtils.deepEquals(this.isProtectedContext, other.isProtectedContext) && GeckoPigeonUtils.deepEquals(this.canRemember, other.canRemember) && GeckoPigeonUtils.deepEquals(this.isModal, other.isModal) && GeckoPigeonUtils.deepEquals(this.target, other.target)
return GeckoPigeonUtils.deepEquals(this.requestId, other.requestId) && GeckoPigeonUtils.deepEquals(this.owner, other.owner) && GeckoPigeonUtils.deepEquals(this.tabId, other.tabId) && GeckoPigeonUtils.deepEquals(this.contextId, other.contextId) && GeckoPigeonUtils.deepEquals(this.sourceUrl, other.sourceUrl) && GeckoPigeonUtils.deepEquals(this.isPrivate, other.isPrivate) && GeckoPigeonUtils.deepEquals(this.isWallet, other.isWallet) && GeckoPigeonUtils.deepEquals(this.isProtectedContext, other.isProtectedContext) && GeckoPigeonUtils.deepEquals(this.canRemember, other.canRemember) && GeckoPigeonUtils.deepEquals(this.isModal, other.isModal) && GeckoPigeonUtils.deepEquals(this.target, other.target) && GeckoPigeonUtils.deepEquals(this.expiresInMs, other.expiresInMs)
}
override fun hashCode(): Int {
@@ -5987,10 +5996,11 @@ data class AppLinkPromptRequest (
result = 31 * result + GeckoPigeonUtils.deepHash(this.canRemember)
result = 31 * result + GeckoPigeonUtils.deepHash(this.isModal)
result = 31 * result + GeckoPigeonUtils.deepHash(this.target)
result = 31 * result + GeckoPigeonUtils.deepHash(this.expiresInMs)
return result
}
override fun toString(): String {
return "AppLinkPromptRequest(requestId=$requestId, owner=$owner, tabId=$tabId, contextId=$contextId, sourceUrl=$sourceUrl, isPrivate=$isPrivate, isWallet=$isWallet, isProtectedContext=$isProtectedContext, canRemember=$canRemember, isModal=$isModal, target=$target)"
return "AppLinkPromptRequest(requestId=$requestId, owner=$owner, tabId=$tabId, contextId=$contextId, sourceUrl=$sourceUrl, isPrivate=$isPrivate, isWallet=$isWallet, isProtectedContext=$isProtectedContext, canRemember=$canRemember, isModal=$isModal, target=$target, expiresInMs=$expiresInMs)"
}
}
@@ -103,33 +103,82 @@ class PendingAppLinkStoreTest {
}
@Test
fun bannerTargetCommitKeepsRequestButUnrelatedCommitInvalidates() {
val store = PendingAppLinkStore(FakeClock())
val banner = store.createRequest(
newRequest(urlClass = AppLinkUrlClass.BANNER, url = "https://youtu.be/x"),
fun aNewerBannerForTheTabReplacesTheOlderOne() {
val clock = FakeClock()
val store = PendingAppLinkStore(clock)
val first = store.createRequest(
newRequest(urlClass = AppLinkUrlClass.BANNER, url = "https://a.example/x"),
)
// The banner's own target committing keeps it alive.
store.onCommittedNavigation("tab1", "https://youtu.be/x")
assertNotNull(store.peek(banner.requestId))
// An unrelated commit invalidates it.
store.onCommittedNavigation("tab1", "https://example.com/other")
assertNull(store.peek(banner.requestId))
clock.now = 5000L // past the dedupe window, so this is a genuinely new offer
val second = store.createRequest(
newRequest(
urlClass = AppLinkUrlClass.BANNER,
url = "https://b.example/y",
fingerprint = "fp2",
),
)
// One live banner per tab: visiting a second app-link site replaces the offer rather than
// stacking behind it.
assertNull(store.peek(first.requestId))
assertNotNull(store.peek(second.requestId))
assertEquals(1, store.getPending(AppLinkPromptOwner.FLUTTER_BROWSER).size)
}
@Test
fun bannerSurvivesSameSiteRedirectAndNormalisation() {
val store = PendingAppLinkStore(FakeClock())
// The intercepted URL is rarely byte-identical to the committed one: the initial
// load redirects/normalises (www stripped, tracking params added, trailing slash).
val banner = store.createRequest(
newRequest(urlClass = AppLinkUrlClass.BANNER, url = "https://www.reddit.com/r/foo"),
fun aBannerForAnotherTabIsUntouched() {
val clock = FakeClock()
val store = PendingAppLinkStore(clock)
val other = store.createRequest(
newRequest(tabId = "tab2", urlClass = AppLinkUrlClass.BANNER, url = "https://a.example/x"),
)
store.onCommittedNavigation("tab1", "https://reddit.com/r/foo/?utm_source=share")
assertNotNull(store.peek(banner.requestId))
clock.now = 5000L
store.createRequest(
newRequest(tabId = "tab1", urlClass = AppLinkUrlClass.BANNER, url = "https://b.example/y"),
)
assertNotNull(store.peek(other.requestId))
}
// A commit to a genuinely different site still invalidates it.
store.onCommittedNavigation("tab1", "https://twitter.com/reddit")
@Test
fun bannersExpireSoonerThanModals() {
val clock = FakeClock()
val store = PendingAppLinkStore(
clock,
requestExpiryMs = 10_000L,
bannerExpiryMs = 1_000L,
)
val banner = store.createRequest(
newRequest(urlClass = AppLinkUrlClass.BANNER, url = "https://a.example/x"),
)
val modal = store.createRequest(
newRequest(urlClass = AppLinkUrlClass.MODAL, fingerprint = "fp-modal"),
)
// The banner is a passive offer bounded by time; the modal is holding a navigation open
// and keeps the long window.
clock.now = 1001L
assertNull(store.peek(banner.requestId))
assertNotNull(store.peek(modal.requestId))
clock.now = 10_001L
assertNull(store.peek(modal.requestId))
}
@Test
fun remainingTtlIsReportedSoTheSurfaceCanRetireThePromptOnTime() {
val clock = FakeClock()
val store = PendingAppLinkStore(clock, bannerExpiryMs = 1_000L)
val banner = store.createRequest(
newRequest(urlClass = AppLinkUrlClass.BANNER, url = "https://a.example/x"),
)
assertEquals(1_000L, store.expiresInMs(banner))
clock.now = 400L
assertEquals(600L, store.expiresInMs(banner))
// Never negative: a surface schedules on this value, and expiry itself is lazy.
clock.now = 5_000L
assertEquals(0L, store.expiresInMs(banner))
}
@Test
@@ -137,7 +186,7 @@ class PendingAppLinkStoreTest {
val store = PendingAppLinkStore(FakeClock())
val request = store.createRequest(newRequest())
store.recordSuppression("tab1", "fp1")
store.invalidateTab("tab1")
assertEquals(setOf(AppLinkPromptOwner.FLUTTER_BROWSER), store.invalidateTab("tab1"))
assertNull(store.peek(request.requestId))
assertFalse(store.isSuppressed("tab1", "fp1"))
}
@@ -148,8 +197,7 @@ class PendingAppLinkStoreTest {
val store = PendingAppLinkStore(clock, suppressionExpiryMs = 1000L)
store.recordSuppression("tab1", "fp1")
assertTrue(store.isSuppressed("tab1", "fp1"))
// Ordinary committed navigation does not clear it.
store.onCommittedNavigation("tab1", "https://redirect.example")
// A redirect within the current load does not clear it (no load start is dispatched).
assertTrue(store.isSuppressed("tab1", "fp1"))
// Direct navigation clears it.
store.clearSuppressionForTab("tab1")
File diff suppressed because it is too large Load Diff
@@ -3050,6 +3050,12 @@ class AppLinkPromptRequest {
final bool isModal;
final AppLinkTarget target;
/// Milliseconds until the native store drops this request, measured at query
/// time. The surface showing it must stop offering it by then: resolving an
/// expired request is a no-op, so a prompt left on screen past this becomes a
/// button that silently does nothing.
final int expiresInMs;
const AppLinkPromptRequest({
required this.requestId,
required this.owner,
@@ -3062,6 +3068,7 @@ class AppLinkPromptRequest {
required this.canRemember,
required this.isModal,
required this.target,
required this.expiresInMs,
});
}