app links initial

This commit is contained in:
Fabian Freund
2026-07-30 03:58:46 +02:00
parent 1b0c2b0d06
commit 4bc267969b
97 changed files with 9138 additions and 1054 deletions
@@ -0,0 +1,94 @@
/*
* Copyright (c) 2024-2026 Fabian Freund.
*
* This file is part of WebLibre
* (see https://weblibre.eu).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import 'package:flutter_mozilla_components/flutter_mozilla_components.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:weblibre/features/app_links/domain/entities/app_link_rule.dart';
import 'package:weblibre/features/app_links/presentation/widgets/app_link_prompt_dialog.dart';
AppLinkTarget _target({
String url = 'https://youtu.be/abc',
String? packageName = 'com.google.android.youtube',
bool isAmbiguous = false,
String scopeKey = 'host:youtu.be',
bool engineSupportsScheme = true,
}) {
return AppLinkTarget(
url: url,
appName: 'YouTube',
packageName: packageName,
fallbackUrl: null,
isMarketplace: false,
isAmbiguous: isAmbiguous,
engineSupportsScheme: engineSupportsScheme,
scopeKey: scopeKey,
);
}
void main() {
group('alwaysOpenRuleFor', () {
test('binds the resolved package to the target scope', () {
final rule = alwaysOpenRuleFor(_target());
expect(rule, isNotNull);
expect(rule!.decision, AppLinkRuleDecision.alwaysOpen);
expect(rule.scope, 'host:youtu.be');
expect(rule.packageName, 'com.google.android.youtube');
});
test('cannot be remembered for an ambiguous resolution', () {
expect(alwaysOpenRuleFor(_target(isAmbiguous: true)), isNull);
});
test('cannot be remembered without a bound package', () {
expect(alwaysOpenRuleFor(_target(packageName: null)), isNull);
expect(alwaysOpenRuleFor(_target(packageName: '')), isNull);
});
test('scopes a custom-scheme target by its package key', () {
final rule = alwaysOpenRuleFor(
_target(
url: 'zoommtg://zoom.us/join',
packageName: 'us.zoom.videomeetings',
scopeKey: 'pkg:us.zoom.videomeetings',
engineSupportsScheme: false,
),
);
expect(rule, isNotNull);
expect(rule!.scope, 'pkg:us.zoom.videomeetings');
expect(rule.packageName, 'us.zoom.videomeetings');
});
});
group('neverOpenRuleFor', () {
test('scopes to the target without binding a package', () {
final rule = neverOpenRuleFor(_target());
expect(rule.decision, AppLinkRuleDecision.neverOpen);
expect(rule.scope, 'host:youtu.be');
expect(rule.packageName, isNull);
});
test('is producible even for an ambiguous resolution', () {
// neverOpen never launches, so it does not need a bound package.
final rule = neverOpenRuleFor(_target(isAmbiguous: true, packageName: null));
expect(rule.decision, AppLinkRuleDecision.neverOpen);
expect(rule.isValid, isTrue);
});
});
}
@@ -0,0 +1,112 @@
/*
* Copyright (c) 2024-2026 Fabian Freund.
*
* This file is part of WebLibre
* (see https://weblibre.eu).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import 'package:flutter_test/flutter_test.dart';
import 'package:weblibre/features/app_links/domain/entities/app_link_rule.dart';
void main() {
group('PersistedAppLinkRule', () {
test('round-trips through json', () {
final rule = PersistedAppLinkRule(
decision: AppLinkRuleDecision.alwaysOpen,
scope: 'host:youtube.com',
packageName: 'com.google.android.youtube',
);
final restored = PersistedAppLinkRule.fromJson(rule.toJson());
expect(restored, rule);
});
test('validity requires a package for alwaysOpen and a known prefix', () {
expect(
PersistedAppLinkRule(
decision: AppLinkRuleDecision.alwaysOpen,
scope: 'host:x.com',
packageName: 'pkg',
).isValid,
isTrue,
);
expect(
PersistedAppLinkRule(
decision: AppLinkRuleDecision.alwaysOpen,
scope: 'host:x.com',
).isValid,
isFalse,
);
expect(
PersistedAppLinkRule(
decision: AppLinkRuleDecision.neverOpen,
scope: 'host:x.com',
).isValid,
isTrue,
);
expect(
PersistedAppLinkRule(
decision: AppLinkRuleDecision.neverOpen,
scope: 'notaprefix',
).isValid,
isFalse,
);
});
});
group('parseAppLinkRules', () {
test('keeps valid rules keyed by matching scope', () {
final parsed = parseAppLinkRules({
'host:youtube.com': {
'decision': 'alwaysOpen',
'scope': 'host:youtube.com',
'packageName': 'com.google.android.youtube',
},
'pkg:us.zoom.videomeetings': {
'decision': 'neverOpen',
'scope': 'pkg:us.zoom.videomeetings',
},
});
expect(parsed.length, 2);
expect(parsed['host:youtube.com']!.decision, AppLinkRuleDecision.alwaysOpen);
});
test('drops entries whose map key disagrees with the rule scope', () {
final parsed = parseAppLinkRules({
'host:wrong.com': {
'decision': 'neverOpen',
'scope': 'host:right.com',
},
});
expect(parsed, isEmpty);
});
test('drops malformed and invalid rules', () {
final parsed = parseAppLinkRules({
'host:a.com': {'decision': 'garbage', 'scope': 'host:a.com'},
'host:b.com': {
'decision': 'alwaysOpen',
'scope': 'host:b.com',
}, // missing package
'host:c.com': 'not a map',
});
expect(parsed, isEmpty);
});
test('null input yields an empty map', () {
expect(parseAppLinkRules(null), isEmpty);
});
});
}
@@ -0,0 +1,149 @@
/*
* Copyright (c) 2024-2026 Fabian Freund.
*
* This file is part of WebLibre
* (see https://weblibre.eu).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:weblibre/features/app_links/domain/services/effective_app_link_policy.dart';
import 'package:weblibre/features/geckoview/features/tabs/data/models/container_data.dart';
ContainerDataWithCount _container(
String id, {
String? contextId,
bool isolatedAppLinkSettings = false,
}) {
return ContainerDataWithCount(
id: id,
name: 'Container $id',
color: const Color(0xFF336699),
orderKey: 'a',
metadata: ContainerMetadata.withDefaults(
contextualIdentity: contextId,
isolatedAppLinkSettings: isolatedAppLinkSettings,
),
tabCount: 0,
);
}
void main() {
group('resolveAppLinkOverrideKey', () {
test('null contextId resolves to the global bucket', () {
final key = resolveAppLinkOverrideKey(
liveContextId: null,
containers: [
_container('1', contextId: 'ctx-1', isolatedAppLinkSettings: true),
],
isolationContextContainerMap: const {},
);
expect(key, isNull);
});
test('regular tab in a non-isolated container resolves globally', () {
final key = resolveAppLinkOverrideKey(
liveContextId: 'ctx-1',
containers: [_container('1', contextId: 'ctx-1')],
isolationContextContainerMap: const {},
);
expect(key, isNull);
});
test(
'regular tab in an isolated-app-link container resolves to its base',
() {
final key = resolveAppLinkOverrideKey(
liveContextId: 'ctx-1',
containers: [
_container('1', contextId: 'ctx-1', isolatedAppLinkSettings: true),
],
isolationContextContainerMap: const {},
);
expect(key, 'ctx-1');
},
);
test('isolated tab resolves via the isolation map', () {
final key = resolveAppLinkOverrideKey(
liveContextId: 'iso-1',
containers: [
_container('1', contextId: 'ctx-1', isolatedAppLinkSettings: true),
],
isolationContextContainerMap: const {
'iso-1': {'1'},
},
);
expect(key, 'ctx-1');
});
test(
'isolated tab of a non-isolated-app-link container resolves globally',
() {
final key = resolveAppLinkOverrideKey(
liveContextId: 'iso-1',
containers: [_container('1', contextId: 'ctx-1')],
isolationContextContainerMap: const {
'iso-1': {'1'},
},
);
expect(key, isNull);
},
);
test('shared isolation context picks the lowest sorted base contextId', () {
final key = resolveAppLinkOverrideKey(
liveContextId: 'iso-1',
containers: [
_container('1', contextId: 'ctx-b', isolatedAppLinkSettings: true),
_container('2', contextId: 'ctx-a', isolatedAppLinkSettings: true),
],
isolationContextContainerMap: const {
'iso-1': {'1', '2'},
},
);
expect(key, 'ctx-a');
});
test(
'shared isolation context skips containers without isolated settings',
() {
final key = resolveAppLinkOverrideKey(
liveContextId: 'iso-1',
containers: [
_container('1', contextId: 'ctx-a'),
_container('2', contextId: 'ctx-b', isolatedAppLinkSettings: true),
],
isolationContextContainerMap: const {
'iso-1': {'1', '2'},
},
);
expect(key, 'ctx-b');
},
);
test('unknown contextId resolves globally', () {
final key = resolveAppLinkOverrideKey(
liveContextId: 'ctx-unknown',
containers: [
_container('1', contextId: 'ctx-1', isolatedAppLinkSettings: true),
],
isolationContextContainerMap: const {},
);
expect(key, isNull);
});
});
}
@@ -0,0 +1,188 @@
/*
* Copyright (c) 2024-2026 Fabian Freund.
*
* This file is part of WebLibre
* (see https://weblibre.eu).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import 'package:flutter_test/flutter_test.dart';
import 'package:weblibre/features/app_links/domain/services/effective_routing.dart';
import 'package:weblibre/features/geckoview/features/tabs/data/models/site_assignment.dart';
import 'package:weblibre/features/proxy/data/proxy_connection.dart';
SiteAssignment _assignment(String site, {String? contextId}) => SiteAssignment(
id: site,
contextualIdentity: contextId,
assignedSite: site,
);
void main() {
group('resolveContainerAssignment', () {
test('explicit proxy connection wins', () {
final assignment = resolveContainerAssignment(
contextId: 'ctx',
proxyConnectionId: const TorProxyConnectionId(),
bypassGlobalProxy: false,
);
expect(assignment, isA<ExplicitProxyAssignment>());
});
test('bypassGlobalProxy with no proxy is direct scoped to the context', () {
final assignment = resolveContainerAssignment(
contextId: 'ctx',
proxyConnectionId: null,
bypassGlobalProxy: true,
);
expect(assignment, isA<DirectProxyAssignment>());
expect((assignment as DirectProxyAssignment).scopeId, 'ctx');
});
test('no proxy and no bypass inherits', () {
final assignment = resolveContainerAssignment(
contextId: 'ctx',
proxyConnectionId: null,
bypassGlobalProxy: false,
);
expect(assignment, isA<InheritProxyAssignment>());
});
});
group('resolveIsolationContextRouting', () {
test('any explicit proxy wins (lowest sorted id)', () {
final routing = resolveIsolationContextRouting([
ProxyAssignment.inherit(),
ProxyAssignment.explicit('zeta'),
ProxyAssignment.explicit('alpha'),
ProxyAssignment.direct('scope'),
]);
expect(routing.chosen, isA<ExplicitProxyAssignment>());
expect((routing.chosen as ExplicitProxyAssignment).proxyId, 'alpha');
expect(routing.distinctAssignmentCount, 4);
});
test('direct wins only when no container inherits', () {
final routing = resolveIsolationContextRouting([
ProxyAssignment.direct('scopeB'),
ProxyAssignment.direct('scopeA'),
]);
expect(routing.chosen, isA<DirectProxyAssignment>());
expect((routing.chosen as DirectProxyAssignment).scopeId, 'scopeA');
});
test('direct plus inherit collapses to inherit', () {
final routing = resolveIsolationContextRouting([
ProxyAssignment.direct('scope'),
ProxyAssignment.inherit(),
]);
expect(routing.chosen, isA<InheritProxyAssignment>());
expect(routing.distinctAssignmentCount, 2);
expect(routing.assignmentLabels, ['inherit', 'direct:scope']);
});
});
group('isAssignmentProtected', () {
test('explicit is always protected', () {
expect(
isAssignmentProtected(
ProxyAssignment.explicit('p'),
protectGeneralContext: false,
),
isTrue,
);
});
test('direct is never protected', () {
expect(
isAssignmentProtected(
ProxyAssignment.direct('s'),
protectGeneralContext: true,
),
isFalse,
);
});
test('inherit follows the general context', () {
expect(
isAssignmentProtected(
ProxyAssignment.inherit(),
protectGeneralContext: true,
),
isTrue,
);
expect(
isAssignmentProtected(
ProxyAssignment.inherit(),
protectGeneralContext: false,
),
isFalse,
);
});
});
group('protectedTargetPatternForSite', () {
test('wildcard entry includes subdomains and ignores port', () {
final pattern = protectedTargetPatternForSite(
Uri.parse('https://*.example.com'),
);
expect(pattern.scheme, 'https');
expect(pattern.hostOrSuffix, 'example.com');
expect(pattern.includeSubdomains, isTrue);
expect(pattern.port, isNull);
});
test('exact entry preserves effective port', () {
final defaultPort = protectedTargetPatternForSite(
Uri.parse('https://example.com'),
);
expect(defaultPort.hostOrSuffix, 'example.com');
expect(defaultPort.includeSubdomains, isFalse);
expect(defaultPort.port, 443);
final explicitPort = protectedTargetPatternForSite(
Uri.parse('http://example.com:8080'),
);
expect(explicitPort.port, 8080);
});
});
group('computeProtectedTargetPatterns', () {
test('keeps only assignments in a protected or strict container', () {
final patterns = computeProtectedTargetPatterns(
assignments: [
_assignment('https://proxied.example', contextId: 'proxied'),
_assignment('https://direct.example', contextId: 'direct'),
_assignment('https://strict.example', contextId: 'strict'),
_assignment('https://unassigned.example', contextId: null),
],
protectedOrStrictContextIds: {'proxied', 'strict'},
);
final hosts = patterns.map((p) => p.hostOrSuffix).toSet();
expect(hosts, {'proxied.example', 'strict.example'});
});
test('deduplicates identical patterns', () {
final patterns = computeProtectedTargetPatterns(
assignments: [
_assignment('https://dup.example', contextId: 'a'),
_assignment('https://dup.example', contextId: 'a'),
],
protectedOrStrictContextIds: {'a'},
);
expect(patterns.length, 1);
});
});
}
@@ -0,0 +1,114 @@
/*
* Copyright (c) 2024-2026 Fabian Freund.
*
* This file is part of WebLibre
* (see https://weblibre.eu).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import 'package:flutter_mozilla_components/flutter_mozilla_components.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:weblibre/features/app_links/domain/entities/app_link_rule.dart';
import 'package:weblibre/features/app_links/domain/entities/context_app_link_policy.dart';
import 'package:weblibre/features/user/data/models/general_settings.dart';
void main() {
group('GeneralSettings app-link fields', () {
test('defaults are ask / empty rules / marketplace off', () {
final settings = GeneralSettings.withDefaults();
expect(settings.appLinksMode, AppLinksMode.ask);
expect(settings.appLinkRules, isEmpty);
expect(settings.appLinkMarketplaceFallback, isFalse);
});
test('the three fields survive a toJson -> fromJson round-trip', () {
final rule = PersistedAppLinkRule(
decision: AppLinkRuleDecision.alwaysOpen,
scope: 'host:youtu.be',
packageName: 'com.google.android.youtube',
);
final settings = GeneralSettings.withDefaults(
appLinksMode: AppLinksMode.always,
appLinkRules: {rule.scope: rule},
appLinkMarketplaceFallback: true,
);
final restored = GeneralSettings.fromJson(settings.toJson());
expect(restored.appLinksMode, AppLinksMode.always);
expect(restored.appLinkMarketplaceFallback, isTrue);
expect(restored.appLinkRules.keys, ['host:youtu.be']);
expect(restored.appLinkRules['host:youtu.be'], rule);
});
test('malformed persisted rules are dropped on read (parseAppLinkRules)', () {
final json = GeneralSettings.withDefaults().toJson();
// A scope key that disagrees with the rule's own scope is invalid and dropped.
json['appLinkRules'] = {
'host:youtu.be': {
'decision': 'alwaysOpen',
'scope': 'host:evil.example',
'packageName': 'com.google.android.youtube',
},
};
final restored = GeneralSettings.fromJson(json);
expect(restored.appLinkRules, isEmpty);
});
});
group('GeneralSettings per-container app-link overrides', () {
test('defaults to an empty override map', () {
expect(GeneralSettings.withDefaults().appLinkContextOverrides, isEmpty);
});
test('a container override survives a toJson -> fromJson round-trip', () {
final rule = PersistedAppLinkRule(
decision: AppLinkRuleDecision.neverOpen,
scope: 'host:reddit.com',
);
final override = ContextAppLinkPolicy(
mode: AppLinksMode.never,
rules: {rule.scope: rule},
);
final settings = GeneralSettings.withDefaults(
appLinkContextOverrides: {'work': override},
);
final restored = GeneralSettings.fromJson(settings.toJson());
expect(restored.appLinkContextOverrides.keys, ['work']);
final restoredOverride = restored.appLinkContextOverrides['work']!;
expect(restoredOverride.mode, AppLinksMode.never);
expect(restoredOverride.rules['host:reddit.com'], rule);
});
test('the blank override is ask / empty rules', () {
final blank = ContextAppLinkPolicy.blank();
expect(blank.mode, AppLinksMode.ask);
expect(blank.rules, isEmpty);
});
test('malformed override entries are dropped on read', () {
final json = GeneralSettings.withDefaults().toJson();
json['appLinkContextOverrides'] = {
'work': <String, dynamic>{'mode': 'not-a-mode'},
};
final restored = GeneralSettings.fromJson(json);
expect(restored.appLinkContextOverrides, isEmpty);
});
});
}
@@ -35,6 +35,44 @@ void main() {
});
});
group('ContainerMetadata isolatedAppLinkSettings invariant', () {
test('stays enabled when the container has a contextId', () {
final metadata = ContainerMetadata.withDefaults(
contextualIdentity: 'work',
isolatedAppLinkSettings: true,
);
expect(metadata.isolatedAppLinkSettings, isTrue);
expect(metadata.sanitized().isolatedAppLinkSettings, isTrue);
});
test('is normalized off without a contextId (read + sanitized)', () {
final metadata = ContainerMetadata.withDefaults(
contextualIdentity: null,
isolatedAppLinkSettings: true,
);
// withDefaults normalizes on construction/read.
expect(metadata.isolatedAppLinkSettings, isFalse);
// A record that somehow carries the bad combination is re-normalized.
final restored = ContainerMetadata.fromJson({
...metadata.toJson(),
'isolatedAppLinkSettings': true,
'contextualIdentity': null,
});
expect(restored.isolatedAppLinkSettings, isFalse);
expect(restored.sanitized().isolatedAppLinkSettings, isFalse);
});
test('defaults to false', () {
expect(
ContainerMetadata.withDefaults().isolatedAppLinkSettings,
isFalse,
);
});
});
group('ContainerMetadata icon serialization', () {
test('stores MDI icon names', () {
final metadata = ContainerMetadata.withDefaults(