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,122 @@
/*
* Copyright (c) 2024-2026 Fabian Freund.
*
* This file is part of WebLibre
* (see https://weblibre.eu).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:flutter_mozilla_components/flutter_mozilla_components.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:weblibre/features/app_links/domain/services/app_links_coordinator.dart';
import 'package:weblibre/features/app_links/presentation/widgets/app_link_prompt_dialog.dart';
/// Non-modal banner for an http(s) app link (§2.2). The page is allowed to load
/// while the banner is up; nothing blocks on it. Declining leaves the page
/// loaded; choosing the app leaves the tab on the committed page.
class AppLinkOpenBanner extends HookConsumerWidget {
final AppLinkPromptRequest request;
const AppLinkOpenBanner({super.key, required this.request});
@override
Widget build(BuildContext context, WidgetRef ref) {
final target = request.target;
final appName = target.appName;
final remember = useState(false);
final coordinator = ref.read(appLinksCoordinatorProvider.notifier);
final theme = Theme.of(context);
Future<void> resolve(AppLinkDecision decision) async {
if (remember.value && request.canRemember) {
final rule = decision == AppLinkDecision.open
? alwaysOpenRuleFor(target)
: neverOpenRuleFor(target);
if (rule != null) {
await coordinator.resolveWithRule(
request.requestId,
decision,
rule,
contextId: request.contextId,
);
return;
}
}
await coordinator.resolve(request.requestId, decision);
}
return Material(
elevation: 3,
color: theme.colorScheme.surfaceContainerHigh,
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 8, 8),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
const Icon(Icons.open_in_new, size: 20),
const SizedBox(width: 12),
Expanded(
child: Text(
appName != null
? 'Open this link in $appName?'
: 'Open this link in an app?',
style: theme.textTheme.bodyMedium,
),
),
IconButton(
icon: const Icon(Icons.close),
tooltip: 'Dismiss',
// A back/swipe/cancel resolves as dismiss (§2.6).
onPressed: () => resolve(AppLinkDecision.dismiss),
),
],
),
if (request.canRemember)
Row(
children: [
Checkbox(
value: remember.value,
onChanged: (value) => remember.value = value ?? false,
),
const Flexible(child: Text('Remember for this site')),
],
),
Align(
alignment: Alignment.centerRight,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
TextButton(
onPressed: () => resolve(AppLinkDecision.cancel),
child: const Text('Stay in browser'),
),
const SizedBox(width: 8),
FilledButton(
onPressed: () => resolve(AppLinkDecision.open),
child: const Text('Open app'),
),
],
),
),
],
),
),
);
}
}
@@ -0,0 +1,131 @@
/*
* Copyright (c) 2024-2026 Fabian Freund.
*
* This file is part of WebLibre
* (see https://weblibre.eu).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:flutter_mozilla_components/flutter_mozilla_components.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:weblibre/features/app_links/domain/entities/app_link_rule.dart';
import 'package:weblibre/features/app_links/domain/services/app_links_coordinator.dart';
/// Build the `alwaysOpen` rule for a target, or null when it cannot be remembered
/// (ambiguous resolution / no bound package).
PersistedAppLinkRule? alwaysOpenRuleFor(AppLinkTarget target) {
final packageName = target.packageName;
if (target.isAmbiguous || packageName == null || packageName.isEmpty) {
return null;
}
return PersistedAppLinkRule(
decision: AppLinkRuleDecision.alwaysOpen,
scope: target.scopeKey,
packageName: packageName,
);
}
PersistedAppLinkRule neverOpenRuleFor(AppLinkTarget target) {
return PersistedAppLinkRule(
decision: AppLinkRuleDecision.neverOpen,
scope: target.scopeKey,
);
}
/// Modal prompt for an unsupported-scheme app link (§2.2). The navigation is
/// genuinely stalled and there is no page to show behind it.
class AppLinkPromptDialog extends HookConsumerWidget {
final AppLinkPromptRequest request;
const AppLinkPromptDialog({super.key, required this.request});
@override
Widget build(BuildContext context, WidgetRef ref) {
final target = request.target;
final appName = target.appName;
final remember = useState(false);
final coordinator = ref.read(appLinksCoordinatorProvider.notifier);
// Guards against a double-tap running two resolves + two Navigator.pop()s
// (the second pop would tear down the route beneath the dialog).
final resolving = useRef(false);
Future<void> resolve(AppLinkDecision decision) async {
if (resolving.value) return;
resolving.value = true;
final navigator = Navigator.of(context);
if (remember.value && request.canRemember) {
final rule = decision == AppLinkDecision.open
? alwaysOpenRuleFor(target)
: neverOpenRuleFor(target);
if (rule != null) {
await coordinator.resolveWithRule(
request.requestId,
decision,
rule,
contextId: request.contextId,
);
navigator.pop();
return;
}
}
await coordinator.resolve(request.requestId, decision);
navigator.pop();
}
return AlertDialog(
icon: const Icon(Icons.open_in_new),
title: Text(
appName != null ? 'Open in $appName?' : 'Open in another app?',
),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('This link is handled by an app outside WebLibre.'),
const SizedBox(height: 8),
Text(
_displayScope(target.scopeKey),
style: Theme.of(context).textTheme.bodySmall,
),
if (request.canRemember)
CheckboxListTile(
contentPadding: EdgeInsets.zero,
controlAffinity: ListTileControlAffinity.leading,
value: remember.value,
onChanged: (value) => remember.value = value ?? false,
title: const Text('Remember my choice for this site'),
),
],
),
actions: [
TextButton(
onPressed: () => resolve(AppLinkDecision.cancel),
child: const Text('Cancel'),
),
FilledButton(
onPressed: () => resolve(AppLinkDecision.open),
child: const Text('Open'),
),
],
);
}
}
String _displayScope(String scope) {
if (scope.startsWith('host:')) return scope.substring('host:'.length);
if (scope.startsWith('pkg:')) return scope.substring('pkg:'.length);
return scope;
}
@@ -0,0 +1,117 @@
/*
* Copyright (c) 2024-2026 Fabian Freund.
*
* This file is part of WebLibre
* (see https://weblibre.eu).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:flutter_mozilla_components/flutter_mozilla_components.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:weblibre/features/app_links/domain/services/app_links_coordinator.dart';
import 'package:weblibre/features/app_links/presentation/widgets/app_link_open_banner.dart';
import 'package:weblibre/features/app_links/presentation/widgets/app_link_prompt_dialog.dart';
import 'package:weblibre/features/geckoview/domain/providers/selected_tab.dart';
/// Presents Flutter-owned app-link prompts (§2.6): renders at most one banner for
/// the active tab, and drives one modal at a time via [showDialog]. A request is
/// only shown while its originating tab is active. Rotation/teardown is not a
/// dismissal — the request stays pending and is re-presented on the next query.
///
/// Mount this as a layer of the browser Stack that is positioned *above* the bottom
/// app bar (see `browser.dart`, next to the find-in-page layer). It renders the
/// banner inline, bottom-anchored within that positioned region — Flutter composites
/// over the live GeckoView fine (the toolbars do the same); the only requirement is
/// that the host is not placed underneath the bottom app bar.
class AppLinkPromptHost extends HookConsumerWidget {
const AppLinkPromptHost({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final prompts = ref.watch(appLinksCoordinatorProvider);
final activeTabId = ref.watch(selectedTabProvider);
// The Pigeon availability event has no replay: an event emitted while Flutter
// was detached is lost, so re-query the native pending store on resume (§2.6).
useOnAppLifecycleStateChange((previous, current) {
if (current == AppLifecycleState.resumed) {
unawaited(ref.read(appLinksCoordinatorProvider.notifier).refresh());
}
});
final activeRequests = prompts
.where((request) => request.tabId == activeTabId)
.toList();
final modalRequest = activeRequests
.where((request) => request.isModal)
.lastOrNull;
// At most one banner per tab; a newer banner-class request simply becomes the
// one the UI renders.
final bannerRequest = activeRequests
.where((request) => !request.isModal)
.lastOrNull;
// A modal is shown at most once per requestId. Rotation/teardown is not a
// dismissal — the request stays pending and is re-presented on the next query
// (a subsequent build re-runs this effect with the still-present id).
final shownModalId = useRef<int?>(null);
useEffect(() {
final request = modalRequest;
if (request == null) {
shownModalId.value = null;
return null;
}
if (shownModalId.value == request.requestId) {
return null;
}
shownModalId.value = request.requestId;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!context.mounted) return;
unawaited(
showDialog<void>(
context: context,
builder: (_) => AppLinkPromptDialog(request: request),
).then((_) {
// Catch-all for a passive dismissal (Android back / touch-outside):
// the dialog buttons resolve the request themselves, but a barrier
// dismiss closes it without resolving, leaving the native request
// pending forever (and `shownModalId` blocks a re-show). Resolving as
// dismiss here is idempotent — if a button already consumed it, the
// native store returns stale and this is a no-op.
unawaited(
ref
.read(appLinksCoordinatorProvider.notifier)
.resolve(request.requestId, AppLinkDecision.dismiss),
);
}),
);
});
return null;
}, [modalRequest?.requestId]);
if (bannerRequest == null) {
return const SizedBox.shrink();
}
return AppLinkOpenBanner(
key: ValueKey(bannerRequest.requestId),
request: bannerRequest,
);
}
}
@@ -0,0 +1,178 @@
/*
* Copyright (c) 2024-2026 Fabian Freund.
*
* This file is part of WebLibre
* (see https://weblibre.eu).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import 'package:flutter/material.dart';
import 'package:flutter_material_design_icons/flutter_material_design_icons.dart';
import 'package:flutter_mozilla_components/flutter_mozilla_components.dart'
show AppLinksMode;
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:weblibre/features/app_links/domain/entities/app_link_rule.dart';
import 'package:weblibre/features/app_links/domain/entities/context_app_link_policy.dart';
import 'package:weblibre/features/settings/presentation/controllers/save_settings.dart';
import 'package:weblibre/features/user/data/models/general_settings.dart';
import 'package:weblibre/features/user/domain/repositories/general_settings.dart';
/// Per-container app-link settings (§ container isolation), bound to
/// `GeneralSettings.appLinkContextOverrides[contextId]`. Mirrors the global
/// app-links section but writes into the container's own override bucket, which
/// fully replaces the global mode + rules for that container (replace semantics).
///
/// Present via `showDialog`; edits save live (no separate confirm step), matching
/// the global settings screen. Only meaningful for an isolated, cookie-isolated
/// container — the caller gates on that.
class ContainerAppLinkSettingsDialog extends ConsumerWidget {
/// The container's Gecko contextId (`contextualIdentity`); the override key.
final String contextId;
/// Optional container name for the title.
final String? containerName;
const ContainerAppLinkSettingsDialog({
super.key,
required this.contextId,
this.containerName,
});
Future<void> _updateOverride(
WidgetRef ref,
ContextAppLinkPolicy Function(ContextAppLinkPolicy current) update,
) async {
await ref
.read(saveGeneralSettingsControllerProvider.notifier)
.save((current) {
final existing =
current.appLinkContextOverrides[contextId] ??
ContextAppLinkPolicy.blank();
return current.copyWith.appLinkContextOverrides({
...current.appLinkContextOverrides,
contextId: update(existing),
});
});
}
@override
Widget build(BuildContext context, WidgetRef ref) {
final override = ref.watch(
generalSettingsWithDefaultsProvider.select(
(s) => s.appLinkContextOverrides[contextId],
),
);
final policy = override ?? ContextAppLinkPolicy.blank();
final rules = policy.rules.entries.toList()
..sort((a, b) => a.key.compareTo(b.key));
return Dialog.fullscreen(
child: Scaffold(
appBar: AppBar(
title: Text(
containerName != null
? 'App Links — $containerName'
: 'Container App Links',
),
leading: IconButton(
icon: const Icon(Icons.close),
onPressed: () => Navigator.of(context).pop(),
),
),
body: ListView(
padding: const EdgeInsets.symmetric(vertical: 8),
children: [
const Padding(
padding: EdgeInsets.fromLTRB(16, 8, 16, 0),
child: Text(
'These settings apply only to this container and fully replace '
'the global app-link settings for its tabs.',
),
),
RadioGroup(
groupValue: policy.mode,
onChanged: (value) async {
if (value != null) {
await _updateOverride(ref, (c) => c.copyWith.mode(value));
}
},
child: const Column(
children: [
RadioListTile.adaptive(
value: AppLinksMode.always,
title: Text('Always'),
subtitle: Text(
'Always open links in their native apps without asking',
),
),
RadioListTile.adaptive(
value: AppLinksMode.ask,
title: Text('Ask before opening'),
subtitle: Text('Show a prompt before opening links in apps'),
),
RadioListTile.adaptive(
value: AppLinksMode.never,
title: Text('Never'),
subtitle: Text(
'Always open links in the browser instead of apps',
),
),
],
),
),
if (rules.isNotEmpty) ...[
const Divider(),
const Padding(
padding: EdgeInsets.fromLTRB(16, 8, 16, 4),
child: Text('Remembered site rules'),
),
for (final MapEntry(:key, :value) in rules)
ListTile(
dense: true,
leading: Icon(
value.decision == AppLinkRuleDecision.alwaysOpen
? MdiIcons.openInApp
: Icons.public,
),
title: Text(_displayScope(key)),
subtitle: Text(
value.decision == AppLinkRuleDecision.alwaysOpen
? 'Always open in the app'
: 'Always keep in the browser',
),
trailing: IconButton(
icon: const Icon(Icons.delete_outline),
tooltip: 'Remove rule',
onPressed: () async {
await _updateOverride(
ref,
(c) => c.copyWith.rules({...c.rules}..remove(key)),
);
},
),
),
],
],
),
),
);
}
}
String _displayScope(String scope) {
if (scope.startsWith('host:')) return scope.substring('host:'.length);
if (scope.startsWith('pkg:')) return scope.substring('pkg:'.length);
return scope;
}