intent gatekeeper initial

This commit is contained in:
Fabian Freund
2026-04-19 02:23:04 +02:00
parent 5e6cc7a0f1
commit 57c3dcca86
27 changed files with 1413 additions and 12 deletions
@@ -53,6 +53,11 @@ import 'package:weblibre/features/geckoview/features/tabs/data/entities/tab_mode
import 'package:weblibre/features/geckoview/features/tabs/domain/providers/selected_container.dart';
import 'package:weblibre/features/geckoview/features/tabs/domain/repositories/container.dart';
import 'package:weblibre/features/geckoview/features/tabs/domain/repositories/tab.dart';
import 'package:weblibre/features/intent_gatekeeper/domain/entities/intent_source_policy.dart';
import 'package:weblibre/features/intent_gatekeeper/domain/entities/pending_intent_decision.dart';
import 'package:weblibre/features/intent_gatekeeper/domain/services/intent_gatekeeper.dart';
import 'package:weblibre/features/intent_gatekeeper/domain/services/native_gatekeeper_replicator.dart';
import 'package:weblibre/features/intent_gatekeeper/presentation/widgets/intent_gatekeeper_dialog.dart';
import 'package:weblibre/features/share_intent/domain/entities/shared_content.dart';
import 'package:weblibre/features/user/data/models/general_settings.dart';
import 'package:weblibre/features/user/domain/providers/profile_auth.dart';
@@ -347,6 +352,37 @@ class _BrowserViewState extends ConsumerState<BrowserView>
}
});
ref.listenManual<AsyncValue<PendingIntentDecision>>(
intentGatekeeperProvider,
(previous, next) async {
final request = next.value;
if (request == null) {
return;
}
final gatekeeper = ref.read(intentGatekeeperProvider.notifier);
if (!context.mounted) {
await gatekeeper.resolve(
id: request.id,
decision: IntentSourcePolicy.block,
);
return;
}
final outcome = await showDialog<DialogOutcome>(
context: context,
builder: (context) => IntentGatekeeperDialog(request: request),
);
await gatekeeper.resolve(
id: request.id,
decision: outcome?.decision ?? IntentSourcePolicy.block,
persist: outcome?.persist ?? false,
packageName: request.packageName,
);
},
);
ref.listenManual(
engineBoundIntentStreamProvider,
(previous, next) {
@@ -443,6 +479,19 @@ class _BrowserViewState extends ConsumerState<BrowserView>
},
);
ref.listenManual(
fireImmediately: true,
nativeIntentGatekeeperReplicatorProvider,
(previous, next) {},
onError: (error, stackTrace) {
logger.e(
'Error listening to nativeIntentGatekeeperReplicatorProvider',
error: error,
stackTrace: stackTrace,
);
},
);
ref.listenManual(
fireImmediately: true,
selectionActionServiceProvider,
@@ -0,0 +1,20 @@
/*
* 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/>.
*/
enum IntentSourcePolicy { allow, block }
@@ -0,0 +1,35 @@
/*
* 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';
class PendingIntentDecision with FastEquatable {
final int id;
final String packageName;
final String? url;
PendingIntentDecision({
required this.id,
required this.packageName,
required this.url,
});
@override
List<Object?> get hashParameters => [id, packageName, url];
}
@@ -0,0 +1,115 @@
/*
* 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:riverpod_annotation/riverpod_annotation.dart';
import 'package:weblibre/features/intent_gatekeeper/domain/entities/intent_source_policy.dart';
import 'package:weblibre/features/intent_gatekeeper/domain/entities/pending_intent_decision.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';
part 'intent_gatekeeper.g.dart';
const _ownPackageName = 'eu.weblibre.gecko';
@Riverpod(keepAlive: true)
class IntentGatekeeper extends _$IntentGatekeeper {
late StreamController<PendingIntentDecision> _decisionRequests;
final _pending = <int, Completer<bool>>{};
int _nextId = 0;
@override
Stream<PendingIntentDecision> build() {
_decisionRequests = StreamController<PendingIntentDecision>.broadcast();
ref.onDispose(() async {
for (final completer in _pending.values) {
if (!completer.isCompleted) {
completer.complete(false);
}
}
_pending.clear();
await _decisionRequests.close();
});
return _decisionRequests.stream;
}
/// Resolves whether an intent coming from [fromPackageName] targeting [url]
/// should be allowed through. If the user has to decide, this waits for
/// [resolve] to be called with the matching decision id.
Future<bool> shouldAllow({
required String? fromPackageName,
required String? url,
}) async {
final settings = ref.read(generalSettingsWithDefaultsProvider);
if (!settings.blockExternalAppsEnabled) {
return true;
}
// Internal / unknown callers: no package to gate on — let through.
if (fromPackageName == null || fromPackageName == _ownPackageName) {
return true;
}
final existing = settings.externalAppIntentPolicies[fromPackageName];
if (existing == IntentSourcePolicy.allow) {
return true;
}
if (existing == IntentSourcePolicy.block) {
return false;
}
final id = _nextId++;
final completer = Completer<bool>();
_pending[id] = completer;
_decisionRequests.add(
PendingIntentDecision(id: id, packageName: fromPackageName, url: url),
);
return completer.future;
}
Future<void> resolve({
required int id,
required IntentSourcePolicy decision,
bool persist = false,
String? packageName,
}) async {
final completer = _pending.remove(id);
completer?.complete(decision == IntentSourcePolicy.allow);
if (persist && packageName != null) {
await ref
.read(saveGeneralSettingsControllerProvider.notifier)
.save(
(current) => current.copyWith.externalAppIntentPolicies({
...current.externalAppIntentPolicies,
packageName: decision,
}),
);
}
}
}
@@ -0,0 +1,60 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'intent_gatekeeper.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint, type=warning
@ProviderFor(IntentGatekeeper)
final intentGatekeeperProvider = IntentGatekeeperProvider._();
final class IntentGatekeeperProvider
extends $StreamNotifierProvider<IntentGatekeeper, PendingIntentDecision> {
IntentGatekeeperProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'intentGatekeeperProvider',
isAutoDispose: false,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$intentGatekeeperHash();
@$internal
@override
IntentGatekeeper create() => IntentGatekeeper();
}
String _$intentGatekeeperHash() => r'94df8850478ad6695eb14752e82af1919ea8a077';
abstract class _$IntentGatekeeper
extends $StreamNotifier<PendingIntentDecision> {
Stream<PendingIntentDecision> build();
@$mustCallSuper
@override
void runBuild() {
final ref =
this.ref
as $Ref<AsyncValue<PendingIntentDecision>, PendingIntentDecision>;
final element =
ref.element
as $ClassProviderElement<
AnyNotifier<
AsyncValue<PendingIntentDecision>,
PendingIntentDecision
>,
AsyncValue<PendingIntentDecision>,
Object?,
Object?
>;
element.handleCreate(ref, build);
}
}
@@ -0,0 +1,87 @@
/*
* 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:collection/collection.dart';
import 'package:fast_equatable/fast_equatable.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:simple_intent_receiver/simple_intent_receiver.dart';
import 'package:weblibre/core/logger.dart';
import 'package:weblibre/features/intent_gatekeeper/domain/entities/intent_source_policy.dart';
import 'package:weblibre/features/user/domain/repositories/general_settings.dart';
part 'native_gatekeeper_replicator.g.dart';
/// Mirrors the Flutter-side block list to the native side so the
/// `IntentReceiverActivity` can reject intents without launching Flutter.
/// Only blocked packages are replicated — allow/unknown still fall through to
/// the Flutter gatekeeper dialog.
@Riverpod(keepAlive: true)
class NativeIntentGatekeeperReplicator
extends _$NativeIntentGatekeeperReplicator {
final _api = IntentGatekeeperHostApi();
Future<void> _push(
({bool enabled, Map<String, IntentSourcePolicy> policies}) config,
) async {
final blocked = config.policies.entries
.where((entry) => entry.value == IntentSourcePolicy.block)
.map((entry) => entry.key)
.toList();
try {
await _api.setConfig(config.enabled, blocked);
} catch (error, stackTrace) {
logger.e(
'Failed to replicate intent gatekeeper config to native',
error: error,
stackTrace: stackTrace,
);
}
}
@override
void build() {
ref.listen(
generalSettingsWithDefaultsProvider.select(
(settings) => EquatableValue((
enabled: settings.blockExternalAppsEnabled,
policies: settings.externalAppIntentPolicies,
)),
),
fireImmediately: true,
(p, n) {
final previous = p?.value;
final next = n.value;
if (previous != null &&
previous.enabled == next.enabled &&
const DeepCollectionEquality.unordered().equals(
previous.policies,
next.policies,
)) {
return;
}
unawaited(_push(next));
},
);
}
}
@@ -0,0 +1,82 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'native_gatekeeper_replicator.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint, type=warning
/// Mirrors the Flutter-side block list to the native side so the
/// `IntentReceiverActivity` can reject intents without launching Flutter.
/// Only blocked packages are replicated — allow/unknown still fall through to
/// the Flutter gatekeeper dialog.
@ProviderFor(NativeIntentGatekeeperReplicator)
final nativeIntentGatekeeperReplicatorProvider =
NativeIntentGatekeeperReplicatorProvider._();
/// Mirrors the Flutter-side block list to the native side so the
/// `IntentReceiverActivity` can reject intents without launching Flutter.
/// Only blocked packages are replicated — allow/unknown still fall through to
/// the Flutter gatekeeper dialog.
final class NativeIntentGatekeeperReplicatorProvider
extends $NotifierProvider<NativeIntentGatekeeperReplicator, void> {
/// Mirrors the Flutter-side block list to the native side so the
/// `IntentReceiverActivity` can reject intents without launching Flutter.
/// Only blocked packages are replicated — allow/unknown still fall through to
/// the Flutter gatekeeper dialog.
NativeIntentGatekeeperReplicatorProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'nativeIntentGatekeeperReplicatorProvider',
isAutoDispose: false,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$nativeIntentGatekeeperReplicatorHash();
@$internal
@override
NativeIntentGatekeeperReplicator create() =>
NativeIntentGatekeeperReplicator();
/// {@macro riverpod.override_with_value}
Override overrideWithValue(void value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<void>(value),
);
}
}
String _$nativeIntentGatekeeperReplicatorHash() =>
r'ee97dbd489e4e946e0a98cd640300f939f3b0682';
/// Mirrors the Flutter-side block list to the native side so the
/// `IntentReceiverActivity` can reject intents without launching Flutter.
/// Only blocked packages are replicated — allow/unknown still fall through to
/// the Flutter gatekeeper dialog.
abstract class _$NativeIntentGatekeeperReplicator extends $Notifier<void> {
void build();
@$mustCallSuper
@override
void runBuild() {
final ref = this.ref as $Ref<void, void>;
final element =
ref.element
as $ClassProviderElement<
AnyNotifier<void, void>,
void,
Object?,
Object?
>;
element.handleCreate(ref, build);
}
}
@@ -0,0 +1,29 @@
/*
* 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:riverpod_annotation/riverpod_annotation.dart';
import 'package:simple_intent_receiver/simple_intent_receiver.dart';
part 'package_label_resolver.g.dart';
@Riverpod(keepAlive: true)
Future<String?> packageLabel(Ref ref, String packageName) {
final api = IntentGatekeeperHostApi();
return api.resolvePackageLabel(packageName);
}
@@ -0,0 +1,79 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'package_label_resolver.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint, type=warning
@ProviderFor(packageLabel)
final packageLabelProvider = PackageLabelFamily._();
final class PackageLabelProvider
extends $FunctionalProvider<AsyncValue<String?>, String?, FutureOr<String?>>
with $FutureModifier<String?>, $FutureProvider<String?> {
PackageLabelProvider._({
required PackageLabelFamily super.from,
required String super.argument,
}) : super(
retry: null,
name: r'packageLabelProvider',
isAutoDispose: false,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$packageLabelHash();
@override
String toString() {
return r'packageLabelProvider'
''
'($argument)';
}
@$internal
@override
$FutureProviderElement<String?> $createElement($ProviderPointer pointer) =>
$FutureProviderElement(pointer);
@override
FutureOr<String?> create(Ref ref) {
final argument = this.argument as String;
return packageLabel(ref, argument);
}
@override
bool operator ==(Object other) {
return other is PackageLabelProvider && other.argument == argument;
}
@override
int get hashCode {
return argument.hashCode;
}
}
String _$packageLabelHash() => r'14f966e502c5dde332cc42d52727ab111bf6a2b2';
final class PackageLabelFamily extends $Family
with $FunctionalFamilyOverride<FutureOr<String?>, String> {
PackageLabelFamily._()
: super(
retry: null,
name: r'packageLabelProvider',
dependencies: null,
$allTransitiveDependencies: null,
isAutoDispose: false,
);
PackageLabelProvider call(String packageName) =>
PackageLabelProvider._(argument: packageName, from: this);
@override
String toString() => r'packageLabelProvider';
}
@@ -0,0 +1,143 @@
/*
* 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:hooks_riverpod/hooks_riverpod.dart';
import 'package:weblibre/features/intent_gatekeeper/domain/entities/intent_source_policy.dart';
import 'package:weblibre/features/intent_gatekeeper/domain/entities/pending_intent_decision.dart';
import 'package:weblibre/features/intent_gatekeeper/domain/services/package_label_resolver.dart';
import 'package:weblibre/presentation/widgets/uri_breadcrumb.dart';
class DialogOutcome {
final IntentSourcePolicy decision;
final bool persist;
const DialogOutcome({required this.decision, this.persist = false});
}
class IntentGatekeeperDialog extends HookConsumerWidget {
final PendingIntentDecision request;
const IntentGatekeeperDialog({super.key, required this.request});
@override
Widget build(BuildContext context, WidgetRef ref) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
final bold = TextStyle(
fontWeight: FontWeight.bold,
color: colorScheme.onSurface,
);
final label = ref.watch(
packageLabelProvider(request.packageName).select((value) => value.value),
);
final displayName = (label != null && label.isNotEmpty)
? label
: request.packageName;
final uri = request.url != null ? Uri.tryParse(request.url!) : null;
return AlertDialog(
icon: const Icon(Icons.shield_outlined, size: 32),
title: const Text('Open link in WebLibre?'),
content: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text.rich(
TextSpan(
style: theme.textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
),
children: [
TextSpan(text: displayName, style: bold),
const TextSpan(text: ' is trying to open a link in '),
TextSpan(text: 'WebLibre', style: bold),
const TextSpan(text: '.'),
],
),
),
if (uri != null) ...[
const SizedBox(height: 16),
Container(
width: double.infinity,
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(8),
),
child: UriBreadcrumb(
uri: uri,
style: theme.textTheme.bodyMedium?.copyWith(
color: colorScheme.primary,
),
),
),
],
],
),
),
contentPadding: const EdgeInsets.fromLTRB(24, 16, 24, 0),
actionsPadding: const EdgeInsets.all(24),
actions: [
Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
FilledButton(
onPressed: () => Navigator.of(context).pop(
const DialogOutcome(
decision: IntentSourcePolicy.allow,
persist: true,
),
),
child: const Text('Always allow'),
),
const SizedBox(height: 8),
FilledButton.tonal(
onPressed: () => Navigator.of(context).pop(
const DialogOutcome(decision: IntentSourcePolicy.allow),
),
child: const Text('Allow once'),
),
const SizedBox(height: 8),
OutlinedButton(
onPressed: () => Navigator.of(context).pop(
const DialogOutcome(decision: IntentSourcePolicy.block),
),
child: const Text('Block once'),
),
const SizedBox(height: 8),
TextButton(
onPressed: () => Navigator.of(context).pop(
const DialogOutcome(
decision: IntentSourcePolicy.block,
persist: true,
),
),
child: const Text('Always block'),
),
],
),
],
);
}
}
@@ -25,6 +25,8 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:nullability/nullability.dart';
import 'package:weblibre/core/routing/routes.dart';
import 'package:weblibre/features/geckoview/features/browser/presentation/dialogs/delete_data.dart';
import 'package:weblibre/features/intent_gatekeeper/domain/entities/intent_source_policy.dart';
import 'package:weblibre/features/intent_gatekeeper/domain/services/package_label_resolver.dart';
import 'package:weblibre/features/settings/presentation/controllers/save_settings.dart';
import 'package:weblibre/features/settings/presentation/widgets/sections.dart';
import 'package:weblibre/features/user/data/models/engine_settings.dart';
@@ -54,6 +56,7 @@ class PrivacySecuritySettingsScreen extends StatelessWidget {
_ConnectionSecuritySection(),
_NetworkProtectionSection(),
_PrivacySignalsSection(),
_AppOpeningProtectionSection(),
_DataManagementSection(),
_SafeBrowsingSection(),
_AdvancedSecuritySection(),
@@ -853,6 +856,149 @@ Future<void> _showRestartDialog(BuildContext context, WidgetRef ref) async {
}
}
class _AppOpeningProtectionSection extends HookConsumerWidget {
const _AppOpeningProtectionSection();
@override
Widget build(BuildContext context, WidgetRef ref) {
final enabled = ref.watch(
generalSettingsWithDefaultsProvider.select(
(s) => s.blockExternalAppsEnabled,
),
);
final policies = ref.watch(
generalSettingsWithDefaultsProvider.select(
(s) => s.externalAppIntentPolicies,
),
);
return Column(
children: [
const SettingSection(name: 'App-Opening Protection'),
SwitchListTile.adaptive(
title: const Text('Block apps from opening your browser'),
subtitle: const Text(
'Ask before opening links that other apps send to WebLibre.',
),
secondary: const Icon(MdiIcons.appsBox),
value: enabled,
onChanged: (value) async {
await ref
.read(saveGeneralSettingsControllerProvider.notifier)
.save(
(current) => current.copyWith.blockExternalAppsEnabled(value),
);
},
),
if (enabled && policies.isNotEmpty)
_ManagedAppPolicyList(policies: policies),
],
);
}
}
class _ManagedAppPolicyList extends HookConsumerWidget {
final Map<String, IntentSourcePolicy> policies;
const _ManagedAppPolicyList({required this.policies});
@override
Widget build(BuildContext context, WidgetRef ref) {
final entries = policies.entries.toList(growable: false);
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Managed apps', style: Theme.of(context).textTheme.titleSmall),
const SizedBox(height: 4),
for (final entry in entries)
_ManagedAppPolicyTile(
packageName: entry.key,
policy: entry.value,
onAction: (action) async {
final notifier = ref.read(
saveGeneralSettingsControllerProvider.notifier,
);
switch (action) {
case _PolicyAction.allow:
await notifier.save(
(current) => current.copyWith.externalAppIntentPolicies({
...current.externalAppIntentPolicies,
entry.key: IntentSourcePolicy.allow,
}),
);
case _PolicyAction.block:
await notifier.save(
(current) => current.copyWith.externalAppIntentPolicies({
...current.externalAppIntentPolicies,
entry.key: IntentSourcePolicy.block,
}),
);
case _PolicyAction.remove:
await notifier.save(
(current) => current.copyWith.externalAppIntentPolicies(
{...current.externalAppIntentPolicies}
..remove(entry.key),
),
);
}
},
),
],
),
);
}
}
class _ManagedAppPolicyTile extends HookConsumerWidget {
final String packageName;
final IntentSourcePolicy policy;
final Future<void> Function(_PolicyAction action) onAction;
const _ManagedAppPolicyTile({
required this.packageName,
required this.policy,
required this.onAction,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final label = ref.watch(
packageLabelProvider(packageName).select((value) => value.value),
);
final hasLabel = label != null && label.isNotEmpty;
return ListTile(
contentPadding: EdgeInsets.zero,
leading: Icon(
policy == IntentSourcePolicy.allow
? MdiIcons.checkCircleOutline
: MdiIcons.cancel,
),
title: Text(hasLabel ? label : packageName),
subtitle: Text(
hasLabel
? '${policy == IntentSourcePolicy.allow ? 'Always allowed' : 'Always blocked'} · $packageName'
: (policy == IntentSourcePolicy.allow
? 'Always allowed'
: 'Always blocked'),
),
trailing: PopupMenuButton<_PolicyAction>(
onSelected: onAction,
itemBuilder: (context) => const [
PopupMenuItem(value: _PolicyAction.allow, child: Text('Allow')),
PopupMenuItem(value: _PolicyAction.block, child: Text('Block')),
PopupMenuItem(value: _PolicyAction.remove, child: Text('Remove')),
],
),
);
}
}
enum _PolicyAction { allow, block, remove }
class _NetworkProtectionSection extends StatelessWidget {
const _NetworkProtectionSection();
@@ -27,12 +27,33 @@ import 'package:simple_intent_receiver/simple_intent_receiver.dart';
import 'package:uri_to_file/uri_to_file.dart' as uri_to_file;
import 'package:weblibre/core/logger.dart';
import 'package:weblibre/data/models/received_intent_parameter.dart';
import 'package:weblibre/features/intent_gatekeeper/domain/services/intent_gatekeeper.dart';
part 'sharing_intent.g.dart';
final _sharingIntentTransformer =
StreamTransformer<Intent, ReceivedIntentParameter>
_buildSharingIntentTransformer(IntentGatekeeper gatekeeper) =>
StreamTransformer<Intent, ReceivedIntentParameter>.fromHandlers(
handleData: (intent, sink) async {
// PWA shortcut intents carry our own signed context id — always allow.
final pwaContextId =
intent.action == 'android.intent.action.VIEW'
? intent.extra['pwa_context_id'] as String?
: null;
if (pwaContextId == null) {
final allowed = await gatekeeper.shouldAllow(
fromPackageName: intent.fromPackageName,
url: intent.data,
);
if (!allowed) {
logger.i(
'Blocked intent from ${intent.fromPackageName ?? 'unknown app'}',
);
return;
}
}
final data = switch (intent.action) {
'android.intent.action.PROCESS_TEXT' =>
intent.extra['android.intent.extra.PROCESS_TEXT'] as String?,
@@ -46,10 +67,7 @@ final _sharingIntentTransformer =
};
// Extract container context from shortcut intents
final contextId =
intent.action == 'android.intent.action.VIEW'
? intent.extra['pwa_context_id'] as String?
: null;
final contextId = pwaContextId;
if (data != null) {
if (uri_to_file.isUriSupported(data)) {
@@ -101,6 +119,7 @@ final _sharingIntentTransformer =
@Riverpod(keepAlive: true)
Raw<Stream<ReceivedIntentParameter>> sharingIntentStream(Ref ref) {
final receiver = IntentReceiver.setUp();
final gatekeeper = ref.watch(intentGatekeeperProvider.notifier);
return receiver.events.transform(_sharingIntentTransformer);
return receiver.events.transform(_buildSharingIntentTransformer(gatekeeper));
}
@@ -56,4 +56,4 @@ final class SharingIntentStreamProvider
}
String _$sharingIntentStreamHash() =>
r'486f994fc0e01a2cffdb19d93f0a332f96a9850c';
r'21b189c5df56f81ed5ab88115c11ddce1189cfb7';
@@ -30,7 +30,9 @@ class SettingDao extends DatabaseAccessor<UserDatabase> with $SettingDaoMixin {
SettingDao(super.attachedDatabase);
Future<int> updateSetting(String key, String? partitionKey, Object? value) {
final normalizedValue = (value is Iterable) ? jsonEncode(value) : value;
final normalizedValue = (value is Iterable || value is Map)
? jsonEncode(value)
: value;
final driftvalue = normalizedValue.mapNotNull(
(normalizedValue) => DriftAny(normalizedValue),
@@ -24,6 +24,7 @@ import 'package:json_annotation/json_annotation.dart';
import 'package:weblibre/core/routing/routes.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/intent_gatekeeper/domain/entities/intent_source_policy.dart';
import 'package:weblibre/features/search/domain/entities/abstract/i_search_suggestion_provider.dart';
part 'general_settings.g.dart';
@@ -119,6 +120,8 @@ class GeneralSettings with FastEquatable {
final bool unshortenerEnabled;
final String unshortenerToken;
final bool allowNonManifestPwaInstall;
final bool blockExternalAppsEnabled;
final Map<String, IntentSourcePolicy> externalAppIntentPolicies;
GeneralSettings({
required this.themeMode,
@@ -170,6 +173,8 @@ class GeneralSettings with FastEquatable {
required this.unshortenerEnabled,
required this.unshortenerToken,
required this.allowNonManifestPwaInstall,
required this.blockExternalAppsEnabled,
required this.externalAppIntentPolicies,
});
GeneralSettings.withDefaults({
@@ -222,6 +227,8 @@ class GeneralSettings with FastEquatable {
bool? unshortenerEnabled,
String? unshortenerToken,
bool? allowNonManifestPwaInstall,
bool? blockExternalAppsEnabled,
Map<String, IntentSourcePolicy>? externalAppIntentPolicies,
}) : themeMode = themeMode ?? ThemeMode.dark,
uiScaleFactor = uiScaleFactor ?? defaultUiScaleFactor,
disableAnimations = disableAnimations ?? false,
@@ -280,7 +287,9 @@ class GeneralSettings with FastEquatable {
tabBarLongPressUrlCopy = tabBarLongPressUrlCopy ?? true,
unshortenerEnabled = unshortenerEnabled ?? false,
unshortenerToken = unshortenerToken ?? '',
allowNonManifestPwaInstall = allowNonManifestPwaInstall ?? false;
allowNonManifestPwaInstall = allowNonManifestPwaInstall ?? false,
blockExternalAppsEnabled = blockExternalAppsEnabled ?? false,
externalAppIntentPolicies = externalAppIntentPolicies ?? const {};
factory GeneralSettings.fromJson(Map<String, dynamic> json) =>
_$GeneralSettingsFromJson(json);
@@ -353,5 +362,7 @@ class GeneralSettings with FastEquatable {
unshortenerEnabled,
unshortenerToken,
allowNonManifestPwaInstall,
blockExternalAppsEnabled,
externalAppIntentPolicies,
];
}
@@ -123,6 +123,12 @@ abstract class _$GeneralSettingsCWProxy {
GeneralSettings allowNonManifestPwaInstall(bool allowNonManifestPwaInstall);
GeneralSettings blockExternalAppsEnabled(bool blockExternalAppsEnabled);
GeneralSettings externalAppIntentPolicies(
Map<String, IntentSourcePolicy> externalAppIntentPolicies,
);
/// 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 `GeneralSettings(...).copyWith.fieldName(value)`.
///
@@ -180,6 +186,8 @@ abstract class _$GeneralSettingsCWProxy {
bool unshortenerEnabled,
String unshortenerToken,
bool allowNonManifestPwaInstall,
bool blockExternalAppsEnabled,
Map<String, IntentSourcePolicy> externalAppIntentPolicies,
});
}
@@ -398,6 +406,15 @@ class _$GeneralSettingsCWProxyImpl implements _$GeneralSettingsCWProxy {
GeneralSettings allowNonManifestPwaInstall(bool allowNonManifestPwaInstall) =>
call(allowNonManifestPwaInstall: allowNonManifestPwaInstall);
@override
GeneralSettings blockExternalAppsEnabled(bool blockExternalAppsEnabled) =>
call(blockExternalAppsEnabled: blockExternalAppsEnabled);
@override
GeneralSettings externalAppIntentPolicies(
Map<String, IntentSourcePolicy> externalAppIntentPolicies,
) => call(externalAppIntentPolicies: externalAppIntentPolicies);
@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 `GeneralSettings(...).copyWith.fieldName(value)`.
@@ -457,6 +474,8 @@ class _$GeneralSettingsCWProxyImpl implements _$GeneralSettingsCWProxy {
Object? unshortenerEnabled = const $CopyWithPlaceholder(),
Object? unshortenerToken = const $CopyWithPlaceholder(),
Object? allowNonManifestPwaInstall = const $CopyWithPlaceholder(),
Object? blockExternalAppsEnabled = const $CopyWithPlaceholder(),
Object? externalAppIntentPolicies = const $CopyWithPlaceholder(),
}) {
return GeneralSettings(
themeMode: themeMode == const $CopyWithPlaceholder() || themeMode == null
@@ -747,6 +766,18 @@ class _$GeneralSettingsCWProxyImpl implements _$GeneralSettingsCWProxy {
? _value.allowNonManifestPwaInstall
// ignore: cast_nullable_to_non_nullable
: allowNonManifestPwaInstall as bool,
blockExternalAppsEnabled:
blockExternalAppsEnabled == const $CopyWithPlaceholder() ||
blockExternalAppsEnabled == null
? _value.blockExternalAppsEnabled
// ignore: cast_nullable_to_non_nullable
: blockExternalAppsEnabled as bool,
externalAppIntentPolicies:
externalAppIntentPolicies == const $CopyWithPlaceholder() ||
externalAppIntentPolicies == null
? _value.externalAppIntentPolicies
// ignore: cast_nullable_to_non_nullable
: externalAppIntentPolicies as Map<String, IntentSourcePolicy>,
);
}
}
@@ -858,6 +889,11 @@ GeneralSettings _$GeneralSettingsFromJson(
unshortenerEnabled: json['unshortenerEnabled'] as bool?,
unshortenerToken: json['unshortenerToken'] as String?,
allowNonManifestPwaInstall: json['allowNonManifestPwaInstall'] as bool?,
blockExternalAppsEnabled: json['blockExternalAppsEnabled'] as bool?,
externalAppIntentPolicies:
(json['externalAppIntentPolicies'] as Map<String, dynamic>?)?.map(
(k, e) => MapEntry(k, $enumDecode(_$IntentSourcePolicyEnumMap, e)),
),
);
Map<String, dynamic> _$GeneralSettingsToJson(
@@ -923,6 +959,10 @@ Map<String, dynamic> _$GeneralSettingsToJson(
'unshortenerEnabled': instance.unshortenerEnabled,
'unshortenerToken': instance.unshortenerToken,
'allowNonManifestPwaInstall': instance.allowNonManifestPwaInstall,
'blockExternalAppsEnabled': instance.blockExternalAppsEnabled,
'externalAppIntentPolicies': instance.externalAppIntentPolicies.map(
(k, e) => MapEntry(k, _$IntentSourcePolicyEnumMap[e]!),
),
};
const _$ThemeModeEnumMap = {
@@ -985,3 +1025,8 @@ const _$QuickTabSwitcherModeEnumMap = {
QuickTabSwitcherMode.lastUsedTabs: 'lastUsedTabs',
QuickTabSwitcherMode.containerTabs: 'containerTabs',
};
const _$IntentSourcePolicyEnumMap = {
IntentSourcePolicy.allow: 'allow',
IntentSourcePolicy.block: 'block',
};
@@ -231,6 +231,13 @@ class GeneralSettingsRepository extends _$GeneralSettingsRepository {
),
'allowNonManifestPwaInstall': settings['allowNonManifestPwaInstall']
?.readAs(DriftSqlType.bool, db.typeMapping),
'blockExternalAppsEnabled': settings['blockExternalAppsEnabled']?.readAs(
DriftSqlType.bool,
db.typeMapping,
),
'externalAppIntentPolicies': settings['externalAppIntentPolicies']
?.readAs(DriftSqlType.string, db.typeMapping)
.mapNotNull(jsonDecode),
});
}
@@ -35,7 +35,7 @@ final class GeneralSettingsRepositoryProvider
}
String _$generalSettingsRepositoryHash() =>
r'afc63f4d929ea146f0b8a7c0f6936b06c5a41024';
r'9d34ea4b802d2d1b1c9543f41ad5fdf28f279e06';
abstract class _$GeneralSettingsRepository
extends $StreamNotifier<GeneralSettings> {