app links initial
This commit is contained in:
@@ -0,0 +1,95 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2024-2026 Fabian Freund.
|
||||||
|
*
|
||||||
|
* This file is part of WebLibre
|
||||||
|
* (see https://weblibre.eu).
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU Affero General Public License as
|
||||||
|
* published by the Free Software Foundation, either version 3 of the
|
||||||
|
* License, or (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU Affero General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU Affero General Public License
|
||||||
|
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
import 'package:copy_with_extension/copy_with_extension.dart';
|
||||||
|
import 'package:fast_equatable/fast_equatable.dart';
|
||||||
|
import 'package:json_annotation/json_annotation.dart';
|
||||||
|
|
||||||
|
part 'app_link_rule.g.dart';
|
||||||
|
|
||||||
|
enum AppLinkRuleDecision { alwaysOpen, neverOpen }
|
||||||
|
|
||||||
|
/// A remembered per-scope app-link rule (persistence contract, §2.5/§2.9).
|
||||||
|
///
|
||||||
|
/// Stored in `GeneralSettings.appLinkRules` as `Map<String, PersistedAppLinkRule>`
|
||||||
|
/// keyed by [scope] — one canonical rule per scope, upsert/last-write-wins. The
|
||||||
|
/// [scope] is a native-owned canonical key (`host:youtube.com` | `pkg:...`) that
|
||||||
|
/// Dart persists opaquely and never reconstructs.
|
||||||
|
@CopyWith()
|
||||||
|
@JsonSerializable()
|
||||||
|
class PersistedAppLinkRule with FastEquatable {
|
||||||
|
final AppLinkRuleDecision decision;
|
||||||
|
|
||||||
|
/// Canonical scope key: `host:<host>` or `pkg:<package>`.
|
||||||
|
final String scope;
|
||||||
|
|
||||||
|
/// Resolved package name. Required for [AppLinkRuleDecision.alwaysOpen]
|
||||||
|
/// (binds the launch target); null for [AppLinkRuleDecision.neverOpen].
|
||||||
|
final String? packageName;
|
||||||
|
|
||||||
|
PersistedAppLinkRule({
|
||||||
|
required this.decision,
|
||||||
|
required this.scope,
|
||||||
|
this.packageName,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory PersistedAppLinkRule.fromJson(Map<String, dynamic> json) =>
|
||||||
|
_$PersistedAppLinkRuleFromJson(json);
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() => _$PersistedAppLinkRuleToJson(this);
|
||||||
|
|
||||||
|
/// Whether this rule is internally consistent: an `alwaysOpen` rule must bind
|
||||||
|
/// a package; the scope must be a recognised canonical key.
|
||||||
|
bool get isValid {
|
||||||
|
if (scope.isEmpty) return false;
|
||||||
|
final hasKnownPrefix =
|
||||||
|
scope.startsWith('host:') || scope.startsWith('pkg:');
|
||||||
|
if (!hasKnownPrefix) return false;
|
||||||
|
if (decision == AppLinkRuleDecision.alwaysOpen &&
|
||||||
|
(packageName == null || packageName!.isEmpty)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<Object?> get hashParameters => [decision, scope, packageName];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse the persisted rule map, dropping malformed rules (with a warning) and
|
||||||
|
/// entries whose map key disagrees with the rule's own scope (§2.9).
|
||||||
|
Map<String, PersistedAppLinkRule> parseAppLinkRules(
|
||||||
|
Map<String, dynamic>? json,
|
||||||
|
) {
|
||||||
|
if (json == null) return const {};
|
||||||
|
final result = <String, PersistedAppLinkRule>{};
|
||||||
|
for (final MapEntry(:key, :value) in json.entries) {
|
||||||
|
if (value is! Map<String, dynamic>) continue;
|
||||||
|
final PersistedAppLinkRule rule;
|
||||||
|
try {
|
||||||
|
rule = PersistedAppLinkRule.fromJson(value);
|
||||||
|
} catch (_) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (rule.scope != key) continue;
|
||||||
|
if (!rule.isValid) continue;
|
||||||
|
result[key] = rule;
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||||
|
|
||||||
|
part of 'app_link_rule.dart';
|
||||||
|
|
||||||
|
// **************************************************************************
|
||||||
|
// CopyWithGenerator
|
||||||
|
// **************************************************************************
|
||||||
|
|
||||||
|
abstract class _$PersistedAppLinkRuleCWProxy {
|
||||||
|
PersistedAppLinkRule decision(AppLinkRuleDecision decision);
|
||||||
|
|
||||||
|
PersistedAppLinkRule scope(String scope);
|
||||||
|
|
||||||
|
PersistedAppLinkRule packageName(String? packageName);
|
||||||
|
|
||||||
|
/// Creates a new instance with the provided field values.
|
||||||
|
/// Passing `null` to a nullable field nullifies it, while `null` for a non-nullable field is ignored. To update a single field use `PersistedAppLinkRule(...).copyWith.fieldName(value)`.
|
||||||
|
///
|
||||||
|
/// Example:
|
||||||
|
/// ```dart
|
||||||
|
/// PersistedAppLinkRule(...).copyWith(id: 12, name: "My name")
|
||||||
|
/// ```
|
||||||
|
PersistedAppLinkRule call({
|
||||||
|
AppLinkRuleDecision decision,
|
||||||
|
String scope,
|
||||||
|
String? packageName,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Callable proxy for `copyWith` functionality.
|
||||||
|
/// Use as `instanceOfPersistedAppLinkRule.copyWith(...)` or call `instanceOfPersistedAppLinkRule.copyWith.fieldName(value)` for a single field.
|
||||||
|
class _$PersistedAppLinkRuleCWProxyImpl
|
||||||
|
implements _$PersistedAppLinkRuleCWProxy {
|
||||||
|
const _$PersistedAppLinkRuleCWProxyImpl(this._value);
|
||||||
|
|
||||||
|
final PersistedAppLinkRule _value;
|
||||||
|
|
||||||
|
@override
|
||||||
|
PersistedAppLinkRule decision(AppLinkRuleDecision decision) =>
|
||||||
|
call(decision: decision);
|
||||||
|
|
||||||
|
@override
|
||||||
|
PersistedAppLinkRule scope(String scope) => call(scope: scope);
|
||||||
|
|
||||||
|
@override
|
||||||
|
PersistedAppLinkRule packageName(String? packageName) =>
|
||||||
|
call(packageName: packageName);
|
||||||
|
|
||||||
|
@override
|
||||||
|
/// Creates a new instance with the provided field values.
|
||||||
|
/// Passing `null` to a nullable field nullifies it, while `null` for a non-nullable field is ignored. To update a single field use `PersistedAppLinkRule(...).copyWith.fieldName(value)`.
|
||||||
|
///
|
||||||
|
/// Example:
|
||||||
|
/// ```dart
|
||||||
|
/// PersistedAppLinkRule(...).copyWith(id: 12, name: "My name")
|
||||||
|
/// ```
|
||||||
|
PersistedAppLinkRule call({
|
||||||
|
Object? decision = const $CopyWithPlaceholder(),
|
||||||
|
Object? scope = const $CopyWithPlaceholder(),
|
||||||
|
Object? packageName = const $CopyWithPlaceholder(),
|
||||||
|
}) {
|
||||||
|
return PersistedAppLinkRule(
|
||||||
|
decision: decision == const $CopyWithPlaceholder() || decision == null
|
||||||
|
? _value.decision
|
||||||
|
// ignore: cast_nullable_to_non_nullable
|
||||||
|
: decision as AppLinkRuleDecision,
|
||||||
|
scope: scope == const $CopyWithPlaceholder() || scope == null
|
||||||
|
? _value.scope
|
||||||
|
// ignore: cast_nullable_to_non_nullable
|
||||||
|
: scope as String,
|
||||||
|
packageName: packageName == const $CopyWithPlaceholder()
|
||||||
|
? _value.packageName
|
||||||
|
// ignore: cast_nullable_to_non_nullable
|
||||||
|
: packageName as String?,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension $PersistedAppLinkRuleCopyWith on PersistedAppLinkRule {
|
||||||
|
/// Returns a callable class used to build a new instance with modified fields.
|
||||||
|
/// Example: `instanceOfPersistedAppLinkRule.copyWith(...)` or `instanceOfPersistedAppLinkRule.copyWith.fieldName(...)`.
|
||||||
|
// ignore: library_private_types_in_public_api
|
||||||
|
_$PersistedAppLinkRuleCWProxy get copyWith =>
|
||||||
|
_$PersistedAppLinkRuleCWProxyImpl(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
// **************************************************************************
|
||||||
|
// JsonSerializableGenerator
|
||||||
|
// **************************************************************************
|
||||||
|
|
||||||
|
PersistedAppLinkRule _$PersistedAppLinkRuleFromJson(
|
||||||
|
Map<String, dynamic> json,
|
||||||
|
) => PersistedAppLinkRule(
|
||||||
|
decision: $enumDecode(_$AppLinkRuleDecisionEnumMap, json['decision']),
|
||||||
|
scope: json['scope'] as String,
|
||||||
|
packageName: json['packageName'] as String?,
|
||||||
|
);
|
||||||
|
|
||||||
|
Map<String, dynamic> _$PersistedAppLinkRuleToJson(
|
||||||
|
PersistedAppLinkRule instance,
|
||||||
|
) => <String, dynamic>{
|
||||||
|
'decision': _$AppLinkRuleDecisionEnumMap[instance.decision]!,
|
||||||
|
'scope': instance.scope,
|
||||||
|
'packageName': instance.packageName,
|
||||||
|
};
|
||||||
|
|
||||||
|
const _$AppLinkRuleDecisionEnumMap = {
|
||||||
|
AppLinkRuleDecision.alwaysOpen: 'alwaysOpen',
|
||||||
|
AppLinkRuleDecision.neverOpen: 'neverOpen',
|
||||||
|
};
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2024-2026 Fabian Freund.
|
||||||
|
*
|
||||||
|
* This file is part of WebLibre
|
||||||
|
* (see https://weblibre.eu).
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU Affero General Public License as
|
||||||
|
* published by the Free Software Foundation, either version 3 of the
|
||||||
|
* License, or (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU Affero General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU Affero General Public License
|
||||||
|
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
import 'package:copy_with_extension/copy_with_extension.dart';
|
||||||
|
import 'package:fast_equatable/fast_equatable.dart';
|
||||||
|
import 'package:flutter_mozilla_components/flutter_mozilla_components.dart'
|
||||||
|
show AppLinksMode;
|
||||||
|
import 'package:json_annotation/json_annotation.dart';
|
||||||
|
import 'package:weblibre/features/app_links/domain/entities/app_link_rule.dart';
|
||||||
|
|
||||||
|
part 'context_app_link_policy.g.dart';
|
||||||
|
|
||||||
|
/// A container's self-contained app-link policy, used when the container has
|
||||||
|
/// "isolated app link settings" enabled (replace semantics — it fully takes the
|
||||||
|
/// place of the global mode + rules for navigations in that container).
|
||||||
|
///
|
||||||
|
/// Stored in `GeneralSettings.appLinkContextOverrides` keyed by the container's
|
||||||
|
/// Gecko contextId (`contextualIdentity`). Only isolated containers have an
|
||||||
|
/// entry; the snapshot builder synthesises a blank-slate default for a freshly
|
||||||
|
/// isolated container that has not customised anything yet.
|
||||||
|
@CopyWith()
|
||||||
|
@JsonSerializable()
|
||||||
|
class ContextAppLinkPolicy with FastEquatable {
|
||||||
|
/// The container's own global app-links mode (default [AppLinksMode.ask]).
|
||||||
|
final AppLinksMode mode;
|
||||||
|
|
||||||
|
/// The container's own remembered per-scope rules, keyed by canonical scope
|
||||||
|
/// (`host:youtube.com` | `pkg:...`). Same shape/validation as the global
|
||||||
|
/// [GeneralSettings.appLinkRules]; malformed entries are dropped on read.
|
||||||
|
@JsonKey(fromJson: parseAppLinkRules)
|
||||||
|
final Map<String, PersistedAppLinkRule> rules;
|
||||||
|
|
||||||
|
ContextAppLinkPolicy({required this.mode, required this.rules});
|
||||||
|
|
||||||
|
/// The blank-slate policy a container starts from when it is first isolated.
|
||||||
|
ContextAppLinkPolicy.blank() : this(mode: AppLinksMode.ask, rules: const {});
|
||||||
|
|
||||||
|
factory ContextAppLinkPolicy.fromJson(Map<String, dynamic> json) =>
|
||||||
|
_$ContextAppLinkPolicyFromJson(json);
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() => _$ContextAppLinkPolicyToJson(this);
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<Object?> get hashParameters => [mode, rules];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse the persisted override map, dropping malformed entries (§2.9 style).
|
||||||
|
/// Keys are contextIds; the interceptor only ever consults entries whose
|
||||||
|
/// contextId belongs to a currently-isolated container, so an orphaned entry
|
||||||
|
/// (container deleted / isolation turned off) is inert.
|
||||||
|
Map<String, ContextAppLinkPolicy> parseAppLinkContextOverrides(
|
||||||
|
Map<String, dynamic>? json,
|
||||||
|
) {
|
||||||
|
if (json == null) return const {};
|
||||||
|
final result = <String, ContextAppLinkPolicy>{};
|
||||||
|
for (final MapEntry(:key, :value) in json.entries) {
|
||||||
|
if (value is! Map<String, dynamic>) continue;
|
||||||
|
try {
|
||||||
|
result[key] = ContextAppLinkPolicy.fromJson(value);
|
||||||
|
} catch (_) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||||
|
|
||||||
|
part of 'context_app_link_policy.dart';
|
||||||
|
|
||||||
|
// **************************************************************************
|
||||||
|
// CopyWithGenerator
|
||||||
|
// **************************************************************************
|
||||||
|
|
||||||
|
abstract class _$ContextAppLinkPolicyCWProxy {
|
||||||
|
ContextAppLinkPolicy mode(AppLinksMode mode);
|
||||||
|
|
||||||
|
ContextAppLinkPolicy rules(Map<String, PersistedAppLinkRule> rules);
|
||||||
|
|
||||||
|
/// Creates a new instance with the provided field values.
|
||||||
|
/// Passing `null` to a nullable field nullifies it, while `null` for a non-nullable field is ignored. To update a single field use `ContextAppLinkPolicy(...).copyWith.fieldName(value)`.
|
||||||
|
///
|
||||||
|
/// Example:
|
||||||
|
/// ```dart
|
||||||
|
/// ContextAppLinkPolicy(...).copyWith(id: 12, name: "My name")
|
||||||
|
/// ```
|
||||||
|
ContextAppLinkPolicy call({
|
||||||
|
AppLinksMode mode,
|
||||||
|
Map<String, PersistedAppLinkRule> rules,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Callable proxy for `copyWith` functionality.
|
||||||
|
/// Use as `instanceOfContextAppLinkPolicy.copyWith(...)` or call `instanceOfContextAppLinkPolicy.copyWith.fieldName(value)` for a single field.
|
||||||
|
class _$ContextAppLinkPolicyCWProxyImpl
|
||||||
|
implements _$ContextAppLinkPolicyCWProxy {
|
||||||
|
const _$ContextAppLinkPolicyCWProxyImpl(this._value);
|
||||||
|
|
||||||
|
final ContextAppLinkPolicy _value;
|
||||||
|
|
||||||
|
@override
|
||||||
|
ContextAppLinkPolicy mode(AppLinksMode mode) => call(mode: mode);
|
||||||
|
|
||||||
|
@override
|
||||||
|
ContextAppLinkPolicy rules(Map<String, PersistedAppLinkRule> rules) =>
|
||||||
|
call(rules: rules);
|
||||||
|
|
||||||
|
@override
|
||||||
|
/// Creates a new instance with the provided field values.
|
||||||
|
/// Passing `null` to a nullable field nullifies it, while `null` for a non-nullable field is ignored. To update a single field use `ContextAppLinkPolicy(...).copyWith.fieldName(value)`.
|
||||||
|
///
|
||||||
|
/// Example:
|
||||||
|
/// ```dart
|
||||||
|
/// ContextAppLinkPolicy(...).copyWith(id: 12, name: "My name")
|
||||||
|
/// ```
|
||||||
|
ContextAppLinkPolicy call({
|
||||||
|
Object? mode = const $CopyWithPlaceholder(),
|
||||||
|
Object? rules = const $CopyWithPlaceholder(),
|
||||||
|
}) {
|
||||||
|
return ContextAppLinkPolicy(
|
||||||
|
mode: mode == const $CopyWithPlaceholder() || mode == null
|
||||||
|
? _value.mode
|
||||||
|
// ignore: cast_nullable_to_non_nullable
|
||||||
|
: mode as AppLinksMode,
|
||||||
|
rules: rules == const $CopyWithPlaceholder() || rules == null
|
||||||
|
? _value.rules
|
||||||
|
// ignore: cast_nullable_to_non_nullable
|
||||||
|
: rules as Map<String, PersistedAppLinkRule>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension $ContextAppLinkPolicyCopyWith on ContextAppLinkPolicy {
|
||||||
|
/// Returns a callable class used to build a new instance with modified fields.
|
||||||
|
/// Example: `instanceOfContextAppLinkPolicy.copyWith(...)` or `instanceOfContextAppLinkPolicy.copyWith.fieldName(...)`.
|
||||||
|
// ignore: library_private_types_in_public_api
|
||||||
|
_$ContextAppLinkPolicyCWProxy get copyWith =>
|
||||||
|
_$ContextAppLinkPolicyCWProxyImpl(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
// **************************************************************************
|
||||||
|
// JsonSerializableGenerator
|
||||||
|
// **************************************************************************
|
||||||
|
|
||||||
|
ContextAppLinkPolicy _$ContextAppLinkPolicyFromJson(
|
||||||
|
Map<String, dynamic> json,
|
||||||
|
) => ContextAppLinkPolicy(
|
||||||
|
mode: $enumDecode(_$AppLinksModeEnumMap, json['mode']),
|
||||||
|
rules: parseAppLinkRules(json['rules'] as Map<String, dynamic>?),
|
||||||
|
);
|
||||||
|
|
||||||
|
Map<String, dynamic> _$ContextAppLinkPolicyToJson(
|
||||||
|
ContextAppLinkPolicy instance,
|
||||||
|
) => <String, dynamic>{
|
||||||
|
'mode': _$AppLinksModeEnumMap[instance.mode]!,
|
||||||
|
'rules': instance.rules.map((k, e) => MapEntry(k, e.toJson())),
|
||||||
|
};
|
||||||
|
|
||||||
|
const _$AppLinksModeEnumMap = {
|
||||||
|
AppLinksMode.always: 'always',
|
||||||
|
AppLinksMode.ask: 'ask',
|
||||||
|
AppLinksMode.never: 'never',
|
||||||
|
};
|
||||||
+273
@@ -0,0 +1,273 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2024-2026 Fabian Freund.
|
||||||
|
*
|
||||||
|
* This file is part of WebLibre
|
||||||
|
* (see https://weblibre.eu).
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU Affero General Public License as
|
||||||
|
* published by the Free Software Foundation, either version 3 of the
|
||||||
|
* License, or (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU Affero General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU Affero General Public License
|
||||||
|
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:flutter_mozilla_components/flutter_mozilla_components.dart'
|
||||||
|
hide ProtectedTargetPattern;
|
||||||
|
import 'package:flutter_mozilla_components/flutter_mozilla_components.dart'
|
||||||
|
as pigeon
|
||||||
|
show ProtectedTargetPattern;
|
||||||
|
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||||
|
import 'package:synchronized/synchronized.dart';
|
||||||
|
import 'package:weblibre/core/logger.dart';
|
||||||
|
import 'package:weblibre/features/app_links/domain/entities/app_link_rule.dart';
|
||||||
|
import 'package:weblibre/features/app_links/domain/entities/context_app_link_policy.dart';
|
||||||
|
import 'package:weblibre/features/app_links/domain/services/effective_routing.dart';
|
||||||
|
import 'package:weblibre/features/geckoview/features/tabs/domain/providers.dart';
|
||||||
|
import 'package:weblibre/features/user/data/models/general_settings.dart';
|
||||||
|
import 'package:weblibre/features/user/data/models/proxy_routing_settings.dart';
|
||||||
|
import 'package:weblibre/features/user/domain/repositories/general_settings.dart';
|
||||||
|
import 'package:weblibre/features/user/domain/repositories/proxy_routing_settings.dart';
|
||||||
|
|
||||||
|
part 'app_link_policy_replication.g.dart';
|
||||||
|
|
||||||
|
/// Effective app-link protection (§2.3), recomputed whenever routing, strict
|
||||||
|
/// mode, contextual identities, or site assignments change.
|
||||||
|
@Riverpod(keepAlive: true)
|
||||||
|
AppLinkProtection appLinkProtection(Ref ref) {
|
||||||
|
final routing = ref.watch(proxyRoutingSettingsWithDefaultsProvider);
|
||||||
|
final protectGeneralContext =
|
||||||
|
routing.regularTabsMode == ProxyRegularTabRoutingMode.all &&
|
||||||
|
routing.regularTabsProxyConnectionId != null;
|
||||||
|
|
||||||
|
final containers =
|
||||||
|
ref.watch(watchContainersWithCountProvider).value ?? const [];
|
||||||
|
final isolationMap =
|
||||||
|
ref.watch(watchIsolatedContextContainerMapProvider).value ?? const {};
|
||||||
|
final strict =
|
||||||
|
ref.watch(watchStrictContextAssignmentsProvider).value ?? const {};
|
||||||
|
final sites = ref.watch(watchAllAssignedSitesProvider).value ?? const [];
|
||||||
|
|
||||||
|
return computeAppLinkProtection(
|
||||||
|
protectGeneralContext: protectGeneralContext,
|
||||||
|
containers: containers,
|
||||||
|
isolationContextContainerMap: isolationMap,
|
||||||
|
strictContextIds: strict.keys.toSet(),
|
||||||
|
siteAssignments: sites,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The complete policy snapshot to push, or null until the real persisted
|
||||||
|
/// settings have loaded (the `...WithDefaults` loading placeholder is not valid
|
||||||
|
/// input, §2.8). Combines the user-intent settings with computed protection.
|
||||||
|
@Riverpod(keepAlive: true)
|
||||||
|
AppLinkPolicySnapshot? appLinkPolicySnapshot(Ref ref) {
|
||||||
|
final settings = ref.watch(generalSettingsRepositoryProvider).value;
|
||||||
|
if (settings == null) return null;
|
||||||
|
|
||||||
|
// Don't push a snapshot until the protection/override inputs have actually
|
||||||
|
// loaded (§2.8). `appLinkProtection` and `_computeContextOverrides` fall back to
|
||||||
|
// empty collections while these streams are still loading; pushing that would
|
||||||
|
// briefly persist "no protected contexts / no overrides" to native and let a
|
||||||
|
// protected or isolated container's links leak out during startup. Native keeps
|
||||||
|
// last session's persisted snapshot until the real one is ready.
|
||||||
|
final containersLoaded = ref.watch(watchContainersWithCountProvider).hasValue;
|
||||||
|
final isolationLoaded = ref
|
||||||
|
.watch(watchIsolatedContextContainerMapProvider)
|
||||||
|
.hasValue;
|
||||||
|
final strictLoaded = ref.watch(watchStrictContextAssignmentsProvider).hasValue;
|
||||||
|
final sitesLoaded = ref.watch(watchAllAssignedSitesProvider).hasValue;
|
||||||
|
// The real proxy-routing settings drive `protectGeneralContext`; the
|
||||||
|
// `...WithDefaults` view silently substitutes defaults while the row loads,
|
||||||
|
// which would compute "general context not proxied" and let a globally-proxied
|
||||||
|
// setup auto-launch during startup. Wait for the actual value.
|
||||||
|
final routingLoaded = ref
|
||||||
|
.watch(proxyRoutingSettingsRepositoryProvider)
|
||||||
|
.hasValue;
|
||||||
|
if (!containersLoaded ||
|
||||||
|
!isolationLoaded ||
|
||||||
|
!strictLoaded ||
|
||||||
|
!sitesLoaded ||
|
||||||
|
!routingLoaded) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
final protection = ref.watch(appLinkProtectionProvider);
|
||||||
|
|
||||||
|
return AppLinkPolicySnapshot(
|
||||||
|
globalMode: settings.appLinksMode,
|
||||||
|
rules: {
|
||||||
|
for (final MapEntry(:key, :value) in settings.appLinkRules.entries)
|
||||||
|
key: _toNativeRule(value),
|
||||||
|
},
|
||||||
|
marketplaceFallbackEnabled: settings.appLinkMarketplaceFallback,
|
||||||
|
protectGeneralContext: protection.protectGeneralContext,
|
||||||
|
protectedContextIds: protection.protectedContextIds.toList(),
|
||||||
|
strictContextIds: protection.strictContextIds.toList(),
|
||||||
|
protectedTargetPatterns: protection.protectedTargetPatterns
|
||||||
|
.map(_toNativePattern)
|
||||||
|
.toList(),
|
||||||
|
contextOverrides: _computeContextOverrides(ref, settings),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build the per-container override map (§ container isolation): one entry per
|
||||||
|
/// container whose "isolated app link settings" toggle is on and which has a
|
||||||
|
/// contextId. A freshly isolated container with no stored override still gets a
|
||||||
|
/// blank-slate entry so its "replace" behaviour takes effect immediately rather
|
||||||
|
/// than silently falling back to the global policy.
|
||||||
|
///
|
||||||
|
/// The override is published under the container's base contextId **and** under
|
||||||
|
/// every active isolation context id belonging to that container: isolated tabs
|
||||||
|
/// (`tab_mode = 2`) load under their own `isolation_context_id`, which is the
|
||||||
|
/// `session.contextId` the native interceptor keys the lookup on — so without the
|
||||||
|
/// fan-out isolated tabs would silently fall back to the global policy (mirrors
|
||||||
|
/// how `computeAppLinkProtection` expands protection to isolation contexts). When
|
||||||
|
/// an isolation context is shared by several isolated-app-link containers, the
|
||||||
|
/// container with the lowest sorted base contextId wins (deterministic).
|
||||||
|
Map<String, NativeContextAppLinkPolicy> _computeContextOverrides(
|
||||||
|
Ref ref,
|
||||||
|
GeneralSettings settings,
|
||||||
|
) {
|
||||||
|
final containers =
|
||||||
|
ref.watch(watchContainersWithCountProvider).value ?? const [];
|
||||||
|
final isolationMap =
|
||||||
|
ref.watch(watchIsolatedContextContainerMapProvider).value ?? const {};
|
||||||
|
|
||||||
|
NativeContextAppLinkPolicy toNative(ContextAppLinkPolicy policy) {
|
||||||
|
return NativeContextAppLinkPolicy(
|
||||||
|
mode: policy.mode,
|
||||||
|
rules: {
|
||||||
|
for (final MapEntry(:key, :value) in policy.rules.entries)
|
||||||
|
key: _toNativeRule(value),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Base contextId -> native override, plus containerId -> base contextId for the
|
||||||
|
// isolation-context fan-out below (only isolated-app-link containers).
|
||||||
|
final overrideByBaseContextId = <String, NativeContextAppLinkPolicy>{};
|
||||||
|
final baseContextIdByContainerId = <String, String>{};
|
||||||
|
for (final container in containers) {
|
||||||
|
final contextId = container.metadata.contextualIdentity;
|
||||||
|
if (contextId == null || !container.metadata.isolatedAppLinkSettings) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
overrideByBaseContextId[contextId] = toNative(
|
||||||
|
settings.appLinkContextOverrides[contextId] ??
|
||||||
|
ContextAppLinkPolicy.blank(),
|
||||||
|
);
|
||||||
|
baseContextIdByContainerId[container.id] = contextId;
|
||||||
|
}
|
||||||
|
|
||||||
|
final overrides = <String, NativeContextAppLinkPolicy>{
|
||||||
|
...overrideByBaseContextId,
|
||||||
|
};
|
||||||
|
|
||||||
|
for (final MapEntry(key: isolationContextId, value: containerIds)
|
||||||
|
in isolationMap.entries) {
|
||||||
|
final baseIds =
|
||||||
|
containerIds
|
||||||
|
.map((id) => baseContextIdByContainerId[id])
|
||||||
|
.nonNulls
|
||||||
|
.toList()
|
||||||
|
..sort();
|
||||||
|
if (baseIds.isEmpty) continue;
|
||||||
|
// A base contextId never collides with an isolation context id, but guard
|
||||||
|
// so a real container's own entry always wins if one ever did.
|
||||||
|
overrides.putIfAbsent(
|
||||||
|
isolationContextId,
|
||||||
|
() => overrideByBaseContextId[baseIds.first]!,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return overrides;
|
||||||
|
}
|
||||||
|
|
||||||
|
NativeAppLinkRule _toNativeRule(PersistedAppLinkRule rule) {
|
||||||
|
return NativeAppLinkRule(
|
||||||
|
decision: switch (rule.decision) {
|
||||||
|
AppLinkRuleDecision.alwaysOpen => NativeAppLinkRuleDecision.alwaysOpen,
|
||||||
|
AppLinkRuleDecision.neverOpen => NativeAppLinkRuleDecision.neverOpen,
|
||||||
|
},
|
||||||
|
scope: rule.scope,
|
||||||
|
packageName: rule.packageName,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
pigeon.ProtectedTargetPattern _toNativePattern(ProtectedTargetPattern pattern) {
|
||||||
|
return pigeon.ProtectedTargetPattern(
|
||||||
|
scheme: pattern.scheme,
|
||||||
|
hostOrSuffix: pattern.hostOrSuffix,
|
||||||
|
includeSubdomains: pattern.includeSubdomains,
|
||||||
|
port: pattern.port,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Single serialised writer that mirrors the Dart-owned app-link policy to the
|
||||||
|
/// native profile-scoped store (§2.8), the sole policy source consulted by the
|
||||||
|
/// interceptor. Structured like `ProxySettingsReplication`; mounted from app root
|
||||||
|
/// after initialisation.
|
||||||
|
@Riverpod(keepAlive: true)
|
||||||
|
class AppLinkPolicyReplication extends _$AppLinkPolicyReplication {
|
||||||
|
final _appLinks = GeckoAppLinksService();
|
||||||
|
|
||||||
|
final _pushLock = Lock();
|
||||||
|
// Coalesces the most recent snapshot while a push is in flight; genuinely
|
||||||
|
// nullable (no snapshot pushed yet).
|
||||||
|
// ignore: use_late_for_private_fields_and_variables
|
||||||
|
AppLinkPolicySnapshot? _latest;
|
||||||
|
var _pushDirty = false;
|
||||||
|
|
||||||
|
Future<void> _queuePush(AppLinkPolicySnapshot snapshot) async {
|
||||||
|
_latest = snapshot;
|
||||||
|
_pushDirty = true;
|
||||||
|
if (_pushLock.inLock) return;
|
||||||
|
|
||||||
|
await _pushLock.synchronized(() async {
|
||||||
|
while (_pushDirty) {
|
||||||
|
_pushDirty = false;
|
||||||
|
final pending = _latest!;
|
||||||
|
try {
|
||||||
|
await _appLinks.setAppLinkPolicy(pending);
|
||||||
|
} catch (error, stackTrace) {
|
||||||
|
// `setAppLinkPolicy` before a profile is bound is an error the
|
||||||
|
// replicator retries after initialisation (§2.8).
|
||||||
|
logger.w(
|
||||||
|
'Failed to push app-link policy; will retry',
|
||||||
|
error: error,
|
||||||
|
stackTrace: stackTrace,
|
||||||
|
);
|
||||||
|
_pushDirty = true;
|
||||||
|
await Future<void>.delayed(const Duration(seconds: 1));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void build() {
|
||||||
|
ref.listen(
|
||||||
|
fireImmediately: true,
|
||||||
|
appLinkPolicySnapshotProvider,
|
||||||
|
(previous, next) {
|
||||||
|
if (next == null) return;
|
||||||
|
unawaited(_queuePush(next));
|
||||||
|
},
|
||||||
|
onError: (error, stackTrace) {
|
||||||
|
logger.e(
|
||||||
|
'Error computing app-link policy snapshot',
|
||||||
|
error: error,
|
||||||
|
stackTrace: stackTrace,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
+194
@@ -0,0 +1,194 @@
|
|||||||
|
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||||
|
|
||||||
|
part of 'app_link_policy_replication.dart';
|
||||||
|
|
||||||
|
// **************************************************************************
|
||||||
|
// RiverpodGenerator
|
||||||
|
// **************************************************************************
|
||||||
|
|
||||||
|
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||||
|
// ignore_for_file: type=lint, type=warning
|
||||||
|
/// Effective app-link protection (§2.3), recomputed whenever routing, strict
|
||||||
|
/// mode, contextual identities, or site assignments change.
|
||||||
|
|
||||||
|
@ProviderFor(appLinkProtection)
|
||||||
|
final appLinkProtectionProvider = AppLinkProtectionProvider._();
|
||||||
|
|
||||||
|
/// Effective app-link protection (§2.3), recomputed whenever routing, strict
|
||||||
|
/// mode, contextual identities, or site assignments change.
|
||||||
|
|
||||||
|
final class AppLinkProtectionProvider
|
||||||
|
extends
|
||||||
|
$FunctionalProvider<
|
||||||
|
AppLinkProtection,
|
||||||
|
AppLinkProtection,
|
||||||
|
AppLinkProtection
|
||||||
|
>
|
||||||
|
with $Provider<AppLinkProtection> {
|
||||||
|
/// Effective app-link protection (§2.3), recomputed whenever routing, strict
|
||||||
|
/// mode, contextual identities, or site assignments change.
|
||||||
|
AppLinkProtectionProvider._()
|
||||||
|
: super(
|
||||||
|
from: null,
|
||||||
|
argument: null,
|
||||||
|
retry: null,
|
||||||
|
name: r'appLinkProtectionProvider',
|
||||||
|
isAutoDispose: false,
|
||||||
|
dependencies: null,
|
||||||
|
$allTransitiveDependencies: null,
|
||||||
|
);
|
||||||
|
|
||||||
|
@override
|
||||||
|
String debugGetCreateSourceHash() => _$appLinkProtectionHash();
|
||||||
|
|
||||||
|
@$internal
|
||||||
|
@override
|
||||||
|
$ProviderElement<AppLinkProtection> $createElement(
|
||||||
|
$ProviderPointer pointer,
|
||||||
|
) => $ProviderElement(pointer);
|
||||||
|
|
||||||
|
@override
|
||||||
|
AppLinkProtection create(Ref ref) {
|
||||||
|
return appLinkProtection(ref);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// {@macro riverpod.override_with_value}
|
||||||
|
Override overrideWithValue(AppLinkProtection value) {
|
||||||
|
return $ProviderOverride(
|
||||||
|
origin: this,
|
||||||
|
providerOverride: $SyncValueProvider<AppLinkProtection>(value),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String _$appLinkProtectionHash() => r'6aab203c9b7d2f2c8a73684ea0b624e09fe6179f';
|
||||||
|
|
||||||
|
/// The complete policy snapshot to push, or null until the real persisted
|
||||||
|
/// settings have loaded (the `...WithDefaults` loading placeholder is not valid
|
||||||
|
/// input, §2.8). Combines the user-intent settings with computed protection.
|
||||||
|
|
||||||
|
@ProviderFor(appLinkPolicySnapshot)
|
||||||
|
final appLinkPolicySnapshotProvider = AppLinkPolicySnapshotProvider._();
|
||||||
|
|
||||||
|
/// The complete policy snapshot to push, or null until the real persisted
|
||||||
|
/// settings have loaded (the `...WithDefaults` loading placeholder is not valid
|
||||||
|
/// input, §2.8). Combines the user-intent settings with computed protection.
|
||||||
|
|
||||||
|
final class AppLinkPolicySnapshotProvider
|
||||||
|
extends
|
||||||
|
$FunctionalProvider<
|
||||||
|
AppLinkPolicySnapshot?,
|
||||||
|
AppLinkPolicySnapshot?,
|
||||||
|
AppLinkPolicySnapshot?
|
||||||
|
>
|
||||||
|
with $Provider<AppLinkPolicySnapshot?> {
|
||||||
|
/// The complete policy snapshot to push, or null until the real persisted
|
||||||
|
/// settings have loaded (the `...WithDefaults` loading placeholder is not valid
|
||||||
|
/// input, §2.8). Combines the user-intent settings with computed protection.
|
||||||
|
AppLinkPolicySnapshotProvider._()
|
||||||
|
: super(
|
||||||
|
from: null,
|
||||||
|
argument: null,
|
||||||
|
retry: null,
|
||||||
|
name: r'appLinkPolicySnapshotProvider',
|
||||||
|
isAutoDispose: false,
|
||||||
|
dependencies: null,
|
||||||
|
$allTransitiveDependencies: null,
|
||||||
|
);
|
||||||
|
|
||||||
|
@override
|
||||||
|
String debugGetCreateSourceHash() => _$appLinkPolicySnapshotHash();
|
||||||
|
|
||||||
|
@$internal
|
||||||
|
@override
|
||||||
|
$ProviderElement<AppLinkPolicySnapshot?> $createElement(
|
||||||
|
$ProviderPointer pointer,
|
||||||
|
) => $ProviderElement(pointer);
|
||||||
|
|
||||||
|
@override
|
||||||
|
AppLinkPolicySnapshot? create(Ref ref) {
|
||||||
|
return appLinkPolicySnapshot(ref);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// {@macro riverpod.override_with_value}
|
||||||
|
Override overrideWithValue(AppLinkPolicySnapshot? value) {
|
||||||
|
return $ProviderOverride(
|
||||||
|
origin: this,
|
||||||
|
providerOverride: $SyncValueProvider<AppLinkPolicySnapshot?>(value),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String _$appLinkPolicySnapshotHash() =>
|
||||||
|
r'7f700b67d3b7b0b435fe82a98de455c6e374a1a2';
|
||||||
|
|
||||||
|
/// Single serialised writer that mirrors the Dart-owned app-link policy to the
|
||||||
|
/// native profile-scoped store (§2.8), the sole policy source consulted by the
|
||||||
|
/// interceptor. Structured like `ProxySettingsReplication`; mounted from app root
|
||||||
|
/// after initialisation.
|
||||||
|
|
||||||
|
@ProviderFor(AppLinkPolicyReplication)
|
||||||
|
final appLinkPolicyReplicationProvider = AppLinkPolicyReplicationProvider._();
|
||||||
|
|
||||||
|
/// Single serialised writer that mirrors the Dart-owned app-link policy to the
|
||||||
|
/// native profile-scoped store (§2.8), the sole policy source consulted by the
|
||||||
|
/// interceptor. Structured like `ProxySettingsReplication`; mounted from app root
|
||||||
|
/// after initialisation.
|
||||||
|
final class AppLinkPolicyReplicationProvider
|
||||||
|
extends $NotifierProvider<AppLinkPolicyReplication, void> {
|
||||||
|
/// Single serialised writer that mirrors the Dart-owned app-link policy to the
|
||||||
|
/// native profile-scoped store (§2.8), the sole policy source consulted by the
|
||||||
|
/// interceptor. Structured like `ProxySettingsReplication`; mounted from app root
|
||||||
|
/// after initialisation.
|
||||||
|
AppLinkPolicyReplicationProvider._()
|
||||||
|
: super(
|
||||||
|
from: null,
|
||||||
|
argument: null,
|
||||||
|
retry: null,
|
||||||
|
name: r'appLinkPolicyReplicationProvider',
|
||||||
|
isAutoDispose: false,
|
||||||
|
dependencies: null,
|
||||||
|
$allTransitiveDependencies: null,
|
||||||
|
);
|
||||||
|
|
||||||
|
@override
|
||||||
|
String debugGetCreateSourceHash() => _$appLinkPolicyReplicationHash();
|
||||||
|
|
||||||
|
@$internal
|
||||||
|
@override
|
||||||
|
AppLinkPolicyReplication create() => AppLinkPolicyReplication();
|
||||||
|
|
||||||
|
/// {@macro riverpod.override_with_value}
|
||||||
|
Override overrideWithValue(void value) {
|
||||||
|
return $ProviderOverride(
|
||||||
|
origin: this,
|
||||||
|
providerOverride: $SyncValueProvider<void>(value),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String _$appLinkPolicyReplicationHash() =>
|
||||||
|
r'866e749328bef9f65c2124585d8c03d798802563';
|
||||||
|
|
||||||
|
/// Single serialised writer that mirrors the Dart-owned app-link policy to the
|
||||||
|
/// native profile-scoped store (§2.8), the sole policy source consulted by the
|
||||||
|
/// interceptor. Structured like `ProxySettingsReplication`; mounted from app root
|
||||||
|
/// after initialisation.
|
||||||
|
|
||||||
|
abstract class _$AppLinkPolicyReplication extends $Notifier<void> {
|
||||||
|
void build();
|
||||||
|
@$mustCallSuper
|
||||||
|
@override
|
||||||
|
WhenComplete runBuild() {
|
||||||
|
final ref = this.ref as $Ref<void, void>;
|
||||||
|
final element =
|
||||||
|
ref.element
|
||||||
|
as $ClassProviderElement<
|
||||||
|
AnyNotifier<void, void>,
|
||||||
|
void,
|
||||||
|
Object?,
|
||||||
|
Object?
|
||||||
|
>;
|
||||||
|
return element.handleCreate(ref, build);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,165 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2024-2026 Fabian Freund.
|
||||||
|
*
|
||||||
|
* This file is part of WebLibre
|
||||||
|
* (see https://weblibre.eu).
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU Affero General Public License as
|
||||||
|
* published by the Free Software Foundation, either version 3 of the
|
||||||
|
* License, or (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU Affero General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU Affero General Public License
|
||||||
|
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
import 'package:flutter_mozilla_components/flutter_mozilla_components.dart';
|
||||||
|
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||||
|
import 'package:weblibre/core/logger.dart';
|
||||||
|
import 'package:weblibre/features/app_links/domain/entities/app_link_rule.dart';
|
||||||
|
import 'package:weblibre/features/app_links/domain/entities/context_app_link_policy.dart';
|
||||||
|
import 'package:weblibre/features/app_links/domain/services/effective_app_link_policy.dart';
|
||||||
|
import 'package:weblibre/features/geckoview/features/tabs/domain/providers.dart';
|
||||||
|
import 'package:weblibre/features/user/data/models/general_settings.dart';
|
||||||
|
import 'package:weblibre/features/user/domain/repositories/general_settings.dart';
|
||||||
|
|
||||||
|
part 'app_links_coordinator.g.dart';
|
||||||
|
|
||||||
|
/// Receives the native availability signal for Flutter-owned prompts. The event
|
||||||
|
/// is optimisation-only (no buffering/replay); the store query is authoritative.
|
||||||
|
class _AppLinkEventsReceiver extends GeckoAppLinkEvents {
|
||||||
|
_AppLinkEventsReceiver(this._onAvailable);
|
||||||
|
|
||||||
|
final void Function(AppLinkPromptOwner owner) _onAvailable;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void onAppLinkPromptAvailable(int sequence, AppLinkPromptOwner owner) {
|
||||||
|
_onAvailable(owner);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Orchestrates Flutter-owned app-link prompts (§2.6): registers the availability
|
||||||
|
/// event handler, queries the native pending store on attach/resume/event, and
|
||||||
|
/// exposes resolution (including the remember-then-resolve flow). The presented
|
||||||
|
/// list is authoritative from the query and deduped by `requestId` — the event
|
||||||
|
/// is only a nudge to re-query.
|
||||||
|
@Riverpod(keepAlive: true)
|
||||||
|
class AppLinksCoordinator extends _$AppLinksCoordinator {
|
||||||
|
final _service = GeckoAppLinksService();
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<AppLinkPromptRequest> build() {
|
||||||
|
final receiver = _AppLinkEventsReceiver((owner) {
|
||||||
|
if (owner == AppLinkPromptOwner.flutterBrowser) {
|
||||||
|
// ignore: discarded_futures
|
||||||
|
refresh();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
GeckoAppLinkEvents.setUp(receiver);
|
||||||
|
ref.onDispose(() => GeckoAppLinkEvents.setUp(null));
|
||||||
|
|
||||||
|
// Initial query; the returned future updates state when it completes.
|
||||||
|
// ignore: discarded_futures
|
||||||
|
refresh();
|
||||||
|
|
||||||
|
return const [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Re-query the native pending store (called on attach, lifecycle resume, and
|
||||||
|
/// when the availability event fires).
|
||||||
|
Future<void> refresh() async {
|
||||||
|
try {
|
||||||
|
final prompts = await _service.getPendingAppLinkPrompts(
|
||||||
|
AppLinkPromptOwner.flutterBrowser,
|
||||||
|
);
|
||||||
|
logger.i(
|
||||||
|
'app-link refresh -> ${prompts.length} prompt(s): '
|
||||||
|
'${prompts.map((p) => '${p.requestId}@${p.tabId}(${p.isModal ? 'modal' : 'banner'})').toList()}',
|
||||||
|
);
|
||||||
|
state = prompts;
|
||||||
|
} catch (error, stackTrace) {
|
||||||
|
logger.w(
|
||||||
|
'Failed to query pending app-link prompts',
|
||||||
|
error: error,
|
||||||
|
stackTrace: stackTrace,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolve a pending prompt and re-query.
|
||||||
|
Future<AppLinkResolutionResult> resolve(
|
||||||
|
int requestId,
|
||||||
|
AppLinkDecision decision,
|
||||||
|
) async {
|
||||||
|
final result = await _service.resolvePendingAppLink(requestId, decision);
|
||||||
|
await refresh();
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Remember-then-resolve (§2.6): persist the rule to `GeneralSettings` first so
|
||||||
|
/// it is replicated to native, then resolve the still-pending request.
|
||||||
|
///
|
||||||
|
/// [contextId] is the source tab's live contextId (from the prompt request) —
|
||||||
|
/// the container's base contextId for a regular tab, or the tab's
|
||||||
|
/// `isolation_context_id` for an isolated tab. When it resolves to a container
|
||||||
|
/// with "isolated app link settings" enabled, the rule is written to that
|
||||||
|
/// container's own override bucket (`appLinkContextOverrides`, keyed by the
|
||||||
|
/// container's base contextId) rather than the global [GeneralSettings.appLinkRules],
|
||||||
|
/// keeping the two rule sets separate (replace semantics).
|
||||||
|
Future<AppLinkResolutionResult> resolveWithRule(
|
||||||
|
int requestId,
|
||||||
|
AppLinkDecision decision,
|
||||||
|
PersistedAppLinkRule rule, {
|
||||||
|
String? contextId,
|
||||||
|
}) async {
|
||||||
|
final overrideKey = await _overrideKeyForContext(contextId);
|
||||||
|
|
||||||
|
await ref.read(generalSettingsRepositoryProvider.notifier).updateSettings((
|
||||||
|
current,
|
||||||
|
) {
|
||||||
|
if (overrideKey != null) {
|
||||||
|
final existing =
|
||||||
|
current.appLinkContextOverrides[overrideKey] ??
|
||||||
|
ContextAppLinkPolicy.blank();
|
||||||
|
final updated = existing.copyWith.rules({
|
||||||
|
...existing.rules,
|
||||||
|
rule.scope: rule,
|
||||||
|
});
|
||||||
|
return current.copyWith.appLinkContextOverrides({
|
||||||
|
...current.appLinkContextOverrides,
|
||||||
|
overrideKey: updated,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return current.copyWith.appLinkRules({
|
||||||
|
...current.appLinkRules,
|
||||||
|
rule.scope: rule,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return resolve(requestId, decision);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolve the source tab's live [contextId] to the override storage key — the
|
||||||
|
/// base contextId of the owning isolated-app-link container — or null to write
|
||||||
|
/// globally. Handles both a regular tab (contextId is already the container
|
||||||
|
/// base) and an isolated tab (contextId is an `isolation_context_id` mapping to
|
||||||
|
/// its container). Delegates to [resolveAppLinkOverrideKey] so writes land in
|
||||||
|
/// the bucket that is published back to native.
|
||||||
|
Future<String?> _overrideKeyForContext(String? contextId) async {
|
||||||
|
if (contextId == null) return null;
|
||||||
|
|
||||||
|
final containers = await ref.read(watchContainersWithCountProvider.future);
|
||||||
|
final isolationMap = await ref.read(
|
||||||
|
watchIsolatedContextContainerMapProvider.future,
|
||||||
|
);
|
||||||
|
|
||||||
|
return resolveAppLinkOverrideKey(
|
||||||
|
liveContextId: contextId,
|
||||||
|
containers: containers,
|
||||||
|
isolationContextContainerMap: isolationMap,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||||
|
|
||||||
|
part of 'app_links_coordinator.dart';
|
||||||
|
|
||||||
|
// **************************************************************************
|
||||||
|
// RiverpodGenerator
|
||||||
|
// **************************************************************************
|
||||||
|
|
||||||
|
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||||
|
// ignore_for_file: type=lint, type=warning
|
||||||
|
/// Orchestrates Flutter-owned app-link prompts (§2.6): registers the availability
|
||||||
|
/// event handler, queries the native pending store on attach/resume/event, and
|
||||||
|
/// exposes resolution (including the remember-then-resolve flow). The presented
|
||||||
|
/// list is authoritative from the query and deduped by `requestId` — the event
|
||||||
|
/// is only a nudge to re-query.
|
||||||
|
|
||||||
|
@ProviderFor(AppLinksCoordinator)
|
||||||
|
final appLinksCoordinatorProvider = AppLinksCoordinatorProvider._();
|
||||||
|
|
||||||
|
/// Orchestrates Flutter-owned app-link prompts (§2.6): registers the availability
|
||||||
|
/// event handler, queries the native pending store on attach/resume/event, and
|
||||||
|
/// exposes resolution (including the remember-then-resolve flow). The presented
|
||||||
|
/// list is authoritative from the query and deduped by `requestId` — the event
|
||||||
|
/// is only a nudge to re-query.
|
||||||
|
final class AppLinksCoordinatorProvider
|
||||||
|
extends $NotifierProvider<AppLinksCoordinator, List<AppLinkPromptRequest>> {
|
||||||
|
/// Orchestrates Flutter-owned app-link prompts (§2.6): registers the availability
|
||||||
|
/// event handler, queries the native pending store on attach/resume/event, and
|
||||||
|
/// exposes resolution (including the remember-then-resolve flow). The presented
|
||||||
|
/// list is authoritative from the query and deduped by `requestId` — the event
|
||||||
|
/// is only a nudge to re-query.
|
||||||
|
AppLinksCoordinatorProvider._()
|
||||||
|
: super(
|
||||||
|
from: null,
|
||||||
|
argument: null,
|
||||||
|
retry: null,
|
||||||
|
name: r'appLinksCoordinatorProvider',
|
||||||
|
isAutoDispose: false,
|
||||||
|
dependencies: null,
|
||||||
|
$allTransitiveDependencies: null,
|
||||||
|
);
|
||||||
|
|
||||||
|
@override
|
||||||
|
String debugGetCreateSourceHash() => _$appLinksCoordinatorHash();
|
||||||
|
|
||||||
|
@$internal
|
||||||
|
@override
|
||||||
|
AppLinksCoordinator create() => AppLinksCoordinator();
|
||||||
|
|
||||||
|
/// {@macro riverpod.override_with_value}
|
||||||
|
Override overrideWithValue(List<AppLinkPromptRequest> value) {
|
||||||
|
return $ProviderOverride(
|
||||||
|
origin: this,
|
||||||
|
providerOverride: $SyncValueProvider<List<AppLinkPromptRequest>>(value),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String _$appLinksCoordinatorHash() =>
|
||||||
|
r'183fc7ac1264a63c24b1d10f4a22cbfbf6046da7';
|
||||||
|
|
||||||
|
/// Orchestrates Flutter-owned app-link prompts (§2.6): registers the availability
|
||||||
|
/// event handler, queries the native pending store on attach/resume/event, and
|
||||||
|
/// exposes resolution (including the remember-then-resolve flow). The presented
|
||||||
|
/// list is authoritative from the query and deduped by `requestId` — the event
|
||||||
|
/// is only a nudge to re-query.
|
||||||
|
|
||||||
|
abstract class _$AppLinksCoordinator
|
||||||
|
extends $Notifier<List<AppLinkPromptRequest>> {
|
||||||
|
List<AppLinkPromptRequest> build();
|
||||||
|
@$mustCallSuper
|
||||||
|
@override
|
||||||
|
WhenComplete runBuild() {
|
||||||
|
final ref =
|
||||||
|
this.ref
|
||||||
|
as $Ref<List<AppLinkPromptRequest>, List<AppLinkPromptRequest>>;
|
||||||
|
final element =
|
||||||
|
ref.element
|
||||||
|
as $ClassProviderElement<
|
||||||
|
AnyNotifier<
|
||||||
|
List<AppLinkPromptRequest>,
|
||||||
|
List<AppLinkPromptRequest>
|
||||||
|
>,
|
||||||
|
List<AppLinkPromptRequest>,
|
||||||
|
Object?,
|
||||||
|
Object?
|
||||||
|
>;
|
||||||
|
return element.handleCreate(ref, build);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2024-2026 Fabian Freund.
|
||||||
|
*
|
||||||
|
* This file is part of WebLibre
|
||||||
|
* (see https://weblibre.eu).
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU Affero General Public License as
|
||||||
|
* published by the Free Software Foundation, either version 3 of the
|
||||||
|
* License, or (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU Affero General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU Affero General Public License
|
||||||
|
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
import 'package:fast_equatable/fast_equatable.dart';
|
||||||
|
import 'package:flutter_mozilla_components/flutter_mozilla_components.dart'
|
||||||
|
show AppLinksMode;
|
||||||
|
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||||
|
import 'package:weblibre/features/app_links/domain/entities/app_link_rule.dart';
|
||||||
|
import 'package:weblibre/features/app_links/domain/entities/context_app_link_policy.dart';
|
||||||
|
import 'package:weblibre/features/geckoview/features/tabs/data/models/container_data.dart';
|
||||||
|
import 'package:weblibre/features/geckoview/features/tabs/domain/providers.dart';
|
||||||
|
import 'package:weblibre/features/user/domain/repositories/general_settings.dart';
|
||||||
|
|
||||||
|
part 'effective_app_link_policy.g.dart';
|
||||||
|
|
||||||
|
/// Resolve a tab's live contextId to the app-link override storage key — the
|
||||||
|
/// base contextId of the container with "isolated app link settings" enabled
|
||||||
|
/// that governs the tab — or null when the global mode + rules apply.
|
||||||
|
///
|
||||||
|
/// [liveContextId] is the container's base contextId for a regular tab, or the
|
||||||
|
/// tab's `isolation_context_id` for an isolated tab (i.e. `TabState.contextId`
|
||||||
|
/// as reported by the engine). Uses the same lowest-sorted-base tiebreak as the
|
||||||
|
/// snapshot builder so lookups land on the bucket that is published to native.
|
||||||
|
String? resolveAppLinkOverrideKey({
|
||||||
|
required String? liveContextId,
|
||||||
|
required List<ContainerDataWithCount> containers,
|
||||||
|
required Map<String, Set<String>> isolationContextContainerMap,
|
||||||
|
}) {
|
||||||
|
if (liveContextId == null) return null;
|
||||||
|
|
||||||
|
// Regular tab: liveContextId is a container's own base contextId.
|
||||||
|
for (final container in containers) {
|
||||||
|
if (container.metadata.contextualIdentity == liveContextId) {
|
||||||
|
return container.metadata.isolatedAppLinkSettings ? liveContextId : null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Isolated tab: liveContextId is an isolation context shared by one or more
|
||||||
|
// containers; pick the isolated-app-link one with the lowest base contextId.
|
||||||
|
final containerIds = isolationContextContainerMap[liveContextId];
|
||||||
|
if (containerIds == null || containerIds.isEmpty) return null;
|
||||||
|
|
||||||
|
final byId = {for (final container in containers) container.id: container};
|
||||||
|
final baseIds =
|
||||||
|
containerIds
|
||||||
|
.map((id) => byId[id])
|
||||||
|
.nonNulls
|
||||||
|
.where(
|
||||||
|
(container) =>
|
||||||
|
container.metadata.isolatedAppLinkSettings &&
|
||||||
|
container.metadata.contextualIdentity != null,
|
||||||
|
)
|
||||||
|
.map((container) => container.metadata.contextualIdentity!)
|
||||||
|
.toList()
|
||||||
|
..sort();
|
||||||
|
return baseIds.isEmpty ? null : baseIds.first;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The app-link policy effectively governing a tab: the global mode + rules,
|
||||||
|
/// or the owning container's override when it has isolated app-link settings
|
||||||
|
/// (replace semantics). Used by the site settings sheet to display and edit
|
||||||
|
/// the settings in the bucket that actually applies to the shown tab.
|
||||||
|
class EffectiveAppLinkPolicy with FastEquatable {
|
||||||
|
/// The override storage key (container base contextId), or null when the
|
||||||
|
/// global bucket governs the tab.
|
||||||
|
final String? overrideKey;
|
||||||
|
|
||||||
|
/// Display name of the governing container; null when global.
|
||||||
|
final String? containerName;
|
||||||
|
|
||||||
|
/// The effective open-links-in-apps mode.
|
||||||
|
final AppLinksMode mode;
|
||||||
|
|
||||||
|
/// The effective remembered rules, keyed by canonical scope
|
||||||
|
/// (`host:<host>` | `pkg:<package>`).
|
||||||
|
final Map<String, PersistedAppLinkRule> rules;
|
||||||
|
|
||||||
|
EffectiveAppLinkPolicy({
|
||||||
|
required this.overrideKey,
|
||||||
|
required this.containerName,
|
||||||
|
required this.mode,
|
||||||
|
required this.rules,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Whether the tab is governed by a container override (true) or the global
|
||||||
|
/// bucket (false).
|
||||||
|
bool get isContainerScoped => overrideKey != null;
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<Object?> get hashParameters => [overrideKey, containerName, mode, rules];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Compute the [EffectiveAppLinkPolicy] for a tab's live contextId. Returns
|
||||||
|
/// null until the container/isolation inputs have loaded — resolving against
|
||||||
|
/// empty placeholders could misattribute an isolated container's tab to the
|
||||||
|
/// global bucket, so callers show a loading state instead.
|
||||||
|
@Riverpod()
|
||||||
|
EffectiveAppLinkPolicy? effectiveAppLinkPolicy(Ref ref, String? liveContextId) {
|
||||||
|
final settings = ref.watch(generalSettingsWithDefaultsProvider);
|
||||||
|
final containers = ref.watch(watchContainersWithCountProvider).value;
|
||||||
|
final isolationMap = ref
|
||||||
|
.watch(watchIsolatedContextContainerMapProvider)
|
||||||
|
.value;
|
||||||
|
if (containers == null || isolationMap == null) return null;
|
||||||
|
|
||||||
|
final overrideKey = resolveAppLinkOverrideKey(
|
||||||
|
liveContextId: liveContextId,
|
||||||
|
containers: containers,
|
||||||
|
isolationContextContainerMap: isolationMap,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (overrideKey == null) {
|
||||||
|
return EffectiveAppLinkPolicy(
|
||||||
|
overrideKey: null,
|
||||||
|
containerName: null,
|
||||||
|
mode: settings.appLinksMode,
|
||||||
|
rules: settings.appLinkRules,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final override =
|
||||||
|
settings.appLinkContextOverrides[overrideKey] ??
|
||||||
|
ContextAppLinkPolicy.blank();
|
||||||
|
final containerName = containers
|
||||||
|
.where((c) => c.metadata.contextualIdentity == overrideKey)
|
||||||
|
.firstOrNull
|
||||||
|
?.name;
|
||||||
|
|
||||||
|
return EffectiveAppLinkPolicy(
|
||||||
|
overrideKey: overrideKey,
|
||||||
|
containerName: containerName,
|
||||||
|
mode: override.mode,
|
||||||
|
rules: override.rules,
|
||||||
|
);
|
||||||
|
}
|
||||||
+118
@@ -0,0 +1,118 @@
|
|||||||
|
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||||
|
|
||||||
|
part of 'effective_app_link_policy.dart';
|
||||||
|
|
||||||
|
// **************************************************************************
|
||||||
|
// RiverpodGenerator
|
||||||
|
// **************************************************************************
|
||||||
|
|
||||||
|
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||||
|
// ignore_for_file: type=lint, type=warning
|
||||||
|
/// Compute the [EffectiveAppLinkPolicy] for a tab's live contextId. Returns
|
||||||
|
/// null until the container/isolation inputs have loaded — resolving against
|
||||||
|
/// empty placeholders could misattribute an isolated container's tab to the
|
||||||
|
/// global bucket, so callers show a loading state instead.
|
||||||
|
|
||||||
|
@ProviderFor(effectiveAppLinkPolicy)
|
||||||
|
final effectiveAppLinkPolicyProvider = EffectiveAppLinkPolicyFamily._();
|
||||||
|
|
||||||
|
/// Compute the [EffectiveAppLinkPolicy] for a tab's live contextId. Returns
|
||||||
|
/// null until the container/isolation inputs have loaded — resolving against
|
||||||
|
/// empty placeholders could misattribute an isolated container's tab to the
|
||||||
|
/// global bucket, so callers show a loading state instead.
|
||||||
|
|
||||||
|
final class EffectiveAppLinkPolicyProvider
|
||||||
|
extends
|
||||||
|
$FunctionalProvider<
|
||||||
|
EffectiveAppLinkPolicy?,
|
||||||
|
EffectiveAppLinkPolicy?,
|
||||||
|
EffectiveAppLinkPolicy?
|
||||||
|
>
|
||||||
|
with $Provider<EffectiveAppLinkPolicy?> {
|
||||||
|
/// Compute the [EffectiveAppLinkPolicy] for a tab's live contextId. Returns
|
||||||
|
/// null until the container/isolation inputs have loaded — resolving against
|
||||||
|
/// empty placeholders could misattribute an isolated container's tab to the
|
||||||
|
/// global bucket, so callers show a loading state instead.
|
||||||
|
EffectiveAppLinkPolicyProvider._({
|
||||||
|
required EffectiveAppLinkPolicyFamily super.from,
|
||||||
|
required String? super.argument,
|
||||||
|
}) : super(
|
||||||
|
retry: null,
|
||||||
|
name: r'effectiveAppLinkPolicyProvider',
|
||||||
|
isAutoDispose: true,
|
||||||
|
dependencies: null,
|
||||||
|
$allTransitiveDependencies: null,
|
||||||
|
);
|
||||||
|
|
||||||
|
@override
|
||||||
|
String debugGetCreateSourceHash() => _$effectiveAppLinkPolicyHash();
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() {
|
||||||
|
return r'effectiveAppLinkPolicyProvider'
|
||||||
|
''
|
||||||
|
'($argument)';
|
||||||
|
}
|
||||||
|
|
||||||
|
@$internal
|
||||||
|
@override
|
||||||
|
$ProviderElement<EffectiveAppLinkPolicy?> $createElement(
|
||||||
|
$ProviderPointer pointer,
|
||||||
|
) => $ProviderElement(pointer);
|
||||||
|
|
||||||
|
@override
|
||||||
|
EffectiveAppLinkPolicy? create(Ref ref) {
|
||||||
|
final argument = this.argument as String?;
|
||||||
|
return effectiveAppLinkPolicy(ref, argument);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// {@macro riverpod.override_with_value}
|
||||||
|
Override overrideWithValue(EffectiveAppLinkPolicy? value) {
|
||||||
|
return $ProviderOverride(
|
||||||
|
origin: this,
|
||||||
|
providerOverride: $SyncValueProvider<EffectiveAppLinkPolicy?>(value),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool operator ==(Object other) {
|
||||||
|
return other is EffectiveAppLinkPolicyProvider &&
|
||||||
|
other.argument == argument;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
int get hashCode {
|
||||||
|
return argument.hashCode;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String _$effectiveAppLinkPolicyHash() =>
|
||||||
|
r'da8101e842a9cf516eb18d817560813bc0cc94f0';
|
||||||
|
|
||||||
|
/// Compute the [EffectiveAppLinkPolicy] for a tab's live contextId. Returns
|
||||||
|
/// null until the container/isolation inputs have loaded — resolving against
|
||||||
|
/// empty placeholders could misattribute an isolated container's tab to the
|
||||||
|
/// global bucket, so callers show a loading state instead.
|
||||||
|
|
||||||
|
final class EffectiveAppLinkPolicyFamily extends $Family
|
||||||
|
with $FunctionalFamilyOverride<EffectiveAppLinkPolicy?, String?> {
|
||||||
|
EffectiveAppLinkPolicyFamily._()
|
||||||
|
: super(
|
||||||
|
retry: null,
|
||||||
|
name: r'effectiveAppLinkPolicyProvider',
|
||||||
|
dependencies: null,
|
||||||
|
$allTransitiveDependencies: null,
|
||||||
|
isAutoDispose: true,
|
||||||
|
);
|
||||||
|
|
||||||
|
/// Compute the [EffectiveAppLinkPolicy] for a tab's live contextId. Returns
|
||||||
|
/// null until the container/isolation inputs have loaded — resolving against
|
||||||
|
/// empty placeholders could misattribute an isolated container's tab to the
|
||||||
|
/// global bucket, so callers show a loading state instead.
|
||||||
|
|
||||||
|
EffectiveAppLinkPolicyProvider call(String? liveContextId) =>
|
||||||
|
EffectiveAppLinkPolicyProvider._(argument: liveContextId, from: this);
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() => r'effectiveAppLinkPolicyProvider';
|
||||||
|
}
|
||||||
@@ -0,0 +1,343 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2024-2026 Fabian Freund.
|
||||||
|
*
|
||||||
|
* This file is part of WebLibre
|
||||||
|
* (see https://weblibre.eu).
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU Affero General Public License as
|
||||||
|
* published by the Free Software Foundation, either version 3 of the
|
||||||
|
* License, or (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU Affero General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU Affero General Public License
|
||||||
|
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/// Shared, pure routing-resolution model (see APP_LINKS_OWN_IMPLEMENTATION_PLAN.md
|
||||||
|
/// §2.3). Owned by neither `ProxySettingsReplication` nor app-link protection —
|
||||||
|
/// both consume it so there is exactly one notion of "how is this container
|
||||||
|
/// routed" and "is this tab effectively proxied".
|
||||||
|
library;
|
||||||
|
|
||||||
|
import 'package:fast_equatable/fast_equatable.dart';
|
||||||
|
import 'package:weblibre/features/geckoview/features/tabs/data/models/container_data.dart';
|
||||||
|
import 'package:weblibre/features/geckoview/features/tabs/data/models/site_assignment.dart';
|
||||||
|
import 'package:weblibre/features/proxy/data/proxy_connection.dart';
|
||||||
|
|
||||||
|
/// How a container (or isolation context) is routed after resolving its own
|
||||||
|
/// proxy settings — before inheriting/aliasing.
|
||||||
|
sealed class ProxyAssignment with FastEquatable {
|
||||||
|
ProxyAssignment();
|
||||||
|
|
||||||
|
/// Follows the global (`general`) routing.
|
||||||
|
factory ProxyAssignment.inherit() = InheritProxyAssignment;
|
||||||
|
|
||||||
|
/// Explicitly bypasses the global proxy (direct connection), scoped to [scopeId].
|
||||||
|
factory ProxyAssignment.direct(String scopeId) = DirectProxyAssignment;
|
||||||
|
|
||||||
|
/// Routed through the proxy identified by [proxyId].
|
||||||
|
factory ProxyAssignment.explicit(String proxyId) = ExplicitProxyAssignment;
|
||||||
|
}
|
||||||
|
|
||||||
|
final class InheritProxyAssignment extends ProxyAssignment {
|
||||||
|
InheritProxyAssignment();
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<Object?> get hashParameters => const ['inherit'];
|
||||||
|
}
|
||||||
|
|
||||||
|
final class DirectProxyAssignment extends ProxyAssignment {
|
||||||
|
final String scopeId;
|
||||||
|
|
||||||
|
DirectProxyAssignment(this.scopeId);
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<Object?> get hashParameters => ['direct', scopeId];
|
||||||
|
}
|
||||||
|
|
||||||
|
final class ExplicitProxyAssignment extends ProxyAssignment {
|
||||||
|
final String proxyId;
|
||||||
|
|
||||||
|
ExplicitProxyAssignment(this.proxyId);
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<Object?> get hashParameters => ['explicit', proxyId];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolve a single container's routing from its own metadata fields.
|
||||||
|
///
|
||||||
|
/// - a set [proxyConnectionId] → `explicit`
|
||||||
|
/// - no proxy but [bypassGlobalProxy] → `direct` scoped to [contextId]
|
||||||
|
/// - otherwise → `inherit`
|
||||||
|
///
|
||||||
|
/// This is the one place the per-container `proxyConnectionId`/`bypassGlobalProxy`
|
||||||
|
/// precedence lives; the proxy replicator and app-link protection both call it.
|
||||||
|
ProxyAssignment resolveContainerAssignment({
|
||||||
|
required String contextId,
|
||||||
|
required ProxyConnectionId? proxyConnectionId,
|
||||||
|
required bool bypassGlobalProxy,
|
||||||
|
}) {
|
||||||
|
return switch (proxyConnectionId) {
|
||||||
|
final proxyId? => ProxyAssignment.explicit(proxyId.encode()),
|
||||||
|
null when bypassGlobalProxy => ProxyAssignment.direct(contextId),
|
||||||
|
null => ProxyAssignment.inherit(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The result of collapsing the (possibly conflicting) routing of the containers
|
||||||
|
/// that share an isolation context into a single alias.
|
||||||
|
class IsolationContextRouting {
|
||||||
|
/// The assignment the isolation context aliases to.
|
||||||
|
final ProxyAssignment chosen;
|
||||||
|
|
||||||
|
/// Human-readable label for [chosen] (used in the conflict warning).
|
||||||
|
final String chosenLabel;
|
||||||
|
|
||||||
|
/// The distinct assignment labels observed, ordered `inherit`, `direct:*`,
|
||||||
|
/// then proxy ids — used to describe conflicts.
|
||||||
|
final List<String> assignmentLabels;
|
||||||
|
|
||||||
|
/// Number of distinct assignments; `> 1` means the containers disagree.
|
||||||
|
final int distinctAssignmentCount;
|
||||||
|
|
||||||
|
IsolationContextRouting({
|
||||||
|
required this.chosen,
|
||||||
|
required this.chosenLabel,
|
||||||
|
required this.assignmentLabels,
|
||||||
|
required this.distinctAssignmentCount,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Collapse the routing of the containers sharing one isolation context.
|
||||||
|
///
|
||||||
|
/// Precedence: any explicit proxy wins (lowest sorted id); else a direct
|
||||||
|
/// connection wins only if no container inherits; else inherit.
|
||||||
|
IsolationContextRouting resolveIsolationContextRouting(
|
||||||
|
Iterable<ProxyAssignment> assignments,
|
||||||
|
) {
|
||||||
|
final proxyIds =
|
||||||
|
assignments
|
||||||
|
.whereType<ExplicitProxyAssignment>()
|
||||||
|
.map((assignment) => assignment.proxyId)
|
||||||
|
.toSet()
|
||||||
|
.toList()
|
||||||
|
..sort();
|
||||||
|
final directScopeIds =
|
||||||
|
assignments
|
||||||
|
.whereType<DirectProxyAssignment>()
|
||||||
|
.map((assignment) => assignment.scopeId)
|
||||||
|
.toSet()
|
||||||
|
.toList()
|
||||||
|
..sort();
|
||||||
|
final hasInheritedAssignment = assignments.any(
|
||||||
|
(assignment) => assignment is InheritProxyAssignment,
|
||||||
|
);
|
||||||
|
|
||||||
|
final chosen = proxyIds.isNotEmpty
|
||||||
|
? ProxyAssignment.explicit(proxyIds.first)
|
||||||
|
: directScopeIds.isNotEmpty && !hasInheritedAssignment
|
||||||
|
? ProxyAssignment.direct(directScopeIds.first)
|
||||||
|
: ProxyAssignment.inherit();
|
||||||
|
|
||||||
|
final chosenLabel = switch (chosen) {
|
||||||
|
DirectProxyAssignment(:final scopeId) => 'direct:$scopeId',
|
||||||
|
ExplicitProxyAssignment(:final proxyId) => proxyId,
|
||||||
|
InheritProxyAssignment() => 'inherit',
|
||||||
|
};
|
||||||
|
|
||||||
|
return IsolationContextRouting(
|
||||||
|
chosen: chosen,
|
||||||
|
chosenLabel: chosenLabel,
|
||||||
|
assignmentLabels: [
|
||||||
|
if (hasInheritedAssignment) 'inherit',
|
||||||
|
...directScopeIds.map((id) => 'direct:$id'),
|
||||||
|
...proxyIds,
|
||||||
|
],
|
||||||
|
distinctAssignmentCount:
|
||||||
|
proxyIds.length +
|
||||||
|
directScopeIds.length +
|
||||||
|
(hasInheritedAssignment ? 1 : 0),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether a tab whose container resolves to [assignment] is effectively
|
||||||
|
/// proxied — the app-link "protected context" test (§2.3).
|
||||||
|
///
|
||||||
|
/// - `explicit` → proxied
|
||||||
|
/// - `direct` → never proxied (deliberately bypasses the global proxy)
|
||||||
|
/// - `inherit` → proxied iff the global (`general`) route is a proxy
|
||||||
|
bool isAssignmentProtected(
|
||||||
|
ProxyAssignment assignment, {
|
||||||
|
required bool protectGeneralContext,
|
||||||
|
}) {
|
||||||
|
return switch (assignment) {
|
||||||
|
ExplicitProxyAssignment() => true,
|
||||||
|
DirectProxyAssignment() => false,
|
||||||
|
InheritProxyAssignment() => protectGeneralContext,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A target-side protection pattern (§2.3/§2.8). Any navigation target assigned
|
||||||
|
/// to an effectively-proxied or strict container is protected independent of the
|
||||||
|
/// source tab, because site assignment moves the URL into its container
|
||||||
|
/// *asynchronously*, after the navigation.
|
||||||
|
class ProtectedTargetPattern with FastEquatable {
|
||||||
|
final String scheme;
|
||||||
|
final String hostOrSuffix;
|
||||||
|
final bool includeSubdomains;
|
||||||
|
|
||||||
|
/// Effective port for exact entries; null for wildcard entries (which ignore
|
||||||
|
/// port), preserving [siteAssignmentMatches] semantics.
|
||||||
|
final int? port;
|
||||||
|
|
||||||
|
ProtectedTargetPattern({
|
||||||
|
required this.scheme,
|
||||||
|
required this.hostOrSuffix,
|
||||||
|
required this.includeSubdomains,
|
||||||
|
required this.port,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<Object?> get hashParameters => [
|
||||||
|
scheme,
|
||||||
|
hostOrSuffix,
|
||||||
|
includeSubdomains,
|
||||||
|
port,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build the [ProtectedTargetPattern] for a single site-assignment [Uri],
|
||||||
|
/// preserving [siteAssignmentMatches] semantics: wildcard (`*.host`) entries
|
||||||
|
/// match apex+subdomains for the scheme and ignore port; exact entries compare
|
||||||
|
/// scheme + origin (including effective port).
|
||||||
|
ProtectedTargetPattern protectedTargetPatternForSite(Uri assignedSite) {
|
||||||
|
if (isWildcardSite(assignedSite)) {
|
||||||
|
return ProtectedTargetPattern(
|
||||||
|
scheme: assignedSite.scheme,
|
||||||
|
hostOrSuffix: assignedSite.host.substring('*.'.length),
|
||||||
|
includeSubdomains: true,
|
||||||
|
port: null,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return ProtectedTargetPattern(
|
||||||
|
scheme: assignedSite.scheme,
|
||||||
|
hostOrSuffix: assignedSite.host,
|
||||||
|
includeSubdomains: false,
|
||||||
|
// Uri.port yields the effective port (scheme default when unspecified), so
|
||||||
|
// exact entries preserve the effective port as `siteAssignmentMatches` does
|
||||||
|
// via origin comparison.
|
||||||
|
port: assignedSite.port,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Compute the protected target patterns from all site assignments, keeping only
|
||||||
|
/// those whose container [contextualIdentity] is effectively proxied or strict
|
||||||
|
/// ([protectedOrStrictContextIds]). Deduplicated.
|
||||||
|
List<ProtectedTargetPattern> computeProtectedTargetPatterns({
|
||||||
|
required Iterable<SiteAssignment> assignments,
|
||||||
|
required Set<String> protectedOrStrictContextIds,
|
||||||
|
}) {
|
||||||
|
final patterns = <ProtectedTargetPattern>{};
|
||||||
|
for (final assignment in assignments) {
|
||||||
|
final contextId = assignment.contextualIdentity;
|
||||||
|
if (contextId == null) continue;
|
||||||
|
if (!protectedOrStrictContextIds.contains(contextId)) continue;
|
||||||
|
patterns.add(protectedTargetPatternForSite(assignment.assignedSite));
|
||||||
|
}
|
||||||
|
return patterns.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The complete app-link protection view (§2.3) replicated to native.
|
||||||
|
class AppLinkProtection with FastEquatable {
|
||||||
|
/// Regular / no-contextId tabs are proxied via the `general` scope.
|
||||||
|
final bool protectGeneralContext;
|
||||||
|
|
||||||
|
/// contextIds (containers and isolation contexts) that resolve to a proxy.
|
||||||
|
final Set<String> protectedContextIds;
|
||||||
|
|
||||||
|
/// strictMode-enforced contextIds, independent of routing.
|
||||||
|
final Set<String> strictContextIds;
|
||||||
|
|
||||||
|
final List<ProtectedTargetPattern> protectedTargetPatterns;
|
||||||
|
|
||||||
|
AppLinkProtection({
|
||||||
|
required this.protectGeneralContext,
|
||||||
|
required this.protectedContextIds,
|
||||||
|
required this.strictContextIds,
|
||||||
|
required this.protectedTargetPatterns,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<Object?> get hashParameters => [
|
||||||
|
protectGeneralContext,
|
||||||
|
protectedContextIds,
|
||||||
|
strictContextIds,
|
||||||
|
protectedTargetPatterns,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pure protection computation from the routing/container/assignment inputs
|
||||||
|
/// (§2.3). A container's contextId is protected when its effective assignment is
|
||||||
|
/// proxied; an isolation context is protected when the alias it collapses to is
|
||||||
|
/// proxied; strict contexts are always protected. Target patterns cover any site
|
||||||
|
/// assigned to a protected or strict container.
|
||||||
|
AppLinkProtection computeAppLinkProtection({
|
||||||
|
required bool protectGeneralContext,
|
||||||
|
required Iterable<ContainerData> containers,
|
||||||
|
required Map<String, Set<String>> isolationContextContainerMap,
|
||||||
|
required Set<String> strictContextIds,
|
||||||
|
required Iterable<SiteAssignment> siteAssignments,
|
||||||
|
}) {
|
||||||
|
final assignmentByContextId = <String, ProxyAssignment>{};
|
||||||
|
final assignmentByContainerId = <String, ProxyAssignment>{};
|
||||||
|
|
||||||
|
for (final container in containers) {
|
||||||
|
final contextId = container.metadata.contextualIdentity;
|
||||||
|
if (contextId == null || contextId.isEmpty) continue;
|
||||||
|
final assignment = resolveContainerAssignment(
|
||||||
|
contextId: contextId,
|
||||||
|
proxyConnectionId: container.metadata.proxyConnectionId,
|
||||||
|
bypassGlobalProxy: container.metadata.bypassGlobalProxy,
|
||||||
|
);
|
||||||
|
assignmentByContextId[contextId] = assignment;
|
||||||
|
assignmentByContainerId[container.id] = assignment;
|
||||||
|
}
|
||||||
|
|
||||||
|
final protectedContextIds = <String>{};
|
||||||
|
for (final MapEntry(:key, :value) in assignmentByContextId.entries) {
|
||||||
|
if (isAssignmentProtected(value, protectGeneralContext: protectGeneralContext)) {
|
||||||
|
protectedContextIds.add(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (final MapEntry(:key, :value) in isolationContextContainerMap.entries) {
|
||||||
|
final assignments = value
|
||||||
|
.map((containerId) => assignmentByContainerId[containerId])
|
||||||
|
.nonNulls
|
||||||
|
.toList();
|
||||||
|
if (assignments.isEmpty) continue;
|
||||||
|
final chosen = resolveIsolationContextRouting(assignments).chosen;
|
||||||
|
if (isAssignmentProtected(chosen, protectGeneralContext: protectGeneralContext)) {
|
||||||
|
protectedContextIds.add(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final protectedOrStrict = {...protectedContextIds, ...strictContextIds};
|
||||||
|
final patterns = computeProtectedTargetPatterns(
|
||||||
|
assignments: siteAssignments,
|
||||||
|
protectedOrStrictContextIds: protectedOrStrict,
|
||||||
|
);
|
||||||
|
|
||||||
|
return AppLinkProtection(
|
||||||
|
protectGeneralContext: protectGeneralContext,
|
||||||
|
protectedContextIds: protectedContextIds,
|
||||||
|
strictContextIds: strictContextIds,
|
||||||
|
protectedTargetPatterns: patterns,
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2024-2026 Fabian Freund.
|
||||||
|
*
|
||||||
|
* This file is part of WebLibre
|
||||||
|
* (see https://weblibre.eu).
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU Affero General Public License as
|
||||||
|
* published by the Free Software Foundation, either version 3 of the
|
||||||
|
* License, or (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU Affero General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU Affero General Public License
|
||||||
|
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||||
|
import 'package:flutter_mozilla_components/flutter_mozilla_components.dart';
|
||||||
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
|
import 'package:weblibre/features/app_links/domain/services/app_links_coordinator.dart';
|
||||||
|
import 'package:weblibre/features/app_links/presentation/widgets/app_link_prompt_dialog.dart';
|
||||||
|
|
||||||
|
/// Non-modal banner for an http(s) app link (§2.2). The page is allowed to load
|
||||||
|
/// while the banner is up; nothing blocks on it. Declining leaves the page
|
||||||
|
/// loaded; choosing the app leaves the tab on the committed page.
|
||||||
|
class AppLinkOpenBanner extends HookConsumerWidget {
|
||||||
|
final AppLinkPromptRequest request;
|
||||||
|
|
||||||
|
const AppLinkOpenBanner({super.key, required this.request});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final target = request.target;
|
||||||
|
final appName = target.appName;
|
||||||
|
final remember = useState(false);
|
||||||
|
final coordinator = ref.read(appLinksCoordinatorProvider.notifier);
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
|
||||||
|
Future<void> resolve(AppLinkDecision decision) async {
|
||||||
|
if (remember.value && request.canRemember) {
|
||||||
|
final rule = decision == AppLinkDecision.open
|
||||||
|
? alwaysOpenRuleFor(target)
|
||||||
|
: neverOpenRuleFor(target);
|
||||||
|
if (rule != null) {
|
||||||
|
await coordinator.resolveWithRule(
|
||||||
|
request.requestId,
|
||||||
|
decision,
|
||||||
|
rule,
|
||||||
|
contextId: request.contextId,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await coordinator.resolve(request.requestId, decision);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Material(
|
||||||
|
elevation: 3,
|
||||||
|
color: theme.colorScheme.surfaceContainerHigh,
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(16, 8, 8, 8),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
const Icon(Icons.open_in_new, size: 20),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
appName != null
|
||||||
|
? 'Open this link in $appName?'
|
||||||
|
: 'Open this link in an app?',
|
||||||
|
style: theme.textTheme.bodyMedium,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.close),
|
||||||
|
tooltip: 'Dismiss',
|
||||||
|
// A back/swipe/cancel resolves as dismiss (§2.6).
|
||||||
|
onPressed: () => resolve(AppLinkDecision.dismiss),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
if (request.canRemember)
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Checkbox(
|
||||||
|
value: remember.value,
|
||||||
|
onChanged: (value) => remember.value = value ?? false,
|
||||||
|
),
|
||||||
|
const Flexible(child: Text('Remember for this site')),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
Align(
|
||||||
|
alignment: Alignment.centerRight,
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => resolve(AppLinkDecision.cancel),
|
||||||
|
child: const Text('Stay in browser'),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
FilledButton(
|
||||||
|
onPressed: () => resolve(AppLinkDecision.open),
|
||||||
|
child: const Text('Open app'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
+131
@@ -0,0 +1,131 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2024-2026 Fabian Freund.
|
||||||
|
*
|
||||||
|
* This file is part of WebLibre
|
||||||
|
* (see https://weblibre.eu).
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU Affero General Public License as
|
||||||
|
* published by the Free Software Foundation, either version 3 of the
|
||||||
|
* License, or (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU Affero General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU Affero General Public License
|
||||||
|
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||||
|
import 'package:flutter_mozilla_components/flutter_mozilla_components.dart';
|
||||||
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
|
import 'package:weblibre/features/app_links/domain/entities/app_link_rule.dart';
|
||||||
|
import 'package:weblibre/features/app_links/domain/services/app_links_coordinator.dart';
|
||||||
|
|
||||||
|
/// Build the `alwaysOpen` rule for a target, or null when it cannot be remembered
|
||||||
|
/// (ambiguous resolution / no bound package).
|
||||||
|
PersistedAppLinkRule? alwaysOpenRuleFor(AppLinkTarget target) {
|
||||||
|
final packageName = target.packageName;
|
||||||
|
if (target.isAmbiguous || packageName == null || packageName.isEmpty) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return PersistedAppLinkRule(
|
||||||
|
decision: AppLinkRuleDecision.alwaysOpen,
|
||||||
|
scope: target.scopeKey,
|
||||||
|
packageName: packageName,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
PersistedAppLinkRule neverOpenRuleFor(AppLinkTarget target) {
|
||||||
|
return PersistedAppLinkRule(
|
||||||
|
decision: AppLinkRuleDecision.neverOpen,
|
||||||
|
scope: target.scopeKey,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Modal prompt for an unsupported-scheme app link (§2.2). The navigation is
|
||||||
|
/// genuinely stalled and there is no page to show behind it.
|
||||||
|
class AppLinkPromptDialog extends HookConsumerWidget {
|
||||||
|
final AppLinkPromptRequest request;
|
||||||
|
|
||||||
|
const AppLinkPromptDialog({super.key, required this.request});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final target = request.target;
|
||||||
|
final appName = target.appName;
|
||||||
|
final remember = useState(false);
|
||||||
|
final coordinator = ref.read(appLinksCoordinatorProvider.notifier);
|
||||||
|
// Guards against a double-tap running two resolves + two Navigator.pop()s
|
||||||
|
// (the second pop would tear down the route beneath the dialog).
|
||||||
|
final resolving = useRef(false);
|
||||||
|
|
||||||
|
Future<void> resolve(AppLinkDecision decision) async {
|
||||||
|
if (resolving.value) return;
|
||||||
|
resolving.value = true;
|
||||||
|
final navigator = Navigator.of(context);
|
||||||
|
if (remember.value && request.canRemember) {
|
||||||
|
final rule = decision == AppLinkDecision.open
|
||||||
|
? alwaysOpenRuleFor(target)
|
||||||
|
: neverOpenRuleFor(target);
|
||||||
|
if (rule != null) {
|
||||||
|
await coordinator.resolveWithRule(
|
||||||
|
request.requestId,
|
||||||
|
decision,
|
||||||
|
rule,
|
||||||
|
contextId: request.contextId,
|
||||||
|
);
|
||||||
|
navigator.pop();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await coordinator.resolve(request.requestId, decision);
|
||||||
|
navigator.pop();
|
||||||
|
}
|
||||||
|
|
||||||
|
return AlertDialog(
|
||||||
|
icon: const Icon(Icons.open_in_new),
|
||||||
|
title: Text(
|
||||||
|
appName != null ? 'Open in $appName?' : 'Open in another app?',
|
||||||
|
),
|
||||||
|
content: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
const Text('This link is handled by an app outside WebLibre.'),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
_displayScope(target.scopeKey),
|
||||||
|
style: Theme.of(context).textTheme.bodySmall,
|
||||||
|
),
|
||||||
|
if (request.canRemember)
|
||||||
|
CheckboxListTile(
|
||||||
|
contentPadding: EdgeInsets.zero,
|
||||||
|
controlAffinity: ListTileControlAffinity.leading,
|
||||||
|
value: remember.value,
|
||||||
|
onChanged: (value) => remember.value = value ?? false,
|
||||||
|
title: const Text('Remember my choice for this site'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => resolve(AppLinkDecision.cancel),
|
||||||
|
child: const Text('Cancel'),
|
||||||
|
),
|
||||||
|
FilledButton(
|
||||||
|
onPressed: () => resolve(AppLinkDecision.open),
|
||||||
|
child: const Text('Open'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String _displayScope(String scope) {
|
||||||
|
if (scope.startsWith('host:')) return scope.substring('host:'.length);
|
||||||
|
if (scope.startsWith('pkg:')) return scope.substring('pkg:'.length);
|
||||||
|
return scope;
|
||||||
|
}
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2024-2026 Fabian Freund.
|
||||||
|
*
|
||||||
|
* This file is part of WebLibre
|
||||||
|
* (see https://weblibre.eu).
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU Affero General Public License as
|
||||||
|
* published by the Free Software Foundation, either version 3 of the
|
||||||
|
* License, or (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU Affero General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU Affero General Public License
|
||||||
|
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||||
|
import 'package:flutter_mozilla_components/flutter_mozilla_components.dart';
|
||||||
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
|
import 'package:weblibre/features/app_links/domain/services/app_links_coordinator.dart';
|
||||||
|
import 'package:weblibre/features/app_links/presentation/widgets/app_link_open_banner.dart';
|
||||||
|
import 'package:weblibre/features/app_links/presentation/widgets/app_link_prompt_dialog.dart';
|
||||||
|
import 'package:weblibre/features/geckoview/domain/providers/selected_tab.dart';
|
||||||
|
|
||||||
|
/// Presents Flutter-owned app-link prompts (§2.6): renders at most one banner for
|
||||||
|
/// the active tab, and drives one modal at a time via [showDialog]. A request is
|
||||||
|
/// only shown while its originating tab is active. Rotation/teardown is not a
|
||||||
|
/// dismissal — the request stays pending and is re-presented on the next query.
|
||||||
|
///
|
||||||
|
/// Mount this as a layer of the browser Stack that is positioned *above* the bottom
|
||||||
|
/// app bar (see `browser.dart`, next to the find-in-page layer). It renders the
|
||||||
|
/// banner inline, bottom-anchored within that positioned region — Flutter composites
|
||||||
|
/// over the live GeckoView fine (the toolbars do the same); the only requirement is
|
||||||
|
/// that the host is not placed underneath the bottom app bar.
|
||||||
|
class AppLinkPromptHost extends HookConsumerWidget {
|
||||||
|
const AppLinkPromptHost({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final prompts = ref.watch(appLinksCoordinatorProvider);
|
||||||
|
final activeTabId = ref.watch(selectedTabProvider);
|
||||||
|
|
||||||
|
// The Pigeon availability event has no replay: an event emitted while Flutter
|
||||||
|
// was detached is lost, so re-query the native pending store on resume (§2.6).
|
||||||
|
useOnAppLifecycleStateChange((previous, current) {
|
||||||
|
if (current == AppLifecycleState.resumed) {
|
||||||
|
unawaited(ref.read(appLinksCoordinatorProvider.notifier).refresh());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
final activeRequests = prompts
|
||||||
|
.where((request) => request.tabId == activeTabId)
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
final modalRequest = activeRequests
|
||||||
|
.where((request) => request.isModal)
|
||||||
|
.lastOrNull;
|
||||||
|
// At most one banner per tab; a newer banner-class request simply becomes the
|
||||||
|
// one the UI renders.
|
||||||
|
final bannerRequest = activeRequests
|
||||||
|
.where((request) => !request.isModal)
|
||||||
|
.lastOrNull;
|
||||||
|
|
||||||
|
// A modal is shown at most once per requestId. Rotation/teardown is not a
|
||||||
|
// dismissal — the request stays pending and is re-presented on the next query
|
||||||
|
// (a subsequent build re-runs this effect with the still-present id).
|
||||||
|
final shownModalId = useRef<int?>(null);
|
||||||
|
useEffect(() {
|
||||||
|
final request = modalRequest;
|
||||||
|
if (request == null) {
|
||||||
|
shownModalId.value = null;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (shownModalId.value == request.requestId) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
shownModalId.value = request.requestId;
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
if (!context.mounted) return;
|
||||||
|
unawaited(
|
||||||
|
showDialog<void>(
|
||||||
|
context: context,
|
||||||
|
builder: (_) => AppLinkPromptDialog(request: request),
|
||||||
|
).then((_) {
|
||||||
|
// Catch-all for a passive dismissal (Android back / touch-outside):
|
||||||
|
// the dialog buttons resolve the request themselves, but a barrier
|
||||||
|
// dismiss closes it without resolving, leaving the native request
|
||||||
|
// pending forever (and `shownModalId` blocks a re-show). Resolving as
|
||||||
|
// dismiss here is idempotent — if a button already consumed it, the
|
||||||
|
// native store returns stale and this is a no-op.
|
||||||
|
unawaited(
|
||||||
|
ref
|
||||||
|
.read(appLinksCoordinatorProvider.notifier)
|
||||||
|
.resolve(request.requestId, AppLinkDecision.dismiss),
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
return null;
|
||||||
|
}, [modalRequest?.requestId]);
|
||||||
|
|
||||||
|
if (bannerRequest == null) {
|
||||||
|
return const SizedBox.shrink();
|
||||||
|
}
|
||||||
|
|
||||||
|
return AppLinkOpenBanner(
|
||||||
|
key: ValueKey(bannerRequest.requestId),
|
||||||
|
request: bannerRequest,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
+178
@@ -0,0 +1,178 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2024-2026 Fabian Freund.
|
||||||
|
*
|
||||||
|
* This file is part of WebLibre
|
||||||
|
* (see https://weblibre.eu).
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU Affero General Public License as
|
||||||
|
* published by the Free Software Foundation, either version 3 of the
|
||||||
|
* License, or (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU Affero General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU Affero General Public License
|
||||||
|
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_material_design_icons/flutter_material_design_icons.dart';
|
||||||
|
import 'package:flutter_mozilla_components/flutter_mozilla_components.dart'
|
||||||
|
show AppLinksMode;
|
||||||
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
|
import 'package:weblibre/features/app_links/domain/entities/app_link_rule.dart';
|
||||||
|
import 'package:weblibre/features/app_links/domain/entities/context_app_link_policy.dart';
|
||||||
|
import 'package:weblibre/features/settings/presentation/controllers/save_settings.dart';
|
||||||
|
import 'package:weblibre/features/user/data/models/general_settings.dart';
|
||||||
|
import 'package:weblibre/features/user/domain/repositories/general_settings.dart';
|
||||||
|
|
||||||
|
/// Per-container app-link settings (§ container isolation), bound to
|
||||||
|
/// `GeneralSettings.appLinkContextOverrides[contextId]`. Mirrors the global
|
||||||
|
/// app-links section but writes into the container's own override bucket, which
|
||||||
|
/// fully replaces the global mode + rules for that container (replace semantics).
|
||||||
|
///
|
||||||
|
/// Present via `showDialog`; edits save live (no separate confirm step), matching
|
||||||
|
/// the global settings screen. Only meaningful for an isolated, cookie-isolated
|
||||||
|
/// container — the caller gates on that.
|
||||||
|
class ContainerAppLinkSettingsDialog extends ConsumerWidget {
|
||||||
|
/// The container's Gecko contextId (`contextualIdentity`); the override key.
|
||||||
|
final String contextId;
|
||||||
|
|
||||||
|
/// Optional container name for the title.
|
||||||
|
final String? containerName;
|
||||||
|
|
||||||
|
const ContainerAppLinkSettingsDialog({
|
||||||
|
super.key,
|
||||||
|
required this.contextId,
|
||||||
|
this.containerName,
|
||||||
|
});
|
||||||
|
|
||||||
|
Future<void> _updateOverride(
|
||||||
|
WidgetRef ref,
|
||||||
|
ContextAppLinkPolicy Function(ContextAppLinkPolicy current) update,
|
||||||
|
) async {
|
||||||
|
await ref
|
||||||
|
.read(saveGeneralSettingsControllerProvider.notifier)
|
||||||
|
.save((current) {
|
||||||
|
final existing =
|
||||||
|
current.appLinkContextOverrides[contextId] ??
|
||||||
|
ContextAppLinkPolicy.blank();
|
||||||
|
return current.copyWith.appLinkContextOverrides({
|
||||||
|
...current.appLinkContextOverrides,
|
||||||
|
contextId: update(existing),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final override = ref.watch(
|
||||||
|
generalSettingsWithDefaultsProvider.select(
|
||||||
|
(s) => s.appLinkContextOverrides[contextId],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
final policy = override ?? ContextAppLinkPolicy.blank();
|
||||||
|
|
||||||
|
final rules = policy.rules.entries.toList()
|
||||||
|
..sort((a, b) => a.key.compareTo(b.key));
|
||||||
|
|
||||||
|
return Dialog.fullscreen(
|
||||||
|
child: Scaffold(
|
||||||
|
appBar: AppBar(
|
||||||
|
title: Text(
|
||||||
|
containerName != null
|
||||||
|
? 'App Links — $containerName'
|
||||||
|
: 'Container App Links',
|
||||||
|
),
|
||||||
|
leading: IconButton(
|
||||||
|
icon: const Icon(Icons.close),
|
||||||
|
onPressed: () => Navigator.of(context).pop(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
body: ListView(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||||
|
children: [
|
||||||
|
const Padding(
|
||||||
|
padding: EdgeInsets.fromLTRB(16, 8, 16, 0),
|
||||||
|
child: Text(
|
||||||
|
'These settings apply only to this container and fully replace '
|
||||||
|
'the global app-link settings for its tabs.',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
RadioGroup(
|
||||||
|
groupValue: policy.mode,
|
||||||
|
onChanged: (value) async {
|
||||||
|
if (value != null) {
|
||||||
|
await _updateOverride(ref, (c) => c.copyWith.mode(value));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
child: const Column(
|
||||||
|
children: [
|
||||||
|
RadioListTile.adaptive(
|
||||||
|
value: AppLinksMode.always,
|
||||||
|
title: Text('Always'),
|
||||||
|
subtitle: Text(
|
||||||
|
'Always open links in their native apps without asking',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
RadioListTile.adaptive(
|
||||||
|
value: AppLinksMode.ask,
|
||||||
|
title: Text('Ask before opening'),
|
||||||
|
subtitle: Text('Show a prompt before opening links in apps'),
|
||||||
|
),
|
||||||
|
RadioListTile.adaptive(
|
||||||
|
value: AppLinksMode.never,
|
||||||
|
title: Text('Never'),
|
||||||
|
subtitle: Text(
|
||||||
|
'Always open links in the browser instead of apps',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (rules.isNotEmpty) ...[
|
||||||
|
const Divider(),
|
||||||
|
const Padding(
|
||||||
|
padding: EdgeInsets.fromLTRB(16, 8, 16, 4),
|
||||||
|
child: Text('Remembered site rules'),
|
||||||
|
),
|
||||||
|
for (final MapEntry(:key, :value) in rules)
|
||||||
|
ListTile(
|
||||||
|
dense: true,
|
||||||
|
leading: Icon(
|
||||||
|
value.decision == AppLinkRuleDecision.alwaysOpen
|
||||||
|
? MdiIcons.openInApp
|
||||||
|
: Icons.public,
|
||||||
|
),
|
||||||
|
title: Text(_displayScope(key)),
|
||||||
|
subtitle: Text(
|
||||||
|
value.decision == AppLinkRuleDecision.alwaysOpen
|
||||||
|
? 'Always open in the app'
|
||||||
|
: 'Always keep in the browser',
|
||||||
|
),
|
||||||
|
trailing: IconButton(
|
||||||
|
icon: const Icon(Icons.delete_outline),
|
||||||
|
tooltip: 'Remove rule',
|
||||||
|
onPressed: () async {
|
||||||
|
await _updateOverride(
|
||||||
|
ref,
|
||||||
|
(c) => c.copyWith.rules({...c.rules}..remove(key)),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String _displayScope(String scope) {
|
||||||
|
if (scope.startsWith('host:')) return scope.substring('host:'.length);
|
||||||
|
if (scope.startsWith('pkg:')) return scope.substring('pkg:'.length);
|
||||||
|
return scope;
|
||||||
|
}
|
||||||
@@ -1267,18 +1267,3 @@ class _TabGroupRecord {
|
|||||||
required this.dateKey,
|
required this.dateKey,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@Riverpod()
|
|
||||||
class AppLinksModeNotifier extends _$AppLinksModeNotifier {
|
|
||||||
final _service = GeckoEngineSettingsService();
|
|
||||||
|
|
||||||
Future<void> setMode(AppLinksMode mode) async {
|
|
||||||
await _service.setAppLinksMode(mode);
|
|
||||||
ref.invalidateSelf();
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Future<AppLinksMode> build() {
|
|
||||||
return _service.getAppLinksMode();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1253,48 +1253,3 @@ final class GroupedTabListItemsFamily extends $Family
|
|||||||
@override
|
@override
|
||||||
String toString() => r'groupedTabListItemsProvider';
|
String toString() => r'groupedTabListItemsProvider';
|
||||||
}
|
}
|
||||||
|
|
||||||
@ProviderFor(AppLinksModeNotifier)
|
|
||||||
final appLinksModeProvider = AppLinksModeNotifierProvider._();
|
|
||||||
|
|
||||||
final class AppLinksModeNotifierProvider
|
|
||||||
extends $AsyncNotifierProvider<AppLinksModeNotifier, AppLinksMode> {
|
|
||||||
AppLinksModeNotifierProvider._()
|
|
||||||
: super(
|
|
||||||
from: null,
|
|
||||||
argument: null,
|
|
||||||
retry: null,
|
|
||||||
name: r'appLinksModeProvider',
|
|
||||||
isAutoDispose: true,
|
|
||||||
dependencies: null,
|
|
||||||
$allTransitiveDependencies: null,
|
|
||||||
);
|
|
||||||
|
|
||||||
@override
|
|
||||||
String debugGetCreateSourceHash() => _$appLinksModeNotifierHash();
|
|
||||||
|
|
||||||
@$internal
|
|
||||||
@override
|
|
||||||
AppLinksModeNotifier create() => AppLinksModeNotifier();
|
|
||||||
}
|
|
||||||
|
|
||||||
String _$appLinksModeNotifierHash() =>
|
|
||||||
r'2643b7d2799870fd444f7db204ea452d975368a3';
|
|
||||||
|
|
||||||
abstract class _$AppLinksModeNotifier extends $AsyncNotifier<AppLinksMode> {
|
|
||||||
FutureOr<AppLinksMode> build();
|
|
||||||
@$mustCallSuper
|
|
||||||
@override
|
|
||||||
WhenComplete runBuild() {
|
|
||||||
final ref = this.ref as $Ref<AsyncValue<AppLinksMode>, AppLinksMode>;
|
|
||||||
final element =
|
|
||||||
ref.element
|
|
||||||
as $ClassProviderElement<
|
|
||||||
AnyNotifier<AsyncValue<AppLinksMode>, AppLinksMode>,
|
|
||||||
AsyncValue<AppLinksMode>,
|
|
||||||
Object?,
|
|
||||||
Object?
|
|
||||||
>;
|
|
||||||
return element.handleCreate(ref, build);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
+1
-1
@@ -42,7 +42,7 @@ final class BrowserDataServiceProvider
|
|||||||
}
|
}
|
||||||
|
|
||||||
String _$browserDataServiceHash() =>
|
String _$browserDataServiceHash() =>
|
||||||
r'2df2f652342efc3e16606b92fdef6062b02f72df';
|
r'5df7ca0b61a5f34e69280311777e98fc31907269';
|
||||||
|
|
||||||
abstract class _$BrowserDataService extends $Notifier<void> {
|
abstract class _$BrowserDataService extends $Notifier<void> {
|
||||||
void build();
|
void build();
|
||||||
|
|||||||
+26
-94
@@ -22,6 +22,7 @@ import 'package:riverpod/riverpod.dart';
|
|||||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||||
import 'package:synchronized/synchronized.dart';
|
import 'package:synchronized/synchronized.dart';
|
||||||
import 'package:weblibre/core/logger.dart';
|
import 'package:weblibre/core/logger.dart';
|
||||||
|
import 'package:weblibre/features/app_links/domain/services/effective_routing.dart';
|
||||||
import 'package:weblibre/features/geckoview/features/tabs/data/models/container_data.dart';
|
import 'package:weblibre/features/geckoview/features/tabs/data/models/container_data.dart';
|
||||||
import 'package:weblibre/features/geckoview/features/tabs/data/providers.dart';
|
import 'package:weblibre/features/geckoview/features/tabs/data/providers.dart';
|
||||||
import 'package:weblibre/features/geckoview/features/tabs/domain/providers.dart';
|
import 'package:weblibre/features/geckoview/features/tabs/domain/providers.dart';
|
||||||
@@ -33,45 +34,10 @@ import 'package:weblibre/features/user/domain/repositories/proxy_routing_setting
|
|||||||
|
|
||||||
part 'proxy_settings_replication.g.dart';
|
part 'proxy_settings_replication.g.dart';
|
||||||
|
|
||||||
sealed class _ProxyAssignment with FastEquatable {
|
|
||||||
_ProxyAssignment();
|
|
||||||
|
|
||||||
factory _ProxyAssignment.inherit() = _InheritProxyAssignment;
|
|
||||||
|
|
||||||
factory _ProxyAssignment.direct(String scopeId) = _DirectProxyAssignment;
|
|
||||||
|
|
||||||
factory _ProxyAssignment.explicit(String proxyId) = _ExplicitProxyAssignment;
|
|
||||||
}
|
|
||||||
|
|
||||||
final class _InheritProxyAssignment extends _ProxyAssignment {
|
|
||||||
_InheritProxyAssignment();
|
|
||||||
|
|
||||||
@override
|
|
||||||
List<Object?> get hashParameters => const ['inherit'];
|
|
||||||
}
|
|
||||||
|
|
||||||
final class _DirectProxyAssignment extends _ProxyAssignment {
|
|
||||||
final String scopeId;
|
|
||||||
|
|
||||||
_DirectProxyAssignment(this.scopeId);
|
|
||||||
|
|
||||||
@override
|
|
||||||
List<Object?> get hashParameters => [scopeId];
|
|
||||||
}
|
|
||||||
|
|
||||||
final class _ExplicitProxyAssignment extends _ProxyAssignment {
|
|
||||||
final String proxyId;
|
|
||||||
|
|
||||||
_ExplicitProxyAssignment(this.proxyId);
|
|
||||||
|
|
||||||
@override
|
|
||||||
List<Object?> get hashParameters => [proxyId];
|
|
||||||
}
|
|
||||||
|
|
||||||
@Riverpod(keepAlive: true)
|
@Riverpod(keepAlive: true)
|
||||||
class ProxySettingsReplication extends _$ProxySettingsReplication {
|
class ProxySettingsReplication extends _$ProxySettingsReplication {
|
||||||
var _isolatedProxyAssignments = <String, _ProxyAssignment>{};
|
var _isolatedProxyAssignments = <String, ProxyAssignment>{};
|
||||||
var _appliedContainerProxies = <String, _ProxyAssignment>{};
|
var _appliedContainerProxies = <String, ProxyAssignment>{};
|
||||||
|
|
||||||
final _recomputeLock = Lock();
|
final _recomputeLock = Lock();
|
||||||
var _recomputeDirty = false;
|
var _recomputeDirty = false;
|
||||||
@@ -117,19 +83,17 @@ class ProxySettingsReplication extends _$ProxySettingsReplication {
|
|||||||
.read(containerRepositoryProvider.notifier)
|
.read(containerRepositoryProvider.notifier)
|
||||||
.getAllContainersWithCount();
|
.getAllContainersWithCount();
|
||||||
|
|
||||||
final containerAssignments = <String, _ProxyAssignment>{
|
final containerAssignments = <String, ProxyAssignment>{
|
||||||
for (final c in containers)
|
for (final c in containers)
|
||||||
if (c.metadata.contextualIdentity case final contextId?)
|
if (c.metadata.contextualIdentity case final contextId?)
|
||||||
c.id: switch (c.metadata.proxyConnectionId) {
|
c.id: resolveContainerAssignment(
|
||||||
final proxyId? => _ProxyAssignment.explicit(proxyId.encode()),
|
contextId: contextId,
|
||||||
null when c.metadata.bypassGlobalProxy => _ProxyAssignment.direct(
|
proxyConnectionId: c.metadata.proxyConnectionId,
|
||||||
contextId,
|
bypassGlobalProxy: c.metadata.bypassGlobalProxy,
|
||||||
),
|
),
|
||||||
null => _ProxyAssignment.inherit(),
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
final newAssignments = <String, _ProxyAssignment>{};
|
final newAssignments = <String, ProxyAssignment>{};
|
||||||
for (final entry in contextContainerMap.entries) {
|
for (final entry in contextContainerMap.entries) {
|
||||||
final assignments = entry.value
|
final assignments = entry.value
|
||||||
.map((containerId) => containerAssignments[containerId])
|
.map((containerId) => containerAssignments[containerId])
|
||||||
@@ -138,50 +102,19 @@ class ProxySettingsReplication extends _$ProxySettingsReplication {
|
|||||||
|
|
||||||
if (assignments.isEmpty) continue;
|
if (assignments.isEmpty) continue;
|
||||||
|
|
||||||
final proxyIds =
|
final routing = resolveIsolationContextRouting(assignments);
|
||||||
assignments
|
if (routing.chosen is! InheritProxyAssignment) {
|
||||||
.whereType<_ExplicitProxyAssignment>()
|
newAssignments[entry.key] = routing.chosen;
|
||||||
.map((assignment) => assignment.proxyId)
|
|
||||||
.toSet()
|
|
||||||
.toList()
|
|
||||||
..sort();
|
|
||||||
final directScopeIds =
|
|
||||||
assignments
|
|
||||||
.whereType<_DirectProxyAssignment>()
|
|
||||||
.map((assignment) => assignment.scopeId)
|
|
||||||
.toSet()
|
|
||||||
.toList()
|
|
||||||
..sort();
|
|
||||||
final hasInheritedAssignment = assignments.any(
|
|
||||||
(assignment) => assignment is _InheritProxyAssignment,
|
|
||||||
);
|
|
||||||
|
|
||||||
final chosenAssignment = proxyIds.isNotEmpty
|
|
||||||
? _ProxyAssignment.explicit(proxyIds.first)
|
|
||||||
: directScopeIds.isNotEmpty && !hasInheritedAssignment
|
|
||||||
? _ProxyAssignment.direct(directScopeIds.first)
|
|
||||||
: _ProxyAssignment.inherit();
|
|
||||||
if (chosenAssignment is! _InheritProxyAssignment) {
|
|
||||||
newAssignments[entry.key] = chosenAssignment;
|
|
||||||
}
|
}
|
||||||
final chosenLabel = switch (chosenAssignment) {
|
|
||||||
_DirectProxyAssignment(:final scopeId) => 'direct:$scopeId',
|
|
||||||
_ExplicitProxyAssignment(:final proxyId) => proxyId,
|
|
||||||
_InheritProxyAssignment() => 'inherit',
|
|
||||||
};
|
|
||||||
|
|
||||||
final distinctAssignmentCount =
|
if (routing.distinctAssignmentCount > 1) {
|
||||||
proxyIds.length +
|
|
||||||
directScopeIds.length +
|
|
||||||
(hasInheritedAssignment ? 1 : 0);
|
|
||||||
if (distinctAssignmentCount > 1) {
|
|
||||||
// Isolation contexts can hold multiple containers; if they disagree on
|
// Isolation contexts can hold multiple containers; if they disagree on
|
||||||
// routing, the alias is forced to pick one. Surface this so the user
|
// routing, the alias is forced to pick one. Surface this so the user
|
||||||
// can split the containers across isolation contexts.
|
// can split the containers across isolation contexts.
|
||||||
logger.w(
|
logger.w(
|
||||||
'Isolation context ${entry.key} has containers with multiple '
|
'Isolation context ${entry.key} has containers with multiple '
|
||||||
'proxy routing assignments '
|
'proxy routing assignments '
|
||||||
'(${[if (hasInheritedAssignment) 'inherit', ...directScopeIds.map((id) => 'direct:$id'), ...proxyIds].join(', ')}); using $chosenLabel',
|
'(${routing.assignmentLabels.join(', ')}); using ${routing.chosenLabel}',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -339,20 +272,19 @@ class ProxySettingsReplication extends _$ProxySettingsReplication {
|
|||||||
) async {
|
) async {
|
||||||
if (containers == null) return;
|
if (containers == null) return;
|
||||||
|
|
||||||
final desired = <String, _ProxyAssignment>{};
|
final desired = <String, ProxyAssignment>{};
|
||||||
for (final container in containers) {
|
for (final container in containers) {
|
||||||
final contextId = container.metadata.contextualIdentity;
|
final contextId = container.metadata.contextualIdentity;
|
||||||
if (contextId == null || contextId.isEmpty) continue;
|
if (contextId == null || contextId.isEmpty) continue;
|
||||||
final proxyConnectionId = container.metadata.proxyConnectionId;
|
desired[contextId] = resolveContainerAssignment(
|
||||||
desired[contextId] = proxyConnectionId != null
|
contextId: contextId,
|
||||||
? _ProxyAssignment.explicit(proxyConnectionId.encode())
|
proxyConnectionId: container.metadata.proxyConnectionId,
|
||||||
: container.metadata.bypassGlobalProxy
|
bypassGlobalProxy: container.metadata.bypassGlobalProxy,
|
||||||
? _ProxyAssignment.direct(contextId)
|
);
|
||||||
: _ProxyAssignment.inherit();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
final repo = ref.read(containerProxyRepositoryProvider.notifier);
|
final repo = ref.read(containerProxyRepositoryProvider.notifier);
|
||||||
final nextApplied = Map<String, _ProxyAssignment>.from(
|
final nextApplied = Map<String, ProxyAssignment>.from(
|
||||||
_appliedContainerProxies,
|
_appliedContainerProxies,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -395,15 +327,15 @@ class ProxySettingsReplication extends _$ProxySettingsReplication {
|
|||||||
|
|
||||||
Future<void> _applyProxyAssignment(
|
Future<void> _applyProxyAssignment(
|
||||||
String contextId,
|
String contextId,
|
||||||
_ProxyAssignment assignment,
|
ProxyAssignment assignment,
|
||||||
) async {
|
) async {
|
||||||
final repo = ref.read(containerProxyRepositoryProvider.notifier);
|
final repo = ref.read(containerProxyRepositoryProvider.notifier);
|
||||||
switch (assignment) {
|
switch (assignment) {
|
||||||
case _ExplicitProxyAssignment(:final proxyId):
|
case ExplicitProxyAssignment(:final proxyId):
|
||||||
await repo.setContainerProxy(contextId, proxyId);
|
await repo.setContainerProxy(contextId, proxyId);
|
||||||
case _DirectProxyAssignment(:final scopeId):
|
case DirectProxyAssignment(:final scopeId):
|
||||||
await repo.setContainerDirectConnection(contextId, scopeId: scopeId);
|
await repo.setContainerDirectConnection(contextId, scopeId: scopeId);
|
||||||
case _InheritProxyAssignment():
|
case InheritProxyAssignment():
|
||||||
await repo.clearContainerProxy(contextId);
|
await repo.clearContainerProxy(contextId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -42,7 +42,7 @@ final class ProxySettingsReplicationProvider
|
|||||||
}
|
}
|
||||||
|
|
||||||
String _$proxySettingsReplicationHash() =>
|
String _$proxySettingsReplicationHash() =>
|
||||||
r'69787c85c94ff165e3eeb0f0a3f3fc83e88a1b83';
|
r'bea07ab165545a6bef8a72ddf0503e0cd135eb8a';
|
||||||
|
|
||||||
abstract class _$ProxySettingsReplication extends $Notifier<void> {
|
abstract class _$ProxySettingsReplication extends $Notifier<void> {
|
||||||
void build();
|
void build();
|
||||||
|
|||||||
+1
-1
@@ -44,7 +44,7 @@ List<ToolbarButtonConfig> _buildDefaultToolbarButtonConfigs({
|
|||||||
return ToolbarButtonConfig(
|
return ToolbarButtonConfig(
|
||||||
buttonId: spec.id.name,
|
buttonId: spec.id.name,
|
||||||
orderKey: key,
|
orderKey: key,
|
||||||
isVisible: allHidden ? false : spec.defaultVisible,
|
isVisible: !allHidden && spec.defaultVisible,
|
||||||
fallbackId: allHidden ? null : spec.defaultFallback?.name,
|
fallbackId: allHidden ? null : spec.defaultFallback?.name,
|
||||||
);
|
);
|
||||||
}).toList();
|
}).toList();
|
||||||
|
|||||||
+34
@@ -29,6 +29,7 @@ import 'package:weblibre/core/providers/global_drop.dart';
|
|||||||
import 'package:weblibre/core/routing/routes.dart';
|
import 'package:weblibre/core/routing/routes.dart';
|
||||||
import 'package:weblibre/data/models/drag_data.dart';
|
import 'package:weblibre/data/models/drag_data.dart';
|
||||||
import 'package:weblibre/extensions/media_query.dart';
|
import 'package:weblibre/extensions/media_query.dart';
|
||||||
|
import 'package:weblibre/features/app_links/presentation/widgets/app_link_prompt_host.dart';
|
||||||
import 'package:weblibre/features/geckoview/domain/controllers/bottom_sheet.dart';
|
import 'package:weblibre/features/geckoview/domain/controllers/bottom_sheet.dart';
|
||||||
import 'package:weblibre/features/geckoview/domain/controllers/overlay.dart';
|
import 'package:weblibre/features/geckoview/domain/controllers/overlay.dart';
|
||||||
import 'package:weblibre/features/geckoview/domain/entities/states/tab.dart';
|
import 'package:weblibre/features/geckoview/domain/entities/states/tab.dart';
|
||||||
@@ -1276,6 +1277,39 @@ class BrowserScreen extends HookConsumerWidget {
|
|||||||
},
|
},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
|
// Layer 7: App-link prompt banner (§2.6). Anchored above the bottom app
|
||||||
|
// bar / keyboard exactly like find-in-page, so it is never hidden behind
|
||||||
|
// the toolbar. Custom Tab sessions are prompted natively instead; this is
|
||||||
|
// the browser-tab surface only.
|
||||||
|
Consumer(
|
||||||
|
builder: (context, ref, child) {
|
||||||
|
final toolbarState = ref.watch(
|
||||||
|
toolbarVisibilityControllerProvider(selectedTabId),
|
||||||
|
);
|
||||||
|
final visible =
|
||||||
|
sheetDisplayed ||
|
||||||
|
(!tabInFullScreen &&
|
||||||
|
toolbarState == ToolbarVisibility.visible);
|
||||||
|
return Positioned(
|
||||||
|
left: (tabBarPosition == TabBarPosition.left && visible)
|
||||||
|
? sideRailTotalWidth
|
||||||
|
: 0.0,
|
||||||
|
right: (tabBarPosition == TabBarPosition.right && visible)
|
||||||
|
? sideRailTotalWidth
|
||||||
|
: 0.0,
|
||||||
|
bottom: math.max(
|
||||||
|
isRail
|
||||||
|
? bottomSafeArea
|
||||||
|
: (visible
|
||||||
|
? bottomAppBarTotalHeight
|
||||||
|
: bottomSafeArea),
|
||||||
|
MediaQuery.viewInsetsOf(context).bottom,
|
||||||
|
),
|
||||||
|
child: const AppLinkPromptHost(),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
+8
-5
@@ -1575,22 +1575,25 @@ class _OpenInAppTile extends HookConsumerWidget {
|
|||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final tabState = ref.watch(tabStateProvider(selectedTabId));
|
final tabState = ref.watch(tabStateProvider(selectedTabId));
|
||||||
final url = tabState?.url;
|
final url = tabState?.url;
|
||||||
final hasExternalApp = useCachedFuture(
|
final appLink = useCachedFuture(
|
||||||
() => url != null ? _service.hasExternalApp(url) : Future.value(false),
|
() => url != null ? _service.resolveAppLink(url) : Future.value(null),
|
||||||
[url],
|
[url],
|
||||||
);
|
);
|
||||||
|
|
||||||
if (hasExternalApp.data != true) return const SizedBox.shrink();
|
final target = appLink.data;
|
||||||
|
if (target == null) return const SizedBox.shrink();
|
||||||
|
|
||||||
|
final appName = target.appName;
|
||||||
|
|
||||||
return Column(
|
return Column(
|
||||||
children: [
|
children: [
|
||||||
_buildDivider(),
|
_buildDivider(),
|
||||||
ListTile(
|
ListTile(
|
||||||
leading: const Icon(Icons.open_in_new),
|
leading: const Icon(Icons.open_in_new),
|
||||||
title: const Text('Open in App'),
|
title: Text(appName != null ? 'Open in $appName' : 'Open in App'),
|
||||||
onTap: () async {
|
onTap: () async {
|
||||||
if (url == null) return;
|
if (url == null) return;
|
||||||
final success = await _service.openAppLink(url);
|
final success = await _service.launchAppLink(url);
|
||||||
if (success && context.mounted) Navigator.pop(context);
|
if (success && context.mounted) Navigator.pop(context);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
|||||||
+14
@@ -30,6 +30,7 @@ import 'package:weblibre/core/logger.dart';
|
|||||||
import 'package:weblibre/core/providers/device_info.dart';
|
import 'package:weblibre/core/providers/device_info.dart';
|
||||||
import 'package:weblibre/core/providers/router.dart';
|
import 'package:weblibre/core/providers/router.dart';
|
||||||
import 'package:weblibre/core/routing/routes.dart';
|
import 'package:weblibre/core/routing/routes.dart';
|
||||||
|
import 'package:weblibre/features/app_links/domain/services/app_link_policy_replication.dart';
|
||||||
import 'package:weblibre/features/bangs/data/models/web_search_bang.dart';
|
import 'package:weblibre/features/bangs/data/models/web_search_bang.dart';
|
||||||
import 'package:weblibre/features/bangs/domain/providers/bangs.dart';
|
import 'package:weblibre/features/bangs/domain/providers/bangs.dart';
|
||||||
import 'package:weblibre/features/bangs/domain/services/search_history_cleanup.dart';
|
import 'package:weblibre/features/bangs/domain/services/search_history_cleanup.dart';
|
||||||
@@ -676,6 +677,19 @@ class _BrowserViewState extends ConsumerState<BrowserView>
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
ref.listenManual(
|
||||||
|
fireImmediately: true,
|
||||||
|
appLinkPolicyReplicationProvider,
|
||||||
|
(previous, next) {},
|
||||||
|
onError: (error, stackTrace) {
|
||||||
|
logger.e(
|
||||||
|
'Error listening to appLinkPolicyReplicationProvider',
|
||||||
|
error: error,
|
||||||
|
stackTrace: stackTrace,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
ref.listenManual(
|
ref.listenManual(
|
||||||
fireImmediately: true,
|
fireImmediately: true,
|
||||||
historyExclusionReplicationProvider,
|
historyExclusionReplicationProvider,
|
||||||
|
|||||||
+8
-5
@@ -329,24 +329,27 @@ class OpenInAppMenuItemButton extends HookConsumerWidget {
|
|||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final tabState = ref.watch(tabStateProvider(selectedTabId));
|
final tabState = ref.watch(tabStateProvider(selectedTabId));
|
||||||
final url = tabState?.url;
|
final url = tabState?.url;
|
||||||
final hasExternalApp = useCachedFuture(
|
final appLink = useCachedFuture(
|
||||||
// ignore: discarded_futures useFuture
|
// ignore: discarded_futures useFuture
|
||||||
() => url != null ? _service.hasExternalApp(url) : Future.value(false),
|
() => url != null ? _service.resolveAppLink(url) : Future.value(null),
|
||||||
[url],
|
[url],
|
||||||
);
|
);
|
||||||
|
|
||||||
if (hasExternalApp.data != true) {
|
final target = appLink.data;
|
||||||
|
if (target == null) {
|
||||||
return const SizedBox.shrink();
|
return const SizedBox.shrink();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
final appName = target.appName;
|
||||||
|
|
||||||
return MenuItemButton(
|
return MenuItemButton(
|
||||||
leadingIcon: const Icon(Icons.open_in_new),
|
leadingIcon: const Icon(Icons.open_in_new),
|
||||||
closeOnActivate: false,
|
closeOnActivate: false,
|
||||||
child: const Text('Open in App'),
|
child: Text(appName != null ? 'Open in $appName' : 'Open in App'),
|
||||||
onPressed: () async {
|
onPressed: () async {
|
||||||
if (url == null) return;
|
if (url == null) return;
|
||||||
|
|
||||||
final success = await _service.openAppLink(url);
|
final success = await _service.launchAppLink(url);
|
||||||
|
|
||||||
if (success && context.mounted) {
|
if (success && context.mounted) {
|
||||||
MenuController.maybeOf(context)?.close();
|
MenuController.maybeOf(context)?.close();
|
||||||
|
|||||||
+8
-5
@@ -357,19 +357,22 @@ class _OpenInAppTile extends HookConsumerWidget {
|
|||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final tabState = ref.watch(tabStateProvider(selectedTabId));
|
final tabState = ref.watch(tabStateProvider(selectedTabId));
|
||||||
final url = tabState?.url;
|
final url = tabState?.url;
|
||||||
final hasExternalApp = useCachedFuture(
|
final appLink = useCachedFuture(
|
||||||
() => url != null ? _service.hasExternalApp(url) : Future.value(false),
|
() => url != null ? _service.resolveAppLink(url) : Future.value(null),
|
||||||
[url],
|
[url],
|
||||||
);
|
);
|
||||||
|
|
||||||
if (hasExternalApp.data != true) return const SizedBox.shrink();
|
final target = appLink.data;
|
||||||
|
if (target == null) return const SizedBox.shrink();
|
||||||
|
|
||||||
|
final appName = target.appName;
|
||||||
|
|
||||||
return ListTile(
|
return ListTile(
|
||||||
leading: const Icon(Icons.open_in_new),
|
leading: const Icon(Icons.open_in_new),
|
||||||
title: const Text('Open in App'),
|
title: Text(appName != null ? 'Open in $appName' : 'Open in App'),
|
||||||
onTap: () async {
|
onTap: () async {
|
||||||
if (url == null) return;
|
if (url == null) return;
|
||||||
final success = await _service.openAppLink(url);
|
final success = await _service.launchAppLink(url);
|
||||||
if (success && context.mounted) Navigator.pop(context);
|
if (success && context.mounted) Navigator.pop(context);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
+238
@@ -0,0 +1,238 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2024-2026 Fabian Freund.
|
||||||
|
*
|
||||||
|
* This file is part of WebLibre
|
||||||
|
* (see https://weblibre.eu).
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU Affero General Public License as
|
||||||
|
* published by the Free Software Foundation, either version 3 of the
|
||||||
|
* License, or (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU Affero General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU Affero General Public License
|
||||||
|
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_material_design_icons/flutter_material_design_icons.dart';
|
||||||
|
import 'package:flutter_mozilla_components/flutter_mozilla_components.dart'
|
||||||
|
show AppLinkTarget, AppLinksMode, GeckoAppLinksService;
|
||||||
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
|
import 'package:skeletonizer/skeletonizer.dart';
|
||||||
|
import 'package:weblibre/features/app_links/domain/entities/app_link_rule.dart';
|
||||||
|
import 'package:weblibre/features/app_links/domain/entities/context_app_link_policy.dart';
|
||||||
|
import 'package:weblibre/features/app_links/domain/services/effective_app_link_policy.dart';
|
||||||
|
import 'package:weblibre/features/settings/presentation/controllers/save_settings.dart';
|
||||||
|
import 'package:weblibre/features/user/data/models/general_settings.dart';
|
||||||
|
|
||||||
|
final _appLinkTargetProvider = FutureProvider.autoDispose
|
||||||
|
.family<AppLinkTarget?, Uri>((ref, url) {
|
||||||
|
return GeckoAppLinksService().resolveAppLink(url);
|
||||||
|
});
|
||||||
|
|
||||||
|
enum _SiteRuleChoice { followDefault, alwaysOpen, neverOpen }
|
||||||
|
|
||||||
|
/// Section widget showing the app-link rule for the current tab's site. Edits
|
||||||
|
/// the effective bucket — the owning container's override when it has isolated
|
||||||
|
/// app-link settings, otherwise the global rules — but does not expose the
|
||||||
|
/// global/container default from this site-specific sheet.
|
||||||
|
class AppLinkSection extends HookConsumerWidget {
|
||||||
|
final Uri url;
|
||||||
|
|
||||||
|
/// The tab's live contextId (`TabState.contextId`): the container base
|
||||||
|
/// contextId for a regular tab, the isolation contextId for an isolated tab.
|
||||||
|
final String? contextId;
|
||||||
|
|
||||||
|
const AppLinkSection({required this.url, required this.contextId, super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final policy = ref.watch(effectiveAppLinkPolicyProvider(contextId));
|
||||||
|
final target = ref.watch(_appLinkTargetProvider(url));
|
||||||
|
final isLoadingTarget = target.isLoading && !target.hasValue;
|
||||||
|
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0),
|
||||||
|
child: Text(
|
||||||
|
'App Links',
|
||||||
|
style: Theme.of(context).textTheme.titleSmall?.copyWith(
|
||||||
|
color: Theme.of(context).colorScheme.primary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (policy == null || isLoadingTarget)
|
||||||
|
const Skeletonizer(
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
ListTile(
|
||||||
|
leading: Icon(Icons.link),
|
||||||
|
title: Text('Open links for this site'),
|
||||||
|
subtitle: Text('Follows the default'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
else
|
||||||
|
_SiteRuleTile(
|
||||||
|
policy: policy,
|
||||||
|
target: target.hasValue ? target.value : null,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _SiteRuleTile extends ConsumerWidget {
|
||||||
|
final EffectiveAppLinkPolicy policy;
|
||||||
|
final AppLinkTarget? target;
|
||||||
|
|
||||||
|
const _SiteRuleTile({required this.policy, required this.target});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final scope = target?.scopeKey;
|
||||||
|
final rule = (scope != null && scope.isNotEmpty)
|
||||||
|
? policy.rules[scope]
|
||||||
|
: null;
|
||||||
|
final choice = switch (rule?.decision) {
|
||||||
|
AppLinkRuleDecision.alwaysOpen => _SiteRuleChoice.alwaysOpen,
|
||||||
|
AppLinkRuleDecision.neverOpen => _SiteRuleChoice.neverOpen,
|
||||||
|
null => _SiteRuleChoice.followDefault,
|
||||||
|
};
|
||||||
|
final canAlwaysOpen = _alwaysOpenRuleFor(target) != null;
|
||||||
|
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
|
||||||
|
final (IconData icon, Color color) = switch (choice) {
|
||||||
|
_SiteRuleChoice.alwaysOpen => (MdiIcons.openInApp, colorScheme.primary),
|
||||||
|
_SiteRuleChoice.neverOpen => (Icons.public, colorScheme.primary),
|
||||||
|
_SiteRuleChoice.followDefault => (
|
||||||
|
Icons.link,
|
||||||
|
colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
return ListTile(
|
||||||
|
leading: Icon(icon, color: color),
|
||||||
|
title: const Text('Open links for this site'),
|
||||||
|
subtitle: Text(_subtitle(scope, rule, choice, canAlwaysOpen)),
|
||||||
|
trailing: DropdownButton<_SiteRuleChoice>(
|
||||||
|
value: choice,
|
||||||
|
underline: const SizedBox(),
|
||||||
|
items: [
|
||||||
|
const DropdownMenuItem(
|
||||||
|
value: _SiteRuleChoice.followDefault,
|
||||||
|
child: Text('Follow default'),
|
||||||
|
),
|
||||||
|
DropdownMenuItem(
|
||||||
|
value: _SiteRuleChoice.alwaysOpen,
|
||||||
|
enabled: canAlwaysOpen || choice == _SiteRuleChoice.alwaysOpen,
|
||||||
|
child: const Text('Open in app'),
|
||||||
|
),
|
||||||
|
const DropdownMenuItem(
|
||||||
|
value: _SiteRuleChoice.neverOpen,
|
||||||
|
child: Text('Keep in browser'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
onChanged: scope == null || scope.isEmpty
|
||||||
|
? null
|
||||||
|
: (value) async {
|
||||||
|
if (value != null && value != choice) {
|
||||||
|
await _setSiteRule(ref, scope, target, value);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
String _subtitle(
|
||||||
|
String? scope,
|
||||||
|
PersistedAppLinkRule? rule,
|
||||||
|
_SiteRuleChoice choice,
|
||||||
|
bool canAlwaysOpen,
|
||||||
|
) {
|
||||||
|
if (scope == null || scope.isEmpty) return 'No app found for this site';
|
||||||
|
return switch (choice) {
|
||||||
|
_SiteRuleChoice.alwaysOpen =>
|
||||||
|
'Always opens in ${rule!.packageName ?? 'the app'}',
|
||||||
|
_SiteRuleChoice.neverOpen => 'Always stays in the browser',
|
||||||
|
_SiteRuleChoice.followDefault => switch (policy.mode) {
|
||||||
|
AppLinksMode.always =>
|
||||||
|
canAlwaysOpen
|
||||||
|
? 'Follows the default: opens in apps'
|
||||||
|
: 'Follows the default: no app found',
|
||||||
|
AppLinksMode.ask => 'Follows the default: asks first',
|
||||||
|
AppLinksMode.never => 'Follows the default: stays in the browser',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _setSiteRule(
|
||||||
|
WidgetRef ref,
|
||||||
|
String scope,
|
||||||
|
AppLinkTarget? target,
|
||||||
|
_SiteRuleChoice choice,
|
||||||
|
) async {
|
||||||
|
Map<String, PersistedAppLinkRule> updateRules(
|
||||||
|
Map<String, PersistedAppLinkRule> rules,
|
||||||
|
) {
|
||||||
|
final next = {...rules};
|
||||||
|
switch (choice) {
|
||||||
|
case _SiteRuleChoice.followDefault:
|
||||||
|
next.remove(scope);
|
||||||
|
case _SiteRuleChoice.neverOpen:
|
||||||
|
next[scope] = PersistedAppLinkRule(
|
||||||
|
decision: AppLinkRuleDecision.neverOpen,
|
||||||
|
scope: scope,
|
||||||
|
);
|
||||||
|
case _SiteRuleChoice.alwaysOpen:
|
||||||
|
final rule = _alwaysOpenRuleFor(target);
|
||||||
|
if (rule != null) next[scope] = rule;
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
final overrideKey = policy.overrideKey;
|
||||||
|
await ref.read(saveGeneralSettingsControllerProvider.notifier).save((
|
||||||
|
current,
|
||||||
|
) {
|
||||||
|
if (overrideKey == null) {
|
||||||
|
return current.copyWith.appLinkRules(updateRules(current.appLinkRules));
|
||||||
|
}
|
||||||
|
final existing =
|
||||||
|
current.appLinkContextOverrides[overrideKey] ??
|
||||||
|
ContextAppLinkPolicy.blank();
|
||||||
|
return current.copyWith.appLinkContextOverrides({
|
||||||
|
...current.appLinkContextOverrides,
|
||||||
|
overrideKey: existing.copyWith.rules(updateRules(existing.rules)),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
PersistedAppLinkRule? _alwaysOpenRuleFor(AppLinkTarget? target) {
|
||||||
|
final packageName = target?.packageName;
|
||||||
|
final scope = target?.scopeKey;
|
||||||
|
if (target == null ||
|
||||||
|
target.isAmbiguous ||
|
||||||
|
packageName == null ||
|
||||||
|
packageName.isEmpty ||
|
||||||
|
scope == null ||
|
||||||
|
scope.isEmpty) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return PersistedAppLinkRule(
|
||||||
|
decision: AppLinkRuleDecision.alwaysOpen,
|
||||||
|
scope: scope,
|
||||||
|
packageName: packageName,
|
||||||
|
);
|
||||||
|
}
|
||||||
+7
@@ -26,6 +26,7 @@ import 'package:weblibre/core/routing/routes.dart';
|
|||||||
import 'package:weblibre/features/geckoview/domain/entities/states/tab.dart';
|
import 'package:weblibre/features/geckoview/domain/entities/states/tab.dart';
|
||||||
import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/certificate_tile.dart';
|
import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/certificate_tile.dart';
|
||||||
import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/draggable_scrollable_header.dart';
|
import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/draggable_scrollable_header.dart';
|
||||||
|
import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/sheets/app_link_section.dart';
|
||||||
import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/sheets/clear_site_data_section.dart';
|
import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/sheets/clear_site_data_section.dart';
|
||||||
import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/sheets/desktop_mode_section.dart';
|
import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/sheets/desktop_mode_section.dart';
|
||||||
import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/sheets/gesture_exclusion_section.dart';
|
import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/sheets/gesture_exclusion_section.dart';
|
||||||
@@ -164,6 +165,12 @@ class ViewTabSheetWidget extends HookConsumerWidget {
|
|||||||
url: initialTabState.url,
|
url: initialTabState.url,
|
||||||
),
|
),
|
||||||
const Divider(),
|
const Divider(),
|
||||||
|
// App Link Section
|
||||||
|
AppLinkSection(
|
||||||
|
url: initialTabState.url,
|
||||||
|
contextId: initialTabState.contextId,
|
||||||
|
),
|
||||||
|
const Divider(),
|
||||||
// Permissions Section
|
// Permissions Section
|
||||||
PermissionsSection(
|
PermissionsSection(
|
||||||
origin: initialTabState.url.origin,
|
origin: initialTabState.url.origin,
|
||||||
|
|||||||
+11
-3
@@ -23,6 +23,7 @@ import 'package:go_router/go_router.dart';
|
|||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:nullability/nullability.dart';
|
import 'package:nullability/nullability.dart';
|
||||||
import 'package:weblibre/features/geckoview/features/contextmenu/extensions/hit_result.dart';
|
import 'package:weblibre/features/geckoview/features/contextmenu/extensions/hit_result.dart';
|
||||||
|
import 'package:weblibre/presentation/hooks/cached_future.dart';
|
||||||
|
|
||||||
class LaunchExternal extends HookConsumerWidget {
|
class LaunchExternal extends HookConsumerWidget {
|
||||||
final HitResult hitResult;
|
final HitResult hitResult;
|
||||||
@@ -33,19 +34,26 @@ class LaunchExternal extends HookConsumerWidget {
|
|||||||
|
|
||||||
static Future<bool> isSupported(HitResult hitResult) async {
|
static Future<bool> isSupported(HitResult hitResult) async {
|
||||||
return hitResult.tryGetLink().mapNotNull(
|
return hitResult.tryGetLink().mapNotNull(
|
||||||
(url) => _service.hasExternalApp(url),
|
(url) async => (await _service.resolveAppLink(url)) != null,
|
||||||
) ??
|
) ??
|
||||||
false;
|
false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final url = hitResult.tryGetLink();
|
||||||
|
final appLink = useCachedFuture(
|
||||||
|
() => url != null ? _service.resolveAppLink(url) : Future.value(null),
|
||||||
|
[url],
|
||||||
|
);
|
||||||
|
final appName = appLink.data?.appName;
|
||||||
|
|
||||||
return ListTile(
|
return ListTile(
|
||||||
leading: const Icon(Icons.open_in_new),
|
leading: const Icon(Icons.open_in_new),
|
||||||
title: const Text('Open in App'),
|
title: Text(appName != null ? 'Open in $appName' : 'Open in App'),
|
||||||
onTap: () async {
|
onTap: () async {
|
||||||
await hitResult.tryGetLink().mapNotNull((url) async {
|
await hitResult.tryGetLink().mapNotNull((url) async {
|
||||||
final success = await _service.openAppLink(url);
|
final success = await _service.launchAppLink(url);
|
||||||
|
|
||||||
if (success && context.mounted) {
|
if (success && context.mounted) {
|
||||||
context.pop();
|
context.pop();
|
||||||
|
|||||||
+8
-6
@@ -184,11 +184,11 @@ class OpenSharedContent extends HookConsumerWidget {
|
|||||||
};
|
};
|
||||||
}, [containerMode, contextId, selectionUrlKey, globalSelectedContainer]);
|
}, [containerMode, contextId, selectionUrlKey, globalSelectedContainer]);
|
||||||
|
|
||||||
final hasExternalApp = useCachedFuture(
|
final appLink = useCachedFuture(
|
||||||
// ignore: discarded_futures useFuture
|
// ignore: discarded_futures useFuture
|
||||||
() => parsedDebouncedUrl != null
|
() => parsedDebouncedUrl != null
|
||||||
? _appLinksService.hasExternalApp(parsedDebouncedUrl)
|
? _appLinksService.resolveAppLink(parsedDebouncedUrl)
|
||||||
: Future.value(false),
|
: Future.value(null),
|
||||||
[parsedDebouncedUrl],
|
[parsedDebouncedUrl],
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -288,7 +288,7 @@ class OpenSharedContent extends HookConsumerWidget {
|
|||||||
final uri = parseValidatedUrl(textController.text, eagerParsing: false);
|
final uri = parseValidatedUrl(textController.text, eagerParsing: false);
|
||||||
if (uri == null) return;
|
if (uri == null) return;
|
||||||
|
|
||||||
final success = await _appLinksService.openAppLink(uri);
|
final success = await _appLinksService.launchAppLink(uri);
|
||||||
|
|
||||||
if (success && context.mounted) {
|
if (success && context.mounted) {
|
||||||
context.pop(true);
|
context.pop(true);
|
||||||
@@ -429,9 +429,11 @@ class OpenSharedContent extends HookConsumerWidget {
|
|||||||
},
|
},
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
if (hasExternalApp.data == true)
|
if (appLink.data != null)
|
||||||
_OpenActionTile(
|
_OpenActionTile(
|
||||||
title: 'Open in App',
|
title: appLink.data?.appName != null
|
||||||
|
? 'Open in ${appLink.data!.appName}'
|
||||||
|
: 'Open in App',
|
||||||
subtitle: 'Open in an installed app',
|
subtitle: 'Open in an installed app',
|
||||||
icon: Icons.open_in_new,
|
icon: Icons.open_in_new,
|
||||||
onTap: openInApp,
|
onTap: openInApp,
|
||||||
|
|||||||
@@ -19,11 +19,8 @@
|
|||||||
*/
|
*/
|
||||||
import 'package:drift/drift.dart';
|
import 'package:drift/drift.dart';
|
||||||
import 'package:drift/internal/versioned_schema.dart';
|
import 'package:drift/internal/versioned_schema.dart';
|
||||||
import 'package:drift_dev/api/migrations_native.dart';
|
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:lexo_rank/lexo_rank.dart';
|
import 'package:lexo_rank/lexo_rank.dart';
|
||||||
import 'package:weblibre/data/database/functions/lexo_rank_functions.dart';
|
|
||||||
import 'package:weblibre/data/database/functions/url_functions.dart';
|
|
||||||
import 'package:weblibre/features/geckoview/features/tabs/data/database/daos/capture_tab.dart';
|
import 'package:weblibre/features/geckoview/features/tabs/data/database/daos/capture_tab.dart';
|
||||||
import 'package:weblibre/features/geckoview/features/tabs/data/database/daos/container.dart';
|
import 'package:weblibre/features/geckoview/features/tabs/data/database/daos/container.dart';
|
||||||
import 'package:weblibre/features/geckoview/features/tabs/data/database/daos/history.dart';
|
import 'package:weblibre/features/geckoview/features/tabs/data/database/daos/history.dart';
|
||||||
|
|||||||
@@ -83,6 +83,16 @@ class ContainerMetadata with FastEquatable {
|
|||||||
@JsonKey(defaultValue: false)
|
@JsonKey(defaultValue: false)
|
||||||
final bool strictMode;
|
final bool strictMode;
|
||||||
|
|
||||||
|
// When true, this container has its own app-link policy (open-in-app mode +
|
||||||
|
// remembered per-site rules) that fully replaces the global one for its tabs.
|
||||||
|
// The override itself lives in `GeneralSettings.appLinkContextOverrides` keyed
|
||||||
|
// by [contextualIdentity]; this flag only gates whether that override is
|
||||||
|
// consulted. Requires a Gecko contextId — the native interceptor keys the
|
||||||
|
// override on the tab's contextId, so it is normalized to false when
|
||||||
|
// [contextualIdentity] is null (mirrors [strictMode]/[excludeFromHistory]).
|
||||||
|
@JsonKey(defaultValue: false)
|
||||||
|
final bool isolatedAppLinkSettings;
|
||||||
|
|
||||||
ContainerMetadata({
|
ContainerMetadata({
|
||||||
required this.iconData,
|
required this.iconData,
|
||||||
required this.contextualIdentity,
|
required this.contextualIdentity,
|
||||||
@@ -94,6 +104,7 @@ class ContainerMetadata with FastEquatable {
|
|||||||
required this.useCustomColor,
|
required this.useCustomColor,
|
||||||
required this.assignedSites,
|
required this.assignedSites,
|
||||||
required this.strictMode,
|
required this.strictMode,
|
||||||
|
required this.isolatedAppLinkSettings,
|
||||||
});
|
});
|
||||||
|
|
||||||
ContainerMetadata.withDefaults({
|
ContainerMetadata.withDefaults({
|
||||||
@@ -107,6 +118,7 @@ class ContainerMetadata with FastEquatable {
|
|||||||
bool? useCustomColor,
|
bool? useCustomColor,
|
||||||
List<Uri>? assignedSites,
|
List<Uri>? assignedSites,
|
||||||
bool? strictMode,
|
bool? strictMode,
|
||||||
|
bool? isolatedAppLinkSettings,
|
||||||
}) : this(
|
}) : this(
|
||||||
iconData: iconData,
|
iconData: iconData,
|
||||||
contextualIdentity: contextualIdentity,
|
contextualIdentity: contextualIdentity,
|
||||||
@@ -128,6 +140,11 @@ class ContainerMetadata with FastEquatable {
|
|||||||
// normalize away the invalid combination on read, and writers re-apply
|
// normalize away the invalid combination on read, and writers re-apply
|
||||||
// it via [sanitized].
|
// it via [sanitized].
|
||||||
strictMode: (strictMode ?? false) && contextualIdentity != null,
|
strictMode: (strictMode ?? false) && contextualIdentity != null,
|
||||||
|
// Isolated app-link settings need a contextId — the native interceptor
|
||||||
|
// keys the override on the tab's contextId. Normalize the invalid
|
||||||
|
// combination on read; writers re-apply it via [sanitized].
|
||||||
|
isolatedAppLinkSettings:
|
||||||
|
(isolatedAppLinkSettings ?? false) && contextualIdentity != null,
|
||||||
);
|
);
|
||||||
|
|
||||||
/// Enforce the [excludeFromHistory] invariant before persistence: it can only
|
/// Enforce the [excludeFromHistory] invariant before persistence: it can only
|
||||||
@@ -145,6 +162,11 @@ class ContainerMetadata with FastEquatable {
|
|||||||
if (result.strictMode && result.contextualIdentity == null) {
|
if (result.strictMode && result.contextualIdentity == null) {
|
||||||
result = result.copyWith(strictMode: false);
|
result = result.copyWith(strictMode: false);
|
||||||
}
|
}
|
||||||
|
// Isolated app-link settings need a contextId: the interceptor keys the
|
||||||
|
// override on the tab's contextId.
|
||||||
|
if (result.isolatedAppLinkSettings && result.contextualIdentity == null) {
|
||||||
|
result = result.copyWith(isolatedAppLinkSettings: false);
|
||||||
|
}
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -167,6 +189,7 @@ class ContainerMetadata with FastEquatable {
|
|||||||
useCustomColor,
|
useCustomColor,
|
||||||
assignedSites,
|
assignedSites,
|
||||||
strictMode,
|
strictMode,
|
||||||
|
isolatedAppLinkSettings,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -27,6 +27,8 @@ abstract class _$ContainerMetadataCWProxy {
|
|||||||
|
|
||||||
ContainerMetadata strictMode(bool strictMode);
|
ContainerMetadata strictMode(bool strictMode);
|
||||||
|
|
||||||
|
ContainerMetadata isolatedAppLinkSettings(bool isolatedAppLinkSettings);
|
||||||
|
|
||||||
/// Creates a new instance with the provided field values.
|
/// Creates a new instance with the provided field values.
|
||||||
/// Passing `null` to a nullable field nullifies it, while `null` for a non-nullable field is ignored. To update a single field use `ContainerMetadata(...).copyWith.fieldName(value)`.
|
/// Passing `null` to a nullable field nullifies it, while `null` for a non-nullable field is ignored. To update a single field use `ContainerMetadata(...).copyWith.fieldName(value)`.
|
||||||
///
|
///
|
||||||
@@ -45,6 +47,7 @@ abstract class _$ContainerMetadataCWProxy {
|
|||||||
bool useCustomColor,
|
bool useCustomColor,
|
||||||
List<Uri>? assignedSites,
|
List<Uri>? assignedSites,
|
||||||
bool strictMode,
|
bool strictMode,
|
||||||
|
bool isolatedAppLinkSettings,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -93,6 +96,10 @@ class _$ContainerMetadataCWProxyImpl implements _$ContainerMetadataCWProxy {
|
|||||||
@override
|
@override
|
||||||
ContainerMetadata strictMode(bool strictMode) => call(strictMode: strictMode);
|
ContainerMetadata strictMode(bool strictMode) => call(strictMode: strictMode);
|
||||||
|
|
||||||
|
@override
|
||||||
|
ContainerMetadata isolatedAppLinkSettings(bool isolatedAppLinkSettings) =>
|
||||||
|
call(isolatedAppLinkSettings: isolatedAppLinkSettings);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
/// Creates a new instance with the provided field values.
|
/// Creates a new instance with the provided field values.
|
||||||
/// Passing `null` to a nullable field nullifies it, while `null` for a non-nullable field is ignored. To update a single field use `ContainerMetadata(...).copyWith.fieldName(value)`.
|
/// Passing `null` to a nullable field nullifies it, while `null` for a non-nullable field is ignored. To update a single field use `ContainerMetadata(...).copyWith.fieldName(value)`.
|
||||||
@@ -112,6 +119,7 @@ class _$ContainerMetadataCWProxyImpl implements _$ContainerMetadataCWProxy {
|
|||||||
Object? useCustomColor = const $CopyWithPlaceholder(),
|
Object? useCustomColor = const $CopyWithPlaceholder(),
|
||||||
Object? assignedSites = const $CopyWithPlaceholder(),
|
Object? assignedSites = const $CopyWithPlaceholder(),
|
||||||
Object? strictMode = const $CopyWithPlaceholder(),
|
Object? strictMode = const $CopyWithPlaceholder(),
|
||||||
|
Object? isolatedAppLinkSettings = const $CopyWithPlaceholder(),
|
||||||
}) {
|
}) {
|
||||||
return ContainerMetadata(
|
return ContainerMetadata(
|
||||||
iconData: iconData == const $CopyWithPlaceholder()
|
iconData: iconData == const $CopyWithPlaceholder()
|
||||||
@@ -165,6 +173,12 @@ class _$ContainerMetadataCWProxyImpl implements _$ContainerMetadataCWProxy {
|
|||||||
? _value.strictMode
|
? _value.strictMode
|
||||||
// ignore: cast_nullable_to_non_nullable
|
// ignore: cast_nullable_to_non_nullable
|
||||||
: strictMode as bool,
|
: strictMode as bool,
|
||||||
|
isolatedAppLinkSettings:
|
||||||
|
isolatedAppLinkSettings == const $CopyWithPlaceholder() ||
|
||||||
|
isolatedAppLinkSettings == null
|
||||||
|
? _value.isolatedAppLinkSettings
|
||||||
|
// ignore: cast_nullable_to_non_nullable
|
||||||
|
: isolatedAppLinkSettings as bool,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -308,6 +322,8 @@ ContainerMetadata _$ContainerMetadataFromJson(Map<String, dynamic> json) =>
|
|||||||
?.map((e) => Uri.parse(e as String))
|
?.map((e) => Uri.parse(e as String))
|
||||||
.toList(),
|
.toList(),
|
||||||
strictMode: json['strictMode'] as bool? ?? false,
|
strictMode: json['strictMode'] as bool? ?? false,
|
||||||
|
isolatedAppLinkSettings:
|
||||||
|
json['isolatedAppLinkSettings'] as bool? ?? false,
|
||||||
);
|
);
|
||||||
|
|
||||||
Map<String, dynamic> _$ContainerMetadataToJson(
|
Map<String, dynamic> _$ContainerMetadataToJson(
|
||||||
@@ -326,6 +342,7 @@ Map<String, dynamic> _$ContainerMetadataToJson(
|
|||||||
'useCustomColor': instance.useCustomColor,
|
'useCustomColor': instance.useCustomColor,
|
||||||
'assignedSites': instance.assignedSites?.map((e) => e.toString()).toList(),
|
'assignedSites': instance.assignedSites?.map((e) => e.toString()).toList(),
|
||||||
'strictMode': instance.strictMode,
|
'strictMode': instance.strictMode,
|
||||||
|
'isolatedAppLinkSettings': instance.isolatedAppLinkSettings,
|
||||||
};
|
};
|
||||||
|
|
||||||
Value? _$JsonConverterFromJson<Json, Value>(
|
Value? _$JsonConverterFromJson<Json, Value>(
|
||||||
|
|||||||
@@ -103,12 +103,10 @@ class TabDataRepository extends _$TabDataRepository {
|
|||||||
),
|
),
|
||||||
// parentId defaults to null - breaks parent chain when changing contextual identity
|
// parentId defaults to null - breaks parent chain when changing contextual identity
|
||||||
selectTab: selectedTabId == tabState.id,
|
selectTab: selectedTabId == tabState.id,
|
||||||
// Assignment-driven navigation to an assigned site: bypass the
|
// Assignment-driven navigation is classified in its assigned context
|
||||||
// app-links delegate so cancelling an "open in app" prompt does
|
// like any other load; the app-links fallback re-entry map (§2.7)
|
||||||
// not re-trigger it on the recreated tab's load.
|
// covers the redirect loop the old delegate bypass used to guard.
|
||||||
flags: replacementUrl != null
|
flags: LoadUrlFlags.NONE,
|
||||||
? LoadUrlFlags.LOAD_FLAGS_BYPASS_LOAD_URI_DELEGATE
|
|
||||||
: LoadUrlFlags.NONE,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ final class TabDataRepositoryProvider
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
String _$tabDataRepositoryHash() => r'adc1c664b492e41a96a0310d92252dbbacc1a089';
|
String _$tabDataRepositoryHash() => r'd4eb49e25077aea6de479ea738ec92a213b71f78';
|
||||||
|
|
||||||
abstract class _$TabDataRepository extends $Notifier<void> {
|
abstract class _$TabDataRepository extends $Notifier<void> {
|
||||||
void build();
|
void build();
|
||||||
|
|||||||
+120
@@ -26,6 +26,7 @@ import 'package:flutter_material_design_icons/flutter_material_design_icons.dart
|
|||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:weblibre/core/uuid.dart';
|
import 'package:weblibre/core/uuid.dart';
|
||||||
|
import 'package:weblibre/features/app_links/presentation/widgets/container_app_link_settings_dialog.dart';
|
||||||
import 'package:weblibre/features/geckoview/features/history/domain/repositories/container_history.dart';
|
import 'package:weblibre/features/geckoview/features/history/domain/repositories/container_history.dart';
|
||||||
import 'package:weblibre/features/geckoview/features/tabs/data/models/container_data.dart';
|
import 'package:weblibre/features/geckoview/features/tabs/data/models/container_data.dart';
|
||||||
import 'package:weblibre/features/geckoview/features/tabs/domain/repositories/container.dart';
|
import 'package:weblibre/features/geckoview/features/tabs/domain/repositories/container.dart';
|
||||||
@@ -40,9 +41,32 @@ import 'package:weblibre/features/geckoview/features/tabs/utils/container_icons.
|
|||||||
import 'package:weblibre/features/proxy/data/proxy_connection.dart';
|
import 'package:weblibre/features/proxy/data/proxy_connection.dart';
|
||||||
import 'package:weblibre/features/proxy/domain/providers/proxy_connection_options.dart';
|
import 'package:weblibre/features/proxy/domain/providers/proxy_connection_options.dart';
|
||||||
import 'package:weblibre/features/proxy/domain/repositories/singbox_proxy_profiles.dart';
|
import 'package:weblibre/features/proxy/domain/repositories/singbox_proxy_profiles.dart';
|
||||||
|
import 'package:weblibre/features/user/data/models/general_settings.dart';
|
||||||
|
import 'package:weblibre/features/user/domain/repositories/general_settings.dart';
|
||||||
|
|
||||||
enum _DialogMode { create, edit }
|
enum _DialogMode { create, edit }
|
||||||
|
|
||||||
|
/// Remove any per-container app-link overrides (§ container isolation) stored for
|
||||||
|
/// [contextIds] in GeneralSettings. Null ids are ignored; a no-op when none are
|
||||||
|
/// present. Keeps overrides from lingering after a container drops isolation or
|
||||||
|
/// is deleted.
|
||||||
|
Future<void> _removeAppLinkOverrides(
|
||||||
|
WidgetRef ref,
|
||||||
|
Set<String?> contextIds,
|
||||||
|
) async {
|
||||||
|
final ids = contextIds.nonNulls.toSet();
|
||||||
|
if (ids.isEmpty) return;
|
||||||
|
|
||||||
|
await ref.read(generalSettingsRepositoryProvider.notifier).updateSettings((
|
||||||
|
current,
|
||||||
|
) {
|
||||||
|
if (!ids.any(current.appLinkContextOverrides.containsKey)) return current;
|
||||||
|
return current.copyWith.appLinkContextOverrides(
|
||||||
|
{...current.appLinkContextOverrides}..removeWhere((key, _) => ids.contains(key)),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
class ContainerEditScreen extends HookConsumerWidget {
|
class ContainerEditScreen extends HookConsumerWidget {
|
||||||
final _DialogMode _mode;
|
final _DialogMode _mode;
|
||||||
|
|
||||||
@@ -109,6 +133,9 @@ class ContainerEditScreen extends HookConsumerWidget {
|
|||||||
);
|
);
|
||||||
final assignedSites = useState(initialContainer.metadata.assignedSites);
|
final assignedSites = useState(initialContainer.metadata.assignedSites);
|
||||||
final strictMode = useState(initialContainer.metadata.strictMode);
|
final strictMode = useState(initialContainer.metadata.strictMode);
|
||||||
|
final isolatedAppLinkSettings = useState(
|
||||||
|
initialContainer.metadata.isolatedAppLinkSettings,
|
||||||
|
);
|
||||||
final isPinned = useState(initialContainer.isPinned);
|
final isPinned = useState(initialContainer.isPinned);
|
||||||
|
|
||||||
final textController = useTextEditingController(
|
final textController = useTextEditingController(
|
||||||
@@ -147,6 +174,12 @@ class ContainerEditScreen extends HookConsumerWidget {
|
|||||||
// strictness on the tab's cookieStoreId). sanitized() enforces the
|
// strictness on the tab's cookieStoreId). sanitized() enforces the
|
||||||
// same invariant defensively on write.
|
// same invariant defensively on write.
|
||||||
strictMode: strictMode.value && contextualIdentity.value != null,
|
strictMode: strictMode.value && contextualIdentity.value != null,
|
||||||
|
// Isolated app-link settings require a Gecko contextId (the
|
||||||
|
// interceptor keys the override on the tab's contextId).
|
||||||
|
// sanitized() enforces the same invariant defensively on write.
|
||||||
|
isolatedAppLinkSettings:
|
||||||
|
isolatedAppLinkSettings.value &&
|
||||||
|
contextualIdentity.value != null,
|
||||||
)
|
)
|
||||||
.sanitized(),
|
.sanitized(),
|
||||||
);
|
);
|
||||||
@@ -167,6 +200,15 @@ class ContainerEditScreen extends HookConsumerWidget {
|
|||||||
isPinned: isPinned.value,
|
isPinned: isPinned.value,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
// Keep the per-container app-link override in step with the isolation
|
||||||
|
// toggle: drop it when the container is no longer isolated (or lost its
|
||||||
|
// contextId) so it can't linger orphaned in GeneralSettings.
|
||||||
|
if (!container.metadata.isolatedAppLinkSettings) {
|
||||||
|
await _removeAppLinkOverrides(ref, {
|
||||||
|
initialContainer.metadata.contextualIdentity,
|
||||||
|
container.metadata.contextualIdentity,
|
||||||
|
});
|
||||||
|
}
|
||||||
return container;
|
return container;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -264,6 +306,11 @@ class ContainerEditScreen extends HookConsumerWidget {
|
|||||||
.read(containerRepositoryProvider.notifier)
|
.read(containerRepositoryProvider.notifier)
|
||||||
.deleteContainer(initialContainer.id);
|
.deleteContainer(initialContainer.id);
|
||||||
|
|
||||||
|
// Drop the container's app-link override so it doesn't outlive it.
|
||||||
|
await _removeAppLinkOverrides(ref, {
|
||||||
|
initialContainer.metadata.contextualIdentity,
|
||||||
|
});
|
||||||
|
|
||||||
if (context.mounted) {
|
if (context.mounted) {
|
||||||
context.pop();
|
context.pop();
|
||||||
}
|
}
|
||||||
@@ -654,6 +701,79 @@ class ContainerEditScreen extends HookConsumerWidget {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
Text(
|
||||||
|
'App Links',
|
||||||
|
style: theme.textTheme.titleSmall?.copyWith(
|
||||||
|
color: colorScheme.primary,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Card.filled(
|
||||||
|
margin: EdgeInsets.zero,
|
||||||
|
color: colorScheme.surfaceContainer,
|
||||||
|
clipBehavior: Clip.antiAlias,
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
SwitchListTile.adaptive(
|
||||||
|
value:
|
||||||
|
contextualIdentity.value != null &&
|
||||||
|
isolatedAppLinkSettings.value,
|
||||||
|
title: const Text('Isolated App Link Settings'),
|
||||||
|
subtitle: Text(
|
||||||
|
contextualIdentity.value != null
|
||||||
|
? 'Use a separate open-in-app mode and remembered '
|
||||||
|
'site rules for this container instead of the '
|
||||||
|
'global settings'
|
||||||
|
: 'Requires cookie isolation to be enabled',
|
||||||
|
),
|
||||||
|
secondary: const Icon(MdiIcons.openInApp),
|
||||||
|
onChanged: (contextualIdentity.value != null)
|
||||||
|
? (value) {
|
||||||
|
isolatedAppLinkSettings.value = value;
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
// The per-container mode + rules live in GeneralSettings
|
||||||
|
// (keyed by the persisted contextId) and are edited live,
|
||||||
|
// like the global app-link settings. Only offered in edit
|
||||||
|
// mode against the saved, immutable contextId — a create
|
||||||
|
// draft's contextId can still churn (cookie-isolation
|
||||||
|
// toggling regenerates it), which would orphan overrides.
|
||||||
|
if (_mode == _DialogMode.edit &&
|
||||||
|
initialContainer.metadata.contextualIdentity !=
|
||||||
|
null &&
|
||||||
|
isolatedAppLinkSettings.value) ...[
|
||||||
|
const Divider(height: 1, indent: 56),
|
||||||
|
ListTile(
|
||||||
|
leading: const Icon(Icons.tune),
|
||||||
|
title: const Text('App Link Behavior'),
|
||||||
|
subtitle: const Text(
|
||||||
|
"Configure this container's open-in-app mode and "
|
||||||
|
'remembered sites',
|
||||||
|
),
|
||||||
|
trailing: const Icon(Icons.chevron_right),
|
||||||
|
onTap: () async {
|
||||||
|
await showDialog<void>(
|
||||||
|
context: context,
|
||||||
|
builder: (context) =>
|
||||||
|
ContainerAppLinkSettingsDialog(
|
||||||
|
contextId: initialContainer
|
||||||
|
.metadata
|
||||||
|
.contextualIdentity!,
|
||||||
|
containerName:
|
||||||
|
textController.text.trim().isNotEmpty
|
||||||
|
? textController.text.trim()
|
||||||
|
: initialContainer.name,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ import 'package:flutter_mozilla_components/flutter_mozilla_components.dart';
|
|||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:weblibre/core/design/app_colors.dart';
|
import 'package:weblibre/core/design/app_colors.dart';
|
||||||
import 'package:weblibre/core/routing/routes.dart';
|
import 'package:weblibre/core/routing/routes.dart';
|
||||||
import 'package:weblibre/features/geckoview/features/browser/domain/providers.dart';
|
import 'package:weblibre/features/app_links/domain/entities/app_link_rule.dart';
|
||||||
import 'package:weblibre/features/geckoview/features/tabs/domain/providers/selected_container.dart';
|
import 'package:weblibre/features/geckoview/features/tabs/domain/providers/selected_container.dart';
|
||||||
import 'package:weblibre/features/settings/presentation/controllers/save_settings.dart';
|
import 'package:weblibre/features/settings/presentation/controllers/save_settings.dart';
|
||||||
import 'package:weblibre/features/settings/presentation/widgets/settings_detail.dart';
|
import 'package:weblibre/features/settings/presentation/widgets/settings_detail.dart';
|
||||||
@@ -709,7 +709,15 @@ class _AppLinksModeSection extends HookConsumerWidget {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final appLinksMode = ref.watch(
|
final appLinksMode = ref.watch(
|
||||||
appLinksModeProvider.select((value) => value.value),
|
generalSettingsWithDefaultsProvider.select((s) => s.appLinksMode),
|
||||||
|
);
|
||||||
|
final marketplaceFallback = ref.watch(
|
||||||
|
generalSettingsWithDefaultsProvider.select(
|
||||||
|
(s) => s.appLinkMarketplaceFallback,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
final rules = ref.watch(
|
||||||
|
generalSettingsWithDefaultsProvider.select((s) => s.appLinkRules),
|
||||||
);
|
);
|
||||||
|
|
||||||
return Padding(
|
return Padding(
|
||||||
@@ -730,7 +738,9 @@ class _AppLinksModeSection extends HookConsumerWidget {
|
|||||||
groupValue: appLinksMode,
|
groupValue: appLinksMode,
|
||||||
onChanged: (value) async {
|
onChanged: (value) async {
|
||||||
if (value != null) {
|
if (value != null) {
|
||||||
await ref.read(appLinksModeProvider.notifier).setMode(value);
|
await ref
|
||||||
|
.read(saveGeneralSettingsControllerProvider.notifier)
|
||||||
|
.save((current) => current.copyWith.appLinksMode(value));
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
child: const Column(
|
child: const Column(
|
||||||
@@ -757,12 +767,96 @@ class _AppLinksModeSection extends HookConsumerWidget {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
SwitchListTile.adaptive(
|
||||||
|
contentPadding: EdgeInsets.zero,
|
||||||
|
title: const Text('Offer app store fallback'),
|
||||||
|
subtitle: const Text(
|
||||||
|
"When a link points to an app you don't have installed and there "
|
||||||
|
'is no web fallback, offer to open the app store',
|
||||||
|
),
|
||||||
|
value: marketplaceFallback,
|
||||||
|
onChanged: appLinksMode == AppLinksMode.never
|
||||||
|
? null
|
||||||
|
: (value) async {
|
||||||
|
await ref
|
||||||
|
.read(saveGeneralSettingsControllerProvider.notifier)
|
||||||
|
.save(
|
||||||
|
(current) =>
|
||||||
|
current.copyWith.appLinkMarketplaceFallback(value),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
_AppLinkRulesSubsection(rules: rules),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Managed per-site app-link rules (§2.5): "always open" and "never open"
|
||||||
|
/// decisions the user remembered from a prompt. Read-only list with removal.
|
||||||
|
class _AppLinkRulesSubsection extends ConsumerWidget {
|
||||||
|
final Map<String, PersistedAppLinkRule> rules;
|
||||||
|
|
||||||
|
const _AppLinkRulesSubsection({required this.rules});
|
||||||
|
|
||||||
|
String _displayScope(String scope) {
|
||||||
|
if (scope.startsWith('host:')) return scope.substring('host:'.length);
|
||||||
|
if (scope.startsWith('pkg:')) return scope.substring('pkg:'.length);
|
||||||
|
return scope;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
if (rules.isEmpty) {
|
||||||
|
return const SizedBox.shrink();
|
||||||
|
}
|
||||||
|
|
||||||
|
final entries = rules.entries.toList()
|
||||||
|
..sort((a, b) => a.key.compareTo(b.key));
|
||||||
|
|
||||||
|
return Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
const Padding(
|
||||||
|
padding: EdgeInsets.only(top: 16, bottom: 4),
|
||||||
|
child: Text('Remembered site rules'),
|
||||||
|
),
|
||||||
|
for (final MapEntry(:key, :value) in entries)
|
||||||
|
ListTile(
|
||||||
|
contentPadding: EdgeInsets.zero,
|
||||||
|
dense: true,
|
||||||
|
leading: Icon(
|
||||||
|
value.decision == AppLinkRuleDecision.alwaysOpen
|
||||||
|
? MdiIcons.openInApp
|
||||||
|
: Icons.public,
|
||||||
|
),
|
||||||
|
title: Text(_displayScope(key)),
|
||||||
|
subtitle: Text(
|
||||||
|
value.decision == AppLinkRuleDecision.alwaysOpen
|
||||||
|
? 'Always open in the app'
|
||||||
|
: 'Always keep in the browser',
|
||||||
|
),
|
||||||
|
trailing: IconButton(
|
||||||
|
icon: const Icon(Icons.delete_outline),
|
||||||
|
tooltip: 'Remove rule',
|
||||||
|
onPressed: () async {
|
||||||
|
await ref
|
||||||
|
.read(saveGeneralSettingsControllerProvider.notifier)
|
||||||
|
.save(
|
||||||
|
(current) => current.copyWith.appLinkRules({
|
||||||
|
...current.appLinkRules,
|
||||||
|
}..remove(key)),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
class _GlobalDesktopModeTile extends HookConsumerWidget {
|
class _GlobalDesktopModeTile extends HookConsumerWidget {
|
||||||
const _GlobalDesktopModeTile();
|
const _GlobalDesktopModeTile();
|
||||||
|
|
||||||
|
|||||||
@@ -20,8 +20,12 @@
|
|||||||
import 'package:copy_with_extension/copy_with_extension.dart';
|
import 'package:copy_with_extension/copy_with_extension.dart';
|
||||||
import 'package:fast_equatable/fast_equatable.dart';
|
import 'package:fast_equatable/fast_equatable.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_mozilla_components/flutter_mozilla_components.dart'
|
||||||
|
show AppLinksMode;
|
||||||
import 'package:json_annotation/json_annotation.dart';
|
import 'package:json_annotation/json_annotation.dart';
|
||||||
import 'package:weblibre/core/routing/routes.dart';
|
import 'package:weblibre/core/routing/routes.dart';
|
||||||
|
import 'package:weblibre/features/app_links/domain/entities/app_link_rule.dart';
|
||||||
|
import 'package:weblibre/features/app_links/domain/entities/context_app_link_policy.dart';
|
||||||
import 'package:weblibre/features/bangs/data/models/bang_group.dart';
|
import 'package:weblibre/features/bangs/data/models/bang_group.dart';
|
||||||
import 'package:weblibre/features/bangs/data/models/bang_key.dart';
|
import 'package:weblibre/features/bangs/data/models/bang_key.dart';
|
||||||
import 'package:weblibre/features/intent_gatekeeper/domain/entities/intent_source_policy.dart';
|
import 'package:weblibre/features/intent_gatekeeper/domain/entities/intent_source_policy.dart';
|
||||||
@@ -202,6 +206,30 @@ class GeneralSettings with FastEquatable {
|
|||||||
/// via the intent gatekeeper prefs bridge. Defaults to true.
|
/// via the intent gatekeeper prefs bridge. Defaults to true.
|
||||||
final bool customTabsEnabled;
|
final bool customTabsEnabled;
|
||||||
|
|
||||||
|
/// Global app-links behaviour: always open in native apps, ask each time, or
|
||||||
|
/// never leave the browser. Defaults to [AppLinksMode.ask]. Per-site rules in
|
||||||
|
/// [appLinkRules] and container/proxy protection can override this per-target.
|
||||||
|
final AppLinksMode appLinksMode;
|
||||||
|
|
||||||
|
/// Remembered per-scope app-link rules, keyed by canonical scope
|
||||||
|
/// (`host:youtube.com` | `pkg:...`). One rule per scope, last write wins.
|
||||||
|
/// Malformed entries are dropped on read (see [parseAppLinkRules]).
|
||||||
|
@JsonKey(fromJson: parseAppLinkRules)
|
||||||
|
final Map<String, PersistedAppLinkRule> appLinkRules;
|
||||||
|
|
||||||
|
/// Per-container app-link overrides for containers with "isolated app link
|
||||||
|
/// settings" enabled, keyed by the container's Gecko contextId. Each entry
|
||||||
|
/// fully *replaces* the global mode + [appLinkRules] for navigations in that
|
||||||
|
/// container (replace semantics). Containers without isolation have no entry
|
||||||
|
/// and fall back to the global policy. Malformed entries dropped on read.
|
||||||
|
@JsonKey(fromJson: parseAppLinkContextOverrides)
|
||||||
|
final Map<String, ContextAppLinkPolicy> appLinkContextOverrides;
|
||||||
|
|
||||||
|
/// Whether an install-app (marketplace) intent is offered when an app link
|
||||||
|
/// resolves to no installed app and has no validated http(s) fallback.
|
||||||
|
/// Defaults to false — the wrong default for a de-Googled browser.
|
||||||
|
final bool appLinkMarketplaceFallback;
|
||||||
|
|
||||||
/// Whether the local search index (`history` table populated via tab→
|
/// Whether the local search index (`history` table populated via tab→
|
||||||
/// history triggers) is active. When false, the SQL trigger guard returns
|
/// history triggers) is active. When false, the SQL trigger guard returns
|
||||||
/// without writing; existing rows stay until the user clears them.
|
/// without writing; existing rows stay until the user clears them.
|
||||||
@@ -296,6 +324,10 @@ class GeneralSettings with FastEquatable {
|
|||||||
required this.blockExternalAppsEnabled,
|
required this.blockExternalAppsEnabled,
|
||||||
required this.externalAppIntentPolicies,
|
required this.externalAppIntentPolicies,
|
||||||
required this.customTabsEnabled,
|
required this.customTabsEnabled,
|
||||||
|
required this.appLinksMode,
|
||||||
|
required this.appLinkRules,
|
||||||
|
required this.appLinkContextOverrides,
|
||||||
|
required this.appLinkMarketplaceFallback,
|
||||||
required this.enableLocalSearchIndex,
|
required this.enableLocalSearchIndex,
|
||||||
required this.indexPrivateTabs,
|
required this.indexPrivateTabs,
|
||||||
required this.acceptSuggestionOnSubmit,
|
required this.acceptSuggestionOnSubmit,
|
||||||
@@ -364,6 +396,10 @@ class GeneralSettings with FastEquatable {
|
|||||||
bool? blockExternalAppsEnabled,
|
bool? blockExternalAppsEnabled,
|
||||||
Map<String, IntentSourcePolicy>? externalAppIntentPolicies,
|
Map<String, IntentSourcePolicy>? externalAppIntentPolicies,
|
||||||
bool? customTabsEnabled,
|
bool? customTabsEnabled,
|
||||||
|
AppLinksMode? appLinksMode,
|
||||||
|
Map<String, PersistedAppLinkRule>? appLinkRules,
|
||||||
|
Map<String, ContextAppLinkPolicy>? appLinkContextOverrides,
|
||||||
|
bool? appLinkMarketplaceFallback,
|
||||||
bool? enableLocalSearchIndex,
|
bool? enableLocalSearchIndex,
|
||||||
bool? indexPrivateTabs,
|
bool? indexPrivateTabs,
|
||||||
bool? acceptSuggestionOnSubmit,
|
bool? acceptSuggestionOnSubmit,
|
||||||
@@ -442,6 +478,10 @@ class GeneralSettings with FastEquatable {
|
|||||||
blockExternalAppsEnabled = blockExternalAppsEnabled ?? false,
|
blockExternalAppsEnabled = blockExternalAppsEnabled ?? false,
|
||||||
externalAppIntentPolicies = externalAppIntentPolicies ?? const {},
|
externalAppIntentPolicies = externalAppIntentPolicies ?? const {},
|
||||||
customTabsEnabled = customTabsEnabled ?? true,
|
customTabsEnabled = customTabsEnabled ?? true,
|
||||||
|
appLinksMode = appLinksMode ?? AppLinksMode.ask,
|
||||||
|
appLinkRules = appLinkRules ?? const {},
|
||||||
|
appLinkContextOverrides = appLinkContextOverrides ?? const {},
|
||||||
|
appLinkMarketplaceFallback = appLinkMarketplaceFallback ?? false,
|
||||||
enableLocalSearchIndex = enableLocalSearchIndex ?? true,
|
enableLocalSearchIndex = enableLocalSearchIndex ?? true,
|
||||||
indexPrivateTabs = indexPrivateTabs ?? false,
|
indexPrivateTabs = indexPrivateTabs ?? false,
|
||||||
acceptSuggestionOnSubmit = acceptSuggestionOnSubmit ?? true,
|
acceptSuggestionOnSubmit = acceptSuggestionOnSubmit ?? true,
|
||||||
@@ -593,6 +633,10 @@ class GeneralSettings with FastEquatable {
|
|||||||
blockExternalAppsEnabled,
|
blockExternalAppsEnabled,
|
||||||
externalAppIntentPolicies,
|
externalAppIntentPolicies,
|
||||||
customTabsEnabled,
|
customTabsEnabled,
|
||||||
|
appLinksMode,
|
||||||
|
appLinkRules,
|
||||||
|
appLinkContextOverrides,
|
||||||
|
appLinkMarketplaceFallback,
|
||||||
enableLocalSearchIndex,
|
enableLocalSearchIndex,
|
||||||
indexPrivateTabs,
|
indexPrivateTabs,
|
||||||
acceptSuggestionOnSubmit,
|
acceptSuggestionOnSubmit,
|
||||||
|
|||||||
@@ -143,6 +143,16 @@ abstract class _$GeneralSettingsCWProxy {
|
|||||||
|
|
||||||
GeneralSettings customTabsEnabled(bool customTabsEnabled);
|
GeneralSettings customTabsEnabled(bool customTabsEnabled);
|
||||||
|
|
||||||
|
GeneralSettings appLinksMode(AppLinksMode appLinksMode);
|
||||||
|
|
||||||
|
GeneralSettings appLinkRules(Map<String, PersistedAppLinkRule> appLinkRules);
|
||||||
|
|
||||||
|
GeneralSettings appLinkContextOverrides(
|
||||||
|
Map<String, ContextAppLinkPolicy> appLinkContextOverrides,
|
||||||
|
);
|
||||||
|
|
||||||
|
GeneralSettings appLinkMarketplaceFallback(bool appLinkMarketplaceFallback);
|
||||||
|
|
||||||
GeneralSettings enableLocalSearchIndex(bool enableLocalSearchIndex);
|
GeneralSettings enableLocalSearchIndex(bool enableLocalSearchIndex);
|
||||||
|
|
||||||
GeneralSettings indexPrivateTabs(bool indexPrivateTabs);
|
GeneralSettings indexPrivateTabs(bool indexPrivateTabs);
|
||||||
@@ -223,6 +233,10 @@ abstract class _$GeneralSettingsCWProxy {
|
|||||||
bool blockExternalAppsEnabled,
|
bool blockExternalAppsEnabled,
|
||||||
Map<String, IntentSourcePolicy> externalAppIntentPolicies,
|
Map<String, IntentSourcePolicy> externalAppIntentPolicies,
|
||||||
bool customTabsEnabled,
|
bool customTabsEnabled,
|
||||||
|
AppLinksMode appLinksMode,
|
||||||
|
Map<String, PersistedAppLinkRule> appLinkRules,
|
||||||
|
Map<String, ContextAppLinkPolicy> appLinkContextOverrides,
|
||||||
|
bool appLinkMarketplaceFallback,
|
||||||
bool enableLocalSearchIndex,
|
bool enableLocalSearchIndex,
|
||||||
bool indexPrivateTabs,
|
bool indexPrivateTabs,
|
||||||
bool acceptSuggestionOnSubmit,
|
bool acceptSuggestionOnSubmit,
|
||||||
@@ -490,6 +504,24 @@ class _$GeneralSettingsCWProxyImpl implements _$GeneralSettingsCWProxy {
|
|||||||
GeneralSettings customTabsEnabled(bool customTabsEnabled) =>
|
GeneralSettings customTabsEnabled(bool customTabsEnabled) =>
|
||||||
call(customTabsEnabled: customTabsEnabled);
|
call(customTabsEnabled: customTabsEnabled);
|
||||||
|
|
||||||
|
@override
|
||||||
|
GeneralSettings appLinksMode(AppLinksMode appLinksMode) =>
|
||||||
|
call(appLinksMode: appLinksMode);
|
||||||
|
|
||||||
|
@override
|
||||||
|
GeneralSettings appLinkRules(
|
||||||
|
Map<String, PersistedAppLinkRule> appLinkRules,
|
||||||
|
) => call(appLinkRules: appLinkRules);
|
||||||
|
|
||||||
|
@override
|
||||||
|
GeneralSettings appLinkContextOverrides(
|
||||||
|
Map<String, ContextAppLinkPolicy> appLinkContextOverrides,
|
||||||
|
) => call(appLinkContextOverrides: appLinkContextOverrides);
|
||||||
|
|
||||||
|
@override
|
||||||
|
GeneralSettings appLinkMarketplaceFallback(bool appLinkMarketplaceFallback) =>
|
||||||
|
call(appLinkMarketplaceFallback: appLinkMarketplaceFallback);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
GeneralSettings enableLocalSearchIndex(bool enableLocalSearchIndex) =>
|
GeneralSettings enableLocalSearchIndex(bool enableLocalSearchIndex) =>
|
||||||
call(enableLocalSearchIndex: enableLocalSearchIndex);
|
call(enableLocalSearchIndex: enableLocalSearchIndex);
|
||||||
@@ -586,6 +618,10 @@ class _$GeneralSettingsCWProxyImpl implements _$GeneralSettingsCWProxy {
|
|||||||
Object? blockExternalAppsEnabled = const $CopyWithPlaceholder(),
|
Object? blockExternalAppsEnabled = const $CopyWithPlaceholder(),
|
||||||
Object? externalAppIntentPolicies = const $CopyWithPlaceholder(),
|
Object? externalAppIntentPolicies = const $CopyWithPlaceholder(),
|
||||||
Object? customTabsEnabled = const $CopyWithPlaceholder(),
|
Object? customTabsEnabled = const $CopyWithPlaceholder(),
|
||||||
|
Object? appLinksMode = const $CopyWithPlaceholder(),
|
||||||
|
Object? appLinkRules = const $CopyWithPlaceholder(),
|
||||||
|
Object? appLinkContextOverrides = const $CopyWithPlaceholder(),
|
||||||
|
Object? appLinkMarketplaceFallback = const $CopyWithPlaceholder(),
|
||||||
Object? enableLocalSearchIndex = const $CopyWithPlaceholder(),
|
Object? enableLocalSearchIndex = const $CopyWithPlaceholder(),
|
||||||
Object? indexPrivateTabs = const $CopyWithPlaceholder(),
|
Object? indexPrivateTabs = const $CopyWithPlaceholder(),
|
||||||
Object? acceptSuggestionOnSubmit = const $CopyWithPlaceholder(),
|
Object? acceptSuggestionOnSubmit = const $CopyWithPlaceholder(),
|
||||||
@@ -938,6 +974,28 @@ class _$GeneralSettingsCWProxyImpl implements _$GeneralSettingsCWProxy {
|
|||||||
? _value.customTabsEnabled
|
? _value.customTabsEnabled
|
||||||
// ignore: cast_nullable_to_non_nullable
|
// ignore: cast_nullable_to_non_nullable
|
||||||
: customTabsEnabled as bool,
|
: customTabsEnabled as bool,
|
||||||
|
appLinksMode:
|
||||||
|
appLinksMode == const $CopyWithPlaceholder() || appLinksMode == null
|
||||||
|
? _value.appLinksMode
|
||||||
|
// ignore: cast_nullable_to_non_nullable
|
||||||
|
: appLinksMode as AppLinksMode,
|
||||||
|
appLinkRules:
|
||||||
|
appLinkRules == const $CopyWithPlaceholder() || appLinkRules == null
|
||||||
|
? _value.appLinkRules
|
||||||
|
// ignore: cast_nullable_to_non_nullable
|
||||||
|
: appLinkRules as Map<String, PersistedAppLinkRule>,
|
||||||
|
appLinkContextOverrides:
|
||||||
|
appLinkContextOverrides == const $CopyWithPlaceholder() ||
|
||||||
|
appLinkContextOverrides == null
|
||||||
|
? _value.appLinkContextOverrides
|
||||||
|
// ignore: cast_nullable_to_non_nullable
|
||||||
|
: appLinkContextOverrides as Map<String, ContextAppLinkPolicy>,
|
||||||
|
appLinkMarketplaceFallback:
|
||||||
|
appLinkMarketplaceFallback == const $CopyWithPlaceholder() ||
|
||||||
|
appLinkMarketplaceFallback == null
|
||||||
|
? _value.appLinkMarketplaceFallback
|
||||||
|
// ignore: cast_nullable_to_non_nullable
|
||||||
|
: appLinkMarketplaceFallback as bool,
|
||||||
enableLocalSearchIndex:
|
enableLocalSearchIndex:
|
||||||
enableLocalSearchIndex == const $CopyWithPlaceholder() ||
|
enableLocalSearchIndex == const $CopyWithPlaceholder() ||
|
||||||
enableLocalSearchIndex == null
|
enableLocalSearchIndex == null
|
||||||
@@ -1110,6 +1168,17 @@ GeneralSettings _$GeneralSettingsFromJson(
|
|||||||
(k, e) => MapEntry(k, $enumDecode(_$IntentSourcePolicyEnumMap, e)),
|
(k, e) => MapEntry(k, $enumDecode(_$IntentSourcePolicyEnumMap, e)),
|
||||||
),
|
),
|
||||||
customTabsEnabled: json['customTabsEnabled'] as bool?,
|
customTabsEnabled: json['customTabsEnabled'] as bool?,
|
||||||
|
appLinksMode: $enumDecodeNullable(
|
||||||
|
_$AppLinksModeEnumMap,
|
||||||
|
json['appLinksMode'],
|
||||||
|
),
|
||||||
|
appLinkRules: parseAppLinkRules(
|
||||||
|
json['appLinkRules'] as Map<String, dynamic>?,
|
||||||
|
),
|
||||||
|
appLinkContextOverrides: parseAppLinkContextOverrides(
|
||||||
|
json['appLinkContextOverrides'] as Map<String, dynamic>?,
|
||||||
|
),
|
||||||
|
appLinkMarketplaceFallback: json['appLinkMarketplaceFallback'] as bool?,
|
||||||
enableLocalSearchIndex: json['enableLocalSearchIndex'] as bool?,
|
enableLocalSearchIndex: json['enableLocalSearchIndex'] as bool?,
|
||||||
indexPrivateTabs: json['indexPrivateTabs'] as bool?,
|
indexPrivateTabs: json['indexPrivateTabs'] as bool?,
|
||||||
acceptSuggestionOnSubmit: json['acceptSuggestionOnSubmit'] as bool?,
|
acceptSuggestionOnSubmit: json['acceptSuggestionOnSubmit'] as bool?,
|
||||||
@@ -1196,6 +1265,12 @@ Map<String, dynamic> _$GeneralSettingsToJson(
|
|||||||
(k, e) => MapEntry(k, _$IntentSourcePolicyEnumMap[e]!),
|
(k, e) => MapEntry(k, _$IntentSourcePolicyEnumMap[e]!),
|
||||||
),
|
),
|
||||||
'customTabsEnabled': instance.customTabsEnabled,
|
'customTabsEnabled': instance.customTabsEnabled,
|
||||||
|
'appLinksMode': _$AppLinksModeEnumMap[instance.appLinksMode]!,
|
||||||
|
'appLinkRules': instance.appLinkRules.map((k, e) => MapEntry(k, e.toJson())),
|
||||||
|
'appLinkContextOverrides': instance.appLinkContextOverrides.map(
|
||||||
|
(k, e) => MapEntry(k, e.toJson()),
|
||||||
|
),
|
||||||
|
'appLinkMarketplaceFallback': instance.appLinkMarketplaceFallback,
|
||||||
'enableLocalSearchIndex': instance.enableLocalSearchIndex,
|
'enableLocalSearchIndex': instance.enableLocalSearchIndex,
|
||||||
'indexPrivateTabs': instance.indexPrivateTabs,
|
'indexPrivateTabs': instance.indexPrivateTabs,
|
||||||
'acceptSuggestionOnSubmit': instance.acceptSuggestionOnSubmit,
|
'acceptSuggestionOnSubmit': instance.acceptSuggestionOnSubmit,
|
||||||
@@ -1283,3 +1358,9 @@ const _$IntentSourcePolicyEnumMap = {
|
|||||||
IntentSourcePolicy.allow: 'allow',
|
IntentSourcePolicy.allow: 'allow',
|
||||||
IntentSourcePolicy.block: 'block',
|
IntentSourcePolicy.block: 'block',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const _$AppLinksModeEnumMap = {
|
||||||
|
AppLinksMode.always: 'always',
|
||||||
|
AppLinksMode.ask: 'ask',
|
||||||
|
AppLinksMode.never: 'never',
|
||||||
|
};
|
||||||
|
|||||||
@@ -272,6 +272,18 @@ class GeneralSettingsRepository extends _$GeneralSettingsRepository {
|
|||||||
DriftSqlType.bool,
|
DriftSqlType.bool,
|
||||||
db.typeMapping,
|
db.typeMapping,
|
||||||
),
|
),
|
||||||
|
'appLinksMode': settings['appLinksMode']?.readAs(
|
||||||
|
DriftSqlType.string,
|
||||||
|
db.typeMapping,
|
||||||
|
),
|
||||||
|
'appLinkRules': settings['appLinkRules']
|
||||||
|
?.readAs(DriftSqlType.string, db.typeMapping)
|
||||||
|
.mapNotNull(jsonDecode),
|
||||||
|
'appLinkContextOverrides': settings['appLinkContextOverrides']
|
||||||
|
?.readAs(DriftSqlType.string, db.typeMapping)
|
||||||
|
.mapNotNull(jsonDecode),
|
||||||
|
'appLinkMarketplaceFallback': settings['appLinkMarketplaceFallback']
|
||||||
|
?.readAs(DriftSqlType.bool, db.typeMapping),
|
||||||
'enableLocalSearchIndex': settings['enableLocalSearchIndex']?.readAs(
|
'enableLocalSearchIndex': settings['enableLocalSearchIndex']?.readAs(
|
||||||
DriftSqlType.bool,
|
DriftSqlType.bool,
|
||||||
db.typeMapping,
|
db.typeMapping,
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ final class GeneralSettingsRepositoryProvider
|
|||||||
}
|
}
|
||||||
|
|
||||||
String _$generalSettingsRepositoryHash() =>
|
String _$generalSettingsRepositoryHash() =>
|
||||||
r'4e72c8ebed8b08ced417ca24d6e4a840f2abf1be';
|
r'7020706aafbac7ee64f678f918ef9fc24c3b98fb';
|
||||||
|
|
||||||
abstract class _$GeneralSettingsRepository
|
abstract class _$GeneralSettingsRepository
|
||||||
extends $StreamNotifier<GeneralSettings> {
|
extends $StreamNotifier<GeneralSettings> {
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ final class ProfileRepositoryProvider
|
|||||||
ProfileRepository create() => ProfileRepository();
|
ProfileRepository create() => ProfileRepository();
|
||||||
}
|
}
|
||||||
|
|
||||||
String _$profileRepositoryHash() => r'3055487626bdf6bdc6a51284f68eaf4067cd52ef';
|
String _$profileRepositoryHash() => r'504539c5ec7c9126ed7b07d920820af481f40444';
|
||||||
|
|
||||||
abstract class _$ProfileRepository extends $AsyncNotifier<List<Profile>> {
|
abstract class _$ProfileRepository extends $AsyncNotifier<List<Profile>> {
|
||||||
FutureOr<List<Profile>> build();
|
FutureOr<List<Profile>> build();
|
||||||
|
|||||||
@@ -226,7 +226,7 @@ final class PushDistributorMutationProvider
|
|||||||
}
|
}
|
||||||
|
|
||||||
String _$pushDistributorMutationHash() =>
|
String _$pushDistributorMutationHash() =>
|
||||||
r'5797ca731c90c1e06e089fb71ad602aecda59634';
|
r'58e489179c2e1fdaf6d8a6bd3b758ec16641358c';
|
||||||
|
|
||||||
abstract class _$PushDistributorMutation extends $AsyncNotifier<void> {
|
abstract class _$PushDistributorMutation extends $AsyncNotifier<void> {
|
||||||
FutureOr<void> build();
|
FutureOr<void> build();
|
||||||
|
|||||||
@@ -0,0 +1,94 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2024-2026 Fabian Freund.
|
||||||
|
*
|
||||||
|
* This file is part of WebLibre
|
||||||
|
* (see https://weblibre.eu).
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU Affero General Public License as
|
||||||
|
* published by the Free Software Foundation, either version 3 of the
|
||||||
|
* License, or (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU Affero General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU Affero General Public License
|
||||||
|
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import 'package:flutter_mozilla_components/flutter_mozilla_components.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:weblibre/features/app_links/domain/entities/app_link_rule.dart';
|
||||||
|
import 'package:weblibre/features/app_links/presentation/widgets/app_link_prompt_dialog.dart';
|
||||||
|
|
||||||
|
AppLinkTarget _target({
|
||||||
|
String url = 'https://youtu.be/abc',
|
||||||
|
String? packageName = 'com.google.android.youtube',
|
||||||
|
bool isAmbiguous = false,
|
||||||
|
String scopeKey = 'host:youtu.be',
|
||||||
|
bool engineSupportsScheme = true,
|
||||||
|
}) {
|
||||||
|
return AppLinkTarget(
|
||||||
|
url: url,
|
||||||
|
appName: 'YouTube',
|
||||||
|
packageName: packageName,
|
||||||
|
fallbackUrl: null,
|
||||||
|
isMarketplace: false,
|
||||||
|
isAmbiguous: isAmbiguous,
|
||||||
|
engineSupportsScheme: engineSupportsScheme,
|
||||||
|
scopeKey: scopeKey,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
group('alwaysOpenRuleFor', () {
|
||||||
|
test('binds the resolved package to the target scope', () {
|
||||||
|
final rule = alwaysOpenRuleFor(_target());
|
||||||
|
expect(rule, isNotNull);
|
||||||
|
expect(rule!.decision, AppLinkRuleDecision.alwaysOpen);
|
||||||
|
expect(rule.scope, 'host:youtu.be');
|
||||||
|
expect(rule.packageName, 'com.google.android.youtube');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('cannot be remembered for an ambiguous resolution', () {
|
||||||
|
expect(alwaysOpenRuleFor(_target(isAmbiguous: true)), isNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('cannot be remembered without a bound package', () {
|
||||||
|
expect(alwaysOpenRuleFor(_target(packageName: null)), isNull);
|
||||||
|
expect(alwaysOpenRuleFor(_target(packageName: '')), isNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('scopes a custom-scheme target by its package key', () {
|
||||||
|
final rule = alwaysOpenRuleFor(
|
||||||
|
_target(
|
||||||
|
url: 'zoommtg://zoom.us/join',
|
||||||
|
packageName: 'us.zoom.videomeetings',
|
||||||
|
scopeKey: 'pkg:us.zoom.videomeetings',
|
||||||
|
engineSupportsScheme: false,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(rule, isNotNull);
|
||||||
|
expect(rule!.scope, 'pkg:us.zoom.videomeetings');
|
||||||
|
expect(rule.packageName, 'us.zoom.videomeetings');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('neverOpenRuleFor', () {
|
||||||
|
test('scopes to the target without binding a package', () {
|
||||||
|
final rule = neverOpenRuleFor(_target());
|
||||||
|
expect(rule.decision, AppLinkRuleDecision.neverOpen);
|
||||||
|
expect(rule.scope, 'host:youtu.be');
|
||||||
|
expect(rule.packageName, isNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('is producible even for an ambiguous resolution', () {
|
||||||
|
// neverOpen never launches, so it does not need a bound package.
|
||||||
|
final rule = neverOpenRuleFor(_target(isAmbiguous: true, packageName: null));
|
||||||
|
expect(rule.decision, AppLinkRuleDecision.neverOpen);
|
||||||
|
expect(rule.isValid, isTrue);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2024-2026 Fabian Freund.
|
||||||
|
*
|
||||||
|
* This file is part of WebLibre
|
||||||
|
* (see https://weblibre.eu).
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU Affero General Public License as
|
||||||
|
* published by the Free Software Foundation, either version 3 of the
|
||||||
|
* License, or (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU Affero General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU Affero General Public License
|
||||||
|
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:weblibre/features/app_links/domain/entities/app_link_rule.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
group('PersistedAppLinkRule', () {
|
||||||
|
test('round-trips through json', () {
|
||||||
|
final rule = PersistedAppLinkRule(
|
||||||
|
decision: AppLinkRuleDecision.alwaysOpen,
|
||||||
|
scope: 'host:youtube.com',
|
||||||
|
packageName: 'com.google.android.youtube',
|
||||||
|
);
|
||||||
|
final restored = PersistedAppLinkRule.fromJson(rule.toJson());
|
||||||
|
expect(restored, rule);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('validity requires a package for alwaysOpen and a known prefix', () {
|
||||||
|
expect(
|
||||||
|
PersistedAppLinkRule(
|
||||||
|
decision: AppLinkRuleDecision.alwaysOpen,
|
||||||
|
scope: 'host:x.com',
|
||||||
|
packageName: 'pkg',
|
||||||
|
).isValid,
|
||||||
|
isTrue,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
PersistedAppLinkRule(
|
||||||
|
decision: AppLinkRuleDecision.alwaysOpen,
|
||||||
|
scope: 'host:x.com',
|
||||||
|
).isValid,
|
||||||
|
isFalse,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
PersistedAppLinkRule(
|
||||||
|
decision: AppLinkRuleDecision.neverOpen,
|
||||||
|
scope: 'host:x.com',
|
||||||
|
).isValid,
|
||||||
|
isTrue,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
PersistedAppLinkRule(
|
||||||
|
decision: AppLinkRuleDecision.neverOpen,
|
||||||
|
scope: 'notaprefix',
|
||||||
|
).isValid,
|
||||||
|
isFalse,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('parseAppLinkRules', () {
|
||||||
|
test('keeps valid rules keyed by matching scope', () {
|
||||||
|
final parsed = parseAppLinkRules({
|
||||||
|
'host:youtube.com': {
|
||||||
|
'decision': 'alwaysOpen',
|
||||||
|
'scope': 'host:youtube.com',
|
||||||
|
'packageName': 'com.google.android.youtube',
|
||||||
|
},
|
||||||
|
'pkg:us.zoom.videomeetings': {
|
||||||
|
'decision': 'neverOpen',
|
||||||
|
'scope': 'pkg:us.zoom.videomeetings',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(parsed.length, 2);
|
||||||
|
expect(parsed['host:youtube.com']!.decision, AppLinkRuleDecision.alwaysOpen);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('drops entries whose map key disagrees with the rule scope', () {
|
||||||
|
final parsed = parseAppLinkRules({
|
||||||
|
'host:wrong.com': {
|
||||||
|
'decision': 'neverOpen',
|
||||||
|
'scope': 'host:right.com',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(parsed, isEmpty);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('drops malformed and invalid rules', () {
|
||||||
|
final parsed = parseAppLinkRules({
|
||||||
|
'host:a.com': {'decision': 'garbage', 'scope': 'host:a.com'},
|
||||||
|
'host:b.com': {
|
||||||
|
'decision': 'alwaysOpen',
|
||||||
|
'scope': 'host:b.com',
|
||||||
|
}, // missing package
|
||||||
|
'host:c.com': 'not a map',
|
||||||
|
});
|
||||||
|
expect(parsed, isEmpty);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('null input yields an empty map', () {
|
||||||
|
expect(parseAppLinkRules(null), isEmpty);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2024-2026 Fabian Freund.
|
||||||
|
*
|
||||||
|
* This file is part of WebLibre
|
||||||
|
* (see https://weblibre.eu).
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU Affero General Public License as
|
||||||
|
* published by the Free Software Foundation, either version 3 of the
|
||||||
|
* License, or (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU Affero General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU Affero General Public License
|
||||||
|
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:weblibre/features/app_links/domain/services/effective_app_link_policy.dart';
|
||||||
|
import 'package:weblibre/features/geckoview/features/tabs/data/models/container_data.dart';
|
||||||
|
|
||||||
|
ContainerDataWithCount _container(
|
||||||
|
String id, {
|
||||||
|
String? contextId,
|
||||||
|
bool isolatedAppLinkSettings = false,
|
||||||
|
}) {
|
||||||
|
return ContainerDataWithCount(
|
||||||
|
id: id,
|
||||||
|
name: 'Container $id',
|
||||||
|
color: const Color(0xFF336699),
|
||||||
|
orderKey: 'a',
|
||||||
|
metadata: ContainerMetadata.withDefaults(
|
||||||
|
contextualIdentity: contextId,
|
||||||
|
isolatedAppLinkSettings: isolatedAppLinkSettings,
|
||||||
|
),
|
||||||
|
tabCount: 0,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
group('resolveAppLinkOverrideKey', () {
|
||||||
|
test('null contextId resolves to the global bucket', () {
|
||||||
|
final key = resolveAppLinkOverrideKey(
|
||||||
|
liveContextId: null,
|
||||||
|
containers: [
|
||||||
|
_container('1', contextId: 'ctx-1', isolatedAppLinkSettings: true),
|
||||||
|
],
|
||||||
|
isolationContextContainerMap: const {},
|
||||||
|
);
|
||||||
|
expect(key, isNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('regular tab in a non-isolated container resolves globally', () {
|
||||||
|
final key = resolveAppLinkOverrideKey(
|
||||||
|
liveContextId: 'ctx-1',
|
||||||
|
containers: [_container('1', contextId: 'ctx-1')],
|
||||||
|
isolationContextContainerMap: const {},
|
||||||
|
);
|
||||||
|
expect(key, isNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
test(
|
||||||
|
'regular tab in an isolated-app-link container resolves to its base',
|
||||||
|
() {
|
||||||
|
final key = resolveAppLinkOverrideKey(
|
||||||
|
liveContextId: 'ctx-1',
|
||||||
|
containers: [
|
||||||
|
_container('1', contextId: 'ctx-1', isolatedAppLinkSettings: true),
|
||||||
|
],
|
||||||
|
isolationContextContainerMap: const {},
|
||||||
|
);
|
||||||
|
expect(key, 'ctx-1');
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
test('isolated tab resolves via the isolation map', () {
|
||||||
|
final key = resolveAppLinkOverrideKey(
|
||||||
|
liveContextId: 'iso-1',
|
||||||
|
containers: [
|
||||||
|
_container('1', contextId: 'ctx-1', isolatedAppLinkSettings: true),
|
||||||
|
],
|
||||||
|
isolationContextContainerMap: const {
|
||||||
|
'iso-1': {'1'},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
expect(key, 'ctx-1');
|
||||||
|
});
|
||||||
|
|
||||||
|
test(
|
||||||
|
'isolated tab of a non-isolated-app-link container resolves globally',
|
||||||
|
() {
|
||||||
|
final key = resolveAppLinkOverrideKey(
|
||||||
|
liveContextId: 'iso-1',
|
||||||
|
containers: [_container('1', contextId: 'ctx-1')],
|
||||||
|
isolationContextContainerMap: const {
|
||||||
|
'iso-1': {'1'},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
expect(key, isNull);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
test('shared isolation context picks the lowest sorted base contextId', () {
|
||||||
|
final key = resolveAppLinkOverrideKey(
|
||||||
|
liveContextId: 'iso-1',
|
||||||
|
containers: [
|
||||||
|
_container('1', contextId: 'ctx-b', isolatedAppLinkSettings: true),
|
||||||
|
_container('2', contextId: 'ctx-a', isolatedAppLinkSettings: true),
|
||||||
|
],
|
||||||
|
isolationContextContainerMap: const {
|
||||||
|
'iso-1': {'1', '2'},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
expect(key, 'ctx-a');
|
||||||
|
});
|
||||||
|
|
||||||
|
test(
|
||||||
|
'shared isolation context skips containers without isolated settings',
|
||||||
|
() {
|
||||||
|
final key = resolveAppLinkOverrideKey(
|
||||||
|
liveContextId: 'iso-1',
|
||||||
|
containers: [
|
||||||
|
_container('1', contextId: 'ctx-a'),
|
||||||
|
_container('2', contextId: 'ctx-b', isolatedAppLinkSettings: true),
|
||||||
|
],
|
||||||
|
isolationContextContainerMap: const {
|
||||||
|
'iso-1': {'1', '2'},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
expect(key, 'ctx-b');
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
test('unknown contextId resolves globally', () {
|
||||||
|
final key = resolveAppLinkOverrideKey(
|
||||||
|
liveContextId: 'ctx-unknown',
|
||||||
|
containers: [
|
||||||
|
_container('1', contextId: 'ctx-1', isolatedAppLinkSettings: true),
|
||||||
|
],
|
||||||
|
isolationContextContainerMap: const {},
|
||||||
|
);
|
||||||
|
expect(key, isNull);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,188 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2024-2026 Fabian Freund.
|
||||||
|
*
|
||||||
|
* This file is part of WebLibre
|
||||||
|
* (see https://weblibre.eu).
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU Affero General Public License as
|
||||||
|
* published by the Free Software Foundation, either version 3 of the
|
||||||
|
* License, or (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU Affero General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU Affero General Public License
|
||||||
|
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:weblibre/features/app_links/domain/services/effective_routing.dart';
|
||||||
|
import 'package:weblibre/features/geckoview/features/tabs/data/models/site_assignment.dart';
|
||||||
|
import 'package:weblibre/features/proxy/data/proxy_connection.dart';
|
||||||
|
|
||||||
|
SiteAssignment _assignment(String site, {String? contextId}) => SiteAssignment(
|
||||||
|
id: site,
|
||||||
|
contextualIdentity: contextId,
|
||||||
|
assignedSite: site,
|
||||||
|
);
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
group('resolveContainerAssignment', () {
|
||||||
|
test('explicit proxy connection wins', () {
|
||||||
|
final assignment = resolveContainerAssignment(
|
||||||
|
contextId: 'ctx',
|
||||||
|
proxyConnectionId: const TorProxyConnectionId(),
|
||||||
|
bypassGlobalProxy: false,
|
||||||
|
);
|
||||||
|
expect(assignment, isA<ExplicitProxyAssignment>());
|
||||||
|
});
|
||||||
|
|
||||||
|
test('bypassGlobalProxy with no proxy is direct scoped to the context', () {
|
||||||
|
final assignment = resolveContainerAssignment(
|
||||||
|
contextId: 'ctx',
|
||||||
|
proxyConnectionId: null,
|
||||||
|
bypassGlobalProxy: true,
|
||||||
|
);
|
||||||
|
expect(assignment, isA<DirectProxyAssignment>());
|
||||||
|
expect((assignment as DirectProxyAssignment).scopeId, 'ctx');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('no proxy and no bypass inherits', () {
|
||||||
|
final assignment = resolveContainerAssignment(
|
||||||
|
contextId: 'ctx',
|
||||||
|
proxyConnectionId: null,
|
||||||
|
bypassGlobalProxy: false,
|
||||||
|
);
|
||||||
|
expect(assignment, isA<InheritProxyAssignment>());
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('resolveIsolationContextRouting', () {
|
||||||
|
test('any explicit proxy wins (lowest sorted id)', () {
|
||||||
|
final routing = resolveIsolationContextRouting([
|
||||||
|
ProxyAssignment.inherit(),
|
||||||
|
ProxyAssignment.explicit('zeta'),
|
||||||
|
ProxyAssignment.explicit('alpha'),
|
||||||
|
ProxyAssignment.direct('scope'),
|
||||||
|
]);
|
||||||
|
expect(routing.chosen, isA<ExplicitProxyAssignment>());
|
||||||
|
expect((routing.chosen as ExplicitProxyAssignment).proxyId, 'alpha');
|
||||||
|
expect(routing.distinctAssignmentCount, 4);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('direct wins only when no container inherits', () {
|
||||||
|
final routing = resolveIsolationContextRouting([
|
||||||
|
ProxyAssignment.direct('scopeB'),
|
||||||
|
ProxyAssignment.direct('scopeA'),
|
||||||
|
]);
|
||||||
|
expect(routing.chosen, isA<DirectProxyAssignment>());
|
||||||
|
expect((routing.chosen as DirectProxyAssignment).scopeId, 'scopeA');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('direct plus inherit collapses to inherit', () {
|
||||||
|
final routing = resolveIsolationContextRouting([
|
||||||
|
ProxyAssignment.direct('scope'),
|
||||||
|
ProxyAssignment.inherit(),
|
||||||
|
]);
|
||||||
|
expect(routing.chosen, isA<InheritProxyAssignment>());
|
||||||
|
expect(routing.distinctAssignmentCount, 2);
|
||||||
|
expect(routing.assignmentLabels, ['inherit', 'direct:scope']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('isAssignmentProtected', () {
|
||||||
|
test('explicit is always protected', () {
|
||||||
|
expect(
|
||||||
|
isAssignmentProtected(
|
||||||
|
ProxyAssignment.explicit('p'),
|
||||||
|
protectGeneralContext: false,
|
||||||
|
),
|
||||||
|
isTrue,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('direct is never protected', () {
|
||||||
|
expect(
|
||||||
|
isAssignmentProtected(
|
||||||
|
ProxyAssignment.direct('s'),
|
||||||
|
protectGeneralContext: true,
|
||||||
|
),
|
||||||
|
isFalse,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('inherit follows the general context', () {
|
||||||
|
expect(
|
||||||
|
isAssignmentProtected(
|
||||||
|
ProxyAssignment.inherit(),
|
||||||
|
protectGeneralContext: true,
|
||||||
|
),
|
||||||
|
isTrue,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
isAssignmentProtected(
|
||||||
|
ProxyAssignment.inherit(),
|
||||||
|
protectGeneralContext: false,
|
||||||
|
),
|
||||||
|
isFalse,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('protectedTargetPatternForSite', () {
|
||||||
|
test('wildcard entry includes subdomains and ignores port', () {
|
||||||
|
final pattern = protectedTargetPatternForSite(
|
||||||
|
Uri.parse('https://*.example.com'),
|
||||||
|
);
|
||||||
|
expect(pattern.scheme, 'https');
|
||||||
|
expect(pattern.hostOrSuffix, 'example.com');
|
||||||
|
expect(pattern.includeSubdomains, isTrue);
|
||||||
|
expect(pattern.port, isNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('exact entry preserves effective port', () {
|
||||||
|
final defaultPort = protectedTargetPatternForSite(
|
||||||
|
Uri.parse('https://example.com'),
|
||||||
|
);
|
||||||
|
expect(defaultPort.hostOrSuffix, 'example.com');
|
||||||
|
expect(defaultPort.includeSubdomains, isFalse);
|
||||||
|
expect(defaultPort.port, 443);
|
||||||
|
|
||||||
|
final explicitPort = protectedTargetPatternForSite(
|
||||||
|
Uri.parse('http://example.com:8080'),
|
||||||
|
);
|
||||||
|
expect(explicitPort.port, 8080);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('computeProtectedTargetPatterns', () {
|
||||||
|
test('keeps only assignments in a protected or strict container', () {
|
||||||
|
final patterns = computeProtectedTargetPatterns(
|
||||||
|
assignments: [
|
||||||
|
_assignment('https://proxied.example', contextId: 'proxied'),
|
||||||
|
_assignment('https://direct.example', contextId: 'direct'),
|
||||||
|
_assignment('https://strict.example', contextId: 'strict'),
|
||||||
|
_assignment('https://unassigned.example', contextId: null),
|
||||||
|
],
|
||||||
|
protectedOrStrictContextIds: {'proxied', 'strict'},
|
||||||
|
);
|
||||||
|
|
||||||
|
final hosts = patterns.map((p) => p.hostOrSuffix).toSet();
|
||||||
|
expect(hosts, {'proxied.example', 'strict.example'});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('deduplicates identical patterns', () {
|
||||||
|
final patterns = computeProtectedTargetPatterns(
|
||||||
|
assignments: [
|
||||||
|
_assignment('https://dup.example', contextId: 'a'),
|
||||||
|
_assignment('https://dup.example', contextId: 'a'),
|
||||||
|
],
|
||||||
|
protectedOrStrictContextIds: {'a'},
|
||||||
|
);
|
||||||
|
expect(patterns.length, 1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2024-2026 Fabian Freund.
|
||||||
|
*
|
||||||
|
* This file is part of WebLibre
|
||||||
|
* (see https://weblibre.eu).
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU Affero General Public License as
|
||||||
|
* published by the Free Software Foundation, either version 3 of the
|
||||||
|
* License, or (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU Affero General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU Affero General Public License
|
||||||
|
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import 'package:flutter_mozilla_components/flutter_mozilla_components.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:weblibre/features/app_links/domain/entities/app_link_rule.dart';
|
||||||
|
import 'package:weblibre/features/app_links/domain/entities/context_app_link_policy.dart';
|
||||||
|
import 'package:weblibre/features/user/data/models/general_settings.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
group('GeneralSettings app-link fields', () {
|
||||||
|
test('defaults are ask / empty rules / marketplace off', () {
|
||||||
|
final settings = GeneralSettings.withDefaults();
|
||||||
|
expect(settings.appLinksMode, AppLinksMode.ask);
|
||||||
|
expect(settings.appLinkRules, isEmpty);
|
||||||
|
expect(settings.appLinkMarketplaceFallback, isFalse);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the three fields survive a toJson -> fromJson round-trip', () {
|
||||||
|
final rule = PersistedAppLinkRule(
|
||||||
|
decision: AppLinkRuleDecision.alwaysOpen,
|
||||||
|
scope: 'host:youtu.be',
|
||||||
|
packageName: 'com.google.android.youtube',
|
||||||
|
);
|
||||||
|
final settings = GeneralSettings.withDefaults(
|
||||||
|
appLinksMode: AppLinksMode.always,
|
||||||
|
appLinkRules: {rule.scope: rule},
|
||||||
|
appLinkMarketplaceFallback: true,
|
||||||
|
);
|
||||||
|
|
||||||
|
final restored = GeneralSettings.fromJson(settings.toJson());
|
||||||
|
|
||||||
|
expect(restored.appLinksMode, AppLinksMode.always);
|
||||||
|
expect(restored.appLinkMarketplaceFallback, isTrue);
|
||||||
|
expect(restored.appLinkRules.keys, ['host:youtu.be']);
|
||||||
|
expect(restored.appLinkRules['host:youtu.be'], rule);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('malformed persisted rules are dropped on read (parseAppLinkRules)', () {
|
||||||
|
final json = GeneralSettings.withDefaults().toJson();
|
||||||
|
// A scope key that disagrees with the rule's own scope is invalid and dropped.
|
||||||
|
json['appLinkRules'] = {
|
||||||
|
'host:youtu.be': {
|
||||||
|
'decision': 'alwaysOpen',
|
||||||
|
'scope': 'host:evil.example',
|
||||||
|
'packageName': 'com.google.android.youtube',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
final restored = GeneralSettings.fromJson(json);
|
||||||
|
expect(restored.appLinkRules, isEmpty);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('GeneralSettings per-container app-link overrides', () {
|
||||||
|
test('defaults to an empty override map', () {
|
||||||
|
expect(GeneralSettings.withDefaults().appLinkContextOverrides, isEmpty);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a container override survives a toJson -> fromJson round-trip', () {
|
||||||
|
final rule = PersistedAppLinkRule(
|
||||||
|
decision: AppLinkRuleDecision.neverOpen,
|
||||||
|
scope: 'host:reddit.com',
|
||||||
|
);
|
||||||
|
final override = ContextAppLinkPolicy(
|
||||||
|
mode: AppLinksMode.never,
|
||||||
|
rules: {rule.scope: rule},
|
||||||
|
);
|
||||||
|
final settings = GeneralSettings.withDefaults(
|
||||||
|
appLinkContextOverrides: {'work': override},
|
||||||
|
);
|
||||||
|
|
||||||
|
final restored = GeneralSettings.fromJson(settings.toJson());
|
||||||
|
|
||||||
|
expect(restored.appLinkContextOverrides.keys, ['work']);
|
||||||
|
final restoredOverride = restored.appLinkContextOverrides['work']!;
|
||||||
|
expect(restoredOverride.mode, AppLinksMode.never);
|
||||||
|
expect(restoredOverride.rules['host:reddit.com'], rule);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the blank override is ask / empty rules', () {
|
||||||
|
final blank = ContextAppLinkPolicy.blank();
|
||||||
|
expect(blank.mode, AppLinksMode.ask);
|
||||||
|
expect(blank.rules, isEmpty);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('malformed override entries are dropped on read', () {
|
||||||
|
final json = GeneralSettings.withDefaults().toJson();
|
||||||
|
json['appLinkContextOverrides'] = {
|
||||||
|
'work': <String, dynamic>{'mode': 'not-a-mode'},
|
||||||
|
};
|
||||||
|
|
||||||
|
final restored = GeneralSettings.fromJson(json);
|
||||||
|
expect(restored.appLinkContextOverrides, isEmpty);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
+38
@@ -35,6 +35,44 @@ void main() {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
group('ContainerMetadata isolatedAppLinkSettings invariant', () {
|
||||||
|
test('stays enabled when the container has a contextId', () {
|
||||||
|
final metadata = ContainerMetadata.withDefaults(
|
||||||
|
contextualIdentity: 'work',
|
||||||
|
isolatedAppLinkSettings: true,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(metadata.isolatedAppLinkSettings, isTrue);
|
||||||
|
expect(metadata.sanitized().isolatedAppLinkSettings, isTrue);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('is normalized off without a contextId (read + sanitized)', () {
|
||||||
|
final metadata = ContainerMetadata.withDefaults(
|
||||||
|
contextualIdentity: null,
|
||||||
|
isolatedAppLinkSettings: true,
|
||||||
|
);
|
||||||
|
|
||||||
|
// withDefaults normalizes on construction/read.
|
||||||
|
expect(metadata.isolatedAppLinkSettings, isFalse);
|
||||||
|
|
||||||
|
// A record that somehow carries the bad combination is re-normalized.
|
||||||
|
final restored = ContainerMetadata.fromJson({
|
||||||
|
...metadata.toJson(),
|
||||||
|
'isolatedAppLinkSettings': true,
|
||||||
|
'contextualIdentity': null,
|
||||||
|
});
|
||||||
|
expect(restored.isolatedAppLinkSettings, isFalse);
|
||||||
|
expect(restored.sanitized().isolatedAppLinkSettings, isFalse);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('defaults to false', () {
|
||||||
|
expect(
|
||||||
|
ContainerMetadata.withDefaults().isolatedAppLinkSettings,
|
||||||
|
isFalse,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
group('ContainerMetadata icon serialization', () {
|
group('ContainerMetadata icon serialization', () {
|
||||||
test('stores MDI icon names', () {
|
test('stores MDI icon names', () {
|
||||||
final metadata = ContainerMetadata.withDefaults(
|
final metadata = ContainerMetadata.withDefaults(
|
||||||
|
|||||||
@@ -114,7 +114,6 @@ dependencies {
|
|||||||
implementation "org.mozilla.components:browser-icons:$mozillaComponentsVersion"
|
implementation "org.mozilla.components:browser-icons:$mozillaComponentsVersion"
|
||||||
implementation "org.mozilla.components:browser-thumbnails:$mozillaComponentsVersion"
|
implementation "org.mozilla.components:browser-thumbnails:$mozillaComponentsVersion"
|
||||||
implementation "org.mozilla.components:feature-addons:$mozillaComponentsVersion"
|
implementation "org.mozilla.components:feature-addons:$mozillaComponentsVersion"
|
||||||
implementation "org.mozilla.components:feature-app-links:$mozillaComponentsVersion"
|
|
||||||
implementation "org.mozilla.components:feature-accounts:$mozillaComponentsVersion"
|
implementation "org.mozilla.components:feature-accounts:$mozillaComponentsVersion"
|
||||||
implementation "org.mozilla.components:feature-accounts-push:$mozillaComponentsVersion"
|
implementation "org.mozilla.components:feature-accounts-push:$mozillaComponentsVersion"
|
||||||
implementation "org.mozilla.components:feature-awesomebar:$mozillaComponentsVersion"
|
implementation "org.mozilla.components:feature-awesomebar:$mozillaComponentsVersion"
|
||||||
|
|||||||
+23
-39
@@ -25,7 +25,6 @@ import androidx.core.content.edit
|
|||||||
import androidx.fragment.app.Fragment
|
import androidx.fragment.app.Fragment
|
||||||
import androidx.preference.PreferenceManager
|
import androidx.preference.PreferenceManager
|
||||||
import eu.weblibre.flutter_mozilla_components.addons.WebExtensionPromptFeature
|
import eu.weblibre.flutter_mozilla_components.addons.WebExtensionPromptFeature
|
||||||
import eu.weblibre.flutter_mozilla_components.activities.ExternalAppBrowserActivity
|
|
||||||
import eu.weblibre.flutter_mozilla_components.databinding.FragmentBrowserBinding
|
import eu.weblibre.flutter_mozilla_components.databinding.FragmentBrowserBinding
|
||||||
import eu.weblibre.flutter_mozilla_components.ext.EventSequence
|
import eu.weblibre.flutter_mozilla_components.ext.EventSequence
|
||||||
import eu.weblibre.flutter_mozilla_components.ext.getPreferenceKey
|
import eu.weblibre.flutter_mozilla_components.ext.getPreferenceKey
|
||||||
@@ -37,6 +36,9 @@ import eu.weblibre.flutter_mozilla_components.feature.ReadabilityExtractFeature
|
|||||||
import eu.weblibre.flutter_mozilla_components.feature.WebExtensionToolbarFeature
|
import eu.weblibre.flutter_mozilla_components.feature.WebExtensionToolbarFeature
|
||||||
import eu.weblibre.flutter_mozilla_components.integration.ReaderViewIntegration
|
import eu.weblibre.flutter_mozilla_components.integration.ReaderViewIntegration
|
||||||
import eu.weblibre.flutter_mozilla_components.services.DownloadService
|
import eu.weblibre.flutter_mozilla_components.services.DownloadService
|
||||||
|
import eu.weblibre.flutter_mozilla_components.applinks.AppLinkRuntime
|
||||||
|
import eu.weblibre.flutter_mozilla_components.applinks.NativeAppLinkPromptFeature
|
||||||
|
import eu.weblibre.flutter_mozilla_components.applinks.PendingAppLinkStores
|
||||||
import io.flutter.Log
|
import io.flutter.Log
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.flow.distinctUntilChangedBy
|
import kotlinx.coroutines.flow.distinctUntilChangedBy
|
||||||
@@ -49,7 +51,6 @@ import mozilla.components.browser.thumbnails.BrowserThumbnails
|
|||||||
import mozilla.components.concept.engine.EngineView
|
import mozilla.components.concept.engine.EngineView
|
||||||
import mozilla.components.feature.accounts.FxaCapability
|
import mozilla.components.feature.accounts.FxaCapability
|
||||||
import mozilla.components.feature.accounts.FxaWebChannelFeature
|
import mozilla.components.feature.accounts.FxaWebChannelFeature
|
||||||
import mozilla.components.feature.app.links.AppLinksFeature
|
|
||||||
import mozilla.components.feature.downloads.DownloadsFeature
|
import mozilla.components.feature.downloads.DownloadsFeature
|
||||||
import mozilla.components.feature.downloads.manager.FetchDownloadManager
|
import mozilla.components.feature.downloads.manager.FetchDownloadManager
|
||||||
import mozilla.components.feature.downloads.temporary.CopyDownloadFeature
|
import mozilla.components.feature.downloads.temporary.CopyDownloadFeature
|
||||||
@@ -89,7 +90,8 @@ abstract class BaseBrowserFragment : Fragment(), UserInteractionHandler, Activit
|
|||||||
private val shareResourceFeature = ViewBoundFeatureWrapper<ShareResourceFeature>()
|
private val shareResourceFeature = ViewBoundFeatureWrapper<ShareResourceFeature>()
|
||||||
private val copyDownloadFeature = ViewBoundFeatureWrapper<CopyDownloadFeature>()
|
private val copyDownloadFeature = ViewBoundFeatureWrapper<CopyDownloadFeature>()
|
||||||
private val downloadsFeature = ViewBoundFeatureWrapper<DownloadsFeature>()
|
private val downloadsFeature = ViewBoundFeatureWrapper<DownloadsFeature>()
|
||||||
private val appLinksFeature = ViewBoundFeatureWrapper<AppLinksFeature>()
|
// Native prompt for Custom Tab sessions with no Flutter engine.
|
||||||
|
private val nativeAppLinkPromptFeature = ViewBoundFeatureWrapper<NativeAppLinkPromptFeature>()
|
||||||
private val promptFeature = ViewBoundFeatureWrapper<PromptFeature>()
|
private val promptFeature = ViewBoundFeatureWrapper<PromptFeature>()
|
||||||
private val webExtensionPromptFeature = ViewBoundFeatureWrapper<WebExtensionPromptFeature>()
|
private val webExtensionPromptFeature = ViewBoundFeatureWrapper<WebExtensionPromptFeature>()
|
||||||
private val sitePermissionsFeature = ViewBoundFeatureWrapper<SitePermissionsFeature>()
|
private val sitePermissionsFeature = ViewBoundFeatureWrapper<SitePermissionsFeature>()
|
||||||
@@ -362,42 +364,24 @@ abstract class BaseBrowserFragment : Fragment(), UserInteractionHandler, Activit
|
|||||||
view = view,
|
view = view,
|
||||||
)
|
)
|
||||||
|
|
||||||
appLinksFeature.set(
|
// App-link prompting: browser tabs are prompted by Flutter's AppLinkPromptHost, so only
|
||||||
feature = AppLinksFeature(
|
// native Custom Tab sessions (no Flutter engine) install a native prompt feature here.
|
||||||
context = profileContext,
|
val nativeTabId = sessionId
|
||||||
store = components.core.store,
|
if (this is ExternalAppBrowserFragment && nativeTabId != null) {
|
||||||
sessionId = sessionId,
|
nativeAppLinkPromptFeature.set(
|
||||||
fragmentManager = parentFragmentManager,
|
feature = NativeAppLinkPromptFeature(
|
||||||
loadUrlUseCase = components.useCases.sessionUseCases.loadUrl,
|
context = profileContext,
|
||||||
launchInApp = {
|
tabId = nativeTabId,
|
||||||
GlobalComponents.shouldOpenLinksInApp(
|
store = PendingAppLinkStores.forProfile(
|
||||||
requireActivity() is ExternalAppBrowserActivity
|
components.profileApplicationContext.relativePath,
|
||||||
)
|
),
|
||||||
},
|
launcher = AppLinkRuntime.get(profileContext).launcher,
|
||||||
shouldPrompt = {
|
sessionUseCases = components.useCases.sessionUseCases,
|
||||||
GlobalComponents.shouldPromptOpenLinksInApp(
|
),
|
||||||
requireActivity() is ExternalAppBrowserActivity
|
owner = this,
|
||||||
)
|
view = view,
|
||||||
},
|
)
|
||||||
alwaysOpenCheckboxAction = {
|
}
|
||||||
GlobalComponents.engineSettingsApi?.setAppLinksMode(
|
|
||||||
eu.weblibre.flutter_mozilla_components.pigeons.AppLinksMode.ALWAYS
|
|
||||||
)
|
|
||||||
},
|
|
||||||
failedToLaunchAction = { fallbackUrl ->
|
|
||||||
fallbackUrl?.let {
|
|
||||||
val appLinksUseCases = components.useCases.appLinksUseCases
|
|
||||||
val getRedirect = appLinksUseCases.appLinkRedirect
|
|
||||||
val redirect = getRedirect.invoke(fallbackUrl)
|
|
||||||
redirect.appIntent?.flags =
|
|
||||||
Intent.FLAG_ACTIVITY_NEW_DOCUMENT or Intent.FLAG_ACTIVITY_MULTIPLE_TASK
|
|
||||||
appLinksUseCases.openAppLink.invoke(redirect.appIntent)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
),
|
|
||||||
owner = this,
|
|
||||||
view = view,
|
|
||||||
)
|
|
||||||
|
|
||||||
promptFeature.set(
|
promptFeature.set(
|
||||||
feature = PromptFeature(
|
feature = PromptFeature(
|
||||||
|
|||||||
+3
@@ -47,6 +47,9 @@ class FlutterMozillaComponentsPlugin: FlutterPlugin, ActivityAware {
|
|||||||
GeckoPushApi.setUp(binding.binaryMessenger, null)
|
GeckoPushApi.setUp(binding.binaryMessenger, null)
|
||||||
browserApi.disposePushApi()
|
browserApi.disposePushApi()
|
||||||
GlobalComponents.historyEvents = null
|
GlobalComponents.historyEvents = null
|
||||||
|
// The availability event is optimisation-only; once Flutter detaches, the surface
|
||||||
|
// re-queries pending prompts on its next attach/resume, so dropping the sink is safe.
|
||||||
|
GlobalComponents.appLinkEvents = null
|
||||||
// The UnifiedPush receiver outlives the Flutter engine; without this it would keep dispatching
|
// The UnifiedPush receiver outlives the Flutter engine; without this it would keep dispatching
|
||||||
// onto a dead messenger. Failures are still retained on Push.lastError.
|
// onto a dead messenger. Failures are still retained on Push.lastError.
|
||||||
GlobalComponents.pushEvents = null
|
GlobalComponents.pushEvents = null
|
||||||
|
|||||||
+6
-16
@@ -16,6 +16,7 @@ import eu.weblibre.flutter_mozilla_components.pigeons.BounceTrackingProtectionMo
|
|||||||
import eu.weblibre.flutter_mozilla_components.pigeons.ContentBlocking
|
import eu.weblibre.flutter_mozilla_components.pigeons.ContentBlocking
|
||||||
import eu.weblibre.flutter_mozilla_components.pigeons.GeckoAddonEvents
|
import eu.weblibre.flutter_mozilla_components.pigeons.GeckoAddonEvents
|
||||||
import eu.weblibre.flutter_mozilla_components.pigeons.GeckoEngineSettings
|
import eu.weblibre.flutter_mozilla_components.pigeons.GeckoEngineSettings
|
||||||
|
import eu.weblibre.flutter_mozilla_components.pigeons.GeckoAppLinkEvents
|
||||||
import eu.weblibre.flutter_mozilla_components.pigeons.GeckoGestureEvents
|
import eu.weblibre.flutter_mozilla_components.pigeons.GeckoGestureEvents
|
||||||
import eu.weblibre.flutter_mozilla_components.pigeons.GeckoHistoryEvents
|
import eu.weblibre.flutter_mozilla_components.pigeons.GeckoHistoryEvents
|
||||||
import eu.weblibre.flutter_mozilla_components.pigeons.GeckoPushEvents
|
import eu.weblibre.flutter_mozilla_components.pigeons.GeckoPushEvents
|
||||||
@@ -150,6 +151,11 @@ object GlobalComponents {
|
|||||||
// path), in which case failures are logged natively only.
|
// path), in which case failures are logged natively only.
|
||||||
var pushEvents: GeckoPushEvents? = null
|
var pushEvents: GeckoPushEvents? = null
|
||||||
|
|
||||||
|
// Native -> Dart availability signal for pending app-link prompts. Optimisation
|
||||||
|
// only (no buffering/replay): null when Flutter is detached, in which case the
|
||||||
|
// Flutter surface picks the prompt up on its next getPendingAppLinkPrompts query.
|
||||||
|
var appLinkEvents: GeckoAppLinkEvents? = null
|
||||||
|
|
||||||
// Gecko contextIds of containers with hard exclude-from-history enabled.
|
// Gecko contextIds of containers with hard exclude-from-history enabled.
|
||||||
// Pushed from Dart; read by WebLibreHistoryDelegate to skip the Places
|
// Pushed from Dart; read by WebLibreHistoryDelegate to skip the Places
|
||||||
// write for visits resolved to one of these containers.
|
// write for visits resolved to one of these containers.
|
||||||
@@ -250,22 +256,6 @@ object GlobalComponents {
|
|||||||
context?.stopService(Intent(context, PrivateTabsNotificationService::class.java))
|
context?.stopService(Intent(context, PrivateTabsNotificationService::class.java))
|
||||||
}
|
}
|
||||||
|
|
||||||
fun shouldOpenLinksInApp(isExternalSession: Boolean = false): Boolean {
|
|
||||||
return when (engineSettingsApi!!.getAppLinksMode()) {
|
|
||||||
eu.weblibre.flutter_mozilla_components.pigeons.AppLinksMode.ALWAYS -> true
|
|
||||||
eu.weblibre.flutter_mozilla_components.pigeons.AppLinksMode.ASK -> true
|
|
||||||
eu.weblibre.flutter_mozilla_components.pigeons.AppLinksMode.NEVER -> isExternalSession
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun shouldPromptOpenLinksInApp(isExternalSession: Boolean = false): Boolean {
|
|
||||||
return when (engineSettingsApi!!.getAppLinksMode()) {
|
|
||||||
eu.weblibre.flutter_mozilla_components.pigeons.AppLinksMode.ALWAYS -> false
|
|
||||||
eu.weblibre.flutter_mozilla_components.pigeons.AppLinksMode.ASK -> true
|
|
||||||
eu.weblibre.flutter_mozilla_components.pigeons.AppLinksMode.NEVER -> isExternalSession
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@DelicateCoroutinesApi
|
@DelicateCoroutinesApi
|
||||||
private fun restoreBrowserState(
|
private fun restoreBrowserState(
|
||||||
newComponents: Components,
|
newComponents: Components,
|
||||||
|
|||||||
+186
-28
@@ -7,64 +7,222 @@
|
|||||||
package eu.weblibre.flutter_mozilla_components.api
|
package eu.weblibre.flutter_mozilla_components.api
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import eu.weblibre.flutter_mozilla_components.Components
|
||||||
import eu.weblibre.flutter_mozilla_components.GlobalComponents
|
import eu.weblibre.flutter_mozilla_components.GlobalComponents
|
||||||
|
import eu.weblibre.flutter_mozilla_components.applinks.AppLinkLaunchMode
|
||||||
|
import eu.weblibre.flutter_mozilla_components.applinks.AppLinkLaunchResult
|
||||||
|
import eu.weblibre.flutter_mozilla_components.applinks.AppLinkPolicyStores
|
||||||
|
import eu.weblibre.flutter_mozilla_components.applinks.AppLinkRuntime
|
||||||
|
import eu.weblibre.flutter_mozilla_components.applinks.PendingAppLinkRequest
|
||||||
|
import eu.weblibre.flutter_mozilla_components.applinks.PendingAppLinkStore
|
||||||
|
import eu.weblibre.flutter_mozilla_components.applinks.PendingAppLinkStores
|
||||||
|
import eu.weblibre.flutter_mozilla_components.applinks.toAppLinkPolicy
|
||||||
|
import eu.weblibre.flutter_mozilla_components.pigeons.AppLinkDecision
|
||||||
|
import eu.weblibre.flutter_mozilla_components.pigeons.AppLinkPolicySnapshot
|
||||||
|
import eu.weblibre.flutter_mozilla_components.pigeons.AppLinkPromptOwner
|
||||||
|
import eu.weblibre.flutter_mozilla_components.pigeons.AppLinkPromptRequest
|
||||||
|
import eu.weblibre.flutter_mozilla_components.pigeons.AppLinkResolutionResult
|
||||||
|
import eu.weblibre.flutter_mozilla_components.pigeons.AppLinkTarget
|
||||||
import eu.weblibre.flutter_mozilla_components.pigeons.GeckoAppLinksApi
|
import eu.weblibre.flutter_mozilla_components.pigeons.GeckoAppLinksApi
|
||||||
|
import mozilla.components.browser.state.selector.findTabOrCustomTab
|
||||||
|
import mozilla.components.support.base.log.logger.Logger
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.SupervisorJob
|
import kotlinx.coroutines.SupervisorJob
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Implementation of GeckoAppLinksApi that detects and launches external applications
|
* WebLibre-owned implementation of [GeckoAppLinksApi] backed by [ExternalAppResolver] and
|
||||||
* that can handle URLs.
|
* [AppLinkLauncher] (APP_LINKS_OWN_IMPLEMENTATION_PLAN.md Phase 1). Policy lives in Dart; this
|
||||||
*
|
* surface owns PackageManager resolution and Intent launch for the manual entry points.
|
||||||
* This uses Mozilla Android Components' AppLinksUseCases to properly detect if a native
|
|
||||||
* app is available to handle a URL, matching the behavior in Firefox/Fenix.
|
|
||||||
*/
|
*/
|
||||||
class GeckoAppLinksApiImpl(
|
class GeckoAppLinksApiImpl(
|
||||||
private val context: Context
|
private val context: Context,
|
||||||
) : GeckoAppLinksApi {
|
) : GeckoAppLinksApi {
|
||||||
companion object {
|
companion object {
|
||||||
private val coroutineScope = CoroutineScope(Dispatchers.Default + SupervisorJob())
|
private val coroutineScope = CoroutineScope(Dispatchers.Default + SupervisorJob())
|
||||||
|
private val logger = Logger("GeckoAppLinksApi")
|
||||||
}
|
}
|
||||||
|
|
||||||
private val components by lazy {
|
// Shared process-level resolver/launcher (§2.7): the 2 s auto-launch cooldown and 30 s
|
||||||
requireNotNull(GlobalComponents.components) { "Components not initialized" }
|
// resolution cache are observed across the interceptor tail, the manual entry points, and
|
||||||
}
|
// prompt resolution alike.
|
||||||
|
private val resolver get() = AppLinkRuntime.get(context).resolver
|
||||||
|
private val launcher get() = AppLinkRuntime.get(context).launcher
|
||||||
|
|
||||||
override fun hasExternalApp(url: String, callback: (Result<Boolean>) -> Unit) {
|
override fun setAppLinkPolicy(
|
||||||
|
snapshot: AppLinkPolicySnapshot,
|
||||||
|
callback: (Result<Unit>) -> Unit,
|
||||||
|
) {
|
||||||
coroutineScope.launch {
|
coroutineScope.launch {
|
||||||
try {
|
try {
|
||||||
val redirect = components.useCases.appLinksUseCases.appLinkRedirect(url)
|
// A profile must be bound before policy can be applied. The Dart
|
||||||
callback(Result.success(redirect.hasExternalApp()))
|
// replicator retries after initialisation (§2.8, §2.10).
|
||||||
|
val profileContext = GlobalComponents.components?.profileApplicationContext
|
||||||
|
?: throw IllegalStateException("No profile bound for app-link policy")
|
||||||
|
val store = AppLinkPolicyStores.forProfile(profileContext)
|
||||||
|
val persisted = store.setPolicy(snapshot.toAppLinkPolicy())
|
||||||
|
if (persisted) {
|
||||||
|
callback(Result.success(Unit))
|
||||||
|
} else {
|
||||||
|
callback(Result.failure(IllegalStateException("Failed to persist app-link policy")))
|
||||||
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
callback(Result.success(false))
|
callback(Result.failure(e))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun openAppLink(url: String, callback: (Result<Boolean>) -> Unit) {
|
override fun resolveAppLink(
|
||||||
|
url: String,
|
||||||
|
includeHttpAppLinks: Boolean,
|
||||||
|
callback: (Result<AppLinkTarget?>) -> Unit,
|
||||||
|
) {
|
||||||
coroutineScope.launch {
|
coroutineScope.launch {
|
||||||
try {
|
try {
|
||||||
val redirect = components.useCases.appLinksUseCases.appLinkRedirect(url)
|
val resolved = resolver.resolve(url, includeHttpAppLinks = includeHttpAppLinks)
|
||||||
|
if (!resolved.hasExternalApp) {
|
||||||
if (!redirect.hasExternalApp()) {
|
callback(Result.success(null))
|
||||||
callback(Result.success(false))
|
|
||||||
return@launch
|
return@launch
|
||||||
}
|
}
|
||||||
|
callback(
|
||||||
// Use NEW_DOCUMENT + MULTIPLE_TASK so the target app opens in its own
|
Result.success(
|
||||||
// task and doesn't get absorbed into WebLibre's recents entry.
|
AppLinkTarget(
|
||||||
// This matches Fenix's ShareController behaviour.
|
url = url,
|
||||||
redirect.appIntent?.flags =
|
appName = resolved.appName,
|
||||||
Intent.FLAG_ACTIVITY_NEW_DOCUMENT or Intent.FLAG_ACTIVITY_MULTIPLE_TASK
|
packageName = resolved.packageName,
|
||||||
|
fallbackUrl = resolved.fallbackUrl,
|
||||||
components.useCases.appLinksUseCases.openAppLink.invoke(redirect.appIntent)
|
isMarketplace = false,
|
||||||
callback(Result.success(true))
|
isAmbiguous = resolved.isAmbiguous,
|
||||||
|
engineSupportsScheme = resolved.engineSupportsScheme,
|
||||||
|
scopeKey = resolved.scopeKey,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
|
// Uniform failure semantics (§2.8): callers cannot distinguish "nothing installed"
|
||||||
|
// from "resolution failed".
|
||||||
|
callback(Result.success(null))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun launchAppLink(url: String, callback: (Result<Boolean>) -> Unit) {
|
||||||
|
coroutineScope.launch {
|
||||||
|
try {
|
||||||
|
val result = launcher.launch(url, mode = AppLinkLaunchMode.MANUAL)
|
||||||
|
logger.info("launchAppLink($url) -> $result")
|
||||||
|
callback(Result.success(result == AppLinkLaunchResult.LAUNCHED))
|
||||||
|
} catch (e: Exception) {
|
||||||
|
logger.error("launchAppLink($url) failed", e)
|
||||||
callback(Result.success(false))
|
callback(Result.success(false))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun pendingStoreFor(components: Components): PendingAppLinkStore {
|
||||||
|
return PendingAppLinkStores.forProfile(
|
||||||
|
components.profileApplicationContext.relativePath,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getPendingAppLinkPrompts(
|
||||||
|
owner: AppLinkPromptOwner,
|
||||||
|
callback: (Result<List<AppLinkPromptRequest>>) -> Unit,
|
||||||
|
) {
|
||||||
|
coroutineScope.launch {
|
||||||
|
try {
|
||||||
|
val components = GlobalComponents.components
|
||||||
|
val list = components
|
||||||
|
?.let { pendingStoreFor(it).getPending(owner).map(PendingAppLinkRequest::toPigeon) }
|
||||||
|
?: emptyList()
|
||||||
|
callback(Result.success(list))
|
||||||
|
} catch (e: Exception) {
|
||||||
|
callback(Result.success(emptyList()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun resolvePendingAppLink(
|
||||||
|
requestId: Long,
|
||||||
|
decision: AppLinkDecision,
|
||||||
|
callback: (Result<AppLinkResolutionResult>) -> Unit,
|
||||||
|
) {
|
||||||
|
coroutineScope.launch {
|
||||||
|
try {
|
||||||
|
val components = GlobalComponents.components
|
||||||
|
?: return@launch callback(Result.success(stale()))
|
||||||
|
val store = pendingStoreFor(components)
|
||||||
|
|
||||||
|
// Consume atomically; the store lock is released before any side effect.
|
||||||
|
val request = store.consume(requestId)
|
||||||
|
if (request == null) {
|
||||||
|
// The request was invalidated (navigation/tab close/expiry) before the user
|
||||||
|
// resolved it — the prompt shown was stale. No launch, no page change.
|
||||||
|
logger.info("resolvePendingAppLink($requestId, $decision) -> stale (no pending request)")
|
||||||
|
return@launch callback(Result.success(stale()))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Never launch into a session that no longer exists.
|
||||||
|
val tabAlive = components.core.store.state
|
||||||
|
.findTabOrCustomTab(request.tabId) != null
|
||||||
|
if (!tabAlive) {
|
||||||
|
logger.info("resolvePendingAppLink($requestId) -> dead_session (${request.tabId})")
|
||||||
|
return@launch callback(
|
||||||
|
Result.success(AppLinkResolutionResult(false, false, "dead_session")),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
val result = when (decision) {
|
||||||
|
AppLinkDecision.OPEN -> handleOpen(components, request)
|
||||||
|
AppLinkDecision.CANCEL, AppLinkDecision.DISMISS -> {
|
||||||
|
store.recordSuppression(request.tabId, request.targetFingerprint)
|
||||||
|
AppLinkResolutionResult(false, false, null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
callback(Result.success(result))
|
||||||
|
} catch (e: Exception) {
|
||||||
|
callback(Result.success(AppLinkResolutionResult(false, false, "launch_failed")))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun handleOpen(
|
||||||
|
components: Components,
|
||||||
|
request: PendingAppLinkRequest,
|
||||||
|
): AppLinkResolutionResult {
|
||||||
|
val mode = if (request.isMarketplace) {
|
||||||
|
AppLinkLaunchMode.MARKETPLACE
|
||||||
|
} else {
|
||||||
|
// Prompt-resolved opens are user gestures (bypass the cooldown).
|
||||||
|
AppLinkLaunchMode.MANUAL
|
||||||
|
}
|
||||||
|
// Honour the package captured when the prompt was created for a *named*
|
||||||
|
// (non-ambiguous) target, so a change in handlers before the user taps Open
|
||||||
|
// can't launch a different app (§2.5/§2.7). Ambiguous/chooser prompts store a
|
||||||
|
// null expectedPackage, so this stays null and the chooser still opens.
|
||||||
|
val result = launcher.launch(request.url, mode, expectedPackage = request.expectedPackage)
|
||||||
|
logger.info("resolvePendingAppLink open: launch(${request.url}, $mode) -> $result")
|
||||||
|
if (result == AppLinkLaunchResult.LAUNCHED) {
|
||||||
|
return AppLinkResolutionResult(true, false, null)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Launch failed: load a validated fallback if present, else leave the page.
|
||||||
|
val fallback = request.fallbackUrl
|
||||||
|
if (fallback != null) {
|
||||||
|
// Guard the fallback load against immediately bouncing back out to an app
|
||||||
|
// (§2.7): a validated http(s) fallback can itself resolve to an external
|
||||||
|
// handler, which would re-prompt/auto-launch. The interceptor records the
|
||||||
|
// same for fallbacks it issues.
|
||||||
|
pendingStoreFor(components).recordFallbackReentry(fallback)
|
||||||
|
components.useCases.sessionUseCases.loadUrl(
|
||||||
|
url = fallback,
|
||||||
|
sessionId = request.tabId,
|
||||||
|
)
|
||||||
|
return AppLinkResolutionResult(false, true, "launch_failed")
|
||||||
|
}
|
||||||
|
return AppLinkResolutionResult(false, false, "launch_failed")
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun stale() = AppLinkResolutionResult(false, false, "stale")
|
||||||
}
|
}
|
||||||
|
|||||||
+5
@@ -51,6 +51,7 @@ import eu.weblibre.flutter_mozilla_components.pigeons.GeckoPushApi
|
|||||||
import eu.weblibre.flutter_mozilla_components.pigeons.GeckoPushEvents
|
import eu.weblibre.flutter_mozilla_components.pigeons.GeckoPushEvents
|
||||||
import eu.weblibre.flutter_mozilla_components.pigeons.GeckoPwaApi
|
import eu.weblibre.flutter_mozilla_components.pigeons.GeckoPwaApi
|
||||||
import eu.weblibre.flutter_mozilla_components.pigeons.GeckoSelectionActionController
|
import eu.weblibre.flutter_mozilla_components.pigeons.GeckoSelectionActionController
|
||||||
|
import eu.weblibre.flutter_mozilla_components.pigeons.GeckoAppLinkEvents
|
||||||
import eu.weblibre.flutter_mozilla_components.pigeons.GeckoHistoryEvents
|
import eu.weblibre.flutter_mozilla_components.pigeons.GeckoHistoryEvents
|
||||||
import eu.weblibre.flutter_mozilla_components.pigeons.GeckoSelectionActionEvents
|
import eu.weblibre.flutter_mozilla_components.pigeons.GeckoSelectionActionEvents
|
||||||
import eu.weblibre.flutter_mozilla_components.pigeons.GeckoSessionApi
|
import eu.weblibre.flutter_mozilla_components.pigeons.GeckoSessionApi
|
||||||
@@ -280,6 +281,10 @@ class GeckoBrowserApiImpl : GeckoBrowserApi {
|
|||||||
GlobalComponents.historyEvents =
|
GlobalComponents.historyEvents =
|
||||||
GeckoHistoryEvents(_flutterPluginBinding.binaryMessenger)
|
GeckoHistoryEvents(_flutterPluginBinding.binaryMessenger)
|
||||||
|
|
||||||
|
// Availability signal for pending app-link prompts (Flutter-owned prompts).
|
||||||
|
GlobalComponents.appLinkEvents =
|
||||||
|
GeckoAppLinkEvents(_flutterPluginBinding.binaryMessenger)
|
||||||
|
|
||||||
// Also set before GlobalComponents.setUp, which calls push.initialize() and can therefore
|
// Also set before GlobalComponents.setUp, which calls push.initialize() and can therefore
|
||||||
// surface a registration failure before this sink would otherwise exist.
|
// surface a registration failure before this sink would otherwise exist.
|
||||||
GlobalComponents.pushEvents = GeckoPushEvents(_flutterPluginBinding.binaryMessenger)
|
GlobalComponents.pushEvents = GeckoPushEvents(_flutterPluginBinding.binaryMessenger)
|
||||||
|
|||||||
-33
@@ -7,13 +7,9 @@
|
|||||||
package eu.weblibre.flutter_mozilla_components.api
|
package eu.weblibre.flutter_mozilla_components.api
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import androidx.core.content.edit
|
|
||||||
import androidx.preference.PreferenceManager
|
|
||||||
import eu.weblibre.flutter_mozilla_components.ColorSchemePreference
|
import eu.weblibre.flutter_mozilla_components.ColorSchemePreference
|
||||||
import eu.weblibre.flutter_mozilla_components.GlobalComponents
|
import eu.weblibre.flutter_mozilla_components.GlobalComponents
|
||||||
import eu.weblibre.flutter_mozilla_components.R
|
|
||||||
import eu.weblibre.flutter_mozilla_components.feature.ReaderViewAppearanceFeature
|
import eu.weblibre.flutter_mozilla_components.feature.ReaderViewAppearanceFeature
|
||||||
import eu.weblibre.flutter_mozilla_components.pigeons.AppLinksMode
|
|
||||||
import eu.weblibre.flutter_mozilla_components.pigeons.BounceTrackingProtectionMode as PigeonBounceTrackingProtectionMode
|
import eu.weblibre.flutter_mozilla_components.pigeons.BounceTrackingProtectionMode as PigeonBounceTrackingProtectionMode
|
||||||
import eu.weblibre.flutter_mozilla_components.pigeons.ColorScheme
|
import eu.weblibre.flutter_mozilla_components.pigeons.ColorScheme
|
||||||
import eu.weblibre.flutter_mozilla_components.pigeons.CookieBannerHandlingMode
|
import eu.weblibre.flutter_mozilla_components.pigeons.CookieBannerHandlingMode
|
||||||
@@ -436,35 +432,6 @@ class GeckoEngineSettingsApiImpl(
|
|||||||
GlobalComponents.screenshotProtectionEnabled = enabled
|
GlobalComponents.screenshotProtectionEnabled = enabled
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun setAppLinksMode(mode: AppLinksMode) {
|
|
||||||
val context = components.profileApplicationContext
|
|
||||||
val prefKey = context.getString(R.string.pref_key_open_links_in_apps)
|
|
||||||
val modeValue = when (mode) {
|
|
||||||
AppLinksMode.ALWAYS -> context.getString(R.string.pref_key_open_links_in_apps_always)
|
|
||||||
AppLinksMode.ASK -> context.getString(R.string.pref_key_open_links_in_apps_ask)
|
|
||||||
AppLinksMode.NEVER -> context.getString(R.string.pref_key_open_links_in_apps_never)
|
|
||||||
}
|
|
||||||
|
|
||||||
PreferenceManager.getDefaultSharedPreferences(context).edit {
|
|
||||||
putString(prefKey, modeValue)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun getAppLinksMode(): AppLinksMode {
|
|
||||||
val context = components.profileApplicationContext
|
|
||||||
val prefKey = context.getString(R.string.pref_key_open_links_in_apps)
|
|
||||||
val defaultValue = context.getString(R.string.pref_key_open_links_in_apps_ask)
|
|
||||||
val modeValue = PreferenceManager.getDefaultSharedPreferences(context)
|
|
||||||
.getString(prefKey, defaultValue) ?: defaultValue
|
|
||||||
|
|
||||||
return when (modeValue) {
|
|
||||||
context.getString(R.string.pref_key_open_links_in_apps_always) -> AppLinksMode.ALWAYS
|
|
||||||
context.getString(R.string.pref_key_open_links_in_apps_ask) -> AppLinksMode.ASK
|
|
||||||
context.getString(R.string.pref_key_open_links_in_apps_never) -> AppLinksMode.NEVER
|
|
||||||
else -> AppLinksMode.ASK
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun setUseExternalDownloadManager(enabled: Boolean) {
|
override fun setUseExternalDownloadManager(enabled: Boolean) {
|
||||||
GlobalComponents.useExternalDownloadManager = enabled
|
GlobalComponents.useExternalDownloadManager = enabled
|
||||||
}
|
}
|
||||||
|
|||||||
+225
@@ -0,0 +1,225 @@
|
|||||||
|
/*
|
||||||
|
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package eu.weblibre.flutter_mozilla_components.applinks
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Global app-links behaviour, Kotlin-native mirror of the Pigeon `AppLinksMode` transport enum.
|
||||||
|
*/
|
||||||
|
enum class AppLinkMode {
|
||||||
|
ALWAYS,
|
||||||
|
ASK,
|
||||||
|
NEVER,
|
||||||
|
}
|
||||||
|
|
||||||
|
enum class AppLinkRuleDecision {
|
||||||
|
ALWAYS_OPEN,
|
||||||
|
NEVER_OPEN,
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A remembered per-scope rule (Kotlin-native mirror of the persisted/Pigeon rule model). */
|
||||||
|
data class AppLinkRule(
|
||||||
|
val decision: AppLinkRuleDecision,
|
||||||
|
val scope: String,
|
||||||
|
val packageName: String?,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Non-source-tab protection pattern (§2.3), matched against the navigation target. */
|
||||||
|
data class ProtectedTargetPattern(
|
||||||
|
val scheme: String,
|
||||||
|
val hostOrSuffix: String,
|
||||||
|
val includeSubdomains: Boolean,
|
||||||
|
val port: Int?,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A container's self-contained app-link policy (§ container isolation). Present only for containers
|
||||||
|
* with "isolated app link settings" enabled; when a navigation's source contextId has an entry, its
|
||||||
|
* [globalMode] + [rules] fully *replace* the global ones for that navigation (no layering).
|
||||||
|
*/
|
||||||
|
data class ContextAppLinkPolicy(
|
||||||
|
val globalMode: AppLinkMode,
|
||||||
|
val rules: Map<String, AppLinkRule>,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The complete policy the classifier reads. Populated from the replicated snapshot (§2.8); the
|
||||||
|
* classifier itself holds no Android types and no I/O.
|
||||||
|
*/
|
||||||
|
data class AppLinkPolicy(
|
||||||
|
val globalMode: AppLinkMode,
|
||||||
|
val rules: Map<String, AppLinkRule>,
|
||||||
|
val marketplaceFallbackEnabled: Boolean,
|
||||||
|
val protectGeneralContext: Boolean,
|
||||||
|
val protectedContextIds: Set<String>,
|
||||||
|
val strictContextIds: Set<String>,
|
||||||
|
val protectedTargetPatterns: List<ProtectedTargetPattern>,
|
||||||
|
/**
|
||||||
|
* Per-container overrides keyed by contextId; only isolated containers appear. A navigation whose
|
||||||
|
* source contextId is a key uses the entry's mode + rules instead of the global ones (replace).
|
||||||
|
*/
|
||||||
|
val contextOverrides: Map<String, ContextAppLinkPolicy> = emptyMap(),
|
||||||
|
) {
|
||||||
|
companion object {
|
||||||
|
val SAFE_DEFAULT = AppLinkPolicy(
|
||||||
|
globalMode = AppLinkMode.ASK,
|
||||||
|
rules = emptyMap(),
|
||||||
|
marketplaceFallbackEnabled = false,
|
||||||
|
protectGeneralContext = false,
|
||||||
|
protectedContextIds = emptySet(),
|
||||||
|
strictContextIds = emptySet(),
|
||||||
|
protectedTargetPatterns = emptyList(),
|
||||||
|
contextOverrides = emptyMap(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The prompt classes of §2.2. */
|
||||||
|
enum class AppLinkPromptKind {
|
||||||
|
/** http(s), non-modal — the page is allowed to load while the banner is up. */
|
||||||
|
BANNER,
|
||||||
|
|
||||||
|
/** Unsupported scheme, modal — the navigation is genuinely stalled and there is no page. */
|
||||||
|
MODAL,
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A pure decision the interceptor executes. The classifier never performs side effects.
|
||||||
|
*/
|
||||||
|
sealed interface AppLinkDecision {
|
||||||
|
/** Return `null` from the interceptor — the engine proceeds normally. */
|
||||||
|
data object AllowEngine : AppLinkDecision
|
||||||
|
|
||||||
|
/** Deny the load and leave the current page unchanged. */
|
||||||
|
data object DenyKeepPage : AppLinkDecision
|
||||||
|
|
||||||
|
/** Return `InterceptionResponse.Url(url)` — a validated http(s) fallback. */
|
||||||
|
data class LoadFallback(val url: String) : AppLinkDecision
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Automatic launch (global-`always` or a remembered `alwaysOpen` rule). The interceptor calls
|
||||||
|
* the launcher and maps its outcome per §2.7's launch-failure branches.
|
||||||
|
*/
|
||||||
|
data class AutoLaunch(val expectedPackage: String?) : AppLinkDecision
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a pending prompt request. [kind] chooses banner vs modal; the page is allowed to load
|
||||||
|
* for a banner and denied (stalled) for a modal.
|
||||||
|
*/
|
||||||
|
data class Prompt(
|
||||||
|
val kind: AppLinkPromptKind,
|
||||||
|
val canRemember: Boolean,
|
||||||
|
val isMarketplace: Boolean,
|
||||||
|
) : AppLinkDecision
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Everything the classifier needs, all computed by the caller so the classifier stays pure. */
|
||||||
|
data class ClassifierInput(
|
||||||
|
val resolved: ResolvedAppLink,
|
||||||
|
val isProtected: Boolean,
|
||||||
|
val isPrivate: Boolean,
|
||||||
|
val isWallet: Boolean,
|
||||||
|
val missingSession: Boolean,
|
||||||
|
val suppressionHit: Boolean,
|
||||||
|
val matchingRule: AppLinkRule?,
|
||||||
|
val globalMode: AppLinkMode,
|
||||||
|
val marketplaceFallbackEnabled: Boolean,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pure §2.4 policy precedence over the §2.2 URL-class table. Structural guards (§2.4 step 1) and
|
||||||
|
* navigation eligibility (step 2) are handled by the interceptor before this is consulted.
|
||||||
|
*/
|
||||||
|
object AppLinkClassifier {
|
||||||
|
fun classify(input: ClassifierInput): AppLinkDecision {
|
||||||
|
val resolved = input.resolved
|
||||||
|
|
||||||
|
// Step 3 — no external app resolves.
|
||||||
|
if (!resolved.hasExternalApp) {
|
||||||
|
resolved.fallbackUrl?.let { return AppLinkDecision.LoadFallback(it) }
|
||||||
|
// Step 8 — marketplace, only when enabled, mode != never, and no validated fallback.
|
||||||
|
if (input.marketplaceFallbackEnabled &&
|
||||||
|
input.globalMode != AppLinkMode.NEVER &&
|
||||||
|
resolved.marketplaceIntent != null
|
||||||
|
) {
|
||||||
|
return AppLinkDecision.Prompt(
|
||||||
|
kind = AppLinkPromptKind.MODAL,
|
||||||
|
canRemember = false,
|
||||||
|
isMarketplace = true,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return if (resolved.engineSupportsScheme) {
|
||||||
|
AppLinkDecision.AllowEngine
|
||||||
|
} else {
|
||||||
|
AppLinkDecision.DenyKeepPage
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 4 — missing session cannot host a prompt: fall back to the safe non-launch behaviour.
|
||||||
|
if (input.missingSession) {
|
||||||
|
return safeNonLaunch(resolved)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 4 — forced-prompt contexts (protected/private/wallet), ignoring matching rules.
|
||||||
|
if (input.isProtected || input.isPrivate || input.isWallet) {
|
||||||
|
return promptFor(resolved, canRemember = false)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 5 — suppression hit: never launch, never prompt.
|
||||||
|
if (input.suppressionHit) {
|
||||||
|
return safeNonLaunch(resolved)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 6 — a matching remembered rule for this scope.
|
||||||
|
input.matchingRule?.let { rule ->
|
||||||
|
when (rule.decision) {
|
||||||
|
AppLinkRuleDecision.ALWAYS_OPEN ->
|
||||||
|
return AppLinkDecision.AutoLaunch(expectedPackage = rule.packageName)
|
||||||
|
AppLinkRuleDecision.NEVER_OPEN ->
|
||||||
|
return neverBehaviour(resolved)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 7 — global mode, applied uniformly (including Custom Tabs).
|
||||||
|
return when (input.globalMode) {
|
||||||
|
AppLinkMode.ALWAYS -> AppLinkDecision.AutoLaunch(expectedPackage = null)
|
||||||
|
AppLinkMode.ASK -> promptFor(resolved, canRemember = canRemember(resolved))
|
||||||
|
AppLinkMode.NEVER -> neverBehaviour(resolved)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The `never` row of §2.2: allow an engine-supported page; otherwise deny (+ validated fallback). */
|
||||||
|
private fun neverBehaviour(resolved: ResolvedAppLink): AppLinkDecision {
|
||||||
|
return if (resolved.engineSupportsScheme) {
|
||||||
|
AppLinkDecision.AllowEngine
|
||||||
|
} else {
|
||||||
|
resolved.fallbackUrl?.let { AppLinkDecision.LoadFallback(it) }
|
||||||
|
?: AppLinkDecision.DenyKeepPage
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Suppression/missing-session: allow an engine-supported URL; else deny, using only a fallback. */
|
||||||
|
private fun safeNonLaunch(resolved: ResolvedAppLink): AppLinkDecision {
|
||||||
|
return if (resolved.engineSupportsScheme) {
|
||||||
|
AppLinkDecision.AllowEngine
|
||||||
|
} else {
|
||||||
|
resolved.fallbackUrl?.let { AppLinkDecision.LoadFallback(it) }
|
||||||
|
?: AppLinkDecision.DenyKeepPage
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun promptFor(resolved: ResolvedAppLink, canRemember: Boolean): AppLinkDecision {
|
||||||
|
val kind = if (resolved.engineSupportsScheme) {
|
||||||
|
AppLinkPromptKind.BANNER
|
||||||
|
} else {
|
||||||
|
AppLinkPromptKind.MODAL
|
||||||
|
}
|
||||||
|
return AppLinkDecision.Prompt(kind = kind, canRemember = canRemember, isMarketplace = false)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ambiguous resolution can never be remembered (§2.5). */
|
||||||
|
private fun canRemember(resolved: ResolvedAppLink): Boolean = !resolved.isAmbiguous
|
||||||
|
}
|
||||||
+100
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
+128
@@ -0,0 +1,128 @@
|
|||||||
|
/*
|
||||||
|
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package eu.weblibre.flutter_mozilla_components.applinks
|
||||||
|
|
||||||
|
import android.content.ActivityNotFoundException
|
||||||
|
import android.content.Intent
|
||||||
|
import mozilla.components.support.base.log.logger.Logger
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Distinct launch modes, each with an exact flag set (§6):
|
||||||
|
* - [MANUAL]: user-driven "Open in <App>" — preserves the `NEW_DOCUMENT | MULTIPLE_TASK` task
|
||||||
|
* behaviour so the app opens in its own recents entry.
|
||||||
|
* - [AUTOMATIC]: global-`always` or a remembered `alwaysOpen` rule — `NEW_TASK`, subject to the
|
||||||
|
* 2 s same-package cooldown loop-breaker (§2.4).
|
||||||
|
* - [MARKETPLACE]: install-app fallback — `NEW_TASK | CLEAR_TASK`.
|
||||||
|
*/
|
||||||
|
enum class AppLinkLaunchMode {
|
||||||
|
MANUAL,
|
||||||
|
AUTOMATIC,
|
||||||
|
MARKETPLACE,
|
||||||
|
}
|
||||||
|
|
||||||
|
enum class AppLinkLaunchResult {
|
||||||
|
LAUNCHED,
|
||||||
|
NO_APP,
|
||||||
|
COOLDOWN,
|
||||||
|
PACKAGE_MISMATCH,
|
||||||
|
FAILED,
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Launches external apps. Every launch re-resolves immediately first (no cache) and verifies the
|
||||||
|
* expected package before `startActivity` (§2.7). Automatic launches honour a 2 s same-package
|
||||||
|
* cooldown to break app→browser→app ping-pong loops (§2.4); manual and prompt-resolved opens are
|
||||||
|
* user gestures that bypass the check but still record it.
|
||||||
|
*/
|
||||||
|
class AppLinkLauncher(
|
||||||
|
private val resolver: ExternalAppResolver,
|
||||||
|
private val startActivity: (Intent) -> Unit,
|
||||||
|
private val clock: MonotonicClock = MonotonicClock.SYSTEM,
|
||||||
|
private val cooldownMs: Long = APP_LINKS_DO_NOT_INTERCEPT_INTERVAL,
|
||||||
|
) {
|
||||||
|
private val logger = Logger("AppLinkLauncher")
|
||||||
|
|
||||||
|
@Volatile
|
||||||
|
private var lastLaunch: Pair<String?, Long> = Pair(null, 0L)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Re-resolve [url] and launch it in the appropriate external app.
|
||||||
|
*
|
||||||
|
* @param expectedPackage when non-null (remembered/manual rebind paths), the freshly resolved
|
||||||
|
* package must equal it or the launch is refused with [AppLinkLaunchResult.PACKAGE_MISMATCH].
|
||||||
|
*/
|
||||||
|
@Synchronized
|
||||||
|
fun launch(
|
||||||
|
url: String,
|
||||||
|
mode: AppLinkLaunchMode,
|
||||||
|
expectedPackage: String? = null,
|
||||||
|
): AppLinkLaunchResult {
|
||||||
|
val resolved = resolver.resolve(url, includeHttpAppLinks = true, useCache = false)
|
||||||
|
|
||||||
|
val intent: Intent = when (mode) {
|
||||||
|
AppLinkLaunchMode.MARKETPLACE -> resolved.marketplaceIntent ?: return AppLinkLaunchResult.NO_APP
|
||||||
|
else -> {
|
||||||
|
if (!resolved.hasExternalApp || resolved.appIntent == null) {
|
||||||
|
return AppLinkLaunchResult.NO_APP
|
||||||
|
}
|
||||||
|
if (expectedPackage != null && resolved.packageName != expectedPackage) {
|
||||||
|
return AppLinkLaunchResult.PACKAGE_MISMATCH
|
||||||
|
}
|
||||||
|
resolved.appIntent
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val targetPackage = when (mode) {
|
||||||
|
AppLinkLaunchMode.MARKETPLACE -> intent.`package`
|
||||||
|
else -> resolved.packageName
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mode == AppLinkLaunchMode.AUTOMATIC) {
|
||||||
|
val (lastPackage, lastTs) = lastLaunch
|
||||||
|
if (lastPackage != null && lastPackage == targetPackage &&
|
||||||
|
clock.elapsedRealtime() < lastTs + cooldownMs
|
||||||
|
) {
|
||||||
|
return AppLinkLaunchResult.COOLDOWN
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
applyLaunchFlags(intent, mode)
|
||||||
|
|
||||||
|
return try {
|
||||||
|
startActivity(intent)
|
||||||
|
lastLaunch = Pair(targetPackage, clock.elapsedRealtime())
|
||||||
|
AppLinkLaunchResult.LAUNCHED
|
||||||
|
} catch (e: ActivityNotFoundException) {
|
||||||
|
logger.error("failed to start external app activity", e)
|
||||||
|
AppLinkLaunchResult.FAILED
|
||||||
|
} catch (e: SecurityException) {
|
||||||
|
logger.error("not permitted to start external app activity", e)
|
||||||
|
AppLinkLaunchResult.FAILED
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun applyLaunchFlags(intent: Intent, mode: AppLinkLaunchMode) {
|
||||||
|
intent.flags = when (mode) {
|
||||||
|
// NEW_DOCUMENT | MULTIPLE_TASK gives the app its own recents entry; NEW_TASK is
|
||||||
|
// mandatory because every launch path now dispatches through the process-level
|
||||||
|
// application context (AppLinkRuntime), and startActivity() from a non-Activity
|
||||||
|
// context requires it.
|
||||||
|
AppLinkLaunchMode.MANUAL ->
|
||||||
|
Intent.FLAG_ACTIVITY_NEW_DOCUMENT or
|
||||||
|
Intent.FLAG_ACTIVITY_MULTIPLE_TASK or
|
||||||
|
Intent.FLAG_ACTIVITY_NEW_TASK
|
||||||
|
AppLinkLaunchMode.AUTOMATIC ->
|
||||||
|
Intent.FLAG_ACTIVITY_NEW_TASK
|
||||||
|
AppLinkLaunchMode.MARKETPLACE ->
|
||||||
|
Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val APP_LINKS_DO_NOT_INTERCEPT_INTERVAL = 2000L
|
||||||
|
}
|
||||||
|
}
|
||||||
+53
@@ -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,
|
||||||
|
)
|
||||||
+236
@@ -0,0 +1,236 @@
|
|||||||
|
/*
|
||||||
|
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package eu.weblibre.flutter_mozilla_components.applinks
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import eu.weblibre.flutter_mozilla_components.ProfileContext
|
||||||
|
import mozilla.components.support.base.log.logger.Logger
|
||||||
|
import org.json.JSONArray
|
||||||
|
import org.json.JSONObject
|
||||||
|
import java.util.concurrent.ConcurrentHashMap
|
||||||
|
import java.util.concurrent.atomic.AtomicReference
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Process-level registry of profile-scoped [AppLinkPolicyStore] singletons
|
||||||
|
* (APP_LINKS_OWN_IMPLEMENTATION_PLAN.md §2.10). Keyed only by native's canonical
|
||||||
|
* [ProfileContext.relativePath]; created on first use, torn down on profile
|
||||||
|
* replacement. Survives `GlobalComponents.setUp()` replacing the `Components`.
|
||||||
|
*/
|
||||||
|
object AppLinkPolicyStores {
|
||||||
|
private val stores = ConcurrentHashMap<String, AppLinkPolicyStore>()
|
||||||
|
|
||||||
|
fun forProfile(profileContext: ProfileContext): AppLinkPolicyStore {
|
||||||
|
return stores.getOrPut(profileContext.relativePath) {
|
||||||
|
AppLinkPolicyStore(profileContext)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Remove a torn-down profile's store (profile replacement/deletion). */
|
||||||
|
fun remove(relativePath: String) {
|
||||||
|
stores.remove(relativePath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The only policy source in `ComponentsMode.EXTERNAL` and before Flutter attaches.
|
||||||
|
* Holds the classifier [AppLinkPolicy] in an [AtomicReference] backed by a single
|
||||||
|
* profile-scoped SharedPreferences record. Writes persist synchronously
|
||||||
|
* (`commit()`) and publish the new reference only after durable success. There is
|
||||||
|
* exactly one writer (the Dart replicator via `setAppLinkPolicy`).
|
||||||
|
*/
|
||||||
|
class AppLinkPolicyStore internal constructor(
|
||||||
|
private val context: Context,
|
||||||
|
) {
|
||||||
|
private val logger = Logger("AppLinkPolicyStore")
|
||||||
|
private val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||||
|
private val reference = AtomicReference(loadOrSeed())
|
||||||
|
|
||||||
|
val policy: AppLinkPolicy
|
||||||
|
get() = reference.get()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Persist [policy] durably, then publish it. Serialised so concurrent writers
|
||||||
|
* cannot interleave a half-written record with a published reference.
|
||||||
|
*/
|
||||||
|
@Synchronized
|
||||||
|
fun setPolicy(policy: AppLinkPolicy): Boolean {
|
||||||
|
val json = encode(policy, migrated = true)
|
||||||
|
val committed = prefs.edit().putString(KEY_SNAPSHOT, json).commit()
|
||||||
|
if (!committed) {
|
||||||
|
logger.error("failed to persist app-link policy; keeping previous snapshot")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
reference.set(policy)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun loadOrSeed(): AppLinkPolicy {
|
||||||
|
val stored = prefs.getString(KEY_SNAPSHOT, null)
|
||||||
|
if (stored != null) {
|
||||||
|
runCatching { return decode(stored) }
|
||||||
|
.onFailure { logger.error("corrupt app-link policy record; reseeding", it) }
|
||||||
|
}
|
||||||
|
// Seed the safe default (globalMode = ASK): the seed carries no protected-context data (that
|
||||||
|
// is computed in Dart and arrives only with the first replicated snapshot), so it must never
|
||||||
|
// auto-launch — an `ALWAYS` seed would leak links out of proxied/strict containers and cold
|
||||||
|
// Custom Tabs before protection is known. The legacy AC "open links in apps" preference is
|
||||||
|
// deliberately not migrated (a de-Googled browser resets to the safe ASK default; the user
|
||||||
|
// re-sets it in Settings), so the seed does not read it.
|
||||||
|
val seeded = AppLinkPolicy.SAFE_DEFAULT
|
||||||
|
val committed = prefs.edit().putString(KEY_SNAPSHOT, encode(seeded, migrated = true)).commit()
|
||||||
|
if (!committed) {
|
||||||
|
logger.error("failed to persist seeded app-link policy; using defaults in memory")
|
||||||
|
}
|
||||||
|
return seeded
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun encode(policy: AppLinkPolicy, migrated: Boolean): String {
|
||||||
|
val root = JSONObject()
|
||||||
|
root.put(FIELD_MIGRATED, migrated)
|
||||||
|
root.put(FIELD_GLOBAL_MODE, policy.globalMode.name)
|
||||||
|
root.put(FIELD_MARKETPLACE, policy.marketplaceFallbackEnabled)
|
||||||
|
root.put(FIELD_PROTECT_GENERAL, policy.protectGeneralContext)
|
||||||
|
root.put(FIELD_PROTECTED_CONTEXTS, JSONArray(policy.protectedContextIds.toList()))
|
||||||
|
root.put(FIELD_STRICT_CONTEXTS, JSONArray(policy.strictContextIds.toList()))
|
||||||
|
|
||||||
|
root.put(FIELD_RULES, encodeRules(policy.rules))
|
||||||
|
|
||||||
|
val overrides = JSONObject()
|
||||||
|
for ((contextId, override) in policy.contextOverrides) {
|
||||||
|
overrides.put(
|
||||||
|
contextId,
|
||||||
|
JSONObject()
|
||||||
|
.put(FIELD_OVERRIDE_MODE, override.globalMode.name)
|
||||||
|
.put(FIELD_RULES, encodeRules(override.rules)),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
root.put(FIELD_CONTEXT_OVERRIDES, overrides)
|
||||||
|
|
||||||
|
val patterns = JSONArray()
|
||||||
|
for (pattern in policy.protectedTargetPatterns) {
|
||||||
|
patterns.put(
|
||||||
|
JSONObject()
|
||||||
|
.put(FIELD_PATTERN_SCHEME, pattern.scheme)
|
||||||
|
.put(FIELD_PATTERN_HOST, pattern.hostOrSuffix)
|
||||||
|
.put(FIELD_PATTERN_SUBDOMAINS, pattern.includeSubdomains)
|
||||||
|
.putOpt(FIELD_PATTERN_PORT, pattern.port),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
root.put(FIELD_PATTERNS, patterns)
|
||||||
|
return root.toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun encodeRules(rules: Map<String, AppLinkRule>): JSONObject {
|
||||||
|
val obj = JSONObject()
|
||||||
|
for ((scope, rule) in rules) {
|
||||||
|
obj.put(
|
||||||
|
scope,
|
||||||
|
JSONObject()
|
||||||
|
.put(FIELD_RULE_DECISION, rule.decision.name)
|
||||||
|
.put(FIELD_RULE_SCOPE, rule.scope)
|
||||||
|
.putOpt(FIELD_RULE_PACKAGE, rule.packageName),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return obj
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun decode(json: String): AppLinkPolicy {
|
||||||
|
val root = JSONObject(json)
|
||||||
|
|
||||||
|
val rules = decodeRules(root.optJSONObject(FIELD_RULES))
|
||||||
|
|
||||||
|
val contextOverrides = mutableMapOf<String, ContextAppLinkPolicy>()
|
||||||
|
root.optJSONObject(FIELD_CONTEXT_OVERRIDES)?.let { obj ->
|
||||||
|
for (contextId in obj.keys()) {
|
||||||
|
val overrideJson = obj.getJSONObject(contextId)
|
||||||
|
contextOverrides[contextId] = ContextAppLinkPolicy(
|
||||||
|
globalMode = AppLinkMode.valueOf(overrideJson.getString(FIELD_OVERRIDE_MODE)),
|
||||||
|
rules = decodeRules(overrideJson.optJSONObject(FIELD_RULES)),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val patterns = mutableListOf<ProtectedTargetPattern>()
|
||||||
|
root.optJSONArray(FIELD_PATTERNS)?.let { arr ->
|
||||||
|
for (i in 0 until arr.length()) {
|
||||||
|
val p = arr.getJSONObject(i)
|
||||||
|
patterns.add(
|
||||||
|
ProtectedTargetPattern(
|
||||||
|
scheme = p.getString(FIELD_PATTERN_SCHEME),
|
||||||
|
hostOrSuffix = p.getString(FIELD_PATTERN_HOST),
|
||||||
|
includeSubdomains = p.getBoolean(FIELD_PATTERN_SUBDOMAINS),
|
||||||
|
port = if (p.has(FIELD_PATTERN_PORT) && !p.isNull(FIELD_PATTERN_PORT)) {
|
||||||
|
p.getInt(FIELD_PATTERN_PORT)
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return AppLinkPolicy(
|
||||||
|
globalMode = AppLinkMode.valueOf(root.getString(FIELD_GLOBAL_MODE)),
|
||||||
|
rules = rules,
|
||||||
|
marketplaceFallbackEnabled = root.optBoolean(FIELD_MARKETPLACE, false),
|
||||||
|
protectGeneralContext = root.optBoolean(FIELD_PROTECT_GENERAL, false),
|
||||||
|
protectedContextIds = root.optJSONArray(FIELD_PROTECTED_CONTEXTS).toStringSet(),
|
||||||
|
strictContextIds = root.optJSONArray(FIELD_STRICT_CONTEXTS).toStringSet(),
|
||||||
|
protectedTargetPatterns = patterns,
|
||||||
|
contextOverrides = contextOverrides,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun decodeRules(obj: JSONObject?): Map<String, AppLinkRule> {
|
||||||
|
if (obj == null) return emptyMap()
|
||||||
|
val rules = mutableMapOf<String, AppLinkRule>()
|
||||||
|
for (scope in obj.keys()) {
|
||||||
|
val ruleJson = obj.getJSONObject(scope)
|
||||||
|
rules[scope] = AppLinkRule(
|
||||||
|
decision = AppLinkRuleDecision.valueOf(ruleJson.getString(FIELD_RULE_DECISION)),
|
||||||
|
scope = ruleJson.getString(FIELD_RULE_SCOPE),
|
||||||
|
packageName = ruleJson.optStringOrNull(FIELD_RULE_PACKAGE),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return rules
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun JSONObject.optStringOrNull(key: String): String? =
|
||||||
|
if (has(key) && !isNull(key)) getString(key) else null
|
||||||
|
|
||||||
|
private fun JSONArray?.toStringSet(): Set<String> {
|
||||||
|
if (this == null) return emptySet()
|
||||||
|
val out = LinkedHashSet<String>(length())
|
||||||
|
for (i in 0 until length()) {
|
||||||
|
out.add(getString(i))
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val PREFS_NAME = "weblibre_app_link_policy"
|
||||||
|
private const val KEY_SNAPSHOT = "snapshot"
|
||||||
|
|
||||||
|
private const val FIELD_MIGRATED = "migrated"
|
||||||
|
private const val FIELD_GLOBAL_MODE = "globalMode"
|
||||||
|
private const val FIELD_MARKETPLACE = "marketplaceFallbackEnabled"
|
||||||
|
private const val FIELD_PROTECT_GENERAL = "protectGeneralContext"
|
||||||
|
private const val FIELD_PROTECTED_CONTEXTS = "protectedContextIds"
|
||||||
|
private const val FIELD_STRICT_CONTEXTS = "strictContextIds"
|
||||||
|
private const val FIELD_RULES = "rules"
|
||||||
|
private const val FIELD_RULE_DECISION = "decision"
|
||||||
|
private const val FIELD_RULE_SCOPE = "scope"
|
||||||
|
private const val FIELD_RULE_PACKAGE = "packageName"
|
||||||
|
private const val FIELD_CONTEXT_OVERRIDES = "contextOverrides"
|
||||||
|
private const val FIELD_OVERRIDE_MODE = "mode"
|
||||||
|
private const val FIELD_PATTERNS = "protectedTargetPatterns"
|
||||||
|
private const val FIELD_PATTERN_SCHEME = "scheme"
|
||||||
|
private const val FIELD_PATTERN_HOST = "hostOrSuffix"
|
||||||
|
private const val FIELD_PATTERN_SUBDOMAINS = "includeSubdomains"
|
||||||
|
private const val FIELD_PATTERN_PORT = "port"
|
||||||
|
}
|
||||||
|
}
|
||||||
+39
@@ -0,0 +1,39 @@
|
|||||||
|
/*
|
||||||
|
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package eu.weblibre.flutter_mozilla_components.applinks
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Process-level holder for the shared [ExternalAppResolver] and [AppLinkLauncher]
|
||||||
|
* (APP_LINKS_OWN_IMPLEMENTATION_PLAN.md §2.7). Neither is profile-scoped — they wrap the
|
||||||
|
* `PackageManager` and `startActivity`, both application-global.
|
||||||
|
*
|
||||||
|
* A single shared launcher is important: its 2 s same-package auto-launch cooldown
|
||||||
|
* (§2.4 loop breaker) must be observed across *every* launch path — the synchronous
|
||||||
|
* interceptor tail ([WebLibreAppLinksInterceptor]), the manual "Open in <App>" entry points
|
||||||
|
* (`GeckoAppLinksApiImpl.launchAppLink`), and prompt resolution. If each site built its own
|
||||||
|
* launcher the cooldown would be per-instance and the ping-pong defence would break.
|
||||||
|
*/
|
||||||
|
object AppLinkRuntime {
|
||||||
|
@Volatile
|
||||||
|
private var holder: Holder? = null
|
||||||
|
|
||||||
|
fun get(context: Context): Holder {
|
||||||
|
return holder ?: synchronized(this) {
|
||||||
|
holder ?: Holder(context.applicationContext).also { holder = it }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class Holder(appContext: Context) {
|
||||||
|
val resolver: ExternalAppResolver = ExternalAppResolver(AndroidPackageResolver(appContext))
|
||||||
|
val launcher: AppLinkLauncher = AppLinkLauncher(
|
||||||
|
resolver = resolver,
|
||||||
|
startActivity = { intent -> appContext.startActivity(intent) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
+82
@@ -0,0 +1,82 @@
|
|||||||
|
/*
|
||||||
|
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package eu.weblibre.flutter_mozilla_components.applinks
|
||||||
|
|
||||||
|
import java.util.Locale
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Frozen scheme classification tables for the WebLibre-owned app-links implementation
|
||||||
|
* (see APP_LINKS_OWN_IMPLEMENTATION_PLAN.md §2.2).
|
||||||
|
*
|
||||||
|
* These tables initially match Mozilla Android Components
|
||||||
|
* ([mozilla.components.feature.app.links.AppLinksUseCases] companion,
|
||||||
|
* [mozilla.components.feature.app.links.AppLinksInterceptor]). All comparisons are
|
||||||
|
* case-insensitive via [Locale.ROOT] lowercase — AC lowercases only the denied set;
|
||||||
|
* making the engine-supported comparison case-insensitive too is a deliberate small
|
||||||
|
* correctness improvement. `JavaScript:` must be denied as surely as `javascript:`.
|
||||||
|
*
|
||||||
|
* These tables describe what Gecko can load, not what the user wants, and are consumed
|
||||||
|
* on the synchronous interception path — they stay in Kotlin.
|
||||||
|
*/
|
||||||
|
object AppLinkSchemes {
|
||||||
|
// Schemes the Gecko engine can load itself.
|
||||||
|
// https://searchfox.org/firefox-main/source/netwerk/build/components.conf
|
||||||
|
val ENGINE_SUPPORTED: Set<String> = setOf(
|
||||||
|
"about",
|
||||||
|
"data",
|
||||||
|
"file",
|
||||||
|
"ftp",
|
||||||
|
"http",
|
||||||
|
"https",
|
||||||
|
"moz-extension",
|
||||||
|
"moz-safe-about",
|
||||||
|
"resource",
|
||||||
|
"view-source",
|
||||||
|
"ws",
|
||||||
|
"wss",
|
||||||
|
"blob",
|
||||||
|
)
|
||||||
|
|
||||||
|
// Schemes that must never be resolved or launched in a third-party app.
|
||||||
|
val ALWAYS_DENIED: Set<String> = setOf(
|
||||||
|
"jar",
|
||||||
|
"file",
|
||||||
|
"javascript",
|
||||||
|
"data",
|
||||||
|
"about",
|
||||||
|
"content",
|
||||||
|
"fido",
|
||||||
|
)
|
||||||
|
|
||||||
|
// Schemes allowed to open an external application from a subframe.
|
||||||
|
val SUBFRAME_ALLOWED: Set<String> = setOf(
|
||||||
|
"msteams",
|
||||||
|
)
|
||||||
|
|
||||||
|
// Wallet schemes — always prompt, never remembered (§2.4).
|
||||||
|
val WALLET: Set<String> = setOf(
|
||||||
|
"openid4vp",
|
||||||
|
"mdoc",
|
||||||
|
"mdoc-openid4vp",
|
||||||
|
"haip",
|
||||||
|
"eudi-wallet",
|
||||||
|
"eudi-openid4vp",
|
||||||
|
"openid-credential-offer",
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun normalize(scheme: String?): String? = scheme?.lowercase(Locale.ROOT)
|
||||||
|
|
||||||
|
fun isEngineSupported(scheme: String?): Boolean = normalize(scheme) in ENGINE_SUPPORTED
|
||||||
|
|
||||||
|
fun isAlwaysDenied(scheme: String?): Boolean = normalize(scheme) in ALWAYS_DENIED
|
||||||
|
|
||||||
|
fun isSubframeAllowed(scheme: String?): Boolean = normalize(scheme) in SUBFRAME_ALLOWED
|
||||||
|
|
||||||
|
fun isWallet(scheme: String?): Boolean = normalize(scheme) in WALLET
|
||||||
|
|
||||||
|
fun isHttpOrHttps(scheme: String?): Boolean = normalize(scheme).let { it == "http" || it == "https" }
|
||||||
|
}
|
||||||
+344
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
+22
@@ -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() }
|
||||||
|
}
|
||||||
|
}
|
||||||
+147
@@ -0,0 +1,147 @@
|
|||||||
|
/*
|
||||||
|
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package eu.weblibre.flutter_mozilla_components.applinks
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.os.Handler
|
||||||
|
import android.os.Looper
|
||||||
|
import androidx.appcompat.app.AlertDialog
|
||||||
|
import eu.weblibre.flutter_mozilla_components.R
|
||||||
|
import eu.weblibre.flutter_mozilla_components.pigeons.AppLinkPromptOwner
|
||||||
|
import mozilla.components.feature.session.SessionUseCases
|
||||||
|
import mozilla.components.support.base.feature.LifecycleAwareFeature
|
||||||
|
import java.util.concurrent.ConcurrentHashMap
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Process-level registry of the *started* [NativeAppLinkPromptFeature] instances, keyed by tabId.
|
||||||
|
* The [WebLibreAppLinksInterceptor] runs on an engine thread and creates prompt requests
|
||||||
|
* asynchronously; a Custom Tab feature only queries the store at lifecycle start, so without this a
|
||||||
|
* request created after start would sit unshown (its navigation already denied) until a rotation or
|
||||||
|
* restart. The interceptor pings [notifyPromptAvailable] so the feature re-queries immediately.
|
||||||
|
*/
|
||||||
|
object NativeAppLinkPromptNotifier {
|
||||||
|
private val features = ConcurrentHashMap<String, NativeAppLinkPromptFeature>()
|
||||||
|
|
||||||
|
fun register(tabId: String, feature: NativeAppLinkPromptFeature) {
|
||||||
|
features[tabId] = feature
|
||||||
|
}
|
||||||
|
|
||||||
|
fun unregister(tabId: String, feature: NativeAppLinkPromptFeature) {
|
||||||
|
features.remove(tabId, feature)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun notifyPromptAvailable(tabId: String) {
|
||||||
|
features[tabId]?.onPromptAvailable()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Presents the minimal native app-link prompt for Custom Tab sessions that have no
|
||||||
|
* Flutter engine (APP_LINKS_OWN_IMPLEMENTATION_PLAN.md §2.6). Title, message,
|
||||||
|
* open/cancel — **no remember checkbox**, so native never creates policy.
|
||||||
|
*
|
||||||
|
* Queries [PendingAppLinkStore] for its own tab on start (and re-queries after each
|
||||||
|
* resolution); a request that is rotated/backgrounded away stays pending and is
|
||||||
|
* re-presented on the next start. Owner is fixed to [AppLinkPromptOwner.NATIVE_EXTERNAL].
|
||||||
|
*/
|
||||||
|
class NativeAppLinkPromptFeature(
|
||||||
|
private val context: Context,
|
||||||
|
private val tabId: String,
|
||||||
|
private val store: PendingAppLinkStore,
|
||||||
|
private val launcher: AppLinkLauncher,
|
||||||
|
private val sessionUseCases: SessionUseCases,
|
||||||
|
) : LifecycleAwareFeature {
|
||||||
|
private var dialog: AlertDialog? = null
|
||||||
|
private val mainHandler = Handler(Looper.getMainLooper())
|
||||||
|
|
||||||
|
override fun start() {
|
||||||
|
NativeAppLinkPromptNotifier.register(tabId, this)
|
||||||
|
showNext()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun stop() {
|
||||||
|
NativeAppLinkPromptNotifier.unregister(tabId, this)
|
||||||
|
// Dismissing on stop is not a user dismissal: the request stays pending and
|
||||||
|
// is re-presented on the next start().
|
||||||
|
dialog?.setOnDismissListener(null)
|
||||||
|
dialog?.dismiss()
|
||||||
|
dialog = null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A new pending request may have been created for this tab (interceptor, engine thread) after
|
||||||
|
* [start] already queried. Re-check on the main thread; [showNext] is idempotent (a no-op while a
|
||||||
|
* dialog is up or when nothing pends).
|
||||||
|
*/
|
||||||
|
fun onPromptAvailable() {
|
||||||
|
mainHandler.post { showNext() }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun showNext() {
|
||||||
|
if (dialog != null) return
|
||||||
|
|
||||||
|
val request = store.getPending(AppLinkPromptOwner.NATIVE_EXTERNAL)
|
||||||
|
.firstOrNull { it.tabId == tabId }
|
||||||
|
?: return
|
||||||
|
|
||||||
|
val title = request.appName?.let {
|
||||||
|
context.getString(R.string.weblibre_app_link_prompt_title_named, it)
|
||||||
|
} ?: context.getString(R.string.weblibre_app_link_prompt_title_generic)
|
||||||
|
|
||||||
|
dialog = AlertDialog.Builder(context)
|
||||||
|
.setTitle(title)
|
||||||
|
.setMessage(context.getString(R.string.weblibre_app_link_prompt_message))
|
||||||
|
.setPositiveButton(R.string.weblibre_app_link_prompt_open) { _, _ ->
|
||||||
|
resolveOpen(request)
|
||||||
|
}
|
||||||
|
.setNegativeButton(R.string.weblibre_app_link_prompt_cancel) { _, _ ->
|
||||||
|
resolveCancel(request)
|
||||||
|
}
|
||||||
|
.setOnCancelListener {
|
||||||
|
// Back / touch-outside is an explicit passive dismissal (§2.6).
|
||||||
|
resolveCancel(request)
|
||||||
|
}
|
||||||
|
.setOnDismissListener { dialog = null }
|
||||||
|
.show()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun resolveOpen(request: PendingAppLinkRequest) {
|
||||||
|
val consumed = store.consume(request.requestId) ?: return afterResolve()
|
||||||
|
val mode = if (consumed.isMarketplace) {
|
||||||
|
AppLinkLaunchMode.MARKETPLACE
|
||||||
|
} else {
|
||||||
|
AppLinkLaunchMode.MANUAL
|
||||||
|
}
|
||||||
|
// Fresh prompt-open: no remembered package binding to enforce (§2.5); the
|
||||||
|
// launcher's pre-launch re-resolution still validates the handler.
|
||||||
|
// Honour the package captured when the prompt was created for a *named*
|
||||||
|
// (non-ambiguous) target, so a change in handlers before the user taps Open
|
||||||
|
// can't launch a different app (§2.5/§2.7). Ambiguous/chooser prompts store a
|
||||||
|
// null expectedPackage, so this stays null and the chooser still opens.
|
||||||
|
val result = launcher.launch(consumed.url, mode, expectedPackage = consumed.expectedPackage)
|
||||||
|
if (result != AppLinkLaunchResult.LAUNCHED) {
|
||||||
|
consumed.fallbackUrl?.let { fallback ->
|
||||||
|
// Guard the fallback load against immediately bouncing back out to an
|
||||||
|
// app (§2.7): a validated fallback can itself resolve externally.
|
||||||
|
store.recordFallbackReentry(fallback)
|
||||||
|
sessionUseCases.loadUrl(url = fallback, sessionId = consumed.tabId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
afterResolve()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun resolveCancel(request: PendingAppLinkRequest) {
|
||||||
|
val consumed = store.consume(request.requestId) ?: return afterResolve()
|
||||||
|
store.recordSuppression(consumed.tabId, consumed.targetFingerprint)
|
||||||
|
afterResolve()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun afterResolve() {
|
||||||
|
dialog = null
|
||||||
|
showNext()
|
||||||
|
}
|
||||||
|
}
|
||||||
+92
@@ -0,0 +1,92 @@
|
|||||||
|
/*
|
||||||
|
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package eu.weblibre.flutter_mozilla_components.applinks
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.content.pm.PackageManager
|
||||||
|
import android.content.pm.ResolveInfo
|
||||||
|
import mozilla.components.support.base.log.logger.Logger
|
||||||
|
import mozilla.components.support.ktx.android.content.pm.isPackageInstalled
|
||||||
|
import mozilla.components.support.utils.BrowsersCache
|
||||||
|
import mozilla.components.support.utils.ext.packageManagerCompatHelper
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Seam over [PackageManager] and browser detection so the resolver can be unit-tested
|
||||||
|
* (see APP_LINKS_OWN_IMPLEMENTATION_PLAN.md §2.7, Phase 1). Intent construction and
|
||||||
|
* sanitisation are still exercised under Robolectric because plain JVM stubs never
|
||||||
|
* populate the fields the resolver strips.
|
||||||
|
*/
|
||||||
|
interface PackageResolver {
|
||||||
|
val selfPackageName: String
|
||||||
|
|
||||||
|
/** May throw [RuntimeException] internally on large result sets; returns empty on failure. */
|
||||||
|
fun queryActivities(intent: Intent): List<ResolveInfo>
|
||||||
|
|
||||||
|
/** The default activity for [intent], honouring `MATCH_DEFAULT_ONLY`. */
|
||||||
|
fun resolveDefaultActivity(intent: Intent): ResolveInfo?
|
||||||
|
|
||||||
|
fun isPackageInstalled(packageName: String): Boolean
|
||||||
|
|
||||||
|
/** True when [packageName] is an installed browser (excluded for engine-supported schemes). */
|
||||||
|
fun isInstalledBrowser(packageName: String): Boolean
|
||||||
|
|
||||||
|
fun applicationLabel(resolveInfo: ResolveInfo): String?
|
||||||
|
}
|
||||||
|
|
||||||
|
class AndroidPackageResolver(private val context: Context) : PackageResolver {
|
||||||
|
private val logger = Logger("AppLinkPackageResolver")
|
||||||
|
|
||||||
|
override val selfPackageName: String
|
||||||
|
get() = context.packageName
|
||||||
|
|
||||||
|
@Suppress("QueryPermissionsNeeded", "TooGenericExceptionCaught")
|
||||||
|
override fun queryActivities(intent: Intent): List<ResolveInfo> {
|
||||||
|
return try {
|
||||||
|
context.packageManagerCompatHelper.queryIntentActivitiesCompat(
|
||||||
|
intent,
|
||||||
|
PackageManager.GET_RESOLVED_FILTER,
|
||||||
|
)
|
||||||
|
} catch (e: RuntimeException) {
|
||||||
|
// queryIntentActivities throws on very large result sets — treat as "nothing".
|
||||||
|
logger.error("failed to query activities", e)
|
||||||
|
emptyList()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Suppress("TooGenericExceptionCaught")
|
||||||
|
override fun resolveDefaultActivity(intent: Intent): ResolveInfo? {
|
||||||
|
return try {
|
||||||
|
context.packageManagerCompatHelper.resolveActivityCompat(
|
||||||
|
intent,
|
||||||
|
PackageManager.MATCH_DEFAULT_ONLY,
|
||||||
|
)
|
||||||
|
} catch (e: RuntimeException) {
|
||||||
|
logger.error("failed to resolve default activity", e)
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun isPackageInstalled(packageName: String): Boolean {
|
||||||
|
return context.packageManagerCompatHelper.isPackageInstalled(packageName)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun isInstalledBrowser(packageName: String): Boolean {
|
||||||
|
return BrowsersCache.all(context).isInstalled(packageName)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Suppress("TooGenericExceptionCaught")
|
||||||
|
override fun applicationLabel(resolveInfo: ResolveInfo): String? {
|
||||||
|
return try {
|
||||||
|
val appInfo = resolveInfo.activityInfo?.applicationInfo ?: return null
|
||||||
|
context.packageManager.getApplicationLabel(appInfo).toString()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
logger.error("failed to read application label", e)
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+342
@@ -0,0 +1,342 @@
|
|||||||
|
/*
|
||||||
|
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package eu.weblibre.flutter_mozilla_components.applinks
|
||||||
|
|
||||||
|
import eu.weblibre.flutter_mozilla_components.pigeons.AppLinkPromptOwner
|
||||||
|
import eu.weblibre.flutter_mozilla_components.pigeons.AppLinkPromptRequest
|
||||||
|
import eu.weblibre.flutter_mozilla_components.pigeons.AppLinkTarget
|
||||||
|
import mozilla.components.support.base.log.logger.Logger
|
||||||
|
import java.util.concurrent.ConcurrentHashMap
|
||||||
|
import java.util.concurrent.atomic.AtomicLong
|
||||||
|
|
||||||
|
/** The §2.2 URL class a pending request belongs to; part of the dedupe key. */
|
||||||
|
enum class AppLinkUrlClass {
|
||||||
|
BANNER,
|
||||||
|
MODAL,
|
||||||
|
MARKETPLACE,
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A pending prompt, stored until resolved/invalidated/expired (§2.6). Holds only
|
||||||
|
* stable identifiers and sanitised data — never a Components/EngineSession/store
|
||||||
|
* reference. Carries everything needed both to render the prompt and to perform
|
||||||
|
* the resolution side effect (re-resolve + launch, or load a validated fallback).
|
||||||
|
*/
|
||||||
|
data class PendingAppLinkRequest(
|
||||||
|
val requestId: Long,
|
||||||
|
val owner: AppLinkPromptOwner,
|
||||||
|
val tabId: String,
|
||||||
|
val contextId: String?,
|
||||||
|
val sourceUrl: String?,
|
||||||
|
val isPrivate: Boolean,
|
||||||
|
val isWallet: Boolean,
|
||||||
|
val isProtectedContext: Boolean,
|
||||||
|
val canRemember: Boolean,
|
||||||
|
val isModal: Boolean,
|
||||||
|
val urlClass: AppLinkUrlClass,
|
||||||
|
// Resolution data:
|
||||||
|
val url: String,
|
||||||
|
val expectedPackage: String?,
|
||||||
|
val fallbackUrl: String?,
|
||||||
|
val engineSupportsScheme: Boolean,
|
||||||
|
val isMarketplace: Boolean,
|
||||||
|
// Full sanitised-target fingerprint (URL + intent payload), the dedupe/invalidation key.
|
||||||
|
val targetFingerprint: String,
|
||||||
|
val appName: String?,
|
||||||
|
val packageName: String?,
|
||||||
|
val scopeKey: String,
|
||||||
|
val createdAt: Long,
|
||||||
|
) {
|
||||||
|
fun toPigeon(): AppLinkPromptRequest = AppLinkPromptRequest(
|
||||||
|
requestId = requestId,
|
||||||
|
owner = owner,
|
||||||
|
tabId = tabId,
|
||||||
|
contextId = contextId,
|
||||||
|
sourceUrl = sourceUrl,
|
||||||
|
isPrivate = isPrivate,
|
||||||
|
isWallet = isWallet,
|
||||||
|
isProtectedContext = isProtectedContext,
|
||||||
|
canRemember = canRemember,
|
||||||
|
isModal = isModal,
|
||||||
|
target = AppLinkTarget(
|
||||||
|
url = url,
|
||||||
|
appName = appName,
|
||||||
|
packageName = packageName,
|
||||||
|
fallbackUrl = fallbackUrl,
|
||||||
|
isMarketplace = isMarketplace,
|
||||||
|
isAmbiguous = !canRemember,
|
||||||
|
engineSupportsScheme = engineSupportsScheme,
|
||||||
|
scopeKey = scopeKey,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Everything needed to create a request; the store assigns the id and timestamp. */
|
||||||
|
data class NewAppLinkRequest(
|
||||||
|
val owner: AppLinkPromptOwner,
|
||||||
|
val tabId: String,
|
||||||
|
val contextId: String?,
|
||||||
|
val sourceUrl: String?,
|
||||||
|
val isPrivate: Boolean,
|
||||||
|
val isWallet: Boolean,
|
||||||
|
val isProtectedContext: Boolean,
|
||||||
|
val canRemember: Boolean,
|
||||||
|
val isModal: Boolean,
|
||||||
|
val urlClass: AppLinkUrlClass,
|
||||||
|
val url: String,
|
||||||
|
val expectedPackage: String?,
|
||||||
|
val fallbackUrl: String?,
|
||||||
|
val engineSupportsScheme: Boolean,
|
||||||
|
val isMarketplace: Boolean,
|
||||||
|
val targetFingerprint: String,
|
||||||
|
val appName: String?,
|
||||||
|
val packageName: String?,
|
||||||
|
val scopeKey: String,
|
||||||
|
/** A user-gesture attempt is never deduped into an older request (§2.6). */
|
||||||
|
val isUserGesture: Boolean = false,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Process-level registry of profile-scoped [PendingAppLinkStore] singletons (§2.10).
|
||||||
|
* Keyed by native's canonical profile relative path; survives `GlobalComponents.setUp()`.
|
||||||
|
*/
|
||||||
|
object PendingAppLinkStores {
|
||||||
|
private val stores = ConcurrentHashMap<String, PendingAppLinkStore>()
|
||||||
|
|
||||||
|
fun forProfile(relativePath: String): PendingAppLinkStore =
|
||||||
|
stores.getOrPut(relativePath) { PendingAppLinkStore() }
|
||||||
|
|
||||||
|
fun remove(relativePath: String) {
|
||||||
|
stores.remove(relativePath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Holds pending prompts, dedupe, suppression, and the fallback re-entry map (§2.6).
|
||||||
|
* Query + consume: requests stay until resolved, invalidated, or expired. The store
|
||||||
|
* never holds its lock across a side effect — [consume] returns the request and the
|
||||||
|
* caller performs launch/fallback after the lock is released.
|
||||||
|
*/
|
||||||
|
class PendingAppLinkStore(
|
||||||
|
private val clock: MonotonicClock = MonotonicClock.SYSTEM,
|
||||||
|
private val requestExpiryMs: Long = REQUEST_EXPIRY_MS,
|
||||||
|
private val suppressionExpiryMs: Long = SUPPRESSION_EXPIRY_MS,
|
||||||
|
private val dedupeWindowMs: Long = DEDUPE_WINDOW_MS,
|
||||||
|
private val fallbackReentryMs: Long = FALLBACK_REENTRY_MS,
|
||||||
|
) {
|
||||||
|
private val logger = Logger("PendingAppLinkStore")
|
||||||
|
private val lock = Any()
|
||||||
|
private val idGenerator = AtomicLong(0L)
|
||||||
|
|
||||||
|
private val requests = LinkedHashMap<Long, PendingAppLinkRequest>()
|
||||||
|
private val suppression = HashMap<String, Long>()
|
||||||
|
private val fallbackReentry = HashMap<String, Long>()
|
||||||
|
|
||||||
|
private fun suppressionKey(tabId: String, fingerprint: String) = "$tabId\u0000$fingerprint"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a request, collapsing a matching non-user-gesture request that arrived
|
||||||
|
* within the dedupe window into the existing one (§2.6).
|
||||||
|
*/
|
||||||
|
fun createRequest(input: NewAppLinkRequest): PendingAppLinkRequest {
|
||||||
|
synchronized(lock) {
|
||||||
|
sweepExpiredLocked()
|
||||||
|
|
||||||
|
if (!input.isUserGesture) {
|
||||||
|
val existing = requests.values.firstOrNull { candidate ->
|
||||||
|
candidate.tabId == input.tabId &&
|
||||||
|
candidate.targetFingerprint == input.targetFingerprint &&
|
||||||
|
candidate.owner == input.owner &&
|
||||||
|
candidate.urlClass == input.urlClass &&
|
||||||
|
clock.elapsedRealtime() <= candidate.createdAt + dedupeWindowMs
|
||||||
|
}
|
||||||
|
if (existing != null) return existing
|
||||||
|
}
|
||||||
|
|
||||||
|
val request = PendingAppLinkRequest(
|
||||||
|
requestId = idGenerator.incrementAndGet(),
|
||||||
|
owner = input.owner,
|
||||||
|
tabId = input.tabId,
|
||||||
|
contextId = input.contextId,
|
||||||
|
sourceUrl = input.sourceUrl,
|
||||||
|
isPrivate = input.isPrivate,
|
||||||
|
isWallet = input.isWallet,
|
||||||
|
isProtectedContext = input.isProtectedContext,
|
||||||
|
canRemember = input.canRemember,
|
||||||
|
isModal = input.isModal,
|
||||||
|
urlClass = input.urlClass,
|
||||||
|
url = input.url,
|
||||||
|
expectedPackage = input.expectedPackage,
|
||||||
|
fallbackUrl = input.fallbackUrl,
|
||||||
|
engineSupportsScheme = input.engineSupportsScheme,
|
||||||
|
isMarketplace = input.isMarketplace,
|
||||||
|
targetFingerprint = input.targetFingerprint,
|
||||||
|
appName = input.appName,
|
||||||
|
packageName = input.packageName,
|
||||||
|
scopeKey = input.scopeKey,
|
||||||
|
createdAt = clock.elapsedRealtime(),
|
||||||
|
)
|
||||||
|
requests[request.requestId] = request
|
||||||
|
return request
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Non-consuming query of live requests for [owner]. */
|
||||||
|
fun getPending(owner: AppLinkPromptOwner): List<PendingAppLinkRequest> {
|
||||||
|
synchronized(lock) {
|
||||||
|
sweepExpiredLocked()
|
||||||
|
return requests.values.filter { it.owner == owner }.toList()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Atomically remove and return a request; null if already resolved/expired. */
|
||||||
|
fun consume(requestId: Long): PendingAppLinkRequest? {
|
||||||
|
synchronized(lock) {
|
||||||
|
sweepExpiredLocked()
|
||||||
|
return requests.remove(requestId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun peek(requestId: Long): PendingAppLinkRequest? {
|
||||||
|
synchronized(lock) {
|
||||||
|
sweepExpiredLocked()
|
||||||
|
return requests[requestId]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun invalidate(requestId: Long) {
|
||||||
|
synchronized(lock) { requests.remove(requestId) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Invalidate every pending request for a tab (tab close / replacement). */
|
||||||
|
fun invalidateTab(tabId: String) {
|
||||||
|
synchronized(lock) {
|
||||||
|
requests.values.removeAll { it.tabId == tabId }
|
||||||
|
suppression.keys.removeAll { it.startsWith("$tabId\u0000") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A committed top-level navigation in [tabId]. A request whose own page committed
|
||||||
|
* stays alive (that commit is the page the prompt sits on); a commit to a
|
||||||
|
* *different site* invalidates the tab's pending requests (§2.6).
|
||||||
|
*
|
||||||
|
* Matching is by **normalised host**, not exact URL: the initial load a banner
|
||||||
|
* rides on almost always commits at a redirected/normalised URL (`www`, trailing
|
||||||
|
* slash, tracking params) that never equals the intercepted URL, so an exact-URL
|
||||||
|
* check would invalidate every banner on its own page load. The anchor is the
|
||||||
|
* target host for a banner (the page it loads) and the source host for a modal
|
||||||
|
* (the page it is shown over, since the modal's own navigation was denied). When
|
||||||
|
* no host can be derived, the request is kept and left to expiry/tab-close.
|
||||||
|
*/
|
||||||
|
fun onCommittedNavigation(tabId: String, committedUrl: String) {
|
||||||
|
val committedHost = siteKey(committedUrl)
|
||||||
|
synchronized(lock) {
|
||||||
|
val removed = mutableListOf<Long>()
|
||||||
|
requests.values.removeAll { request ->
|
||||||
|
if (request.tabId != tabId) return@removeAll false
|
||||||
|
val anchorHost = siteKey(if (request.isModal) request.sourceUrl else request.url)
|
||||||
|
val invalidate = anchorHost != null && committedHost != null && anchorHost != committedHost
|
||||||
|
if (invalidate) removed.add(request.requestId)
|
||||||
|
invalidate
|
||||||
|
}
|
||||||
|
if (removed.isNotEmpty()) {
|
||||||
|
logger.info(
|
||||||
|
"onCommittedNavigation tab=$tabId committedHost=$committedHost invalidated=$removed",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Normalised, subdomain-stripped host for same-site comparison; null if underivable. */
|
||||||
|
private fun siteKey(url: String?): String? {
|
||||||
|
val rawHost = extractHost(url) ?: return null
|
||||||
|
val normalized = AppLinkHostNormalizer.normalizeHost(rawHost) ?: return null
|
||||||
|
return stripCommonSubDomains(normalized)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun extractHost(url: String?): String? {
|
||||||
|
if (url.isNullOrEmpty()) return null
|
||||||
|
val schemeSep = url.indexOf("://")
|
||||||
|
if (schemeSep < 0) return null
|
||||||
|
val afterScheme = url.substring(schemeSep + 3)
|
||||||
|
val end = afterScheme.indexOfFirst { it == '/' || it == '?' || it == '#' }
|
||||||
|
var authority = if (end >= 0) afterScheme.substring(0, end) else afterScheme
|
||||||
|
val at = authority.lastIndexOf('@')
|
||||||
|
if (at >= 0) authority = authority.substring(at + 1)
|
||||||
|
// Preserve a bracketed IPv6 literal; AppLinkHostNormalizer canonicalises it.
|
||||||
|
if (authority.startsWith("[")) {
|
||||||
|
val close = authority.indexOf(']')
|
||||||
|
return if (close >= 0) authority.substring(0, close + 1) else null
|
||||||
|
}
|
||||||
|
val colon = authority.lastIndexOf(':')
|
||||||
|
if (colon >= 0) authority = authority.substring(0, colon)
|
||||||
|
return authority.ifEmpty { null }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun stripCommonSubDomains(host: String): String = when {
|
||||||
|
host.startsWith("www.") -> host.removePrefix("www.")
|
||||||
|
host.startsWith("m.") -> host.removePrefix("m.")
|
||||||
|
host.startsWith("mobile.") -> host.removePrefix("mobile.")
|
||||||
|
host.startsWith("maps.") -> host.removePrefix("maps.")
|
||||||
|
else -> host
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Suppression (§2.6) ----
|
||||||
|
|
||||||
|
fun recordSuppression(tabId: String, fingerprint: String) {
|
||||||
|
synchronized(lock) {
|
||||||
|
suppression[suppressionKey(tabId, fingerprint)] =
|
||||||
|
clock.elapsedRealtime() + suppressionExpiryMs
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun isSuppressed(tabId: String, fingerprint: String): Boolean {
|
||||||
|
synchronized(lock) {
|
||||||
|
sweepExpiredLocked()
|
||||||
|
val expiresAt = suppression[suppressionKey(tabId, fingerprint)] ?: return false
|
||||||
|
return clock.elapsedRealtime() <= expiresAt
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Clear a tab's suppression on a new user-initiated/direct navigation (§2.6). */
|
||||||
|
fun clearSuppressionForTab(tabId: String) {
|
||||||
|
synchronized(lock) {
|
||||||
|
suppression.keys.removeAll { it.startsWith("$tabId\u0000") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Fallback re-entry map (§2.7) ----
|
||||||
|
|
||||||
|
fun recordFallbackReentry(canonicalUrl: String) {
|
||||||
|
synchronized(lock) {
|
||||||
|
fallbackReentry[canonicalUrl] = clock.elapsedRealtime() + fallbackReentryMs
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun isFallbackReentry(canonicalUrl: String): Boolean {
|
||||||
|
synchronized(lock) {
|
||||||
|
sweepExpiredLocked()
|
||||||
|
val expiresAt = fallbackReentry[canonicalUrl] ?: return false
|
||||||
|
return clock.elapsedRealtime() <= expiresAt
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun sweepExpiredLocked() {
|
||||||
|
val now = clock.elapsedRealtime()
|
||||||
|
requests.values.removeAll { now > it.createdAt + requestExpiryMs }
|
||||||
|
suppression.values.removeAll { now > it }
|
||||||
|
fallbackReentry.values.removeAll { now > it }
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val REQUEST_EXPIRY_MS = 10 * 60 * 1000L
|
||||||
|
const val SUPPRESSION_EXPIRY_MS = 10 * 60 * 1000L
|
||||||
|
const val DEDUPE_WINDOW_MS = 2000L
|
||||||
|
const val FALLBACK_REENTRY_MS = 10 * 1000L
|
||||||
|
}
|
||||||
|
}
|
||||||
+382
@@ -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<ProtectedTargetPattern>, uri: String): Boolean {
|
||||||
|
if (patterns.isEmpty()) return false
|
||||||
|
val parsed = runCatching { Uri.parse(uri) }.getOrNull() ?: return false
|
||||||
|
val scheme = parsed.scheme?.lowercase(Locale.ROOT) ?: return false
|
||||||
|
val host = AppLinkHostNormalizer.normalizeHost(parsed.host) ?: return false
|
||||||
|
val effectivePort = if (parsed.port != -1) parsed.port else defaultPortForScheme(scheme)
|
||||||
|
|
||||||
|
return patterns.any { pattern ->
|
||||||
|
if (pattern.scheme.lowercase(Locale.ROOT) != scheme) return@any false
|
||||||
|
val patternHost = AppLinkHostNormalizer.normalizeHost(pattern.hostOrSuffix) ?: return@any false
|
||||||
|
if (pattern.includeSubdomains) {
|
||||||
|
// Wildcard entries match apex + subdomains and ignore port (§2.3).
|
||||||
|
host == patternHost || host.endsWith(".$patternHost")
|
||||||
|
} else {
|
||||||
|
// Exact entries compare scheme + origin including effective port.
|
||||||
|
host == patternHost && effectivePort == (pattern.port ?: defaultPortForScheme(scheme))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun defaultPortForScheme(scheme: String): Int = when (scheme) {
|
||||||
|
"http", "ws" -> 80
|
||||||
|
"https", "wss" -> 443
|
||||||
|
"ftp" -> 21
|
||||||
|
else -> -1
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Helpers ----
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The dedupe/invalidation/suppression key: the full sanitised target, not just the rule scope,
|
||||||
|
* so different paths sharing one policy scope never collapse into one request (§2.6).
|
||||||
|
*/
|
||||||
|
private fun targetFingerprint(uri: String, resolved: ResolvedAppLink): String {
|
||||||
|
val intentPayload = resolved.appIntent?.let {
|
||||||
|
runCatching { it.toUri(Intent.URI_INTENT_SCHEME) }.getOrNull()
|
||||||
|
}.orEmpty()
|
||||||
|
return buildString {
|
||||||
|
append(uri)
|
||||||
|
append('\u0000')
|
||||||
|
append(resolved.packageName.orEmpty())
|
||||||
|
append('\u0000')
|
||||||
|
append(intentPayload)
|
||||||
|
append('\u0000')
|
||||||
|
append(resolved.fallbackUrl.orEmpty())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Canonical key for the fallback re-entry map — the raw URL, matched on identity round-trip. */
|
||||||
|
private fun canonicalReentryKey(url: String): String = url
|
||||||
|
|
||||||
|
private fun pendingStoreFor(components: Components): PendingAppLinkStore {
|
||||||
|
return PendingAppLinkStores.forProfile(components.profileApplicationContext.relativePath)
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val WWW = "www."
|
||||||
|
private const val M = "m."
|
||||||
|
private const val MOBILE = "mobile."
|
||||||
|
private const val MAPS = "maps."
|
||||||
|
}
|
||||||
|
}
|
||||||
+8
-2
@@ -24,7 +24,8 @@ import eu.weblibre.flutter_mozilla_components.services.MediaSessionService
|
|||||||
import eu.weblibre.flutter_mozilla_components.activities.NotificationActivity
|
import eu.weblibre.flutter_mozilla_components.activities.NotificationActivity
|
||||||
import eu.weblibre.flutter_mozilla_components.R
|
import eu.weblibre.flutter_mozilla_components.R
|
||||||
import eu.weblibre.flutter_mozilla_components.ext.getPreferenceKey
|
import eu.weblibre.flutter_mozilla_components.ext.getPreferenceKey
|
||||||
import eu.weblibre.flutter_mozilla_components.middleware.AppLinksCancelRetryMiddleware
|
import eu.weblibre.flutter_mozilla_components.applinks.PendingAppLinkStores
|
||||||
|
import eu.weblibre.flutter_mozilla_components.middleware.AppLinkNavigationMiddleware
|
||||||
import eu.weblibre.flutter_mozilla_components.middleware.FlutterEventMiddleware
|
import eu.weblibre.flutter_mozilla_components.middleware.FlutterEventMiddleware
|
||||||
import eu.weblibre.flutter_mozilla_components.middleware.HistoryMetadataMiddleware
|
import eu.weblibre.flutter_mozilla_components.middleware.HistoryMetadataMiddleware
|
||||||
import eu.weblibre.flutter_mozilla_components.middleware.HistoryMetadataService
|
import eu.weblibre.flutter_mozilla_components.middleware.HistoryMetadataService
|
||||||
@@ -238,7 +239,12 @@ class Core(
|
|||||||
// Must run before any engine middleware so we can rewrite
|
// Must run before any engine middleware so we can rewrite
|
||||||
// sandbox new-tab URLs before Gecko issues a request.
|
// sandbox new-tab URLs before Gecko issues a request.
|
||||||
SandboxCaptureMiddleware,
|
SandboxCaptureMiddleware,
|
||||||
AppLinksCancelRetryMiddleware(),
|
// WebLibre-owned app-link pending-request invalidation + suppression clearing.
|
||||||
|
AppLinkNavigationMiddleware(
|
||||||
|
PendingAppLinkStores.forProfile(
|
||||||
|
components.profileApplicationContext.relativePath,
|
||||||
|
),
|
||||||
|
),
|
||||||
HistoryMetadataMiddleware(historyMetadataService),
|
HistoryMetadataMiddleware(historyMetadataService),
|
||||||
// Correlates url -> contextId so WebLibreHistoryDelegate can
|
// Correlates url -> contextId so WebLibreHistoryDelegate can
|
||||||
// resolve a visit's container at record time.
|
// resolve a visit's container at record time.
|
||||||
|
|||||||
-9
@@ -9,7 +9,6 @@ import android.content.Intent
|
|||||||
import androidx.browser.customtabs.CustomTabsIntent
|
import androidx.browser.customtabs.CustomTabsIntent
|
||||||
import androidx.core.net.toUri
|
import androidx.core.net.toUri
|
||||||
import androidx.preference.PreferenceManager
|
import androidx.preference.PreferenceManager
|
||||||
import eu.weblibre.flutter_mozilla_components.GlobalComponents
|
|
||||||
import eu.weblibre.flutter_mozilla_components.R
|
import eu.weblibre.flutter_mozilla_components.R
|
||||||
import eu.weblibre.flutter_mozilla_components.activities.AuthIntentReceiverActivity
|
import eu.weblibre.flutter_mozilla_components.activities.AuthIntentReceiverActivity
|
||||||
import eu.weblibre.flutter_mozilla_components.ext.getPreferenceKey
|
import eu.weblibre.flutter_mozilla_components.ext.getPreferenceKey
|
||||||
@@ -18,7 +17,6 @@ import mozilla.components.concept.engine.Engine
|
|||||||
import mozilla.components.feature.accounts.FirefoxAccountsAuthFeature
|
import mozilla.components.feature.accounts.FirefoxAccountsAuthFeature
|
||||||
import mozilla.components.feature.accounts.FxaCapability
|
import mozilla.components.feature.accounts.FxaCapability
|
||||||
import mozilla.components.feature.accounts.FxaWebChannelFeature
|
import mozilla.components.feature.accounts.FxaWebChannelFeature
|
||||||
import mozilla.components.feature.app.links.AppLinksInterceptor
|
|
||||||
import mozilla.components.feature.tabs.TabsUseCases
|
import mozilla.components.feature.tabs.TabsUseCases
|
||||||
import mozilla.components.service.fxa.ServerConfig
|
import mozilla.components.service.fxa.ServerConfig
|
||||||
import mozilla.components.service.fxa.manager.FxaAccountManager
|
import mozilla.components.service.fxa.manager.FxaAccountManager
|
||||||
@@ -66,11 +64,4 @@ class Services(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
val appLinksInterceptor by lazy {
|
|
||||||
AppLinksInterceptor(
|
|
||||||
context = context,
|
|
||||||
launchInApp = { GlobalComponents.shouldOpenLinksInApp() },
|
|
||||||
store = store,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
-3
@@ -8,7 +8,6 @@ import android.content.Context
|
|||||||
import android.os.Environment
|
import android.os.Environment
|
||||||
import mozilla.components.browser.state.store.BrowserStore
|
import mozilla.components.browser.state.store.BrowserStore
|
||||||
import mozilla.components.concept.engine.Engine
|
import mozilla.components.concept.engine.Engine
|
||||||
import mozilla.components.feature.app.links.AppLinksUseCases
|
|
||||||
import mozilla.components.feature.contextmenu.ContextMenuUseCases
|
import mozilla.components.feature.contextmenu.ContextMenuUseCases
|
||||||
import mozilla.components.feature.downloads.DownloadsUseCases
|
import mozilla.components.feature.downloads.DownloadsUseCases
|
||||||
import mozilla.components.feature.session.SessionUseCases
|
import mozilla.components.feature.session.SessionUseCases
|
||||||
@@ -70,8 +69,6 @@ class UseCases(
|
|||||||
*/
|
*/
|
||||||
val customTabsUseCases: CustomTabsUseCases by lazy { CustomTabsUseCases(store, sessionUseCases.loadUrl) }
|
val customTabsUseCases: CustomTabsUseCases by lazy { CustomTabsUseCases(store, sessionUseCases.loadUrl) }
|
||||||
|
|
||||||
val appLinksUseCases by lazy { AppLinksUseCases(context) }
|
|
||||||
|
|
||||||
val trackingProtectionUseCases by lazy { TrackingProtectionUseCases(store, engine) }
|
val trackingProtectionUseCases by lazy { TrackingProtectionUseCases(store, engine) }
|
||||||
|
|
||||||
val webAppUseCases by lazy {
|
val webAppUseCases by lazy {
|
||||||
|
|||||||
+7
-2
@@ -12,6 +12,7 @@ import android.content.Intent
|
|||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import eu.weblibre.flutter_mozilla_components.GlobalComponents
|
import eu.weblibre.flutter_mozilla_components.GlobalComponents
|
||||||
|
import eu.weblibre.flutter_mozilla_components.applinks.WebLibreAppLinksInterceptor
|
||||||
import eu.weblibre.flutter_mozilla_components.ext.EventSequence
|
import eu.weblibre.flutter_mozilla_components.ext.EventSequence
|
||||||
import eu.weblibre.flutter_mozilla_components.feature.InertExternalSchemes
|
import eu.weblibre.flutter_mozilla_components.feature.InertExternalSchemes
|
||||||
import eu.weblibre.flutter_mozilla_components.feature.SandboxCaptureBridge
|
import eu.weblibre.flutter_mozilla_components.feature.SandboxCaptureBridge
|
||||||
@@ -30,6 +31,9 @@ class AppRequestInterceptor(private val context: Context) : RequestInterceptor {
|
|||||||
requireNotNull(GlobalComponents.components) { "Components not initialized" }
|
requireNotNull(GlobalComponents.components) { "Components not initialized" }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The WebLibre-owned §2.4 app-links tail.
|
||||||
|
private val webLibreAppLinks by lazy { WebLibreAppLinksInterceptor(context) }
|
||||||
|
|
||||||
override fun onLoadRequest(
|
override fun onLoadRequest(
|
||||||
engineSession: EngineSession,
|
engineSession: EngineSession,
|
||||||
uri: String,
|
uri: String,
|
||||||
@@ -130,12 +134,13 @@ class AppRequestInterceptor(private val context: Context) : RequestInterceptor {
|
|||||||
return it
|
return it
|
||||||
}
|
}
|
||||||
|
|
||||||
return components.services.appLinksInterceptor.onLoadRequest(
|
// App-links tail: the WebLibre-owned §2.4 implementation. Structural guards above
|
||||||
|
// (PWA/TWA, sandbox, weblibre://, FxA) already answered.
|
||||||
|
return webLibreAppLinks.onLoadRequest(
|
||||||
engineSession,
|
engineSession,
|
||||||
uri,
|
uri,
|
||||||
lastUri,
|
lastUri,
|
||||||
hasUserGesture,
|
hasUserGesture,
|
||||||
isSameDomain,
|
|
||||||
isRedirect,
|
isRedirect,
|
||||||
isDirectNavigation,
|
isDirectNavigation,
|
||||||
isSubframeRequest,
|
isSubframeRequest,
|
||||||
|
|||||||
+72
@@ -0,0 +1,72 @@
|
|||||||
|
/*
|
||||||
|
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package eu.weblibre.flutter_mozilla_components.middleware
|
||||||
|
|
||||||
|
import eu.weblibre.flutter_mozilla_components.applinks.PendingAppLinkStore
|
||||||
|
import mozilla.components.browser.state.action.BrowserAction
|
||||||
|
import mozilla.components.browser.state.action.ContentAction
|
||||||
|
import mozilla.components.browser.state.action.CustomTabListAction
|
||||||
|
import mozilla.components.browser.state.action.EngineAction
|
||||||
|
import mozilla.components.browser.state.action.TabListAction
|
||||||
|
import mozilla.components.browser.state.state.BrowserState
|
||||||
|
import mozilla.components.lib.state.Middleware
|
||||||
|
import mozilla.components.lib.state.Store
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Observes the [BrowserStore] and drives [PendingAppLinkStore] invalidation and
|
||||||
|
* suppression clearing (APP_LINKS_OWN_IMPLEMENTATION_PLAN.md §2.6):
|
||||||
|
*
|
||||||
|
* - a committed top-level navigation whose URL is not a request's own target
|
||||||
|
* invalidates that request (a banner-class request's target committing keeps it
|
||||||
|
* alive — that commit is the page the banner sits on);
|
||||||
|
* - tab close / Custom Tab removal invalidates the tab's pending requests and
|
||||||
|
* suppression;
|
||||||
|
* - a new user-initiated/direct navigation (omnibar, bookmark, typed URL — which
|
||||||
|
* dispatch a `LoadUrlAction`) clears the tab's suppression. In-page redirects
|
||||||
|
* do not dispatch these actions, so the redirect-loop defence stays intact.
|
||||||
|
*/
|
||||||
|
class AppLinkNavigationMiddleware(
|
||||||
|
private val store: PendingAppLinkStore,
|
||||||
|
) : Middleware<BrowserState, BrowserAction> {
|
||||||
|
override fun invoke(
|
||||||
|
store: Store<BrowserState, BrowserAction>,
|
||||||
|
next: (BrowserAction) -> Unit,
|
||||||
|
action: BrowserAction,
|
||||||
|
) {
|
||||||
|
when (action) {
|
||||||
|
is ContentAction.UpdateUrlAction -> {
|
||||||
|
// A committed top-level navigation.
|
||||||
|
this.store.onCommittedNavigation(action.sessionId, action.url)
|
||||||
|
}
|
||||||
|
|
||||||
|
is EngineAction.LoadUrlAction -> {
|
||||||
|
// App-initiated (direct) navigation — clears suppression.
|
||||||
|
this.store.clearSuppressionForTab(action.tabId)
|
||||||
|
}
|
||||||
|
|
||||||
|
is EngineAction.OptimizedLoadUrlTriggeredAction -> {
|
||||||
|
this.store.clearSuppressionForTab(action.tabId)
|
||||||
|
}
|
||||||
|
|
||||||
|
is TabListAction.RemoveTabAction -> {
|
||||||
|
this.store.invalidateTab(action.tabId)
|
||||||
|
}
|
||||||
|
|
||||||
|
is TabListAction.RemoveTabsAction -> {
|
||||||
|
action.tabIds.forEach(this.store::invalidateTab)
|
||||||
|
}
|
||||||
|
|
||||||
|
is CustomTabListAction.RemoveCustomTabAction -> {
|
||||||
|
this.store.invalidateTab(action.tabId)
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> {}
|
||||||
|
}
|
||||||
|
|
||||||
|
next(action)
|
||||||
|
}
|
||||||
|
}
|
||||||
-149
@@ -1,149 +0,0 @@
|
|||||||
/*
|
|
||||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
|
||||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
||||||
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
|
||||||
*/
|
|
||||||
|
|
||||||
package eu.weblibre.flutter_mozilla_components.middleware
|
|
||||||
|
|
||||||
import android.os.Handler
|
|
||||||
import android.os.Looper
|
|
||||||
import mozilla.components.browser.state.action.BrowserAction
|
|
||||||
import mozilla.components.browser.state.action.ContentAction
|
|
||||||
import mozilla.components.browser.state.action.EngineAction
|
|
||||||
import mozilla.components.browser.state.selector.findTabOrCustomTab
|
|
||||||
import mozilla.components.browser.state.state.BrowserState
|
|
||||||
import mozilla.components.concept.engine.EngineSession
|
|
||||||
import mozilla.components.concept.engine.EngineSession.LoadUrlFlags.Companion.EXTERNAL
|
|
||||||
import mozilla.components.concept.engine.EngineSession.LoadUrlFlags.Companion.LOAD_FLAGS_BYPASS_LOAD_URI_DELEGATE
|
|
||||||
import mozilla.components.lib.state.Middleware
|
|
||||||
import mozilla.components.lib.state.Store
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Workaround for Android Components/GeckoView app-link cancel handling: the
|
|
||||||
* cancel load can be clobbered by Gecko's recovery load back to the previous
|
|
||||||
* history entry. This retries only that specific cancel-load signature.
|
|
||||||
*/
|
|
||||||
class AppLinksCancelRetryMiddleware(
|
|
||||||
private val handler: Handler = Handler(Looper.getMainLooper()),
|
|
||||||
private val retryDelayMillis: Long = RETRY_DELAY_MILLIS,
|
|
||||||
) : Middleware<BrowserState, BrowserAction> {
|
|
||||||
private val pendingCancels = mutableMapOf<String, PendingCancel>()
|
|
||||||
|
|
||||||
override fun invoke(
|
|
||||||
store: Store<BrowserState, BrowserAction>,
|
|
||||||
next: (BrowserAction) -> Unit,
|
|
||||||
action: BrowserAction,
|
|
||||||
) {
|
|
||||||
when (action) {
|
|
||||||
is EngineAction.OptimizedLoadUrlTriggeredAction -> {
|
|
||||||
recordCancelLoad(store, action)
|
|
||||||
}
|
|
||||||
is ContentAction.UpdateLoadRequestAction -> {
|
|
||||||
handleLoadRequest(store, action)
|
|
||||||
}
|
|
||||||
is ContentAction.UpdateUrlAction -> {
|
|
||||||
handleUrlUpdate(action)
|
|
||||||
}
|
|
||||||
else -> {}
|
|
||||||
}
|
|
||||||
|
|
||||||
next(action)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun recordCancelLoad(
|
|
||||||
store: Store<BrowserState, BrowserAction>,
|
|
||||||
action: EngineAction.OptimizedLoadUrlTriggeredAction,
|
|
||||||
) {
|
|
||||||
if (!action.flags.contains(EXTERNAL) ||
|
|
||||||
!action.flags.contains(LOAD_FLAGS_BYPASS_LOAD_URI_DELEGATE)
|
|
||||||
) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
val sourceUrl = store.state.findTabOrCustomTab(action.tabId)?.content?.url
|
|
||||||
?: return
|
|
||||||
if (sourceUrl == action.url || sourceUrl == ABOUT_BLANK) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
pendingCancels[action.tabId] = PendingCancel(
|
|
||||||
tabId = action.tabId,
|
|
||||||
sourceUrl = sourceUrl,
|
|
||||||
targetUrl = action.url,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun handleLoadRequest(
|
|
||||||
store: Store<BrowserState, BrowserAction>,
|
|
||||||
action: ContentAction.UpdateLoadRequestAction,
|
|
||||||
) {
|
|
||||||
val pending = pendingCancels[action.sessionId] ?: return
|
|
||||||
when (action.loadRequest.url) {
|
|
||||||
pending.sourceUrl -> scheduleRetry(store, pending)
|
|
||||||
pending.targetUrl -> pendingCancels.remove(action.sessionId)
|
|
||||||
ABOUT_BLANK -> {}
|
|
||||||
else -> pendingCancels.remove(action.sessionId)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun handleUrlUpdate(action: ContentAction.UpdateUrlAction) {
|
|
||||||
val pending = pendingCancels[action.sessionId] ?: return
|
|
||||||
when (action.url) {
|
|
||||||
pending.targetUrl -> pendingCancels.remove(action.sessionId)
|
|
||||||
pending.sourceUrl, ABOUT_BLANK -> {}
|
|
||||||
else -> pendingCancels.remove(action.sessionId)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun scheduleRetry(
|
|
||||||
store: Store<BrowserState, BrowserAction>,
|
|
||||||
pending: PendingCancel,
|
|
||||||
) {
|
|
||||||
if (pending.retryScheduled) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
val scheduled = pending.copy(retryScheduled = true)
|
|
||||||
pendingCancels[pending.tabId] = scheduled
|
|
||||||
|
|
||||||
handler.postDelayed({
|
|
||||||
if (pendingCancels[pending.tabId] != scheduled) {
|
|
||||||
return@postDelayed
|
|
||||||
}
|
|
||||||
|
|
||||||
val currentUrl = store.state.findTabOrCustomTab(pending.tabId)?.content?.url
|
|
||||||
if (currentUrl == pending.targetUrl) {
|
|
||||||
pendingCancels.remove(pending.tabId)
|
|
||||||
return@postDelayed
|
|
||||||
}
|
|
||||||
|
|
||||||
if (currentUrl == pending.sourceUrl || currentUrl == ABOUT_BLANK) {
|
|
||||||
pendingCancels.remove(pending.tabId)
|
|
||||||
store.dispatch(
|
|
||||||
EngineAction.LoadUrlAction(
|
|
||||||
tabId = pending.tabId,
|
|
||||||
url = pending.targetUrl,
|
|
||||||
flags = EngineSession.LoadUrlFlags.select(
|
|
||||||
LOAD_FLAGS_BYPASS_LOAD_URI_DELEGATE,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
pendingCancels.remove(pending.tabId)
|
|
||||||
}
|
|
||||||
}, retryDelayMillis)
|
|
||||||
}
|
|
||||||
|
|
||||||
private data class PendingCancel(
|
|
||||||
val tabId: String,
|
|
||||||
val sourceUrl: String,
|
|
||||||
val targetUrl: String,
|
|
||||||
val retryScheduled: Boolean = false,
|
|
||||||
)
|
|
||||||
|
|
||||||
private companion object {
|
|
||||||
const val ABOUT_BLANK = "about:blank"
|
|
||||||
const val RETRY_DELAY_MILLIS = 1000L
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+875
-246
File diff suppressed because it is too large
Load Diff
@@ -30,4 +30,12 @@
|
|||||||
<string name="private_tabs_notification_title_android_14">Close private tabs?</string>
|
<string name="private_tabs_notification_title_android_14">Close private tabs?</string>
|
||||||
<string name="private_tabs_notification_text_android_14">Tap or swipe this notification to close private tabs.</string>
|
<string name="private_tabs_notification_text_android_14">Tap or swipe this notification to close private tabs.</string>
|
||||||
|
|
||||||
|
<!-- App-link prompt shown in native Custom Tab sessions (no Flutter engine). -->
|
||||||
|
<!-- %1$s is the target app name. -->
|
||||||
|
<string name="weblibre_app_link_prompt_title_named">Open in %1$s?</string>
|
||||||
|
<string name="weblibre_app_link_prompt_title_generic">Open in another app?</string>
|
||||||
|
<string name="weblibre_app_link_prompt_message">This link is handled by an app outside WebLibre.</string>
|
||||||
|
<string name="weblibre_app_link_prompt_open">Open</string>
|
||||||
|
<string name="weblibre_app_link_prompt_cancel">Cancel</string>
|
||||||
|
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
+287
@@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
+60
@@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
+114
@@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
+68
@@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
+184
@@ -0,0 +1,184 @@
|
|||||||
|
/*
|
||||||
|
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package eu.weblibre.flutter_mozilla_components.applinks
|
||||||
|
|
||||||
|
import eu.weblibre.flutter_mozilla_components.pigeons.AppLinkPromptOwner
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.test.assertFalse
|
||||||
|
import kotlin.test.assertNotEquals
|
||||||
|
import kotlin.test.assertNotNull
|
||||||
|
import kotlin.test.assertNull
|
||||||
|
import kotlin.test.assertTrue
|
||||||
|
|
||||||
|
class PendingAppLinkStoreTest {
|
||||||
|
private class FakeClock(var now: Long = 0L) : MonotonicClock {
|
||||||
|
override fun elapsedRealtime(): Long = now
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun newRequest(
|
||||||
|
tabId: String = "tab1",
|
||||||
|
fingerprint: String = "fp1",
|
||||||
|
owner: AppLinkPromptOwner = AppLinkPromptOwner.FLUTTER_BROWSER,
|
||||||
|
urlClass: AppLinkUrlClass = AppLinkUrlClass.MODAL,
|
||||||
|
url: String = "zoommtg://join",
|
||||||
|
isUserGesture: Boolean = false,
|
||||||
|
) = NewAppLinkRequest(
|
||||||
|
owner = owner,
|
||||||
|
tabId = tabId,
|
||||||
|
contextId = null,
|
||||||
|
sourceUrl = null,
|
||||||
|
isPrivate = false,
|
||||||
|
isWallet = false,
|
||||||
|
isProtectedContext = false,
|
||||||
|
canRemember = true,
|
||||||
|
isModal = urlClass == AppLinkUrlClass.MODAL,
|
||||||
|
urlClass = urlClass,
|
||||||
|
url = url,
|
||||||
|
expectedPackage = null,
|
||||||
|
fallbackUrl = null,
|
||||||
|
engineSupportsScheme = false,
|
||||||
|
isMarketplace = false,
|
||||||
|
targetFingerprint = fingerprint,
|
||||||
|
appName = "App",
|
||||||
|
packageName = "com.app",
|
||||||
|
scopeKey = "pkg:com.app",
|
||||||
|
isUserGesture = isUserGesture,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun idsAreMonotonic() {
|
||||||
|
val store = PendingAppLinkStore(FakeClock())
|
||||||
|
val a = store.createRequest(newRequest(fingerprint = "a"))
|
||||||
|
val b = store.createRequest(newRequest(fingerprint = "b"))
|
||||||
|
assertNotEquals(a.requestId, b.requestId)
|
||||||
|
assertTrue(b.requestId > a.requestId)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun queryIsNonConsumingAndConsumeIsAtomic() {
|
||||||
|
val store = PendingAppLinkStore(FakeClock())
|
||||||
|
val request = store.createRequest(newRequest())
|
||||||
|
assertEquals(1, store.getPending(AppLinkPromptOwner.FLUTTER_BROWSER).size)
|
||||||
|
// Non-consuming.
|
||||||
|
assertEquals(1, store.getPending(AppLinkPromptOwner.FLUTTER_BROWSER).size)
|
||||||
|
assertEquals(request.requestId, store.consume(request.requestId)?.requestId)
|
||||||
|
// Double-consume is a no-op.
|
||||||
|
assertNull(store.consume(request.requestId))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun ownerFilterSeparatesSurfaces() {
|
||||||
|
val store = PendingAppLinkStore(FakeClock())
|
||||||
|
store.createRequest(newRequest(owner = AppLinkPromptOwner.FLUTTER_BROWSER, fingerprint = "a"))
|
||||||
|
store.createRequest(newRequest(owner = AppLinkPromptOwner.NATIVE_EXTERNAL, fingerprint = "b"))
|
||||||
|
assertEquals(1, store.getPending(AppLinkPromptOwner.FLUTTER_BROWSER).size)
|
||||||
|
assertEquals(1, store.getPending(AppLinkPromptOwner.NATIVE_EXTERNAL).size)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun dedupeCollapsesWithinWindowButNotAcrossUserGesture() {
|
||||||
|
val clock = FakeClock()
|
||||||
|
val store = PendingAppLinkStore(clock, dedupeWindowMs = 2000L)
|
||||||
|
val first = store.createRequest(newRequest())
|
||||||
|
clock.now = 1000L
|
||||||
|
val second = store.createRequest(newRequest())
|
||||||
|
assertEquals(first.requestId, second.requestId)
|
||||||
|
|
||||||
|
// A user-gesture attempt is never deduped.
|
||||||
|
val gesture = store.createRequest(newRequest(isUserGesture = true))
|
||||||
|
assertNotEquals(first.requestId, gesture.requestId)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun distinctFingerprintsSharingAScopeAreNotDeduped() {
|
||||||
|
val store = PendingAppLinkStore(FakeClock())
|
||||||
|
val a = store.createRequest(newRequest(fingerprint = "path-a"))
|
||||||
|
val b = store.createRequest(newRequest(fingerprint = "path-b"))
|
||||||
|
assertNotEquals(a.requestId, b.requestId)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun bannerTargetCommitKeepsRequestButUnrelatedCommitInvalidates() {
|
||||||
|
val store = PendingAppLinkStore(FakeClock())
|
||||||
|
val banner = store.createRequest(
|
||||||
|
newRequest(urlClass = AppLinkUrlClass.BANNER, url = "https://youtu.be/x"),
|
||||||
|
)
|
||||||
|
// The banner's own target committing keeps it alive.
|
||||||
|
store.onCommittedNavigation("tab1", "https://youtu.be/x")
|
||||||
|
assertNotNull(store.peek(banner.requestId))
|
||||||
|
// An unrelated commit invalidates it.
|
||||||
|
store.onCommittedNavigation("tab1", "https://example.com/other")
|
||||||
|
assertNull(store.peek(banner.requestId))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun bannerSurvivesSameSiteRedirectAndNormalisation() {
|
||||||
|
val store = PendingAppLinkStore(FakeClock())
|
||||||
|
// The intercepted URL is rarely byte-identical to the committed one: the initial
|
||||||
|
// load redirects/normalises (www stripped, tracking params added, trailing slash).
|
||||||
|
val banner = store.createRequest(
|
||||||
|
newRequest(urlClass = AppLinkUrlClass.BANNER, url = "https://www.reddit.com/r/foo"),
|
||||||
|
)
|
||||||
|
store.onCommittedNavigation("tab1", "https://reddit.com/r/foo/?utm_source=share")
|
||||||
|
assertNotNull(store.peek(banner.requestId))
|
||||||
|
|
||||||
|
// A commit to a genuinely different site still invalidates it.
|
||||||
|
store.onCommittedNavigation("tab1", "https://twitter.com/reddit")
|
||||||
|
assertNull(store.peek(banner.requestId))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun tabCloseInvalidatesRequestsAndSuppression() {
|
||||||
|
val store = PendingAppLinkStore(FakeClock())
|
||||||
|
val request = store.createRequest(newRequest())
|
||||||
|
store.recordSuppression("tab1", "fp1")
|
||||||
|
store.invalidateTab("tab1")
|
||||||
|
assertNull(store.peek(request.requestId))
|
||||||
|
assertFalse(store.isSuppressed("tab1", "fp1"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun suppressionSurvivesRedirectsButClearsOnDirectNavAndTimeout() {
|
||||||
|
val clock = FakeClock()
|
||||||
|
val store = PendingAppLinkStore(clock, suppressionExpiryMs = 1000L)
|
||||||
|
store.recordSuppression("tab1", "fp1")
|
||||||
|
assertTrue(store.isSuppressed("tab1", "fp1"))
|
||||||
|
// Ordinary committed navigation does not clear it.
|
||||||
|
store.onCommittedNavigation("tab1", "https://redirect.example")
|
||||||
|
assertTrue(store.isSuppressed("tab1", "fp1"))
|
||||||
|
// Direct navigation clears it.
|
||||||
|
store.clearSuppressionForTab("tab1")
|
||||||
|
assertFalse(store.isSuppressed("tab1", "fp1"))
|
||||||
|
|
||||||
|
// Timeout clears it.
|
||||||
|
store.recordSuppression("tab1", "fp2")
|
||||||
|
clock.now = 1001L
|
||||||
|
assertFalse(store.isSuppressed("tab1", "fp2"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun requestsExpire() {
|
||||||
|
val clock = FakeClock()
|
||||||
|
val store = PendingAppLinkStore(clock, requestExpiryMs = 1000L)
|
||||||
|
val request = store.createRequest(newRequest())
|
||||||
|
clock.now = 1001L
|
||||||
|
assertNull(store.consume(request.requestId))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun fallbackReentryIsReusableInWindowAndExpires() {
|
||||||
|
val clock = FakeClock()
|
||||||
|
val store = PendingAppLinkStore(clock, fallbackReentryMs = 10_000L)
|
||||||
|
store.recordFallbackReentry("https://fallback.example/")
|
||||||
|
assertTrue(store.isFallbackReentry("https://fallback.example/"))
|
||||||
|
// Reusable within its window (does not consume).
|
||||||
|
assertTrue(store.isFallbackReentry("https://fallback.example/"))
|
||||||
|
clock.now = 10_001L
|
||||||
|
assertFalse(store.isFallbackReentry("https://fallback.example/"))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -50,6 +50,12 @@ export 'src/pigeons/gecko.g.dart'
|
|||||||
AddonStorePromoted,
|
AddonStorePromoted,
|
||||||
AddonUpdateAttemptInfo,
|
AddonUpdateAttemptInfo,
|
||||||
AddonUpdateStatus,
|
AddonUpdateStatus,
|
||||||
|
AppLinkDecision,
|
||||||
|
AppLinkPolicySnapshot,
|
||||||
|
AppLinkPromptOwner,
|
||||||
|
AppLinkPromptRequest,
|
||||||
|
AppLinkResolutionResult,
|
||||||
|
AppLinkTarget,
|
||||||
AppLinksMode,
|
AppLinksMode,
|
||||||
AudioHitResult,
|
AudioHitResult,
|
||||||
AutoplayStatus,
|
AutoplayStatus,
|
||||||
@@ -70,6 +76,7 @@ export 'src/pigeons/gecko.g.dart'
|
|||||||
DownloadStatus,
|
DownloadStatus,
|
||||||
EmailHitResult,
|
EmailHitResult,
|
||||||
FrecencyThresholdOption,
|
FrecencyThresholdOption,
|
||||||
|
GeckoAppLinkEvents,
|
||||||
GeckoDeleteBrowsingDataController,
|
GeckoDeleteBrowsingDataController,
|
||||||
GeckoEngineSettings,
|
GeckoEngineSettings,
|
||||||
GeckoFetchResponse,
|
GeckoFetchResponse,
|
||||||
@@ -99,7 +106,11 @@ export 'src/pigeons/gecko.g.dart'
|
|||||||
MlProgressData,
|
MlProgressData,
|
||||||
MlProgressStatus,
|
MlProgressStatus,
|
||||||
MlProgressType,
|
MlProgressType,
|
||||||
|
NativeAppLinkRule,
|
||||||
|
NativeAppLinkRuleDecision,
|
||||||
|
NativeContextAppLinkPolicy,
|
||||||
PhoneHitResult,
|
PhoneHitResult,
|
||||||
|
ProtectedTargetPattern,
|
||||||
ProxyLoadError,
|
ProxyLoadError,
|
||||||
PushDistributor,
|
PushDistributor,
|
||||||
PushDistributorStatus,
|
PushDistributorStatus,
|
||||||
|
|||||||
@@ -10,31 +10,52 @@ final _api = GeckoAppLinksApi();
|
|||||||
|
|
||||||
/// Service for detecting and launching external applications that can handle URLs.
|
/// Service for detecting and launching external applications that can handle URLs.
|
||||||
///
|
///
|
||||||
/// This service wraps Mozilla Android Components' AppLinksUseCases to allow
|
/// WebLibre-owned resolution/launch surface. Policy lives in Dart; the native side
|
||||||
/// checking if native apps can handle URLs and launching them directly.
|
/// owns PackageManager resolution and Intent launch. Used by the manual
|
||||||
/// This matches the behavior in Firefox/Fenix for "Open in App" functionality.
|
/// "Open in app" entry points.
|
||||||
class GeckoAppLinksService {
|
class GeckoAppLinksService {
|
||||||
/// Checks if an external application is available to handle the given URL.
|
/// Resolve [url] to an external-app target, or null when no external app is
|
||||||
|
/// available (or on any resolution error / always-denied scheme).
|
||||||
///
|
///
|
||||||
/// This method uses mozilla-components AppLinksUseCases to determine if
|
/// [includeHttpAppLinks] when true, an app resolving an engine-supported
|
||||||
/// a native app can handle the URL (e.g., YouTube app for youtube.com links).
|
/// (http(s)) URL is surfaced (e.g. the YouTube app for a youtube.com link).
|
||||||
///
|
Future<AppLinkTarget?> resolveAppLink(
|
||||||
/// @param url The URL to check.
|
Uri url, {
|
||||||
/// @return true if an external app is available, false otherwise.
|
bool includeHttpAppLinks = true,
|
||||||
Future<bool> hasExternalApp(Uri url) {
|
}) {
|
||||||
return _api.hasExternalApp(url.toString());
|
return _api.resolveAppLink(url.toString(), includeHttpAppLinks);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Opens the URL in an external application if available.
|
/// Re-resolve [url] and launch it in an external app.
|
||||||
///
|
///
|
||||||
/// This method will:
|
/// Returns true if launched, false if no app is available or the launch failed.
|
||||||
/// 1. Check if an external app can handle the URL
|
Future<bool> launchAppLink(Uri url) {
|
||||||
/// 2. If available, launch the app directly with Intent.FLAG_ACTIVITY_NEW_TASK
|
return _api.launchAppLink(url.toString());
|
||||||
/// 3. Return true if successfully launched, false otherwise
|
}
|
||||||
|
|
||||||
|
/// Push the complete app-link policy snapshot to native (last-write-wins).
|
||||||
///
|
///
|
||||||
/// @param url The URL to open in external app.
|
/// Throws if no profile is bound yet; the caller (replicator) retries after
|
||||||
/// @return true if URL was opened in external app, false if no app available.
|
/// initialisation.
|
||||||
Future<bool> openAppLink(Uri url) {
|
Future<void> setAppLinkPolicy(AppLinkPolicySnapshot snapshot) {
|
||||||
return _api.openAppLink(url.toString());
|
return _api.setAppLinkPolicy(snapshot);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Non-consuming query of pending prompts for [owner] (§2.6). Query on
|
||||||
|
/// attach/resume and when the availability event fires; render idempotently by
|
||||||
|
/// requestId.
|
||||||
|
Future<List<AppLinkPromptRequest>> getPendingAppLinkPrompts(
|
||||||
|
AppLinkPromptOwner owner,
|
||||||
|
) {
|
||||||
|
return _api.getPendingAppLinkPrompts(owner);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Atomically resolve a pending prompt (§2.6). A double-resolve or stale id is
|
||||||
|
/// a no-op returning `failureReason == "stale"`.
|
||||||
|
Future<AppLinkResolutionResult> resolvePendingAppLink(
|
||||||
|
int requestId,
|
||||||
|
AppLinkDecision decision,
|
||||||
|
) {
|
||||||
|
return _api.resolvePendingAppLink(requestId, decision);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
-10
@@ -199,16 +199,6 @@ class GeckoEngineSettingsService {
|
|||||||
return _api.setPullToRefreshEnabled(enabled);
|
return _api.setPullToRefreshEnabled(enabled);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Sets the app links mode preference.
|
|
||||||
/// Controls how external app links are handled in browser.
|
|
||||||
Future<void> setAppLinksMode(AppLinksMode mode) {
|
|
||||||
return _api.setAppLinksMode(mode);
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<AppLinksMode> getAppLinksMode() {
|
|
||||||
return _api.getAppLinksMode();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Sets whether to use external download managers for downloads.
|
/// Sets whether to use external download managers for downloads.
|
||||||
/// When enabled, downloads are forwarded to third-party apps like ADM, 1DM, AB DM.
|
/// When enabled, downloads are forwarded to third-party apps like ADM, 1DM, AB DM.
|
||||||
Future<void> setUseExternalDownloadManager(bool enabled) {
|
Future<void> setUseExternalDownloadManager(bool enabled) {
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1557,12 +1557,6 @@ abstract class GeckoEngineSettingsApi {
|
|||||||
void setScreenshotProtectionEnabled(bool enabled);
|
void setScreenshotProtectionEnabled(bool enabled);
|
||||||
void setPullToRefreshEnabled(bool enabled);
|
void setPullToRefreshEnabled(bool enabled);
|
||||||
|
|
||||||
/// Sets the app links mode preference (stored in SharedPreferences).
|
|
||||||
/// Controls how external app links are handled in the browser.
|
|
||||||
void setAppLinksMode(AppLinksMode mode);
|
|
||||||
|
|
||||||
AppLinksMode getAppLinksMode();
|
|
||||||
|
|
||||||
/// Sets whether to use external download managers for downloads.
|
/// Sets whether to use external download managers for downloads.
|
||||||
/// When enabled, downloads are forwarded to third-party apps like ADM, 1DM, AB DM.
|
/// When enabled, downloads are forwarded to third-party apps like ADM, 1DM, AB DM.
|
||||||
void setUseExternalDownloadManager(bool enabled);
|
void setUseExternalDownloadManager(bool enabled);
|
||||||
@@ -2800,31 +2794,239 @@ abstract class GeckoTrackingProtectionApi {
|
|||||||
// App Links API
|
// App Links API
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
||||||
|
/// Resolved external-app target for a URL (see APP_LINKS_OWN_IMPLEMENTATION_PLAN.md §2.8).
|
||||||
|
class AppLinkTarget {
|
||||||
|
/// The URL that was resolved.
|
||||||
|
final String url;
|
||||||
|
|
||||||
|
/// User-facing app label (control/bidi-sanitised), or null when unknown.
|
||||||
|
final String? appName;
|
||||||
|
|
||||||
|
/// Resolved package name, or null when ambiguous / unknown.
|
||||||
|
final String? packageName;
|
||||||
|
|
||||||
|
/// Pre-validated http(s) fallback URL, or null.
|
||||||
|
final String? fallbackUrl;
|
||||||
|
|
||||||
|
/// True when the only offer is a marketplace (install-app) intent.
|
||||||
|
final bool isMarketplace;
|
||||||
|
|
||||||
|
/// True when resolution is ambiguous (chooser / multiple handlers / no default).
|
||||||
|
final bool isAmbiguous;
|
||||||
|
|
||||||
|
/// True when the Gecko engine can load the URL scheme itself.
|
||||||
|
final bool engineSupportsScheme;
|
||||||
|
|
||||||
|
/// Canonical native-owned rule scope key ("host:youtube.com" | "pkg:...").
|
||||||
|
final String scopeKey;
|
||||||
|
|
||||||
|
const AppLinkTarget({
|
||||||
|
required this.url,
|
||||||
|
this.appName,
|
||||||
|
this.packageName,
|
||||||
|
this.fallbackUrl,
|
||||||
|
required this.isMarketplace,
|
||||||
|
required this.isAmbiguous,
|
||||||
|
required this.engineSupportsScheme,
|
||||||
|
required this.scopeKey,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Target-side protection pattern replicated to native (§2.3/§2.8). Any target
|
||||||
|
/// assigned to an effectively-proxied or strict container is protected
|
||||||
|
/// independent of the source tab.
|
||||||
|
class ProtectedTargetPattern {
|
||||||
|
final String scheme;
|
||||||
|
final String hostOrSuffix;
|
||||||
|
final bool includeSubdomains;
|
||||||
|
|
||||||
|
/// Effective port for exact entries; null for wildcard entries (ignore port).
|
||||||
|
final int? port;
|
||||||
|
|
||||||
|
const ProtectedTargetPattern({
|
||||||
|
required this.scheme,
|
||||||
|
required this.hostOrSuffix,
|
||||||
|
required this.includeSubdomains,
|
||||||
|
this.port,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
enum NativeAppLinkRuleDecision { alwaysOpen, neverOpen }
|
||||||
|
|
||||||
|
/// A remembered per-scope rule replicated to native (§2.8). Distinct from the
|
||||||
|
/// Dart-persisted `PersistedAppLinkRule`; explicit mappers bridge the two.
|
||||||
|
class NativeAppLinkRule {
|
||||||
|
final NativeAppLinkRuleDecision decision;
|
||||||
|
final String scope;
|
||||||
|
final String? packageName;
|
||||||
|
|
||||||
|
const NativeAppLinkRule({
|
||||||
|
required this.decision,
|
||||||
|
required this.scope,
|
||||||
|
this.packageName,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A container's self-contained app-link policy override (§ container isolation).
|
||||||
|
/// Present only for containers with "isolated app link settings" enabled; when a
|
||||||
|
/// navigation's source contextId has an entry here, it fully *replaces* the
|
||||||
|
/// global mode + rules for that navigation (no layering with the global policy).
|
||||||
|
class NativeContextAppLinkPolicy {
|
||||||
|
final AppLinksMode mode;
|
||||||
|
|
||||||
|
/// The container's own remembered rules keyed by canonical scope.
|
||||||
|
final Map<String, NativeAppLinkRule> rules;
|
||||||
|
|
||||||
|
const NativeContextAppLinkPolicy({required this.mode, required this.rules});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Complete, last-write-wins policy snapshot pushed from the single Dart writer
|
||||||
|
/// to native (§2.8). Native persists it to the profile-scoped prefs record
|
||||||
|
/// before swapping the in-memory reference.
|
||||||
|
class AppLinkPolicySnapshot {
|
||||||
|
final AppLinksMode globalMode;
|
||||||
|
|
||||||
|
/// Remembered rules keyed by canonical scope.
|
||||||
|
final Map<String, NativeAppLinkRule> rules;
|
||||||
|
|
||||||
|
final bool marketplaceFallbackEnabled;
|
||||||
|
|
||||||
|
/// Regular / no-contextId tabs are proxied via the `general` scope.
|
||||||
|
final bool protectGeneralContext;
|
||||||
|
|
||||||
|
/// contextIds that resolve to a proxy after inherit/bypass/alias.
|
||||||
|
final List<String> protectedContextIds;
|
||||||
|
|
||||||
|
/// strictMode containers, independent of routing.
|
||||||
|
final List<String> strictContextIds;
|
||||||
|
|
||||||
|
final List<ProtectedTargetPattern> protectedTargetPatterns;
|
||||||
|
|
||||||
|
/// Per-container app-link policy overrides keyed by contextId. Only isolated
|
||||||
|
/// containers appear here; a navigation whose source contextId is a key uses
|
||||||
|
/// the entry's mode + rules in place of the global ones (replace semantics).
|
||||||
|
final Map<String, NativeContextAppLinkPolicy> contextOverrides;
|
||||||
|
|
||||||
|
const AppLinkPolicySnapshot({
|
||||||
|
required this.globalMode,
|
||||||
|
required this.rules,
|
||||||
|
required this.marketplaceFallbackEnabled,
|
||||||
|
required this.protectGeneralContext,
|
||||||
|
required this.protectedContextIds,
|
||||||
|
required this.strictContextIds,
|
||||||
|
required this.protectedTargetPatterns,
|
||||||
|
required this.contextOverrides,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Which surface owns a pending prompt (§2.6). Fixed at creation, never transfers.
|
||||||
|
enum AppLinkPromptOwner { flutterBrowser, nativeExternal }
|
||||||
|
|
||||||
|
/// A pending app-link prompt request held in the native `PendingAppLinkStore`
|
||||||
|
/// until resolved, invalidated, or expired (§2.6/§2.8). Holds only stable
|
||||||
|
/// identifiers and sanitised data — never engine/store references.
|
||||||
|
class AppLinkPromptRequest {
|
||||||
|
/// Monotonic per-process id (Kotlin Long).
|
||||||
|
final int requestId;
|
||||||
|
final AppLinkPromptOwner owner;
|
||||||
|
final String tabId;
|
||||||
|
final String? contextId;
|
||||||
|
final String? sourceUrl;
|
||||||
|
final bool isPrivate;
|
||||||
|
final bool isWallet;
|
||||||
|
final bool isProtectedContext;
|
||||||
|
final bool canRemember;
|
||||||
|
|
||||||
|
/// false for the http(s) banner class (non-modal); true for the modal
|
||||||
|
/// unsupported-scheme prompt.
|
||||||
|
final bool isModal;
|
||||||
|
final AppLinkTarget target;
|
||||||
|
|
||||||
|
const AppLinkPromptRequest({
|
||||||
|
required this.requestId,
|
||||||
|
required this.owner,
|
||||||
|
required this.tabId,
|
||||||
|
this.contextId,
|
||||||
|
this.sourceUrl,
|
||||||
|
required this.isPrivate,
|
||||||
|
required this.isWallet,
|
||||||
|
required this.isProtectedContext,
|
||||||
|
required this.canRemember,
|
||||||
|
required this.isModal,
|
||||||
|
required this.target,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// User decision on a pending prompt (§2.6).
|
||||||
|
enum AppLinkDecision { open, cancel, dismiss }
|
||||||
|
|
||||||
|
/// Result of resolving a pending prompt (§2.8).
|
||||||
|
class AppLinkResolutionResult {
|
||||||
|
final bool launched;
|
||||||
|
final bool loadedFallback;
|
||||||
|
|
||||||
|
/// "stale" | "dead_session" | "launch_failed" | null.
|
||||||
|
final String? failureReason;
|
||||||
|
|
||||||
|
const AppLinkResolutionResult({
|
||||||
|
required this.launched,
|
||||||
|
required this.loadedFallback,
|
||||||
|
this.failureReason,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/// API for detecting and launching external applications that can handle URLs.
|
/// API for detecting and launching external applications that can handle URLs.
|
||||||
///
|
///
|
||||||
/// This API wraps Mozilla Android Components' AppLinksUseCases to allow Flutter
|
/// WebLibre-owned resolution/launch surface (replaces the Mozilla AC use-case
|
||||||
/// code to check if native apps can handle URLs and launch them directly.
|
/// wrappers). Policy lives in Dart; this surface owns PackageManager resolution
|
||||||
|
/// and Intent launch.
|
||||||
@HostApi()
|
@HostApi()
|
||||||
abstract class GeckoAppLinksApi {
|
abstract class GeckoAppLinksApi {
|
||||||
/// Checks if an external application is available to handle the given URL.
|
/// Push the complete policy snapshot to native (last-write-wins). Native
|
||||||
///
|
/// persists it durably to the active profile's prefs record before acking.
|
||||||
/// This method uses mozilla-components AppLinksUseCases to determine if
|
|
||||||
/// a native app can handle the URL (e.g., YouTube app for youtube.com links).
|
|
||||||
///
|
|
||||||
/// Returns true if an external app is available, false otherwise.
|
|
||||||
@async
|
@async
|
||||||
bool hasExternalApp(String url);
|
void setAppLinkPolicy(AppLinkPolicySnapshot snapshot);
|
||||||
|
|
||||||
/// Opens the URL in an external application if available.
|
/// Non-consuming query of pending prompts for [owner] (§2.6). Surfaces call
|
||||||
///
|
/// this on attach/resume/rotation and when the availability event fires, and
|
||||||
/// This method will:
|
/// render idempotently by requestId.
|
||||||
/// 1. Check if an external app can handle the URL
|
|
||||||
/// 2. If available, launch the app directly with Intent.FLAG_ACTIVITY_NEW_TASK
|
|
||||||
/// 3. Return true if successfully launched, false otherwise
|
|
||||||
///
|
|
||||||
/// Returns true if URL was opened in external app, false if no app available.
|
|
||||||
@async
|
@async
|
||||||
bool openAppLink(String url);
|
List<AppLinkPromptRequest> getPendingAppLinkPrompts(AppLinkPromptOwner owner);
|
||||||
|
|
||||||
|
/// Atomically resolve a pending prompt: validate it still exists and its tab
|
||||||
|
/// is alive, consume it (double-resolve is a no-op), then perform side effects
|
||||||
|
/// after releasing the store lock (§2.6).
|
||||||
|
@async
|
||||||
|
AppLinkResolutionResult resolvePendingAppLink(int requestId, AppLinkDecision decision);
|
||||||
|
|
||||||
|
/// Resolve [url] to an external-app target.
|
||||||
|
///
|
||||||
|
/// Returns null when no external app is available, on any resolution error, or
|
||||||
|
/// for always-denied schemes — callers cannot distinguish "nothing installed"
|
||||||
|
/// from "resolution failed", matching the previous `hasExternalApp` contract.
|
||||||
|
///
|
||||||
|
/// [includeHttpAppLinks] when true, an app resolving an engine-supported
|
||||||
|
/// (http(s)) URL is surfaced (e.g. the YouTube app for a youtube.com link).
|
||||||
|
@async
|
||||||
|
AppLinkTarget? resolveAppLink(String url, bool includeHttpAppLinks);
|
||||||
|
|
||||||
|
/// Re-resolve [url] and launch it in an external app.
|
||||||
|
///
|
||||||
|
/// Re-resolves internally immediately before launch and returns false on
|
||||||
|
/// no-app or ActivityNotFoundException/SecurityException; never throws across
|
||||||
|
/// the channel for expected conditions.
|
||||||
|
@async
|
||||||
|
bool launchAppLink(String url);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Optimisation-only availability signal for pending app-link prompts (§2.8).
|
||||||
|
///
|
||||||
|
/// A Pigeon `@FlutterApi()` callback has no buffering or replay: an event
|
||||||
|
/// emitted while Flutter is detached is lost. The `PendingAppLinkStore` is the
|
||||||
|
/// source of truth; surfaces query on attach/resume and dedupe by requestId.
|
||||||
|
@FlutterApi()
|
||||||
|
abstract class GeckoAppLinkEvents {
|
||||||
|
void onAppLinkPromptAvailable(int sequence, AppLinkPromptOwner owner);
|
||||||
}
|
}
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|||||||
+25
-1
@@ -1,4 +1,4 @@
|
|||||||
// Autogenerated from Pigeon (v26.3.4), do not edit directly.
|
// Autogenerated from Pigeon (v27.1.0), do not edit directly.
|
||||||
// See also: https://pub.dev/packages/pigeon
|
// See also: https://pub.dev/packages/pigeon
|
||||||
@file:Suppress("UNCHECKED_CAST", "ArrayInDataClass")
|
@file:Suppress("UNCHECKED_CAST", "ArrayInDataClass")
|
||||||
|
|
||||||
@@ -289,6 +289,9 @@ data class SingboxProxyProfile (
|
|||||||
result = 31 * result + SingboxProxyApiPigeonUtils.deepHash(this.secretJson)
|
result = 31 * result + SingboxProxyApiPigeonUtils.deepHash(this.secretJson)
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
override fun toString(): String {
|
||||||
|
return "SingboxProxyProfile(id=$id, name=$name, type=$type, configJson=$configJson, secretJson=$secretJson)"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Generated class from Pigeon that represents data sent in messages. */
|
/** Generated class from Pigeon that represents data sent in messages. */
|
||||||
@@ -352,6 +355,9 @@ data class SingboxProxyRuntimeOptions (
|
|||||||
result = 31 * result + SingboxProxyApiPigeonUtils.deepHash(this.bootstrapDohUrl)
|
result = 31 * result + SingboxProxyApiPigeonUtils.deepHash(this.bootstrapDohUrl)
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
override fun toString(): String {
|
||||||
|
return "SingboxProxyRuntimeOptions(preferredBasePort=$preferredBasePort, blockUnmatchedTraffic=$blockUnmatchedTraffic, dnsConfig=$dnsConfig, bootstrapDohUrl=$bootstrapDohUrl)"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Generated class from Pigeon that represents data sent in messages. */
|
/** Generated class from Pigeon that represents data sent in messages. */
|
||||||
@@ -440,6 +446,9 @@ data class SingboxProxyDnsServerConfig (
|
|||||||
result = 31 * result + SingboxProxyApiPigeonUtils.deepHash(this.matchInbounds)
|
result = 31 * result + SingboxProxyApiPigeonUtils.deepHash(this.matchInbounds)
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
override fun toString(): String {
|
||||||
|
return "SingboxProxyDnsServerConfig(tag=$tag, address=$address, detourTag=$detourTag, matchDomainSuffixes=$matchDomainSuffixes, matchGeosites=$matchGeosites, matchOutbounds=$matchOutbounds, matchInbounds=$matchInbounds)"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Generated class from Pigeon that represents data sent in messages. */
|
/** Generated class from Pigeon that represents data sent in messages. */
|
||||||
@@ -487,6 +496,9 @@ data class SingboxProxyDnsConfig (
|
|||||||
result = 31 * result + SingboxProxyApiPigeonUtils.deepHash(this.domainStrategy)
|
result = 31 * result + SingboxProxyApiPigeonUtils.deepHash(this.domainStrategy)
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
override fun toString(): String {
|
||||||
|
return "SingboxProxyDnsConfig(servers=$servers, finalServerTag=$finalServerTag, domainStrategy=$domainStrategy)"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Generated class from Pigeon that represents data sent in messages. */
|
/** Generated class from Pigeon that represents data sent in messages. */
|
||||||
@@ -537,6 +549,9 @@ data class SingboxProxyRuntimeEndpoint (
|
|||||||
result = 31 * result + SingboxProxyApiPigeonUtils.deepHash(this.password)
|
result = 31 * result + SingboxProxyApiPigeonUtils.deepHash(this.password)
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
override fun toString(): String {
|
||||||
|
return "SingboxProxyRuntimeEndpoint(profileId=$profileId, host=$host, port=$port, username=$username, password=$password)"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Generated class from Pigeon that represents data sent in messages. */
|
/** Generated class from Pigeon that represents data sent in messages. */
|
||||||
@@ -579,6 +594,9 @@ data class SingboxProxyRuntimeState (
|
|||||||
result = 31 * result + SingboxProxyApiPigeonUtils.deepHash(this.message)
|
result = 31 * result + SingboxProxyApiPigeonUtils.deepHash(this.message)
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
override fun toString(): String {
|
||||||
|
return "SingboxProxyRuntimeState(status=$status, endpoints=$endpoints, message=$message)"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Generated class from Pigeon that represents data sent in messages. */
|
/** Generated class from Pigeon that represents data sent in messages. */
|
||||||
@@ -617,6 +635,9 @@ data class SingboxProxyConfigResult (
|
|||||||
result = 31 * result + SingboxProxyApiPigeonUtils.deepHash(this.endpoints)
|
result = 31 * result + SingboxProxyApiPigeonUtils.deepHash(this.endpoints)
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
override fun toString(): String {
|
||||||
|
return "SingboxProxyConfigResult(configJson=$configJson, endpoints=$endpoints)"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Generated class from Pigeon that represents data sent in messages. */
|
/** Generated class from Pigeon that represents data sent in messages. */
|
||||||
@@ -663,6 +684,9 @@ data class SingboxProxyLogMessage (
|
|||||||
result = 31 * result + SingboxProxyApiPigeonUtils.deepHash(this.profileId)
|
result = 31 * result + SingboxProxyApiPigeonUtils.deepHash(this.profileId)
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
override fun toString(): String {
|
||||||
|
return "SingboxProxyLogMessage(level=$level, message=$message, timestamp=$timestamp, profileId=$profileId)"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
private open class SingboxProxyApiPigeonCodec : StandardMessageCodec() {
|
private open class SingboxProxyApiPigeonCodec : StandardMessageCodec() {
|
||||||
override fun readValueOfType(type: Byte, buffer: ByteBuffer): Any? {
|
override fun readValueOfType(type: Byte, buffer: ByteBuffer): Any? {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
// Autogenerated from Pigeon (v26.3.4), do not edit directly.
|
// Autogenerated from Pigeon (v27.1.0), do not edit directly.
|
||||||
// See also: https://pub.dev/packages/pigeon
|
// See also: https://pub.dev/packages/pigeon
|
||||||
// ignore_for_file: unused_import, unused_shown_name
|
// ignore_for_file: unused_import, unused_shown_name
|
||||||
// ignore_for_file: type=lint
|
// ignore_for_file: type=lint
|
||||||
@@ -195,6 +195,11 @@ class SingboxProxyProfile {
|
|||||||
@override
|
@override
|
||||||
// ignore: avoid_equals_and_hash_code_on_mutable_classes
|
// ignore: avoid_equals_and_hash_code_on_mutable_classes
|
||||||
int get hashCode => _deepHash(<Object?>[runtimeType, ..._toList()]);
|
int get hashCode => _deepHash(<Object?>[runtimeType, ..._toList()]);
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() {
|
||||||
|
return 'SingboxProxyProfile(id: $id, name: $name, type: $type, configJson: $configJson, secretJson: $secretJson)';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class SingboxProxyRuntimeOptions {
|
class SingboxProxyRuntimeOptions {
|
||||||
@@ -261,6 +266,11 @@ class SingboxProxyRuntimeOptions {
|
|||||||
@override
|
@override
|
||||||
// ignore: avoid_equals_and_hash_code_on_mutable_classes
|
// ignore: avoid_equals_and_hash_code_on_mutable_classes
|
||||||
int get hashCode => _deepHash(<Object?>[runtimeType, ..._toList()]);
|
int get hashCode => _deepHash(<Object?>[runtimeType, ..._toList()]);
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() {
|
||||||
|
return 'SingboxProxyRuntimeOptions(preferredBasePort: $preferredBasePort, blockUnmatchedTraffic: $blockUnmatchedTraffic, dnsConfig: $dnsConfig, bootstrapDohUrl: $bootstrapDohUrl)';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class SingboxProxyDnsServerConfig {
|
class SingboxProxyDnsServerConfig {
|
||||||
@@ -351,6 +361,11 @@ class SingboxProxyDnsServerConfig {
|
|||||||
@override
|
@override
|
||||||
// ignore: avoid_equals_and_hash_code_on_mutable_classes
|
// ignore: avoid_equals_and_hash_code_on_mutable_classes
|
||||||
int get hashCode => _deepHash(<Object?>[runtimeType, ..._toList()]);
|
int get hashCode => _deepHash(<Object?>[runtimeType, ..._toList()]);
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() {
|
||||||
|
return 'SingboxProxyDnsServerConfig(tag: $tag, address: $address, detourTag: $detourTag, matchDomainSuffixes: $matchDomainSuffixes, matchGeosites: $matchGeosites, matchOutbounds: $matchOutbounds, matchInbounds: $matchInbounds)';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class SingboxProxyDnsConfig {
|
class SingboxProxyDnsConfig {
|
||||||
@@ -404,6 +419,11 @@ class SingboxProxyDnsConfig {
|
|||||||
@override
|
@override
|
||||||
// ignore: avoid_equals_and_hash_code_on_mutable_classes
|
// ignore: avoid_equals_and_hash_code_on_mutable_classes
|
||||||
int get hashCode => _deepHash(<Object?>[runtimeType, ..._toList()]);
|
int get hashCode => _deepHash(<Object?>[runtimeType, ..._toList()]);
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() {
|
||||||
|
return 'SingboxProxyDnsConfig(servers: $servers, finalServerTag: $finalServerTag, domainStrategy: $domainStrategy)';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class SingboxProxyRuntimeEndpoint {
|
class SingboxProxyRuntimeEndpoint {
|
||||||
@@ -464,6 +484,11 @@ class SingboxProxyRuntimeEndpoint {
|
|||||||
@override
|
@override
|
||||||
// ignore: avoid_equals_and_hash_code_on_mutable_classes
|
// ignore: avoid_equals_and_hash_code_on_mutable_classes
|
||||||
int get hashCode => _deepHash(<Object?>[runtimeType, ..._toList()]);
|
int get hashCode => _deepHash(<Object?>[runtimeType, ..._toList()]);
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() {
|
||||||
|
return 'SingboxProxyRuntimeEndpoint(profileId: $profileId, host: $host, port: $port, username: $username, password: $password)';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class SingboxProxyRuntimeState {
|
class SingboxProxyRuntimeState {
|
||||||
@@ -514,6 +539,11 @@ class SingboxProxyRuntimeState {
|
|||||||
@override
|
@override
|
||||||
// ignore: avoid_equals_and_hash_code_on_mutable_classes
|
// ignore: avoid_equals_and_hash_code_on_mutable_classes
|
||||||
int get hashCode => _deepHash(<Object?>[runtimeType, ..._toList()]);
|
int get hashCode => _deepHash(<Object?>[runtimeType, ..._toList()]);
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() {
|
||||||
|
return 'SingboxProxyRuntimeState(status: $status, endpoints: $endpoints, message: $message)';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class SingboxProxyConfigResult {
|
class SingboxProxyConfigResult {
|
||||||
@@ -559,6 +589,11 @@ class SingboxProxyConfigResult {
|
|||||||
@override
|
@override
|
||||||
// ignore: avoid_equals_and_hash_code_on_mutable_classes
|
// ignore: avoid_equals_and_hash_code_on_mutable_classes
|
||||||
int get hashCode => _deepHash(<Object?>[runtimeType, ..._toList()]);
|
int get hashCode => _deepHash(<Object?>[runtimeType, ..._toList()]);
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() {
|
||||||
|
return 'SingboxProxyConfigResult(configJson: $configJson, endpoints: $endpoints)';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class SingboxProxyLogMessage {
|
class SingboxProxyLogMessage {
|
||||||
@@ -614,6 +649,11 @@ class SingboxProxyLogMessage {
|
|||||||
@override
|
@override
|
||||||
// ignore: avoid_equals_and_hash_code_on_mutable_classes
|
// ignore: avoid_equals_and_hash_code_on_mutable_classes
|
||||||
int get hashCode => _deepHash(<Object?>[runtimeType, ..._toList()]);
|
int get hashCode => _deepHash(<Object?>[runtimeType, ..._toList()]);
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() {
|
||||||
|
return 'SingboxProxyLogMessage(level: $level, message: $message, timestamp: $timestamp, profileId: $profileId)';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -691,8 +731,8 @@ class _PigeonCodec extends StandardMessageCodec {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class SingboxProxyApi {
|
class SingboxProxyApi {
|
||||||
/// Constructor for [SingboxProxyApi]. The [binaryMessenger] named argument is
|
/// Constructor for [SingboxProxyApi]. The [binaryMessenger] named argument is
|
||||||
/// available for dependency injection. If it is left null, the default
|
/// available for dependency injection. If it is left null, the default
|
||||||
/// BinaryMessenger will be used which routes to the host platform.
|
/// BinaryMessenger will be used which routes to the host platform.
|
||||||
SingboxProxyApi({BinaryMessenger? binaryMessenger, String messageChannelSuffix = ''})
|
SingboxProxyApi({BinaryMessenger? binaryMessenger, String messageChannelSuffix = ''})
|
||||||
: pigeonVar_binaryMessenger = binaryMessenger,
|
: pigeonVar_binaryMessenger = binaryMessenger,
|
||||||
|
|||||||
+10
-1
@@ -1,4 +1,4 @@
|
|||||||
// Autogenerated from Pigeon (v26.3.4), do not edit directly.
|
// Autogenerated from Pigeon (v27.1.0), do not edit directly.
|
||||||
// See also: https://pub.dev/packages/pigeon
|
// See also: https://pub.dev/packages/pigeon
|
||||||
@file:Suppress("UNCHECKED_CAST", "ArrayInDataClass")
|
@file:Suppress("UNCHECKED_CAST", "ArrayInDataClass")
|
||||||
|
|
||||||
@@ -278,6 +278,9 @@ data class TorConfiguration (
|
|||||||
result = 31 * result + TorApiPigeonUtils.deepHash(this.strictNodes)
|
result = 31 * result + TorApiPigeonUtils.deepHash(this.strictNodes)
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
override fun toString(): String {
|
||||||
|
return "TorConfiguration(transport=$transport, bridgeLines=$bridgeLines, entryNodeCountries=$entryNodeCountries, exitNodeCountries=$exitNodeCountries, strictNodes=$strictNodes)"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -337,6 +340,9 @@ data class TorStatus (
|
|||||||
result = 31 * result + TorApiPigeonUtils.deepHash(this.exitNodeCountry)
|
result = 31 * result + TorApiPigeonUtils.deepHash(this.exitNodeCountry)
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
override fun toString(): String {
|
||||||
|
return "TorStatus(isRunning=$isRunning, socksPort=$socksPort, bootstrapProgress=$bootstrapProgress, currentCircuit=$currentCircuit, exitNodeCountry=$exitNodeCountry)"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -386,6 +392,9 @@ data class TorLogMessage (
|
|||||||
result = 31 * result + TorApiPigeonUtils.deepHash(this.timestamp)
|
result = 31 * result + TorApiPigeonUtils.deepHash(this.timestamp)
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
override fun toString(): String {
|
||||||
|
return "TorLogMessage(severity=$severity, message=$message, timestamp=$timestamp)"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
private open class TorApiPigeonCodec : StandardMessageCodec() {
|
private open class TorApiPigeonCodec : StandardMessageCodec() {
|
||||||
override fun readValueOfType(type: Byte, buffer: ByteBuffer): Any? {
|
override fun readValueOfType(type: Byte, buffer: ByteBuffer): Any? {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
// Autogenerated from Pigeon (v26.3.4), do not edit directly.
|
// Autogenerated from Pigeon (v27.1.0), do not edit directly.
|
||||||
// See also: https://pub.dev/packages/pigeon
|
// See also: https://pub.dev/packages/pigeon
|
||||||
// ignore_for_file: unused_import, unused_shown_name
|
// ignore_for_file: unused_import, unused_shown_name
|
||||||
// ignore_for_file: type=lint
|
// ignore_for_file: type=lint
|
||||||
@@ -191,6 +191,11 @@ class TorConfiguration {
|
|||||||
@override
|
@override
|
||||||
// ignore: avoid_equals_and_hash_code_on_mutable_classes
|
// ignore: avoid_equals_and_hash_code_on_mutable_classes
|
||||||
int get hashCode => _deepHash(<Object?>[runtimeType, ..._toList()]);
|
int get hashCode => _deepHash(<Object?>[runtimeType, ..._toList()]);
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() {
|
||||||
|
return 'TorConfiguration(transport: $transport, bridgeLines: $bridgeLines, entryNodeCountries: $entryNodeCountries, exitNodeCountries: $exitNodeCountries, strictNodes: $strictNodes)';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Current Tor status
|
/// Current Tor status
|
||||||
@@ -257,6 +262,11 @@ class TorStatus {
|
|||||||
@override
|
@override
|
||||||
// ignore: avoid_equals_and_hash_code_on_mutable_classes
|
// ignore: avoid_equals_and_hash_code_on_mutable_classes
|
||||||
int get hashCode => _deepHash(<Object?>[runtimeType, ..._toList()]);
|
int get hashCode => _deepHash(<Object?>[runtimeType, ..._toList()]);
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() {
|
||||||
|
return 'TorStatus(isRunning: $isRunning, socksPort: $socksPort, bootstrapProgress: $bootstrapProgress, currentCircuit: $currentCircuit, exitNodeCountry: $exitNodeCountry)';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Log message from Tor
|
/// Log message from Tor
|
||||||
@@ -311,6 +321,11 @@ class TorLogMessage {
|
|||||||
@override
|
@override
|
||||||
// ignore: avoid_equals_and_hash_code_on_mutable_classes
|
// ignore: avoid_equals_and_hash_code_on_mutable_classes
|
||||||
int get hashCode => _deepHash(<Object?>[runtimeType, ..._toList()]);
|
int get hashCode => _deepHash(<Object?>[runtimeType, ..._toList()]);
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() {
|
||||||
|
return 'TorLogMessage(severity: $severity, message: $message, timestamp: $timestamp)';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -358,8 +373,8 @@ class _PigeonCodec extends StandardMessageCodec {
|
|||||||
|
|
||||||
/// Host API (Flutter -> Native)
|
/// Host API (Flutter -> Native)
|
||||||
class TorApi {
|
class TorApi {
|
||||||
/// Constructor for [TorApi]. The [binaryMessenger] named argument is
|
/// Constructor for [TorApi]. The [binaryMessenger] named argument is
|
||||||
/// available for dependency injection. If it is left null, the default
|
/// available for dependency injection. If it is left null, the default
|
||||||
/// BinaryMessenger will be used which routes to the host platform.
|
/// BinaryMessenger will be used which routes to the host platform.
|
||||||
TorApi({BinaryMessenger? binaryMessenger, String messageChannelSuffix = ''})
|
TorApi({BinaryMessenger? binaryMessenger, String messageChannelSuffix = ''})
|
||||||
: pigeonVar_binaryMessenger = binaryMessenger,
|
: pigeonVar_binaryMessenger = binaryMessenger,
|
||||||
@@ -508,8 +523,8 @@ abstract class TorLogApi {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class IPtProxyController {
|
class IPtProxyController {
|
||||||
/// Constructor for [IPtProxyController]. The [binaryMessenger] named argument is
|
/// Constructor for [IPtProxyController]. The [binaryMessenger] named argument is
|
||||||
/// available for dependency injection. If it is left null, the default
|
/// available for dependency injection. If it is left null, the default
|
||||||
/// BinaryMessenger will be used which routes to the host platform.
|
/// BinaryMessenger will be used which routes to the host platform.
|
||||||
IPtProxyController({BinaryMessenger? binaryMessenger, String messageChannelSuffix = ''})
|
IPtProxyController({BinaryMessenger? binaryMessenger, String messageChannelSuffix = ''})
|
||||||
: pigeonVar_binaryMessenger = binaryMessenger,
|
: pigeonVar_binaryMessenger = binaryMessenger,
|
||||||
|
|||||||
+4
-1
@@ -1,4 +1,4 @@
|
|||||||
// Autogenerated from Pigeon (v26.3.4), do not edit directly.
|
// Autogenerated from Pigeon (v27.1.0), do not edit directly.
|
||||||
// See also: https://pub.dev/packages/pigeon
|
// See also: https://pub.dev/packages/pigeon
|
||||||
@file:Suppress("UNCHECKED_CAST", "ArrayInDataClass")
|
@file:Suppress("UNCHECKED_CAST", "ArrayInDataClass")
|
||||||
|
|
||||||
@@ -228,6 +228,9 @@ data class LocalizedResult (
|
|||||||
result = 31 * result + LocalesPigeonUtils.deepHash(this.countryName)
|
result = 31 * result + LocalesPigeonUtils.deepHash(this.countryName)
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
override fun toString(): String {
|
||||||
|
return "LocalizedResult(languageName=$languageName, countryName=$countryName)"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
private open class LocalesPigeonCodec : StandardMessageCodec() {
|
private open class LocalesPigeonCodec : StandardMessageCodec() {
|
||||||
override fun readValueOfType(type: Byte, buffer: ByteBuffer): Any? {
|
override fun readValueOfType(type: Byte, buffer: ByteBuffer): Any? {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
// Autogenerated from Pigeon (v26.3.4), do not edit directly.
|
// Autogenerated from Pigeon (v27.1.0), do not edit directly.
|
||||||
// See also: https://pub.dev/packages/pigeon
|
// See also: https://pub.dev/packages/pigeon
|
||||||
// ignore_for_file: unused_import, unused_shown_name
|
// ignore_for_file: unused_import, unused_shown_name
|
||||||
// ignore_for_file: type=lint
|
// ignore_for_file: type=lint
|
||||||
@@ -140,6 +140,11 @@ class LocalizedResult {
|
|||||||
@override
|
@override
|
||||||
// ignore: avoid_equals_and_hash_code_on_mutable_classes
|
// ignore: avoid_equals_and_hash_code_on_mutable_classes
|
||||||
int get hashCode => _deepHash(<Object?>[runtimeType, ..._toList()]);
|
int get hashCode => _deepHash(<Object?>[runtimeType, ..._toList()]);
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() {
|
||||||
|
return 'LocalizedResult(languageName: $languageName, countryName: $countryName)';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -170,8 +175,8 @@ class _PigeonCodec extends StandardMessageCodec {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class LocaleResolver {
|
class LocaleResolver {
|
||||||
/// Constructor for [LocaleResolver]. The [binaryMessenger] named argument is
|
/// Constructor for [LocaleResolver]. The [binaryMessenger] named argument is
|
||||||
/// available for dependency injection. If it is left null, the default
|
/// available for dependency injection. If it is left null, the default
|
||||||
/// BinaryMessenger will be used which routes to the host platform.
|
/// BinaryMessenger will be used which routes to the host platform.
|
||||||
LocaleResolver({BinaryMessenger? binaryMessenger, String messageChannelSuffix = ''})
|
LocaleResolver({BinaryMessenger? binaryMessenger, String messageChannelSuffix = ''})
|
||||||
: pigeonVar_binaryMessenger = binaryMessenger,
|
: pigeonVar_binaryMessenger = binaryMessenger,
|
||||||
|
|||||||
+4
-1
@@ -1,4 +1,4 @@
|
|||||||
// Autogenerated from Pigeon (v26.3.4), do not edit directly.
|
// Autogenerated from Pigeon (v27.1.0), do not edit directly.
|
||||||
// See also: https://pub.dev/packages/pigeon
|
// See also: https://pub.dev/packages/pigeon
|
||||||
@file:Suppress("UNCHECKED_CAST", "ArrayInDataClass")
|
@file:Suppress("UNCHECKED_CAST", "ArrayInDataClass")
|
||||||
|
|
||||||
@@ -247,6 +247,9 @@ data class Intent (
|
|||||||
result = 31 * result + IntentPigeonUtils.deepHash(this.extra)
|
result = 31 * result + IntentPigeonUtils.deepHash(this.extra)
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
override fun toString(): String {
|
||||||
|
return "Intent(fromPackageName=$fromPackageName, action=$action, data=$data, categories=$categories, mimeType=$mimeType, extra=$extra)"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
private open class IntentPigeonCodec : StandardMessageCodec() {
|
private open class IntentPigeonCodec : StandardMessageCodec() {
|
||||||
override fun readValueOfType(type: Byte, buffer: ByteBuffer): Any? {
|
override fun readValueOfType(type: Byte, buffer: ByteBuffer): Any? {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
// Autogenerated from Pigeon (v26.3.4), do not edit directly.
|
// Autogenerated from Pigeon (v27.1.0), do not edit directly.
|
||||||
// See also: https://pub.dev/packages/pigeon
|
// See also: https://pub.dev/packages/pigeon
|
||||||
// ignore_for_file: unused_import, unused_shown_name
|
// ignore_for_file: unused_import, unused_shown_name
|
||||||
// ignore_for_file: type=lint
|
// ignore_for_file: type=lint
|
||||||
@@ -170,6 +170,11 @@ class Intent {
|
|||||||
@override
|
@override
|
||||||
// ignore: avoid_equals_and_hash_code_on_mutable_classes
|
// ignore: avoid_equals_and_hash_code_on_mutable_classes
|
||||||
int get hashCode => _deepHash(<Object?>[runtimeType, ..._toList()]);
|
int get hashCode => _deepHash(<Object?>[runtimeType, ..._toList()]);
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() {
|
||||||
|
return 'Intent(fromPackageName: $fromPackageName, action: $action, data: $data, categories: $categories, mimeType: $mimeType, extra: $extra)';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -200,8 +205,8 @@ class _PigeonCodec extends StandardMessageCodec {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class IntentHost {
|
class IntentHost {
|
||||||
/// Constructor for [IntentHost]. The [binaryMessenger] named argument is
|
/// Constructor for [IntentHost]. The [binaryMessenger] named argument is
|
||||||
/// available for dependency injection. If it is left null, the default
|
/// available for dependency injection. If it is left null, the default
|
||||||
/// BinaryMessenger will be used which routes to the host platform.
|
/// BinaryMessenger will be used which routes to the host platform.
|
||||||
IntentHost({BinaryMessenger? binaryMessenger, String messageChannelSuffix = ''})
|
IntentHost({BinaryMessenger? binaryMessenger, String messageChannelSuffix = ''})
|
||||||
: pigeonVar_binaryMessenger = binaryMessenger,
|
: pigeonVar_binaryMessenger = binaryMessenger,
|
||||||
@@ -269,8 +274,8 @@ abstract class IntentEvents {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class IntentGatekeeperHostApi {
|
class IntentGatekeeperHostApi {
|
||||||
/// Constructor for [IntentGatekeeperHostApi]. The [binaryMessenger] named argument is
|
/// Constructor for [IntentGatekeeperHostApi]. The [binaryMessenger] named argument is
|
||||||
/// available for dependency injection. If it is left null, the default
|
/// available for dependency injection. If it is left null, the default
|
||||||
/// BinaryMessenger will be used which routes to the host platform.
|
/// BinaryMessenger will be used which routes to the host platform.
|
||||||
IntentGatekeeperHostApi({BinaryMessenger? binaryMessenger, String messageChannelSuffix = ''})
|
IntentGatekeeperHostApi({BinaryMessenger? binaryMessenger, String messageChannelSuffix = ''})
|
||||||
: pigeonVar_binaryMessenger = binaryMessenger,
|
: pigeonVar_binaryMessenger = binaryMessenger,
|
||||||
|
|||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
// Autogenerated from Pigeon (v26.3.4), do not edit directly.
|
// Autogenerated from Pigeon (v27.1.0), do not edit directly.
|
||||||
// See also: https://pub.dev/packages/pigeon
|
// See also: https://pub.dev/packages/pigeon
|
||||||
@file:Suppress("UNCHECKED_CAST", "ArrayInDataClass")
|
@file:Suppress("UNCHECKED_CAST", "ArrayInDataClass")
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
// Autogenerated from Pigeon (v26.3.4), do not edit directly.
|
// Autogenerated from Pigeon (v27.1.0), do not edit directly.
|
||||||
// See also: https://pub.dev/packages/pigeon
|
// See also: https://pub.dev/packages/pigeon
|
||||||
// ignore_for_file: unused_import, unused_shown_name
|
// ignore_for_file: unused_import, unused_shown_name
|
||||||
// ignore_for_file: type=lint
|
// ignore_for_file: type=lint
|
||||||
@@ -69,8 +69,8 @@ class _PigeonCodec extends StandardMessageCodec {
|
|||||||
|
|
||||||
/// Host API - methods called from Flutter to native Android.
|
/// Host API - methods called from Flutter to native Android.
|
||||||
class SpeechToTextApi {
|
class SpeechToTextApi {
|
||||||
/// Constructor for [SpeechToTextApi]. The [binaryMessenger] named argument is
|
/// Constructor for [SpeechToTextApi]. The [binaryMessenger] named argument is
|
||||||
/// available for dependency injection. If it is left null, the default
|
/// available for dependency injection. If it is left null, the default
|
||||||
/// BinaryMessenger will be used which routes to the host platform.
|
/// BinaryMessenger will be used which routes to the host platform.
|
||||||
SpeechToTextApi({BinaryMessenger? binaryMessenger, String messageChannelSuffix = ''})
|
SpeechToTextApi({BinaryMessenger? binaryMessenger, String messageChannelSuffix = ''})
|
||||||
: pigeonVar_binaryMessenger = binaryMessenger,
|
: pigeonVar_binaryMessenger = binaryMessenger,
|
||||||
|
|||||||
Reference in New Issue
Block a user