refactor and cleanup

This commit is contained in:
Fabian Freund
2026-08-09 05:53:02 +02:00
parent 54e277be72
commit cc27a6daa8
5 changed files with 175 additions and 61 deletions
@@ -56,9 +56,25 @@ class NativeAppLinkPromptFeature(
private val sessionUseCases: SessionUseCases,
) : LifecycleAwareFeature {
private var dialog: AlertDialog? = null
private var shownRequestId: Long? = null
private var shownRequest: PendingAppLinkRequest? = null
private val mainHandler = Handler(Looper.getMainLooper())
/**
* The lapse tick for the dialog currently on screen. Held as a single instance so it can be
* cancelled: the delay is up to [PendingAppLinkStore.REQUEST_EXPIRY_MS] (10 minutes) and the
* runnable retains this feature — and through it the Activity-derived [context] — for its whole
* duration, so it must never outlive [stop].
*/
private val expiryTick = Runnable {
dismissStaleDialog()
// The store sweeps on a strict `>`, so a tick can land a millisecond before the request is
// actually droppable and dismiss nothing. Re-arm in that case rather than leave the dialog
// with no deadline at all; [MIN_EXPIRY_TICK_MS] keeps that from spinning.
shownRequest?.let(::scheduleExpiryTick)
// A dialog retired by its own deadline still has to make way for whatever else pends.
showNext()
}
override fun start() {
NativeAppLinkPromptNotifier.register(tabId, this)
showNext()
@@ -66,12 +82,13 @@ class NativeAppLinkPromptFeature(
override fun stop() {
NativeAppLinkPromptNotifier.unregister(tabId, this)
mainHandler.removeCallbacksAndMessages(null)
// Dismissing on stop is not a user dismissal: the request stays pending and
// is re-presented on the next start().
dialog?.setOnDismissListener(null)
dialog?.dismiss()
dialog = null
shownRequestId = null
shownRequest = null
}
/**
@@ -91,13 +108,26 @@ class NativeAppLinkPromptFeature(
* 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
val shown = shownRequest ?: return
if (store.peek(shown.requestId) != null) return
mainHandler.removeCallbacks(expiryTick)
dialog?.setOnCancelListener(null)
dialog?.setOnDismissListener(null)
dialog?.dismiss()
dialog = null
shownRequestId = null
shownRequest = null
}
/**
* Expiry in the store is lazy, so nothing would take a dialog down when its request lapses —
* its buttons would consume nothing. Retire it on its own deadline instead.
*/
private fun scheduleExpiryTick(request: PendingAppLinkRequest) {
mainHandler.removeCallbacks(expiryTick)
mainHandler.postDelayed(
expiryTick,
store.expiresInMs(request).coerceAtLeast(MIN_EXPIRY_TICK_MS),
)
}
private fun showNext() {
@@ -126,14 +156,8 @@ 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),
)
shownRequest = request
scheduleExpiryTick(request)
}
private fun resolveOpen(request: PendingAppLinkRequest) {
@@ -168,8 +192,9 @@ class NativeAppLinkPromptFeature(
}
private fun afterResolve() {
mainHandler.removeCallbacks(expiryTick)
dialog = null
shownRequestId = null
shownRequest = null
showNext()
}
@@ -65,17 +65,17 @@ class WebLibreAppLinksInterceptor(
// round trip. Gated on the policy so turning the carve-out off restores the plain §2.4
// eligibility rules rather than only skipping the launch below.
val authExceptionsAllowed = policy.authExceptionsEnabled && isPossibleAuthentication(session)
val isSameDomainNavigation = isSameDomain(lastUri, uri)
// Step 2 — navigation eligibility. Any hit lets the engine proceed normally.
if (!isEligible(
uri,
lastUri,
uriScheme,
engineSupportsScheme,
hasUserGesture,
isRedirect,
isDirectNavigation,
isSubframeRequest,
isSameDomainNavigation,
authExceptionsAllowed,
)
) {
@@ -109,15 +109,35 @@ class WebLibreAppLinksInterceptor(
// to the app. AC declines here too — its package comes from the bound component, which is
// only set for an unambiguous handler.
val authTargetPackage = if (resolved.isAmbiguous) null else resolved.packageName
val isAuthCallback = isAuthenticationCallback(session, authTargetPackage)
// Re-apply the same-domain guard now that the target is known (AC parity: `AppLinksInterceptor`
// re-checks after resolution for exactly this reason). Eligibility waived it on the mere
// possibility of a sign-in round trip — the tab was opened by *some* app — which would
// otherwise re-classify every ordinary in-site navigation for the whole life of a Custom Tab
// and, under the default `ask` mode, prompt on each one. Only a navigation that really does
// target the calling app keeps the waiver.
if (engineSupportsScheme && isSameDomainNavigation && authExceptionsAllowed && !isAuthCallback) {
return null
}
val matchingRule = effectiveRules[resolved.scopeKey]
val fingerprint = targetFingerprint(uri, resolved)
val suppressionHit = session != null && pendingStore.isSuppressed(session.id, fingerprint)
// §2.4 authentication carve-out (AC parity): a tab opened *by* the app the navigation
// targets is a sign-in round trip rather than a general app link, so it returns to its
// caller even under `never`. The forced-prompt contexts still win — a protected container,
// a private tab or a wallet scheme must not leak out silently, so those fall through to the
// classifier, which prompts for them regardless of mode (§2.4 step 4).
// classifier, which prompts for them regardless of mode (§2.4 step 4). An explicit
// `neverOpen` rule for this scope and a live suppression are the user having answered this
// exact question already (classifier steps 56); the carve-out is about a mode the user set
// for links in general, not a licence to override a specific "no".
if (authExceptionsAllowed &&
isAuthenticationCallback(session, authTargetPackage) &&
!isProtectedNavigation && !isPrivateNavigation && !isWalletNavigation
isAuthCallback &&
!isProtectedNavigation && !isPrivateNavigation && !isWalletNavigation &&
matchingRule?.decision != AppLinkRuleDecision.NEVER_OPEN &&
!suppressionHit
) {
val result = runtime.launcher.launch(
uri,
@@ -141,9 +161,8 @@ class WebLibreAppLinksInterceptor(
isPrivate = isPrivateNavigation,
isWallet = isWalletNavigation,
missingSession = session == null,
suppressionHit = session != null &&
pendingStore.isSuppressed(session.id, targetFingerprint(uri, resolved)),
matchingRule = effectiveRules[resolved.scopeKey],
suppressionHit = suppressionHit,
matchingRule = matchingRule,
globalMode = effectiveMode,
marketplaceFallbackEnabled = policy.marketplaceFallbackEnabled,
)
@@ -346,14 +365,13 @@ class WebLibreAppLinksInterceptor(
// ---- Eligibility (§2.4 step 2) ----
private fun isEligible(
uri: String,
lastUri: String?,
uriScheme: String?,
engineSupportsScheme: Boolean,
hasUserGesture: Boolean,
isRedirect: Boolean,
isDirectNavigation: Boolean,
isSubframeRequest: Boolean,
isSameDomainNavigation: Boolean,
authExceptionsAllowed: Boolean,
): Boolean {
if (uriScheme == null) return false
@@ -366,8 +384,10 @@ class WebLibreAppLinksInterceptor(
if (engineSupportsScheme && !isIntentionalNavigation) return false
// Same-domain engine-supported navigation continues in the browser (AC subdomain stripping),
// unless this tab could be hosting an authentication round trip whose callback is an http
// app link on the same site.
if (engineSupportsScheme && isSameDomain(lastUri, uri) && !authExceptionsAllowed) return false
// app link on the same site. That "could be" is provisional — it only knows the tab was
// opened by *some* app, not that this navigation targets it — so the guard is re-applied in
// [onLoadRequest] once resolution reveals the actual target package.
if (engineSupportsScheme && isSameDomainNavigation && !authExceptionsAllowed) return false
// Always-denied schemes never resolve or launch externally.
if (AppLinkSchemes.isAlwaysDenied(uriScheme)) return false
return true