app links initial

This commit is contained in:
Fabian Freund
2026-07-30 03:58:46 +02:00
parent 1b0c2b0d06
commit 4bc267969b
97 changed files with 9138 additions and 1054 deletions
@@ -114,7 +114,6 @@ dependencies {
implementation "org.mozilla.components:browser-icons:$mozillaComponentsVersion"
implementation "org.mozilla.components:browser-thumbnails:$mozillaComponentsVersion"
implementation "org.mozilla.components:feature-addons:$mozillaComponentsVersion"
implementation "org.mozilla.components:feature-app-links:$mozillaComponentsVersion"
implementation "org.mozilla.components:feature-accounts:$mozillaComponentsVersion"
implementation "org.mozilla.components:feature-accounts-push:$mozillaComponentsVersion"
implementation "org.mozilla.components:feature-awesomebar:$mozillaComponentsVersion"
@@ -25,7 +25,6 @@ import androidx.core.content.edit
import androidx.fragment.app.Fragment
import androidx.preference.PreferenceManager
import eu.weblibre.flutter_mozilla_components.addons.WebExtensionPromptFeature
import eu.weblibre.flutter_mozilla_components.activities.ExternalAppBrowserActivity
import eu.weblibre.flutter_mozilla_components.databinding.FragmentBrowserBinding
import eu.weblibre.flutter_mozilla_components.ext.EventSequence
import eu.weblibre.flutter_mozilla_components.ext.getPreferenceKey
@@ -37,6 +36,9 @@ import eu.weblibre.flutter_mozilla_components.feature.ReadabilityExtractFeature
import eu.weblibre.flutter_mozilla_components.feature.WebExtensionToolbarFeature
import eu.weblibre.flutter_mozilla_components.integration.ReaderViewIntegration
import eu.weblibre.flutter_mozilla_components.services.DownloadService
import eu.weblibre.flutter_mozilla_components.applinks.AppLinkRuntime
import eu.weblibre.flutter_mozilla_components.applinks.NativeAppLinkPromptFeature
import eu.weblibre.flutter_mozilla_components.applinks.PendingAppLinkStores
import io.flutter.Log
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.distinctUntilChangedBy
@@ -49,7 +51,6 @@ import mozilla.components.browser.thumbnails.BrowserThumbnails
import mozilla.components.concept.engine.EngineView
import mozilla.components.feature.accounts.FxaCapability
import mozilla.components.feature.accounts.FxaWebChannelFeature
import mozilla.components.feature.app.links.AppLinksFeature
import mozilla.components.feature.downloads.DownloadsFeature
import mozilla.components.feature.downloads.manager.FetchDownloadManager
import mozilla.components.feature.downloads.temporary.CopyDownloadFeature
@@ -89,7 +90,8 @@ abstract class BaseBrowserFragment : Fragment(), UserInteractionHandler, Activit
private val shareResourceFeature = ViewBoundFeatureWrapper<ShareResourceFeature>()
private val copyDownloadFeature = ViewBoundFeatureWrapper<CopyDownloadFeature>()
private val downloadsFeature = ViewBoundFeatureWrapper<DownloadsFeature>()
private val appLinksFeature = ViewBoundFeatureWrapper<AppLinksFeature>()
// Native prompt for Custom Tab sessions with no Flutter engine.
private val nativeAppLinkPromptFeature = ViewBoundFeatureWrapper<NativeAppLinkPromptFeature>()
private val promptFeature = ViewBoundFeatureWrapper<PromptFeature>()
private val webExtensionPromptFeature = ViewBoundFeatureWrapper<WebExtensionPromptFeature>()
private val sitePermissionsFeature = ViewBoundFeatureWrapper<SitePermissionsFeature>()
@@ -362,42 +364,24 @@ abstract class BaseBrowserFragment : Fragment(), UserInteractionHandler, Activit
view = view,
)
appLinksFeature.set(
feature = AppLinksFeature(
context = profileContext,
store = components.core.store,
sessionId = sessionId,
fragmentManager = parentFragmentManager,
loadUrlUseCase = components.useCases.sessionUseCases.loadUrl,
launchInApp = {
GlobalComponents.shouldOpenLinksInApp(
requireActivity() is ExternalAppBrowserActivity
)
},
shouldPrompt = {
GlobalComponents.shouldPromptOpenLinksInApp(
requireActivity() is ExternalAppBrowserActivity
)
},
alwaysOpenCheckboxAction = {
GlobalComponents.engineSettingsApi?.setAppLinksMode(
eu.weblibre.flutter_mozilla_components.pigeons.AppLinksMode.ALWAYS
)
},
failedToLaunchAction = { fallbackUrl ->
fallbackUrl?.let {
val appLinksUseCases = components.useCases.appLinksUseCases
val getRedirect = appLinksUseCases.appLinkRedirect
val redirect = getRedirect.invoke(fallbackUrl)
redirect.appIntent?.flags =
Intent.FLAG_ACTIVITY_NEW_DOCUMENT or Intent.FLAG_ACTIVITY_MULTIPLE_TASK
appLinksUseCases.openAppLink.invoke(redirect.appIntent)
}
},
),
owner = this,
view = view,
)
// App-link prompting: browser tabs are prompted by Flutter's AppLinkPromptHost, so only
// native Custom Tab sessions (no Flutter engine) install a native prompt feature here.
val nativeTabId = sessionId
if (this is ExternalAppBrowserFragment && nativeTabId != null) {
nativeAppLinkPromptFeature.set(
feature = NativeAppLinkPromptFeature(
context = profileContext,
tabId = nativeTabId,
store = PendingAppLinkStores.forProfile(
components.profileApplicationContext.relativePath,
),
launcher = AppLinkRuntime.get(profileContext).launcher,
sessionUseCases = components.useCases.sessionUseCases,
),
owner = this,
view = view,
)
}
promptFeature.set(
feature = PromptFeature(
@@ -47,6 +47,9 @@ class FlutterMozillaComponentsPlugin: FlutterPlugin, ActivityAware {
GeckoPushApi.setUp(binding.binaryMessenger, null)
browserApi.disposePushApi()
GlobalComponents.historyEvents = null
// The availability event is optimisation-only; once Flutter detaches, the surface
// re-queries pending prompts on its next attach/resume, so dropping the sink is safe.
GlobalComponents.appLinkEvents = null
// The UnifiedPush receiver outlives the Flutter engine; without this it would keep dispatching
// onto a dead messenger. Failures are still retained on Push.lastError.
GlobalComponents.pushEvents = null
@@ -16,6 +16,7 @@ import eu.weblibre.flutter_mozilla_components.pigeons.BounceTrackingProtectionMo
import eu.weblibre.flutter_mozilla_components.pigeons.ContentBlocking
import eu.weblibre.flutter_mozilla_components.pigeons.GeckoAddonEvents
import eu.weblibre.flutter_mozilla_components.pigeons.GeckoEngineSettings
import eu.weblibre.flutter_mozilla_components.pigeons.GeckoAppLinkEvents
import eu.weblibre.flutter_mozilla_components.pigeons.GeckoGestureEvents
import eu.weblibre.flutter_mozilla_components.pigeons.GeckoHistoryEvents
import eu.weblibre.flutter_mozilla_components.pigeons.GeckoPushEvents
@@ -150,6 +151,11 @@ object GlobalComponents {
// path), in which case failures are logged natively only.
var pushEvents: GeckoPushEvents? = null
// Native -> Dart availability signal for pending app-link prompts. Optimisation
// only (no buffering/replay): null when Flutter is detached, in which case the
// Flutter surface picks the prompt up on its next getPendingAppLinkPrompts query.
var appLinkEvents: GeckoAppLinkEvents? = null
// Gecko contextIds of containers with hard exclude-from-history enabled.
// Pushed from Dart; read by WebLibreHistoryDelegate to skip the Places
// write for visits resolved to one of these containers.
@@ -250,22 +256,6 @@ object GlobalComponents {
context?.stopService(Intent(context, PrivateTabsNotificationService::class.java))
}
fun shouldOpenLinksInApp(isExternalSession: Boolean = false): Boolean {
return when (engineSettingsApi!!.getAppLinksMode()) {
eu.weblibre.flutter_mozilla_components.pigeons.AppLinksMode.ALWAYS -> true
eu.weblibre.flutter_mozilla_components.pigeons.AppLinksMode.ASK -> true
eu.weblibre.flutter_mozilla_components.pigeons.AppLinksMode.NEVER -> isExternalSession
}
}
fun shouldPromptOpenLinksInApp(isExternalSession: Boolean = false): Boolean {
return when (engineSettingsApi!!.getAppLinksMode()) {
eu.weblibre.flutter_mozilla_components.pigeons.AppLinksMode.ALWAYS -> false
eu.weblibre.flutter_mozilla_components.pigeons.AppLinksMode.ASK -> true
eu.weblibre.flutter_mozilla_components.pigeons.AppLinksMode.NEVER -> isExternalSession
}
}
@DelicateCoroutinesApi
private fun restoreBrowserState(
newComponents: Components,
@@ -7,64 +7,222 @@
package eu.weblibre.flutter_mozilla_components.api
import android.content.Context
import android.content.Intent
import eu.weblibre.flutter_mozilla_components.Components
import eu.weblibre.flutter_mozilla_components.GlobalComponents
import eu.weblibre.flutter_mozilla_components.applinks.AppLinkLaunchMode
import eu.weblibre.flutter_mozilla_components.applinks.AppLinkLaunchResult
import eu.weblibre.flutter_mozilla_components.applinks.AppLinkPolicyStores
import eu.weblibre.flutter_mozilla_components.applinks.AppLinkRuntime
import eu.weblibre.flutter_mozilla_components.applinks.PendingAppLinkRequest
import eu.weblibre.flutter_mozilla_components.applinks.PendingAppLinkStore
import eu.weblibre.flutter_mozilla_components.applinks.PendingAppLinkStores
import eu.weblibre.flutter_mozilla_components.applinks.toAppLinkPolicy
import eu.weblibre.flutter_mozilla_components.pigeons.AppLinkDecision
import eu.weblibre.flutter_mozilla_components.pigeons.AppLinkPolicySnapshot
import eu.weblibre.flutter_mozilla_components.pigeons.AppLinkPromptOwner
import eu.weblibre.flutter_mozilla_components.pigeons.AppLinkPromptRequest
import eu.weblibre.flutter_mozilla_components.pigeons.AppLinkResolutionResult
import eu.weblibre.flutter_mozilla_components.pigeons.AppLinkTarget
import eu.weblibre.flutter_mozilla_components.pigeons.GeckoAppLinksApi
import mozilla.components.browser.state.selector.findTabOrCustomTab
import mozilla.components.support.base.log.logger.Logger
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
/**
* Implementation of GeckoAppLinksApi that detects and launches external applications
* that can handle URLs.
*
* This uses Mozilla Android Components' AppLinksUseCases to properly detect if a native
* app is available to handle a URL, matching the behavior in Firefox/Fenix.
* WebLibre-owned implementation of [GeckoAppLinksApi] backed by [ExternalAppResolver] and
* [AppLinkLauncher] (APP_LINKS_OWN_IMPLEMENTATION_PLAN.md Phase 1). Policy lives in Dart; this
* surface owns PackageManager resolution and Intent launch for the manual entry points.
*/
class GeckoAppLinksApiImpl(
private val context: Context
private val context: Context,
) : GeckoAppLinksApi {
companion object {
private val coroutineScope = CoroutineScope(Dispatchers.Default + SupervisorJob())
private val logger = Logger("GeckoAppLinksApi")
}
private val components by lazy {
requireNotNull(GlobalComponents.components) { "Components not initialized" }
}
// Shared process-level resolver/launcher (§2.7): the 2 s auto-launch cooldown and 30 s
// resolution cache are observed across the interceptor tail, the manual entry points, and
// prompt resolution alike.
private val resolver get() = AppLinkRuntime.get(context).resolver
private val launcher get() = AppLinkRuntime.get(context).launcher
override fun hasExternalApp(url: String, callback: (Result<Boolean>) -> Unit) {
override fun setAppLinkPolicy(
snapshot: AppLinkPolicySnapshot,
callback: (Result<Unit>) -> Unit,
) {
coroutineScope.launch {
try {
val redirect = components.useCases.appLinksUseCases.appLinkRedirect(url)
callback(Result.success(redirect.hasExternalApp()))
// A profile must be bound before policy can be applied. The Dart
// replicator retries after initialisation (§2.8, §2.10).
val profileContext = GlobalComponents.components?.profileApplicationContext
?: throw IllegalStateException("No profile bound for app-link policy")
val store = AppLinkPolicyStores.forProfile(profileContext)
val persisted = store.setPolicy(snapshot.toAppLinkPolicy())
if (persisted) {
callback(Result.success(Unit))
} else {
callback(Result.failure(IllegalStateException("Failed to persist app-link policy")))
}
} catch (e: Exception) {
callback(Result.success(false))
callback(Result.failure(e))
}
}
}
override fun openAppLink(url: String, callback: (Result<Boolean>) -> Unit) {
override fun resolveAppLink(
url: String,
includeHttpAppLinks: Boolean,
callback: (Result<AppLinkTarget?>) -> Unit,
) {
coroutineScope.launch {
try {
val redirect = components.useCases.appLinksUseCases.appLinkRedirect(url)
if (!redirect.hasExternalApp()) {
callback(Result.success(false))
val resolved = resolver.resolve(url, includeHttpAppLinks = includeHttpAppLinks)
if (!resolved.hasExternalApp) {
callback(Result.success(null))
return@launch
}
// Use NEW_DOCUMENT + MULTIPLE_TASK so the target app opens in its own
// task and doesn't get absorbed into WebLibre's recents entry.
// This matches Fenix's ShareController behaviour.
redirect.appIntent?.flags =
Intent.FLAG_ACTIVITY_NEW_DOCUMENT or Intent.FLAG_ACTIVITY_MULTIPLE_TASK
components.useCases.appLinksUseCases.openAppLink.invoke(redirect.appIntent)
callback(Result.success(true))
callback(
Result.success(
AppLinkTarget(
url = url,
appName = resolved.appName,
packageName = resolved.packageName,
fallbackUrl = resolved.fallbackUrl,
isMarketplace = false,
isAmbiguous = resolved.isAmbiguous,
engineSupportsScheme = resolved.engineSupportsScheme,
scopeKey = resolved.scopeKey,
),
),
)
} catch (e: Exception) {
// Uniform failure semantics (§2.8): callers cannot distinguish "nothing installed"
// from "resolution failed".
callback(Result.success(null))
}
}
}
override fun launchAppLink(url: String, callback: (Result<Boolean>) -> Unit) {
coroutineScope.launch {
try {
val result = launcher.launch(url, mode = AppLinkLaunchMode.MANUAL)
logger.info("launchAppLink($url) -> $result")
callback(Result.success(result == AppLinkLaunchResult.LAUNCHED))
} catch (e: Exception) {
logger.error("launchAppLink($url) failed", e)
callback(Result.success(false))
}
}
}
private fun pendingStoreFor(components: Components): PendingAppLinkStore {
return PendingAppLinkStores.forProfile(
components.profileApplicationContext.relativePath,
)
}
override fun getPendingAppLinkPrompts(
owner: AppLinkPromptOwner,
callback: (Result<List<AppLinkPromptRequest>>) -> Unit,
) {
coroutineScope.launch {
try {
val components = GlobalComponents.components
val list = components
?.let { pendingStoreFor(it).getPending(owner).map(PendingAppLinkRequest::toPigeon) }
?: emptyList()
callback(Result.success(list))
} catch (e: Exception) {
callback(Result.success(emptyList()))
}
}
}
override fun resolvePendingAppLink(
requestId: Long,
decision: AppLinkDecision,
callback: (Result<AppLinkResolutionResult>) -> Unit,
) {
coroutineScope.launch {
try {
val components = GlobalComponents.components
?: return@launch callback(Result.success(stale()))
val store = pendingStoreFor(components)
// Consume atomically; the store lock is released before any side effect.
val request = store.consume(requestId)
if (request == null) {
// The request was invalidated (navigation/tab close/expiry) before the user
// resolved it — the prompt shown was stale. No launch, no page change.
logger.info("resolvePendingAppLink($requestId, $decision) -> stale (no pending request)")
return@launch callback(Result.success(stale()))
}
// Never launch into a session that no longer exists.
val tabAlive = components.core.store.state
.findTabOrCustomTab(request.tabId) != null
if (!tabAlive) {
logger.info("resolvePendingAppLink($requestId) -> dead_session (${request.tabId})")
return@launch callback(
Result.success(AppLinkResolutionResult(false, false, "dead_session")),
)
}
val result = when (decision) {
AppLinkDecision.OPEN -> handleOpen(components, request)
AppLinkDecision.CANCEL, AppLinkDecision.DISMISS -> {
store.recordSuppression(request.tabId, request.targetFingerprint)
AppLinkResolutionResult(false, false, null)
}
}
callback(Result.success(result))
} catch (e: Exception) {
callback(Result.success(AppLinkResolutionResult(false, false, "launch_failed")))
}
}
}
private fun handleOpen(
components: Components,
request: PendingAppLinkRequest,
): AppLinkResolutionResult {
val mode = if (request.isMarketplace) {
AppLinkLaunchMode.MARKETPLACE
} else {
// Prompt-resolved opens are user gestures (bypass the cooldown).
AppLinkLaunchMode.MANUAL
}
// Honour the package captured when the prompt was created for a *named*
// (non-ambiguous) target, so a change in handlers before the user taps Open
// can't launch a different app (§2.5/§2.7). Ambiguous/chooser prompts store a
// null expectedPackage, so this stays null and the chooser still opens.
val result = launcher.launch(request.url, mode, expectedPackage = request.expectedPackage)
logger.info("resolvePendingAppLink open: launch(${request.url}, $mode) -> $result")
if (result == AppLinkLaunchResult.LAUNCHED) {
return AppLinkResolutionResult(true, false, null)
}
// Launch failed: load a validated fallback if present, else leave the page.
val fallback = request.fallbackUrl
if (fallback != null) {
// Guard the fallback load against immediately bouncing back out to an app
// (§2.7): a validated http(s) fallback can itself resolve to an external
// handler, which would re-prompt/auto-launch. The interceptor records the
// same for fallbacks it issues.
pendingStoreFor(components).recordFallbackReentry(fallback)
components.useCases.sessionUseCases.loadUrl(
url = fallback,
sessionId = request.tabId,
)
return AppLinkResolutionResult(false, true, "launch_failed")
}
return AppLinkResolutionResult(false, false, "launch_failed")
}
private fun stale() = AppLinkResolutionResult(false, false, "stale")
}
@@ -51,6 +51,7 @@ import eu.weblibre.flutter_mozilla_components.pigeons.GeckoPushApi
import eu.weblibre.flutter_mozilla_components.pigeons.GeckoPushEvents
import eu.weblibre.flutter_mozilla_components.pigeons.GeckoPwaApi
import eu.weblibre.flutter_mozilla_components.pigeons.GeckoSelectionActionController
import eu.weblibre.flutter_mozilla_components.pigeons.GeckoAppLinkEvents
import eu.weblibre.flutter_mozilla_components.pigeons.GeckoHistoryEvents
import eu.weblibre.flutter_mozilla_components.pigeons.GeckoSelectionActionEvents
import eu.weblibre.flutter_mozilla_components.pigeons.GeckoSessionApi
@@ -280,6 +281,10 @@ class GeckoBrowserApiImpl : GeckoBrowserApi {
GlobalComponents.historyEvents =
GeckoHistoryEvents(_flutterPluginBinding.binaryMessenger)
// Availability signal for pending app-link prompts (Flutter-owned prompts).
GlobalComponents.appLinkEvents =
GeckoAppLinkEvents(_flutterPluginBinding.binaryMessenger)
// Also set before GlobalComponents.setUp, which calls push.initialize() and can therefore
// surface a registration failure before this sink would otherwise exist.
GlobalComponents.pushEvents = GeckoPushEvents(_flutterPluginBinding.binaryMessenger)
@@ -7,13 +7,9 @@
package eu.weblibre.flutter_mozilla_components.api
import android.content.Context
import androidx.core.content.edit
import androidx.preference.PreferenceManager
import eu.weblibre.flutter_mozilla_components.ColorSchemePreference
import eu.weblibre.flutter_mozilla_components.GlobalComponents
import eu.weblibre.flutter_mozilla_components.R
import eu.weblibre.flutter_mozilla_components.feature.ReaderViewAppearanceFeature
import eu.weblibre.flutter_mozilla_components.pigeons.AppLinksMode
import eu.weblibre.flutter_mozilla_components.pigeons.BounceTrackingProtectionMode as PigeonBounceTrackingProtectionMode
import eu.weblibre.flutter_mozilla_components.pigeons.ColorScheme
import eu.weblibre.flutter_mozilla_components.pigeons.CookieBannerHandlingMode
@@ -436,35 +432,6 @@ class GeckoEngineSettingsApiImpl(
GlobalComponents.screenshotProtectionEnabled = enabled
}
override fun setAppLinksMode(mode: AppLinksMode) {
val context = components.profileApplicationContext
val prefKey = context.getString(R.string.pref_key_open_links_in_apps)
val modeValue = when (mode) {
AppLinksMode.ALWAYS -> context.getString(R.string.pref_key_open_links_in_apps_always)
AppLinksMode.ASK -> context.getString(R.string.pref_key_open_links_in_apps_ask)
AppLinksMode.NEVER -> context.getString(R.string.pref_key_open_links_in_apps_never)
}
PreferenceManager.getDefaultSharedPreferences(context).edit {
putString(prefKey, modeValue)
}
}
override fun getAppLinksMode(): AppLinksMode {
val context = components.profileApplicationContext
val prefKey = context.getString(R.string.pref_key_open_links_in_apps)
val defaultValue = context.getString(R.string.pref_key_open_links_in_apps_ask)
val modeValue = PreferenceManager.getDefaultSharedPreferences(context)
.getString(prefKey, defaultValue) ?: defaultValue
return when (modeValue) {
context.getString(R.string.pref_key_open_links_in_apps_always) -> AppLinksMode.ALWAYS
context.getString(R.string.pref_key_open_links_in_apps_ask) -> AppLinksMode.ASK
context.getString(R.string.pref_key_open_links_in_apps_never) -> AppLinksMode.NEVER
else -> AppLinksMode.ASK
}
}
override fun setUseExternalDownloadManager(enabled: Boolean) {
GlobalComponents.useExternalDownloadManager = enabled
}
@@ -0,0 +1,225 @@
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
package eu.weblibre.flutter_mozilla_components.applinks
/**
* Global app-links behaviour, Kotlin-native mirror of the Pigeon `AppLinksMode` transport enum.
*/
enum class AppLinkMode {
ALWAYS,
ASK,
NEVER,
}
enum class AppLinkRuleDecision {
ALWAYS_OPEN,
NEVER_OPEN,
}
/** A remembered per-scope rule (Kotlin-native mirror of the persisted/Pigeon rule model). */
data class AppLinkRule(
val decision: AppLinkRuleDecision,
val scope: String,
val packageName: String?,
)
/** Non-source-tab protection pattern (§2.3), matched against the navigation target. */
data class ProtectedTargetPattern(
val scheme: String,
val hostOrSuffix: String,
val includeSubdomains: Boolean,
val port: Int?,
)
/**
* A container's self-contained app-link policy (§ container isolation). Present only for containers
* with "isolated app link settings" enabled; when a navigation's source contextId has an entry, its
* [globalMode] + [rules] fully *replace* the global ones for that navigation (no layering).
*/
data class ContextAppLinkPolicy(
val globalMode: AppLinkMode,
val rules: Map<String, AppLinkRule>,
)
/**
* The complete policy the classifier reads. Populated from the replicated snapshot (§2.8); the
* classifier itself holds no Android types and no I/O.
*/
data class AppLinkPolicy(
val globalMode: AppLinkMode,
val rules: Map<String, AppLinkRule>,
val marketplaceFallbackEnabled: Boolean,
val protectGeneralContext: Boolean,
val protectedContextIds: Set<String>,
val strictContextIds: Set<String>,
val protectedTargetPatterns: List<ProtectedTargetPattern>,
/**
* Per-container overrides keyed by contextId; only isolated containers appear. A navigation whose
* source contextId is a key uses the entry's mode + rules instead of the global ones (replace).
*/
val contextOverrides: Map<String, ContextAppLinkPolicy> = emptyMap(),
) {
companion object {
val SAFE_DEFAULT = AppLinkPolicy(
globalMode = AppLinkMode.ASK,
rules = emptyMap(),
marketplaceFallbackEnabled = false,
protectGeneralContext = false,
protectedContextIds = emptySet(),
strictContextIds = emptySet(),
protectedTargetPatterns = emptyList(),
contextOverrides = emptyMap(),
)
}
}
/** The prompt classes of §2.2. */
enum class AppLinkPromptKind {
/** http(s), non-modal — the page is allowed to load while the banner is up. */
BANNER,
/** Unsupported scheme, modal — the navigation is genuinely stalled and there is no page. */
MODAL,
}
/**
* A pure decision the interceptor executes. The classifier never performs side effects.
*/
sealed interface AppLinkDecision {
/** Return `null` from the interceptor — the engine proceeds normally. */
data object AllowEngine : AppLinkDecision
/** Deny the load and leave the current page unchanged. */
data object DenyKeepPage : AppLinkDecision
/** Return `InterceptionResponse.Url(url)` — a validated http(s) fallback. */
data class LoadFallback(val url: String) : AppLinkDecision
/**
* Automatic launch (global-`always` or a remembered `alwaysOpen` rule). The interceptor calls
* the launcher and maps its outcome per §2.7's launch-failure branches.
*/
data class AutoLaunch(val expectedPackage: String?) : AppLinkDecision
/**
* Create a pending prompt request. [kind] chooses banner vs modal; the page is allowed to load
* for a banner and denied (stalled) for a modal.
*/
data class Prompt(
val kind: AppLinkPromptKind,
val canRemember: Boolean,
val isMarketplace: Boolean,
) : AppLinkDecision
}
/** Everything the classifier needs, all computed by the caller so the classifier stays pure. */
data class ClassifierInput(
val resolved: ResolvedAppLink,
val isProtected: Boolean,
val isPrivate: Boolean,
val isWallet: Boolean,
val missingSession: Boolean,
val suppressionHit: Boolean,
val matchingRule: AppLinkRule?,
val globalMode: AppLinkMode,
val marketplaceFallbackEnabled: Boolean,
)
/**
* Pure §2.4 policy precedence over the §2.2 URL-class table. Structural guards (§2.4 step 1) and
* navigation eligibility (step 2) are handled by the interceptor before this is consulted.
*/
object AppLinkClassifier {
fun classify(input: ClassifierInput): AppLinkDecision {
val resolved = input.resolved
// Step 3 — no external app resolves.
if (!resolved.hasExternalApp) {
resolved.fallbackUrl?.let { return AppLinkDecision.LoadFallback(it) }
// Step 8 — marketplace, only when enabled, mode != never, and no validated fallback.
if (input.marketplaceFallbackEnabled &&
input.globalMode != AppLinkMode.NEVER &&
resolved.marketplaceIntent != null
) {
return AppLinkDecision.Prompt(
kind = AppLinkPromptKind.MODAL,
canRemember = false,
isMarketplace = true,
)
}
return if (resolved.engineSupportsScheme) {
AppLinkDecision.AllowEngine
} else {
AppLinkDecision.DenyKeepPage
}
}
// Step 4 — missing session cannot host a prompt: fall back to the safe non-launch behaviour.
if (input.missingSession) {
return safeNonLaunch(resolved)
}
// Step 4 — forced-prompt contexts (protected/private/wallet), ignoring matching rules.
if (input.isProtected || input.isPrivate || input.isWallet) {
return promptFor(resolved, canRemember = false)
}
// Step 5 — suppression hit: never launch, never prompt.
if (input.suppressionHit) {
return safeNonLaunch(resolved)
}
// Step 6 — a matching remembered rule for this scope.
input.matchingRule?.let { rule ->
when (rule.decision) {
AppLinkRuleDecision.ALWAYS_OPEN ->
return AppLinkDecision.AutoLaunch(expectedPackage = rule.packageName)
AppLinkRuleDecision.NEVER_OPEN ->
return neverBehaviour(resolved)
}
}
// Step 7 — global mode, applied uniformly (including Custom Tabs).
return when (input.globalMode) {
AppLinkMode.ALWAYS -> AppLinkDecision.AutoLaunch(expectedPackage = null)
AppLinkMode.ASK -> promptFor(resolved, canRemember = canRemember(resolved))
AppLinkMode.NEVER -> neverBehaviour(resolved)
}
}
/** The `never` row of §2.2: allow an engine-supported page; otherwise deny (+ validated fallback). */
private fun neverBehaviour(resolved: ResolvedAppLink): AppLinkDecision {
return if (resolved.engineSupportsScheme) {
AppLinkDecision.AllowEngine
} else {
resolved.fallbackUrl?.let { AppLinkDecision.LoadFallback(it) }
?: AppLinkDecision.DenyKeepPage
}
}
/** Suppression/missing-session: allow an engine-supported URL; else deny, using only a fallback. */
private fun safeNonLaunch(resolved: ResolvedAppLink): AppLinkDecision {
return if (resolved.engineSupportsScheme) {
AppLinkDecision.AllowEngine
} else {
resolved.fallbackUrl?.let { AppLinkDecision.LoadFallback(it) }
?: AppLinkDecision.DenyKeepPage
}
}
private fun promptFor(resolved: ResolvedAppLink, canRemember: Boolean): AppLinkDecision {
val kind = if (resolved.engineSupportsScheme) {
AppLinkPromptKind.BANNER
} else {
AppLinkPromptKind.MODAL
}
return AppLinkDecision.Prompt(kind = kind, canRemember = canRemember, isMarketplace = false)
}
/** Ambiguous resolution can never be remembered (§2.5). */
private fun canRemember(resolved: ResolvedAppLink): Boolean = !resolved.isAmbiguous
}
@@ -0,0 +1,100 @@
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
package eu.weblibre.flutter_mozilla_components.applinks
import java.net.IDN
import java.net.InetAddress
import java.util.Locale
/**
* Native-owned host normalisation (see APP_LINKS_OWN_IMPLEMENTATION_PLAN.md §2.5).
*
* The resolver returns the canonical scope key used by prompts and rules; Dart persists
* it opaquely and never reconstructs it. The same helper normalises hosts when matching
* `protectedTargetPatterns`.
*/
object AppLinkHostNormalizer {
const val HOST_SCOPE_PREFIX = "host:"
const val PACKAGE_SCOPE_PREFIX = "pkg:"
/**
* Canonicalise a host:
* - [Locale.ROOT] lowercase,
* - strip a single trailing dot,
* - `IDN.toASCII` for non-ASCII hosts,
* - reject empty/invalid hosts and IPv6 zone IDs,
* - canonicalise IP literals.
*
* @return the canonical host, or `null` if the host is empty or invalid.
*/
fun normalizeHost(rawHost: String?): String? {
if (rawHost.isNullOrEmpty()) return null
// Reject IPv6 zone identifiers (e.g. fe80::1%eth0) — the zone is host-local
// and must never participate in a cross-navigation scope key.
if (rawHost.contains('%')) return null
var host = rawHost.trim()
if (host.isEmpty()) return null
// Strip a single trailing dot (fully-qualified form).
if (host.endsWith(".")) {
host = host.dropLast(1)
}
if (host.isEmpty()) return null
// IPv6 literal in brackets: canonicalise the address inside.
if (host.startsWith("[") && host.endsWith("]")) {
val inner = host.substring(1, host.length - 1)
if (inner.contains('%')) return null
return canonicalizeIpLiteral(inner)?.let { "[$it]" } ?: return null
}
// Try to canonicalise as an IP literal first (IPv4 / bare IPv6).
canonicalizeIpLiteral(host)?.let { return it }
val lowered = host.lowercase(Locale.ROOT)
return try {
val ascii = IDN.toASCII(lowered, IDN.ALLOW_UNASSIGNED)
if (ascii.isEmpty()) null else ascii.lowercase(Locale.ROOT)
} catch (e: IllegalArgumentException) {
null
}
}
/**
* Canonicalise an IP literal (numeric address only). Returns `null` when [value] is not a
* numeric IP literal, so callers can fall through to hostname handling.
*/
private fun canonicalizeIpLiteral(value: String): String? {
if (value.isEmpty()) return null
// Only treat clearly-numeric forms as IP literals; a real hostname must go through IDN.
val looksNumeric = value.all { it.isDigit() || it == '.' } ||
(value.contains(':') && value.all { it.isDigit() || it == ':' || it in 'a'..'f' || it in 'A'..'F' })
if (!looksNumeric) return null
return try {
val address = InetAddress.getByName(value)
address.hostAddress?.lowercase(Locale.ROOT)
} catch (e: Exception) {
null
}
}
/** Build the canonical scope key for a host (`host:youtube.com`). */
fun hostScopeKey(rawHost: String?): String? {
val host = normalizeHost(rawHost) ?: return null
return HOST_SCOPE_PREFIX + host
}
/** Build the canonical scope key for a package (`pkg:us.zoom.videomeetings`). */
fun packageScopeKey(packageName: String?): String? {
if (packageName.isNullOrEmpty()) return null
return PACKAGE_SCOPE_PREFIX + packageName
}
}
@@ -0,0 +1,128 @@
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
package eu.weblibre.flutter_mozilla_components.applinks
import android.content.ActivityNotFoundException
import android.content.Intent
import mozilla.components.support.base.log.logger.Logger
/**
* Distinct launch modes, each with an exact flag set (§6):
* - [MANUAL]: user-driven "Open in <App>" — preserves the `NEW_DOCUMENT | MULTIPLE_TASK` task
* behaviour so the app opens in its own recents entry.
* - [AUTOMATIC]: global-`always` or a remembered `alwaysOpen` rule — `NEW_TASK`, subject to the
* 2 s same-package cooldown loop-breaker (§2.4).
* - [MARKETPLACE]: install-app fallback — `NEW_TASK | CLEAR_TASK`.
*/
enum class AppLinkLaunchMode {
MANUAL,
AUTOMATIC,
MARKETPLACE,
}
enum class AppLinkLaunchResult {
LAUNCHED,
NO_APP,
COOLDOWN,
PACKAGE_MISMATCH,
FAILED,
}
/**
* Launches external apps. Every launch re-resolves immediately first (no cache) and verifies the
* expected package before `startActivity` (§2.7). Automatic launches honour a 2 s same-package
* cooldown to break app→browser→app ping-pong loops (§2.4); manual and prompt-resolved opens are
* user gestures that bypass the check but still record it.
*/
class AppLinkLauncher(
private val resolver: ExternalAppResolver,
private val startActivity: (Intent) -> Unit,
private val clock: MonotonicClock = MonotonicClock.SYSTEM,
private val cooldownMs: Long = APP_LINKS_DO_NOT_INTERCEPT_INTERVAL,
) {
private val logger = Logger("AppLinkLauncher")
@Volatile
private var lastLaunch: Pair<String?, Long> = Pair(null, 0L)
/**
* Re-resolve [url] and launch it in the appropriate external app.
*
* @param expectedPackage when non-null (remembered/manual rebind paths), the freshly resolved
* package must equal it or the launch is refused with [AppLinkLaunchResult.PACKAGE_MISMATCH].
*/
@Synchronized
fun launch(
url: String,
mode: AppLinkLaunchMode,
expectedPackage: String? = null,
): AppLinkLaunchResult {
val resolved = resolver.resolve(url, includeHttpAppLinks = true, useCache = false)
val intent: Intent = when (mode) {
AppLinkLaunchMode.MARKETPLACE -> resolved.marketplaceIntent ?: return AppLinkLaunchResult.NO_APP
else -> {
if (!resolved.hasExternalApp || resolved.appIntent == null) {
return AppLinkLaunchResult.NO_APP
}
if (expectedPackage != null && resolved.packageName != expectedPackage) {
return AppLinkLaunchResult.PACKAGE_MISMATCH
}
resolved.appIntent
}
}
val targetPackage = when (mode) {
AppLinkLaunchMode.MARKETPLACE -> intent.`package`
else -> resolved.packageName
}
if (mode == AppLinkLaunchMode.AUTOMATIC) {
val (lastPackage, lastTs) = lastLaunch
if (lastPackage != null && lastPackage == targetPackage &&
clock.elapsedRealtime() < lastTs + cooldownMs
) {
return AppLinkLaunchResult.COOLDOWN
}
}
applyLaunchFlags(intent, mode)
return try {
startActivity(intent)
lastLaunch = Pair(targetPackage, clock.elapsedRealtime())
AppLinkLaunchResult.LAUNCHED
} catch (e: ActivityNotFoundException) {
logger.error("failed to start external app activity", e)
AppLinkLaunchResult.FAILED
} catch (e: SecurityException) {
logger.error("not permitted to start external app activity", e)
AppLinkLaunchResult.FAILED
}
}
private fun applyLaunchFlags(intent: Intent, mode: AppLinkLaunchMode) {
intent.flags = when (mode) {
// NEW_DOCUMENT | MULTIPLE_TASK gives the app its own recents entry; NEW_TASK is
// mandatory because every launch path now dispatches through the process-level
// application context (AppLinkRuntime), and startActivity() from a non-Activity
// context requires it.
AppLinkLaunchMode.MANUAL ->
Intent.FLAG_ACTIVITY_NEW_DOCUMENT or
Intent.FLAG_ACTIVITY_MULTIPLE_TASK or
Intent.FLAG_ACTIVITY_NEW_TASK
AppLinkLaunchMode.AUTOMATIC ->
Intent.FLAG_ACTIVITY_NEW_TASK
AppLinkLaunchMode.MARKETPLACE ->
Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
}
}
companion object {
const val APP_LINKS_DO_NOT_INTERCEPT_INTERVAL = 2000L
}
}
@@ -0,0 +1,53 @@
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
package eu.weblibre.flutter_mozilla_components.applinks
import eu.weblibre.flutter_mozilla_components.pigeons.AppLinkPolicySnapshot
import eu.weblibre.flutter_mozilla_components.pigeons.AppLinksMode as PigeonAppLinksMode
import eu.weblibre.flutter_mozilla_components.pigeons.NativeAppLinkRule
import eu.weblibre.flutter_mozilla_components.pigeons.NativeAppLinkRuleDecision
/** Map the replicated Pigeon snapshot to the Kotlin-native classifier policy (§2.8). */
fun AppLinkPolicySnapshot.toAppLinkPolicy(): AppLinkPolicy {
return AppLinkPolicy(
globalMode = globalMode.toAppLinkMode(),
rules = rules.mapValues { (_, rule) -> rule.toAppLinkRule() },
marketplaceFallbackEnabled = marketplaceFallbackEnabled,
protectGeneralContext = protectGeneralContext,
protectedContextIds = protectedContextIds.toSet(),
strictContextIds = strictContextIds.toSet(),
protectedTargetPatterns = protectedTargetPatterns.map { pattern ->
ProtectedTargetPattern(
scheme = pattern.scheme,
hostOrSuffix = pattern.hostOrSuffix,
includeSubdomains = pattern.includeSubdomains,
port = pattern.port?.toInt(),
)
},
contextOverrides = contextOverrides.mapValues { (_, override) ->
ContextAppLinkPolicy(
globalMode = override.mode.toAppLinkMode(),
rules = override.rules.mapValues { (_, rule) -> rule.toAppLinkRule() },
)
},
)
}
private fun PigeonAppLinksMode.toAppLinkMode(): AppLinkMode = when (this) {
PigeonAppLinksMode.ALWAYS -> AppLinkMode.ALWAYS
PigeonAppLinksMode.ASK -> AppLinkMode.ASK
PigeonAppLinksMode.NEVER -> AppLinkMode.NEVER
}
private fun NativeAppLinkRule.toAppLinkRule(): AppLinkRule = AppLinkRule(
decision = when (decision) {
NativeAppLinkRuleDecision.ALWAYS_OPEN -> AppLinkRuleDecision.ALWAYS_OPEN
NativeAppLinkRuleDecision.NEVER_OPEN -> AppLinkRuleDecision.NEVER_OPEN
},
scope = scope,
packageName = packageName,
)
@@ -0,0 +1,236 @@
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
package eu.weblibre.flutter_mozilla_components.applinks
import android.content.Context
import eu.weblibre.flutter_mozilla_components.ProfileContext
import mozilla.components.support.base.log.logger.Logger
import org.json.JSONArray
import org.json.JSONObject
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.atomic.AtomicReference
/**
* Process-level registry of profile-scoped [AppLinkPolicyStore] singletons
* (APP_LINKS_OWN_IMPLEMENTATION_PLAN.md §2.10). Keyed only by native's canonical
* [ProfileContext.relativePath]; created on first use, torn down on profile
* replacement. Survives `GlobalComponents.setUp()` replacing the `Components`.
*/
object AppLinkPolicyStores {
private val stores = ConcurrentHashMap<String, AppLinkPolicyStore>()
fun forProfile(profileContext: ProfileContext): AppLinkPolicyStore {
return stores.getOrPut(profileContext.relativePath) {
AppLinkPolicyStore(profileContext)
}
}
/** Remove a torn-down profile's store (profile replacement/deletion). */
fun remove(relativePath: String) {
stores.remove(relativePath)
}
}
/**
* The only policy source in `ComponentsMode.EXTERNAL` and before Flutter attaches.
* Holds the classifier [AppLinkPolicy] in an [AtomicReference] backed by a single
* profile-scoped SharedPreferences record. Writes persist synchronously
* (`commit()`) and publish the new reference only after durable success. There is
* exactly one writer (the Dart replicator via `setAppLinkPolicy`).
*/
class AppLinkPolicyStore internal constructor(
private val context: Context,
) {
private val logger = Logger("AppLinkPolicyStore")
private val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
private val reference = AtomicReference(loadOrSeed())
val policy: AppLinkPolicy
get() = reference.get()
/**
* Persist [policy] durably, then publish it. Serialised so concurrent writers
* cannot interleave a half-written record with a published reference.
*/
@Synchronized
fun setPolicy(policy: AppLinkPolicy): Boolean {
val json = encode(policy, migrated = true)
val committed = prefs.edit().putString(KEY_SNAPSHOT, json).commit()
if (!committed) {
logger.error("failed to persist app-link policy; keeping previous snapshot")
return false
}
reference.set(policy)
return true
}
private fun loadOrSeed(): AppLinkPolicy {
val stored = prefs.getString(KEY_SNAPSHOT, null)
if (stored != null) {
runCatching { return decode(stored) }
.onFailure { logger.error("corrupt app-link policy record; reseeding", it) }
}
// Seed the safe default (globalMode = ASK): the seed carries no protected-context data (that
// is computed in Dart and arrives only with the first replicated snapshot), so it must never
// auto-launch — an `ALWAYS` seed would leak links out of proxied/strict containers and cold
// Custom Tabs before protection is known. The legacy AC "open links in apps" preference is
// deliberately not migrated (a de-Googled browser resets to the safe ASK default; the user
// re-sets it in Settings), so the seed does not read it.
val seeded = AppLinkPolicy.SAFE_DEFAULT
val committed = prefs.edit().putString(KEY_SNAPSHOT, encode(seeded, migrated = true)).commit()
if (!committed) {
logger.error("failed to persist seeded app-link policy; using defaults in memory")
}
return seeded
}
private fun encode(policy: AppLinkPolicy, migrated: Boolean): String {
val root = JSONObject()
root.put(FIELD_MIGRATED, migrated)
root.put(FIELD_GLOBAL_MODE, policy.globalMode.name)
root.put(FIELD_MARKETPLACE, policy.marketplaceFallbackEnabled)
root.put(FIELD_PROTECT_GENERAL, policy.protectGeneralContext)
root.put(FIELD_PROTECTED_CONTEXTS, JSONArray(policy.protectedContextIds.toList()))
root.put(FIELD_STRICT_CONTEXTS, JSONArray(policy.strictContextIds.toList()))
root.put(FIELD_RULES, encodeRules(policy.rules))
val overrides = JSONObject()
for ((contextId, override) in policy.contextOverrides) {
overrides.put(
contextId,
JSONObject()
.put(FIELD_OVERRIDE_MODE, override.globalMode.name)
.put(FIELD_RULES, encodeRules(override.rules)),
)
}
root.put(FIELD_CONTEXT_OVERRIDES, overrides)
val patterns = JSONArray()
for (pattern in policy.protectedTargetPatterns) {
patterns.put(
JSONObject()
.put(FIELD_PATTERN_SCHEME, pattern.scheme)
.put(FIELD_PATTERN_HOST, pattern.hostOrSuffix)
.put(FIELD_PATTERN_SUBDOMAINS, pattern.includeSubdomains)
.putOpt(FIELD_PATTERN_PORT, pattern.port),
)
}
root.put(FIELD_PATTERNS, patterns)
return root.toString()
}
private fun encodeRules(rules: Map<String, AppLinkRule>): JSONObject {
val obj = JSONObject()
for ((scope, rule) in rules) {
obj.put(
scope,
JSONObject()
.put(FIELD_RULE_DECISION, rule.decision.name)
.put(FIELD_RULE_SCOPE, rule.scope)
.putOpt(FIELD_RULE_PACKAGE, rule.packageName),
)
}
return obj
}
private fun decode(json: String): AppLinkPolicy {
val root = JSONObject(json)
val rules = decodeRules(root.optJSONObject(FIELD_RULES))
val contextOverrides = mutableMapOf<String, ContextAppLinkPolicy>()
root.optJSONObject(FIELD_CONTEXT_OVERRIDES)?.let { obj ->
for (contextId in obj.keys()) {
val overrideJson = obj.getJSONObject(contextId)
contextOverrides[contextId] = ContextAppLinkPolicy(
globalMode = AppLinkMode.valueOf(overrideJson.getString(FIELD_OVERRIDE_MODE)),
rules = decodeRules(overrideJson.optJSONObject(FIELD_RULES)),
)
}
}
val patterns = mutableListOf<ProtectedTargetPattern>()
root.optJSONArray(FIELD_PATTERNS)?.let { arr ->
for (i in 0 until arr.length()) {
val p = arr.getJSONObject(i)
patterns.add(
ProtectedTargetPattern(
scheme = p.getString(FIELD_PATTERN_SCHEME),
hostOrSuffix = p.getString(FIELD_PATTERN_HOST),
includeSubdomains = p.getBoolean(FIELD_PATTERN_SUBDOMAINS),
port = if (p.has(FIELD_PATTERN_PORT) && !p.isNull(FIELD_PATTERN_PORT)) {
p.getInt(FIELD_PATTERN_PORT)
} else {
null
},
),
)
}
}
return AppLinkPolicy(
globalMode = AppLinkMode.valueOf(root.getString(FIELD_GLOBAL_MODE)),
rules = rules,
marketplaceFallbackEnabled = root.optBoolean(FIELD_MARKETPLACE, false),
protectGeneralContext = root.optBoolean(FIELD_PROTECT_GENERAL, false),
protectedContextIds = root.optJSONArray(FIELD_PROTECTED_CONTEXTS).toStringSet(),
strictContextIds = root.optJSONArray(FIELD_STRICT_CONTEXTS).toStringSet(),
protectedTargetPatterns = patterns,
contextOverrides = contextOverrides,
)
}
private fun decodeRules(obj: JSONObject?): Map<String, AppLinkRule> {
if (obj == null) return emptyMap()
val rules = mutableMapOf<String, AppLinkRule>()
for (scope in obj.keys()) {
val ruleJson = obj.getJSONObject(scope)
rules[scope] = AppLinkRule(
decision = AppLinkRuleDecision.valueOf(ruleJson.getString(FIELD_RULE_DECISION)),
scope = ruleJson.getString(FIELD_RULE_SCOPE),
packageName = ruleJson.optStringOrNull(FIELD_RULE_PACKAGE),
)
}
return rules
}
private fun JSONObject.optStringOrNull(key: String): String? =
if (has(key) && !isNull(key)) getString(key) else null
private fun JSONArray?.toStringSet(): Set<String> {
if (this == null) return emptySet()
val out = LinkedHashSet<String>(length())
for (i in 0 until length()) {
out.add(getString(i))
}
return out
}
companion object {
const val PREFS_NAME = "weblibre_app_link_policy"
private const val KEY_SNAPSHOT = "snapshot"
private const val FIELD_MIGRATED = "migrated"
private const val FIELD_GLOBAL_MODE = "globalMode"
private const val FIELD_MARKETPLACE = "marketplaceFallbackEnabled"
private const val FIELD_PROTECT_GENERAL = "protectGeneralContext"
private const val FIELD_PROTECTED_CONTEXTS = "protectedContextIds"
private const val FIELD_STRICT_CONTEXTS = "strictContextIds"
private const val FIELD_RULES = "rules"
private const val FIELD_RULE_DECISION = "decision"
private const val FIELD_RULE_SCOPE = "scope"
private const val FIELD_RULE_PACKAGE = "packageName"
private const val FIELD_CONTEXT_OVERRIDES = "contextOverrides"
private const val FIELD_OVERRIDE_MODE = "mode"
private const val FIELD_PATTERNS = "protectedTargetPatterns"
private const val FIELD_PATTERN_SCHEME = "scheme"
private const val FIELD_PATTERN_HOST = "hostOrSuffix"
private const val FIELD_PATTERN_SUBDOMAINS = "includeSubdomains"
private const val FIELD_PATTERN_PORT = "port"
}
}
@@ -0,0 +1,39 @@
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
package eu.weblibre.flutter_mozilla_components.applinks
import android.content.Context
/**
* Process-level holder for the shared [ExternalAppResolver] and [AppLinkLauncher]
* (APP_LINKS_OWN_IMPLEMENTATION_PLAN.md §2.7). Neither is profile-scoped — they wrap the
* `PackageManager` and `startActivity`, both application-global.
*
* A single shared launcher is important: its 2 s same-package auto-launch cooldown
* (§2.4 loop breaker) must be observed across *every* launch path — the synchronous
* interceptor tail ([WebLibreAppLinksInterceptor]), the manual "Open in <App>" entry points
* (`GeckoAppLinksApiImpl.launchAppLink`), and prompt resolution. If each site built its own
* launcher the cooldown would be per-instance and the ping-pong defence would break.
*/
object AppLinkRuntime {
@Volatile
private var holder: Holder? = null
fun get(context: Context): Holder {
return holder ?: synchronized(this) {
holder ?: Holder(context.applicationContext).also { holder = it }
}
}
class Holder(appContext: Context) {
val resolver: ExternalAppResolver = ExternalAppResolver(AndroidPackageResolver(appContext))
val launcher: AppLinkLauncher = AppLinkLauncher(
resolver = resolver,
startActivity = { intent -> appContext.startActivity(intent) },
)
}
}
@@ -0,0 +1,82 @@
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
package eu.weblibre.flutter_mozilla_components.applinks
import java.util.Locale
/**
* Frozen scheme classification tables for the WebLibre-owned app-links implementation
* (see APP_LINKS_OWN_IMPLEMENTATION_PLAN.md §2.2).
*
* These tables initially match Mozilla Android Components
* ([mozilla.components.feature.app.links.AppLinksUseCases] companion,
* [mozilla.components.feature.app.links.AppLinksInterceptor]). All comparisons are
* case-insensitive via [Locale.ROOT] lowercase — AC lowercases only the denied set;
* making the engine-supported comparison case-insensitive too is a deliberate small
* correctness improvement. `JavaScript:` must be denied as surely as `javascript:`.
*
* These tables describe what Gecko can load, not what the user wants, and are consumed
* on the synchronous interception path — they stay in Kotlin.
*/
object AppLinkSchemes {
// Schemes the Gecko engine can load itself.
// https://searchfox.org/firefox-main/source/netwerk/build/components.conf
val ENGINE_SUPPORTED: Set<String> = setOf(
"about",
"data",
"file",
"ftp",
"http",
"https",
"moz-extension",
"moz-safe-about",
"resource",
"view-source",
"ws",
"wss",
"blob",
)
// Schemes that must never be resolved or launched in a third-party app.
val ALWAYS_DENIED: Set<String> = setOf(
"jar",
"file",
"javascript",
"data",
"about",
"content",
"fido",
)
// Schemes allowed to open an external application from a subframe.
val SUBFRAME_ALLOWED: Set<String> = setOf(
"msteams",
)
// Wallet schemes — always prompt, never remembered (§2.4).
val WALLET: Set<String> = setOf(
"openid4vp",
"mdoc",
"mdoc-openid4vp",
"haip",
"eudi-wallet",
"eudi-openid4vp",
"openid-credential-offer",
)
private fun normalize(scheme: String?): String? = scheme?.lowercase(Locale.ROOT)
fun isEngineSupported(scheme: String?): Boolean = normalize(scheme) in ENGINE_SUPPORTED
fun isAlwaysDenied(scheme: String?): Boolean = normalize(scheme) in ALWAYS_DENIED
fun isSubframeAllowed(scheme: String?): Boolean = normalize(scheme) in SUBFRAME_ALLOWED
fun isWallet(scheme: String?): Boolean = normalize(scheme) in WALLET
fun isHttpOrHttps(scheme: String?): Boolean = normalize(scheme).let { it == "http" || it == "https" }
}
@@ -0,0 +1,344 @@
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
package eu.weblibre.flutter_mozilla_components.applinks
import android.content.ComponentName
import android.content.Intent
import android.content.pm.ResolveInfo
import android.net.Uri
import android.os.Build
import android.provider.Browser.EXTRA_APPLICATION_ID
import androidx.core.net.toUri
import mozilla.components.support.base.log.logger.Logger
import java.net.URISyntaxException
import java.util.Locale
private const val EXTRA_BROWSER_FALLBACK_URL = "browser_fallback_url"
private const val MARKET_INTENT_URI_PACKAGE_PREFIX = "market://details?id="
private const val ANDROID_RESOLVER_PACKAGE_NAME = "android"
private const val APP_LABEL_MAX_LENGTH = 64
private val PLAY_STORE_URL_REGEX = Regex("https?://play\\.google\\.com/store/.*")
/**
* Immutable result of resolving a URL against installed apps
* (APP_LINKS_OWN_IMPLEMENTATION_PLAN.md §2.7). Holds a sanitised, launchable [appIntent]
* (trusted component set), never a page-controlled one.
*/
data class ResolvedAppLink(
val hasExternalApp: Boolean,
val appIntent: Intent?,
val packageName: String?,
val appName: String?,
val fallbackUrl: String?,
val marketplaceIntent: Intent?,
val isAmbiguous: Boolean,
val engineSupportsScheme: Boolean,
val scopeKey: String,
val originalScheme: String?,
val intentDataScheme: String?,
)
/**
* Resolves URLs to external apps, preserving every security-critical behaviour of
* `AppLinksUseCases.createBrowsableIntents` and adding the §2.7 field allowlist. The launched
* intent is rebuilt from a strict allowlist: `ACTION_VIEW`, `CATEGORY_BROWSABLE`, the data URI,
* and a documented compatibility extra — every page-supplied component, selector, bounds,
* identifier, clip/grant state, incoming flag, and browser-fallback metadata is cleared.
*
* A ~30 s resolution cache (AC's `APP_LINKS_CACHE_INTERVAL`) serves the synchronous classify path
* and the "show the button?" queries. There is no package-broadcast invalidator: the mandatory
* pre-launch re-resolution in [AppLinkLauncher] is the correctness guard.
*/
class ExternalAppResolver(
private val packages: PackageResolver,
private val clock: MonotonicClock = MonotonicClock.SYSTEM,
private val cacheTtlMs: Long = APP_LINKS_CACHE_INTERVAL,
) {
private val logger = Logger("ExternalAppResolver")
private data class CacheEntry(val timestamp: Long, val key: Int, val value: ResolvedAppLink)
@Volatile
private var cache: CacheEntry? = null
/**
* Resolve [url] against installed apps.
*
* @param includeHttpAppLinks when `false`, an app resolving an engine-supported (http(s)) URL
* is not treated as an external app — the engine keeps the load. Manual "Open in app" callers
* pass `true` so a YouTube link surfaces the YouTube app.
* @param useCache consult/populate the short-lived resolution cache. Launch paths pass `false`
* so they always re-resolve immediately before `startActivity`.
*/
fun resolve(
url: String,
includeHttpAppLinks: Boolean,
useCache: Boolean = true,
): ResolvedAppLink {
val key = (url + "|" + includeHttpAppLinks).hashCode()
val now = clock.elapsedRealtime()
if (useCache) {
cache?.let { entry ->
if (entry.key == key && now <= entry.timestamp + cacheTtlMs) {
return entry.value
}
}
}
val result = resolveUncached(url, includeHttpAppLinks)
if (useCache) {
cache = CacheEntry(now, key, result)
}
return result
}
fun clearCache() {
cache = null
}
private fun resolveUncached(url: String, includeHttpAppLinks: Boolean): ResolvedAppLink {
val originalScheme = try {
url.toUri().scheme?.lowercase(Locale.ROOT)
} catch (e: Exception) {
null
}
val engineSupported = AppLinkSchemes.isEngineSupported(originalScheme)
val hostScope = AppLinkHostNormalizer.hostScopeKey(runCatching { url.toUri().host }.getOrNull())
fun empty(scope: String, intentDataScheme: String? = null) = ResolvedAppLink(
hasExternalApp = false,
appIntent = null,
packageName = null,
appName = null,
fallbackUrl = null,
marketplaceIntent = null,
isAmbiguous = false,
engineSupportsScheme = engineSupported,
scopeKey = scope,
originalScheme = originalScheme,
intentDataScheme = intentDataScheme,
)
// Always-denied schemes never resolve or launch externally (§2.2). Return early so no
// fallback or marketplace intent is extracted from them.
if (AppLinkSchemes.isAlwaysDenied(originalScheme)) {
return empty(hostScope ?: "")
}
val parsed = safeParseUri(url) ?: return empty(hostScope ?: "")
val dataScheme = parsed.data?.scheme?.lowercase(Locale.ROOT)
// Reject a sanitised intent whose data scheme is itself always-denied.
if (parsed.data == null || AppLinkSchemes.isAlwaysDenied(dataScheme)) {
return empty(hostScope ?: "", dataScheme)
}
val requestedPackage = parsed.`package`
val appIntent = buildLaunchIntent(parsed)
val pageFallback = parsed.getStringExtra(EXTRA_BROWSER_FALLBACK_URL)
// Resolve the external-app handler. A browser default for an http(s) link is not itself an
// "open in app" target — as with no default or the Android chooser sentinel — so look past
// it for a non-browser handler (e.g. the YouTube app for a youtube.com link the default
// browser also handles). Browsers are excluded only for engine-supported (http) schemes.
var isAmbiguous = false
var resolvedPackage: String? = null
var resolvedActivityName: String? = null
var resolvedInfo: ResolveInfo? = null
val defaultInfo = packages.resolveDefaultActivity(appIntent)
val defaultPackage = defaultInfo?.activityInfo?.packageName
val defaultIsUsableApp = defaultPackage != null &&
defaultPackage != packages.selfPackageName &&
defaultPackage != ANDROID_RESOLVER_PACKAGE_NAME &&
!(engineSupported && packages.isInstalledBrowser(defaultPackage))
when {
defaultIsUsableApp -> {
resolvedPackage = defaultPackage
resolvedActivityName = defaultInfo?.activityInfo?.name
resolvedInfo = defaultInfo
}
// A page must not relaunch WebLibre through the app-link path: if WebLibre itself is the
// default handler, keep the load in-browser rather than hunting for other apps.
defaultPackage == packages.selfPackageName -> {
resolvedPackage = null
}
// No usable default (none / chooser / a browser for an http link): pick a non-browser
// handler. A single one launches directly (rememberable); several stay ambiguous (chooser).
else -> {
val candidates = packages.queryActivities(appIntent).filter { info ->
val pkg = info.activityInfo?.packageName
info.filter != null &&
pkg != null &&
pkg != packages.selfPackageName &&
!(engineSupported && packages.isInstalledBrowser(pkg))
}
candidates.firstOrNull()?.let { chosen ->
resolvedPackage = chosen.activityInfo?.packageName
resolvedActivityName = chosen.activityInfo?.name
resolvedInfo = chosen
isAmbiguous = candidates.size > 1
}
}
}
// hasExternalApp mirrors AC's appIntent decision, minus the launchInApp() policy gate
// (policy lives in the classifier). A resolved package is never a browser for an http link
// (excluded above), so the only remaining http gate is includeHttpAppLinks.
val hasExternalApp = when {
resolvedPackage == null -> false
// http(s) app links only count when the caller asks for them.
engineSupported && !includeHttpAppLinks -> false
else -> true
}
// Bind the trusted, resolved component (never a page-supplied one).
if (hasExternalApp && resolvedPackage != null && resolvedActivityName != null && !isAmbiguous) {
appIntent.component = ComponentName(resolvedPackage, resolvedActivityName)
}
val appName = if (hasExternalApp && resolvedInfo != null) {
sanitizeAppLabel(packages.applicationLabel(resolvedInfo))
} else {
null
}
// Fallback: accepted only if http(s), the original scheme is not engine-supported, and it is
// not a Play Store URL for an already-installed app.
val fallbackUrl = pageFallback?.let { validateFallback(it, engineSupported, appInstalled = resolvedPackage != null) }
// Marketplace intent: only when the target package is not installed.
val marketplaceIntent = requestedPackage
?.takeIf { !packages.isPackageInstalled(it) }
?.let { safeParseRawUri(MARKET_INTENT_URI_PACKAGE_PREFIX + it) }
?.apply { flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK }
// Scope key: host for engine-supported (http) links; resolved package otherwise (§2.5).
val scopeKey = when {
engineSupported && hostScope != null -> hostScope
resolvedPackage != null -> AppLinkHostNormalizer.packageScopeKey(resolvedPackage) ?: (hostScope ?: "")
else -> hostScope ?: ""
}
return ResolvedAppLink(
hasExternalApp = hasExternalApp,
appIntent = if (hasExternalApp) appIntent else null,
packageName = if (hasExternalApp) resolvedPackage else null,
appName = appName,
fallbackUrl = fallbackUrl,
marketplaceIntent = marketplaceIntent,
isAmbiguous = isAmbiguous,
engineSupportsScheme = engineSupported,
scopeKey = scopeKey,
originalScheme = originalScheme,
intentDataScheme = dataScheme,
)
}
/** Parse an `intent:`/URL into an Intent, rejecting self-package targets. */
private fun safeParseUri(url: String): Intent? {
val intent = safeParseRawUri(url, Intent.URI_INTENT_SCHEME) ?: return null
return if (intent.`package` == packages.selfPackageName) {
// Ignore intents that would relaunch WebLibre.
null
} else {
intent
}
}
private fun safeParseRawUri(uri: String, flags: Int = 0): Intent? {
return try {
Intent.parseUri(uri, flags)
} catch (e: URISyntaxException) {
logger.error("failed to parse URI", e)
null
} catch (e: NumberFormatException) {
// Intent.parseUri may throw NumberFormatException on malformed numeric extras.
logger.error("failed to parse URI", e)
null
}
}
/**
* Rebuild [source] into a sanitised, launchable intent using a field allowlist (§2.7):
* force ACTION_VIEW; add CATEGORY_BROWSABLE; retain only the data URI and documented
* compatibility extras; clear every page-supplied structural field and all incoming flags.
*/
private fun buildLaunchIntent(source: Intent): Intent {
val sanitized = Intent(Intent.ACTION_VIEW)
source.data?.let { sanitized.data = it }
sanitized.addCategory(Intent.CATEGORY_BROWSABLE)
// Preserve an explicit `intent:...;package=` target: it is a package-id constraint (not a
// component, which could point at a non-exported activity), so resolution/launch targets the
// app the link actually names instead of some other handler or WebLibre itself. `safeParseUri`
// already rejected a self-package target. This mirrors AC's createBrowsableIntents.
source.`package`?.let { pkg ->
if (pkg != packages.selfPackageName) sanitized.`package` = pkg
}
// Explicitly clear every structural field a page could weaponise.
sanitized.component = null
sanitized.selector = null
sanitized.sourceBounds = null
sanitized.clipData = null
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
sanitized.identifier = null
}
// flags = FLAG_ACTIVITY_NEW_TASK — assignment, not `or`. Clears page-supplied flags such as
// FLAG_GRANT_READ_URI_PERMISSION.
sanitized.flags = Intent.FLAG_ACTIVITY_NEW_TASK
// Documented compatibility extra only. EXTRA_BROWSER_FALLBACK_URL is deliberately not copied
// onto the launched intent (it is extracted separately for the interceptor).
sanitized.putExtra(EXTRA_APPLICATION_ID, packages.selfPackageName)
return sanitized
}
private fun validateFallback(
rawFallback: String,
originalSchemeEngineSupported: Boolean,
appInstalled: Boolean,
): String? {
val scheme = try {
Uri.parse(rawFallback).scheme?.lowercase(Locale.ROOT)
} catch (e: Exception) {
return null
}
if (!AppLinkSchemes.isHttpOrHttps(scheme)) return null
if (originalSchemeEngineSupported) return null
val isPlayStoreUrlForInstalledApp = PLAY_STORE_URL_REGEX.matches(rawFallback) && appInstalled
if (isPlayStoreUrlForInstalledApp) return null
return rawFallback
}
/** App labels are app-controlled: strip control/bidi characters and length-bound. */
private fun sanitizeAppLabel(label: String?): String? {
if (label.isNullOrEmpty()) return null
val cleaned = buildString {
for (ch in label) {
val type = Character.getType(ch)
if (type == Character.CONTROL.toInt() || type == Character.FORMAT.toInt()) {
continue
}
append(ch)
}
}.trim()
if (cleaned.isEmpty()) return null
return if (cleaned.length > APP_LABEL_MAX_LENGTH) {
cleaned.substring(0, APP_LABEL_MAX_LENGTH)
} else {
cleaned
}
}
companion object {
const val APP_LINKS_CACHE_INTERVAL = 30 * 1000L
}
}
@@ -0,0 +1,22 @@
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
package eu.weblibre.flutter_mozilla_components.applinks
import android.os.SystemClock
/**
* Injectable monotonic clock. All app-links timing (resolution cache TTL, launch cooldown,
* pending-request expiry, suppression timeout) reads from this seam so tests can advance
* time deterministically.
*/
fun interface MonotonicClock {
fun elapsedRealtime(): Long
companion object {
val SYSTEM = MonotonicClock { SystemClock.elapsedRealtime() }
}
}
@@ -0,0 +1,147 @@
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
package eu.weblibre.flutter_mozilla_components.applinks
import android.content.Context
import android.os.Handler
import android.os.Looper
import androidx.appcompat.app.AlertDialog
import eu.weblibre.flutter_mozilla_components.R
import eu.weblibre.flutter_mozilla_components.pigeons.AppLinkPromptOwner
import mozilla.components.feature.session.SessionUseCases
import mozilla.components.support.base.feature.LifecycleAwareFeature
import java.util.concurrent.ConcurrentHashMap
/**
* Process-level registry of the *started* [NativeAppLinkPromptFeature] instances, keyed by tabId.
* The [WebLibreAppLinksInterceptor] runs on an engine thread and creates prompt requests
* asynchronously; a Custom Tab feature only queries the store at lifecycle start, so without this a
* request created after start would sit unshown (its navigation already denied) until a rotation or
* restart. The interceptor pings [notifyPromptAvailable] so the feature re-queries immediately.
*/
object NativeAppLinkPromptNotifier {
private val features = ConcurrentHashMap<String, NativeAppLinkPromptFeature>()
fun register(tabId: String, feature: NativeAppLinkPromptFeature) {
features[tabId] = feature
}
fun unregister(tabId: String, feature: NativeAppLinkPromptFeature) {
features.remove(tabId, feature)
}
fun notifyPromptAvailable(tabId: String) {
features[tabId]?.onPromptAvailable()
}
}
/**
* Presents the minimal native app-link prompt for Custom Tab sessions that have no
* Flutter engine (APP_LINKS_OWN_IMPLEMENTATION_PLAN.md §2.6). Title, message,
* open/cancel — **no remember checkbox**, so native never creates policy.
*
* Queries [PendingAppLinkStore] for its own tab on start (and re-queries after each
* resolution); a request that is rotated/backgrounded away stays pending and is
* re-presented on the next start. Owner is fixed to [AppLinkPromptOwner.NATIVE_EXTERNAL].
*/
class NativeAppLinkPromptFeature(
private val context: Context,
private val tabId: String,
private val store: PendingAppLinkStore,
private val launcher: AppLinkLauncher,
private val sessionUseCases: SessionUseCases,
) : LifecycleAwareFeature {
private var dialog: AlertDialog? = null
private val mainHandler = Handler(Looper.getMainLooper())
override fun start() {
NativeAppLinkPromptNotifier.register(tabId, this)
showNext()
}
override fun stop() {
NativeAppLinkPromptNotifier.unregister(tabId, this)
// 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
}
/**
* 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).
*/
fun onPromptAvailable() {
mainHandler.post { showNext() }
}
private fun showNext() {
if (dialog != null) return
val request = store.getPending(AppLinkPromptOwner.NATIVE_EXTERNAL)
.firstOrNull { it.tabId == tabId }
?: return
val title = request.appName?.let {
context.getString(R.string.weblibre_app_link_prompt_title_named, it)
} ?: context.getString(R.string.weblibre_app_link_prompt_title_generic)
dialog = AlertDialog.Builder(context)
.setTitle(title)
.setMessage(context.getString(R.string.weblibre_app_link_prompt_message))
.setPositiveButton(R.string.weblibre_app_link_prompt_open) { _, _ ->
resolveOpen(request)
}
.setNegativeButton(R.string.weblibre_app_link_prompt_cancel) { _, _ ->
resolveCancel(request)
}
.setOnCancelListener {
// Back / touch-outside is an explicit passive dismissal (§2.6).
resolveCancel(request)
}
.setOnDismissListener { dialog = null }
.show()
}
private fun resolveOpen(request: PendingAppLinkRequest) {
val consumed = store.consume(request.requestId) ?: return afterResolve()
val mode = if (consumed.isMarketplace) {
AppLinkLaunchMode.MARKETPLACE
} else {
AppLinkLaunchMode.MANUAL
}
// Fresh prompt-open: no remembered package binding to enforce (§2.5); the
// launcher's pre-launch re-resolution still validates the handler.
// Honour the package captured when the prompt was created for a *named*
// (non-ambiguous) target, so a change in handlers before the user taps Open
// can't launch a different app (§2.5/§2.7). Ambiguous/chooser prompts store a
// null expectedPackage, so this stays null and the chooser still opens.
val result = launcher.launch(consumed.url, mode, expectedPackage = consumed.expectedPackage)
if (result != AppLinkLaunchResult.LAUNCHED) {
consumed.fallbackUrl?.let { fallback ->
// Guard the fallback load against immediately bouncing back out to an
// app (§2.7): a validated fallback can itself resolve externally.
store.recordFallbackReentry(fallback)
sessionUseCases.loadUrl(url = fallback, sessionId = consumed.tabId)
}
}
afterResolve()
}
private fun resolveCancel(request: PendingAppLinkRequest) {
val consumed = store.consume(request.requestId) ?: return afterResolve()
store.recordSuppression(consumed.tabId, consumed.targetFingerprint)
afterResolve()
}
private fun afterResolve() {
dialog = null
showNext()
}
}
@@ -0,0 +1,92 @@
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
package eu.weblibre.flutter_mozilla_components.applinks
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.content.pm.ResolveInfo
import mozilla.components.support.base.log.logger.Logger
import mozilla.components.support.ktx.android.content.pm.isPackageInstalled
import mozilla.components.support.utils.BrowsersCache
import mozilla.components.support.utils.ext.packageManagerCompatHelper
/**
* Seam over [PackageManager] and browser detection so the resolver can be unit-tested
* (see APP_LINKS_OWN_IMPLEMENTATION_PLAN.md §2.7, Phase 1). Intent construction and
* sanitisation are still exercised under Robolectric because plain JVM stubs never
* populate the fields the resolver strips.
*/
interface PackageResolver {
val selfPackageName: String
/** May throw [RuntimeException] internally on large result sets; returns empty on failure. */
fun queryActivities(intent: Intent): List<ResolveInfo>
/** The default activity for [intent], honouring `MATCH_DEFAULT_ONLY`. */
fun resolveDefaultActivity(intent: Intent): ResolveInfo?
fun isPackageInstalled(packageName: String): Boolean
/** True when [packageName] is an installed browser (excluded for engine-supported schemes). */
fun isInstalledBrowser(packageName: String): Boolean
fun applicationLabel(resolveInfo: ResolveInfo): String?
}
class AndroidPackageResolver(private val context: Context) : PackageResolver {
private val logger = Logger("AppLinkPackageResolver")
override val selfPackageName: String
get() = context.packageName
@Suppress("QueryPermissionsNeeded", "TooGenericExceptionCaught")
override fun queryActivities(intent: Intent): List<ResolveInfo> {
return try {
context.packageManagerCompatHelper.queryIntentActivitiesCompat(
intent,
PackageManager.GET_RESOLVED_FILTER,
)
} catch (e: RuntimeException) {
// queryIntentActivities throws on very large result sets — treat as "nothing".
logger.error("failed to query activities", e)
emptyList()
}
}
@Suppress("TooGenericExceptionCaught")
override fun resolveDefaultActivity(intent: Intent): ResolveInfo? {
return try {
context.packageManagerCompatHelper.resolveActivityCompat(
intent,
PackageManager.MATCH_DEFAULT_ONLY,
)
} catch (e: RuntimeException) {
logger.error("failed to resolve default activity", e)
null
}
}
override fun isPackageInstalled(packageName: String): Boolean {
return context.packageManagerCompatHelper.isPackageInstalled(packageName)
}
override fun isInstalledBrowser(packageName: String): Boolean {
return BrowsersCache.all(context).isInstalled(packageName)
}
@Suppress("TooGenericExceptionCaught")
override fun applicationLabel(resolveInfo: ResolveInfo): String? {
return try {
val appInfo = resolveInfo.activityInfo?.applicationInfo ?: return null
context.packageManager.getApplicationLabel(appInfo).toString()
} catch (e: Exception) {
logger.error("failed to read application label", e)
null
}
}
}
@@ -0,0 +1,342 @@
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
package eu.weblibre.flutter_mozilla_components.applinks
import eu.weblibre.flutter_mozilla_components.pigeons.AppLinkPromptOwner
import eu.weblibre.flutter_mozilla_components.pigeons.AppLinkPromptRequest
import eu.weblibre.flutter_mozilla_components.pigeons.AppLinkTarget
import mozilla.components.support.base.log.logger.Logger
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.atomic.AtomicLong
/** The §2.2 URL class a pending request belongs to; part of the dedupe key. */
enum class AppLinkUrlClass {
BANNER,
MODAL,
MARKETPLACE,
}
/**
* A pending prompt, stored until resolved/invalidated/expired (§2.6). Holds only
* stable identifiers and sanitised data — never a Components/EngineSession/store
* reference. Carries everything needed both to render the prompt and to perform
* the resolution side effect (re-resolve + launch, or load a validated fallback).
*/
data class PendingAppLinkRequest(
val requestId: Long,
val owner: AppLinkPromptOwner,
val tabId: String,
val contextId: String?,
val sourceUrl: String?,
val isPrivate: Boolean,
val isWallet: Boolean,
val isProtectedContext: Boolean,
val canRemember: Boolean,
val isModal: Boolean,
val urlClass: AppLinkUrlClass,
// Resolution data:
val url: String,
val expectedPackage: String?,
val fallbackUrl: String?,
val engineSupportsScheme: Boolean,
val isMarketplace: Boolean,
// Full sanitised-target fingerprint (URL + intent payload), the dedupe/invalidation key.
val targetFingerprint: String,
val appName: String?,
val packageName: String?,
val scopeKey: String,
val createdAt: Long,
) {
fun toPigeon(): AppLinkPromptRequest = AppLinkPromptRequest(
requestId = requestId,
owner = owner,
tabId = tabId,
contextId = contextId,
sourceUrl = sourceUrl,
isPrivate = isPrivate,
isWallet = isWallet,
isProtectedContext = isProtectedContext,
canRemember = canRemember,
isModal = isModal,
target = AppLinkTarget(
url = url,
appName = appName,
packageName = packageName,
fallbackUrl = fallbackUrl,
isMarketplace = isMarketplace,
isAmbiguous = !canRemember,
engineSupportsScheme = engineSupportsScheme,
scopeKey = scopeKey,
),
)
}
/** Everything needed to create a request; the store assigns the id and timestamp. */
data class NewAppLinkRequest(
val owner: AppLinkPromptOwner,
val tabId: String,
val contextId: String?,
val sourceUrl: String?,
val isPrivate: Boolean,
val isWallet: Boolean,
val isProtectedContext: Boolean,
val canRemember: Boolean,
val isModal: Boolean,
val urlClass: AppLinkUrlClass,
val url: String,
val expectedPackage: String?,
val fallbackUrl: String?,
val engineSupportsScheme: Boolean,
val isMarketplace: Boolean,
val targetFingerprint: String,
val appName: String?,
val packageName: String?,
val scopeKey: String,
/** A user-gesture attempt is never deduped into an older request (§2.6). */
val isUserGesture: Boolean = false,
)
/**
* Process-level registry of profile-scoped [PendingAppLinkStore] singletons (§2.10).
* Keyed by native's canonical profile relative path; survives `GlobalComponents.setUp()`.
*/
object PendingAppLinkStores {
private val stores = ConcurrentHashMap<String, PendingAppLinkStore>()
fun forProfile(relativePath: String): PendingAppLinkStore =
stores.getOrPut(relativePath) { PendingAppLinkStore() }
fun remove(relativePath: String) {
stores.remove(relativePath)
}
}
/**
* Holds pending prompts, dedupe, suppression, and the fallback re-entry map (§2.6).
* 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.
*/
class PendingAppLinkStore(
private val clock: MonotonicClock = MonotonicClock.SYSTEM,
private val requestExpiryMs: Long = REQUEST_EXPIRY_MS,
private val suppressionExpiryMs: Long = SUPPRESSION_EXPIRY_MS,
private val dedupeWindowMs: Long = DEDUPE_WINDOW_MS,
private val fallbackReentryMs: Long = FALLBACK_REENTRY_MS,
) {
private val logger = Logger("PendingAppLinkStore")
private val lock = Any()
private val idGenerator = AtomicLong(0L)
private val requests = LinkedHashMap<Long, PendingAppLinkRequest>()
private val suppression = HashMap<String, Long>()
private val fallbackReentry = HashMap<String, Long>()
private fun suppressionKey(tabId: String, fingerprint: String) = "$tabId\u0000$fingerprint"
/**
* Create a request, collapsing a matching non-user-gesture request that arrived
* within the dedupe window into the existing one (§2.6).
*/
fun createRequest(input: NewAppLinkRequest): PendingAppLinkRequest {
synchronized(lock) {
sweepExpiredLocked()
if (!input.isUserGesture) {
val existing = requests.values.firstOrNull { candidate ->
candidate.tabId == input.tabId &&
candidate.targetFingerprint == input.targetFingerprint &&
candidate.owner == input.owner &&
candidate.urlClass == input.urlClass &&
clock.elapsedRealtime() <= candidate.createdAt + dedupeWindowMs
}
if (existing != null) return existing
}
val request = PendingAppLinkRequest(
requestId = idGenerator.incrementAndGet(),
owner = input.owner,
tabId = input.tabId,
contextId = input.contextId,
sourceUrl = input.sourceUrl,
isPrivate = input.isPrivate,
isWallet = input.isWallet,
isProtectedContext = input.isProtectedContext,
canRemember = input.canRemember,
isModal = input.isModal,
urlClass = input.urlClass,
url = input.url,
expectedPackage = input.expectedPackage,
fallbackUrl = input.fallbackUrl,
engineSupportsScheme = input.engineSupportsScheme,
isMarketplace = input.isMarketplace,
targetFingerprint = input.targetFingerprint,
appName = input.appName,
packageName = input.packageName,
scopeKey = input.scopeKey,
createdAt = clock.elapsedRealtime(),
)
requests[request.requestId] = request
return request
}
}
/** Non-consuming query of live requests for [owner]. */
fun getPending(owner: AppLinkPromptOwner): List<PendingAppLinkRequest> {
synchronized(lock) {
sweepExpiredLocked()
return requests.values.filter { it.owner == owner }.toList()
}
}
/** Atomically remove and return a request; null if already resolved/expired. */
fun consume(requestId: Long): PendingAppLinkRequest? {
synchronized(lock) {
sweepExpiredLocked()
return requests.remove(requestId)
}
}
fun peek(requestId: Long): PendingAppLinkRequest? {
synchronized(lock) {
sweepExpiredLocked()
return requests[requestId]
}
}
fun invalidate(requestId: Long) {
synchronized(lock) { requests.remove(requestId) }
}
/** Invalidate every pending request for a tab (tab close / replacement). */
fun invalidateTab(tabId: String) {
synchronized(lock) {
requests.values.removeAll { it.tabId == tabId }
suppression.keys.removeAll { it.startsWith("$tabId\u0000") }
}
}
/**
* 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) {
synchronized(lock) {
suppression[suppressionKey(tabId, fingerprint)] =
clock.elapsedRealtime() + suppressionExpiryMs
}
}
fun isSuppressed(tabId: String, fingerprint: String): Boolean {
synchronized(lock) {
sweepExpiredLocked()
val expiresAt = suppression[suppressionKey(tabId, fingerprint)] ?: return false
return clock.elapsedRealtime() <= expiresAt
}
}
/** Clear a tab's suppression on a new user-initiated/direct navigation (§2.6). */
fun clearSuppressionForTab(tabId: String) {
synchronized(lock) {
suppression.keys.removeAll { it.startsWith("$tabId\u0000") }
}
}
// ---- Fallback re-entry map (§2.7) ----
fun recordFallbackReentry(canonicalUrl: String) {
synchronized(lock) {
fallbackReentry[canonicalUrl] = clock.elapsedRealtime() + fallbackReentryMs
}
}
fun isFallbackReentry(canonicalUrl: String): Boolean {
synchronized(lock) {
sweepExpiredLocked()
val expiresAt = fallbackReentry[canonicalUrl] ?: return false
return clock.elapsedRealtime() <= expiresAt
}
}
private fun sweepExpiredLocked() {
val now = clock.elapsedRealtime()
requests.values.removeAll { now > it.createdAt + requestExpiryMs }
suppression.values.removeAll { now > it }
fallbackReentry.values.removeAll { now > it }
}
companion object {
const val REQUEST_EXPIRY_MS = 10 * 60 * 1000L
const val SUPPRESSION_EXPIRY_MS = 10 * 60 * 1000L
const val DEDUPE_WINDOW_MS = 2000L
const val FALLBACK_REENTRY_MS = 10 * 1000L
}
}
@@ -0,0 +1,382 @@
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
package eu.weblibre.flutter_mozilla_components.applinks
import android.content.Context
import android.content.Intent
import android.net.Uri
import androidx.core.net.toUri
import eu.weblibre.flutter_mozilla_components.Components
import eu.weblibre.flutter_mozilla_components.GlobalComponents
import eu.weblibre.flutter_mozilla_components.ext.EventSequence
import eu.weblibre.flutter_mozilla_components.pigeons.AppLinkPromptOwner
import mozilla.components.browser.state.selector.findTabOrCustomTab
import mozilla.components.browser.state.state.CustomTabSessionState
import mozilla.components.browser.state.state.SessionState
import mozilla.components.concept.engine.EngineSession
import mozilla.components.concept.engine.request.RequestInterceptor
import mozilla.components.support.base.log.logger.Logger
import mozilla.components.support.ktx.kotlin.tryGetHostFromUrl
import java.util.Locale
/**
* The WebLibre-owned §2.4 interception tail (APP_LINKS_OWN_IMPLEMENTATION_PLAN.md Phase 5). Replaces
* Mozilla AC's `AppLinksInterceptor` + `AppLinksFeature` + `AppLinksCancelRetryMiddleware` on the
* synchronous `RequestInterceptor.onLoadRequest` path.
*
* Structural guards (PWA/TWA, sandbox capture, `weblibre://`, FxA) already ran in
* [eu.weblibre.flutter_mozilla_components.interceptor.AppRequestInterceptor] before this is called;
* this tail owns steps 28: navigation eligibility, resolution/sanitisation ([ExternalAppResolver]),
* the pure [AppLinkClassifier] decision, and its execution (auto-launch, validated fallback, or a
* pending prompt). Policy comes from the profile-scoped [AppLinkPolicyStore]; prompts land in the
* profile-scoped [PendingAppLinkStore]. It never denies a load and re-issues the same load.
*/
class WebLibreAppLinksInterceptor(
private val context: Context,
) {
private val logger = Logger("WebLibreAppLinks")
private val runtime get() = AppLinkRuntime.get(context)
/**
* @return the interception response, or `null` to let the engine proceed. Creating a pending
* prompt is a side effect performed here; the return value only controls the current load.
*/
fun onLoadRequest(
engineSession: EngineSession,
uri: String,
lastUri: String?,
hasUserGesture: Boolean,
isRedirect: Boolean,
isDirectNavigation: Boolean,
isSubframeRequest: Boolean,
): RequestInterceptor.InterceptionResponse? {
val components = GlobalComponents.components ?: return null
val uriScheme = runCatching { uri.toUri().scheme }.getOrNull()
val engineSupportsScheme = AppLinkSchemes.isEngineSupported(uriScheme)
// Step 2 — navigation eligibility. Any hit lets the engine proceed normally.
if (!isEligible(uri, lastUri, uriScheme, engineSupportsScheme, hasUserGesture, isRedirect, isDirectNavigation, isSubframeRequest)) {
return null
}
val pendingStore = pendingStoreFor(components)
// Fallback re-entry guard (§2.7): a fallback we issued has come back around. Keep it in the
// browser — never let it bounce out to an app. Consulted before resolution/classification.
if (pendingStore.isFallbackReentry(canonicalReentryKey(uri))) {
return null
}
val resolved = runtime.resolver.resolve(uri, includeHttpAppLinks = true, useCache = true)
val policy = AppLinkPolicyStores.forProfile(components.profileApplicationContext).policy
val session = components.core.store.state.findTabOrCustomTab(engineSession)
// Container isolation (replace semantics): a container with "isolated app link settings"
// enabled contributes an entry keyed by its contextId. When the source tab's contextId has
// one, its mode + rules fully replace the global ones for this navigation.
val override = session?.contextId?.let { policy.contextOverrides[it] }
val effectiveMode = override?.globalMode ?: policy.globalMode
val effectiveRules = override?.rules ?: policy.rules
val input = ClassifierInput(
resolved = resolved,
isProtected = isProtected(policy, session, uri),
isPrivate = session?.content?.private ?: false,
isWallet = AppLinkSchemes.isWallet(resolved.originalScheme) ||
AppLinkSchemes.isWallet(resolved.intentDataScheme),
missingSession = session == null,
suppressionHit = session != null &&
pendingStore.isSuppressed(session.id, targetFingerprint(uri, resolved)),
matchingRule = effectiveRules[resolved.scopeKey],
globalMode = effectiveMode,
marketplaceFallbackEnabled = policy.marketplaceFallbackEnabled,
)
val decision = AppLinkClassifier.classify(input)
logger.info(
"classify uri=$uri tab=${session?.id} ctx=${session?.contextId} " +
"isolated=${override != null} hasApp=${resolved.hasExternalApp} " +
"engineScheme=${resolved.engineSupportsScheme} mode=${input.globalMode} " +
"protected=${input.isProtected} private=${input.isPrivate} wallet=${input.isWallet} " +
"suppressed=${input.suppressionHit} rule=${input.matchingRule?.decision} -> $decision",
)
return execute(decision, components, pendingStore, session, uri, lastUri, input, hasUserGesture)
}
private fun execute(
decision: AppLinkDecision,
components: Components,
pendingStore: PendingAppLinkStore,
session: SessionState?,
uri: String,
lastUri: String?,
input: ClassifierInput,
hasUserGesture: Boolean,
): RequestInterceptor.InterceptionResponse? {
val resolved = input.resolved
return when (decision) {
is AppLinkDecision.AllowEngine -> null
is AppLinkDecision.DenyKeepPage -> RequestInterceptor.InterceptionResponse.Deny
is AppLinkDecision.LoadFallback -> {
pendingStore.recordFallbackReentry(canonicalReentryKey(decision.url))
RequestInterceptor.InterceptionResponse.Url(decision.url)
}
is AppLinkDecision.AutoLaunch -> {
val result = runtime.launcher.launch(
uri,
AppLinkLaunchMode.AUTOMATIC,
decision.expectedPackage,
)
when (result) {
AppLinkLaunchResult.LAUNCHED -> RequestInterceptor.InterceptionResponse.Deny
// A remembered `alwaysOpen` rule whose package no longer resolves must not
// silently launch a different app: fall through to a prompt (§2.5). Reclassify
// once with the rule removed so the global mode decides.
AppLinkLaunchResult.PACKAGE_MISMATCH -> {
val withoutRule = input.copy(matchingRule = null)
execute(
AppLinkClassifier.classify(withoutRule),
components, pendingStore, session, uri, lastUri, withoutRule, hasUserGesture,
)
}
// Launch failed/cooldown: answer in the original callback (§2.7). Never deny an
// engine-supported original and reload it — return null so it loads once.
else -> when {
resolved.engineSupportsScheme -> null
resolved.fallbackUrl != null -> {
pendingStore.recordFallbackReentry(canonicalReentryKey(resolved.fallbackUrl))
RequestInterceptor.InterceptionResponse.Url(resolved.fallbackUrl)
}
else -> RequestInterceptor.InterceptionResponse.Deny
}
}
}
is AppLinkDecision.Prompt -> {
// A missing session cannot host a prompt; the classifier never reaches Prompt in that
// case, so `session` is non-null here.
val tab = session ?: return safeNonLaunchResponse(pendingStore, resolved)
createPrompt(pendingStore, tab, uri, lastUri, input, decision, hasUserGesture)
if (decision.kind == AppLinkPromptKind.BANNER) {
// Engine-supported: allow the page to load while the non-modal banner is up.
null
} else {
// Unsupported scheme (or marketplace): the navigation is stalled, no page to show.
RequestInterceptor.InterceptionResponse.Deny
}
}
}
}
private fun createPrompt(
pendingStore: PendingAppLinkStore,
tab: SessionState,
uri: String,
lastUri: String?,
input: ClassifierInput,
decision: AppLinkDecision.Prompt,
hasUserGesture: Boolean,
) {
val resolved = input.resolved
val owner = if (tab is CustomTabSessionState) {
AppLinkPromptOwner.NATIVE_EXTERNAL
} else {
AppLinkPromptOwner.FLUTTER_BROWSER
}
val urlClass = when {
decision.isMarketplace -> AppLinkUrlClass.MARKETPLACE
decision.kind == AppLinkPromptKind.MODAL -> AppLinkUrlClass.MODAL
else -> AppLinkUrlClass.BANNER
}
val created = pendingStore.createRequest(
NewAppLinkRequest(
owner = owner,
tabId = tab.id,
contextId = tab.contextId,
sourceUrl = lastUri,
isPrivate = input.isPrivate,
isWallet = input.isWallet,
isProtectedContext = input.isProtected,
canRemember = decision.canRemember,
isModal = decision.kind == AppLinkPromptKind.MODAL,
urlClass = urlClass,
url = uri,
// The package to enforce at launch: only meaningful for a single,
// non-ambiguous handler. Null for an ambiguous/chooser target so the
// open path shows the chooser instead of refusing (§2.5/§2.7).
expectedPackage = if (resolved.isAmbiguous) null else resolved.packageName,
fallbackUrl = resolved.fallbackUrl,
engineSupportsScheme = resolved.engineSupportsScheme,
isMarketplace = decision.isMarketplace,
targetFingerprint = targetFingerprint(uri, resolved),
appName = resolved.appName,
packageName = resolved.packageName,
scopeKey = resolved.scopeKey,
isUserGesture = hasUserGesture,
),
)
logger.info(
"createPrompt owner=$owner tab=${tab.id} class=$urlClass id=${created.requestId} " +
"canRemember=${decision.canRemember} url=$uri",
)
when (owner) {
// The Custom Tab prompt feature only queries the store at lifecycle start, so a request
// created afterwards (this navigation, on an engine thread) needs an explicit nudge or it
// would sit unshown until a restart. The notifier re-queries on the main thread.
AppLinkPromptOwner.NATIVE_EXTERNAL ->
NativeAppLinkPromptNotifier.notifyPromptAvailable(tab.id)
// Best-effort availability nudge for the Flutter surface; the pending store + query is the
// contract (§2.8), so a lost event (Flutter detached) is harmless — it re-queries on resume.
AppLinkPromptOwner.FLUTTER_BROWSER ->
GlobalComponents.appLinkEvents?.onAppLinkPromptAvailable(EventSequence.next(), owner) { _ -> }
}
}
private fun safeNonLaunchResponse(
pendingStore: PendingAppLinkStore,
resolved: ResolvedAppLink,
): RequestInterceptor.InterceptionResponse? {
return if (resolved.engineSupportsScheme) {
null
} else {
resolved.fallbackUrl?.let {
pendingStore.recordFallbackReentry(canonicalReentryKey(it))
RequestInterceptor.InterceptionResponse.Url(it)
} ?: RequestInterceptor.InterceptionResponse.Deny
}
}
// ---- Eligibility (§2.4 step 2) ----
private fun isEligible(
uri: String,
lastUri: String?,
uriScheme: String?,
engineSupportsScheme: Boolean,
hasUserGesture: Boolean,
isRedirect: Boolean,
isDirectNavigation: Boolean,
isSubframeRequest: Boolean,
): Boolean {
if (uriScheme == null) return false
// A subframe request not triggered by the user and outside the allowlist stays in-page.
if (!hasUserGesture && isSubframeRequest && !AppLinkSchemes.isSubframeAllowed(uriScheme)) return false
val isAllowedRedirect = isRedirect && !isSubframeRequest
val isIntentionalNavigation = hasUserGesture || isAllowedRedirect || isDirectNavigation
// Unintentional engine-supported navigation continues in the browser.
if (engineSupportsScheme && !isIntentionalNavigation) return false
// Same-domain engine-supported navigation continues in the browser (AC subdomain stripping).
if (engineSupportsScheme && isSameDomain(lastUri, uri)) return false
// Always-denied schemes never resolve or launch externally.
if (AppLinkSchemes.isAlwaysDenied(uriScheme)) return false
return true
}
private fun isSameDomain(url1: String?, url2: String?): Boolean {
return stripCommonSubDomains(url1?.tryGetHostFromUrl()) ==
stripCommonSubDomains(url2?.tryGetHostFromUrl())
}
private fun stripCommonSubDomains(host: String?): String? {
return when {
host == null -> null
host.startsWith(WWW) -> host.replaceFirst(WWW, "")
host.startsWith(M) -> host.replaceFirst(M, "")
host.startsWith(MOBILE) -> host.replaceFirst(MOBILE, "")
host.startsWith(MAPS) -> host.replaceFirst(MAPS, "")
else -> host
}
}
// ---- Protection model (§2.3) ----
private fun isProtected(policy: AppLinkPolicy, session: SessionState?, uri: String): Boolean {
val contextId = session?.contextId
val protectedByContext = if (contextId == null) {
policy.protectGeneralContext
} else {
contextId in policy.protectedContextIds || contextId in policy.strictContextIds
}
if (protectedByContext) return true
return matchesProtectedTarget(policy.protectedTargetPatterns, uri)
}
private fun matchesProtectedTarget(patterns: List<ProtectedTargetPattern>, uri: String): Boolean {
if (patterns.isEmpty()) return false
val parsed = runCatching { Uri.parse(uri) }.getOrNull() ?: return false
val scheme = parsed.scheme?.lowercase(Locale.ROOT) ?: return false
val host = AppLinkHostNormalizer.normalizeHost(parsed.host) ?: return false
val effectivePort = if (parsed.port != -1) parsed.port else defaultPortForScheme(scheme)
return patterns.any { pattern ->
if (pattern.scheme.lowercase(Locale.ROOT) != scheme) return@any false
val patternHost = AppLinkHostNormalizer.normalizeHost(pattern.hostOrSuffix) ?: return@any false
if (pattern.includeSubdomains) {
// Wildcard entries match apex + subdomains and ignore port (§2.3).
host == patternHost || host.endsWith(".$patternHost")
} else {
// Exact entries compare scheme + origin including effective port.
host == patternHost && effectivePort == (pattern.port ?: defaultPortForScheme(scheme))
}
}
}
private fun defaultPortForScheme(scheme: String): Int = when (scheme) {
"http", "ws" -> 80
"https", "wss" -> 443
"ftp" -> 21
else -> -1
}
// ---- Helpers ----
/**
* The dedupe/invalidation/suppression key: the full sanitised target, not just the rule scope,
* so different paths sharing one policy scope never collapse into one request (§2.6).
*/
private fun targetFingerprint(uri: String, resolved: ResolvedAppLink): String {
val intentPayload = resolved.appIntent?.let {
runCatching { it.toUri(Intent.URI_INTENT_SCHEME) }.getOrNull()
}.orEmpty()
return buildString {
append(uri)
append('\u0000')
append(resolved.packageName.orEmpty())
append('\u0000')
append(intentPayload)
append('\u0000')
append(resolved.fallbackUrl.orEmpty())
}
}
/** Canonical key for the fallback re-entry map — the raw URL, matched on identity round-trip. */
private fun canonicalReentryKey(url: String): String = url
private fun pendingStoreFor(components: Components): PendingAppLinkStore {
return PendingAppLinkStores.forProfile(components.profileApplicationContext.relativePath)
}
companion object {
private const val WWW = "www."
private const val M = "m."
private const val MOBILE = "mobile."
private const val MAPS = "maps."
}
}
@@ -24,7 +24,8 @@ import eu.weblibre.flutter_mozilla_components.services.MediaSessionService
import eu.weblibre.flutter_mozilla_components.activities.NotificationActivity
import eu.weblibre.flutter_mozilla_components.R
import eu.weblibre.flutter_mozilla_components.ext.getPreferenceKey
import eu.weblibre.flutter_mozilla_components.middleware.AppLinksCancelRetryMiddleware
import eu.weblibre.flutter_mozilla_components.applinks.PendingAppLinkStores
import eu.weblibre.flutter_mozilla_components.middleware.AppLinkNavigationMiddleware
import eu.weblibre.flutter_mozilla_components.middleware.FlutterEventMiddleware
import eu.weblibre.flutter_mozilla_components.middleware.HistoryMetadataMiddleware
import eu.weblibre.flutter_mozilla_components.middleware.HistoryMetadataService
@@ -238,7 +239,12 @@ class Core(
// Must run before any engine middleware so we can rewrite
// sandbox new-tab URLs before Gecko issues a request.
SandboxCaptureMiddleware,
AppLinksCancelRetryMiddleware(),
// WebLibre-owned app-link pending-request invalidation + suppression clearing.
AppLinkNavigationMiddleware(
PendingAppLinkStores.forProfile(
components.profileApplicationContext.relativePath,
),
),
HistoryMetadataMiddleware(historyMetadataService),
// Correlates url -> contextId so WebLibreHistoryDelegate can
// resolve a visit's container at record time.
@@ -9,7 +9,6 @@ import android.content.Intent
import androidx.browser.customtabs.CustomTabsIntent
import androidx.core.net.toUri
import androidx.preference.PreferenceManager
import eu.weblibre.flutter_mozilla_components.GlobalComponents
import eu.weblibre.flutter_mozilla_components.R
import eu.weblibre.flutter_mozilla_components.activities.AuthIntentReceiverActivity
import eu.weblibre.flutter_mozilla_components.ext.getPreferenceKey
@@ -18,7 +17,6 @@ import mozilla.components.concept.engine.Engine
import mozilla.components.feature.accounts.FirefoxAccountsAuthFeature
import mozilla.components.feature.accounts.FxaCapability
import mozilla.components.feature.accounts.FxaWebChannelFeature
import mozilla.components.feature.app.links.AppLinksInterceptor
import mozilla.components.feature.tabs.TabsUseCases
import mozilla.components.service.fxa.ServerConfig
import mozilla.components.service.fxa.manager.FxaAccountManager
@@ -66,11 +64,4 @@ class Services(
)
}
val appLinksInterceptor by lazy {
AppLinksInterceptor(
context = context,
launchInApp = { GlobalComponents.shouldOpenLinksInApp() },
store = store,
)
}
}
@@ -8,7 +8,6 @@ import android.content.Context
import android.os.Environment
import mozilla.components.browser.state.store.BrowserStore
import mozilla.components.concept.engine.Engine
import mozilla.components.feature.app.links.AppLinksUseCases
import mozilla.components.feature.contextmenu.ContextMenuUseCases
import mozilla.components.feature.downloads.DownloadsUseCases
import mozilla.components.feature.session.SessionUseCases
@@ -70,8 +69,6 @@ class UseCases(
*/
val customTabsUseCases: CustomTabsUseCases by lazy { CustomTabsUseCases(store, sessionUseCases.loadUrl) }
val appLinksUseCases by lazy { AppLinksUseCases(context) }
val trackingProtectionUseCases by lazy { TrackingProtectionUseCases(store, engine) }
val webAppUseCases by lazy {
@@ -12,6 +12,7 @@ import android.content.Intent
import android.net.Uri
import android.util.Log
import eu.weblibre.flutter_mozilla_components.GlobalComponents
import eu.weblibre.flutter_mozilla_components.applinks.WebLibreAppLinksInterceptor
import eu.weblibre.flutter_mozilla_components.ext.EventSequence
import eu.weblibre.flutter_mozilla_components.feature.InertExternalSchemes
import eu.weblibre.flutter_mozilla_components.feature.SandboxCaptureBridge
@@ -30,6 +31,9 @@ class AppRequestInterceptor(private val context: Context) : RequestInterceptor {
requireNotNull(GlobalComponents.components) { "Components not initialized" }
}
// The WebLibre-owned §2.4 app-links tail.
private val webLibreAppLinks by lazy { WebLibreAppLinksInterceptor(context) }
override fun onLoadRequest(
engineSession: EngineSession,
uri: String,
@@ -130,12 +134,13 @@ class AppRequestInterceptor(private val context: Context) : RequestInterceptor {
return it
}
return components.services.appLinksInterceptor.onLoadRequest(
// App-links tail: the WebLibre-owned §2.4 implementation. Structural guards above
// (PWA/TWA, sandbox, weblibre://, FxA) already answered.
return webLibreAppLinks.onLoadRequest(
engineSession,
uri,
lastUri,
hasUserGesture,
isSameDomain,
isRedirect,
isDirectNavigation,
isSubframeRequest,
@@ -0,0 +1,72 @@
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
package eu.weblibre.flutter_mozilla_components.middleware
import eu.weblibre.flutter_mozilla_components.applinks.PendingAppLinkStore
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
import mozilla.components.browser.state.state.BrowserState
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):
*
* - 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.
*/
class AppLinkNavigationMiddleware(
private val store: PendingAppLinkStore,
) : Middleware<BrowserState, BrowserAction> {
override fun invoke(
store: Store<BrowserState, BrowserAction>,
next: (BrowserAction) -> Unit,
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)
}
is EngineAction.OptimizedLoadUrlTriggeredAction -> {
this.store.clearSuppressionForTab(action.tabId)
}
is TabListAction.RemoveTabAction -> {
this.store.invalidateTab(action.tabId)
}
is TabListAction.RemoveTabsAction -> {
action.tabIds.forEach(this.store::invalidateTab)
}
is CustomTabListAction.RemoveCustomTabAction -> {
this.store.invalidateTab(action.tabId)
}
else -> {}
}
next(action)
}
}
@@ -1,149 +0,0 @@
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
package eu.weblibre.flutter_mozilla_components.middleware
import android.os.Handler
import android.os.Looper
import mozilla.components.browser.state.action.BrowserAction
import mozilla.components.browser.state.action.ContentAction
import mozilla.components.browser.state.action.EngineAction
import mozilla.components.browser.state.selector.findTabOrCustomTab
import mozilla.components.browser.state.state.BrowserState
import mozilla.components.concept.engine.EngineSession
import mozilla.components.concept.engine.EngineSession.LoadUrlFlags.Companion.EXTERNAL
import mozilla.components.concept.engine.EngineSession.LoadUrlFlags.Companion.LOAD_FLAGS_BYPASS_LOAD_URI_DELEGATE
import mozilla.components.lib.state.Middleware
import mozilla.components.lib.state.Store
/**
* Workaround for Android Components/GeckoView app-link cancel handling: the
* cancel load can be clobbered by Gecko's recovery load back to the previous
* history entry. This retries only that specific cancel-load signature.
*/
class AppLinksCancelRetryMiddleware(
private val handler: Handler = Handler(Looper.getMainLooper()),
private val retryDelayMillis: Long = RETRY_DELAY_MILLIS,
) : Middleware<BrowserState, BrowserAction> {
private val pendingCancels = mutableMapOf<String, PendingCancel>()
override fun invoke(
store: Store<BrowserState, BrowserAction>,
next: (BrowserAction) -> Unit,
action: BrowserAction,
) {
when (action) {
is EngineAction.OptimizedLoadUrlTriggeredAction -> {
recordCancelLoad(store, action)
}
is ContentAction.UpdateLoadRequestAction -> {
handleLoadRequest(store, action)
}
is ContentAction.UpdateUrlAction -> {
handleUrlUpdate(action)
}
else -> {}
}
next(action)
}
private fun recordCancelLoad(
store: Store<BrowserState, BrowserAction>,
action: EngineAction.OptimizedLoadUrlTriggeredAction,
) {
if (!action.flags.contains(EXTERNAL) ||
!action.flags.contains(LOAD_FLAGS_BYPASS_LOAD_URI_DELEGATE)
) {
return
}
val sourceUrl = store.state.findTabOrCustomTab(action.tabId)?.content?.url
?: return
if (sourceUrl == action.url || sourceUrl == ABOUT_BLANK) {
return
}
pendingCancels[action.tabId] = PendingCancel(
tabId = action.tabId,
sourceUrl = sourceUrl,
targetUrl = action.url,
)
}
private fun handleLoadRequest(
store: Store<BrowserState, BrowserAction>,
action: ContentAction.UpdateLoadRequestAction,
) {
val pending = pendingCancels[action.sessionId] ?: return
when (action.loadRequest.url) {
pending.sourceUrl -> scheduleRetry(store, pending)
pending.targetUrl -> pendingCancels.remove(action.sessionId)
ABOUT_BLANK -> {}
else -> pendingCancels.remove(action.sessionId)
}
}
private fun handleUrlUpdate(action: ContentAction.UpdateUrlAction) {
val pending = pendingCancels[action.sessionId] ?: return
when (action.url) {
pending.targetUrl -> pendingCancels.remove(action.sessionId)
pending.sourceUrl, ABOUT_BLANK -> {}
else -> pendingCancels.remove(action.sessionId)
}
}
private fun scheduleRetry(
store: Store<BrowserState, BrowserAction>,
pending: PendingCancel,
) {
if (pending.retryScheduled) {
return
}
val scheduled = pending.copy(retryScheduled = true)
pendingCancels[pending.tabId] = scheduled
handler.postDelayed({
if (pendingCancels[pending.tabId] != scheduled) {
return@postDelayed
}
val currentUrl = store.state.findTabOrCustomTab(pending.tabId)?.content?.url
if (currentUrl == pending.targetUrl) {
pendingCancels.remove(pending.tabId)
return@postDelayed
}
if (currentUrl == pending.sourceUrl || currentUrl == ABOUT_BLANK) {
pendingCancels.remove(pending.tabId)
store.dispatch(
EngineAction.LoadUrlAction(
tabId = pending.tabId,
url = pending.targetUrl,
flags = EngineSession.LoadUrlFlags.select(
LOAD_FLAGS_BYPASS_LOAD_URI_DELEGATE,
),
),
)
} else {
pendingCancels.remove(pending.tabId)
}
}, retryDelayMillis)
}
private data class PendingCancel(
val tabId: String,
val sourceUrl: String,
val targetUrl: String,
val retryScheduled: Boolean = false,
)
private companion object {
const val ABOUT_BLANK = "about:blank"
const val RETRY_DELAY_MILLIS = 1000L
}
}
@@ -30,4 +30,12 @@
<string name="private_tabs_notification_title_android_14">Close private tabs?</string>
<string name="private_tabs_notification_text_android_14">Tap or swipe this notification to close private tabs.</string>
<!-- App-link prompt shown in native Custom Tab sessions (no Flutter engine). -->
<!-- %1$s is the target app name. -->
<string name="weblibre_app_link_prompt_title_named">Open in %1$s?</string>
<string name="weblibre_app_link_prompt_title_generic">Open in another app?</string>
<string name="weblibre_app_link_prompt_message">This link is handled by an app outside WebLibre.</string>
<string name="weblibre_app_link_prompt_open">Open</string>
<string name="weblibre_app_link_prompt_cancel">Cancel</string>
</resources>
@@ -0,0 +1,287 @@
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
package eu.weblibre.flutter_mozilla_components.applinks
import android.content.Intent
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
import org.mockito.Mockito.mock
class AppLinkClassifierTest {
private fun resolved(
hasExternalApp: Boolean = true,
engineSupportsScheme: Boolean = false,
fallbackUrl: String? = null,
marketplace: Boolean = false,
isAmbiguous: Boolean = false,
packageName: String? = "com.example.app",
) = ResolvedAppLink(
hasExternalApp = hasExternalApp,
appIntent = null,
packageName = if (hasExternalApp) packageName else null,
appName = "Example",
fallbackUrl = fallbackUrl,
marketplaceIntent = if (marketplace) mock(Intent::class.java) else null,
isAmbiguous = isAmbiguous,
engineSupportsScheme = engineSupportsScheme,
scopeKey = "host:example.com",
originalScheme = if (engineSupportsScheme) "https" else "zoommtg",
intentDataScheme = if (engineSupportsScheme) "https" else "zoommtg",
)
private fun input(
resolved: ResolvedAppLink,
isProtected: Boolean = false,
isPrivate: Boolean = false,
isWallet: Boolean = false,
missingSession: Boolean = false,
suppressionHit: Boolean = false,
matchingRule: AppLinkRule? = null,
globalMode: AppLinkMode = AppLinkMode.ASK,
marketplaceFallbackEnabled: Boolean = false,
) = ClassifierInput(
resolved = resolved,
isProtected = isProtected,
isPrivate = isPrivate,
isWallet = isWallet,
missingSession = missingSession,
suppressionHit = suppressionHit,
matchingRule = matchingRule,
globalMode = globalMode,
marketplaceFallbackEnabled = marketplaceFallbackEnabled,
)
// ---- §2.2 table: engine-supported (http) scheme, app resolves ----
@Test
fun engineSupportedAlwaysAutoLaunches() {
val d = AppLinkClassifier.classify(
input(resolved(engineSupportsScheme = true), globalMode = AppLinkMode.ALWAYS),
)
assertEquals(AppLinkDecision.AutoLaunch(expectedPackage = null), d)
}
@Test
fun engineSupportedAskShowsBanner() {
val d = AppLinkClassifier.classify(
input(resolved(engineSupportsScheme = true), globalMode = AppLinkMode.ASK),
)
assertEquals(
AppLinkDecision.Prompt(AppLinkPromptKind.BANNER, canRemember = true, isMarketplace = false),
d,
)
}
@Test
fun engineSupportedNeverAllowsPage() {
val d = AppLinkClassifier.classify(
input(resolved(engineSupportsScheme = true), globalMode = AppLinkMode.NEVER),
)
assertEquals(AppLinkDecision.AllowEngine, d)
}
// ---- §2.2 table: unsupported scheme, app resolves ----
@Test
fun unsupportedAskShowsModal() {
val d = AppLinkClassifier.classify(
input(resolved(engineSupportsScheme = false), globalMode = AppLinkMode.ASK),
)
assertEquals(
AppLinkDecision.Prompt(AppLinkPromptKind.MODAL, canRemember = true, isMarketplace = false),
d,
)
}
@Test
fun unsupportedNeverWithFallbackLoadsFallback() {
val d = AppLinkClassifier.classify(
input(
resolved(engineSupportsScheme = false, fallbackUrl = "https://fallback.example"),
globalMode = AppLinkMode.NEVER,
),
)
assertEquals(AppLinkDecision.LoadFallback("https://fallback.example"), d)
}
@Test
fun unsupportedNeverWithoutFallbackKeepsPage() {
val d = AppLinkClassifier.classify(
input(resolved(engineSupportsScheme = false), globalMode = AppLinkMode.NEVER),
)
assertEquals(AppLinkDecision.DenyKeepPage, d)
}
// ---- §2.2 table: no app ----
@Test
fun noAppWithFallbackLoadsFallback() {
val d = AppLinkClassifier.classify(
input(resolved(hasExternalApp = false, fallbackUrl = "https://fb.example")),
)
assertEquals(AppLinkDecision.LoadFallback("https://fb.example"), d)
}
@Test
fun noAppNoFallbackEngineSupportedAllows() {
val d = AppLinkClassifier.classify(
input(resolved(hasExternalApp = false, engineSupportsScheme = true)),
)
assertEquals(AppLinkDecision.AllowEngine, d)
}
@Test
fun noAppNoFallbackUnsupportedDenies() {
val d = AppLinkClassifier.classify(
input(resolved(hasExternalApp = false, engineSupportsScheme = false)),
)
assertEquals(AppLinkDecision.DenyKeepPage, d)
}
@Test
fun noAppMarketplaceWhenEnabledAndNotNever() {
val d = AppLinkClassifier.classify(
input(
resolved(hasExternalApp = false, marketplace = true),
globalMode = AppLinkMode.ASK,
marketplaceFallbackEnabled = true,
),
)
assertEquals(
AppLinkDecision.Prompt(AppLinkPromptKind.MODAL, canRemember = false, isMarketplace = true),
d,
)
}
@Test
fun noAppMarketplaceSuppressedUnderNever() {
val d = AppLinkClassifier.classify(
input(
resolved(hasExternalApp = false, marketplace = true, engineSupportsScheme = false),
globalMode = AppLinkMode.NEVER,
marketplaceFallbackEnabled = true,
),
)
assertEquals(AppLinkDecision.DenyKeepPage, d)
}
// ---- §2.4 precedence: forced-prompt contexts override rules ----
@Test
fun protectedContextPromptsEvenWithAlwaysOpenRule() {
val d = AppLinkClassifier.classify(
input(
resolved(engineSupportsScheme = true),
isProtected = true,
matchingRule = AppLinkRule(AppLinkRuleDecision.ALWAYS_OPEN, "host:example.com", "com.example.app"),
globalMode = AppLinkMode.ALWAYS,
),
)
assertEquals(
AppLinkDecision.Prompt(AppLinkPromptKind.BANNER, canRemember = false, isMarketplace = false),
d,
)
}
@Test
fun privateTabPromptsWithoutRemember() {
val d = AppLinkClassifier.classify(
input(resolved(engineSupportsScheme = false), isPrivate = true, globalMode = AppLinkMode.ALWAYS),
)
assertEquals(
AppLinkDecision.Prompt(AppLinkPromptKind.MODAL, canRemember = false, isMarketplace = false),
d,
)
}
@Test
fun walletPromptsWithoutRemember() {
val d = AppLinkClassifier.classify(
input(resolved(engineSupportsScheme = false), isWallet = true),
)
assertTrue(d is AppLinkDecision.Prompt && !d.canRemember)
}
// (helpers above build ResolvedAppLink/ClassifierInput.)
@Test
fun missingSessionNeverAutoLaunches() {
// Engine-supported → allow the page; unsupported → deny (or fallback).
assertEquals(
AppLinkDecision.AllowEngine,
AppLinkClassifier.classify(
input(resolved(engineSupportsScheme = true), missingSession = true, globalMode = AppLinkMode.ALWAYS),
),
)
assertEquals(
AppLinkDecision.DenyKeepPage,
AppLinkClassifier.classify(
input(resolved(engineSupportsScheme = false), missingSession = true, globalMode = AppLinkMode.ALWAYS),
),
)
}
// ---- §2.4 step 5: suppression ----
@Test
fun suppressionHitNeverLaunchesEngineSupported() {
val d = AppLinkClassifier.classify(
input(resolved(engineSupportsScheme = true), suppressionHit = true, globalMode = AppLinkMode.ALWAYS),
)
assertEquals(AppLinkDecision.AllowEngine, d)
}
@Test
fun suppressionHitUnsupportedUsesFallbackOnly() {
val d = AppLinkClassifier.classify(
input(
resolved(engineSupportsScheme = false, fallbackUrl = "https://fb.example"),
suppressionHit = true,
globalMode = AppLinkMode.ALWAYS,
),
)
assertEquals(AppLinkDecision.LoadFallback("https://fb.example"), d)
}
// ---- §2.4 step 6: remembered rules ----
@Test
fun alwaysOpenRuleAutoLaunchesWithExpectedPackage() {
val d = AppLinkClassifier.classify(
input(
resolved(engineSupportsScheme = true),
matchingRule = AppLinkRule(AppLinkRuleDecision.ALWAYS_OPEN, "host:example.com", "com.example.app"),
globalMode = AppLinkMode.ASK,
),
)
assertEquals(AppLinkDecision.AutoLaunch(expectedPackage = "com.example.app"), d)
}
@Test
fun neverOpenRuleFollowsNeverRow() {
val d = AppLinkClassifier.classify(
input(
resolved(engineSupportsScheme = false),
matchingRule = AppLinkRule(AppLinkRuleDecision.NEVER_OPEN, "host:example.com", null),
globalMode = AppLinkMode.ALWAYS,
),
)
assertEquals(AppLinkDecision.DenyKeepPage, d)
}
@Test
fun ambiguousResolutionCannotBeRemembered() {
val d = AppLinkClassifier.classify(
input(resolved(engineSupportsScheme = true, isAmbiguous = true), globalMode = AppLinkMode.ASK),
)
assertEquals(
AppLinkDecision.Prompt(AppLinkPromptKind.BANNER, canRemember = false, isMarketplace = false),
d,
)
}
}
@@ -0,0 +1,60 @@
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
package eu.weblibre.flutter_mozilla_components.applinks
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNull
class AppLinkHostNormalizerTest {
@Test
fun lowercasesAndStripsTrailingDot() {
assertEquals("youtube.com", AppLinkHostNormalizer.normalizeHost("YouTube.com"))
assertEquals("youtube.com", AppLinkHostNormalizer.normalizeHost("youtube.com."))
assertEquals("youtube.com", AppLinkHostNormalizer.normalizeHost("YOUTUBE.COM."))
}
@Test
fun convertsNonAsciiHostsToPunycode() {
// bücher.example → xn--bcher-kva.example
assertEquals(
"xn--bcher-kva.example",
AppLinkHostNormalizer.normalizeHost("bücher.example"),
)
}
@Test
fun rejectsEmptyAndInvalidHosts() {
assertNull(AppLinkHostNormalizer.normalizeHost(null))
assertNull(AppLinkHostNormalizer.normalizeHost(""))
assertNull(AppLinkHostNormalizer.normalizeHost("."))
}
@Test
fun rejectsIpv6ZoneIds() {
assertNull(AppLinkHostNormalizer.normalizeHost("fe80::1%eth0"))
assertNull(AppLinkHostNormalizer.normalizeHost("[fe80::1%eth0]"))
}
@Test
fun canonicalisesIpLiterals() {
assertEquals("127.0.0.1", AppLinkHostNormalizer.normalizeHost("127.0.0.1"))
// Leading zeros / equivalent forms normalise to canonical dotted-quad.
assertEquals("[::1]", AppLinkHostNormalizer.normalizeHost("[::1]"))
}
@Test
fun buildsScopeKeys() {
assertEquals("host:youtube.com", AppLinkHostNormalizer.hostScopeKey("YouTube.com"))
assertNull(AppLinkHostNormalizer.hostScopeKey(""))
assertEquals(
"pkg:us.zoom.videomeetings",
AppLinkHostNormalizer.packageScopeKey("us.zoom.videomeetings"),
)
assertNull(AppLinkHostNormalizer.packageScopeKey(null))
}
}
@@ -0,0 +1,114 @@
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
package eu.weblibre.flutter_mozilla_components.applinks
import android.content.ActivityNotFoundException
import android.content.Intent
import kotlin.test.Test
import kotlin.test.assertEquals
import org.mockito.ArgumentMatchers.anyBoolean
import org.mockito.ArgumentMatchers.anyString
import org.mockito.Mockito.mock
import org.mockito.Mockito.`when`
class AppLinkLauncherTest {
private class FakeClock(var now: Long = 0L) : MonotonicClock {
override fun elapsedRealtime(): Long = now
}
private fun resolvedFor(packageName: String?, marketplace: Boolean = false): ResolvedAppLink {
return ResolvedAppLink(
hasExternalApp = packageName != null,
appIntent = if (packageName != null) mock(Intent::class.java) else null,
packageName = packageName,
appName = "App",
fallbackUrl = null,
marketplaceIntent = if (marketplace) mock(Intent::class.java) else null,
isAmbiguous = false,
engineSupportsScheme = false,
scopeKey = "pkg:$packageName",
originalScheme = "zoommtg",
intentDataScheme = "zoommtg",
)
}
private fun launcher(
resolved: ResolvedAppLink,
clock: FakeClock,
onStart: (Intent) -> Unit = {},
): AppLinkLauncher {
val resolver = mock(ExternalAppResolver::class.java)
`when`(resolver.resolve(anyString(), anyBoolean(), anyBoolean())).thenReturn(resolved)
return AppLinkLauncher(resolver, onStart, clock)
}
@Test
fun noAppReturnsNoApp() {
val l = launcher(resolvedFor(null), FakeClock())
assertEquals(AppLinkLaunchResult.NO_APP, l.launch("zoommtg://x", AppLinkLaunchMode.MANUAL))
}
@Test
fun packageMismatchIsRefused() {
val l = launcher(resolvedFor("com.actual.app"), FakeClock())
assertEquals(
AppLinkLaunchResult.PACKAGE_MISMATCH,
l.launch("zoommtg://x", AppLinkLaunchMode.AUTOMATIC, expectedPackage = "com.expected.app"),
)
}
@Test
fun successfulManualLaunchStartsActivity() {
var started = 0
val l = launcher(resolvedFor("com.example.app"), FakeClock()) { started++ }
assertEquals(AppLinkLaunchResult.LAUNCHED, l.launch("zoommtg://x", AppLinkLaunchMode.MANUAL))
assertEquals(1, started)
}
@Test
fun automaticLaunchWithinCooldownIsRefused() {
val clock = FakeClock(1000L)
val l = launcher(resolvedFor("com.example.app"), clock)
assertEquals(AppLinkLaunchResult.LAUNCHED, l.launch("zoommtg://x", AppLinkLaunchMode.AUTOMATIC))
clock.now = 1500L // < 2000 ms later
assertEquals(AppLinkLaunchResult.COOLDOWN, l.launch("zoommtg://x", AppLinkLaunchMode.AUTOMATIC))
}
@Test
fun automaticLaunchAfterCooldownSucceeds() {
val clock = FakeClock(1000L)
val l = launcher(resolvedFor("com.example.app"), clock)
assertEquals(AppLinkLaunchResult.LAUNCHED, l.launch("zoommtg://x", AppLinkLaunchMode.AUTOMATIC))
clock.now = 3001L // > 2000 ms later
assertEquals(AppLinkLaunchResult.LAUNCHED, l.launch("zoommtg://x", AppLinkLaunchMode.AUTOMATIC))
}
@Test
fun manualLaunchBypassesCooldownButRecordsIt() {
val clock = FakeClock(1000L)
val l = launcher(resolvedFor("com.example.app"), clock)
// Two manual launches back-to-back both succeed (user gesture bypasses the check).
assertEquals(AppLinkLaunchResult.LAUNCHED, l.launch("zoommtg://x", AppLinkLaunchMode.MANUAL))
assertEquals(AppLinkLaunchResult.LAUNCHED, l.launch("zoommtg://x", AppLinkLaunchMode.MANUAL))
// But the manual launch recorded the timestamp, so a following automatic launch is cooled.
assertEquals(AppLinkLaunchResult.COOLDOWN, l.launch("zoommtg://x", AppLinkLaunchMode.AUTOMATIC))
}
@Test
fun activityNotFoundYieldsFailed() {
val l = launcher(resolvedFor("com.example.app"), FakeClock()) {
throw ActivityNotFoundException("no activity")
}
assertEquals(AppLinkLaunchResult.FAILED, l.launch("zoommtg://x", AppLinkLaunchMode.MANUAL))
}
@Test
fun marketplaceModeWithoutMarketplaceIntentIsNoApp() {
val l = launcher(resolvedFor("com.example.app", marketplace = false), FakeClock())
assertEquals(AppLinkLaunchResult.NO_APP, l.launch("market://x", AppLinkLaunchMode.MARKETPLACE))
}
}
@@ -0,0 +1,68 @@
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
package eu.weblibre.flutter_mozilla_components.applinks
import kotlin.test.Test
import kotlin.test.assertFalse
import kotlin.test.assertTrue
class AppLinkSchemesTest {
@Test
fun engineSupportedSchemesMatchTheFrozenTable() {
for (scheme in listOf(
"about", "data", "file", "ftp", "http", "https", "moz-extension",
"moz-safe-about", "resource", "view-source", "ws", "wss", "blob",
)) {
assertTrue(AppLinkSchemes.isEngineSupported(scheme), "$scheme should be engine-supported")
// Case-insensitive: a mixed-case spelling matches too.
assertTrue(
AppLinkSchemes.isEngineSupported(scheme.uppercase()),
"${scheme.uppercase()} should be engine-supported (case-insensitive)",
)
}
assertFalse(AppLinkSchemes.isEngineSupported("zoommtg"))
assertFalse(AppLinkSchemes.isEngineSupported(null))
}
@Test
fun alwaysDeniedSchemesMatchTheFrozenTable() {
for (scheme in listOf("jar", "file", "javascript", "data", "about", "content", "fido")) {
assertTrue(AppLinkSchemes.isAlwaysDenied(scheme), "$scheme should be always-denied")
}
// JavaScript: must be denied as surely as javascript:.
assertTrue(AppLinkSchemes.isAlwaysDenied("JavaScript"))
assertTrue(AppLinkSchemes.isAlwaysDenied("FILE"))
assertFalse(AppLinkSchemes.isAlwaysDenied("https"))
}
@Test
fun subframeAllowedSchemes() {
assertTrue(AppLinkSchemes.isSubframeAllowed("msteams"))
assertTrue(AppLinkSchemes.isSubframeAllowed("MSTeams"))
assertFalse(AppLinkSchemes.isSubframeAllowed("whatsapp"))
}
@Test
fun walletSchemes() {
for (scheme in listOf(
"openid4vp", "mdoc", "mdoc-openid4vp", "haip", "eudi-wallet",
"eudi-openid4vp", "openid-credential-offer",
)) {
assertTrue(AppLinkSchemes.isWallet(scheme), "$scheme should be a wallet scheme")
}
assertTrue(AppLinkSchemes.isWallet("OpenID4VP"))
assertFalse(AppLinkSchemes.isWallet("https"))
}
@Test
fun httpOrHttpsIsCaseInsensitive() {
assertTrue(AppLinkSchemes.isHttpOrHttps("http"))
assertTrue(AppLinkSchemes.isHttpOrHttps("HTTPS"))
assertFalse(AppLinkSchemes.isHttpOrHttps("ftp"))
assertFalse(AppLinkSchemes.isHttpOrHttps(null))
}
}
@@ -0,0 +1,184 @@
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
package eu.weblibre.flutter_mozilla_components.applinks
import eu.weblibre.flutter_mozilla_components.pigeons.AppLinkPromptOwner
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertNotEquals
import kotlin.test.assertNotNull
import kotlin.test.assertNull
import kotlin.test.assertTrue
class PendingAppLinkStoreTest {
private class FakeClock(var now: Long = 0L) : MonotonicClock {
override fun elapsedRealtime(): Long = now
}
private fun newRequest(
tabId: String = "tab1",
fingerprint: String = "fp1",
owner: AppLinkPromptOwner = AppLinkPromptOwner.FLUTTER_BROWSER,
urlClass: AppLinkUrlClass = AppLinkUrlClass.MODAL,
url: String = "zoommtg://join",
isUserGesture: Boolean = false,
) = NewAppLinkRequest(
owner = owner,
tabId = tabId,
contextId = null,
sourceUrl = null,
isPrivate = false,
isWallet = false,
isProtectedContext = false,
canRemember = true,
isModal = urlClass == AppLinkUrlClass.MODAL,
urlClass = urlClass,
url = url,
expectedPackage = null,
fallbackUrl = null,
engineSupportsScheme = false,
isMarketplace = false,
targetFingerprint = fingerprint,
appName = "App",
packageName = "com.app",
scopeKey = "pkg:com.app",
isUserGesture = isUserGesture,
)
@Test
fun idsAreMonotonic() {
val store = PendingAppLinkStore(FakeClock())
val a = store.createRequest(newRequest(fingerprint = "a"))
val b = store.createRequest(newRequest(fingerprint = "b"))
assertNotEquals(a.requestId, b.requestId)
assertTrue(b.requestId > a.requestId)
}
@Test
fun queryIsNonConsumingAndConsumeIsAtomic() {
val store = PendingAppLinkStore(FakeClock())
val request = store.createRequest(newRequest())
assertEquals(1, store.getPending(AppLinkPromptOwner.FLUTTER_BROWSER).size)
// Non-consuming.
assertEquals(1, store.getPending(AppLinkPromptOwner.FLUTTER_BROWSER).size)
assertEquals(request.requestId, store.consume(request.requestId)?.requestId)
// Double-consume is a no-op.
assertNull(store.consume(request.requestId))
}
@Test
fun ownerFilterSeparatesSurfaces() {
val store = PendingAppLinkStore(FakeClock())
store.createRequest(newRequest(owner = AppLinkPromptOwner.FLUTTER_BROWSER, fingerprint = "a"))
store.createRequest(newRequest(owner = AppLinkPromptOwner.NATIVE_EXTERNAL, fingerprint = "b"))
assertEquals(1, store.getPending(AppLinkPromptOwner.FLUTTER_BROWSER).size)
assertEquals(1, store.getPending(AppLinkPromptOwner.NATIVE_EXTERNAL).size)
}
@Test
fun dedupeCollapsesWithinWindowButNotAcrossUserGesture() {
val clock = FakeClock()
val store = PendingAppLinkStore(clock, dedupeWindowMs = 2000L)
val first = store.createRequest(newRequest())
clock.now = 1000L
val second = store.createRequest(newRequest())
assertEquals(first.requestId, second.requestId)
// A user-gesture attempt is never deduped.
val gesture = store.createRequest(newRequest(isUserGesture = true))
assertNotEquals(first.requestId, gesture.requestId)
}
@Test
fun distinctFingerprintsSharingAScopeAreNotDeduped() {
val store = PendingAppLinkStore(FakeClock())
val a = store.createRequest(newRequest(fingerprint = "path-a"))
val b = store.createRequest(newRequest(fingerprint = "path-b"))
assertNotEquals(a.requestId, b.requestId)
}
@Test
fun bannerTargetCommitKeepsRequestButUnrelatedCommitInvalidates() {
val store = PendingAppLinkStore(FakeClock())
val banner = store.createRequest(
newRequest(urlClass = AppLinkUrlClass.BANNER, url = "https://youtu.be/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))
}
@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"),
)
store.onCommittedNavigation("tab1", "https://reddit.com/r/foo/?utm_source=share")
assertNotNull(store.peek(banner.requestId))
// A commit to a genuinely different site still invalidates it.
store.onCommittedNavigation("tab1", "https://twitter.com/reddit")
assertNull(store.peek(banner.requestId))
}
@Test
fun tabCloseInvalidatesRequestsAndSuppression() {
val store = PendingAppLinkStore(FakeClock())
val request = store.createRequest(newRequest())
store.recordSuppression("tab1", "fp1")
store.invalidateTab("tab1")
assertNull(store.peek(request.requestId))
assertFalse(store.isSuppressed("tab1", "fp1"))
}
@Test
fun suppressionSurvivesRedirectsButClearsOnDirectNavAndTimeout() {
val clock = FakeClock()
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")
assertTrue(store.isSuppressed("tab1", "fp1"))
// Direct navigation clears it.
store.clearSuppressionForTab("tab1")
assertFalse(store.isSuppressed("tab1", "fp1"))
// Timeout clears it.
store.recordSuppression("tab1", "fp2")
clock.now = 1001L
assertFalse(store.isSuppressed("tab1", "fp2"))
}
@Test
fun requestsExpire() {
val clock = FakeClock()
val store = PendingAppLinkStore(clock, requestExpiryMs = 1000L)
val request = store.createRequest(newRequest())
clock.now = 1001L
assertNull(store.consume(request.requestId))
}
@Test
fun fallbackReentryIsReusableInWindowAndExpires() {
val clock = FakeClock()
val store = PendingAppLinkStore(clock, fallbackReentryMs = 10_000L)
store.recordFallbackReentry("https://fallback.example/")
assertTrue(store.isFallbackReentry("https://fallback.example/"))
// Reusable within its window (does not consume).
assertTrue(store.isFallbackReentry("https://fallback.example/"))
clock.now = 10_001L
assertFalse(store.isFallbackReentry("https://fallback.example/"))
}
}
@@ -50,6 +50,12 @@ export 'src/pigeons/gecko.g.dart'
AddonStorePromoted,
AddonUpdateAttemptInfo,
AddonUpdateStatus,
AppLinkDecision,
AppLinkPolicySnapshot,
AppLinkPromptOwner,
AppLinkPromptRequest,
AppLinkResolutionResult,
AppLinkTarget,
AppLinksMode,
AudioHitResult,
AutoplayStatus,
@@ -70,6 +76,7 @@ export 'src/pigeons/gecko.g.dart'
DownloadStatus,
EmailHitResult,
FrecencyThresholdOption,
GeckoAppLinkEvents,
GeckoDeleteBrowsingDataController,
GeckoEngineSettings,
GeckoFetchResponse,
@@ -99,7 +106,11 @@ export 'src/pigeons/gecko.g.dart'
MlProgressData,
MlProgressStatus,
MlProgressType,
NativeAppLinkRule,
NativeAppLinkRuleDecision,
NativeContextAppLinkPolicy,
PhoneHitResult,
ProtectedTargetPattern,
ProxyLoadError,
PushDistributor,
PushDistributorStatus,
@@ -10,31 +10,52 @@ final _api = GeckoAppLinksApi();
/// Service for detecting and launching external applications that can handle URLs.
///
/// This service wraps Mozilla Android Components' AppLinksUseCases to allow
/// checking if native apps can handle URLs and launching them directly.
/// This matches the behavior in Firefox/Fenix for "Open in App" functionality.
/// WebLibre-owned resolution/launch surface. Policy lives in Dart; the native side
/// owns PackageManager resolution and Intent launch. Used by the manual
/// "Open in app" entry points.
class GeckoAppLinksService {
/// Checks if an external application is available to handle the given URL.
/// Resolve [url] to an external-app target, or null when no external app is
/// available (or on any resolution error / always-denied scheme).
///
/// This method uses mozilla-components AppLinksUseCases to determine if
/// a native app can handle the URL (e.g., YouTube app for youtube.com links).
///
/// @param url The URL to check.
/// @return true if an external app is available, false otherwise.
Future<bool> hasExternalApp(Uri url) {
return _api.hasExternalApp(url.toString());
/// [includeHttpAppLinks] when true, an app resolving an engine-supported
/// (http(s)) URL is surfaced (e.g. the YouTube app for a youtube.com link).
Future<AppLinkTarget?> resolveAppLink(
Uri url, {
bool includeHttpAppLinks = true,
}) {
return _api.resolveAppLink(url.toString(), includeHttpAppLinks);
}
/// Opens the URL in an external application if available.
/// Re-resolve [url] and launch it in an external app.
///
/// This method will:
/// 1. Check if an external app can handle the URL
/// 2. If available, launch the app directly with Intent.FLAG_ACTIVITY_NEW_TASK
/// 3. Return true if successfully launched, false otherwise
/// Returns true if launched, false if no app is available or the launch failed.
Future<bool> launchAppLink(Uri url) {
return _api.launchAppLink(url.toString());
}
/// Push the complete app-link policy snapshot to native (last-write-wins).
///
/// @param url The URL to open in external app.
/// @return true if URL was opened in external app, false if no app available.
Future<bool> openAppLink(Uri url) {
return _api.openAppLink(url.toString());
/// Throws if no profile is bound yet; the caller (replicator) retries after
/// initialisation.
Future<void> setAppLinkPolicy(AppLinkPolicySnapshot snapshot) {
return _api.setAppLinkPolicy(snapshot);
}
/// Non-consuming query of pending prompts for [owner] (§2.6). Query on
/// attach/resume and when the availability event fires; render idempotently by
/// requestId.
Future<List<AppLinkPromptRequest>> getPendingAppLinkPrompts(
AppLinkPromptOwner owner,
) {
return _api.getPendingAppLinkPrompts(owner);
}
/// Atomically resolve a pending prompt (§2.6). A double-resolve or stale id is
/// a no-op returning `failureReason == "stale"`.
Future<AppLinkResolutionResult> resolvePendingAppLink(
int requestId,
AppLinkDecision decision,
) {
return _api.resolvePendingAppLink(requestId, decision);
}
}
@@ -199,16 +199,6 @@ class GeckoEngineSettingsService {
return _api.setPullToRefreshEnabled(enabled);
}
/// Sets the app links mode preference.
/// Controls how external app links are handled in browser.
Future<void> setAppLinksMode(AppLinksMode mode) {
return _api.setAppLinksMode(mode);
}
Future<AppLinksMode> getAppLinksMode() {
return _api.getAppLinksMode();
}
/// Sets whether to use external download managers for downloads.
/// When enabled, downloads are forwarded to third-party apps like ADM, 1DM, AB DM.
Future<void> setUseExternalDownloadManager(bool enabled) {
File diff suppressed because it is too large Load Diff
@@ -1557,12 +1557,6 @@ abstract class GeckoEngineSettingsApi {
void setScreenshotProtectionEnabled(bool enabled);
void setPullToRefreshEnabled(bool enabled);
/// Sets the app links mode preference (stored in SharedPreferences).
/// Controls how external app links are handled in the browser.
void setAppLinksMode(AppLinksMode mode);
AppLinksMode getAppLinksMode();
/// Sets whether to use external download managers for downloads.
/// When enabled, downloads are forwarded to third-party apps like ADM, 1DM, AB DM.
void setUseExternalDownloadManager(bool enabled);
@@ -2800,31 +2794,239 @@ abstract class GeckoTrackingProtectionApi {
// App Links API
// =============================================================================
/// Resolved external-app target for a URL (see APP_LINKS_OWN_IMPLEMENTATION_PLAN.md §2.8).
class AppLinkTarget {
/// The URL that was resolved.
final String url;
/// User-facing app label (control/bidi-sanitised), or null when unknown.
final String? appName;
/// Resolved package name, or null when ambiguous / unknown.
final String? packageName;
/// Pre-validated http(s) fallback URL, or null.
final String? fallbackUrl;
/// True when the only offer is a marketplace (install-app) intent.
final bool isMarketplace;
/// True when resolution is ambiguous (chooser / multiple handlers / no default).
final bool isAmbiguous;
/// True when the Gecko engine can load the URL scheme itself.
final bool engineSupportsScheme;
/// Canonical native-owned rule scope key ("host:youtube.com" | "pkg:...").
final String scopeKey;
const AppLinkTarget({
required this.url,
this.appName,
this.packageName,
this.fallbackUrl,
required this.isMarketplace,
required this.isAmbiguous,
required this.engineSupportsScheme,
required this.scopeKey,
});
}
/// Target-side protection pattern replicated to native (§2.3/§2.8). Any target
/// assigned to an effectively-proxied or strict container is protected
/// independent of the source tab.
class ProtectedTargetPattern {
final String scheme;
final String hostOrSuffix;
final bool includeSubdomains;
/// Effective port for exact entries; null for wildcard entries (ignore port).
final int? port;
const ProtectedTargetPattern({
required this.scheme,
required this.hostOrSuffix,
required this.includeSubdomains,
this.port,
});
}
enum NativeAppLinkRuleDecision { alwaysOpen, neverOpen }
/// A remembered per-scope rule replicated to native (§2.8). Distinct from the
/// Dart-persisted `PersistedAppLinkRule`; explicit mappers bridge the two.
class NativeAppLinkRule {
final NativeAppLinkRuleDecision decision;
final String scope;
final String? packageName;
const NativeAppLinkRule({
required this.decision,
required this.scope,
this.packageName,
});
}
/// A container's self-contained app-link policy override (§ container isolation).
/// Present only for containers with "isolated app link settings" enabled; when a
/// navigation's source contextId has an entry here, it fully *replaces* the
/// global mode + rules for that navigation (no layering with the global policy).
class NativeContextAppLinkPolicy {
final AppLinksMode mode;
/// The container's own remembered rules keyed by canonical scope.
final Map<String, NativeAppLinkRule> rules;
const NativeContextAppLinkPolicy({required this.mode, required this.rules});
}
/// Complete, last-write-wins policy snapshot pushed from the single Dart writer
/// to native (§2.8). Native persists it to the profile-scoped prefs record
/// before swapping the in-memory reference.
class AppLinkPolicySnapshot {
final AppLinksMode globalMode;
/// Remembered rules keyed by canonical scope.
final Map<String, NativeAppLinkRule> rules;
final bool marketplaceFallbackEnabled;
/// Regular / no-contextId tabs are proxied via the `general` scope.
final bool protectGeneralContext;
/// contextIds that resolve to a proxy after inherit/bypass/alias.
final List<String> protectedContextIds;
/// strictMode containers, independent of routing.
final List<String> strictContextIds;
final List<ProtectedTargetPattern> protectedTargetPatterns;
/// Per-container app-link policy overrides keyed by contextId. Only isolated
/// containers appear here; a navigation whose source contextId is a key uses
/// the entry's mode + rules in place of the global ones (replace semantics).
final Map<String, NativeContextAppLinkPolicy> contextOverrides;
const AppLinkPolicySnapshot({
required this.globalMode,
required this.rules,
required this.marketplaceFallbackEnabled,
required this.protectGeneralContext,
required this.protectedContextIds,
required this.strictContextIds,
required this.protectedTargetPatterns,
required this.contextOverrides,
});
}
/// Which surface owns a pending prompt (§2.6). Fixed at creation, never transfers.
enum AppLinkPromptOwner { flutterBrowser, nativeExternal }
/// A pending app-link prompt request held in the native `PendingAppLinkStore`
/// until resolved, invalidated, or expired (§2.6/§2.8). Holds only stable
/// identifiers and sanitised data — never engine/store references.
class AppLinkPromptRequest {
/// Monotonic per-process id (Kotlin Long).
final int requestId;
final AppLinkPromptOwner owner;
final String tabId;
final String? contextId;
final String? sourceUrl;
final bool isPrivate;
final bool isWallet;
final bool isProtectedContext;
final bool canRemember;
/// false for the http(s) banner class (non-modal); true for the modal
/// unsupported-scheme prompt.
final bool isModal;
final AppLinkTarget target;
const AppLinkPromptRequest({
required this.requestId,
required this.owner,
required this.tabId,
this.contextId,
this.sourceUrl,
required this.isPrivate,
required this.isWallet,
required this.isProtectedContext,
required this.canRemember,
required this.isModal,
required this.target,
});
}
/// User decision on a pending prompt (§2.6).
enum AppLinkDecision { open, cancel, dismiss }
/// Result of resolving a pending prompt (§2.8).
class AppLinkResolutionResult {
final bool launched;
final bool loadedFallback;
/// "stale" | "dead_session" | "launch_failed" | null.
final String? failureReason;
const AppLinkResolutionResult({
required this.launched,
required this.loadedFallback,
this.failureReason,
});
}
/// API for detecting and launching external applications that can handle URLs.
///
/// This API wraps Mozilla Android Components' AppLinksUseCases to allow Flutter
/// code to check if native apps can handle URLs and launch them directly.
/// WebLibre-owned resolution/launch surface (replaces the Mozilla AC use-case
/// wrappers). Policy lives in Dart; this surface owns PackageManager resolution
/// and Intent launch.
@HostApi()
abstract class GeckoAppLinksApi {
/// Checks if an external application is available to handle the given URL.
///
/// This method uses mozilla-components AppLinksUseCases to determine if
/// a native app can handle the URL (e.g., YouTube app for youtube.com links).
///
/// Returns true if an external app is available, false otherwise.
/// Push the complete policy snapshot to native (last-write-wins). Native
/// persists it durably to the active profile's prefs record before acking.
@async
bool hasExternalApp(String url);
void setAppLinkPolicy(AppLinkPolicySnapshot snapshot);
/// Opens the URL in an external application if available.
///
/// This method will:
/// 1. Check if an external app can handle the URL
/// 2. If available, launch the app directly with Intent.FLAG_ACTIVITY_NEW_TASK
/// 3. Return true if successfully launched, false otherwise
///
/// Returns true if URL was opened in external app, false if no app available.
/// Non-consuming query of pending prompts for [owner] (§2.6). Surfaces call
/// this on attach/resume/rotation and when the availability event fires, and
/// render idempotently by requestId.
@async
bool openAppLink(String url);
List<AppLinkPromptRequest> getPendingAppLinkPrompts(AppLinkPromptOwner owner);
/// Atomically resolve a pending prompt: validate it still exists and its tab
/// is alive, consume it (double-resolve is a no-op), then perform side effects
/// after releasing the store lock (§2.6).
@async
AppLinkResolutionResult resolvePendingAppLink(int requestId, AppLinkDecision decision);
/// Resolve [url] to an external-app target.
///
/// Returns null when no external app is available, on any resolution error, or
/// for always-denied schemes — callers cannot distinguish "nothing installed"
/// from "resolution failed", matching the previous `hasExternalApp` contract.
///
/// [includeHttpAppLinks] when true, an app resolving an engine-supported
/// (http(s)) URL is surfaced (e.g. the YouTube app for a youtube.com link).
@async
AppLinkTarget? resolveAppLink(String url, bool includeHttpAppLinks);
/// Re-resolve [url] and launch it in an external app.
///
/// Re-resolves internally immediately before launch and returns false on
/// no-app or ActivityNotFoundException/SecurityException; never throws across
/// the channel for expected conditions.
@async
bool launchAppLink(String url);
}
/// Optimisation-only availability signal for pending app-link prompts (§2.8).
///
/// A Pigeon `@FlutterApi()` callback has no buffering or replay: an event
/// emitted while Flutter is detached is lost. The `PendingAppLinkStore` is the
/// source of truth; surfaces query on attach/resume and dedupe by requestId.
@FlutterApi()
abstract class GeckoAppLinkEvents {
void onAppLinkPromptAvailable(int sequence, AppLinkPromptOwner owner);
}
// =============================================================================