diff --git a/apps/weblibre/lib/features/app_links/domain/entities/app_link_rule.dart b/apps/weblibre/lib/features/app_links/domain/entities/app_link_rule.dart
new file mode 100644
index 00000000..c319dd6b
--- /dev/null
+++ b/apps/weblibre/lib/features/app_links/domain/entities/app_link_rule.dart
@@ -0,0 +1,95 @@
+/*
+ * Copyright (c) 2024-2026 Fabian Freund.
+ *
+ * This file is part of WebLibre
+ * (see https://weblibre.eu).
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+import 'package:copy_with_extension/copy_with_extension.dart';
+import 'package:fast_equatable/fast_equatable.dart';
+import 'package:json_annotation/json_annotation.dart';
+
+part 'app_link_rule.g.dart';
+
+enum AppLinkRuleDecision { alwaysOpen, neverOpen }
+
+/// A remembered per-scope app-link rule (persistence contract, §2.5/§2.9).
+///
+/// Stored in `GeneralSettings.appLinkRules` as `Map`
+/// keyed by [scope] — one canonical rule per scope, upsert/last-write-wins. The
+/// [scope] is a native-owned canonical key (`host:youtube.com` | `pkg:...`) that
+/// Dart persists opaquely and never reconstructs.
+@CopyWith()
+@JsonSerializable()
+class PersistedAppLinkRule with FastEquatable {
+ final AppLinkRuleDecision decision;
+
+ /// Canonical scope key: `host:` or `pkg:`.
+ final String scope;
+
+ /// Resolved package name. Required for [AppLinkRuleDecision.alwaysOpen]
+ /// (binds the launch target); null for [AppLinkRuleDecision.neverOpen].
+ final String? packageName;
+
+ PersistedAppLinkRule({
+ required this.decision,
+ required this.scope,
+ this.packageName,
+ });
+
+ factory PersistedAppLinkRule.fromJson(Map json) =>
+ _$PersistedAppLinkRuleFromJson(json);
+
+ Map toJson() => _$PersistedAppLinkRuleToJson(this);
+
+ /// Whether this rule is internally consistent: an `alwaysOpen` rule must bind
+ /// a package; the scope must be a recognised canonical key.
+ bool get isValid {
+ if (scope.isEmpty) return false;
+ final hasKnownPrefix =
+ scope.startsWith('host:') || scope.startsWith('pkg:');
+ if (!hasKnownPrefix) return false;
+ if (decision == AppLinkRuleDecision.alwaysOpen &&
+ (packageName == null || packageName!.isEmpty)) {
+ return false;
+ }
+ return true;
+ }
+
+ @override
+ List get hashParameters => [decision, scope, packageName];
+}
+
+/// Parse the persisted rule map, dropping malformed rules (with a warning) and
+/// entries whose map key disagrees with the rule's own scope (§2.9).
+Map parseAppLinkRules(
+ Map? json,
+) {
+ if (json == null) return const {};
+ final result = {};
+ for (final MapEntry(:key, :value) in json.entries) {
+ if (value is! Map) continue;
+ final PersistedAppLinkRule rule;
+ try {
+ rule = PersistedAppLinkRule.fromJson(value);
+ } catch (_) {
+ continue;
+ }
+ if (rule.scope != key) continue;
+ if (!rule.isValid) continue;
+ result[key] = rule;
+ }
+ return result;
+}
diff --git a/apps/weblibre/lib/features/app_links/domain/entities/app_link_rule.g.dart b/apps/weblibre/lib/features/app_links/domain/entities/app_link_rule.g.dart
new file mode 100644
index 00000000..4fbdf1ea
--- /dev/null
+++ b/apps/weblibre/lib/features/app_links/domain/entities/app_link_rule.g.dart
@@ -0,0 +1,110 @@
+// GENERATED CODE - DO NOT MODIFY BY HAND
+
+part of 'app_link_rule.dart';
+
+// **************************************************************************
+// CopyWithGenerator
+// **************************************************************************
+
+abstract class _$PersistedAppLinkRuleCWProxy {
+ PersistedAppLinkRule decision(AppLinkRuleDecision decision);
+
+ PersistedAppLinkRule scope(String scope);
+
+ PersistedAppLinkRule packageName(String? packageName);
+
+ /// Creates a new instance with the provided field values.
+ /// Passing `null` to a nullable field nullifies it, while `null` for a non-nullable field is ignored. To update a single field use `PersistedAppLinkRule(...).copyWith.fieldName(value)`.
+ ///
+ /// Example:
+ /// ```dart
+ /// PersistedAppLinkRule(...).copyWith(id: 12, name: "My name")
+ /// ```
+ PersistedAppLinkRule call({
+ AppLinkRuleDecision decision,
+ String scope,
+ String? packageName,
+ });
+}
+
+/// Callable proxy for `copyWith` functionality.
+/// Use as `instanceOfPersistedAppLinkRule.copyWith(...)` or call `instanceOfPersistedAppLinkRule.copyWith.fieldName(value)` for a single field.
+class _$PersistedAppLinkRuleCWProxyImpl
+ implements _$PersistedAppLinkRuleCWProxy {
+ const _$PersistedAppLinkRuleCWProxyImpl(this._value);
+
+ final PersistedAppLinkRule _value;
+
+ @override
+ PersistedAppLinkRule decision(AppLinkRuleDecision decision) =>
+ call(decision: decision);
+
+ @override
+ PersistedAppLinkRule scope(String scope) => call(scope: scope);
+
+ @override
+ PersistedAppLinkRule packageName(String? packageName) =>
+ call(packageName: packageName);
+
+ @override
+ /// Creates a new instance with the provided field values.
+ /// Passing `null` to a nullable field nullifies it, while `null` for a non-nullable field is ignored. To update a single field use `PersistedAppLinkRule(...).copyWith.fieldName(value)`.
+ ///
+ /// Example:
+ /// ```dart
+ /// PersistedAppLinkRule(...).copyWith(id: 12, name: "My name")
+ /// ```
+ PersistedAppLinkRule call({
+ Object? decision = const $CopyWithPlaceholder(),
+ Object? scope = const $CopyWithPlaceholder(),
+ Object? packageName = const $CopyWithPlaceholder(),
+ }) {
+ return PersistedAppLinkRule(
+ decision: decision == const $CopyWithPlaceholder() || decision == null
+ ? _value.decision
+ // ignore: cast_nullable_to_non_nullable
+ : decision as AppLinkRuleDecision,
+ scope: scope == const $CopyWithPlaceholder() || scope == null
+ ? _value.scope
+ // ignore: cast_nullable_to_non_nullable
+ : scope as String,
+ packageName: packageName == const $CopyWithPlaceholder()
+ ? _value.packageName
+ // ignore: cast_nullable_to_non_nullable
+ : packageName as String?,
+ );
+ }
+}
+
+extension $PersistedAppLinkRuleCopyWith on PersistedAppLinkRule {
+ /// Returns a callable class used to build a new instance with modified fields.
+ /// Example: `instanceOfPersistedAppLinkRule.copyWith(...)` or `instanceOfPersistedAppLinkRule.copyWith.fieldName(...)`.
+ // ignore: library_private_types_in_public_api
+ _$PersistedAppLinkRuleCWProxy get copyWith =>
+ _$PersistedAppLinkRuleCWProxyImpl(this);
+}
+
+// **************************************************************************
+// JsonSerializableGenerator
+// **************************************************************************
+
+PersistedAppLinkRule _$PersistedAppLinkRuleFromJson(
+ Map json,
+) => PersistedAppLinkRule(
+ decision: $enumDecode(_$AppLinkRuleDecisionEnumMap, json['decision']),
+ scope: json['scope'] as String,
+ packageName: json['packageName'] as String?,
+);
+
+Map _$PersistedAppLinkRuleToJson(
+ PersistedAppLinkRule instance,
+) => {
+ 'decision': _$AppLinkRuleDecisionEnumMap[instance.decision]!,
+ 'scope': instance.scope,
+ 'packageName': instance.packageName,
+};
+
+const _$AppLinkRuleDecisionEnumMap = {
+ AppLinkRuleDecision.alwaysOpen: 'alwaysOpen',
+ AppLinkRuleDecision.neverOpen: 'neverOpen',
+};
diff --git a/apps/weblibre/lib/features/app_links/domain/entities/context_app_link_policy.dart b/apps/weblibre/lib/features/app_links/domain/entities/context_app_link_policy.dart
new file mode 100644
index 00000000..dce0c4eb
--- /dev/null
+++ b/apps/weblibre/lib/features/app_links/domain/entities/context_app_link_policy.dart
@@ -0,0 +1,81 @@
+/*
+ * Copyright (c) 2024-2026 Fabian Freund.
+ *
+ * This file is part of WebLibre
+ * (see https://weblibre.eu).
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+import 'package:copy_with_extension/copy_with_extension.dart';
+import 'package:fast_equatable/fast_equatable.dart';
+import 'package:flutter_mozilla_components/flutter_mozilla_components.dart'
+ show AppLinksMode;
+import 'package:json_annotation/json_annotation.dart';
+import 'package:weblibre/features/app_links/domain/entities/app_link_rule.dart';
+
+part 'context_app_link_policy.g.dart';
+
+/// A container's self-contained app-link policy, used when the container has
+/// "isolated app link settings" enabled (replace semantics — it fully takes the
+/// place of the global mode + rules for navigations in that container).
+///
+/// Stored in `GeneralSettings.appLinkContextOverrides` keyed by the container's
+/// Gecko contextId (`contextualIdentity`). Only isolated containers have an
+/// entry; the snapshot builder synthesises a blank-slate default for a freshly
+/// isolated container that has not customised anything yet.
+@CopyWith()
+@JsonSerializable()
+class ContextAppLinkPolicy with FastEquatable {
+ /// The container's own global app-links mode (default [AppLinksMode.ask]).
+ final AppLinksMode mode;
+
+ /// The container's own remembered per-scope rules, keyed by canonical scope
+ /// (`host:youtube.com` | `pkg:...`). Same shape/validation as the global
+ /// [GeneralSettings.appLinkRules]; malformed entries are dropped on read.
+ @JsonKey(fromJson: parseAppLinkRules)
+ final Map rules;
+
+ ContextAppLinkPolicy({required this.mode, required this.rules});
+
+ /// The blank-slate policy a container starts from when it is first isolated.
+ ContextAppLinkPolicy.blank() : this(mode: AppLinksMode.ask, rules: const {});
+
+ factory ContextAppLinkPolicy.fromJson(Map json) =>
+ _$ContextAppLinkPolicyFromJson(json);
+
+ Map toJson() => _$ContextAppLinkPolicyToJson(this);
+
+ @override
+ List get hashParameters => [mode, rules];
+}
+
+/// Parse the persisted override map, dropping malformed entries (§2.9 style).
+/// Keys are contextIds; the interceptor only ever consults entries whose
+/// contextId belongs to a currently-isolated container, so an orphaned entry
+/// (container deleted / isolation turned off) is inert.
+Map parseAppLinkContextOverrides(
+ Map? json,
+) {
+ if (json == null) return const {};
+ final result = {};
+ for (final MapEntry(:key, :value) in json.entries) {
+ if (value is! Map) continue;
+ try {
+ result[key] = ContextAppLinkPolicy.fromJson(value);
+ } catch (_) {
+ continue;
+ }
+ }
+ return result;
+}
diff --git a/apps/weblibre/lib/features/app_links/domain/entities/context_app_link_policy.g.dart b/apps/weblibre/lib/features/app_links/domain/entities/context_app_link_policy.g.dart
new file mode 100644
index 00000000..9770aeb9
--- /dev/null
+++ b/apps/weblibre/lib/features/app_links/domain/entities/context_app_link_policy.g.dart
@@ -0,0 +1,97 @@
+// GENERATED CODE - DO NOT MODIFY BY HAND
+
+part of 'context_app_link_policy.dart';
+
+// **************************************************************************
+// CopyWithGenerator
+// **************************************************************************
+
+abstract class _$ContextAppLinkPolicyCWProxy {
+ ContextAppLinkPolicy mode(AppLinksMode mode);
+
+ ContextAppLinkPolicy rules(Map rules);
+
+ /// Creates a new instance with the provided field values.
+ /// Passing `null` to a nullable field nullifies it, while `null` for a non-nullable field is ignored. To update a single field use `ContextAppLinkPolicy(...).copyWith.fieldName(value)`.
+ ///
+ /// Example:
+ /// ```dart
+ /// ContextAppLinkPolicy(...).copyWith(id: 12, name: "My name")
+ /// ```
+ ContextAppLinkPolicy call({
+ AppLinksMode mode,
+ Map rules,
+ });
+}
+
+/// Callable proxy for `copyWith` functionality.
+/// Use as `instanceOfContextAppLinkPolicy.copyWith(...)` or call `instanceOfContextAppLinkPolicy.copyWith.fieldName(value)` for a single field.
+class _$ContextAppLinkPolicyCWProxyImpl
+ implements _$ContextAppLinkPolicyCWProxy {
+ const _$ContextAppLinkPolicyCWProxyImpl(this._value);
+
+ final ContextAppLinkPolicy _value;
+
+ @override
+ ContextAppLinkPolicy mode(AppLinksMode mode) => call(mode: mode);
+
+ @override
+ ContextAppLinkPolicy rules(Map rules) =>
+ call(rules: rules);
+
+ @override
+ /// Creates a new instance with the provided field values.
+ /// Passing `null` to a nullable field nullifies it, while `null` for a non-nullable field is ignored. To update a single field use `ContextAppLinkPolicy(...).copyWith.fieldName(value)`.
+ ///
+ /// Example:
+ /// ```dart
+ /// ContextAppLinkPolicy(...).copyWith(id: 12, name: "My name")
+ /// ```
+ ContextAppLinkPolicy call({
+ Object? mode = const $CopyWithPlaceholder(),
+ Object? rules = const $CopyWithPlaceholder(),
+ }) {
+ return ContextAppLinkPolicy(
+ mode: mode == const $CopyWithPlaceholder() || mode == null
+ ? _value.mode
+ // ignore: cast_nullable_to_non_nullable
+ : mode as AppLinksMode,
+ rules: rules == const $CopyWithPlaceholder() || rules == null
+ ? _value.rules
+ // ignore: cast_nullable_to_non_nullable
+ : rules as Map,
+ );
+ }
+}
+
+extension $ContextAppLinkPolicyCopyWith on ContextAppLinkPolicy {
+ /// Returns a callable class used to build a new instance with modified fields.
+ /// Example: `instanceOfContextAppLinkPolicy.copyWith(...)` or `instanceOfContextAppLinkPolicy.copyWith.fieldName(...)`.
+ // ignore: library_private_types_in_public_api
+ _$ContextAppLinkPolicyCWProxy get copyWith =>
+ _$ContextAppLinkPolicyCWProxyImpl(this);
+}
+
+// **************************************************************************
+// JsonSerializableGenerator
+// **************************************************************************
+
+ContextAppLinkPolicy _$ContextAppLinkPolicyFromJson(
+ Map json,
+) => ContextAppLinkPolicy(
+ mode: $enumDecode(_$AppLinksModeEnumMap, json['mode']),
+ rules: parseAppLinkRules(json['rules'] as Map?),
+);
+
+Map _$ContextAppLinkPolicyToJson(
+ ContextAppLinkPolicy instance,
+) => {
+ 'mode': _$AppLinksModeEnumMap[instance.mode]!,
+ 'rules': instance.rules.map((k, e) => MapEntry(k, e.toJson())),
+};
+
+const _$AppLinksModeEnumMap = {
+ AppLinksMode.always: 'always',
+ AppLinksMode.ask: 'ask',
+ AppLinksMode.never: 'never',
+};
diff --git a/apps/weblibre/lib/features/app_links/domain/services/app_link_policy_replication.dart b/apps/weblibre/lib/features/app_links/domain/services/app_link_policy_replication.dart
new file mode 100644
index 00000000..530261d3
--- /dev/null
+++ b/apps/weblibre/lib/features/app_links/domain/services/app_link_policy_replication.dart
@@ -0,0 +1,273 @@
+/*
+ * Copyright (c) 2024-2026 Fabian Freund.
+ *
+ * This file is part of WebLibre
+ * (see https://weblibre.eu).
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+import 'dart:async';
+
+import 'package:flutter_mozilla_components/flutter_mozilla_components.dart'
+ hide ProtectedTargetPattern;
+import 'package:flutter_mozilla_components/flutter_mozilla_components.dart'
+ as pigeon
+ show ProtectedTargetPattern;
+import 'package:riverpod_annotation/riverpod_annotation.dart';
+import 'package:synchronized/synchronized.dart';
+import 'package:weblibre/core/logger.dart';
+import 'package:weblibre/features/app_links/domain/entities/app_link_rule.dart';
+import 'package:weblibre/features/app_links/domain/entities/context_app_link_policy.dart';
+import 'package:weblibre/features/app_links/domain/services/effective_routing.dart';
+import 'package:weblibre/features/geckoview/features/tabs/domain/providers.dart';
+import 'package:weblibre/features/user/data/models/general_settings.dart';
+import 'package:weblibre/features/user/data/models/proxy_routing_settings.dart';
+import 'package:weblibre/features/user/domain/repositories/general_settings.dart';
+import 'package:weblibre/features/user/domain/repositories/proxy_routing_settings.dart';
+
+part 'app_link_policy_replication.g.dart';
+
+/// Effective app-link protection (§2.3), recomputed whenever routing, strict
+/// mode, contextual identities, or site assignments change.
+@Riverpod(keepAlive: true)
+AppLinkProtection appLinkProtection(Ref ref) {
+ final routing = ref.watch(proxyRoutingSettingsWithDefaultsProvider);
+ final protectGeneralContext =
+ routing.regularTabsMode == ProxyRegularTabRoutingMode.all &&
+ routing.regularTabsProxyConnectionId != null;
+
+ final containers =
+ ref.watch(watchContainersWithCountProvider).value ?? const [];
+ final isolationMap =
+ ref.watch(watchIsolatedContextContainerMapProvider).value ?? const {};
+ final strict =
+ ref.watch(watchStrictContextAssignmentsProvider).value ?? const {};
+ final sites = ref.watch(watchAllAssignedSitesProvider).value ?? const [];
+
+ return computeAppLinkProtection(
+ protectGeneralContext: protectGeneralContext,
+ containers: containers,
+ isolationContextContainerMap: isolationMap,
+ strictContextIds: strict.keys.toSet(),
+ siteAssignments: sites,
+ );
+}
+
+/// The complete policy snapshot to push, or null until the real persisted
+/// settings have loaded (the `...WithDefaults` loading placeholder is not valid
+/// input, §2.8). Combines the user-intent settings with computed protection.
+@Riverpod(keepAlive: true)
+AppLinkPolicySnapshot? appLinkPolicySnapshot(Ref ref) {
+ final settings = ref.watch(generalSettingsRepositoryProvider).value;
+ if (settings == null) return null;
+
+ // Don't push a snapshot until the protection/override inputs have actually
+ // loaded (§2.8). `appLinkProtection` and `_computeContextOverrides` fall back to
+ // empty collections while these streams are still loading; pushing that would
+ // briefly persist "no protected contexts / no overrides" to native and let a
+ // protected or isolated container's links leak out during startup. Native keeps
+ // last session's persisted snapshot until the real one is ready.
+ final containersLoaded = ref.watch(watchContainersWithCountProvider).hasValue;
+ final isolationLoaded = ref
+ .watch(watchIsolatedContextContainerMapProvider)
+ .hasValue;
+ final strictLoaded = ref.watch(watchStrictContextAssignmentsProvider).hasValue;
+ final sitesLoaded = ref.watch(watchAllAssignedSitesProvider).hasValue;
+ // The real proxy-routing settings drive `protectGeneralContext`; the
+ // `...WithDefaults` view silently substitutes defaults while the row loads,
+ // which would compute "general context not proxied" and let a globally-proxied
+ // setup auto-launch during startup. Wait for the actual value.
+ final routingLoaded = ref
+ .watch(proxyRoutingSettingsRepositoryProvider)
+ .hasValue;
+ if (!containersLoaded ||
+ !isolationLoaded ||
+ !strictLoaded ||
+ !sitesLoaded ||
+ !routingLoaded) {
+ return null;
+ }
+
+ final protection = ref.watch(appLinkProtectionProvider);
+
+ return AppLinkPolicySnapshot(
+ globalMode: settings.appLinksMode,
+ rules: {
+ for (final MapEntry(:key, :value) in settings.appLinkRules.entries)
+ key: _toNativeRule(value),
+ },
+ marketplaceFallbackEnabled: settings.appLinkMarketplaceFallback,
+ protectGeneralContext: protection.protectGeneralContext,
+ protectedContextIds: protection.protectedContextIds.toList(),
+ strictContextIds: protection.strictContextIds.toList(),
+ protectedTargetPatterns: protection.protectedTargetPatterns
+ .map(_toNativePattern)
+ .toList(),
+ contextOverrides: _computeContextOverrides(ref, settings),
+ );
+}
+
+/// Build the per-container override map (§ container isolation): one entry per
+/// container whose "isolated app link settings" toggle is on and which has a
+/// contextId. A freshly isolated container with no stored override still gets a
+/// blank-slate entry so its "replace" behaviour takes effect immediately rather
+/// than silently falling back to the global policy.
+///
+/// The override is published under the container's base contextId **and** under
+/// every active isolation context id belonging to that container: isolated tabs
+/// (`tab_mode = 2`) load under their own `isolation_context_id`, which is the
+/// `session.contextId` the native interceptor keys the lookup on — so without the
+/// fan-out isolated tabs would silently fall back to the global policy (mirrors
+/// how `computeAppLinkProtection` expands protection to isolation contexts). When
+/// an isolation context is shared by several isolated-app-link containers, the
+/// container with the lowest sorted base contextId wins (deterministic).
+Map _computeContextOverrides(
+ Ref ref,
+ GeneralSettings settings,
+) {
+ final containers =
+ ref.watch(watchContainersWithCountProvider).value ?? const [];
+ final isolationMap =
+ ref.watch(watchIsolatedContextContainerMapProvider).value ?? const {};
+
+ NativeContextAppLinkPolicy toNative(ContextAppLinkPolicy policy) {
+ return NativeContextAppLinkPolicy(
+ mode: policy.mode,
+ rules: {
+ for (final MapEntry(:key, :value) in policy.rules.entries)
+ key: _toNativeRule(value),
+ },
+ );
+ }
+
+ // Base contextId -> native override, plus containerId -> base contextId for the
+ // isolation-context fan-out below (only isolated-app-link containers).
+ final overrideByBaseContextId = {};
+ final baseContextIdByContainerId = {};
+ for (final container in containers) {
+ final contextId = container.metadata.contextualIdentity;
+ if (contextId == null || !container.metadata.isolatedAppLinkSettings) {
+ continue;
+ }
+ overrideByBaseContextId[contextId] = toNative(
+ settings.appLinkContextOverrides[contextId] ??
+ ContextAppLinkPolicy.blank(),
+ );
+ baseContextIdByContainerId[container.id] = contextId;
+ }
+
+ final overrides = {
+ ...overrideByBaseContextId,
+ };
+
+ for (final MapEntry(key: isolationContextId, value: containerIds)
+ in isolationMap.entries) {
+ final baseIds =
+ containerIds
+ .map((id) => baseContextIdByContainerId[id])
+ .nonNulls
+ .toList()
+ ..sort();
+ if (baseIds.isEmpty) continue;
+ // A base contextId never collides with an isolation context id, but guard
+ // so a real container's own entry always wins if one ever did.
+ overrides.putIfAbsent(
+ isolationContextId,
+ () => overrideByBaseContextId[baseIds.first]!,
+ );
+ }
+
+ return overrides;
+}
+
+NativeAppLinkRule _toNativeRule(PersistedAppLinkRule rule) {
+ return NativeAppLinkRule(
+ decision: switch (rule.decision) {
+ AppLinkRuleDecision.alwaysOpen => NativeAppLinkRuleDecision.alwaysOpen,
+ AppLinkRuleDecision.neverOpen => NativeAppLinkRuleDecision.neverOpen,
+ },
+ scope: rule.scope,
+ packageName: rule.packageName,
+ );
+}
+
+pigeon.ProtectedTargetPattern _toNativePattern(ProtectedTargetPattern pattern) {
+ return pigeon.ProtectedTargetPattern(
+ scheme: pattern.scheme,
+ hostOrSuffix: pattern.hostOrSuffix,
+ includeSubdomains: pattern.includeSubdomains,
+ port: pattern.port,
+ );
+}
+
+/// Single serialised writer that mirrors the Dart-owned app-link policy to the
+/// native profile-scoped store (§2.8), the sole policy source consulted by the
+/// interceptor. Structured like `ProxySettingsReplication`; mounted from app root
+/// after initialisation.
+@Riverpod(keepAlive: true)
+class AppLinkPolicyReplication extends _$AppLinkPolicyReplication {
+ final _appLinks = GeckoAppLinksService();
+
+ final _pushLock = Lock();
+ // Coalesces the most recent snapshot while a push is in flight; genuinely
+ // nullable (no snapshot pushed yet).
+ // ignore: use_late_for_private_fields_and_variables
+ AppLinkPolicySnapshot? _latest;
+ var _pushDirty = false;
+
+ Future _queuePush(AppLinkPolicySnapshot snapshot) async {
+ _latest = snapshot;
+ _pushDirty = true;
+ if (_pushLock.inLock) return;
+
+ await _pushLock.synchronized(() async {
+ while (_pushDirty) {
+ _pushDirty = false;
+ final pending = _latest!;
+ try {
+ await _appLinks.setAppLinkPolicy(pending);
+ } catch (error, stackTrace) {
+ // `setAppLinkPolicy` before a profile is bound is an error the
+ // replicator retries after initialisation (§2.8).
+ logger.w(
+ 'Failed to push app-link policy; will retry',
+ error: error,
+ stackTrace: stackTrace,
+ );
+ _pushDirty = true;
+ await Future.delayed(const Duration(seconds: 1));
+ }
+ }
+ });
+ }
+
+ @override
+ void build() {
+ ref.listen(
+ fireImmediately: true,
+ appLinkPolicySnapshotProvider,
+ (previous, next) {
+ if (next == null) return;
+ unawaited(_queuePush(next));
+ },
+ onError: (error, stackTrace) {
+ logger.e(
+ 'Error computing app-link policy snapshot',
+ error: error,
+ stackTrace: stackTrace,
+ );
+ },
+ );
+ }
+}
diff --git a/apps/weblibre/lib/features/app_links/domain/services/app_link_policy_replication.g.dart b/apps/weblibre/lib/features/app_links/domain/services/app_link_policy_replication.g.dart
new file mode 100644
index 00000000..24c96746
--- /dev/null
+++ b/apps/weblibre/lib/features/app_links/domain/services/app_link_policy_replication.g.dart
@@ -0,0 +1,194 @@
+// GENERATED CODE - DO NOT MODIFY BY HAND
+
+part of 'app_link_policy_replication.dart';
+
+// **************************************************************************
+// RiverpodGenerator
+// **************************************************************************
+
+// GENERATED CODE - DO NOT MODIFY BY HAND
+// ignore_for_file: type=lint, type=warning
+/// Effective app-link protection (§2.3), recomputed whenever routing, strict
+/// mode, contextual identities, or site assignments change.
+
+@ProviderFor(appLinkProtection)
+final appLinkProtectionProvider = AppLinkProtectionProvider._();
+
+/// Effective app-link protection (§2.3), recomputed whenever routing, strict
+/// mode, contextual identities, or site assignments change.
+
+final class AppLinkProtectionProvider
+ extends
+ $FunctionalProvider<
+ AppLinkProtection,
+ AppLinkProtection,
+ AppLinkProtection
+ >
+ with $Provider {
+ /// Effective app-link protection (§2.3), recomputed whenever routing, strict
+ /// mode, contextual identities, or site assignments change.
+ AppLinkProtectionProvider._()
+ : super(
+ from: null,
+ argument: null,
+ retry: null,
+ name: r'appLinkProtectionProvider',
+ isAutoDispose: false,
+ dependencies: null,
+ $allTransitiveDependencies: null,
+ );
+
+ @override
+ String debugGetCreateSourceHash() => _$appLinkProtectionHash();
+
+ @$internal
+ @override
+ $ProviderElement $createElement(
+ $ProviderPointer pointer,
+ ) => $ProviderElement(pointer);
+
+ @override
+ AppLinkProtection create(Ref ref) {
+ return appLinkProtection(ref);
+ }
+
+ /// {@macro riverpod.override_with_value}
+ Override overrideWithValue(AppLinkProtection value) {
+ return $ProviderOverride(
+ origin: this,
+ providerOverride: $SyncValueProvider(value),
+ );
+ }
+}
+
+String _$appLinkProtectionHash() => r'6aab203c9b7d2f2c8a73684ea0b624e09fe6179f';
+
+/// The complete policy snapshot to push, or null until the real persisted
+/// settings have loaded (the `...WithDefaults` loading placeholder is not valid
+/// input, §2.8). Combines the user-intent settings with computed protection.
+
+@ProviderFor(appLinkPolicySnapshot)
+final appLinkPolicySnapshotProvider = AppLinkPolicySnapshotProvider._();
+
+/// The complete policy snapshot to push, or null until the real persisted
+/// settings have loaded (the `...WithDefaults` loading placeholder is not valid
+/// input, §2.8). Combines the user-intent settings with computed protection.
+
+final class AppLinkPolicySnapshotProvider
+ extends
+ $FunctionalProvider<
+ AppLinkPolicySnapshot?,
+ AppLinkPolicySnapshot?,
+ AppLinkPolicySnapshot?
+ >
+ with $Provider {
+ /// The complete policy snapshot to push, or null until the real persisted
+ /// settings have loaded (the `...WithDefaults` loading placeholder is not valid
+ /// input, §2.8). Combines the user-intent settings with computed protection.
+ AppLinkPolicySnapshotProvider._()
+ : super(
+ from: null,
+ argument: null,
+ retry: null,
+ name: r'appLinkPolicySnapshotProvider',
+ isAutoDispose: false,
+ dependencies: null,
+ $allTransitiveDependencies: null,
+ );
+
+ @override
+ String debugGetCreateSourceHash() => _$appLinkPolicySnapshotHash();
+
+ @$internal
+ @override
+ $ProviderElement $createElement(
+ $ProviderPointer pointer,
+ ) => $ProviderElement(pointer);
+
+ @override
+ AppLinkPolicySnapshot? create(Ref ref) {
+ return appLinkPolicySnapshot(ref);
+ }
+
+ /// {@macro riverpod.override_with_value}
+ Override overrideWithValue(AppLinkPolicySnapshot? value) {
+ return $ProviderOverride(
+ origin: this,
+ providerOverride: $SyncValueProvider(value),
+ );
+ }
+}
+
+String _$appLinkPolicySnapshotHash() =>
+ r'7f700b67d3b7b0b435fe82a98de455c6e374a1a2';
+
+/// Single serialised writer that mirrors the Dart-owned app-link policy to the
+/// native profile-scoped store (§2.8), the sole policy source consulted by the
+/// interceptor. Structured like `ProxySettingsReplication`; mounted from app root
+/// after initialisation.
+
+@ProviderFor(AppLinkPolicyReplication)
+final appLinkPolicyReplicationProvider = AppLinkPolicyReplicationProvider._();
+
+/// Single serialised writer that mirrors the Dart-owned app-link policy to the
+/// native profile-scoped store (§2.8), the sole policy source consulted by the
+/// interceptor. Structured like `ProxySettingsReplication`; mounted from app root
+/// after initialisation.
+final class AppLinkPolicyReplicationProvider
+ extends $NotifierProvider {
+ /// Single serialised writer that mirrors the Dart-owned app-link policy to the
+ /// native profile-scoped store (§2.8), the sole policy source consulted by the
+ /// interceptor. Structured like `ProxySettingsReplication`; mounted from app root
+ /// after initialisation.
+ AppLinkPolicyReplicationProvider._()
+ : super(
+ from: null,
+ argument: null,
+ retry: null,
+ name: r'appLinkPolicyReplicationProvider',
+ isAutoDispose: false,
+ dependencies: null,
+ $allTransitiveDependencies: null,
+ );
+
+ @override
+ String debugGetCreateSourceHash() => _$appLinkPolicyReplicationHash();
+
+ @$internal
+ @override
+ AppLinkPolicyReplication create() => AppLinkPolicyReplication();
+
+ /// {@macro riverpod.override_with_value}
+ Override overrideWithValue(void value) {
+ return $ProviderOverride(
+ origin: this,
+ providerOverride: $SyncValueProvider(value),
+ );
+ }
+}
+
+String _$appLinkPolicyReplicationHash() =>
+ r'866e749328bef9f65c2124585d8c03d798802563';
+
+/// Single serialised writer that mirrors the Dart-owned app-link policy to the
+/// native profile-scoped store (§2.8), the sole policy source consulted by the
+/// interceptor. Structured like `ProxySettingsReplication`; mounted from app root
+/// after initialisation.
+
+abstract class _$AppLinkPolicyReplication extends $Notifier {
+ void build();
+ @$mustCallSuper
+ @override
+ WhenComplete runBuild() {
+ final ref = this.ref as $Ref;
+ final element =
+ ref.element
+ as $ClassProviderElement<
+ AnyNotifier,
+ void,
+ Object?,
+ Object?
+ >;
+ return element.handleCreate(ref, build);
+ }
+}
diff --git a/apps/weblibre/lib/features/app_links/domain/services/app_links_coordinator.dart b/apps/weblibre/lib/features/app_links/domain/services/app_links_coordinator.dart
new file mode 100644
index 00000000..570a046d
--- /dev/null
+++ b/apps/weblibre/lib/features/app_links/domain/services/app_links_coordinator.dart
@@ -0,0 +1,165 @@
+/*
+ * Copyright (c) 2024-2026 Fabian Freund.
+ *
+ * This file is part of WebLibre
+ * (see https://weblibre.eu).
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+import 'package:flutter_mozilla_components/flutter_mozilla_components.dart';
+import 'package:riverpod_annotation/riverpod_annotation.dart';
+import 'package:weblibre/core/logger.dart';
+import 'package:weblibre/features/app_links/domain/entities/app_link_rule.dart';
+import 'package:weblibre/features/app_links/domain/entities/context_app_link_policy.dart';
+import 'package:weblibre/features/app_links/domain/services/effective_app_link_policy.dart';
+import 'package:weblibre/features/geckoview/features/tabs/domain/providers.dart';
+import 'package:weblibre/features/user/data/models/general_settings.dart';
+import 'package:weblibre/features/user/domain/repositories/general_settings.dart';
+
+part 'app_links_coordinator.g.dart';
+
+/// Receives the native availability signal for Flutter-owned prompts. The event
+/// is optimisation-only (no buffering/replay); the store query is authoritative.
+class _AppLinkEventsReceiver extends GeckoAppLinkEvents {
+ _AppLinkEventsReceiver(this._onAvailable);
+
+ final void Function(AppLinkPromptOwner owner) _onAvailable;
+
+ @override
+ void onAppLinkPromptAvailable(int sequence, AppLinkPromptOwner owner) {
+ _onAvailable(owner);
+ }
+}
+
+/// Orchestrates Flutter-owned app-link prompts (§2.6): registers the availability
+/// event handler, queries the native pending store on attach/resume/event, and
+/// exposes resolution (including the remember-then-resolve flow). The presented
+/// list is authoritative from the query and deduped by `requestId` — the event
+/// is only a nudge to re-query.
+@Riverpod(keepAlive: true)
+class AppLinksCoordinator extends _$AppLinksCoordinator {
+ final _service = GeckoAppLinksService();
+
+ @override
+ List build() {
+ final receiver = _AppLinkEventsReceiver((owner) {
+ if (owner == AppLinkPromptOwner.flutterBrowser) {
+ // ignore: discarded_futures
+ refresh();
+ }
+ });
+ GeckoAppLinkEvents.setUp(receiver);
+ ref.onDispose(() => GeckoAppLinkEvents.setUp(null));
+
+ // Initial query; the returned future updates state when it completes.
+ // ignore: discarded_futures
+ refresh();
+
+ return const [];
+ }
+
+ /// Re-query the native pending store (called on attach, lifecycle resume, and
+ /// when the availability event fires).
+ Future refresh() async {
+ try {
+ final prompts = await _service.getPendingAppLinkPrompts(
+ AppLinkPromptOwner.flutterBrowser,
+ );
+ logger.i(
+ 'app-link refresh -> ${prompts.length} prompt(s): '
+ '${prompts.map((p) => '${p.requestId}@${p.tabId}(${p.isModal ? 'modal' : 'banner'})').toList()}',
+ );
+ state = prompts;
+ } catch (error, stackTrace) {
+ logger.w(
+ 'Failed to query pending app-link prompts',
+ error: error,
+ stackTrace: stackTrace,
+ );
+ }
+ }
+
+ /// Resolve a pending prompt and re-query.
+ Future resolve(
+ int requestId,
+ AppLinkDecision decision,
+ ) async {
+ final result = await _service.resolvePendingAppLink(requestId, decision);
+ await refresh();
+ return result;
+ }
+
+ /// Remember-then-resolve (§2.6): persist the rule to `GeneralSettings` first so
+ /// it is replicated to native, then resolve the still-pending request.
+ ///
+ /// [contextId] is the source tab's live contextId (from the prompt request) —
+ /// the container's base contextId for a regular tab, or the tab's
+ /// `isolation_context_id` for an isolated tab. When it resolves to a container
+ /// with "isolated app link settings" enabled, the rule is written to that
+ /// container's own override bucket (`appLinkContextOverrides`, keyed by the
+ /// container's base contextId) rather than the global [GeneralSettings.appLinkRules],
+ /// keeping the two rule sets separate (replace semantics).
+ Future resolveWithRule(
+ int requestId,
+ AppLinkDecision decision,
+ PersistedAppLinkRule rule, {
+ String? contextId,
+ }) async {
+ final overrideKey = await _overrideKeyForContext(contextId);
+
+ await ref.read(generalSettingsRepositoryProvider.notifier).updateSettings((
+ current,
+ ) {
+ if (overrideKey != null) {
+ final existing =
+ current.appLinkContextOverrides[overrideKey] ??
+ ContextAppLinkPolicy.blank();
+ final updated = existing.copyWith.rules({
+ ...existing.rules,
+ rule.scope: rule,
+ });
+ return current.copyWith.appLinkContextOverrides({
+ ...current.appLinkContextOverrides,
+ overrideKey: updated,
+ });
+ }
+ return current.copyWith.appLinkRules({
+ ...current.appLinkRules,
+ rule.scope: rule,
+ });
+ });
+ return resolve(requestId, decision);
+ }
+
+ /// Resolve the source tab's live [contextId] to the override storage key — the
+ /// base contextId of the owning isolated-app-link container — or null to write
+ /// globally. Handles both a regular tab (contextId is already the container
+ /// base) and an isolated tab (contextId is an `isolation_context_id` mapping to
+ /// its container). Delegates to [resolveAppLinkOverrideKey] so writes land in
+ /// the bucket that is published back to native.
+ Future _overrideKeyForContext(String? contextId) async {
+ if (contextId == null) return null;
+
+ final containers = await ref.read(watchContainersWithCountProvider.future);
+ final isolationMap = await ref.read(
+ watchIsolatedContextContainerMapProvider.future,
+ );
+
+ return resolveAppLinkOverrideKey(
+ liveContextId: contextId,
+ containers: containers,
+ isolationContextContainerMap: isolationMap,
+ );
+ }
+}
diff --git a/apps/weblibre/lib/features/app_links/domain/services/app_links_coordinator.g.dart b/apps/weblibre/lib/features/app_links/domain/services/app_links_coordinator.g.dart
new file mode 100644
index 00000000..5cf2e47b
--- /dev/null
+++ b/apps/weblibre/lib/features/app_links/domain/services/app_links_coordinator.g.dart
@@ -0,0 +1,90 @@
+// GENERATED CODE - DO NOT MODIFY BY HAND
+
+part of 'app_links_coordinator.dart';
+
+// **************************************************************************
+// RiverpodGenerator
+// **************************************************************************
+
+// GENERATED CODE - DO NOT MODIFY BY HAND
+// ignore_for_file: type=lint, type=warning
+/// Orchestrates Flutter-owned app-link prompts (§2.6): registers the availability
+/// event handler, queries the native pending store on attach/resume/event, and
+/// exposes resolution (including the remember-then-resolve flow). The presented
+/// list is authoritative from the query and deduped by `requestId` — the event
+/// is only a nudge to re-query.
+
+@ProviderFor(AppLinksCoordinator)
+final appLinksCoordinatorProvider = AppLinksCoordinatorProvider._();
+
+/// Orchestrates Flutter-owned app-link prompts (§2.6): registers the availability
+/// event handler, queries the native pending store on attach/resume/event, and
+/// exposes resolution (including the remember-then-resolve flow). The presented
+/// list is authoritative from the query and deduped by `requestId` — the event
+/// is only a nudge to re-query.
+final class AppLinksCoordinatorProvider
+ extends $NotifierProvider> {
+ /// Orchestrates Flutter-owned app-link prompts (§2.6): registers the availability
+ /// event handler, queries the native pending store on attach/resume/event, and
+ /// exposes resolution (including the remember-then-resolve flow). The presented
+ /// list is authoritative from the query and deduped by `requestId` — the event
+ /// is only a nudge to re-query.
+ AppLinksCoordinatorProvider._()
+ : super(
+ from: null,
+ argument: null,
+ retry: null,
+ name: r'appLinksCoordinatorProvider',
+ isAutoDispose: false,
+ dependencies: null,
+ $allTransitiveDependencies: null,
+ );
+
+ @override
+ String debugGetCreateSourceHash() => _$appLinksCoordinatorHash();
+
+ @$internal
+ @override
+ AppLinksCoordinator create() => AppLinksCoordinator();
+
+ /// {@macro riverpod.override_with_value}
+ Override overrideWithValue(List value) {
+ return $ProviderOverride(
+ origin: this,
+ providerOverride: $SyncValueProvider>(value),
+ );
+ }
+}
+
+String _$appLinksCoordinatorHash() =>
+ r'183fc7ac1264a63c24b1d10f4a22cbfbf6046da7';
+
+/// Orchestrates Flutter-owned app-link prompts (§2.6): registers the availability
+/// event handler, queries the native pending store on attach/resume/event, and
+/// exposes resolution (including the remember-then-resolve flow). The presented
+/// list is authoritative from the query and deduped by `requestId` — the event
+/// is only a nudge to re-query.
+
+abstract class _$AppLinksCoordinator
+ extends $Notifier> {
+ List build();
+ @$mustCallSuper
+ @override
+ WhenComplete runBuild() {
+ final ref =
+ this.ref
+ as $Ref, List>;
+ final element =
+ ref.element
+ as $ClassProviderElement<
+ AnyNotifier<
+ List,
+ List
+ >,
+ List,
+ Object?,
+ Object?
+ >;
+ return element.handleCreate(ref, build);
+ }
+}
diff --git a/apps/weblibre/lib/features/app_links/domain/services/effective_app_link_policy.dart b/apps/weblibre/lib/features/app_links/domain/services/effective_app_link_policy.dart
new file mode 100644
index 00000000..7942783d
--- /dev/null
+++ b/apps/weblibre/lib/features/app_links/domain/services/effective_app_link_policy.dart
@@ -0,0 +1,151 @@
+/*
+ * Copyright (c) 2024-2026 Fabian Freund.
+ *
+ * This file is part of WebLibre
+ * (see https://weblibre.eu).
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+import 'package:fast_equatable/fast_equatable.dart';
+import 'package:flutter_mozilla_components/flutter_mozilla_components.dart'
+ show AppLinksMode;
+import 'package:riverpod_annotation/riverpod_annotation.dart';
+import 'package:weblibre/features/app_links/domain/entities/app_link_rule.dart';
+import 'package:weblibre/features/app_links/domain/entities/context_app_link_policy.dart';
+import 'package:weblibre/features/geckoview/features/tabs/data/models/container_data.dart';
+import 'package:weblibre/features/geckoview/features/tabs/domain/providers.dart';
+import 'package:weblibre/features/user/domain/repositories/general_settings.dart';
+
+part 'effective_app_link_policy.g.dart';
+
+/// Resolve a tab's live contextId to the app-link override storage key — the
+/// base contextId of the container with "isolated app link settings" enabled
+/// that governs the tab — or null when the global mode + rules apply.
+///
+/// [liveContextId] is the container's base contextId for a regular tab, or the
+/// tab's `isolation_context_id` for an isolated tab (i.e. `TabState.contextId`
+/// as reported by the engine). Uses the same lowest-sorted-base tiebreak as the
+/// snapshot builder so lookups land on the bucket that is published to native.
+String? resolveAppLinkOverrideKey({
+ required String? liveContextId,
+ required List containers,
+ required Map> isolationContextContainerMap,
+}) {
+ if (liveContextId == null) return null;
+
+ // Regular tab: liveContextId is a container's own base contextId.
+ for (final container in containers) {
+ if (container.metadata.contextualIdentity == liveContextId) {
+ return container.metadata.isolatedAppLinkSettings ? liveContextId : null;
+ }
+ }
+
+ // Isolated tab: liveContextId is an isolation context shared by one or more
+ // containers; pick the isolated-app-link one with the lowest base contextId.
+ final containerIds = isolationContextContainerMap[liveContextId];
+ if (containerIds == null || containerIds.isEmpty) return null;
+
+ final byId = {for (final container in containers) container.id: container};
+ final baseIds =
+ containerIds
+ .map((id) => byId[id])
+ .nonNulls
+ .where(
+ (container) =>
+ container.metadata.isolatedAppLinkSettings &&
+ container.metadata.contextualIdentity != null,
+ )
+ .map((container) => container.metadata.contextualIdentity!)
+ .toList()
+ ..sort();
+ return baseIds.isEmpty ? null : baseIds.first;
+}
+
+/// The app-link policy effectively governing a tab: the global mode + rules,
+/// or the owning container's override when it has isolated app-link settings
+/// (replace semantics). Used by the site settings sheet to display and edit
+/// the settings in the bucket that actually applies to the shown tab.
+class EffectiveAppLinkPolicy with FastEquatable {
+ /// The override storage key (container base contextId), or null when the
+ /// global bucket governs the tab.
+ final String? overrideKey;
+
+ /// Display name of the governing container; null when global.
+ final String? containerName;
+
+ /// The effective open-links-in-apps mode.
+ final AppLinksMode mode;
+
+ /// The effective remembered rules, keyed by canonical scope
+ /// (`host:` | `pkg:`).
+ final Map rules;
+
+ EffectiveAppLinkPolicy({
+ required this.overrideKey,
+ required this.containerName,
+ required this.mode,
+ required this.rules,
+ });
+
+ /// Whether the tab is governed by a container override (true) or the global
+ /// bucket (false).
+ bool get isContainerScoped => overrideKey != null;
+
+ @override
+ List get hashParameters => [overrideKey, containerName, mode, rules];
+}
+
+/// Compute the [EffectiveAppLinkPolicy] for a tab's live contextId. Returns
+/// null until the container/isolation inputs have loaded — resolving against
+/// empty placeholders could misattribute an isolated container's tab to the
+/// global bucket, so callers show a loading state instead.
+@Riverpod()
+EffectiveAppLinkPolicy? effectiveAppLinkPolicy(Ref ref, String? liveContextId) {
+ final settings = ref.watch(generalSettingsWithDefaultsProvider);
+ final containers = ref.watch(watchContainersWithCountProvider).value;
+ final isolationMap = ref
+ .watch(watchIsolatedContextContainerMapProvider)
+ .value;
+ if (containers == null || isolationMap == null) return null;
+
+ final overrideKey = resolveAppLinkOverrideKey(
+ liveContextId: liveContextId,
+ containers: containers,
+ isolationContextContainerMap: isolationMap,
+ );
+
+ if (overrideKey == null) {
+ return EffectiveAppLinkPolicy(
+ overrideKey: null,
+ containerName: null,
+ mode: settings.appLinksMode,
+ rules: settings.appLinkRules,
+ );
+ }
+
+ final override =
+ settings.appLinkContextOverrides[overrideKey] ??
+ ContextAppLinkPolicy.blank();
+ final containerName = containers
+ .where((c) => c.metadata.contextualIdentity == overrideKey)
+ .firstOrNull
+ ?.name;
+
+ return EffectiveAppLinkPolicy(
+ overrideKey: overrideKey,
+ containerName: containerName,
+ mode: override.mode,
+ rules: override.rules,
+ );
+}
diff --git a/apps/weblibre/lib/features/app_links/domain/services/effective_app_link_policy.g.dart b/apps/weblibre/lib/features/app_links/domain/services/effective_app_link_policy.g.dart
new file mode 100644
index 00000000..5d5e3141
--- /dev/null
+++ b/apps/weblibre/lib/features/app_links/domain/services/effective_app_link_policy.g.dart
@@ -0,0 +1,118 @@
+// GENERATED CODE - DO NOT MODIFY BY HAND
+
+part of 'effective_app_link_policy.dart';
+
+// **************************************************************************
+// RiverpodGenerator
+// **************************************************************************
+
+// GENERATED CODE - DO NOT MODIFY BY HAND
+// ignore_for_file: type=lint, type=warning
+/// Compute the [EffectiveAppLinkPolicy] for a tab's live contextId. Returns
+/// null until the container/isolation inputs have loaded — resolving against
+/// empty placeholders could misattribute an isolated container's tab to the
+/// global bucket, so callers show a loading state instead.
+
+@ProviderFor(effectiveAppLinkPolicy)
+final effectiveAppLinkPolicyProvider = EffectiveAppLinkPolicyFamily._();
+
+/// Compute the [EffectiveAppLinkPolicy] for a tab's live contextId. Returns
+/// null until the container/isolation inputs have loaded — resolving against
+/// empty placeholders could misattribute an isolated container's tab to the
+/// global bucket, so callers show a loading state instead.
+
+final class EffectiveAppLinkPolicyProvider
+ extends
+ $FunctionalProvider<
+ EffectiveAppLinkPolicy?,
+ EffectiveAppLinkPolicy?,
+ EffectiveAppLinkPolicy?
+ >
+ with $Provider {
+ /// Compute the [EffectiveAppLinkPolicy] for a tab's live contextId. Returns
+ /// null until the container/isolation inputs have loaded — resolving against
+ /// empty placeholders could misattribute an isolated container's tab to the
+ /// global bucket, so callers show a loading state instead.
+ EffectiveAppLinkPolicyProvider._({
+ required EffectiveAppLinkPolicyFamily super.from,
+ required String? super.argument,
+ }) : super(
+ retry: null,
+ name: r'effectiveAppLinkPolicyProvider',
+ isAutoDispose: true,
+ dependencies: null,
+ $allTransitiveDependencies: null,
+ );
+
+ @override
+ String debugGetCreateSourceHash() => _$effectiveAppLinkPolicyHash();
+
+ @override
+ String toString() {
+ return r'effectiveAppLinkPolicyProvider'
+ ''
+ '($argument)';
+ }
+
+ @$internal
+ @override
+ $ProviderElement $createElement(
+ $ProviderPointer pointer,
+ ) => $ProviderElement(pointer);
+
+ @override
+ EffectiveAppLinkPolicy? create(Ref ref) {
+ final argument = this.argument as String?;
+ return effectiveAppLinkPolicy(ref, argument);
+ }
+
+ /// {@macro riverpod.override_with_value}
+ Override overrideWithValue(EffectiveAppLinkPolicy? value) {
+ return $ProviderOverride(
+ origin: this,
+ providerOverride: $SyncValueProvider(value),
+ );
+ }
+
+ @override
+ bool operator ==(Object other) {
+ return other is EffectiveAppLinkPolicyProvider &&
+ other.argument == argument;
+ }
+
+ @override
+ int get hashCode {
+ return argument.hashCode;
+ }
+}
+
+String _$effectiveAppLinkPolicyHash() =>
+ r'da8101e842a9cf516eb18d817560813bc0cc94f0';
+
+/// Compute the [EffectiveAppLinkPolicy] for a tab's live contextId. Returns
+/// null until the container/isolation inputs have loaded — resolving against
+/// empty placeholders could misattribute an isolated container's tab to the
+/// global bucket, so callers show a loading state instead.
+
+final class EffectiveAppLinkPolicyFamily extends $Family
+ with $FunctionalFamilyOverride {
+ EffectiveAppLinkPolicyFamily._()
+ : super(
+ retry: null,
+ name: r'effectiveAppLinkPolicyProvider',
+ dependencies: null,
+ $allTransitiveDependencies: null,
+ isAutoDispose: true,
+ );
+
+ /// Compute the [EffectiveAppLinkPolicy] for a tab's live contextId. Returns
+ /// null until the container/isolation inputs have loaded — resolving against
+ /// empty placeholders could misattribute an isolated container's tab to the
+ /// global bucket, so callers show a loading state instead.
+
+ EffectiveAppLinkPolicyProvider call(String? liveContextId) =>
+ EffectiveAppLinkPolicyProvider._(argument: liveContextId, from: this);
+
+ @override
+ String toString() => r'effectiveAppLinkPolicyProvider';
+}
diff --git a/apps/weblibre/lib/features/app_links/domain/services/effective_routing.dart b/apps/weblibre/lib/features/app_links/domain/services/effective_routing.dart
new file mode 100644
index 00000000..3539367c
--- /dev/null
+++ b/apps/weblibre/lib/features/app_links/domain/services/effective_routing.dart
@@ -0,0 +1,343 @@
+/*
+ * Copyright (c) 2024-2026 Fabian Freund.
+ *
+ * This file is part of WebLibre
+ * (see https://weblibre.eu).
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+
+/// Shared, pure routing-resolution model (see APP_LINKS_OWN_IMPLEMENTATION_PLAN.md
+/// §2.3). Owned by neither `ProxySettingsReplication` nor app-link protection —
+/// both consume it so there is exactly one notion of "how is this container
+/// routed" and "is this tab effectively proxied".
+library;
+
+import 'package:fast_equatable/fast_equatable.dart';
+import 'package:weblibre/features/geckoview/features/tabs/data/models/container_data.dart';
+import 'package:weblibre/features/geckoview/features/tabs/data/models/site_assignment.dart';
+import 'package:weblibre/features/proxy/data/proxy_connection.dart';
+
+/// How a container (or isolation context) is routed after resolving its own
+/// proxy settings — before inheriting/aliasing.
+sealed class ProxyAssignment with FastEquatable {
+ ProxyAssignment();
+
+ /// Follows the global (`general`) routing.
+ factory ProxyAssignment.inherit() = InheritProxyAssignment;
+
+ /// Explicitly bypasses the global proxy (direct connection), scoped to [scopeId].
+ factory ProxyAssignment.direct(String scopeId) = DirectProxyAssignment;
+
+ /// Routed through the proxy identified by [proxyId].
+ factory ProxyAssignment.explicit(String proxyId) = ExplicitProxyAssignment;
+}
+
+final class InheritProxyAssignment extends ProxyAssignment {
+ InheritProxyAssignment();
+
+ @override
+ List get hashParameters => const ['inherit'];
+}
+
+final class DirectProxyAssignment extends ProxyAssignment {
+ final String scopeId;
+
+ DirectProxyAssignment(this.scopeId);
+
+ @override
+ List get hashParameters => ['direct', scopeId];
+}
+
+final class ExplicitProxyAssignment extends ProxyAssignment {
+ final String proxyId;
+
+ ExplicitProxyAssignment(this.proxyId);
+
+ @override
+ List get hashParameters => ['explicit', proxyId];
+}
+
+/// Resolve a single container's routing from its own metadata fields.
+///
+/// - a set [proxyConnectionId] → `explicit`
+/// - no proxy but [bypassGlobalProxy] → `direct` scoped to [contextId]
+/// - otherwise → `inherit`
+///
+/// This is the one place the per-container `proxyConnectionId`/`bypassGlobalProxy`
+/// precedence lives; the proxy replicator and app-link protection both call it.
+ProxyAssignment resolveContainerAssignment({
+ required String contextId,
+ required ProxyConnectionId? proxyConnectionId,
+ required bool bypassGlobalProxy,
+}) {
+ return switch (proxyConnectionId) {
+ final proxyId? => ProxyAssignment.explicit(proxyId.encode()),
+ null when bypassGlobalProxy => ProxyAssignment.direct(contextId),
+ null => ProxyAssignment.inherit(),
+ };
+}
+
+/// The result of collapsing the (possibly conflicting) routing of the containers
+/// that share an isolation context into a single alias.
+class IsolationContextRouting {
+ /// The assignment the isolation context aliases to.
+ final ProxyAssignment chosen;
+
+ /// Human-readable label for [chosen] (used in the conflict warning).
+ final String chosenLabel;
+
+ /// The distinct assignment labels observed, ordered `inherit`, `direct:*`,
+ /// then proxy ids — used to describe conflicts.
+ final List assignmentLabels;
+
+ /// Number of distinct assignments; `> 1` means the containers disagree.
+ final int distinctAssignmentCount;
+
+ IsolationContextRouting({
+ required this.chosen,
+ required this.chosenLabel,
+ required this.assignmentLabels,
+ required this.distinctAssignmentCount,
+ });
+}
+
+/// Collapse the routing of the containers sharing one isolation context.
+///
+/// Precedence: any explicit proxy wins (lowest sorted id); else a direct
+/// connection wins only if no container inherits; else inherit.
+IsolationContextRouting resolveIsolationContextRouting(
+ Iterable assignments,
+) {
+ final proxyIds =
+ assignments
+ .whereType()
+ .map((assignment) => assignment.proxyId)
+ .toSet()
+ .toList()
+ ..sort();
+ final directScopeIds =
+ assignments
+ .whereType()
+ .map((assignment) => assignment.scopeId)
+ .toSet()
+ .toList()
+ ..sort();
+ final hasInheritedAssignment = assignments.any(
+ (assignment) => assignment is InheritProxyAssignment,
+ );
+
+ final chosen = proxyIds.isNotEmpty
+ ? ProxyAssignment.explicit(proxyIds.first)
+ : directScopeIds.isNotEmpty && !hasInheritedAssignment
+ ? ProxyAssignment.direct(directScopeIds.first)
+ : ProxyAssignment.inherit();
+
+ final chosenLabel = switch (chosen) {
+ DirectProxyAssignment(:final scopeId) => 'direct:$scopeId',
+ ExplicitProxyAssignment(:final proxyId) => proxyId,
+ InheritProxyAssignment() => 'inherit',
+ };
+
+ return IsolationContextRouting(
+ chosen: chosen,
+ chosenLabel: chosenLabel,
+ assignmentLabels: [
+ if (hasInheritedAssignment) 'inherit',
+ ...directScopeIds.map((id) => 'direct:$id'),
+ ...proxyIds,
+ ],
+ distinctAssignmentCount:
+ proxyIds.length +
+ directScopeIds.length +
+ (hasInheritedAssignment ? 1 : 0),
+ );
+}
+
+/// Whether a tab whose container resolves to [assignment] is effectively
+/// proxied — the app-link "protected context" test (§2.3).
+///
+/// - `explicit` → proxied
+/// - `direct` → never proxied (deliberately bypasses the global proxy)
+/// - `inherit` → proxied iff the global (`general`) route is a proxy
+bool isAssignmentProtected(
+ ProxyAssignment assignment, {
+ required bool protectGeneralContext,
+}) {
+ return switch (assignment) {
+ ExplicitProxyAssignment() => true,
+ DirectProxyAssignment() => false,
+ InheritProxyAssignment() => protectGeneralContext,
+ };
+}
+
+/// A target-side protection pattern (§2.3/§2.8). Any navigation target assigned
+/// to an effectively-proxied or strict container is protected independent of the
+/// source tab, because site assignment moves the URL into its container
+/// *asynchronously*, after the navigation.
+class ProtectedTargetPattern with FastEquatable {
+ final String scheme;
+ final String hostOrSuffix;
+ final bool includeSubdomains;
+
+ /// Effective port for exact entries; null for wildcard entries (which ignore
+ /// port), preserving [siteAssignmentMatches] semantics.
+ final int? port;
+
+ ProtectedTargetPattern({
+ required this.scheme,
+ required this.hostOrSuffix,
+ required this.includeSubdomains,
+ required this.port,
+ });
+
+ @override
+ List get hashParameters => [
+ scheme,
+ hostOrSuffix,
+ includeSubdomains,
+ port,
+ ];
+}
+
+/// Build the [ProtectedTargetPattern] for a single site-assignment [Uri],
+/// preserving [siteAssignmentMatches] semantics: wildcard (`*.host`) entries
+/// match apex+subdomains for the scheme and ignore port; exact entries compare
+/// scheme + origin (including effective port).
+ProtectedTargetPattern protectedTargetPatternForSite(Uri assignedSite) {
+ if (isWildcardSite(assignedSite)) {
+ return ProtectedTargetPattern(
+ scheme: assignedSite.scheme,
+ hostOrSuffix: assignedSite.host.substring('*.'.length),
+ includeSubdomains: true,
+ port: null,
+ );
+ }
+
+ return ProtectedTargetPattern(
+ scheme: assignedSite.scheme,
+ hostOrSuffix: assignedSite.host,
+ includeSubdomains: false,
+ // Uri.port yields the effective port (scheme default when unspecified), so
+ // exact entries preserve the effective port as `siteAssignmentMatches` does
+ // via origin comparison.
+ port: assignedSite.port,
+ );
+}
+
+/// Compute the protected target patterns from all site assignments, keeping only
+/// those whose container [contextualIdentity] is effectively proxied or strict
+/// ([protectedOrStrictContextIds]). Deduplicated.
+List computeProtectedTargetPatterns({
+ required Iterable assignments,
+ required Set protectedOrStrictContextIds,
+}) {
+ final patterns = {};
+ for (final assignment in assignments) {
+ final contextId = assignment.contextualIdentity;
+ if (contextId == null) continue;
+ if (!protectedOrStrictContextIds.contains(contextId)) continue;
+ patterns.add(protectedTargetPatternForSite(assignment.assignedSite));
+ }
+ return patterns.toList();
+}
+
+/// The complete app-link protection view (§2.3) replicated to native.
+class AppLinkProtection with FastEquatable {
+ /// Regular / no-contextId tabs are proxied via the `general` scope.
+ final bool protectGeneralContext;
+
+ /// contextIds (containers and isolation contexts) that resolve to a proxy.
+ final Set protectedContextIds;
+
+ /// strictMode-enforced contextIds, independent of routing.
+ final Set strictContextIds;
+
+ final List protectedTargetPatterns;
+
+ AppLinkProtection({
+ required this.protectGeneralContext,
+ required this.protectedContextIds,
+ required this.strictContextIds,
+ required this.protectedTargetPatterns,
+ });
+
+ @override
+ List get hashParameters => [
+ protectGeneralContext,
+ protectedContextIds,
+ strictContextIds,
+ protectedTargetPatterns,
+ ];
+}
+
+/// Pure protection computation from the routing/container/assignment inputs
+/// (§2.3). A container's contextId is protected when its effective assignment is
+/// proxied; an isolation context is protected when the alias it collapses to is
+/// proxied; strict contexts are always protected. Target patterns cover any site
+/// assigned to a protected or strict container.
+AppLinkProtection computeAppLinkProtection({
+ required bool protectGeneralContext,
+ required Iterable containers,
+ required Map> isolationContextContainerMap,
+ required Set strictContextIds,
+ required Iterable siteAssignments,
+}) {
+ final assignmentByContextId = {};
+ final assignmentByContainerId = {};
+
+ for (final container in containers) {
+ final contextId = container.metadata.contextualIdentity;
+ if (contextId == null || contextId.isEmpty) continue;
+ final assignment = resolveContainerAssignment(
+ contextId: contextId,
+ proxyConnectionId: container.metadata.proxyConnectionId,
+ bypassGlobalProxy: container.metadata.bypassGlobalProxy,
+ );
+ assignmentByContextId[contextId] = assignment;
+ assignmentByContainerId[container.id] = assignment;
+ }
+
+ final protectedContextIds = {};
+ for (final MapEntry(:key, :value) in assignmentByContextId.entries) {
+ if (isAssignmentProtected(value, protectGeneralContext: protectGeneralContext)) {
+ protectedContextIds.add(key);
+ }
+ }
+
+ for (final MapEntry(:key, :value) in isolationContextContainerMap.entries) {
+ final assignments = value
+ .map((containerId) => assignmentByContainerId[containerId])
+ .nonNulls
+ .toList();
+ if (assignments.isEmpty) continue;
+ final chosen = resolveIsolationContextRouting(assignments).chosen;
+ if (isAssignmentProtected(chosen, protectGeneralContext: protectGeneralContext)) {
+ protectedContextIds.add(key);
+ }
+ }
+
+ final protectedOrStrict = {...protectedContextIds, ...strictContextIds};
+ final patterns = computeProtectedTargetPatterns(
+ assignments: siteAssignments,
+ protectedOrStrictContextIds: protectedOrStrict,
+ );
+
+ return AppLinkProtection(
+ protectGeneralContext: protectGeneralContext,
+ protectedContextIds: protectedContextIds,
+ strictContextIds: strictContextIds,
+ protectedTargetPatterns: patterns,
+ );
+}
diff --git a/apps/weblibre/lib/features/app_links/presentation/widgets/app_link_open_banner.dart b/apps/weblibre/lib/features/app_links/presentation/widgets/app_link_open_banner.dart
new file mode 100644
index 00000000..719d749b
--- /dev/null
+++ b/apps/weblibre/lib/features/app_links/presentation/widgets/app_link_open_banner.dart
@@ -0,0 +1,122 @@
+/*
+ * Copyright (c) 2024-2026 Fabian Freund.
+ *
+ * This file is part of WebLibre
+ * (see https://weblibre.eu).
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+import 'package:flutter/material.dart';
+import 'package:flutter_hooks/flutter_hooks.dart';
+import 'package:flutter_mozilla_components/flutter_mozilla_components.dart';
+import 'package:hooks_riverpod/hooks_riverpod.dart';
+import 'package:weblibre/features/app_links/domain/services/app_links_coordinator.dart';
+import 'package:weblibre/features/app_links/presentation/widgets/app_link_prompt_dialog.dart';
+
+/// Non-modal banner for an http(s) app link (§2.2). The page is allowed to load
+/// while the banner is up; nothing blocks on it. Declining leaves the page
+/// loaded; choosing the app leaves the tab on the committed page.
+class AppLinkOpenBanner extends HookConsumerWidget {
+ final AppLinkPromptRequest request;
+
+ const AppLinkOpenBanner({super.key, required this.request});
+
+ @override
+ Widget build(BuildContext context, WidgetRef ref) {
+ final target = request.target;
+ final appName = target.appName;
+ final remember = useState(false);
+ final coordinator = ref.read(appLinksCoordinatorProvider.notifier);
+ final theme = Theme.of(context);
+
+ Future resolve(AppLinkDecision decision) async {
+ if (remember.value && request.canRemember) {
+ final rule = decision == AppLinkDecision.open
+ ? alwaysOpenRuleFor(target)
+ : neverOpenRuleFor(target);
+ if (rule != null) {
+ await coordinator.resolveWithRule(
+ request.requestId,
+ decision,
+ rule,
+ contextId: request.contextId,
+ );
+ return;
+ }
+ }
+ await coordinator.resolve(request.requestId, decision);
+ }
+
+ return Material(
+ elevation: 3,
+ color: theme.colorScheme.surfaceContainerHigh,
+ child: Padding(
+ padding: const EdgeInsets.fromLTRB(16, 8, 8, 8),
+ child: Column(
+ mainAxisSize: MainAxisSize.min,
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Row(
+ children: [
+ const Icon(Icons.open_in_new, size: 20),
+ const SizedBox(width: 12),
+ Expanded(
+ child: Text(
+ appName != null
+ ? 'Open this link in $appName?'
+ : 'Open this link in an app?',
+ style: theme.textTheme.bodyMedium,
+ ),
+ ),
+ IconButton(
+ icon: const Icon(Icons.close),
+ tooltip: 'Dismiss',
+ // A back/swipe/cancel resolves as dismiss (§2.6).
+ onPressed: () => resolve(AppLinkDecision.dismiss),
+ ),
+ ],
+ ),
+ if (request.canRemember)
+ Row(
+ children: [
+ Checkbox(
+ value: remember.value,
+ onChanged: (value) => remember.value = value ?? false,
+ ),
+ const Flexible(child: Text('Remember for this site')),
+ ],
+ ),
+ Align(
+ alignment: Alignment.centerRight,
+ child: Row(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ TextButton(
+ onPressed: () => resolve(AppLinkDecision.cancel),
+ child: const Text('Stay in browser'),
+ ),
+ const SizedBox(width: 8),
+ FilledButton(
+ onPressed: () => resolve(AppLinkDecision.open),
+ child: const Text('Open app'),
+ ),
+ ],
+ ),
+ ),
+ ],
+ ),
+ ),
+ );
+ }
+}
diff --git a/apps/weblibre/lib/features/app_links/presentation/widgets/app_link_prompt_dialog.dart b/apps/weblibre/lib/features/app_links/presentation/widgets/app_link_prompt_dialog.dart
new file mode 100644
index 00000000..892d7bbc
--- /dev/null
+++ b/apps/weblibre/lib/features/app_links/presentation/widgets/app_link_prompt_dialog.dart
@@ -0,0 +1,131 @@
+/*
+ * Copyright (c) 2024-2026 Fabian Freund.
+ *
+ * This file is part of WebLibre
+ * (see https://weblibre.eu).
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+import 'package:flutter/material.dart';
+import 'package:flutter_hooks/flutter_hooks.dart';
+import 'package:flutter_mozilla_components/flutter_mozilla_components.dart';
+import 'package:hooks_riverpod/hooks_riverpod.dart';
+import 'package:weblibre/features/app_links/domain/entities/app_link_rule.dart';
+import 'package:weblibre/features/app_links/domain/services/app_links_coordinator.dart';
+
+/// Build the `alwaysOpen` rule for a target, or null when it cannot be remembered
+/// (ambiguous resolution / no bound package).
+PersistedAppLinkRule? alwaysOpenRuleFor(AppLinkTarget target) {
+ final packageName = target.packageName;
+ if (target.isAmbiguous || packageName == null || packageName.isEmpty) {
+ return null;
+ }
+ return PersistedAppLinkRule(
+ decision: AppLinkRuleDecision.alwaysOpen,
+ scope: target.scopeKey,
+ packageName: packageName,
+ );
+}
+
+PersistedAppLinkRule neverOpenRuleFor(AppLinkTarget target) {
+ return PersistedAppLinkRule(
+ decision: AppLinkRuleDecision.neverOpen,
+ scope: target.scopeKey,
+ );
+}
+
+/// Modal prompt for an unsupported-scheme app link (§2.2). The navigation is
+/// genuinely stalled and there is no page to show behind it.
+class AppLinkPromptDialog extends HookConsumerWidget {
+ final AppLinkPromptRequest request;
+
+ const AppLinkPromptDialog({super.key, required this.request});
+
+ @override
+ Widget build(BuildContext context, WidgetRef ref) {
+ final target = request.target;
+ final appName = target.appName;
+ final remember = useState(false);
+ final coordinator = ref.read(appLinksCoordinatorProvider.notifier);
+ // Guards against a double-tap running two resolves + two Navigator.pop()s
+ // (the second pop would tear down the route beneath the dialog).
+ final resolving = useRef(false);
+
+ Future resolve(AppLinkDecision decision) async {
+ if (resolving.value) return;
+ resolving.value = true;
+ final navigator = Navigator.of(context);
+ if (remember.value && request.canRemember) {
+ final rule = decision == AppLinkDecision.open
+ ? alwaysOpenRuleFor(target)
+ : neverOpenRuleFor(target);
+ if (rule != null) {
+ await coordinator.resolveWithRule(
+ request.requestId,
+ decision,
+ rule,
+ contextId: request.contextId,
+ );
+ navigator.pop();
+ return;
+ }
+ }
+ await coordinator.resolve(request.requestId, decision);
+ navigator.pop();
+ }
+
+ return AlertDialog(
+ icon: const Icon(Icons.open_in_new),
+ title: Text(
+ appName != null ? 'Open in $appName?' : 'Open in another app?',
+ ),
+ content: Column(
+ mainAxisSize: MainAxisSize.min,
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ const Text('This link is handled by an app outside WebLibre.'),
+ const SizedBox(height: 8),
+ Text(
+ _displayScope(target.scopeKey),
+ style: Theme.of(context).textTheme.bodySmall,
+ ),
+ if (request.canRemember)
+ CheckboxListTile(
+ contentPadding: EdgeInsets.zero,
+ controlAffinity: ListTileControlAffinity.leading,
+ value: remember.value,
+ onChanged: (value) => remember.value = value ?? false,
+ title: const Text('Remember my choice for this site'),
+ ),
+ ],
+ ),
+ actions: [
+ TextButton(
+ onPressed: () => resolve(AppLinkDecision.cancel),
+ child: const Text('Cancel'),
+ ),
+ FilledButton(
+ onPressed: () => resolve(AppLinkDecision.open),
+ child: const Text('Open'),
+ ),
+ ],
+ );
+ }
+}
+
+String _displayScope(String scope) {
+ if (scope.startsWith('host:')) return scope.substring('host:'.length);
+ if (scope.startsWith('pkg:')) return scope.substring('pkg:'.length);
+ return scope;
+}
diff --git a/apps/weblibre/lib/features/app_links/presentation/widgets/app_link_prompt_host.dart b/apps/weblibre/lib/features/app_links/presentation/widgets/app_link_prompt_host.dart
new file mode 100644
index 00000000..e3f7e8d2
--- /dev/null
+++ b/apps/weblibre/lib/features/app_links/presentation/widgets/app_link_prompt_host.dart
@@ -0,0 +1,117 @@
+/*
+ * Copyright (c) 2024-2026 Fabian Freund.
+ *
+ * This file is part of WebLibre
+ * (see https://weblibre.eu).
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+import 'dart:async';
+
+import 'package:flutter/material.dart';
+import 'package:flutter_hooks/flutter_hooks.dart';
+import 'package:flutter_mozilla_components/flutter_mozilla_components.dart';
+import 'package:hooks_riverpod/hooks_riverpod.dart';
+import 'package:weblibre/features/app_links/domain/services/app_links_coordinator.dart';
+import 'package:weblibre/features/app_links/presentation/widgets/app_link_open_banner.dart';
+import 'package:weblibre/features/app_links/presentation/widgets/app_link_prompt_dialog.dart';
+import 'package:weblibre/features/geckoview/domain/providers/selected_tab.dart';
+
+/// Presents Flutter-owned app-link prompts (§2.6): renders at most one banner for
+/// the active tab, and drives one modal at a time via [showDialog]. A request is
+/// only shown while its originating tab is active. Rotation/teardown is not a
+/// dismissal — the request stays pending and is re-presented on the next query.
+///
+/// Mount this as a layer of the browser Stack that is positioned *above* the bottom
+/// app bar (see `browser.dart`, next to the find-in-page layer). It renders the
+/// banner inline, bottom-anchored within that positioned region — Flutter composites
+/// over the live GeckoView fine (the toolbars do the same); the only requirement is
+/// that the host is not placed underneath the bottom app bar.
+class AppLinkPromptHost extends HookConsumerWidget {
+ const AppLinkPromptHost({super.key});
+
+ @override
+ Widget build(BuildContext context, WidgetRef ref) {
+ final prompts = ref.watch(appLinksCoordinatorProvider);
+ final activeTabId = ref.watch(selectedTabProvider);
+
+ // The Pigeon availability event has no replay: an event emitted while Flutter
+ // was detached is lost, so re-query the native pending store on resume (§2.6).
+ useOnAppLifecycleStateChange((previous, current) {
+ if (current == AppLifecycleState.resumed) {
+ unawaited(ref.read(appLinksCoordinatorProvider.notifier).refresh());
+ }
+ });
+
+ final activeRequests = prompts
+ .where((request) => request.tabId == activeTabId)
+ .toList();
+
+ final modalRequest = activeRequests
+ .where((request) => request.isModal)
+ .lastOrNull;
+ // At most one banner per tab; a newer banner-class request simply becomes the
+ // one the UI renders.
+ final bannerRequest = activeRequests
+ .where((request) => !request.isModal)
+ .lastOrNull;
+
+ // A modal is shown at most once per requestId. Rotation/teardown is not a
+ // dismissal — the request stays pending and is re-presented on the next query
+ // (a subsequent build re-runs this effect with the still-present id).
+ final shownModalId = useRef(null);
+ useEffect(() {
+ final request = modalRequest;
+ if (request == null) {
+ shownModalId.value = null;
+ return null;
+ }
+ if (shownModalId.value == request.requestId) {
+ return null;
+ }
+ shownModalId.value = request.requestId;
+ WidgetsBinding.instance.addPostFrameCallback((_) {
+ if (!context.mounted) return;
+ unawaited(
+ showDialog(
+ context: context,
+ builder: (_) => AppLinkPromptDialog(request: request),
+ ).then((_) {
+ // Catch-all for a passive dismissal (Android back / touch-outside):
+ // the dialog buttons resolve the request themselves, but a barrier
+ // dismiss closes it without resolving, leaving the native request
+ // pending forever (and `shownModalId` blocks a re-show). Resolving as
+ // dismiss here is idempotent — if a button already consumed it, the
+ // native store returns stale and this is a no-op.
+ unawaited(
+ ref
+ .read(appLinksCoordinatorProvider.notifier)
+ .resolve(request.requestId, AppLinkDecision.dismiss),
+ );
+ }),
+ );
+ });
+ return null;
+ }, [modalRequest?.requestId]);
+
+ if (bannerRequest == null) {
+ return const SizedBox.shrink();
+ }
+
+ return AppLinkOpenBanner(
+ key: ValueKey(bannerRequest.requestId),
+ request: bannerRequest,
+ );
+ }
+}
diff --git a/apps/weblibre/lib/features/app_links/presentation/widgets/container_app_link_settings_dialog.dart b/apps/weblibre/lib/features/app_links/presentation/widgets/container_app_link_settings_dialog.dart
new file mode 100644
index 00000000..2fd56f87
--- /dev/null
+++ b/apps/weblibre/lib/features/app_links/presentation/widgets/container_app_link_settings_dialog.dart
@@ -0,0 +1,178 @@
+/*
+ * Copyright (c) 2024-2026 Fabian Freund.
+ *
+ * This file is part of WebLibre
+ * (see https://weblibre.eu).
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+import 'package:flutter/material.dart';
+import 'package:flutter_material_design_icons/flutter_material_design_icons.dart';
+import 'package:flutter_mozilla_components/flutter_mozilla_components.dart'
+ show AppLinksMode;
+import 'package:hooks_riverpod/hooks_riverpod.dart';
+import 'package:weblibre/features/app_links/domain/entities/app_link_rule.dart';
+import 'package:weblibre/features/app_links/domain/entities/context_app_link_policy.dart';
+import 'package:weblibre/features/settings/presentation/controllers/save_settings.dart';
+import 'package:weblibre/features/user/data/models/general_settings.dart';
+import 'package:weblibre/features/user/domain/repositories/general_settings.dart';
+
+/// Per-container app-link settings (§ container isolation), bound to
+/// `GeneralSettings.appLinkContextOverrides[contextId]`. Mirrors the global
+/// app-links section but writes into the container's own override bucket, which
+/// fully replaces the global mode + rules for that container (replace semantics).
+///
+/// Present via `showDialog`; edits save live (no separate confirm step), matching
+/// the global settings screen. Only meaningful for an isolated, cookie-isolated
+/// container — the caller gates on that.
+class ContainerAppLinkSettingsDialog extends ConsumerWidget {
+ /// The container's Gecko contextId (`contextualIdentity`); the override key.
+ final String contextId;
+
+ /// Optional container name for the title.
+ final String? containerName;
+
+ const ContainerAppLinkSettingsDialog({
+ super.key,
+ required this.contextId,
+ this.containerName,
+ });
+
+ Future _updateOverride(
+ WidgetRef ref,
+ ContextAppLinkPolicy Function(ContextAppLinkPolicy current) update,
+ ) async {
+ await ref
+ .read(saveGeneralSettingsControllerProvider.notifier)
+ .save((current) {
+ final existing =
+ current.appLinkContextOverrides[contextId] ??
+ ContextAppLinkPolicy.blank();
+ return current.copyWith.appLinkContextOverrides({
+ ...current.appLinkContextOverrides,
+ contextId: update(existing),
+ });
+ });
+ }
+
+ @override
+ Widget build(BuildContext context, WidgetRef ref) {
+ final override = ref.watch(
+ generalSettingsWithDefaultsProvider.select(
+ (s) => s.appLinkContextOverrides[contextId],
+ ),
+ );
+ final policy = override ?? ContextAppLinkPolicy.blank();
+
+ final rules = policy.rules.entries.toList()
+ ..sort((a, b) => a.key.compareTo(b.key));
+
+ return Dialog.fullscreen(
+ child: Scaffold(
+ appBar: AppBar(
+ title: Text(
+ containerName != null
+ ? 'App Links — $containerName'
+ : 'Container App Links',
+ ),
+ leading: IconButton(
+ icon: const Icon(Icons.close),
+ onPressed: () => Navigator.of(context).pop(),
+ ),
+ ),
+ body: ListView(
+ padding: const EdgeInsets.symmetric(vertical: 8),
+ children: [
+ const Padding(
+ padding: EdgeInsets.fromLTRB(16, 8, 16, 0),
+ child: Text(
+ 'These settings apply only to this container and fully replace '
+ 'the global app-link settings for its tabs.',
+ ),
+ ),
+ RadioGroup(
+ groupValue: policy.mode,
+ onChanged: (value) async {
+ if (value != null) {
+ await _updateOverride(ref, (c) => c.copyWith.mode(value));
+ }
+ },
+ child: const Column(
+ children: [
+ RadioListTile.adaptive(
+ value: AppLinksMode.always,
+ title: Text('Always'),
+ subtitle: Text(
+ 'Always open links in their native apps without asking',
+ ),
+ ),
+ RadioListTile.adaptive(
+ value: AppLinksMode.ask,
+ title: Text('Ask before opening'),
+ subtitle: Text('Show a prompt before opening links in apps'),
+ ),
+ RadioListTile.adaptive(
+ value: AppLinksMode.never,
+ title: Text('Never'),
+ subtitle: Text(
+ 'Always open links in the browser instead of apps',
+ ),
+ ),
+ ],
+ ),
+ ),
+ if (rules.isNotEmpty) ...[
+ const Divider(),
+ const Padding(
+ padding: EdgeInsets.fromLTRB(16, 8, 16, 4),
+ child: Text('Remembered site rules'),
+ ),
+ for (final MapEntry(:key, :value) in rules)
+ ListTile(
+ dense: true,
+ leading: Icon(
+ value.decision == AppLinkRuleDecision.alwaysOpen
+ ? MdiIcons.openInApp
+ : Icons.public,
+ ),
+ title: Text(_displayScope(key)),
+ subtitle: Text(
+ value.decision == AppLinkRuleDecision.alwaysOpen
+ ? 'Always open in the app'
+ : 'Always keep in the browser',
+ ),
+ trailing: IconButton(
+ icon: const Icon(Icons.delete_outline),
+ tooltip: 'Remove rule',
+ onPressed: () async {
+ await _updateOverride(
+ ref,
+ (c) => c.copyWith.rules({...c.rules}..remove(key)),
+ );
+ },
+ ),
+ ),
+ ],
+ ],
+ ),
+ ),
+ );
+ }
+}
+
+String _displayScope(String scope) {
+ if (scope.startsWith('host:')) return scope.substring('host:'.length);
+ if (scope.startsWith('pkg:')) return scope.substring('pkg:'.length);
+ return scope;
+}
diff --git a/apps/weblibre/lib/features/geckoview/features/browser/domain/providers.dart b/apps/weblibre/lib/features/geckoview/features/browser/domain/providers.dart
index 6e8c6a18..7c83cff2 100644
--- a/apps/weblibre/lib/features/geckoview/features/browser/domain/providers.dart
+++ b/apps/weblibre/lib/features/geckoview/features/browser/domain/providers.dart
@@ -1267,18 +1267,3 @@ class _TabGroupRecord {
required this.dateKey,
});
}
-
-@Riverpod()
-class AppLinksModeNotifier extends _$AppLinksModeNotifier {
- final _service = GeckoEngineSettingsService();
-
- Future setMode(AppLinksMode mode) async {
- await _service.setAppLinksMode(mode);
- ref.invalidateSelf();
- }
-
- @override
- Future build() {
- return _service.getAppLinksMode();
- }
-}
diff --git a/apps/weblibre/lib/features/geckoview/features/browser/domain/providers.g.dart b/apps/weblibre/lib/features/geckoview/features/browser/domain/providers.g.dart
index a97d22dc..fa70ab96 100644
--- a/apps/weblibre/lib/features/geckoview/features/browser/domain/providers.g.dart
+++ b/apps/weblibre/lib/features/geckoview/features/browser/domain/providers.g.dart
@@ -1253,48 +1253,3 @@ final class GroupedTabListItemsFamily extends $Family
@override
String toString() => r'groupedTabListItemsProvider';
}
-
-@ProviderFor(AppLinksModeNotifier)
-final appLinksModeProvider = AppLinksModeNotifierProvider._();
-
-final class AppLinksModeNotifierProvider
- extends $AsyncNotifierProvider {
- AppLinksModeNotifierProvider._()
- : super(
- from: null,
- argument: null,
- retry: null,
- name: r'appLinksModeProvider',
- isAutoDispose: true,
- dependencies: null,
- $allTransitiveDependencies: null,
- );
-
- @override
- String debugGetCreateSourceHash() => _$appLinksModeNotifierHash();
-
- @$internal
- @override
- AppLinksModeNotifier create() => AppLinksModeNotifier();
-}
-
-String _$appLinksModeNotifierHash() =>
- r'2643b7d2799870fd444f7db204ea452d975368a3';
-
-abstract class _$AppLinksModeNotifier extends $AsyncNotifier {
- FutureOr build();
- @$mustCallSuper
- @override
- WhenComplete runBuild() {
- final ref = this.ref as $Ref, AppLinksMode>;
- final element =
- ref.element
- as $ClassProviderElement<
- AnyNotifier, AppLinksMode>,
- AsyncValue,
- Object?,
- Object?
- >;
- return element.handleCreate(ref, build);
- }
-}
diff --git a/apps/weblibre/lib/features/geckoview/features/browser/domain/services/browser_data.g.dart b/apps/weblibre/lib/features/geckoview/features/browser/domain/services/browser_data.g.dart
index b9e8a004..e5f7e30a 100644
--- a/apps/weblibre/lib/features/geckoview/features/browser/domain/services/browser_data.g.dart
+++ b/apps/weblibre/lib/features/geckoview/features/browser/domain/services/browser_data.g.dart
@@ -42,7 +42,7 @@ final class BrowserDataServiceProvider
}
String _$browserDataServiceHash() =>
- r'2df2f652342efc3e16606b92fdef6062b02f72df';
+ r'5df7ca0b61a5f34e69280311777e98fc31907269';
abstract class _$BrowserDataService extends $Notifier {
void build();
diff --git a/apps/weblibre/lib/features/geckoview/features/browser/domain/services/proxy_settings_replication.dart b/apps/weblibre/lib/features/geckoview/features/browser/domain/services/proxy_settings_replication.dart
index 3772a077..5b215c82 100644
--- a/apps/weblibre/lib/features/geckoview/features/browser/domain/services/proxy_settings_replication.dart
+++ b/apps/weblibre/lib/features/geckoview/features/browser/domain/services/proxy_settings_replication.dart
@@ -22,6 +22,7 @@ import 'package:riverpod/riverpod.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:synchronized/synchronized.dart';
import 'package:weblibre/core/logger.dart';
+import 'package:weblibre/features/app_links/domain/services/effective_routing.dart';
import 'package:weblibre/features/geckoview/features/tabs/data/models/container_data.dart';
import 'package:weblibre/features/geckoview/features/tabs/data/providers.dart';
import 'package:weblibre/features/geckoview/features/tabs/domain/providers.dart';
@@ -33,45 +34,10 @@ import 'package:weblibre/features/user/domain/repositories/proxy_routing_setting
part 'proxy_settings_replication.g.dart';
-sealed class _ProxyAssignment with FastEquatable {
- _ProxyAssignment();
-
- factory _ProxyAssignment.inherit() = _InheritProxyAssignment;
-
- factory _ProxyAssignment.direct(String scopeId) = _DirectProxyAssignment;
-
- factory _ProxyAssignment.explicit(String proxyId) = _ExplicitProxyAssignment;
-}
-
-final class _InheritProxyAssignment extends _ProxyAssignment {
- _InheritProxyAssignment();
-
- @override
- List get hashParameters => const ['inherit'];
-}
-
-final class _DirectProxyAssignment extends _ProxyAssignment {
- final String scopeId;
-
- _DirectProxyAssignment(this.scopeId);
-
- @override
- List get hashParameters => [scopeId];
-}
-
-final class _ExplicitProxyAssignment extends _ProxyAssignment {
- final String proxyId;
-
- _ExplicitProxyAssignment(this.proxyId);
-
- @override
- List get hashParameters => [proxyId];
-}
-
@Riverpod(keepAlive: true)
class ProxySettingsReplication extends _$ProxySettingsReplication {
- var _isolatedProxyAssignments = {};
- var _appliedContainerProxies = {};
+ var _isolatedProxyAssignments = {};
+ var _appliedContainerProxies = {};
final _recomputeLock = Lock();
var _recomputeDirty = false;
@@ -117,19 +83,17 @@ class ProxySettingsReplication extends _$ProxySettingsReplication {
.read(containerRepositoryProvider.notifier)
.getAllContainersWithCount();
- final containerAssignments = {
+ final containerAssignments = {
for (final c in containers)
if (c.metadata.contextualIdentity case final contextId?)
- c.id: switch (c.metadata.proxyConnectionId) {
- final proxyId? => _ProxyAssignment.explicit(proxyId.encode()),
- null when c.metadata.bypassGlobalProxy => _ProxyAssignment.direct(
- contextId,
- ),
- null => _ProxyAssignment.inherit(),
- },
+ c.id: resolveContainerAssignment(
+ contextId: contextId,
+ proxyConnectionId: c.metadata.proxyConnectionId,
+ bypassGlobalProxy: c.metadata.bypassGlobalProxy,
+ ),
};
- final newAssignments = {};
+ final newAssignments = {};
for (final entry in contextContainerMap.entries) {
final assignments = entry.value
.map((containerId) => containerAssignments[containerId])
@@ -138,50 +102,19 @@ class ProxySettingsReplication extends _$ProxySettingsReplication {
if (assignments.isEmpty) continue;
- final proxyIds =
- assignments
- .whereType<_ExplicitProxyAssignment>()
- .map((assignment) => assignment.proxyId)
- .toSet()
- .toList()
- ..sort();
- final directScopeIds =
- assignments
- .whereType<_DirectProxyAssignment>()
- .map((assignment) => assignment.scopeId)
- .toSet()
- .toList()
- ..sort();
- final hasInheritedAssignment = assignments.any(
- (assignment) => assignment is _InheritProxyAssignment,
- );
-
- final chosenAssignment = proxyIds.isNotEmpty
- ? _ProxyAssignment.explicit(proxyIds.first)
- : directScopeIds.isNotEmpty && !hasInheritedAssignment
- ? _ProxyAssignment.direct(directScopeIds.first)
- : _ProxyAssignment.inherit();
- if (chosenAssignment is! _InheritProxyAssignment) {
- newAssignments[entry.key] = chosenAssignment;
+ final routing = resolveIsolationContextRouting(assignments);
+ if (routing.chosen is! InheritProxyAssignment) {
+ newAssignments[entry.key] = routing.chosen;
}
- final chosenLabel = switch (chosenAssignment) {
- _DirectProxyAssignment(:final scopeId) => 'direct:$scopeId',
- _ExplicitProxyAssignment(:final proxyId) => proxyId,
- _InheritProxyAssignment() => 'inherit',
- };
- final distinctAssignmentCount =
- proxyIds.length +
- directScopeIds.length +
- (hasInheritedAssignment ? 1 : 0);
- if (distinctAssignmentCount > 1) {
+ if (routing.distinctAssignmentCount > 1) {
// Isolation contexts can hold multiple containers; if they disagree on
// routing, the alias is forced to pick one. Surface this so the user
// can split the containers across isolation contexts.
logger.w(
'Isolation context ${entry.key} has containers with multiple '
'proxy routing assignments '
- '(${[if (hasInheritedAssignment) 'inherit', ...directScopeIds.map((id) => 'direct:$id'), ...proxyIds].join(', ')}); using $chosenLabel',
+ '(${routing.assignmentLabels.join(', ')}); using ${routing.chosenLabel}',
);
}
}
@@ -339,20 +272,19 @@ class ProxySettingsReplication extends _$ProxySettingsReplication {
) async {
if (containers == null) return;
- final desired = {};
+ final desired = {};
for (final container in containers) {
final contextId = container.metadata.contextualIdentity;
if (contextId == null || contextId.isEmpty) continue;
- final proxyConnectionId = container.metadata.proxyConnectionId;
- desired[contextId] = proxyConnectionId != null
- ? _ProxyAssignment.explicit(proxyConnectionId.encode())
- : container.metadata.bypassGlobalProxy
- ? _ProxyAssignment.direct(contextId)
- : _ProxyAssignment.inherit();
+ desired[contextId] = resolveContainerAssignment(
+ contextId: contextId,
+ proxyConnectionId: container.metadata.proxyConnectionId,
+ bypassGlobalProxy: container.metadata.bypassGlobalProxy,
+ );
}
final repo = ref.read(containerProxyRepositoryProvider.notifier);
- final nextApplied = Map.from(
+ final nextApplied = Map.from(
_appliedContainerProxies,
);
@@ -395,15 +327,15 @@ class ProxySettingsReplication extends _$ProxySettingsReplication {
Future _applyProxyAssignment(
String contextId,
- _ProxyAssignment assignment,
+ ProxyAssignment assignment,
) async {
final repo = ref.read(containerProxyRepositoryProvider.notifier);
switch (assignment) {
- case _ExplicitProxyAssignment(:final proxyId):
+ case ExplicitProxyAssignment(:final proxyId):
await repo.setContainerProxy(contextId, proxyId);
- case _DirectProxyAssignment(:final scopeId):
+ case DirectProxyAssignment(:final scopeId):
await repo.setContainerDirectConnection(contextId, scopeId: scopeId);
- case _InheritProxyAssignment():
+ case InheritProxyAssignment():
await repo.clearContainerProxy(contextId);
}
}
diff --git a/apps/weblibre/lib/features/geckoview/features/browser/domain/services/proxy_settings_replication.g.dart b/apps/weblibre/lib/features/geckoview/features/browser/domain/services/proxy_settings_replication.g.dart
index e2acc1c2..6265a443 100644
--- a/apps/weblibre/lib/features/geckoview/features/browser/domain/services/proxy_settings_replication.g.dart
+++ b/apps/weblibre/lib/features/geckoview/features/browser/domain/services/proxy_settings_replication.g.dart
@@ -42,7 +42,7 @@ final class ProxySettingsReplicationProvider
}
String _$proxySettingsReplicationHash() =>
- r'69787c85c94ff165e3eeb0f0a3f3fc83e88a1b83';
+ r'bea07ab165545a6bef8a72ddf0503e0cd135eb8a';
abstract class _$ProxySettingsReplication extends $Notifier {
void build();
diff --git a/apps/weblibre/lib/features/geckoview/features/browser/features/contextual_toolbar/data/providers/toolbar_button_configs.dart b/apps/weblibre/lib/features/geckoview/features/browser/features/contextual_toolbar/data/providers/toolbar_button_configs.dart
index 6788f361..6eb3a798 100644
--- a/apps/weblibre/lib/features/geckoview/features/browser/features/contextual_toolbar/data/providers/toolbar_button_configs.dart
+++ b/apps/weblibre/lib/features/geckoview/features/browser/features/contextual_toolbar/data/providers/toolbar_button_configs.dart
@@ -44,7 +44,7 @@ List _buildDefaultToolbarButtonConfigs({
return ToolbarButtonConfig(
buttonId: spec.id.name,
orderKey: key,
- isVisible: allHidden ? false : spec.defaultVisible,
+ isVisible: !allHidden && spec.defaultVisible,
fallbackId: allHidden ? null : spec.defaultFallback?.name,
);
}).toList();
diff --git a/apps/weblibre/lib/features/geckoview/features/browser/presentation/screens/browser.dart b/apps/weblibre/lib/features/geckoview/features/browser/presentation/screens/browser.dart
index d1e7ea6c..5bd112bd 100644
--- a/apps/weblibre/lib/features/geckoview/features/browser/presentation/screens/browser.dart
+++ b/apps/weblibre/lib/features/geckoview/features/browser/presentation/screens/browser.dart
@@ -29,6 +29,7 @@ import 'package:weblibre/core/providers/global_drop.dart';
import 'package:weblibre/core/routing/routes.dart';
import 'package:weblibre/data/models/drag_data.dart';
import 'package:weblibre/extensions/media_query.dart';
+import 'package:weblibre/features/app_links/presentation/widgets/app_link_prompt_host.dart';
import 'package:weblibre/features/geckoview/domain/controllers/bottom_sheet.dart';
import 'package:weblibre/features/geckoview/domain/controllers/overlay.dart';
import 'package:weblibre/features/geckoview/domain/entities/states/tab.dart';
@@ -1276,6 +1277,39 @@ class BrowserScreen extends HookConsumerWidget {
},
),
),
+
+ // Layer 7: App-link prompt banner (§2.6). Anchored above the bottom app
+ // bar / keyboard exactly like find-in-page, so it is never hidden behind
+ // the toolbar. Custom Tab sessions are prompted natively instead; this is
+ // the browser-tab surface only.
+ Consumer(
+ builder: (context, ref, child) {
+ final toolbarState = ref.watch(
+ toolbarVisibilityControllerProvider(selectedTabId),
+ );
+ final visible =
+ sheetDisplayed ||
+ (!tabInFullScreen &&
+ toolbarState == ToolbarVisibility.visible);
+ return Positioned(
+ left: (tabBarPosition == TabBarPosition.left && visible)
+ ? sideRailTotalWidth
+ : 0.0,
+ right: (tabBarPosition == TabBarPosition.right && visible)
+ ? sideRailTotalWidth
+ : 0.0,
+ bottom: math.max(
+ isRail
+ ? bottomSafeArea
+ : (visible
+ ? bottomAppBarTotalHeight
+ : bottomSafeArea),
+ MediaQuery.viewInsetsOf(context).bottom,
+ ),
+ child: const AppLinkPromptHost(),
+ );
+ },
+ ),
],
),
),
diff --git a/apps/weblibre/lib/features/geckoview/features/browser/presentation/widgets/browser_menu_sheet.dart b/apps/weblibre/lib/features/geckoview/features/browser/presentation/widgets/browser_menu_sheet.dart
index cd77d8b9..689e28d1 100644
--- a/apps/weblibre/lib/features/geckoview/features/browser/presentation/widgets/browser_menu_sheet.dart
+++ b/apps/weblibre/lib/features/geckoview/features/browser/presentation/widgets/browser_menu_sheet.dart
@@ -1575,22 +1575,25 @@ class _OpenInAppTile extends HookConsumerWidget {
Widget build(BuildContext context, WidgetRef ref) {
final tabState = ref.watch(tabStateProvider(selectedTabId));
final url = tabState?.url;
- final hasExternalApp = useCachedFuture(
- () => url != null ? _service.hasExternalApp(url) : Future.value(false),
+ final appLink = useCachedFuture(
+ () => url != null ? _service.resolveAppLink(url) : Future.value(null),
[url],
);
- if (hasExternalApp.data != true) return const SizedBox.shrink();
+ final target = appLink.data;
+ if (target == null) return const SizedBox.shrink();
+
+ final appName = target.appName;
return Column(
children: [
_buildDivider(),
ListTile(
leading: const Icon(Icons.open_in_new),
- title: const Text('Open in App'),
+ title: Text(appName != null ? 'Open in $appName' : 'Open in App'),
onTap: () async {
if (url == null) return;
- final success = await _service.openAppLink(url);
+ final success = await _service.launchAppLink(url);
if (success && context.mounted) Navigator.pop(context);
},
),
diff --git a/apps/weblibre/lib/features/geckoview/features/browser/presentation/widgets/browser_modules/browser_view.dart b/apps/weblibre/lib/features/geckoview/features/browser/presentation/widgets/browser_modules/browser_view.dart
index a197f78b..4c9e09db 100644
--- a/apps/weblibre/lib/features/geckoview/features/browser/presentation/widgets/browser_modules/browser_view.dart
+++ b/apps/weblibre/lib/features/geckoview/features/browser/presentation/widgets/browser_modules/browser_view.dart
@@ -30,6 +30,7 @@ import 'package:weblibre/core/logger.dart';
import 'package:weblibre/core/providers/device_info.dart';
import 'package:weblibre/core/providers/router.dart';
import 'package:weblibre/core/routing/routes.dart';
+import 'package:weblibre/features/app_links/domain/services/app_link_policy_replication.dart';
import 'package:weblibre/features/bangs/data/models/web_search_bang.dart';
import 'package:weblibre/features/bangs/domain/providers/bangs.dart';
import 'package:weblibre/features/bangs/domain/services/search_history_cleanup.dart';
@@ -676,6 +677,19 @@ class _BrowserViewState extends ConsumerState
},
);
+ ref.listenManual(
+ fireImmediately: true,
+ appLinkPolicyReplicationProvider,
+ (previous, next) {},
+ onError: (error, stackTrace) {
+ logger.e(
+ 'Error listening to appLinkPolicyReplicationProvider',
+ error: error,
+ stackTrace: stackTrace,
+ );
+ },
+ );
+
ref.listenManual(
fireImmediately: true,
historyExclusionReplicationProvider,
diff --git a/apps/weblibre/lib/features/geckoview/features/browser/presentation/widgets/menu_item_buttons.dart b/apps/weblibre/lib/features/geckoview/features/browser/presentation/widgets/menu_item_buttons.dart
index 1ae57a23..4793c9e4 100644
--- a/apps/weblibre/lib/features/geckoview/features/browser/presentation/widgets/menu_item_buttons.dart
+++ b/apps/weblibre/lib/features/geckoview/features/browser/presentation/widgets/menu_item_buttons.dart
@@ -329,24 +329,27 @@ class OpenInAppMenuItemButton extends HookConsumerWidget {
Widget build(BuildContext context, WidgetRef ref) {
final tabState = ref.watch(tabStateProvider(selectedTabId));
final url = tabState?.url;
- final hasExternalApp = useCachedFuture(
+ final appLink = useCachedFuture(
// ignore: discarded_futures useFuture
- () => url != null ? _service.hasExternalApp(url) : Future.value(false),
+ () => url != null ? _service.resolveAppLink(url) : Future.value(null),
[url],
);
- if (hasExternalApp.data != true) {
+ final target = appLink.data;
+ if (target == null) {
return const SizedBox.shrink();
}
+ final appName = target.appName;
+
return MenuItemButton(
leadingIcon: const Icon(Icons.open_in_new),
closeOnActivate: false,
- child: const Text('Open in App'),
+ child: Text(appName != null ? 'Open in $appName' : 'Open in App'),
onPressed: () async {
if (url == null) return;
- final success = await _service.openAppLink(url);
+ final success = await _service.launchAppLink(url);
if (success && context.mounted) {
MenuController.maybeOf(context)?.close();
diff --git a/apps/weblibre/lib/features/geckoview/features/browser/presentation/widgets/share_bottom_sheet.dart b/apps/weblibre/lib/features/geckoview/features/browser/presentation/widgets/share_bottom_sheet.dart
index f46a5810..1ffaf4ec 100644
--- a/apps/weblibre/lib/features/geckoview/features/browser/presentation/widgets/share_bottom_sheet.dart
+++ b/apps/weblibre/lib/features/geckoview/features/browser/presentation/widgets/share_bottom_sheet.dart
@@ -357,19 +357,22 @@ class _OpenInAppTile extends HookConsumerWidget {
Widget build(BuildContext context, WidgetRef ref) {
final tabState = ref.watch(tabStateProvider(selectedTabId));
final url = tabState?.url;
- final hasExternalApp = useCachedFuture(
- () => url != null ? _service.hasExternalApp(url) : Future.value(false),
+ final appLink = useCachedFuture(
+ () => url != null ? _service.resolveAppLink(url) : Future.value(null),
[url],
);
- if (hasExternalApp.data != true) return const SizedBox.shrink();
+ final target = appLink.data;
+ if (target == null) return const SizedBox.shrink();
+
+ final appName = target.appName;
return ListTile(
leading: const Icon(Icons.open_in_new),
- title: const Text('Open in App'),
+ title: Text(appName != null ? 'Open in $appName' : 'Open in App'),
onTap: () async {
if (url == null) return;
- final success = await _service.openAppLink(url);
+ final success = await _service.launchAppLink(url);
if (success && context.mounted) Navigator.pop(context);
},
);
diff --git a/apps/weblibre/lib/features/geckoview/features/browser/presentation/widgets/sheets/app_link_section.dart b/apps/weblibre/lib/features/geckoview/features/browser/presentation/widgets/sheets/app_link_section.dart
new file mode 100644
index 00000000..d09f6f40
--- /dev/null
+++ b/apps/weblibre/lib/features/geckoview/features/browser/presentation/widgets/sheets/app_link_section.dart
@@ -0,0 +1,238 @@
+/*
+ * Copyright (c) 2024-2026 Fabian Freund.
+ *
+ * This file is part of WebLibre
+ * (see https://weblibre.eu).
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+import 'package:flutter/material.dart';
+import 'package:flutter_material_design_icons/flutter_material_design_icons.dart';
+import 'package:flutter_mozilla_components/flutter_mozilla_components.dart'
+ show AppLinkTarget, AppLinksMode, GeckoAppLinksService;
+import 'package:hooks_riverpod/hooks_riverpod.dart';
+import 'package:skeletonizer/skeletonizer.dart';
+import 'package:weblibre/features/app_links/domain/entities/app_link_rule.dart';
+import 'package:weblibre/features/app_links/domain/entities/context_app_link_policy.dart';
+import 'package:weblibre/features/app_links/domain/services/effective_app_link_policy.dart';
+import 'package:weblibre/features/settings/presentation/controllers/save_settings.dart';
+import 'package:weblibre/features/user/data/models/general_settings.dart';
+
+final _appLinkTargetProvider = FutureProvider.autoDispose
+ .family((ref, url) {
+ return GeckoAppLinksService().resolveAppLink(url);
+ });
+
+enum _SiteRuleChoice { followDefault, alwaysOpen, neverOpen }
+
+/// Section widget showing the app-link rule for the current tab's site. Edits
+/// the effective bucket — the owning container's override when it has isolated
+/// app-link settings, otherwise the global rules — but does not expose the
+/// global/container default from this site-specific sheet.
+class AppLinkSection extends HookConsumerWidget {
+ final Uri url;
+
+ /// The tab's live contextId (`TabState.contextId`): the container base
+ /// contextId for a regular tab, the isolation contextId for an isolated tab.
+ final String? contextId;
+
+ const AppLinkSection({required this.url, required this.contextId, super.key});
+
+ @override
+ Widget build(BuildContext context, WidgetRef ref) {
+ final policy = ref.watch(effectiveAppLinkPolicyProvider(contextId));
+ final target = ref.watch(_appLinkTargetProvider(url));
+ final isLoadingTarget = target.isLoading && !target.hasValue;
+
+ return Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ Padding(
+ padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0),
+ child: Text(
+ 'App Links',
+ style: Theme.of(context).textTheme.titleSmall?.copyWith(
+ color: Theme.of(context).colorScheme.primary,
+ ),
+ ),
+ ),
+ if (policy == null || isLoadingTarget)
+ const Skeletonizer(
+ child: Column(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ ListTile(
+ leading: Icon(Icons.link),
+ title: Text('Open links for this site'),
+ subtitle: Text('Follows the default'),
+ ),
+ ],
+ ),
+ )
+ else
+ _SiteRuleTile(
+ policy: policy,
+ target: target.hasValue ? target.value : null,
+ ),
+ ],
+ );
+ }
+}
+
+class _SiteRuleTile extends ConsumerWidget {
+ final EffectiveAppLinkPolicy policy;
+ final AppLinkTarget? target;
+
+ const _SiteRuleTile({required this.policy, required this.target});
+
+ @override
+ Widget build(BuildContext context, WidgetRef ref) {
+ final scope = target?.scopeKey;
+ final rule = (scope != null && scope.isNotEmpty)
+ ? policy.rules[scope]
+ : null;
+ final choice = switch (rule?.decision) {
+ AppLinkRuleDecision.alwaysOpen => _SiteRuleChoice.alwaysOpen,
+ AppLinkRuleDecision.neverOpen => _SiteRuleChoice.neverOpen,
+ null => _SiteRuleChoice.followDefault,
+ };
+ final canAlwaysOpen = _alwaysOpenRuleFor(target) != null;
+
+ final colorScheme = Theme.of(context).colorScheme;
+
+ final (IconData icon, Color color) = switch (choice) {
+ _SiteRuleChoice.alwaysOpen => (MdiIcons.openInApp, colorScheme.primary),
+ _SiteRuleChoice.neverOpen => (Icons.public, colorScheme.primary),
+ _SiteRuleChoice.followDefault => (
+ Icons.link,
+ colorScheme.onSurfaceVariant,
+ ),
+ };
+
+ return ListTile(
+ leading: Icon(icon, color: color),
+ title: const Text('Open links for this site'),
+ subtitle: Text(_subtitle(scope, rule, choice, canAlwaysOpen)),
+ trailing: DropdownButton<_SiteRuleChoice>(
+ value: choice,
+ underline: const SizedBox(),
+ items: [
+ const DropdownMenuItem(
+ value: _SiteRuleChoice.followDefault,
+ child: Text('Follow default'),
+ ),
+ DropdownMenuItem(
+ value: _SiteRuleChoice.alwaysOpen,
+ enabled: canAlwaysOpen || choice == _SiteRuleChoice.alwaysOpen,
+ child: const Text('Open in app'),
+ ),
+ const DropdownMenuItem(
+ value: _SiteRuleChoice.neverOpen,
+ child: Text('Keep in browser'),
+ ),
+ ],
+ onChanged: scope == null || scope.isEmpty
+ ? null
+ : (value) async {
+ if (value != null && value != choice) {
+ await _setSiteRule(ref, scope, target, value);
+ }
+ },
+ ),
+ );
+ }
+
+ String _subtitle(
+ String? scope,
+ PersistedAppLinkRule? rule,
+ _SiteRuleChoice choice,
+ bool canAlwaysOpen,
+ ) {
+ if (scope == null || scope.isEmpty) return 'No app found for this site';
+ return switch (choice) {
+ _SiteRuleChoice.alwaysOpen =>
+ 'Always opens in ${rule!.packageName ?? 'the app'}',
+ _SiteRuleChoice.neverOpen => 'Always stays in the browser',
+ _SiteRuleChoice.followDefault => switch (policy.mode) {
+ AppLinksMode.always =>
+ canAlwaysOpen
+ ? 'Follows the default: opens in apps'
+ : 'Follows the default: no app found',
+ AppLinksMode.ask => 'Follows the default: asks first',
+ AppLinksMode.never => 'Follows the default: stays in the browser',
+ },
+ };
+ }
+
+ Future _setSiteRule(
+ WidgetRef ref,
+ String scope,
+ AppLinkTarget? target,
+ _SiteRuleChoice choice,
+ ) async {
+ Map updateRules(
+ Map rules,
+ ) {
+ final next = {...rules};
+ switch (choice) {
+ case _SiteRuleChoice.followDefault:
+ next.remove(scope);
+ case _SiteRuleChoice.neverOpen:
+ next[scope] = PersistedAppLinkRule(
+ decision: AppLinkRuleDecision.neverOpen,
+ scope: scope,
+ );
+ case _SiteRuleChoice.alwaysOpen:
+ final rule = _alwaysOpenRuleFor(target);
+ if (rule != null) next[scope] = rule;
+ }
+ return next;
+ }
+
+ final overrideKey = policy.overrideKey;
+ await ref.read(saveGeneralSettingsControllerProvider.notifier).save((
+ current,
+ ) {
+ if (overrideKey == null) {
+ return current.copyWith.appLinkRules(updateRules(current.appLinkRules));
+ }
+ final existing =
+ current.appLinkContextOverrides[overrideKey] ??
+ ContextAppLinkPolicy.blank();
+ return current.copyWith.appLinkContextOverrides({
+ ...current.appLinkContextOverrides,
+ overrideKey: existing.copyWith.rules(updateRules(existing.rules)),
+ });
+ });
+ }
+}
+
+PersistedAppLinkRule? _alwaysOpenRuleFor(AppLinkTarget? target) {
+ final packageName = target?.packageName;
+ final scope = target?.scopeKey;
+ if (target == null ||
+ target.isAmbiguous ||
+ packageName == null ||
+ packageName.isEmpty ||
+ scope == null ||
+ scope.isEmpty) {
+ return null;
+ }
+ return PersistedAppLinkRule(
+ decision: AppLinkRuleDecision.alwaysOpen,
+ scope: scope,
+ packageName: packageName,
+ );
+}
diff --git a/apps/weblibre/lib/features/geckoview/features/browser/presentation/widgets/sheets/view_tab.dart b/apps/weblibre/lib/features/geckoview/features/browser/presentation/widgets/sheets/view_tab.dart
index 46615aea..dd79dd8e 100644
--- a/apps/weblibre/lib/features/geckoview/features/browser/presentation/widgets/sheets/view_tab.dart
+++ b/apps/weblibre/lib/features/geckoview/features/browser/presentation/widgets/sheets/view_tab.dart
@@ -26,6 +26,7 @@ import 'package:weblibre/core/routing/routes.dart';
import 'package:weblibre/features/geckoview/domain/entities/states/tab.dart';
import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/certificate_tile.dart';
import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/draggable_scrollable_header.dart';
+import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/sheets/app_link_section.dart';
import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/sheets/clear_site_data_section.dart';
import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/sheets/desktop_mode_section.dart';
import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/sheets/gesture_exclusion_section.dart';
@@ -164,6 +165,12 @@ class ViewTabSheetWidget extends HookConsumerWidget {
url: initialTabState.url,
),
const Divider(),
+ // App Link Section
+ AppLinkSection(
+ url: initialTabState.url,
+ contextId: initialTabState.contextId,
+ ),
+ const Divider(),
// Permissions Section
PermissionsSection(
origin: initialTabState.url.origin,
diff --git a/apps/weblibre/lib/features/geckoview/features/contextmenu/presentation/candidates/launch_external.dart b/apps/weblibre/lib/features/geckoview/features/contextmenu/presentation/candidates/launch_external.dart
index 240b34a8..ae875592 100644
--- a/apps/weblibre/lib/features/geckoview/features/contextmenu/presentation/candidates/launch_external.dart
+++ b/apps/weblibre/lib/features/geckoview/features/contextmenu/presentation/candidates/launch_external.dart
@@ -23,6 +23,7 @@ import 'package:go_router/go_router.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:nullability/nullability.dart';
import 'package:weblibre/features/geckoview/features/contextmenu/extensions/hit_result.dart';
+import 'package:weblibre/presentation/hooks/cached_future.dart';
class LaunchExternal extends HookConsumerWidget {
final HitResult hitResult;
@@ -33,19 +34,26 @@ class LaunchExternal extends HookConsumerWidget {
static Future isSupported(HitResult hitResult) async {
return hitResult.tryGetLink().mapNotNull(
- (url) => _service.hasExternalApp(url),
+ (url) async => (await _service.resolveAppLink(url)) != null,
) ??
false;
}
@override
Widget build(BuildContext context, WidgetRef ref) {
+ final url = hitResult.tryGetLink();
+ final appLink = useCachedFuture(
+ () => url != null ? _service.resolveAppLink(url) : Future.value(null),
+ [url],
+ );
+ final appName = appLink.data?.appName;
+
return ListTile(
leading: const Icon(Icons.open_in_new),
- title: const Text('Open in App'),
+ title: Text(appName != null ? 'Open in $appName' : 'Open in App'),
onTap: () async {
await hitResult.tryGetLink().mapNotNull((url) async {
- final success = await _service.openAppLink(url);
+ final success = await _service.launchAppLink(url);
if (success && context.mounted) {
context.pop();
diff --git a/apps/weblibre/lib/features/geckoview/features/open_link_tools/presentation/dialogs/open_shared_content.dart b/apps/weblibre/lib/features/geckoview/features/open_link_tools/presentation/dialogs/open_shared_content.dart
index c8a4165f..886c0cd5 100644
--- a/apps/weblibre/lib/features/geckoview/features/open_link_tools/presentation/dialogs/open_shared_content.dart
+++ b/apps/weblibre/lib/features/geckoview/features/open_link_tools/presentation/dialogs/open_shared_content.dart
@@ -184,11 +184,11 @@ class OpenSharedContent extends HookConsumerWidget {
};
}, [containerMode, contextId, selectionUrlKey, globalSelectedContainer]);
- final hasExternalApp = useCachedFuture(
+ final appLink = useCachedFuture(
// ignore: discarded_futures useFuture
() => parsedDebouncedUrl != null
- ? _appLinksService.hasExternalApp(parsedDebouncedUrl)
- : Future.value(false),
+ ? _appLinksService.resolveAppLink(parsedDebouncedUrl)
+ : Future.value(null),
[parsedDebouncedUrl],
);
@@ -288,7 +288,7 @@ class OpenSharedContent extends HookConsumerWidget {
final uri = parseValidatedUrl(textController.text, eagerParsing: false);
if (uri == null) return;
- final success = await _appLinksService.openAppLink(uri);
+ final success = await _appLinksService.launchAppLink(uri);
if (success && context.mounted) {
context.pop(true);
@@ -429,9 +429,11 @@ class OpenSharedContent extends HookConsumerWidget {
},
),
],
- if (hasExternalApp.data == true)
+ if (appLink.data != null)
_OpenActionTile(
- title: 'Open in App',
+ title: appLink.data?.appName != null
+ ? 'Open in ${appLink.data!.appName}'
+ : 'Open in App',
subtitle: 'Open in an installed app',
icon: Icons.open_in_new,
onTap: openInApp,
diff --git a/apps/weblibre/lib/features/geckoview/features/tabs/data/database/database.dart b/apps/weblibre/lib/features/geckoview/features/tabs/data/database/database.dart
index 73f7f077..7cf85723 100644
--- a/apps/weblibre/lib/features/geckoview/features/tabs/data/database/database.dart
+++ b/apps/weblibre/lib/features/geckoview/features/tabs/data/database/database.dart
@@ -19,11 +19,8 @@
*/
import 'package:drift/drift.dart';
import 'package:drift/internal/versioned_schema.dart';
-import 'package:drift_dev/api/migrations_native.dart';
import 'package:flutter/foundation.dart';
import 'package:lexo_rank/lexo_rank.dart';
-import 'package:weblibre/data/database/functions/lexo_rank_functions.dart';
-import 'package:weblibre/data/database/functions/url_functions.dart';
import 'package:weblibre/features/geckoview/features/tabs/data/database/daos/capture_tab.dart';
import 'package:weblibre/features/geckoview/features/tabs/data/database/daos/container.dart';
import 'package:weblibre/features/geckoview/features/tabs/data/database/daos/history.dart';
diff --git a/apps/weblibre/lib/features/geckoview/features/tabs/data/models/container_data.dart b/apps/weblibre/lib/features/geckoview/features/tabs/data/models/container_data.dart
index 9369c040..36b49e37 100644
--- a/apps/weblibre/lib/features/geckoview/features/tabs/data/models/container_data.dart
+++ b/apps/weblibre/lib/features/geckoview/features/tabs/data/models/container_data.dart
@@ -83,6 +83,16 @@ class ContainerMetadata with FastEquatable {
@JsonKey(defaultValue: false)
final bool strictMode;
+ // When true, this container has its own app-link policy (open-in-app mode +
+ // remembered per-site rules) that fully replaces the global one for its tabs.
+ // The override itself lives in `GeneralSettings.appLinkContextOverrides` keyed
+ // by [contextualIdentity]; this flag only gates whether that override is
+ // consulted. Requires a Gecko contextId — the native interceptor keys the
+ // override on the tab's contextId, so it is normalized to false when
+ // [contextualIdentity] is null (mirrors [strictMode]/[excludeFromHistory]).
+ @JsonKey(defaultValue: false)
+ final bool isolatedAppLinkSettings;
+
ContainerMetadata({
required this.iconData,
required this.contextualIdentity,
@@ -94,6 +104,7 @@ class ContainerMetadata with FastEquatable {
required this.useCustomColor,
required this.assignedSites,
required this.strictMode,
+ required this.isolatedAppLinkSettings,
});
ContainerMetadata.withDefaults({
@@ -107,6 +118,7 @@ class ContainerMetadata with FastEquatable {
bool? useCustomColor,
List? assignedSites,
bool? strictMode,
+ bool? isolatedAppLinkSettings,
}) : this(
iconData: iconData,
contextualIdentity: contextualIdentity,
@@ -128,6 +140,11 @@ class ContainerMetadata with FastEquatable {
// normalize away the invalid combination on read, and writers re-apply
// it via [sanitized].
strictMode: (strictMode ?? false) && contextualIdentity != null,
+ // Isolated app-link settings need a contextId — the native interceptor
+ // keys the override on the tab's contextId. Normalize the invalid
+ // combination on read; writers re-apply it via [sanitized].
+ isolatedAppLinkSettings:
+ (isolatedAppLinkSettings ?? false) && contextualIdentity != null,
);
/// Enforce the [excludeFromHistory] invariant before persistence: it can only
@@ -145,6 +162,11 @@ class ContainerMetadata with FastEquatable {
if (result.strictMode && result.contextualIdentity == null) {
result = result.copyWith(strictMode: false);
}
+ // Isolated app-link settings need a contextId: the interceptor keys the
+ // override on the tab's contextId.
+ if (result.isolatedAppLinkSettings && result.contextualIdentity == null) {
+ result = result.copyWith(isolatedAppLinkSettings: false);
+ }
return result;
}
@@ -167,6 +189,7 @@ class ContainerMetadata with FastEquatable {
useCustomColor,
assignedSites,
strictMode,
+ isolatedAppLinkSettings,
];
}
diff --git a/apps/weblibre/lib/features/geckoview/features/tabs/data/models/container_data.g.dart b/apps/weblibre/lib/features/geckoview/features/tabs/data/models/container_data.g.dart
index de2732e2..666130b7 100644
--- a/apps/weblibre/lib/features/geckoview/features/tabs/data/models/container_data.g.dart
+++ b/apps/weblibre/lib/features/geckoview/features/tabs/data/models/container_data.g.dart
@@ -27,6 +27,8 @@ abstract class _$ContainerMetadataCWProxy {
ContainerMetadata strictMode(bool strictMode);
+ ContainerMetadata isolatedAppLinkSettings(bool isolatedAppLinkSettings);
+
/// Creates a new instance with the provided field values.
/// Passing `null` to a nullable field nullifies it, while `null` for a non-nullable field is ignored. To update a single field use `ContainerMetadata(...).copyWith.fieldName(value)`.
///
@@ -45,6 +47,7 @@ abstract class _$ContainerMetadataCWProxy {
bool useCustomColor,
List? assignedSites,
bool strictMode,
+ bool isolatedAppLinkSettings,
});
}
@@ -93,6 +96,10 @@ class _$ContainerMetadataCWProxyImpl implements _$ContainerMetadataCWProxy {
@override
ContainerMetadata strictMode(bool strictMode) => call(strictMode: strictMode);
+ @override
+ ContainerMetadata isolatedAppLinkSettings(bool isolatedAppLinkSettings) =>
+ call(isolatedAppLinkSettings: isolatedAppLinkSettings);
+
@override
/// Creates a new instance with the provided field values.
/// Passing `null` to a nullable field nullifies it, while `null` for a non-nullable field is ignored. To update a single field use `ContainerMetadata(...).copyWith.fieldName(value)`.
@@ -112,6 +119,7 @@ class _$ContainerMetadataCWProxyImpl implements _$ContainerMetadataCWProxy {
Object? useCustomColor = const $CopyWithPlaceholder(),
Object? assignedSites = const $CopyWithPlaceholder(),
Object? strictMode = const $CopyWithPlaceholder(),
+ Object? isolatedAppLinkSettings = const $CopyWithPlaceholder(),
}) {
return ContainerMetadata(
iconData: iconData == const $CopyWithPlaceholder()
@@ -165,6 +173,12 @@ class _$ContainerMetadataCWProxyImpl implements _$ContainerMetadataCWProxy {
? _value.strictMode
// ignore: cast_nullable_to_non_nullable
: strictMode as bool,
+ isolatedAppLinkSettings:
+ isolatedAppLinkSettings == const $CopyWithPlaceholder() ||
+ isolatedAppLinkSettings == null
+ ? _value.isolatedAppLinkSettings
+ // ignore: cast_nullable_to_non_nullable
+ : isolatedAppLinkSettings as bool,
);
}
}
@@ -308,6 +322,8 @@ ContainerMetadata _$ContainerMetadataFromJson(Map json) =>
?.map((e) => Uri.parse(e as String))
.toList(),
strictMode: json['strictMode'] as bool? ?? false,
+ isolatedAppLinkSettings:
+ json['isolatedAppLinkSettings'] as bool? ?? false,
);
Map _$ContainerMetadataToJson(
@@ -326,6 +342,7 @@ Map _$ContainerMetadataToJson(
'useCustomColor': instance.useCustomColor,
'assignedSites': instance.assignedSites?.map((e) => e.toString()).toList(),
'strictMode': instance.strictMode,
+ 'isolatedAppLinkSettings': instance.isolatedAppLinkSettings,
};
Value? _$JsonConverterFromJson(
diff --git a/apps/weblibre/lib/features/geckoview/features/tabs/domain/repositories/tab.dart b/apps/weblibre/lib/features/geckoview/features/tabs/domain/repositories/tab.dart
index 91560a5e..d5fb0ee9 100644
--- a/apps/weblibre/lib/features/geckoview/features/tabs/domain/repositories/tab.dart
+++ b/apps/weblibre/lib/features/geckoview/features/tabs/domain/repositories/tab.dart
@@ -103,12 +103,10 @@ class TabDataRepository extends _$TabDataRepository {
),
// parentId defaults to null - breaks parent chain when changing contextual identity
selectTab: selectedTabId == tabState.id,
- // Assignment-driven navigation to an assigned site: bypass the
- // app-links delegate so cancelling an "open in app" prompt does
- // not re-trigger it on the recreated tab's load.
- flags: replacementUrl != null
- ? LoadUrlFlags.LOAD_FLAGS_BYPASS_LOAD_URI_DELEGATE
- : LoadUrlFlags.NONE,
+ // Assignment-driven navigation is classified in its assigned context
+ // like any other load; the app-links fallback re-entry map (§2.7)
+ // covers the redirect loop the old delegate bypass used to guard.
+ flags: LoadUrlFlags.NONE,
);
}
}
diff --git a/apps/weblibre/lib/features/geckoview/features/tabs/domain/repositories/tab.g.dart b/apps/weblibre/lib/features/geckoview/features/tabs/domain/repositories/tab.g.dart
index a340a5b2..d95fd5cb 100644
--- a/apps/weblibre/lib/features/geckoview/features/tabs/domain/repositories/tab.g.dart
+++ b/apps/weblibre/lib/features/geckoview/features/tabs/domain/repositories/tab.g.dart
@@ -41,7 +41,7 @@ final class TabDataRepositoryProvider
}
}
-String _$tabDataRepositoryHash() => r'adc1c664b492e41a96a0310d92252dbbacc1a089';
+String _$tabDataRepositoryHash() => r'd4eb49e25077aea6de479ea738ec92a213b71f78';
abstract class _$TabDataRepository extends $Notifier {
void build();
diff --git a/apps/weblibre/lib/features/geckoview/features/tabs/presentation/screens/container_edit.dart b/apps/weblibre/lib/features/geckoview/features/tabs/presentation/screens/container_edit.dart
index 4e457f09..99540064 100644
--- a/apps/weblibre/lib/features/geckoview/features/tabs/presentation/screens/container_edit.dart
+++ b/apps/weblibre/lib/features/geckoview/features/tabs/presentation/screens/container_edit.dart
@@ -26,6 +26,7 @@ import 'package:flutter_material_design_icons/flutter_material_design_icons.dart
import 'package:go_router/go_router.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:weblibre/core/uuid.dart';
+import 'package:weblibre/features/app_links/presentation/widgets/container_app_link_settings_dialog.dart';
import 'package:weblibre/features/geckoview/features/history/domain/repositories/container_history.dart';
import 'package:weblibre/features/geckoview/features/tabs/data/models/container_data.dart';
import 'package:weblibre/features/geckoview/features/tabs/domain/repositories/container.dart';
@@ -40,9 +41,32 @@ import 'package:weblibre/features/geckoview/features/tabs/utils/container_icons.
import 'package:weblibre/features/proxy/data/proxy_connection.dart';
import 'package:weblibre/features/proxy/domain/providers/proxy_connection_options.dart';
import 'package:weblibre/features/proxy/domain/repositories/singbox_proxy_profiles.dart';
+import 'package:weblibre/features/user/data/models/general_settings.dart';
+import 'package:weblibre/features/user/domain/repositories/general_settings.dart';
enum _DialogMode { create, edit }
+/// Remove any per-container app-link overrides (§ container isolation) stored for
+/// [contextIds] in GeneralSettings. Null ids are ignored; a no-op when none are
+/// present. Keeps overrides from lingering after a container drops isolation or
+/// is deleted.
+Future _removeAppLinkOverrides(
+ WidgetRef ref,
+ Set contextIds,
+) async {
+ final ids = contextIds.nonNulls.toSet();
+ if (ids.isEmpty) return;
+
+ await ref.read(generalSettingsRepositoryProvider.notifier).updateSettings((
+ current,
+ ) {
+ if (!ids.any(current.appLinkContextOverrides.containsKey)) return current;
+ return current.copyWith.appLinkContextOverrides(
+ {...current.appLinkContextOverrides}..removeWhere((key, _) => ids.contains(key)),
+ );
+ });
+}
+
class ContainerEditScreen extends HookConsumerWidget {
final _DialogMode _mode;
@@ -109,6 +133,9 @@ class ContainerEditScreen extends HookConsumerWidget {
);
final assignedSites = useState(initialContainer.metadata.assignedSites);
final strictMode = useState(initialContainer.metadata.strictMode);
+ final isolatedAppLinkSettings = useState(
+ initialContainer.metadata.isolatedAppLinkSettings,
+ );
final isPinned = useState(initialContainer.isPinned);
final textController = useTextEditingController(
@@ -147,6 +174,12 @@ class ContainerEditScreen extends HookConsumerWidget {
// strictness on the tab's cookieStoreId). sanitized() enforces the
// same invariant defensively on write.
strictMode: strictMode.value && contextualIdentity.value != null,
+ // Isolated app-link settings require a Gecko contextId (the
+ // interceptor keys the override on the tab's contextId).
+ // sanitized() enforces the same invariant defensively on write.
+ isolatedAppLinkSettings:
+ isolatedAppLinkSettings.value &&
+ contextualIdentity.value != null,
)
.sanitized(),
);
@@ -167,6 +200,15 @@ class ContainerEditScreen extends HookConsumerWidget {
isPinned: isPinned.value,
);
}
+ // Keep the per-container app-link override in step with the isolation
+ // toggle: drop it when the container is no longer isolated (or lost its
+ // contextId) so it can't linger orphaned in GeneralSettings.
+ if (!container.metadata.isolatedAppLinkSettings) {
+ await _removeAppLinkOverrides(ref, {
+ initialContainer.metadata.contextualIdentity,
+ container.metadata.contextualIdentity,
+ });
+ }
return container;
}
@@ -264,6 +306,11 @@ class ContainerEditScreen extends HookConsumerWidget {
.read(containerRepositoryProvider.notifier)
.deleteContainer(initialContainer.id);
+ // Drop the container's app-link override so it doesn't outlive it.
+ await _removeAppLinkOverrides(ref, {
+ initialContainer.metadata.contextualIdentity,
+ });
+
if (context.mounted) {
context.pop();
}
@@ -654,6 +701,79 @@ class ContainerEditScreen extends HookConsumerWidget {
],
),
),
+ const SizedBox(height: 24),
+ Text(
+ 'App Links',
+ style: theme.textTheme.titleSmall?.copyWith(
+ color: colorScheme.primary,
+ fontWeight: FontWeight.w700,
+ ),
+ ),
+ const SizedBox(height: 12),
+ Card.filled(
+ margin: EdgeInsets.zero,
+ color: colorScheme.surfaceContainer,
+ clipBehavior: Clip.antiAlias,
+ child: Column(
+ children: [
+ SwitchListTile.adaptive(
+ value:
+ contextualIdentity.value != null &&
+ isolatedAppLinkSettings.value,
+ title: const Text('Isolated App Link Settings'),
+ subtitle: Text(
+ contextualIdentity.value != null
+ ? 'Use a separate open-in-app mode and remembered '
+ 'site rules for this container instead of the '
+ 'global settings'
+ : 'Requires cookie isolation to be enabled',
+ ),
+ secondary: const Icon(MdiIcons.openInApp),
+ onChanged: (contextualIdentity.value != null)
+ ? (value) {
+ isolatedAppLinkSettings.value = value;
+ }
+ : null,
+ ),
+ // The per-container mode + rules live in GeneralSettings
+ // (keyed by the persisted contextId) and are edited live,
+ // like the global app-link settings. Only offered in edit
+ // mode against the saved, immutable contextId — a create
+ // draft's contextId can still churn (cookie-isolation
+ // toggling regenerates it), which would orphan overrides.
+ if (_mode == _DialogMode.edit &&
+ initialContainer.metadata.contextualIdentity !=
+ null &&
+ isolatedAppLinkSettings.value) ...[
+ const Divider(height: 1, indent: 56),
+ ListTile(
+ leading: const Icon(Icons.tune),
+ title: const Text('App Link Behavior'),
+ subtitle: const Text(
+ "Configure this container's open-in-app mode and "
+ 'remembered sites',
+ ),
+ trailing: const Icon(Icons.chevron_right),
+ onTap: () async {
+ await showDialog(
+ context: context,
+ builder: (context) =>
+ ContainerAppLinkSettingsDialog(
+ contextId: initialContainer
+ .metadata
+ .contextualIdentity!,
+ containerName:
+ textController.text.trim().isNotEmpty
+ ? textController.text.trim()
+ : initialContainer.name,
+ ),
+ );
+ },
+ ),
+ ],
+ ],
+ ),
+ ),
],
),
),
diff --git a/apps/weblibre/lib/features/settings/presentation/screens/browsing_settings.dart b/apps/weblibre/lib/features/settings/presentation/screens/browsing_settings.dart
index 368fceb9..c4055e97 100644
--- a/apps/weblibre/lib/features/settings/presentation/screens/browsing_settings.dart
+++ b/apps/weblibre/lib/features/settings/presentation/screens/browsing_settings.dart
@@ -23,7 +23,7 @@ import 'package:flutter_mozilla_components/flutter_mozilla_components.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:weblibre/core/design/app_colors.dart';
import 'package:weblibre/core/routing/routes.dart';
-import 'package:weblibre/features/geckoview/features/browser/domain/providers.dart';
+import 'package:weblibre/features/app_links/domain/entities/app_link_rule.dart';
import 'package:weblibre/features/geckoview/features/tabs/domain/providers/selected_container.dart';
import 'package:weblibre/features/settings/presentation/controllers/save_settings.dart';
import 'package:weblibre/features/settings/presentation/widgets/settings_detail.dart';
@@ -709,7 +709,15 @@ class _AppLinksModeSection extends HookConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final appLinksMode = ref.watch(
- appLinksModeProvider.select((value) => value.value),
+ generalSettingsWithDefaultsProvider.select((s) => s.appLinksMode),
+ );
+ final marketplaceFallback = ref.watch(
+ generalSettingsWithDefaultsProvider.select(
+ (s) => s.appLinkMarketplaceFallback,
+ ),
+ );
+ final rules = ref.watch(
+ generalSettingsWithDefaultsProvider.select((s) => s.appLinkRules),
);
return Padding(
@@ -730,7 +738,9 @@ class _AppLinksModeSection extends HookConsumerWidget {
groupValue: appLinksMode,
onChanged: (value) async {
if (value != null) {
- await ref.read(appLinksModeProvider.notifier).setMode(value);
+ await ref
+ .read(saveGeneralSettingsControllerProvider.notifier)
+ .save((current) => current.copyWith.appLinksMode(value));
}
},
child: const Column(
@@ -757,12 +767,96 @@ class _AppLinksModeSection extends HookConsumerWidget {
],
),
),
+ SwitchListTile.adaptive(
+ contentPadding: EdgeInsets.zero,
+ title: const Text('Offer app store fallback'),
+ subtitle: const Text(
+ "When a link points to an app you don't have installed and there "
+ 'is no web fallback, offer to open the app store',
+ ),
+ value: marketplaceFallback,
+ onChanged: appLinksMode == AppLinksMode.never
+ ? null
+ : (value) async {
+ await ref
+ .read(saveGeneralSettingsControllerProvider.notifier)
+ .save(
+ (current) =>
+ current.copyWith.appLinkMarketplaceFallback(value),
+ );
+ },
+ ),
+ _AppLinkRulesSubsection(rules: rules),
],
),
);
}
}
+/// Managed per-site app-link rules (§2.5): "always open" and "never open"
+/// decisions the user remembered from a prompt. Read-only list with removal.
+class _AppLinkRulesSubsection extends ConsumerWidget {
+ final Map rules;
+
+ const _AppLinkRulesSubsection({required this.rules});
+
+ String _displayScope(String scope) {
+ if (scope.startsWith('host:')) return scope.substring('host:'.length);
+ if (scope.startsWith('pkg:')) return scope.substring('pkg:'.length);
+ return scope;
+ }
+
+ @override
+ Widget build(BuildContext context, WidgetRef ref) {
+ if (rules.isEmpty) {
+ return const SizedBox.shrink();
+ }
+
+ final entries = rules.entries.toList()
+ ..sort((a, b) => a.key.compareTo(b.key));
+
+ return Column(
+ mainAxisSize: MainAxisSize.min,
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ const Padding(
+ padding: EdgeInsets.only(top: 16, bottom: 4),
+ child: Text('Remembered site rules'),
+ ),
+ for (final MapEntry(:key, :value) in entries)
+ ListTile(
+ contentPadding: EdgeInsets.zero,
+ dense: true,
+ leading: Icon(
+ value.decision == AppLinkRuleDecision.alwaysOpen
+ ? MdiIcons.openInApp
+ : Icons.public,
+ ),
+ title: Text(_displayScope(key)),
+ subtitle: Text(
+ value.decision == AppLinkRuleDecision.alwaysOpen
+ ? 'Always open in the app'
+ : 'Always keep in the browser',
+ ),
+ trailing: IconButton(
+ icon: const Icon(Icons.delete_outline),
+ tooltip: 'Remove rule',
+ onPressed: () async {
+ await ref
+ .read(saveGeneralSettingsControllerProvider.notifier)
+ .save(
+ (current) => current.copyWith.appLinkRules({
+ ...current.appLinkRules,
+ }..remove(key)),
+ );
+ },
+ ),
+ ),
+ ],
+ );
+ }
+}
+
class _GlobalDesktopModeTile extends HookConsumerWidget {
const _GlobalDesktopModeTile();
diff --git a/apps/weblibre/lib/features/user/data/models/general_settings.dart b/apps/weblibre/lib/features/user/data/models/general_settings.dart
index fdec4a6d..03d7ecbf 100644
--- a/apps/weblibre/lib/features/user/data/models/general_settings.dart
+++ b/apps/weblibre/lib/features/user/data/models/general_settings.dart
@@ -20,8 +20,12 @@
import 'package:copy_with_extension/copy_with_extension.dart';
import 'package:fast_equatable/fast_equatable.dart';
import 'package:flutter/material.dart';
+import 'package:flutter_mozilla_components/flutter_mozilla_components.dart'
+ show AppLinksMode;
import 'package:json_annotation/json_annotation.dart';
import 'package:weblibre/core/routing/routes.dart';
+import 'package:weblibre/features/app_links/domain/entities/app_link_rule.dart';
+import 'package:weblibre/features/app_links/domain/entities/context_app_link_policy.dart';
import 'package:weblibre/features/bangs/data/models/bang_group.dart';
import 'package:weblibre/features/bangs/data/models/bang_key.dart';
import 'package:weblibre/features/intent_gatekeeper/domain/entities/intent_source_policy.dart';
@@ -202,6 +206,30 @@ class GeneralSettings with FastEquatable {
/// via the intent gatekeeper prefs bridge. Defaults to true.
final bool customTabsEnabled;
+ /// Global app-links behaviour: always open in native apps, ask each time, or
+ /// never leave the browser. Defaults to [AppLinksMode.ask]. Per-site rules in
+ /// [appLinkRules] and container/proxy protection can override this per-target.
+ final AppLinksMode appLinksMode;
+
+ /// Remembered per-scope app-link rules, keyed by canonical scope
+ /// (`host:youtube.com` | `pkg:...`). One rule per scope, last write wins.
+ /// Malformed entries are dropped on read (see [parseAppLinkRules]).
+ @JsonKey(fromJson: parseAppLinkRules)
+ final Map appLinkRules;
+
+ /// Per-container app-link overrides for containers with "isolated app link
+ /// settings" enabled, keyed by the container's Gecko contextId. Each entry
+ /// fully *replaces* the global mode + [appLinkRules] for navigations in that
+ /// container (replace semantics). Containers without isolation have no entry
+ /// and fall back to the global policy. Malformed entries dropped on read.
+ @JsonKey(fromJson: parseAppLinkContextOverrides)
+ final Map appLinkContextOverrides;
+
+ /// Whether an install-app (marketplace) intent is offered when an app link
+ /// resolves to no installed app and has no validated http(s) fallback.
+ /// Defaults to false — the wrong default for a de-Googled browser.
+ final bool appLinkMarketplaceFallback;
+
/// Whether the local search index (`history` table populated via tab→
/// history triggers) is active. When false, the SQL trigger guard returns
/// without writing; existing rows stay until the user clears them.
@@ -296,6 +324,10 @@ class GeneralSettings with FastEquatable {
required this.blockExternalAppsEnabled,
required this.externalAppIntentPolicies,
required this.customTabsEnabled,
+ required this.appLinksMode,
+ required this.appLinkRules,
+ required this.appLinkContextOverrides,
+ required this.appLinkMarketplaceFallback,
required this.enableLocalSearchIndex,
required this.indexPrivateTabs,
required this.acceptSuggestionOnSubmit,
@@ -364,6 +396,10 @@ class GeneralSettings with FastEquatable {
bool? blockExternalAppsEnabled,
Map? externalAppIntentPolicies,
bool? customTabsEnabled,
+ AppLinksMode? appLinksMode,
+ Map? appLinkRules,
+ Map? appLinkContextOverrides,
+ bool? appLinkMarketplaceFallback,
bool? enableLocalSearchIndex,
bool? indexPrivateTabs,
bool? acceptSuggestionOnSubmit,
@@ -442,6 +478,10 @@ class GeneralSettings with FastEquatable {
blockExternalAppsEnabled = blockExternalAppsEnabled ?? false,
externalAppIntentPolicies = externalAppIntentPolicies ?? const {},
customTabsEnabled = customTabsEnabled ?? true,
+ appLinksMode = appLinksMode ?? AppLinksMode.ask,
+ appLinkRules = appLinkRules ?? const {},
+ appLinkContextOverrides = appLinkContextOverrides ?? const {},
+ appLinkMarketplaceFallback = appLinkMarketplaceFallback ?? false,
enableLocalSearchIndex = enableLocalSearchIndex ?? true,
indexPrivateTabs = indexPrivateTabs ?? false,
acceptSuggestionOnSubmit = acceptSuggestionOnSubmit ?? true,
@@ -593,6 +633,10 @@ class GeneralSettings with FastEquatable {
blockExternalAppsEnabled,
externalAppIntentPolicies,
customTabsEnabled,
+ appLinksMode,
+ appLinkRules,
+ appLinkContextOverrides,
+ appLinkMarketplaceFallback,
enableLocalSearchIndex,
indexPrivateTabs,
acceptSuggestionOnSubmit,
diff --git a/apps/weblibre/lib/features/user/data/models/general_settings.g.dart b/apps/weblibre/lib/features/user/data/models/general_settings.g.dart
index 95517dde..7cd836cf 100644
--- a/apps/weblibre/lib/features/user/data/models/general_settings.g.dart
+++ b/apps/weblibre/lib/features/user/data/models/general_settings.g.dart
@@ -143,6 +143,16 @@ abstract class _$GeneralSettingsCWProxy {
GeneralSettings customTabsEnabled(bool customTabsEnabled);
+ GeneralSettings appLinksMode(AppLinksMode appLinksMode);
+
+ GeneralSettings appLinkRules(Map appLinkRules);
+
+ GeneralSettings appLinkContextOverrides(
+ Map appLinkContextOverrides,
+ );
+
+ GeneralSettings appLinkMarketplaceFallback(bool appLinkMarketplaceFallback);
+
GeneralSettings enableLocalSearchIndex(bool enableLocalSearchIndex);
GeneralSettings indexPrivateTabs(bool indexPrivateTabs);
@@ -223,6 +233,10 @@ abstract class _$GeneralSettingsCWProxy {
bool blockExternalAppsEnabled,
Map externalAppIntentPolicies,
bool customTabsEnabled,
+ AppLinksMode appLinksMode,
+ Map appLinkRules,
+ Map appLinkContextOverrides,
+ bool appLinkMarketplaceFallback,
bool enableLocalSearchIndex,
bool indexPrivateTabs,
bool acceptSuggestionOnSubmit,
@@ -490,6 +504,24 @@ class _$GeneralSettingsCWProxyImpl implements _$GeneralSettingsCWProxy {
GeneralSettings customTabsEnabled(bool customTabsEnabled) =>
call(customTabsEnabled: customTabsEnabled);
+ @override
+ GeneralSettings appLinksMode(AppLinksMode appLinksMode) =>
+ call(appLinksMode: appLinksMode);
+
+ @override
+ GeneralSettings appLinkRules(
+ Map appLinkRules,
+ ) => call(appLinkRules: appLinkRules);
+
+ @override
+ GeneralSettings appLinkContextOverrides(
+ Map appLinkContextOverrides,
+ ) => call(appLinkContextOverrides: appLinkContextOverrides);
+
+ @override
+ GeneralSettings appLinkMarketplaceFallback(bool appLinkMarketplaceFallback) =>
+ call(appLinkMarketplaceFallback: appLinkMarketplaceFallback);
+
@override
GeneralSettings enableLocalSearchIndex(bool enableLocalSearchIndex) =>
call(enableLocalSearchIndex: enableLocalSearchIndex);
@@ -586,6 +618,10 @@ class _$GeneralSettingsCWProxyImpl implements _$GeneralSettingsCWProxy {
Object? blockExternalAppsEnabled = const $CopyWithPlaceholder(),
Object? externalAppIntentPolicies = const $CopyWithPlaceholder(),
Object? customTabsEnabled = const $CopyWithPlaceholder(),
+ Object? appLinksMode = const $CopyWithPlaceholder(),
+ Object? appLinkRules = const $CopyWithPlaceholder(),
+ Object? appLinkContextOverrides = const $CopyWithPlaceholder(),
+ Object? appLinkMarketplaceFallback = const $CopyWithPlaceholder(),
Object? enableLocalSearchIndex = const $CopyWithPlaceholder(),
Object? indexPrivateTabs = const $CopyWithPlaceholder(),
Object? acceptSuggestionOnSubmit = const $CopyWithPlaceholder(),
@@ -938,6 +974,28 @@ class _$GeneralSettingsCWProxyImpl implements _$GeneralSettingsCWProxy {
? _value.customTabsEnabled
// ignore: cast_nullable_to_non_nullable
: customTabsEnabled as bool,
+ appLinksMode:
+ appLinksMode == const $CopyWithPlaceholder() || appLinksMode == null
+ ? _value.appLinksMode
+ // ignore: cast_nullable_to_non_nullable
+ : appLinksMode as AppLinksMode,
+ appLinkRules:
+ appLinkRules == const $CopyWithPlaceholder() || appLinkRules == null
+ ? _value.appLinkRules
+ // ignore: cast_nullable_to_non_nullable
+ : appLinkRules as Map,
+ appLinkContextOverrides:
+ appLinkContextOverrides == const $CopyWithPlaceholder() ||
+ appLinkContextOverrides == null
+ ? _value.appLinkContextOverrides
+ // ignore: cast_nullable_to_non_nullable
+ : appLinkContextOverrides as Map,
+ appLinkMarketplaceFallback:
+ appLinkMarketplaceFallback == const $CopyWithPlaceholder() ||
+ appLinkMarketplaceFallback == null
+ ? _value.appLinkMarketplaceFallback
+ // ignore: cast_nullable_to_non_nullable
+ : appLinkMarketplaceFallback as bool,
enableLocalSearchIndex:
enableLocalSearchIndex == const $CopyWithPlaceholder() ||
enableLocalSearchIndex == null
@@ -1110,6 +1168,17 @@ GeneralSettings _$GeneralSettingsFromJson(
(k, e) => MapEntry(k, $enumDecode(_$IntentSourcePolicyEnumMap, e)),
),
customTabsEnabled: json['customTabsEnabled'] as bool?,
+ appLinksMode: $enumDecodeNullable(
+ _$AppLinksModeEnumMap,
+ json['appLinksMode'],
+ ),
+ appLinkRules: parseAppLinkRules(
+ json['appLinkRules'] as Map?,
+ ),
+ appLinkContextOverrides: parseAppLinkContextOverrides(
+ json['appLinkContextOverrides'] as Map?,
+ ),
+ appLinkMarketplaceFallback: json['appLinkMarketplaceFallback'] as bool?,
enableLocalSearchIndex: json['enableLocalSearchIndex'] as bool?,
indexPrivateTabs: json['indexPrivateTabs'] as bool?,
acceptSuggestionOnSubmit: json['acceptSuggestionOnSubmit'] as bool?,
@@ -1196,6 +1265,12 @@ Map _$GeneralSettingsToJson(
(k, e) => MapEntry(k, _$IntentSourcePolicyEnumMap[e]!),
),
'customTabsEnabled': instance.customTabsEnabled,
+ 'appLinksMode': _$AppLinksModeEnumMap[instance.appLinksMode]!,
+ 'appLinkRules': instance.appLinkRules.map((k, e) => MapEntry(k, e.toJson())),
+ 'appLinkContextOverrides': instance.appLinkContextOverrides.map(
+ (k, e) => MapEntry(k, e.toJson()),
+ ),
+ 'appLinkMarketplaceFallback': instance.appLinkMarketplaceFallback,
'enableLocalSearchIndex': instance.enableLocalSearchIndex,
'indexPrivateTabs': instance.indexPrivateTabs,
'acceptSuggestionOnSubmit': instance.acceptSuggestionOnSubmit,
@@ -1283,3 +1358,9 @@ const _$IntentSourcePolicyEnumMap = {
IntentSourcePolicy.allow: 'allow',
IntentSourcePolicy.block: 'block',
};
+
+const _$AppLinksModeEnumMap = {
+ AppLinksMode.always: 'always',
+ AppLinksMode.ask: 'ask',
+ AppLinksMode.never: 'never',
+};
diff --git a/apps/weblibre/lib/features/user/domain/repositories/general_settings.dart b/apps/weblibre/lib/features/user/domain/repositories/general_settings.dart
index 13f14476..e7613885 100644
--- a/apps/weblibre/lib/features/user/domain/repositories/general_settings.dart
+++ b/apps/weblibre/lib/features/user/domain/repositories/general_settings.dart
@@ -272,6 +272,18 @@ class GeneralSettingsRepository extends _$GeneralSettingsRepository {
DriftSqlType.bool,
db.typeMapping,
),
+ 'appLinksMode': settings['appLinksMode']?.readAs(
+ DriftSqlType.string,
+ db.typeMapping,
+ ),
+ 'appLinkRules': settings['appLinkRules']
+ ?.readAs(DriftSqlType.string, db.typeMapping)
+ .mapNotNull(jsonDecode),
+ 'appLinkContextOverrides': settings['appLinkContextOverrides']
+ ?.readAs(DriftSqlType.string, db.typeMapping)
+ .mapNotNull(jsonDecode),
+ 'appLinkMarketplaceFallback': settings['appLinkMarketplaceFallback']
+ ?.readAs(DriftSqlType.bool, db.typeMapping),
'enableLocalSearchIndex': settings['enableLocalSearchIndex']?.readAs(
DriftSqlType.bool,
db.typeMapping,
diff --git a/apps/weblibre/lib/features/user/domain/repositories/general_settings.g.dart b/apps/weblibre/lib/features/user/domain/repositories/general_settings.g.dart
index d7a3f00f..c502ea31 100644
--- a/apps/weblibre/lib/features/user/domain/repositories/general_settings.g.dart
+++ b/apps/weblibre/lib/features/user/domain/repositories/general_settings.g.dart
@@ -35,7 +35,7 @@ final class GeneralSettingsRepositoryProvider
}
String _$generalSettingsRepositoryHash() =>
- r'4e72c8ebed8b08ced417ca24d6e4a840f2abf1be';
+ r'7020706aafbac7ee64f678f918ef9fc24c3b98fb';
abstract class _$GeneralSettingsRepository
extends $StreamNotifier {
diff --git a/apps/weblibre/lib/features/user/domain/repositories/profile.g.dart b/apps/weblibre/lib/features/user/domain/repositories/profile.g.dart
index 3f678c9a..d5dae67f 100644
--- a/apps/weblibre/lib/features/user/domain/repositories/profile.g.dart
+++ b/apps/weblibre/lib/features/user/domain/repositories/profile.g.dart
@@ -33,7 +33,7 @@ final class ProfileRepositoryProvider
ProfileRepository create() => ProfileRepository();
}
-String _$profileRepositoryHash() => r'3055487626bdf6bdc6a51284f68eaf4067cd52ef';
+String _$profileRepositoryHash() => r'504539c5ec7c9126ed7b07d920820af481f40444';
abstract class _$ProfileRepository extends $AsyncNotifier> {
FutureOr> build();
diff --git a/apps/weblibre/lib/features/web_push/domain/providers.g.dart b/apps/weblibre/lib/features/web_push/domain/providers.g.dart
index 7cfe2275..7a71d3c4 100644
--- a/apps/weblibre/lib/features/web_push/domain/providers.g.dart
+++ b/apps/weblibre/lib/features/web_push/domain/providers.g.dart
@@ -226,7 +226,7 @@ final class PushDistributorMutationProvider
}
String _$pushDistributorMutationHash() =>
- r'5797ca731c90c1e06e089fb71ad602aecda59634';
+ r'58e489179c2e1fdaf6d8a6bd3b758ec16641358c';
abstract class _$PushDistributorMutation extends $AsyncNotifier {
FutureOr build();
diff --git a/apps/weblibre/test/features/app_links/app_link_prompt_rules_test.dart b/apps/weblibre/test/features/app_links/app_link_prompt_rules_test.dart
new file mode 100644
index 00000000..505ca066
--- /dev/null
+++ b/apps/weblibre/test/features/app_links/app_link_prompt_rules_test.dart
@@ -0,0 +1,94 @@
+/*
+ * Copyright (c) 2024-2026 Fabian Freund.
+ *
+ * This file is part of WebLibre
+ * (see https://weblibre.eu).
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+
+import 'package:flutter_mozilla_components/flutter_mozilla_components.dart';
+import 'package:flutter_test/flutter_test.dart';
+import 'package:weblibre/features/app_links/domain/entities/app_link_rule.dart';
+import 'package:weblibre/features/app_links/presentation/widgets/app_link_prompt_dialog.dart';
+
+AppLinkTarget _target({
+ String url = 'https://youtu.be/abc',
+ String? packageName = 'com.google.android.youtube',
+ bool isAmbiguous = false,
+ String scopeKey = 'host:youtu.be',
+ bool engineSupportsScheme = true,
+}) {
+ return AppLinkTarget(
+ url: url,
+ appName: 'YouTube',
+ packageName: packageName,
+ fallbackUrl: null,
+ isMarketplace: false,
+ isAmbiguous: isAmbiguous,
+ engineSupportsScheme: engineSupportsScheme,
+ scopeKey: scopeKey,
+ );
+}
+
+void main() {
+ group('alwaysOpenRuleFor', () {
+ test('binds the resolved package to the target scope', () {
+ final rule = alwaysOpenRuleFor(_target());
+ expect(rule, isNotNull);
+ expect(rule!.decision, AppLinkRuleDecision.alwaysOpen);
+ expect(rule.scope, 'host:youtu.be');
+ expect(rule.packageName, 'com.google.android.youtube');
+ });
+
+ test('cannot be remembered for an ambiguous resolution', () {
+ expect(alwaysOpenRuleFor(_target(isAmbiguous: true)), isNull);
+ });
+
+ test('cannot be remembered without a bound package', () {
+ expect(alwaysOpenRuleFor(_target(packageName: null)), isNull);
+ expect(alwaysOpenRuleFor(_target(packageName: '')), isNull);
+ });
+
+ test('scopes a custom-scheme target by its package key', () {
+ final rule = alwaysOpenRuleFor(
+ _target(
+ url: 'zoommtg://zoom.us/join',
+ packageName: 'us.zoom.videomeetings',
+ scopeKey: 'pkg:us.zoom.videomeetings',
+ engineSupportsScheme: false,
+ ),
+ );
+ expect(rule, isNotNull);
+ expect(rule!.scope, 'pkg:us.zoom.videomeetings');
+ expect(rule.packageName, 'us.zoom.videomeetings');
+ });
+ });
+
+ group('neverOpenRuleFor', () {
+ test('scopes to the target without binding a package', () {
+ final rule = neverOpenRuleFor(_target());
+ expect(rule.decision, AppLinkRuleDecision.neverOpen);
+ expect(rule.scope, 'host:youtu.be');
+ expect(rule.packageName, isNull);
+ });
+
+ test('is producible even for an ambiguous resolution', () {
+ // neverOpen never launches, so it does not need a bound package.
+ final rule = neverOpenRuleFor(_target(isAmbiguous: true, packageName: null));
+ expect(rule.decision, AppLinkRuleDecision.neverOpen);
+ expect(rule.isValid, isTrue);
+ });
+ });
+}
diff --git a/apps/weblibre/test/features/app_links/app_link_rule_test.dart b/apps/weblibre/test/features/app_links/app_link_rule_test.dart
new file mode 100644
index 00000000..ad0424b8
--- /dev/null
+++ b/apps/weblibre/test/features/app_links/app_link_rule_test.dart
@@ -0,0 +1,112 @@
+/*
+ * Copyright (c) 2024-2026 Fabian Freund.
+ *
+ * This file is part of WebLibre
+ * (see https://weblibre.eu).
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+
+import 'package:flutter_test/flutter_test.dart';
+import 'package:weblibre/features/app_links/domain/entities/app_link_rule.dart';
+
+void main() {
+ group('PersistedAppLinkRule', () {
+ test('round-trips through json', () {
+ final rule = PersistedAppLinkRule(
+ decision: AppLinkRuleDecision.alwaysOpen,
+ scope: 'host:youtube.com',
+ packageName: 'com.google.android.youtube',
+ );
+ final restored = PersistedAppLinkRule.fromJson(rule.toJson());
+ expect(restored, rule);
+ });
+
+ test('validity requires a package for alwaysOpen and a known prefix', () {
+ expect(
+ PersistedAppLinkRule(
+ decision: AppLinkRuleDecision.alwaysOpen,
+ scope: 'host:x.com',
+ packageName: 'pkg',
+ ).isValid,
+ isTrue,
+ );
+ expect(
+ PersistedAppLinkRule(
+ decision: AppLinkRuleDecision.alwaysOpen,
+ scope: 'host:x.com',
+ ).isValid,
+ isFalse,
+ );
+ expect(
+ PersistedAppLinkRule(
+ decision: AppLinkRuleDecision.neverOpen,
+ scope: 'host:x.com',
+ ).isValid,
+ isTrue,
+ );
+ expect(
+ PersistedAppLinkRule(
+ decision: AppLinkRuleDecision.neverOpen,
+ scope: 'notaprefix',
+ ).isValid,
+ isFalse,
+ );
+ });
+ });
+
+ group('parseAppLinkRules', () {
+ test('keeps valid rules keyed by matching scope', () {
+ final parsed = parseAppLinkRules({
+ 'host:youtube.com': {
+ 'decision': 'alwaysOpen',
+ 'scope': 'host:youtube.com',
+ 'packageName': 'com.google.android.youtube',
+ },
+ 'pkg:us.zoom.videomeetings': {
+ 'decision': 'neverOpen',
+ 'scope': 'pkg:us.zoom.videomeetings',
+ },
+ });
+ expect(parsed.length, 2);
+ expect(parsed['host:youtube.com']!.decision, AppLinkRuleDecision.alwaysOpen);
+ });
+
+ test('drops entries whose map key disagrees with the rule scope', () {
+ final parsed = parseAppLinkRules({
+ 'host:wrong.com': {
+ 'decision': 'neverOpen',
+ 'scope': 'host:right.com',
+ },
+ });
+ expect(parsed, isEmpty);
+ });
+
+ test('drops malformed and invalid rules', () {
+ final parsed = parseAppLinkRules({
+ 'host:a.com': {'decision': 'garbage', 'scope': 'host:a.com'},
+ 'host:b.com': {
+ 'decision': 'alwaysOpen',
+ 'scope': 'host:b.com',
+ }, // missing package
+ 'host:c.com': 'not a map',
+ });
+ expect(parsed, isEmpty);
+ });
+
+ test('null input yields an empty map', () {
+ expect(parseAppLinkRules(null), isEmpty);
+ });
+ });
+}
diff --git a/apps/weblibre/test/features/app_links/effective_app_link_policy_test.dart b/apps/weblibre/test/features/app_links/effective_app_link_policy_test.dart
new file mode 100644
index 00000000..7c73d030
--- /dev/null
+++ b/apps/weblibre/test/features/app_links/effective_app_link_policy_test.dart
@@ -0,0 +1,149 @@
+/*
+ * Copyright (c) 2024-2026 Fabian Freund.
+ *
+ * This file is part of WebLibre
+ * (see https://weblibre.eu).
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+
+import 'package:flutter/material.dart';
+import 'package:flutter_test/flutter_test.dart';
+import 'package:weblibre/features/app_links/domain/services/effective_app_link_policy.dart';
+import 'package:weblibre/features/geckoview/features/tabs/data/models/container_data.dart';
+
+ContainerDataWithCount _container(
+ String id, {
+ String? contextId,
+ bool isolatedAppLinkSettings = false,
+}) {
+ return ContainerDataWithCount(
+ id: id,
+ name: 'Container $id',
+ color: const Color(0xFF336699),
+ orderKey: 'a',
+ metadata: ContainerMetadata.withDefaults(
+ contextualIdentity: contextId,
+ isolatedAppLinkSettings: isolatedAppLinkSettings,
+ ),
+ tabCount: 0,
+ );
+}
+
+void main() {
+ group('resolveAppLinkOverrideKey', () {
+ test('null contextId resolves to the global bucket', () {
+ final key = resolveAppLinkOverrideKey(
+ liveContextId: null,
+ containers: [
+ _container('1', contextId: 'ctx-1', isolatedAppLinkSettings: true),
+ ],
+ isolationContextContainerMap: const {},
+ );
+ expect(key, isNull);
+ });
+
+ test('regular tab in a non-isolated container resolves globally', () {
+ final key = resolveAppLinkOverrideKey(
+ liveContextId: 'ctx-1',
+ containers: [_container('1', contextId: 'ctx-1')],
+ isolationContextContainerMap: const {},
+ );
+ expect(key, isNull);
+ });
+
+ test(
+ 'regular tab in an isolated-app-link container resolves to its base',
+ () {
+ final key = resolveAppLinkOverrideKey(
+ liveContextId: 'ctx-1',
+ containers: [
+ _container('1', contextId: 'ctx-1', isolatedAppLinkSettings: true),
+ ],
+ isolationContextContainerMap: const {},
+ );
+ expect(key, 'ctx-1');
+ },
+ );
+
+ test('isolated tab resolves via the isolation map', () {
+ final key = resolveAppLinkOverrideKey(
+ liveContextId: 'iso-1',
+ containers: [
+ _container('1', contextId: 'ctx-1', isolatedAppLinkSettings: true),
+ ],
+ isolationContextContainerMap: const {
+ 'iso-1': {'1'},
+ },
+ );
+ expect(key, 'ctx-1');
+ });
+
+ test(
+ 'isolated tab of a non-isolated-app-link container resolves globally',
+ () {
+ final key = resolveAppLinkOverrideKey(
+ liveContextId: 'iso-1',
+ containers: [_container('1', contextId: 'ctx-1')],
+ isolationContextContainerMap: const {
+ 'iso-1': {'1'},
+ },
+ );
+ expect(key, isNull);
+ },
+ );
+
+ test('shared isolation context picks the lowest sorted base contextId', () {
+ final key = resolveAppLinkOverrideKey(
+ liveContextId: 'iso-1',
+ containers: [
+ _container('1', contextId: 'ctx-b', isolatedAppLinkSettings: true),
+ _container('2', contextId: 'ctx-a', isolatedAppLinkSettings: true),
+ ],
+ isolationContextContainerMap: const {
+ 'iso-1': {'1', '2'},
+ },
+ );
+ expect(key, 'ctx-a');
+ });
+
+ test(
+ 'shared isolation context skips containers without isolated settings',
+ () {
+ final key = resolveAppLinkOverrideKey(
+ liveContextId: 'iso-1',
+ containers: [
+ _container('1', contextId: 'ctx-a'),
+ _container('2', contextId: 'ctx-b', isolatedAppLinkSettings: true),
+ ],
+ isolationContextContainerMap: const {
+ 'iso-1': {'1', '2'},
+ },
+ );
+ expect(key, 'ctx-b');
+ },
+ );
+
+ test('unknown contextId resolves globally', () {
+ final key = resolveAppLinkOverrideKey(
+ liveContextId: 'ctx-unknown',
+ containers: [
+ _container('1', contextId: 'ctx-1', isolatedAppLinkSettings: true),
+ ],
+ isolationContextContainerMap: const {},
+ );
+ expect(key, isNull);
+ });
+ });
+}
diff --git a/apps/weblibre/test/features/app_links/effective_routing_test.dart b/apps/weblibre/test/features/app_links/effective_routing_test.dart
new file mode 100644
index 00000000..5d93985c
--- /dev/null
+++ b/apps/weblibre/test/features/app_links/effective_routing_test.dart
@@ -0,0 +1,188 @@
+/*
+ * Copyright (c) 2024-2026 Fabian Freund.
+ *
+ * This file is part of WebLibre
+ * (see https://weblibre.eu).
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+
+import 'package:flutter_test/flutter_test.dart';
+import 'package:weblibre/features/app_links/domain/services/effective_routing.dart';
+import 'package:weblibre/features/geckoview/features/tabs/data/models/site_assignment.dart';
+import 'package:weblibre/features/proxy/data/proxy_connection.dart';
+
+SiteAssignment _assignment(String site, {String? contextId}) => SiteAssignment(
+ id: site,
+ contextualIdentity: contextId,
+ assignedSite: site,
+);
+
+void main() {
+ group('resolveContainerAssignment', () {
+ test('explicit proxy connection wins', () {
+ final assignment = resolveContainerAssignment(
+ contextId: 'ctx',
+ proxyConnectionId: const TorProxyConnectionId(),
+ bypassGlobalProxy: false,
+ );
+ expect(assignment, isA());
+ });
+
+ test('bypassGlobalProxy with no proxy is direct scoped to the context', () {
+ final assignment = resolveContainerAssignment(
+ contextId: 'ctx',
+ proxyConnectionId: null,
+ bypassGlobalProxy: true,
+ );
+ expect(assignment, isA());
+ expect((assignment as DirectProxyAssignment).scopeId, 'ctx');
+ });
+
+ test('no proxy and no bypass inherits', () {
+ final assignment = resolveContainerAssignment(
+ contextId: 'ctx',
+ proxyConnectionId: null,
+ bypassGlobalProxy: false,
+ );
+ expect(assignment, isA());
+ });
+ });
+
+ group('resolveIsolationContextRouting', () {
+ test('any explicit proxy wins (lowest sorted id)', () {
+ final routing = resolveIsolationContextRouting([
+ ProxyAssignment.inherit(),
+ ProxyAssignment.explicit('zeta'),
+ ProxyAssignment.explicit('alpha'),
+ ProxyAssignment.direct('scope'),
+ ]);
+ expect(routing.chosen, isA());
+ expect((routing.chosen as ExplicitProxyAssignment).proxyId, 'alpha');
+ expect(routing.distinctAssignmentCount, 4);
+ });
+
+ test('direct wins only when no container inherits', () {
+ final routing = resolveIsolationContextRouting([
+ ProxyAssignment.direct('scopeB'),
+ ProxyAssignment.direct('scopeA'),
+ ]);
+ expect(routing.chosen, isA());
+ expect((routing.chosen as DirectProxyAssignment).scopeId, 'scopeA');
+ });
+
+ test('direct plus inherit collapses to inherit', () {
+ final routing = resolveIsolationContextRouting([
+ ProxyAssignment.direct('scope'),
+ ProxyAssignment.inherit(),
+ ]);
+ expect(routing.chosen, isA());
+ expect(routing.distinctAssignmentCount, 2);
+ expect(routing.assignmentLabels, ['inherit', 'direct:scope']);
+ });
+ });
+
+ group('isAssignmentProtected', () {
+ test('explicit is always protected', () {
+ expect(
+ isAssignmentProtected(
+ ProxyAssignment.explicit('p'),
+ protectGeneralContext: false,
+ ),
+ isTrue,
+ );
+ });
+
+ test('direct is never protected', () {
+ expect(
+ isAssignmentProtected(
+ ProxyAssignment.direct('s'),
+ protectGeneralContext: true,
+ ),
+ isFalse,
+ );
+ });
+
+ test('inherit follows the general context', () {
+ expect(
+ isAssignmentProtected(
+ ProxyAssignment.inherit(),
+ protectGeneralContext: true,
+ ),
+ isTrue,
+ );
+ expect(
+ isAssignmentProtected(
+ ProxyAssignment.inherit(),
+ protectGeneralContext: false,
+ ),
+ isFalse,
+ );
+ });
+ });
+
+ group('protectedTargetPatternForSite', () {
+ test('wildcard entry includes subdomains and ignores port', () {
+ final pattern = protectedTargetPatternForSite(
+ Uri.parse('https://*.example.com'),
+ );
+ expect(pattern.scheme, 'https');
+ expect(pattern.hostOrSuffix, 'example.com');
+ expect(pattern.includeSubdomains, isTrue);
+ expect(pattern.port, isNull);
+ });
+
+ test('exact entry preserves effective port', () {
+ final defaultPort = protectedTargetPatternForSite(
+ Uri.parse('https://example.com'),
+ );
+ expect(defaultPort.hostOrSuffix, 'example.com');
+ expect(defaultPort.includeSubdomains, isFalse);
+ expect(defaultPort.port, 443);
+
+ final explicitPort = protectedTargetPatternForSite(
+ Uri.parse('http://example.com:8080'),
+ );
+ expect(explicitPort.port, 8080);
+ });
+ });
+
+ group('computeProtectedTargetPatterns', () {
+ test('keeps only assignments in a protected or strict container', () {
+ final patterns = computeProtectedTargetPatterns(
+ assignments: [
+ _assignment('https://proxied.example', contextId: 'proxied'),
+ _assignment('https://direct.example', contextId: 'direct'),
+ _assignment('https://strict.example', contextId: 'strict'),
+ _assignment('https://unassigned.example', contextId: null),
+ ],
+ protectedOrStrictContextIds: {'proxied', 'strict'},
+ );
+
+ final hosts = patterns.map((p) => p.hostOrSuffix).toSet();
+ expect(hosts, {'proxied.example', 'strict.example'});
+ });
+
+ test('deduplicates identical patterns', () {
+ final patterns = computeProtectedTargetPatterns(
+ assignments: [
+ _assignment('https://dup.example', contextId: 'a'),
+ _assignment('https://dup.example', contextId: 'a'),
+ ],
+ protectedOrStrictContextIds: {'a'},
+ );
+ expect(patterns.length, 1);
+ });
+ });
+}
diff --git a/apps/weblibre/test/features/app_links/general_settings_app_links_test.dart b/apps/weblibre/test/features/app_links/general_settings_app_links_test.dart
new file mode 100644
index 00000000..f60c4ce2
--- /dev/null
+++ b/apps/weblibre/test/features/app_links/general_settings_app_links_test.dart
@@ -0,0 +1,114 @@
+/*
+ * Copyright (c) 2024-2026 Fabian Freund.
+ *
+ * This file is part of WebLibre
+ * (see https://weblibre.eu).
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+
+import 'package:flutter_mozilla_components/flutter_mozilla_components.dart';
+import 'package:flutter_test/flutter_test.dart';
+import 'package:weblibre/features/app_links/domain/entities/app_link_rule.dart';
+import 'package:weblibre/features/app_links/domain/entities/context_app_link_policy.dart';
+import 'package:weblibre/features/user/data/models/general_settings.dart';
+
+void main() {
+ group('GeneralSettings app-link fields', () {
+ test('defaults are ask / empty rules / marketplace off', () {
+ final settings = GeneralSettings.withDefaults();
+ expect(settings.appLinksMode, AppLinksMode.ask);
+ expect(settings.appLinkRules, isEmpty);
+ expect(settings.appLinkMarketplaceFallback, isFalse);
+ });
+
+ test('the three fields survive a toJson -> fromJson round-trip', () {
+ final rule = PersistedAppLinkRule(
+ decision: AppLinkRuleDecision.alwaysOpen,
+ scope: 'host:youtu.be',
+ packageName: 'com.google.android.youtube',
+ );
+ final settings = GeneralSettings.withDefaults(
+ appLinksMode: AppLinksMode.always,
+ appLinkRules: {rule.scope: rule},
+ appLinkMarketplaceFallback: true,
+ );
+
+ final restored = GeneralSettings.fromJson(settings.toJson());
+
+ expect(restored.appLinksMode, AppLinksMode.always);
+ expect(restored.appLinkMarketplaceFallback, isTrue);
+ expect(restored.appLinkRules.keys, ['host:youtu.be']);
+ expect(restored.appLinkRules['host:youtu.be'], rule);
+ });
+
+ test('malformed persisted rules are dropped on read (parseAppLinkRules)', () {
+ final json = GeneralSettings.withDefaults().toJson();
+ // A scope key that disagrees with the rule's own scope is invalid and dropped.
+ json['appLinkRules'] = {
+ 'host:youtu.be': {
+ 'decision': 'alwaysOpen',
+ 'scope': 'host:evil.example',
+ 'packageName': 'com.google.android.youtube',
+ },
+ };
+
+ final restored = GeneralSettings.fromJson(json);
+ expect(restored.appLinkRules, isEmpty);
+ });
+ });
+
+ group('GeneralSettings per-container app-link overrides', () {
+ test('defaults to an empty override map', () {
+ expect(GeneralSettings.withDefaults().appLinkContextOverrides, isEmpty);
+ });
+
+ test('a container override survives a toJson -> fromJson round-trip', () {
+ final rule = PersistedAppLinkRule(
+ decision: AppLinkRuleDecision.neverOpen,
+ scope: 'host:reddit.com',
+ );
+ final override = ContextAppLinkPolicy(
+ mode: AppLinksMode.never,
+ rules: {rule.scope: rule},
+ );
+ final settings = GeneralSettings.withDefaults(
+ appLinkContextOverrides: {'work': override},
+ );
+
+ final restored = GeneralSettings.fromJson(settings.toJson());
+
+ expect(restored.appLinkContextOverrides.keys, ['work']);
+ final restoredOverride = restored.appLinkContextOverrides['work']!;
+ expect(restoredOverride.mode, AppLinksMode.never);
+ expect(restoredOverride.rules['host:reddit.com'], rule);
+ });
+
+ test('the blank override is ask / empty rules', () {
+ final blank = ContextAppLinkPolicy.blank();
+ expect(blank.mode, AppLinksMode.ask);
+ expect(blank.rules, isEmpty);
+ });
+
+ test('malformed override entries are dropped on read', () {
+ final json = GeneralSettings.withDefaults().toJson();
+ json['appLinkContextOverrides'] = {
+ 'work': {'mode': 'not-a-mode'},
+ };
+
+ final restored = GeneralSettings.fromJson(json);
+ expect(restored.appLinkContextOverrides, isEmpty);
+ });
+ });
+}
diff --git a/apps/weblibre/test/features/geckoview/features/tabs/data/models/container_data_test.dart b/apps/weblibre/test/features/geckoview/features/tabs/data/models/container_data_test.dart
index 69b1329c..c6d09309 100644
--- a/apps/weblibre/test/features/geckoview/features/tabs/data/models/container_data_test.dart
+++ b/apps/weblibre/test/features/geckoview/features/tabs/data/models/container_data_test.dart
@@ -35,6 +35,44 @@ void main() {
});
});
+ group('ContainerMetadata isolatedAppLinkSettings invariant', () {
+ test('stays enabled when the container has a contextId', () {
+ final metadata = ContainerMetadata.withDefaults(
+ contextualIdentity: 'work',
+ isolatedAppLinkSettings: true,
+ );
+
+ expect(metadata.isolatedAppLinkSettings, isTrue);
+ expect(metadata.sanitized().isolatedAppLinkSettings, isTrue);
+ });
+
+ test('is normalized off without a contextId (read + sanitized)', () {
+ final metadata = ContainerMetadata.withDefaults(
+ contextualIdentity: null,
+ isolatedAppLinkSettings: true,
+ );
+
+ // withDefaults normalizes on construction/read.
+ expect(metadata.isolatedAppLinkSettings, isFalse);
+
+ // A record that somehow carries the bad combination is re-normalized.
+ final restored = ContainerMetadata.fromJson({
+ ...metadata.toJson(),
+ 'isolatedAppLinkSettings': true,
+ 'contextualIdentity': null,
+ });
+ expect(restored.isolatedAppLinkSettings, isFalse);
+ expect(restored.sanitized().isolatedAppLinkSettings, isFalse);
+ });
+
+ test('defaults to false', () {
+ expect(
+ ContainerMetadata.withDefaults().isolatedAppLinkSettings,
+ isFalse,
+ );
+ });
+ });
+
group('ContainerMetadata icon serialization', () {
test('stores MDI icon names', () {
final metadata = ContainerMetadata.withDefaults(
diff --git a/packages/flutter_mozilla_components/android/build.gradle b/packages/flutter_mozilla_components/android/build.gradle
index 43b6cd85..8579dbe4 100644
--- a/packages/flutter_mozilla_components/android/build.gradle
+++ b/packages/flutter_mozilla_components/android/build.gradle
@@ -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"
diff --git a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/BaseBrowserFragment.kt b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/BaseBrowserFragment.kt
index 998a3e22..6c23a4ec 100644
--- a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/BaseBrowserFragment.kt
+++ b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/BaseBrowserFragment.kt
@@ -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()
private val copyDownloadFeature = ViewBoundFeatureWrapper()
private val downloadsFeature = ViewBoundFeatureWrapper()
- private val appLinksFeature = ViewBoundFeatureWrapper()
+ // Native prompt for Custom Tab sessions with no Flutter engine.
+ private val nativeAppLinkPromptFeature = ViewBoundFeatureWrapper()
private val promptFeature = ViewBoundFeatureWrapper()
private val webExtensionPromptFeature = ViewBoundFeatureWrapper()
private val sitePermissionsFeature = ViewBoundFeatureWrapper()
@@ -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(
diff --git a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/FlutterMozillaComponentsPlugin.kt b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/FlutterMozillaComponentsPlugin.kt
index 40f233c1..208bdd30 100644
--- a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/FlutterMozillaComponentsPlugin.kt
+++ b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/FlutterMozillaComponentsPlugin.kt
@@ -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
diff --git a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/GlobalComponents.kt b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/GlobalComponents.kt
index 3b2ef45a..28a5a3e1 100644
--- a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/GlobalComponents.kt
+++ b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/GlobalComponents.kt
@@ -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,
diff --git a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/api/GeckoAppLinksApiImpl.kt b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/api/GeckoAppLinksApiImpl.kt
index d7b47053..1cc67080 100644
--- a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/api/GeckoAppLinksApiImpl.kt
+++ b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/api/GeckoAppLinksApiImpl.kt
@@ -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) -> Unit) {
+ override fun setAppLinkPolicy(
+ snapshot: AppLinkPolicySnapshot,
+ callback: (Result) -> 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) -> Unit) {
+ override fun resolveAppLink(
+ url: String,
+ includeHttpAppLinks: Boolean,
+ callback: (Result) -> 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) -> 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>) -> 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) -> 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")
}
diff --git a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/api/GeckoBrowserApiImpl.kt b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/api/GeckoBrowserApiImpl.kt
index 6d75b2a3..cfbde531 100644
--- a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/api/GeckoBrowserApiImpl.kt
+++ b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/api/GeckoBrowserApiImpl.kt
@@ -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)
diff --git a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/api/GeckoEngineSettingsApiImpl.kt b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/api/GeckoEngineSettingsApiImpl.kt
index 216397de..53261c3e 100644
--- a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/api/GeckoEngineSettingsApiImpl.kt
+++ b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/api/GeckoEngineSettingsApiImpl.kt
@@ -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
}
diff --git a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/applinks/AppLinkClassifier.kt b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/applinks/AppLinkClassifier.kt
new file mode 100644
index 00000000..a3c82fb8
--- /dev/null
+++ b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/applinks/AppLinkClassifier.kt
@@ -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,
+)
+
+/**
+ * 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,
+ val marketplaceFallbackEnabled: Boolean,
+ val protectGeneralContext: Boolean,
+ val protectedContextIds: Set,
+ val strictContextIds: Set,
+ val protectedTargetPatterns: List,
+ /**
+ * 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 = 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
+}
diff --git a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/applinks/AppLinkHostNormalizer.kt b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/applinks/AppLinkHostNormalizer.kt
new file mode 100644
index 00000000..4433c491
--- /dev/null
+++ b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/applinks/AppLinkHostNormalizer.kt
@@ -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
+ }
+}
diff --git a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/applinks/AppLinkLauncher.kt b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/applinks/AppLinkLauncher.kt
new file mode 100644
index 00000000..b9a9d0d1
--- /dev/null
+++ b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/applinks/AppLinkLauncher.kt
@@ -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 " — 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 = 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
+ }
+}
diff --git a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/applinks/AppLinkPolicyMapper.kt b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/applinks/AppLinkPolicyMapper.kt
new file mode 100644
index 00000000..03646fc7
--- /dev/null
+++ b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/applinks/AppLinkPolicyMapper.kt
@@ -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,
+)
diff --git a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/applinks/AppLinkPolicyStore.kt b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/applinks/AppLinkPolicyStore.kt
new file mode 100644
index 00000000..47482868
--- /dev/null
+++ b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/applinks/AppLinkPolicyStore.kt
@@ -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()
+
+ 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): 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()
+ 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()
+ 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 {
+ if (obj == null) return emptyMap()
+ val rules = mutableMapOf()
+ 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 {
+ if (this == null) return emptySet()
+ val out = LinkedHashSet(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"
+ }
+}
diff --git a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/applinks/AppLinkRuntime.kt b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/applinks/AppLinkRuntime.kt
new file mode 100644
index 00000000..403254d6
--- /dev/null
+++ b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/applinks/AppLinkRuntime.kt
@@ -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 " 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) },
+ )
+ }
+}
diff --git a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/applinks/AppLinkSchemes.kt b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/applinks/AppLinkSchemes.kt
new file mode 100644
index 00000000..8d1982e1
--- /dev/null
+++ b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/applinks/AppLinkSchemes.kt
@@ -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 = 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 = setOf(
+ "jar",
+ "file",
+ "javascript",
+ "data",
+ "about",
+ "content",
+ "fido",
+ )
+
+ // Schemes allowed to open an external application from a subframe.
+ val SUBFRAME_ALLOWED: Set = setOf(
+ "msteams",
+ )
+
+ // Wallet schemes — always prompt, never remembered (§2.4).
+ val WALLET: Set = 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" }
+}
diff --git a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/applinks/ExternalAppResolver.kt b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/applinks/ExternalAppResolver.kt
new file mode 100644
index 00000000..964a9186
--- /dev/null
+++ b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/applinks/ExternalAppResolver.kt
@@ -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
+ }
+}
diff --git a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/applinks/MonotonicClock.kt b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/applinks/MonotonicClock.kt
new file mode 100644
index 00000000..cadbc2f3
--- /dev/null
+++ b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/applinks/MonotonicClock.kt
@@ -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() }
+ }
+}
diff --git a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/applinks/NativeAppLinkPromptFeature.kt b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/applinks/NativeAppLinkPromptFeature.kt
new file mode 100644
index 00000000..114e72db
--- /dev/null
+++ b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/applinks/NativeAppLinkPromptFeature.kt
@@ -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()
+
+ 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()
+ }
+}
diff --git a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/applinks/PackageResolver.kt b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/applinks/PackageResolver.kt
new file mode 100644
index 00000000..6dd98da8
--- /dev/null
+++ b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/applinks/PackageResolver.kt
@@ -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
+
+ /** 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 {
+ 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
+ }
+ }
+}
diff --git a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/applinks/PendingAppLinkStore.kt b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/applinks/PendingAppLinkStore.kt
new file mode 100644
index 00000000..08612ded
--- /dev/null
+++ b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/applinks/PendingAppLinkStore.kt
@@ -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()
+
+ 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()
+ private val suppression = HashMap()
+ private val fallbackReentry = HashMap()
+
+ 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 {
+ 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()
+ 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
+ }
+}
diff --git a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/applinks/WebLibreAppLinksInterceptor.kt b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/applinks/WebLibreAppLinksInterceptor.kt
new file mode 100644
index 00000000..82b67ade
--- /dev/null
+++ b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/applinks/WebLibreAppLinksInterceptor.kt
@@ -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 2–8: 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, 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."
+ }
+}
diff --git a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/components/Core.kt b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/components/Core.kt
index 63509ece..ee174a6b 100644
--- a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/components/Core.kt
+++ b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/components/Core.kt
@@ -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.
diff --git a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/components/Services.kt b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/components/Services.kt
index 2780713f..998b0704 100644
--- a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/components/Services.kt
+++ b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/components/Services.kt
@@ -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,
- )
- }
}
diff --git a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/components/UseCases.kt b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/components/UseCases.kt
index bdf39f99..62be03e0 100644
--- a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/components/UseCases.kt
+++ b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/components/UseCases.kt
@@ -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 {
diff --git a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/interceptor/AppRequestInterceptor.kt b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/interceptor/AppRequestInterceptor.kt
index 95304c57..268b3e5e 100644
--- a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/interceptor/AppRequestInterceptor.kt
+++ b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/interceptor/AppRequestInterceptor.kt
@@ -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,
diff --git a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/middleware/AppLinkNavigationMiddleware.kt b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/middleware/AppLinkNavigationMiddleware.kt
new file mode 100644
index 00000000..ac9e447b
--- /dev/null
+++ b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/middleware/AppLinkNavigationMiddleware.kt
@@ -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 {
+ override fun invoke(
+ store: Store,
+ 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)
+ }
+}
diff --git a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/middleware/AppLinksCancelRetryMiddleware.kt b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/middleware/AppLinksCancelRetryMiddleware.kt
deleted file mode 100644
index d303d841..00000000
--- a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/middleware/AppLinksCancelRetryMiddleware.kt
+++ /dev/null
@@ -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 {
- private val pendingCancels = mutableMapOf()
-
- override fun invoke(
- store: Store,
- 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,
- 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,
- 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,
- 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
- }
-}
diff --git a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/pigeons/Gecko.g.kt b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/pigeons/Gecko.g.kt
index fc33c50b..fe91986e 100644
--- a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/pigeons/Gecko.g.kt
+++ b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/pigeons/Gecko.g.kt
@@ -795,6 +795,42 @@ enum class AutoplayStatus(val raw: Int) {
}
}
+enum class NativeAppLinkRuleDecision(val raw: Int) {
+ ALWAYS_OPEN(0),
+ NEVER_OPEN(1);
+
+ companion object {
+ fun ofRaw(raw: Int): NativeAppLinkRuleDecision? {
+ return values().firstOrNull { it.raw == raw }
+ }
+ }
+}
+
+/** Which surface owns a pending prompt (§2.6). Fixed at creation, never transfers. */
+enum class AppLinkPromptOwner(val raw: Int) {
+ FLUTTER_BROWSER(0),
+ NATIVE_EXTERNAL(1);
+
+ companion object {
+ fun ofRaw(raw: Int): AppLinkPromptOwner? {
+ return values().firstOrNull { it.raw == raw }
+ }
+ }
+}
+
+/** User decision on a pending prompt (§2.6). */
+enum class AppLinkDecision(val raw: Int) {
+ OPEN(0),
+ CANCEL(1),
+ DISMISS(2);
+
+ companion object {
+ fun ofRaw(raw: Int): AppLinkDecision? {
+ return values().firstOrNull { it.raw == raw }
+ }
+ }
+}
+
/** Lifecycle state of the selected UnifiedPush distributor. */
enum class PushDistributorStatus(val raw: Int) {
/** No distributor app is installed on the device. */
@@ -5421,6 +5457,456 @@ data class TrackingProtectionException (
}
}
+/**
+ * Resolved external-app target for a URL (see APP_LINKS_OWN_IMPLEMENTATION_PLAN.md §2.8).
+ *
+ * Generated class from Pigeon that represents data sent in messages.
+ */
+data class AppLinkTarget (
+ /** The URL that was resolved. */
+ val url: String,
+ /** User-facing app label (control/bidi-sanitised), or null when unknown. */
+ val appName: String? = null,
+ /** Resolved package name, or null when ambiguous / unknown. */
+ val packageName: String? = null,
+ /** Pre-validated http(s) fallback URL, or null. */
+ val fallbackUrl: String? = null,
+ /** True when the only offer is a marketplace (install-app) intent. */
+ val isMarketplace: Boolean,
+ /** True when resolution is ambiguous (chooser / multiple handlers / no default). */
+ val isAmbiguous: Boolean,
+ /** True when the Gecko engine can load the URL scheme itself. */
+ val engineSupportsScheme: Boolean,
+ /** Canonical native-owned rule scope key ("host:youtube.com" | "pkg:..."). */
+ val scopeKey: String
+)
+ {
+ companion object {
+ fun fromList(pigeonVar_list: List): AppLinkTarget {
+ val url = pigeonVar_list[0] as String
+ val appName = pigeonVar_list[1] as String?
+ val packageName = pigeonVar_list[2] as String?
+ val fallbackUrl = pigeonVar_list[3] as String?
+ val isMarketplace = pigeonVar_list[4] as Boolean
+ val isAmbiguous = pigeonVar_list[5] as Boolean
+ val engineSupportsScheme = pigeonVar_list[6] as Boolean
+ val scopeKey = pigeonVar_list[7] as String
+ return AppLinkTarget(url, appName, packageName, fallbackUrl, isMarketplace, isAmbiguous, engineSupportsScheme, scopeKey)
+ }
+ }
+ fun toList(): List {
+ return listOf(
+ url,
+ appName,
+ packageName,
+ fallbackUrl,
+ isMarketplace,
+ isAmbiguous,
+ engineSupportsScheme,
+ scopeKey,
+ )
+ }
+ override fun equals(other: Any?): Boolean {
+ if (other == null || other.javaClass != javaClass) {
+ return false
+ }
+ if (this === other) {
+ return true
+ }
+ val other = other as AppLinkTarget
+ return GeckoPigeonUtils.deepEquals(this.url, other.url) && GeckoPigeonUtils.deepEquals(this.appName, other.appName) && GeckoPigeonUtils.deepEquals(this.packageName, other.packageName) && GeckoPigeonUtils.deepEquals(this.fallbackUrl, other.fallbackUrl) && GeckoPigeonUtils.deepEquals(this.isMarketplace, other.isMarketplace) && GeckoPigeonUtils.deepEquals(this.isAmbiguous, other.isAmbiguous) && GeckoPigeonUtils.deepEquals(this.engineSupportsScheme, other.engineSupportsScheme) && GeckoPigeonUtils.deepEquals(this.scopeKey, other.scopeKey)
+ }
+
+ override fun hashCode(): Int {
+ var result = javaClass.hashCode()
+ result = 31 * result + GeckoPigeonUtils.deepHash(this.url)
+ result = 31 * result + GeckoPigeonUtils.deepHash(this.appName)
+ result = 31 * result + GeckoPigeonUtils.deepHash(this.packageName)
+ result = 31 * result + GeckoPigeonUtils.deepHash(this.fallbackUrl)
+ result = 31 * result + GeckoPigeonUtils.deepHash(this.isMarketplace)
+ result = 31 * result + GeckoPigeonUtils.deepHash(this.isAmbiguous)
+ result = 31 * result + GeckoPigeonUtils.deepHash(this.engineSupportsScheme)
+ result = 31 * result + GeckoPigeonUtils.deepHash(this.scopeKey)
+ return result
+ }
+ override fun toString(): String {
+ return "AppLinkTarget(url=$url, appName=$appName, packageName=$packageName, fallbackUrl=$fallbackUrl, isMarketplace=$isMarketplace, isAmbiguous=$isAmbiguous, engineSupportsScheme=$engineSupportsScheme, scopeKey=$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.
+ *
+ * Generated class from Pigeon that represents data sent in messages.
+ */
+data class ProtectedTargetPattern (
+ val scheme: String,
+ val hostOrSuffix: String,
+ val includeSubdomains: Boolean,
+ /** Effective port for exact entries; null for wildcard entries (ignore port). */
+ val port: Long? = null
+)
+ {
+ companion object {
+ fun fromList(pigeonVar_list: List): ProtectedTargetPattern {
+ val scheme = pigeonVar_list[0] as String
+ val hostOrSuffix = pigeonVar_list[1] as String
+ val includeSubdomains = pigeonVar_list[2] as Boolean
+ val port = pigeonVar_list[3] as Long?
+ return ProtectedTargetPattern(scheme, hostOrSuffix, includeSubdomains, port)
+ }
+ }
+ fun toList(): List {
+ return listOf(
+ scheme,
+ hostOrSuffix,
+ includeSubdomains,
+ port,
+ )
+ }
+ override fun equals(other: Any?): Boolean {
+ if (other == null || other.javaClass != javaClass) {
+ return false
+ }
+ if (this === other) {
+ return true
+ }
+ val other = other as ProtectedTargetPattern
+ return GeckoPigeonUtils.deepEquals(this.scheme, other.scheme) && GeckoPigeonUtils.deepEquals(this.hostOrSuffix, other.hostOrSuffix) && GeckoPigeonUtils.deepEquals(this.includeSubdomains, other.includeSubdomains) && GeckoPigeonUtils.deepEquals(this.port, other.port)
+ }
+
+ override fun hashCode(): Int {
+ var result = javaClass.hashCode()
+ result = 31 * result + GeckoPigeonUtils.deepHash(this.scheme)
+ result = 31 * result + GeckoPigeonUtils.deepHash(this.hostOrSuffix)
+ result = 31 * result + GeckoPigeonUtils.deepHash(this.includeSubdomains)
+ result = 31 * result + GeckoPigeonUtils.deepHash(this.port)
+ return result
+ }
+ override fun toString(): String {
+ return "ProtectedTargetPattern(scheme=$scheme, hostOrSuffix=$hostOrSuffix, includeSubdomains=$includeSubdomains, port=$port)"
+ }
+}
+
+/**
+ * A remembered per-scope rule replicated to native (§2.8). Distinct from the
+ * Dart-persisted `PersistedAppLinkRule`; explicit mappers bridge the two.
+ *
+ * Generated class from Pigeon that represents data sent in messages.
+ */
+data class NativeAppLinkRule (
+ val decision: NativeAppLinkRuleDecision,
+ val scope: String,
+ val packageName: String? = null
+)
+ {
+ companion object {
+ fun fromList(pigeonVar_list: List): NativeAppLinkRule {
+ val decision = pigeonVar_list[0] as NativeAppLinkRuleDecision
+ val scope = pigeonVar_list[1] as String
+ val packageName = pigeonVar_list[2] as String?
+ return NativeAppLinkRule(decision, scope, packageName)
+ }
+ }
+ fun toList(): List {
+ return listOf(
+ decision,
+ scope,
+ packageName,
+ )
+ }
+ override fun equals(other: Any?): Boolean {
+ if (other == null || other.javaClass != javaClass) {
+ return false
+ }
+ if (this === other) {
+ return true
+ }
+ val other = other as NativeAppLinkRule
+ return GeckoPigeonUtils.deepEquals(this.decision, other.decision) && GeckoPigeonUtils.deepEquals(this.scope, other.scope) && GeckoPigeonUtils.deepEquals(this.packageName, other.packageName)
+ }
+
+ override fun hashCode(): Int {
+ var result = javaClass.hashCode()
+ result = 31 * result + GeckoPigeonUtils.deepHash(this.decision)
+ result = 31 * result + GeckoPigeonUtils.deepHash(this.scope)
+ result = 31 * result + GeckoPigeonUtils.deepHash(this.packageName)
+ return result
+ }
+ override fun toString(): String {
+ return "NativeAppLinkRule(decision=$decision, scope=$scope, packageName=$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).
+ *
+ * Generated class from Pigeon that represents data sent in messages.
+ */
+data class NativeContextAppLinkPolicy (
+ val mode: AppLinksMode,
+ /** The container's own remembered rules keyed by canonical scope. */
+ val rules: Map
+)
+ {
+ companion object {
+ fun fromList(pigeonVar_list: List): NativeContextAppLinkPolicy {
+ val mode = pigeonVar_list[0] as AppLinksMode
+ val rules = pigeonVar_list[1] as Map
+ return NativeContextAppLinkPolicy(mode, rules)
+ }
+ }
+ fun toList(): List {
+ return listOf(
+ mode,
+ rules,
+ )
+ }
+ override fun equals(other: Any?): Boolean {
+ if (other == null || other.javaClass != javaClass) {
+ return false
+ }
+ if (this === other) {
+ return true
+ }
+ val other = other as NativeContextAppLinkPolicy
+ return GeckoPigeonUtils.deepEquals(this.mode, other.mode) && GeckoPigeonUtils.deepEquals(this.rules, other.rules)
+ }
+
+ override fun hashCode(): Int {
+ var result = javaClass.hashCode()
+ result = 31 * result + GeckoPigeonUtils.deepHash(this.mode)
+ result = 31 * result + GeckoPigeonUtils.deepHash(this.rules)
+ return result
+ }
+ override fun toString(): String {
+ return "NativeContextAppLinkPolicy(mode=$mode, rules=$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.
+ *
+ * Generated class from Pigeon that represents data sent in messages.
+ */
+data class AppLinkPolicySnapshot (
+ val globalMode: AppLinksMode,
+ /** Remembered rules keyed by canonical scope. */
+ val rules: Map,
+ val marketplaceFallbackEnabled: Boolean,
+ /** Regular / no-contextId tabs are proxied via the `general` scope. */
+ val protectGeneralContext: Boolean,
+ /** contextIds that resolve to a proxy after inherit/bypass/alias. */
+ val protectedContextIds: List,
+ /** strictMode containers, independent of routing. */
+ val strictContextIds: List,
+ val protectedTargetPatterns: List,
+ /**
+ * 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).
+ */
+ val contextOverrides: Map
+)
+ {
+ companion object {
+ fun fromList(pigeonVar_list: List): AppLinkPolicySnapshot {
+ val globalMode = pigeonVar_list[0] as AppLinksMode
+ val rules = pigeonVar_list[1] as Map
+ val marketplaceFallbackEnabled = pigeonVar_list[2] as Boolean
+ val protectGeneralContext = pigeonVar_list[3] as Boolean
+ val protectedContextIds = pigeonVar_list[4] as List
+ val strictContextIds = pigeonVar_list[5] as List
+ val protectedTargetPatterns = pigeonVar_list[6] as List
+ val contextOverrides = pigeonVar_list[7] as Map
+ return AppLinkPolicySnapshot(globalMode, rules, marketplaceFallbackEnabled, protectGeneralContext, protectedContextIds, strictContextIds, protectedTargetPatterns, contextOverrides)
+ }
+ }
+ fun toList(): List {
+ return listOf(
+ globalMode,
+ rules,
+ marketplaceFallbackEnabled,
+ protectGeneralContext,
+ protectedContextIds,
+ strictContextIds,
+ protectedTargetPatterns,
+ contextOverrides,
+ )
+ }
+ override fun equals(other: Any?): Boolean {
+ if (other == null || other.javaClass != javaClass) {
+ return false
+ }
+ if (this === other) {
+ return true
+ }
+ val other = other as AppLinkPolicySnapshot
+ return GeckoPigeonUtils.deepEquals(this.globalMode, other.globalMode) && GeckoPigeonUtils.deepEquals(this.rules, other.rules) && GeckoPigeonUtils.deepEquals(this.marketplaceFallbackEnabled, other.marketplaceFallbackEnabled) && GeckoPigeonUtils.deepEquals(this.protectGeneralContext, other.protectGeneralContext) && GeckoPigeonUtils.deepEquals(this.protectedContextIds, other.protectedContextIds) && GeckoPigeonUtils.deepEquals(this.strictContextIds, other.strictContextIds) && GeckoPigeonUtils.deepEquals(this.protectedTargetPatterns, other.protectedTargetPatterns) && GeckoPigeonUtils.deepEquals(this.contextOverrides, other.contextOverrides)
+ }
+
+ override fun hashCode(): Int {
+ var result = javaClass.hashCode()
+ result = 31 * result + GeckoPigeonUtils.deepHash(this.globalMode)
+ result = 31 * result + GeckoPigeonUtils.deepHash(this.rules)
+ result = 31 * result + GeckoPigeonUtils.deepHash(this.marketplaceFallbackEnabled)
+ result = 31 * result + GeckoPigeonUtils.deepHash(this.protectGeneralContext)
+ result = 31 * result + GeckoPigeonUtils.deepHash(this.protectedContextIds)
+ result = 31 * result + GeckoPigeonUtils.deepHash(this.strictContextIds)
+ result = 31 * result + GeckoPigeonUtils.deepHash(this.protectedTargetPatterns)
+ result = 31 * result + GeckoPigeonUtils.deepHash(this.contextOverrides)
+ return result
+ }
+ override fun toString(): String {
+ return "AppLinkPolicySnapshot(globalMode=$globalMode, rules=$rules, marketplaceFallbackEnabled=$marketplaceFallbackEnabled, protectGeneralContext=$protectGeneralContext, protectedContextIds=$protectedContextIds, strictContextIds=$strictContextIds, protectedTargetPatterns=$protectedTargetPatterns, contextOverrides=$contextOverrides)"
+ }
+}
+
+/**
+ * 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.
+ *
+ * Generated class from Pigeon that represents data sent in messages.
+ */
+data class AppLinkPromptRequest (
+ /** Monotonic per-process id (Kotlin Long). */
+ val requestId: Long,
+ val owner: AppLinkPromptOwner,
+ val tabId: String,
+ val contextId: String? = null,
+ val sourceUrl: String? = null,
+ val isPrivate: Boolean,
+ val isWallet: Boolean,
+ val isProtectedContext: Boolean,
+ val canRemember: Boolean,
+ /**
+ * false for the http(s) banner class (non-modal); true for the modal
+ * unsupported-scheme prompt.
+ */
+ val isModal: Boolean,
+ val target: AppLinkTarget
+)
+ {
+ companion object {
+ fun fromList(pigeonVar_list: List): AppLinkPromptRequest {
+ val requestId = pigeonVar_list[0] as Long
+ val owner = pigeonVar_list[1] as AppLinkPromptOwner
+ val tabId = pigeonVar_list[2] as String
+ val contextId = pigeonVar_list[3] as String?
+ val sourceUrl = pigeonVar_list[4] as String?
+ val isPrivate = pigeonVar_list[5] as Boolean
+ val isWallet = pigeonVar_list[6] as Boolean
+ val isProtectedContext = pigeonVar_list[7] as Boolean
+ val canRemember = pigeonVar_list[8] as Boolean
+ val isModal = pigeonVar_list[9] as Boolean
+ val target = pigeonVar_list[10] as AppLinkTarget
+ return AppLinkPromptRequest(requestId, owner, tabId, contextId, sourceUrl, isPrivate, isWallet, isProtectedContext, canRemember, isModal, target)
+ }
+ }
+ fun toList(): List {
+ return listOf(
+ requestId,
+ owner,
+ tabId,
+ contextId,
+ sourceUrl,
+ isPrivate,
+ isWallet,
+ isProtectedContext,
+ canRemember,
+ isModal,
+ target,
+ )
+ }
+ override fun equals(other: Any?): Boolean {
+ if (other == null || other.javaClass != javaClass) {
+ return false
+ }
+ if (this === other) {
+ return true
+ }
+ val other = other as AppLinkPromptRequest
+ return GeckoPigeonUtils.deepEquals(this.requestId, other.requestId) && GeckoPigeonUtils.deepEquals(this.owner, other.owner) && GeckoPigeonUtils.deepEquals(this.tabId, other.tabId) && GeckoPigeonUtils.deepEquals(this.contextId, other.contextId) && GeckoPigeonUtils.deepEquals(this.sourceUrl, other.sourceUrl) && GeckoPigeonUtils.deepEquals(this.isPrivate, other.isPrivate) && GeckoPigeonUtils.deepEquals(this.isWallet, other.isWallet) && GeckoPigeonUtils.deepEquals(this.isProtectedContext, other.isProtectedContext) && GeckoPigeonUtils.deepEquals(this.canRemember, other.canRemember) && GeckoPigeonUtils.deepEquals(this.isModal, other.isModal) && GeckoPigeonUtils.deepEquals(this.target, other.target)
+ }
+
+ override fun hashCode(): Int {
+ var result = javaClass.hashCode()
+ result = 31 * result + GeckoPigeonUtils.deepHash(this.requestId)
+ result = 31 * result + GeckoPigeonUtils.deepHash(this.owner)
+ result = 31 * result + GeckoPigeonUtils.deepHash(this.tabId)
+ result = 31 * result + GeckoPigeonUtils.deepHash(this.contextId)
+ result = 31 * result + GeckoPigeonUtils.deepHash(this.sourceUrl)
+ result = 31 * result + GeckoPigeonUtils.deepHash(this.isPrivate)
+ result = 31 * result + GeckoPigeonUtils.deepHash(this.isWallet)
+ result = 31 * result + GeckoPigeonUtils.deepHash(this.isProtectedContext)
+ result = 31 * result + GeckoPigeonUtils.deepHash(this.canRemember)
+ result = 31 * result + GeckoPigeonUtils.deepHash(this.isModal)
+ result = 31 * result + GeckoPigeonUtils.deepHash(this.target)
+ return result
+ }
+ override fun toString(): String {
+ return "AppLinkPromptRequest(requestId=$requestId, owner=$owner, tabId=$tabId, contextId=$contextId, sourceUrl=$sourceUrl, isPrivate=$isPrivate, isWallet=$isWallet, isProtectedContext=$isProtectedContext, canRemember=$canRemember, isModal=$isModal, target=$target)"
+ }
+}
+
+/**
+ * Result of resolving a pending prompt (§2.8).
+ *
+ * Generated class from Pigeon that represents data sent in messages.
+ */
+data class AppLinkResolutionResult (
+ val launched: Boolean,
+ val loadedFallback: Boolean,
+ /** "stale" | "dead_session" | "launch_failed" | null. */
+ val failureReason: String? = null
+)
+ {
+ companion object {
+ fun fromList(pigeonVar_list: List): AppLinkResolutionResult {
+ val launched = pigeonVar_list[0] as Boolean
+ val loadedFallback = pigeonVar_list[1] as Boolean
+ val failureReason = pigeonVar_list[2] as String?
+ return AppLinkResolutionResult(launched, loadedFallback, failureReason)
+ }
+ }
+ fun toList(): List {
+ return listOf(
+ launched,
+ loadedFallback,
+ failureReason,
+ )
+ }
+ override fun equals(other: Any?): Boolean {
+ if (other == null || other.javaClass != javaClass) {
+ return false
+ }
+ if (this === other) {
+ return true
+ }
+ val other = other as AppLinkResolutionResult
+ return GeckoPigeonUtils.deepEquals(this.launched, other.launched) && GeckoPigeonUtils.deepEquals(this.loadedFallback, other.loadedFallback) && GeckoPigeonUtils.deepEquals(this.failureReason, other.failureReason)
+ }
+
+ override fun hashCode(): Int {
+ var result = javaClass.hashCode()
+ result = 31 * result + GeckoPigeonUtils.deepHash(this.launched)
+ result = 31 * result + GeckoPigeonUtils.deepHash(this.loadedFallback)
+ result = 31 * result + GeckoPigeonUtils.deepHash(this.failureReason)
+ return result
+ }
+ override fun toString(): String {
+ return "AppLinkResolutionResult(launched=$launched, loadedFallback=$loadedFallback, failureReason=$failureReason)"
+ }
+}
+
/**
* Represents an icon from a PWA manifest.
*
@@ -6127,8 +6613,28 @@ private data class GeckoPigeonInternalCodecOverflow (
when (type.toInt()) {
0 ->
- return PushStatus.fromList(wrapped as List)
+ return AppLinkResolutionResult.fromList(wrapped as List)
1 ->
+ return PwaIcon.fromList(wrapped as List