app links initial
This commit is contained in:
@@ -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', () {
|
||||
test('stores MDI icon names', () {
|
||||
final metadata = ContainerMetadata.withDefaults(
|
||||
|
||||
Reference in New Issue
Block a user