From 4bc267969bd5eab4b5b2c40b799fb0622925a039 Mon Sep 17 00:00:00 2001 From: Fabian Freund Date: Thu, 30 Jul 2026 03:58:46 +0200 Subject: [PATCH] app links initial --- .../domain/entities/app_link_rule.dart | 95 ++ .../domain/entities/app_link_rule.g.dart | 110 ++ .../entities/context_app_link_policy.dart | 81 ++ .../entities/context_app_link_policy.g.dart | 97 ++ .../services/app_link_policy_replication.dart | 273 ++++ .../app_link_policy_replication.g.dart | 194 +++ .../services/app_links_coordinator.dart | 165 +++ .../services/app_links_coordinator.g.dart | 90 ++ .../services/effective_app_link_policy.dart | 151 +++ .../services/effective_app_link_policy.g.dart | 118 ++ .../domain/services/effective_routing.dart | 343 +++++ .../widgets/app_link_open_banner.dart | 122 ++ .../widgets/app_link_prompt_dialog.dart | 131 ++ .../widgets/app_link_prompt_host.dart | 117 ++ .../container_app_link_settings_dialog.dart | 178 +++ .../features/browser/domain/providers.dart | 15 - .../features/browser/domain/providers.g.dart | 45 - .../domain/services/browser_data.g.dart | 2 +- .../services/proxy_settings_replication.dart | 120 +- .../proxy_settings_replication.g.dart | 2 +- .../providers/toolbar_button_configs.dart | 2 +- .../browser/presentation/screens/browser.dart | 34 + .../widgets/browser_menu_sheet.dart | 13 +- .../widgets/browser_modules/browser_view.dart | 14 + .../widgets/menu_item_buttons.dart | 13 +- .../widgets/share_bottom_sheet.dart | 13 +- .../widgets/sheets/app_link_section.dart | 238 ++++ .../presentation/widgets/sheets/view_tab.dart | 7 + .../candidates/launch_external.dart | 14 +- .../dialogs/open_shared_content.dart | 14 +- .../features/tabs/data/database/database.dart | 3 - .../tabs/data/models/container_data.dart | 23 + .../tabs/data/models/container_data.g.dart | 17 + .../tabs/domain/repositories/tab.dart | 10 +- .../tabs/domain/repositories/tab.g.dart | 2 +- .../presentation/screens/container_edit.dart | 120 ++ .../screens/browsing_settings.dart | 100 +- .../user/data/models/general_settings.dart | 44 + .../user/data/models/general_settings.g.dart | 81 ++ .../domain/repositories/general_settings.dart | 12 + .../repositories/general_settings.g.dart | 2 +- .../user/domain/repositories/profile.g.dart | 2 +- .../features/web_push/domain/providers.g.dart | 2 +- .../app_links/app_link_prompt_rules_test.dart | 94 ++ .../app_links/app_link_rule_test.dart | 112 ++ .../effective_app_link_policy_test.dart | 149 +++ .../app_links/effective_routing_test.dart | 188 +++ .../general_settings_app_links_test.dart | 114 ++ .../tabs/data/models/container_data_test.dart | 38 + .../android/build.gradle | 1 - .../BaseBrowserFragment.kt | 62 +- .../FlutterMozillaComponentsPlugin.kt | 3 + .../GlobalComponents.kt | 22 +- .../api/GeckoAppLinksApiImpl.kt | 214 ++- .../api/GeckoBrowserApiImpl.kt | 5 + .../api/GeckoEngineSettingsApiImpl.kt | 33 - .../applinks/AppLinkClassifier.kt | 225 ++++ .../applinks/AppLinkHostNormalizer.kt | 100 ++ .../applinks/AppLinkLauncher.kt | 128 ++ .../applinks/AppLinkPolicyMapper.kt | 53 + .../applinks/AppLinkPolicyStore.kt | 236 ++++ .../applinks/AppLinkRuntime.kt | 39 + .../applinks/AppLinkSchemes.kt | 82 ++ .../applinks/ExternalAppResolver.kt | 344 +++++ .../applinks/MonotonicClock.kt | 22 + .../applinks/NativeAppLinkPromptFeature.kt | 147 +++ .../applinks/PackageResolver.kt | 92 ++ .../applinks/PendingAppLinkStore.kt | 342 +++++ .../applinks/WebLibreAppLinksInterceptor.kt | 382 ++++++ .../components/Core.kt | 10 +- .../components/Services.kt | 9 - .../components/UseCases.kt | 3 - .../interceptor/AppRequestInterceptor.kt | 9 +- .../middleware/AppLinkNavigationMiddleware.kt | 72 + .../AppLinksCancelRetryMiddleware.kt | 149 --- .../pigeons/Gecko.g.kt | 1121 ++++++++++++---- .../android/src/main/res/values/strings.xml | 8 + .../applinks/AppLinkClassifierTest.kt | 287 ++++ .../applinks/AppLinkHostNormalizerTest.kt | 60 + .../applinks/AppLinkLauncherTest.kt | 114 ++ .../applinks/AppLinkSchemesTest.kt | 68 + .../applinks/PendingAppLinkStoreTest.kt | 184 +++ .../lib/flutter_mozilla_components.dart | 11 + .../src/domain/services/gecko_app_links.dart | 61 +- .../services/gecko_engine_settings.dart | 10 - .../lib/src/pigeons/gecko.g.dart | 1158 +++++++++++++---- .../pigeons/gecko.dart | 250 +++- .../generated/SingboxProxyApi.g.kt | 26 +- .../lib/src/singbox_proxy_api.g.dart | 46 +- .../flutter_tor/generated/TorApi.g.kt | 11 +- packages/flutter_tor/lib/src/tor_api.g.dart | 25 +- .../locale_resolver/pigeons/Locales.g.kt | 5 +- .../lib/src/pigeons/locales.g.dart | 11 +- .../pigeons/Intent.g.kt | 5 +- .../lib/src/pigeons/intent.g.dart | 15 +- .../pigeons/SpeechToText.g.kt | 2 +- .../lib/src/pigeons/speech_to_text.g.dart | 6 +- 97 files changed, 9138 insertions(+), 1054 deletions(-) create mode 100644 apps/weblibre/lib/features/app_links/domain/entities/app_link_rule.dart create mode 100644 apps/weblibre/lib/features/app_links/domain/entities/app_link_rule.g.dart create mode 100644 apps/weblibre/lib/features/app_links/domain/entities/context_app_link_policy.dart create mode 100644 apps/weblibre/lib/features/app_links/domain/entities/context_app_link_policy.g.dart create mode 100644 apps/weblibre/lib/features/app_links/domain/services/app_link_policy_replication.dart create mode 100644 apps/weblibre/lib/features/app_links/domain/services/app_link_policy_replication.g.dart create mode 100644 apps/weblibre/lib/features/app_links/domain/services/app_links_coordinator.dart create mode 100644 apps/weblibre/lib/features/app_links/domain/services/app_links_coordinator.g.dart create mode 100644 apps/weblibre/lib/features/app_links/domain/services/effective_app_link_policy.dart create mode 100644 apps/weblibre/lib/features/app_links/domain/services/effective_app_link_policy.g.dart create mode 100644 apps/weblibre/lib/features/app_links/domain/services/effective_routing.dart create mode 100644 apps/weblibre/lib/features/app_links/presentation/widgets/app_link_open_banner.dart create mode 100644 apps/weblibre/lib/features/app_links/presentation/widgets/app_link_prompt_dialog.dart create mode 100644 apps/weblibre/lib/features/app_links/presentation/widgets/app_link_prompt_host.dart create mode 100644 apps/weblibre/lib/features/app_links/presentation/widgets/container_app_link_settings_dialog.dart create mode 100644 apps/weblibre/lib/features/geckoview/features/browser/presentation/widgets/sheets/app_link_section.dart create mode 100644 apps/weblibre/test/features/app_links/app_link_prompt_rules_test.dart create mode 100644 apps/weblibre/test/features/app_links/app_link_rule_test.dart create mode 100644 apps/weblibre/test/features/app_links/effective_app_link_policy_test.dart create mode 100644 apps/weblibre/test/features/app_links/effective_routing_test.dart create mode 100644 apps/weblibre/test/features/app_links/general_settings_app_links_test.dart create mode 100644 packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/applinks/AppLinkClassifier.kt create mode 100644 packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/applinks/AppLinkHostNormalizer.kt create mode 100644 packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/applinks/AppLinkLauncher.kt create mode 100644 packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/applinks/AppLinkPolicyMapper.kt create mode 100644 packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/applinks/AppLinkPolicyStore.kt create mode 100644 packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/applinks/AppLinkRuntime.kt create mode 100644 packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/applinks/AppLinkSchemes.kt create mode 100644 packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/applinks/ExternalAppResolver.kt create mode 100644 packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/applinks/MonotonicClock.kt create mode 100644 packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/applinks/NativeAppLinkPromptFeature.kt create mode 100644 packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/applinks/PackageResolver.kt create mode 100644 packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/applinks/PendingAppLinkStore.kt create mode 100644 packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/applinks/WebLibreAppLinksInterceptor.kt create mode 100644 packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/middleware/AppLinkNavigationMiddleware.kt delete mode 100644 packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/middleware/AppLinksCancelRetryMiddleware.kt create mode 100644 packages/flutter_mozilla_components/android/src/test/kotlin/eu/weblibre/flutter_mozilla_components/applinks/AppLinkClassifierTest.kt create mode 100644 packages/flutter_mozilla_components/android/src/test/kotlin/eu/weblibre/flutter_mozilla_components/applinks/AppLinkHostNormalizerTest.kt create mode 100644 packages/flutter_mozilla_components/android/src/test/kotlin/eu/weblibre/flutter_mozilla_components/applinks/AppLinkLauncherTest.kt create mode 100644 packages/flutter_mozilla_components/android/src/test/kotlin/eu/weblibre/flutter_mozilla_components/applinks/AppLinkSchemesTest.kt create mode 100644 packages/flutter_mozilla_components/android/src/test/kotlin/eu/weblibre/flutter_mozilla_components/applinks/PendingAppLinkStoreTest.kt diff --git a/apps/weblibre/lib/features/app_links/domain/entities/app_link_rule.dart b/apps/weblibre/lib/features/app_links/domain/entities/app_link_rule.dart new file mode 100644 index 00000000..c319dd6b --- /dev/null +++ b/apps/weblibre/lib/features/app_links/domain/entities/app_link_rule.dart @@ -0,0 +1,95 @@ +/* + * Copyright (c) 2024-2026 Fabian Freund. + * + * This file is part of WebLibre + * (see https://weblibre.eu). + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +import 'package:copy_with_extension/copy_with_extension.dart'; +import 'package:fast_equatable/fast_equatable.dart'; +import 'package:json_annotation/json_annotation.dart'; + +part 'app_link_rule.g.dart'; + +enum AppLinkRuleDecision { alwaysOpen, neverOpen } + +/// A remembered per-scope app-link rule (persistence contract, §2.5/§2.9). +/// +/// Stored in `GeneralSettings.appLinkRules` as `Map` +/// keyed by [scope] — one canonical rule per scope, upsert/last-write-wins. The +/// [scope] is a native-owned canonical key (`host:youtube.com` | `pkg:...`) that +/// Dart persists opaquely and never reconstructs. +@CopyWith() +@JsonSerializable() +class PersistedAppLinkRule with FastEquatable { + final AppLinkRuleDecision decision; + + /// Canonical scope key: `host:` or `pkg:`. + final String scope; + + /// Resolved package name. Required for [AppLinkRuleDecision.alwaysOpen] + /// (binds the launch target); null for [AppLinkRuleDecision.neverOpen]. + final String? packageName; + + PersistedAppLinkRule({ + required this.decision, + required this.scope, + this.packageName, + }); + + factory PersistedAppLinkRule.fromJson(Map json) => + _$PersistedAppLinkRuleFromJson(json); + + Map toJson() => _$PersistedAppLinkRuleToJson(this); + + /// Whether this rule is internally consistent: an `alwaysOpen` rule must bind + /// a package; the scope must be a recognised canonical key. + bool get isValid { + if (scope.isEmpty) return false; + final hasKnownPrefix = + scope.startsWith('host:') || scope.startsWith('pkg:'); + if (!hasKnownPrefix) return false; + if (decision == AppLinkRuleDecision.alwaysOpen && + (packageName == null || packageName!.isEmpty)) { + return false; + } + return true; + } + + @override + List get hashParameters => [decision, scope, packageName]; +} + +/// Parse the persisted rule map, dropping malformed rules (with a warning) and +/// entries whose map key disagrees with the rule's own scope (§2.9). +Map parseAppLinkRules( + Map? json, +) { + if (json == null) return const {}; + final result = {}; + for (final MapEntry(:key, :value) in json.entries) { + if (value is! Map) continue; + final PersistedAppLinkRule rule; + try { + rule = PersistedAppLinkRule.fromJson(value); + } catch (_) { + continue; + } + if (rule.scope != key) continue; + if (!rule.isValid) continue; + result[key] = rule; + } + return result; +} diff --git a/apps/weblibre/lib/features/app_links/domain/entities/app_link_rule.g.dart b/apps/weblibre/lib/features/app_links/domain/entities/app_link_rule.g.dart new file mode 100644 index 00000000..4fbdf1ea --- /dev/null +++ b/apps/weblibre/lib/features/app_links/domain/entities/app_link_rule.g.dart @@ -0,0 +1,110 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'app_link_rule.dart'; + +// ************************************************************************** +// CopyWithGenerator +// ************************************************************************** + +abstract class _$PersistedAppLinkRuleCWProxy { + PersistedAppLinkRule decision(AppLinkRuleDecision decision); + + PersistedAppLinkRule scope(String scope); + + PersistedAppLinkRule packageName(String? packageName); + + /// Creates a new instance with the provided field values. + /// Passing `null` to a nullable field nullifies it, while `null` for a non-nullable field is ignored. To update a single field use `PersistedAppLinkRule(...).copyWith.fieldName(value)`. + /// + /// Example: + /// ```dart + /// PersistedAppLinkRule(...).copyWith(id: 12, name: "My name") + /// ``` + PersistedAppLinkRule call({ + AppLinkRuleDecision decision, + String scope, + String? packageName, + }); +} + +/// Callable proxy for `copyWith` functionality. +/// Use as `instanceOfPersistedAppLinkRule.copyWith(...)` or call `instanceOfPersistedAppLinkRule.copyWith.fieldName(value)` for a single field. +class _$PersistedAppLinkRuleCWProxyImpl + implements _$PersistedAppLinkRuleCWProxy { + const _$PersistedAppLinkRuleCWProxyImpl(this._value); + + final PersistedAppLinkRule _value; + + @override + PersistedAppLinkRule decision(AppLinkRuleDecision decision) => + call(decision: decision); + + @override + PersistedAppLinkRule scope(String scope) => call(scope: scope); + + @override + PersistedAppLinkRule packageName(String? packageName) => + call(packageName: packageName); + + @override + /// Creates a new instance with the provided field values. + /// Passing `null` to a nullable field nullifies it, while `null` for a non-nullable field is ignored. To update a single field use `PersistedAppLinkRule(...).copyWith.fieldName(value)`. + /// + /// Example: + /// ```dart + /// PersistedAppLinkRule(...).copyWith(id: 12, name: "My name") + /// ``` + PersistedAppLinkRule call({ + Object? decision = const $CopyWithPlaceholder(), + Object? scope = const $CopyWithPlaceholder(), + Object? packageName = const $CopyWithPlaceholder(), + }) { + return PersistedAppLinkRule( + decision: decision == const $CopyWithPlaceholder() || decision == null + ? _value.decision + // ignore: cast_nullable_to_non_nullable + : decision as AppLinkRuleDecision, + scope: scope == const $CopyWithPlaceholder() || scope == null + ? _value.scope + // ignore: cast_nullable_to_non_nullable + : scope as String, + packageName: packageName == const $CopyWithPlaceholder() + ? _value.packageName + // ignore: cast_nullable_to_non_nullable + : packageName as String?, + ); + } +} + +extension $PersistedAppLinkRuleCopyWith on PersistedAppLinkRule { + /// Returns a callable class used to build a new instance with modified fields. + /// Example: `instanceOfPersistedAppLinkRule.copyWith(...)` or `instanceOfPersistedAppLinkRule.copyWith.fieldName(...)`. + // ignore: library_private_types_in_public_api + _$PersistedAppLinkRuleCWProxy get copyWith => + _$PersistedAppLinkRuleCWProxyImpl(this); +} + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +PersistedAppLinkRule _$PersistedAppLinkRuleFromJson( + Map json, +) => PersistedAppLinkRule( + decision: $enumDecode(_$AppLinkRuleDecisionEnumMap, json['decision']), + scope: json['scope'] as String, + packageName: json['packageName'] as String?, +); + +Map _$PersistedAppLinkRuleToJson( + PersistedAppLinkRule instance, +) => { + 'decision': _$AppLinkRuleDecisionEnumMap[instance.decision]!, + 'scope': instance.scope, + 'packageName': instance.packageName, +}; + +const _$AppLinkRuleDecisionEnumMap = { + AppLinkRuleDecision.alwaysOpen: 'alwaysOpen', + AppLinkRuleDecision.neverOpen: 'neverOpen', +}; diff --git a/apps/weblibre/lib/features/app_links/domain/entities/context_app_link_policy.dart b/apps/weblibre/lib/features/app_links/domain/entities/context_app_link_policy.dart new file mode 100644 index 00000000..dce0c4eb --- /dev/null +++ b/apps/weblibre/lib/features/app_links/domain/entities/context_app_link_policy.dart @@ -0,0 +1,81 @@ +/* + * Copyright (c) 2024-2026 Fabian Freund. + * + * This file is part of WebLibre + * (see https://weblibre.eu). + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +import 'package:copy_with_extension/copy_with_extension.dart'; +import 'package:fast_equatable/fast_equatable.dart'; +import 'package:flutter_mozilla_components/flutter_mozilla_components.dart' + show AppLinksMode; +import 'package:json_annotation/json_annotation.dart'; +import 'package:weblibre/features/app_links/domain/entities/app_link_rule.dart'; + +part 'context_app_link_policy.g.dart'; + +/// A container's self-contained app-link policy, used when the container has +/// "isolated app link settings" enabled (replace semantics — it fully takes the +/// place of the global mode + rules for navigations in that container). +/// +/// Stored in `GeneralSettings.appLinkContextOverrides` keyed by the container's +/// Gecko contextId (`contextualIdentity`). Only isolated containers have an +/// entry; the snapshot builder synthesises a blank-slate default for a freshly +/// isolated container that has not customised anything yet. +@CopyWith() +@JsonSerializable() +class ContextAppLinkPolicy with FastEquatable { + /// The container's own global app-links mode (default [AppLinksMode.ask]). + final AppLinksMode mode; + + /// The container's own remembered per-scope rules, keyed by canonical scope + /// (`host:youtube.com` | `pkg:...`). Same shape/validation as the global + /// [GeneralSettings.appLinkRules]; malformed entries are dropped on read. + @JsonKey(fromJson: parseAppLinkRules) + final Map rules; + + ContextAppLinkPolicy({required this.mode, required this.rules}); + + /// The blank-slate policy a container starts from when it is first isolated. + ContextAppLinkPolicy.blank() : this(mode: AppLinksMode.ask, rules: const {}); + + factory ContextAppLinkPolicy.fromJson(Map json) => + _$ContextAppLinkPolicyFromJson(json); + + Map toJson() => _$ContextAppLinkPolicyToJson(this); + + @override + List get hashParameters => [mode, rules]; +} + +/// Parse the persisted override map, dropping malformed entries (§2.9 style). +/// Keys are contextIds; the interceptor only ever consults entries whose +/// contextId belongs to a currently-isolated container, so an orphaned entry +/// (container deleted / isolation turned off) is inert. +Map parseAppLinkContextOverrides( + Map? json, +) { + if (json == null) return const {}; + final result = {}; + for (final MapEntry(:key, :value) in json.entries) { + if (value is! Map) continue; + try { + result[key] = ContextAppLinkPolicy.fromJson(value); + } catch (_) { + continue; + } + } + return result; +} diff --git a/apps/weblibre/lib/features/app_links/domain/entities/context_app_link_policy.g.dart b/apps/weblibre/lib/features/app_links/domain/entities/context_app_link_policy.g.dart new file mode 100644 index 00000000..9770aeb9 --- /dev/null +++ b/apps/weblibre/lib/features/app_links/domain/entities/context_app_link_policy.g.dart @@ -0,0 +1,97 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'context_app_link_policy.dart'; + +// ************************************************************************** +// CopyWithGenerator +// ************************************************************************** + +abstract class _$ContextAppLinkPolicyCWProxy { + ContextAppLinkPolicy mode(AppLinksMode mode); + + ContextAppLinkPolicy rules(Map rules); + + /// Creates a new instance with the provided field values. + /// Passing `null` to a nullable field nullifies it, while `null` for a non-nullable field is ignored. To update a single field use `ContextAppLinkPolicy(...).copyWith.fieldName(value)`. + /// + /// Example: + /// ```dart + /// ContextAppLinkPolicy(...).copyWith(id: 12, name: "My name") + /// ``` + ContextAppLinkPolicy call({ + AppLinksMode mode, + Map rules, + }); +} + +/// Callable proxy for `copyWith` functionality. +/// Use as `instanceOfContextAppLinkPolicy.copyWith(...)` or call `instanceOfContextAppLinkPolicy.copyWith.fieldName(value)` for a single field. +class _$ContextAppLinkPolicyCWProxyImpl + implements _$ContextAppLinkPolicyCWProxy { + const _$ContextAppLinkPolicyCWProxyImpl(this._value); + + final ContextAppLinkPolicy _value; + + @override + ContextAppLinkPolicy mode(AppLinksMode mode) => call(mode: mode); + + @override + ContextAppLinkPolicy rules(Map rules) => + call(rules: rules); + + @override + /// Creates a new instance with the provided field values. + /// Passing `null` to a nullable field nullifies it, while `null` for a non-nullable field is ignored. To update a single field use `ContextAppLinkPolicy(...).copyWith.fieldName(value)`. + /// + /// Example: + /// ```dart + /// ContextAppLinkPolicy(...).copyWith(id: 12, name: "My name") + /// ``` + ContextAppLinkPolicy call({ + Object? mode = const $CopyWithPlaceholder(), + Object? rules = const $CopyWithPlaceholder(), + }) { + return ContextAppLinkPolicy( + mode: mode == const $CopyWithPlaceholder() || mode == null + ? _value.mode + // ignore: cast_nullable_to_non_nullable + : mode as AppLinksMode, + rules: rules == const $CopyWithPlaceholder() || rules == null + ? _value.rules + // ignore: cast_nullable_to_non_nullable + : rules as Map, + ); + } +} + +extension $ContextAppLinkPolicyCopyWith on ContextAppLinkPolicy { + /// Returns a callable class used to build a new instance with modified fields. + /// Example: `instanceOfContextAppLinkPolicy.copyWith(...)` or `instanceOfContextAppLinkPolicy.copyWith.fieldName(...)`. + // ignore: library_private_types_in_public_api + _$ContextAppLinkPolicyCWProxy get copyWith => + _$ContextAppLinkPolicyCWProxyImpl(this); +} + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +ContextAppLinkPolicy _$ContextAppLinkPolicyFromJson( + Map json, +) => ContextAppLinkPolicy( + mode: $enumDecode(_$AppLinksModeEnumMap, json['mode']), + rules: parseAppLinkRules(json['rules'] as Map?), +); + +Map _$ContextAppLinkPolicyToJson( + ContextAppLinkPolicy instance, +) => { + 'mode': _$AppLinksModeEnumMap[instance.mode]!, + 'rules': instance.rules.map((k, e) => MapEntry(k, e.toJson())), +}; + +const _$AppLinksModeEnumMap = { + AppLinksMode.always: 'always', + AppLinksMode.ask: 'ask', + AppLinksMode.never: 'never', +}; diff --git a/apps/weblibre/lib/features/app_links/domain/services/app_link_policy_replication.dart b/apps/weblibre/lib/features/app_links/domain/services/app_link_policy_replication.dart new file mode 100644 index 00000000..530261d3 --- /dev/null +++ b/apps/weblibre/lib/features/app_links/domain/services/app_link_policy_replication.dart @@ -0,0 +1,273 @@ +/* + * Copyright (c) 2024-2026 Fabian Freund. + * + * This file is part of WebLibre + * (see https://weblibre.eu). + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +import 'dart:async'; + +import 'package:flutter_mozilla_components/flutter_mozilla_components.dart' + hide ProtectedTargetPattern; +import 'package:flutter_mozilla_components/flutter_mozilla_components.dart' + as pigeon + show ProtectedTargetPattern; +import 'package:riverpod_annotation/riverpod_annotation.dart'; +import 'package:synchronized/synchronized.dart'; +import 'package:weblibre/core/logger.dart'; +import 'package:weblibre/features/app_links/domain/entities/app_link_rule.dart'; +import 'package:weblibre/features/app_links/domain/entities/context_app_link_policy.dart'; +import 'package:weblibre/features/app_links/domain/services/effective_routing.dart'; +import 'package:weblibre/features/geckoview/features/tabs/domain/providers.dart'; +import 'package:weblibre/features/user/data/models/general_settings.dart'; +import 'package:weblibre/features/user/data/models/proxy_routing_settings.dart'; +import 'package:weblibre/features/user/domain/repositories/general_settings.dart'; +import 'package:weblibre/features/user/domain/repositories/proxy_routing_settings.dart'; + +part 'app_link_policy_replication.g.dart'; + +/// Effective app-link protection (§2.3), recomputed whenever routing, strict +/// mode, contextual identities, or site assignments change. +@Riverpod(keepAlive: true) +AppLinkProtection appLinkProtection(Ref ref) { + final routing = ref.watch(proxyRoutingSettingsWithDefaultsProvider); + final protectGeneralContext = + routing.regularTabsMode == ProxyRegularTabRoutingMode.all && + routing.regularTabsProxyConnectionId != null; + + final containers = + ref.watch(watchContainersWithCountProvider).value ?? const []; + final isolationMap = + ref.watch(watchIsolatedContextContainerMapProvider).value ?? const {}; + final strict = + ref.watch(watchStrictContextAssignmentsProvider).value ?? const {}; + final sites = ref.watch(watchAllAssignedSitesProvider).value ?? const []; + + return computeAppLinkProtection( + protectGeneralContext: protectGeneralContext, + containers: containers, + isolationContextContainerMap: isolationMap, + strictContextIds: strict.keys.toSet(), + siteAssignments: sites, + ); +} + +/// The complete policy snapshot to push, or null until the real persisted +/// settings have loaded (the `...WithDefaults` loading placeholder is not valid +/// input, §2.8). Combines the user-intent settings with computed protection. +@Riverpod(keepAlive: true) +AppLinkPolicySnapshot? appLinkPolicySnapshot(Ref ref) { + final settings = ref.watch(generalSettingsRepositoryProvider).value; + if (settings == null) return null; + + // Don't push a snapshot until the protection/override inputs have actually + // loaded (§2.8). `appLinkProtection` and `_computeContextOverrides` fall back to + // empty collections while these streams are still loading; pushing that would + // briefly persist "no protected contexts / no overrides" to native and let a + // protected or isolated container's links leak out during startup. Native keeps + // last session's persisted snapshot until the real one is ready. + final containersLoaded = ref.watch(watchContainersWithCountProvider).hasValue; + final isolationLoaded = ref + .watch(watchIsolatedContextContainerMapProvider) + .hasValue; + final strictLoaded = ref.watch(watchStrictContextAssignmentsProvider).hasValue; + final sitesLoaded = ref.watch(watchAllAssignedSitesProvider).hasValue; + // The real proxy-routing settings drive `protectGeneralContext`; the + // `...WithDefaults` view silently substitutes defaults while the row loads, + // which would compute "general context not proxied" and let a globally-proxied + // setup auto-launch during startup. Wait for the actual value. + final routingLoaded = ref + .watch(proxyRoutingSettingsRepositoryProvider) + .hasValue; + if (!containersLoaded || + !isolationLoaded || + !strictLoaded || + !sitesLoaded || + !routingLoaded) { + return null; + } + + final protection = ref.watch(appLinkProtectionProvider); + + return AppLinkPolicySnapshot( + globalMode: settings.appLinksMode, + rules: { + for (final MapEntry(:key, :value) in settings.appLinkRules.entries) + key: _toNativeRule(value), + }, + marketplaceFallbackEnabled: settings.appLinkMarketplaceFallback, + protectGeneralContext: protection.protectGeneralContext, + protectedContextIds: protection.protectedContextIds.toList(), + strictContextIds: protection.strictContextIds.toList(), + protectedTargetPatterns: protection.protectedTargetPatterns + .map(_toNativePattern) + .toList(), + contextOverrides: _computeContextOverrides(ref, settings), + ); +} + +/// Build the per-container override map (§ container isolation): one entry per +/// container whose "isolated app link settings" toggle is on and which has a +/// contextId. A freshly isolated container with no stored override still gets a +/// blank-slate entry so its "replace" behaviour takes effect immediately rather +/// than silently falling back to the global policy. +/// +/// The override is published under the container's base contextId **and** under +/// every active isolation context id belonging to that container: isolated tabs +/// (`tab_mode = 2`) load under their own `isolation_context_id`, which is the +/// `session.contextId` the native interceptor keys the lookup on — so without the +/// fan-out isolated tabs would silently fall back to the global policy (mirrors +/// how `computeAppLinkProtection` expands protection to isolation contexts). When +/// an isolation context is shared by several isolated-app-link containers, the +/// container with the lowest sorted base contextId wins (deterministic). +Map _computeContextOverrides( + Ref ref, + GeneralSettings settings, +) { + final containers = + ref.watch(watchContainersWithCountProvider).value ?? const []; + final isolationMap = + ref.watch(watchIsolatedContextContainerMapProvider).value ?? const {}; + + NativeContextAppLinkPolicy toNative(ContextAppLinkPolicy policy) { + return NativeContextAppLinkPolicy( + mode: policy.mode, + rules: { + for (final MapEntry(:key, :value) in policy.rules.entries) + key: _toNativeRule(value), + }, + ); + } + + // Base contextId -> native override, plus containerId -> base contextId for the + // isolation-context fan-out below (only isolated-app-link containers). + final overrideByBaseContextId = {}; + final baseContextIdByContainerId = {}; + for (final container in containers) { + final contextId = container.metadata.contextualIdentity; + if (contextId == null || !container.metadata.isolatedAppLinkSettings) { + continue; + } + overrideByBaseContextId[contextId] = toNative( + settings.appLinkContextOverrides[contextId] ?? + ContextAppLinkPolicy.blank(), + ); + baseContextIdByContainerId[container.id] = contextId; + } + + final overrides = { + ...overrideByBaseContextId, + }; + + for (final MapEntry(key: isolationContextId, value: containerIds) + in isolationMap.entries) { + final baseIds = + containerIds + .map((id) => baseContextIdByContainerId[id]) + .nonNulls + .toList() + ..sort(); + if (baseIds.isEmpty) continue; + // A base contextId never collides with an isolation context id, but guard + // so a real container's own entry always wins if one ever did. + overrides.putIfAbsent( + isolationContextId, + () => overrideByBaseContextId[baseIds.first]!, + ); + } + + return overrides; +} + +NativeAppLinkRule _toNativeRule(PersistedAppLinkRule rule) { + return NativeAppLinkRule( + decision: switch (rule.decision) { + AppLinkRuleDecision.alwaysOpen => NativeAppLinkRuleDecision.alwaysOpen, + AppLinkRuleDecision.neverOpen => NativeAppLinkRuleDecision.neverOpen, + }, + scope: rule.scope, + packageName: rule.packageName, + ); +} + +pigeon.ProtectedTargetPattern _toNativePattern(ProtectedTargetPattern pattern) { + return pigeon.ProtectedTargetPattern( + scheme: pattern.scheme, + hostOrSuffix: pattern.hostOrSuffix, + includeSubdomains: pattern.includeSubdomains, + port: pattern.port, + ); +} + +/// Single serialised writer that mirrors the Dart-owned app-link policy to the +/// native profile-scoped store (§2.8), the sole policy source consulted by the +/// interceptor. Structured like `ProxySettingsReplication`; mounted from app root +/// after initialisation. +@Riverpod(keepAlive: true) +class AppLinkPolicyReplication extends _$AppLinkPolicyReplication { + final _appLinks = GeckoAppLinksService(); + + final _pushLock = Lock(); + // Coalesces the most recent snapshot while a push is in flight; genuinely + // nullable (no snapshot pushed yet). + // ignore: use_late_for_private_fields_and_variables + AppLinkPolicySnapshot? _latest; + var _pushDirty = false; + + Future _queuePush(AppLinkPolicySnapshot snapshot) async { + _latest = snapshot; + _pushDirty = true; + if (_pushLock.inLock) return; + + await _pushLock.synchronized(() async { + while (_pushDirty) { + _pushDirty = false; + final pending = _latest!; + try { + await _appLinks.setAppLinkPolicy(pending); + } catch (error, stackTrace) { + // `setAppLinkPolicy` before a profile is bound is an error the + // replicator retries after initialisation (§2.8). + logger.w( + 'Failed to push app-link policy; will retry', + error: error, + stackTrace: stackTrace, + ); + _pushDirty = true; + await Future.delayed(const Duration(seconds: 1)); + } + } + }); + } + + @override + void build() { + ref.listen( + fireImmediately: true, + appLinkPolicySnapshotProvider, + (previous, next) { + if (next == null) return; + unawaited(_queuePush(next)); + }, + onError: (error, stackTrace) { + logger.e( + 'Error computing app-link policy snapshot', + error: error, + stackTrace: stackTrace, + ); + }, + ); + } +} diff --git a/apps/weblibre/lib/features/app_links/domain/services/app_link_policy_replication.g.dart b/apps/weblibre/lib/features/app_links/domain/services/app_link_policy_replication.g.dart new file mode 100644 index 00000000..24c96746 --- /dev/null +++ b/apps/weblibre/lib/features/app_links/domain/services/app_link_policy_replication.g.dart @@ -0,0 +1,194 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'app_link_policy_replication.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint, type=warning +/// Effective app-link protection (§2.3), recomputed whenever routing, strict +/// mode, contextual identities, or site assignments change. + +@ProviderFor(appLinkProtection) +final appLinkProtectionProvider = AppLinkProtectionProvider._(); + +/// Effective app-link protection (§2.3), recomputed whenever routing, strict +/// mode, contextual identities, or site assignments change. + +final class AppLinkProtectionProvider + extends + $FunctionalProvider< + AppLinkProtection, + AppLinkProtection, + AppLinkProtection + > + with $Provider { + /// Effective app-link protection (§2.3), recomputed whenever routing, strict + /// mode, contextual identities, or site assignments change. + AppLinkProtectionProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'appLinkProtectionProvider', + isAutoDispose: false, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$appLinkProtectionHash(); + + @$internal + @override + $ProviderElement $createElement( + $ProviderPointer pointer, + ) => $ProviderElement(pointer); + + @override + AppLinkProtection create(Ref ref) { + return appLinkProtection(ref); + } + + /// {@macro riverpod.override_with_value} + Override overrideWithValue(AppLinkProtection value) { + return $ProviderOverride( + origin: this, + providerOverride: $SyncValueProvider(value), + ); + } +} + +String _$appLinkProtectionHash() => r'6aab203c9b7d2f2c8a73684ea0b624e09fe6179f'; + +/// The complete policy snapshot to push, or null until the real persisted +/// settings have loaded (the `...WithDefaults` loading placeholder is not valid +/// input, §2.8). Combines the user-intent settings with computed protection. + +@ProviderFor(appLinkPolicySnapshot) +final appLinkPolicySnapshotProvider = AppLinkPolicySnapshotProvider._(); + +/// The complete policy snapshot to push, or null until the real persisted +/// settings have loaded (the `...WithDefaults` loading placeholder is not valid +/// input, §2.8). Combines the user-intent settings with computed protection. + +final class AppLinkPolicySnapshotProvider + extends + $FunctionalProvider< + AppLinkPolicySnapshot?, + AppLinkPolicySnapshot?, + AppLinkPolicySnapshot? + > + with $Provider { + /// The complete policy snapshot to push, or null until the real persisted + /// settings have loaded (the `...WithDefaults` loading placeholder is not valid + /// input, §2.8). Combines the user-intent settings with computed protection. + AppLinkPolicySnapshotProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'appLinkPolicySnapshotProvider', + isAutoDispose: false, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$appLinkPolicySnapshotHash(); + + @$internal + @override + $ProviderElement $createElement( + $ProviderPointer pointer, + ) => $ProviderElement(pointer); + + @override + AppLinkPolicySnapshot? create(Ref ref) { + return appLinkPolicySnapshot(ref); + } + + /// {@macro riverpod.override_with_value} + Override overrideWithValue(AppLinkPolicySnapshot? value) { + return $ProviderOverride( + origin: this, + providerOverride: $SyncValueProvider(value), + ); + } +} + +String _$appLinkPolicySnapshotHash() => + r'7f700b67d3b7b0b435fe82a98de455c6e374a1a2'; + +/// Single serialised writer that mirrors the Dart-owned app-link policy to the +/// native profile-scoped store (§2.8), the sole policy source consulted by the +/// interceptor. Structured like `ProxySettingsReplication`; mounted from app root +/// after initialisation. + +@ProviderFor(AppLinkPolicyReplication) +final appLinkPolicyReplicationProvider = AppLinkPolicyReplicationProvider._(); + +/// Single serialised writer that mirrors the Dart-owned app-link policy to the +/// native profile-scoped store (§2.8), the sole policy source consulted by the +/// interceptor. Structured like `ProxySettingsReplication`; mounted from app root +/// after initialisation. +final class AppLinkPolicyReplicationProvider + extends $NotifierProvider { + /// Single serialised writer that mirrors the Dart-owned app-link policy to the + /// native profile-scoped store (§2.8), the sole policy source consulted by the + /// interceptor. Structured like `ProxySettingsReplication`; mounted from app root + /// after initialisation. + AppLinkPolicyReplicationProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'appLinkPolicyReplicationProvider', + isAutoDispose: false, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$appLinkPolicyReplicationHash(); + + @$internal + @override + AppLinkPolicyReplication create() => AppLinkPolicyReplication(); + + /// {@macro riverpod.override_with_value} + Override overrideWithValue(void value) { + return $ProviderOverride( + origin: this, + providerOverride: $SyncValueProvider(value), + ); + } +} + +String _$appLinkPolicyReplicationHash() => + r'866e749328bef9f65c2124585d8c03d798802563'; + +/// Single serialised writer that mirrors the Dart-owned app-link policy to the +/// native profile-scoped store (§2.8), the sole policy source consulted by the +/// interceptor. Structured like `ProxySettingsReplication`; mounted from app root +/// after initialisation. + +abstract class _$AppLinkPolicyReplication extends $Notifier { + void build(); + @$mustCallSuper + @override + WhenComplete runBuild() { + final ref = this.ref as $Ref; + final element = + ref.element + as $ClassProviderElement< + AnyNotifier, + void, + Object?, + Object? + >; + return element.handleCreate(ref, build); + } +} diff --git a/apps/weblibre/lib/features/app_links/domain/services/app_links_coordinator.dart b/apps/weblibre/lib/features/app_links/domain/services/app_links_coordinator.dart new file mode 100644 index 00000000..570a046d --- /dev/null +++ b/apps/weblibre/lib/features/app_links/domain/services/app_links_coordinator.dart @@ -0,0 +1,165 @@ +/* + * Copyright (c) 2024-2026 Fabian Freund. + * + * This file is part of WebLibre + * (see https://weblibre.eu). + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +import 'package:flutter_mozilla_components/flutter_mozilla_components.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; +import 'package:weblibre/core/logger.dart'; +import 'package:weblibre/features/app_links/domain/entities/app_link_rule.dart'; +import 'package:weblibre/features/app_links/domain/entities/context_app_link_policy.dart'; +import 'package:weblibre/features/app_links/domain/services/effective_app_link_policy.dart'; +import 'package:weblibre/features/geckoview/features/tabs/domain/providers.dart'; +import 'package:weblibre/features/user/data/models/general_settings.dart'; +import 'package:weblibre/features/user/domain/repositories/general_settings.dart'; + +part 'app_links_coordinator.g.dart'; + +/// Receives the native availability signal for Flutter-owned prompts. The event +/// is optimisation-only (no buffering/replay); the store query is authoritative. +class _AppLinkEventsReceiver extends GeckoAppLinkEvents { + _AppLinkEventsReceiver(this._onAvailable); + + final void Function(AppLinkPromptOwner owner) _onAvailable; + + @override + void onAppLinkPromptAvailable(int sequence, AppLinkPromptOwner owner) { + _onAvailable(owner); + } +} + +/// Orchestrates Flutter-owned app-link prompts (§2.6): registers the availability +/// event handler, queries the native pending store on attach/resume/event, and +/// exposes resolution (including the remember-then-resolve flow). The presented +/// list is authoritative from the query and deduped by `requestId` — the event +/// is only a nudge to re-query. +@Riverpod(keepAlive: true) +class AppLinksCoordinator extends _$AppLinksCoordinator { + final _service = GeckoAppLinksService(); + + @override + List build() { + final receiver = _AppLinkEventsReceiver((owner) { + if (owner == AppLinkPromptOwner.flutterBrowser) { + // ignore: discarded_futures + refresh(); + } + }); + GeckoAppLinkEvents.setUp(receiver); + ref.onDispose(() => GeckoAppLinkEvents.setUp(null)); + + // Initial query; the returned future updates state when it completes. + // ignore: discarded_futures + refresh(); + + return const []; + } + + /// Re-query the native pending store (called on attach, lifecycle resume, and + /// when the availability event fires). + Future refresh() async { + try { + final prompts = await _service.getPendingAppLinkPrompts( + AppLinkPromptOwner.flutterBrowser, + ); + logger.i( + 'app-link refresh -> ${prompts.length} prompt(s): ' + '${prompts.map((p) => '${p.requestId}@${p.tabId}(${p.isModal ? 'modal' : 'banner'})').toList()}', + ); + state = prompts; + } catch (error, stackTrace) { + logger.w( + 'Failed to query pending app-link prompts', + error: error, + stackTrace: stackTrace, + ); + } + } + + /// Resolve a pending prompt and re-query. + Future resolve( + int requestId, + AppLinkDecision decision, + ) async { + final result = await _service.resolvePendingAppLink(requestId, decision); + await refresh(); + return result; + } + + /// Remember-then-resolve (§2.6): persist the rule to `GeneralSettings` first so + /// it is replicated to native, then resolve the still-pending request. + /// + /// [contextId] is the source tab's live contextId (from the prompt request) — + /// the container's base contextId for a regular tab, or the tab's + /// `isolation_context_id` for an isolated tab. When it resolves to a container + /// with "isolated app link settings" enabled, the rule is written to that + /// container's own override bucket (`appLinkContextOverrides`, keyed by the + /// container's base contextId) rather than the global [GeneralSettings.appLinkRules], + /// keeping the two rule sets separate (replace semantics). + Future resolveWithRule( + int requestId, + AppLinkDecision decision, + PersistedAppLinkRule rule, { + String? contextId, + }) async { + final overrideKey = await _overrideKeyForContext(contextId); + + await ref.read(generalSettingsRepositoryProvider.notifier).updateSettings(( + current, + ) { + if (overrideKey != null) { + final existing = + current.appLinkContextOverrides[overrideKey] ?? + ContextAppLinkPolicy.blank(); + final updated = existing.copyWith.rules({ + ...existing.rules, + rule.scope: rule, + }); + return current.copyWith.appLinkContextOverrides({ + ...current.appLinkContextOverrides, + overrideKey: updated, + }); + } + return current.copyWith.appLinkRules({ + ...current.appLinkRules, + rule.scope: rule, + }); + }); + return resolve(requestId, decision); + } + + /// Resolve the source tab's live [contextId] to the override storage key — the + /// base contextId of the owning isolated-app-link container — or null to write + /// globally. Handles both a regular tab (contextId is already the container + /// base) and an isolated tab (contextId is an `isolation_context_id` mapping to + /// its container). Delegates to [resolveAppLinkOverrideKey] so writes land in + /// the bucket that is published back to native. + Future _overrideKeyForContext(String? contextId) async { + if (contextId == null) return null; + + final containers = await ref.read(watchContainersWithCountProvider.future); + final isolationMap = await ref.read( + watchIsolatedContextContainerMapProvider.future, + ); + + return resolveAppLinkOverrideKey( + liveContextId: contextId, + containers: containers, + isolationContextContainerMap: isolationMap, + ); + } +} diff --git a/apps/weblibre/lib/features/app_links/domain/services/app_links_coordinator.g.dart b/apps/weblibre/lib/features/app_links/domain/services/app_links_coordinator.g.dart new file mode 100644 index 00000000..5cf2e47b --- /dev/null +++ b/apps/weblibre/lib/features/app_links/domain/services/app_links_coordinator.g.dart @@ -0,0 +1,90 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'app_links_coordinator.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint, type=warning +/// Orchestrates Flutter-owned app-link prompts (§2.6): registers the availability +/// event handler, queries the native pending store on attach/resume/event, and +/// exposes resolution (including the remember-then-resolve flow). The presented +/// list is authoritative from the query and deduped by `requestId` — the event +/// is only a nudge to re-query. + +@ProviderFor(AppLinksCoordinator) +final appLinksCoordinatorProvider = AppLinksCoordinatorProvider._(); + +/// Orchestrates Flutter-owned app-link prompts (§2.6): registers the availability +/// event handler, queries the native pending store on attach/resume/event, and +/// exposes resolution (including the remember-then-resolve flow). The presented +/// list is authoritative from the query and deduped by `requestId` — the event +/// is only a nudge to re-query. +final class AppLinksCoordinatorProvider + extends $NotifierProvider> { + /// Orchestrates Flutter-owned app-link prompts (§2.6): registers the availability + /// event handler, queries the native pending store on attach/resume/event, and + /// exposes resolution (including the remember-then-resolve flow). The presented + /// list is authoritative from the query and deduped by `requestId` — the event + /// is only a nudge to re-query. + AppLinksCoordinatorProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'appLinksCoordinatorProvider', + isAutoDispose: false, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$appLinksCoordinatorHash(); + + @$internal + @override + AppLinksCoordinator create() => AppLinksCoordinator(); + + /// {@macro riverpod.override_with_value} + Override overrideWithValue(List value) { + return $ProviderOverride( + origin: this, + providerOverride: $SyncValueProvider>(value), + ); + } +} + +String _$appLinksCoordinatorHash() => + r'183fc7ac1264a63c24b1d10f4a22cbfbf6046da7'; + +/// Orchestrates Flutter-owned app-link prompts (§2.6): registers the availability +/// event handler, queries the native pending store on attach/resume/event, and +/// exposes resolution (including the remember-then-resolve flow). The presented +/// list is authoritative from the query and deduped by `requestId` — the event +/// is only a nudge to re-query. + +abstract class _$AppLinksCoordinator + extends $Notifier> { + List build(); + @$mustCallSuper + @override + WhenComplete runBuild() { + final ref = + this.ref + as $Ref, List>; + final element = + ref.element + as $ClassProviderElement< + AnyNotifier< + List, + List + >, + List, + Object?, + Object? + >; + return element.handleCreate(ref, build); + } +} diff --git a/apps/weblibre/lib/features/app_links/domain/services/effective_app_link_policy.dart b/apps/weblibre/lib/features/app_links/domain/services/effective_app_link_policy.dart new file mode 100644 index 00000000..7942783d --- /dev/null +++ b/apps/weblibre/lib/features/app_links/domain/services/effective_app_link_policy.dart @@ -0,0 +1,151 @@ +/* + * Copyright (c) 2024-2026 Fabian Freund. + * + * This file is part of WebLibre + * (see https://weblibre.eu). + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +import 'package:fast_equatable/fast_equatable.dart'; +import 'package:flutter_mozilla_components/flutter_mozilla_components.dart' + show AppLinksMode; +import 'package:riverpod_annotation/riverpod_annotation.dart'; +import 'package:weblibre/features/app_links/domain/entities/app_link_rule.dart'; +import 'package:weblibre/features/app_links/domain/entities/context_app_link_policy.dart'; +import 'package:weblibre/features/geckoview/features/tabs/data/models/container_data.dart'; +import 'package:weblibre/features/geckoview/features/tabs/domain/providers.dart'; +import 'package:weblibre/features/user/domain/repositories/general_settings.dart'; + +part 'effective_app_link_policy.g.dart'; + +/// Resolve a tab's live contextId to the app-link override storage key — the +/// base contextId of the container with "isolated app link settings" enabled +/// that governs the tab — or null when the global mode + rules apply. +/// +/// [liveContextId] is the container's base contextId for a regular tab, or the +/// tab's `isolation_context_id` for an isolated tab (i.e. `TabState.contextId` +/// as reported by the engine). Uses the same lowest-sorted-base tiebreak as the +/// snapshot builder so lookups land on the bucket that is published to native. +String? resolveAppLinkOverrideKey({ + required String? liveContextId, + required List containers, + required Map> isolationContextContainerMap, +}) { + if (liveContextId == null) return null; + + // Regular tab: liveContextId is a container's own base contextId. + for (final container in containers) { + if (container.metadata.contextualIdentity == liveContextId) { + return container.metadata.isolatedAppLinkSettings ? liveContextId : null; + } + } + + // Isolated tab: liveContextId is an isolation context shared by one or more + // containers; pick the isolated-app-link one with the lowest base contextId. + final containerIds = isolationContextContainerMap[liveContextId]; + if (containerIds == null || containerIds.isEmpty) return null; + + final byId = {for (final container in containers) container.id: container}; + final baseIds = + containerIds + .map((id) => byId[id]) + .nonNulls + .where( + (container) => + container.metadata.isolatedAppLinkSettings && + container.metadata.contextualIdentity != null, + ) + .map((container) => container.metadata.contextualIdentity!) + .toList() + ..sort(); + return baseIds.isEmpty ? null : baseIds.first; +} + +/// The app-link policy effectively governing a tab: the global mode + rules, +/// or the owning container's override when it has isolated app-link settings +/// (replace semantics). Used by the site settings sheet to display and edit +/// the settings in the bucket that actually applies to the shown tab. +class EffectiveAppLinkPolicy with FastEquatable { + /// The override storage key (container base contextId), or null when the + /// global bucket governs the tab. + final String? overrideKey; + + /// Display name of the governing container; null when global. + final String? containerName; + + /// The effective open-links-in-apps mode. + final AppLinksMode mode; + + /// The effective remembered rules, keyed by canonical scope + /// (`host:` | `pkg:`). + final Map rules; + + EffectiveAppLinkPolicy({ + required this.overrideKey, + required this.containerName, + required this.mode, + required this.rules, + }); + + /// Whether the tab is governed by a container override (true) or the global + /// bucket (false). + bool get isContainerScoped => overrideKey != null; + + @override + List get hashParameters => [overrideKey, containerName, mode, rules]; +} + +/// Compute the [EffectiveAppLinkPolicy] for a tab's live contextId. Returns +/// null until the container/isolation inputs have loaded — resolving against +/// empty placeholders could misattribute an isolated container's tab to the +/// global bucket, so callers show a loading state instead. +@Riverpod() +EffectiveAppLinkPolicy? effectiveAppLinkPolicy(Ref ref, String? liveContextId) { + final settings = ref.watch(generalSettingsWithDefaultsProvider); + final containers = ref.watch(watchContainersWithCountProvider).value; + final isolationMap = ref + .watch(watchIsolatedContextContainerMapProvider) + .value; + if (containers == null || isolationMap == null) return null; + + final overrideKey = resolveAppLinkOverrideKey( + liveContextId: liveContextId, + containers: containers, + isolationContextContainerMap: isolationMap, + ); + + if (overrideKey == null) { + return EffectiveAppLinkPolicy( + overrideKey: null, + containerName: null, + mode: settings.appLinksMode, + rules: settings.appLinkRules, + ); + } + + final override = + settings.appLinkContextOverrides[overrideKey] ?? + ContextAppLinkPolicy.blank(); + final containerName = containers + .where((c) => c.metadata.contextualIdentity == overrideKey) + .firstOrNull + ?.name; + + return EffectiveAppLinkPolicy( + overrideKey: overrideKey, + containerName: containerName, + mode: override.mode, + rules: override.rules, + ); +} diff --git a/apps/weblibre/lib/features/app_links/domain/services/effective_app_link_policy.g.dart b/apps/weblibre/lib/features/app_links/domain/services/effective_app_link_policy.g.dart new file mode 100644 index 00000000..5d5e3141 --- /dev/null +++ b/apps/weblibre/lib/features/app_links/domain/services/effective_app_link_policy.g.dart @@ -0,0 +1,118 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'effective_app_link_policy.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint, type=warning +/// Compute the [EffectiveAppLinkPolicy] for a tab's live contextId. Returns +/// null until the container/isolation inputs have loaded — resolving against +/// empty placeholders could misattribute an isolated container's tab to the +/// global bucket, so callers show a loading state instead. + +@ProviderFor(effectiveAppLinkPolicy) +final effectiveAppLinkPolicyProvider = EffectiveAppLinkPolicyFamily._(); + +/// Compute the [EffectiveAppLinkPolicy] for a tab's live contextId. Returns +/// null until the container/isolation inputs have loaded — resolving against +/// empty placeholders could misattribute an isolated container's tab to the +/// global bucket, so callers show a loading state instead. + +final class EffectiveAppLinkPolicyProvider + extends + $FunctionalProvider< + EffectiveAppLinkPolicy?, + EffectiveAppLinkPolicy?, + EffectiveAppLinkPolicy? + > + with $Provider { + /// Compute the [EffectiveAppLinkPolicy] for a tab's live contextId. Returns + /// null until the container/isolation inputs have loaded — resolving against + /// empty placeholders could misattribute an isolated container's tab to the + /// global bucket, so callers show a loading state instead. + EffectiveAppLinkPolicyProvider._({ + required EffectiveAppLinkPolicyFamily super.from, + required String? super.argument, + }) : super( + retry: null, + name: r'effectiveAppLinkPolicyProvider', + isAutoDispose: true, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$effectiveAppLinkPolicyHash(); + + @override + String toString() { + return r'effectiveAppLinkPolicyProvider' + '' + '($argument)'; + } + + @$internal + @override + $ProviderElement $createElement( + $ProviderPointer pointer, + ) => $ProviderElement(pointer); + + @override + EffectiveAppLinkPolicy? create(Ref ref) { + final argument = this.argument as String?; + return effectiveAppLinkPolicy(ref, argument); + } + + /// {@macro riverpod.override_with_value} + Override overrideWithValue(EffectiveAppLinkPolicy? value) { + return $ProviderOverride( + origin: this, + providerOverride: $SyncValueProvider(value), + ); + } + + @override + bool operator ==(Object other) { + return other is EffectiveAppLinkPolicyProvider && + other.argument == argument; + } + + @override + int get hashCode { + return argument.hashCode; + } +} + +String _$effectiveAppLinkPolicyHash() => + r'da8101e842a9cf516eb18d817560813bc0cc94f0'; + +/// Compute the [EffectiveAppLinkPolicy] for a tab's live contextId. Returns +/// null until the container/isolation inputs have loaded — resolving against +/// empty placeholders could misattribute an isolated container's tab to the +/// global bucket, so callers show a loading state instead. + +final class EffectiveAppLinkPolicyFamily extends $Family + with $FunctionalFamilyOverride { + EffectiveAppLinkPolicyFamily._() + : super( + retry: null, + name: r'effectiveAppLinkPolicyProvider', + dependencies: null, + $allTransitiveDependencies: null, + isAutoDispose: true, + ); + + /// Compute the [EffectiveAppLinkPolicy] for a tab's live contextId. Returns + /// null until the container/isolation inputs have loaded — resolving against + /// empty placeholders could misattribute an isolated container's tab to the + /// global bucket, so callers show a loading state instead. + + EffectiveAppLinkPolicyProvider call(String? liveContextId) => + EffectiveAppLinkPolicyProvider._(argument: liveContextId, from: this); + + @override + String toString() => r'effectiveAppLinkPolicyProvider'; +} diff --git a/apps/weblibre/lib/features/app_links/domain/services/effective_routing.dart b/apps/weblibre/lib/features/app_links/domain/services/effective_routing.dart new file mode 100644 index 00000000..3539367c --- /dev/null +++ b/apps/weblibre/lib/features/app_links/domain/services/effective_routing.dart @@ -0,0 +1,343 @@ +/* + * Copyright (c) 2024-2026 Fabian Freund. + * + * This file is part of WebLibre + * (see https://weblibre.eu). + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +/// Shared, pure routing-resolution model (see APP_LINKS_OWN_IMPLEMENTATION_PLAN.md +/// §2.3). Owned by neither `ProxySettingsReplication` nor app-link protection — +/// both consume it so there is exactly one notion of "how is this container +/// routed" and "is this tab effectively proxied". +library; + +import 'package:fast_equatable/fast_equatable.dart'; +import 'package:weblibre/features/geckoview/features/tabs/data/models/container_data.dart'; +import 'package:weblibre/features/geckoview/features/tabs/data/models/site_assignment.dart'; +import 'package:weblibre/features/proxy/data/proxy_connection.dart'; + +/// How a container (or isolation context) is routed after resolving its own +/// proxy settings — before inheriting/aliasing. +sealed class ProxyAssignment with FastEquatable { + ProxyAssignment(); + + /// Follows the global (`general`) routing. + factory ProxyAssignment.inherit() = InheritProxyAssignment; + + /// Explicitly bypasses the global proxy (direct connection), scoped to [scopeId]. + factory ProxyAssignment.direct(String scopeId) = DirectProxyAssignment; + + /// Routed through the proxy identified by [proxyId]. + factory ProxyAssignment.explicit(String proxyId) = ExplicitProxyAssignment; +} + +final class InheritProxyAssignment extends ProxyAssignment { + InheritProxyAssignment(); + + @override + List get hashParameters => const ['inherit']; +} + +final class DirectProxyAssignment extends ProxyAssignment { + final String scopeId; + + DirectProxyAssignment(this.scopeId); + + @override + List get hashParameters => ['direct', scopeId]; +} + +final class ExplicitProxyAssignment extends ProxyAssignment { + final String proxyId; + + ExplicitProxyAssignment(this.proxyId); + + @override + List get hashParameters => ['explicit', proxyId]; +} + +/// Resolve a single container's routing from its own metadata fields. +/// +/// - a set [proxyConnectionId] → `explicit` +/// - no proxy but [bypassGlobalProxy] → `direct` scoped to [contextId] +/// - otherwise → `inherit` +/// +/// This is the one place the per-container `proxyConnectionId`/`bypassGlobalProxy` +/// precedence lives; the proxy replicator and app-link protection both call it. +ProxyAssignment resolveContainerAssignment({ + required String contextId, + required ProxyConnectionId? proxyConnectionId, + required bool bypassGlobalProxy, +}) { + return switch (proxyConnectionId) { + final proxyId? => ProxyAssignment.explicit(proxyId.encode()), + null when bypassGlobalProxy => ProxyAssignment.direct(contextId), + null => ProxyAssignment.inherit(), + }; +} + +/// The result of collapsing the (possibly conflicting) routing of the containers +/// that share an isolation context into a single alias. +class IsolationContextRouting { + /// The assignment the isolation context aliases to. + final ProxyAssignment chosen; + + /// Human-readable label for [chosen] (used in the conflict warning). + final String chosenLabel; + + /// The distinct assignment labels observed, ordered `inherit`, `direct:*`, + /// then proxy ids — used to describe conflicts. + final List assignmentLabels; + + /// Number of distinct assignments; `> 1` means the containers disagree. + final int distinctAssignmentCount; + + IsolationContextRouting({ + required this.chosen, + required this.chosenLabel, + required this.assignmentLabels, + required this.distinctAssignmentCount, + }); +} + +/// Collapse the routing of the containers sharing one isolation context. +/// +/// Precedence: any explicit proxy wins (lowest sorted id); else a direct +/// connection wins only if no container inherits; else inherit. +IsolationContextRouting resolveIsolationContextRouting( + Iterable assignments, +) { + final proxyIds = + assignments + .whereType() + .map((assignment) => assignment.proxyId) + .toSet() + .toList() + ..sort(); + final directScopeIds = + assignments + .whereType() + .map((assignment) => assignment.scopeId) + .toSet() + .toList() + ..sort(); + final hasInheritedAssignment = assignments.any( + (assignment) => assignment is InheritProxyAssignment, + ); + + final chosen = proxyIds.isNotEmpty + ? ProxyAssignment.explicit(proxyIds.first) + : directScopeIds.isNotEmpty && !hasInheritedAssignment + ? ProxyAssignment.direct(directScopeIds.first) + : ProxyAssignment.inherit(); + + final chosenLabel = switch (chosen) { + DirectProxyAssignment(:final scopeId) => 'direct:$scopeId', + ExplicitProxyAssignment(:final proxyId) => proxyId, + InheritProxyAssignment() => 'inherit', + }; + + return IsolationContextRouting( + chosen: chosen, + chosenLabel: chosenLabel, + assignmentLabels: [ + if (hasInheritedAssignment) 'inherit', + ...directScopeIds.map((id) => 'direct:$id'), + ...proxyIds, + ], + distinctAssignmentCount: + proxyIds.length + + directScopeIds.length + + (hasInheritedAssignment ? 1 : 0), + ); +} + +/// Whether a tab whose container resolves to [assignment] is effectively +/// proxied — the app-link "protected context" test (§2.3). +/// +/// - `explicit` → proxied +/// - `direct` → never proxied (deliberately bypasses the global proxy) +/// - `inherit` → proxied iff the global (`general`) route is a proxy +bool isAssignmentProtected( + ProxyAssignment assignment, { + required bool protectGeneralContext, +}) { + return switch (assignment) { + ExplicitProxyAssignment() => true, + DirectProxyAssignment() => false, + InheritProxyAssignment() => protectGeneralContext, + }; +} + +/// A target-side protection pattern (§2.3/§2.8). Any navigation target assigned +/// to an effectively-proxied or strict container is protected independent of the +/// source tab, because site assignment moves the URL into its container +/// *asynchronously*, after the navigation. +class ProtectedTargetPattern with FastEquatable { + final String scheme; + final String hostOrSuffix; + final bool includeSubdomains; + + /// Effective port for exact entries; null for wildcard entries (which ignore + /// port), preserving [siteAssignmentMatches] semantics. + final int? port; + + ProtectedTargetPattern({ + required this.scheme, + required this.hostOrSuffix, + required this.includeSubdomains, + required this.port, + }); + + @override + List get hashParameters => [ + scheme, + hostOrSuffix, + includeSubdomains, + port, + ]; +} + +/// Build the [ProtectedTargetPattern] for a single site-assignment [Uri], +/// preserving [siteAssignmentMatches] semantics: wildcard (`*.host`) entries +/// match apex+subdomains for the scheme and ignore port; exact entries compare +/// scheme + origin (including effective port). +ProtectedTargetPattern protectedTargetPatternForSite(Uri assignedSite) { + if (isWildcardSite(assignedSite)) { + return ProtectedTargetPattern( + scheme: assignedSite.scheme, + hostOrSuffix: assignedSite.host.substring('*.'.length), + includeSubdomains: true, + port: null, + ); + } + + return ProtectedTargetPattern( + scheme: assignedSite.scheme, + hostOrSuffix: assignedSite.host, + includeSubdomains: false, + // Uri.port yields the effective port (scheme default when unspecified), so + // exact entries preserve the effective port as `siteAssignmentMatches` does + // via origin comparison. + port: assignedSite.port, + ); +} + +/// Compute the protected target patterns from all site assignments, keeping only +/// those whose container [contextualIdentity] is effectively proxied or strict +/// ([protectedOrStrictContextIds]). Deduplicated. +List computeProtectedTargetPatterns({ + required Iterable assignments, + required Set protectedOrStrictContextIds, +}) { + final patterns = {}; + for (final assignment in assignments) { + final contextId = assignment.contextualIdentity; + if (contextId == null) continue; + if (!protectedOrStrictContextIds.contains(contextId)) continue; + patterns.add(protectedTargetPatternForSite(assignment.assignedSite)); + } + return patterns.toList(); +} + +/// The complete app-link protection view (§2.3) replicated to native. +class AppLinkProtection with FastEquatable { + /// Regular / no-contextId tabs are proxied via the `general` scope. + final bool protectGeneralContext; + + /// contextIds (containers and isolation contexts) that resolve to a proxy. + final Set protectedContextIds; + + /// strictMode-enforced contextIds, independent of routing. + final Set strictContextIds; + + final List protectedTargetPatterns; + + AppLinkProtection({ + required this.protectGeneralContext, + required this.protectedContextIds, + required this.strictContextIds, + required this.protectedTargetPatterns, + }); + + @override + List get hashParameters => [ + protectGeneralContext, + protectedContextIds, + strictContextIds, + protectedTargetPatterns, + ]; +} + +/// Pure protection computation from the routing/container/assignment inputs +/// (§2.3). A container's contextId is protected when its effective assignment is +/// proxied; an isolation context is protected when the alias it collapses to is +/// proxied; strict contexts are always protected. Target patterns cover any site +/// assigned to a protected or strict container. +AppLinkProtection computeAppLinkProtection({ + required bool protectGeneralContext, + required Iterable containers, + required Map> isolationContextContainerMap, + required Set strictContextIds, + required Iterable siteAssignments, +}) { + final assignmentByContextId = {}; + final assignmentByContainerId = {}; + + for (final container in containers) { + final contextId = container.metadata.contextualIdentity; + if (contextId == null || contextId.isEmpty) continue; + final assignment = resolveContainerAssignment( + contextId: contextId, + proxyConnectionId: container.metadata.proxyConnectionId, + bypassGlobalProxy: container.metadata.bypassGlobalProxy, + ); + assignmentByContextId[contextId] = assignment; + assignmentByContainerId[container.id] = assignment; + } + + final protectedContextIds = {}; + for (final MapEntry(:key, :value) in assignmentByContextId.entries) { + if (isAssignmentProtected(value, protectGeneralContext: protectGeneralContext)) { + protectedContextIds.add(key); + } + } + + for (final MapEntry(:key, :value) in isolationContextContainerMap.entries) { + final assignments = value + .map((containerId) => assignmentByContainerId[containerId]) + .nonNulls + .toList(); + if (assignments.isEmpty) continue; + final chosen = resolveIsolationContextRouting(assignments).chosen; + if (isAssignmentProtected(chosen, protectGeneralContext: protectGeneralContext)) { + protectedContextIds.add(key); + } + } + + final protectedOrStrict = {...protectedContextIds, ...strictContextIds}; + final patterns = computeProtectedTargetPatterns( + assignments: siteAssignments, + protectedOrStrictContextIds: protectedOrStrict, + ); + + return AppLinkProtection( + protectGeneralContext: protectGeneralContext, + protectedContextIds: protectedContextIds, + strictContextIds: strictContextIds, + protectedTargetPatterns: patterns, + ); +} diff --git a/apps/weblibre/lib/features/app_links/presentation/widgets/app_link_open_banner.dart b/apps/weblibre/lib/features/app_links/presentation/widgets/app_link_open_banner.dart new file mode 100644 index 00000000..719d749b --- /dev/null +++ b/apps/weblibre/lib/features/app_links/presentation/widgets/app_link_open_banner.dart @@ -0,0 +1,122 @@ +/* + * Copyright (c) 2024-2026 Fabian Freund. + * + * This file is part of WebLibre + * (see https://weblibre.eu). + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:flutter_mozilla_components/flutter_mozilla_components.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:weblibre/features/app_links/domain/services/app_links_coordinator.dart'; +import 'package:weblibre/features/app_links/presentation/widgets/app_link_prompt_dialog.dart'; + +/// Non-modal banner for an http(s) app link (§2.2). The page is allowed to load +/// while the banner is up; nothing blocks on it. Declining leaves the page +/// loaded; choosing the app leaves the tab on the committed page. +class AppLinkOpenBanner extends HookConsumerWidget { + final AppLinkPromptRequest request; + + const AppLinkOpenBanner({super.key, required this.request}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final target = request.target; + final appName = target.appName; + final remember = useState(false); + final coordinator = ref.read(appLinksCoordinatorProvider.notifier); + final theme = Theme.of(context); + + Future resolve(AppLinkDecision decision) async { + if (remember.value && request.canRemember) { + final rule = decision == AppLinkDecision.open + ? alwaysOpenRuleFor(target) + : neverOpenRuleFor(target); + if (rule != null) { + await coordinator.resolveWithRule( + request.requestId, + decision, + rule, + contextId: request.contextId, + ); + return; + } + } + await coordinator.resolve(request.requestId, decision); + } + + return Material( + elevation: 3, + color: theme.colorScheme.surfaceContainerHigh, + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 8, 8), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + const Icon(Icons.open_in_new, size: 20), + const SizedBox(width: 12), + Expanded( + child: Text( + appName != null + ? 'Open this link in $appName?' + : 'Open this link in an app?', + style: theme.textTheme.bodyMedium, + ), + ), + IconButton( + icon: const Icon(Icons.close), + tooltip: 'Dismiss', + // A back/swipe/cancel resolves as dismiss (§2.6). + onPressed: () => resolve(AppLinkDecision.dismiss), + ), + ], + ), + if (request.canRemember) + Row( + children: [ + Checkbox( + value: remember.value, + onChanged: (value) => remember.value = value ?? false, + ), + const Flexible(child: Text('Remember for this site')), + ], + ), + Align( + alignment: Alignment.centerRight, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + TextButton( + onPressed: () => resolve(AppLinkDecision.cancel), + child: const Text('Stay in browser'), + ), + const SizedBox(width: 8), + FilledButton( + onPressed: () => resolve(AppLinkDecision.open), + child: const Text('Open app'), + ), + ], + ), + ), + ], + ), + ), + ); + } +} diff --git a/apps/weblibre/lib/features/app_links/presentation/widgets/app_link_prompt_dialog.dart b/apps/weblibre/lib/features/app_links/presentation/widgets/app_link_prompt_dialog.dart new file mode 100644 index 00000000..892d7bbc --- /dev/null +++ b/apps/weblibre/lib/features/app_links/presentation/widgets/app_link_prompt_dialog.dart @@ -0,0 +1,131 @@ +/* + * Copyright (c) 2024-2026 Fabian Freund. + * + * This file is part of WebLibre + * (see https://weblibre.eu). + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:flutter_mozilla_components/flutter_mozilla_components.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:weblibre/features/app_links/domain/entities/app_link_rule.dart'; +import 'package:weblibre/features/app_links/domain/services/app_links_coordinator.dart'; + +/// Build the `alwaysOpen` rule for a target, or null when it cannot be remembered +/// (ambiguous resolution / no bound package). +PersistedAppLinkRule? alwaysOpenRuleFor(AppLinkTarget target) { + final packageName = target.packageName; + if (target.isAmbiguous || packageName == null || packageName.isEmpty) { + return null; + } + return PersistedAppLinkRule( + decision: AppLinkRuleDecision.alwaysOpen, + scope: target.scopeKey, + packageName: packageName, + ); +} + +PersistedAppLinkRule neverOpenRuleFor(AppLinkTarget target) { + return PersistedAppLinkRule( + decision: AppLinkRuleDecision.neverOpen, + scope: target.scopeKey, + ); +} + +/// Modal prompt for an unsupported-scheme app link (§2.2). The navigation is +/// genuinely stalled and there is no page to show behind it. +class AppLinkPromptDialog extends HookConsumerWidget { + final AppLinkPromptRequest request; + + const AppLinkPromptDialog({super.key, required this.request}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final target = request.target; + final appName = target.appName; + final remember = useState(false); + final coordinator = ref.read(appLinksCoordinatorProvider.notifier); + // Guards against a double-tap running two resolves + two Navigator.pop()s + // (the second pop would tear down the route beneath the dialog). + final resolving = useRef(false); + + Future resolve(AppLinkDecision decision) async { + if (resolving.value) return; + resolving.value = true; + final navigator = Navigator.of(context); + if (remember.value && request.canRemember) { + final rule = decision == AppLinkDecision.open + ? alwaysOpenRuleFor(target) + : neverOpenRuleFor(target); + if (rule != null) { + await coordinator.resolveWithRule( + request.requestId, + decision, + rule, + contextId: request.contextId, + ); + navigator.pop(); + return; + } + } + await coordinator.resolve(request.requestId, decision); + navigator.pop(); + } + + return AlertDialog( + icon: const Icon(Icons.open_in_new), + title: Text( + appName != null ? 'Open in $appName?' : 'Open in another app?', + ), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text('This link is handled by an app outside WebLibre.'), + const SizedBox(height: 8), + Text( + _displayScope(target.scopeKey), + style: Theme.of(context).textTheme.bodySmall, + ), + if (request.canRemember) + CheckboxListTile( + contentPadding: EdgeInsets.zero, + controlAffinity: ListTileControlAffinity.leading, + value: remember.value, + onChanged: (value) => remember.value = value ?? false, + title: const Text('Remember my choice for this site'), + ), + ], + ), + actions: [ + TextButton( + onPressed: () => resolve(AppLinkDecision.cancel), + child: const Text('Cancel'), + ), + FilledButton( + onPressed: () => resolve(AppLinkDecision.open), + child: const Text('Open'), + ), + ], + ); + } +} + +String _displayScope(String scope) { + if (scope.startsWith('host:')) return scope.substring('host:'.length); + if (scope.startsWith('pkg:')) return scope.substring('pkg:'.length); + return scope; +} diff --git a/apps/weblibre/lib/features/app_links/presentation/widgets/app_link_prompt_host.dart b/apps/weblibre/lib/features/app_links/presentation/widgets/app_link_prompt_host.dart new file mode 100644 index 00000000..e3f7e8d2 --- /dev/null +++ b/apps/weblibre/lib/features/app_links/presentation/widgets/app_link_prompt_host.dart @@ -0,0 +1,117 @@ +/* + * Copyright (c) 2024-2026 Fabian Freund. + * + * This file is part of WebLibre + * (see https://weblibre.eu). + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:flutter_mozilla_components/flutter_mozilla_components.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:weblibre/features/app_links/domain/services/app_links_coordinator.dart'; +import 'package:weblibre/features/app_links/presentation/widgets/app_link_open_banner.dart'; +import 'package:weblibre/features/app_links/presentation/widgets/app_link_prompt_dialog.dart'; +import 'package:weblibre/features/geckoview/domain/providers/selected_tab.dart'; + +/// Presents Flutter-owned app-link prompts (§2.6): renders at most one banner for +/// the active tab, and drives one modal at a time via [showDialog]. A request is +/// only shown while its originating tab is active. Rotation/teardown is not a +/// dismissal — the request stays pending and is re-presented on the next query. +/// +/// Mount this as a layer of the browser Stack that is positioned *above* the bottom +/// app bar (see `browser.dart`, next to the find-in-page layer). It renders the +/// banner inline, bottom-anchored within that positioned region — Flutter composites +/// over the live GeckoView fine (the toolbars do the same); the only requirement is +/// that the host is not placed underneath the bottom app bar. +class AppLinkPromptHost extends HookConsumerWidget { + const AppLinkPromptHost({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final prompts = ref.watch(appLinksCoordinatorProvider); + final activeTabId = ref.watch(selectedTabProvider); + + // The Pigeon availability event has no replay: an event emitted while Flutter + // was detached is lost, so re-query the native pending store on resume (§2.6). + useOnAppLifecycleStateChange((previous, current) { + if (current == AppLifecycleState.resumed) { + unawaited(ref.read(appLinksCoordinatorProvider.notifier).refresh()); + } + }); + + final activeRequests = prompts + .where((request) => request.tabId == activeTabId) + .toList(); + + final modalRequest = activeRequests + .where((request) => request.isModal) + .lastOrNull; + // At most one banner per tab; a newer banner-class request simply becomes the + // one the UI renders. + final bannerRequest = activeRequests + .where((request) => !request.isModal) + .lastOrNull; + + // A modal is shown at most once per requestId. Rotation/teardown is not a + // dismissal — the request stays pending and is re-presented on the next query + // (a subsequent build re-runs this effect with the still-present id). + final shownModalId = useRef(null); + useEffect(() { + final request = modalRequest; + if (request == null) { + shownModalId.value = null; + return null; + } + if (shownModalId.value == request.requestId) { + return null; + } + shownModalId.value = request.requestId; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!context.mounted) return; + unawaited( + showDialog( + context: context, + builder: (_) => AppLinkPromptDialog(request: request), + ).then((_) { + // Catch-all for a passive dismissal (Android back / touch-outside): + // the dialog buttons resolve the request themselves, but a barrier + // dismiss closes it without resolving, leaving the native request + // pending forever (and `shownModalId` blocks a re-show). Resolving as + // dismiss here is idempotent — if a button already consumed it, the + // native store returns stale and this is a no-op. + unawaited( + ref + .read(appLinksCoordinatorProvider.notifier) + .resolve(request.requestId, AppLinkDecision.dismiss), + ); + }), + ); + }); + return null; + }, [modalRequest?.requestId]); + + if (bannerRequest == null) { + return const SizedBox.shrink(); + } + + return AppLinkOpenBanner( + key: ValueKey(bannerRequest.requestId), + request: bannerRequest, + ); + } +} diff --git a/apps/weblibre/lib/features/app_links/presentation/widgets/container_app_link_settings_dialog.dart b/apps/weblibre/lib/features/app_links/presentation/widgets/container_app_link_settings_dialog.dart new file mode 100644 index 00000000..2fd56f87 --- /dev/null +++ b/apps/weblibre/lib/features/app_links/presentation/widgets/container_app_link_settings_dialog.dart @@ -0,0 +1,178 @@ +/* + * Copyright (c) 2024-2026 Fabian Freund. + * + * This file is part of WebLibre + * (see https://weblibre.eu). + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +import 'package:flutter/material.dart'; +import 'package:flutter_material_design_icons/flutter_material_design_icons.dart'; +import 'package:flutter_mozilla_components/flutter_mozilla_components.dart' + show AppLinksMode; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:weblibre/features/app_links/domain/entities/app_link_rule.dart'; +import 'package:weblibre/features/app_links/domain/entities/context_app_link_policy.dart'; +import 'package:weblibre/features/settings/presentation/controllers/save_settings.dart'; +import 'package:weblibre/features/user/data/models/general_settings.dart'; +import 'package:weblibre/features/user/domain/repositories/general_settings.dart'; + +/// Per-container app-link settings (§ container isolation), bound to +/// `GeneralSettings.appLinkContextOverrides[contextId]`. Mirrors the global +/// app-links section but writes into the container's own override bucket, which +/// fully replaces the global mode + rules for that container (replace semantics). +/// +/// Present via `showDialog`; edits save live (no separate confirm step), matching +/// the global settings screen. Only meaningful for an isolated, cookie-isolated +/// container — the caller gates on that. +class ContainerAppLinkSettingsDialog extends ConsumerWidget { + /// The container's Gecko contextId (`contextualIdentity`); the override key. + final String contextId; + + /// Optional container name for the title. + final String? containerName; + + const ContainerAppLinkSettingsDialog({ + super.key, + required this.contextId, + this.containerName, + }); + + Future _updateOverride( + WidgetRef ref, + ContextAppLinkPolicy Function(ContextAppLinkPolicy current) update, + ) async { + await ref + .read(saveGeneralSettingsControllerProvider.notifier) + .save((current) { + final existing = + current.appLinkContextOverrides[contextId] ?? + ContextAppLinkPolicy.blank(); + return current.copyWith.appLinkContextOverrides({ + ...current.appLinkContextOverrides, + contextId: update(existing), + }); + }); + } + + @override + Widget build(BuildContext context, WidgetRef ref) { + final override = ref.watch( + generalSettingsWithDefaultsProvider.select( + (s) => s.appLinkContextOverrides[contextId], + ), + ); + final policy = override ?? ContextAppLinkPolicy.blank(); + + final rules = policy.rules.entries.toList() + ..sort((a, b) => a.key.compareTo(b.key)); + + return Dialog.fullscreen( + child: Scaffold( + appBar: AppBar( + title: Text( + containerName != null + ? 'App Links — $containerName' + : 'Container App Links', + ), + leading: IconButton( + icon: const Icon(Icons.close), + onPressed: () => Navigator.of(context).pop(), + ), + ), + body: ListView( + padding: const EdgeInsets.symmetric(vertical: 8), + children: [ + const Padding( + padding: EdgeInsets.fromLTRB(16, 8, 16, 0), + child: Text( + 'These settings apply only to this container and fully replace ' + 'the global app-link settings for its tabs.', + ), + ), + RadioGroup( + groupValue: policy.mode, + onChanged: (value) async { + if (value != null) { + await _updateOverride(ref, (c) => c.copyWith.mode(value)); + } + }, + child: const Column( + children: [ + RadioListTile.adaptive( + value: AppLinksMode.always, + title: Text('Always'), + subtitle: Text( + 'Always open links in their native apps without asking', + ), + ), + RadioListTile.adaptive( + value: AppLinksMode.ask, + title: Text('Ask before opening'), + subtitle: Text('Show a prompt before opening links in apps'), + ), + RadioListTile.adaptive( + value: AppLinksMode.never, + title: Text('Never'), + subtitle: Text( + 'Always open links in the browser instead of apps', + ), + ), + ], + ), + ), + if (rules.isNotEmpty) ...[ + const Divider(), + const Padding( + padding: EdgeInsets.fromLTRB(16, 8, 16, 4), + child: Text('Remembered site rules'), + ), + for (final MapEntry(:key, :value) in rules) + ListTile( + dense: true, + leading: Icon( + value.decision == AppLinkRuleDecision.alwaysOpen + ? MdiIcons.openInApp + : Icons.public, + ), + title: Text(_displayScope(key)), + subtitle: Text( + value.decision == AppLinkRuleDecision.alwaysOpen + ? 'Always open in the app' + : 'Always keep in the browser', + ), + trailing: IconButton( + icon: const Icon(Icons.delete_outline), + tooltip: 'Remove rule', + onPressed: () async { + await _updateOverride( + ref, + (c) => c.copyWith.rules({...c.rules}..remove(key)), + ); + }, + ), + ), + ], + ], + ), + ), + ); + } +} + +String _displayScope(String scope) { + if (scope.startsWith('host:')) return scope.substring('host:'.length); + if (scope.startsWith('pkg:')) return scope.substring('pkg:'.length); + return scope; +} diff --git a/apps/weblibre/lib/features/geckoview/features/browser/domain/providers.dart b/apps/weblibre/lib/features/geckoview/features/browser/domain/providers.dart index 6e8c6a18..7c83cff2 100644 --- a/apps/weblibre/lib/features/geckoview/features/browser/domain/providers.dart +++ b/apps/weblibre/lib/features/geckoview/features/browser/domain/providers.dart @@ -1267,18 +1267,3 @@ class _TabGroupRecord { required this.dateKey, }); } - -@Riverpod() -class AppLinksModeNotifier extends _$AppLinksModeNotifier { - final _service = GeckoEngineSettingsService(); - - Future setMode(AppLinksMode mode) async { - await _service.setAppLinksMode(mode); - ref.invalidateSelf(); - } - - @override - Future build() { - return _service.getAppLinksMode(); - } -} diff --git a/apps/weblibre/lib/features/geckoview/features/browser/domain/providers.g.dart b/apps/weblibre/lib/features/geckoview/features/browser/domain/providers.g.dart index a97d22dc..fa70ab96 100644 --- a/apps/weblibre/lib/features/geckoview/features/browser/domain/providers.g.dart +++ b/apps/weblibre/lib/features/geckoview/features/browser/domain/providers.g.dart @@ -1253,48 +1253,3 @@ final class GroupedTabListItemsFamily extends $Family @override String toString() => r'groupedTabListItemsProvider'; } - -@ProviderFor(AppLinksModeNotifier) -final appLinksModeProvider = AppLinksModeNotifierProvider._(); - -final class AppLinksModeNotifierProvider - extends $AsyncNotifierProvider { - AppLinksModeNotifierProvider._() - : super( - from: null, - argument: null, - retry: null, - name: r'appLinksModeProvider', - isAutoDispose: true, - dependencies: null, - $allTransitiveDependencies: null, - ); - - @override - String debugGetCreateSourceHash() => _$appLinksModeNotifierHash(); - - @$internal - @override - AppLinksModeNotifier create() => AppLinksModeNotifier(); -} - -String _$appLinksModeNotifierHash() => - r'2643b7d2799870fd444f7db204ea452d975368a3'; - -abstract class _$AppLinksModeNotifier extends $AsyncNotifier { - FutureOr build(); - @$mustCallSuper - @override - WhenComplete runBuild() { - final ref = this.ref as $Ref, AppLinksMode>; - final element = - ref.element - as $ClassProviderElement< - AnyNotifier, AppLinksMode>, - AsyncValue, - Object?, - Object? - >; - return element.handleCreate(ref, build); - } -} diff --git a/apps/weblibre/lib/features/geckoview/features/browser/domain/services/browser_data.g.dart b/apps/weblibre/lib/features/geckoview/features/browser/domain/services/browser_data.g.dart index b9e8a004..e5f7e30a 100644 --- a/apps/weblibre/lib/features/geckoview/features/browser/domain/services/browser_data.g.dart +++ b/apps/weblibre/lib/features/geckoview/features/browser/domain/services/browser_data.g.dart @@ -42,7 +42,7 @@ final class BrowserDataServiceProvider } String _$browserDataServiceHash() => - r'2df2f652342efc3e16606b92fdef6062b02f72df'; + r'5df7ca0b61a5f34e69280311777e98fc31907269'; abstract class _$BrowserDataService extends $Notifier { void build(); diff --git a/apps/weblibre/lib/features/geckoview/features/browser/domain/services/proxy_settings_replication.dart b/apps/weblibre/lib/features/geckoview/features/browser/domain/services/proxy_settings_replication.dart index 3772a077..5b215c82 100644 --- a/apps/weblibre/lib/features/geckoview/features/browser/domain/services/proxy_settings_replication.dart +++ b/apps/weblibre/lib/features/geckoview/features/browser/domain/services/proxy_settings_replication.dart @@ -22,6 +22,7 @@ import 'package:riverpod/riverpod.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; import 'package:synchronized/synchronized.dart'; import 'package:weblibre/core/logger.dart'; +import 'package:weblibre/features/app_links/domain/services/effective_routing.dart'; import 'package:weblibre/features/geckoview/features/tabs/data/models/container_data.dart'; import 'package:weblibre/features/geckoview/features/tabs/data/providers.dart'; import 'package:weblibre/features/geckoview/features/tabs/domain/providers.dart'; @@ -33,45 +34,10 @@ import 'package:weblibre/features/user/domain/repositories/proxy_routing_setting part 'proxy_settings_replication.g.dart'; -sealed class _ProxyAssignment with FastEquatable { - _ProxyAssignment(); - - factory _ProxyAssignment.inherit() = _InheritProxyAssignment; - - factory _ProxyAssignment.direct(String scopeId) = _DirectProxyAssignment; - - factory _ProxyAssignment.explicit(String proxyId) = _ExplicitProxyAssignment; -} - -final class _InheritProxyAssignment extends _ProxyAssignment { - _InheritProxyAssignment(); - - @override - List get hashParameters => const ['inherit']; -} - -final class _DirectProxyAssignment extends _ProxyAssignment { - final String scopeId; - - _DirectProxyAssignment(this.scopeId); - - @override - List get hashParameters => [scopeId]; -} - -final class _ExplicitProxyAssignment extends _ProxyAssignment { - final String proxyId; - - _ExplicitProxyAssignment(this.proxyId); - - @override - List get hashParameters => [proxyId]; -} - @Riverpod(keepAlive: true) class ProxySettingsReplication extends _$ProxySettingsReplication { - var _isolatedProxyAssignments = {}; - var _appliedContainerProxies = {}; + var _isolatedProxyAssignments = {}; + var _appliedContainerProxies = {}; final _recomputeLock = Lock(); var _recomputeDirty = false; @@ -117,19 +83,17 @@ class ProxySettingsReplication extends _$ProxySettingsReplication { .read(containerRepositoryProvider.notifier) .getAllContainersWithCount(); - final containerAssignments = { + final containerAssignments = { for (final c in containers) if (c.metadata.contextualIdentity case final contextId?) - c.id: switch (c.metadata.proxyConnectionId) { - final proxyId? => _ProxyAssignment.explicit(proxyId.encode()), - null when c.metadata.bypassGlobalProxy => _ProxyAssignment.direct( - contextId, - ), - null => _ProxyAssignment.inherit(), - }, + c.id: resolveContainerAssignment( + contextId: contextId, + proxyConnectionId: c.metadata.proxyConnectionId, + bypassGlobalProxy: c.metadata.bypassGlobalProxy, + ), }; - final newAssignments = {}; + final newAssignments = {}; for (final entry in contextContainerMap.entries) { final assignments = entry.value .map((containerId) => containerAssignments[containerId]) @@ -138,50 +102,19 @@ class ProxySettingsReplication extends _$ProxySettingsReplication { if (assignments.isEmpty) continue; - final proxyIds = - assignments - .whereType<_ExplicitProxyAssignment>() - .map((assignment) => assignment.proxyId) - .toSet() - .toList() - ..sort(); - final directScopeIds = - assignments - .whereType<_DirectProxyAssignment>() - .map((assignment) => assignment.scopeId) - .toSet() - .toList() - ..sort(); - final hasInheritedAssignment = assignments.any( - (assignment) => assignment is _InheritProxyAssignment, - ); - - final chosenAssignment = proxyIds.isNotEmpty - ? _ProxyAssignment.explicit(proxyIds.first) - : directScopeIds.isNotEmpty && !hasInheritedAssignment - ? _ProxyAssignment.direct(directScopeIds.first) - : _ProxyAssignment.inherit(); - if (chosenAssignment is! _InheritProxyAssignment) { - newAssignments[entry.key] = chosenAssignment; + final routing = resolveIsolationContextRouting(assignments); + if (routing.chosen is! InheritProxyAssignment) { + newAssignments[entry.key] = routing.chosen; } - final chosenLabel = switch (chosenAssignment) { - _DirectProxyAssignment(:final scopeId) => 'direct:$scopeId', - _ExplicitProxyAssignment(:final proxyId) => proxyId, - _InheritProxyAssignment() => 'inherit', - }; - final distinctAssignmentCount = - proxyIds.length + - directScopeIds.length + - (hasInheritedAssignment ? 1 : 0); - if (distinctAssignmentCount > 1) { + if (routing.distinctAssignmentCount > 1) { // Isolation contexts can hold multiple containers; if they disagree on // routing, the alias is forced to pick one. Surface this so the user // can split the containers across isolation contexts. logger.w( 'Isolation context ${entry.key} has containers with multiple ' 'proxy routing assignments ' - '(${[if (hasInheritedAssignment) 'inherit', ...directScopeIds.map((id) => 'direct:$id'), ...proxyIds].join(', ')}); using $chosenLabel', + '(${routing.assignmentLabels.join(', ')}); using ${routing.chosenLabel}', ); } } @@ -339,20 +272,19 @@ class ProxySettingsReplication extends _$ProxySettingsReplication { ) async { if (containers == null) return; - final desired = {}; + final desired = {}; for (final container in containers) { final contextId = container.metadata.contextualIdentity; if (contextId == null || contextId.isEmpty) continue; - final proxyConnectionId = container.metadata.proxyConnectionId; - desired[contextId] = proxyConnectionId != null - ? _ProxyAssignment.explicit(proxyConnectionId.encode()) - : container.metadata.bypassGlobalProxy - ? _ProxyAssignment.direct(contextId) - : _ProxyAssignment.inherit(); + desired[contextId] = resolveContainerAssignment( + contextId: contextId, + proxyConnectionId: container.metadata.proxyConnectionId, + bypassGlobalProxy: container.metadata.bypassGlobalProxy, + ); } final repo = ref.read(containerProxyRepositoryProvider.notifier); - final nextApplied = Map.from( + final nextApplied = Map.from( _appliedContainerProxies, ); @@ -395,15 +327,15 @@ class ProxySettingsReplication extends _$ProxySettingsReplication { Future _applyProxyAssignment( String contextId, - _ProxyAssignment assignment, + ProxyAssignment assignment, ) async { final repo = ref.read(containerProxyRepositoryProvider.notifier); switch (assignment) { - case _ExplicitProxyAssignment(:final proxyId): + case ExplicitProxyAssignment(:final proxyId): await repo.setContainerProxy(contextId, proxyId); - case _DirectProxyAssignment(:final scopeId): + case DirectProxyAssignment(:final scopeId): await repo.setContainerDirectConnection(contextId, scopeId: scopeId); - case _InheritProxyAssignment(): + case InheritProxyAssignment(): await repo.clearContainerProxy(contextId); } } diff --git a/apps/weblibre/lib/features/geckoview/features/browser/domain/services/proxy_settings_replication.g.dart b/apps/weblibre/lib/features/geckoview/features/browser/domain/services/proxy_settings_replication.g.dart index e2acc1c2..6265a443 100644 --- a/apps/weblibre/lib/features/geckoview/features/browser/domain/services/proxy_settings_replication.g.dart +++ b/apps/weblibre/lib/features/geckoview/features/browser/domain/services/proxy_settings_replication.g.dart @@ -42,7 +42,7 @@ final class ProxySettingsReplicationProvider } String _$proxySettingsReplicationHash() => - r'69787c85c94ff165e3eeb0f0a3f3fc83e88a1b83'; + r'bea07ab165545a6bef8a72ddf0503e0cd135eb8a'; abstract class _$ProxySettingsReplication extends $Notifier { void build(); diff --git a/apps/weblibre/lib/features/geckoview/features/browser/features/contextual_toolbar/data/providers/toolbar_button_configs.dart b/apps/weblibre/lib/features/geckoview/features/browser/features/contextual_toolbar/data/providers/toolbar_button_configs.dart index 6788f361..6eb3a798 100644 --- a/apps/weblibre/lib/features/geckoview/features/browser/features/contextual_toolbar/data/providers/toolbar_button_configs.dart +++ b/apps/weblibre/lib/features/geckoview/features/browser/features/contextual_toolbar/data/providers/toolbar_button_configs.dart @@ -44,7 +44,7 @@ List _buildDefaultToolbarButtonConfigs({ return ToolbarButtonConfig( buttonId: spec.id.name, orderKey: key, - isVisible: allHidden ? false : spec.defaultVisible, + isVisible: !allHidden && spec.defaultVisible, fallbackId: allHidden ? null : spec.defaultFallback?.name, ); }).toList(); diff --git a/apps/weblibre/lib/features/geckoview/features/browser/presentation/screens/browser.dart b/apps/weblibre/lib/features/geckoview/features/browser/presentation/screens/browser.dart index d1e7ea6c..5bd112bd 100644 --- a/apps/weblibre/lib/features/geckoview/features/browser/presentation/screens/browser.dart +++ b/apps/weblibre/lib/features/geckoview/features/browser/presentation/screens/browser.dart @@ -29,6 +29,7 @@ import 'package:weblibre/core/providers/global_drop.dart'; import 'package:weblibre/core/routing/routes.dart'; import 'package:weblibre/data/models/drag_data.dart'; import 'package:weblibre/extensions/media_query.dart'; +import 'package:weblibre/features/app_links/presentation/widgets/app_link_prompt_host.dart'; import 'package:weblibre/features/geckoview/domain/controllers/bottom_sheet.dart'; import 'package:weblibre/features/geckoview/domain/controllers/overlay.dart'; import 'package:weblibre/features/geckoview/domain/entities/states/tab.dart'; @@ -1276,6 +1277,39 @@ class BrowserScreen extends HookConsumerWidget { }, ), ), + + // Layer 7: App-link prompt banner (§2.6). Anchored above the bottom app + // bar / keyboard exactly like find-in-page, so it is never hidden behind + // the toolbar. Custom Tab sessions are prompted natively instead; this is + // the browser-tab surface only. + Consumer( + builder: (context, ref, child) { + final toolbarState = ref.watch( + toolbarVisibilityControllerProvider(selectedTabId), + ); + final visible = + sheetDisplayed || + (!tabInFullScreen && + toolbarState == ToolbarVisibility.visible); + return Positioned( + left: (tabBarPosition == TabBarPosition.left && visible) + ? sideRailTotalWidth + : 0.0, + right: (tabBarPosition == TabBarPosition.right && visible) + ? sideRailTotalWidth + : 0.0, + bottom: math.max( + isRail + ? bottomSafeArea + : (visible + ? bottomAppBarTotalHeight + : bottomSafeArea), + MediaQuery.viewInsetsOf(context).bottom, + ), + child: const AppLinkPromptHost(), + ); + }, + ), ], ), ), diff --git a/apps/weblibre/lib/features/geckoview/features/browser/presentation/widgets/browser_menu_sheet.dart b/apps/weblibre/lib/features/geckoview/features/browser/presentation/widgets/browser_menu_sheet.dart index cd77d8b9..689e28d1 100644 --- a/apps/weblibre/lib/features/geckoview/features/browser/presentation/widgets/browser_menu_sheet.dart +++ b/apps/weblibre/lib/features/geckoview/features/browser/presentation/widgets/browser_menu_sheet.dart @@ -1575,22 +1575,25 @@ class _OpenInAppTile extends HookConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { final tabState = ref.watch(tabStateProvider(selectedTabId)); final url = tabState?.url; - final hasExternalApp = useCachedFuture( - () => url != null ? _service.hasExternalApp(url) : Future.value(false), + final appLink = useCachedFuture( + () => url != null ? _service.resolveAppLink(url) : Future.value(null), [url], ); - if (hasExternalApp.data != true) return const SizedBox.shrink(); + final target = appLink.data; + if (target == null) return const SizedBox.shrink(); + + final appName = target.appName; return Column( children: [ _buildDivider(), ListTile( leading: const Icon(Icons.open_in_new), - title: const Text('Open in App'), + title: Text(appName != null ? 'Open in $appName' : 'Open in App'), onTap: () async { if (url == null) return; - final success = await _service.openAppLink(url); + final success = await _service.launchAppLink(url); if (success && context.mounted) Navigator.pop(context); }, ), diff --git a/apps/weblibre/lib/features/geckoview/features/browser/presentation/widgets/browser_modules/browser_view.dart b/apps/weblibre/lib/features/geckoview/features/browser/presentation/widgets/browser_modules/browser_view.dart index a197f78b..4c9e09db 100644 --- a/apps/weblibre/lib/features/geckoview/features/browser/presentation/widgets/browser_modules/browser_view.dart +++ b/apps/weblibre/lib/features/geckoview/features/browser/presentation/widgets/browser_modules/browser_view.dart @@ -30,6 +30,7 @@ import 'package:weblibre/core/logger.dart'; import 'package:weblibre/core/providers/device_info.dart'; import 'package:weblibre/core/providers/router.dart'; import 'package:weblibre/core/routing/routes.dart'; +import 'package:weblibre/features/app_links/domain/services/app_link_policy_replication.dart'; import 'package:weblibre/features/bangs/data/models/web_search_bang.dart'; import 'package:weblibre/features/bangs/domain/providers/bangs.dart'; import 'package:weblibre/features/bangs/domain/services/search_history_cleanup.dart'; @@ -676,6 +677,19 @@ class _BrowserViewState extends ConsumerState }, ); + ref.listenManual( + fireImmediately: true, + appLinkPolicyReplicationProvider, + (previous, next) {}, + onError: (error, stackTrace) { + logger.e( + 'Error listening to appLinkPolicyReplicationProvider', + error: error, + stackTrace: stackTrace, + ); + }, + ); + ref.listenManual( fireImmediately: true, historyExclusionReplicationProvider, diff --git a/apps/weblibre/lib/features/geckoview/features/browser/presentation/widgets/menu_item_buttons.dart b/apps/weblibre/lib/features/geckoview/features/browser/presentation/widgets/menu_item_buttons.dart index 1ae57a23..4793c9e4 100644 --- a/apps/weblibre/lib/features/geckoview/features/browser/presentation/widgets/menu_item_buttons.dart +++ b/apps/weblibre/lib/features/geckoview/features/browser/presentation/widgets/menu_item_buttons.dart @@ -329,24 +329,27 @@ class OpenInAppMenuItemButton extends HookConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { final tabState = ref.watch(tabStateProvider(selectedTabId)); final url = tabState?.url; - final hasExternalApp = useCachedFuture( + final appLink = useCachedFuture( // ignore: discarded_futures useFuture - () => url != null ? _service.hasExternalApp(url) : Future.value(false), + () => url != null ? _service.resolveAppLink(url) : Future.value(null), [url], ); - if (hasExternalApp.data != true) { + final target = appLink.data; + if (target == null) { return const SizedBox.shrink(); } + final appName = target.appName; + return MenuItemButton( leadingIcon: const Icon(Icons.open_in_new), closeOnActivate: false, - child: const Text('Open in App'), + child: Text(appName != null ? 'Open in $appName' : 'Open in App'), onPressed: () async { if (url == null) return; - final success = await _service.openAppLink(url); + final success = await _service.launchAppLink(url); if (success && context.mounted) { MenuController.maybeOf(context)?.close(); diff --git a/apps/weblibre/lib/features/geckoview/features/browser/presentation/widgets/share_bottom_sheet.dart b/apps/weblibre/lib/features/geckoview/features/browser/presentation/widgets/share_bottom_sheet.dart index f46a5810..1ffaf4ec 100644 --- a/apps/weblibre/lib/features/geckoview/features/browser/presentation/widgets/share_bottom_sheet.dart +++ b/apps/weblibre/lib/features/geckoview/features/browser/presentation/widgets/share_bottom_sheet.dart @@ -357,19 +357,22 @@ class _OpenInAppTile extends HookConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { final tabState = ref.watch(tabStateProvider(selectedTabId)); final url = tabState?.url; - final hasExternalApp = useCachedFuture( - () => url != null ? _service.hasExternalApp(url) : Future.value(false), + final appLink = useCachedFuture( + () => url != null ? _service.resolveAppLink(url) : Future.value(null), [url], ); - if (hasExternalApp.data != true) return const SizedBox.shrink(); + final target = appLink.data; + if (target == null) return const SizedBox.shrink(); + + final appName = target.appName; return ListTile( leading: const Icon(Icons.open_in_new), - title: const Text('Open in App'), + title: Text(appName != null ? 'Open in $appName' : 'Open in App'), onTap: () async { if (url == null) return; - final success = await _service.openAppLink(url); + final success = await _service.launchAppLink(url); if (success && context.mounted) Navigator.pop(context); }, ); diff --git a/apps/weblibre/lib/features/geckoview/features/browser/presentation/widgets/sheets/app_link_section.dart b/apps/weblibre/lib/features/geckoview/features/browser/presentation/widgets/sheets/app_link_section.dart new file mode 100644 index 00000000..d09f6f40 --- /dev/null +++ b/apps/weblibre/lib/features/geckoview/features/browser/presentation/widgets/sheets/app_link_section.dart @@ -0,0 +1,238 @@ +/* + * Copyright (c) 2024-2026 Fabian Freund. + * + * This file is part of WebLibre + * (see https://weblibre.eu). + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +import 'package:flutter/material.dart'; +import 'package:flutter_material_design_icons/flutter_material_design_icons.dart'; +import 'package:flutter_mozilla_components/flutter_mozilla_components.dart' + show AppLinkTarget, AppLinksMode, GeckoAppLinksService; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:skeletonizer/skeletonizer.dart'; +import 'package:weblibre/features/app_links/domain/entities/app_link_rule.dart'; +import 'package:weblibre/features/app_links/domain/entities/context_app_link_policy.dart'; +import 'package:weblibre/features/app_links/domain/services/effective_app_link_policy.dart'; +import 'package:weblibre/features/settings/presentation/controllers/save_settings.dart'; +import 'package:weblibre/features/user/data/models/general_settings.dart'; + +final _appLinkTargetProvider = FutureProvider.autoDispose + .family((ref, url) { + return GeckoAppLinksService().resolveAppLink(url); + }); + +enum _SiteRuleChoice { followDefault, alwaysOpen, neverOpen } + +/// Section widget showing the app-link rule for the current tab's site. Edits +/// the effective bucket — the owning container's override when it has isolated +/// app-link settings, otherwise the global rules — but does not expose the +/// global/container default from this site-specific sheet. +class AppLinkSection extends HookConsumerWidget { + final Uri url; + + /// The tab's live contextId (`TabState.contextId`): the container base + /// contextId for a regular tab, the isolation contextId for an isolated tab. + final String? contextId; + + const AppLinkSection({required this.url, required this.contextId, super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final policy = ref.watch(effectiveAppLinkPolicyProvider(contextId)); + final target = ref.watch(_appLinkTargetProvider(url)); + final isLoadingTarget = target.isLoading && !target.hasValue; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0), + child: Text( + 'App Links', + style: Theme.of(context).textTheme.titleSmall?.copyWith( + color: Theme.of(context).colorScheme.primary, + ), + ), + ), + if (policy == null || isLoadingTarget) + const Skeletonizer( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + ListTile( + leading: Icon(Icons.link), + title: Text('Open links for this site'), + subtitle: Text('Follows the default'), + ), + ], + ), + ) + else + _SiteRuleTile( + policy: policy, + target: target.hasValue ? target.value : null, + ), + ], + ); + } +} + +class _SiteRuleTile extends ConsumerWidget { + final EffectiveAppLinkPolicy policy; + final AppLinkTarget? target; + + const _SiteRuleTile({required this.policy, required this.target}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final scope = target?.scopeKey; + final rule = (scope != null && scope.isNotEmpty) + ? policy.rules[scope] + : null; + final choice = switch (rule?.decision) { + AppLinkRuleDecision.alwaysOpen => _SiteRuleChoice.alwaysOpen, + AppLinkRuleDecision.neverOpen => _SiteRuleChoice.neverOpen, + null => _SiteRuleChoice.followDefault, + }; + final canAlwaysOpen = _alwaysOpenRuleFor(target) != null; + + final colorScheme = Theme.of(context).colorScheme; + + final (IconData icon, Color color) = switch (choice) { + _SiteRuleChoice.alwaysOpen => (MdiIcons.openInApp, colorScheme.primary), + _SiteRuleChoice.neverOpen => (Icons.public, colorScheme.primary), + _SiteRuleChoice.followDefault => ( + Icons.link, + colorScheme.onSurfaceVariant, + ), + }; + + return ListTile( + leading: Icon(icon, color: color), + title: const Text('Open links for this site'), + subtitle: Text(_subtitle(scope, rule, choice, canAlwaysOpen)), + trailing: DropdownButton<_SiteRuleChoice>( + value: choice, + underline: const SizedBox(), + items: [ + const DropdownMenuItem( + value: _SiteRuleChoice.followDefault, + child: Text('Follow default'), + ), + DropdownMenuItem( + value: _SiteRuleChoice.alwaysOpen, + enabled: canAlwaysOpen || choice == _SiteRuleChoice.alwaysOpen, + child: const Text('Open in app'), + ), + const DropdownMenuItem( + value: _SiteRuleChoice.neverOpen, + child: Text('Keep in browser'), + ), + ], + onChanged: scope == null || scope.isEmpty + ? null + : (value) async { + if (value != null && value != choice) { + await _setSiteRule(ref, scope, target, value); + } + }, + ), + ); + } + + String _subtitle( + String? scope, + PersistedAppLinkRule? rule, + _SiteRuleChoice choice, + bool canAlwaysOpen, + ) { + if (scope == null || scope.isEmpty) return 'No app found for this site'; + return switch (choice) { + _SiteRuleChoice.alwaysOpen => + 'Always opens in ${rule!.packageName ?? 'the app'}', + _SiteRuleChoice.neverOpen => 'Always stays in the browser', + _SiteRuleChoice.followDefault => switch (policy.mode) { + AppLinksMode.always => + canAlwaysOpen + ? 'Follows the default: opens in apps' + : 'Follows the default: no app found', + AppLinksMode.ask => 'Follows the default: asks first', + AppLinksMode.never => 'Follows the default: stays in the browser', + }, + }; + } + + Future _setSiteRule( + WidgetRef ref, + String scope, + AppLinkTarget? target, + _SiteRuleChoice choice, + ) async { + Map updateRules( + Map rules, + ) { + final next = {...rules}; + switch (choice) { + case _SiteRuleChoice.followDefault: + next.remove(scope); + case _SiteRuleChoice.neverOpen: + next[scope] = PersistedAppLinkRule( + decision: AppLinkRuleDecision.neverOpen, + scope: scope, + ); + case _SiteRuleChoice.alwaysOpen: + final rule = _alwaysOpenRuleFor(target); + if (rule != null) next[scope] = rule; + } + return next; + } + + final overrideKey = policy.overrideKey; + await ref.read(saveGeneralSettingsControllerProvider.notifier).save(( + current, + ) { + if (overrideKey == null) { + return current.copyWith.appLinkRules(updateRules(current.appLinkRules)); + } + final existing = + current.appLinkContextOverrides[overrideKey] ?? + ContextAppLinkPolicy.blank(); + return current.copyWith.appLinkContextOverrides({ + ...current.appLinkContextOverrides, + overrideKey: existing.copyWith.rules(updateRules(existing.rules)), + }); + }); + } +} + +PersistedAppLinkRule? _alwaysOpenRuleFor(AppLinkTarget? target) { + final packageName = target?.packageName; + final scope = target?.scopeKey; + if (target == null || + target.isAmbiguous || + packageName == null || + packageName.isEmpty || + scope == null || + scope.isEmpty) { + return null; + } + return PersistedAppLinkRule( + decision: AppLinkRuleDecision.alwaysOpen, + scope: scope, + packageName: packageName, + ); +} diff --git a/apps/weblibre/lib/features/geckoview/features/browser/presentation/widgets/sheets/view_tab.dart b/apps/weblibre/lib/features/geckoview/features/browser/presentation/widgets/sheets/view_tab.dart index 46615aea..dd79dd8e 100644 --- a/apps/weblibre/lib/features/geckoview/features/browser/presentation/widgets/sheets/view_tab.dart +++ b/apps/weblibre/lib/features/geckoview/features/browser/presentation/widgets/sheets/view_tab.dart @@ -26,6 +26,7 @@ import 'package:weblibre/core/routing/routes.dart'; import 'package:weblibre/features/geckoview/domain/entities/states/tab.dart'; import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/certificate_tile.dart'; import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/draggable_scrollable_header.dart'; +import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/sheets/app_link_section.dart'; import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/sheets/clear_site_data_section.dart'; import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/sheets/desktop_mode_section.dart'; import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/sheets/gesture_exclusion_section.dart'; @@ -164,6 +165,12 @@ class ViewTabSheetWidget extends HookConsumerWidget { url: initialTabState.url, ), const Divider(), + // App Link Section + AppLinkSection( + url: initialTabState.url, + contextId: initialTabState.contextId, + ), + const Divider(), // Permissions Section PermissionsSection( origin: initialTabState.url.origin, diff --git a/apps/weblibre/lib/features/geckoview/features/contextmenu/presentation/candidates/launch_external.dart b/apps/weblibre/lib/features/geckoview/features/contextmenu/presentation/candidates/launch_external.dart index 240b34a8..ae875592 100644 --- a/apps/weblibre/lib/features/geckoview/features/contextmenu/presentation/candidates/launch_external.dart +++ b/apps/weblibre/lib/features/geckoview/features/contextmenu/presentation/candidates/launch_external.dart @@ -23,6 +23,7 @@ import 'package:go_router/go_router.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:nullability/nullability.dart'; import 'package:weblibre/features/geckoview/features/contextmenu/extensions/hit_result.dart'; +import 'package:weblibre/presentation/hooks/cached_future.dart'; class LaunchExternal extends HookConsumerWidget { final HitResult hitResult; @@ -33,19 +34,26 @@ class LaunchExternal extends HookConsumerWidget { static Future isSupported(HitResult hitResult) async { return hitResult.tryGetLink().mapNotNull( - (url) => _service.hasExternalApp(url), + (url) async => (await _service.resolveAppLink(url)) != null, ) ?? false; } @override Widget build(BuildContext context, WidgetRef ref) { + final url = hitResult.tryGetLink(); + final appLink = useCachedFuture( + () => url != null ? _service.resolveAppLink(url) : Future.value(null), + [url], + ); + final appName = appLink.data?.appName; + return ListTile( leading: const Icon(Icons.open_in_new), - title: const Text('Open in App'), + title: Text(appName != null ? 'Open in $appName' : 'Open in App'), onTap: () async { await hitResult.tryGetLink().mapNotNull((url) async { - final success = await _service.openAppLink(url); + final success = await _service.launchAppLink(url); if (success && context.mounted) { context.pop(); diff --git a/apps/weblibre/lib/features/geckoview/features/open_link_tools/presentation/dialogs/open_shared_content.dart b/apps/weblibre/lib/features/geckoview/features/open_link_tools/presentation/dialogs/open_shared_content.dart index c8a4165f..886c0cd5 100644 --- a/apps/weblibre/lib/features/geckoview/features/open_link_tools/presentation/dialogs/open_shared_content.dart +++ b/apps/weblibre/lib/features/geckoview/features/open_link_tools/presentation/dialogs/open_shared_content.dart @@ -184,11 +184,11 @@ class OpenSharedContent extends HookConsumerWidget { }; }, [containerMode, contextId, selectionUrlKey, globalSelectedContainer]); - final hasExternalApp = useCachedFuture( + final appLink = useCachedFuture( // ignore: discarded_futures useFuture () => parsedDebouncedUrl != null - ? _appLinksService.hasExternalApp(parsedDebouncedUrl) - : Future.value(false), + ? _appLinksService.resolveAppLink(parsedDebouncedUrl) + : Future.value(null), [parsedDebouncedUrl], ); @@ -288,7 +288,7 @@ class OpenSharedContent extends HookConsumerWidget { final uri = parseValidatedUrl(textController.text, eagerParsing: false); if (uri == null) return; - final success = await _appLinksService.openAppLink(uri); + final success = await _appLinksService.launchAppLink(uri); if (success && context.mounted) { context.pop(true); @@ -429,9 +429,11 @@ class OpenSharedContent extends HookConsumerWidget { }, ), ], - if (hasExternalApp.data == true) + if (appLink.data != null) _OpenActionTile( - title: 'Open in App', + title: appLink.data?.appName != null + ? 'Open in ${appLink.data!.appName}' + : 'Open in App', subtitle: 'Open in an installed app', icon: Icons.open_in_new, onTap: openInApp, diff --git a/apps/weblibre/lib/features/geckoview/features/tabs/data/database/database.dart b/apps/weblibre/lib/features/geckoview/features/tabs/data/database/database.dart index 73f7f077..7cf85723 100644 --- a/apps/weblibre/lib/features/geckoview/features/tabs/data/database/database.dart +++ b/apps/weblibre/lib/features/geckoview/features/tabs/data/database/database.dart @@ -19,11 +19,8 @@ */ import 'package:drift/drift.dart'; import 'package:drift/internal/versioned_schema.dart'; -import 'package:drift_dev/api/migrations_native.dart'; import 'package:flutter/foundation.dart'; import 'package:lexo_rank/lexo_rank.dart'; -import 'package:weblibre/data/database/functions/lexo_rank_functions.dart'; -import 'package:weblibre/data/database/functions/url_functions.dart'; import 'package:weblibre/features/geckoview/features/tabs/data/database/daos/capture_tab.dart'; import 'package:weblibre/features/geckoview/features/tabs/data/database/daos/container.dart'; import 'package:weblibre/features/geckoview/features/tabs/data/database/daos/history.dart'; diff --git a/apps/weblibre/lib/features/geckoview/features/tabs/data/models/container_data.dart b/apps/weblibre/lib/features/geckoview/features/tabs/data/models/container_data.dart index 9369c040..36b49e37 100644 --- a/apps/weblibre/lib/features/geckoview/features/tabs/data/models/container_data.dart +++ b/apps/weblibre/lib/features/geckoview/features/tabs/data/models/container_data.dart @@ -83,6 +83,16 @@ class ContainerMetadata with FastEquatable { @JsonKey(defaultValue: false) final bool strictMode; + // When true, this container has its own app-link policy (open-in-app mode + + // remembered per-site rules) that fully replaces the global one for its tabs. + // The override itself lives in `GeneralSettings.appLinkContextOverrides` keyed + // by [contextualIdentity]; this flag only gates whether that override is + // consulted. Requires a Gecko contextId — the native interceptor keys the + // override on the tab's contextId, so it is normalized to false when + // [contextualIdentity] is null (mirrors [strictMode]/[excludeFromHistory]). + @JsonKey(defaultValue: false) + final bool isolatedAppLinkSettings; + ContainerMetadata({ required this.iconData, required this.contextualIdentity, @@ -94,6 +104,7 @@ class ContainerMetadata with FastEquatable { required this.useCustomColor, required this.assignedSites, required this.strictMode, + required this.isolatedAppLinkSettings, }); ContainerMetadata.withDefaults({ @@ -107,6 +118,7 @@ class ContainerMetadata with FastEquatable { bool? useCustomColor, List? assignedSites, bool? strictMode, + bool? isolatedAppLinkSettings, }) : this( iconData: iconData, contextualIdentity: contextualIdentity, @@ -128,6 +140,11 @@ class ContainerMetadata with FastEquatable { // normalize away the invalid combination on read, and writers re-apply // it via [sanitized]. strictMode: (strictMode ?? false) && contextualIdentity != null, + // Isolated app-link settings need a contextId — the native interceptor + // keys the override on the tab's contextId. Normalize the invalid + // combination on read; writers re-apply it via [sanitized]. + isolatedAppLinkSettings: + (isolatedAppLinkSettings ?? false) && contextualIdentity != null, ); /// Enforce the [excludeFromHistory] invariant before persistence: it can only @@ -145,6 +162,11 @@ class ContainerMetadata with FastEquatable { if (result.strictMode && result.contextualIdentity == null) { result = result.copyWith(strictMode: false); } + // Isolated app-link settings need a contextId: the interceptor keys the + // override on the tab's contextId. + if (result.isolatedAppLinkSettings && result.contextualIdentity == null) { + result = result.copyWith(isolatedAppLinkSettings: false); + } return result; } @@ -167,6 +189,7 @@ class ContainerMetadata with FastEquatable { useCustomColor, assignedSites, strictMode, + isolatedAppLinkSettings, ]; } diff --git a/apps/weblibre/lib/features/geckoview/features/tabs/data/models/container_data.g.dart b/apps/weblibre/lib/features/geckoview/features/tabs/data/models/container_data.g.dart index de2732e2..666130b7 100644 --- a/apps/weblibre/lib/features/geckoview/features/tabs/data/models/container_data.g.dart +++ b/apps/weblibre/lib/features/geckoview/features/tabs/data/models/container_data.g.dart @@ -27,6 +27,8 @@ abstract class _$ContainerMetadataCWProxy { ContainerMetadata strictMode(bool strictMode); + ContainerMetadata isolatedAppLinkSettings(bool isolatedAppLinkSettings); + /// Creates a new instance with the provided field values. /// Passing `null` to a nullable field nullifies it, while `null` for a non-nullable field is ignored. To update a single field use `ContainerMetadata(...).copyWith.fieldName(value)`. /// @@ -45,6 +47,7 @@ abstract class _$ContainerMetadataCWProxy { bool useCustomColor, List? assignedSites, bool strictMode, + bool isolatedAppLinkSettings, }); } @@ -93,6 +96,10 @@ class _$ContainerMetadataCWProxyImpl implements _$ContainerMetadataCWProxy { @override ContainerMetadata strictMode(bool strictMode) => call(strictMode: strictMode); + @override + ContainerMetadata isolatedAppLinkSettings(bool isolatedAppLinkSettings) => + call(isolatedAppLinkSettings: isolatedAppLinkSettings); + @override /// Creates a new instance with the provided field values. /// Passing `null` to a nullable field nullifies it, while `null` for a non-nullable field is ignored. To update a single field use `ContainerMetadata(...).copyWith.fieldName(value)`. @@ -112,6 +119,7 @@ class _$ContainerMetadataCWProxyImpl implements _$ContainerMetadataCWProxy { Object? useCustomColor = const $CopyWithPlaceholder(), Object? assignedSites = const $CopyWithPlaceholder(), Object? strictMode = const $CopyWithPlaceholder(), + Object? isolatedAppLinkSettings = const $CopyWithPlaceholder(), }) { return ContainerMetadata( iconData: iconData == const $CopyWithPlaceholder() @@ -165,6 +173,12 @@ class _$ContainerMetadataCWProxyImpl implements _$ContainerMetadataCWProxy { ? _value.strictMode // ignore: cast_nullable_to_non_nullable : strictMode as bool, + isolatedAppLinkSettings: + isolatedAppLinkSettings == const $CopyWithPlaceholder() || + isolatedAppLinkSettings == null + ? _value.isolatedAppLinkSettings + // ignore: cast_nullable_to_non_nullable + : isolatedAppLinkSettings as bool, ); } } @@ -308,6 +322,8 @@ ContainerMetadata _$ContainerMetadataFromJson(Map json) => ?.map((e) => Uri.parse(e as String)) .toList(), strictMode: json['strictMode'] as bool? ?? false, + isolatedAppLinkSettings: + json['isolatedAppLinkSettings'] as bool? ?? false, ); Map _$ContainerMetadataToJson( @@ -326,6 +342,7 @@ Map _$ContainerMetadataToJson( 'useCustomColor': instance.useCustomColor, 'assignedSites': instance.assignedSites?.map((e) => e.toString()).toList(), 'strictMode': instance.strictMode, + 'isolatedAppLinkSettings': instance.isolatedAppLinkSettings, }; Value? _$JsonConverterFromJson( diff --git a/apps/weblibre/lib/features/geckoview/features/tabs/domain/repositories/tab.dart b/apps/weblibre/lib/features/geckoview/features/tabs/domain/repositories/tab.dart index 91560a5e..d5fb0ee9 100644 --- a/apps/weblibre/lib/features/geckoview/features/tabs/domain/repositories/tab.dart +++ b/apps/weblibre/lib/features/geckoview/features/tabs/domain/repositories/tab.dart @@ -103,12 +103,10 @@ class TabDataRepository extends _$TabDataRepository { ), // parentId defaults to null - breaks parent chain when changing contextual identity selectTab: selectedTabId == tabState.id, - // Assignment-driven navigation to an assigned site: bypass the - // app-links delegate so cancelling an "open in app" prompt does - // not re-trigger it on the recreated tab's load. - flags: replacementUrl != null - ? LoadUrlFlags.LOAD_FLAGS_BYPASS_LOAD_URI_DELEGATE - : LoadUrlFlags.NONE, + // Assignment-driven navigation is classified in its assigned context + // like any other load; the app-links fallback re-entry map (§2.7) + // covers the redirect loop the old delegate bypass used to guard. + flags: LoadUrlFlags.NONE, ); } } diff --git a/apps/weblibre/lib/features/geckoview/features/tabs/domain/repositories/tab.g.dart b/apps/weblibre/lib/features/geckoview/features/tabs/domain/repositories/tab.g.dart index a340a5b2..d95fd5cb 100644 --- a/apps/weblibre/lib/features/geckoview/features/tabs/domain/repositories/tab.g.dart +++ b/apps/weblibre/lib/features/geckoview/features/tabs/domain/repositories/tab.g.dart @@ -41,7 +41,7 @@ final class TabDataRepositoryProvider } } -String _$tabDataRepositoryHash() => r'adc1c664b492e41a96a0310d92252dbbacc1a089'; +String _$tabDataRepositoryHash() => r'd4eb49e25077aea6de479ea738ec92a213b71f78'; abstract class _$TabDataRepository extends $Notifier { void build(); diff --git a/apps/weblibre/lib/features/geckoview/features/tabs/presentation/screens/container_edit.dart b/apps/weblibre/lib/features/geckoview/features/tabs/presentation/screens/container_edit.dart index 4e457f09..99540064 100644 --- a/apps/weblibre/lib/features/geckoview/features/tabs/presentation/screens/container_edit.dart +++ b/apps/weblibre/lib/features/geckoview/features/tabs/presentation/screens/container_edit.dart @@ -26,6 +26,7 @@ import 'package:flutter_material_design_icons/flutter_material_design_icons.dart import 'package:go_router/go_router.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:weblibre/core/uuid.dart'; +import 'package:weblibre/features/app_links/presentation/widgets/container_app_link_settings_dialog.dart'; import 'package:weblibre/features/geckoview/features/history/domain/repositories/container_history.dart'; import 'package:weblibre/features/geckoview/features/tabs/data/models/container_data.dart'; import 'package:weblibre/features/geckoview/features/tabs/domain/repositories/container.dart'; @@ -40,9 +41,32 @@ import 'package:weblibre/features/geckoview/features/tabs/utils/container_icons. import 'package:weblibre/features/proxy/data/proxy_connection.dart'; import 'package:weblibre/features/proxy/domain/providers/proxy_connection_options.dart'; import 'package:weblibre/features/proxy/domain/repositories/singbox_proxy_profiles.dart'; +import 'package:weblibre/features/user/data/models/general_settings.dart'; +import 'package:weblibre/features/user/domain/repositories/general_settings.dart'; enum _DialogMode { create, edit } +/// Remove any per-container app-link overrides (§ container isolation) stored for +/// [contextIds] in GeneralSettings. Null ids are ignored; a no-op when none are +/// present. Keeps overrides from lingering after a container drops isolation or +/// is deleted. +Future _removeAppLinkOverrides( + WidgetRef ref, + Set contextIds, +) async { + final ids = contextIds.nonNulls.toSet(); + if (ids.isEmpty) return; + + await ref.read(generalSettingsRepositoryProvider.notifier).updateSettings(( + current, + ) { + if (!ids.any(current.appLinkContextOverrides.containsKey)) return current; + return current.copyWith.appLinkContextOverrides( + {...current.appLinkContextOverrides}..removeWhere((key, _) => ids.contains(key)), + ); + }); +} + class ContainerEditScreen extends HookConsumerWidget { final _DialogMode _mode; @@ -109,6 +133,9 @@ class ContainerEditScreen extends HookConsumerWidget { ); final assignedSites = useState(initialContainer.metadata.assignedSites); final strictMode = useState(initialContainer.metadata.strictMode); + final isolatedAppLinkSettings = useState( + initialContainer.metadata.isolatedAppLinkSettings, + ); final isPinned = useState(initialContainer.isPinned); final textController = useTextEditingController( @@ -147,6 +174,12 @@ class ContainerEditScreen extends HookConsumerWidget { // strictness on the tab's cookieStoreId). sanitized() enforces the // same invariant defensively on write. strictMode: strictMode.value && contextualIdentity.value != null, + // Isolated app-link settings require a Gecko contextId (the + // interceptor keys the override on the tab's contextId). + // sanitized() enforces the same invariant defensively on write. + isolatedAppLinkSettings: + isolatedAppLinkSettings.value && + contextualIdentity.value != null, ) .sanitized(), ); @@ -167,6 +200,15 @@ class ContainerEditScreen extends HookConsumerWidget { isPinned: isPinned.value, ); } + // Keep the per-container app-link override in step with the isolation + // toggle: drop it when the container is no longer isolated (or lost its + // contextId) so it can't linger orphaned in GeneralSettings. + if (!container.metadata.isolatedAppLinkSettings) { + await _removeAppLinkOverrides(ref, { + initialContainer.metadata.contextualIdentity, + container.metadata.contextualIdentity, + }); + } return container; } @@ -264,6 +306,11 @@ class ContainerEditScreen extends HookConsumerWidget { .read(containerRepositoryProvider.notifier) .deleteContainer(initialContainer.id); + // Drop the container's app-link override so it doesn't outlive it. + await _removeAppLinkOverrides(ref, { + initialContainer.metadata.contextualIdentity, + }); + if (context.mounted) { context.pop(); } @@ -654,6 +701,79 @@ class ContainerEditScreen extends HookConsumerWidget { ], ), ), + const SizedBox(height: 24), + Text( + 'App Links', + style: theme.textTheme.titleSmall?.copyWith( + color: colorScheme.primary, + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 12), + Card.filled( + margin: EdgeInsets.zero, + color: colorScheme.surfaceContainer, + clipBehavior: Clip.antiAlias, + child: Column( + children: [ + SwitchListTile.adaptive( + value: + contextualIdentity.value != null && + isolatedAppLinkSettings.value, + title: const Text('Isolated App Link Settings'), + subtitle: Text( + contextualIdentity.value != null + ? 'Use a separate open-in-app mode and remembered ' + 'site rules for this container instead of the ' + 'global settings' + : 'Requires cookie isolation to be enabled', + ), + secondary: const Icon(MdiIcons.openInApp), + onChanged: (contextualIdentity.value != null) + ? (value) { + isolatedAppLinkSettings.value = value; + } + : null, + ), + // The per-container mode + rules live in GeneralSettings + // (keyed by the persisted contextId) and are edited live, + // like the global app-link settings. Only offered in edit + // mode against the saved, immutable contextId — a create + // draft's contextId can still churn (cookie-isolation + // toggling regenerates it), which would orphan overrides. + if (_mode == _DialogMode.edit && + initialContainer.metadata.contextualIdentity != + null && + isolatedAppLinkSettings.value) ...[ + const Divider(height: 1, indent: 56), + ListTile( + leading: const Icon(Icons.tune), + title: const Text('App Link Behavior'), + subtitle: const Text( + "Configure this container's open-in-app mode and " + 'remembered sites', + ), + trailing: const Icon(Icons.chevron_right), + onTap: () async { + await showDialog( + context: context, + builder: (context) => + ContainerAppLinkSettingsDialog( + contextId: initialContainer + .metadata + .contextualIdentity!, + containerName: + textController.text.trim().isNotEmpty + ? textController.text.trim() + : initialContainer.name, + ), + ); + }, + ), + ], + ], + ), + ), ], ), ), diff --git a/apps/weblibre/lib/features/settings/presentation/screens/browsing_settings.dart b/apps/weblibre/lib/features/settings/presentation/screens/browsing_settings.dart index 368fceb9..c4055e97 100644 --- a/apps/weblibre/lib/features/settings/presentation/screens/browsing_settings.dart +++ b/apps/weblibre/lib/features/settings/presentation/screens/browsing_settings.dart @@ -23,7 +23,7 @@ import 'package:flutter_mozilla_components/flutter_mozilla_components.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:weblibre/core/design/app_colors.dart'; import 'package:weblibre/core/routing/routes.dart'; -import 'package:weblibre/features/geckoview/features/browser/domain/providers.dart'; +import 'package:weblibre/features/app_links/domain/entities/app_link_rule.dart'; import 'package:weblibre/features/geckoview/features/tabs/domain/providers/selected_container.dart'; import 'package:weblibre/features/settings/presentation/controllers/save_settings.dart'; import 'package:weblibre/features/settings/presentation/widgets/settings_detail.dart'; @@ -709,7 +709,15 @@ class _AppLinksModeSection extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final appLinksMode = ref.watch( - appLinksModeProvider.select((value) => value.value), + generalSettingsWithDefaultsProvider.select((s) => s.appLinksMode), + ); + final marketplaceFallback = ref.watch( + generalSettingsWithDefaultsProvider.select( + (s) => s.appLinkMarketplaceFallback, + ), + ); + final rules = ref.watch( + generalSettingsWithDefaultsProvider.select((s) => s.appLinkRules), ); return Padding( @@ -730,7 +738,9 @@ class _AppLinksModeSection extends HookConsumerWidget { groupValue: appLinksMode, onChanged: (value) async { if (value != null) { - await ref.read(appLinksModeProvider.notifier).setMode(value); + await ref + .read(saveGeneralSettingsControllerProvider.notifier) + .save((current) => current.copyWith.appLinksMode(value)); } }, child: const Column( @@ -757,12 +767,96 @@ class _AppLinksModeSection extends HookConsumerWidget { ], ), ), + SwitchListTile.adaptive( + contentPadding: EdgeInsets.zero, + title: const Text('Offer app store fallback'), + subtitle: const Text( + "When a link points to an app you don't have installed and there " + 'is no web fallback, offer to open the app store', + ), + value: marketplaceFallback, + onChanged: appLinksMode == AppLinksMode.never + ? null + : (value) async { + await ref + .read(saveGeneralSettingsControllerProvider.notifier) + .save( + (current) => + current.copyWith.appLinkMarketplaceFallback(value), + ); + }, + ), + _AppLinkRulesSubsection(rules: rules), ], ), ); } } +/// Managed per-site app-link rules (§2.5): "always open" and "never open" +/// decisions the user remembered from a prompt. Read-only list with removal. +class _AppLinkRulesSubsection extends ConsumerWidget { + final Map rules; + + const _AppLinkRulesSubsection({required this.rules}); + + String _displayScope(String scope) { + if (scope.startsWith('host:')) return scope.substring('host:'.length); + if (scope.startsWith('pkg:')) return scope.substring('pkg:'.length); + return scope; + } + + @override + Widget build(BuildContext context, WidgetRef ref) { + if (rules.isEmpty) { + return const SizedBox.shrink(); + } + + final entries = rules.entries.toList() + ..sort((a, b) => a.key.compareTo(b.key)); + + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Padding( + padding: EdgeInsets.only(top: 16, bottom: 4), + child: Text('Remembered site rules'), + ), + for (final MapEntry(:key, :value) in entries) + ListTile( + contentPadding: EdgeInsets.zero, + dense: true, + leading: Icon( + value.decision == AppLinkRuleDecision.alwaysOpen + ? MdiIcons.openInApp + : Icons.public, + ), + title: Text(_displayScope(key)), + subtitle: Text( + value.decision == AppLinkRuleDecision.alwaysOpen + ? 'Always open in the app' + : 'Always keep in the browser', + ), + trailing: IconButton( + icon: const Icon(Icons.delete_outline), + tooltip: 'Remove rule', + onPressed: () async { + await ref + .read(saveGeneralSettingsControllerProvider.notifier) + .save( + (current) => current.copyWith.appLinkRules({ + ...current.appLinkRules, + }..remove(key)), + ); + }, + ), + ), + ], + ); + } +} + class _GlobalDesktopModeTile extends HookConsumerWidget { const _GlobalDesktopModeTile(); diff --git a/apps/weblibre/lib/features/user/data/models/general_settings.dart b/apps/weblibre/lib/features/user/data/models/general_settings.dart index fdec4a6d..03d7ecbf 100644 --- a/apps/weblibre/lib/features/user/data/models/general_settings.dart +++ b/apps/weblibre/lib/features/user/data/models/general_settings.dart @@ -20,8 +20,12 @@ import 'package:copy_with_extension/copy_with_extension.dart'; import 'package:fast_equatable/fast_equatable.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_mozilla_components/flutter_mozilla_components.dart' + show AppLinksMode; import 'package:json_annotation/json_annotation.dart'; import 'package:weblibre/core/routing/routes.dart'; +import 'package:weblibre/features/app_links/domain/entities/app_link_rule.dart'; +import 'package:weblibre/features/app_links/domain/entities/context_app_link_policy.dart'; import 'package:weblibre/features/bangs/data/models/bang_group.dart'; import 'package:weblibre/features/bangs/data/models/bang_key.dart'; import 'package:weblibre/features/intent_gatekeeper/domain/entities/intent_source_policy.dart'; @@ -202,6 +206,30 @@ class GeneralSettings with FastEquatable { /// via the intent gatekeeper prefs bridge. Defaults to true. final bool customTabsEnabled; + /// Global app-links behaviour: always open in native apps, ask each time, or + /// never leave the browser. Defaults to [AppLinksMode.ask]. Per-site rules in + /// [appLinkRules] and container/proxy protection can override this per-target. + final AppLinksMode appLinksMode; + + /// Remembered per-scope app-link rules, keyed by canonical scope + /// (`host:youtube.com` | `pkg:...`). One rule per scope, last write wins. + /// Malformed entries are dropped on read (see [parseAppLinkRules]). + @JsonKey(fromJson: parseAppLinkRules) + final Map appLinkRules; + + /// Per-container app-link overrides for containers with "isolated app link + /// settings" enabled, keyed by the container's Gecko contextId. Each entry + /// fully *replaces* the global mode + [appLinkRules] for navigations in that + /// container (replace semantics). Containers without isolation have no entry + /// and fall back to the global policy. Malformed entries dropped on read. + @JsonKey(fromJson: parseAppLinkContextOverrides) + final Map appLinkContextOverrides; + + /// Whether an install-app (marketplace) intent is offered when an app link + /// resolves to no installed app and has no validated http(s) fallback. + /// Defaults to false — the wrong default for a de-Googled browser. + final bool appLinkMarketplaceFallback; + /// Whether the local search index (`history` table populated via tab→ /// history triggers) is active. When false, the SQL trigger guard returns /// without writing; existing rows stay until the user clears them. @@ -296,6 +324,10 @@ class GeneralSettings with FastEquatable { required this.blockExternalAppsEnabled, required this.externalAppIntentPolicies, required this.customTabsEnabled, + required this.appLinksMode, + required this.appLinkRules, + required this.appLinkContextOverrides, + required this.appLinkMarketplaceFallback, required this.enableLocalSearchIndex, required this.indexPrivateTabs, required this.acceptSuggestionOnSubmit, @@ -364,6 +396,10 @@ class GeneralSettings with FastEquatable { bool? blockExternalAppsEnabled, Map? externalAppIntentPolicies, bool? customTabsEnabled, + AppLinksMode? appLinksMode, + Map? appLinkRules, + Map? appLinkContextOverrides, + bool? appLinkMarketplaceFallback, bool? enableLocalSearchIndex, bool? indexPrivateTabs, bool? acceptSuggestionOnSubmit, @@ -442,6 +478,10 @@ class GeneralSettings with FastEquatable { blockExternalAppsEnabled = blockExternalAppsEnabled ?? false, externalAppIntentPolicies = externalAppIntentPolicies ?? const {}, customTabsEnabled = customTabsEnabled ?? true, + appLinksMode = appLinksMode ?? AppLinksMode.ask, + appLinkRules = appLinkRules ?? const {}, + appLinkContextOverrides = appLinkContextOverrides ?? const {}, + appLinkMarketplaceFallback = appLinkMarketplaceFallback ?? false, enableLocalSearchIndex = enableLocalSearchIndex ?? true, indexPrivateTabs = indexPrivateTabs ?? false, acceptSuggestionOnSubmit = acceptSuggestionOnSubmit ?? true, @@ -593,6 +633,10 @@ class GeneralSettings with FastEquatable { blockExternalAppsEnabled, externalAppIntentPolicies, customTabsEnabled, + appLinksMode, + appLinkRules, + appLinkContextOverrides, + appLinkMarketplaceFallback, enableLocalSearchIndex, indexPrivateTabs, acceptSuggestionOnSubmit, diff --git a/apps/weblibre/lib/features/user/data/models/general_settings.g.dart b/apps/weblibre/lib/features/user/data/models/general_settings.g.dart index 95517dde..7cd836cf 100644 --- a/apps/weblibre/lib/features/user/data/models/general_settings.g.dart +++ b/apps/weblibre/lib/features/user/data/models/general_settings.g.dart @@ -143,6 +143,16 @@ abstract class _$GeneralSettingsCWProxy { GeneralSettings customTabsEnabled(bool customTabsEnabled); + GeneralSettings appLinksMode(AppLinksMode appLinksMode); + + GeneralSettings appLinkRules(Map appLinkRules); + + GeneralSettings appLinkContextOverrides( + Map appLinkContextOverrides, + ); + + GeneralSettings appLinkMarketplaceFallback(bool appLinkMarketplaceFallback); + GeneralSettings enableLocalSearchIndex(bool enableLocalSearchIndex); GeneralSettings indexPrivateTabs(bool indexPrivateTabs); @@ -223,6 +233,10 @@ abstract class _$GeneralSettingsCWProxy { bool blockExternalAppsEnabled, Map externalAppIntentPolicies, bool customTabsEnabled, + AppLinksMode appLinksMode, + Map appLinkRules, + Map appLinkContextOverrides, + bool appLinkMarketplaceFallback, bool enableLocalSearchIndex, bool indexPrivateTabs, bool acceptSuggestionOnSubmit, @@ -490,6 +504,24 @@ class _$GeneralSettingsCWProxyImpl implements _$GeneralSettingsCWProxy { GeneralSettings customTabsEnabled(bool customTabsEnabled) => call(customTabsEnabled: customTabsEnabled); + @override + GeneralSettings appLinksMode(AppLinksMode appLinksMode) => + call(appLinksMode: appLinksMode); + + @override + GeneralSettings appLinkRules( + Map appLinkRules, + ) => call(appLinkRules: appLinkRules); + + @override + GeneralSettings appLinkContextOverrides( + Map appLinkContextOverrides, + ) => call(appLinkContextOverrides: appLinkContextOverrides); + + @override + GeneralSettings appLinkMarketplaceFallback(bool appLinkMarketplaceFallback) => + call(appLinkMarketplaceFallback: appLinkMarketplaceFallback); + @override GeneralSettings enableLocalSearchIndex(bool enableLocalSearchIndex) => call(enableLocalSearchIndex: enableLocalSearchIndex); @@ -586,6 +618,10 @@ class _$GeneralSettingsCWProxyImpl implements _$GeneralSettingsCWProxy { Object? blockExternalAppsEnabled = const $CopyWithPlaceholder(), Object? externalAppIntentPolicies = const $CopyWithPlaceholder(), Object? customTabsEnabled = const $CopyWithPlaceholder(), + Object? appLinksMode = const $CopyWithPlaceholder(), + Object? appLinkRules = const $CopyWithPlaceholder(), + Object? appLinkContextOverrides = const $CopyWithPlaceholder(), + Object? appLinkMarketplaceFallback = const $CopyWithPlaceholder(), Object? enableLocalSearchIndex = const $CopyWithPlaceholder(), Object? indexPrivateTabs = const $CopyWithPlaceholder(), Object? acceptSuggestionOnSubmit = const $CopyWithPlaceholder(), @@ -938,6 +974,28 @@ class _$GeneralSettingsCWProxyImpl implements _$GeneralSettingsCWProxy { ? _value.customTabsEnabled // ignore: cast_nullable_to_non_nullable : customTabsEnabled as bool, + appLinksMode: + appLinksMode == const $CopyWithPlaceholder() || appLinksMode == null + ? _value.appLinksMode + // ignore: cast_nullable_to_non_nullable + : appLinksMode as AppLinksMode, + appLinkRules: + appLinkRules == const $CopyWithPlaceholder() || appLinkRules == null + ? _value.appLinkRules + // ignore: cast_nullable_to_non_nullable + : appLinkRules as Map, + appLinkContextOverrides: + appLinkContextOverrides == const $CopyWithPlaceholder() || + appLinkContextOverrides == null + ? _value.appLinkContextOverrides + // ignore: cast_nullable_to_non_nullable + : appLinkContextOverrides as Map, + appLinkMarketplaceFallback: + appLinkMarketplaceFallback == const $CopyWithPlaceholder() || + appLinkMarketplaceFallback == null + ? _value.appLinkMarketplaceFallback + // ignore: cast_nullable_to_non_nullable + : appLinkMarketplaceFallback as bool, enableLocalSearchIndex: enableLocalSearchIndex == const $CopyWithPlaceholder() || enableLocalSearchIndex == null @@ -1110,6 +1168,17 @@ GeneralSettings _$GeneralSettingsFromJson( (k, e) => MapEntry(k, $enumDecode(_$IntentSourcePolicyEnumMap, e)), ), customTabsEnabled: json['customTabsEnabled'] as bool?, + appLinksMode: $enumDecodeNullable( + _$AppLinksModeEnumMap, + json['appLinksMode'], + ), + appLinkRules: parseAppLinkRules( + json['appLinkRules'] as Map?, + ), + appLinkContextOverrides: parseAppLinkContextOverrides( + json['appLinkContextOverrides'] as Map?, + ), + appLinkMarketplaceFallback: json['appLinkMarketplaceFallback'] as bool?, enableLocalSearchIndex: json['enableLocalSearchIndex'] as bool?, indexPrivateTabs: json['indexPrivateTabs'] as bool?, acceptSuggestionOnSubmit: json['acceptSuggestionOnSubmit'] as bool?, @@ -1196,6 +1265,12 @@ Map _$GeneralSettingsToJson( (k, e) => MapEntry(k, _$IntentSourcePolicyEnumMap[e]!), ), 'customTabsEnabled': instance.customTabsEnabled, + 'appLinksMode': _$AppLinksModeEnumMap[instance.appLinksMode]!, + 'appLinkRules': instance.appLinkRules.map((k, e) => MapEntry(k, e.toJson())), + 'appLinkContextOverrides': instance.appLinkContextOverrides.map( + (k, e) => MapEntry(k, e.toJson()), + ), + 'appLinkMarketplaceFallback': instance.appLinkMarketplaceFallback, 'enableLocalSearchIndex': instance.enableLocalSearchIndex, 'indexPrivateTabs': instance.indexPrivateTabs, 'acceptSuggestionOnSubmit': instance.acceptSuggestionOnSubmit, @@ -1283,3 +1358,9 @@ const _$IntentSourcePolicyEnumMap = { IntentSourcePolicy.allow: 'allow', IntentSourcePolicy.block: 'block', }; + +const _$AppLinksModeEnumMap = { + AppLinksMode.always: 'always', + AppLinksMode.ask: 'ask', + AppLinksMode.never: 'never', +}; diff --git a/apps/weblibre/lib/features/user/domain/repositories/general_settings.dart b/apps/weblibre/lib/features/user/domain/repositories/general_settings.dart index 13f14476..e7613885 100644 --- a/apps/weblibre/lib/features/user/domain/repositories/general_settings.dart +++ b/apps/weblibre/lib/features/user/domain/repositories/general_settings.dart @@ -272,6 +272,18 @@ class GeneralSettingsRepository extends _$GeneralSettingsRepository { DriftSqlType.bool, db.typeMapping, ), + 'appLinksMode': settings['appLinksMode']?.readAs( + DriftSqlType.string, + db.typeMapping, + ), + 'appLinkRules': settings['appLinkRules'] + ?.readAs(DriftSqlType.string, db.typeMapping) + .mapNotNull(jsonDecode), + 'appLinkContextOverrides': settings['appLinkContextOverrides'] + ?.readAs(DriftSqlType.string, db.typeMapping) + .mapNotNull(jsonDecode), + 'appLinkMarketplaceFallback': settings['appLinkMarketplaceFallback'] + ?.readAs(DriftSqlType.bool, db.typeMapping), 'enableLocalSearchIndex': settings['enableLocalSearchIndex']?.readAs( DriftSqlType.bool, db.typeMapping, diff --git a/apps/weblibre/lib/features/user/domain/repositories/general_settings.g.dart b/apps/weblibre/lib/features/user/domain/repositories/general_settings.g.dart index d7a3f00f..c502ea31 100644 --- a/apps/weblibre/lib/features/user/domain/repositories/general_settings.g.dart +++ b/apps/weblibre/lib/features/user/domain/repositories/general_settings.g.dart @@ -35,7 +35,7 @@ final class GeneralSettingsRepositoryProvider } String _$generalSettingsRepositoryHash() => - r'4e72c8ebed8b08ced417ca24d6e4a840f2abf1be'; + r'7020706aafbac7ee64f678f918ef9fc24c3b98fb'; abstract class _$GeneralSettingsRepository extends $StreamNotifier { diff --git a/apps/weblibre/lib/features/user/domain/repositories/profile.g.dart b/apps/weblibre/lib/features/user/domain/repositories/profile.g.dart index 3f678c9a..d5dae67f 100644 --- a/apps/weblibre/lib/features/user/domain/repositories/profile.g.dart +++ b/apps/weblibre/lib/features/user/domain/repositories/profile.g.dart @@ -33,7 +33,7 @@ final class ProfileRepositoryProvider ProfileRepository create() => ProfileRepository(); } -String _$profileRepositoryHash() => r'3055487626bdf6bdc6a51284f68eaf4067cd52ef'; +String _$profileRepositoryHash() => r'504539c5ec7c9126ed7b07d920820af481f40444'; abstract class _$ProfileRepository extends $AsyncNotifier> { FutureOr> build(); diff --git a/apps/weblibre/lib/features/web_push/domain/providers.g.dart b/apps/weblibre/lib/features/web_push/domain/providers.g.dart index 7cfe2275..7a71d3c4 100644 --- a/apps/weblibre/lib/features/web_push/domain/providers.g.dart +++ b/apps/weblibre/lib/features/web_push/domain/providers.g.dart @@ -226,7 +226,7 @@ final class PushDistributorMutationProvider } String _$pushDistributorMutationHash() => - r'5797ca731c90c1e06e089fb71ad602aecda59634'; + r'58e489179c2e1fdaf6d8a6bd3b758ec16641358c'; abstract class _$PushDistributorMutation extends $AsyncNotifier { FutureOr build(); diff --git a/apps/weblibre/test/features/app_links/app_link_prompt_rules_test.dart b/apps/weblibre/test/features/app_links/app_link_prompt_rules_test.dart new file mode 100644 index 00000000..505ca066 --- /dev/null +++ b/apps/weblibre/test/features/app_links/app_link_prompt_rules_test.dart @@ -0,0 +1,94 @@ +/* + * Copyright (c) 2024-2026 Fabian Freund. + * + * This file is part of WebLibre + * (see https://weblibre.eu). + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import 'package:flutter_mozilla_components/flutter_mozilla_components.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:weblibre/features/app_links/domain/entities/app_link_rule.dart'; +import 'package:weblibre/features/app_links/presentation/widgets/app_link_prompt_dialog.dart'; + +AppLinkTarget _target({ + String url = 'https://youtu.be/abc', + String? packageName = 'com.google.android.youtube', + bool isAmbiguous = false, + String scopeKey = 'host:youtu.be', + bool engineSupportsScheme = true, +}) { + return AppLinkTarget( + url: url, + appName: 'YouTube', + packageName: packageName, + fallbackUrl: null, + isMarketplace: false, + isAmbiguous: isAmbiguous, + engineSupportsScheme: engineSupportsScheme, + scopeKey: scopeKey, + ); +} + +void main() { + group('alwaysOpenRuleFor', () { + test('binds the resolved package to the target scope', () { + final rule = alwaysOpenRuleFor(_target()); + expect(rule, isNotNull); + expect(rule!.decision, AppLinkRuleDecision.alwaysOpen); + expect(rule.scope, 'host:youtu.be'); + expect(rule.packageName, 'com.google.android.youtube'); + }); + + test('cannot be remembered for an ambiguous resolution', () { + expect(alwaysOpenRuleFor(_target(isAmbiguous: true)), isNull); + }); + + test('cannot be remembered without a bound package', () { + expect(alwaysOpenRuleFor(_target(packageName: null)), isNull); + expect(alwaysOpenRuleFor(_target(packageName: '')), isNull); + }); + + test('scopes a custom-scheme target by its package key', () { + final rule = alwaysOpenRuleFor( + _target( + url: 'zoommtg://zoom.us/join', + packageName: 'us.zoom.videomeetings', + scopeKey: 'pkg:us.zoom.videomeetings', + engineSupportsScheme: false, + ), + ); + expect(rule, isNotNull); + expect(rule!.scope, 'pkg:us.zoom.videomeetings'); + expect(rule.packageName, 'us.zoom.videomeetings'); + }); + }); + + group('neverOpenRuleFor', () { + test('scopes to the target without binding a package', () { + final rule = neverOpenRuleFor(_target()); + expect(rule.decision, AppLinkRuleDecision.neverOpen); + expect(rule.scope, 'host:youtu.be'); + expect(rule.packageName, isNull); + }); + + test('is producible even for an ambiguous resolution', () { + // neverOpen never launches, so it does not need a bound package. + final rule = neverOpenRuleFor(_target(isAmbiguous: true, packageName: null)); + expect(rule.decision, AppLinkRuleDecision.neverOpen); + expect(rule.isValid, isTrue); + }); + }); +} diff --git a/apps/weblibre/test/features/app_links/app_link_rule_test.dart b/apps/weblibre/test/features/app_links/app_link_rule_test.dart new file mode 100644 index 00000000..ad0424b8 --- /dev/null +++ b/apps/weblibre/test/features/app_links/app_link_rule_test.dart @@ -0,0 +1,112 @@ +/* + * Copyright (c) 2024-2026 Fabian Freund. + * + * This file is part of WebLibre + * (see https://weblibre.eu). + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import 'package:flutter_test/flutter_test.dart'; +import 'package:weblibre/features/app_links/domain/entities/app_link_rule.dart'; + +void main() { + group('PersistedAppLinkRule', () { + test('round-trips through json', () { + final rule = PersistedAppLinkRule( + decision: AppLinkRuleDecision.alwaysOpen, + scope: 'host:youtube.com', + packageName: 'com.google.android.youtube', + ); + final restored = PersistedAppLinkRule.fromJson(rule.toJson()); + expect(restored, rule); + }); + + test('validity requires a package for alwaysOpen and a known prefix', () { + expect( + PersistedAppLinkRule( + decision: AppLinkRuleDecision.alwaysOpen, + scope: 'host:x.com', + packageName: 'pkg', + ).isValid, + isTrue, + ); + expect( + PersistedAppLinkRule( + decision: AppLinkRuleDecision.alwaysOpen, + scope: 'host:x.com', + ).isValid, + isFalse, + ); + expect( + PersistedAppLinkRule( + decision: AppLinkRuleDecision.neverOpen, + scope: 'host:x.com', + ).isValid, + isTrue, + ); + expect( + PersistedAppLinkRule( + decision: AppLinkRuleDecision.neverOpen, + scope: 'notaprefix', + ).isValid, + isFalse, + ); + }); + }); + + group('parseAppLinkRules', () { + test('keeps valid rules keyed by matching scope', () { + final parsed = parseAppLinkRules({ + 'host:youtube.com': { + 'decision': 'alwaysOpen', + 'scope': 'host:youtube.com', + 'packageName': 'com.google.android.youtube', + }, + 'pkg:us.zoom.videomeetings': { + 'decision': 'neverOpen', + 'scope': 'pkg:us.zoom.videomeetings', + }, + }); + expect(parsed.length, 2); + expect(parsed['host:youtube.com']!.decision, AppLinkRuleDecision.alwaysOpen); + }); + + test('drops entries whose map key disagrees with the rule scope', () { + final parsed = parseAppLinkRules({ + 'host:wrong.com': { + 'decision': 'neverOpen', + 'scope': 'host:right.com', + }, + }); + expect(parsed, isEmpty); + }); + + test('drops malformed and invalid rules', () { + final parsed = parseAppLinkRules({ + 'host:a.com': {'decision': 'garbage', 'scope': 'host:a.com'}, + 'host:b.com': { + 'decision': 'alwaysOpen', + 'scope': 'host:b.com', + }, // missing package + 'host:c.com': 'not a map', + }); + expect(parsed, isEmpty); + }); + + test('null input yields an empty map', () { + expect(parseAppLinkRules(null), isEmpty); + }); + }); +} diff --git a/apps/weblibre/test/features/app_links/effective_app_link_policy_test.dart b/apps/weblibre/test/features/app_links/effective_app_link_policy_test.dart new file mode 100644 index 00000000..7c73d030 --- /dev/null +++ b/apps/weblibre/test/features/app_links/effective_app_link_policy_test.dart @@ -0,0 +1,149 @@ +/* + * Copyright (c) 2024-2026 Fabian Freund. + * + * This file is part of WebLibre + * (see https://weblibre.eu). + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:weblibre/features/app_links/domain/services/effective_app_link_policy.dart'; +import 'package:weblibre/features/geckoview/features/tabs/data/models/container_data.dart'; + +ContainerDataWithCount _container( + String id, { + String? contextId, + bool isolatedAppLinkSettings = false, +}) { + return ContainerDataWithCount( + id: id, + name: 'Container $id', + color: const Color(0xFF336699), + orderKey: 'a', + metadata: ContainerMetadata.withDefaults( + contextualIdentity: contextId, + isolatedAppLinkSettings: isolatedAppLinkSettings, + ), + tabCount: 0, + ); +} + +void main() { + group('resolveAppLinkOverrideKey', () { + test('null contextId resolves to the global bucket', () { + final key = resolveAppLinkOverrideKey( + liveContextId: null, + containers: [ + _container('1', contextId: 'ctx-1', isolatedAppLinkSettings: true), + ], + isolationContextContainerMap: const {}, + ); + expect(key, isNull); + }); + + test('regular tab in a non-isolated container resolves globally', () { + final key = resolveAppLinkOverrideKey( + liveContextId: 'ctx-1', + containers: [_container('1', contextId: 'ctx-1')], + isolationContextContainerMap: const {}, + ); + expect(key, isNull); + }); + + test( + 'regular tab in an isolated-app-link container resolves to its base', + () { + final key = resolveAppLinkOverrideKey( + liveContextId: 'ctx-1', + containers: [ + _container('1', contextId: 'ctx-1', isolatedAppLinkSettings: true), + ], + isolationContextContainerMap: const {}, + ); + expect(key, 'ctx-1'); + }, + ); + + test('isolated tab resolves via the isolation map', () { + final key = resolveAppLinkOverrideKey( + liveContextId: 'iso-1', + containers: [ + _container('1', contextId: 'ctx-1', isolatedAppLinkSettings: true), + ], + isolationContextContainerMap: const { + 'iso-1': {'1'}, + }, + ); + expect(key, 'ctx-1'); + }); + + test( + 'isolated tab of a non-isolated-app-link container resolves globally', + () { + final key = resolveAppLinkOverrideKey( + liveContextId: 'iso-1', + containers: [_container('1', contextId: 'ctx-1')], + isolationContextContainerMap: const { + 'iso-1': {'1'}, + }, + ); + expect(key, isNull); + }, + ); + + test('shared isolation context picks the lowest sorted base contextId', () { + final key = resolveAppLinkOverrideKey( + liveContextId: 'iso-1', + containers: [ + _container('1', contextId: 'ctx-b', isolatedAppLinkSettings: true), + _container('2', contextId: 'ctx-a', isolatedAppLinkSettings: true), + ], + isolationContextContainerMap: const { + 'iso-1': {'1', '2'}, + }, + ); + expect(key, 'ctx-a'); + }); + + test( + 'shared isolation context skips containers without isolated settings', + () { + final key = resolveAppLinkOverrideKey( + liveContextId: 'iso-1', + containers: [ + _container('1', contextId: 'ctx-a'), + _container('2', contextId: 'ctx-b', isolatedAppLinkSettings: true), + ], + isolationContextContainerMap: const { + 'iso-1': {'1', '2'}, + }, + ); + expect(key, 'ctx-b'); + }, + ); + + test('unknown contextId resolves globally', () { + final key = resolveAppLinkOverrideKey( + liveContextId: 'ctx-unknown', + containers: [ + _container('1', contextId: 'ctx-1', isolatedAppLinkSettings: true), + ], + isolationContextContainerMap: const {}, + ); + expect(key, isNull); + }); + }); +} diff --git a/apps/weblibre/test/features/app_links/effective_routing_test.dart b/apps/weblibre/test/features/app_links/effective_routing_test.dart new file mode 100644 index 00000000..5d93985c --- /dev/null +++ b/apps/weblibre/test/features/app_links/effective_routing_test.dart @@ -0,0 +1,188 @@ +/* + * Copyright (c) 2024-2026 Fabian Freund. + * + * This file is part of WebLibre + * (see https://weblibre.eu). + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import 'package:flutter_test/flutter_test.dart'; +import 'package:weblibre/features/app_links/domain/services/effective_routing.dart'; +import 'package:weblibre/features/geckoview/features/tabs/data/models/site_assignment.dart'; +import 'package:weblibre/features/proxy/data/proxy_connection.dart'; + +SiteAssignment _assignment(String site, {String? contextId}) => SiteAssignment( + id: site, + contextualIdentity: contextId, + assignedSite: site, +); + +void main() { + group('resolveContainerAssignment', () { + test('explicit proxy connection wins', () { + final assignment = resolveContainerAssignment( + contextId: 'ctx', + proxyConnectionId: const TorProxyConnectionId(), + bypassGlobalProxy: false, + ); + expect(assignment, isA()); + }); + + test('bypassGlobalProxy with no proxy is direct scoped to the context', () { + final assignment = resolveContainerAssignment( + contextId: 'ctx', + proxyConnectionId: null, + bypassGlobalProxy: true, + ); + expect(assignment, isA()); + expect((assignment as DirectProxyAssignment).scopeId, 'ctx'); + }); + + test('no proxy and no bypass inherits', () { + final assignment = resolveContainerAssignment( + contextId: 'ctx', + proxyConnectionId: null, + bypassGlobalProxy: false, + ); + expect(assignment, isA()); + }); + }); + + group('resolveIsolationContextRouting', () { + test('any explicit proxy wins (lowest sorted id)', () { + final routing = resolveIsolationContextRouting([ + ProxyAssignment.inherit(), + ProxyAssignment.explicit('zeta'), + ProxyAssignment.explicit('alpha'), + ProxyAssignment.direct('scope'), + ]); + expect(routing.chosen, isA()); + expect((routing.chosen as ExplicitProxyAssignment).proxyId, 'alpha'); + expect(routing.distinctAssignmentCount, 4); + }); + + test('direct wins only when no container inherits', () { + final routing = resolveIsolationContextRouting([ + ProxyAssignment.direct('scopeB'), + ProxyAssignment.direct('scopeA'), + ]); + expect(routing.chosen, isA()); + expect((routing.chosen as DirectProxyAssignment).scopeId, 'scopeA'); + }); + + test('direct plus inherit collapses to inherit', () { + final routing = resolveIsolationContextRouting([ + ProxyAssignment.direct('scope'), + ProxyAssignment.inherit(), + ]); + expect(routing.chosen, isA()); + expect(routing.distinctAssignmentCount, 2); + expect(routing.assignmentLabels, ['inherit', 'direct:scope']); + }); + }); + + group('isAssignmentProtected', () { + test('explicit is always protected', () { + expect( + isAssignmentProtected( + ProxyAssignment.explicit('p'), + protectGeneralContext: false, + ), + isTrue, + ); + }); + + test('direct is never protected', () { + expect( + isAssignmentProtected( + ProxyAssignment.direct('s'), + protectGeneralContext: true, + ), + isFalse, + ); + }); + + test('inherit follows the general context', () { + expect( + isAssignmentProtected( + ProxyAssignment.inherit(), + protectGeneralContext: true, + ), + isTrue, + ); + expect( + isAssignmentProtected( + ProxyAssignment.inherit(), + protectGeneralContext: false, + ), + isFalse, + ); + }); + }); + + group('protectedTargetPatternForSite', () { + test('wildcard entry includes subdomains and ignores port', () { + final pattern = protectedTargetPatternForSite( + Uri.parse('https://*.example.com'), + ); + expect(pattern.scheme, 'https'); + expect(pattern.hostOrSuffix, 'example.com'); + expect(pattern.includeSubdomains, isTrue); + expect(pattern.port, isNull); + }); + + test('exact entry preserves effective port', () { + final defaultPort = protectedTargetPatternForSite( + Uri.parse('https://example.com'), + ); + expect(defaultPort.hostOrSuffix, 'example.com'); + expect(defaultPort.includeSubdomains, isFalse); + expect(defaultPort.port, 443); + + final explicitPort = protectedTargetPatternForSite( + Uri.parse('http://example.com:8080'), + ); + expect(explicitPort.port, 8080); + }); + }); + + group('computeProtectedTargetPatterns', () { + test('keeps only assignments in a protected or strict container', () { + final patterns = computeProtectedTargetPatterns( + assignments: [ + _assignment('https://proxied.example', contextId: 'proxied'), + _assignment('https://direct.example', contextId: 'direct'), + _assignment('https://strict.example', contextId: 'strict'), + _assignment('https://unassigned.example', contextId: null), + ], + protectedOrStrictContextIds: {'proxied', 'strict'}, + ); + + final hosts = patterns.map((p) => p.hostOrSuffix).toSet(); + expect(hosts, {'proxied.example', 'strict.example'}); + }); + + test('deduplicates identical patterns', () { + final patterns = computeProtectedTargetPatterns( + assignments: [ + _assignment('https://dup.example', contextId: 'a'), + _assignment('https://dup.example', contextId: 'a'), + ], + protectedOrStrictContextIds: {'a'}, + ); + expect(patterns.length, 1); + }); + }); +} diff --git a/apps/weblibre/test/features/app_links/general_settings_app_links_test.dart b/apps/weblibre/test/features/app_links/general_settings_app_links_test.dart new file mode 100644 index 00000000..f60c4ce2 --- /dev/null +++ b/apps/weblibre/test/features/app_links/general_settings_app_links_test.dart @@ -0,0 +1,114 @@ +/* + * Copyright (c) 2024-2026 Fabian Freund. + * + * This file is part of WebLibre + * (see https://weblibre.eu). + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import 'package:flutter_mozilla_components/flutter_mozilla_components.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:weblibre/features/app_links/domain/entities/app_link_rule.dart'; +import 'package:weblibre/features/app_links/domain/entities/context_app_link_policy.dart'; +import 'package:weblibre/features/user/data/models/general_settings.dart'; + +void main() { + group('GeneralSettings app-link fields', () { + test('defaults are ask / empty rules / marketplace off', () { + final settings = GeneralSettings.withDefaults(); + expect(settings.appLinksMode, AppLinksMode.ask); + expect(settings.appLinkRules, isEmpty); + expect(settings.appLinkMarketplaceFallback, isFalse); + }); + + test('the three fields survive a toJson -> fromJson round-trip', () { + final rule = PersistedAppLinkRule( + decision: AppLinkRuleDecision.alwaysOpen, + scope: 'host:youtu.be', + packageName: 'com.google.android.youtube', + ); + final settings = GeneralSettings.withDefaults( + appLinksMode: AppLinksMode.always, + appLinkRules: {rule.scope: rule}, + appLinkMarketplaceFallback: true, + ); + + final restored = GeneralSettings.fromJson(settings.toJson()); + + expect(restored.appLinksMode, AppLinksMode.always); + expect(restored.appLinkMarketplaceFallback, isTrue); + expect(restored.appLinkRules.keys, ['host:youtu.be']); + expect(restored.appLinkRules['host:youtu.be'], rule); + }); + + test('malformed persisted rules are dropped on read (parseAppLinkRules)', () { + final json = GeneralSettings.withDefaults().toJson(); + // A scope key that disagrees with the rule's own scope is invalid and dropped. + json['appLinkRules'] = { + 'host:youtu.be': { + 'decision': 'alwaysOpen', + 'scope': 'host:evil.example', + 'packageName': 'com.google.android.youtube', + }, + }; + + final restored = GeneralSettings.fromJson(json); + expect(restored.appLinkRules, isEmpty); + }); + }); + + group('GeneralSettings per-container app-link overrides', () { + test('defaults to an empty override map', () { + expect(GeneralSettings.withDefaults().appLinkContextOverrides, isEmpty); + }); + + test('a container override survives a toJson -> fromJson round-trip', () { + final rule = PersistedAppLinkRule( + decision: AppLinkRuleDecision.neverOpen, + scope: 'host:reddit.com', + ); + final override = ContextAppLinkPolicy( + mode: AppLinksMode.never, + rules: {rule.scope: rule}, + ); + final settings = GeneralSettings.withDefaults( + appLinkContextOverrides: {'work': override}, + ); + + final restored = GeneralSettings.fromJson(settings.toJson()); + + expect(restored.appLinkContextOverrides.keys, ['work']); + final restoredOverride = restored.appLinkContextOverrides['work']!; + expect(restoredOverride.mode, AppLinksMode.never); + expect(restoredOverride.rules['host:reddit.com'], rule); + }); + + test('the blank override is ask / empty rules', () { + final blank = ContextAppLinkPolicy.blank(); + expect(blank.mode, AppLinksMode.ask); + expect(blank.rules, isEmpty); + }); + + test('malformed override entries are dropped on read', () { + final json = GeneralSettings.withDefaults().toJson(); + json['appLinkContextOverrides'] = { + 'work': {'mode': 'not-a-mode'}, + }; + + final restored = GeneralSettings.fromJson(json); + expect(restored.appLinkContextOverrides, isEmpty); + }); + }); +} diff --git a/apps/weblibre/test/features/geckoview/features/tabs/data/models/container_data_test.dart b/apps/weblibre/test/features/geckoview/features/tabs/data/models/container_data_test.dart index 69b1329c..c6d09309 100644 --- a/apps/weblibre/test/features/geckoview/features/tabs/data/models/container_data_test.dart +++ b/apps/weblibre/test/features/geckoview/features/tabs/data/models/container_data_test.dart @@ -35,6 +35,44 @@ void main() { }); }); + group('ContainerMetadata isolatedAppLinkSettings invariant', () { + test('stays enabled when the container has a contextId', () { + final metadata = ContainerMetadata.withDefaults( + contextualIdentity: 'work', + isolatedAppLinkSettings: true, + ); + + expect(metadata.isolatedAppLinkSettings, isTrue); + expect(metadata.sanitized().isolatedAppLinkSettings, isTrue); + }); + + test('is normalized off without a contextId (read + sanitized)', () { + final metadata = ContainerMetadata.withDefaults( + contextualIdentity: null, + isolatedAppLinkSettings: true, + ); + + // withDefaults normalizes on construction/read. + expect(metadata.isolatedAppLinkSettings, isFalse); + + // A record that somehow carries the bad combination is re-normalized. + final restored = ContainerMetadata.fromJson({ + ...metadata.toJson(), + 'isolatedAppLinkSettings': true, + 'contextualIdentity': null, + }); + expect(restored.isolatedAppLinkSettings, isFalse); + expect(restored.sanitized().isolatedAppLinkSettings, isFalse); + }); + + test('defaults to false', () { + expect( + ContainerMetadata.withDefaults().isolatedAppLinkSettings, + isFalse, + ); + }); + }); + group('ContainerMetadata icon serialization', () { test('stores MDI icon names', () { final metadata = ContainerMetadata.withDefaults( diff --git a/packages/flutter_mozilla_components/android/build.gradle b/packages/flutter_mozilla_components/android/build.gradle index 43b6cd85..8579dbe4 100644 --- a/packages/flutter_mozilla_components/android/build.gradle +++ b/packages/flutter_mozilla_components/android/build.gradle @@ -114,7 +114,6 @@ dependencies { implementation "org.mozilla.components:browser-icons:$mozillaComponentsVersion" implementation "org.mozilla.components:browser-thumbnails:$mozillaComponentsVersion" implementation "org.mozilla.components:feature-addons:$mozillaComponentsVersion" - implementation "org.mozilla.components:feature-app-links:$mozillaComponentsVersion" implementation "org.mozilla.components:feature-accounts:$mozillaComponentsVersion" implementation "org.mozilla.components:feature-accounts-push:$mozillaComponentsVersion" implementation "org.mozilla.components:feature-awesomebar:$mozillaComponentsVersion" diff --git a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/BaseBrowserFragment.kt b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/BaseBrowserFragment.kt index 998a3e22..6c23a4ec 100644 --- a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/BaseBrowserFragment.kt +++ b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/BaseBrowserFragment.kt @@ -25,7 +25,6 @@ import androidx.core.content.edit import androidx.fragment.app.Fragment import androidx.preference.PreferenceManager import eu.weblibre.flutter_mozilla_components.addons.WebExtensionPromptFeature -import eu.weblibre.flutter_mozilla_components.activities.ExternalAppBrowserActivity import eu.weblibre.flutter_mozilla_components.databinding.FragmentBrowserBinding import eu.weblibre.flutter_mozilla_components.ext.EventSequence import eu.weblibre.flutter_mozilla_components.ext.getPreferenceKey @@ -37,6 +36,9 @@ import eu.weblibre.flutter_mozilla_components.feature.ReadabilityExtractFeature import eu.weblibre.flutter_mozilla_components.feature.WebExtensionToolbarFeature import eu.weblibre.flutter_mozilla_components.integration.ReaderViewIntegration import eu.weblibre.flutter_mozilla_components.services.DownloadService +import eu.weblibre.flutter_mozilla_components.applinks.AppLinkRuntime +import eu.weblibre.flutter_mozilla_components.applinks.NativeAppLinkPromptFeature +import eu.weblibre.flutter_mozilla_components.applinks.PendingAppLinkStores import io.flutter.Log import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.distinctUntilChangedBy @@ -49,7 +51,6 @@ import mozilla.components.browser.thumbnails.BrowserThumbnails import mozilla.components.concept.engine.EngineView import mozilla.components.feature.accounts.FxaCapability import mozilla.components.feature.accounts.FxaWebChannelFeature -import mozilla.components.feature.app.links.AppLinksFeature import mozilla.components.feature.downloads.DownloadsFeature import mozilla.components.feature.downloads.manager.FetchDownloadManager import mozilla.components.feature.downloads.temporary.CopyDownloadFeature @@ -89,7 +90,8 @@ abstract class BaseBrowserFragment : Fragment(), UserInteractionHandler, Activit private val shareResourceFeature = ViewBoundFeatureWrapper() private val copyDownloadFeature = ViewBoundFeatureWrapper() private val downloadsFeature = ViewBoundFeatureWrapper() - private val appLinksFeature = ViewBoundFeatureWrapper() + // Native prompt for Custom Tab sessions with no Flutter engine. + private val nativeAppLinkPromptFeature = ViewBoundFeatureWrapper() private val promptFeature = ViewBoundFeatureWrapper() private val webExtensionPromptFeature = ViewBoundFeatureWrapper() private val sitePermissionsFeature = ViewBoundFeatureWrapper() @@ -362,42 +364,24 @@ abstract class BaseBrowserFragment : Fragment(), UserInteractionHandler, Activit view = view, ) - appLinksFeature.set( - feature = AppLinksFeature( - context = profileContext, - store = components.core.store, - sessionId = sessionId, - fragmentManager = parentFragmentManager, - loadUrlUseCase = components.useCases.sessionUseCases.loadUrl, - launchInApp = { - GlobalComponents.shouldOpenLinksInApp( - requireActivity() is ExternalAppBrowserActivity - ) - }, - shouldPrompt = { - GlobalComponents.shouldPromptOpenLinksInApp( - requireActivity() is ExternalAppBrowserActivity - ) - }, - alwaysOpenCheckboxAction = { - GlobalComponents.engineSettingsApi?.setAppLinksMode( - eu.weblibre.flutter_mozilla_components.pigeons.AppLinksMode.ALWAYS - ) - }, - failedToLaunchAction = { fallbackUrl -> - fallbackUrl?.let { - val appLinksUseCases = components.useCases.appLinksUseCases - val getRedirect = appLinksUseCases.appLinkRedirect - val redirect = getRedirect.invoke(fallbackUrl) - redirect.appIntent?.flags = - Intent.FLAG_ACTIVITY_NEW_DOCUMENT or Intent.FLAG_ACTIVITY_MULTIPLE_TASK - appLinksUseCases.openAppLink.invoke(redirect.appIntent) - } - }, - ), - owner = this, - view = view, - ) + // App-link prompting: browser tabs are prompted by Flutter's AppLinkPromptHost, so only + // native Custom Tab sessions (no Flutter engine) install a native prompt feature here. + val nativeTabId = sessionId + if (this is ExternalAppBrowserFragment && nativeTabId != null) { + nativeAppLinkPromptFeature.set( + feature = NativeAppLinkPromptFeature( + context = profileContext, + tabId = nativeTabId, + store = PendingAppLinkStores.forProfile( + components.profileApplicationContext.relativePath, + ), + launcher = AppLinkRuntime.get(profileContext).launcher, + sessionUseCases = components.useCases.sessionUseCases, + ), + owner = this, + view = view, + ) + } promptFeature.set( feature = PromptFeature( diff --git a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/FlutterMozillaComponentsPlugin.kt b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/FlutterMozillaComponentsPlugin.kt index 40f233c1..208bdd30 100644 --- a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/FlutterMozillaComponentsPlugin.kt +++ b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/FlutterMozillaComponentsPlugin.kt @@ -47,6 +47,9 @@ class FlutterMozillaComponentsPlugin: FlutterPlugin, ActivityAware { GeckoPushApi.setUp(binding.binaryMessenger, null) browserApi.disposePushApi() GlobalComponents.historyEvents = null + // The availability event is optimisation-only; once Flutter detaches, the surface + // re-queries pending prompts on its next attach/resume, so dropping the sink is safe. + GlobalComponents.appLinkEvents = null // The UnifiedPush receiver outlives the Flutter engine; without this it would keep dispatching // onto a dead messenger. Failures are still retained on Push.lastError. GlobalComponents.pushEvents = null diff --git a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/GlobalComponents.kt b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/GlobalComponents.kt index 3b2ef45a..28a5a3e1 100644 --- a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/GlobalComponents.kt +++ b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/GlobalComponents.kt @@ -16,6 +16,7 @@ import eu.weblibre.flutter_mozilla_components.pigeons.BounceTrackingProtectionMo import eu.weblibre.flutter_mozilla_components.pigeons.ContentBlocking import eu.weblibre.flutter_mozilla_components.pigeons.GeckoAddonEvents import eu.weblibre.flutter_mozilla_components.pigeons.GeckoEngineSettings +import eu.weblibre.flutter_mozilla_components.pigeons.GeckoAppLinkEvents import eu.weblibre.flutter_mozilla_components.pigeons.GeckoGestureEvents import eu.weblibre.flutter_mozilla_components.pigeons.GeckoHistoryEvents import eu.weblibre.flutter_mozilla_components.pigeons.GeckoPushEvents @@ -150,6 +151,11 @@ object GlobalComponents { // path), in which case failures are logged natively only. var pushEvents: GeckoPushEvents? = null + // Native -> Dart availability signal for pending app-link prompts. Optimisation + // only (no buffering/replay): null when Flutter is detached, in which case the + // Flutter surface picks the prompt up on its next getPendingAppLinkPrompts query. + var appLinkEvents: GeckoAppLinkEvents? = null + // Gecko contextIds of containers with hard exclude-from-history enabled. // Pushed from Dart; read by WebLibreHistoryDelegate to skip the Places // write for visits resolved to one of these containers. @@ -250,22 +256,6 @@ object GlobalComponents { context?.stopService(Intent(context, PrivateTabsNotificationService::class.java)) } - fun shouldOpenLinksInApp(isExternalSession: Boolean = false): Boolean { - return when (engineSettingsApi!!.getAppLinksMode()) { - eu.weblibre.flutter_mozilla_components.pigeons.AppLinksMode.ALWAYS -> true - eu.weblibre.flutter_mozilla_components.pigeons.AppLinksMode.ASK -> true - eu.weblibre.flutter_mozilla_components.pigeons.AppLinksMode.NEVER -> isExternalSession - } - } - - fun shouldPromptOpenLinksInApp(isExternalSession: Boolean = false): Boolean { - return when (engineSettingsApi!!.getAppLinksMode()) { - eu.weblibre.flutter_mozilla_components.pigeons.AppLinksMode.ALWAYS -> false - eu.weblibre.flutter_mozilla_components.pigeons.AppLinksMode.ASK -> true - eu.weblibre.flutter_mozilla_components.pigeons.AppLinksMode.NEVER -> isExternalSession - } - } - @DelicateCoroutinesApi private fun restoreBrowserState( newComponents: Components, diff --git a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/api/GeckoAppLinksApiImpl.kt b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/api/GeckoAppLinksApiImpl.kt index d7b47053..1cc67080 100644 --- a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/api/GeckoAppLinksApiImpl.kt +++ b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/api/GeckoAppLinksApiImpl.kt @@ -7,64 +7,222 @@ package eu.weblibre.flutter_mozilla_components.api import android.content.Context -import android.content.Intent +import eu.weblibre.flutter_mozilla_components.Components import eu.weblibre.flutter_mozilla_components.GlobalComponents +import eu.weblibre.flutter_mozilla_components.applinks.AppLinkLaunchMode +import eu.weblibre.flutter_mozilla_components.applinks.AppLinkLaunchResult +import eu.weblibre.flutter_mozilla_components.applinks.AppLinkPolicyStores +import eu.weblibre.flutter_mozilla_components.applinks.AppLinkRuntime +import eu.weblibre.flutter_mozilla_components.applinks.PendingAppLinkRequest +import eu.weblibre.flutter_mozilla_components.applinks.PendingAppLinkStore +import eu.weblibre.flutter_mozilla_components.applinks.PendingAppLinkStores +import eu.weblibre.flutter_mozilla_components.applinks.toAppLinkPolicy +import eu.weblibre.flutter_mozilla_components.pigeons.AppLinkDecision +import eu.weblibre.flutter_mozilla_components.pigeons.AppLinkPolicySnapshot +import eu.weblibre.flutter_mozilla_components.pigeons.AppLinkPromptOwner +import eu.weblibre.flutter_mozilla_components.pigeons.AppLinkPromptRequest +import eu.weblibre.flutter_mozilla_components.pigeons.AppLinkResolutionResult +import eu.weblibre.flutter_mozilla_components.pigeons.AppLinkTarget import eu.weblibre.flutter_mozilla_components.pigeons.GeckoAppLinksApi +import mozilla.components.browser.state.selector.findTabOrCustomTab +import mozilla.components.support.base.log.logger.Logger import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.launch /** - * Implementation of GeckoAppLinksApi that detects and launches external applications - * that can handle URLs. - * - * This uses Mozilla Android Components' AppLinksUseCases to properly detect if a native - * app is available to handle a URL, matching the behavior in Firefox/Fenix. + * WebLibre-owned implementation of [GeckoAppLinksApi] backed by [ExternalAppResolver] and + * [AppLinkLauncher] (APP_LINKS_OWN_IMPLEMENTATION_PLAN.md Phase 1). Policy lives in Dart; this + * surface owns PackageManager resolution and Intent launch for the manual entry points. */ class GeckoAppLinksApiImpl( - private val context: Context + private val context: Context, ) : GeckoAppLinksApi { companion object { private val coroutineScope = CoroutineScope(Dispatchers.Default + SupervisorJob()) + private val logger = Logger("GeckoAppLinksApi") } - private val components by lazy { - requireNotNull(GlobalComponents.components) { "Components not initialized" } - } + // Shared process-level resolver/launcher (§2.7): the 2 s auto-launch cooldown and 30 s + // resolution cache are observed across the interceptor tail, the manual entry points, and + // prompt resolution alike. + private val resolver get() = AppLinkRuntime.get(context).resolver + private val launcher get() = AppLinkRuntime.get(context).launcher - override fun hasExternalApp(url: String, callback: (Result) -> Unit) { + override fun setAppLinkPolicy( + snapshot: AppLinkPolicySnapshot, + callback: (Result) -> Unit, + ) { coroutineScope.launch { try { - val redirect = components.useCases.appLinksUseCases.appLinkRedirect(url) - callback(Result.success(redirect.hasExternalApp())) + // A profile must be bound before policy can be applied. The Dart + // replicator retries after initialisation (§2.8, §2.10). + val profileContext = GlobalComponents.components?.profileApplicationContext + ?: throw IllegalStateException("No profile bound for app-link policy") + val store = AppLinkPolicyStores.forProfile(profileContext) + val persisted = store.setPolicy(snapshot.toAppLinkPolicy()) + if (persisted) { + callback(Result.success(Unit)) + } else { + callback(Result.failure(IllegalStateException("Failed to persist app-link policy"))) + } } catch (e: Exception) { - callback(Result.success(false)) + callback(Result.failure(e)) } } } - override fun openAppLink(url: String, callback: (Result) -> Unit) { + override fun resolveAppLink( + url: String, + includeHttpAppLinks: Boolean, + callback: (Result) -> Unit, + ) { coroutineScope.launch { try { - val redirect = components.useCases.appLinksUseCases.appLinkRedirect(url) - - if (!redirect.hasExternalApp()) { - callback(Result.success(false)) + val resolved = resolver.resolve(url, includeHttpAppLinks = includeHttpAppLinks) + if (!resolved.hasExternalApp) { + callback(Result.success(null)) return@launch } - - // Use NEW_DOCUMENT + MULTIPLE_TASK so the target app opens in its own - // task and doesn't get absorbed into WebLibre's recents entry. - // This matches Fenix's ShareController behaviour. - redirect.appIntent?.flags = - Intent.FLAG_ACTIVITY_NEW_DOCUMENT or Intent.FLAG_ACTIVITY_MULTIPLE_TASK - - components.useCases.appLinksUseCases.openAppLink.invoke(redirect.appIntent) - callback(Result.success(true)) + callback( + Result.success( + AppLinkTarget( + url = url, + appName = resolved.appName, + packageName = resolved.packageName, + fallbackUrl = resolved.fallbackUrl, + isMarketplace = false, + isAmbiguous = resolved.isAmbiguous, + engineSupportsScheme = resolved.engineSupportsScheme, + scopeKey = resolved.scopeKey, + ), + ), + ) } catch (e: Exception) { + // Uniform failure semantics (§2.8): callers cannot distinguish "nothing installed" + // from "resolution failed". + callback(Result.success(null)) + } + } + } + + override fun launchAppLink(url: String, callback: (Result) -> Unit) { + coroutineScope.launch { + try { + val result = launcher.launch(url, mode = AppLinkLaunchMode.MANUAL) + logger.info("launchAppLink($url) -> $result") + callback(Result.success(result == AppLinkLaunchResult.LAUNCHED)) + } catch (e: Exception) { + logger.error("launchAppLink($url) failed", e) callback(Result.success(false)) } } } + + private fun pendingStoreFor(components: Components): PendingAppLinkStore { + return PendingAppLinkStores.forProfile( + components.profileApplicationContext.relativePath, + ) + } + + override fun getPendingAppLinkPrompts( + owner: AppLinkPromptOwner, + callback: (Result>) -> Unit, + ) { + coroutineScope.launch { + try { + val components = GlobalComponents.components + val list = components + ?.let { pendingStoreFor(it).getPending(owner).map(PendingAppLinkRequest::toPigeon) } + ?: emptyList() + callback(Result.success(list)) + } catch (e: Exception) { + callback(Result.success(emptyList())) + } + } + } + + override fun resolvePendingAppLink( + requestId: Long, + decision: AppLinkDecision, + callback: (Result) -> Unit, + ) { + coroutineScope.launch { + try { + val components = GlobalComponents.components + ?: return@launch callback(Result.success(stale())) + val store = pendingStoreFor(components) + + // Consume atomically; the store lock is released before any side effect. + val request = store.consume(requestId) + if (request == null) { + // The request was invalidated (navigation/tab close/expiry) before the user + // resolved it — the prompt shown was stale. No launch, no page change. + logger.info("resolvePendingAppLink($requestId, $decision) -> stale (no pending request)") + return@launch callback(Result.success(stale())) + } + + // Never launch into a session that no longer exists. + val tabAlive = components.core.store.state + .findTabOrCustomTab(request.tabId) != null + if (!tabAlive) { + logger.info("resolvePendingAppLink($requestId) -> dead_session (${request.tabId})") + return@launch callback( + Result.success(AppLinkResolutionResult(false, false, "dead_session")), + ) + } + + val result = when (decision) { + AppLinkDecision.OPEN -> handleOpen(components, request) + AppLinkDecision.CANCEL, AppLinkDecision.DISMISS -> { + store.recordSuppression(request.tabId, request.targetFingerprint) + AppLinkResolutionResult(false, false, null) + } + } + callback(Result.success(result)) + } catch (e: Exception) { + callback(Result.success(AppLinkResolutionResult(false, false, "launch_failed"))) + } + } + } + + private fun handleOpen( + components: Components, + request: PendingAppLinkRequest, + ): AppLinkResolutionResult { + val mode = if (request.isMarketplace) { + AppLinkLaunchMode.MARKETPLACE + } else { + // Prompt-resolved opens are user gestures (bypass the cooldown). + AppLinkLaunchMode.MANUAL + } + // Honour the package captured when the prompt was created for a *named* + // (non-ambiguous) target, so a change in handlers before the user taps Open + // can't launch a different app (§2.5/§2.7). Ambiguous/chooser prompts store a + // null expectedPackage, so this stays null and the chooser still opens. + val result = launcher.launch(request.url, mode, expectedPackage = request.expectedPackage) + logger.info("resolvePendingAppLink open: launch(${request.url}, $mode) -> $result") + if (result == AppLinkLaunchResult.LAUNCHED) { + return AppLinkResolutionResult(true, false, null) + } + + // Launch failed: load a validated fallback if present, else leave the page. + val fallback = request.fallbackUrl + if (fallback != null) { + // Guard the fallback load against immediately bouncing back out to an app + // (§2.7): a validated http(s) fallback can itself resolve to an external + // handler, which would re-prompt/auto-launch. The interceptor records the + // same for fallbacks it issues. + pendingStoreFor(components).recordFallbackReentry(fallback) + components.useCases.sessionUseCases.loadUrl( + url = fallback, + sessionId = request.tabId, + ) + return AppLinkResolutionResult(false, true, "launch_failed") + } + return AppLinkResolutionResult(false, false, "launch_failed") + } + + private fun stale() = AppLinkResolutionResult(false, false, "stale") } diff --git a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/api/GeckoBrowserApiImpl.kt b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/api/GeckoBrowserApiImpl.kt index 6d75b2a3..cfbde531 100644 --- a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/api/GeckoBrowserApiImpl.kt +++ b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/api/GeckoBrowserApiImpl.kt @@ -51,6 +51,7 @@ import eu.weblibre.flutter_mozilla_components.pigeons.GeckoPushApi import eu.weblibre.flutter_mozilla_components.pigeons.GeckoPushEvents import eu.weblibre.flutter_mozilla_components.pigeons.GeckoPwaApi import eu.weblibre.flutter_mozilla_components.pigeons.GeckoSelectionActionController +import eu.weblibre.flutter_mozilla_components.pigeons.GeckoAppLinkEvents import eu.weblibre.flutter_mozilla_components.pigeons.GeckoHistoryEvents import eu.weblibre.flutter_mozilla_components.pigeons.GeckoSelectionActionEvents import eu.weblibre.flutter_mozilla_components.pigeons.GeckoSessionApi @@ -280,6 +281,10 @@ class GeckoBrowserApiImpl : GeckoBrowserApi { GlobalComponents.historyEvents = GeckoHistoryEvents(_flutterPluginBinding.binaryMessenger) + // Availability signal for pending app-link prompts (Flutter-owned prompts). + GlobalComponents.appLinkEvents = + GeckoAppLinkEvents(_flutterPluginBinding.binaryMessenger) + // Also set before GlobalComponents.setUp, which calls push.initialize() and can therefore // surface a registration failure before this sink would otherwise exist. GlobalComponents.pushEvents = GeckoPushEvents(_flutterPluginBinding.binaryMessenger) diff --git a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/api/GeckoEngineSettingsApiImpl.kt b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/api/GeckoEngineSettingsApiImpl.kt index 216397de..53261c3e 100644 --- a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/api/GeckoEngineSettingsApiImpl.kt +++ b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/api/GeckoEngineSettingsApiImpl.kt @@ -7,13 +7,9 @@ package eu.weblibre.flutter_mozilla_components.api import android.content.Context -import androidx.core.content.edit -import androidx.preference.PreferenceManager import eu.weblibre.flutter_mozilla_components.ColorSchemePreference import eu.weblibre.flutter_mozilla_components.GlobalComponents -import eu.weblibre.flutter_mozilla_components.R import eu.weblibre.flutter_mozilla_components.feature.ReaderViewAppearanceFeature -import eu.weblibre.flutter_mozilla_components.pigeons.AppLinksMode import eu.weblibre.flutter_mozilla_components.pigeons.BounceTrackingProtectionMode as PigeonBounceTrackingProtectionMode import eu.weblibre.flutter_mozilla_components.pigeons.ColorScheme import eu.weblibre.flutter_mozilla_components.pigeons.CookieBannerHandlingMode @@ -436,35 +432,6 @@ class GeckoEngineSettingsApiImpl( GlobalComponents.screenshotProtectionEnabled = enabled } - override fun setAppLinksMode(mode: AppLinksMode) { - val context = components.profileApplicationContext - val prefKey = context.getString(R.string.pref_key_open_links_in_apps) - val modeValue = when (mode) { - AppLinksMode.ALWAYS -> context.getString(R.string.pref_key_open_links_in_apps_always) - AppLinksMode.ASK -> context.getString(R.string.pref_key_open_links_in_apps_ask) - AppLinksMode.NEVER -> context.getString(R.string.pref_key_open_links_in_apps_never) - } - - PreferenceManager.getDefaultSharedPreferences(context).edit { - putString(prefKey, modeValue) - } - } - - override fun getAppLinksMode(): AppLinksMode { - val context = components.profileApplicationContext - val prefKey = context.getString(R.string.pref_key_open_links_in_apps) - val defaultValue = context.getString(R.string.pref_key_open_links_in_apps_ask) - val modeValue = PreferenceManager.getDefaultSharedPreferences(context) - .getString(prefKey, defaultValue) ?: defaultValue - - return when (modeValue) { - context.getString(R.string.pref_key_open_links_in_apps_always) -> AppLinksMode.ALWAYS - context.getString(R.string.pref_key_open_links_in_apps_ask) -> AppLinksMode.ASK - context.getString(R.string.pref_key_open_links_in_apps_never) -> AppLinksMode.NEVER - else -> AppLinksMode.ASK - } - } - override fun setUseExternalDownloadManager(enabled: Boolean) { GlobalComponents.useExternalDownloadManager = enabled } diff --git a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/applinks/AppLinkClassifier.kt b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/applinks/AppLinkClassifier.kt new file mode 100644 index 00000000..a3c82fb8 --- /dev/null +++ b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/applinks/AppLinkClassifier.kt @@ -0,0 +1,225 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +package eu.weblibre.flutter_mozilla_components.applinks + +/** + * Global app-links behaviour, Kotlin-native mirror of the Pigeon `AppLinksMode` transport enum. + */ +enum class AppLinkMode { + ALWAYS, + ASK, + NEVER, +} + +enum class AppLinkRuleDecision { + ALWAYS_OPEN, + NEVER_OPEN, +} + +/** A remembered per-scope rule (Kotlin-native mirror of the persisted/Pigeon rule model). */ +data class AppLinkRule( + val decision: AppLinkRuleDecision, + val scope: String, + val packageName: String?, +) + +/** Non-source-tab protection pattern (§2.3), matched against the navigation target. */ +data class ProtectedTargetPattern( + val scheme: String, + val hostOrSuffix: String, + val includeSubdomains: Boolean, + val port: Int?, +) + +/** + * A container's self-contained app-link policy (§ container isolation). Present only for containers + * with "isolated app link settings" enabled; when a navigation's source contextId has an entry, its + * [globalMode] + [rules] fully *replace* the global ones for that navigation (no layering). + */ +data class ContextAppLinkPolicy( + val globalMode: AppLinkMode, + val rules: Map, +) + +/** + * The complete policy the classifier reads. Populated from the replicated snapshot (§2.8); the + * classifier itself holds no Android types and no I/O. + */ +data class AppLinkPolicy( + val globalMode: AppLinkMode, + val rules: Map, + val marketplaceFallbackEnabled: Boolean, + val protectGeneralContext: Boolean, + val protectedContextIds: Set, + val strictContextIds: Set, + val protectedTargetPatterns: List, + /** + * Per-container overrides keyed by contextId; only isolated containers appear. A navigation whose + * source contextId is a key uses the entry's mode + rules instead of the global ones (replace). + */ + val contextOverrides: Map = emptyMap(), +) { + companion object { + val SAFE_DEFAULT = AppLinkPolicy( + globalMode = AppLinkMode.ASK, + rules = emptyMap(), + marketplaceFallbackEnabled = false, + protectGeneralContext = false, + protectedContextIds = emptySet(), + strictContextIds = emptySet(), + protectedTargetPatterns = emptyList(), + contextOverrides = emptyMap(), + ) + } +} + +/** The prompt classes of §2.2. */ +enum class AppLinkPromptKind { + /** http(s), non-modal — the page is allowed to load while the banner is up. */ + BANNER, + + /** Unsupported scheme, modal — the navigation is genuinely stalled and there is no page. */ + MODAL, +} + +/** + * A pure decision the interceptor executes. The classifier never performs side effects. + */ +sealed interface AppLinkDecision { + /** Return `null` from the interceptor — the engine proceeds normally. */ + data object AllowEngine : AppLinkDecision + + /** Deny the load and leave the current page unchanged. */ + data object DenyKeepPage : AppLinkDecision + + /** Return `InterceptionResponse.Url(url)` — a validated http(s) fallback. */ + data class LoadFallback(val url: String) : AppLinkDecision + + /** + * Automatic launch (global-`always` or a remembered `alwaysOpen` rule). The interceptor calls + * the launcher and maps its outcome per §2.7's launch-failure branches. + */ + data class AutoLaunch(val expectedPackage: String?) : AppLinkDecision + + /** + * Create a pending prompt request. [kind] chooses banner vs modal; the page is allowed to load + * for a banner and denied (stalled) for a modal. + */ + data class Prompt( + val kind: AppLinkPromptKind, + val canRemember: Boolean, + val isMarketplace: Boolean, + ) : AppLinkDecision +} + +/** Everything the classifier needs, all computed by the caller so the classifier stays pure. */ +data class ClassifierInput( + val resolved: ResolvedAppLink, + val isProtected: Boolean, + val isPrivate: Boolean, + val isWallet: Boolean, + val missingSession: Boolean, + val suppressionHit: Boolean, + val matchingRule: AppLinkRule?, + val globalMode: AppLinkMode, + val marketplaceFallbackEnabled: Boolean, +) + +/** + * Pure §2.4 policy precedence over the §2.2 URL-class table. Structural guards (§2.4 step 1) and + * navigation eligibility (step 2) are handled by the interceptor before this is consulted. + */ +object AppLinkClassifier { + fun classify(input: ClassifierInput): AppLinkDecision { + val resolved = input.resolved + + // Step 3 — no external app resolves. + if (!resolved.hasExternalApp) { + resolved.fallbackUrl?.let { return AppLinkDecision.LoadFallback(it) } + // Step 8 — marketplace, only when enabled, mode != never, and no validated fallback. + if (input.marketplaceFallbackEnabled && + input.globalMode != AppLinkMode.NEVER && + resolved.marketplaceIntent != null + ) { + return AppLinkDecision.Prompt( + kind = AppLinkPromptKind.MODAL, + canRemember = false, + isMarketplace = true, + ) + } + return if (resolved.engineSupportsScheme) { + AppLinkDecision.AllowEngine + } else { + AppLinkDecision.DenyKeepPage + } + } + + // Step 4 — missing session cannot host a prompt: fall back to the safe non-launch behaviour. + if (input.missingSession) { + return safeNonLaunch(resolved) + } + + // Step 4 — forced-prompt contexts (protected/private/wallet), ignoring matching rules. + if (input.isProtected || input.isPrivate || input.isWallet) { + return promptFor(resolved, canRemember = false) + } + + // Step 5 — suppression hit: never launch, never prompt. + if (input.suppressionHit) { + return safeNonLaunch(resolved) + } + + // Step 6 — a matching remembered rule for this scope. + input.matchingRule?.let { rule -> + when (rule.decision) { + AppLinkRuleDecision.ALWAYS_OPEN -> + return AppLinkDecision.AutoLaunch(expectedPackage = rule.packageName) + AppLinkRuleDecision.NEVER_OPEN -> + return neverBehaviour(resolved) + } + } + + // Step 7 — global mode, applied uniformly (including Custom Tabs). + return when (input.globalMode) { + AppLinkMode.ALWAYS -> AppLinkDecision.AutoLaunch(expectedPackage = null) + AppLinkMode.ASK -> promptFor(resolved, canRemember = canRemember(resolved)) + AppLinkMode.NEVER -> neverBehaviour(resolved) + } + } + + /** The `never` row of §2.2: allow an engine-supported page; otherwise deny (+ validated fallback). */ + private fun neverBehaviour(resolved: ResolvedAppLink): AppLinkDecision { + return if (resolved.engineSupportsScheme) { + AppLinkDecision.AllowEngine + } else { + resolved.fallbackUrl?.let { AppLinkDecision.LoadFallback(it) } + ?: AppLinkDecision.DenyKeepPage + } + } + + /** Suppression/missing-session: allow an engine-supported URL; else deny, using only a fallback. */ + private fun safeNonLaunch(resolved: ResolvedAppLink): AppLinkDecision { + return if (resolved.engineSupportsScheme) { + AppLinkDecision.AllowEngine + } else { + resolved.fallbackUrl?.let { AppLinkDecision.LoadFallback(it) } + ?: AppLinkDecision.DenyKeepPage + } + } + + private fun promptFor(resolved: ResolvedAppLink, canRemember: Boolean): AppLinkDecision { + val kind = if (resolved.engineSupportsScheme) { + AppLinkPromptKind.BANNER + } else { + AppLinkPromptKind.MODAL + } + return AppLinkDecision.Prompt(kind = kind, canRemember = canRemember, isMarketplace = false) + } + + /** Ambiguous resolution can never be remembered (§2.5). */ + private fun canRemember(resolved: ResolvedAppLink): Boolean = !resolved.isAmbiguous +} diff --git a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/applinks/AppLinkHostNormalizer.kt b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/applinks/AppLinkHostNormalizer.kt new file mode 100644 index 00000000..4433c491 --- /dev/null +++ b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/applinks/AppLinkHostNormalizer.kt @@ -0,0 +1,100 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +package eu.weblibre.flutter_mozilla_components.applinks + +import java.net.IDN +import java.net.InetAddress +import java.util.Locale + +/** + * Native-owned host normalisation (see APP_LINKS_OWN_IMPLEMENTATION_PLAN.md §2.5). + * + * The resolver returns the canonical scope key used by prompts and rules; Dart persists + * it opaquely and never reconstructs it. The same helper normalises hosts when matching + * `protectedTargetPatterns`. + */ +object AppLinkHostNormalizer { + const val HOST_SCOPE_PREFIX = "host:" + const val PACKAGE_SCOPE_PREFIX = "pkg:" + + /** + * Canonicalise a host: + * - [Locale.ROOT] lowercase, + * - strip a single trailing dot, + * - `IDN.toASCII` for non-ASCII hosts, + * - reject empty/invalid hosts and IPv6 zone IDs, + * - canonicalise IP literals. + * + * @return the canonical host, or `null` if the host is empty or invalid. + */ + fun normalizeHost(rawHost: String?): String? { + if (rawHost.isNullOrEmpty()) return null + + // Reject IPv6 zone identifiers (e.g. fe80::1%eth0) — the zone is host-local + // and must never participate in a cross-navigation scope key. + if (rawHost.contains('%')) return null + + var host = rawHost.trim() + if (host.isEmpty()) return null + + // Strip a single trailing dot (fully-qualified form). + if (host.endsWith(".")) { + host = host.dropLast(1) + } + if (host.isEmpty()) return null + + // IPv6 literal in brackets: canonicalise the address inside. + if (host.startsWith("[") && host.endsWith("]")) { + val inner = host.substring(1, host.length - 1) + if (inner.contains('%')) return null + return canonicalizeIpLiteral(inner)?.let { "[$it]" } ?: return null + } + + // Try to canonicalise as an IP literal first (IPv4 / bare IPv6). + canonicalizeIpLiteral(host)?.let { return it } + + val lowered = host.lowercase(Locale.ROOT) + + return try { + val ascii = IDN.toASCII(lowered, IDN.ALLOW_UNASSIGNED) + if (ascii.isEmpty()) null else ascii.lowercase(Locale.ROOT) + } catch (e: IllegalArgumentException) { + null + } + } + + /** + * Canonicalise an IP literal (numeric address only). Returns `null` when [value] is not a + * numeric IP literal, so callers can fall through to hostname handling. + */ + private fun canonicalizeIpLiteral(value: String): String? { + if (value.isEmpty()) return null + // Only treat clearly-numeric forms as IP literals; a real hostname must go through IDN. + val looksNumeric = value.all { it.isDigit() || it == '.' } || + (value.contains(':') && value.all { it.isDigit() || it == ':' || it in 'a'..'f' || it in 'A'..'F' }) + if (!looksNumeric) return null + + return try { + val address = InetAddress.getByName(value) + address.hostAddress?.lowercase(Locale.ROOT) + } catch (e: Exception) { + null + } + } + + /** Build the canonical scope key for a host (`host:youtube.com`). */ + fun hostScopeKey(rawHost: String?): String? { + val host = normalizeHost(rawHost) ?: return null + return HOST_SCOPE_PREFIX + host + } + + /** Build the canonical scope key for a package (`pkg:us.zoom.videomeetings`). */ + fun packageScopeKey(packageName: String?): String? { + if (packageName.isNullOrEmpty()) return null + return PACKAGE_SCOPE_PREFIX + packageName + } +} diff --git a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/applinks/AppLinkLauncher.kt b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/applinks/AppLinkLauncher.kt new file mode 100644 index 00000000..b9a9d0d1 --- /dev/null +++ b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/applinks/AppLinkLauncher.kt @@ -0,0 +1,128 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +package eu.weblibre.flutter_mozilla_components.applinks + +import android.content.ActivityNotFoundException +import android.content.Intent +import mozilla.components.support.base.log.logger.Logger + +/** + * Distinct launch modes, each with an exact flag set (§6): + * - [MANUAL]: user-driven "Open in " — preserves the `NEW_DOCUMENT | MULTIPLE_TASK` task + * behaviour so the app opens in its own recents entry. + * - [AUTOMATIC]: global-`always` or a remembered `alwaysOpen` rule — `NEW_TASK`, subject to the + * 2 s same-package cooldown loop-breaker (§2.4). + * - [MARKETPLACE]: install-app fallback — `NEW_TASK | CLEAR_TASK`. + */ +enum class AppLinkLaunchMode { + MANUAL, + AUTOMATIC, + MARKETPLACE, +} + +enum class AppLinkLaunchResult { + LAUNCHED, + NO_APP, + COOLDOWN, + PACKAGE_MISMATCH, + FAILED, +} + +/** + * Launches external apps. Every launch re-resolves immediately first (no cache) and verifies the + * expected package before `startActivity` (§2.7). Automatic launches honour a 2 s same-package + * cooldown to break app→browser→app ping-pong loops (§2.4); manual and prompt-resolved opens are + * user gestures that bypass the check but still record it. + */ +class AppLinkLauncher( + private val resolver: ExternalAppResolver, + private val startActivity: (Intent) -> Unit, + private val clock: MonotonicClock = MonotonicClock.SYSTEM, + private val cooldownMs: Long = APP_LINKS_DO_NOT_INTERCEPT_INTERVAL, +) { + private val logger = Logger("AppLinkLauncher") + + @Volatile + private var lastLaunch: Pair = Pair(null, 0L) + + /** + * Re-resolve [url] and launch it in the appropriate external app. + * + * @param expectedPackage when non-null (remembered/manual rebind paths), the freshly resolved + * package must equal it or the launch is refused with [AppLinkLaunchResult.PACKAGE_MISMATCH]. + */ + @Synchronized + fun launch( + url: String, + mode: AppLinkLaunchMode, + expectedPackage: String? = null, + ): AppLinkLaunchResult { + val resolved = resolver.resolve(url, includeHttpAppLinks = true, useCache = false) + + val intent: Intent = when (mode) { + AppLinkLaunchMode.MARKETPLACE -> resolved.marketplaceIntent ?: return AppLinkLaunchResult.NO_APP + else -> { + if (!resolved.hasExternalApp || resolved.appIntent == null) { + return AppLinkLaunchResult.NO_APP + } + if (expectedPackage != null && resolved.packageName != expectedPackage) { + return AppLinkLaunchResult.PACKAGE_MISMATCH + } + resolved.appIntent + } + } + + val targetPackage = when (mode) { + AppLinkLaunchMode.MARKETPLACE -> intent.`package` + else -> resolved.packageName + } + + if (mode == AppLinkLaunchMode.AUTOMATIC) { + val (lastPackage, lastTs) = lastLaunch + if (lastPackage != null && lastPackage == targetPackage && + clock.elapsedRealtime() < lastTs + cooldownMs + ) { + return AppLinkLaunchResult.COOLDOWN + } + } + + applyLaunchFlags(intent, mode) + + return try { + startActivity(intent) + lastLaunch = Pair(targetPackage, clock.elapsedRealtime()) + AppLinkLaunchResult.LAUNCHED + } catch (e: ActivityNotFoundException) { + logger.error("failed to start external app activity", e) + AppLinkLaunchResult.FAILED + } catch (e: SecurityException) { + logger.error("not permitted to start external app activity", e) + AppLinkLaunchResult.FAILED + } + } + + private fun applyLaunchFlags(intent: Intent, mode: AppLinkLaunchMode) { + intent.flags = when (mode) { + // NEW_DOCUMENT | MULTIPLE_TASK gives the app its own recents entry; NEW_TASK is + // mandatory because every launch path now dispatches through the process-level + // application context (AppLinkRuntime), and startActivity() from a non-Activity + // context requires it. + AppLinkLaunchMode.MANUAL -> + Intent.FLAG_ACTIVITY_NEW_DOCUMENT or + Intent.FLAG_ACTIVITY_MULTIPLE_TASK or + Intent.FLAG_ACTIVITY_NEW_TASK + AppLinkLaunchMode.AUTOMATIC -> + Intent.FLAG_ACTIVITY_NEW_TASK + AppLinkLaunchMode.MARKETPLACE -> + Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK + } + } + + companion object { + const val APP_LINKS_DO_NOT_INTERCEPT_INTERVAL = 2000L + } +} diff --git a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/applinks/AppLinkPolicyMapper.kt b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/applinks/AppLinkPolicyMapper.kt new file mode 100644 index 00000000..03646fc7 --- /dev/null +++ b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/applinks/AppLinkPolicyMapper.kt @@ -0,0 +1,53 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +package eu.weblibre.flutter_mozilla_components.applinks + +import eu.weblibre.flutter_mozilla_components.pigeons.AppLinkPolicySnapshot +import eu.weblibre.flutter_mozilla_components.pigeons.AppLinksMode as PigeonAppLinksMode +import eu.weblibre.flutter_mozilla_components.pigeons.NativeAppLinkRule +import eu.weblibre.flutter_mozilla_components.pigeons.NativeAppLinkRuleDecision + +/** Map the replicated Pigeon snapshot to the Kotlin-native classifier policy (§2.8). */ +fun AppLinkPolicySnapshot.toAppLinkPolicy(): AppLinkPolicy { + return AppLinkPolicy( + globalMode = globalMode.toAppLinkMode(), + rules = rules.mapValues { (_, rule) -> rule.toAppLinkRule() }, + marketplaceFallbackEnabled = marketplaceFallbackEnabled, + protectGeneralContext = protectGeneralContext, + protectedContextIds = protectedContextIds.toSet(), + strictContextIds = strictContextIds.toSet(), + protectedTargetPatterns = protectedTargetPatterns.map { pattern -> + ProtectedTargetPattern( + scheme = pattern.scheme, + hostOrSuffix = pattern.hostOrSuffix, + includeSubdomains = pattern.includeSubdomains, + port = pattern.port?.toInt(), + ) + }, + contextOverrides = contextOverrides.mapValues { (_, override) -> + ContextAppLinkPolicy( + globalMode = override.mode.toAppLinkMode(), + rules = override.rules.mapValues { (_, rule) -> rule.toAppLinkRule() }, + ) + }, + ) +} + +private fun PigeonAppLinksMode.toAppLinkMode(): AppLinkMode = when (this) { + PigeonAppLinksMode.ALWAYS -> AppLinkMode.ALWAYS + PigeonAppLinksMode.ASK -> AppLinkMode.ASK + PigeonAppLinksMode.NEVER -> AppLinkMode.NEVER +} + +private fun NativeAppLinkRule.toAppLinkRule(): AppLinkRule = AppLinkRule( + decision = when (decision) { + NativeAppLinkRuleDecision.ALWAYS_OPEN -> AppLinkRuleDecision.ALWAYS_OPEN + NativeAppLinkRuleDecision.NEVER_OPEN -> AppLinkRuleDecision.NEVER_OPEN + }, + scope = scope, + packageName = packageName, +) diff --git a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/applinks/AppLinkPolicyStore.kt b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/applinks/AppLinkPolicyStore.kt new file mode 100644 index 00000000..47482868 --- /dev/null +++ b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/applinks/AppLinkPolicyStore.kt @@ -0,0 +1,236 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +package eu.weblibre.flutter_mozilla_components.applinks + +import android.content.Context +import eu.weblibre.flutter_mozilla_components.ProfileContext +import mozilla.components.support.base.log.logger.Logger +import org.json.JSONArray +import org.json.JSONObject +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.atomic.AtomicReference + +/** + * Process-level registry of profile-scoped [AppLinkPolicyStore] singletons + * (APP_LINKS_OWN_IMPLEMENTATION_PLAN.md §2.10). Keyed only by native's canonical + * [ProfileContext.relativePath]; created on first use, torn down on profile + * replacement. Survives `GlobalComponents.setUp()` replacing the `Components`. + */ +object AppLinkPolicyStores { + private val stores = ConcurrentHashMap() + + fun forProfile(profileContext: ProfileContext): AppLinkPolicyStore { + return stores.getOrPut(profileContext.relativePath) { + AppLinkPolicyStore(profileContext) + } + } + + /** Remove a torn-down profile's store (profile replacement/deletion). */ + fun remove(relativePath: String) { + stores.remove(relativePath) + } +} + +/** + * The only policy source in `ComponentsMode.EXTERNAL` and before Flutter attaches. + * Holds the classifier [AppLinkPolicy] in an [AtomicReference] backed by a single + * profile-scoped SharedPreferences record. Writes persist synchronously + * (`commit()`) and publish the new reference only after durable success. There is + * exactly one writer (the Dart replicator via `setAppLinkPolicy`). + */ +class AppLinkPolicyStore internal constructor( + private val context: Context, +) { + private val logger = Logger("AppLinkPolicyStore") + private val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + private val reference = AtomicReference(loadOrSeed()) + + val policy: AppLinkPolicy + get() = reference.get() + + /** + * Persist [policy] durably, then publish it. Serialised so concurrent writers + * cannot interleave a half-written record with a published reference. + */ + @Synchronized + fun setPolicy(policy: AppLinkPolicy): Boolean { + val json = encode(policy, migrated = true) + val committed = prefs.edit().putString(KEY_SNAPSHOT, json).commit() + if (!committed) { + logger.error("failed to persist app-link policy; keeping previous snapshot") + return false + } + reference.set(policy) + return true + } + + private fun loadOrSeed(): AppLinkPolicy { + val stored = prefs.getString(KEY_SNAPSHOT, null) + if (stored != null) { + runCatching { return decode(stored) } + .onFailure { logger.error("corrupt app-link policy record; reseeding", it) } + } + // Seed the safe default (globalMode = ASK): the seed carries no protected-context data (that + // is computed in Dart and arrives only with the first replicated snapshot), so it must never + // auto-launch — an `ALWAYS` seed would leak links out of proxied/strict containers and cold + // Custom Tabs before protection is known. The legacy AC "open links in apps" preference is + // deliberately not migrated (a de-Googled browser resets to the safe ASK default; the user + // re-sets it in Settings), so the seed does not read it. + val seeded = AppLinkPolicy.SAFE_DEFAULT + val committed = prefs.edit().putString(KEY_SNAPSHOT, encode(seeded, migrated = true)).commit() + if (!committed) { + logger.error("failed to persist seeded app-link policy; using defaults in memory") + } + return seeded + } + + private fun encode(policy: AppLinkPolicy, migrated: Boolean): String { + val root = JSONObject() + root.put(FIELD_MIGRATED, migrated) + root.put(FIELD_GLOBAL_MODE, policy.globalMode.name) + root.put(FIELD_MARKETPLACE, policy.marketplaceFallbackEnabled) + root.put(FIELD_PROTECT_GENERAL, policy.protectGeneralContext) + root.put(FIELD_PROTECTED_CONTEXTS, JSONArray(policy.protectedContextIds.toList())) + root.put(FIELD_STRICT_CONTEXTS, JSONArray(policy.strictContextIds.toList())) + + root.put(FIELD_RULES, encodeRules(policy.rules)) + + val overrides = JSONObject() + for ((contextId, override) in policy.contextOverrides) { + overrides.put( + contextId, + JSONObject() + .put(FIELD_OVERRIDE_MODE, override.globalMode.name) + .put(FIELD_RULES, encodeRules(override.rules)), + ) + } + root.put(FIELD_CONTEXT_OVERRIDES, overrides) + + val patterns = JSONArray() + for (pattern in policy.protectedTargetPatterns) { + patterns.put( + JSONObject() + .put(FIELD_PATTERN_SCHEME, pattern.scheme) + .put(FIELD_PATTERN_HOST, pattern.hostOrSuffix) + .put(FIELD_PATTERN_SUBDOMAINS, pattern.includeSubdomains) + .putOpt(FIELD_PATTERN_PORT, pattern.port), + ) + } + root.put(FIELD_PATTERNS, patterns) + return root.toString() + } + + private fun encodeRules(rules: Map): JSONObject { + val obj = JSONObject() + for ((scope, rule) in rules) { + obj.put( + scope, + JSONObject() + .put(FIELD_RULE_DECISION, rule.decision.name) + .put(FIELD_RULE_SCOPE, rule.scope) + .putOpt(FIELD_RULE_PACKAGE, rule.packageName), + ) + } + return obj + } + + private fun decode(json: String): AppLinkPolicy { + val root = JSONObject(json) + + val rules = decodeRules(root.optJSONObject(FIELD_RULES)) + + val contextOverrides = mutableMapOf() + root.optJSONObject(FIELD_CONTEXT_OVERRIDES)?.let { obj -> + for (contextId in obj.keys()) { + val overrideJson = obj.getJSONObject(contextId) + contextOverrides[contextId] = ContextAppLinkPolicy( + globalMode = AppLinkMode.valueOf(overrideJson.getString(FIELD_OVERRIDE_MODE)), + rules = decodeRules(overrideJson.optJSONObject(FIELD_RULES)), + ) + } + } + + val patterns = mutableListOf() + root.optJSONArray(FIELD_PATTERNS)?.let { arr -> + for (i in 0 until arr.length()) { + val p = arr.getJSONObject(i) + patterns.add( + ProtectedTargetPattern( + scheme = p.getString(FIELD_PATTERN_SCHEME), + hostOrSuffix = p.getString(FIELD_PATTERN_HOST), + includeSubdomains = p.getBoolean(FIELD_PATTERN_SUBDOMAINS), + port = if (p.has(FIELD_PATTERN_PORT) && !p.isNull(FIELD_PATTERN_PORT)) { + p.getInt(FIELD_PATTERN_PORT) + } else { + null + }, + ), + ) + } + } + + return AppLinkPolicy( + globalMode = AppLinkMode.valueOf(root.getString(FIELD_GLOBAL_MODE)), + rules = rules, + marketplaceFallbackEnabled = root.optBoolean(FIELD_MARKETPLACE, false), + protectGeneralContext = root.optBoolean(FIELD_PROTECT_GENERAL, false), + protectedContextIds = root.optJSONArray(FIELD_PROTECTED_CONTEXTS).toStringSet(), + strictContextIds = root.optJSONArray(FIELD_STRICT_CONTEXTS).toStringSet(), + protectedTargetPatterns = patterns, + contextOverrides = contextOverrides, + ) + } + + private fun decodeRules(obj: JSONObject?): Map { + if (obj == null) return emptyMap() + val rules = mutableMapOf() + for (scope in obj.keys()) { + val ruleJson = obj.getJSONObject(scope) + rules[scope] = AppLinkRule( + decision = AppLinkRuleDecision.valueOf(ruleJson.getString(FIELD_RULE_DECISION)), + scope = ruleJson.getString(FIELD_RULE_SCOPE), + packageName = ruleJson.optStringOrNull(FIELD_RULE_PACKAGE), + ) + } + return rules + } + + private fun JSONObject.optStringOrNull(key: String): String? = + if (has(key) && !isNull(key)) getString(key) else null + + private fun JSONArray?.toStringSet(): Set { + if (this == null) return emptySet() + val out = LinkedHashSet(length()) + for (i in 0 until length()) { + out.add(getString(i)) + } + return out + } + + companion object { + const val PREFS_NAME = "weblibre_app_link_policy" + private const val KEY_SNAPSHOT = "snapshot" + + private const val FIELD_MIGRATED = "migrated" + private const val FIELD_GLOBAL_MODE = "globalMode" + private const val FIELD_MARKETPLACE = "marketplaceFallbackEnabled" + private const val FIELD_PROTECT_GENERAL = "protectGeneralContext" + private const val FIELD_PROTECTED_CONTEXTS = "protectedContextIds" + private const val FIELD_STRICT_CONTEXTS = "strictContextIds" + private const val FIELD_RULES = "rules" + private const val FIELD_RULE_DECISION = "decision" + private const val FIELD_RULE_SCOPE = "scope" + private const val FIELD_RULE_PACKAGE = "packageName" + private const val FIELD_CONTEXT_OVERRIDES = "contextOverrides" + private const val FIELD_OVERRIDE_MODE = "mode" + private const val FIELD_PATTERNS = "protectedTargetPatterns" + private const val FIELD_PATTERN_SCHEME = "scheme" + private const val FIELD_PATTERN_HOST = "hostOrSuffix" + private const val FIELD_PATTERN_SUBDOMAINS = "includeSubdomains" + private const val FIELD_PATTERN_PORT = "port" + } +} diff --git a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/applinks/AppLinkRuntime.kt b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/applinks/AppLinkRuntime.kt new file mode 100644 index 00000000..403254d6 --- /dev/null +++ b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/applinks/AppLinkRuntime.kt @@ -0,0 +1,39 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +package eu.weblibre.flutter_mozilla_components.applinks + +import android.content.Context + +/** + * Process-level holder for the shared [ExternalAppResolver] and [AppLinkLauncher] + * (APP_LINKS_OWN_IMPLEMENTATION_PLAN.md §2.7). Neither is profile-scoped — they wrap the + * `PackageManager` and `startActivity`, both application-global. + * + * A single shared launcher is important: its 2 s same-package auto-launch cooldown + * (§2.4 loop breaker) must be observed across *every* launch path — the synchronous + * interceptor tail ([WebLibreAppLinksInterceptor]), the manual "Open in " entry points + * (`GeckoAppLinksApiImpl.launchAppLink`), and prompt resolution. If each site built its own + * launcher the cooldown would be per-instance and the ping-pong defence would break. + */ +object AppLinkRuntime { + @Volatile + private var holder: Holder? = null + + fun get(context: Context): Holder { + return holder ?: synchronized(this) { + holder ?: Holder(context.applicationContext).also { holder = it } + } + } + + class Holder(appContext: Context) { + val resolver: ExternalAppResolver = ExternalAppResolver(AndroidPackageResolver(appContext)) + val launcher: AppLinkLauncher = AppLinkLauncher( + resolver = resolver, + startActivity = { intent -> appContext.startActivity(intent) }, + ) + } +} diff --git a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/applinks/AppLinkSchemes.kt b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/applinks/AppLinkSchemes.kt new file mode 100644 index 00000000..8d1982e1 --- /dev/null +++ b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/applinks/AppLinkSchemes.kt @@ -0,0 +1,82 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +package eu.weblibre.flutter_mozilla_components.applinks + +import java.util.Locale + +/** + * Frozen scheme classification tables for the WebLibre-owned app-links implementation + * (see APP_LINKS_OWN_IMPLEMENTATION_PLAN.md §2.2). + * + * These tables initially match Mozilla Android Components + * ([mozilla.components.feature.app.links.AppLinksUseCases] companion, + * [mozilla.components.feature.app.links.AppLinksInterceptor]). All comparisons are + * case-insensitive via [Locale.ROOT] lowercase — AC lowercases only the denied set; + * making the engine-supported comparison case-insensitive too is a deliberate small + * correctness improvement. `JavaScript:` must be denied as surely as `javascript:`. + * + * These tables describe what Gecko can load, not what the user wants, and are consumed + * on the synchronous interception path — they stay in Kotlin. + */ +object AppLinkSchemes { + // Schemes the Gecko engine can load itself. + // https://searchfox.org/firefox-main/source/netwerk/build/components.conf + val ENGINE_SUPPORTED: Set = setOf( + "about", + "data", + "file", + "ftp", + "http", + "https", + "moz-extension", + "moz-safe-about", + "resource", + "view-source", + "ws", + "wss", + "blob", + ) + + // Schemes that must never be resolved or launched in a third-party app. + val ALWAYS_DENIED: Set = setOf( + "jar", + "file", + "javascript", + "data", + "about", + "content", + "fido", + ) + + // Schemes allowed to open an external application from a subframe. + val SUBFRAME_ALLOWED: Set = setOf( + "msteams", + ) + + // Wallet schemes — always prompt, never remembered (§2.4). + val WALLET: Set = setOf( + "openid4vp", + "mdoc", + "mdoc-openid4vp", + "haip", + "eudi-wallet", + "eudi-openid4vp", + "openid-credential-offer", + ) + + private fun normalize(scheme: String?): String? = scheme?.lowercase(Locale.ROOT) + + fun isEngineSupported(scheme: String?): Boolean = normalize(scheme) in ENGINE_SUPPORTED + + fun isAlwaysDenied(scheme: String?): Boolean = normalize(scheme) in ALWAYS_DENIED + + fun isSubframeAllowed(scheme: String?): Boolean = normalize(scheme) in SUBFRAME_ALLOWED + + fun isWallet(scheme: String?): Boolean = normalize(scheme) in WALLET + + fun isHttpOrHttps(scheme: String?): Boolean = normalize(scheme).let { it == "http" || it == "https" } +} diff --git a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/applinks/ExternalAppResolver.kt b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/applinks/ExternalAppResolver.kt new file mode 100644 index 00000000..964a9186 --- /dev/null +++ b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/applinks/ExternalAppResolver.kt @@ -0,0 +1,344 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +package eu.weblibre.flutter_mozilla_components.applinks + +import android.content.ComponentName +import android.content.Intent +import android.content.pm.ResolveInfo +import android.net.Uri +import android.os.Build +import android.provider.Browser.EXTRA_APPLICATION_ID +import androidx.core.net.toUri +import mozilla.components.support.base.log.logger.Logger +import java.net.URISyntaxException +import java.util.Locale + +private const val EXTRA_BROWSER_FALLBACK_URL = "browser_fallback_url" +private const val MARKET_INTENT_URI_PACKAGE_PREFIX = "market://details?id=" +private const val ANDROID_RESOLVER_PACKAGE_NAME = "android" +private const val APP_LABEL_MAX_LENGTH = 64 +private val PLAY_STORE_URL_REGEX = Regex("https?://play\\.google\\.com/store/.*") + +/** + * Immutable result of resolving a URL against installed apps + * (APP_LINKS_OWN_IMPLEMENTATION_PLAN.md §2.7). Holds a sanitised, launchable [appIntent] + * (trusted component set), never a page-controlled one. + */ +data class ResolvedAppLink( + val hasExternalApp: Boolean, + val appIntent: Intent?, + val packageName: String?, + val appName: String?, + val fallbackUrl: String?, + val marketplaceIntent: Intent?, + val isAmbiguous: Boolean, + val engineSupportsScheme: Boolean, + val scopeKey: String, + val originalScheme: String?, + val intentDataScheme: String?, +) + +/** + * Resolves URLs to external apps, preserving every security-critical behaviour of + * `AppLinksUseCases.createBrowsableIntents` and adding the §2.7 field allowlist. The launched + * intent is rebuilt from a strict allowlist: `ACTION_VIEW`, `CATEGORY_BROWSABLE`, the data URI, + * and a documented compatibility extra — every page-supplied component, selector, bounds, + * identifier, clip/grant state, incoming flag, and browser-fallback metadata is cleared. + * + * A ~30 s resolution cache (AC's `APP_LINKS_CACHE_INTERVAL`) serves the synchronous classify path + * and the "show the button?" queries. There is no package-broadcast invalidator: the mandatory + * pre-launch re-resolution in [AppLinkLauncher] is the correctness guard. + */ +class ExternalAppResolver( + private val packages: PackageResolver, + private val clock: MonotonicClock = MonotonicClock.SYSTEM, + private val cacheTtlMs: Long = APP_LINKS_CACHE_INTERVAL, +) { + private val logger = Logger("ExternalAppResolver") + + private data class CacheEntry(val timestamp: Long, val key: Int, val value: ResolvedAppLink) + + @Volatile + private var cache: CacheEntry? = null + + /** + * Resolve [url] against installed apps. + * + * @param includeHttpAppLinks when `false`, an app resolving an engine-supported (http(s)) URL + * is not treated as an external app — the engine keeps the load. Manual "Open in app" callers + * pass `true` so a YouTube link surfaces the YouTube app. + * @param useCache consult/populate the short-lived resolution cache. Launch paths pass `false` + * so they always re-resolve immediately before `startActivity`. + */ + fun resolve( + url: String, + includeHttpAppLinks: Boolean, + useCache: Boolean = true, + ): ResolvedAppLink { + val key = (url + "|" + includeHttpAppLinks).hashCode() + val now = clock.elapsedRealtime() + if (useCache) { + cache?.let { entry -> + if (entry.key == key && now <= entry.timestamp + cacheTtlMs) { + return entry.value + } + } + } + + val result = resolveUncached(url, includeHttpAppLinks) + if (useCache) { + cache = CacheEntry(now, key, result) + } + return result + } + + fun clearCache() { + cache = null + } + + private fun resolveUncached(url: String, includeHttpAppLinks: Boolean): ResolvedAppLink { + val originalScheme = try { + url.toUri().scheme?.lowercase(Locale.ROOT) + } catch (e: Exception) { + null + } + val engineSupported = AppLinkSchemes.isEngineSupported(originalScheme) + val hostScope = AppLinkHostNormalizer.hostScopeKey(runCatching { url.toUri().host }.getOrNull()) + + fun empty(scope: String, intentDataScheme: String? = null) = ResolvedAppLink( + hasExternalApp = false, + appIntent = null, + packageName = null, + appName = null, + fallbackUrl = null, + marketplaceIntent = null, + isAmbiguous = false, + engineSupportsScheme = engineSupported, + scopeKey = scope, + originalScheme = originalScheme, + intentDataScheme = intentDataScheme, + ) + + // Always-denied schemes never resolve or launch externally (§2.2). Return early so no + // fallback or marketplace intent is extracted from them. + if (AppLinkSchemes.isAlwaysDenied(originalScheme)) { + return empty(hostScope ?: "") + } + + val parsed = safeParseUri(url) ?: return empty(hostScope ?: "") + val dataScheme = parsed.data?.scheme?.lowercase(Locale.ROOT) + + // Reject a sanitised intent whose data scheme is itself always-denied. + if (parsed.data == null || AppLinkSchemes.isAlwaysDenied(dataScheme)) { + return empty(hostScope ?: "", dataScheme) + } + + val requestedPackage = parsed.`package` + val appIntent = buildLaunchIntent(parsed) + val pageFallback = parsed.getStringExtra(EXTRA_BROWSER_FALLBACK_URL) + + // Resolve the external-app handler. A browser default for an http(s) link is not itself an + // "open in app" target — as with no default or the Android chooser sentinel — so look past + // it for a non-browser handler (e.g. the YouTube app for a youtube.com link the default + // browser also handles). Browsers are excluded only for engine-supported (http) schemes. + var isAmbiguous = false + var resolvedPackage: String? = null + var resolvedActivityName: String? = null + var resolvedInfo: ResolveInfo? = null + + val defaultInfo = packages.resolveDefaultActivity(appIntent) + val defaultPackage = defaultInfo?.activityInfo?.packageName + val defaultIsUsableApp = defaultPackage != null && + defaultPackage != packages.selfPackageName && + defaultPackage != ANDROID_RESOLVER_PACKAGE_NAME && + !(engineSupported && packages.isInstalledBrowser(defaultPackage)) + + when { + defaultIsUsableApp -> { + resolvedPackage = defaultPackage + resolvedActivityName = defaultInfo?.activityInfo?.name + resolvedInfo = defaultInfo + } + // A page must not relaunch WebLibre through the app-link path: if WebLibre itself is the + // default handler, keep the load in-browser rather than hunting for other apps. + defaultPackage == packages.selfPackageName -> { + resolvedPackage = null + } + // No usable default (none / chooser / a browser for an http link): pick a non-browser + // handler. A single one launches directly (rememberable); several stay ambiguous (chooser). + else -> { + val candidates = packages.queryActivities(appIntent).filter { info -> + val pkg = info.activityInfo?.packageName + info.filter != null && + pkg != null && + pkg != packages.selfPackageName && + !(engineSupported && packages.isInstalledBrowser(pkg)) + } + candidates.firstOrNull()?.let { chosen -> + resolvedPackage = chosen.activityInfo?.packageName + resolvedActivityName = chosen.activityInfo?.name + resolvedInfo = chosen + isAmbiguous = candidates.size > 1 + } + } + } + + // hasExternalApp mirrors AC's appIntent decision, minus the launchInApp() policy gate + // (policy lives in the classifier). A resolved package is never a browser for an http link + // (excluded above), so the only remaining http gate is includeHttpAppLinks. + val hasExternalApp = when { + resolvedPackage == null -> false + // http(s) app links only count when the caller asks for them. + engineSupported && !includeHttpAppLinks -> false + else -> true + } + + // Bind the trusted, resolved component (never a page-supplied one). + if (hasExternalApp && resolvedPackage != null && resolvedActivityName != null && !isAmbiguous) { + appIntent.component = ComponentName(resolvedPackage, resolvedActivityName) + } + + val appName = if (hasExternalApp && resolvedInfo != null) { + sanitizeAppLabel(packages.applicationLabel(resolvedInfo)) + } else { + null + } + + // Fallback: accepted only if http(s), the original scheme is not engine-supported, and it is + // not a Play Store URL for an already-installed app. + val fallbackUrl = pageFallback?.let { validateFallback(it, engineSupported, appInstalled = resolvedPackage != null) } + + // Marketplace intent: only when the target package is not installed. + val marketplaceIntent = requestedPackage + ?.takeIf { !packages.isPackageInstalled(it) } + ?.let { safeParseRawUri(MARKET_INTENT_URI_PACKAGE_PREFIX + it) } + ?.apply { flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK } + + // Scope key: host for engine-supported (http) links; resolved package otherwise (§2.5). + val scopeKey = when { + engineSupported && hostScope != null -> hostScope + resolvedPackage != null -> AppLinkHostNormalizer.packageScopeKey(resolvedPackage) ?: (hostScope ?: "") + else -> hostScope ?: "" + } + + return ResolvedAppLink( + hasExternalApp = hasExternalApp, + appIntent = if (hasExternalApp) appIntent else null, + packageName = if (hasExternalApp) resolvedPackage else null, + appName = appName, + fallbackUrl = fallbackUrl, + marketplaceIntent = marketplaceIntent, + isAmbiguous = isAmbiguous, + engineSupportsScheme = engineSupported, + scopeKey = scopeKey, + originalScheme = originalScheme, + intentDataScheme = dataScheme, + ) + } + + /** Parse an `intent:`/URL into an Intent, rejecting self-package targets. */ + private fun safeParseUri(url: String): Intent? { + val intent = safeParseRawUri(url, Intent.URI_INTENT_SCHEME) ?: return null + return if (intent.`package` == packages.selfPackageName) { + // Ignore intents that would relaunch WebLibre. + null + } else { + intent + } + } + + private fun safeParseRawUri(uri: String, flags: Int = 0): Intent? { + return try { + Intent.parseUri(uri, flags) + } catch (e: URISyntaxException) { + logger.error("failed to parse URI", e) + null + } catch (e: NumberFormatException) { + // Intent.parseUri may throw NumberFormatException on malformed numeric extras. + logger.error("failed to parse URI", e) + null + } + } + + /** + * Rebuild [source] into a sanitised, launchable intent using a field allowlist (§2.7): + * force ACTION_VIEW; add CATEGORY_BROWSABLE; retain only the data URI and documented + * compatibility extras; clear every page-supplied structural field and all incoming flags. + */ + private fun buildLaunchIntent(source: Intent): Intent { + val sanitized = Intent(Intent.ACTION_VIEW) + source.data?.let { sanitized.data = it } + sanitized.addCategory(Intent.CATEGORY_BROWSABLE) + + // Preserve an explicit `intent:...;package=` target: it is a package-id constraint (not a + // component, which could point at a non-exported activity), so resolution/launch targets the + // app the link actually names instead of some other handler or WebLibre itself. `safeParseUri` + // already rejected a self-package target. This mirrors AC's createBrowsableIntents. + source.`package`?.let { pkg -> + if (pkg != packages.selfPackageName) sanitized.`package` = pkg + } + + // Explicitly clear every structural field a page could weaponise. + sanitized.component = null + sanitized.selector = null + sanitized.sourceBounds = null + sanitized.clipData = null + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + sanitized.identifier = null + } + // flags = FLAG_ACTIVITY_NEW_TASK — assignment, not `or`. Clears page-supplied flags such as + // FLAG_GRANT_READ_URI_PERMISSION. + sanitized.flags = Intent.FLAG_ACTIVITY_NEW_TASK + + // Documented compatibility extra only. EXTRA_BROWSER_FALLBACK_URL is deliberately not copied + // onto the launched intent (it is extracted separately for the interceptor). + sanitized.putExtra(EXTRA_APPLICATION_ID, packages.selfPackageName) + + return sanitized + } + + private fun validateFallback( + rawFallback: String, + originalSchemeEngineSupported: Boolean, + appInstalled: Boolean, + ): String? { + val scheme = try { + Uri.parse(rawFallback).scheme?.lowercase(Locale.ROOT) + } catch (e: Exception) { + return null + } + if (!AppLinkSchemes.isHttpOrHttps(scheme)) return null + if (originalSchemeEngineSupported) return null + val isPlayStoreUrlForInstalledApp = PLAY_STORE_URL_REGEX.matches(rawFallback) && appInstalled + if (isPlayStoreUrlForInstalledApp) return null + return rawFallback + } + + /** App labels are app-controlled: strip control/bidi characters and length-bound. */ + private fun sanitizeAppLabel(label: String?): String? { + if (label.isNullOrEmpty()) return null + val cleaned = buildString { + for (ch in label) { + val type = Character.getType(ch) + if (type == Character.CONTROL.toInt() || type == Character.FORMAT.toInt()) { + continue + } + append(ch) + } + }.trim() + if (cleaned.isEmpty()) return null + return if (cleaned.length > APP_LABEL_MAX_LENGTH) { + cleaned.substring(0, APP_LABEL_MAX_LENGTH) + } else { + cleaned + } + } + + companion object { + const val APP_LINKS_CACHE_INTERVAL = 30 * 1000L + } +} diff --git a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/applinks/MonotonicClock.kt b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/applinks/MonotonicClock.kt new file mode 100644 index 00000000..cadbc2f3 --- /dev/null +++ b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/applinks/MonotonicClock.kt @@ -0,0 +1,22 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +package eu.weblibre.flutter_mozilla_components.applinks + +import android.os.SystemClock + +/** + * Injectable monotonic clock. All app-links timing (resolution cache TTL, launch cooldown, + * pending-request expiry, suppression timeout) reads from this seam so tests can advance + * time deterministically. + */ +fun interface MonotonicClock { + fun elapsedRealtime(): Long + + companion object { + val SYSTEM = MonotonicClock { SystemClock.elapsedRealtime() } + } +} diff --git a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/applinks/NativeAppLinkPromptFeature.kt b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/applinks/NativeAppLinkPromptFeature.kt new file mode 100644 index 00000000..114e72db --- /dev/null +++ b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/applinks/NativeAppLinkPromptFeature.kt @@ -0,0 +1,147 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +package eu.weblibre.flutter_mozilla_components.applinks + +import android.content.Context +import android.os.Handler +import android.os.Looper +import androidx.appcompat.app.AlertDialog +import eu.weblibre.flutter_mozilla_components.R +import eu.weblibre.flutter_mozilla_components.pigeons.AppLinkPromptOwner +import mozilla.components.feature.session.SessionUseCases +import mozilla.components.support.base.feature.LifecycleAwareFeature +import java.util.concurrent.ConcurrentHashMap + +/** + * Process-level registry of the *started* [NativeAppLinkPromptFeature] instances, keyed by tabId. + * The [WebLibreAppLinksInterceptor] runs on an engine thread and creates prompt requests + * asynchronously; a Custom Tab feature only queries the store at lifecycle start, so without this a + * request created after start would sit unshown (its navigation already denied) until a rotation or + * restart. The interceptor pings [notifyPromptAvailable] so the feature re-queries immediately. + */ +object NativeAppLinkPromptNotifier { + private val features = ConcurrentHashMap() + + fun register(tabId: String, feature: NativeAppLinkPromptFeature) { + features[tabId] = feature + } + + fun unregister(tabId: String, feature: NativeAppLinkPromptFeature) { + features.remove(tabId, feature) + } + + fun notifyPromptAvailable(tabId: String) { + features[tabId]?.onPromptAvailable() + } +} + +/** + * Presents the minimal native app-link prompt for Custom Tab sessions that have no + * Flutter engine (APP_LINKS_OWN_IMPLEMENTATION_PLAN.md §2.6). Title, message, + * open/cancel — **no remember checkbox**, so native never creates policy. + * + * Queries [PendingAppLinkStore] for its own tab on start (and re-queries after each + * resolution); a request that is rotated/backgrounded away stays pending and is + * re-presented on the next start. Owner is fixed to [AppLinkPromptOwner.NATIVE_EXTERNAL]. + */ +class NativeAppLinkPromptFeature( + private val context: Context, + private val tabId: String, + private val store: PendingAppLinkStore, + private val launcher: AppLinkLauncher, + private val sessionUseCases: SessionUseCases, +) : LifecycleAwareFeature { + private var dialog: AlertDialog? = null + private val mainHandler = Handler(Looper.getMainLooper()) + + override fun start() { + NativeAppLinkPromptNotifier.register(tabId, this) + showNext() + } + + override fun stop() { + NativeAppLinkPromptNotifier.unregister(tabId, this) + // Dismissing on stop is not a user dismissal: the request stays pending and + // is re-presented on the next start(). + dialog?.setOnDismissListener(null) + dialog?.dismiss() + dialog = null + } + + /** + * A new pending request may have been created for this tab (interceptor, engine thread) after + * [start] already queried. Re-check on the main thread; [showNext] is idempotent (a no-op while a + * dialog is up or when nothing pends). + */ + fun onPromptAvailable() { + mainHandler.post { showNext() } + } + + private fun showNext() { + if (dialog != null) return + + val request = store.getPending(AppLinkPromptOwner.NATIVE_EXTERNAL) + .firstOrNull { it.tabId == tabId } + ?: return + + val title = request.appName?.let { + context.getString(R.string.weblibre_app_link_prompt_title_named, it) + } ?: context.getString(R.string.weblibre_app_link_prompt_title_generic) + + dialog = AlertDialog.Builder(context) + .setTitle(title) + .setMessage(context.getString(R.string.weblibre_app_link_prompt_message)) + .setPositiveButton(R.string.weblibre_app_link_prompt_open) { _, _ -> + resolveOpen(request) + } + .setNegativeButton(R.string.weblibre_app_link_prompt_cancel) { _, _ -> + resolveCancel(request) + } + .setOnCancelListener { + // Back / touch-outside is an explicit passive dismissal (§2.6). + resolveCancel(request) + } + .setOnDismissListener { dialog = null } + .show() + } + + private fun resolveOpen(request: PendingAppLinkRequest) { + val consumed = store.consume(request.requestId) ?: return afterResolve() + val mode = if (consumed.isMarketplace) { + AppLinkLaunchMode.MARKETPLACE + } else { + AppLinkLaunchMode.MANUAL + } + // Fresh prompt-open: no remembered package binding to enforce (§2.5); the + // launcher's pre-launch re-resolution still validates the handler. + // Honour the package captured when the prompt was created for a *named* + // (non-ambiguous) target, so a change in handlers before the user taps Open + // can't launch a different app (§2.5/§2.7). Ambiguous/chooser prompts store a + // null expectedPackage, so this stays null and the chooser still opens. + val result = launcher.launch(consumed.url, mode, expectedPackage = consumed.expectedPackage) + if (result != AppLinkLaunchResult.LAUNCHED) { + consumed.fallbackUrl?.let { fallback -> + // Guard the fallback load against immediately bouncing back out to an + // app (§2.7): a validated fallback can itself resolve externally. + store.recordFallbackReentry(fallback) + sessionUseCases.loadUrl(url = fallback, sessionId = consumed.tabId) + } + } + afterResolve() + } + + private fun resolveCancel(request: PendingAppLinkRequest) { + val consumed = store.consume(request.requestId) ?: return afterResolve() + store.recordSuppression(consumed.tabId, consumed.targetFingerprint) + afterResolve() + } + + private fun afterResolve() { + dialog = null + showNext() + } +} diff --git a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/applinks/PackageResolver.kt b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/applinks/PackageResolver.kt new file mode 100644 index 00000000..6dd98da8 --- /dev/null +++ b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/applinks/PackageResolver.kt @@ -0,0 +1,92 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +package eu.weblibre.flutter_mozilla_components.applinks + +import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager +import android.content.pm.ResolveInfo +import mozilla.components.support.base.log.logger.Logger +import mozilla.components.support.ktx.android.content.pm.isPackageInstalled +import mozilla.components.support.utils.BrowsersCache +import mozilla.components.support.utils.ext.packageManagerCompatHelper + +/** + * Seam over [PackageManager] and browser detection so the resolver can be unit-tested + * (see APP_LINKS_OWN_IMPLEMENTATION_PLAN.md §2.7, Phase 1). Intent construction and + * sanitisation are still exercised under Robolectric because plain JVM stubs never + * populate the fields the resolver strips. + */ +interface PackageResolver { + val selfPackageName: String + + /** May throw [RuntimeException] internally on large result sets; returns empty on failure. */ + fun queryActivities(intent: Intent): List + + /** The default activity for [intent], honouring `MATCH_DEFAULT_ONLY`. */ + fun resolveDefaultActivity(intent: Intent): ResolveInfo? + + fun isPackageInstalled(packageName: String): Boolean + + /** True when [packageName] is an installed browser (excluded for engine-supported schemes). */ + fun isInstalledBrowser(packageName: String): Boolean + + fun applicationLabel(resolveInfo: ResolveInfo): String? +} + +class AndroidPackageResolver(private val context: Context) : PackageResolver { + private val logger = Logger("AppLinkPackageResolver") + + override val selfPackageName: String + get() = context.packageName + + @Suppress("QueryPermissionsNeeded", "TooGenericExceptionCaught") + override fun queryActivities(intent: Intent): List { + return try { + context.packageManagerCompatHelper.queryIntentActivitiesCompat( + intent, + PackageManager.GET_RESOLVED_FILTER, + ) + } catch (e: RuntimeException) { + // queryIntentActivities throws on very large result sets — treat as "nothing". + logger.error("failed to query activities", e) + emptyList() + } + } + + @Suppress("TooGenericExceptionCaught") + override fun resolveDefaultActivity(intent: Intent): ResolveInfo? { + return try { + context.packageManagerCompatHelper.resolveActivityCompat( + intent, + PackageManager.MATCH_DEFAULT_ONLY, + ) + } catch (e: RuntimeException) { + logger.error("failed to resolve default activity", e) + null + } + } + + override fun isPackageInstalled(packageName: String): Boolean { + return context.packageManagerCompatHelper.isPackageInstalled(packageName) + } + + override fun isInstalledBrowser(packageName: String): Boolean { + return BrowsersCache.all(context).isInstalled(packageName) + } + + @Suppress("TooGenericExceptionCaught") + override fun applicationLabel(resolveInfo: ResolveInfo): String? { + return try { + val appInfo = resolveInfo.activityInfo?.applicationInfo ?: return null + context.packageManager.getApplicationLabel(appInfo).toString() + } catch (e: Exception) { + logger.error("failed to read application label", e) + null + } + } +} diff --git a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/applinks/PendingAppLinkStore.kt b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/applinks/PendingAppLinkStore.kt new file mode 100644 index 00000000..08612ded --- /dev/null +++ b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/applinks/PendingAppLinkStore.kt @@ -0,0 +1,342 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +package eu.weblibre.flutter_mozilla_components.applinks + +import eu.weblibre.flutter_mozilla_components.pigeons.AppLinkPromptOwner +import eu.weblibre.flutter_mozilla_components.pigeons.AppLinkPromptRequest +import eu.weblibre.flutter_mozilla_components.pigeons.AppLinkTarget +import mozilla.components.support.base.log.logger.Logger +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.atomic.AtomicLong + +/** The §2.2 URL class a pending request belongs to; part of the dedupe key. */ +enum class AppLinkUrlClass { + BANNER, + MODAL, + MARKETPLACE, +} + +/** + * A pending prompt, stored until resolved/invalidated/expired (§2.6). Holds only + * stable identifiers and sanitised data — never a Components/EngineSession/store + * reference. Carries everything needed both to render the prompt and to perform + * the resolution side effect (re-resolve + launch, or load a validated fallback). + */ +data class PendingAppLinkRequest( + val requestId: Long, + val owner: AppLinkPromptOwner, + val tabId: String, + val contextId: String?, + val sourceUrl: String?, + val isPrivate: Boolean, + val isWallet: Boolean, + val isProtectedContext: Boolean, + val canRemember: Boolean, + val isModal: Boolean, + val urlClass: AppLinkUrlClass, + // Resolution data: + val url: String, + val expectedPackage: String?, + val fallbackUrl: String?, + val engineSupportsScheme: Boolean, + val isMarketplace: Boolean, + // Full sanitised-target fingerprint (URL + intent payload), the dedupe/invalidation key. + val targetFingerprint: String, + val appName: String?, + val packageName: String?, + val scopeKey: String, + val createdAt: Long, +) { + fun toPigeon(): AppLinkPromptRequest = AppLinkPromptRequest( + requestId = requestId, + owner = owner, + tabId = tabId, + contextId = contextId, + sourceUrl = sourceUrl, + isPrivate = isPrivate, + isWallet = isWallet, + isProtectedContext = isProtectedContext, + canRemember = canRemember, + isModal = isModal, + target = AppLinkTarget( + url = url, + appName = appName, + packageName = packageName, + fallbackUrl = fallbackUrl, + isMarketplace = isMarketplace, + isAmbiguous = !canRemember, + engineSupportsScheme = engineSupportsScheme, + scopeKey = scopeKey, + ), + ) +} + +/** Everything needed to create a request; the store assigns the id and timestamp. */ +data class NewAppLinkRequest( + val owner: AppLinkPromptOwner, + val tabId: String, + val contextId: String?, + val sourceUrl: String?, + val isPrivate: Boolean, + val isWallet: Boolean, + val isProtectedContext: Boolean, + val canRemember: Boolean, + val isModal: Boolean, + val urlClass: AppLinkUrlClass, + val url: String, + val expectedPackage: String?, + val fallbackUrl: String?, + val engineSupportsScheme: Boolean, + val isMarketplace: Boolean, + val targetFingerprint: String, + val appName: String?, + val packageName: String?, + val scopeKey: String, + /** A user-gesture attempt is never deduped into an older request (§2.6). */ + val isUserGesture: Boolean = false, +) + +/** + * Process-level registry of profile-scoped [PendingAppLinkStore] singletons (§2.10). + * Keyed by native's canonical profile relative path; survives `GlobalComponents.setUp()`. + */ +object PendingAppLinkStores { + private val stores = ConcurrentHashMap() + + fun forProfile(relativePath: String): PendingAppLinkStore = + stores.getOrPut(relativePath) { PendingAppLinkStore() } + + fun remove(relativePath: String) { + stores.remove(relativePath) + } +} + +/** + * Holds pending prompts, dedupe, suppression, and the fallback re-entry map (§2.6). + * Query + consume: requests stay until resolved, invalidated, or expired. The store + * never holds its lock across a side effect — [consume] returns the request and the + * caller performs launch/fallback after the lock is released. + */ +class PendingAppLinkStore( + private val clock: MonotonicClock = MonotonicClock.SYSTEM, + private val requestExpiryMs: Long = REQUEST_EXPIRY_MS, + private val suppressionExpiryMs: Long = SUPPRESSION_EXPIRY_MS, + private val dedupeWindowMs: Long = DEDUPE_WINDOW_MS, + private val fallbackReentryMs: Long = FALLBACK_REENTRY_MS, +) { + private val logger = Logger("PendingAppLinkStore") + private val lock = Any() + private val idGenerator = AtomicLong(0L) + + private val requests = LinkedHashMap() + private val suppression = HashMap() + private val fallbackReentry = HashMap() + + private fun suppressionKey(tabId: String, fingerprint: String) = "$tabId\u0000$fingerprint" + + /** + * Create a request, collapsing a matching non-user-gesture request that arrived + * within the dedupe window into the existing one (§2.6). + */ + fun createRequest(input: NewAppLinkRequest): PendingAppLinkRequest { + synchronized(lock) { + sweepExpiredLocked() + + if (!input.isUserGesture) { + val existing = requests.values.firstOrNull { candidate -> + candidate.tabId == input.tabId && + candidate.targetFingerprint == input.targetFingerprint && + candidate.owner == input.owner && + candidate.urlClass == input.urlClass && + clock.elapsedRealtime() <= candidate.createdAt + dedupeWindowMs + } + if (existing != null) return existing + } + + val request = PendingAppLinkRequest( + requestId = idGenerator.incrementAndGet(), + owner = input.owner, + tabId = input.tabId, + contextId = input.contextId, + sourceUrl = input.sourceUrl, + isPrivate = input.isPrivate, + isWallet = input.isWallet, + isProtectedContext = input.isProtectedContext, + canRemember = input.canRemember, + isModal = input.isModal, + urlClass = input.urlClass, + url = input.url, + expectedPackage = input.expectedPackage, + fallbackUrl = input.fallbackUrl, + engineSupportsScheme = input.engineSupportsScheme, + isMarketplace = input.isMarketplace, + targetFingerprint = input.targetFingerprint, + appName = input.appName, + packageName = input.packageName, + scopeKey = input.scopeKey, + createdAt = clock.elapsedRealtime(), + ) + requests[request.requestId] = request + return request + } + } + + /** Non-consuming query of live requests for [owner]. */ + fun getPending(owner: AppLinkPromptOwner): List { + synchronized(lock) { + sweepExpiredLocked() + return requests.values.filter { it.owner == owner }.toList() + } + } + + /** Atomically remove and return a request; null if already resolved/expired. */ + fun consume(requestId: Long): PendingAppLinkRequest? { + synchronized(lock) { + sweepExpiredLocked() + return requests.remove(requestId) + } + } + + fun peek(requestId: Long): PendingAppLinkRequest? { + synchronized(lock) { + sweepExpiredLocked() + return requests[requestId] + } + } + + fun invalidate(requestId: Long) { + synchronized(lock) { requests.remove(requestId) } + } + + /** Invalidate every pending request for a tab (tab close / replacement). */ + fun invalidateTab(tabId: String) { + synchronized(lock) { + requests.values.removeAll { it.tabId == tabId } + suppression.keys.removeAll { it.startsWith("$tabId\u0000") } + } + } + + /** + * A committed top-level navigation in [tabId]. A request whose own page committed + * stays alive (that commit is the page the prompt sits on); a commit to a + * *different site* invalidates the tab's pending requests (§2.6). + * + * Matching is by **normalised host**, not exact URL: the initial load a banner + * rides on almost always commits at a redirected/normalised URL (`www`, trailing + * slash, tracking params) that never equals the intercepted URL, so an exact-URL + * check would invalidate every banner on its own page load. The anchor is the + * target host for a banner (the page it loads) and the source host for a modal + * (the page it is shown over, since the modal's own navigation was denied). When + * no host can be derived, the request is kept and left to expiry/tab-close. + */ + fun onCommittedNavigation(tabId: String, committedUrl: String) { + val committedHost = siteKey(committedUrl) + synchronized(lock) { + val removed = mutableListOf() + requests.values.removeAll { request -> + if (request.tabId != tabId) return@removeAll false + val anchorHost = siteKey(if (request.isModal) request.sourceUrl else request.url) + val invalidate = anchorHost != null && committedHost != null && anchorHost != committedHost + if (invalidate) removed.add(request.requestId) + invalidate + } + if (removed.isNotEmpty()) { + logger.info( + "onCommittedNavigation tab=$tabId committedHost=$committedHost invalidated=$removed", + ) + } + } + } + + /** Normalised, subdomain-stripped host for same-site comparison; null if underivable. */ + private fun siteKey(url: String?): String? { + val rawHost = extractHost(url) ?: return null + val normalized = AppLinkHostNormalizer.normalizeHost(rawHost) ?: return null + return stripCommonSubDomains(normalized) + } + + private fun extractHost(url: String?): String? { + if (url.isNullOrEmpty()) return null + val schemeSep = url.indexOf("://") + if (schemeSep < 0) return null + val afterScheme = url.substring(schemeSep + 3) + val end = afterScheme.indexOfFirst { it == '/' || it == '?' || it == '#' } + var authority = if (end >= 0) afterScheme.substring(0, end) else afterScheme + val at = authority.lastIndexOf('@') + if (at >= 0) authority = authority.substring(at + 1) + // Preserve a bracketed IPv6 literal; AppLinkHostNormalizer canonicalises it. + if (authority.startsWith("[")) { + val close = authority.indexOf(']') + return if (close >= 0) authority.substring(0, close + 1) else null + } + val colon = authority.lastIndexOf(':') + if (colon >= 0) authority = authority.substring(0, colon) + return authority.ifEmpty { null } + } + + private fun stripCommonSubDomains(host: String): String = when { + host.startsWith("www.") -> host.removePrefix("www.") + host.startsWith("m.") -> host.removePrefix("m.") + host.startsWith("mobile.") -> host.removePrefix("mobile.") + host.startsWith("maps.") -> host.removePrefix("maps.") + else -> host + } + + // ---- Suppression (§2.6) ---- + + fun recordSuppression(tabId: String, fingerprint: String) { + synchronized(lock) { + suppression[suppressionKey(tabId, fingerprint)] = + clock.elapsedRealtime() + suppressionExpiryMs + } + } + + fun isSuppressed(tabId: String, fingerprint: String): Boolean { + synchronized(lock) { + sweepExpiredLocked() + val expiresAt = suppression[suppressionKey(tabId, fingerprint)] ?: return false + return clock.elapsedRealtime() <= expiresAt + } + } + + /** Clear a tab's suppression on a new user-initiated/direct navigation (§2.6). */ + fun clearSuppressionForTab(tabId: String) { + synchronized(lock) { + suppression.keys.removeAll { it.startsWith("$tabId\u0000") } + } + } + + // ---- Fallback re-entry map (§2.7) ---- + + fun recordFallbackReentry(canonicalUrl: String) { + synchronized(lock) { + fallbackReentry[canonicalUrl] = clock.elapsedRealtime() + fallbackReentryMs + } + } + + fun isFallbackReentry(canonicalUrl: String): Boolean { + synchronized(lock) { + sweepExpiredLocked() + val expiresAt = fallbackReentry[canonicalUrl] ?: return false + return clock.elapsedRealtime() <= expiresAt + } + } + + private fun sweepExpiredLocked() { + val now = clock.elapsedRealtime() + requests.values.removeAll { now > it.createdAt + requestExpiryMs } + suppression.values.removeAll { now > it } + fallbackReentry.values.removeAll { now > it } + } + + companion object { + const val REQUEST_EXPIRY_MS = 10 * 60 * 1000L + const val SUPPRESSION_EXPIRY_MS = 10 * 60 * 1000L + const val DEDUPE_WINDOW_MS = 2000L + const val FALLBACK_REENTRY_MS = 10 * 1000L + } +} diff --git a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/applinks/WebLibreAppLinksInterceptor.kt b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/applinks/WebLibreAppLinksInterceptor.kt new file mode 100644 index 00000000..82b67ade --- /dev/null +++ b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/applinks/WebLibreAppLinksInterceptor.kt @@ -0,0 +1,382 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +package eu.weblibre.flutter_mozilla_components.applinks + +import android.content.Context +import android.content.Intent +import android.net.Uri +import androidx.core.net.toUri +import eu.weblibre.flutter_mozilla_components.Components +import eu.weblibre.flutter_mozilla_components.GlobalComponents +import eu.weblibre.flutter_mozilla_components.ext.EventSequence +import eu.weblibre.flutter_mozilla_components.pigeons.AppLinkPromptOwner +import mozilla.components.browser.state.selector.findTabOrCustomTab +import mozilla.components.browser.state.state.CustomTabSessionState +import mozilla.components.browser.state.state.SessionState +import mozilla.components.concept.engine.EngineSession +import mozilla.components.concept.engine.request.RequestInterceptor +import mozilla.components.support.base.log.logger.Logger +import mozilla.components.support.ktx.kotlin.tryGetHostFromUrl +import java.util.Locale + +/** + * The WebLibre-owned §2.4 interception tail (APP_LINKS_OWN_IMPLEMENTATION_PLAN.md Phase 5). Replaces + * Mozilla AC's `AppLinksInterceptor` + `AppLinksFeature` + `AppLinksCancelRetryMiddleware` on the + * synchronous `RequestInterceptor.onLoadRequest` path. + * + * Structural guards (PWA/TWA, sandbox capture, `weblibre://`, FxA) already ran in + * [eu.weblibre.flutter_mozilla_components.interceptor.AppRequestInterceptor] before this is called; + * this tail owns steps 2–8: navigation eligibility, resolution/sanitisation ([ExternalAppResolver]), + * the pure [AppLinkClassifier] decision, and its execution (auto-launch, validated fallback, or a + * pending prompt). Policy comes from the profile-scoped [AppLinkPolicyStore]; prompts land in the + * profile-scoped [PendingAppLinkStore]. It never denies a load and re-issues the same load. + */ +class WebLibreAppLinksInterceptor( + private val context: Context, +) { + private val logger = Logger("WebLibreAppLinks") + private val runtime get() = AppLinkRuntime.get(context) + + /** + * @return the interception response, or `null` to let the engine proceed. Creating a pending + * prompt is a side effect performed here; the return value only controls the current load. + */ + fun onLoadRequest( + engineSession: EngineSession, + uri: String, + lastUri: String?, + hasUserGesture: Boolean, + isRedirect: Boolean, + isDirectNavigation: Boolean, + isSubframeRequest: Boolean, + ): RequestInterceptor.InterceptionResponse? { + val components = GlobalComponents.components ?: return null + + val uriScheme = runCatching { uri.toUri().scheme }.getOrNull() + val engineSupportsScheme = AppLinkSchemes.isEngineSupported(uriScheme) + + // Step 2 — navigation eligibility. Any hit lets the engine proceed normally. + if (!isEligible(uri, lastUri, uriScheme, engineSupportsScheme, hasUserGesture, isRedirect, isDirectNavigation, isSubframeRequest)) { + return null + } + + val pendingStore = pendingStoreFor(components) + + // Fallback re-entry guard (§2.7): a fallback we issued has come back around. Keep it in the + // browser — never let it bounce out to an app. Consulted before resolution/classification. + if (pendingStore.isFallbackReentry(canonicalReentryKey(uri))) { + return null + } + + val resolved = runtime.resolver.resolve(uri, includeHttpAppLinks = true, useCache = true) + + val policy = AppLinkPolicyStores.forProfile(components.profileApplicationContext).policy + + val session = components.core.store.state.findTabOrCustomTab(engineSession) + + // Container isolation (replace semantics): a container with "isolated app link settings" + // enabled contributes an entry keyed by its contextId. When the source tab's contextId has + // one, its mode + rules fully replace the global ones for this navigation. + val override = session?.contextId?.let { policy.contextOverrides[it] } + val effectiveMode = override?.globalMode ?: policy.globalMode + val effectiveRules = override?.rules ?: policy.rules + + val input = ClassifierInput( + resolved = resolved, + isProtected = isProtected(policy, session, uri), + isPrivate = session?.content?.private ?: false, + isWallet = AppLinkSchemes.isWallet(resolved.originalScheme) || + AppLinkSchemes.isWallet(resolved.intentDataScheme), + missingSession = session == null, + suppressionHit = session != null && + pendingStore.isSuppressed(session.id, targetFingerprint(uri, resolved)), + matchingRule = effectiveRules[resolved.scopeKey], + globalMode = effectiveMode, + marketplaceFallbackEnabled = policy.marketplaceFallbackEnabled, + ) + + val decision = AppLinkClassifier.classify(input) + logger.info( + "classify uri=$uri tab=${session?.id} ctx=${session?.contextId} " + + "isolated=${override != null} hasApp=${resolved.hasExternalApp} " + + "engineScheme=${resolved.engineSupportsScheme} mode=${input.globalMode} " + + "protected=${input.isProtected} private=${input.isPrivate} wallet=${input.isWallet} " + + "suppressed=${input.suppressionHit} rule=${input.matchingRule?.decision} -> $decision", + ) + return execute(decision, components, pendingStore, session, uri, lastUri, input, hasUserGesture) + } + + private fun execute( + decision: AppLinkDecision, + components: Components, + pendingStore: PendingAppLinkStore, + session: SessionState?, + uri: String, + lastUri: String?, + input: ClassifierInput, + hasUserGesture: Boolean, + ): RequestInterceptor.InterceptionResponse? { + val resolved = input.resolved + return when (decision) { + is AppLinkDecision.AllowEngine -> null + + is AppLinkDecision.DenyKeepPage -> RequestInterceptor.InterceptionResponse.Deny + + is AppLinkDecision.LoadFallback -> { + pendingStore.recordFallbackReentry(canonicalReentryKey(decision.url)) + RequestInterceptor.InterceptionResponse.Url(decision.url) + } + + is AppLinkDecision.AutoLaunch -> { + val result = runtime.launcher.launch( + uri, + AppLinkLaunchMode.AUTOMATIC, + decision.expectedPackage, + ) + when (result) { + AppLinkLaunchResult.LAUNCHED -> RequestInterceptor.InterceptionResponse.Deny + + // A remembered `alwaysOpen` rule whose package no longer resolves must not + // silently launch a different app: fall through to a prompt (§2.5). Reclassify + // once with the rule removed so the global mode decides. + AppLinkLaunchResult.PACKAGE_MISMATCH -> { + val withoutRule = input.copy(matchingRule = null) + execute( + AppLinkClassifier.classify(withoutRule), + components, pendingStore, session, uri, lastUri, withoutRule, hasUserGesture, + ) + } + + // Launch failed/cooldown: answer in the original callback (§2.7). Never deny an + // engine-supported original and reload it — return null so it loads once. + else -> when { + resolved.engineSupportsScheme -> null + resolved.fallbackUrl != null -> { + pendingStore.recordFallbackReentry(canonicalReentryKey(resolved.fallbackUrl)) + RequestInterceptor.InterceptionResponse.Url(resolved.fallbackUrl) + } + else -> RequestInterceptor.InterceptionResponse.Deny + } + } + } + + is AppLinkDecision.Prompt -> { + // A missing session cannot host a prompt; the classifier never reaches Prompt in that + // case, so `session` is non-null here. + val tab = session ?: return safeNonLaunchResponse(pendingStore, resolved) + createPrompt(pendingStore, tab, uri, lastUri, input, decision, hasUserGesture) + if (decision.kind == AppLinkPromptKind.BANNER) { + // Engine-supported: allow the page to load while the non-modal banner is up. + null + } else { + // Unsupported scheme (or marketplace): the navigation is stalled, no page to show. + RequestInterceptor.InterceptionResponse.Deny + } + } + } + } + + private fun createPrompt( + pendingStore: PendingAppLinkStore, + tab: SessionState, + uri: String, + lastUri: String?, + input: ClassifierInput, + decision: AppLinkDecision.Prompt, + hasUserGesture: Boolean, + ) { + val resolved = input.resolved + val owner = if (tab is CustomTabSessionState) { + AppLinkPromptOwner.NATIVE_EXTERNAL + } else { + AppLinkPromptOwner.FLUTTER_BROWSER + } + val urlClass = when { + decision.isMarketplace -> AppLinkUrlClass.MARKETPLACE + decision.kind == AppLinkPromptKind.MODAL -> AppLinkUrlClass.MODAL + else -> AppLinkUrlClass.BANNER + } + + val created = pendingStore.createRequest( + NewAppLinkRequest( + owner = owner, + tabId = tab.id, + contextId = tab.contextId, + sourceUrl = lastUri, + isPrivate = input.isPrivate, + isWallet = input.isWallet, + isProtectedContext = input.isProtected, + canRemember = decision.canRemember, + isModal = decision.kind == AppLinkPromptKind.MODAL, + urlClass = urlClass, + url = uri, + // The package to enforce at launch: only meaningful for a single, + // non-ambiguous handler. Null for an ambiguous/chooser target so the + // open path shows the chooser instead of refusing (§2.5/§2.7). + expectedPackage = if (resolved.isAmbiguous) null else resolved.packageName, + fallbackUrl = resolved.fallbackUrl, + engineSupportsScheme = resolved.engineSupportsScheme, + isMarketplace = decision.isMarketplace, + targetFingerprint = targetFingerprint(uri, resolved), + appName = resolved.appName, + packageName = resolved.packageName, + scopeKey = resolved.scopeKey, + isUserGesture = hasUserGesture, + ), + ) + + logger.info( + "createPrompt owner=$owner tab=${tab.id} class=$urlClass id=${created.requestId} " + + "canRemember=${decision.canRemember} url=$uri", + ) + + when (owner) { + // The Custom Tab prompt feature only queries the store at lifecycle start, so a request + // created afterwards (this navigation, on an engine thread) needs an explicit nudge or it + // would sit unshown until a restart. The notifier re-queries on the main thread. + AppLinkPromptOwner.NATIVE_EXTERNAL -> + NativeAppLinkPromptNotifier.notifyPromptAvailable(tab.id) + + // Best-effort availability nudge for the Flutter surface; the pending store + query is the + // contract (§2.8), so a lost event (Flutter detached) is harmless — it re-queries on resume. + AppLinkPromptOwner.FLUTTER_BROWSER -> + GlobalComponents.appLinkEvents?.onAppLinkPromptAvailable(EventSequence.next(), owner) { _ -> } + } + } + + private fun safeNonLaunchResponse( + pendingStore: PendingAppLinkStore, + resolved: ResolvedAppLink, + ): RequestInterceptor.InterceptionResponse? { + return if (resolved.engineSupportsScheme) { + null + } else { + resolved.fallbackUrl?.let { + pendingStore.recordFallbackReentry(canonicalReentryKey(it)) + RequestInterceptor.InterceptionResponse.Url(it) + } ?: RequestInterceptor.InterceptionResponse.Deny + } + } + + // ---- Eligibility (§2.4 step 2) ---- + + private fun isEligible( + uri: String, + lastUri: String?, + uriScheme: String?, + engineSupportsScheme: Boolean, + hasUserGesture: Boolean, + isRedirect: Boolean, + isDirectNavigation: Boolean, + isSubframeRequest: Boolean, + ): Boolean { + if (uriScheme == null) return false + // A subframe request not triggered by the user and outside the allowlist stays in-page. + if (!hasUserGesture && isSubframeRequest && !AppLinkSchemes.isSubframeAllowed(uriScheme)) return false + + val isAllowedRedirect = isRedirect && !isSubframeRequest + val isIntentionalNavigation = hasUserGesture || isAllowedRedirect || isDirectNavigation + // Unintentional engine-supported navigation continues in the browser. + if (engineSupportsScheme && !isIntentionalNavigation) return false + // Same-domain engine-supported navigation continues in the browser (AC subdomain stripping). + if (engineSupportsScheme && isSameDomain(lastUri, uri)) return false + // Always-denied schemes never resolve or launch externally. + if (AppLinkSchemes.isAlwaysDenied(uriScheme)) return false + return true + } + + private fun isSameDomain(url1: String?, url2: String?): Boolean { + return stripCommonSubDomains(url1?.tryGetHostFromUrl()) == + stripCommonSubDomains(url2?.tryGetHostFromUrl()) + } + + private fun stripCommonSubDomains(host: String?): String? { + return when { + host == null -> null + host.startsWith(WWW) -> host.replaceFirst(WWW, "") + host.startsWith(M) -> host.replaceFirst(M, "") + host.startsWith(MOBILE) -> host.replaceFirst(MOBILE, "") + host.startsWith(MAPS) -> host.replaceFirst(MAPS, "") + else -> host + } + } + + // ---- Protection model (§2.3) ---- + + private fun isProtected(policy: AppLinkPolicy, session: SessionState?, uri: String): Boolean { + val contextId = session?.contextId + val protectedByContext = if (contextId == null) { + policy.protectGeneralContext + } else { + contextId in policy.protectedContextIds || contextId in policy.strictContextIds + } + if (protectedByContext) return true + return matchesProtectedTarget(policy.protectedTargetPatterns, uri) + } + + private fun matchesProtectedTarget(patterns: List, uri: String): Boolean { + if (patterns.isEmpty()) return false + val parsed = runCatching { Uri.parse(uri) }.getOrNull() ?: return false + val scheme = parsed.scheme?.lowercase(Locale.ROOT) ?: return false + val host = AppLinkHostNormalizer.normalizeHost(parsed.host) ?: return false + val effectivePort = if (parsed.port != -1) parsed.port else defaultPortForScheme(scheme) + + return patterns.any { pattern -> + if (pattern.scheme.lowercase(Locale.ROOT) != scheme) return@any false + val patternHost = AppLinkHostNormalizer.normalizeHost(pattern.hostOrSuffix) ?: return@any false + if (pattern.includeSubdomains) { + // Wildcard entries match apex + subdomains and ignore port (§2.3). + host == patternHost || host.endsWith(".$patternHost") + } else { + // Exact entries compare scheme + origin including effective port. + host == patternHost && effectivePort == (pattern.port ?: defaultPortForScheme(scheme)) + } + } + } + + private fun defaultPortForScheme(scheme: String): Int = when (scheme) { + "http", "ws" -> 80 + "https", "wss" -> 443 + "ftp" -> 21 + else -> -1 + } + + // ---- Helpers ---- + + /** + * The dedupe/invalidation/suppression key: the full sanitised target, not just the rule scope, + * so different paths sharing one policy scope never collapse into one request (§2.6). + */ + private fun targetFingerprint(uri: String, resolved: ResolvedAppLink): String { + val intentPayload = resolved.appIntent?.let { + runCatching { it.toUri(Intent.URI_INTENT_SCHEME) }.getOrNull() + }.orEmpty() + return buildString { + append(uri) + append('\u0000') + append(resolved.packageName.orEmpty()) + append('\u0000') + append(intentPayload) + append('\u0000') + append(resolved.fallbackUrl.orEmpty()) + } + } + + /** Canonical key for the fallback re-entry map — the raw URL, matched on identity round-trip. */ + private fun canonicalReentryKey(url: String): String = url + + private fun pendingStoreFor(components: Components): PendingAppLinkStore { + return PendingAppLinkStores.forProfile(components.profileApplicationContext.relativePath) + } + + companion object { + private const val WWW = "www." + private const val M = "m." + private const val MOBILE = "mobile." + private const val MAPS = "maps." + } +} diff --git a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/components/Core.kt b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/components/Core.kt index 63509ece..ee174a6b 100644 --- a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/components/Core.kt +++ b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/components/Core.kt @@ -24,7 +24,8 @@ import eu.weblibre.flutter_mozilla_components.services.MediaSessionService import eu.weblibre.flutter_mozilla_components.activities.NotificationActivity import eu.weblibre.flutter_mozilla_components.R import eu.weblibre.flutter_mozilla_components.ext.getPreferenceKey -import eu.weblibre.flutter_mozilla_components.middleware.AppLinksCancelRetryMiddleware +import eu.weblibre.flutter_mozilla_components.applinks.PendingAppLinkStores +import eu.weblibre.flutter_mozilla_components.middleware.AppLinkNavigationMiddleware import eu.weblibre.flutter_mozilla_components.middleware.FlutterEventMiddleware import eu.weblibre.flutter_mozilla_components.middleware.HistoryMetadataMiddleware import eu.weblibre.flutter_mozilla_components.middleware.HistoryMetadataService @@ -238,7 +239,12 @@ class Core( // Must run before any engine middleware so we can rewrite // sandbox new-tab URLs before Gecko issues a request. SandboxCaptureMiddleware, - AppLinksCancelRetryMiddleware(), + // WebLibre-owned app-link pending-request invalidation + suppression clearing. + AppLinkNavigationMiddleware( + PendingAppLinkStores.forProfile( + components.profileApplicationContext.relativePath, + ), + ), HistoryMetadataMiddleware(historyMetadataService), // Correlates url -> contextId so WebLibreHistoryDelegate can // resolve a visit's container at record time. diff --git a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/components/Services.kt b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/components/Services.kt index 2780713f..998b0704 100644 --- a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/components/Services.kt +++ b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/components/Services.kt @@ -9,7 +9,6 @@ import android.content.Intent import androidx.browser.customtabs.CustomTabsIntent import androidx.core.net.toUri import androidx.preference.PreferenceManager -import eu.weblibre.flutter_mozilla_components.GlobalComponents import eu.weblibre.flutter_mozilla_components.R import eu.weblibre.flutter_mozilla_components.activities.AuthIntentReceiverActivity import eu.weblibre.flutter_mozilla_components.ext.getPreferenceKey @@ -18,7 +17,6 @@ import mozilla.components.concept.engine.Engine import mozilla.components.feature.accounts.FirefoxAccountsAuthFeature import mozilla.components.feature.accounts.FxaCapability import mozilla.components.feature.accounts.FxaWebChannelFeature -import mozilla.components.feature.app.links.AppLinksInterceptor import mozilla.components.feature.tabs.TabsUseCases import mozilla.components.service.fxa.ServerConfig import mozilla.components.service.fxa.manager.FxaAccountManager @@ -66,11 +64,4 @@ class Services( ) } - val appLinksInterceptor by lazy { - AppLinksInterceptor( - context = context, - launchInApp = { GlobalComponents.shouldOpenLinksInApp() }, - store = store, - ) - } } diff --git a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/components/UseCases.kt b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/components/UseCases.kt index bdf39f99..62be03e0 100644 --- a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/components/UseCases.kt +++ b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/components/UseCases.kt @@ -8,7 +8,6 @@ import android.content.Context import android.os.Environment import mozilla.components.browser.state.store.BrowserStore import mozilla.components.concept.engine.Engine -import mozilla.components.feature.app.links.AppLinksUseCases import mozilla.components.feature.contextmenu.ContextMenuUseCases import mozilla.components.feature.downloads.DownloadsUseCases import mozilla.components.feature.session.SessionUseCases @@ -70,8 +69,6 @@ class UseCases( */ val customTabsUseCases: CustomTabsUseCases by lazy { CustomTabsUseCases(store, sessionUseCases.loadUrl) } - val appLinksUseCases by lazy { AppLinksUseCases(context) } - val trackingProtectionUseCases by lazy { TrackingProtectionUseCases(store, engine) } val webAppUseCases by lazy { diff --git a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/interceptor/AppRequestInterceptor.kt b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/interceptor/AppRequestInterceptor.kt index 95304c57..268b3e5e 100644 --- a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/interceptor/AppRequestInterceptor.kt +++ b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/interceptor/AppRequestInterceptor.kt @@ -12,6 +12,7 @@ import android.content.Intent import android.net.Uri import android.util.Log import eu.weblibre.flutter_mozilla_components.GlobalComponents +import eu.weblibre.flutter_mozilla_components.applinks.WebLibreAppLinksInterceptor import eu.weblibre.flutter_mozilla_components.ext.EventSequence import eu.weblibre.flutter_mozilla_components.feature.InertExternalSchemes import eu.weblibre.flutter_mozilla_components.feature.SandboxCaptureBridge @@ -30,6 +31,9 @@ class AppRequestInterceptor(private val context: Context) : RequestInterceptor { requireNotNull(GlobalComponents.components) { "Components not initialized" } } + // The WebLibre-owned §2.4 app-links tail. + private val webLibreAppLinks by lazy { WebLibreAppLinksInterceptor(context) } + override fun onLoadRequest( engineSession: EngineSession, uri: String, @@ -130,12 +134,13 @@ class AppRequestInterceptor(private val context: Context) : RequestInterceptor { return it } - return components.services.appLinksInterceptor.onLoadRequest( + // App-links tail: the WebLibre-owned §2.4 implementation. Structural guards above + // (PWA/TWA, sandbox, weblibre://, FxA) already answered. + return webLibreAppLinks.onLoadRequest( engineSession, uri, lastUri, hasUserGesture, - isSameDomain, isRedirect, isDirectNavigation, isSubframeRequest, diff --git a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/middleware/AppLinkNavigationMiddleware.kt b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/middleware/AppLinkNavigationMiddleware.kt new file mode 100644 index 00000000..ac9e447b --- /dev/null +++ b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/middleware/AppLinkNavigationMiddleware.kt @@ -0,0 +1,72 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +package eu.weblibre.flutter_mozilla_components.middleware + +import eu.weblibre.flutter_mozilla_components.applinks.PendingAppLinkStore +import mozilla.components.browser.state.action.BrowserAction +import mozilla.components.browser.state.action.ContentAction +import mozilla.components.browser.state.action.CustomTabListAction +import mozilla.components.browser.state.action.EngineAction +import mozilla.components.browser.state.action.TabListAction +import mozilla.components.browser.state.state.BrowserState +import mozilla.components.lib.state.Middleware +import mozilla.components.lib.state.Store + +/** + * Observes the [BrowserStore] and drives [PendingAppLinkStore] invalidation and + * suppression clearing (APP_LINKS_OWN_IMPLEMENTATION_PLAN.md §2.6): + * + * - a committed top-level navigation whose URL is not a request's own target + * invalidates that request (a banner-class request's target committing keeps it + * alive — that commit is the page the banner sits on); + * - tab close / Custom Tab removal invalidates the tab's pending requests and + * suppression; + * - a new user-initiated/direct navigation (omnibar, bookmark, typed URL — which + * dispatch a `LoadUrlAction`) clears the tab's suppression. In-page redirects + * do not dispatch these actions, so the redirect-loop defence stays intact. + */ +class AppLinkNavigationMiddleware( + private val store: PendingAppLinkStore, +) : Middleware { + override fun invoke( + store: Store, + next: (BrowserAction) -> Unit, + action: BrowserAction, + ) { + when (action) { + is ContentAction.UpdateUrlAction -> { + // A committed top-level navigation. + this.store.onCommittedNavigation(action.sessionId, action.url) + } + + is EngineAction.LoadUrlAction -> { + // App-initiated (direct) navigation — clears suppression. + this.store.clearSuppressionForTab(action.tabId) + } + + is EngineAction.OptimizedLoadUrlTriggeredAction -> { + this.store.clearSuppressionForTab(action.tabId) + } + + is TabListAction.RemoveTabAction -> { + this.store.invalidateTab(action.tabId) + } + + is TabListAction.RemoveTabsAction -> { + action.tabIds.forEach(this.store::invalidateTab) + } + + is CustomTabListAction.RemoveCustomTabAction -> { + this.store.invalidateTab(action.tabId) + } + + else -> {} + } + + next(action) + } +} diff --git a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/middleware/AppLinksCancelRetryMiddleware.kt b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/middleware/AppLinksCancelRetryMiddleware.kt deleted file mode 100644 index d303d841..00000000 --- a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/middleware/AppLinksCancelRetryMiddleware.kt +++ /dev/null @@ -1,149 +0,0 @@ -/* - * This Source Code Form is subject to the terms of the Mozilla Public - * License, v. 2.0. If a copy of the MPL was not distributed with this - * file, You can obtain one at https://mozilla.org/MPL/2.0/. - */ - -package eu.weblibre.flutter_mozilla_components.middleware - -import android.os.Handler -import android.os.Looper -import mozilla.components.browser.state.action.BrowserAction -import mozilla.components.browser.state.action.ContentAction -import mozilla.components.browser.state.action.EngineAction -import mozilla.components.browser.state.selector.findTabOrCustomTab -import mozilla.components.browser.state.state.BrowserState -import mozilla.components.concept.engine.EngineSession -import mozilla.components.concept.engine.EngineSession.LoadUrlFlags.Companion.EXTERNAL -import mozilla.components.concept.engine.EngineSession.LoadUrlFlags.Companion.LOAD_FLAGS_BYPASS_LOAD_URI_DELEGATE -import mozilla.components.lib.state.Middleware -import mozilla.components.lib.state.Store - -/** - * Workaround for Android Components/GeckoView app-link cancel handling: the - * cancel load can be clobbered by Gecko's recovery load back to the previous - * history entry. This retries only that specific cancel-load signature. - */ -class AppLinksCancelRetryMiddleware( - private val handler: Handler = Handler(Looper.getMainLooper()), - private val retryDelayMillis: Long = RETRY_DELAY_MILLIS, -) : Middleware { - private val pendingCancels = mutableMapOf() - - override fun invoke( - store: Store, - next: (BrowserAction) -> Unit, - action: BrowserAction, - ) { - when (action) { - is EngineAction.OptimizedLoadUrlTriggeredAction -> { - recordCancelLoad(store, action) - } - is ContentAction.UpdateLoadRequestAction -> { - handleLoadRequest(store, action) - } - is ContentAction.UpdateUrlAction -> { - handleUrlUpdate(action) - } - else -> {} - } - - next(action) - } - - private fun recordCancelLoad( - store: Store, - action: EngineAction.OptimizedLoadUrlTriggeredAction, - ) { - if (!action.flags.contains(EXTERNAL) || - !action.flags.contains(LOAD_FLAGS_BYPASS_LOAD_URI_DELEGATE) - ) { - return - } - - val sourceUrl = store.state.findTabOrCustomTab(action.tabId)?.content?.url - ?: return - if (sourceUrl == action.url || sourceUrl == ABOUT_BLANK) { - return - } - - pendingCancels[action.tabId] = PendingCancel( - tabId = action.tabId, - sourceUrl = sourceUrl, - targetUrl = action.url, - ) - } - - private fun handleLoadRequest( - store: Store, - action: ContentAction.UpdateLoadRequestAction, - ) { - val pending = pendingCancels[action.sessionId] ?: return - when (action.loadRequest.url) { - pending.sourceUrl -> scheduleRetry(store, pending) - pending.targetUrl -> pendingCancels.remove(action.sessionId) - ABOUT_BLANK -> {} - else -> pendingCancels.remove(action.sessionId) - } - } - - private fun handleUrlUpdate(action: ContentAction.UpdateUrlAction) { - val pending = pendingCancels[action.sessionId] ?: return - when (action.url) { - pending.targetUrl -> pendingCancels.remove(action.sessionId) - pending.sourceUrl, ABOUT_BLANK -> {} - else -> pendingCancels.remove(action.sessionId) - } - } - - private fun scheduleRetry( - store: Store, - pending: PendingCancel, - ) { - if (pending.retryScheduled) { - return - } - - val scheduled = pending.copy(retryScheduled = true) - pendingCancels[pending.tabId] = scheduled - - handler.postDelayed({ - if (pendingCancels[pending.tabId] != scheduled) { - return@postDelayed - } - - val currentUrl = store.state.findTabOrCustomTab(pending.tabId)?.content?.url - if (currentUrl == pending.targetUrl) { - pendingCancels.remove(pending.tabId) - return@postDelayed - } - - if (currentUrl == pending.sourceUrl || currentUrl == ABOUT_BLANK) { - pendingCancels.remove(pending.tabId) - store.dispatch( - EngineAction.LoadUrlAction( - tabId = pending.tabId, - url = pending.targetUrl, - flags = EngineSession.LoadUrlFlags.select( - LOAD_FLAGS_BYPASS_LOAD_URI_DELEGATE, - ), - ), - ) - } else { - pendingCancels.remove(pending.tabId) - } - }, retryDelayMillis) - } - - private data class PendingCancel( - val tabId: String, - val sourceUrl: String, - val targetUrl: String, - val retryScheduled: Boolean = false, - ) - - private companion object { - const val ABOUT_BLANK = "about:blank" - const val RETRY_DELAY_MILLIS = 1000L - } -} diff --git a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/pigeons/Gecko.g.kt b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/pigeons/Gecko.g.kt index fc33c50b..fe91986e 100644 --- a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/pigeons/Gecko.g.kt +++ b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/pigeons/Gecko.g.kt @@ -795,6 +795,42 @@ enum class AutoplayStatus(val raw: Int) { } } +enum class NativeAppLinkRuleDecision(val raw: Int) { + ALWAYS_OPEN(0), + NEVER_OPEN(1); + + companion object { + fun ofRaw(raw: Int): NativeAppLinkRuleDecision? { + return values().firstOrNull { it.raw == raw } + } + } +} + +/** Which surface owns a pending prompt (§2.6). Fixed at creation, never transfers. */ +enum class AppLinkPromptOwner(val raw: Int) { + FLUTTER_BROWSER(0), + NATIVE_EXTERNAL(1); + + companion object { + fun ofRaw(raw: Int): AppLinkPromptOwner? { + return values().firstOrNull { it.raw == raw } + } + } +} + +/** User decision on a pending prompt (§2.6). */ +enum class AppLinkDecision(val raw: Int) { + OPEN(0), + CANCEL(1), + DISMISS(2); + + companion object { + fun ofRaw(raw: Int): AppLinkDecision? { + return values().firstOrNull { it.raw == raw } + } + } +} + /** Lifecycle state of the selected UnifiedPush distributor. */ enum class PushDistributorStatus(val raw: Int) { /** No distributor app is installed on the device. */ @@ -5421,6 +5457,456 @@ data class TrackingProtectionException ( } } +/** + * Resolved external-app target for a URL (see APP_LINKS_OWN_IMPLEMENTATION_PLAN.md §2.8). + * + * Generated class from Pigeon that represents data sent in messages. + */ +data class AppLinkTarget ( + /** The URL that was resolved. */ + val url: String, + /** User-facing app label (control/bidi-sanitised), or null when unknown. */ + val appName: String? = null, + /** Resolved package name, or null when ambiguous / unknown. */ + val packageName: String? = null, + /** Pre-validated http(s) fallback URL, or null. */ + val fallbackUrl: String? = null, + /** True when the only offer is a marketplace (install-app) intent. */ + val isMarketplace: Boolean, + /** True when resolution is ambiguous (chooser / multiple handlers / no default). */ + val isAmbiguous: Boolean, + /** True when the Gecko engine can load the URL scheme itself. */ + val engineSupportsScheme: Boolean, + /** Canonical native-owned rule scope key ("host:youtube.com" | "pkg:..."). */ + val scopeKey: String +) + { + companion object { + fun fromList(pigeonVar_list: List): AppLinkTarget { + val url = pigeonVar_list[0] as String + val appName = pigeonVar_list[1] as String? + val packageName = pigeonVar_list[2] as String? + val fallbackUrl = pigeonVar_list[3] as String? + val isMarketplace = pigeonVar_list[4] as Boolean + val isAmbiguous = pigeonVar_list[5] as Boolean + val engineSupportsScheme = pigeonVar_list[6] as Boolean + val scopeKey = pigeonVar_list[7] as String + return AppLinkTarget(url, appName, packageName, fallbackUrl, isMarketplace, isAmbiguous, engineSupportsScheme, scopeKey) + } + } + fun toList(): List { + return listOf( + url, + appName, + packageName, + fallbackUrl, + isMarketplace, + isAmbiguous, + engineSupportsScheme, + scopeKey, + ) + } + override fun equals(other: Any?): Boolean { + if (other == null || other.javaClass != javaClass) { + return false + } + if (this === other) { + return true + } + val other = other as AppLinkTarget + return GeckoPigeonUtils.deepEquals(this.url, other.url) && GeckoPigeonUtils.deepEquals(this.appName, other.appName) && GeckoPigeonUtils.deepEquals(this.packageName, other.packageName) && GeckoPigeonUtils.deepEquals(this.fallbackUrl, other.fallbackUrl) && GeckoPigeonUtils.deepEquals(this.isMarketplace, other.isMarketplace) && GeckoPigeonUtils.deepEquals(this.isAmbiguous, other.isAmbiguous) && GeckoPigeonUtils.deepEquals(this.engineSupportsScheme, other.engineSupportsScheme) && GeckoPigeonUtils.deepEquals(this.scopeKey, other.scopeKey) + } + + override fun hashCode(): Int { + var result = javaClass.hashCode() + result = 31 * result + GeckoPigeonUtils.deepHash(this.url) + result = 31 * result + GeckoPigeonUtils.deepHash(this.appName) + result = 31 * result + GeckoPigeonUtils.deepHash(this.packageName) + result = 31 * result + GeckoPigeonUtils.deepHash(this.fallbackUrl) + result = 31 * result + GeckoPigeonUtils.deepHash(this.isMarketplace) + result = 31 * result + GeckoPigeonUtils.deepHash(this.isAmbiguous) + result = 31 * result + GeckoPigeonUtils.deepHash(this.engineSupportsScheme) + result = 31 * result + GeckoPigeonUtils.deepHash(this.scopeKey) + return result + } + override fun toString(): String { + return "AppLinkTarget(url=$url, appName=$appName, packageName=$packageName, fallbackUrl=$fallbackUrl, isMarketplace=$isMarketplace, isAmbiguous=$isAmbiguous, engineSupportsScheme=$engineSupportsScheme, scopeKey=$scopeKey)" + } +} + +/** + * Target-side protection pattern replicated to native (§2.3/§2.8). Any target + * assigned to an effectively-proxied or strict container is protected + * independent of the source tab. + * + * Generated class from Pigeon that represents data sent in messages. + */ +data class ProtectedTargetPattern ( + val scheme: String, + val hostOrSuffix: String, + val includeSubdomains: Boolean, + /** Effective port for exact entries; null for wildcard entries (ignore port). */ + val port: Long? = null +) + { + companion object { + fun fromList(pigeonVar_list: List): ProtectedTargetPattern { + val scheme = pigeonVar_list[0] as String + val hostOrSuffix = pigeonVar_list[1] as String + val includeSubdomains = pigeonVar_list[2] as Boolean + val port = pigeonVar_list[3] as Long? + return ProtectedTargetPattern(scheme, hostOrSuffix, includeSubdomains, port) + } + } + fun toList(): List { + return listOf( + scheme, + hostOrSuffix, + includeSubdomains, + port, + ) + } + override fun equals(other: Any?): Boolean { + if (other == null || other.javaClass != javaClass) { + return false + } + if (this === other) { + return true + } + val other = other as ProtectedTargetPattern + return GeckoPigeonUtils.deepEquals(this.scheme, other.scheme) && GeckoPigeonUtils.deepEquals(this.hostOrSuffix, other.hostOrSuffix) && GeckoPigeonUtils.deepEquals(this.includeSubdomains, other.includeSubdomains) && GeckoPigeonUtils.deepEquals(this.port, other.port) + } + + override fun hashCode(): Int { + var result = javaClass.hashCode() + result = 31 * result + GeckoPigeonUtils.deepHash(this.scheme) + result = 31 * result + GeckoPigeonUtils.deepHash(this.hostOrSuffix) + result = 31 * result + GeckoPigeonUtils.deepHash(this.includeSubdomains) + result = 31 * result + GeckoPigeonUtils.deepHash(this.port) + return result + } + override fun toString(): String { + return "ProtectedTargetPattern(scheme=$scheme, hostOrSuffix=$hostOrSuffix, includeSubdomains=$includeSubdomains, port=$port)" + } +} + +/** + * A remembered per-scope rule replicated to native (§2.8). Distinct from the + * Dart-persisted `PersistedAppLinkRule`; explicit mappers bridge the two. + * + * Generated class from Pigeon that represents data sent in messages. + */ +data class NativeAppLinkRule ( + val decision: NativeAppLinkRuleDecision, + val scope: String, + val packageName: String? = null +) + { + companion object { + fun fromList(pigeonVar_list: List): NativeAppLinkRule { + val decision = pigeonVar_list[0] as NativeAppLinkRuleDecision + val scope = pigeonVar_list[1] as String + val packageName = pigeonVar_list[2] as String? + return NativeAppLinkRule(decision, scope, packageName) + } + } + fun toList(): List { + return listOf( + decision, + scope, + packageName, + ) + } + override fun equals(other: Any?): Boolean { + if (other == null || other.javaClass != javaClass) { + return false + } + if (this === other) { + return true + } + val other = other as NativeAppLinkRule + return GeckoPigeonUtils.deepEquals(this.decision, other.decision) && GeckoPigeonUtils.deepEquals(this.scope, other.scope) && GeckoPigeonUtils.deepEquals(this.packageName, other.packageName) + } + + override fun hashCode(): Int { + var result = javaClass.hashCode() + result = 31 * result + GeckoPigeonUtils.deepHash(this.decision) + result = 31 * result + GeckoPigeonUtils.deepHash(this.scope) + result = 31 * result + GeckoPigeonUtils.deepHash(this.packageName) + return result + } + override fun toString(): String { + return "NativeAppLinkRule(decision=$decision, scope=$scope, packageName=$packageName)" + } +} + +/** + * A container's self-contained app-link policy override (§ container isolation). + * Present only for containers with "isolated app link settings" enabled; when a + * navigation's source contextId has an entry here, it fully *replaces* the + * global mode + rules for that navigation (no layering with the global policy). + * + * Generated class from Pigeon that represents data sent in messages. + */ +data class NativeContextAppLinkPolicy ( + val mode: AppLinksMode, + /** The container's own remembered rules keyed by canonical scope. */ + val rules: Map +) + { + companion object { + fun fromList(pigeonVar_list: List): NativeContextAppLinkPolicy { + val mode = pigeonVar_list[0] as AppLinksMode + val rules = pigeonVar_list[1] as Map + return NativeContextAppLinkPolicy(mode, rules) + } + } + fun toList(): List { + return listOf( + mode, + rules, + ) + } + override fun equals(other: Any?): Boolean { + if (other == null || other.javaClass != javaClass) { + return false + } + if (this === other) { + return true + } + val other = other as NativeContextAppLinkPolicy + return GeckoPigeonUtils.deepEquals(this.mode, other.mode) && GeckoPigeonUtils.deepEquals(this.rules, other.rules) + } + + override fun hashCode(): Int { + var result = javaClass.hashCode() + result = 31 * result + GeckoPigeonUtils.deepHash(this.mode) + result = 31 * result + GeckoPigeonUtils.deepHash(this.rules) + return result + } + override fun toString(): String { + return "NativeContextAppLinkPolicy(mode=$mode, rules=$rules)" + } +} + +/** + * Complete, last-write-wins policy snapshot pushed from the single Dart writer + * to native (§2.8). Native persists it to the profile-scoped prefs record + * before swapping the in-memory reference. + * + * Generated class from Pigeon that represents data sent in messages. + */ +data class AppLinkPolicySnapshot ( + val globalMode: AppLinksMode, + /** Remembered rules keyed by canonical scope. */ + val rules: Map, + val marketplaceFallbackEnabled: Boolean, + /** Regular / no-contextId tabs are proxied via the `general` scope. */ + val protectGeneralContext: Boolean, + /** contextIds that resolve to a proxy after inherit/bypass/alias. */ + val protectedContextIds: List, + /** strictMode containers, independent of routing. */ + val strictContextIds: List, + val protectedTargetPatterns: List, + /** + * Per-container app-link policy overrides keyed by contextId. Only isolated + * containers appear here; a navigation whose source contextId is a key uses + * the entry's mode + rules in place of the global ones (replace semantics). + */ + val contextOverrides: Map +) + { + companion object { + fun fromList(pigeonVar_list: List): AppLinkPolicySnapshot { + val globalMode = pigeonVar_list[0] as AppLinksMode + val rules = pigeonVar_list[1] as Map + val marketplaceFallbackEnabled = pigeonVar_list[2] as Boolean + val protectGeneralContext = pigeonVar_list[3] as Boolean + val protectedContextIds = pigeonVar_list[4] as List + val strictContextIds = pigeonVar_list[5] as List + val protectedTargetPatterns = pigeonVar_list[6] as List + val contextOverrides = pigeonVar_list[7] as Map + return AppLinkPolicySnapshot(globalMode, rules, marketplaceFallbackEnabled, protectGeneralContext, protectedContextIds, strictContextIds, protectedTargetPatterns, contextOverrides) + } + } + fun toList(): List { + return listOf( + globalMode, + rules, + marketplaceFallbackEnabled, + protectGeneralContext, + protectedContextIds, + strictContextIds, + protectedTargetPatterns, + contextOverrides, + ) + } + override fun equals(other: Any?): Boolean { + if (other == null || other.javaClass != javaClass) { + return false + } + if (this === other) { + return true + } + val other = other as AppLinkPolicySnapshot + return GeckoPigeonUtils.deepEquals(this.globalMode, other.globalMode) && GeckoPigeonUtils.deepEquals(this.rules, other.rules) && GeckoPigeonUtils.deepEquals(this.marketplaceFallbackEnabled, other.marketplaceFallbackEnabled) && GeckoPigeonUtils.deepEquals(this.protectGeneralContext, other.protectGeneralContext) && GeckoPigeonUtils.deepEquals(this.protectedContextIds, other.protectedContextIds) && GeckoPigeonUtils.deepEquals(this.strictContextIds, other.strictContextIds) && GeckoPigeonUtils.deepEquals(this.protectedTargetPatterns, other.protectedTargetPatterns) && GeckoPigeonUtils.deepEquals(this.contextOverrides, other.contextOverrides) + } + + override fun hashCode(): Int { + var result = javaClass.hashCode() + result = 31 * result + GeckoPigeonUtils.deepHash(this.globalMode) + result = 31 * result + GeckoPigeonUtils.deepHash(this.rules) + result = 31 * result + GeckoPigeonUtils.deepHash(this.marketplaceFallbackEnabled) + result = 31 * result + GeckoPigeonUtils.deepHash(this.protectGeneralContext) + result = 31 * result + GeckoPigeonUtils.deepHash(this.protectedContextIds) + result = 31 * result + GeckoPigeonUtils.deepHash(this.strictContextIds) + result = 31 * result + GeckoPigeonUtils.deepHash(this.protectedTargetPatterns) + result = 31 * result + GeckoPigeonUtils.deepHash(this.contextOverrides) + return result + } + override fun toString(): String { + return "AppLinkPolicySnapshot(globalMode=$globalMode, rules=$rules, marketplaceFallbackEnabled=$marketplaceFallbackEnabled, protectGeneralContext=$protectGeneralContext, protectedContextIds=$protectedContextIds, strictContextIds=$strictContextIds, protectedTargetPatterns=$protectedTargetPatterns, contextOverrides=$contextOverrides)" + } +} + +/** + * A pending app-link prompt request held in the native `PendingAppLinkStore` + * until resolved, invalidated, or expired (§2.6/§2.8). Holds only stable + * identifiers and sanitised data — never engine/store references. + * + * Generated class from Pigeon that represents data sent in messages. + */ +data class AppLinkPromptRequest ( + /** Monotonic per-process id (Kotlin Long). */ + val requestId: Long, + val owner: AppLinkPromptOwner, + val tabId: String, + val contextId: String? = null, + val sourceUrl: String? = null, + val isPrivate: Boolean, + val isWallet: Boolean, + val isProtectedContext: Boolean, + val canRemember: Boolean, + /** + * false for the http(s) banner class (non-modal); true for the modal + * unsupported-scheme prompt. + */ + val isModal: Boolean, + val target: AppLinkTarget +) + { + companion object { + fun fromList(pigeonVar_list: List): AppLinkPromptRequest { + val requestId = pigeonVar_list[0] as Long + val owner = pigeonVar_list[1] as AppLinkPromptOwner + val tabId = pigeonVar_list[2] as String + val contextId = pigeonVar_list[3] as String? + val sourceUrl = pigeonVar_list[4] as String? + val isPrivate = pigeonVar_list[5] as Boolean + val isWallet = pigeonVar_list[6] as Boolean + val isProtectedContext = pigeonVar_list[7] as Boolean + val canRemember = pigeonVar_list[8] as Boolean + val isModal = pigeonVar_list[9] as Boolean + val target = pigeonVar_list[10] as AppLinkTarget + return AppLinkPromptRequest(requestId, owner, tabId, contextId, sourceUrl, isPrivate, isWallet, isProtectedContext, canRemember, isModal, target) + } + } + fun toList(): List { + return listOf( + requestId, + owner, + tabId, + contextId, + sourceUrl, + isPrivate, + isWallet, + isProtectedContext, + canRemember, + isModal, + target, + ) + } + override fun equals(other: Any?): Boolean { + if (other == null || other.javaClass != javaClass) { + return false + } + if (this === other) { + return true + } + val other = other as AppLinkPromptRequest + return GeckoPigeonUtils.deepEquals(this.requestId, other.requestId) && GeckoPigeonUtils.deepEquals(this.owner, other.owner) && GeckoPigeonUtils.deepEquals(this.tabId, other.tabId) && GeckoPigeonUtils.deepEquals(this.contextId, other.contextId) && GeckoPigeonUtils.deepEquals(this.sourceUrl, other.sourceUrl) && GeckoPigeonUtils.deepEquals(this.isPrivate, other.isPrivate) && GeckoPigeonUtils.deepEquals(this.isWallet, other.isWallet) && GeckoPigeonUtils.deepEquals(this.isProtectedContext, other.isProtectedContext) && GeckoPigeonUtils.deepEquals(this.canRemember, other.canRemember) && GeckoPigeonUtils.deepEquals(this.isModal, other.isModal) && GeckoPigeonUtils.deepEquals(this.target, other.target) + } + + override fun hashCode(): Int { + var result = javaClass.hashCode() + result = 31 * result + GeckoPigeonUtils.deepHash(this.requestId) + result = 31 * result + GeckoPigeonUtils.deepHash(this.owner) + result = 31 * result + GeckoPigeonUtils.deepHash(this.tabId) + result = 31 * result + GeckoPigeonUtils.deepHash(this.contextId) + result = 31 * result + GeckoPigeonUtils.deepHash(this.sourceUrl) + result = 31 * result + GeckoPigeonUtils.deepHash(this.isPrivate) + result = 31 * result + GeckoPigeonUtils.deepHash(this.isWallet) + result = 31 * result + GeckoPigeonUtils.deepHash(this.isProtectedContext) + result = 31 * result + GeckoPigeonUtils.deepHash(this.canRemember) + result = 31 * result + GeckoPigeonUtils.deepHash(this.isModal) + result = 31 * result + GeckoPigeonUtils.deepHash(this.target) + return result + } + override fun toString(): String { + return "AppLinkPromptRequest(requestId=$requestId, owner=$owner, tabId=$tabId, contextId=$contextId, sourceUrl=$sourceUrl, isPrivate=$isPrivate, isWallet=$isWallet, isProtectedContext=$isProtectedContext, canRemember=$canRemember, isModal=$isModal, target=$target)" + } +} + +/** + * Result of resolving a pending prompt (§2.8). + * + * Generated class from Pigeon that represents data sent in messages. + */ +data class AppLinkResolutionResult ( + val launched: Boolean, + val loadedFallback: Boolean, + /** "stale" | "dead_session" | "launch_failed" | null. */ + val failureReason: String? = null +) + { + companion object { + fun fromList(pigeonVar_list: List): AppLinkResolutionResult { + val launched = pigeonVar_list[0] as Boolean + val loadedFallback = pigeonVar_list[1] as Boolean + val failureReason = pigeonVar_list[2] as String? + return AppLinkResolutionResult(launched, loadedFallback, failureReason) + } + } + fun toList(): List { + return listOf( + launched, + loadedFallback, + failureReason, + ) + } + override fun equals(other: Any?): Boolean { + if (other == null || other.javaClass != javaClass) { + return false + } + if (this === other) { + return true + } + val other = other as AppLinkResolutionResult + return GeckoPigeonUtils.deepEquals(this.launched, other.launched) && GeckoPigeonUtils.deepEquals(this.loadedFallback, other.loadedFallback) && GeckoPigeonUtils.deepEquals(this.failureReason, other.failureReason) + } + + override fun hashCode(): Int { + var result = javaClass.hashCode() + result = 31 * result + GeckoPigeonUtils.deepHash(this.launched) + result = 31 * result + GeckoPigeonUtils.deepHash(this.loadedFallback) + result = 31 * result + GeckoPigeonUtils.deepHash(this.failureReason) + return result + } + override fun toString(): String { + return "AppLinkResolutionResult(launched=$launched, loadedFallback=$loadedFallback, failureReason=$failureReason)" + } +} + /** * Represents an icon from a PWA manifest. * @@ -6127,8 +6613,28 @@ private data class GeckoPigeonInternalCodecOverflow ( when (type.toInt()) { 0 -> - return PushStatus.fromList(wrapped as List) + return AppLinkResolutionResult.fromList(wrapped as List) 1 -> + return PwaIcon.fromList(wrapped as List) + 2 -> + return ShareTargetFiles.fromList(wrapped as List) + 3 -> + return ShareTargetParams.fromList(wrapped as List) + 4 -> + return ShareTarget.fromList(wrapped as List) + 5 -> + return ExternalApplicationResource.fromList(wrapped as List) + 6 -> + return PwaManifest.fromList(wrapped as List) + 7 -> + return SandboxCaptureEntry.fromList(wrapped as List) + 8 -> + return GestureConfig.fromList(wrapped as List) + 9 -> + return PushDistributor.fromList(wrapped as List) + 10 -> + return PushStatus.fromList(wrapped as List) + 11 -> return PushSubscription.fromList(wrapped as List) } return null @@ -6334,437 +6840,437 @@ private open class GeckoPigeonCodec : StandardMessageCodec() { } 168.toByte() -> { return (readValue(buffer) as Long?)?.let { - PushDistributorStatus.ofRaw(it.toInt()) + NativeAppLinkRuleDecision.ofRaw(it.toInt()) } } 169.toByte() -> { - return (readValue(buffer) as? List)?.let { - TranslationOptions.fromList(it) + return (readValue(buffer) as Long?)?.let { + AppLinkPromptOwner.ofRaw(it.toInt()) } } 170.toByte() -> { - return (readValue(buffer) as? List)?.let { - TranslationLanguage.fromList(it) + return (readValue(buffer) as Long?)?.let { + AppLinkDecision.ofRaw(it.toInt()) } } 171.toByte() -> { - return (readValue(buffer) as? List)?.let { - TranslationDetectedLanguages.fromList(it) + return (readValue(buffer) as Long?)?.let { + PushDistributorStatus.ofRaw(it.toInt()) } } 172.toByte() -> { return (readValue(buffer) as? List)?.let { - TranslationPair.fromList(it) + TranslationOptions.fromList(it) } } 173.toByte() -> { return (readValue(buffer) as? List)?.let { - TranslationEngineStateData.fromList(it) + TranslationLanguage.fromList(it) } } 174.toByte() -> { return (readValue(buffer) as? List)?.let { - TabTranslationStateData.fromList(it) + TranslationDetectedLanguages.fromList(it) } } 175.toByte() -> { return (readValue(buffer) as? List)?.let { - ReaderState.fromList(it) + TranslationPair.fromList(it) } } 176.toByte() -> { return (readValue(buffer) as? List)?.let { - AddTabParams.fromList(it) + TranslationEngineStateData.fromList(it) } } 177.toByte() -> { return (readValue(buffer) as? List)?.let { - LastMediaAccessState.fromList(it) + TabTranslationStateData.fromList(it) } } 178.toByte() -> { return (readValue(buffer) as? List)?.let { - HistoryMetadataKey.fromList(it) + ReaderState.fromList(it) } } 179.toByte() -> { return (readValue(buffer) as? List)?.let { - PackageCategoryValue.fromList(it) + AddTabParams.fromList(it) } } 180.toByte() -> { return (readValue(buffer) as? List)?.let { - ExternalPackage.fromList(it) + LastMediaAccessState.fromList(it) } } 181.toByte() -> { return (readValue(buffer) as? List)?.let { - LoadUrlFlagsValue.fromList(it) + HistoryMetadataKey.fromList(it) } } 182.toByte() -> { return (readValue(buffer) as? List)?.let { - SourceValue.fromList(it) + PackageCategoryValue.fromList(it) } } 183.toByte() -> { return (readValue(buffer) as? List)?.let { - TabState.fromList(it) + ExternalPackage.fromList(it) } } 184.toByte() -> { return (readValue(buffer) as? List)?.let { - RecoverableTab.fromList(it) + LoadUrlFlagsValue.fromList(it) } } 185.toByte() -> { return (readValue(buffer) as? List)?.let { - IconRequest.fromList(it) + SourceValue.fromList(it) } } 186.toByte() -> { return (readValue(buffer) as? List)?.let { - ResourceSize.fromList(it) + TabState.fromList(it) } } 187.toByte() -> { return (readValue(buffer) as? List)?.let { - Resource.fromList(it) + RecoverableTab.fromList(it) } } 188.toByte() -> { return (readValue(buffer) as? List)?.let { - IconResult.fromList(it) + IconRequest.fromList(it) } } 189.toByte() -> { return (readValue(buffer) as? List)?.let { - CookiePartitionKey.fromList(it) + ResourceSize.fromList(it) } } 190.toByte() -> { return (readValue(buffer) as? List)?.let { - Cookie.fromList(it) + Resource.fromList(it) } } 191.toByte() -> { return (readValue(buffer) as? List)?.let { - VisitInfo.fromList(it) + IconResult.fromList(it) } } 192.toByte() -> { return (readValue(buffer) as? List)?.let { - HistoryHighlightWeights.fromList(it) + CookiePartitionKey.fromList(it) } } 193.toByte() -> { return (readValue(buffer) as? List)?.let { - HistoryHighlight.fromList(it) + Cookie.fromList(it) } } 194.toByte() -> { return (readValue(buffer) as? List)?.let { - TopFrecentSiteInfo.fromList(it) + VisitInfo.fromList(it) } } 195.toByte() -> { return (readValue(buffer) as? List)?.let { - HistoryMetadata.fromList(it) + HistoryHighlightWeights.fromList(it) } } 196.toByte() -> { return (readValue(buffer) as? List)?.let { - HistorySuggestion.fromList(it) + HistoryHighlight.fromList(it) } } 197.toByte() -> { return (readValue(buffer) as? List)?.let { - PageObservation.fromList(it) + TopFrecentSiteInfo.fromList(it) } } 198.toByte() -> { return (readValue(buffer) as? List)?.let { - HistoryItem.fromList(it) + HistoryMetadata.fromList(it) } } 199.toByte() -> { return (readValue(buffer) as? List)?.let { - HistoryState.fromList(it) + HistorySuggestion.fromList(it) } } 200.toByte() -> { return (readValue(buffer) as? List)?.let { - ReaderableState.fromList(it) + PageObservation.fromList(it) } } 201.toByte() -> { return (readValue(buffer) as? List)?.let { - SecurityInfoState.fromList(it) + HistoryItem.fromList(it) } } 202.toByte() -> { return (readValue(buffer) as? List)?.let { - TabContentState.fromList(it) + HistoryState.fromList(it) } } 203.toByte() -> { return (readValue(buffer) as? List)?.let { - FindResultState.fromList(it) + ReaderableState.fromList(it) } } 204.toByte() -> { return (readValue(buffer) as? List)?.let { - CustomSelectionAction.fromList(it) + SecurityInfoState.fromList(it) } } 205.toByte() -> { return (readValue(buffer) as? List)?.let { - WebExtensionData.fromList(it) + TabContentState.fromList(it) } } 206.toByte() -> { return (readValue(buffer) as? List)?.let { - AddonInfo.fromList(it) + FindResultState.fromList(it) } } 207.toByte() -> { return (readValue(buffer) as? List)?.let { - AddonListingPreview.fromList(it) + CustomSelectionAction.fromList(it) } } 208.toByte() -> { return (readValue(buffer) as? List)?.let { - AddonListing.fromList(it) + WebExtensionData.fromList(it) } } 209.toByte() -> { return (readValue(buffer) as? List)?.let { - AddonStoreInfo.fromList(it) + AddonInfo.fromList(it) } } 210.toByte() -> { return (readValue(buffer) as? List)?.let { - AddonUpdateAttemptInfo.fromList(it) + AddonListingPreview.fromList(it) } } 211.toByte() -> { return (readValue(buffer) as? List)?.let { - GeckoSuggestion.fromList(it) + AddonListing.fromList(it) } } 212.toByte() -> { return (readValue(buffer) as? List)?.let { - TabContent.fromList(it) + AddonStoreInfo.fromList(it) } } 213.toByte() -> { return (readValue(buffer) as? List)?.let { - ContentBlocking.fromList(it) + AddonUpdateAttemptInfo.fromList(it) } } 214.toByte() -> { return (readValue(buffer) as? List)?.let { - DohSettings.fromList(it) + GeckoSuggestion.fromList(it) } } 215.toByte() -> { return (readValue(buffer) as? List)?.let { - GeckoEngineSettings.fromList(it) + TabContent.fromList(it) } } 216.toByte() -> { return (readValue(buffer) as? List)?.let { - AutocompleteResult.fromList(it) + ContentBlocking.fromList(it) } } 217.toByte() -> { return (readValue(buffer) as? List)?.let { - UnknownHitResult.fromList(it) + DohSettings.fromList(it) } } 218.toByte() -> { return (readValue(buffer) as? List)?.let { - ImageHitResult.fromList(it) + GeckoEngineSettings.fromList(it) } } 219.toByte() -> { return (readValue(buffer) as? List)?.let { - VideoHitResult.fromList(it) + AutocompleteResult.fromList(it) } } 220.toByte() -> { return (readValue(buffer) as? List)?.let { - AudioHitResult.fromList(it) + UnknownHitResult.fromList(it) } } 221.toByte() -> { return (readValue(buffer) as? List)?.let { - ImageSrcHitResult.fromList(it) + ImageHitResult.fromList(it) } } 222.toByte() -> { return (readValue(buffer) as? List)?.let { - PhoneHitResult.fromList(it) + VideoHitResult.fromList(it) } } 223.toByte() -> { return (readValue(buffer) as? List)?.let { - EmailHitResult.fromList(it) + AudioHitResult.fromList(it) } } 224.toByte() -> { return (readValue(buffer) as? List)?.let { - GeoHitResult.fromList(it) + ImageSrcHitResult.fromList(it) } } 225.toByte() -> { return (readValue(buffer) as? List)?.let { - DownloadState.fromList(it) + PhoneHitResult.fromList(it) } } 226.toByte() -> { return (readValue(buffer) as? List)?.let { - ShareInternetResourceState.fromList(it) + EmailHitResult.fromList(it) } } 227.toByte() -> { return (readValue(buffer) as? List)?.let { - AddonCollection.fromList(it) + GeoHitResult.fromList(it) } } 228.toByte() -> { return (readValue(buffer) as? List)?.let { - SyncEngineStatus.fromList(it) + DownloadState.fromList(it) } } 229.toByte() -> { return (readValue(buffer) as? List)?.let { - SyncAccountInfo.fromList(it) + ShareInternetResourceState.fromList(it) } } 230.toByte() -> { return (readValue(buffer) as? List)?.let { - SyncDevice.fromList(it) + AddonCollection.fromList(it) } } 231.toByte() -> { return (readValue(buffer) as? List)?.let { - SyncIncomingTab.fromList(it) + SyncEngineStatus.fromList(it) } } 232.toByte() -> { return (readValue(buffer) as? List)?.let { - SyncRemoteTab.fromList(it) + SyncAccountInfo.fromList(it) } } 233.toByte() -> { return (readValue(buffer) as? List)?.let { - SyncDeviceTabs.fromList(it) + SyncDevice.fromList(it) } } 234.toByte() -> { return (readValue(buffer) as? List)?.let { - GeckoPref.fromList(it) + SyncIncomingTab.fromList(it) } } 235.toByte() -> { return (readValue(buffer) as? List)?.let { - MlProgressData.fromList(it) + SyncRemoteTab.fromList(it) } } 236.toByte() -> { return (readValue(buffer) as? List)?.let { - GeckoProxySettings.fromList(it) + SyncDeviceTabs.fromList(it) } } 237.toByte() -> { return (readValue(buffer) as? List)?.let { - ContainerSiteAssignment.fromList(it) + GeckoPref.fromList(it) } } 238.toByte() -> { return (readValue(buffer) as? List)?.let { - ProxyLoadError.fromList(it) + MlProgressData.fromList(it) } } 239.toByte() -> { return (readValue(buffer) as? List)?.let { - GeckoHeader.fromList(it) + GeckoProxySettings.fromList(it) } } 240.toByte() -> { return (readValue(buffer) as? List)?.let { - GeckoFetchRequest.fromList(it) + ContainerSiteAssignment.fromList(it) } } 241.toByte() -> { return (readValue(buffer) as? List)?.let { - GeckoFetchResponse.fromList(it) + ProxyLoadError.fromList(it) } } 242.toByte() -> { return (readValue(buffer) as? List)?.let { - BookmarkNode.fromList(it) + GeckoHeader.fromList(it) } } 243.toByte() -> { return (readValue(buffer) as? List)?.let { - BookmarkInfo.fromList(it) + GeckoFetchRequest.fromList(it) } } 244.toByte() -> { return (readValue(buffer) as? List)?.let { - SitePermissions.fromList(it) + GeckoFetchResponse.fromList(it) } } 245.toByte() -> { return (readValue(buffer) as? List)?.let { - TrackingProtectionException.fromList(it) + BookmarkNode.fromList(it) } } 246.toByte() -> { return (readValue(buffer) as? List)?.let { - PwaIcon.fromList(it) + BookmarkInfo.fromList(it) } } 247.toByte() -> { return (readValue(buffer) as? List)?.let { - ShareTargetFiles.fromList(it) + SitePermissions.fromList(it) } } 248.toByte() -> { return (readValue(buffer) as? List)?.let { - ShareTargetParams.fromList(it) + TrackingProtectionException.fromList(it) } } 249.toByte() -> { return (readValue(buffer) as? List)?.let { - ShareTarget.fromList(it) + AppLinkTarget.fromList(it) } } 250.toByte() -> { return (readValue(buffer) as? List)?.let { - ExternalApplicationResource.fromList(it) + ProtectedTargetPattern.fromList(it) } } 251.toByte() -> { return (readValue(buffer) as? List)?.let { - PwaManifest.fromList(it) + NativeAppLinkRule.fromList(it) } } 252.toByte() -> { return (readValue(buffer) as? List)?.let { - SandboxCaptureEntry.fromList(it) + NativeContextAppLinkPolicy.fromList(it) } } 253.toByte() -> { return (readValue(buffer) as? List)?.let { - GestureConfig.fromList(it) + AppLinkPolicySnapshot.fromList(it) } } 254.toByte() -> { return (readValue(buffer) as? List)?.let { - PushDistributor.fromList(it) + AppLinkPromptRequest.fromList(it) } } 255.toByte() -> { @@ -6933,364 +7439,414 @@ private open class GeckoPigeonCodec : StandardMessageCodec() { stream.write(167) writeValue(stream, value.raw.toLong()) } - is PushDistributorStatus -> { + is NativeAppLinkRuleDecision -> { stream.write(168) writeValue(stream, value.raw.toLong()) } - is TranslationOptions -> { + is AppLinkPromptOwner -> { stream.write(169) - writeValue(stream, value.toList()) + writeValue(stream, value.raw.toLong()) } - is TranslationLanguage -> { + is AppLinkDecision -> { stream.write(170) - writeValue(stream, value.toList()) + writeValue(stream, value.raw.toLong()) } - is TranslationDetectedLanguages -> { + is PushDistributorStatus -> { stream.write(171) - writeValue(stream, value.toList()) + writeValue(stream, value.raw.toLong()) } - is TranslationPair -> { + is TranslationOptions -> { stream.write(172) writeValue(stream, value.toList()) } - is TranslationEngineStateData -> { + is TranslationLanguage -> { stream.write(173) writeValue(stream, value.toList()) } - is TabTranslationStateData -> { + is TranslationDetectedLanguages -> { stream.write(174) writeValue(stream, value.toList()) } - is ReaderState -> { + is TranslationPair -> { stream.write(175) writeValue(stream, value.toList()) } - is AddTabParams -> { + is TranslationEngineStateData -> { stream.write(176) writeValue(stream, value.toList()) } - is LastMediaAccessState -> { + is TabTranslationStateData -> { stream.write(177) writeValue(stream, value.toList()) } - is HistoryMetadataKey -> { + is ReaderState -> { stream.write(178) writeValue(stream, value.toList()) } - is PackageCategoryValue -> { + is AddTabParams -> { stream.write(179) writeValue(stream, value.toList()) } - is ExternalPackage -> { + is LastMediaAccessState -> { stream.write(180) writeValue(stream, value.toList()) } - is LoadUrlFlagsValue -> { + is HistoryMetadataKey -> { stream.write(181) writeValue(stream, value.toList()) } - is SourceValue -> { + is PackageCategoryValue -> { stream.write(182) writeValue(stream, value.toList()) } - is TabState -> { + is ExternalPackage -> { stream.write(183) writeValue(stream, value.toList()) } - is RecoverableTab -> { + is LoadUrlFlagsValue -> { stream.write(184) writeValue(stream, value.toList()) } - is IconRequest -> { + is SourceValue -> { stream.write(185) writeValue(stream, value.toList()) } - is ResourceSize -> { + is TabState -> { stream.write(186) writeValue(stream, value.toList()) } - is Resource -> { + is RecoverableTab -> { stream.write(187) writeValue(stream, value.toList()) } - is IconResult -> { + is IconRequest -> { stream.write(188) writeValue(stream, value.toList()) } - is CookiePartitionKey -> { + is ResourceSize -> { stream.write(189) writeValue(stream, value.toList()) } - is Cookie -> { + is Resource -> { stream.write(190) writeValue(stream, value.toList()) } - is VisitInfo -> { + is IconResult -> { stream.write(191) writeValue(stream, value.toList()) } - is HistoryHighlightWeights -> { + is CookiePartitionKey -> { stream.write(192) writeValue(stream, value.toList()) } - is HistoryHighlight -> { + is Cookie -> { stream.write(193) writeValue(stream, value.toList()) } - is TopFrecentSiteInfo -> { + is VisitInfo -> { stream.write(194) writeValue(stream, value.toList()) } - is HistoryMetadata -> { + is HistoryHighlightWeights -> { stream.write(195) writeValue(stream, value.toList()) } - is HistorySuggestion -> { + is HistoryHighlight -> { stream.write(196) writeValue(stream, value.toList()) } - is PageObservation -> { + is TopFrecentSiteInfo -> { stream.write(197) writeValue(stream, value.toList()) } - is HistoryItem -> { + is HistoryMetadata -> { stream.write(198) writeValue(stream, value.toList()) } - is HistoryState -> { + is HistorySuggestion -> { stream.write(199) writeValue(stream, value.toList()) } - is ReaderableState -> { + is PageObservation -> { stream.write(200) writeValue(stream, value.toList()) } - is SecurityInfoState -> { + is HistoryItem -> { stream.write(201) writeValue(stream, value.toList()) } - is TabContentState -> { + is HistoryState -> { stream.write(202) writeValue(stream, value.toList()) } - is FindResultState -> { + is ReaderableState -> { stream.write(203) writeValue(stream, value.toList()) } - is CustomSelectionAction -> { + is SecurityInfoState -> { stream.write(204) writeValue(stream, value.toList()) } - is WebExtensionData -> { + is TabContentState -> { stream.write(205) writeValue(stream, value.toList()) } - is AddonInfo -> { + is FindResultState -> { stream.write(206) writeValue(stream, value.toList()) } - is AddonListingPreview -> { + is CustomSelectionAction -> { stream.write(207) writeValue(stream, value.toList()) } - is AddonListing -> { + is WebExtensionData -> { stream.write(208) writeValue(stream, value.toList()) } - is AddonStoreInfo -> { + is AddonInfo -> { stream.write(209) writeValue(stream, value.toList()) } - is AddonUpdateAttemptInfo -> { + is AddonListingPreview -> { stream.write(210) writeValue(stream, value.toList()) } - is GeckoSuggestion -> { + is AddonListing -> { stream.write(211) writeValue(stream, value.toList()) } - is TabContent -> { + is AddonStoreInfo -> { stream.write(212) writeValue(stream, value.toList()) } - is ContentBlocking -> { + is AddonUpdateAttemptInfo -> { stream.write(213) writeValue(stream, value.toList()) } - is DohSettings -> { + is GeckoSuggestion -> { stream.write(214) writeValue(stream, value.toList()) } - is GeckoEngineSettings -> { + is TabContent -> { stream.write(215) writeValue(stream, value.toList()) } - is AutocompleteResult -> { + is ContentBlocking -> { stream.write(216) writeValue(stream, value.toList()) } - is UnknownHitResult -> { + is DohSettings -> { stream.write(217) writeValue(stream, value.toList()) } - is ImageHitResult -> { + is GeckoEngineSettings -> { stream.write(218) writeValue(stream, value.toList()) } - is VideoHitResult -> { + is AutocompleteResult -> { stream.write(219) writeValue(stream, value.toList()) } - is AudioHitResult -> { + is UnknownHitResult -> { stream.write(220) writeValue(stream, value.toList()) } - is ImageSrcHitResult -> { + is ImageHitResult -> { stream.write(221) writeValue(stream, value.toList()) } - is PhoneHitResult -> { + is VideoHitResult -> { stream.write(222) writeValue(stream, value.toList()) } - is EmailHitResult -> { + is AudioHitResult -> { stream.write(223) writeValue(stream, value.toList()) } - is GeoHitResult -> { + is ImageSrcHitResult -> { stream.write(224) writeValue(stream, value.toList()) } - is DownloadState -> { + is PhoneHitResult -> { stream.write(225) writeValue(stream, value.toList()) } - is ShareInternetResourceState -> { + is EmailHitResult -> { stream.write(226) writeValue(stream, value.toList()) } - is AddonCollection -> { + is GeoHitResult -> { stream.write(227) writeValue(stream, value.toList()) } - is SyncEngineStatus -> { + is DownloadState -> { stream.write(228) writeValue(stream, value.toList()) } - is SyncAccountInfo -> { + is ShareInternetResourceState -> { stream.write(229) writeValue(stream, value.toList()) } - is SyncDevice -> { + is AddonCollection -> { stream.write(230) writeValue(stream, value.toList()) } - is SyncIncomingTab -> { + is SyncEngineStatus -> { stream.write(231) writeValue(stream, value.toList()) } - is SyncRemoteTab -> { + is SyncAccountInfo -> { stream.write(232) writeValue(stream, value.toList()) } - is SyncDeviceTabs -> { + is SyncDevice -> { stream.write(233) writeValue(stream, value.toList()) } - is GeckoPref -> { + is SyncIncomingTab -> { stream.write(234) writeValue(stream, value.toList()) } - is MlProgressData -> { + is SyncRemoteTab -> { stream.write(235) writeValue(stream, value.toList()) } - is GeckoProxySettings -> { + is SyncDeviceTabs -> { stream.write(236) writeValue(stream, value.toList()) } - is ContainerSiteAssignment -> { + is GeckoPref -> { stream.write(237) writeValue(stream, value.toList()) } - is ProxyLoadError -> { + is MlProgressData -> { stream.write(238) writeValue(stream, value.toList()) } - is GeckoHeader -> { + is GeckoProxySettings -> { stream.write(239) writeValue(stream, value.toList()) } - is GeckoFetchRequest -> { + is ContainerSiteAssignment -> { stream.write(240) writeValue(stream, value.toList()) } - is GeckoFetchResponse -> { + is ProxyLoadError -> { stream.write(241) writeValue(stream, value.toList()) } - is BookmarkNode -> { + is GeckoHeader -> { stream.write(242) writeValue(stream, value.toList()) } - is BookmarkInfo -> { + is GeckoFetchRequest -> { stream.write(243) writeValue(stream, value.toList()) } - is SitePermissions -> { + is GeckoFetchResponse -> { stream.write(244) writeValue(stream, value.toList()) } - is TrackingProtectionException -> { + is BookmarkNode -> { stream.write(245) writeValue(stream, value.toList()) } - is PwaIcon -> { + is BookmarkInfo -> { stream.write(246) writeValue(stream, value.toList()) } - is ShareTargetFiles -> { + is SitePermissions -> { stream.write(247) writeValue(stream, value.toList()) } - is ShareTargetParams -> { + is TrackingProtectionException -> { stream.write(248) writeValue(stream, value.toList()) } - is ShareTarget -> { + is AppLinkTarget -> { stream.write(249) writeValue(stream, value.toList()) } - is ExternalApplicationResource -> { + is ProtectedTargetPattern -> { stream.write(250) writeValue(stream, value.toList()) } - is PwaManifest -> { + is NativeAppLinkRule -> { stream.write(251) writeValue(stream, value.toList()) } - is SandboxCaptureEntry -> { + is NativeContextAppLinkPolicy -> { stream.write(252) writeValue(stream, value.toList()) } - is GestureConfig -> { + is AppLinkPolicySnapshot -> { stream.write(253) writeValue(stream, value.toList()) } - is PushDistributor -> { + is AppLinkPromptRequest -> { stream.write(254) writeValue(stream, value.toList()) } - is PushStatus -> { + is AppLinkResolutionResult -> { val wrap = GeckoPigeonInternalCodecOverflow(type = 0, wrapped = value.toList()) stream.write(255) writeValue(stream, wrap.toList()) } - is PushSubscription -> { + is PwaIcon -> { val wrap = GeckoPigeonInternalCodecOverflow(type = 1, wrapped = value.toList()) stream.write(255) writeValue(stream, wrap.toList()) } + is ShareTargetFiles -> { + val wrap = GeckoPigeonInternalCodecOverflow(type = 2, wrapped = value.toList()) + stream.write(255) + writeValue(stream, wrap.toList()) + } + is ShareTargetParams -> { + val wrap = GeckoPigeonInternalCodecOverflow(type = 3, wrapped = value.toList()) + stream.write(255) + writeValue(stream, wrap.toList()) + } + is ShareTarget -> { + val wrap = GeckoPigeonInternalCodecOverflow(type = 4, wrapped = value.toList()) + stream.write(255) + writeValue(stream, wrap.toList()) + } + is ExternalApplicationResource -> { + val wrap = GeckoPigeonInternalCodecOverflow(type = 5, wrapped = value.toList()) + stream.write(255) + writeValue(stream, wrap.toList()) + } + is PwaManifest -> { + val wrap = GeckoPigeonInternalCodecOverflow(type = 6, wrapped = value.toList()) + stream.write(255) + writeValue(stream, wrap.toList()) + } + is SandboxCaptureEntry -> { + val wrap = GeckoPigeonInternalCodecOverflow(type = 7, wrapped = value.toList()) + stream.write(255) + writeValue(stream, wrap.toList()) + } + is GestureConfig -> { + val wrap = GeckoPigeonInternalCodecOverflow(type = 8, wrapped = value.toList()) + stream.write(255) + writeValue(stream, wrap.toList()) + } + is PushDistributor -> { + val wrap = GeckoPigeonInternalCodecOverflow(type = 9, wrapped = value.toList()) + stream.write(255) + writeValue(stream, wrap.toList()) + } + is PushStatus -> { + val wrap = GeckoPigeonInternalCodecOverflow(type = 10, wrapped = value.toList()) + stream.write(255) + writeValue(stream, wrap.toList()) + } + is PushSubscription -> { + val wrap = GeckoPigeonInternalCodecOverflow(type = 11, wrapped = value.toList()) + stream.write(255) + writeValue(stream, wrap.toList()) + } else -> super.writeValue(stream, value) } } @@ -7753,12 +8309,6 @@ interface GeckoEngineSettingsApi { fun updateRuntimeSettings(settings: GeckoEngineSettings) fun setScreenshotProtectionEnabled(enabled: Boolean) fun setPullToRefreshEnabled(enabled: Boolean) - /** - * Sets the app links mode preference (stored in SharedPreferences). - * Controls how external app links are handled in the browser. - */ - fun setAppLinksMode(mode: AppLinksMode) - fun getAppLinksMode(): AppLinksMode /** * Sets whether to use external download managers for downloads. * When enabled, downloads are forwarded to third-party apps like ADM, 1DM, AB DM. @@ -7872,39 +8422,6 @@ interface GeckoEngineSettingsApi { channel.setMessageHandler(null) } } - run { - val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.flutter_mozilla_components.GeckoEngineSettingsApi.setAppLinksMode$separatedMessageChannelSuffix", codec) - if (api != null) { - channel.setMessageHandler { message, reply -> - val args = message as List - val modeArg = args[0] as AppLinksMode - val wrapped: List = try { - api.setAppLinksMode(modeArg) - listOf(null) - } catch (exception: Throwable) { - GeckoPigeonUtils.wrapError(exception) - } - reply.reply(wrapped) - } - } else { - channel.setMessageHandler(null) - } - } - run { - val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.flutter_mozilla_components.GeckoEngineSettingsApi.getAppLinksMode$separatedMessageChannelSuffix", codec) - if (api != null) { - channel.setMessageHandler { _, reply -> - val wrapped: List = try { - listOf(api.getAppLinksMode()) - } catch (exception: Throwable) { - GeckoPigeonUtils.wrapError(exception) - } - reply.reply(wrapped) - } - } else { - channel.setMessageHandler(null) - } - } run { val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.flutter_mozilla_components.GeckoEngineSettingsApi.setUseExternalDownloadManager$separatedMessageChannelSuffix", codec) if (api != null) { @@ -12244,32 +12761,49 @@ interface GeckoTrackingProtectionApi { /** * API for detecting and launching external applications that can handle URLs. * - * This API wraps Mozilla Android Components' AppLinksUseCases to allow Flutter - * code to check if native apps can handle URLs and launch them directly. + * WebLibre-owned resolution/launch surface (replaces the Mozilla AC use-case + * wrappers). Policy lives in Dart; this surface owns PackageManager resolution + * and Intent launch. * * Generated interface from Pigeon that represents a handler of messages from Flutter. */ interface GeckoAppLinksApi { /** - * Checks if an external application is available to handle the given URL. - * - * This method uses mozilla-components AppLinksUseCases to determine if - * a native app can handle the URL (e.g., YouTube app for youtube.com links). - * - * Returns true if an external app is available, false otherwise. + * Push the complete policy snapshot to native (last-write-wins). Native + * persists it durably to the active profile's prefs record before acking. */ - fun hasExternalApp(url: String, callback: (Result) -> Unit) + fun setAppLinkPolicy(snapshot: AppLinkPolicySnapshot, callback: (Result) -> Unit) /** - * Opens the URL in an external application if available. - * - * This method will: - * 1. Check if an external app can handle the URL - * 2. If available, launch the app directly with Intent.FLAG_ACTIVITY_NEW_TASK - * 3. Return true if successfully launched, false otherwise - * - * Returns true if URL was opened in external app, false if no app available. + * Non-consuming query of pending prompts for [owner] (§2.6). Surfaces call + * this on attach/resume/rotation and when the availability event fires, and + * render idempotently by requestId. */ - fun openAppLink(url: String, callback: (Result) -> Unit) + fun getPendingAppLinkPrompts(owner: AppLinkPromptOwner, callback: (Result>) -> Unit) + /** + * 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). + */ + fun resolvePendingAppLink(requestId: Long, decision: AppLinkDecision, callback: (Result) -> Unit) + /** + * 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). + */ + fun resolveAppLink(url: String, includeHttpAppLinks: Boolean, callback: (Result) -> Unit) + /** + * 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. + */ + fun launchAppLink(url: String, callback: (Result) -> Unit) companion object { /** The codec used by GeckoAppLinksApi. */ @@ -12281,12 +12815,31 @@ interface GeckoAppLinksApi { fun setUp(binaryMessenger: BinaryMessenger, api: GeckoAppLinksApi?, messageChannelSuffix: String = "") { val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else "" run { - val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.flutter_mozilla_components.GeckoAppLinksApi.hasExternalApp$separatedMessageChannelSuffix", codec) + val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.flutter_mozilla_components.GeckoAppLinksApi.setAppLinkPolicy$separatedMessageChannelSuffix", codec) if (api != null) { channel.setMessageHandler { message, reply -> val args = message as List - val urlArg = args[0] as String - api.hasExternalApp(urlArg) { result: Result -> + val snapshotArg = args[0] as AppLinkPolicySnapshot + api.setAppLinkPolicy(snapshotArg) { result: Result -> + val error = result.exceptionOrNull() + if (error != null) { + reply.reply(GeckoPigeonUtils.wrapError(error)) + } else { + reply.reply(GeckoPigeonUtils.wrapResult(null)) + } + } + } + } else { + channel.setMessageHandler(null) + } + } + run { + val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.flutter_mozilla_components.GeckoAppLinksApi.getPendingAppLinkPrompts$separatedMessageChannelSuffix", codec) + if (api != null) { + channel.setMessageHandler { message, reply -> + val args = message as List + val ownerArg = args[0] as AppLinkPromptOwner + api.getPendingAppLinkPrompts(ownerArg) { result: Result> -> val error = result.exceptionOrNull() if (error != null) { reply.reply(GeckoPigeonUtils.wrapError(error)) @@ -12301,12 +12854,13 @@ interface GeckoAppLinksApi { } } run { - val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.flutter_mozilla_components.GeckoAppLinksApi.openAppLink$separatedMessageChannelSuffix", codec) + val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.flutter_mozilla_components.GeckoAppLinksApi.resolvePendingAppLink$separatedMessageChannelSuffix", codec) if (api != null) { channel.setMessageHandler { message, reply -> val args = message as List - val urlArg = args[0] as String - api.openAppLink(urlArg) { result: Result -> + val requestIdArg = args[0] as Long + val decisionArg = args[1] as AppLinkDecision + api.resolvePendingAppLink(requestIdArg, decisionArg) { result: Result -> val error = result.exceptionOrNull() if (error != null) { reply.reply(GeckoPigeonUtils.wrapError(error)) @@ -12320,6 +12874,81 @@ interface GeckoAppLinksApi { channel.setMessageHandler(null) } } + run { + val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.flutter_mozilla_components.GeckoAppLinksApi.resolveAppLink$separatedMessageChannelSuffix", codec) + if (api != null) { + channel.setMessageHandler { message, reply -> + val args = message as List + val urlArg = args[0] as String + val includeHttpAppLinksArg = args[1] as Boolean + api.resolveAppLink(urlArg, includeHttpAppLinksArg) { result: Result -> + val error = result.exceptionOrNull() + if (error != null) { + reply.reply(GeckoPigeonUtils.wrapError(error)) + } else { + val data = result.getOrNull() + reply.reply(GeckoPigeonUtils.wrapResult(data)) + } + } + } + } else { + channel.setMessageHandler(null) + } + } + run { + val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.flutter_mozilla_components.GeckoAppLinksApi.launchAppLink$separatedMessageChannelSuffix", codec) + if (api != null) { + channel.setMessageHandler { message, reply -> + val args = message as List + val urlArg = args[0] as String + api.launchAppLink(urlArg) { result: Result -> + val error = result.exceptionOrNull() + if (error != null) { + reply.reply(GeckoPigeonUtils.wrapError(error)) + } else { + val data = result.getOrNull() + reply.reply(GeckoPigeonUtils.wrapResult(data)) + } + } + } + } else { + channel.setMessageHandler(null) + } + } + } + } +} +/** + * 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. + * + * Generated class from Pigeon that represents Flutter messages that can be called from Kotlin. + */ +class GeckoAppLinkEvents(private val binaryMessenger: BinaryMessenger, private val messageChannelSuffix: String = "") { + companion object { + /** The codec used by GeckoAppLinkEvents. */ + val codec: MessageCodec by lazy { + GeckoPigeonCodec() + } + } + fun onAppLinkPromptAvailable(sequenceArg: Long, ownerArg: AppLinkPromptOwner, callback: (Result) -> Unit) +{ + val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else "" + val channelName = "dev.flutter.pigeon.flutter_mozilla_components.GeckoAppLinkEvents.onAppLinkPromptAvailable$separatedMessageChannelSuffix" + val channel = BasicMessageChannel(binaryMessenger, channelName, codec) + channel.send(listOf(sequenceArg, ownerArg)) { + if (it is List<*>) { + if (it.size > 1) { + callback(Result.failure(FlutterError(it[0] as String, it[1] as String, it[2] as String?))) + } else { + callback(Result.success(Unit)) + } + } else { + callback(Result.failure(GeckoPigeonUtils.createConnectionError(channelName))) + } } } } diff --git a/packages/flutter_mozilla_components/android/src/main/res/values/strings.xml b/packages/flutter_mozilla_components/android/src/main/res/values/strings.xml index 390a335a..1a6f76e7 100644 --- a/packages/flutter_mozilla_components/android/src/main/res/values/strings.xml +++ b/packages/flutter_mozilla_components/android/src/main/res/values/strings.xml @@ -30,4 +30,12 @@ Close private tabs? Tap or swipe this notification to close private tabs. + + + Open in %1$s? + Open in another app? + This link is handled by an app outside WebLibre. + Open + Cancel + diff --git a/packages/flutter_mozilla_components/android/src/test/kotlin/eu/weblibre/flutter_mozilla_components/applinks/AppLinkClassifierTest.kt b/packages/flutter_mozilla_components/android/src/test/kotlin/eu/weblibre/flutter_mozilla_components/applinks/AppLinkClassifierTest.kt new file mode 100644 index 00000000..30eb3c93 --- /dev/null +++ b/packages/flutter_mozilla_components/android/src/test/kotlin/eu/weblibre/flutter_mozilla_components/applinks/AppLinkClassifierTest.kt @@ -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, + ) + } +} diff --git a/packages/flutter_mozilla_components/android/src/test/kotlin/eu/weblibre/flutter_mozilla_components/applinks/AppLinkHostNormalizerTest.kt b/packages/flutter_mozilla_components/android/src/test/kotlin/eu/weblibre/flutter_mozilla_components/applinks/AppLinkHostNormalizerTest.kt new file mode 100644 index 00000000..4ac526b0 --- /dev/null +++ b/packages/flutter_mozilla_components/android/src/test/kotlin/eu/weblibre/flutter_mozilla_components/applinks/AppLinkHostNormalizerTest.kt @@ -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)) + } +} diff --git a/packages/flutter_mozilla_components/android/src/test/kotlin/eu/weblibre/flutter_mozilla_components/applinks/AppLinkLauncherTest.kt b/packages/flutter_mozilla_components/android/src/test/kotlin/eu/weblibre/flutter_mozilla_components/applinks/AppLinkLauncherTest.kt new file mode 100644 index 00000000..36c2434c --- /dev/null +++ b/packages/flutter_mozilla_components/android/src/test/kotlin/eu/weblibre/flutter_mozilla_components/applinks/AppLinkLauncherTest.kt @@ -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)) + } +} diff --git a/packages/flutter_mozilla_components/android/src/test/kotlin/eu/weblibre/flutter_mozilla_components/applinks/AppLinkSchemesTest.kt b/packages/flutter_mozilla_components/android/src/test/kotlin/eu/weblibre/flutter_mozilla_components/applinks/AppLinkSchemesTest.kt new file mode 100644 index 00000000..e708e7ce --- /dev/null +++ b/packages/flutter_mozilla_components/android/src/test/kotlin/eu/weblibre/flutter_mozilla_components/applinks/AppLinkSchemesTest.kt @@ -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)) + } +} diff --git a/packages/flutter_mozilla_components/android/src/test/kotlin/eu/weblibre/flutter_mozilla_components/applinks/PendingAppLinkStoreTest.kt b/packages/flutter_mozilla_components/android/src/test/kotlin/eu/weblibre/flutter_mozilla_components/applinks/PendingAppLinkStoreTest.kt new file mode 100644 index 00000000..21365617 --- /dev/null +++ b/packages/flutter_mozilla_components/android/src/test/kotlin/eu/weblibre/flutter_mozilla_components/applinks/PendingAppLinkStoreTest.kt @@ -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/")) + } +} diff --git a/packages/flutter_mozilla_components/lib/flutter_mozilla_components.dart b/packages/flutter_mozilla_components/lib/flutter_mozilla_components.dart index 4104d3ac..85f92b61 100644 --- a/packages/flutter_mozilla_components/lib/flutter_mozilla_components.dart +++ b/packages/flutter_mozilla_components/lib/flutter_mozilla_components.dart @@ -50,6 +50,12 @@ export 'src/pigeons/gecko.g.dart' AddonStorePromoted, AddonUpdateAttemptInfo, AddonUpdateStatus, + AppLinkDecision, + AppLinkPolicySnapshot, + AppLinkPromptOwner, + AppLinkPromptRequest, + AppLinkResolutionResult, + AppLinkTarget, AppLinksMode, AudioHitResult, AutoplayStatus, @@ -70,6 +76,7 @@ export 'src/pigeons/gecko.g.dart' DownloadStatus, EmailHitResult, FrecencyThresholdOption, + GeckoAppLinkEvents, GeckoDeleteBrowsingDataController, GeckoEngineSettings, GeckoFetchResponse, @@ -99,7 +106,11 @@ export 'src/pigeons/gecko.g.dart' MlProgressData, MlProgressStatus, MlProgressType, + NativeAppLinkRule, + NativeAppLinkRuleDecision, + NativeContextAppLinkPolicy, PhoneHitResult, + ProtectedTargetPattern, ProxyLoadError, PushDistributor, PushDistributorStatus, diff --git a/packages/flutter_mozilla_components/lib/src/domain/services/gecko_app_links.dart b/packages/flutter_mozilla_components/lib/src/domain/services/gecko_app_links.dart index d5c17cae..fe81858a 100644 --- a/packages/flutter_mozilla_components/lib/src/domain/services/gecko_app_links.dart +++ b/packages/flutter_mozilla_components/lib/src/domain/services/gecko_app_links.dart @@ -10,31 +10,52 @@ final _api = GeckoAppLinksApi(); /// Service for detecting and launching external applications that can handle URLs. /// -/// This service wraps Mozilla Android Components' AppLinksUseCases to allow -/// checking if native apps can handle URLs and launching them directly. -/// This matches the behavior in Firefox/Fenix for "Open in App" functionality. +/// WebLibre-owned resolution/launch surface. Policy lives in Dart; the native side +/// owns PackageManager resolution and Intent launch. Used by the manual +/// "Open in app" entry points. class GeckoAppLinksService { - /// Checks if an external application is available to handle the given URL. + /// Resolve [url] to an external-app target, or null when no external app is + /// available (or on any resolution error / always-denied scheme). /// - /// This method uses mozilla-components AppLinksUseCases to determine if - /// a native app can handle the URL (e.g., YouTube app for youtube.com links). - /// - /// @param url The URL to check. - /// @return true if an external app is available, false otherwise. - Future hasExternalApp(Uri url) { - return _api.hasExternalApp(url.toString()); + /// [includeHttpAppLinks] when true, an app resolving an engine-supported + /// (http(s)) URL is surfaced (e.g. the YouTube app for a youtube.com link). + Future resolveAppLink( + Uri url, { + bool includeHttpAppLinks = true, + }) { + return _api.resolveAppLink(url.toString(), includeHttpAppLinks); } - /// Opens the URL in an external application if available. + /// Re-resolve [url] and launch it in an external app. /// - /// This method will: - /// 1. Check if an external app can handle the URL - /// 2. If available, launch the app directly with Intent.FLAG_ACTIVITY_NEW_TASK - /// 3. Return true if successfully launched, false otherwise + /// Returns true if launched, false if no app is available or the launch failed. + Future launchAppLink(Uri url) { + return _api.launchAppLink(url.toString()); + } + + /// Push the complete app-link policy snapshot to native (last-write-wins). /// - /// @param url The URL to open in external app. - /// @return true if URL was opened in external app, false if no app available. - Future openAppLink(Uri url) { - return _api.openAppLink(url.toString()); + /// Throws if no profile is bound yet; the caller (replicator) retries after + /// initialisation. + Future setAppLinkPolicy(AppLinkPolicySnapshot snapshot) { + return _api.setAppLinkPolicy(snapshot); + } + + /// Non-consuming query of pending prompts for [owner] (§2.6). Query on + /// attach/resume and when the availability event fires; render idempotently by + /// requestId. + Future> 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 resolvePendingAppLink( + int requestId, + AppLinkDecision decision, + ) { + return _api.resolvePendingAppLink(requestId, decision); } } diff --git a/packages/flutter_mozilla_components/lib/src/domain/services/gecko_engine_settings.dart b/packages/flutter_mozilla_components/lib/src/domain/services/gecko_engine_settings.dart index 0690038c..97400ffd 100644 --- a/packages/flutter_mozilla_components/lib/src/domain/services/gecko_engine_settings.dart +++ b/packages/flutter_mozilla_components/lib/src/domain/services/gecko_engine_settings.dart @@ -199,16 +199,6 @@ class GeckoEngineSettingsService { return _api.setPullToRefreshEnabled(enabled); } - /// Sets the app links mode preference. - /// Controls how external app links are handled in browser. - Future setAppLinksMode(AppLinksMode mode) { - return _api.setAppLinksMode(mode); - } - - Future getAppLinksMode() { - return _api.getAppLinksMode(); - } - /// Sets whether to use external download managers for downloads. /// When enabled, downloads are forwarded to third-party apps like ADM, 1DM, AB DM. Future setUseExternalDownloadManager(bool enabled) { diff --git a/packages/flutter_mozilla_components/lib/src/pigeons/gecko.g.dart b/packages/flutter_mozilla_components/lib/src/pigeons/gecko.g.dart index c73ebfe1..fa24349e 100644 --- a/packages/flutter_mozilla_components/lib/src/pigeons/gecko.g.dart +++ b/packages/flutter_mozilla_components/lib/src/pigeons/gecko.g.dart @@ -449,6 +449,24 @@ enum AutoplayStatus { allowOnWifi, } +enum NativeAppLinkRuleDecision { + alwaysOpen, + neverOpen, +} + +/// Which surface owns a pending prompt (§2.6). Fixed at creation, never transfers. +enum AppLinkPromptOwner { + flutterBrowser, + nativeExternal, +} + +/// User decision on a pending prompt (§2.6). +enum AppLinkDecision { + open, + cancel, + dismiss, +} + /// Lifecycle state of the selected UnifiedPush distributor. enum PushDistributorStatus { /// No distributor app is installed on the device. @@ -5926,6 +5944,519 @@ class TrackingProtectionException { } } +/// Resolved external-app target for a URL (see APP_LINKS_OWN_IMPLEMENTATION_PLAN.md §2.8). +class AppLinkTarget { + AppLinkTarget({ + required this.url, + this.appName, + this.packageName, + this.fallbackUrl, + required this.isMarketplace, + required this.isAmbiguous, + required this.engineSupportsScheme, + required this.scopeKey, + }); + + /// The URL that was resolved. + String url; + + /// User-facing app label (control/bidi-sanitised), or null when unknown. + String? appName; + + /// Resolved package name, or null when ambiguous / unknown. + String? packageName; + + /// Pre-validated http(s) fallback URL, or null. + String? fallbackUrl; + + /// True when the only offer is a marketplace (install-app) intent. + bool isMarketplace; + + /// True when resolution is ambiguous (chooser / multiple handlers / no default). + bool isAmbiguous; + + /// True when the Gecko engine can load the URL scheme itself. + bool engineSupportsScheme; + + /// Canonical native-owned rule scope key ("host:youtube.com" | "pkg:..."). + String scopeKey; + + List _toList() { + return [ + url, + appName, + packageName, + fallbackUrl, + isMarketplace, + isAmbiguous, + engineSupportsScheme, + scopeKey, + ]; + } + + Object encode() { + return _toList(); } + + static AppLinkTarget decode(Object result) { + result as List; + return AppLinkTarget( + url: result[0]! as String, + appName: result[1] as String?, + packageName: result[2] as String?, + fallbackUrl: result[3] as String?, + isMarketplace: result[4]! as bool, + isAmbiguous: result[5]! as bool, + engineSupportsScheme: result[6]! as bool, + scopeKey: result[7]! as String, + ); + } + + @override + // ignore: avoid_equals_and_hash_code_on_mutable_classes + bool operator ==(Object other) { + if (other is! AppLinkTarget || other.runtimeType != runtimeType) { + return false; + } + if (identical(this, other)) { + return true; + } + return _deepEquals(url, other.url) && _deepEquals(appName, other.appName) && _deepEquals(packageName, other.packageName) && _deepEquals(fallbackUrl, other.fallbackUrl) && _deepEquals(isMarketplace, other.isMarketplace) && _deepEquals(isAmbiguous, other.isAmbiguous) && _deepEquals(engineSupportsScheme, other.engineSupportsScheme) && _deepEquals(scopeKey, other.scopeKey); + } + + @override + // ignore: avoid_equals_and_hash_code_on_mutable_classes + int get hashCode => _deepHash([runtimeType, ..._toList()]); + + @override + String toString() { + return 'AppLinkTarget(url: $url, appName: $appName, packageName: $packageName, fallbackUrl: $fallbackUrl, isMarketplace: $isMarketplace, isAmbiguous: $isAmbiguous, engineSupportsScheme: $engineSupportsScheme, scopeKey: $scopeKey)'; + } +} + +/// Target-side protection pattern replicated to native (§2.3/§2.8). Any target +/// assigned to an effectively-proxied or strict container is protected +/// independent of the source tab. +class ProtectedTargetPattern { + ProtectedTargetPattern({ + required this.scheme, + required this.hostOrSuffix, + required this.includeSubdomains, + this.port, + }); + + String scheme; + + String hostOrSuffix; + + bool includeSubdomains; + + /// Effective port for exact entries; null for wildcard entries (ignore port). + int? port; + + List _toList() { + return [ + scheme, + hostOrSuffix, + includeSubdomains, + port, + ]; + } + + Object encode() { + return _toList(); } + + static ProtectedTargetPattern decode(Object result) { + result as List; + return ProtectedTargetPattern( + scheme: result[0]! as String, + hostOrSuffix: result[1]! as String, + includeSubdomains: result[2]! as bool, + port: result[3] as int?, + ); + } + + @override + // ignore: avoid_equals_and_hash_code_on_mutable_classes + bool operator ==(Object other) { + if (other is! ProtectedTargetPattern || other.runtimeType != runtimeType) { + return false; + } + if (identical(this, other)) { + return true; + } + return _deepEquals(scheme, other.scheme) && _deepEquals(hostOrSuffix, other.hostOrSuffix) && _deepEquals(includeSubdomains, other.includeSubdomains) && _deepEquals(port, other.port); + } + + @override + // ignore: avoid_equals_and_hash_code_on_mutable_classes + int get hashCode => _deepHash([runtimeType, ..._toList()]); + + @override + String toString() { + return 'ProtectedTargetPattern(scheme: $scheme, hostOrSuffix: $hostOrSuffix, includeSubdomains: $includeSubdomains, port: $port)'; + } +} + +/// A remembered per-scope rule replicated to native (§2.8). Distinct from the +/// Dart-persisted `PersistedAppLinkRule`; explicit mappers bridge the two. +class NativeAppLinkRule { + NativeAppLinkRule({ + required this.decision, + required this.scope, + this.packageName, + }); + + NativeAppLinkRuleDecision decision; + + String scope; + + String? packageName; + + List _toList() { + return [ + decision, + scope, + packageName, + ]; + } + + Object encode() { + return _toList(); } + + static NativeAppLinkRule decode(Object result) { + result as List; + return NativeAppLinkRule( + decision: result[0]! as NativeAppLinkRuleDecision, + scope: result[1]! as String, + packageName: result[2] as String?, + ); + } + + @override + // ignore: avoid_equals_and_hash_code_on_mutable_classes + bool operator ==(Object other) { + if (other is! NativeAppLinkRule || other.runtimeType != runtimeType) { + return false; + } + if (identical(this, other)) { + return true; + } + return _deepEquals(decision, other.decision) && _deepEquals(scope, other.scope) && _deepEquals(packageName, other.packageName); + } + + @override + // ignore: avoid_equals_and_hash_code_on_mutable_classes + int get hashCode => _deepHash([runtimeType, ..._toList()]); + + @override + String toString() { + return 'NativeAppLinkRule(decision: $decision, scope: $scope, packageName: $packageName)'; + } +} + +/// A container's self-contained app-link policy override (§ container isolation). +/// Present only for containers with "isolated app link settings" enabled; when a +/// navigation's source contextId has an entry here, it fully *replaces* the +/// global mode + rules for that navigation (no layering with the global policy). +class NativeContextAppLinkPolicy { + NativeContextAppLinkPolicy({ + required this.mode, + required this.rules, + }); + + AppLinksMode mode; + + /// The container's own remembered rules keyed by canonical scope. + Map rules; + + List _toList() { + return [ + mode, + rules, + ]; + } + + Object encode() { + return _toList(); } + + static NativeContextAppLinkPolicy decode(Object result) { + result as List; + return NativeContextAppLinkPolicy( + mode: result[0]! as AppLinksMode, + rules: (result[1]! as Map).cast(), + ); + } + + @override + // ignore: avoid_equals_and_hash_code_on_mutable_classes + bool operator ==(Object other) { + if (other is! NativeContextAppLinkPolicy || other.runtimeType != runtimeType) { + return false; + } + if (identical(this, other)) { + return true; + } + return _deepEquals(mode, other.mode) && _deepEquals(rules, other.rules); + } + + @override + // ignore: avoid_equals_and_hash_code_on_mutable_classes + int get hashCode => _deepHash([runtimeType, ..._toList()]); + + @override + String toString() { + return 'NativeContextAppLinkPolicy(mode: $mode, rules: $rules)'; + } +} + +/// Complete, last-write-wins policy snapshot pushed from the single Dart writer +/// to native (§2.8). Native persists it to the profile-scoped prefs record +/// before swapping the in-memory reference. +class AppLinkPolicySnapshot { + 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, + }); + + AppLinksMode globalMode; + + /// Remembered rules keyed by canonical scope. + Map rules; + + bool marketplaceFallbackEnabled; + + /// Regular / no-contextId tabs are proxied via the `general` scope. + bool protectGeneralContext; + + /// contextIds that resolve to a proxy after inherit/bypass/alias. + List protectedContextIds; + + /// strictMode containers, independent of routing. + List strictContextIds; + + List 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). + Map contextOverrides; + + List _toList() { + return [ + globalMode, + rules, + marketplaceFallbackEnabled, + protectGeneralContext, + protectedContextIds, + strictContextIds, + protectedTargetPatterns, + contextOverrides, + ]; + } + + Object encode() { + return _toList(); } + + static AppLinkPolicySnapshot decode(Object result) { + result as List; + return AppLinkPolicySnapshot( + globalMode: result[0]! as AppLinksMode, + rules: (result[1]! as Map).cast(), + marketplaceFallbackEnabled: result[2]! as bool, + protectGeneralContext: result[3]! as bool, + protectedContextIds: (result[4]! as List).cast(), + strictContextIds: (result[5]! as List).cast(), + protectedTargetPatterns: (result[6]! as List).cast(), + contextOverrides: (result[7]! as Map).cast(), + ); + } + + @override + // ignore: avoid_equals_and_hash_code_on_mutable_classes + bool operator ==(Object other) { + if (other is! AppLinkPolicySnapshot || other.runtimeType != runtimeType) { + return false; + } + if (identical(this, other)) { + return true; + } + return _deepEquals(globalMode, other.globalMode) && _deepEquals(rules, other.rules) && _deepEquals(marketplaceFallbackEnabled, other.marketplaceFallbackEnabled) && _deepEquals(protectGeneralContext, other.protectGeneralContext) && _deepEquals(protectedContextIds, other.protectedContextIds) && _deepEquals(strictContextIds, other.strictContextIds) && _deepEquals(protectedTargetPatterns, other.protectedTargetPatterns) && _deepEquals(contextOverrides, other.contextOverrides); + } + + @override + // ignore: avoid_equals_and_hash_code_on_mutable_classes + int get hashCode => _deepHash([runtimeType, ..._toList()]); + + @override + String toString() { + return 'AppLinkPolicySnapshot(globalMode: $globalMode, rules: $rules, marketplaceFallbackEnabled: $marketplaceFallbackEnabled, protectGeneralContext: $protectGeneralContext, protectedContextIds: $protectedContextIds, strictContextIds: $strictContextIds, protectedTargetPatterns: $protectedTargetPatterns, contextOverrides: $contextOverrides)'; + } +} + +/// A pending app-link prompt request held in the native `PendingAppLinkStore` +/// until resolved, invalidated, or expired (§2.6/§2.8). Holds only stable +/// identifiers and sanitised data — never engine/store references. +class AppLinkPromptRequest { + 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, + }); + + /// Monotonic per-process id (Kotlin Long). + int requestId; + + AppLinkPromptOwner owner; + + String tabId; + + String? contextId; + + String? sourceUrl; + + bool isPrivate; + + bool isWallet; + + bool isProtectedContext; + + bool canRemember; + + /// false for the http(s) banner class (non-modal); true for the modal + /// unsupported-scheme prompt. + bool isModal; + + AppLinkTarget target; + + List _toList() { + return [ + requestId, + owner, + tabId, + contextId, + sourceUrl, + isPrivate, + isWallet, + isProtectedContext, + canRemember, + isModal, + target, + ]; + } + + Object encode() { + return _toList(); } + + static AppLinkPromptRequest decode(Object result) { + result as List; + return AppLinkPromptRequest( + requestId: result[0]! as int, + owner: result[1]! as AppLinkPromptOwner, + tabId: result[2]! as String, + contextId: result[3] as String?, + sourceUrl: result[4] as String?, + isPrivate: result[5]! as bool, + isWallet: result[6]! as bool, + isProtectedContext: result[7]! as bool, + canRemember: result[8]! as bool, + isModal: result[9]! as bool, + target: result[10]! as AppLinkTarget, + ); + } + + @override + // ignore: avoid_equals_and_hash_code_on_mutable_classes + bool operator ==(Object other) { + if (other is! AppLinkPromptRequest || other.runtimeType != runtimeType) { + return false; + } + if (identical(this, other)) { + return true; + } + return _deepEquals(requestId, other.requestId) && _deepEquals(owner, other.owner) && _deepEquals(tabId, other.tabId) && _deepEquals(contextId, other.contextId) && _deepEquals(sourceUrl, other.sourceUrl) && _deepEquals(isPrivate, other.isPrivate) && _deepEquals(isWallet, other.isWallet) && _deepEquals(isProtectedContext, other.isProtectedContext) && _deepEquals(canRemember, other.canRemember) && _deepEquals(isModal, other.isModal) && _deepEquals(target, other.target); + } + + @override + // ignore: avoid_equals_and_hash_code_on_mutable_classes + int get hashCode => _deepHash([runtimeType, ..._toList()]); + + @override + String toString() { + return 'AppLinkPromptRequest(requestId: $requestId, owner: $owner, tabId: $tabId, contextId: $contextId, sourceUrl: $sourceUrl, isPrivate: $isPrivate, isWallet: $isWallet, isProtectedContext: $isProtectedContext, canRemember: $canRemember, isModal: $isModal, target: $target)'; + } +} + +/// Result of resolving a pending prompt (§2.8). +class AppLinkResolutionResult { + AppLinkResolutionResult({ + required this.launched, + required this.loadedFallback, + this.failureReason, + }); + + bool launched; + + bool loadedFallback; + + /// "stale" | "dead_session" | "launch_failed" | null. + String? failureReason; + + List _toList() { + return [ + launched, + loadedFallback, + failureReason, + ]; + } + + Object encode() { + return _toList(); } + + static AppLinkResolutionResult decode(Object result) { + result as List; + return AppLinkResolutionResult( + launched: result[0]! as bool, + loadedFallback: result[1]! as bool, + failureReason: result[2] as String?, + ); + } + + @override + // ignore: avoid_equals_and_hash_code_on_mutable_classes + bool operator ==(Object other) { + if (other is! AppLinkResolutionResult || other.runtimeType != runtimeType) { + return false; + } + if (identical(this, other)) { + return true; + } + return _deepEquals(launched, other.launched) && _deepEquals(loadedFallback, other.loadedFallback) && _deepEquals(failureReason, other.failureReason); + } + + @override + // ignore: avoid_equals_and_hash_code_on_mutable_classes + int get hashCode => _deepHash([runtimeType, ..._toList()]); + + @override + String toString() { + return 'AppLinkResolutionResult(launched: $launched, loadedFallback: $loadedFallback, failureReason: $failureReason)'; + } +} + /// Represents an icon from a PWA manifest. class PwaIcon { PwaIcon({ @@ -6721,8 +7252,28 @@ class _PigeonCodecOverflow { switch (type) { case 0: - return PushStatus.decode(wrapped!); + return AppLinkResolutionResult.decode(wrapped!); case 1: + return PwaIcon.decode(wrapped!); + case 2: + return ShareTargetFiles.decode(wrapped!); + case 3: + return ShareTargetParams.decode(wrapped!); + case 4: + return ShareTarget.decode(wrapped!); + case 5: + return ExternalApplicationResource.decode(wrapped!); + case 6: + return PwaManifest.decode(wrapped!); + case 7: + return SandboxCaptureEntry.decode(wrapped!); + case 8: + return GestureConfig.decode(wrapped!); + case 9: + return PushDistributor.decode(wrapped!); + case 10: + return PushStatus.decode(wrapped!); + case 11: return PushSubscription.decode(wrapped!); } return null; @@ -6853,275 +7404,315 @@ class _PigeonCodec extends StandardMessageCodec { } else if (value is AutoplayStatus) { buffer.putUint8(167); writeValue(buffer, value.index); - } else if (value is PushDistributorStatus) { + } else if (value is NativeAppLinkRuleDecision) { buffer.putUint8(168); writeValue(buffer, value.index); - } else if (value is TranslationOptions) { + } else if (value is AppLinkPromptOwner) { buffer.putUint8(169); - writeValue(buffer, value.encode()); - } else if (value is TranslationLanguage) { + writeValue(buffer, value.index); + } else if (value is AppLinkDecision) { buffer.putUint8(170); - writeValue(buffer, value.encode()); - } else if (value is TranslationDetectedLanguages) { + writeValue(buffer, value.index); + } else if (value is PushDistributorStatus) { buffer.putUint8(171); - writeValue(buffer, value.encode()); - } else if (value is TranslationPair) { + writeValue(buffer, value.index); + } else if (value is TranslationOptions) { buffer.putUint8(172); writeValue(buffer, value.encode()); - } else if (value is TranslationEngineStateData) { + } else if (value is TranslationLanguage) { buffer.putUint8(173); writeValue(buffer, value.encode()); - } else if (value is TabTranslationStateData) { + } else if (value is TranslationDetectedLanguages) { buffer.putUint8(174); writeValue(buffer, value.encode()); - } else if (value is ReaderState) { + } else if (value is TranslationPair) { buffer.putUint8(175); writeValue(buffer, value.encode()); - } else if (value is AddTabParams) { + } else if (value is TranslationEngineStateData) { buffer.putUint8(176); writeValue(buffer, value.encode()); - } else if (value is LastMediaAccessState) { + } else if (value is TabTranslationStateData) { buffer.putUint8(177); writeValue(buffer, value.encode()); - } else if (value is HistoryMetadataKey) { + } else if (value is ReaderState) { buffer.putUint8(178); writeValue(buffer, value.encode()); - } else if (value is PackageCategoryValue) { + } else if (value is AddTabParams) { buffer.putUint8(179); writeValue(buffer, value.encode()); - } else if (value is ExternalPackage) { + } else if (value is LastMediaAccessState) { buffer.putUint8(180); writeValue(buffer, value.encode()); - } else if (value is LoadUrlFlagsValue) { + } else if (value is HistoryMetadataKey) { buffer.putUint8(181); writeValue(buffer, value.encode()); - } else if (value is SourceValue) { + } else if (value is PackageCategoryValue) { buffer.putUint8(182); writeValue(buffer, value.encode()); - } else if (value is TabState) { + } else if (value is ExternalPackage) { buffer.putUint8(183); writeValue(buffer, value.encode()); - } else if (value is RecoverableTab) { + } else if (value is LoadUrlFlagsValue) { buffer.putUint8(184); writeValue(buffer, value.encode()); - } else if (value is IconRequest) { + } else if (value is SourceValue) { buffer.putUint8(185); writeValue(buffer, value.encode()); - } else if (value is ResourceSize) { + } else if (value is TabState) { buffer.putUint8(186); writeValue(buffer, value.encode()); - } else if (value is Resource) { + } else if (value is RecoverableTab) { buffer.putUint8(187); writeValue(buffer, value.encode()); - } else if (value is IconResult) { + } else if (value is IconRequest) { buffer.putUint8(188); writeValue(buffer, value.encode()); - } else if (value is CookiePartitionKey) { + } else if (value is ResourceSize) { buffer.putUint8(189); writeValue(buffer, value.encode()); - } else if (value is Cookie) { + } else if (value is Resource) { buffer.putUint8(190); writeValue(buffer, value.encode()); - } else if (value is VisitInfo) { + } else if (value is IconResult) { buffer.putUint8(191); writeValue(buffer, value.encode()); - } else if (value is HistoryHighlightWeights) { + } else if (value is CookiePartitionKey) { buffer.putUint8(192); writeValue(buffer, value.encode()); - } else if (value is HistoryHighlight) { + } else if (value is Cookie) { buffer.putUint8(193); writeValue(buffer, value.encode()); - } else if (value is TopFrecentSiteInfo) { + } else if (value is VisitInfo) { buffer.putUint8(194); writeValue(buffer, value.encode()); - } else if (value is HistoryMetadata) { + } else if (value is HistoryHighlightWeights) { buffer.putUint8(195); writeValue(buffer, value.encode()); - } else if (value is HistorySuggestion) { + } else if (value is HistoryHighlight) { buffer.putUint8(196); writeValue(buffer, value.encode()); - } else if (value is PageObservation) { + } else if (value is TopFrecentSiteInfo) { buffer.putUint8(197); writeValue(buffer, value.encode()); - } else if (value is HistoryItem) { + } else if (value is HistoryMetadata) { buffer.putUint8(198); writeValue(buffer, value.encode()); - } else if (value is HistoryState) { + } else if (value is HistorySuggestion) { buffer.putUint8(199); writeValue(buffer, value.encode()); - } else if (value is ReaderableState) { + } else if (value is PageObservation) { buffer.putUint8(200); writeValue(buffer, value.encode()); - } else if (value is SecurityInfoState) { + } else if (value is HistoryItem) { buffer.putUint8(201); writeValue(buffer, value.encode()); - } else if (value is TabContentState) { + } else if (value is HistoryState) { buffer.putUint8(202); writeValue(buffer, value.encode()); - } else if (value is FindResultState) { + } else if (value is ReaderableState) { buffer.putUint8(203); writeValue(buffer, value.encode()); - } else if (value is CustomSelectionAction) { + } else if (value is SecurityInfoState) { buffer.putUint8(204); writeValue(buffer, value.encode()); - } else if (value is WebExtensionData) { + } else if (value is TabContentState) { buffer.putUint8(205); writeValue(buffer, value.encode()); - } else if (value is AddonInfo) { + } else if (value is FindResultState) { buffer.putUint8(206); writeValue(buffer, value.encode()); - } else if (value is AddonListingPreview) { + } else if (value is CustomSelectionAction) { buffer.putUint8(207); writeValue(buffer, value.encode()); - } else if (value is AddonListing) { + } else if (value is WebExtensionData) { buffer.putUint8(208); writeValue(buffer, value.encode()); - } else if (value is AddonStoreInfo) { + } else if (value is AddonInfo) { buffer.putUint8(209); writeValue(buffer, value.encode()); - } else if (value is AddonUpdateAttemptInfo) { + } else if (value is AddonListingPreview) { buffer.putUint8(210); writeValue(buffer, value.encode()); - } else if (value is GeckoSuggestion) { + } else if (value is AddonListing) { buffer.putUint8(211); writeValue(buffer, value.encode()); - } else if (value is TabContent) { + } else if (value is AddonStoreInfo) { buffer.putUint8(212); writeValue(buffer, value.encode()); - } else if (value is ContentBlocking) { + } else if (value is AddonUpdateAttemptInfo) { buffer.putUint8(213); writeValue(buffer, value.encode()); - } else if (value is DohSettings) { + } else if (value is GeckoSuggestion) { buffer.putUint8(214); writeValue(buffer, value.encode()); - } else if (value is GeckoEngineSettings) { + } else if (value is TabContent) { buffer.putUint8(215); writeValue(buffer, value.encode()); - } else if (value is AutocompleteResult) { + } else if (value is ContentBlocking) { buffer.putUint8(216); writeValue(buffer, value.encode()); - } else if (value is UnknownHitResult) { + } else if (value is DohSettings) { buffer.putUint8(217); writeValue(buffer, value.encode()); - } else if (value is ImageHitResult) { + } else if (value is GeckoEngineSettings) { buffer.putUint8(218); writeValue(buffer, value.encode()); - } else if (value is VideoHitResult) { + } else if (value is AutocompleteResult) { buffer.putUint8(219); writeValue(buffer, value.encode()); - } else if (value is AudioHitResult) { + } else if (value is UnknownHitResult) { buffer.putUint8(220); writeValue(buffer, value.encode()); - } else if (value is ImageSrcHitResult) { + } else if (value is ImageHitResult) { buffer.putUint8(221); writeValue(buffer, value.encode()); - } else if (value is PhoneHitResult) { + } else if (value is VideoHitResult) { buffer.putUint8(222); writeValue(buffer, value.encode()); - } else if (value is EmailHitResult) { + } else if (value is AudioHitResult) { buffer.putUint8(223); writeValue(buffer, value.encode()); - } else if (value is GeoHitResult) { + } else if (value is ImageSrcHitResult) { buffer.putUint8(224); writeValue(buffer, value.encode()); - } else if (value is DownloadState) { + } else if (value is PhoneHitResult) { buffer.putUint8(225); writeValue(buffer, value.encode()); - } else if (value is ShareInternetResourceState) { + } else if (value is EmailHitResult) { buffer.putUint8(226); writeValue(buffer, value.encode()); - } else if (value is AddonCollection) { + } else if (value is GeoHitResult) { buffer.putUint8(227); writeValue(buffer, value.encode()); - } else if (value is SyncEngineStatus) { + } else if (value is DownloadState) { buffer.putUint8(228); writeValue(buffer, value.encode()); - } else if (value is SyncAccountInfo) { + } else if (value is ShareInternetResourceState) { buffer.putUint8(229); writeValue(buffer, value.encode()); - } else if (value is SyncDevice) { + } else if (value is AddonCollection) { buffer.putUint8(230); writeValue(buffer, value.encode()); - } else if (value is SyncIncomingTab) { + } else if (value is SyncEngineStatus) { buffer.putUint8(231); writeValue(buffer, value.encode()); - } else if (value is SyncRemoteTab) { + } else if (value is SyncAccountInfo) { buffer.putUint8(232); writeValue(buffer, value.encode()); - } else if (value is SyncDeviceTabs) { + } else if (value is SyncDevice) { buffer.putUint8(233); writeValue(buffer, value.encode()); - } else if (value is GeckoPref) { + } else if (value is SyncIncomingTab) { buffer.putUint8(234); writeValue(buffer, value.encode()); - } else if (value is MlProgressData) { + } else if (value is SyncRemoteTab) { buffer.putUint8(235); writeValue(buffer, value.encode()); - } else if (value is GeckoProxySettings) { + } else if (value is SyncDeviceTabs) { buffer.putUint8(236); writeValue(buffer, value.encode()); - } else if (value is ContainerSiteAssignment) { + } else if (value is GeckoPref) { buffer.putUint8(237); writeValue(buffer, value.encode()); - } else if (value is ProxyLoadError) { + } else if (value is MlProgressData) { buffer.putUint8(238); writeValue(buffer, value.encode()); - } else if (value is GeckoHeader) { + } else if (value is GeckoProxySettings) { buffer.putUint8(239); writeValue(buffer, value.encode()); - } else if (value is GeckoFetchRequest) { + } else if (value is ContainerSiteAssignment) { buffer.putUint8(240); writeValue(buffer, value.encode()); - } else if (value is GeckoFetchResponse) { + } else if (value is ProxyLoadError) { buffer.putUint8(241); writeValue(buffer, value.encode()); - } else if (value is BookmarkNode) { + } else if (value is GeckoHeader) { buffer.putUint8(242); writeValue(buffer, value.encode()); - } else if (value is BookmarkInfo) { + } else if (value is GeckoFetchRequest) { buffer.putUint8(243); writeValue(buffer, value.encode()); - } else if (value is SitePermissions) { + } else if (value is GeckoFetchResponse) { buffer.putUint8(244); writeValue(buffer, value.encode()); - } else if (value is TrackingProtectionException) { + } else if (value is BookmarkNode) { buffer.putUint8(245); writeValue(buffer, value.encode()); - } else if (value is PwaIcon) { + } else if (value is BookmarkInfo) { buffer.putUint8(246); writeValue(buffer, value.encode()); - } else if (value is ShareTargetFiles) { + } else if (value is SitePermissions) { buffer.putUint8(247); writeValue(buffer, value.encode()); - } else if (value is ShareTargetParams) { + } else if (value is TrackingProtectionException) { buffer.putUint8(248); writeValue(buffer, value.encode()); - } else if (value is ShareTarget) { + } else if (value is AppLinkTarget) { buffer.putUint8(249); writeValue(buffer, value.encode()); - } else if (value is ExternalApplicationResource) { + } else if (value is ProtectedTargetPattern) { buffer.putUint8(250); writeValue(buffer, value.encode()); - } else if (value is PwaManifest) { + } else if (value is NativeAppLinkRule) { buffer.putUint8(251); writeValue(buffer, value.encode()); - } else if (value is SandboxCaptureEntry) { + } else if (value is NativeContextAppLinkPolicy) { buffer.putUint8(252); writeValue(buffer, value.encode()); - } else if (value is GestureConfig) { + } else if (value is AppLinkPolicySnapshot) { buffer.putUint8(253); writeValue(buffer, value.encode()); - } else if (value is PushDistributor) { + } else if (value is AppLinkPromptRequest) { buffer.putUint8(254); writeValue(buffer, value.encode()); - } else if (value is PushStatus) { + } else if (value is AppLinkResolutionResult) { final _PigeonCodecOverflow wrap = _PigeonCodecOverflow(type: 0, wrapped: value.encode()); buffer.putUint8(255); writeValue(buffer, wrap.encode()); - } else if (value is PushSubscription) { + } else if (value is PwaIcon) { final _PigeonCodecOverflow wrap = _PigeonCodecOverflow(type: 1, wrapped: value.encode()); buffer.putUint8(255); writeValue(buffer, wrap.encode()); + } else if (value is ShareTargetFiles) { + final _PigeonCodecOverflow wrap = _PigeonCodecOverflow(type: 2, wrapped: value.encode()); + buffer.putUint8(255); + writeValue(buffer, wrap.encode()); + } else if (value is ShareTargetParams) { + final _PigeonCodecOverflow wrap = _PigeonCodecOverflow(type: 3, wrapped: value.encode()); + buffer.putUint8(255); + writeValue(buffer, wrap.encode()); + } else if (value is ShareTarget) { + final _PigeonCodecOverflow wrap = _PigeonCodecOverflow(type: 4, wrapped: value.encode()); + buffer.putUint8(255); + writeValue(buffer, wrap.encode()); + } else if (value is ExternalApplicationResource) { + final _PigeonCodecOverflow wrap = _PigeonCodecOverflow(type: 5, wrapped: value.encode()); + buffer.putUint8(255); + writeValue(buffer, wrap.encode()); + } else if (value is PwaManifest) { + final _PigeonCodecOverflow wrap = _PigeonCodecOverflow(type: 6, wrapped: value.encode()); + buffer.putUint8(255); + writeValue(buffer, wrap.encode()); + } else if (value is SandboxCaptureEntry) { + final _PigeonCodecOverflow wrap = _PigeonCodecOverflow(type: 7, wrapped: value.encode()); + buffer.putUint8(255); + writeValue(buffer, wrap.encode()); + } else if (value is GestureConfig) { + final _PigeonCodecOverflow wrap = _PigeonCodecOverflow(type: 8, wrapped: value.encode()); + buffer.putUint8(255); + writeValue(buffer, wrap.encode()); + } else if (value is PushDistributor) { + final _PigeonCodecOverflow wrap = _PigeonCodecOverflow(type: 9, wrapped: value.encode()); + buffer.putUint8(255); + writeValue(buffer, wrap.encode()); + } else if (value is PushStatus) { + final _PigeonCodecOverflow wrap = _PigeonCodecOverflow(type: 10, wrapped: value.encode()); + buffer.putUint8(255); + writeValue(buffer, wrap.encode()); + } else if (value is PushSubscription) { + final _PigeonCodecOverflow wrap = _PigeonCodecOverflow(type: 11, wrapped: value.encode()); + buffer.putUint8(255); + writeValue(buffer, wrap.encode()); } else { super.writeValue(buffer, value); } @@ -7249,179 +7840,182 @@ class _PigeonCodec extends StandardMessageCodec { return value == null ? null : AutoplayStatus.values[value]; case 168: final value = readValue(buffer) as int?; - return value == null ? null : PushDistributorStatus.values[value]; + return value == null ? null : NativeAppLinkRuleDecision.values[value]; case 169: - return TranslationOptions.decode(readValue(buffer)!); + final value = readValue(buffer) as int?; + return value == null ? null : AppLinkPromptOwner.values[value]; case 170: - return TranslationLanguage.decode(readValue(buffer)!); + final value = readValue(buffer) as int?; + return value == null ? null : AppLinkDecision.values[value]; case 171: - return TranslationDetectedLanguages.decode(readValue(buffer)!); + final value = readValue(buffer) as int?; + return value == null ? null : PushDistributorStatus.values[value]; case 172: - return TranslationPair.decode(readValue(buffer)!); + return TranslationOptions.decode(readValue(buffer)!); case 173: - return TranslationEngineStateData.decode(readValue(buffer)!); + return TranslationLanguage.decode(readValue(buffer)!); case 174: - return TabTranslationStateData.decode(readValue(buffer)!); + return TranslationDetectedLanguages.decode(readValue(buffer)!); case 175: - return ReaderState.decode(readValue(buffer)!); + return TranslationPair.decode(readValue(buffer)!); case 176: - return AddTabParams.decode(readValue(buffer)!); + return TranslationEngineStateData.decode(readValue(buffer)!); case 177: - return LastMediaAccessState.decode(readValue(buffer)!); + return TabTranslationStateData.decode(readValue(buffer)!); case 178: - return HistoryMetadataKey.decode(readValue(buffer)!); + return ReaderState.decode(readValue(buffer)!); case 179: - return PackageCategoryValue.decode(readValue(buffer)!); + return AddTabParams.decode(readValue(buffer)!); case 180: - return ExternalPackage.decode(readValue(buffer)!); + return LastMediaAccessState.decode(readValue(buffer)!); case 181: - return LoadUrlFlagsValue.decode(readValue(buffer)!); + return HistoryMetadataKey.decode(readValue(buffer)!); case 182: - return SourceValue.decode(readValue(buffer)!); + return PackageCategoryValue.decode(readValue(buffer)!); case 183: - return TabState.decode(readValue(buffer)!); + return ExternalPackage.decode(readValue(buffer)!); case 184: - return RecoverableTab.decode(readValue(buffer)!); + return LoadUrlFlagsValue.decode(readValue(buffer)!); case 185: - return IconRequest.decode(readValue(buffer)!); + return SourceValue.decode(readValue(buffer)!); case 186: - return ResourceSize.decode(readValue(buffer)!); + return TabState.decode(readValue(buffer)!); case 187: - return Resource.decode(readValue(buffer)!); + return RecoverableTab.decode(readValue(buffer)!); case 188: - return IconResult.decode(readValue(buffer)!); + return IconRequest.decode(readValue(buffer)!); case 189: - return CookiePartitionKey.decode(readValue(buffer)!); + return ResourceSize.decode(readValue(buffer)!); case 190: - return Cookie.decode(readValue(buffer)!); + return Resource.decode(readValue(buffer)!); case 191: - return VisitInfo.decode(readValue(buffer)!); + return IconResult.decode(readValue(buffer)!); case 192: - return HistoryHighlightWeights.decode(readValue(buffer)!); + return CookiePartitionKey.decode(readValue(buffer)!); case 193: - return HistoryHighlight.decode(readValue(buffer)!); + return Cookie.decode(readValue(buffer)!); case 194: - return TopFrecentSiteInfo.decode(readValue(buffer)!); + return VisitInfo.decode(readValue(buffer)!); case 195: - return HistoryMetadata.decode(readValue(buffer)!); + return HistoryHighlightWeights.decode(readValue(buffer)!); case 196: - return HistorySuggestion.decode(readValue(buffer)!); + return HistoryHighlight.decode(readValue(buffer)!); case 197: - return PageObservation.decode(readValue(buffer)!); + return TopFrecentSiteInfo.decode(readValue(buffer)!); case 198: - return HistoryItem.decode(readValue(buffer)!); + return HistoryMetadata.decode(readValue(buffer)!); case 199: - return HistoryState.decode(readValue(buffer)!); + return HistorySuggestion.decode(readValue(buffer)!); case 200: - return ReaderableState.decode(readValue(buffer)!); + return PageObservation.decode(readValue(buffer)!); case 201: - return SecurityInfoState.decode(readValue(buffer)!); + return HistoryItem.decode(readValue(buffer)!); case 202: - return TabContentState.decode(readValue(buffer)!); + return HistoryState.decode(readValue(buffer)!); case 203: - return FindResultState.decode(readValue(buffer)!); + return ReaderableState.decode(readValue(buffer)!); case 204: - return CustomSelectionAction.decode(readValue(buffer)!); + return SecurityInfoState.decode(readValue(buffer)!); case 205: - return WebExtensionData.decode(readValue(buffer)!); + return TabContentState.decode(readValue(buffer)!); case 206: - return AddonInfo.decode(readValue(buffer)!); + return FindResultState.decode(readValue(buffer)!); case 207: - return AddonListingPreview.decode(readValue(buffer)!); + return CustomSelectionAction.decode(readValue(buffer)!); case 208: - return AddonListing.decode(readValue(buffer)!); + return WebExtensionData.decode(readValue(buffer)!); case 209: - return AddonStoreInfo.decode(readValue(buffer)!); + return AddonInfo.decode(readValue(buffer)!); case 210: - return AddonUpdateAttemptInfo.decode(readValue(buffer)!); + return AddonListingPreview.decode(readValue(buffer)!); case 211: - return GeckoSuggestion.decode(readValue(buffer)!); + return AddonListing.decode(readValue(buffer)!); case 212: - return TabContent.decode(readValue(buffer)!); + return AddonStoreInfo.decode(readValue(buffer)!); case 213: - return ContentBlocking.decode(readValue(buffer)!); + return AddonUpdateAttemptInfo.decode(readValue(buffer)!); case 214: - return DohSettings.decode(readValue(buffer)!); + return GeckoSuggestion.decode(readValue(buffer)!); case 215: - return GeckoEngineSettings.decode(readValue(buffer)!); + return TabContent.decode(readValue(buffer)!); case 216: - return AutocompleteResult.decode(readValue(buffer)!); + return ContentBlocking.decode(readValue(buffer)!); case 217: - return UnknownHitResult.decode(readValue(buffer)!); + return DohSettings.decode(readValue(buffer)!); case 218: - return ImageHitResult.decode(readValue(buffer)!); + return GeckoEngineSettings.decode(readValue(buffer)!); case 219: - return VideoHitResult.decode(readValue(buffer)!); + return AutocompleteResult.decode(readValue(buffer)!); case 220: - return AudioHitResult.decode(readValue(buffer)!); + return UnknownHitResult.decode(readValue(buffer)!); case 221: - return ImageSrcHitResult.decode(readValue(buffer)!); + return ImageHitResult.decode(readValue(buffer)!); case 222: - return PhoneHitResult.decode(readValue(buffer)!); + return VideoHitResult.decode(readValue(buffer)!); case 223: - return EmailHitResult.decode(readValue(buffer)!); + return AudioHitResult.decode(readValue(buffer)!); case 224: - return GeoHitResult.decode(readValue(buffer)!); + return ImageSrcHitResult.decode(readValue(buffer)!); case 225: - return DownloadState.decode(readValue(buffer)!); + return PhoneHitResult.decode(readValue(buffer)!); case 226: - return ShareInternetResourceState.decode(readValue(buffer)!); + return EmailHitResult.decode(readValue(buffer)!); case 227: - return AddonCollection.decode(readValue(buffer)!); + return GeoHitResult.decode(readValue(buffer)!); case 228: - return SyncEngineStatus.decode(readValue(buffer)!); + return DownloadState.decode(readValue(buffer)!); case 229: - return SyncAccountInfo.decode(readValue(buffer)!); + return ShareInternetResourceState.decode(readValue(buffer)!); case 230: - return SyncDevice.decode(readValue(buffer)!); + return AddonCollection.decode(readValue(buffer)!); case 231: - return SyncIncomingTab.decode(readValue(buffer)!); + return SyncEngineStatus.decode(readValue(buffer)!); case 232: - return SyncRemoteTab.decode(readValue(buffer)!); + return SyncAccountInfo.decode(readValue(buffer)!); case 233: - return SyncDeviceTabs.decode(readValue(buffer)!); + return SyncDevice.decode(readValue(buffer)!); case 234: - return GeckoPref.decode(readValue(buffer)!); + return SyncIncomingTab.decode(readValue(buffer)!); case 235: - return MlProgressData.decode(readValue(buffer)!); + return SyncRemoteTab.decode(readValue(buffer)!); case 236: - return GeckoProxySettings.decode(readValue(buffer)!); + return SyncDeviceTabs.decode(readValue(buffer)!); case 237: - return ContainerSiteAssignment.decode(readValue(buffer)!); + return GeckoPref.decode(readValue(buffer)!); case 238: - return ProxyLoadError.decode(readValue(buffer)!); + return MlProgressData.decode(readValue(buffer)!); case 239: - return GeckoHeader.decode(readValue(buffer)!); + return GeckoProxySettings.decode(readValue(buffer)!); case 240: - return GeckoFetchRequest.decode(readValue(buffer)!); + return ContainerSiteAssignment.decode(readValue(buffer)!); case 241: - return GeckoFetchResponse.decode(readValue(buffer)!); + return ProxyLoadError.decode(readValue(buffer)!); case 242: - return BookmarkNode.decode(readValue(buffer)!); + return GeckoHeader.decode(readValue(buffer)!); case 243: - return BookmarkInfo.decode(readValue(buffer)!); + return GeckoFetchRequest.decode(readValue(buffer)!); case 244: - return SitePermissions.decode(readValue(buffer)!); + return GeckoFetchResponse.decode(readValue(buffer)!); case 245: - return TrackingProtectionException.decode(readValue(buffer)!); + return BookmarkNode.decode(readValue(buffer)!); case 246: - return PwaIcon.decode(readValue(buffer)!); + return BookmarkInfo.decode(readValue(buffer)!); case 247: - return ShareTargetFiles.decode(readValue(buffer)!); + return SitePermissions.decode(readValue(buffer)!); case 248: - return ShareTargetParams.decode(readValue(buffer)!); + return TrackingProtectionException.decode(readValue(buffer)!); case 249: - return ShareTarget.decode(readValue(buffer)!); + return AppLinkTarget.decode(readValue(buffer)!); case 250: - return ExternalApplicationResource.decode(readValue(buffer)!); + return ProtectedTargetPattern.decode(readValue(buffer)!); case 251: - return PwaManifest.decode(readValue(buffer)!); + return NativeAppLinkRule.decode(readValue(buffer)!); case 252: - return SandboxCaptureEntry.decode(readValue(buffer)!); + return NativeContextAppLinkPolicy.decode(readValue(buffer)!); case 253: - return GestureConfig.decode(readValue(buffer)!); + return AppLinkPolicySnapshot.decode(readValue(buffer)!); case 254: - return PushDistributor.decode(readValue(buffer)!); + return AppLinkPromptRequest.decode(readValue(buffer)!); case 255: final _PigeonCodecOverflow wrapper = _PigeonCodecOverflow.decode(readValue(buffer)!); return wrapper.unwrap(); @@ -7950,45 +8544,6 @@ class GeckoEngineSettingsApi { ; } - /// Sets the app links mode preference (stored in SharedPreferences). - /// Controls how external app links are handled in the browser. - Future setAppLinksMode(AppLinksMode mode) async { - final pigeonVar_channelName = 'dev.flutter.pigeon.flutter_mozilla_components.GeckoEngineSettingsApi.setAppLinksMode$pigeonVar_messageChannelSuffix'; - final pigeonVar_channel = BasicMessageChannel( - pigeonVar_channelName, - pigeonChannelCodec, - binaryMessenger: pigeonVar_binaryMessenger, - ); - final Future pigeonVar_sendFuture = pigeonVar_channel.send([mode]); - final pigeonVar_replyList = await pigeonVar_sendFuture as List?; - - _extractReplyValueOrThrow( - pigeonVar_replyList, - pigeonVar_channelName, - isNullValid: true, - ) - ; - } - - Future getAppLinksMode() async { - final pigeonVar_channelName = 'dev.flutter.pigeon.flutter_mozilla_components.GeckoEngineSettingsApi.getAppLinksMode$pigeonVar_messageChannelSuffix'; - final pigeonVar_channel = BasicMessageChannel( - pigeonVar_channelName, - pigeonChannelCodec, - binaryMessenger: pigeonVar_binaryMessenger, - ); - final Future pigeonVar_sendFuture = pigeonVar_channel.send(null); - final pigeonVar_replyList = await pigeonVar_sendFuture as List?; - - final Object? pigeonVar_replyValue = _extractReplyValueOrThrow( - pigeonVar_replyList, - pigeonVar_channelName, - isNullValid: false, - ) - ; - return pigeonVar_replyValue! as AppLinksMode; - } - /// Sets whether to use external download managers for downloads. /// When enabled, downloads are forwarded to third-party apps like ADM, 1DM, AB DM. Future setUseExternalDownloadManager(bool enabled) async { @@ -12246,8 +12801,9 @@ class GeckoTrackingProtectionApi { /// API for detecting and launching external applications that can handle URLs. /// -/// This API wraps Mozilla Android Components' AppLinksUseCases to allow Flutter -/// code to check if native apps can handle URLs and launch them directly. +/// WebLibre-owned resolution/launch surface (replaces the Mozilla AC use-case +/// wrappers). Policy lives in Dart; this surface owns PackageManager resolution +/// and Intent launch. class GeckoAppLinksApi { /// Constructor for [GeckoAppLinksApi]. The [binaryMessenger] named argument is /// available for dependency injection. If it is left null, the default @@ -12261,14 +12817,104 @@ class GeckoAppLinksApi { final String pigeonVar_messageChannelSuffix; - /// 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. + Future setAppLinkPolicy(AppLinkPolicySnapshot snapshot) async { + final pigeonVar_channelName = 'dev.flutter.pigeon.flutter_mozilla_components.GeckoAppLinksApi.setAppLinkPolicy$pigeonVar_messageChannelSuffix'; + final pigeonVar_channel = BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, + ); + final Future pigeonVar_sendFuture = pigeonVar_channel.send([snapshot]); + final pigeonVar_replyList = await pigeonVar_sendFuture as List?; + + _extractReplyValueOrThrow( + pigeonVar_replyList, + pigeonVar_channelName, + isNullValid: true, + ) + ; + } + + /// Non-consuming query of pending prompts for [owner] (§2.6). Surfaces call + /// this on attach/resume/rotation and when the availability event fires, and + /// render idempotently by requestId. + Future> getPendingAppLinkPrompts(AppLinkPromptOwner owner) async { + final pigeonVar_channelName = 'dev.flutter.pigeon.flutter_mozilla_components.GeckoAppLinksApi.getPendingAppLinkPrompts$pigeonVar_messageChannelSuffix'; + final pigeonVar_channel = BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, + ); + final Future pigeonVar_sendFuture = pigeonVar_channel.send([owner]); + final pigeonVar_replyList = await pigeonVar_sendFuture as List?; + + final Object? pigeonVar_replyValue = _extractReplyValueOrThrow( + pigeonVar_replyList, + pigeonVar_channelName, + isNullValid: false, + ) + ; + return (pigeonVar_replyValue! as List).cast(); + } + + /// 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). + Future resolvePendingAppLink(int requestId, AppLinkDecision decision) async { + final pigeonVar_channelName = 'dev.flutter.pigeon.flutter_mozilla_components.GeckoAppLinksApi.resolvePendingAppLink$pigeonVar_messageChannelSuffix'; + final pigeonVar_channel = BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, + ); + final Future pigeonVar_sendFuture = pigeonVar_channel.send([requestId, decision]); + final pigeonVar_replyList = await pigeonVar_sendFuture as List?; + + final Object? pigeonVar_replyValue = _extractReplyValueOrThrow( + pigeonVar_replyList, + pigeonVar_channelName, + isNullValid: false, + ) + ; + return pigeonVar_replyValue! as AppLinkResolutionResult; + } + + /// Resolve [url] to an external-app target. /// - /// 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 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. /// - /// Returns true if an external app is available, false otherwise. - Future hasExternalApp(String url) async { - final pigeonVar_channelName = 'dev.flutter.pigeon.flutter_mozilla_components.GeckoAppLinksApi.hasExternalApp$pigeonVar_messageChannelSuffix'; + /// [includeHttpAppLinks] when true, an app resolving an engine-supported + /// (http(s)) URL is surfaced (e.g. the YouTube app for a youtube.com link). + Future resolveAppLink(String url, bool includeHttpAppLinks) async { + final pigeonVar_channelName = 'dev.flutter.pigeon.flutter_mozilla_components.GeckoAppLinksApi.resolveAppLink$pigeonVar_messageChannelSuffix'; + final pigeonVar_channel = BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, + ); + final Future pigeonVar_sendFuture = pigeonVar_channel.send([url, includeHttpAppLinks]); + final pigeonVar_replyList = await pigeonVar_sendFuture as List?; + + final Object? pigeonVar_replyValue = _extractReplyValueOrThrow( + pigeonVar_replyList, + pigeonVar_channelName, + isNullValid: true, + ) + ; + return pigeonVar_replyValue as AppLinkTarget?; + } + + /// 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. + Future launchAppLink(String url) async { + final pigeonVar_channelName = 'dev.flutter.pigeon.flutter_mozilla_components.GeckoAppLinksApi.launchAppLink$pigeonVar_messageChannelSuffix'; final pigeonVar_channel = BasicMessageChannel( pigeonVar_channelName, pigeonChannelCodec, @@ -12285,32 +12931,42 @@ class GeckoAppLinksApi { ; return pigeonVar_replyValue! as bool; } +} - /// Opens the URL in an external application if available. - /// - /// This method will: - /// 1. Check if an external app can handle the URL - /// 2. If available, launch the app directly with Intent.FLAG_ACTIVITY_NEW_TASK - /// 3. Return true if successfully launched, false otherwise - /// - /// Returns true if URL was opened in external app, false if no app available. - Future openAppLink(String url) async { - final pigeonVar_channelName = 'dev.flutter.pigeon.flutter_mozilla_components.GeckoAppLinksApi.openAppLink$pigeonVar_messageChannelSuffix'; - final pigeonVar_channel = BasicMessageChannel( - pigeonVar_channelName, - pigeonChannelCodec, - binaryMessenger: pigeonVar_binaryMessenger, - ); - final Future pigeonVar_sendFuture = pigeonVar_channel.send([url]); - final pigeonVar_replyList = await pigeonVar_sendFuture as List?; +/// 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. +abstract class GeckoAppLinkEvents { + static const MessageCodec pigeonChannelCodec = _PigeonCodec(); - final Object? pigeonVar_replyValue = _extractReplyValueOrThrow( - pigeonVar_replyList, - pigeonVar_channelName, - isNullValid: false, - ) - ; - return pigeonVar_replyValue! as bool; + void onAppLinkPromptAvailable(int sequence, AppLinkPromptOwner owner); + + static void setUp(GeckoAppLinkEvents? api, {BinaryMessenger? binaryMessenger, String messageChannelSuffix = '',}) { + messageChannelSuffix = messageChannelSuffix.isNotEmpty ? '.$messageChannelSuffix' : ''; + { + final pigeonVar_channel = BasicMessageChannel( + 'dev.flutter.pigeon.flutter_mozilla_components.GeckoAppLinkEvents.onAppLinkPromptAvailable$messageChannelSuffix', pigeonChannelCodec, + binaryMessenger: binaryMessenger); + if (api == null) { + pigeonVar_channel.setMessageHandler(null); + } else { + pigeonVar_channel.setMessageHandler((Object? message) async { + final List args = message! as List; + final int arg_sequence = args[0]! as int; + final AppLinkPromptOwner arg_owner = args[1]! as AppLinkPromptOwner; + try { + api.onAppLinkPromptAvailable(arg_sequence, arg_owner); + return wrapResponse(empty: true); + } on PlatformException catch (e) { + return wrapResponse(error: e); + } catch (e) { + return wrapResponse(error: PlatformException(code: 'error', message: e.toString())); + } + }); + } + } } } diff --git a/packages/flutter_mozilla_components/pigeons/gecko.dart b/packages/flutter_mozilla_components/pigeons/gecko.dart index 504d0481..d0a8ed69 100644 --- a/packages/flutter_mozilla_components/pigeons/gecko.dart +++ b/packages/flutter_mozilla_components/pigeons/gecko.dart @@ -1557,12 +1557,6 @@ abstract class GeckoEngineSettingsApi { void setScreenshotProtectionEnabled(bool enabled); void setPullToRefreshEnabled(bool enabled); - /// Sets the app links mode preference (stored in SharedPreferences). - /// Controls how external app links are handled in the browser. - void setAppLinksMode(AppLinksMode mode); - - AppLinksMode getAppLinksMode(); - /// Sets whether to use external download managers for downloads. /// When enabled, downloads are forwarded to third-party apps like ADM, 1DM, AB DM. void setUseExternalDownloadManager(bool enabled); @@ -2800,31 +2794,239 @@ abstract class GeckoTrackingProtectionApi { // App Links API // ============================================================================= +/// Resolved external-app target for a URL (see APP_LINKS_OWN_IMPLEMENTATION_PLAN.md §2.8). +class AppLinkTarget { + /// The URL that was resolved. + final String url; + + /// User-facing app label (control/bidi-sanitised), or null when unknown. + final String? appName; + + /// Resolved package name, or null when ambiguous / unknown. + final String? packageName; + + /// Pre-validated http(s) fallback URL, or null. + final String? fallbackUrl; + + /// True when the only offer is a marketplace (install-app) intent. + final bool isMarketplace; + + /// True when resolution is ambiguous (chooser / multiple handlers / no default). + final bool isAmbiguous; + + /// True when the Gecko engine can load the URL scheme itself. + final bool engineSupportsScheme; + + /// Canonical native-owned rule scope key ("host:youtube.com" | "pkg:..."). + final String scopeKey; + + const AppLinkTarget({ + required this.url, + this.appName, + this.packageName, + this.fallbackUrl, + required this.isMarketplace, + required this.isAmbiguous, + required this.engineSupportsScheme, + required this.scopeKey, + }); +} + +/// Target-side protection pattern replicated to native (§2.3/§2.8). Any target +/// assigned to an effectively-proxied or strict container is protected +/// independent of the source tab. +class ProtectedTargetPattern { + final String scheme; + final String hostOrSuffix; + final bool includeSubdomains; + + /// Effective port for exact entries; null for wildcard entries (ignore port). + final int? port; + + const ProtectedTargetPattern({ + required this.scheme, + required this.hostOrSuffix, + required this.includeSubdomains, + this.port, + }); +} + +enum NativeAppLinkRuleDecision { alwaysOpen, neverOpen } + +/// A remembered per-scope rule replicated to native (§2.8). Distinct from the +/// Dart-persisted `PersistedAppLinkRule`; explicit mappers bridge the two. +class NativeAppLinkRule { + final NativeAppLinkRuleDecision decision; + final String scope; + final String? packageName; + + const NativeAppLinkRule({ + required this.decision, + required this.scope, + this.packageName, + }); +} + +/// A container's self-contained app-link policy override (§ container isolation). +/// Present only for containers with "isolated app link settings" enabled; when a +/// navigation's source contextId has an entry here, it fully *replaces* the +/// global mode + rules for that navigation (no layering with the global policy). +class NativeContextAppLinkPolicy { + final AppLinksMode mode; + + /// The container's own remembered rules keyed by canonical scope. + final Map 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 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 protectedContextIds; + + /// strictMode containers, independent of routing. + final List strictContextIds; + + final List 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 contextOverrides; + + const AppLinkPolicySnapshot({ + required this.globalMode, + required this.rules, + required this.marketplaceFallbackEnabled, + required this.protectGeneralContext, + required this.protectedContextIds, + required this.strictContextIds, + required this.protectedTargetPatterns, + required this.contextOverrides, + }); +} + +/// Which surface owns a pending prompt (§2.6). Fixed at creation, never transfers. +enum AppLinkPromptOwner { flutterBrowser, nativeExternal } + +/// A pending app-link prompt request held in the native `PendingAppLinkStore` +/// until resolved, invalidated, or expired (§2.6/§2.8). Holds only stable +/// identifiers and sanitised data — never engine/store references. +class AppLinkPromptRequest { + /// Monotonic per-process id (Kotlin Long). + final int requestId; + final AppLinkPromptOwner owner; + final String tabId; + final String? contextId; + final String? sourceUrl; + final bool isPrivate; + final bool isWallet; + final bool isProtectedContext; + final bool canRemember; + + /// false for the http(s) banner class (non-modal); true for the modal + /// unsupported-scheme prompt. + final bool isModal; + final AppLinkTarget target; + + const AppLinkPromptRequest({ + required this.requestId, + required this.owner, + required this.tabId, + this.contextId, + this.sourceUrl, + required this.isPrivate, + required this.isWallet, + required this.isProtectedContext, + required this.canRemember, + required this.isModal, + required this.target, + }); +} + +/// User decision on a pending prompt (§2.6). +enum AppLinkDecision { open, cancel, dismiss } + +/// Result of resolving a pending prompt (§2.8). +class AppLinkResolutionResult { + final bool launched; + final bool loadedFallback; + + /// "stale" | "dead_session" | "launch_failed" | null. + final String? failureReason; + + const AppLinkResolutionResult({ + required this.launched, + required this.loadedFallback, + this.failureReason, + }); +} + /// API for detecting and launching external applications that can handle URLs. /// -/// This API wraps Mozilla Android Components' AppLinksUseCases to allow Flutter -/// code to check if native apps can handle URLs and launch them directly. +/// WebLibre-owned resolution/launch surface (replaces the Mozilla AC use-case +/// wrappers). Policy lives in Dart; this surface owns PackageManager resolution +/// and Intent launch. @HostApi() abstract class GeckoAppLinksApi { - /// Checks if an external application is available to handle the given URL. - /// - /// This method uses mozilla-components AppLinksUseCases to determine if - /// a native app can handle the URL (e.g., YouTube app for youtube.com links). - /// - /// Returns true if an external app is available, false otherwise. + /// Push the complete policy snapshot to native (last-write-wins). Native + /// persists it durably to the active profile's prefs record before acking. @async - bool hasExternalApp(String url); + void setAppLinkPolicy(AppLinkPolicySnapshot snapshot); - /// Opens the URL in an external application if available. - /// - /// This method will: - /// 1. Check if an external app can handle the URL - /// 2. If available, launch the app directly with Intent.FLAG_ACTIVITY_NEW_TASK - /// 3. Return true if successfully launched, false otherwise - /// - /// Returns true if URL was opened in external app, false if no app available. + /// Non-consuming query of pending prompts for [owner] (§2.6). Surfaces call + /// this on attach/resume/rotation and when the availability event fires, and + /// render idempotently by requestId. @async - bool openAppLink(String url); + List 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); } // ============================================================================= diff --git a/packages/flutter_singbox_proxy/android/src/main/kotlin/eu/weblibre/flutter_singbox_proxy/generated/SingboxProxyApi.g.kt b/packages/flutter_singbox_proxy/android/src/main/kotlin/eu/weblibre/flutter_singbox_proxy/generated/SingboxProxyApi.g.kt index 6b4c6e30..30d3d672 100644 --- a/packages/flutter_singbox_proxy/android/src/main/kotlin/eu/weblibre/flutter_singbox_proxy/generated/SingboxProxyApi.g.kt +++ b/packages/flutter_singbox_proxy/android/src/main/kotlin/eu/weblibre/flutter_singbox_proxy/generated/SingboxProxyApi.g.kt @@ -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 @file:Suppress("UNCHECKED_CAST", "ArrayInDataClass") @@ -289,6 +289,9 @@ data class SingboxProxyProfile ( result = 31 * result + SingboxProxyApiPigeonUtils.deepHash(this.secretJson) 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. */ @@ -352,6 +355,9 @@ data class SingboxProxyRuntimeOptions ( result = 31 * result + SingboxProxyApiPigeonUtils.deepHash(this.bootstrapDohUrl) 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. */ @@ -440,6 +446,9 @@ data class SingboxProxyDnsServerConfig ( result = 31 * result + SingboxProxyApiPigeonUtils.deepHash(this.matchInbounds) 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. */ @@ -487,6 +496,9 @@ data class SingboxProxyDnsConfig ( result = 31 * result + SingboxProxyApiPigeonUtils.deepHash(this.domainStrategy) return result } + override fun toString(): String { + return "SingboxProxyDnsConfig(servers=$servers, finalServerTag=$finalServerTag, domainStrategy=$domainStrategy)" + } } /** Generated class from Pigeon that represents data sent in messages. */ @@ -537,6 +549,9 @@ data class SingboxProxyRuntimeEndpoint ( result = 31 * result + SingboxProxyApiPigeonUtils.deepHash(this.password) 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. */ @@ -579,6 +594,9 @@ data class SingboxProxyRuntimeState ( result = 31 * result + SingboxProxyApiPigeonUtils.deepHash(this.message) return result } + override fun toString(): String { + return "SingboxProxyRuntimeState(status=$status, endpoints=$endpoints, message=$message)" + } } /** Generated class from Pigeon that represents data sent in messages. */ @@ -617,6 +635,9 @@ data class SingboxProxyConfigResult ( result = 31 * result + SingboxProxyApiPigeonUtils.deepHash(this.endpoints) return result } + override fun toString(): String { + return "SingboxProxyConfigResult(configJson=$configJson, endpoints=$endpoints)" + } } /** Generated class from Pigeon that represents data sent in messages. */ @@ -663,6 +684,9 @@ data class SingboxProxyLogMessage ( result = 31 * result + SingboxProxyApiPigeonUtils.deepHash(this.profileId) return result } + override fun toString(): String { + return "SingboxProxyLogMessage(level=$level, message=$message, timestamp=$timestamp, profileId=$profileId)" + } } private open class SingboxProxyApiPigeonCodec : StandardMessageCodec() { override fun readValueOfType(type: Byte, buffer: ByteBuffer): Any? { diff --git a/packages/flutter_singbox_proxy/lib/src/singbox_proxy_api.g.dart b/packages/flutter_singbox_proxy/lib/src/singbox_proxy_api.g.dart index d42618d8..9627c08e 100644 --- a/packages/flutter_singbox_proxy/lib/src/singbox_proxy_api.g.dart +++ b/packages/flutter_singbox_proxy/lib/src/singbox_proxy_api.g.dart @@ -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 // ignore_for_file: unused_import, unused_shown_name // ignore_for_file: type=lint @@ -195,6 +195,11 @@ class SingboxProxyProfile { @override // ignore: avoid_equals_and_hash_code_on_mutable_classes int get hashCode => _deepHash([runtimeType, ..._toList()]); + + @override + String toString() { + return 'SingboxProxyProfile(id: $id, name: $name, type: $type, configJson: $configJson, secretJson: $secretJson)'; + } } class SingboxProxyRuntimeOptions { @@ -261,6 +266,11 @@ class SingboxProxyRuntimeOptions { @override // ignore: avoid_equals_and_hash_code_on_mutable_classes int get hashCode => _deepHash([runtimeType, ..._toList()]); + + @override + String toString() { + return 'SingboxProxyRuntimeOptions(preferredBasePort: $preferredBasePort, blockUnmatchedTraffic: $blockUnmatchedTraffic, dnsConfig: $dnsConfig, bootstrapDohUrl: $bootstrapDohUrl)'; + } } class SingboxProxyDnsServerConfig { @@ -351,6 +361,11 @@ class SingboxProxyDnsServerConfig { @override // ignore: avoid_equals_and_hash_code_on_mutable_classes int get hashCode => _deepHash([runtimeType, ..._toList()]); + + @override + String toString() { + return 'SingboxProxyDnsServerConfig(tag: $tag, address: $address, detourTag: $detourTag, matchDomainSuffixes: $matchDomainSuffixes, matchGeosites: $matchGeosites, matchOutbounds: $matchOutbounds, matchInbounds: $matchInbounds)'; + } } class SingboxProxyDnsConfig { @@ -404,6 +419,11 @@ class SingboxProxyDnsConfig { @override // ignore: avoid_equals_and_hash_code_on_mutable_classes int get hashCode => _deepHash([runtimeType, ..._toList()]); + + @override + String toString() { + return 'SingboxProxyDnsConfig(servers: $servers, finalServerTag: $finalServerTag, domainStrategy: $domainStrategy)'; + } } class SingboxProxyRuntimeEndpoint { @@ -464,6 +484,11 @@ class SingboxProxyRuntimeEndpoint { @override // ignore: avoid_equals_and_hash_code_on_mutable_classes int get hashCode => _deepHash([runtimeType, ..._toList()]); + + @override + String toString() { + return 'SingboxProxyRuntimeEndpoint(profileId: $profileId, host: $host, port: $port, username: $username, password: $password)'; + } } class SingboxProxyRuntimeState { @@ -514,6 +539,11 @@ class SingboxProxyRuntimeState { @override // ignore: avoid_equals_and_hash_code_on_mutable_classes int get hashCode => _deepHash([runtimeType, ..._toList()]); + + @override + String toString() { + return 'SingboxProxyRuntimeState(status: $status, endpoints: $endpoints, message: $message)'; + } } class SingboxProxyConfigResult { @@ -559,6 +589,11 @@ class SingboxProxyConfigResult { @override // ignore: avoid_equals_and_hash_code_on_mutable_classes int get hashCode => _deepHash([runtimeType, ..._toList()]); + + @override + String toString() { + return 'SingboxProxyConfigResult(configJson: $configJson, endpoints: $endpoints)'; + } } class SingboxProxyLogMessage { @@ -614,6 +649,11 @@ class SingboxProxyLogMessage { @override // ignore: avoid_equals_and_hash_code_on_mutable_classes int get hashCode => _deepHash([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 { - /// Constructor for [SingboxProxyApi]. The [binaryMessenger] named argument is - /// available for dependency injection. If it is left null, the default + /// Constructor for [SingboxProxyApi]. The [binaryMessenger] named argument is + /// available for dependency injection. If it is left null, the default /// BinaryMessenger will be used which routes to the host platform. SingboxProxyApi({BinaryMessenger? binaryMessenger, String messageChannelSuffix = ''}) : pigeonVar_binaryMessenger = binaryMessenger, diff --git a/packages/flutter_tor/android/src/main/kotlin/eu/weblibre/flutter_tor/generated/TorApi.g.kt b/packages/flutter_tor/android/src/main/kotlin/eu/weblibre/flutter_tor/generated/TorApi.g.kt index 218e6acd..b45af47c 100644 --- a/packages/flutter_tor/android/src/main/kotlin/eu/weblibre/flutter_tor/generated/TorApi.g.kt +++ b/packages/flutter_tor/android/src/main/kotlin/eu/weblibre/flutter_tor/generated/TorApi.g.kt @@ -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 @file:Suppress("UNCHECKED_CAST", "ArrayInDataClass") @@ -278,6 +278,9 @@ data class TorConfiguration ( result = 31 * result + TorApiPigeonUtils.deepHash(this.strictNodes) 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) 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) return result } + override fun toString(): String { + return "TorLogMessage(severity=$severity, message=$message, timestamp=$timestamp)" + } } private open class TorApiPigeonCodec : StandardMessageCodec() { override fun readValueOfType(type: Byte, buffer: ByteBuffer): Any? { diff --git a/packages/flutter_tor/lib/src/tor_api.g.dart b/packages/flutter_tor/lib/src/tor_api.g.dart index 23b1c6a1..52e7d160 100644 --- a/packages/flutter_tor/lib/src/tor_api.g.dart +++ b/packages/flutter_tor/lib/src/tor_api.g.dart @@ -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 // ignore_for_file: unused_import, unused_shown_name // ignore_for_file: type=lint @@ -191,6 +191,11 @@ class TorConfiguration { @override // ignore: avoid_equals_and_hash_code_on_mutable_classes int get hashCode => _deepHash([runtimeType, ..._toList()]); + + @override + String toString() { + return 'TorConfiguration(transport: $transport, bridgeLines: $bridgeLines, entryNodeCountries: $entryNodeCountries, exitNodeCountries: $exitNodeCountries, strictNodes: $strictNodes)'; + } } /// Current Tor status @@ -257,6 +262,11 @@ class TorStatus { @override // ignore: avoid_equals_and_hash_code_on_mutable_classes int get hashCode => _deepHash([runtimeType, ..._toList()]); + + @override + String toString() { + return 'TorStatus(isRunning: $isRunning, socksPort: $socksPort, bootstrapProgress: $bootstrapProgress, currentCircuit: $currentCircuit, exitNodeCountry: $exitNodeCountry)'; + } } /// Log message from Tor @@ -311,6 +321,11 @@ class TorLogMessage { @override // ignore: avoid_equals_and_hash_code_on_mutable_classes int get hashCode => _deepHash([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) class TorApi { - /// Constructor for [TorApi]. The [binaryMessenger] named argument is - /// available for dependency injection. If it is left null, the default + /// Constructor for [TorApi]. The [binaryMessenger] named argument is + /// available for dependency injection. If it is left null, the default /// BinaryMessenger will be used which routes to the host platform. TorApi({BinaryMessenger? binaryMessenger, String messageChannelSuffix = ''}) : pigeonVar_binaryMessenger = binaryMessenger, @@ -508,8 +523,8 @@ abstract class TorLogApi { } class IPtProxyController { - /// Constructor for [IPtProxyController]. The [binaryMessenger] named argument is - /// available for dependency injection. If it is left null, the default + /// Constructor for [IPtProxyController]. The [binaryMessenger] named argument is + /// available for dependency injection. If it is left null, the default /// BinaryMessenger will be used which routes to the host platform. IPtProxyController({BinaryMessenger? binaryMessenger, String messageChannelSuffix = ''}) : pigeonVar_binaryMessenger = binaryMessenger, diff --git a/packages/locale_resolver/android/src/main/kotlin/eu/weblibre/locale_resolver/pigeons/Locales.g.kt b/packages/locale_resolver/android/src/main/kotlin/eu/weblibre/locale_resolver/pigeons/Locales.g.kt index 7f539821..b3040ace 100644 --- a/packages/locale_resolver/android/src/main/kotlin/eu/weblibre/locale_resolver/pigeons/Locales.g.kt +++ b/packages/locale_resolver/android/src/main/kotlin/eu/weblibre/locale_resolver/pigeons/Locales.g.kt @@ -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 @file:Suppress("UNCHECKED_CAST", "ArrayInDataClass") @@ -228,6 +228,9 @@ data class LocalizedResult ( result = 31 * result + LocalesPigeonUtils.deepHash(this.countryName) return result } + override fun toString(): String { + return "LocalizedResult(languageName=$languageName, countryName=$countryName)" + } } private open class LocalesPigeonCodec : StandardMessageCodec() { override fun readValueOfType(type: Byte, buffer: ByteBuffer): Any? { diff --git a/packages/locale_resolver/lib/src/pigeons/locales.g.dart b/packages/locale_resolver/lib/src/pigeons/locales.g.dart index fc5aad04..018474b5 100644 --- a/packages/locale_resolver/lib/src/pigeons/locales.g.dart +++ b/packages/locale_resolver/lib/src/pigeons/locales.g.dart @@ -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 // ignore_for_file: unused_import, unused_shown_name // ignore_for_file: type=lint @@ -140,6 +140,11 @@ class LocalizedResult { @override // ignore: avoid_equals_and_hash_code_on_mutable_classes int get hashCode => _deepHash([runtimeType, ..._toList()]); + + @override + String toString() { + return 'LocalizedResult(languageName: $languageName, countryName: $countryName)'; + } } @@ -170,8 +175,8 @@ class _PigeonCodec extends StandardMessageCodec { } class LocaleResolver { - /// Constructor for [LocaleResolver]. The [binaryMessenger] named argument is - /// available for dependency injection. If it is left null, the default + /// Constructor for [LocaleResolver]. The [binaryMessenger] named argument is + /// available for dependency injection. If it is left null, the default /// BinaryMessenger will be used which routes to the host platform. LocaleResolver({BinaryMessenger? binaryMessenger, String messageChannelSuffix = ''}) : pigeonVar_binaryMessenger = binaryMessenger, diff --git a/packages/simple_intent_receiver/android/src/main/kotlin/eu/weblibre/simple_intent_receiver/pigeons/Intent.g.kt b/packages/simple_intent_receiver/android/src/main/kotlin/eu/weblibre/simple_intent_receiver/pigeons/Intent.g.kt index c48d2b3a..3cb07bd7 100644 --- a/packages/simple_intent_receiver/android/src/main/kotlin/eu/weblibre/simple_intent_receiver/pigeons/Intent.g.kt +++ b/packages/simple_intent_receiver/android/src/main/kotlin/eu/weblibre/simple_intent_receiver/pigeons/Intent.g.kt @@ -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 @file:Suppress("UNCHECKED_CAST", "ArrayInDataClass") @@ -247,6 +247,9 @@ data class Intent ( result = 31 * result + IntentPigeonUtils.deepHash(this.extra) 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() { override fun readValueOfType(type: Byte, buffer: ByteBuffer): Any? { diff --git a/packages/simple_intent_receiver/lib/src/pigeons/intent.g.dart b/packages/simple_intent_receiver/lib/src/pigeons/intent.g.dart index 70a29fe6..2db86d8b 100644 --- a/packages/simple_intent_receiver/lib/src/pigeons/intent.g.dart +++ b/packages/simple_intent_receiver/lib/src/pigeons/intent.g.dart @@ -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 // ignore_for_file: unused_import, unused_shown_name // ignore_for_file: type=lint @@ -170,6 +170,11 @@ class Intent { @override // ignore: avoid_equals_and_hash_code_on_mutable_classes int get hashCode => _deepHash([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 { - /// Constructor for [IntentHost]. The [binaryMessenger] named argument is - /// available for dependency injection. If it is left null, the default + /// Constructor for [IntentHost]. The [binaryMessenger] named argument is + /// available for dependency injection. If it is left null, the default /// BinaryMessenger will be used which routes to the host platform. IntentHost({BinaryMessenger? binaryMessenger, String messageChannelSuffix = ''}) : pigeonVar_binaryMessenger = binaryMessenger, @@ -269,8 +274,8 @@ abstract class IntentEvents { } class IntentGatekeeperHostApi { - /// Constructor for [IntentGatekeeperHostApi]. The [binaryMessenger] named argument is - /// available for dependency injection. If it is left null, the default + /// Constructor for [IntentGatekeeperHostApi]. The [binaryMessenger] named argument is + /// available for dependency injection. If it is left null, the default /// BinaryMessenger will be used which routes to the host platform. IntentGatekeeperHostApi({BinaryMessenger? binaryMessenger, String messageChannelSuffix = ''}) : pigeonVar_binaryMessenger = binaryMessenger, diff --git a/packages/speech_to_text_dialog/android/src/main/kotlin/eu/weblibre/speech_to_text_dialog/pigeons/SpeechToText.g.kt b/packages/speech_to_text_dialog/android/src/main/kotlin/eu/weblibre/speech_to_text_dialog/pigeons/SpeechToText.g.kt index c775760a..f17e52d4 100644 --- a/packages/speech_to_text_dialog/android/src/main/kotlin/eu/weblibre/speech_to_text_dialog/pigeons/SpeechToText.g.kt +++ b/packages/speech_to_text_dialog/android/src/main/kotlin/eu/weblibre/speech_to_text_dialog/pigeons/SpeechToText.g.kt @@ -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 @file:Suppress("UNCHECKED_CAST", "ArrayInDataClass") diff --git a/packages/speech_to_text_dialog/lib/src/pigeons/speech_to_text.g.dart b/packages/speech_to_text_dialog/lib/src/pigeons/speech_to_text.g.dart index 0653b80f..d7308fba 100644 --- a/packages/speech_to_text_dialog/lib/src/pigeons/speech_to_text.g.dart +++ b/packages/speech_to_text_dialog/lib/src/pigeons/speech_to_text.g.dart @@ -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 // ignore_for_file: unused_import, unused_shown_name // ignore_for_file: type=lint @@ -69,8 +69,8 @@ class _PigeonCodec extends StandardMessageCodec { /// Host API - methods called from Flutter to native Android. class SpeechToTextApi { - /// Constructor for [SpeechToTextApi]. The [binaryMessenger] named argument is - /// available for dependency injection. If it is left null, the default + /// Constructor for [SpeechToTextApi]. The [binaryMessenger] named argument is + /// available for dependency injection. If it is left null, the default /// BinaryMessenger will be used which routes to the host platform. SpeechToTextApi({BinaryMessenger? binaryMessenger, String messageChannelSuffix = ''}) : pigeonVar_binaryMessenger = binaryMessenger,