app links initial
This commit is contained in:
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user