improve app link banner handling
This commit is contained in:
+6
-1
@@ -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) {
|
||||
|
||||
+37
-4
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
+60
-70
@@ -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
|
||||
|
||||
+38
-20
@@ -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) { _ -> }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+14
-4
@@ -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)"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+71
-23
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user