refactor and cleanup
This commit is contained in:
+39
-14
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
+32
-12
@@ -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 5–6); 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
|
||||
|
||||
Reference in New Issue
Block a user