app links initial

This commit is contained in:
Fabian Freund
2026-07-30 03:58:46 +02:00
parent 1b0c2b0d06
commit 4bc267969b
97 changed files with 9138 additions and 1054 deletions
@@ -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 <http://www.gnu.org/licenses/>.
*/
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<String, PersistedAppLinkRule>`
/// 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:<host>` or `pkg:<package>`.
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<String, dynamic> json) =>
_$PersistedAppLinkRuleFromJson(json);
Map<String, dynamic> 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<Object?> 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<String, PersistedAppLinkRule> parseAppLinkRules(
Map<String, dynamic>? json,
) {
if (json == null) return const {};
final result = <String, PersistedAppLinkRule>{};
for (final MapEntry(:key, :value) in json.entries) {
if (value is! Map<String, dynamic>) 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;
}
@@ -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<String, dynamic> json,
) => PersistedAppLinkRule(
decision: $enumDecode(_$AppLinkRuleDecisionEnumMap, json['decision']),
scope: json['scope'] as String,
packageName: json['packageName'] as String?,
);
Map<String, dynamic> _$PersistedAppLinkRuleToJson(
PersistedAppLinkRule instance,
) => <String, dynamic>{
'decision': _$AppLinkRuleDecisionEnumMap[instance.decision]!,
'scope': instance.scope,
'packageName': instance.packageName,
};
const _$AppLinkRuleDecisionEnumMap = {
AppLinkRuleDecision.alwaysOpen: 'alwaysOpen',
AppLinkRuleDecision.neverOpen: 'neverOpen',
};
@@ -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 <http://www.gnu.org/licenses/>.
*/
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<String, PersistedAppLinkRule> 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<String, dynamic> json) =>
_$ContextAppLinkPolicyFromJson(json);
Map<String, dynamic> toJson() => _$ContextAppLinkPolicyToJson(this);
@override
List<Object?> 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<String, ContextAppLinkPolicy> parseAppLinkContextOverrides(
Map<String, dynamic>? json,
) {
if (json == null) return const {};
final result = <String, ContextAppLinkPolicy>{};
for (final MapEntry(:key, :value) in json.entries) {
if (value is! Map<String, dynamic>) continue;
try {
result[key] = ContextAppLinkPolicy.fromJson(value);
} catch (_) {
continue;
}
}
return result;
}
@@ -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<String, PersistedAppLinkRule> 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<String, PersistedAppLinkRule> 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<String, PersistedAppLinkRule> 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<String, PersistedAppLinkRule>,
);
}
}
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<String, dynamic> json,
) => ContextAppLinkPolicy(
mode: $enumDecode(_$AppLinksModeEnumMap, json['mode']),
rules: parseAppLinkRules(json['rules'] as Map<String, dynamic>?),
);
Map<String, dynamic> _$ContextAppLinkPolicyToJson(
ContextAppLinkPolicy instance,
) => <String, dynamic>{
'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',
};
@@ -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 <http://www.gnu.org/licenses/>.
*/
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<String, NativeContextAppLinkPolicy> _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 = <String, NativeContextAppLinkPolicy>{};
final baseContextIdByContainerId = <String, String>{};
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 = <String, NativeContextAppLinkPolicy>{
...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<void> _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<void>.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,
);
},
);
}
}
@@ -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<AppLinkProtection> {
/// 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<AppLinkProtection> $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<AppLinkProtection>(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<AppLinkPolicySnapshot?> {
/// 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<AppLinkPolicySnapshot?> $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<AppLinkPolicySnapshot?>(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<AppLinkPolicyReplication, void> {
/// 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<void>(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> {
void build();
@$mustCallSuper
@override
WhenComplete runBuild() {
final ref = this.ref as $Ref<void, void>;
final element =
ref.element
as $ClassProviderElement<
AnyNotifier<void, void>,
void,
Object?,
Object?
>;
return element.handleCreate(ref, build);
}
}
@@ -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 <http://www.gnu.org/licenses/>.
*/
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<AppLinkPromptRequest> 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<void> 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<AppLinkResolutionResult> 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<AppLinkResolutionResult> 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<String?> _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,
);
}
}
@@ -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<AppLinksCoordinator, List<AppLinkPromptRequest>> {
/// 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<AppLinkPromptRequest> value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<List<AppLinkPromptRequest>>(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<AppLinkPromptRequest>> {
List<AppLinkPromptRequest> build();
@$mustCallSuper
@override
WhenComplete runBuild() {
final ref =
this.ref
as $Ref<List<AppLinkPromptRequest>, List<AppLinkPromptRequest>>;
final element =
ref.element
as $ClassProviderElement<
AnyNotifier<
List<AppLinkPromptRequest>,
List<AppLinkPromptRequest>
>,
List<AppLinkPromptRequest>,
Object?,
Object?
>;
return element.handleCreate(ref, build);
}
}
@@ -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 <http://www.gnu.org/licenses/>.
*/
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<ContainerDataWithCount> containers,
required Map<String, Set<String>> 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:<host>` | `pkg:<package>`).
final Map<String, PersistedAppLinkRule> 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<Object?> 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,
);
}
@@ -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<EffectiveAppLinkPolicy?> {
/// 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<EffectiveAppLinkPolicy?> $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<EffectiveAppLinkPolicy?>(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<EffectiveAppLinkPolicy?, String?> {
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';
}
@@ -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 <http://www.gnu.org/licenses/>.
*/
/// 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<Object?> get hashParameters => const ['inherit'];
}
final class DirectProxyAssignment extends ProxyAssignment {
final String scopeId;
DirectProxyAssignment(this.scopeId);
@override
List<Object?> get hashParameters => ['direct', scopeId];
}
final class ExplicitProxyAssignment extends ProxyAssignment {
final String proxyId;
ExplicitProxyAssignment(this.proxyId);
@override
List<Object?> 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<String> 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<ProxyAssignment> assignments,
) {
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 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<Object?> 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<ProtectedTargetPattern> computeProtectedTargetPatterns({
required Iterable<SiteAssignment> assignments,
required Set<String> protectedOrStrictContextIds,
}) {
final patterns = <ProtectedTargetPattern>{};
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<String> protectedContextIds;
/// strictMode-enforced contextIds, independent of routing.
final Set<String> strictContextIds;
final List<ProtectedTargetPattern> protectedTargetPatterns;
AppLinkProtection({
required this.protectGeneralContext,
required this.protectedContextIds,
required this.strictContextIds,
required this.protectedTargetPatterns,
});
@override
List<Object?> 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<ContainerData> containers,
required Map<String, Set<String>> isolationContextContainerMap,
required Set<String> strictContextIds,
required Iterable<SiteAssignment> siteAssignments,
}) {
final assignmentByContextId = <String, ProxyAssignment>{};
final assignmentByContainerId = <String, ProxyAssignment>{};
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 = <String>{};
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,
);
}
@@ -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 <http://www.gnu.org/licenses/>.
*/
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<void> 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'),
),
],
),
),
],
),
),
);
}
}
@@ -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 <http://www.gnu.org/licenses/>.
*/
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<void> 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;
}
@@ -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 <http://www.gnu.org/licenses/>.
*/
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<int?>(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<void>(
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,
);
}
}
@@ -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 <http://www.gnu.org/licenses/>.
*/
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<void> _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;
}
@@ -1267,18 +1267,3 @@ class _TabGroupRecord {
required this.dateKey, required this.dateKey,
}); });
} }
@Riverpod()
class AppLinksModeNotifier extends _$AppLinksModeNotifier {
final _service = GeckoEngineSettingsService();
Future<void> setMode(AppLinksMode mode) async {
await _service.setAppLinksMode(mode);
ref.invalidateSelf();
}
@override
Future<AppLinksMode> build() {
return _service.getAppLinksMode();
}
}
@@ -1253,48 +1253,3 @@ final class GroupedTabListItemsFamily extends $Family
@override @override
String toString() => r'groupedTabListItemsProvider'; String toString() => r'groupedTabListItemsProvider';
} }
@ProviderFor(AppLinksModeNotifier)
final appLinksModeProvider = AppLinksModeNotifierProvider._();
final class AppLinksModeNotifierProvider
extends $AsyncNotifierProvider<AppLinksModeNotifier, AppLinksMode> {
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<AppLinksMode> {
FutureOr<AppLinksMode> build();
@$mustCallSuper
@override
WhenComplete runBuild() {
final ref = this.ref as $Ref<AsyncValue<AppLinksMode>, AppLinksMode>;
final element =
ref.element
as $ClassProviderElement<
AnyNotifier<AsyncValue<AppLinksMode>, AppLinksMode>,
AsyncValue<AppLinksMode>,
Object?,
Object?
>;
return element.handleCreate(ref, build);
}
}
@@ -42,7 +42,7 @@ final class BrowserDataServiceProvider
} }
String _$browserDataServiceHash() => String _$browserDataServiceHash() =>
r'2df2f652342efc3e16606b92fdef6062b02f72df'; r'5df7ca0b61a5f34e69280311777e98fc31907269';
abstract class _$BrowserDataService extends $Notifier<void> { abstract class _$BrowserDataService extends $Notifier<void> {
void build(); void build();
@@ -22,6 +22,7 @@ import 'package:riverpod/riverpod.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:synchronized/synchronized.dart'; import 'package:synchronized/synchronized.dart';
import 'package:weblibre/core/logger.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/models/container_data.dart';
import 'package:weblibre/features/geckoview/features/tabs/data/providers.dart'; import 'package:weblibre/features/geckoview/features/tabs/data/providers.dart';
import 'package:weblibre/features/geckoview/features/tabs/domain/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'; 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<Object?> get hashParameters => const ['inherit'];
}
final class _DirectProxyAssignment extends _ProxyAssignment {
final String scopeId;
_DirectProxyAssignment(this.scopeId);
@override
List<Object?> get hashParameters => [scopeId];
}
final class _ExplicitProxyAssignment extends _ProxyAssignment {
final String proxyId;
_ExplicitProxyAssignment(this.proxyId);
@override
List<Object?> get hashParameters => [proxyId];
}
@Riverpod(keepAlive: true) @Riverpod(keepAlive: true)
class ProxySettingsReplication extends _$ProxySettingsReplication { class ProxySettingsReplication extends _$ProxySettingsReplication {
var _isolatedProxyAssignments = <String, _ProxyAssignment>{}; var _isolatedProxyAssignments = <String, ProxyAssignment>{};
var _appliedContainerProxies = <String, _ProxyAssignment>{}; var _appliedContainerProxies = <String, ProxyAssignment>{};
final _recomputeLock = Lock(); final _recomputeLock = Lock();
var _recomputeDirty = false; var _recomputeDirty = false;
@@ -117,19 +83,17 @@ class ProxySettingsReplication extends _$ProxySettingsReplication {
.read(containerRepositoryProvider.notifier) .read(containerRepositoryProvider.notifier)
.getAllContainersWithCount(); .getAllContainersWithCount();
final containerAssignments = <String, _ProxyAssignment>{ final containerAssignments = <String, ProxyAssignment>{
for (final c in containers) for (final c in containers)
if (c.metadata.contextualIdentity case final contextId?) if (c.metadata.contextualIdentity case final contextId?)
c.id: switch (c.metadata.proxyConnectionId) { c.id: resolveContainerAssignment(
final proxyId? => _ProxyAssignment.explicit(proxyId.encode()), contextId: contextId,
null when c.metadata.bypassGlobalProxy => _ProxyAssignment.direct( proxyConnectionId: c.metadata.proxyConnectionId,
contextId, bypassGlobalProxy: c.metadata.bypassGlobalProxy,
), ),
null => _ProxyAssignment.inherit(),
},
}; };
final newAssignments = <String, _ProxyAssignment>{}; final newAssignments = <String, ProxyAssignment>{};
for (final entry in contextContainerMap.entries) { for (final entry in contextContainerMap.entries) {
final assignments = entry.value final assignments = entry.value
.map((containerId) => containerAssignments[containerId]) .map((containerId) => containerAssignments[containerId])
@@ -138,50 +102,19 @@ class ProxySettingsReplication extends _$ProxySettingsReplication {
if (assignments.isEmpty) continue; if (assignments.isEmpty) continue;
final proxyIds = final routing = resolveIsolationContextRouting(assignments);
assignments if (routing.chosen is! InheritProxyAssignment) {
.whereType<_ExplicitProxyAssignment>() newAssignments[entry.key] = routing.chosen;
.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 chosenLabel = switch (chosenAssignment) {
_DirectProxyAssignment(:final scopeId) => 'direct:$scopeId',
_ExplicitProxyAssignment(:final proxyId) => proxyId,
_InheritProxyAssignment() => 'inherit',
};
final distinctAssignmentCount = if (routing.distinctAssignmentCount > 1) {
proxyIds.length +
directScopeIds.length +
(hasInheritedAssignment ? 1 : 0);
if (distinctAssignmentCount > 1) {
// Isolation contexts can hold multiple containers; if they disagree on // Isolation contexts can hold multiple containers; if they disagree on
// routing, the alias is forced to pick one. Surface this so the user // routing, the alias is forced to pick one. Surface this so the user
// can split the containers across isolation contexts. // can split the containers across isolation contexts.
logger.w( logger.w(
'Isolation context ${entry.key} has containers with multiple ' 'Isolation context ${entry.key} has containers with multiple '
'proxy routing assignments ' '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 { ) async {
if (containers == null) return; if (containers == null) return;
final desired = <String, _ProxyAssignment>{}; final desired = <String, ProxyAssignment>{};
for (final container in containers) { for (final container in containers) {
final contextId = container.metadata.contextualIdentity; final contextId = container.metadata.contextualIdentity;
if (contextId == null || contextId.isEmpty) continue; if (contextId == null || contextId.isEmpty) continue;
final proxyConnectionId = container.metadata.proxyConnectionId; desired[contextId] = resolveContainerAssignment(
desired[contextId] = proxyConnectionId != null contextId: contextId,
? _ProxyAssignment.explicit(proxyConnectionId.encode()) proxyConnectionId: container.metadata.proxyConnectionId,
: container.metadata.bypassGlobalProxy bypassGlobalProxy: container.metadata.bypassGlobalProxy,
? _ProxyAssignment.direct(contextId) );
: _ProxyAssignment.inherit();
} }
final repo = ref.read(containerProxyRepositoryProvider.notifier); final repo = ref.read(containerProxyRepositoryProvider.notifier);
final nextApplied = Map<String, _ProxyAssignment>.from( final nextApplied = Map<String, ProxyAssignment>.from(
_appliedContainerProxies, _appliedContainerProxies,
); );
@@ -395,15 +327,15 @@ class ProxySettingsReplication extends _$ProxySettingsReplication {
Future<void> _applyProxyAssignment( Future<void> _applyProxyAssignment(
String contextId, String contextId,
_ProxyAssignment assignment, ProxyAssignment assignment,
) async { ) async {
final repo = ref.read(containerProxyRepositoryProvider.notifier); final repo = ref.read(containerProxyRepositoryProvider.notifier);
switch (assignment) { switch (assignment) {
case _ExplicitProxyAssignment(:final proxyId): case ExplicitProxyAssignment(:final proxyId):
await repo.setContainerProxy(contextId, proxyId); await repo.setContainerProxy(contextId, proxyId);
case _DirectProxyAssignment(:final scopeId): case DirectProxyAssignment(:final scopeId):
await repo.setContainerDirectConnection(contextId, scopeId: scopeId); await repo.setContainerDirectConnection(contextId, scopeId: scopeId);
case _InheritProxyAssignment(): case InheritProxyAssignment():
await repo.clearContainerProxy(contextId); await repo.clearContainerProxy(contextId);
} }
} }
@@ -42,7 +42,7 @@ final class ProxySettingsReplicationProvider
} }
String _$proxySettingsReplicationHash() => String _$proxySettingsReplicationHash() =>
r'69787c85c94ff165e3eeb0f0a3f3fc83e88a1b83'; r'bea07ab165545a6bef8a72ddf0503e0cd135eb8a';
abstract class _$ProxySettingsReplication extends $Notifier<void> { abstract class _$ProxySettingsReplication extends $Notifier<void> {
void build(); void build();
@@ -44,7 +44,7 @@ List<ToolbarButtonConfig> _buildDefaultToolbarButtonConfigs({
return ToolbarButtonConfig( return ToolbarButtonConfig(
buttonId: spec.id.name, buttonId: spec.id.name,
orderKey: key, orderKey: key,
isVisible: allHidden ? false : spec.defaultVisible, isVisible: !allHidden && spec.defaultVisible,
fallbackId: allHidden ? null : spec.defaultFallback?.name, fallbackId: allHidden ? null : spec.defaultFallback?.name,
); );
}).toList(); }).toList();
@@ -29,6 +29,7 @@ import 'package:weblibre/core/providers/global_drop.dart';
import 'package:weblibre/core/routing/routes.dart'; import 'package:weblibre/core/routing/routes.dart';
import 'package:weblibre/data/models/drag_data.dart'; import 'package:weblibre/data/models/drag_data.dart';
import 'package:weblibre/extensions/media_query.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/bottom_sheet.dart';
import 'package:weblibre/features/geckoview/domain/controllers/overlay.dart'; import 'package:weblibre/features/geckoview/domain/controllers/overlay.dart';
import 'package:weblibre/features/geckoview/domain/entities/states/tab.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(),
);
},
),
], ],
), ),
), ),
@@ -1575,22 +1575,25 @@ class _OpenInAppTile extends HookConsumerWidget {
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final tabState = ref.watch(tabStateProvider(selectedTabId)); final tabState = ref.watch(tabStateProvider(selectedTabId));
final url = tabState?.url; final url = tabState?.url;
final hasExternalApp = useCachedFuture( final appLink = useCachedFuture(
() => url != null ? _service.hasExternalApp(url) : Future.value(false), () => url != null ? _service.resolveAppLink(url) : Future.value(null),
[url], [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( return Column(
children: [ children: [
_buildDivider(), _buildDivider(),
ListTile( ListTile(
leading: const Icon(Icons.open_in_new), 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 { onTap: () async {
if (url == null) return; if (url == null) return;
final success = await _service.openAppLink(url); final success = await _service.launchAppLink(url);
if (success && context.mounted) Navigator.pop(context); if (success && context.mounted) Navigator.pop(context);
}, },
), ),
@@ -30,6 +30,7 @@ import 'package:weblibre/core/logger.dart';
import 'package:weblibre/core/providers/device_info.dart'; import 'package:weblibre/core/providers/device_info.dart';
import 'package:weblibre/core/providers/router.dart'; import 'package:weblibre/core/providers/router.dart';
import 'package:weblibre/core/routing/routes.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/data/models/web_search_bang.dart';
import 'package:weblibre/features/bangs/domain/providers/bangs.dart'; import 'package:weblibre/features/bangs/domain/providers/bangs.dart';
import 'package:weblibre/features/bangs/domain/services/search_history_cleanup.dart'; import 'package:weblibre/features/bangs/domain/services/search_history_cleanup.dart';
@@ -676,6 +677,19 @@ class _BrowserViewState extends ConsumerState<BrowserView>
}, },
); );
ref.listenManual(
fireImmediately: true,
appLinkPolicyReplicationProvider,
(previous, next) {},
onError: (error, stackTrace) {
logger.e(
'Error listening to appLinkPolicyReplicationProvider',
error: error,
stackTrace: stackTrace,
);
},
);
ref.listenManual( ref.listenManual(
fireImmediately: true, fireImmediately: true,
historyExclusionReplicationProvider, historyExclusionReplicationProvider,
@@ -329,24 +329,27 @@ class OpenInAppMenuItemButton extends HookConsumerWidget {
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final tabState = ref.watch(tabStateProvider(selectedTabId)); final tabState = ref.watch(tabStateProvider(selectedTabId));
final url = tabState?.url; final url = tabState?.url;
final hasExternalApp = useCachedFuture( final appLink = useCachedFuture(
// ignore: discarded_futures useFuture // ignore: discarded_futures useFuture
() => url != null ? _service.hasExternalApp(url) : Future.value(false), () => url != null ? _service.resolveAppLink(url) : Future.value(null),
[url], [url],
); );
if (hasExternalApp.data != true) { final target = appLink.data;
if (target == null) {
return const SizedBox.shrink(); return const SizedBox.shrink();
} }
final appName = target.appName;
return MenuItemButton( return MenuItemButton(
leadingIcon: const Icon(Icons.open_in_new), leadingIcon: const Icon(Icons.open_in_new),
closeOnActivate: false, closeOnActivate: false,
child: const Text('Open in App'), child: Text(appName != null ? 'Open in $appName' : 'Open in App'),
onPressed: () async { onPressed: () async {
if (url == null) return; if (url == null) return;
final success = await _service.openAppLink(url); final success = await _service.launchAppLink(url);
if (success && context.mounted) { if (success && context.mounted) {
MenuController.maybeOf(context)?.close(); MenuController.maybeOf(context)?.close();
@@ -357,19 +357,22 @@ class _OpenInAppTile extends HookConsumerWidget {
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final tabState = ref.watch(tabStateProvider(selectedTabId)); final tabState = ref.watch(tabStateProvider(selectedTabId));
final url = tabState?.url; final url = tabState?.url;
final hasExternalApp = useCachedFuture( final appLink = useCachedFuture(
() => url != null ? _service.hasExternalApp(url) : Future.value(false), () => url != null ? _service.resolveAppLink(url) : Future.value(null),
[url], [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( return ListTile(
leading: const Icon(Icons.open_in_new), 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 { onTap: () async {
if (url == null) return; if (url == null) return;
final success = await _service.openAppLink(url); final success = await _service.launchAppLink(url);
if (success && context.mounted) Navigator.pop(context); if (success && context.mounted) Navigator.pop(context);
}, },
); );
@@ -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 <http://www.gnu.org/licenses/>.
*/
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<AppLinkTarget?, Uri>((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<void> _setSiteRule(
WidgetRef ref,
String scope,
AppLinkTarget? target,
_SiteRuleChoice choice,
) async {
Map<String, PersistedAppLinkRule> updateRules(
Map<String, PersistedAppLinkRule> 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,
);
}
@@ -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/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/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/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/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/desktop_mode_section.dart';
import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/sheets/gesture_exclusion_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, url: initialTabState.url,
), ),
const Divider(), const Divider(),
// App Link Section
AppLinkSection(
url: initialTabState.url,
contextId: initialTabState.contextId,
),
const Divider(),
// Permissions Section // Permissions Section
PermissionsSection( PermissionsSection(
origin: initialTabState.url.origin, origin: initialTabState.url.origin,
@@ -23,6 +23,7 @@ import 'package:go_router/go_router.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:nullability/nullability.dart'; import 'package:nullability/nullability.dart';
import 'package:weblibre/features/geckoview/features/contextmenu/extensions/hit_result.dart'; import 'package:weblibre/features/geckoview/features/contextmenu/extensions/hit_result.dart';
import 'package:weblibre/presentation/hooks/cached_future.dart';
class LaunchExternal extends HookConsumerWidget { class LaunchExternal extends HookConsumerWidget {
final HitResult hitResult; final HitResult hitResult;
@@ -33,19 +34,26 @@ class LaunchExternal extends HookConsumerWidget {
static Future<bool> isSupported(HitResult hitResult) async { static Future<bool> isSupported(HitResult hitResult) async {
return hitResult.tryGetLink().mapNotNull( return hitResult.tryGetLink().mapNotNull(
(url) => _service.hasExternalApp(url), (url) async => (await _service.resolveAppLink(url)) != null,
) ?? ) ??
false; false;
} }
@override @override
Widget build(BuildContext context, WidgetRef ref) { 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( return ListTile(
leading: const Icon(Icons.open_in_new), 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 { onTap: () async {
await hitResult.tryGetLink().mapNotNull((url) async { await hitResult.tryGetLink().mapNotNull((url) async {
final success = await _service.openAppLink(url); final success = await _service.launchAppLink(url);
if (success && context.mounted) { if (success && context.mounted) {
context.pop(); context.pop();
@@ -184,11 +184,11 @@ class OpenSharedContent extends HookConsumerWidget {
}; };
}, [containerMode, contextId, selectionUrlKey, globalSelectedContainer]); }, [containerMode, contextId, selectionUrlKey, globalSelectedContainer]);
final hasExternalApp = useCachedFuture( final appLink = useCachedFuture(
// ignore: discarded_futures useFuture // ignore: discarded_futures useFuture
() => parsedDebouncedUrl != null () => parsedDebouncedUrl != null
? _appLinksService.hasExternalApp(parsedDebouncedUrl) ? _appLinksService.resolveAppLink(parsedDebouncedUrl)
: Future.value(false), : Future.value(null),
[parsedDebouncedUrl], [parsedDebouncedUrl],
); );
@@ -288,7 +288,7 @@ class OpenSharedContent extends HookConsumerWidget {
final uri = parseValidatedUrl(textController.text, eagerParsing: false); final uri = parseValidatedUrl(textController.text, eagerParsing: false);
if (uri == null) return; if (uri == null) return;
final success = await _appLinksService.openAppLink(uri); final success = await _appLinksService.launchAppLink(uri);
if (success && context.mounted) { if (success && context.mounted) {
context.pop(true); context.pop(true);
@@ -429,9 +429,11 @@ class OpenSharedContent extends HookConsumerWidget {
}, },
), ),
], ],
if (hasExternalApp.data == true) if (appLink.data != null)
_OpenActionTile( _OpenActionTile(
title: 'Open in App', title: appLink.data?.appName != null
? 'Open in ${appLink.data!.appName}'
: 'Open in App',
subtitle: 'Open in an installed app', subtitle: 'Open in an installed app',
icon: Icons.open_in_new, icon: Icons.open_in_new,
onTap: openInApp, onTap: openInApp,
@@ -19,11 +19,8 @@
*/ */
import 'package:drift/drift.dart'; import 'package:drift/drift.dart';
import 'package:drift/internal/versioned_schema.dart'; import 'package:drift/internal/versioned_schema.dart';
import 'package:drift_dev/api/migrations_native.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:lexo_rank/lexo_rank.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/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/container.dart';
import 'package:weblibre/features/geckoview/features/tabs/data/database/daos/history.dart'; import 'package:weblibre/features/geckoview/features/tabs/data/database/daos/history.dart';
@@ -83,6 +83,16 @@ class ContainerMetadata with FastEquatable {
@JsonKey(defaultValue: false) @JsonKey(defaultValue: false)
final bool strictMode; 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({ ContainerMetadata({
required this.iconData, required this.iconData,
required this.contextualIdentity, required this.contextualIdentity,
@@ -94,6 +104,7 @@ class ContainerMetadata with FastEquatable {
required this.useCustomColor, required this.useCustomColor,
required this.assignedSites, required this.assignedSites,
required this.strictMode, required this.strictMode,
required this.isolatedAppLinkSettings,
}); });
ContainerMetadata.withDefaults({ ContainerMetadata.withDefaults({
@@ -107,6 +118,7 @@ class ContainerMetadata with FastEquatable {
bool? useCustomColor, bool? useCustomColor,
List<Uri>? assignedSites, List<Uri>? assignedSites,
bool? strictMode, bool? strictMode,
bool? isolatedAppLinkSettings,
}) : this( }) : this(
iconData: iconData, iconData: iconData,
contextualIdentity: contextualIdentity, contextualIdentity: contextualIdentity,
@@ -128,6 +140,11 @@ class ContainerMetadata with FastEquatable {
// normalize away the invalid combination on read, and writers re-apply // normalize away the invalid combination on read, and writers re-apply
// it via [sanitized]. // it via [sanitized].
strictMode: (strictMode ?? false) && contextualIdentity != null, 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 /// Enforce the [excludeFromHistory] invariant before persistence: it can only
@@ -145,6 +162,11 @@ class ContainerMetadata with FastEquatable {
if (result.strictMode && result.contextualIdentity == null) { if (result.strictMode && result.contextualIdentity == null) {
result = result.copyWith(strictMode: false); 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; return result;
} }
@@ -167,6 +189,7 @@ class ContainerMetadata with FastEquatable {
useCustomColor, useCustomColor,
assignedSites, assignedSites,
strictMode, strictMode,
isolatedAppLinkSettings,
]; ];
} }
@@ -27,6 +27,8 @@ abstract class _$ContainerMetadataCWProxy {
ContainerMetadata strictMode(bool strictMode); ContainerMetadata strictMode(bool strictMode);
ContainerMetadata isolatedAppLinkSettings(bool isolatedAppLinkSettings);
/// Creates a new instance with the provided field values. /// 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)`. /// 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, bool useCustomColor,
List<Uri>? assignedSites, List<Uri>? assignedSites,
bool strictMode, bool strictMode,
bool isolatedAppLinkSettings,
}); });
} }
@@ -93,6 +96,10 @@ class _$ContainerMetadataCWProxyImpl implements _$ContainerMetadataCWProxy {
@override @override
ContainerMetadata strictMode(bool strictMode) => call(strictMode: strictMode); ContainerMetadata strictMode(bool strictMode) => call(strictMode: strictMode);
@override
ContainerMetadata isolatedAppLinkSettings(bool isolatedAppLinkSettings) =>
call(isolatedAppLinkSettings: isolatedAppLinkSettings);
@override @override
/// Creates a new instance with the provided field values. /// 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)`. /// 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? useCustomColor = const $CopyWithPlaceholder(),
Object? assignedSites = const $CopyWithPlaceholder(), Object? assignedSites = const $CopyWithPlaceholder(),
Object? strictMode = const $CopyWithPlaceholder(), Object? strictMode = const $CopyWithPlaceholder(),
Object? isolatedAppLinkSettings = const $CopyWithPlaceholder(),
}) { }) {
return ContainerMetadata( return ContainerMetadata(
iconData: iconData == const $CopyWithPlaceholder() iconData: iconData == const $CopyWithPlaceholder()
@@ -165,6 +173,12 @@ class _$ContainerMetadataCWProxyImpl implements _$ContainerMetadataCWProxy {
? _value.strictMode ? _value.strictMode
// ignore: cast_nullable_to_non_nullable // ignore: cast_nullable_to_non_nullable
: strictMode as bool, : 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<String, dynamic> json) =>
?.map((e) => Uri.parse(e as String)) ?.map((e) => Uri.parse(e as String))
.toList(), .toList(),
strictMode: json['strictMode'] as bool? ?? false, strictMode: json['strictMode'] as bool? ?? false,
isolatedAppLinkSettings:
json['isolatedAppLinkSettings'] as bool? ?? false,
); );
Map<String, dynamic> _$ContainerMetadataToJson( Map<String, dynamic> _$ContainerMetadataToJson(
@@ -326,6 +342,7 @@ Map<String, dynamic> _$ContainerMetadataToJson(
'useCustomColor': instance.useCustomColor, 'useCustomColor': instance.useCustomColor,
'assignedSites': instance.assignedSites?.map((e) => e.toString()).toList(), 'assignedSites': instance.assignedSites?.map((e) => e.toString()).toList(),
'strictMode': instance.strictMode, 'strictMode': instance.strictMode,
'isolatedAppLinkSettings': instance.isolatedAppLinkSettings,
}; };
Value? _$JsonConverterFromJson<Json, Value>( Value? _$JsonConverterFromJson<Json, Value>(
@@ -103,12 +103,10 @@ class TabDataRepository extends _$TabDataRepository {
), ),
// parentId defaults to null - breaks parent chain when changing contextual identity // parentId defaults to null - breaks parent chain when changing contextual identity
selectTab: selectedTabId == tabState.id, selectTab: selectedTabId == tabState.id,
// Assignment-driven navigation to an assigned site: bypass the // Assignment-driven navigation is classified in its assigned context
// app-links delegate so cancelling an "open in app" prompt does // like any other load; the app-links fallback re-entry map (§2.7)
// not re-trigger it on the recreated tab's load. // covers the redirect loop the old delegate bypass used to guard.
flags: replacementUrl != null flags: LoadUrlFlags.NONE,
? LoadUrlFlags.LOAD_FLAGS_BYPASS_LOAD_URI_DELEGATE
: LoadUrlFlags.NONE,
); );
} }
} }
@@ -41,7 +41,7 @@ final class TabDataRepositoryProvider
} }
} }
String _$tabDataRepositoryHash() => r'adc1c664b492e41a96a0310d92252dbbacc1a089'; String _$tabDataRepositoryHash() => r'd4eb49e25077aea6de479ea738ec92a213b71f78';
abstract class _$TabDataRepository extends $Notifier<void> { abstract class _$TabDataRepository extends $Notifier<void> {
void build(); void build();
@@ -26,6 +26,7 @@ import 'package:flutter_material_design_icons/flutter_material_design_icons.dart
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:weblibre/core/uuid.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/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/data/models/container_data.dart';
import 'package:weblibre/features/geckoview/features/tabs/domain/repositories/container.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/data/proxy_connection.dart';
import 'package:weblibre/features/proxy/domain/providers/proxy_connection_options.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/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 } 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<void> _removeAppLinkOverrides(
WidgetRef ref,
Set<String?> 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 { class ContainerEditScreen extends HookConsumerWidget {
final _DialogMode _mode; final _DialogMode _mode;
@@ -109,6 +133,9 @@ class ContainerEditScreen extends HookConsumerWidget {
); );
final assignedSites = useState(initialContainer.metadata.assignedSites); final assignedSites = useState(initialContainer.metadata.assignedSites);
final strictMode = useState(initialContainer.metadata.strictMode); final strictMode = useState(initialContainer.metadata.strictMode);
final isolatedAppLinkSettings = useState(
initialContainer.metadata.isolatedAppLinkSettings,
);
final isPinned = useState(initialContainer.isPinned); final isPinned = useState(initialContainer.isPinned);
final textController = useTextEditingController( final textController = useTextEditingController(
@@ -147,6 +174,12 @@ class ContainerEditScreen extends HookConsumerWidget {
// strictness on the tab's cookieStoreId). sanitized() enforces the // strictness on the tab's cookieStoreId). sanitized() enforces the
// same invariant defensively on write. // same invariant defensively on write.
strictMode: strictMode.value && contextualIdentity.value != null, 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(), .sanitized(),
); );
@@ -167,6 +200,15 @@ class ContainerEditScreen extends HookConsumerWidget {
isPinned: isPinned.value, 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; return container;
} }
@@ -264,6 +306,11 @@ class ContainerEditScreen extends HookConsumerWidget {
.read(containerRepositoryProvider.notifier) .read(containerRepositoryProvider.notifier)
.deleteContainer(initialContainer.id); .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) { if (context.mounted) {
context.pop(); 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<void>(
context: context,
builder: (context) =>
ContainerAppLinkSettingsDialog(
contextId: initialContainer
.metadata
.contextualIdentity!,
containerName:
textController.text.trim().isNotEmpty
? textController.text.trim()
: initialContainer.name,
),
);
},
),
],
],
),
),
], ],
), ),
), ),
@@ -23,7 +23,7 @@ import 'package:flutter_mozilla_components/flutter_mozilla_components.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:weblibre/core/design/app_colors.dart'; import 'package:weblibre/core/design/app_colors.dart';
import 'package:weblibre/core/routing/routes.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/geckoview/features/tabs/domain/providers/selected_container.dart';
import 'package:weblibre/features/settings/presentation/controllers/save_settings.dart'; import 'package:weblibre/features/settings/presentation/controllers/save_settings.dart';
import 'package:weblibre/features/settings/presentation/widgets/settings_detail.dart'; import 'package:weblibre/features/settings/presentation/widgets/settings_detail.dart';
@@ -709,7 +709,15 @@ class _AppLinksModeSection extends HookConsumerWidget {
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final appLinksMode = ref.watch( 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( return Padding(
@@ -730,7 +738,9 @@ class _AppLinksModeSection extends HookConsumerWidget {
groupValue: appLinksMode, groupValue: appLinksMode,
onChanged: (value) async { onChanged: (value) async {
if (value != null) { if (value != null) {
await ref.read(appLinksModeProvider.notifier).setMode(value); await ref
.read(saveGeneralSettingsControllerProvider.notifier)
.save((current) => current.copyWith.appLinksMode(value));
} }
}, },
child: const Column( 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<String, PersistedAppLinkRule> 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 { class _GlobalDesktopModeTile extends HookConsumerWidget {
const _GlobalDesktopModeTile(); const _GlobalDesktopModeTile();
@@ -20,8 +20,12 @@
import 'package:copy_with_extension/copy_with_extension.dart'; import 'package:copy_with_extension/copy_with_extension.dart';
import 'package:fast_equatable/fast_equatable.dart'; import 'package:fast_equatable/fast_equatable.dart';
import 'package:flutter/material.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:json_annotation/json_annotation.dart';
import 'package:weblibre/core/routing/routes.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_group.dart';
import 'package:weblibre/features/bangs/data/models/bang_key.dart'; import 'package:weblibre/features/bangs/data/models/bang_key.dart';
import 'package:weblibre/features/intent_gatekeeper/domain/entities/intent_source_policy.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. /// via the intent gatekeeper prefs bridge. Defaults to true.
final bool customTabsEnabled; 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<String, PersistedAppLinkRule> 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<String, ContextAppLinkPolicy> 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→ /// Whether the local search index (`history` table populated via tab→
/// history triggers) is active. When false, the SQL trigger guard returns /// history triggers) is active. When false, the SQL trigger guard returns
/// without writing; existing rows stay until the user clears them. /// without writing; existing rows stay until the user clears them.
@@ -296,6 +324,10 @@ class GeneralSettings with FastEquatable {
required this.blockExternalAppsEnabled, required this.blockExternalAppsEnabled,
required this.externalAppIntentPolicies, required this.externalAppIntentPolicies,
required this.customTabsEnabled, required this.customTabsEnabled,
required this.appLinksMode,
required this.appLinkRules,
required this.appLinkContextOverrides,
required this.appLinkMarketplaceFallback,
required this.enableLocalSearchIndex, required this.enableLocalSearchIndex,
required this.indexPrivateTabs, required this.indexPrivateTabs,
required this.acceptSuggestionOnSubmit, required this.acceptSuggestionOnSubmit,
@@ -364,6 +396,10 @@ class GeneralSettings with FastEquatable {
bool? blockExternalAppsEnabled, bool? blockExternalAppsEnabled,
Map<String, IntentSourcePolicy>? externalAppIntentPolicies, Map<String, IntentSourcePolicy>? externalAppIntentPolicies,
bool? customTabsEnabled, bool? customTabsEnabled,
AppLinksMode? appLinksMode,
Map<String, PersistedAppLinkRule>? appLinkRules,
Map<String, ContextAppLinkPolicy>? appLinkContextOverrides,
bool? appLinkMarketplaceFallback,
bool? enableLocalSearchIndex, bool? enableLocalSearchIndex,
bool? indexPrivateTabs, bool? indexPrivateTabs,
bool? acceptSuggestionOnSubmit, bool? acceptSuggestionOnSubmit,
@@ -442,6 +478,10 @@ class GeneralSettings with FastEquatable {
blockExternalAppsEnabled = blockExternalAppsEnabled ?? false, blockExternalAppsEnabled = blockExternalAppsEnabled ?? false,
externalAppIntentPolicies = externalAppIntentPolicies ?? const {}, externalAppIntentPolicies = externalAppIntentPolicies ?? const {},
customTabsEnabled = customTabsEnabled ?? true, customTabsEnabled = customTabsEnabled ?? true,
appLinksMode = appLinksMode ?? AppLinksMode.ask,
appLinkRules = appLinkRules ?? const {},
appLinkContextOverrides = appLinkContextOverrides ?? const {},
appLinkMarketplaceFallback = appLinkMarketplaceFallback ?? false,
enableLocalSearchIndex = enableLocalSearchIndex ?? true, enableLocalSearchIndex = enableLocalSearchIndex ?? true,
indexPrivateTabs = indexPrivateTabs ?? false, indexPrivateTabs = indexPrivateTabs ?? false,
acceptSuggestionOnSubmit = acceptSuggestionOnSubmit ?? true, acceptSuggestionOnSubmit = acceptSuggestionOnSubmit ?? true,
@@ -593,6 +633,10 @@ class GeneralSettings with FastEquatable {
blockExternalAppsEnabled, blockExternalAppsEnabled,
externalAppIntentPolicies, externalAppIntentPolicies,
customTabsEnabled, customTabsEnabled,
appLinksMode,
appLinkRules,
appLinkContextOverrides,
appLinkMarketplaceFallback,
enableLocalSearchIndex, enableLocalSearchIndex,
indexPrivateTabs, indexPrivateTabs,
acceptSuggestionOnSubmit, acceptSuggestionOnSubmit,
@@ -143,6 +143,16 @@ abstract class _$GeneralSettingsCWProxy {
GeneralSettings customTabsEnabled(bool customTabsEnabled); GeneralSettings customTabsEnabled(bool customTabsEnabled);
GeneralSettings appLinksMode(AppLinksMode appLinksMode);
GeneralSettings appLinkRules(Map<String, PersistedAppLinkRule> appLinkRules);
GeneralSettings appLinkContextOverrides(
Map<String, ContextAppLinkPolicy> appLinkContextOverrides,
);
GeneralSettings appLinkMarketplaceFallback(bool appLinkMarketplaceFallback);
GeneralSettings enableLocalSearchIndex(bool enableLocalSearchIndex); GeneralSettings enableLocalSearchIndex(bool enableLocalSearchIndex);
GeneralSettings indexPrivateTabs(bool indexPrivateTabs); GeneralSettings indexPrivateTabs(bool indexPrivateTabs);
@@ -223,6 +233,10 @@ abstract class _$GeneralSettingsCWProxy {
bool blockExternalAppsEnabled, bool blockExternalAppsEnabled,
Map<String, IntentSourcePolicy> externalAppIntentPolicies, Map<String, IntentSourcePolicy> externalAppIntentPolicies,
bool customTabsEnabled, bool customTabsEnabled,
AppLinksMode appLinksMode,
Map<String, PersistedAppLinkRule> appLinkRules,
Map<String, ContextAppLinkPolicy> appLinkContextOverrides,
bool appLinkMarketplaceFallback,
bool enableLocalSearchIndex, bool enableLocalSearchIndex,
bool indexPrivateTabs, bool indexPrivateTabs,
bool acceptSuggestionOnSubmit, bool acceptSuggestionOnSubmit,
@@ -490,6 +504,24 @@ class _$GeneralSettingsCWProxyImpl implements _$GeneralSettingsCWProxy {
GeneralSettings customTabsEnabled(bool customTabsEnabled) => GeneralSettings customTabsEnabled(bool customTabsEnabled) =>
call(customTabsEnabled: customTabsEnabled); call(customTabsEnabled: customTabsEnabled);
@override
GeneralSettings appLinksMode(AppLinksMode appLinksMode) =>
call(appLinksMode: appLinksMode);
@override
GeneralSettings appLinkRules(
Map<String, PersistedAppLinkRule> appLinkRules,
) => call(appLinkRules: appLinkRules);
@override
GeneralSettings appLinkContextOverrides(
Map<String, ContextAppLinkPolicy> appLinkContextOverrides,
) => call(appLinkContextOverrides: appLinkContextOverrides);
@override
GeneralSettings appLinkMarketplaceFallback(bool appLinkMarketplaceFallback) =>
call(appLinkMarketplaceFallback: appLinkMarketplaceFallback);
@override @override
GeneralSettings enableLocalSearchIndex(bool enableLocalSearchIndex) => GeneralSettings enableLocalSearchIndex(bool enableLocalSearchIndex) =>
call(enableLocalSearchIndex: enableLocalSearchIndex); call(enableLocalSearchIndex: enableLocalSearchIndex);
@@ -586,6 +618,10 @@ class _$GeneralSettingsCWProxyImpl implements _$GeneralSettingsCWProxy {
Object? blockExternalAppsEnabled = const $CopyWithPlaceholder(), Object? blockExternalAppsEnabled = const $CopyWithPlaceholder(),
Object? externalAppIntentPolicies = const $CopyWithPlaceholder(), Object? externalAppIntentPolicies = const $CopyWithPlaceholder(),
Object? customTabsEnabled = 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? enableLocalSearchIndex = const $CopyWithPlaceholder(),
Object? indexPrivateTabs = const $CopyWithPlaceholder(), Object? indexPrivateTabs = const $CopyWithPlaceholder(),
Object? acceptSuggestionOnSubmit = const $CopyWithPlaceholder(), Object? acceptSuggestionOnSubmit = const $CopyWithPlaceholder(),
@@ -938,6 +974,28 @@ class _$GeneralSettingsCWProxyImpl implements _$GeneralSettingsCWProxy {
? _value.customTabsEnabled ? _value.customTabsEnabled
// ignore: cast_nullable_to_non_nullable // ignore: cast_nullable_to_non_nullable
: customTabsEnabled as bool, : 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<String, PersistedAppLinkRule>,
appLinkContextOverrides:
appLinkContextOverrides == const $CopyWithPlaceholder() ||
appLinkContextOverrides == null
? _value.appLinkContextOverrides
// ignore: cast_nullable_to_non_nullable
: appLinkContextOverrides as Map<String, ContextAppLinkPolicy>,
appLinkMarketplaceFallback:
appLinkMarketplaceFallback == const $CopyWithPlaceholder() ||
appLinkMarketplaceFallback == null
? _value.appLinkMarketplaceFallback
// ignore: cast_nullable_to_non_nullable
: appLinkMarketplaceFallback as bool,
enableLocalSearchIndex: enableLocalSearchIndex:
enableLocalSearchIndex == const $CopyWithPlaceholder() || enableLocalSearchIndex == const $CopyWithPlaceholder() ||
enableLocalSearchIndex == null enableLocalSearchIndex == null
@@ -1110,6 +1168,17 @@ GeneralSettings _$GeneralSettingsFromJson(
(k, e) => MapEntry(k, $enumDecode(_$IntentSourcePolicyEnumMap, e)), (k, e) => MapEntry(k, $enumDecode(_$IntentSourcePolicyEnumMap, e)),
), ),
customTabsEnabled: json['customTabsEnabled'] as bool?, customTabsEnabled: json['customTabsEnabled'] as bool?,
appLinksMode: $enumDecodeNullable(
_$AppLinksModeEnumMap,
json['appLinksMode'],
),
appLinkRules: parseAppLinkRules(
json['appLinkRules'] as Map<String, dynamic>?,
),
appLinkContextOverrides: parseAppLinkContextOverrides(
json['appLinkContextOverrides'] as Map<String, dynamic>?,
),
appLinkMarketplaceFallback: json['appLinkMarketplaceFallback'] as bool?,
enableLocalSearchIndex: json['enableLocalSearchIndex'] as bool?, enableLocalSearchIndex: json['enableLocalSearchIndex'] as bool?,
indexPrivateTabs: json['indexPrivateTabs'] as bool?, indexPrivateTabs: json['indexPrivateTabs'] as bool?,
acceptSuggestionOnSubmit: json['acceptSuggestionOnSubmit'] as bool?, acceptSuggestionOnSubmit: json['acceptSuggestionOnSubmit'] as bool?,
@@ -1196,6 +1265,12 @@ Map<String, dynamic> _$GeneralSettingsToJson(
(k, e) => MapEntry(k, _$IntentSourcePolicyEnumMap[e]!), (k, e) => MapEntry(k, _$IntentSourcePolicyEnumMap[e]!),
), ),
'customTabsEnabled': instance.customTabsEnabled, '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, 'enableLocalSearchIndex': instance.enableLocalSearchIndex,
'indexPrivateTabs': instance.indexPrivateTabs, 'indexPrivateTabs': instance.indexPrivateTabs,
'acceptSuggestionOnSubmit': instance.acceptSuggestionOnSubmit, 'acceptSuggestionOnSubmit': instance.acceptSuggestionOnSubmit,
@@ -1283,3 +1358,9 @@ const _$IntentSourcePolicyEnumMap = {
IntentSourcePolicy.allow: 'allow', IntentSourcePolicy.allow: 'allow',
IntentSourcePolicy.block: 'block', IntentSourcePolicy.block: 'block',
}; };
const _$AppLinksModeEnumMap = {
AppLinksMode.always: 'always',
AppLinksMode.ask: 'ask',
AppLinksMode.never: 'never',
};
@@ -272,6 +272,18 @@ class GeneralSettingsRepository extends _$GeneralSettingsRepository {
DriftSqlType.bool, DriftSqlType.bool,
db.typeMapping, 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( 'enableLocalSearchIndex': settings['enableLocalSearchIndex']?.readAs(
DriftSqlType.bool, DriftSqlType.bool,
db.typeMapping, db.typeMapping,
@@ -35,7 +35,7 @@ final class GeneralSettingsRepositoryProvider
} }
String _$generalSettingsRepositoryHash() => String _$generalSettingsRepositoryHash() =>
r'4e72c8ebed8b08ced417ca24d6e4a840f2abf1be'; r'7020706aafbac7ee64f678f918ef9fc24c3b98fb';
abstract class _$GeneralSettingsRepository abstract class _$GeneralSettingsRepository
extends $StreamNotifier<GeneralSettings> { extends $StreamNotifier<GeneralSettings> {
@@ -33,7 +33,7 @@ final class ProfileRepositoryProvider
ProfileRepository create() => ProfileRepository(); ProfileRepository create() => ProfileRepository();
} }
String _$profileRepositoryHash() => r'3055487626bdf6bdc6a51284f68eaf4067cd52ef'; String _$profileRepositoryHash() => r'504539c5ec7c9126ed7b07d920820af481f40444';
abstract class _$ProfileRepository extends $AsyncNotifier<List<Profile>> { abstract class _$ProfileRepository extends $AsyncNotifier<List<Profile>> {
FutureOr<List<Profile>> build(); FutureOr<List<Profile>> build();
@@ -226,7 +226,7 @@ final class PushDistributorMutationProvider
} }
String _$pushDistributorMutationHash() => String _$pushDistributorMutationHash() =>
r'5797ca731c90c1e06e089fb71ad602aecda59634'; r'58e489179c2e1fdaf6d8a6bd3b758ec16641358c';
abstract class _$PushDistributorMutation extends $AsyncNotifier<void> { abstract class _$PushDistributorMutation extends $AsyncNotifier<void> {
FutureOr<void> build(); FutureOr<void> build();
@@ -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 <http://www.gnu.org/licenses/>.
*/
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);
});
});
}
@@ -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 <http://www.gnu.org/licenses/>.
*/
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);
});
});
}
@@ -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 <http://www.gnu.org/licenses/>.
*/
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);
});
});
}
@@ -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 <http://www.gnu.org/licenses/>.
*/
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<ExplicitProxyAssignment>());
});
test('bypassGlobalProxy with no proxy is direct scoped to the context', () {
final assignment = resolveContainerAssignment(
contextId: 'ctx',
proxyConnectionId: null,
bypassGlobalProxy: true,
);
expect(assignment, isA<DirectProxyAssignment>());
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<InheritProxyAssignment>());
});
});
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<ExplicitProxyAssignment>());
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<DirectProxyAssignment>());
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<InheritProxyAssignment>());
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);
});
});
}
@@ -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 <http://www.gnu.org/licenses/>.
*/
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': <String, dynamic>{'mode': 'not-a-mode'},
};
final restored = GeneralSettings.fromJson(json);
expect(restored.appLinkContextOverrides, isEmpty);
});
});
}
@@ -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', () { group('ContainerMetadata icon serialization', () {
test('stores MDI icon names', () { test('stores MDI icon names', () {
final metadata = ContainerMetadata.withDefaults( final metadata = ContainerMetadata.withDefaults(
@@ -114,7 +114,6 @@ dependencies {
implementation "org.mozilla.components:browser-icons:$mozillaComponentsVersion" implementation "org.mozilla.components:browser-icons:$mozillaComponentsVersion"
implementation "org.mozilla.components:browser-thumbnails:$mozillaComponentsVersion" implementation "org.mozilla.components:browser-thumbnails:$mozillaComponentsVersion"
implementation "org.mozilla.components:feature-addons:$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:$mozillaComponentsVersion"
implementation "org.mozilla.components:feature-accounts-push:$mozillaComponentsVersion" implementation "org.mozilla.components:feature-accounts-push:$mozillaComponentsVersion"
implementation "org.mozilla.components:feature-awesomebar:$mozillaComponentsVersion" implementation "org.mozilla.components:feature-awesomebar:$mozillaComponentsVersion"
@@ -25,7 +25,6 @@ import androidx.core.content.edit
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
import androidx.preference.PreferenceManager import androidx.preference.PreferenceManager
import eu.weblibre.flutter_mozilla_components.addons.WebExtensionPromptFeature 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.databinding.FragmentBrowserBinding
import eu.weblibre.flutter_mozilla_components.ext.EventSequence import eu.weblibre.flutter_mozilla_components.ext.EventSequence
import eu.weblibre.flutter_mozilla_components.ext.getPreferenceKey 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.feature.WebExtensionToolbarFeature
import eu.weblibre.flutter_mozilla_components.integration.ReaderViewIntegration import eu.weblibre.flutter_mozilla_components.integration.ReaderViewIntegration
import eu.weblibre.flutter_mozilla_components.services.DownloadService 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 io.flutter.Log
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.distinctUntilChangedBy import kotlinx.coroutines.flow.distinctUntilChangedBy
@@ -49,7 +51,6 @@ import mozilla.components.browser.thumbnails.BrowserThumbnails
import mozilla.components.concept.engine.EngineView import mozilla.components.concept.engine.EngineView
import mozilla.components.feature.accounts.FxaCapability import mozilla.components.feature.accounts.FxaCapability
import mozilla.components.feature.accounts.FxaWebChannelFeature import mozilla.components.feature.accounts.FxaWebChannelFeature
import mozilla.components.feature.app.links.AppLinksFeature
import mozilla.components.feature.downloads.DownloadsFeature import mozilla.components.feature.downloads.DownloadsFeature
import mozilla.components.feature.downloads.manager.FetchDownloadManager import mozilla.components.feature.downloads.manager.FetchDownloadManager
import mozilla.components.feature.downloads.temporary.CopyDownloadFeature import mozilla.components.feature.downloads.temporary.CopyDownloadFeature
@@ -89,7 +90,8 @@ abstract class BaseBrowserFragment : Fragment(), UserInteractionHandler, Activit
private val shareResourceFeature = ViewBoundFeatureWrapper<ShareResourceFeature>() private val shareResourceFeature = ViewBoundFeatureWrapper<ShareResourceFeature>()
private val copyDownloadFeature = ViewBoundFeatureWrapper<CopyDownloadFeature>() private val copyDownloadFeature = ViewBoundFeatureWrapper<CopyDownloadFeature>()
private val downloadsFeature = ViewBoundFeatureWrapper<DownloadsFeature>() private val downloadsFeature = ViewBoundFeatureWrapper<DownloadsFeature>()
private val appLinksFeature = ViewBoundFeatureWrapper<AppLinksFeature>() // Native prompt for Custom Tab sessions with no Flutter engine.
private val nativeAppLinkPromptFeature = ViewBoundFeatureWrapper<NativeAppLinkPromptFeature>()
private val promptFeature = ViewBoundFeatureWrapper<PromptFeature>() private val promptFeature = ViewBoundFeatureWrapper<PromptFeature>()
private val webExtensionPromptFeature = ViewBoundFeatureWrapper<WebExtensionPromptFeature>() private val webExtensionPromptFeature = ViewBoundFeatureWrapper<WebExtensionPromptFeature>()
private val sitePermissionsFeature = ViewBoundFeatureWrapper<SitePermissionsFeature>() private val sitePermissionsFeature = ViewBoundFeatureWrapper<SitePermissionsFeature>()
@@ -362,42 +364,24 @@ abstract class BaseBrowserFragment : Fragment(), UserInteractionHandler, Activit
view = view, view = view,
) )
appLinksFeature.set( // App-link prompting: browser tabs are prompted by Flutter's AppLinkPromptHost, so only
feature = AppLinksFeature( // native Custom Tab sessions (no Flutter engine) install a native prompt feature here.
context = profileContext, val nativeTabId = sessionId
store = components.core.store, if (this is ExternalAppBrowserFragment && nativeTabId != null) {
sessionId = sessionId, nativeAppLinkPromptFeature.set(
fragmentManager = parentFragmentManager, feature = NativeAppLinkPromptFeature(
loadUrlUseCase = components.useCases.sessionUseCases.loadUrl, context = profileContext,
launchInApp = { tabId = nativeTabId,
GlobalComponents.shouldOpenLinksInApp( store = PendingAppLinkStores.forProfile(
requireActivity() is ExternalAppBrowserActivity components.profileApplicationContext.relativePath,
) ),
}, launcher = AppLinkRuntime.get(profileContext).launcher,
shouldPrompt = { sessionUseCases = components.useCases.sessionUseCases,
GlobalComponents.shouldPromptOpenLinksInApp( ),
requireActivity() is ExternalAppBrowserActivity owner = this,
) view = view,
}, )
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,
)
promptFeature.set( promptFeature.set(
feature = PromptFeature( feature = PromptFeature(
@@ -47,6 +47,9 @@ class FlutterMozillaComponentsPlugin: FlutterPlugin, ActivityAware {
GeckoPushApi.setUp(binding.binaryMessenger, null) GeckoPushApi.setUp(binding.binaryMessenger, null)
browserApi.disposePushApi() browserApi.disposePushApi()
GlobalComponents.historyEvents = null 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 // The UnifiedPush receiver outlives the Flutter engine; without this it would keep dispatching
// onto a dead messenger. Failures are still retained on Push.lastError. // onto a dead messenger. Failures are still retained on Push.lastError.
GlobalComponents.pushEvents = null GlobalComponents.pushEvents = null
@@ -16,6 +16,7 @@ import eu.weblibre.flutter_mozilla_components.pigeons.BounceTrackingProtectionMo
import eu.weblibre.flutter_mozilla_components.pigeons.ContentBlocking import eu.weblibre.flutter_mozilla_components.pigeons.ContentBlocking
import eu.weblibre.flutter_mozilla_components.pigeons.GeckoAddonEvents import eu.weblibre.flutter_mozilla_components.pigeons.GeckoAddonEvents
import eu.weblibre.flutter_mozilla_components.pigeons.GeckoEngineSettings 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.GeckoGestureEvents
import eu.weblibre.flutter_mozilla_components.pigeons.GeckoHistoryEvents import eu.weblibre.flutter_mozilla_components.pigeons.GeckoHistoryEvents
import eu.weblibre.flutter_mozilla_components.pigeons.GeckoPushEvents import eu.weblibre.flutter_mozilla_components.pigeons.GeckoPushEvents
@@ -150,6 +151,11 @@ object GlobalComponents {
// path), in which case failures are logged natively only. // path), in which case failures are logged natively only.
var pushEvents: GeckoPushEvents? = null 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. // Gecko contextIds of containers with hard exclude-from-history enabled.
// Pushed from Dart; read by WebLibreHistoryDelegate to skip the Places // Pushed from Dart; read by WebLibreHistoryDelegate to skip the Places
// write for visits resolved to one of these containers. // write for visits resolved to one of these containers.
@@ -250,22 +256,6 @@ object GlobalComponents {
context?.stopService(Intent(context, PrivateTabsNotificationService::class.java)) 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 @DelicateCoroutinesApi
private fun restoreBrowserState( private fun restoreBrowserState(
newComponents: Components, newComponents: Components,
@@ -7,64 +7,222 @@
package eu.weblibre.flutter_mozilla_components.api package eu.weblibre.flutter_mozilla_components.api
import android.content.Context 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.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 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.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
/** /**
* Implementation of GeckoAppLinksApi that detects and launches external applications * WebLibre-owned implementation of [GeckoAppLinksApi] backed by [ExternalAppResolver] and
* that can handle URLs. * [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.
* 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.
*/ */
class GeckoAppLinksApiImpl( class GeckoAppLinksApiImpl(
private val context: Context private val context: Context,
) : GeckoAppLinksApi { ) : GeckoAppLinksApi {
companion object { companion object {
private val coroutineScope = CoroutineScope(Dispatchers.Default + SupervisorJob()) private val coroutineScope = CoroutineScope(Dispatchers.Default + SupervisorJob())
private val logger = Logger("GeckoAppLinksApi")
} }
private val components by lazy { // Shared process-level resolver/launcher (§2.7): the 2 s auto-launch cooldown and 30 s
requireNotNull(GlobalComponents.components) { "Components not initialized" } // resolution cache are observed across the interceptor tail, the manual entry points, and
} // prompt resolution alike.
private val resolver get() = AppLinkRuntime.get(context).resolver
private val launcher get() = AppLinkRuntime.get(context).launcher
override fun hasExternalApp(url: String, callback: (Result<Boolean>) -> Unit) { override fun setAppLinkPolicy(
snapshot: AppLinkPolicySnapshot,
callback: (Result<Unit>) -> Unit,
) {
coroutineScope.launch { coroutineScope.launch {
try { try {
val redirect = components.useCases.appLinksUseCases.appLinkRedirect(url) // A profile must be bound before policy can be applied. The Dart
callback(Result.success(redirect.hasExternalApp())) // 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) { } catch (e: Exception) {
callback(Result.success(false)) callback(Result.failure(e))
} }
} }
} }
override fun openAppLink(url: String, callback: (Result<Boolean>) -> Unit) { override fun resolveAppLink(
url: String,
includeHttpAppLinks: Boolean,
callback: (Result<AppLinkTarget?>) -> Unit,
) {
coroutineScope.launch { coroutineScope.launch {
try { try {
val redirect = components.useCases.appLinksUseCases.appLinkRedirect(url) val resolved = resolver.resolve(url, includeHttpAppLinks = includeHttpAppLinks)
if (!resolved.hasExternalApp) {
if (!redirect.hasExternalApp()) { callback(Result.success(null))
callback(Result.success(false))
return@launch return@launch
} }
callback(
// Use NEW_DOCUMENT + MULTIPLE_TASK so the target app opens in its own Result.success(
// task and doesn't get absorbed into WebLibre's recents entry. AppLinkTarget(
// This matches Fenix's ShareController behaviour. url = url,
redirect.appIntent?.flags = appName = resolved.appName,
Intent.FLAG_ACTIVITY_NEW_DOCUMENT or Intent.FLAG_ACTIVITY_MULTIPLE_TASK packageName = resolved.packageName,
fallbackUrl = resolved.fallbackUrl,
components.useCases.appLinksUseCases.openAppLink.invoke(redirect.appIntent) isMarketplace = false,
callback(Result.success(true)) isAmbiguous = resolved.isAmbiguous,
engineSupportsScheme = resolved.engineSupportsScheme,
scopeKey = resolved.scopeKey,
),
),
)
} catch (e: Exception) { } catch (e: Exception) {
// Uniform failure semantics (§2.8): callers cannot distinguish "nothing installed"
// from "resolution failed".
callback(Result.success(null))
}
}
}
override fun launchAppLink(url: String, callback: (Result<Boolean>) -> Unit) {
coroutineScope.launch {
try {
val result = launcher.launch(url, mode = AppLinkLaunchMode.MANUAL)
logger.info("launchAppLink($url) -> $result")
callback(Result.success(result == AppLinkLaunchResult.LAUNCHED))
} catch (e: Exception) {
logger.error("launchAppLink($url) failed", e)
callback(Result.success(false)) callback(Result.success(false))
} }
} }
} }
private fun pendingStoreFor(components: Components): PendingAppLinkStore {
return PendingAppLinkStores.forProfile(
components.profileApplicationContext.relativePath,
)
}
override fun getPendingAppLinkPrompts(
owner: AppLinkPromptOwner,
callback: (Result<List<AppLinkPromptRequest>>) -> Unit,
) {
coroutineScope.launch {
try {
val components = GlobalComponents.components
val list = components
?.let { pendingStoreFor(it).getPending(owner).map(PendingAppLinkRequest::toPigeon) }
?: emptyList()
callback(Result.success(list))
} catch (e: Exception) {
callback(Result.success(emptyList()))
}
}
}
override fun resolvePendingAppLink(
requestId: Long,
decision: AppLinkDecision,
callback: (Result<AppLinkResolutionResult>) -> Unit,
) {
coroutineScope.launch {
try {
val components = GlobalComponents.components
?: return@launch callback(Result.success(stale()))
val store = pendingStoreFor(components)
// Consume atomically; the store lock is released before any side effect.
val request = store.consume(requestId)
if (request == null) {
// The request was invalidated (navigation/tab close/expiry) before the user
// resolved it — the prompt shown was stale. No launch, no page change.
logger.info("resolvePendingAppLink($requestId, $decision) -> stale (no pending request)")
return@launch callback(Result.success(stale()))
}
// Never launch into a session that no longer exists.
val tabAlive = components.core.store.state
.findTabOrCustomTab(request.tabId) != null
if (!tabAlive) {
logger.info("resolvePendingAppLink($requestId) -> dead_session (${request.tabId})")
return@launch callback(
Result.success(AppLinkResolutionResult(false, false, "dead_session")),
)
}
val result = when (decision) {
AppLinkDecision.OPEN -> handleOpen(components, request)
AppLinkDecision.CANCEL, AppLinkDecision.DISMISS -> {
store.recordSuppression(request.tabId, request.targetFingerprint)
AppLinkResolutionResult(false, false, null)
}
}
callback(Result.success(result))
} catch (e: Exception) {
callback(Result.success(AppLinkResolutionResult(false, false, "launch_failed")))
}
}
}
private fun handleOpen(
components: Components,
request: PendingAppLinkRequest,
): AppLinkResolutionResult {
val mode = if (request.isMarketplace) {
AppLinkLaunchMode.MARKETPLACE
} else {
// Prompt-resolved opens are user gestures (bypass the cooldown).
AppLinkLaunchMode.MANUAL
}
// Honour the package captured when the prompt was created for a *named*
// (non-ambiguous) target, so a change in handlers before the user taps Open
// can't launch a different app (§2.5/§2.7). Ambiguous/chooser prompts store a
// null expectedPackage, so this stays null and the chooser still opens.
val result = launcher.launch(request.url, mode, expectedPackage = request.expectedPackage)
logger.info("resolvePendingAppLink open: launch(${request.url}, $mode) -> $result")
if (result == AppLinkLaunchResult.LAUNCHED) {
return AppLinkResolutionResult(true, false, null)
}
// Launch failed: load a validated fallback if present, else leave the page.
val fallback = request.fallbackUrl
if (fallback != null) {
// Guard the fallback load against immediately bouncing back out to an app
// (§2.7): a validated http(s) fallback can itself resolve to an external
// handler, which would re-prompt/auto-launch. The interceptor records the
// same for fallbacks it issues.
pendingStoreFor(components).recordFallbackReentry(fallback)
components.useCases.sessionUseCases.loadUrl(
url = fallback,
sessionId = request.tabId,
)
return AppLinkResolutionResult(false, true, "launch_failed")
}
return AppLinkResolutionResult(false, false, "launch_failed")
}
private fun stale() = AppLinkResolutionResult(false, false, "stale")
} }
@@ -51,6 +51,7 @@ import eu.weblibre.flutter_mozilla_components.pigeons.GeckoPushApi
import eu.weblibre.flutter_mozilla_components.pigeons.GeckoPushEvents import eu.weblibre.flutter_mozilla_components.pigeons.GeckoPushEvents
import eu.weblibre.flutter_mozilla_components.pigeons.GeckoPwaApi import eu.weblibre.flutter_mozilla_components.pigeons.GeckoPwaApi
import eu.weblibre.flutter_mozilla_components.pigeons.GeckoSelectionActionController 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.GeckoHistoryEvents
import eu.weblibre.flutter_mozilla_components.pigeons.GeckoSelectionActionEvents import eu.weblibre.flutter_mozilla_components.pigeons.GeckoSelectionActionEvents
import eu.weblibre.flutter_mozilla_components.pigeons.GeckoSessionApi import eu.weblibre.flutter_mozilla_components.pigeons.GeckoSessionApi
@@ -280,6 +281,10 @@ class GeckoBrowserApiImpl : GeckoBrowserApi {
GlobalComponents.historyEvents = GlobalComponents.historyEvents =
GeckoHistoryEvents(_flutterPluginBinding.binaryMessenger) 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 // Also set before GlobalComponents.setUp, which calls push.initialize() and can therefore
// surface a registration failure before this sink would otherwise exist. // surface a registration failure before this sink would otherwise exist.
GlobalComponents.pushEvents = GeckoPushEvents(_flutterPluginBinding.binaryMessenger) GlobalComponents.pushEvents = GeckoPushEvents(_flutterPluginBinding.binaryMessenger)
@@ -7,13 +7,9 @@
package eu.weblibre.flutter_mozilla_components.api package eu.weblibre.flutter_mozilla_components.api
import android.content.Context 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.ColorSchemePreference
import eu.weblibre.flutter_mozilla_components.GlobalComponents 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.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.BounceTrackingProtectionMode as PigeonBounceTrackingProtectionMode
import eu.weblibre.flutter_mozilla_components.pigeons.ColorScheme import eu.weblibre.flutter_mozilla_components.pigeons.ColorScheme
import eu.weblibre.flutter_mozilla_components.pigeons.CookieBannerHandlingMode import eu.weblibre.flutter_mozilla_components.pigeons.CookieBannerHandlingMode
@@ -436,35 +432,6 @@ class GeckoEngineSettingsApiImpl(
GlobalComponents.screenshotProtectionEnabled = enabled 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) { override fun setUseExternalDownloadManager(enabled: Boolean) {
GlobalComponents.useExternalDownloadManager = enabled GlobalComponents.useExternalDownloadManager = enabled
} }
@@ -0,0 +1,225 @@
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
package eu.weblibre.flutter_mozilla_components.applinks
/**
* Global app-links behaviour, Kotlin-native mirror of the Pigeon `AppLinksMode` transport enum.
*/
enum class AppLinkMode {
ALWAYS,
ASK,
NEVER,
}
enum class AppLinkRuleDecision {
ALWAYS_OPEN,
NEVER_OPEN,
}
/** A remembered per-scope rule (Kotlin-native mirror of the persisted/Pigeon rule model). */
data class AppLinkRule(
val decision: AppLinkRuleDecision,
val scope: String,
val packageName: String?,
)
/** Non-source-tab protection pattern (§2.3), matched against the navigation target. */
data class ProtectedTargetPattern(
val scheme: String,
val hostOrSuffix: String,
val includeSubdomains: Boolean,
val port: Int?,
)
/**
* A container's self-contained app-link policy (§ container isolation). Present only for containers
* with "isolated app link settings" enabled; when a navigation's source contextId has an entry, its
* [globalMode] + [rules] fully *replace* the global ones for that navigation (no layering).
*/
data class ContextAppLinkPolicy(
val globalMode: AppLinkMode,
val rules: Map<String, AppLinkRule>,
)
/**
* The complete policy the classifier reads. Populated from the replicated snapshot (§2.8); the
* classifier itself holds no Android types and no I/O.
*/
data class AppLinkPolicy(
val globalMode: AppLinkMode,
val rules: Map<String, AppLinkRule>,
val marketplaceFallbackEnabled: Boolean,
val protectGeneralContext: Boolean,
val protectedContextIds: Set<String>,
val strictContextIds: Set<String>,
val protectedTargetPatterns: List<ProtectedTargetPattern>,
/**
* Per-container overrides keyed by contextId; only isolated containers appear. A navigation whose
* source contextId is a key uses the entry's mode + rules instead of the global ones (replace).
*/
val contextOverrides: Map<String, ContextAppLinkPolicy> = emptyMap(),
) {
companion object {
val SAFE_DEFAULT = AppLinkPolicy(
globalMode = AppLinkMode.ASK,
rules = emptyMap(),
marketplaceFallbackEnabled = false,
protectGeneralContext = false,
protectedContextIds = emptySet(),
strictContextIds = emptySet(),
protectedTargetPatterns = emptyList(),
contextOverrides = emptyMap(),
)
}
}
/** The prompt classes of §2.2. */
enum class AppLinkPromptKind {
/** http(s), non-modal — the page is allowed to load while the banner is up. */
BANNER,
/** Unsupported scheme, modal — the navigation is genuinely stalled and there is no page. */
MODAL,
}
/**
* A pure decision the interceptor executes. The classifier never performs side effects.
*/
sealed interface AppLinkDecision {
/** Return `null` from the interceptor — the engine proceeds normally. */
data object AllowEngine : AppLinkDecision
/** Deny the load and leave the current page unchanged. */
data object DenyKeepPage : AppLinkDecision
/** Return `InterceptionResponse.Url(url)` — a validated http(s) fallback. */
data class LoadFallback(val url: String) : AppLinkDecision
/**
* Automatic launch (global-`always` or a remembered `alwaysOpen` rule). The interceptor calls
* the launcher and maps its outcome per §2.7's launch-failure branches.
*/
data class AutoLaunch(val expectedPackage: String?) : AppLinkDecision
/**
* Create a pending prompt request. [kind] chooses banner vs modal; the page is allowed to load
* for a banner and denied (stalled) for a modal.
*/
data class Prompt(
val kind: AppLinkPromptKind,
val canRemember: Boolean,
val isMarketplace: Boolean,
) : AppLinkDecision
}
/** Everything the classifier needs, all computed by the caller so the classifier stays pure. */
data class ClassifierInput(
val resolved: ResolvedAppLink,
val isProtected: Boolean,
val isPrivate: Boolean,
val isWallet: Boolean,
val missingSession: Boolean,
val suppressionHit: Boolean,
val matchingRule: AppLinkRule?,
val globalMode: AppLinkMode,
val marketplaceFallbackEnabled: Boolean,
)
/**
* Pure §2.4 policy precedence over the §2.2 URL-class table. Structural guards (§2.4 step 1) and
* navigation eligibility (step 2) are handled by the interceptor before this is consulted.
*/
object AppLinkClassifier {
fun classify(input: ClassifierInput): AppLinkDecision {
val resolved = input.resolved
// Step 3 — no external app resolves.
if (!resolved.hasExternalApp) {
resolved.fallbackUrl?.let { return AppLinkDecision.LoadFallback(it) }
// Step 8 — marketplace, only when enabled, mode != never, and no validated fallback.
if (input.marketplaceFallbackEnabled &&
input.globalMode != AppLinkMode.NEVER &&
resolved.marketplaceIntent != null
) {
return AppLinkDecision.Prompt(
kind = AppLinkPromptKind.MODAL,
canRemember = false,
isMarketplace = true,
)
}
return if (resolved.engineSupportsScheme) {
AppLinkDecision.AllowEngine
} else {
AppLinkDecision.DenyKeepPage
}
}
// Step 4 — missing session cannot host a prompt: fall back to the safe non-launch behaviour.
if (input.missingSession) {
return safeNonLaunch(resolved)
}
// Step 4 — forced-prompt contexts (protected/private/wallet), ignoring matching rules.
if (input.isProtected || input.isPrivate || input.isWallet) {
return promptFor(resolved, canRemember = false)
}
// Step 5 — suppression hit: never launch, never prompt.
if (input.suppressionHit) {
return safeNonLaunch(resolved)
}
// Step 6 — a matching remembered rule for this scope.
input.matchingRule?.let { rule ->
when (rule.decision) {
AppLinkRuleDecision.ALWAYS_OPEN ->
return AppLinkDecision.AutoLaunch(expectedPackage = rule.packageName)
AppLinkRuleDecision.NEVER_OPEN ->
return neverBehaviour(resolved)
}
}
// Step 7 — global mode, applied uniformly (including Custom Tabs).
return when (input.globalMode) {
AppLinkMode.ALWAYS -> AppLinkDecision.AutoLaunch(expectedPackage = null)
AppLinkMode.ASK -> promptFor(resolved, canRemember = canRemember(resolved))
AppLinkMode.NEVER -> neverBehaviour(resolved)
}
}
/** The `never` row of §2.2: allow an engine-supported page; otherwise deny (+ validated fallback). */
private fun neverBehaviour(resolved: ResolvedAppLink): AppLinkDecision {
return if (resolved.engineSupportsScheme) {
AppLinkDecision.AllowEngine
} else {
resolved.fallbackUrl?.let { AppLinkDecision.LoadFallback(it) }
?: AppLinkDecision.DenyKeepPage
}
}
/** Suppression/missing-session: allow an engine-supported URL; else deny, using only a fallback. */
private fun safeNonLaunch(resolved: ResolvedAppLink): AppLinkDecision {
return if (resolved.engineSupportsScheme) {
AppLinkDecision.AllowEngine
} else {
resolved.fallbackUrl?.let { AppLinkDecision.LoadFallback(it) }
?: AppLinkDecision.DenyKeepPage
}
}
private fun promptFor(resolved: ResolvedAppLink, canRemember: Boolean): AppLinkDecision {
val kind = if (resolved.engineSupportsScheme) {
AppLinkPromptKind.BANNER
} else {
AppLinkPromptKind.MODAL
}
return AppLinkDecision.Prompt(kind = kind, canRemember = canRemember, isMarketplace = false)
}
/** Ambiguous resolution can never be remembered (§2.5). */
private fun canRemember(resolved: ResolvedAppLink): Boolean = !resolved.isAmbiguous
}
@@ -0,0 +1,100 @@
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
package eu.weblibre.flutter_mozilla_components.applinks
import java.net.IDN
import java.net.InetAddress
import java.util.Locale
/**
* Native-owned host normalisation (see APP_LINKS_OWN_IMPLEMENTATION_PLAN.md §2.5).
*
* The resolver returns the canonical scope key used by prompts and rules; Dart persists
* it opaquely and never reconstructs it. The same helper normalises hosts when matching
* `protectedTargetPatterns`.
*/
object AppLinkHostNormalizer {
const val HOST_SCOPE_PREFIX = "host:"
const val PACKAGE_SCOPE_PREFIX = "pkg:"
/**
* Canonicalise a host:
* - [Locale.ROOT] lowercase,
* - strip a single trailing dot,
* - `IDN.toASCII` for non-ASCII hosts,
* - reject empty/invalid hosts and IPv6 zone IDs,
* - canonicalise IP literals.
*
* @return the canonical host, or `null` if the host is empty or invalid.
*/
fun normalizeHost(rawHost: String?): String? {
if (rawHost.isNullOrEmpty()) return null
// Reject IPv6 zone identifiers (e.g. fe80::1%eth0) — the zone is host-local
// and must never participate in a cross-navigation scope key.
if (rawHost.contains('%')) return null
var host = rawHost.trim()
if (host.isEmpty()) return null
// Strip a single trailing dot (fully-qualified form).
if (host.endsWith(".")) {
host = host.dropLast(1)
}
if (host.isEmpty()) return null
// IPv6 literal in brackets: canonicalise the address inside.
if (host.startsWith("[") && host.endsWith("]")) {
val inner = host.substring(1, host.length - 1)
if (inner.contains('%')) return null
return canonicalizeIpLiteral(inner)?.let { "[$it]" } ?: return null
}
// Try to canonicalise as an IP literal first (IPv4 / bare IPv6).
canonicalizeIpLiteral(host)?.let { return it }
val lowered = host.lowercase(Locale.ROOT)
return try {
val ascii = IDN.toASCII(lowered, IDN.ALLOW_UNASSIGNED)
if (ascii.isEmpty()) null else ascii.lowercase(Locale.ROOT)
} catch (e: IllegalArgumentException) {
null
}
}
/**
* Canonicalise an IP literal (numeric address only). Returns `null` when [value] is not a
* numeric IP literal, so callers can fall through to hostname handling.
*/
private fun canonicalizeIpLiteral(value: String): String? {
if (value.isEmpty()) return null
// Only treat clearly-numeric forms as IP literals; a real hostname must go through IDN.
val looksNumeric = value.all { it.isDigit() || it == '.' } ||
(value.contains(':') && value.all { it.isDigit() || it == ':' || it in 'a'..'f' || it in 'A'..'F' })
if (!looksNumeric) return null
return try {
val address = InetAddress.getByName(value)
address.hostAddress?.lowercase(Locale.ROOT)
} catch (e: Exception) {
null
}
}
/** Build the canonical scope key for a host (`host:youtube.com`). */
fun hostScopeKey(rawHost: String?): String? {
val host = normalizeHost(rawHost) ?: return null
return HOST_SCOPE_PREFIX + host
}
/** Build the canonical scope key for a package (`pkg:us.zoom.videomeetings`). */
fun packageScopeKey(packageName: String?): String? {
if (packageName.isNullOrEmpty()) return null
return PACKAGE_SCOPE_PREFIX + packageName
}
}
@@ -0,0 +1,128 @@
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
package eu.weblibre.flutter_mozilla_components.applinks
import android.content.ActivityNotFoundException
import android.content.Intent
import mozilla.components.support.base.log.logger.Logger
/**
* Distinct launch modes, each with an exact flag set (§6):
* - [MANUAL]: user-driven "Open in <App>" preserves the `NEW_DOCUMENT | MULTIPLE_TASK` task
* behaviour so the app opens in its own recents entry.
* - [AUTOMATIC]: global-`always` or a remembered `alwaysOpen` rule `NEW_TASK`, subject to the
* 2 s same-package cooldown loop-breaker (§2.4).
* - [MARKETPLACE]: install-app fallback `NEW_TASK | CLEAR_TASK`.
*/
enum class AppLinkLaunchMode {
MANUAL,
AUTOMATIC,
MARKETPLACE,
}
enum class AppLinkLaunchResult {
LAUNCHED,
NO_APP,
COOLDOWN,
PACKAGE_MISMATCH,
FAILED,
}
/**
* Launches external apps. Every launch re-resolves immediately first (no cache) and verifies the
* expected package before `startActivity` (§2.7). Automatic launches honour a 2 s same-package
* cooldown to break appbrowserapp ping-pong loops (§2.4); manual and prompt-resolved opens are
* user gestures that bypass the check but still record it.
*/
class AppLinkLauncher(
private val resolver: ExternalAppResolver,
private val startActivity: (Intent) -> Unit,
private val clock: MonotonicClock = MonotonicClock.SYSTEM,
private val cooldownMs: Long = APP_LINKS_DO_NOT_INTERCEPT_INTERVAL,
) {
private val logger = Logger("AppLinkLauncher")
@Volatile
private var lastLaunch: Pair<String?, Long> = Pair(null, 0L)
/**
* Re-resolve [url] and launch it in the appropriate external app.
*
* @param expectedPackage when non-null (remembered/manual rebind paths), the freshly resolved
* package must equal it or the launch is refused with [AppLinkLaunchResult.PACKAGE_MISMATCH].
*/
@Synchronized
fun launch(
url: String,
mode: AppLinkLaunchMode,
expectedPackage: String? = null,
): AppLinkLaunchResult {
val resolved = resolver.resolve(url, includeHttpAppLinks = true, useCache = false)
val intent: Intent = when (mode) {
AppLinkLaunchMode.MARKETPLACE -> resolved.marketplaceIntent ?: return AppLinkLaunchResult.NO_APP
else -> {
if (!resolved.hasExternalApp || resolved.appIntent == null) {
return AppLinkLaunchResult.NO_APP
}
if (expectedPackage != null && resolved.packageName != expectedPackage) {
return AppLinkLaunchResult.PACKAGE_MISMATCH
}
resolved.appIntent
}
}
val targetPackage = when (mode) {
AppLinkLaunchMode.MARKETPLACE -> intent.`package`
else -> resolved.packageName
}
if (mode == AppLinkLaunchMode.AUTOMATIC) {
val (lastPackage, lastTs) = lastLaunch
if (lastPackage != null && lastPackage == targetPackage &&
clock.elapsedRealtime() < lastTs + cooldownMs
) {
return AppLinkLaunchResult.COOLDOWN
}
}
applyLaunchFlags(intent, mode)
return try {
startActivity(intent)
lastLaunch = Pair(targetPackage, clock.elapsedRealtime())
AppLinkLaunchResult.LAUNCHED
} catch (e: ActivityNotFoundException) {
logger.error("failed to start external app activity", e)
AppLinkLaunchResult.FAILED
} catch (e: SecurityException) {
logger.error("not permitted to start external app activity", e)
AppLinkLaunchResult.FAILED
}
}
private fun applyLaunchFlags(intent: Intent, mode: AppLinkLaunchMode) {
intent.flags = when (mode) {
// NEW_DOCUMENT | MULTIPLE_TASK gives the app its own recents entry; NEW_TASK is
// mandatory because every launch path now dispatches through the process-level
// application context (AppLinkRuntime), and startActivity() from a non-Activity
// context requires it.
AppLinkLaunchMode.MANUAL ->
Intent.FLAG_ACTIVITY_NEW_DOCUMENT or
Intent.FLAG_ACTIVITY_MULTIPLE_TASK or
Intent.FLAG_ACTIVITY_NEW_TASK
AppLinkLaunchMode.AUTOMATIC ->
Intent.FLAG_ACTIVITY_NEW_TASK
AppLinkLaunchMode.MARKETPLACE ->
Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
}
}
companion object {
const val APP_LINKS_DO_NOT_INTERCEPT_INTERVAL = 2000L
}
}
@@ -0,0 +1,53 @@
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
package eu.weblibre.flutter_mozilla_components.applinks
import eu.weblibre.flutter_mozilla_components.pigeons.AppLinkPolicySnapshot
import eu.weblibre.flutter_mozilla_components.pigeons.AppLinksMode as PigeonAppLinksMode
import eu.weblibre.flutter_mozilla_components.pigeons.NativeAppLinkRule
import eu.weblibre.flutter_mozilla_components.pigeons.NativeAppLinkRuleDecision
/** Map the replicated Pigeon snapshot to the Kotlin-native classifier policy (§2.8). */
fun AppLinkPolicySnapshot.toAppLinkPolicy(): AppLinkPolicy {
return AppLinkPolicy(
globalMode = globalMode.toAppLinkMode(),
rules = rules.mapValues { (_, rule) -> rule.toAppLinkRule() },
marketplaceFallbackEnabled = marketplaceFallbackEnabled,
protectGeneralContext = protectGeneralContext,
protectedContextIds = protectedContextIds.toSet(),
strictContextIds = strictContextIds.toSet(),
protectedTargetPatterns = protectedTargetPatterns.map { pattern ->
ProtectedTargetPattern(
scheme = pattern.scheme,
hostOrSuffix = pattern.hostOrSuffix,
includeSubdomains = pattern.includeSubdomains,
port = pattern.port?.toInt(),
)
},
contextOverrides = contextOverrides.mapValues { (_, override) ->
ContextAppLinkPolicy(
globalMode = override.mode.toAppLinkMode(),
rules = override.rules.mapValues { (_, rule) -> rule.toAppLinkRule() },
)
},
)
}
private fun PigeonAppLinksMode.toAppLinkMode(): AppLinkMode = when (this) {
PigeonAppLinksMode.ALWAYS -> AppLinkMode.ALWAYS
PigeonAppLinksMode.ASK -> AppLinkMode.ASK
PigeonAppLinksMode.NEVER -> AppLinkMode.NEVER
}
private fun NativeAppLinkRule.toAppLinkRule(): AppLinkRule = AppLinkRule(
decision = when (decision) {
NativeAppLinkRuleDecision.ALWAYS_OPEN -> AppLinkRuleDecision.ALWAYS_OPEN
NativeAppLinkRuleDecision.NEVER_OPEN -> AppLinkRuleDecision.NEVER_OPEN
},
scope = scope,
packageName = packageName,
)
@@ -0,0 +1,236 @@
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
package eu.weblibre.flutter_mozilla_components.applinks
import android.content.Context
import eu.weblibre.flutter_mozilla_components.ProfileContext
import mozilla.components.support.base.log.logger.Logger
import org.json.JSONArray
import org.json.JSONObject
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.atomic.AtomicReference
/**
* Process-level registry of profile-scoped [AppLinkPolicyStore] singletons
* (APP_LINKS_OWN_IMPLEMENTATION_PLAN.md §2.10). Keyed only by native's canonical
* [ProfileContext.relativePath]; created on first use, torn down on profile
* replacement. Survives `GlobalComponents.setUp()` replacing the `Components`.
*/
object AppLinkPolicyStores {
private val stores = ConcurrentHashMap<String, AppLinkPolicyStore>()
fun forProfile(profileContext: ProfileContext): AppLinkPolicyStore {
return stores.getOrPut(profileContext.relativePath) {
AppLinkPolicyStore(profileContext)
}
}
/** Remove a torn-down profile's store (profile replacement/deletion). */
fun remove(relativePath: String) {
stores.remove(relativePath)
}
}
/**
* The only policy source in `ComponentsMode.EXTERNAL` and before Flutter attaches.
* Holds the classifier [AppLinkPolicy] in an [AtomicReference] backed by a single
* profile-scoped SharedPreferences record. Writes persist synchronously
* (`commit()`) and publish the new reference only after durable success. There is
* exactly one writer (the Dart replicator via `setAppLinkPolicy`).
*/
class AppLinkPolicyStore internal constructor(
private val context: Context,
) {
private val logger = Logger("AppLinkPolicyStore")
private val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
private val reference = AtomicReference(loadOrSeed())
val policy: AppLinkPolicy
get() = reference.get()
/**
* Persist [policy] durably, then publish it. Serialised so concurrent writers
* cannot interleave a half-written record with a published reference.
*/
@Synchronized
fun setPolicy(policy: AppLinkPolicy): Boolean {
val json = encode(policy, migrated = true)
val committed = prefs.edit().putString(KEY_SNAPSHOT, json).commit()
if (!committed) {
logger.error("failed to persist app-link policy; keeping previous snapshot")
return false
}
reference.set(policy)
return true
}
private fun loadOrSeed(): AppLinkPolicy {
val stored = prefs.getString(KEY_SNAPSHOT, null)
if (stored != null) {
runCatching { return decode(stored) }
.onFailure { logger.error("corrupt app-link policy record; reseeding", it) }
}
// Seed the safe default (globalMode = ASK): the seed carries no protected-context data (that
// is computed in Dart and arrives only with the first replicated snapshot), so it must never
// auto-launch — an `ALWAYS` seed would leak links out of proxied/strict containers and cold
// Custom Tabs before protection is known. The legacy AC "open links in apps" preference is
// deliberately not migrated (a de-Googled browser resets to the safe ASK default; the user
// re-sets it in Settings), so the seed does not read it.
val seeded = AppLinkPolicy.SAFE_DEFAULT
val committed = prefs.edit().putString(KEY_SNAPSHOT, encode(seeded, migrated = true)).commit()
if (!committed) {
logger.error("failed to persist seeded app-link policy; using defaults in memory")
}
return seeded
}
private fun encode(policy: AppLinkPolicy, migrated: Boolean): String {
val root = JSONObject()
root.put(FIELD_MIGRATED, migrated)
root.put(FIELD_GLOBAL_MODE, policy.globalMode.name)
root.put(FIELD_MARKETPLACE, policy.marketplaceFallbackEnabled)
root.put(FIELD_PROTECT_GENERAL, policy.protectGeneralContext)
root.put(FIELD_PROTECTED_CONTEXTS, JSONArray(policy.protectedContextIds.toList()))
root.put(FIELD_STRICT_CONTEXTS, JSONArray(policy.strictContextIds.toList()))
root.put(FIELD_RULES, encodeRules(policy.rules))
val overrides = JSONObject()
for ((contextId, override) in policy.contextOverrides) {
overrides.put(
contextId,
JSONObject()
.put(FIELD_OVERRIDE_MODE, override.globalMode.name)
.put(FIELD_RULES, encodeRules(override.rules)),
)
}
root.put(FIELD_CONTEXT_OVERRIDES, overrides)
val patterns = JSONArray()
for (pattern in policy.protectedTargetPatterns) {
patterns.put(
JSONObject()
.put(FIELD_PATTERN_SCHEME, pattern.scheme)
.put(FIELD_PATTERN_HOST, pattern.hostOrSuffix)
.put(FIELD_PATTERN_SUBDOMAINS, pattern.includeSubdomains)
.putOpt(FIELD_PATTERN_PORT, pattern.port),
)
}
root.put(FIELD_PATTERNS, patterns)
return root.toString()
}
private fun encodeRules(rules: Map<String, AppLinkRule>): JSONObject {
val obj = JSONObject()
for ((scope, rule) in rules) {
obj.put(
scope,
JSONObject()
.put(FIELD_RULE_DECISION, rule.decision.name)
.put(FIELD_RULE_SCOPE, rule.scope)
.putOpt(FIELD_RULE_PACKAGE, rule.packageName),
)
}
return obj
}
private fun decode(json: String): AppLinkPolicy {
val root = JSONObject(json)
val rules = decodeRules(root.optJSONObject(FIELD_RULES))
val contextOverrides = mutableMapOf<String, ContextAppLinkPolicy>()
root.optJSONObject(FIELD_CONTEXT_OVERRIDES)?.let { obj ->
for (contextId in obj.keys()) {
val overrideJson = obj.getJSONObject(contextId)
contextOverrides[contextId] = ContextAppLinkPolicy(
globalMode = AppLinkMode.valueOf(overrideJson.getString(FIELD_OVERRIDE_MODE)),
rules = decodeRules(overrideJson.optJSONObject(FIELD_RULES)),
)
}
}
val patterns = mutableListOf<ProtectedTargetPattern>()
root.optJSONArray(FIELD_PATTERNS)?.let { arr ->
for (i in 0 until arr.length()) {
val p = arr.getJSONObject(i)
patterns.add(
ProtectedTargetPattern(
scheme = p.getString(FIELD_PATTERN_SCHEME),
hostOrSuffix = p.getString(FIELD_PATTERN_HOST),
includeSubdomains = p.getBoolean(FIELD_PATTERN_SUBDOMAINS),
port = if (p.has(FIELD_PATTERN_PORT) && !p.isNull(FIELD_PATTERN_PORT)) {
p.getInt(FIELD_PATTERN_PORT)
} else {
null
},
),
)
}
}
return AppLinkPolicy(
globalMode = AppLinkMode.valueOf(root.getString(FIELD_GLOBAL_MODE)),
rules = rules,
marketplaceFallbackEnabled = root.optBoolean(FIELD_MARKETPLACE, false),
protectGeneralContext = root.optBoolean(FIELD_PROTECT_GENERAL, false),
protectedContextIds = root.optJSONArray(FIELD_PROTECTED_CONTEXTS).toStringSet(),
strictContextIds = root.optJSONArray(FIELD_STRICT_CONTEXTS).toStringSet(),
protectedTargetPatterns = patterns,
contextOverrides = contextOverrides,
)
}
private fun decodeRules(obj: JSONObject?): Map<String, AppLinkRule> {
if (obj == null) return emptyMap()
val rules = mutableMapOf<String, AppLinkRule>()
for (scope in obj.keys()) {
val ruleJson = obj.getJSONObject(scope)
rules[scope] = AppLinkRule(
decision = AppLinkRuleDecision.valueOf(ruleJson.getString(FIELD_RULE_DECISION)),
scope = ruleJson.getString(FIELD_RULE_SCOPE),
packageName = ruleJson.optStringOrNull(FIELD_RULE_PACKAGE),
)
}
return rules
}
private fun JSONObject.optStringOrNull(key: String): String? =
if (has(key) && !isNull(key)) getString(key) else null
private fun JSONArray?.toStringSet(): Set<String> {
if (this == null) return emptySet()
val out = LinkedHashSet<String>(length())
for (i in 0 until length()) {
out.add(getString(i))
}
return out
}
companion object {
const val PREFS_NAME = "weblibre_app_link_policy"
private const val KEY_SNAPSHOT = "snapshot"
private const val FIELD_MIGRATED = "migrated"
private const val FIELD_GLOBAL_MODE = "globalMode"
private const val FIELD_MARKETPLACE = "marketplaceFallbackEnabled"
private const val FIELD_PROTECT_GENERAL = "protectGeneralContext"
private const val FIELD_PROTECTED_CONTEXTS = "protectedContextIds"
private const val FIELD_STRICT_CONTEXTS = "strictContextIds"
private const val FIELD_RULES = "rules"
private const val FIELD_RULE_DECISION = "decision"
private const val FIELD_RULE_SCOPE = "scope"
private const val FIELD_RULE_PACKAGE = "packageName"
private const val FIELD_CONTEXT_OVERRIDES = "contextOverrides"
private const val FIELD_OVERRIDE_MODE = "mode"
private const val FIELD_PATTERNS = "protectedTargetPatterns"
private const val FIELD_PATTERN_SCHEME = "scheme"
private const val FIELD_PATTERN_HOST = "hostOrSuffix"
private const val FIELD_PATTERN_SUBDOMAINS = "includeSubdomains"
private const val FIELD_PATTERN_PORT = "port"
}
}
@@ -0,0 +1,39 @@
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
package eu.weblibre.flutter_mozilla_components.applinks
import android.content.Context
/**
* Process-level holder for the shared [ExternalAppResolver] and [AppLinkLauncher]
* (APP_LINKS_OWN_IMPLEMENTATION_PLAN.md §2.7). Neither is profile-scoped they wrap the
* `PackageManager` and `startActivity`, both application-global.
*
* A single shared launcher is important: its 2 s same-package auto-launch cooldown
* (§2.4 loop breaker) must be observed across *every* launch path the synchronous
* interceptor tail ([WebLibreAppLinksInterceptor]), the manual "Open in <App>" entry points
* (`GeckoAppLinksApiImpl.launchAppLink`), and prompt resolution. If each site built its own
* launcher the cooldown would be per-instance and the ping-pong defence would break.
*/
object AppLinkRuntime {
@Volatile
private var holder: Holder? = null
fun get(context: Context): Holder {
return holder ?: synchronized(this) {
holder ?: Holder(context.applicationContext).also { holder = it }
}
}
class Holder(appContext: Context) {
val resolver: ExternalAppResolver = ExternalAppResolver(AndroidPackageResolver(appContext))
val launcher: AppLinkLauncher = AppLinkLauncher(
resolver = resolver,
startActivity = { intent -> appContext.startActivity(intent) },
)
}
}
@@ -0,0 +1,82 @@
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
package eu.weblibre.flutter_mozilla_components.applinks
import java.util.Locale
/**
* Frozen scheme classification tables for the WebLibre-owned app-links implementation
* (see APP_LINKS_OWN_IMPLEMENTATION_PLAN.md §2.2).
*
* These tables initially match Mozilla Android Components
* ([mozilla.components.feature.app.links.AppLinksUseCases] companion,
* [mozilla.components.feature.app.links.AppLinksInterceptor]). All comparisons are
* case-insensitive via [Locale.ROOT] lowercase AC lowercases only the denied set;
* making the engine-supported comparison case-insensitive too is a deliberate small
* correctness improvement. `JavaScript:` must be denied as surely as `javascript:`.
*
* These tables describe what Gecko can load, not what the user wants, and are consumed
* on the synchronous interception path they stay in Kotlin.
*/
object AppLinkSchemes {
// Schemes the Gecko engine can load itself.
// https://searchfox.org/firefox-main/source/netwerk/build/components.conf
val ENGINE_SUPPORTED: Set<String> = setOf(
"about",
"data",
"file",
"ftp",
"http",
"https",
"moz-extension",
"moz-safe-about",
"resource",
"view-source",
"ws",
"wss",
"blob",
)
// Schemes that must never be resolved or launched in a third-party app.
val ALWAYS_DENIED: Set<String> = setOf(
"jar",
"file",
"javascript",
"data",
"about",
"content",
"fido",
)
// Schemes allowed to open an external application from a subframe.
val SUBFRAME_ALLOWED: Set<String> = setOf(
"msteams",
)
// Wallet schemes — always prompt, never remembered (§2.4).
val WALLET: Set<String> = setOf(
"openid4vp",
"mdoc",
"mdoc-openid4vp",
"haip",
"eudi-wallet",
"eudi-openid4vp",
"openid-credential-offer",
)
private fun normalize(scheme: String?): String? = scheme?.lowercase(Locale.ROOT)
fun isEngineSupported(scheme: String?): Boolean = normalize(scheme) in ENGINE_SUPPORTED
fun isAlwaysDenied(scheme: String?): Boolean = normalize(scheme) in ALWAYS_DENIED
fun isSubframeAllowed(scheme: String?): Boolean = normalize(scheme) in SUBFRAME_ALLOWED
fun isWallet(scheme: String?): Boolean = normalize(scheme) in WALLET
fun isHttpOrHttps(scheme: String?): Boolean = normalize(scheme).let { it == "http" || it == "https" }
}
@@ -0,0 +1,344 @@
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
package eu.weblibre.flutter_mozilla_components.applinks
import android.content.ComponentName
import android.content.Intent
import android.content.pm.ResolveInfo
import android.net.Uri
import android.os.Build
import android.provider.Browser.EXTRA_APPLICATION_ID
import androidx.core.net.toUri
import mozilla.components.support.base.log.logger.Logger
import java.net.URISyntaxException
import java.util.Locale
private const val EXTRA_BROWSER_FALLBACK_URL = "browser_fallback_url"
private const val MARKET_INTENT_URI_PACKAGE_PREFIX = "market://details?id="
private const val ANDROID_RESOLVER_PACKAGE_NAME = "android"
private const val APP_LABEL_MAX_LENGTH = 64
private val PLAY_STORE_URL_REGEX = Regex("https?://play\\.google\\.com/store/.*")
/**
* Immutable result of resolving a URL against installed apps
* (APP_LINKS_OWN_IMPLEMENTATION_PLAN.md §2.7). Holds a sanitised, launchable [appIntent]
* (trusted component set), never a page-controlled one.
*/
data class ResolvedAppLink(
val hasExternalApp: Boolean,
val appIntent: Intent?,
val packageName: String?,
val appName: String?,
val fallbackUrl: String?,
val marketplaceIntent: Intent?,
val isAmbiguous: Boolean,
val engineSupportsScheme: Boolean,
val scopeKey: String,
val originalScheme: String?,
val intentDataScheme: String?,
)
/**
* Resolves URLs to external apps, preserving every security-critical behaviour of
* `AppLinksUseCases.createBrowsableIntents` and adding the §2.7 field allowlist. The launched
* intent is rebuilt from a strict allowlist: `ACTION_VIEW`, `CATEGORY_BROWSABLE`, the data URI,
* and a documented compatibility extra every page-supplied component, selector, bounds,
* identifier, clip/grant state, incoming flag, and browser-fallback metadata is cleared.
*
* A ~30 s resolution cache (AC's `APP_LINKS_CACHE_INTERVAL`) serves the synchronous classify path
* and the "show the button?" queries. There is no package-broadcast invalidator: the mandatory
* pre-launch re-resolution in [AppLinkLauncher] is the correctness guard.
*/
class ExternalAppResolver(
private val packages: PackageResolver,
private val clock: MonotonicClock = MonotonicClock.SYSTEM,
private val cacheTtlMs: Long = APP_LINKS_CACHE_INTERVAL,
) {
private val logger = Logger("ExternalAppResolver")
private data class CacheEntry(val timestamp: Long, val key: Int, val value: ResolvedAppLink)
@Volatile
private var cache: CacheEntry? = null
/**
* Resolve [url] against installed apps.
*
* @param includeHttpAppLinks when `false`, an app resolving an engine-supported (http(s)) URL
* is not treated as an external app the engine keeps the load. Manual "Open in app" callers
* pass `true` so a YouTube link surfaces the YouTube app.
* @param useCache consult/populate the short-lived resolution cache. Launch paths pass `false`
* so they always re-resolve immediately before `startActivity`.
*/
fun resolve(
url: String,
includeHttpAppLinks: Boolean,
useCache: Boolean = true,
): ResolvedAppLink {
val key = (url + "|" + includeHttpAppLinks).hashCode()
val now = clock.elapsedRealtime()
if (useCache) {
cache?.let { entry ->
if (entry.key == key && now <= entry.timestamp + cacheTtlMs) {
return entry.value
}
}
}
val result = resolveUncached(url, includeHttpAppLinks)
if (useCache) {
cache = CacheEntry(now, key, result)
}
return result
}
fun clearCache() {
cache = null
}
private fun resolveUncached(url: String, includeHttpAppLinks: Boolean): ResolvedAppLink {
val originalScheme = try {
url.toUri().scheme?.lowercase(Locale.ROOT)
} catch (e: Exception) {
null
}
val engineSupported = AppLinkSchemes.isEngineSupported(originalScheme)
val hostScope = AppLinkHostNormalizer.hostScopeKey(runCatching { url.toUri().host }.getOrNull())
fun empty(scope: String, intentDataScheme: String? = null) = ResolvedAppLink(
hasExternalApp = false,
appIntent = null,
packageName = null,
appName = null,
fallbackUrl = null,
marketplaceIntent = null,
isAmbiguous = false,
engineSupportsScheme = engineSupported,
scopeKey = scope,
originalScheme = originalScheme,
intentDataScheme = intentDataScheme,
)
// Always-denied schemes never resolve or launch externally (§2.2). Return early so no
// fallback or marketplace intent is extracted from them.
if (AppLinkSchemes.isAlwaysDenied(originalScheme)) {
return empty(hostScope ?: "")
}
val parsed = safeParseUri(url) ?: return empty(hostScope ?: "")
val dataScheme = parsed.data?.scheme?.lowercase(Locale.ROOT)
// Reject a sanitised intent whose data scheme is itself always-denied.
if (parsed.data == null || AppLinkSchemes.isAlwaysDenied(dataScheme)) {
return empty(hostScope ?: "", dataScheme)
}
val requestedPackage = parsed.`package`
val appIntent = buildLaunchIntent(parsed)
val pageFallback = parsed.getStringExtra(EXTRA_BROWSER_FALLBACK_URL)
// Resolve the external-app handler. A browser default for an http(s) link is not itself an
// "open in app" target — as with no default or the Android chooser sentinel — so look past
// it for a non-browser handler (e.g. the YouTube app for a youtube.com link the default
// browser also handles). Browsers are excluded only for engine-supported (http) schemes.
var isAmbiguous = false
var resolvedPackage: String? = null
var resolvedActivityName: String? = null
var resolvedInfo: ResolveInfo? = null
val defaultInfo = packages.resolveDefaultActivity(appIntent)
val defaultPackage = defaultInfo?.activityInfo?.packageName
val defaultIsUsableApp = defaultPackage != null &&
defaultPackage != packages.selfPackageName &&
defaultPackage != ANDROID_RESOLVER_PACKAGE_NAME &&
!(engineSupported && packages.isInstalledBrowser(defaultPackage))
when {
defaultIsUsableApp -> {
resolvedPackage = defaultPackage
resolvedActivityName = defaultInfo?.activityInfo?.name
resolvedInfo = defaultInfo
}
// A page must not relaunch WebLibre through the app-link path: if WebLibre itself is the
// default handler, keep the load in-browser rather than hunting for other apps.
defaultPackage == packages.selfPackageName -> {
resolvedPackage = null
}
// No usable default (none / chooser / a browser for an http link): pick a non-browser
// handler. A single one launches directly (rememberable); several stay ambiguous (chooser).
else -> {
val candidates = packages.queryActivities(appIntent).filter { info ->
val pkg = info.activityInfo?.packageName
info.filter != null &&
pkg != null &&
pkg != packages.selfPackageName &&
!(engineSupported && packages.isInstalledBrowser(pkg))
}
candidates.firstOrNull()?.let { chosen ->
resolvedPackage = chosen.activityInfo?.packageName
resolvedActivityName = chosen.activityInfo?.name
resolvedInfo = chosen
isAmbiguous = candidates.size > 1
}
}
}
// hasExternalApp mirrors AC's appIntent decision, minus the launchInApp() policy gate
// (policy lives in the classifier). A resolved package is never a browser for an http link
// (excluded above), so the only remaining http gate is includeHttpAppLinks.
val hasExternalApp = when {
resolvedPackage == null -> false
// http(s) app links only count when the caller asks for them.
engineSupported && !includeHttpAppLinks -> false
else -> true
}
// Bind the trusted, resolved component (never a page-supplied one).
if (hasExternalApp && resolvedPackage != null && resolvedActivityName != null && !isAmbiguous) {
appIntent.component = ComponentName(resolvedPackage, resolvedActivityName)
}
val appName = if (hasExternalApp && resolvedInfo != null) {
sanitizeAppLabel(packages.applicationLabel(resolvedInfo))
} else {
null
}
// Fallback: accepted only if http(s), the original scheme is not engine-supported, and it is
// not a Play Store URL for an already-installed app.
val fallbackUrl = pageFallback?.let { validateFallback(it, engineSupported, appInstalled = resolvedPackage != null) }
// Marketplace intent: only when the target package is not installed.
val marketplaceIntent = requestedPackage
?.takeIf { !packages.isPackageInstalled(it) }
?.let { safeParseRawUri(MARKET_INTENT_URI_PACKAGE_PREFIX + it) }
?.apply { flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK }
// Scope key: host for engine-supported (http) links; resolved package otherwise (§2.5).
val scopeKey = when {
engineSupported && hostScope != null -> hostScope
resolvedPackage != null -> AppLinkHostNormalizer.packageScopeKey(resolvedPackage) ?: (hostScope ?: "")
else -> hostScope ?: ""
}
return ResolvedAppLink(
hasExternalApp = hasExternalApp,
appIntent = if (hasExternalApp) appIntent else null,
packageName = if (hasExternalApp) resolvedPackage else null,
appName = appName,
fallbackUrl = fallbackUrl,
marketplaceIntent = marketplaceIntent,
isAmbiguous = isAmbiguous,
engineSupportsScheme = engineSupported,
scopeKey = scopeKey,
originalScheme = originalScheme,
intentDataScheme = dataScheme,
)
}
/** Parse an `intent:`/URL into an Intent, rejecting self-package targets. */
private fun safeParseUri(url: String): Intent? {
val intent = safeParseRawUri(url, Intent.URI_INTENT_SCHEME) ?: return null
return if (intent.`package` == packages.selfPackageName) {
// Ignore intents that would relaunch WebLibre.
null
} else {
intent
}
}
private fun safeParseRawUri(uri: String, flags: Int = 0): Intent? {
return try {
Intent.parseUri(uri, flags)
} catch (e: URISyntaxException) {
logger.error("failed to parse URI", e)
null
} catch (e: NumberFormatException) {
// Intent.parseUri may throw NumberFormatException on malformed numeric extras.
logger.error("failed to parse URI", e)
null
}
}
/**
* Rebuild [source] into a sanitised, launchable intent using a field allowlist (§2.7):
* force ACTION_VIEW; add CATEGORY_BROWSABLE; retain only the data URI and documented
* compatibility extras; clear every page-supplied structural field and all incoming flags.
*/
private fun buildLaunchIntent(source: Intent): Intent {
val sanitized = Intent(Intent.ACTION_VIEW)
source.data?.let { sanitized.data = it }
sanitized.addCategory(Intent.CATEGORY_BROWSABLE)
// Preserve an explicit `intent:...;package=` target: it is a package-id constraint (not a
// component, which could point at a non-exported activity), so resolution/launch targets the
// app the link actually names instead of some other handler or WebLibre itself. `safeParseUri`
// already rejected a self-package target. This mirrors AC's createBrowsableIntents.
source.`package`?.let { pkg ->
if (pkg != packages.selfPackageName) sanitized.`package` = pkg
}
// Explicitly clear every structural field a page could weaponise.
sanitized.component = null
sanitized.selector = null
sanitized.sourceBounds = null
sanitized.clipData = null
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
sanitized.identifier = null
}
// flags = FLAG_ACTIVITY_NEW_TASK — assignment, not `or`. Clears page-supplied flags such as
// FLAG_GRANT_READ_URI_PERMISSION.
sanitized.flags = Intent.FLAG_ACTIVITY_NEW_TASK
// Documented compatibility extra only. EXTRA_BROWSER_FALLBACK_URL is deliberately not copied
// onto the launched intent (it is extracted separately for the interceptor).
sanitized.putExtra(EXTRA_APPLICATION_ID, packages.selfPackageName)
return sanitized
}
private fun validateFallback(
rawFallback: String,
originalSchemeEngineSupported: Boolean,
appInstalled: Boolean,
): String? {
val scheme = try {
Uri.parse(rawFallback).scheme?.lowercase(Locale.ROOT)
} catch (e: Exception) {
return null
}
if (!AppLinkSchemes.isHttpOrHttps(scheme)) return null
if (originalSchemeEngineSupported) return null
val isPlayStoreUrlForInstalledApp = PLAY_STORE_URL_REGEX.matches(rawFallback) && appInstalled
if (isPlayStoreUrlForInstalledApp) return null
return rawFallback
}
/** App labels are app-controlled: strip control/bidi characters and length-bound. */
private fun sanitizeAppLabel(label: String?): String? {
if (label.isNullOrEmpty()) return null
val cleaned = buildString {
for (ch in label) {
val type = Character.getType(ch)
if (type == Character.CONTROL.toInt() || type == Character.FORMAT.toInt()) {
continue
}
append(ch)
}
}.trim()
if (cleaned.isEmpty()) return null
return if (cleaned.length > APP_LABEL_MAX_LENGTH) {
cleaned.substring(0, APP_LABEL_MAX_LENGTH)
} else {
cleaned
}
}
companion object {
const val APP_LINKS_CACHE_INTERVAL = 30 * 1000L
}
}
@@ -0,0 +1,22 @@
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
package eu.weblibre.flutter_mozilla_components.applinks
import android.os.SystemClock
/**
* Injectable monotonic clock. All app-links timing (resolution cache TTL, launch cooldown,
* pending-request expiry, suppression timeout) reads from this seam so tests can advance
* time deterministically.
*/
fun interface MonotonicClock {
fun elapsedRealtime(): Long
companion object {
val SYSTEM = MonotonicClock { SystemClock.elapsedRealtime() }
}
}
@@ -0,0 +1,147 @@
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
package eu.weblibre.flutter_mozilla_components.applinks
import android.content.Context
import android.os.Handler
import android.os.Looper
import androidx.appcompat.app.AlertDialog
import eu.weblibre.flutter_mozilla_components.R
import eu.weblibre.flutter_mozilla_components.pigeons.AppLinkPromptOwner
import mozilla.components.feature.session.SessionUseCases
import mozilla.components.support.base.feature.LifecycleAwareFeature
import java.util.concurrent.ConcurrentHashMap
/**
* Process-level registry of the *started* [NativeAppLinkPromptFeature] instances, keyed by tabId.
* The [WebLibreAppLinksInterceptor] runs on an engine thread and creates prompt requests
* asynchronously; a Custom Tab feature only queries the store at lifecycle start, so without this a
* request created after start would sit unshown (its navigation already denied) until a rotation or
* restart. The interceptor pings [notifyPromptAvailable] so the feature re-queries immediately.
*/
object NativeAppLinkPromptNotifier {
private val features = ConcurrentHashMap<String, NativeAppLinkPromptFeature>()
fun register(tabId: String, feature: NativeAppLinkPromptFeature) {
features[tabId] = feature
}
fun unregister(tabId: String, feature: NativeAppLinkPromptFeature) {
features.remove(tabId, feature)
}
fun notifyPromptAvailable(tabId: String) {
features[tabId]?.onPromptAvailable()
}
}
/**
* Presents the minimal native app-link prompt for Custom Tab sessions that have no
* Flutter engine (APP_LINKS_OWN_IMPLEMENTATION_PLAN.md §2.6). Title, message,
* open/cancel **no remember checkbox**, so native never creates policy.
*
* Queries [PendingAppLinkStore] for its own tab on start (and re-queries after each
* resolution); a request that is rotated/backgrounded away stays pending and is
* re-presented on the next start. Owner is fixed to [AppLinkPromptOwner.NATIVE_EXTERNAL].
*/
class NativeAppLinkPromptFeature(
private val context: Context,
private val tabId: String,
private val store: PendingAppLinkStore,
private val launcher: AppLinkLauncher,
private val sessionUseCases: SessionUseCases,
) : LifecycleAwareFeature {
private var dialog: AlertDialog? = null
private val mainHandler = Handler(Looper.getMainLooper())
override fun start() {
NativeAppLinkPromptNotifier.register(tabId, this)
showNext()
}
override fun stop() {
NativeAppLinkPromptNotifier.unregister(tabId, this)
// Dismissing on stop is not a user dismissal: the request stays pending and
// is re-presented on the next start().
dialog?.setOnDismissListener(null)
dialog?.dismiss()
dialog = null
}
/**
* A new pending request may have been created for this tab (interceptor, engine thread) after
* [start] already queried. Re-check on the main thread; [showNext] is idempotent (a no-op while a
* dialog is up or when nothing pends).
*/
fun onPromptAvailable() {
mainHandler.post { showNext() }
}
private fun showNext() {
if (dialog != null) return
val request = store.getPending(AppLinkPromptOwner.NATIVE_EXTERNAL)
.firstOrNull { it.tabId == tabId }
?: return
val title = request.appName?.let {
context.getString(R.string.weblibre_app_link_prompt_title_named, it)
} ?: context.getString(R.string.weblibre_app_link_prompt_title_generic)
dialog = AlertDialog.Builder(context)
.setTitle(title)
.setMessage(context.getString(R.string.weblibre_app_link_prompt_message))
.setPositiveButton(R.string.weblibre_app_link_prompt_open) { _, _ ->
resolveOpen(request)
}
.setNegativeButton(R.string.weblibre_app_link_prompt_cancel) { _, _ ->
resolveCancel(request)
}
.setOnCancelListener {
// Back / touch-outside is an explicit passive dismissal (§2.6).
resolveCancel(request)
}
.setOnDismissListener { dialog = null }
.show()
}
private fun resolveOpen(request: PendingAppLinkRequest) {
val consumed = store.consume(request.requestId) ?: return afterResolve()
val mode = if (consumed.isMarketplace) {
AppLinkLaunchMode.MARKETPLACE
} else {
AppLinkLaunchMode.MANUAL
}
// Fresh prompt-open: no remembered package binding to enforce (§2.5); the
// launcher's pre-launch re-resolution still validates the handler.
// Honour the package captured when the prompt was created for a *named*
// (non-ambiguous) target, so a change in handlers before the user taps Open
// can't launch a different app (§2.5/§2.7). Ambiguous/chooser prompts store a
// null expectedPackage, so this stays null and the chooser still opens.
val result = launcher.launch(consumed.url, mode, expectedPackage = consumed.expectedPackage)
if (result != AppLinkLaunchResult.LAUNCHED) {
consumed.fallbackUrl?.let { fallback ->
// Guard the fallback load against immediately bouncing back out to an
// app (§2.7): a validated fallback can itself resolve externally.
store.recordFallbackReentry(fallback)
sessionUseCases.loadUrl(url = fallback, sessionId = consumed.tabId)
}
}
afterResolve()
}
private fun resolveCancel(request: PendingAppLinkRequest) {
val consumed = store.consume(request.requestId) ?: return afterResolve()
store.recordSuppression(consumed.tabId, consumed.targetFingerprint)
afterResolve()
}
private fun afterResolve() {
dialog = null
showNext()
}
}
@@ -0,0 +1,92 @@
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
package eu.weblibre.flutter_mozilla_components.applinks
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.content.pm.ResolveInfo
import mozilla.components.support.base.log.logger.Logger
import mozilla.components.support.ktx.android.content.pm.isPackageInstalled
import mozilla.components.support.utils.BrowsersCache
import mozilla.components.support.utils.ext.packageManagerCompatHelper
/**
* Seam over [PackageManager] and browser detection so the resolver can be unit-tested
* (see APP_LINKS_OWN_IMPLEMENTATION_PLAN.md §2.7, Phase 1). Intent construction and
* sanitisation are still exercised under Robolectric because plain JVM stubs never
* populate the fields the resolver strips.
*/
interface PackageResolver {
val selfPackageName: String
/** May throw [RuntimeException] internally on large result sets; returns empty on failure. */
fun queryActivities(intent: Intent): List<ResolveInfo>
/** The default activity for [intent], honouring `MATCH_DEFAULT_ONLY`. */
fun resolveDefaultActivity(intent: Intent): ResolveInfo?
fun isPackageInstalled(packageName: String): Boolean
/** True when [packageName] is an installed browser (excluded for engine-supported schemes). */
fun isInstalledBrowser(packageName: String): Boolean
fun applicationLabel(resolveInfo: ResolveInfo): String?
}
class AndroidPackageResolver(private val context: Context) : PackageResolver {
private val logger = Logger("AppLinkPackageResolver")
override val selfPackageName: String
get() = context.packageName
@Suppress("QueryPermissionsNeeded", "TooGenericExceptionCaught")
override fun queryActivities(intent: Intent): List<ResolveInfo> {
return try {
context.packageManagerCompatHelper.queryIntentActivitiesCompat(
intent,
PackageManager.GET_RESOLVED_FILTER,
)
} catch (e: RuntimeException) {
// queryIntentActivities throws on very large result sets — treat as "nothing".
logger.error("failed to query activities", e)
emptyList()
}
}
@Suppress("TooGenericExceptionCaught")
override fun resolveDefaultActivity(intent: Intent): ResolveInfo? {
return try {
context.packageManagerCompatHelper.resolveActivityCompat(
intent,
PackageManager.MATCH_DEFAULT_ONLY,
)
} catch (e: RuntimeException) {
logger.error("failed to resolve default activity", e)
null
}
}
override fun isPackageInstalled(packageName: String): Boolean {
return context.packageManagerCompatHelper.isPackageInstalled(packageName)
}
override fun isInstalledBrowser(packageName: String): Boolean {
return BrowsersCache.all(context).isInstalled(packageName)
}
@Suppress("TooGenericExceptionCaught")
override fun applicationLabel(resolveInfo: ResolveInfo): String? {
return try {
val appInfo = resolveInfo.activityInfo?.applicationInfo ?: return null
context.packageManager.getApplicationLabel(appInfo).toString()
} catch (e: Exception) {
logger.error("failed to read application label", e)
null
}
}
}
@@ -0,0 +1,342 @@
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
package eu.weblibre.flutter_mozilla_components.applinks
import eu.weblibre.flutter_mozilla_components.pigeons.AppLinkPromptOwner
import eu.weblibre.flutter_mozilla_components.pigeons.AppLinkPromptRequest
import eu.weblibre.flutter_mozilla_components.pigeons.AppLinkTarget
import mozilla.components.support.base.log.logger.Logger
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.atomic.AtomicLong
/** The §2.2 URL class a pending request belongs to; part of the dedupe key. */
enum class AppLinkUrlClass {
BANNER,
MODAL,
MARKETPLACE,
}
/**
* A pending prompt, stored until resolved/invalidated/expired (§2.6). Holds only
* stable identifiers and sanitised data never a Components/EngineSession/store
* reference. Carries everything needed both to render the prompt and to perform
* the resolution side effect (re-resolve + launch, or load a validated fallback).
*/
data class PendingAppLinkRequest(
val requestId: Long,
val owner: AppLinkPromptOwner,
val tabId: String,
val contextId: String?,
val sourceUrl: String?,
val isPrivate: Boolean,
val isWallet: Boolean,
val isProtectedContext: Boolean,
val canRemember: Boolean,
val isModal: Boolean,
val urlClass: AppLinkUrlClass,
// Resolution data:
val url: String,
val expectedPackage: String?,
val fallbackUrl: String?,
val engineSupportsScheme: Boolean,
val isMarketplace: Boolean,
// Full sanitised-target fingerprint (URL + intent payload), the dedupe/invalidation key.
val targetFingerprint: String,
val appName: String?,
val packageName: String?,
val scopeKey: String,
val createdAt: Long,
) {
fun toPigeon(): AppLinkPromptRequest = AppLinkPromptRequest(
requestId = requestId,
owner = owner,
tabId = tabId,
contextId = contextId,
sourceUrl = sourceUrl,
isPrivate = isPrivate,
isWallet = isWallet,
isProtectedContext = isProtectedContext,
canRemember = canRemember,
isModal = isModal,
target = AppLinkTarget(
url = url,
appName = appName,
packageName = packageName,
fallbackUrl = fallbackUrl,
isMarketplace = isMarketplace,
isAmbiguous = !canRemember,
engineSupportsScheme = engineSupportsScheme,
scopeKey = scopeKey,
),
)
}
/** Everything needed to create a request; the store assigns the id and timestamp. */
data class NewAppLinkRequest(
val owner: AppLinkPromptOwner,
val tabId: String,
val contextId: String?,
val sourceUrl: String?,
val isPrivate: Boolean,
val isWallet: Boolean,
val isProtectedContext: Boolean,
val canRemember: Boolean,
val isModal: Boolean,
val urlClass: AppLinkUrlClass,
val url: String,
val expectedPackage: String?,
val fallbackUrl: String?,
val engineSupportsScheme: Boolean,
val isMarketplace: Boolean,
val targetFingerprint: String,
val appName: String?,
val packageName: String?,
val scopeKey: String,
/** A user-gesture attempt is never deduped into an older request (§2.6). */
val isUserGesture: Boolean = false,
)
/**
* Process-level registry of profile-scoped [PendingAppLinkStore] singletons (§2.10).
* Keyed by native's canonical profile relative path; survives `GlobalComponents.setUp()`.
*/
object PendingAppLinkStores {
private val stores = ConcurrentHashMap<String, PendingAppLinkStore>()
fun forProfile(relativePath: String): PendingAppLinkStore =
stores.getOrPut(relativePath) { PendingAppLinkStore() }
fun remove(relativePath: String) {
stores.remove(relativePath)
}
}
/**
* Holds pending prompts, dedupe, suppression, and the fallback re-entry map (§2.6).
* Query + consume: requests stay until resolved, invalidated, or expired. The store
* never holds its lock across a side effect [consume] returns the request and the
* caller performs launch/fallback after the lock is released.
*/
class PendingAppLinkStore(
private val clock: MonotonicClock = MonotonicClock.SYSTEM,
private val requestExpiryMs: Long = REQUEST_EXPIRY_MS,
private val suppressionExpiryMs: Long = SUPPRESSION_EXPIRY_MS,
private val dedupeWindowMs: Long = DEDUPE_WINDOW_MS,
private val fallbackReentryMs: Long = FALLBACK_REENTRY_MS,
) {
private val logger = Logger("PendingAppLinkStore")
private val lock = Any()
private val idGenerator = AtomicLong(0L)
private val requests = LinkedHashMap<Long, PendingAppLinkRequest>()
private val suppression = HashMap<String, Long>()
private val fallbackReentry = HashMap<String, Long>()
private fun suppressionKey(tabId: String, fingerprint: String) = "$tabId\u0000$fingerprint"
/**
* Create a request, collapsing a matching non-user-gesture request that arrived
* within the dedupe window into the existing one (§2.6).
*/
fun createRequest(input: NewAppLinkRequest): PendingAppLinkRequest {
synchronized(lock) {
sweepExpiredLocked()
if (!input.isUserGesture) {
val existing = requests.values.firstOrNull { candidate ->
candidate.tabId == input.tabId &&
candidate.targetFingerprint == input.targetFingerprint &&
candidate.owner == input.owner &&
candidate.urlClass == input.urlClass &&
clock.elapsedRealtime() <= candidate.createdAt + dedupeWindowMs
}
if (existing != null) return existing
}
val request = PendingAppLinkRequest(
requestId = idGenerator.incrementAndGet(),
owner = input.owner,
tabId = input.tabId,
contextId = input.contextId,
sourceUrl = input.sourceUrl,
isPrivate = input.isPrivate,
isWallet = input.isWallet,
isProtectedContext = input.isProtectedContext,
canRemember = input.canRemember,
isModal = input.isModal,
urlClass = input.urlClass,
url = input.url,
expectedPackage = input.expectedPackage,
fallbackUrl = input.fallbackUrl,
engineSupportsScheme = input.engineSupportsScheme,
isMarketplace = input.isMarketplace,
targetFingerprint = input.targetFingerprint,
appName = input.appName,
packageName = input.packageName,
scopeKey = input.scopeKey,
createdAt = clock.elapsedRealtime(),
)
requests[request.requestId] = request
return request
}
}
/** Non-consuming query of live requests for [owner]. */
fun getPending(owner: AppLinkPromptOwner): List<PendingAppLinkRequest> {
synchronized(lock) {
sweepExpiredLocked()
return requests.values.filter { it.owner == owner }.toList()
}
}
/** Atomically remove and return a request; null if already resolved/expired. */
fun consume(requestId: Long): PendingAppLinkRequest? {
synchronized(lock) {
sweepExpiredLocked()
return requests.remove(requestId)
}
}
fun peek(requestId: Long): PendingAppLinkRequest? {
synchronized(lock) {
sweepExpiredLocked()
return requests[requestId]
}
}
fun invalidate(requestId: Long) {
synchronized(lock) { requests.remove(requestId) }
}
/** Invalidate every pending request for a tab (tab close / replacement). */
fun invalidateTab(tabId: String) {
synchronized(lock) {
requests.values.removeAll { it.tabId == tabId }
suppression.keys.removeAll { it.startsWith("$tabId\u0000") }
}
}
/**
* A committed top-level navigation in [tabId]. A request whose own page committed
* stays alive (that commit is the page the prompt sits on); a commit to a
* *different site* invalidates the tab's pending requests (§2.6).
*
* Matching is by **normalised host**, not exact URL: the initial load a banner
* rides on almost always commits at a redirected/normalised URL (`www`, trailing
* slash, tracking params) that never equals the intercepted URL, so an exact-URL
* check would invalidate every banner on its own page load. The anchor is the
* target host for a banner (the page it loads) and the source host for a modal
* (the page it is shown over, since the modal's own navigation was denied). When
* no host can be derived, the request is kept and left to expiry/tab-close.
*/
fun onCommittedNavigation(tabId: String, committedUrl: String) {
val committedHost = siteKey(committedUrl)
synchronized(lock) {
val removed = mutableListOf<Long>()
requests.values.removeAll { request ->
if (request.tabId != tabId) return@removeAll false
val anchorHost = siteKey(if (request.isModal) request.sourceUrl else request.url)
val invalidate = anchorHost != null && committedHost != null && anchorHost != committedHost
if (invalidate) removed.add(request.requestId)
invalidate
}
if (removed.isNotEmpty()) {
logger.info(
"onCommittedNavigation tab=$tabId committedHost=$committedHost invalidated=$removed",
)
}
}
}
/** Normalised, subdomain-stripped host for same-site comparison; null if underivable. */
private fun siteKey(url: String?): String? {
val rawHost = extractHost(url) ?: return null
val normalized = AppLinkHostNormalizer.normalizeHost(rawHost) ?: return null
return stripCommonSubDomains(normalized)
}
private fun extractHost(url: String?): String? {
if (url.isNullOrEmpty()) return null
val schemeSep = url.indexOf("://")
if (schemeSep < 0) return null
val afterScheme = url.substring(schemeSep + 3)
val end = afterScheme.indexOfFirst { it == '/' || it == '?' || it == '#' }
var authority = if (end >= 0) afterScheme.substring(0, end) else afterScheme
val at = authority.lastIndexOf('@')
if (at >= 0) authority = authority.substring(at + 1)
// Preserve a bracketed IPv6 literal; AppLinkHostNormalizer canonicalises it.
if (authority.startsWith("[")) {
val close = authority.indexOf(']')
return if (close >= 0) authority.substring(0, close + 1) else null
}
val colon = authority.lastIndexOf(':')
if (colon >= 0) authority = authority.substring(0, colon)
return authority.ifEmpty { null }
}
private fun stripCommonSubDomains(host: String): String = when {
host.startsWith("www.") -> host.removePrefix("www.")
host.startsWith("m.") -> host.removePrefix("m.")
host.startsWith("mobile.") -> host.removePrefix("mobile.")
host.startsWith("maps.") -> host.removePrefix("maps.")
else -> host
}
// ---- Suppression (§2.6) ----
fun recordSuppression(tabId: String, fingerprint: String) {
synchronized(lock) {
suppression[suppressionKey(tabId, fingerprint)] =
clock.elapsedRealtime() + suppressionExpiryMs
}
}
fun isSuppressed(tabId: String, fingerprint: String): Boolean {
synchronized(lock) {
sweepExpiredLocked()
val expiresAt = suppression[suppressionKey(tabId, fingerprint)] ?: return false
return clock.elapsedRealtime() <= expiresAt
}
}
/** Clear a tab's suppression on a new user-initiated/direct navigation (§2.6). */
fun clearSuppressionForTab(tabId: String) {
synchronized(lock) {
suppression.keys.removeAll { it.startsWith("$tabId\u0000") }
}
}
// ---- Fallback re-entry map (§2.7) ----
fun recordFallbackReentry(canonicalUrl: String) {
synchronized(lock) {
fallbackReentry[canonicalUrl] = clock.elapsedRealtime() + fallbackReentryMs
}
}
fun isFallbackReentry(canonicalUrl: String): Boolean {
synchronized(lock) {
sweepExpiredLocked()
val expiresAt = fallbackReentry[canonicalUrl] ?: return false
return clock.elapsedRealtime() <= expiresAt
}
}
private fun sweepExpiredLocked() {
val now = clock.elapsedRealtime()
requests.values.removeAll { now > it.createdAt + requestExpiryMs }
suppression.values.removeAll { now > it }
fallbackReentry.values.removeAll { now > it }
}
companion object {
const val REQUEST_EXPIRY_MS = 10 * 60 * 1000L
const val SUPPRESSION_EXPIRY_MS = 10 * 60 * 1000L
const val DEDUPE_WINDOW_MS = 2000L
const val FALLBACK_REENTRY_MS = 10 * 1000L
}
}
@@ -0,0 +1,382 @@
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
package eu.weblibre.flutter_mozilla_components.applinks
import android.content.Context
import android.content.Intent
import android.net.Uri
import androidx.core.net.toUri
import eu.weblibre.flutter_mozilla_components.Components
import eu.weblibre.flutter_mozilla_components.GlobalComponents
import eu.weblibre.flutter_mozilla_components.ext.EventSequence
import eu.weblibre.flutter_mozilla_components.pigeons.AppLinkPromptOwner
import mozilla.components.browser.state.selector.findTabOrCustomTab
import mozilla.components.browser.state.state.CustomTabSessionState
import mozilla.components.browser.state.state.SessionState
import mozilla.components.concept.engine.EngineSession
import mozilla.components.concept.engine.request.RequestInterceptor
import mozilla.components.support.base.log.logger.Logger
import mozilla.components.support.ktx.kotlin.tryGetHostFromUrl
import java.util.Locale
/**
* The WebLibre-owned §2.4 interception tail (APP_LINKS_OWN_IMPLEMENTATION_PLAN.md Phase 5). Replaces
* Mozilla AC's `AppLinksInterceptor` + `AppLinksFeature` + `AppLinksCancelRetryMiddleware` on the
* synchronous `RequestInterceptor.onLoadRequest` path.
*
* Structural guards (PWA/TWA, sandbox capture, `weblibre://`, FxA) already ran in
* [eu.weblibre.flutter_mozilla_components.interceptor.AppRequestInterceptor] before this is called;
* this tail owns steps 28: navigation eligibility, resolution/sanitisation ([ExternalAppResolver]),
* the pure [AppLinkClassifier] decision, and its execution (auto-launch, validated fallback, or a
* pending prompt). Policy comes from the profile-scoped [AppLinkPolicyStore]; prompts land in the
* profile-scoped [PendingAppLinkStore]. It never denies a load and re-issues the same load.
*/
class WebLibreAppLinksInterceptor(
private val context: Context,
) {
private val logger = Logger("WebLibreAppLinks")
private val runtime get() = AppLinkRuntime.get(context)
/**
* @return the interception response, or `null` to let the engine proceed. Creating a pending
* prompt is a side effect performed here; the return value only controls the current load.
*/
fun onLoadRequest(
engineSession: EngineSession,
uri: String,
lastUri: String?,
hasUserGesture: Boolean,
isRedirect: Boolean,
isDirectNavigation: Boolean,
isSubframeRequest: Boolean,
): RequestInterceptor.InterceptionResponse? {
val components = GlobalComponents.components ?: return null
val uriScheme = runCatching { uri.toUri().scheme }.getOrNull()
val engineSupportsScheme = AppLinkSchemes.isEngineSupported(uriScheme)
// Step 2 — navigation eligibility. Any hit lets the engine proceed normally.
if (!isEligible(uri, lastUri, uriScheme, engineSupportsScheme, hasUserGesture, isRedirect, isDirectNavigation, isSubframeRequest)) {
return null
}
val pendingStore = pendingStoreFor(components)
// Fallback re-entry guard (§2.7): a fallback we issued has come back around. Keep it in the
// browser — never let it bounce out to an app. Consulted before resolution/classification.
if (pendingStore.isFallbackReentry(canonicalReentryKey(uri))) {
return null
}
val resolved = runtime.resolver.resolve(uri, includeHttpAppLinks = true, useCache = true)
val policy = AppLinkPolicyStores.forProfile(components.profileApplicationContext).policy
val session = components.core.store.state.findTabOrCustomTab(engineSession)
// Container isolation (replace semantics): a container with "isolated app link settings"
// enabled contributes an entry keyed by its contextId. When the source tab's contextId has
// one, its mode + rules fully replace the global ones for this navigation.
val override = session?.contextId?.let { policy.contextOverrides[it] }
val effectiveMode = override?.globalMode ?: policy.globalMode
val effectiveRules = override?.rules ?: policy.rules
val input = ClassifierInput(
resolved = resolved,
isProtected = isProtected(policy, session, uri),
isPrivate = session?.content?.private ?: false,
isWallet = AppLinkSchemes.isWallet(resolved.originalScheme) ||
AppLinkSchemes.isWallet(resolved.intentDataScheme),
missingSession = session == null,
suppressionHit = session != null &&
pendingStore.isSuppressed(session.id, targetFingerprint(uri, resolved)),
matchingRule = effectiveRules[resolved.scopeKey],
globalMode = effectiveMode,
marketplaceFallbackEnabled = policy.marketplaceFallbackEnabled,
)
val decision = AppLinkClassifier.classify(input)
logger.info(
"classify uri=$uri tab=${session?.id} ctx=${session?.contextId} " +
"isolated=${override != null} hasApp=${resolved.hasExternalApp} " +
"engineScheme=${resolved.engineSupportsScheme} mode=${input.globalMode} " +
"protected=${input.isProtected} private=${input.isPrivate} wallet=${input.isWallet} " +
"suppressed=${input.suppressionHit} rule=${input.matchingRule?.decision} -> $decision",
)
return execute(decision, components, pendingStore, session, uri, lastUri, input, hasUserGesture)
}
private fun execute(
decision: AppLinkDecision,
components: Components,
pendingStore: PendingAppLinkStore,
session: SessionState?,
uri: String,
lastUri: String?,
input: ClassifierInput,
hasUserGesture: Boolean,
): RequestInterceptor.InterceptionResponse? {
val resolved = input.resolved
return when (decision) {
is AppLinkDecision.AllowEngine -> null
is AppLinkDecision.DenyKeepPage -> RequestInterceptor.InterceptionResponse.Deny
is AppLinkDecision.LoadFallback -> {
pendingStore.recordFallbackReentry(canonicalReentryKey(decision.url))
RequestInterceptor.InterceptionResponse.Url(decision.url)
}
is AppLinkDecision.AutoLaunch -> {
val result = runtime.launcher.launch(
uri,
AppLinkLaunchMode.AUTOMATIC,
decision.expectedPackage,
)
when (result) {
AppLinkLaunchResult.LAUNCHED -> RequestInterceptor.InterceptionResponse.Deny
// A remembered `alwaysOpen` rule whose package no longer resolves must not
// silently launch a different app: fall through to a prompt (§2.5). Reclassify
// once with the rule removed so the global mode decides.
AppLinkLaunchResult.PACKAGE_MISMATCH -> {
val withoutRule = input.copy(matchingRule = null)
execute(
AppLinkClassifier.classify(withoutRule),
components, pendingStore, session, uri, lastUri, withoutRule, hasUserGesture,
)
}
// Launch failed/cooldown: answer in the original callback (§2.7). Never deny an
// engine-supported original and reload it — return null so it loads once.
else -> when {
resolved.engineSupportsScheme -> null
resolved.fallbackUrl != null -> {
pendingStore.recordFallbackReentry(canonicalReentryKey(resolved.fallbackUrl))
RequestInterceptor.InterceptionResponse.Url(resolved.fallbackUrl)
}
else -> RequestInterceptor.InterceptionResponse.Deny
}
}
}
is AppLinkDecision.Prompt -> {
// A missing session cannot host a prompt; the classifier never reaches Prompt in that
// case, so `session` is non-null here.
val tab = session ?: return safeNonLaunchResponse(pendingStore, resolved)
createPrompt(pendingStore, tab, uri, lastUri, input, decision, hasUserGesture)
if (decision.kind == AppLinkPromptKind.BANNER) {
// Engine-supported: allow the page to load while the non-modal banner is up.
null
} else {
// Unsupported scheme (or marketplace): the navigation is stalled, no page to show.
RequestInterceptor.InterceptionResponse.Deny
}
}
}
}
private fun createPrompt(
pendingStore: PendingAppLinkStore,
tab: SessionState,
uri: String,
lastUri: String?,
input: ClassifierInput,
decision: AppLinkDecision.Prompt,
hasUserGesture: Boolean,
) {
val resolved = input.resolved
val owner = if (tab is CustomTabSessionState) {
AppLinkPromptOwner.NATIVE_EXTERNAL
} else {
AppLinkPromptOwner.FLUTTER_BROWSER
}
val urlClass = when {
decision.isMarketplace -> AppLinkUrlClass.MARKETPLACE
decision.kind == AppLinkPromptKind.MODAL -> AppLinkUrlClass.MODAL
else -> AppLinkUrlClass.BANNER
}
val created = pendingStore.createRequest(
NewAppLinkRequest(
owner = owner,
tabId = tab.id,
contextId = tab.contextId,
sourceUrl = lastUri,
isPrivate = input.isPrivate,
isWallet = input.isWallet,
isProtectedContext = input.isProtected,
canRemember = decision.canRemember,
isModal = decision.kind == AppLinkPromptKind.MODAL,
urlClass = urlClass,
url = uri,
// The package to enforce at launch: only meaningful for a single,
// non-ambiguous handler. Null for an ambiguous/chooser target so the
// open path shows the chooser instead of refusing (§2.5/§2.7).
expectedPackage = if (resolved.isAmbiguous) null else resolved.packageName,
fallbackUrl = resolved.fallbackUrl,
engineSupportsScheme = resolved.engineSupportsScheme,
isMarketplace = decision.isMarketplace,
targetFingerprint = targetFingerprint(uri, resolved),
appName = resolved.appName,
packageName = resolved.packageName,
scopeKey = resolved.scopeKey,
isUserGesture = hasUserGesture,
),
)
logger.info(
"createPrompt owner=$owner tab=${tab.id} class=$urlClass id=${created.requestId} " +
"canRemember=${decision.canRemember} url=$uri",
)
when (owner) {
// The Custom Tab prompt feature only queries the store at lifecycle start, so a request
// created afterwards (this navigation, on an engine thread) needs an explicit nudge or it
// would sit unshown until a restart. The notifier re-queries on the main thread.
AppLinkPromptOwner.NATIVE_EXTERNAL ->
NativeAppLinkPromptNotifier.notifyPromptAvailable(tab.id)
// Best-effort availability nudge for the Flutter surface; the pending store + query is the
// contract (§2.8), so a lost event (Flutter detached) is harmless — it re-queries on resume.
AppLinkPromptOwner.FLUTTER_BROWSER ->
GlobalComponents.appLinkEvents?.onAppLinkPromptAvailable(EventSequence.next(), owner) { _ -> }
}
}
private fun safeNonLaunchResponse(
pendingStore: PendingAppLinkStore,
resolved: ResolvedAppLink,
): RequestInterceptor.InterceptionResponse? {
return if (resolved.engineSupportsScheme) {
null
} else {
resolved.fallbackUrl?.let {
pendingStore.recordFallbackReentry(canonicalReentryKey(it))
RequestInterceptor.InterceptionResponse.Url(it)
} ?: RequestInterceptor.InterceptionResponse.Deny
}
}
// ---- Eligibility (§2.4 step 2) ----
private fun isEligible(
uri: String,
lastUri: String?,
uriScheme: String?,
engineSupportsScheme: Boolean,
hasUserGesture: Boolean,
isRedirect: Boolean,
isDirectNavigation: Boolean,
isSubframeRequest: Boolean,
): Boolean {
if (uriScheme == null) return false
// A subframe request not triggered by the user and outside the allowlist stays in-page.
if (!hasUserGesture && isSubframeRequest && !AppLinkSchemes.isSubframeAllowed(uriScheme)) return false
val isAllowedRedirect = isRedirect && !isSubframeRequest
val isIntentionalNavigation = hasUserGesture || isAllowedRedirect || isDirectNavigation
// Unintentional engine-supported navigation continues in the browser.
if (engineSupportsScheme && !isIntentionalNavigation) return false
// Same-domain engine-supported navigation continues in the browser (AC subdomain stripping).
if (engineSupportsScheme && isSameDomain(lastUri, uri)) return false
// Always-denied schemes never resolve or launch externally.
if (AppLinkSchemes.isAlwaysDenied(uriScheme)) return false
return true
}
private fun isSameDomain(url1: String?, url2: String?): Boolean {
return stripCommonSubDomains(url1?.tryGetHostFromUrl()) ==
stripCommonSubDomains(url2?.tryGetHostFromUrl())
}
private fun stripCommonSubDomains(host: String?): String? {
return when {
host == null -> null
host.startsWith(WWW) -> host.replaceFirst(WWW, "")
host.startsWith(M) -> host.replaceFirst(M, "")
host.startsWith(MOBILE) -> host.replaceFirst(MOBILE, "")
host.startsWith(MAPS) -> host.replaceFirst(MAPS, "")
else -> host
}
}
// ---- Protection model (§2.3) ----
private fun isProtected(policy: AppLinkPolicy, session: SessionState?, uri: String): Boolean {
val contextId = session?.contextId
val protectedByContext = if (contextId == null) {
policy.protectGeneralContext
} else {
contextId in policy.protectedContextIds || contextId in policy.strictContextIds
}
if (protectedByContext) return true
return matchesProtectedTarget(policy.protectedTargetPatterns, uri)
}
private fun matchesProtectedTarget(patterns: List<ProtectedTargetPattern>, uri: String): Boolean {
if (patterns.isEmpty()) return false
val parsed = runCatching { Uri.parse(uri) }.getOrNull() ?: return false
val scheme = parsed.scheme?.lowercase(Locale.ROOT) ?: return false
val host = AppLinkHostNormalizer.normalizeHost(parsed.host) ?: return false
val effectivePort = if (parsed.port != -1) parsed.port else defaultPortForScheme(scheme)
return patterns.any { pattern ->
if (pattern.scheme.lowercase(Locale.ROOT) != scheme) return@any false
val patternHost = AppLinkHostNormalizer.normalizeHost(pattern.hostOrSuffix) ?: return@any false
if (pattern.includeSubdomains) {
// Wildcard entries match apex + subdomains and ignore port (§2.3).
host == patternHost || host.endsWith(".$patternHost")
} else {
// Exact entries compare scheme + origin including effective port.
host == patternHost && effectivePort == (pattern.port ?: defaultPortForScheme(scheme))
}
}
}
private fun defaultPortForScheme(scheme: String): Int = when (scheme) {
"http", "ws" -> 80
"https", "wss" -> 443
"ftp" -> 21
else -> -1
}
// ---- Helpers ----
/**
* The dedupe/invalidation/suppression key: the full sanitised target, not just the rule scope,
* so different paths sharing one policy scope never collapse into one request (§2.6).
*/
private fun targetFingerprint(uri: String, resolved: ResolvedAppLink): String {
val intentPayload = resolved.appIntent?.let {
runCatching { it.toUri(Intent.URI_INTENT_SCHEME) }.getOrNull()
}.orEmpty()
return buildString {
append(uri)
append('\u0000')
append(resolved.packageName.orEmpty())
append('\u0000')
append(intentPayload)
append('\u0000')
append(resolved.fallbackUrl.orEmpty())
}
}
/** Canonical key for the fallback re-entry map — the raw URL, matched on identity round-trip. */
private fun canonicalReentryKey(url: String): String = url
private fun pendingStoreFor(components: Components): PendingAppLinkStore {
return PendingAppLinkStores.forProfile(components.profileApplicationContext.relativePath)
}
companion object {
private const val WWW = "www."
private const val M = "m."
private const val MOBILE = "mobile."
private const val MAPS = "maps."
}
}
@@ -24,7 +24,8 @@ import eu.weblibre.flutter_mozilla_components.services.MediaSessionService
import eu.weblibre.flutter_mozilla_components.activities.NotificationActivity import eu.weblibre.flutter_mozilla_components.activities.NotificationActivity
import eu.weblibre.flutter_mozilla_components.R import eu.weblibre.flutter_mozilla_components.R
import eu.weblibre.flutter_mozilla_components.ext.getPreferenceKey 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.FlutterEventMiddleware
import eu.weblibre.flutter_mozilla_components.middleware.HistoryMetadataMiddleware import eu.weblibre.flutter_mozilla_components.middleware.HistoryMetadataMiddleware
import eu.weblibre.flutter_mozilla_components.middleware.HistoryMetadataService import eu.weblibre.flutter_mozilla_components.middleware.HistoryMetadataService
@@ -238,7 +239,12 @@ class Core(
// Must run before any engine middleware so we can rewrite // Must run before any engine middleware so we can rewrite
// sandbox new-tab URLs before Gecko issues a request. // sandbox new-tab URLs before Gecko issues a request.
SandboxCaptureMiddleware, SandboxCaptureMiddleware,
AppLinksCancelRetryMiddleware(), // WebLibre-owned app-link pending-request invalidation + suppression clearing.
AppLinkNavigationMiddleware(
PendingAppLinkStores.forProfile(
components.profileApplicationContext.relativePath,
),
),
HistoryMetadataMiddleware(historyMetadataService), HistoryMetadataMiddleware(historyMetadataService),
// Correlates url -> contextId so WebLibreHistoryDelegate can // Correlates url -> contextId so WebLibreHistoryDelegate can
// resolve a visit's container at record time. // resolve a visit's container at record time.
@@ -9,7 +9,6 @@ import android.content.Intent
import androidx.browser.customtabs.CustomTabsIntent import androidx.browser.customtabs.CustomTabsIntent
import androidx.core.net.toUri import androidx.core.net.toUri
import androidx.preference.PreferenceManager import androidx.preference.PreferenceManager
import eu.weblibre.flutter_mozilla_components.GlobalComponents
import eu.weblibre.flutter_mozilla_components.R import eu.weblibre.flutter_mozilla_components.R
import eu.weblibre.flutter_mozilla_components.activities.AuthIntentReceiverActivity import eu.weblibre.flutter_mozilla_components.activities.AuthIntentReceiverActivity
import eu.weblibre.flutter_mozilla_components.ext.getPreferenceKey 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.FirefoxAccountsAuthFeature
import mozilla.components.feature.accounts.FxaCapability import mozilla.components.feature.accounts.FxaCapability
import mozilla.components.feature.accounts.FxaWebChannelFeature import mozilla.components.feature.accounts.FxaWebChannelFeature
import mozilla.components.feature.app.links.AppLinksInterceptor
import mozilla.components.feature.tabs.TabsUseCases import mozilla.components.feature.tabs.TabsUseCases
import mozilla.components.service.fxa.ServerConfig import mozilla.components.service.fxa.ServerConfig
import mozilla.components.service.fxa.manager.FxaAccountManager import mozilla.components.service.fxa.manager.FxaAccountManager
@@ -66,11 +64,4 @@ class Services(
) )
} }
val appLinksInterceptor by lazy {
AppLinksInterceptor(
context = context,
launchInApp = { GlobalComponents.shouldOpenLinksInApp() },
store = store,
)
}
} }
@@ -8,7 +8,6 @@ import android.content.Context
import android.os.Environment import android.os.Environment
import mozilla.components.browser.state.store.BrowserStore import mozilla.components.browser.state.store.BrowserStore
import mozilla.components.concept.engine.Engine import mozilla.components.concept.engine.Engine
import mozilla.components.feature.app.links.AppLinksUseCases
import mozilla.components.feature.contextmenu.ContextMenuUseCases import mozilla.components.feature.contextmenu.ContextMenuUseCases
import mozilla.components.feature.downloads.DownloadsUseCases import mozilla.components.feature.downloads.DownloadsUseCases
import mozilla.components.feature.session.SessionUseCases import mozilla.components.feature.session.SessionUseCases
@@ -70,8 +69,6 @@ class UseCases(
*/ */
val customTabsUseCases: CustomTabsUseCases by lazy { CustomTabsUseCases(store, sessionUseCases.loadUrl) } val customTabsUseCases: CustomTabsUseCases by lazy { CustomTabsUseCases(store, sessionUseCases.loadUrl) }
val appLinksUseCases by lazy { AppLinksUseCases(context) }
val trackingProtectionUseCases by lazy { TrackingProtectionUseCases(store, engine) } val trackingProtectionUseCases by lazy { TrackingProtectionUseCases(store, engine) }
val webAppUseCases by lazy { val webAppUseCases by lazy {
@@ -12,6 +12,7 @@ import android.content.Intent
import android.net.Uri import android.net.Uri
import android.util.Log import android.util.Log
import eu.weblibre.flutter_mozilla_components.GlobalComponents 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.ext.EventSequence
import eu.weblibre.flutter_mozilla_components.feature.InertExternalSchemes import eu.weblibre.flutter_mozilla_components.feature.InertExternalSchemes
import eu.weblibre.flutter_mozilla_components.feature.SandboxCaptureBridge 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" } requireNotNull(GlobalComponents.components) { "Components not initialized" }
} }
// The WebLibre-owned §2.4 app-links tail.
private val webLibreAppLinks by lazy { WebLibreAppLinksInterceptor(context) }
override fun onLoadRequest( override fun onLoadRequest(
engineSession: EngineSession, engineSession: EngineSession,
uri: String, uri: String,
@@ -130,12 +134,13 @@ class AppRequestInterceptor(private val context: Context) : RequestInterceptor {
return it 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, engineSession,
uri, uri,
lastUri, lastUri,
hasUserGesture, hasUserGesture,
isSameDomain,
isRedirect, isRedirect,
isDirectNavigation, isDirectNavigation,
isSubframeRequest, isSubframeRequest,
@@ -0,0 +1,72 @@
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
package eu.weblibre.flutter_mozilla_components.middleware
import eu.weblibre.flutter_mozilla_components.applinks.PendingAppLinkStore
import mozilla.components.browser.state.action.BrowserAction
import mozilla.components.browser.state.action.ContentAction
import mozilla.components.browser.state.action.CustomTabListAction
import mozilla.components.browser.state.action.EngineAction
import mozilla.components.browser.state.action.TabListAction
import mozilla.components.browser.state.state.BrowserState
import mozilla.components.lib.state.Middleware
import mozilla.components.lib.state.Store
/**
* Observes the [BrowserStore] and drives [PendingAppLinkStore] invalidation and
* suppression clearing (APP_LINKS_OWN_IMPLEMENTATION_PLAN.md §2.6):
*
* - a committed top-level navigation whose URL is not a request's own target
* invalidates that request (a banner-class request's target committing keeps it
* alive that commit is the page the banner sits on);
* - tab close / Custom Tab removal invalidates the tab's pending requests and
* suppression;
* - a new user-initiated/direct navigation (omnibar, bookmark, typed URL which
* dispatch a `LoadUrlAction`) clears the tab's suppression. In-page redirects
* do not dispatch these actions, so the redirect-loop defence stays intact.
*/
class AppLinkNavigationMiddleware(
private val store: PendingAppLinkStore,
) : Middleware<BrowserState, BrowserAction> {
override fun invoke(
store: Store<BrowserState, BrowserAction>,
next: (BrowserAction) -> Unit,
action: BrowserAction,
) {
when (action) {
is ContentAction.UpdateUrlAction -> {
// A committed top-level navigation.
this.store.onCommittedNavigation(action.sessionId, action.url)
}
is EngineAction.LoadUrlAction -> {
// App-initiated (direct) navigation — clears suppression.
this.store.clearSuppressionForTab(action.tabId)
}
is EngineAction.OptimizedLoadUrlTriggeredAction -> {
this.store.clearSuppressionForTab(action.tabId)
}
is TabListAction.RemoveTabAction -> {
this.store.invalidateTab(action.tabId)
}
is TabListAction.RemoveTabsAction -> {
action.tabIds.forEach(this.store::invalidateTab)
}
is CustomTabListAction.RemoveCustomTabAction -> {
this.store.invalidateTab(action.tabId)
}
else -> {}
}
next(action)
}
}
@@ -1,149 +0,0 @@
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
package eu.weblibre.flutter_mozilla_components.middleware
import android.os.Handler
import android.os.Looper
import mozilla.components.browser.state.action.BrowserAction
import mozilla.components.browser.state.action.ContentAction
import mozilla.components.browser.state.action.EngineAction
import mozilla.components.browser.state.selector.findTabOrCustomTab
import mozilla.components.browser.state.state.BrowserState
import mozilla.components.concept.engine.EngineSession
import mozilla.components.concept.engine.EngineSession.LoadUrlFlags.Companion.EXTERNAL
import mozilla.components.concept.engine.EngineSession.LoadUrlFlags.Companion.LOAD_FLAGS_BYPASS_LOAD_URI_DELEGATE
import mozilla.components.lib.state.Middleware
import mozilla.components.lib.state.Store
/**
* Workaround for Android Components/GeckoView app-link cancel handling: the
* cancel load can be clobbered by Gecko's recovery load back to the previous
* history entry. This retries only that specific cancel-load signature.
*/
class AppLinksCancelRetryMiddleware(
private val handler: Handler = Handler(Looper.getMainLooper()),
private val retryDelayMillis: Long = RETRY_DELAY_MILLIS,
) : Middleware<BrowserState, BrowserAction> {
private val pendingCancels = mutableMapOf<String, PendingCancel>()
override fun invoke(
store: Store<BrowserState, BrowserAction>,
next: (BrowserAction) -> Unit,
action: BrowserAction,
) {
when (action) {
is EngineAction.OptimizedLoadUrlTriggeredAction -> {
recordCancelLoad(store, action)
}
is ContentAction.UpdateLoadRequestAction -> {
handleLoadRequest(store, action)
}
is ContentAction.UpdateUrlAction -> {
handleUrlUpdate(action)
}
else -> {}
}
next(action)
}
private fun recordCancelLoad(
store: Store<BrowserState, BrowserAction>,
action: EngineAction.OptimizedLoadUrlTriggeredAction,
) {
if (!action.flags.contains(EXTERNAL) ||
!action.flags.contains(LOAD_FLAGS_BYPASS_LOAD_URI_DELEGATE)
) {
return
}
val sourceUrl = store.state.findTabOrCustomTab(action.tabId)?.content?.url
?: return
if (sourceUrl == action.url || sourceUrl == ABOUT_BLANK) {
return
}
pendingCancels[action.tabId] = PendingCancel(
tabId = action.tabId,
sourceUrl = sourceUrl,
targetUrl = action.url,
)
}
private fun handleLoadRequest(
store: Store<BrowserState, BrowserAction>,
action: ContentAction.UpdateLoadRequestAction,
) {
val pending = pendingCancels[action.sessionId] ?: return
when (action.loadRequest.url) {
pending.sourceUrl -> scheduleRetry(store, pending)
pending.targetUrl -> pendingCancels.remove(action.sessionId)
ABOUT_BLANK -> {}
else -> pendingCancels.remove(action.sessionId)
}
}
private fun handleUrlUpdate(action: ContentAction.UpdateUrlAction) {
val pending = pendingCancels[action.sessionId] ?: return
when (action.url) {
pending.targetUrl -> pendingCancels.remove(action.sessionId)
pending.sourceUrl, ABOUT_BLANK -> {}
else -> pendingCancels.remove(action.sessionId)
}
}
private fun scheduleRetry(
store: Store<BrowserState, BrowserAction>,
pending: PendingCancel,
) {
if (pending.retryScheduled) {
return
}
val scheduled = pending.copy(retryScheduled = true)
pendingCancels[pending.tabId] = scheduled
handler.postDelayed({
if (pendingCancels[pending.tabId] != scheduled) {
return@postDelayed
}
val currentUrl = store.state.findTabOrCustomTab(pending.tabId)?.content?.url
if (currentUrl == pending.targetUrl) {
pendingCancels.remove(pending.tabId)
return@postDelayed
}
if (currentUrl == pending.sourceUrl || currentUrl == ABOUT_BLANK) {
pendingCancels.remove(pending.tabId)
store.dispatch(
EngineAction.LoadUrlAction(
tabId = pending.tabId,
url = pending.targetUrl,
flags = EngineSession.LoadUrlFlags.select(
LOAD_FLAGS_BYPASS_LOAD_URI_DELEGATE,
),
),
)
} else {
pendingCancels.remove(pending.tabId)
}
}, retryDelayMillis)
}
private data class PendingCancel(
val tabId: String,
val sourceUrl: String,
val targetUrl: String,
val retryScheduled: Boolean = false,
)
private companion object {
const val ABOUT_BLANK = "about:blank"
const val RETRY_DELAY_MILLIS = 1000L
}
}
@@ -30,4 +30,12 @@
<string name="private_tabs_notification_title_android_14">Close private tabs?</string> <string name="private_tabs_notification_title_android_14">Close private tabs?</string>
<string name="private_tabs_notification_text_android_14">Tap or swipe this notification to close private tabs.</string> <string name="private_tabs_notification_text_android_14">Tap or swipe this notification to close private tabs.</string>
<!-- App-link prompt shown in native Custom Tab sessions (no Flutter engine). -->
<!-- %1$s is the target app name. -->
<string name="weblibre_app_link_prompt_title_named">Open in %1$s?</string>
<string name="weblibre_app_link_prompt_title_generic">Open in another app?</string>
<string name="weblibre_app_link_prompt_message">This link is handled by an app outside WebLibre.</string>
<string name="weblibre_app_link_prompt_open">Open</string>
<string name="weblibre_app_link_prompt_cancel">Cancel</string>
</resources> </resources>
@@ -0,0 +1,287 @@
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
package eu.weblibre.flutter_mozilla_components.applinks
import android.content.Intent
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
import org.mockito.Mockito.mock
class AppLinkClassifierTest {
private fun resolved(
hasExternalApp: Boolean = true,
engineSupportsScheme: Boolean = false,
fallbackUrl: String? = null,
marketplace: Boolean = false,
isAmbiguous: Boolean = false,
packageName: String? = "com.example.app",
) = ResolvedAppLink(
hasExternalApp = hasExternalApp,
appIntent = null,
packageName = if (hasExternalApp) packageName else null,
appName = "Example",
fallbackUrl = fallbackUrl,
marketplaceIntent = if (marketplace) mock(Intent::class.java) else null,
isAmbiguous = isAmbiguous,
engineSupportsScheme = engineSupportsScheme,
scopeKey = "host:example.com",
originalScheme = if (engineSupportsScheme) "https" else "zoommtg",
intentDataScheme = if (engineSupportsScheme) "https" else "zoommtg",
)
private fun input(
resolved: ResolvedAppLink,
isProtected: Boolean = false,
isPrivate: Boolean = false,
isWallet: Boolean = false,
missingSession: Boolean = false,
suppressionHit: Boolean = false,
matchingRule: AppLinkRule? = null,
globalMode: AppLinkMode = AppLinkMode.ASK,
marketplaceFallbackEnabled: Boolean = false,
) = ClassifierInput(
resolved = resolved,
isProtected = isProtected,
isPrivate = isPrivate,
isWallet = isWallet,
missingSession = missingSession,
suppressionHit = suppressionHit,
matchingRule = matchingRule,
globalMode = globalMode,
marketplaceFallbackEnabled = marketplaceFallbackEnabled,
)
// ---- §2.2 table: engine-supported (http) scheme, app resolves ----
@Test
fun engineSupportedAlwaysAutoLaunches() {
val d = AppLinkClassifier.classify(
input(resolved(engineSupportsScheme = true), globalMode = AppLinkMode.ALWAYS),
)
assertEquals(AppLinkDecision.AutoLaunch(expectedPackage = null), d)
}
@Test
fun engineSupportedAskShowsBanner() {
val d = AppLinkClassifier.classify(
input(resolved(engineSupportsScheme = true), globalMode = AppLinkMode.ASK),
)
assertEquals(
AppLinkDecision.Prompt(AppLinkPromptKind.BANNER, canRemember = true, isMarketplace = false),
d,
)
}
@Test
fun engineSupportedNeverAllowsPage() {
val d = AppLinkClassifier.classify(
input(resolved(engineSupportsScheme = true), globalMode = AppLinkMode.NEVER),
)
assertEquals(AppLinkDecision.AllowEngine, d)
}
// ---- §2.2 table: unsupported scheme, app resolves ----
@Test
fun unsupportedAskShowsModal() {
val d = AppLinkClassifier.classify(
input(resolved(engineSupportsScheme = false), globalMode = AppLinkMode.ASK),
)
assertEquals(
AppLinkDecision.Prompt(AppLinkPromptKind.MODAL, canRemember = true, isMarketplace = false),
d,
)
}
@Test
fun unsupportedNeverWithFallbackLoadsFallback() {
val d = AppLinkClassifier.classify(
input(
resolved(engineSupportsScheme = false, fallbackUrl = "https://fallback.example"),
globalMode = AppLinkMode.NEVER,
),
)
assertEquals(AppLinkDecision.LoadFallback("https://fallback.example"), d)
}
@Test
fun unsupportedNeverWithoutFallbackKeepsPage() {
val d = AppLinkClassifier.classify(
input(resolved(engineSupportsScheme = false), globalMode = AppLinkMode.NEVER),
)
assertEquals(AppLinkDecision.DenyKeepPage, d)
}
// ---- §2.2 table: no app ----
@Test
fun noAppWithFallbackLoadsFallback() {
val d = AppLinkClassifier.classify(
input(resolved(hasExternalApp = false, fallbackUrl = "https://fb.example")),
)
assertEquals(AppLinkDecision.LoadFallback("https://fb.example"), d)
}
@Test
fun noAppNoFallbackEngineSupportedAllows() {
val d = AppLinkClassifier.classify(
input(resolved(hasExternalApp = false, engineSupportsScheme = true)),
)
assertEquals(AppLinkDecision.AllowEngine, d)
}
@Test
fun noAppNoFallbackUnsupportedDenies() {
val d = AppLinkClassifier.classify(
input(resolved(hasExternalApp = false, engineSupportsScheme = false)),
)
assertEquals(AppLinkDecision.DenyKeepPage, d)
}
@Test
fun noAppMarketplaceWhenEnabledAndNotNever() {
val d = AppLinkClassifier.classify(
input(
resolved(hasExternalApp = false, marketplace = true),
globalMode = AppLinkMode.ASK,
marketplaceFallbackEnabled = true,
),
)
assertEquals(
AppLinkDecision.Prompt(AppLinkPromptKind.MODAL, canRemember = false, isMarketplace = true),
d,
)
}
@Test
fun noAppMarketplaceSuppressedUnderNever() {
val d = AppLinkClassifier.classify(
input(
resolved(hasExternalApp = false, marketplace = true, engineSupportsScheme = false),
globalMode = AppLinkMode.NEVER,
marketplaceFallbackEnabled = true,
),
)
assertEquals(AppLinkDecision.DenyKeepPage, d)
}
// ---- §2.4 precedence: forced-prompt contexts override rules ----
@Test
fun protectedContextPromptsEvenWithAlwaysOpenRule() {
val d = AppLinkClassifier.classify(
input(
resolved(engineSupportsScheme = true),
isProtected = true,
matchingRule = AppLinkRule(AppLinkRuleDecision.ALWAYS_OPEN, "host:example.com", "com.example.app"),
globalMode = AppLinkMode.ALWAYS,
),
)
assertEquals(
AppLinkDecision.Prompt(AppLinkPromptKind.BANNER, canRemember = false, isMarketplace = false),
d,
)
}
@Test
fun privateTabPromptsWithoutRemember() {
val d = AppLinkClassifier.classify(
input(resolved(engineSupportsScheme = false), isPrivate = true, globalMode = AppLinkMode.ALWAYS),
)
assertEquals(
AppLinkDecision.Prompt(AppLinkPromptKind.MODAL, canRemember = false, isMarketplace = false),
d,
)
}
@Test
fun walletPromptsWithoutRemember() {
val d = AppLinkClassifier.classify(
input(resolved(engineSupportsScheme = false), isWallet = true),
)
assertTrue(d is AppLinkDecision.Prompt && !d.canRemember)
}
// (helpers above build ResolvedAppLink/ClassifierInput.)
@Test
fun missingSessionNeverAutoLaunches() {
// Engine-supported → allow the page; unsupported → deny (or fallback).
assertEquals(
AppLinkDecision.AllowEngine,
AppLinkClassifier.classify(
input(resolved(engineSupportsScheme = true), missingSession = true, globalMode = AppLinkMode.ALWAYS),
),
)
assertEquals(
AppLinkDecision.DenyKeepPage,
AppLinkClassifier.classify(
input(resolved(engineSupportsScheme = false), missingSession = true, globalMode = AppLinkMode.ALWAYS),
),
)
}
// ---- §2.4 step 5: suppression ----
@Test
fun suppressionHitNeverLaunchesEngineSupported() {
val d = AppLinkClassifier.classify(
input(resolved(engineSupportsScheme = true), suppressionHit = true, globalMode = AppLinkMode.ALWAYS),
)
assertEquals(AppLinkDecision.AllowEngine, d)
}
@Test
fun suppressionHitUnsupportedUsesFallbackOnly() {
val d = AppLinkClassifier.classify(
input(
resolved(engineSupportsScheme = false, fallbackUrl = "https://fb.example"),
suppressionHit = true,
globalMode = AppLinkMode.ALWAYS,
),
)
assertEquals(AppLinkDecision.LoadFallback("https://fb.example"), d)
}
// ---- §2.4 step 6: remembered rules ----
@Test
fun alwaysOpenRuleAutoLaunchesWithExpectedPackage() {
val d = AppLinkClassifier.classify(
input(
resolved(engineSupportsScheme = true),
matchingRule = AppLinkRule(AppLinkRuleDecision.ALWAYS_OPEN, "host:example.com", "com.example.app"),
globalMode = AppLinkMode.ASK,
),
)
assertEquals(AppLinkDecision.AutoLaunch(expectedPackage = "com.example.app"), d)
}
@Test
fun neverOpenRuleFollowsNeverRow() {
val d = AppLinkClassifier.classify(
input(
resolved(engineSupportsScheme = false),
matchingRule = AppLinkRule(AppLinkRuleDecision.NEVER_OPEN, "host:example.com", null),
globalMode = AppLinkMode.ALWAYS,
),
)
assertEquals(AppLinkDecision.DenyKeepPage, d)
}
@Test
fun ambiguousResolutionCannotBeRemembered() {
val d = AppLinkClassifier.classify(
input(resolved(engineSupportsScheme = true, isAmbiguous = true), globalMode = AppLinkMode.ASK),
)
assertEquals(
AppLinkDecision.Prompt(AppLinkPromptKind.BANNER, canRemember = false, isMarketplace = false),
d,
)
}
}
@@ -0,0 +1,60 @@
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
package eu.weblibre.flutter_mozilla_components.applinks
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNull
class AppLinkHostNormalizerTest {
@Test
fun lowercasesAndStripsTrailingDot() {
assertEquals("youtube.com", AppLinkHostNormalizer.normalizeHost("YouTube.com"))
assertEquals("youtube.com", AppLinkHostNormalizer.normalizeHost("youtube.com."))
assertEquals("youtube.com", AppLinkHostNormalizer.normalizeHost("YOUTUBE.COM."))
}
@Test
fun convertsNonAsciiHostsToPunycode() {
// bücher.example → xn--bcher-kva.example
assertEquals(
"xn--bcher-kva.example",
AppLinkHostNormalizer.normalizeHost("bücher.example"),
)
}
@Test
fun rejectsEmptyAndInvalidHosts() {
assertNull(AppLinkHostNormalizer.normalizeHost(null))
assertNull(AppLinkHostNormalizer.normalizeHost(""))
assertNull(AppLinkHostNormalizer.normalizeHost("."))
}
@Test
fun rejectsIpv6ZoneIds() {
assertNull(AppLinkHostNormalizer.normalizeHost("fe80::1%eth0"))
assertNull(AppLinkHostNormalizer.normalizeHost("[fe80::1%eth0]"))
}
@Test
fun canonicalisesIpLiterals() {
assertEquals("127.0.0.1", AppLinkHostNormalizer.normalizeHost("127.0.0.1"))
// Leading zeros / equivalent forms normalise to canonical dotted-quad.
assertEquals("[::1]", AppLinkHostNormalizer.normalizeHost("[::1]"))
}
@Test
fun buildsScopeKeys() {
assertEquals("host:youtube.com", AppLinkHostNormalizer.hostScopeKey("YouTube.com"))
assertNull(AppLinkHostNormalizer.hostScopeKey(""))
assertEquals(
"pkg:us.zoom.videomeetings",
AppLinkHostNormalizer.packageScopeKey("us.zoom.videomeetings"),
)
assertNull(AppLinkHostNormalizer.packageScopeKey(null))
}
}
@@ -0,0 +1,114 @@
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
package eu.weblibre.flutter_mozilla_components.applinks
import android.content.ActivityNotFoundException
import android.content.Intent
import kotlin.test.Test
import kotlin.test.assertEquals
import org.mockito.ArgumentMatchers.anyBoolean
import org.mockito.ArgumentMatchers.anyString
import org.mockito.Mockito.mock
import org.mockito.Mockito.`when`
class AppLinkLauncherTest {
private class FakeClock(var now: Long = 0L) : MonotonicClock {
override fun elapsedRealtime(): Long = now
}
private fun resolvedFor(packageName: String?, marketplace: Boolean = false): ResolvedAppLink {
return ResolvedAppLink(
hasExternalApp = packageName != null,
appIntent = if (packageName != null) mock(Intent::class.java) else null,
packageName = packageName,
appName = "App",
fallbackUrl = null,
marketplaceIntent = if (marketplace) mock(Intent::class.java) else null,
isAmbiguous = false,
engineSupportsScheme = false,
scopeKey = "pkg:$packageName",
originalScheme = "zoommtg",
intentDataScheme = "zoommtg",
)
}
private fun launcher(
resolved: ResolvedAppLink,
clock: FakeClock,
onStart: (Intent) -> Unit = {},
): AppLinkLauncher {
val resolver = mock(ExternalAppResolver::class.java)
`when`(resolver.resolve(anyString(), anyBoolean(), anyBoolean())).thenReturn(resolved)
return AppLinkLauncher(resolver, onStart, clock)
}
@Test
fun noAppReturnsNoApp() {
val l = launcher(resolvedFor(null), FakeClock())
assertEquals(AppLinkLaunchResult.NO_APP, l.launch("zoommtg://x", AppLinkLaunchMode.MANUAL))
}
@Test
fun packageMismatchIsRefused() {
val l = launcher(resolvedFor("com.actual.app"), FakeClock())
assertEquals(
AppLinkLaunchResult.PACKAGE_MISMATCH,
l.launch("zoommtg://x", AppLinkLaunchMode.AUTOMATIC, expectedPackage = "com.expected.app"),
)
}
@Test
fun successfulManualLaunchStartsActivity() {
var started = 0
val l = launcher(resolvedFor("com.example.app"), FakeClock()) { started++ }
assertEquals(AppLinkLaunchResult.LAUNCHED, l.launch("zoommtg://x", AppLinkLaunchMode.MANUAL))
assertEquals(1, started)
}
@Test
fun automaticLaunchWithinCooldownIsRefused() {
val clock = FakeClock(1000L)
val l = launcher(resolvedFor("com.example.app"), clock)
assertEquals(AppLinkLaunchResult.LAUNCHED, l.launch("zoommtg://x", AppLinkLaunchMode.AUTOMATIC))
clock.now = 1500L // < 2000 ms later
assertEquals(AppLinkLaunchResult.COOLDOWN, l.launch("zoommtg://x", AppLinkLaunchMode.AUTOMATIC))
}
@Test
fun automaticLaunchAfterCooldownSucceeds() {
val clock = FakeClock(1000L)
val l = launcher(resolvedFor("com.example.app"), clock)
assertEquals(AppLinkLaunchResult.LAUNCHED, l.launch("zoommtg://x", AppLinkLaunchMode.AUTOMATIC))
clock.now = 3001L // > 2000 ms later
assertEquals(AppLinkLaunchResult.LAUNCHED, l.launch("zoommtg://x", AppLinkLaunchMode.AUTOMATIC))
}
@Test
fun manualLaunchBypassesCooldownButRecordsIt() {
val clock = FakeClock(1000L)
val l = launcher(resolvedFor("com.example.app"), clock)
// Two manual launches back-to-back both succeed (user gesture bypasses the check).
assertEquals(AppLinkLaunchResult.LAUNCHED, l.launch("zoommtg://x", AppLinkLaunchMode.MANUAL))
assertEquals(AppLinkLaunchResult.LAUNCHED, l.launch("zoommtg://x", AppLinkLaunchMode.MANUAL))
// But the manual launch recorded the timestamp, so a following automatic launch is cooled.
assertEquals(AppLinkLaunchResult.COOLDOWN, l.launch("zoommtg://x", AppLinkLaunchMode.AUTOMATIC))
}
@Test
fun activityNotFoundYieldsFailed() {
val l = launcher(resolvedFor("com.example.app"), FakeClock()) {
throw ActivityNotFoundException("no activity")
}
assertEquals(AppLinkLaunchResult.FAILED, l.launch("zoommtg://x", AppLinkLaunchMode.MANUAL))
}
@Test
fun marketplaceModeWithoutMarketplaceIntentIsNoApp() {
val l = launcher(resolvedFor("com.example.app", marketplace = false), FakeClock())
assertEquals(AppLinkLaunchResult.NO_APP, l.launch("market://x", AppLinkLaunchMode.MARKETPLACE))
}
}
@@ -0,0 +1,68 @@
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
package eu.weblibre.flutter_mozilla_components.applinks
import kotlin.test.Test
import kotlin.test.assertFalse
import kotlin.test.assertTrue
class AppLinkSchemesTest {
@Test
fun engineSupportedSchemesMatchTheFrozenTable() {
for (scheme in listOf(
"about", "data", "file", "ftp", "http", "https", "moz-extension",
"moz-safe-about", "resource", "view-source", "ws", "wss", "blob",
)) {
assertTrue(AppLinkSchemes.isEngineSupported(scheme), "$scheme should be engine-supported")
// Case-insensitive: a mixed-case spelling matches too.
assertTrue(
AppLinkSchemes.isEngineSupported(scheme.uppercase()),
"${scheme.uppercase()} should be engine-supported (case-insensitive)",
)
}
assertFalse(AppLinkSchemes.isEngineSupported("zoommtg"))
assertFalse(AppLinkSchemes.isEngineSupported(null))
}
@Test
fun alwaysDeniedSchemesMatchTheFrozenTable() {
for (scheme in listOf("jar", "file", "javascript", "data", "about", "content", "fido")) {
assertTrue(AppLinkSchemes.isAlwaysDenied(scheme), "$scheme should be always-denied")
}
// JavaScript: must be denied as surely as javascript:.
assertTrue(AppLinkSchemes.isAlwaysDenied("JavaScript"))
assertTrue(AppLinkSchemes.isAlwaysDenied("FILE"))
assertFalse(AppLinkSchemes.isAlwaysDenied("https"))
}
@Test
fun subframeAllowedSchemes() {
assertTrue(AppLinkSchemes.isSubframeAllowed("msteams"))
assertTrue(AppLinkSchemes.isSubframeAllowed("MSTeams"))
assertFalse(AppLinkSchemes.isSubframeAllowed("whatsapp"))
}
@Test
fun walletSchemes() {
for (scheme in listOf(
"openid4vp", "mdoc", "mdoc-openid4vp", "haip", "eudi-wallet",
"eudi-openid4vp", "openid-credential-offer",
)) {
assertTrue(AppLinkSchemes.isWallet(scheme), "$scheme should be a wallet scheme")
}
assertTrue(AppLinkSchemes.isWallet("OpenID4VP"))
assertFalse(AppLinkSchemes.isWallet("https"))
}
@Test
fun httpOrHttpsIsCaseInsensitive() {
assertTrue(AppLinkSchemes.isHttpOrHttps("http"))
assertTrue(AppLinkSchemes.isHttpOrHttps("HTTPS"))
assertFalse(AppLinkSchemes.isHttpOrHttps("ftp"))
assertFalse(AppLinkSchemes.isHttpOrHttps(null))
}
}
@@ -0,0 +1,184 @@
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
package eu.weblibre.flutter_mozilla_components.applinks
import eu.weblibre.flutter_mozilla_components.pigeons.AppLinkPromptOwner
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertNotEquals
import kotlin.test.assertNotNull
import kotlin.test.assertNull
import kotlin.test.assertTrue
class PendingAppLinkStoreTest {
private class FakeClock(var now: Long = 0L) : MonotonicClock {
override fun elapsedRealtime(): Long = now
}
private fun newRequest(
tabId: String = "tab1",
fingerprint: String = "fp1",
owner: AppLinkPromptOwner = AppLinkPromptOwner.FLUTTER_BROWSER,
urlClass: AppLinkUrlClass = AppLinkUrlClass.MODAL,
url: String = "zoommtg://join",
isUserGesture: Boolean = false,
) = NewAppLinkRequest(
owner = owner,
tabId = tabId,
contextId = null,
sourceUrl = null,
isPrivate = false,
isWallet = false,
isProtectedContext = false,
canRemember = true,
isModal = urlClass == AppLinkUrlClass.MODAL,
urlClass = urlClass,
url = url,
expectedPackage = null,
fallbackUrl = null,
engineSupportsScheme = false,
isMarketplace = false,
targetFingerprint = fingerprint,
appName = "App",
packageName = "com.app",
scopeKey = "pkg:com.app",
isUserGesture = isUserGesture,
)
@Test
fun idsAreMonotonic() {
val store = PendingAppLinkStore(FakeClock())
val a = store.createRequest(newRequest(fingerprint = "a"))
val b = store.createRequest(newRequest(fingerprint = "b"))
assertNotEquals(a.requestId, b.requestId)
assertTrue(b.requestId > a.requestId)
}
@Test
fun queryIsNonConsumingAndConsumeIsAtomic() {
val store = PendingAppLinkStore(FakeClock())
val request = store.createRequest(newRequest())
assertEquals(1, store.getPending(AppLinkPromptOwner.FLUTTER_BROWSER).size)
// Non-consuming.
assertEquals(1, store.getPending(AppLinkPromptOwner.FLUTTER_BROWSER).size)
assertEquals(request.requestId, store.consume(request.requestId)?.requestId)
// Double-consume is a no-op.
assertNull(store.consume(request.requestId))
}
@Test
fun ownerFilterSeparatesSurfaces() {
val store = PendingAppLinkStore(FakeClock())
store.createRequest(newRequest(owner = AppLinkPromptOwner.FLUTTER_BROWSER, fingerprint = "a"))
store.createRequest(newRequest(owner = AppLinkPromptOwner.NATIVE_EXTERNAL, fingerprint = "b"))
assertEquals(1, store.getPending(AppLinkPromptOwner.FLUTTER_BROWSER).size)
assertEquals(1, store.getPending(AppLinkPromptOwner.NATIVE_EXTERNAL).size)
}
@Test
fun dedupeCollapsesWithinWindowButNotAcrossUserGesture() {
val clock = FakeClock()
val store = PendingAppLinkStore(clock, dedupeWindowMs = 2000L)
val first = store.createRequest(newRequest())
clock.now = 1000L
val second = store.createRequest(newRequest())
assertEquals(first.requestId, second.requestId)
// A user-gesture attempt is never deduped.
val gesture = store.createRequest(newRequest(isUserGesture = true))
assertNotEquals(first.requestId, gesture.requestId)
}
@Test
fun distinctFingerprintsSharingAScopeAreNotDeduped() {
val store = PendingAppLinkStore(FakeClock())
val a = store.createRequest(newRequest(fingerprint = "path-a"))
val b = store.createRequest(newRequest(fingerprint = "path-b"))
assertNotEquals(a.requestId, b.requestId)
}
@Test
fun bannerTargetCommitKeepsRequestButUnrelatedCommitInvalidates() {
val store = PendingAppLinkStore(FakeClock())
val banner = store.createRequest(
newRequest(urlClass = AppLinkUrlClass.BANNER, url = "https://youtu.be/x"),
)
// The banner's own target committing keeps it alive.
store.onCommittedNavigation("tab1", "https://youtu.be/x")
assertNotNull(store.peek(banner.requestId))
// An unrelated commit invalidates it.
store.onCommittedNavigation("tab1", "https://example.com/other")
assertNull(store.peek(banner.requestId))
}
@Test
fun bannerSurvivesSameSiteRedirectAndNormalisation() {
val store = PendingAppLinkStore(FakeClock())
// The intercepted URL is rarely byte-identical to the committed one: the initial
// load redirects/normalises (www stripped, tracking params added, trailing slash).
val banner = store.createRequest(
newRequest(urlClass = AppLinkUrlClass.BANNER, url = "https://www.reddit.com/r/foo"),
)
store.onCommittedNavigation("tab1", "https://reddit.com/r/foo/?utm_source=share")
assertNotNull(store.peek(banner.requestId))
// A commit to a genuinely different site still invalidates it.
store.onCommittedNavigation("tab1", "https://twitter.com/reddit")
assertNull(store.peek(banner.requestId))
}
@Test
fun tabCloseInvalidatesRequestsAndSuppression() {
val store = PendingAppLinkStore(FakeClock())
val request = store.createRequest(newRequest())
store.recordSuppression("tab1", "fp1")
store.invalidateTab("tab1")
assertNull(store.peek(request.requestId))
assertFalse(store.isSuppressed("tab1", "fp1"))
}
@Test
fun suppressionSurvivesRedirectsButClearsOnDirectNavAndTimeout() {
val clock = FakeClock()
val store = PendingAppLinkStore(clock, suppressionExpiryMs = 1000L)
store.recordSuppression("tab1", "fp1")
assertTrue(store.isSuppressed("tab1", "fp1"))
// Ordinary committed navigation does not clear it.
store.onCommittedNavigation("tab1", "https://redirect.example")
assertTrue(store.isSuppressed("tab1", "fp1"))
// Direct navigation clears it.
store.clearSuppressionForTab("tab1")
assertFalse(store.isSuppressed("tab1", "fp1"))
// Timeout clears it.
store.recordSuppression("tab1", "fp2")
clock.now = 1001L
assertFalse(store.isSuppressed("tab1", "fp2"))
}
@Test
fun requestsExpire() {
val clock = FakeClock()
val store = PendingAppLinkStore(clock, requestExpiryMs = 1000L)
val request = store.createRequest(newRequest())
clock.now = 1001L
assertNull(store.consume(request.requestId))
}
@Test
fun fallbackReentryIsReusableInWindowAndExpires() {
val clock = FakeClock()
val store = PendingAppLinkStore(clock, fallbackReentryMs = 10_000L)
store.recordFallbackReentry("https://fallback.example/")
assertTrue(store.isFallbackReentry("https://fallback.example/"))
// Reusable within its window (does not consume).
assertTrue(store.isFallbackReentry("https://fallback.example/"))
clock.now = 10_001L
assertFalse(store.isFallbackReentry("https://fallback.example/"))
}
}
@@ -50,6 +50,12 @@ export 'src/pigeons/gecko.g.dart'
AddonStorePromoted, AddonStorePromoted,
AddonUpdateAttemptInfo, AddonUpdateAttemptInfo,
AddonUpdateStatus, AddonUpdateStatus,
AppLinkDecision,
AppLinkPolicySnapshot,
AppLinkPromptOwner,
AppLinkPromptRequest,
AppLinkResolutionResult,
AppLinkTarget,
AppLinksMode, AppLinksMode,
AudioHitResult, AudioHitResult,
AutoplayStatus, AutoplayStatus,
@@ -70,6 +76,7 @@ export 'src/pigeons/gecko.g.dart'
DownloadStatus, DownloadStatus,
EmailHitResult, EmailHitResult,
FrecencyThresholdOption, FrecencyThresholdOption,
GeckoAppLinkEvents,
GeckoDeleteBrowsingDataController, GeckoDeleteBrowsingDataController,
GeckoEngineSettings, GeckoEngineSettings,
GeckoFetchResponse, GeckoFetchResponse,
@@ -99,7 +106,11 @@ export 'src/pigeons/gecko.g.dart'
MlProgressData, MlProgressData,
MlProgressStatus, MlProgressStatus,
MlProgressType, MlProgressType,
NativeAppLinkRule,
NativeAppLinkRuleDecision,
NativeContextAppLinkPolicy,
PhoneHitResult, PhoneHitResult,
ProtectedTargetPattern,
ProxyLoadError, ProxyLoadError,
PushDistributor, PushDistributor,
PushDistributorStatus, PushDistributorStatus,
@@ -10,31 +10,52 @@ final _api = GeckoAppLinksApi();
/// Service for detecting and launching external applications that can handle URLs. /// Service for detecting and launching external applications that can handle URLs.
/// ///
/// This service wraps Mozilla Android Components' AppLinksUseCases to allow /// WebLibre-owned resolution/launch surface. Policy lives in Dart; the native side
/// checking if native apps can handle URLs and launching them directly. /// owns PackageManager resolution and Intent launch. Used by the manual
/// This matches the behavior in Firefox/Fenix for "Open in App" functionality. /// "Open in app" entry points.
class GeckoAppLinksService { class GeckoAppLinksService {
/// Checks if an external application is available to handle the given URL. /// Resolve [url] to an external-app target, or null when no external app is
/// available (or on any resolution error / always-denied scheme).
/// ///
/// This method uses mozilla-components AppLinksUseCases to determine if /// [includeHttpAppLinks] when true, an app resolving an engine-supported
/// a native app can handle the URL (e.g., YouTube app for youtube.com links). /// (http(s)) URL is surfaced (e.g. the YouTube app for a youtube.com link).
/// Future<AppLinkTarget?> resolveAppLink(
/// @param url The URL to check. Uri url, {
/// @return true if an external app is available, false otherwise. bool includeHttpAppLinks = true,
Future<bool> hasExternalApp(Uri url) { }) {
return _api.hasExternalApp(url.toString()); return _api.resolveAppLink(url.toString(), includeHttpAppLinks);
} }
/// Opens the URL in an external application if available. /// Re-resolve [url] and launch it in an external app.
/// ///
/// This method will: /// Returns true if launched, false if no app is available or the launch failed.
/// 1. Check if an external app can handle the URL Future<bool> launchAppLink(Uri url) {
/// 2. If available, launch the app directly with Intent.FLAG_ACTIVITY_NEW_TASK return _api.launchAppLink(url.toString());
/// 3. Return true if successfully launched, false otherwise }
/// Push the complete app-link policy snapshot to native (last-write-wins).
/// ///
/// @param url The URL to open in external app. /// Throws if no profile is bound yet; the caller (replicator) retries after
/// @return true if URL was opened in external app, false if no app available. /// initialisation.
Future<bool> openAppLink(Uri url) { Future<void> setAppLinkPolicy(AppLinkPolicySnapshot snapshot) {
return _api.openAppLink(url.toString()); return _api.setAppLinkPolicy(snapshot);
}
/// Non-consuming query of pending prompts for [owner] (§2.6). Query on
/// attach/resume and when the availability event fires; render idempotently by
/// requestId.
Future<List<AppLinkPromptRequest>> getPendingAppLinkPrompts(
AppLinkPromptOwner owner,
) {
return _api.getPendingAppLinkPrompts(owner);
}
/// Atomically resolve a pending prompt (§2.6). A double-resolve or stale id is
/// a no-op returning `failureReason == "stale"`.
Future<AppLinkResolutionResult> resolvePendingAppLink(
int requestId,
AppLinkDecision decision,
) {
return _api.resolvePendingAppLink(requestId, decision);
} }
} }
@@ -199,16 +199,6 @@ class GeckoEngineSettingsService {
return _api.setPullToRefreshEnabled(enabled); return _api.setPullToRefreshEnabled(enabled);
} }
/// Sets the app links mode preference.
/// Controls how external app links are handled in browser.
Future<void> setAppLinksMode(AppLinksMode mode) {
return _api.setAppLinksMode(mode);
}
Future<AppLinksMode> getAppLinksMode() {
return _api.getAppLinksMode();
}
/// Sets whether to use external download managers for downloads. /// Sets whether to use external download managers for downloads.
/// When enabled, downloads are forwarded to third-party apps like ADM, 1DM, AB DM. /// When enabled, downloads are forwarded to third-party apps like ADM, 1DM, AB DM.
Future<void> setUseExternalDownloadManager(bool enabled) { Future<void> setUseExternalDownloadManager(bool enabled) {
File diff suppressed because it is too large Load Diff
@@ -1557,12 +1557,6 @@ abstract class GeckoEngineSettingsApi {
void setScreenshotProtectionEnabled(bool enabled); void setScreenshotProtectionEnabled(bool enabled);
void setPullToRefreshEnabled(bool enabled); void setPullToRefreshEnabled(bool enabled);
/// Sets the app links mode preference (stored in SharedPreferences).
/// Controls how external app links are handled in the browser.
void setAppLinksMode(AppLinksMode mode);
AppLinksMode getAppLinksMode();
/// Sets whether to use external download managers for downloads. /// Sets whether to use external download managers for downloads.
/// When enabled, downloads are forwarded to third-party apps like ADM, 1DM, AB DM. /// When enabled, downloads are forwarded to third-party apps like ADM, 1DM, AB DM.
void setUseExternalDownloadManager(bool enabled); void setUseExternalDownloadManager(bool enabled);
@@ -2800,31 +2794,239 @@ abstract class GeckoTrackingProtectionApi {
// App Links API // App Links API
// ============================================================================= // =============================================================================
/// Resolved external-app target for a URL (see APP_LINKS_OWN_IMPLEMENTATION_PLAN.md §2.8).
class AppLinkTarget {
/// The URL that was resolved.
final String url;
/// User-facing app label (control/bidi-sanitised), or null when unknown.
final String? appName;
/// Resolved package name, or null when ambiguous / unknown.
final String? packageName;
/// Pre-validated http(s) fallback URL, or null.
final String? fallbackUrl;
/// True when the only offer is a marketplace (install-app) intent.
final bool isMarketplace;
/// True when resolution is ambiguous (chooser / multiple handlers / no default).
final bool isAmbiguous;
/// True when the Gecko engine can load the URL scheme itself.
final bool engineSupportsScheme;
/// Canonical native-owned rule scope key ("host:youtube.com" | "pkg:...").
final String scopeKey;
const AppLinkTarget({
required this.url,
this.appName,
this.packageName,
this.fallbackUrl,
required this.isMarketplace,
required this.isAmbiguous,
required this.engineSupportsScheme,
required this.scopeKey,
});
}
/// Target-side protection pattern replicated to native (§2.3/§2.8). Any target
/// assigned to an effectively-proxied or strict container is protected
/// independent of the source tab.
class ProtectedTargetPattern {
final String scheme;
final String hostOrSuffix;
final bool includeSubdomains;
/// Effective port for exact entries; null for wildcard entries (ignore port).
final int? port;
const ProtectedTargetPattern({
required this.scheme,
required this.hostOrSuffix,
required this.includeSubdomains,
this.port,
});
}
enum NativeAppLinkRuleDecision { alwaysOpen, neverOpen }
/// A remembered per-scope rule replicated to native (§2.8). Distinct from the
/// Dart-persisted `PersistedAppLinkRule`; explicit mappers bridge the two.
class NativeAppLinkRule {
final NativeAppLinkRuleDecision decision;
final String scope;
final String? packageName;
const NativeAppLinkRule({
required this.decision,
required this.scope,
this.packageName,
});
}
/// A container's self-contained app-link policy override (§ container isolation).
/// Present only for containers with "isolated app link settings" enabled; when a
/// navigation's source contextId has an entry here, it fully *replaces* the
/// global mode + rules for that navigation (no layering with the global policy).
class NativeContextAppLinkPolicy {
final AppLinksMode mode;
/// The container's own remembered rules keyed by canonical scope.
final Map<String, NativeAppLinkRule> rules;
const NativeContextAppLinkPolicy({required this.mode, required this.rules});
}
/// Complete, last-write-wins policy snapshot pushed from the single Dart writer
/// to native (§2.8). Native persists it to the profile-scoped prefs record
/// before swapping the in-memory reference.
class AppLinkPolicySnapshot {
final AppLinksMode globalMode;
/// Remembered rules keyed by canonical scope.
final Map<String, NativeAppLinkRule> rules;
final bool marketplaceFallbackEnabled;
/// Regular / no-contextId tabs are proxied via the `general` scope.
final bool protectGeneralContext;
/// contextIds that resolve to a proxy after inherit/bypass/alias.
final List<String> protectedContextIds;
/// strictMode containers, independent of routing.
final List<String> strictContextIds;
final List<ProtectedTargetPattern> protectedTargetPatterns;
/// Per-container app-link policy overrides keyed by contextId. Only isolated
/// containers appear here; a navigation whose source contextId is a key uses
/// the entry's mode + rules in place of the global ones (replace semantics).
final Map<String, NativeContextAppLinkPolicy> contextOverrides;
const AppLinkPolicySnapshot({
required this.globalMode,
required this.rules,
required this.marketplaceFallbackEnabled,
required this.protectGeneralContext,
required this.protectedContextIds,
required this.strictContextIds,
required this.protectedTargetPatterns,
required this.contextOverrides,
});
}
/// Which surface owns a pending prompt (§2.6). Fixed at creation, never transfers.
enum AppLinkPromptOwner { flutterBrowser, nativeExternal }
/// A pending app-link prompt request held in the native `PendingAppLinkStore`
/// until resolved, invalidated, or expired (§2.6/§2.8). Holds only stable
/// identifiers and sanitised data — never engine/store references.
class AppLinkPromptRequest {
/// Monotonic per-process id (Kotlin Long).
final int requestId;
final AppLinkPromptOwner owner;
final String tabId;
final String? contextId;
final String? sourceUrl;
final bool isPrivate;
final bool isWallet;
final bool isProtectedContext;
final bool canRemember;
/// false for the http(s) banner class (non-modal); true for the modal
/// unsupported-scheme prompt.
final bool isModal;
final AppLinkTarget target;
const AppLinkPromptRequest({
required this.requestId,
required this.owner,
required this.tabId,
this.contextId,
this.sourceUrl,
required this.isPrivate,
required this.isWallet,
required this.isProtectedContext,
required this.canRemember,
required this.isModal,
required this.target,
});
}
/// User decision on a pending prompt (§2.6).
enum AppLinkDecision { open, cancel, dismiss }
/// Result of resolving a pending prompt (§2.8).
class AppLinkResolutionResult {
final bool launched;
final bool loadedFallback;
/// "stale" | "dead_session" | "launch_failed" | null.
final String? failureReason;
const AppLinkResolutionResult({
required this.launched,
required this.loadedFallback,
this.failureReason,
});
}
/// API for detecting and launching external applications that can handle URLs. /// API for detecting and launching external applications that can handle URLs.
/// ///
/// This API wraps Mozilla Android Components' AppLinksUseCases to allow Flutter /// WebLibre-owned resolution/launch surface (replaces the Mozilla AC use-case
/// code to check if native apps can handle URLs and launch them directly. /// wrappers). Policy lives in Dart; this surface owns PackageManager resolution
/// and Intent launch.
@HostApi() @HostApi()
abstract class GeckoAppLinksApi { abstract class GeckoAppLinksApi {
/// Checks if an external application is available to handle the given URL. /// Push the complete policy snapshot to native (last-write-wins). Native
/// /// persists it durably to the active profile's prefs record before acking.
/// This method uses mozilla-components AppLinksUseCases to determine if
/// a native app can handle the URL (e.g., YouTube app for youtube.com links).
///
/// Returns true if an external app is available, false otherwise.
@async @async
bool hasExternalApp(String url); void setAppLinkPolicy(AppLinkPolicySnapshot snapshot);
/// Opens the URL in an external application if available. /// Non-consuming query of pending prompts for [owner] (§2.6). Surfaces call
/// /// this on attach/resume/rotation and when the availability event fires, and
/// This method will: /// render idempotently by requestId.
/// 1. Check if an external app can handle the URL
/// 2. If available, launch the app directly with Intent.FLAG_ACTIVITY_NEW_TASK
/// 3. Return true if successfully launched, false otherwise
///
/// Returns true if URL was opened in external app, false if no app available.
@async @async
bool openAppLink(String url); List<AppLinkPromptRequest> getPendingAppLinkPrompts(AppLinkPromptOwner owner);
/// Atomically resolve a pending prompt: validate it still exists and its tab
/// is alive, consume it (double-resolve is a no-op), then perform side effects
/// after releasing the store lock (§2.6).
@async
AppLinkResolutionResult resolvePendingAppLink(int requestId, AppLinkDecision decision);
/// Resolve [url] to an external-app target.
///
/// Returns null when no external app is available, on any resolution error, or
/// for always-denied schemes — callers cannot distinguish "nothing installed"
/// from "resolution failed", matching the previous `hasExternalApp` contract.
///
/// [includeHttpAppLinks] when true, an app resolving an engine-supported
/// (http(s)) URL is surfaced (e.g. the YouTube app for a youtube.com link).
@async
AppLinkTarget? resolveAppLink(String url, bool includeHttpAppLinks);
/// Re-resolve [url] and launch it in an external app.
///
/// Re-resolves internally immediately before launch and returns false on
/// no-app or ActivityNotFoundException/SecurityException; never throws across
/// the channel for expected conditions.
@async
bool launchAppLink(String url);
}
/// Optimisation-only availability signal for pending app-link prompts (§2.8).
///
/// A Pigeon `@FlutterApi()` callback has no buffering or replay: an event
/// emitted while Flutter is detached is lost. The `PendingAppLinkStore` is the
/// source of truth; surfaces query on attach/resume and dedupe by requestId.
@FlutterApi()
abstract class GeckoAppLinkEvents {
void onAppLinkPromptAvailable(int sequence, AppLinkPromptOwner owner);
} }
// ============================================================================= // =============================================================================
@@ -1,4 +1,4 @@
// Autogenerated from Pigeon (v26.3.4), do not edit directly. // Autogenerated from Pigeon (v27.1.0), do not edit directly.
// See also: https://pub.dev/packages/pigeon // See also: https://pub.dev/packages/pigeon
@file:Suppress("UNCHECKED_CAST", "ArrayInDataClass") @file:Suppress("UNCHECKED_CAST", "ArrayInDataClass")
@@ -289,6 +289,9 @@ data class SingboxProxyProfile (
result = 31 * result + SingboxProxyApiPigeonUtils.deepHash(this.secretJson) result = 31 * result + SingboxProxyApiPigeonUtils.deepHash(this.secretJson)
return result return result
} }
override fun toString(): String {
return "SingboxProxyProfile(id=$id, name=$name, type=$type, configJson=$configJson, secretJson=$secretJson)"
}
} }
/** Generated class from Pigeon that represents data sent in messages. */ /** Generated class from Pigeon that represents data sent in messages. */
@@ -352,6 +355,9 @@ data class SingboxProxyRuntimeOptions (
result = 31 * result + SingboxProxyApiPigeonUtils.deepHash(this.bootstrapDohUrl) result = 31 * result + SingboxProxyApiPigeonUtils.deepHash(this.bootstrapDohUrl)
return result return result
} }
override fun toString(): String {
return "SingboxProxyRuntimeOptions(preferredBasePort=$preferredBasePort, blockUnmatchedTraffic=$blockUnmatchedTraffic, dnsConfig=$dnsConfig, bootstrapDohUrl=$bootstrapDohUrl)"
}
} }
/** Generated class from Pigeon that represents data sent in messages. */ /** Generated class from Pigeon that represents data sent in messages. */
@@ -440,6 +446,9 @@ data class SingboxProxyDnsServerConfig (
result = 31 * result + SingboxProxyApiPigeonUtils.deepHash(this.matchInbounds) result = 31 * result + SingboxProxyApiPigeonUtils.deepHash(this.matchInbounds)
return result return result
} }
override fun toString(): String {
return "SingboxProxyDnsServerConfig(tag=$tag, address=$address, detourTag=$detourTag, matchDomainSuffixes=$matchDomainSuffixes, matchGeosites=$matchGeosites, matchOutbounds=$matchOutbounds, matchInbounds=$matchInbounds)"
}
} }
/** Generated class from Pigeon that represents data sent in messages. */ /** Generated class from Pigeon that represents data sent in messages. */
@@ -487,6 +496,9 @@ data class SingboxProxyDnsConfig (
result = 31 * result + SingboxProxyApiPigeonUtils.deepHash(this.domainStrategy) result = 31 * result + SingboxProxyApiPigeonUtils.deepHash(this.domainStrategy)
return result return result
} }
override fun toString(): String {
return "SingboxProxyDnsConfig(servers=$servers, finalServerTag=$finalServerTag, domainStrategy=$domainStrategy)"
}
} }
/** Generated class from Pigeon that represents data sent in messages. */ /** Generated class from Pigeon that represents data sent in messages. */
@@ -537,6 +549,9 @@ data class SingboxProxyRuntimeEndpoint (
result = 31 * result + SingboxProxyApiPigeonUtils.deepHash(this.password) result = 31 * result + SingboxProxyApiPigeonUtils.deepHash(this.password)
return result return result
} }
override fun toString(): String {
return "SingboxProxyRuntimeEndpoint(profileId=$profileId, host=$host, port=$port, username=$username, password=$password)"
}
} }
/** Generated class from Pigeon that represents data sent in messages. */ /** Generated class from Pigeon that represents data sent in messages. */
@@ -579,6 +594,9 @@ data class SingboxProxyRuntimeState (
result = 31 * result + SingboxProxyApiPigeonUtils.deepHash(this.message) result = 31 * result + SingboxProxyApiPigeonUtils.deepHash(this.message)
return result return result
} }
override fun toString(): String {
return "SingboxProxyRuntimeState(status=$status, endpoints=$endpoints, message=$message)"
}
} }
/** Generated class from Pigeon that represents data sent in messages. */ /** Generated class from Pigeon that represents data sent in messages. */
@@ -617,6 +635,9 @@ data class SingboxProxyConfigResult (
result = 31 * result + SingboxProxyApiPigeonUtils.deepHash(this.endpoints) result = 31 * result + SingboxProxyApiPigeonUtils.deepHash(this.endpoints)
return result return result
} }
override fun toString(): String {
return "SingboxProxyConfigResult(configJson=$configJson, endpoints=$endpoints)"
}
} }
/** Generated class from Pigeon that represents data sent in messages. */ /** Generated class from Pigeon that represents data sent in messages. */
@@ -663,6 +684,9 @@ data class SingboxProxyLogMessage (
result = 31 * result + SingboxProxyApiPigeonUtils.deepHash(this.profileId) result = 31 * result + SingboxProxyApiPigeonUtils.deepHash(this.profileId)
return result return result
} }
override fun toString(): String {
return "SingboxProxyLogMessage(level=$level, message=$message, timestamp=$timestamp, profileId=$profileId)"
}
} }
private open class SingboxProxyApiPigeonCodec : StandardMessageCodec() { private open class SingboxProxyApiPigeonCodec : StandardMessageCodec() {
override fun readValueOfType(type: Byte, buffer: ByteBuffer): Any? { override fun readValueOfType(type: Byte, buffer: ByteBuffer): Any? {
@@ -1,4 +1,4 @@
// Autogenerated from Pigeon (v26.3.4), do not edit directly. // Autogenerated from Pigeon (v27.1.0), do not edit directly.
// See also: https://pub.dev/packages/pigeon // See also: https://pub.dev/packages/pigeon
// ignore_for_file: unused_import, unused_shown_name // ignore_for_file: unused_import, unused_shown_name
// ignore_for_file: type=lint // ignore_for_file: type=lint
@@ -195,6 +195,11 @@ class SingboxProxyProfile {
@override @override
// ignore: avoid_equals_and_hash_code_on_mutable_classes // ignore: avoid_equals_and_hash_code_on_mutable_classes
int get hashCode => _deepHash(<Object?>[runtimeType, ..._toList()]); int get hashCode => _deepHash(<Object?>[runtimeType, ..._toList()]);
@override
String toString() {
return 'SingboxProxyProfile(id: $id, name: $name, type: $type, configJson: $configJson, secretJson: $secretJson)';
}
} }
class SingboxProxyRuntimeOptions { class SingboxProxyRuntimeOptions {
@@ -261,6 +266,11 @@ class SingboxProxyRuntimeOptions {
@override @override
// ignore: avoid_equals_and_hash_code_on_mutable_classes // ignore: avoid_equals_and_hash_code_on_mutable_classes
int get hashCode => _deepHash(<Object?>[runtimeType, ..._toList()]); int get hashCode => _deepHash(<Object?>[runtimeType, ..._toList()]);
@override
String toString() {
return 'SingboxProxyRuntimeOptions(preferredBasePort: $preferredBasePort, blockUnmatchedTraffic: $blockUnmatchedTraffic, dnsConfig: $dnsConfig, bootstrapDohUrl: $bootstrapDohUrl)';
}
} }
class SingboxProxyDnsServerConfig { class SingboxProxyDnsServerConfig {
@@ -351,6 +361,11 @@ class SingboxProxyDnsServerConfig {
@override @override
// ignore: avoid_equals_and_hash_code_on_mutable_classes // ignore: avoid_equals_and_hash_code_on_mutable_classes
int get hashCode => _deepHash(<Object?>[runtimeType, ..._toList()]); int get hashCode => _deepHash(<Object?>[runtimeType, ..._toList()]);
@override
String toString() {
return 'SingboxProxyDnsServerConfig(tag: $tag, address: $address, detourTag: $detourTag, matchDomainSuffixes: $matchDomainSuffixes, matchGeosites: $matchGeosites, matchOutbounds: $matchOutbounds, matchInbounds: $matchInbounds)';
}
} }
class SingboxProxyDnsConfig { class SingboxProxyDnsConfig {
@@ -404,6 +419,11 @@ class SingboxProxyDnsConfig {
@override @override
// ignore: avoid_equals_and_hash_code_on_mutable_classes // ignore: avoid_equals_and_hash_code_on_mutable_classes
int get hashCode => _deepHash(<Object?>[runtimeType, ..._toList()]); int get hashCode => _deepHash(<Object?>[runtimeType, ..._toList()]);
@override
String toString() {
return 'SingboxProxyDnsConfig(servers: $servers, finalServerTag: $finalServerTag, domainStrategy: $domainStrategy)';
}
} }
class SingboxProxyRuntimeEndpoint { class SingboxProxyRuntimeEndpoint {
@@ -464,6 +484,11 @@ class SingboxProxyRuntimeEndpoint {
@override @override
// ignore: avoid_equals_and_hash_code_on_mutable_classes // ignore: avoid_equals_and_hash_code_on_mutable_classes
int get hashCode => _deepHash(<Object?>[runtimeType, ..._toList()]); int get hashCode => _deepHash(<Object?>[runtimeType, ..._toList()]);
@override
String toString() {
return 'SingboxProxyRuntimeEndpoint(profileId: $profileId, host: $host, port: $port, username: $username, password: $password)';
}
} }
class SingboxProxyRuntimeState { class SingboxProxyRuntimeState {
@@ -514,6 +539,11 @@ class SingboxProxyRuntimeState {
@override @override
// ignore: avoid_equals_and_hash_code_on_mutable_classes // ignore: avoid_equals_and_hash_code_on_mutable_classes
int get hashCode => _deepHash(<Object?>[runtimeType, ..._toList()]); int get hashCode => _deepHash(<Object?>[runtimeType, ..._toList()]);
@override
String toString() {
return 'SingboxProxyRuntimeState(status: $status, endpoints: $endpoints, message: $message)';
}
} }
class SingboxProxyConfigResult { class SingboxProxyConfigResult {
@@ -559,6 +589,11 @@ class SingboxProxyConfigResult {
@override @override
// ignore: avoid_equals_and_hash_code_on_mutable_classes // ignore: avoid_equals_and_hash_code_on_mutable_classes
int get hashCode => _deepHash(<Object?>[runtimeType, ..._toList()]); int get hashCode => _deepHash(<Object?>[runtimeType, ..._toList()]);
@override
String toString() {
return 'SingboxProxyConfigResult(configJson: $configJson, endpoints: $endpoints)';
}
} }
class SingboxProxyLogMessage { class SingboxProxyLogMessage {
@@ -614,6 +649,11 @@ class SingboxProxyLogMessage {
@override @override
// ignore: avoid_equals_and_hash_code_on_mutable_classes // ignore: avoid_equals_and_hash_code_on_mutable_classes
int get hashCode => _deepHash(<Object?>[runtimeType, ..._toList()]); int get hashCode => _deepHash(<Object?>[runtimeType, ..._toList()]);
@override
String toString() {
return 'SingboxProxyLogMessage(level: $level, message: $message, timestamp: $timestamp, profileId: $profileId)';
}
} }
@@ -691,8 +731,8 @@ class _PigeonCodec extends StandardMessageCodec {
} }
class SingboxProxyApi { class SingboxProxyApi {
/// Constructor for [SingboxProxyApi]. The [binaryMessenger] named argument is /// Constructor for [SingboxProxyApi]. The [binaryMessenger] named argument is
/// available for dependency injection. If it is left null, the default /// available for dependency injection. If it is left null, the default
/// BinaryMessenger will be used which routes to the host platform. /// BinaryMessenger will be used which routes to the host platform.
SingboxProxyApi({BinaryMessenger? binaryMessenger, String messageChannelSuffix = ''}) SingboxProxyApi({BinaryMessenger? binaryMessenger, String messageChannelSuffix = ''})
: pigeonVar_binaryMessenger = binaryMessenger, : pigeonVar_binaryMessenger = binaryMessenger,
@@ -1,4 +1,4 @@
// Autogenerated from Pigeon (v26.3.4), do not edit directly. // Autogenerated from Pigeon (v27.1.0), do not edit directly.
// See also: https://pub.dev/packages/pigeon // See also: https://pub.dev/packages/pigeon
@file:Suppress("UNCHECKED_CAST", "ArrayInDataClass") @file:Suppress("UNCHECKED_CAST", "ArrayInDataClass")
@@ -278,6 +278,9 @@ data class TorConfiguration (
result = 31 * result + TorApiPigeonUtils.deepHash(this.strictNodes) result = 31 * result + TorApiPigeonUtils.deepHash(this.strictNodes)
return result return result
} }
override fun toString(): String {
return "TorConfiguration(transport=$transport, bridgeLines=$bridgeLines, entryNodeCountries=$entryNodeCountries, exitNodeCountries=$exitNodeCountries, strictNodes=$strictNodes)"
}
} }
/** /**
@@ -337,6 +340,9 @@ data class TorStatus (
result = 31 * result + TorApiPigeonUtils.deepHash(this.exitNodeCountry) result = 31 * result + TorApiPigeonUtils.deepHash(this.exitNodeCountry)
return result return result
} }
override fun toString(): String {
return "TorStatus(isRunning=$isRunning, socksPort=$socksPort, bootstrapProgress=$bootstrapProgress, currentCircuit=$currentCircuit, exitNodeCountry=$exitNodeCountry)"
}
} }
/** /**
@@ -386,6 +392,9 @@ data class TorLogMessage (
result = 31 * result + TorApiPigeonUtils.deepHash(this.timestamp) result = 31 * result + TorApiPigeonUtils.deepHash(this.timestamp)
return result return result
} }
override fun toString(): String {
return "TorLogMessage(severity=$severity, message=$message, timestamp=$timestamp)"
}
} }
private open class TorApiPigeonCodec : StandardMessageCodec() { private open class TorApiPigeonCodec : StandardMessageCodec() {
override fun readValueOfType(type: Byte, buffer: ByteBuffer): Any? { override fun readValueOfType(type: Byte, buffer: ByteBuffer): Any? {
+20 -5
View File
@@ -1,4 +1,4 @@
// Autogenerated from Pigeon (v26.3.4), do not edit directly. // Autogenerated from Pigeon (v27.1.0), do not edit directly.
// See also: https://pub.dev/packages/pigeon // See also: https://pub.dev/packages/pigeon
// ignore_for_file: unused_import, unused_shown_name // ignore_for_file: unused_import, unused_shown_name
// ignore_for_file: type=lint // ignore_for_file: type=lint
@@ -191,6 +191,11 @@ class TorConfiguration {
@override @override
// ignore: avoid_equals_and_hash_code_on_mutable_classes // ignore: avoid_equals_and_hash_code_on_mutable_classes
int get hashCode => _deepHash(<Object?>[runtimeType, ..._toList()]); int get hashCode => _deepHash(<Object?>[runtimeType, ..._toList()]);
@override
String toString() {
return 'TorConfiguration(transport: $transport, bridgeLines: $bridgeLines, entryNodeCountries: $entryNodeCountries, exitNodeCountries: $exitNodeCountries, strictNodes: $strictNodes)';
}
} }
/// Current Tor status /// Current Tor status
@@ -257,6 +262,11 @@ class TorStatus {
@override @override
// ignore: avoid_equals_and_hash_code_on_mutable_classes // ignore: avoid_equals_and_hash_code_on_mutable_classes
int get hashCode => _deepHash(<Object?>[runtimeType, ..._toList()]); int get hashCode => _deepHash(<Object?>[runtimeType, ..._toList()]);
@override
String toString() {
return 'TorStatus(isRunning: $isRunning, socksPort: $socksPort, bootstrapProgress: $bootstrapProgress, currentCircuit: $currentCircuit, exitNodeCountry: $exitNodeCountry)';
}
} }
/// Log message from Tor /// Log message from Tor
@@ -311,6 +321,11 @@ class TorLogMessage {
@override @override
// ignore: avoid_equals_and_hash_code_on_mutable_classes // ignore: avoid_equals_and_hash_code_on_mutable_classes
int get hashCode => _deepHash(<Object?>[runtimeType, ..._toList()]); int get hashCode => _deepHash(<Object?>[runtimeType, ..._toList()]);
@override
String toString() {
return 'TorLogMessage(severity: $severity, message: $message, timestamp: $timestamp)';
}
} }
@@ -358,8 +373,8 @@ class _PigeonCodec extends StandardMessageCodec {
/// Host API (Flutter -> Native) /// Host API (Flutter -> Native)
class TorApi { class TorApi {
/// Constructor for [TorApi]. The [binaryMessenger] named argument is /// Constructor for [TorApi]. The [binaryMessenger] named argument is
/// available for dependency injection. If it is left null, the default /// available for dependency injection. If it is left null, the default
/// BinaryMessenger will be used which routes to the host platform. /// BinaryMessenger will be used which routes to the host platform.
TorApi({BinaryMessenger? binaryMessenger, String messageChannelSuffix = ''}) TorApi({BinaryMessenger? binaryMessenger, String messageChannelSuffix = ''})
: pigeonVar_binaryMessenger = binaryMessenger, : pigeonVar_binaryMessenger = binaryMessenger,
@@ -508,8 +523,8 @@ abstract class TorLogApi {
} }
class IPtProxyController { class IPtProxyController {
/// Constructor for [IPtProxyController]. The [binaryMessenger] named argument is /// Constructor for [IPtProxyController]. The [binaryMessenger] named argument is
/// available for dependency injection. If it is left null, the default /// available for dependency injection. If it is left null, the default
/// BinaryMessenger will be used which routes to the host platform. /// BinaryMessenger will be used which routes to the host platform.
IPtProxyController({BinaryMessenger? binaryMessenger, String messageChannelSuffix = ''}) IPtProxyController({BinaryMessenger? binaryMessenger, String messageChannelSuffix = ''})
: pigeonVar_binaryMessenger = binaryMessenger, : pigeonVar_binaryMessenger = binaryMessenger,
@@ -1,4 +1,4 @@
// Autogenerated from Pigeon (v26.3.4), do not edit directly. // Autogenerated from Pigeon (v27.1.0), do not edit directly.
// See also: https://pub.dev/packages/pigeon // See also: https://pub.dev/packages/pigeon
@file:Suppress("UNCHECKED_CAST", "ArrayInDataClass") @file:Suppress("UNCHECKED_CAST", "ArrayInDataClass")
@@ -228,6 +228,9 @@ data class LocalizedResult (
result = 31 * result + LocalesPigeonUtils.deepHash(this.countryName) result = 31 * result + LocalesPigeonUtils.deepHash(this.countryName)
return result return result
} }
override fun toString(): String {
return "LocalizedResult(languageName=$languageName, countryName=$countryName)"
}
} }
private open class LocalesPigeonCodec : StandardMessageCodec() { private open class LocalesPigeonCodec : StandardMessageCodec() {
override fun readValueOfType(type: Byte, buffer: ByteBuffer): Any? { override fun readValueOfType(type: Byte, buffer: ByteBuffer): Any? {
@@ -1,4 +1,4 @@
// Autogenerated from Pigeon (v26.3.4), do not edit directly. // Autogenerated from Pigeon (v27.1.0), do not edit directly.
// See also: https://pub.dev/packages/pigeon // See also: https://pub.dev/packages/pigeon
// ignore_for_file: unused_import, unused_shown_name // ignore_for_file: unused_import, unused_shown_name
// ignore_for_file: type=lint // ignore_for_file: type=lint
@@ -140,6 +140,11 @@ class LocalizedResult {
@override @override
// ignore: avoid_equals_and_hash_code_on_mutable_classes // ignore: avoid_equals_and_hash_code_on_mutable_classes
int get hashCode => _deepHash(<Object?>[runtimeType, ..._toList()]); int get hashCode => _deepHash(<Object?>[runtimeType, ..._toList()]);
@override
String toString() {
return 'LocalizedResult(languageName: $languageName, countryName: $countryName)';
}
} }
@@ -170,8 +175,8 @@ class _PigeonCodec extends StandardMessageCodec {
} }
class LocaleResolver { class LocaleResolver {
/// Constructor for [LocaleResolver]. The [binaryMessenger] named argument is /// Constructor for [LocaleResolver]. The [binaryMessenger] named argument is
/// available for dependency injection. If it is left null, the default /// available for dependency injection. If it is left null, the default
/// BinaryMessenger will be used which routes to the host platform. /// BinaryMessenger will be used which routes to the host platform.
LocaleResolver({BinaryMessenger? binaryMessenger, String messageChannelSuffix = ''}) LocaleResolver({BinaryMessenger? binaryMessenger, String messageChannelSuffix = ''})
: pigeonVar_binaryMessenger = binaryMessenger, : pigeonVar_binaryMessenger = binaryMessenger,
@@ -1,4 +1,4 @@
// Autogenerated from Pigeon (v26.3.4), do not edit directly. // Autogenerated from Pigeon (v27.1.0), do not edit directly.
// See also: https://pub.dev/packages/pigeon // See also: https://pub.dev/packages/pigeon
@file:Suppress("UNCHECKED_CAST", "ArrayInDataClass") @file:Suppress("UNCHECKED_CAST", "ArrayInDataClass")
@@ -247,6 +247,9 @@ data class Intent (
result = 31 * result + IntentPigeonUtils.deepHash(this.extra) result = 31 * result + IntentPigeonUtils.deepHash(this.extra)
return result return result
} }
override fun toString(): String {
return "Intent(fromPackageName=$fromPackageName, action=$action, data=$data, categories=$categories, mimeType=$mimeType, extra=$extra)"
}
} }
private open class IntentPigeonCodec : StandardMessageCodec() { private open class IntentPigeonCodec : StandardMessageCodec() {
override fun readValueOfType(type: Byte, buffer: ByteBuffer): Any? { override fun readValueOfType(type: Byte, buffer: ByteBuffer): Any? {
@@ -1,4 +1,4 @@
// Autogenerated from Pigeon (v26.3.4), do not edit directly. // Autogenerated from Pigeon (v27.1.0), do not edit directly.
// See also: https://pub.dev/packages/pigeon // See also: https://pub.dev/packages/pigeon
// ignore_for_file: unused_import, unused_shown_name // ignore_for_file: unused_import, unused_shown_name
// ignore_for_file: type=lint // ignore_for_file: type=lint
@@ -170,6 +170,11 @@ class Intent {
@override @override
// ignore: avoid_equals_and_hash_code_on_mutable_classes // ignore: avoid_equals_and_hash_code_on_mutable_classes
int get hashCode => _deepHash(<Object?>[runtimeType, ..._toList()]); int get hashCode => _deepHash(<Object?>[runtimeType, ..._toList()]);
@override
String toString() {
return 'Intent(fromPackageName: $fromPackageName, action: $action, data: $data, categories: $categories, mimeType: $mimeType, extra: $extra)';
}
} }
@@ -200,8 +205,8 @@ class _PigeonCodec extends StandardMessageCodec {
} }
class IntentHost { class IntentHost {
/// Constructor for [IntentHost]. The [binaryMessenger] named argument is /// Constructor for [IntentHost]. The [binaryMessenger] named argument is
/// available for dependency injection. If it is left null, the default /// available for dependency injection. If it is left null, the default
/// BinaryMessenger will be used which routes to the host platform. /// BinaryMessenger will be used which routes to the host platform.
IntentHost({BinaryMessenger? binaryMessenger, String messageChannelSuffix = ''}) IntentHost({BinaryMessenger? binaryMessenger, String messageChannelSuffix = ''})
: pigeonVar_binaryMessenger = binaryMessenger, : pigeonVar_binaryMessenger = binaryMessenger,
@@ -269,8 +274,8 @@ abstract class IntentEvents {
} }
class IntentGatekeeperHostApi { class IntentGatekeeperHostApi {
/// Constructor for [IntentGatekeeperHostApi]. The [binaryMessenger] named argument is /// Constructor for [IntentGatekeeperHostApi]. The [binaryMessenger] named argument is
/// available for dependency injection. If it is left null, the default /// available for dependency injection. If it is left null, the default
/// BinaryMessenger will be used which routes to the host platform. /// BinaryMessenger will be used which routes to the host platform.
IntentGatekeeperHostApi({BinaryMessenger? binaryMessenger, String messageChannelSuffix = ''}) IntentGatekeeperHostApi({BinaryMessenger? binaryMessenger, String messageChannelSuffix = ''})
: pigeonVar_binaryMessenger = binaryMessenger, : pigeonVar_binaryMessenger = binaryMessenger,
@@ -1,4 +1,4 @@
// Autogenerated from Pigeon (v26.3.4), do not edit directly. // Autogenerated from Pigeon (v27.1.0), do not edit directly.
// See also: https://pub.dev/packages/pigeon // See also: https://pub.dev/packages/pigeon
@file:Suppress("UNCHECKED_CAST", "ArrayInDataClass") @file:Suppress("UNCHECKED_CAST", "ArrayInDataClass")
@@ -1,4 +1,4 @@
// Autogenerated from Pigeon (v26.3.4), do not edit directly. // Autogenerated from Pigeon (v27.1.0), do not edit directly.
// See also: https://pub.dev/packages/pigeon // See also: https://pub.dev/packages/pigeon
// ignore_for_file: unused_import, unused_shown_name // ignore_for_file: unused_import, unused_shown_name
// ignore_for_file: type=lint // ignore_for_file: type=lint
@@ -69,8 +69,8 @@ class _PigeonCodec extends StandardMessageCodec {
/// Host API - methods called from Flutter to native Android. /// Host API - methods called from Flutter to native Android.
class SpeechToTextApi { class SpeechToTextApi {
/// Constructor for [SpeechToTextApi]. The [binaryMessenger] named argument is /// Constructor for [SpeechToTextApi]. The [binaryMessenger] named argument is
/// available for dependency injection. If it is left null, the default /// available for dependency injection. If it is left null, the default
/// BinaryMessenger will be used which routes to the host platform. /// BinaryMessenger will be used which routes to the host platform.
SpeechToTextApi({BinaryMessenger? binaryMessenger, String messageChannelSuffix = ''}) SpeechToTextApi({BinaryMessenger? binaryMessenger, String messageChannelSuffix = ''})
: pigeonVar_binaryMessenger = binaryMessenger, : pigeonVar_binaryMessenger = binaryMessenger,