From 57c3dcca863b92ffa0fafada1f9067311a689a34 Mon Sep 17 00:00:00 2001 From: Fabian Freund Date: Sun, 19 Apr 2026 02:23:04 +0200 Subject: [PATCH] intent gatekeeper initial --- .../widgets/browser_modules/browser_view.dart | 49 ++++++ .../domain/entities/intent_source_policy.dart | 20 +++ .../entities/pending_intent_decision.dart | 35 +++++ .../domain/services/intent_gatekeeper.dart | 115 ++++++++++++++ .../domain/services/intent_gatekeeper.g.dart | 60 +++++++ .../native_gatekeeper_replicator.dart | 87 +++++++++++ .../native_gatekeeper_replicator.g.dart | 82 ++++++++++ .../services/package_label_resolver.dart | 29 ++++ .../services/package_label_resolver.g.dart | 79 ++++++++++ .../widgets/intent_gatekeeper_dialog.dart | 143 +++++++++++++++++ .../screens/privacy_security_settings.dart | 146 ++++++++++++++++++ .../domain/services/sharing_intent.dart | 31 +++- .../domain/services/sharing_intent.g.dart | 2 +- .../user/data/database/daos/setting.dart | 4 +- .../user/data/models/general_settings.dart | 13 +- .../user/data/models/general_settings.g.dart | 45 ++++++ .../domain/repositories/general_settings.dart | 7 + .../repositories/general_settings.g.dart | 2 +- .../activities/IntentReceiverActivity.kt | 55 +++++++ .../gatekeeper/IntentBlockNotifier.kt | 90 +++++++++++ .../gatekeeper/IntentGatekeeperPreferences.kt | 37 +++++ .../IntentGatekeeperHostApiImpl.kt | 53 +++++++ .../SimpleIntentReceiverPlugin.kt | 63 +++++++- .../pigeons/Intent.g.kt | 82 ++++++++++ .../lib/simple_intent_receiver.dart | 2 +- .../lib/src/pigeons/intent.g.dart | 82 ++++++++++ .../pigeons/intent.dart | 12 ++ 27 files changed, 1413 insertions(+), 12 deletions(-) create mode 100644 apps/weblibre/lib/features/intent_gatekeeper/domain/entities/intent_source_policy.dart create mode 100644 apps/weblibre/lib/features/intent_gatekeeper/domain/entities/pending_intent_decision.dart create mode 100644 apps/weblibre/lib/features/intent_gatekeeper/domain/services/intent_gatekeeper.dart create mode 100644 apps/weblibre/lib/features/intent_gatekeeper/domain/services/intent_gatekeeper.g.dart create mode 100644 apps/weblibre/lib/features/intent_gatekeeper/domain/services/native_gatekeeper_replicator.dart create mode 100644 apps/weblibre/lib/features/intent_gatekeeper/domain/services/native_gatekeeper_replicator.g.dart create mode 100644 apps/weblibre/lib/features/intent_gatekeeper/domain/services/package_label_resolver.dart create mode 100644 apps/weblibre/lib/features/intent_gatekeeper/domain/services/package_label_resolver.g.dart create mode 100644 apps/weblibre/lib/features/intent_gatekeeper/presentation/widgets/intent_gatekeeper_dialog.dart create mode 100644 packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/gatekeeper/IntentBlockNotifier.kt create mode 100644 packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/gatekeeper/IntentGatekeeperPreferences.kt create mode 100644 packages/simple_intent_receiver/android/src/main/kotlin/eu/weblibre/simple_intent_receiver/IntentGatekeeperHostApiImpl.kt diff --git a/apps/weblibre/lib/features/geckoview/features/browser/presentation/widgets/browser_modules/browser_view.dart b/apps/weblibre/lib/features/geckoview/features/browser/presentation/widgets/browser_modules/browser_view.dart index 376841ee..c1ea3e95 100644 --- a/apps/weblibre/lib/features/geckoview/features/browser/presentation/widgets/browser_modules/browser_view.dart +++ b/apps/weblibre/lib/features/geckoview/features/browser/presentation/widgets/browser_modules/browser_view.dart @@ -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 } }); + ref.listenManual>( + 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( + 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 }, ); + 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, diff --git a/apps/weblibre/lib/features/intent_gatekeeper/domain/entities/intent_source_policy.dart b/apps/weblibre/lib/features/intent_gatekeeper/domain/entities/intent_source_policy.dart new file mode 100644 index 00000000..900e9e63 --- /dev/null +++ b/apps/weblibre/lib/features/intent_gatekeeper/domain/entities/intent_source_policy.dart @@ -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 . + */ +enum IntentSourcePolicy { allow, block } diff --git a/apps/weblibre/lib/features/intent_gatekeeper/domain/entities/pending_intent_decision.dart b/apps/weblibre/lib/features/intent_gatekeeper/domain/entities/pending_intent_decision.dart new file mode 100644 index 00000000..7e097183 --- /dev/null +++ b/apps/weblibre/lib/features/intent_gatekeeper/domain/entities/pending_intent_decision.dart @@ -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 . + */ +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 get hashParameters => [id, packageName, url]; +} diff --git a/apps/weblibre/lib/features/intent_gatekeeper/domain/services/intent_gatekeeper.dart b/apps/weblibre/lib/features/intent_gatekeeper/domain/services/intent_gatekeeper.dart new file mode 100644 index 00000000..8acc22d3 --- /dev/null +++ b/apps/weblibre/lib/features/intent_gatekeeper/domain/services/intent_gatekeeper.dart @@ -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 . + */ +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 _decisionRequests; + final _pending = >{}; + int _nextId = 0; + + @override + Stream build() { + _decisionRequests = StreamController.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 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(); + _pending[id] = completer; + + _decisionRequests.add( + PendingIntentDecision(id: id, packageName: fromPackageName, url: url), + ); + + return completer.future; + } + + Future 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, + }), + ); + } + } +} diff --git a/apps/weblibre/lib/features/intent_gatekeeper/domain/services/intent_gatekeeper.g.dart b/apps/weblibre/lib/features/intent_gatekeeper/domain/services/intent_gatekeeper.g.dart new file mode 100644 index 00000000..50adfc37 --- /dev/null +++ b/apps/weblibre/lib/features/intent_gatekeeper/domain/services/intent_gatekeeper.g.dart @@ -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 { + 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 { + Stream build(); + @$mustCallSuper + @override + void runBuild() { + final ref = + this.ref + as $Ref, PendingIntentDecision>; + final element = + ref.element + as $ClassProviderElement< + AnyNotifier< + AsyncValue, + PendingIntentDecision + >, + AsyncValue, + Object?, + Object? + >; + element.handleCreate(ref, build); + } +} diff --git a/apps/weblibre/lib/features/intent_gatekeeper/domain/services/native_gatekeeper_replicator.dart b/apps/weblibre/lib/features/intent_gatekeeper/domain/services/native_gatekeeper_replicator.dart new file mode 100644 index 00000000..9e3d79cd --- /dev/null +++ b/apps/weblibre/lib/features/intent_gatekeeper/domain/services/native_gatekeeper_replicator.dart @@ -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 . + */ +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 _push( + ({bool enabled, Map 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)); + }, + ); + } +} diff --git a/apps/weblibre/lib/features/intent_gatekeeper/domain/services/native_gatekeeper_replicator.g.dart b/apps/weblibre/lib/features/intent_gatekeeper/domain/services/native_gatekeeper_replicator.g.dart new file mode 100644 index 00000000..6e4e834a --- /dev/null +++ b/apps/weblibre/lib/features/intent_gatekeeper/domain/services/native_gatekeeper_replicator.g.dart @@ -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 { + /// 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(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 build(); + @$mustCallSuper + @override + void runBuild() { + final ref = this.ref as $Ref; + final element = + ref.element + as $ClassProviderElement< + AnyNotifier, + void, + Object?, + Object? + >; + element.handleCreate(ref, build); + } +} diff --git a/apps/weblibre/lib/features/intent_gatekeeper/domain/services/package_label_resolver.dart b/apps/weblibre/lib/features/intent_gatekeeper/domain/services/package_label_resolver.dart new file mode 100644 index 00000000..c3978d29 --- /dev/null +++ b/apps/weblibre/lib/features/intent_gatekeeper/domain/services/package_label_resolver.dart @@ -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 . + */ +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 packageLabel(Ref ref, String packageName) { + final api = IntentGatekeeperHostApi(); + return api.resolvePackageLabel(packageName); +} diff --git a/apps/weblibre/lib/features/intent_gatekeeper/domain/services/package_label_resolver.g.dart b/apps/weblibre/lib/features/intent_gatekeeper/domain/services/package_label_resolver.g.dart new file mode 100644 index 00000000..15dc8343 --- /dev/null +++ b/apps/weblibre/lib/features/intent_gatekeeper/domain/services/package_label_resolver.g.dart @@ -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, String?, FutureOr> + with $FutureModifier, $FutureProvider { + 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 $createElement($ProviderPointer pointer) => + $FutureProviderElement(pointer); + + @override + FutureOr 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, 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'; +} diff --git a/apps/weblibre/lib/features/intent_gatekeeper/presentation/widgets/intent_gatekeeper_dialog.dart b/apps/weblibre/lib/features/intent_gatekeeper/presentation/widgets/intent_gatekeeper_dialog.dart new file mode 100644 index 00000000..a1740d8f --- /dev/null +++ b/apps/weblibre/lib/features/intent_gatekeeper/presentation/widgets/intent_gatekeeper_dialog.dart @@ -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 . + */ +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'), + ), + ], + ), + ], + ); + } +} diff --git a/apps/weblibre/lib/features/settings/presentation/screens/privacy_security_settings.dart b/apps/weblibre/lib/features/settings/presentation/screens/privacy_security_settings.dart index b6c34f38..94d434a0 100644 --- a/apps/weblibre/lib/features/settings/presentation/screens/privacy_security_settings.dart +++ b/apps/weblibre/lib/features/settings/presentation/screens/privacy_security_settings.dart @@ -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 _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 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 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(); diff --git a/apps/weblibre/lib/features/share_intent/domain/services/sharing_intent.dart b/apps/weblibre/lib/features/share_intent/domain/services/sharing_intent.dart index 6afb1c7c..7c3b2f07 100644 --- a/apps/weblibre/lib/features/share_intent/domain/services/sharing_intent.dart +++ b/apps/weblibre/lib/features/share_intent/domain/services/sharing_intent.dart @@ -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 +_buildSharingIntentTransformer(IntentGatekeeper gatekeeper) => StreamTransformer.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> sharingIntentStream(Ref ref) { final receiver = IntentReceiver.setUp(); + final gatekeeper = ref.watch(intentGatekeeperProvider.notifier); - return receiver.events.transform(_sharingIntentTransformer); + return receiver.events.transform(_buildSharingIntentTransformer(gatekeeper)); } diff --git a/apps/weblibre/lib/features/share_intent/domain/services/sharing_intent.g.dart b/apps/weblibre/lib/features/share_intent/domain/services/sharing_intent.g.dart index 6eb025b6..e6197409 100644 --- a/apps/weblibre/lib/features/share_intent/domain/services/sharing_intent.g.dart +++ b/apps/weblibre/lib/features/share_intent/domain/services/sharing_intent.g.dart @@ -56,4 +56,4 @@ final class SharingIntentStreamProvider } String _$sharingIntentStreamHash() => - r'486f994fc0e01a2cffdb19d93f0a332f96a9850c'; + r'21b189c5df56f81ed5ab88115c11ddce1189cfb7'; diff --git a/apps/weblibre/lib/features/user/data/database/daos/setting.dart b/apps/weblibre/lib/features/user/data/database/daos/setting.dart index 6e018e27..6f4b5709 100644 --- a/apps/weblibre/lib/features/user/data/database/daos/setting.dart +++ b/apps/weblibre/lib/features/user/data/database/daos/setting.dart @@ -30,7 +30,9 @@ class SettingDao extends DatabaseAccessor with $SettingDaoMixin { SettingDao(super.attachedDatabase); Future 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), diff --git a/apps/weblibre/lib/features/user/data/models/general_settings.dart b/apps/weblibre/lib/features/user/data/models/general_settings.dart index c82e4fa0..4abfbb77 100644 --- a/apps/weblibre/lib/features/user/data/models/general_settings.dart +++ b/apps/weblibre/lib/features/user/data/models/general_settings.dart @@ -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 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? 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 json) => _$GeneralSettingsFromJson(json); @@ -353,5 +362,7 @@ class GeneralSettings with FastEquatable { unshortenerEnabled, unshortenerToken, allowNonManifestPwaInstall, + blockExternalAppsEnabled, + externalAppIntentPolicies, ]; } diff --git a/apps/weblibre/lib/features/user/data/models/general_settings.g.dart b/apps/weblibre/lib/features/user/data/models/general_settings.g.dart index 152d8e95..ac7631fe 100644 --- a/apps/weblibre/lib/features/user/data/models/general_settings.g.dart +++ b/apps/weblibre/lib/features/user/data/models/general_settings.g.dart @@ -123,6 +123,12 @@ abstract class _$GeneralSettingsCWProxy { GeneralSettings allowNonManifestPwaInstall(bool allowNonManifestPwaInstall); + GeneralSettings blockExternalAppsEnabled(bool blockExternalAppsEnabled); + + GeneralSettings externalAppIntentPolicies( + Map 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 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 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, ); } } @@ -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?)?.map( + (k, e) => MapEntry(k, $enumDecode(_$IntentSourcePolicyEnumMap, e)), + ), ); Map _$GeneralSettingsToJson( @@ -923,6 +959,10 @@ Map _$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', +}; diff --git a/apps/weblibre/lib/features/user/domain/repositories/general_settings.dart b/apps/weblibre/lib/features/user/domain/repositories/general_settings.dart index 25394332..0cea99e8 100644 --- a/apps/weblibre/lib/features/user/domain/repositories/general_settings.dart +++ b/apps/weblibre/lib/features/user/domain/repositories/general_settings.dart @@ -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), }); } diff --git a/apps/weblibre/lib/features/user/domain/repositories/general_settings.g.dart b/apps/weblibre/lib/features/user/domain/repositories/general_settings.g.dart index 38fdab90..9e2c9fdc 100644 --- a/apps/weblibre/lib/features/user/domain/repositories/general_settings.g.dart +++ b/apps/weblibre/lib/features/user/domain/repositories/general_settings.g.dart @@ -35,7 +35,7 @@ final class GeneralSettingsRepositoryProvider } String _$generalSettingsRepositoryHash() => - r'afc63f4d929ea146f0b8a7c0f6936b06c5a41024'; + r'9d34ea4b802d2d1b1c9543f41ad5fdf28f279e06'; abstract class _$GeneralSettingsRepository extends $StreamNotifier { diff --git a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/activities/IntentReceiverActivity.kt b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/activities/IntentReceiverActivity.kt index d0fab34c..506c8e2b 100644 --- a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/activities/IntentReceiverActivity.kt +++ b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/activities/IntentReceiverActivity.kt @@ -11,12 +11,15 @@ import android.app.AlertDialog import android.content.Context import android.content.Intent import android.content.pm.ShortcutManager +import android.net.Uri import android.os.Build import android.os.Bundle import android.util.Log import eu.weblibre.flutter_mozilla_components.Components import eu.weblibre.flutter_mozilla_components.GlobalComponents import eu.weblibre.flutter_mozilla_components.PwaConstants +import eu.weblibre.flutter_mozilla_components.gatekeeper.IntentBlockNotifier +import eu.weblibre.flutter_mozilla_components.gatekeeper.IntentGatekeeperPreferences import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob @@ -58,9 +61,55 @@ class IntentReceiverActivity : Activity() { intent.flags = intent.flags and Intent.FLAG_ACTIVITY_NEW_TASK.inv() intent.flags = intent.flags and Intent.FLAG_ACTIVITY_CLEAR_TASK.inv() + if (shouldBlockIntent(intent)) { + finish() + return + } + processIntent(intent) } + /** + * Fast native block-check. Only rejects packages explicitly on the blocked + * list; allowed and unknown packages fall through to the Flutter-side + * gatekeeper which can still prompt the user. + * + * PWA launches carrying our trusted profile metadata are never blocked here — + * those are treated as internal launches regardless of the caller. + */ + private fun shouldBlockIntent(intent: Intent): Boolean { + if (!IntentGatekeeperPreferences.isEnabled(applicationContext)) return false + if (intent.hasExtra(PwaConstants.EXTRA_PWA_PROFILE_UUID)) return false + + val caller = resolveCallerPackage(intent) ?: return false + if (caller == packageName) return false + if (!IntentGatekeeperPreferences.isBlocked(applicationContext, caller)) return false + + Log.i(TAG, "Blocking intent from $caller (native gatekeeper)") + IntentBlockNotifier.notifyBlocked(applicationContext, caller) + return true + } + + private fun resolveCallerPackage(intent: Intent): String? { + referrer?.let { uri -> + if (uri.scheme == "android-app") { + uri.host?.let { return it } + } + } + + @Suppress("DEPRECATION") + val referrerUri: Uri? = intent.getParcelableExtra(Intent.EXTRA_REFERRER) + if (referrerUri?.scheme == "android-app") { + referrerUri.host?.let { return it } + } + + intent.getStringExtra(Intent.EXTRA_REFERRER_NAME)?.let { name -> + Uri.parse(name).takeIf { it.scheme == "android-app" }?.host?.let { return it } + } + + return callingPackage + } + override fun onDestroy() { super.onDestroy() coroutineScope.cancel() @@ -439,6 +488,12 @@ class IntentReceiverActivity : Activity() { val mainActivityIntent = Intent(intent).apply { setClassName(this@IntentReceiverActivity, "eu.weblibre.gecko.MainActivity") addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + // Preserve the original caller so the gatekeeper on the Flutter side + // can identify which app triggered this intent (getReferrer() in the + // forwarded activity would otherwise resolve to ourselves). + if (!hasExtra(Intent.EXTRA_REFERRER) && !hasExtra(Intent.EXTRA_REFERRER_NAME)) { + referrer?.let { putExtra(Intent.EXTRA_REFERRER, it) } + } } startActivity(mainActivityIntent) finish() diff --git a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/gatekeeper/IntentBlockNotifier.kt b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/gatekeeper/IntentBlockNotifier.kt new file mode 100644 index 00000000..6699771f --- /dev/null +++ b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/gatekeeper/IntentBlockNotifier.kt @@ -0,0 +1,90 @@ +/* + * Copyright (c) 2024-2026 Fabian Freund. + * + * This file is part of WebLibre + * (see https://weblibre.eu). + */ +package eu.weblibre.flutter_mozilla_components.gatekeeper + +import android.app.Notification +import android.app.NotificationChannel +import android.app.NotificationManager +import android.content.Context +import android.content.pm.PackageManager +import android.os.Build +import androidx.core.app.NotificationCompat +import androidx.core.content.ContextCompat +import eu.weblibre.flutter_mozilla_components.R + +/** + * Posts a purely informational notification when an intent is blocked by the + * gatekeeper. The notification has no actions and no content intent. + */ +object IntentBlockNotifier { + private const val CHANNEL_ID = "intent_gatekeeper_channel" + private const val CHANNEL_NAME = "Blocked app launches" + private const val CHANNEL_DESC = "Informs you when another app is prevented from opening WebLibre." + + fun notifyBlocked(context: Context, packageName: String) { + val appCtx = context.applicationContext + ensureChannel(appCtx) + + val label = resolveAppLabel(appCtx, packageName) ?: packageName + val notificationId = (System.currentTimeMillis() and 0x7FFFFFFF).toInt() + + val notification: Notification = NotificationCompat.Builder(appCtx, CHANNEL_ID) + .setSmallIcon(R.drawable.ic_launcher_foreground) + .setContentTitle("Blocked app launch") + .setContentText("Prevented $label from opening WebLibre.") + .setStyle( + NotificationCompat.BigTextStyle() + .bigText("Prevented $label from opening WebLibre.") + ) + .setPriority(NotificationCompat.PRIORITY_DEFAULT) + .setSilent(true) + .setAutoCancel(true) + .setShowWhen(true) + .build() + + val manager = ContextCompat.getSystemService(appCtx, NotificationManager::class.java) + ?: return + manager.notify(notificationId, notification) + } + + private fun ensureChannel(context: Context) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return + val manager = ContextCompat.getSystemService(context, NotificationManager::class.java) + ?: return + if (manager.getNotificationChannel(CHANNEL_ID) != null) return + + val channel = NotificationChannel( + CHANNEL_ID, + CHANNEL_NAME, + NotificationManager.IMPORTANCE_DEFAULT, + ).apply { + description = CHANNEL_DESC + setShowBadge(false) + } + manager.createNotificationChannel(channel) + } + + private fun resolveAppLabel(context: Context, packageName: String): String? { + return try { + val pm = context.packageManager + val info = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + pm.getApplicationInfo( + packageName, + PackageManager.ApplicationInfoFlags.of(0), + ) + } else { + @Suppress("DEPRECATION") + pm.getApplicationInfo(packageName, 0) + } + pm.getApplicationLabel(info).toString() + } catch (_: PackageManager.NameNotFoundException) { + null + } catch (_: Exception) { + null + } + } +} diff --git a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/gatekeeper/IntentGatekeeperPreferences.kt b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/gatekeeper/IntentGatekeeperPreferences.kt new file mode 100644 index 00000000..64f889f7 --- /dev/null +++ b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/gatekeeper/IntentGatekeeperPreferences.kt @@ -0,0 +1,37 @@ +/* + * Copyright (c) 2024-2026 Fabian Freund. + * + * This file is part of WebLibre + * (see https://weblibre.eu). + */ +package eu.weblibre.flutter_mozilla_components.gatekeeper + +import android.content.Context +import android.content.SharedPreferences + +/** + * Cross-package shared-prefs file used to replicate the Flutter-side intent + * gatekeeper policy to the native side so [IntentReceiverActivity] can block + * intents without launching Flutter. + * + * The file name is a stable constant: other packages (e.g. simple_intent_receiver) + * write to the same file using [Context.getSharedPreferences] with this name. + */ +object IntentGatekeeperPreferences { + const val PREFS_NAME = "weblibre_intent_gatekeeper" + const val KEY_ENABLED = "enabled" + const val KEY_BLOCKED_PACKAGES = "blocked_packages" + + fun get(context: Context): SharedPreferences = + context.applicationContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + + fun isEnabled(context: Context): Boolean = + get(context).getBoolean(KEY_ENABLED, false) + + fun isBlocked(context: Context, packageName: String): Boolean { + val prefs = get(context) + if (!prefs.getBoolean(KEY_ENABLED, false)) return false + val blocked = prefs.getStringSet(KEY_BLOCKED_PACKAGES, emptySet()) ?: return false + return packageName in blocked + } +} diff --git a/packages/simple_intent_receiver/android/src/main/kotlin/eu/weblibre/simple_intent_receiver/IntentGatekeeperHostApiImpl.kt b/packages/simple_intent_receiver/android/src/main/kotlin/eu/weblibre/simple_intent_receiver/IntentGatekeeperHostApiImpl.kt new file mode 100644 index 00000000..3db7e80f --- /dev/null +++ b/packages/simple_intent_receiver/android/src/main/kotlin/eu/weblibre/simple_intent_receiver/IntentGatekeeperHostApiImpl.kt @@ -0,0 +1,53 @@ +/* + * Copyright (c) 2024-2026 Fabian Freund. + * + * This file is part of WebLibre + * (see https://weblibre.eu). + */ +package eu.weblibre.simple_intent_receiver + +import android.content.Context +import android.content.pm.PackageManager +import android.os.Build +import eu.weblibre.simple_intent_receiver.pigeons.IntentGatekeeperHostApi + +/** + * Persists the Flutter-side gatekeeper policy to a shared-prefs file that + * [eu.weblibre.flutter_mozilla_components.activities.IntentReceiverActivity] + * reads on each incoming intent. + * + * The prefs file name MUST match + * [eu.weblibre.flutter_mozilla_components.gatekeeper.IntentGatekeeperPreferences.PREFS_NAME]. + */ +class IntentGatekeeperHostApiImpl(private val context: Context) : IntentGatekeeperHostApi { + companion object { + private const val PREFS_NAME = "weblibre_intent_gatekeeper" + private const val KEY_ENABLED = "enabled" + private const val KEY_BLOCKED_PACKAGES = "blocked_packages" + } + + override fun setConfig(enabled: Boolean, blockedPackages: List) { + val prefs = context.applicationContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + prefs.edit() + .putBoolean(KEY_ENABLED, enabled) + .putStringSet(KEY_BLOCKED_PACKAGES, blockedPackages.toSet()) + .apply() + } + + override fun resolvePackageLabel(packageName: String): String? { + return try { + val pm = context.applicationContext.packageManager + val info = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + pm.getApplicationInfo(packageName, PackageManager.ApplicationInfoFlags.of(0)) + } else { + @Suppress("DEPRECATION") + pm.getApplicationInfo(packageName, 0) + } + pm.getApplicationLabel(info).toString() + } catch (_: PackageManager.NameNotFoundException) { + null + } catch (_: Exception) { + null + } + } +} diff --git a/packages/simple_intent_receiver/android/src/main/kotlin/eu/weblibre/simple_intent_receiver/SimpleIntentReceiverPlugin.kt b/packages/simple_intent_receiver/android/src/main/kotlin/eu/weblibre/simple_intent_receiver/SimpleIntentReceiverPlugin.kt index c9dc16fe..c3e8909f 100644 --- a/packages/simple_intent_receiver/android/src/main/kotlin/eu/weblibre/simple_intent_receiver/SimpleIntentReceiverPlugin.kt +++ b/packages/simple_intent_receiver/android/src/main/kotlin/eu/weblibre/simple_intent_receiver/SimpleIntentReceiverPlugin.kt @@ -22,7 +22,10 @@ package eu.weblibre.simple_intent_receiver import android.app.Activity import android.content.Context import android.content.Intent +import android.content.pm.ApplicationInfo +import android.content.pm.PackageManager import android.net.Uri +import android.os.Build import android.os.Bundle import io.flutter.Log import io.flutter.embedding.engine.plugins.FlutterPlugin @@ -30,20 +33,29 @@ import io.flutter.embedding.engine.plugins.activity.ActivityAware import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding import io.flutter.plugin.common.PluginRegistry import eu.weblibre.simple_intent_receiver.pigeons.Intent as PigeonIntent +import eu.weblibre.simple_intent_receiver.pigeons.IntentGatekeeperHostApi class SimpleIntentReceiverPlugin: FlutterPlugin, ActivityAware, PluginRegistry.NewIntentListener { private lateinit var context: Context private var intentReceiver: IntentReceiver? = null private var lastHandledIntent: String? = null private var activity: Activity? = null + private var binaryMessenger: io.flutter.plugin.common.BinaryMessenger? = null override fun onAttachedToEngine(flutterPluginBinding: FlutterPlugin.FlutterPluginBinding) { context = flutterPluginBinding.applicationContext intentReceiver = IntentReceiver(flutterPluginBinding.binaryMessenger) + binaryMessenger = flutterPluginBinding.binaryMessenger + IntentGatekeeperHostApi.setUp( + flutterPluginBinding.binaryMessenger, + IntentGatekeeperHostApiImpl(flutterPluginBinding.applicationContext), + ) } override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) { intentReceiver = null + binaryMessenger?.let { IntentGatekeeperHostApi.setUp(it, null) } + binaryMessenger = null } override fun onAttachedToActivity(binding: ActivityPluginBinding) { @@ -118,10 +130,59 @@ class SimpleIntentReceiverPlugin: FlutterPlugin, ActivityAware, PluginRegistry.N return true } + private fun resolveCallerPackage(intent: Intent): String? { + val raw = resolveRawCallerPackage(intent) ?: return null + // Treat system packages (launcher, shell, SystemUI, etc.) as internal — the + // gatekeeper shouldn't prompt the user when the OS itself forwards an intent. + if (isSystemPackage(raw)) return null + return raw + } + + private fun resolveRawCallerPackage(intent: Intent): String? { + // 1. Try Activity.getReferrer() — handles EXTRA_REFERRER/_NAME and real caller. + activity?.referrer?.let { uri -> + if (uri.scheme == "android-app") { + uri.host?.let { return it } + } + } + + // 2. Fallback to explicit referrer extras on the intent itself. + @Suppress("DEPRECATION") + val referrerUri: Uri? = intent.getParcelableExtra(Intent.EXTRA_REFERRER) + if (referrerUri?.scheme == "android-app") { + referrerUri.host?.let { return it } + } + + intent.getStringExtra(Intent.EXTRA_REFERRER_NAME)?.let { name -> + Uri.parse(name).takeIf { it.scheme == "android-app" }?.host?.let { return it } + } + + // 3. Caller for startActivityForResult flows. + return activity?.callingPackage + } + + private fun isSystemPackage(packageName: String): Boolean { + return try { + val pm = context.packageManager + val info = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + pm.getApplicationInfo(packageName, PackageManager.ApplicationInfoFlags.of(0)) + } else { + @Suppress("DEPRECATION") + pm.getApplicationInfo(packageName, 0) + } + val systemFlags = ApplicationInfo.FLAG_SYSTEM or ApplicationInfo.FLAG_UPDATED_SYSTEM_APP + (info.flags and systemFlags) != 0 + } catch (_: PackageManager.NameNotFoundException) { + false + } catch (_: Exception) { + false + } + } + private fun convertToPigeonIntent(intent: Intent): PigeonIntent { val action = intent.action val data = intent.dataString - val fromPackageName = intent.getPackage() + val fromPackageName = resolveCallerPackage(intent) val categories = ArrayList() intent.categories?.let { diff --git a/packages/simple_intent_receiver/android/src/main/kotlin/eu/weblibre/simple_intent_receiver/pigeons/Intent.g.kt b/packages/simple_intent_receiver/android/src/main/kotlin/eu/weblibre/simple_intent_receiver/pigeons/Intent.g.kt index e3324e65..677bb72e 100644 --- a/packages/simple_intent_receiver/android/src/main/kotlin/eu/weblibre/simple_intent_receiver/pigeons/Intent.g.kt +++ b/packages/simple_intent_receiver/android/src/main/kotlin/eu/weblibre/simple_intent_receiver/pigeons/Intent.g.kt @@ -17,6 +17,26 @@ private object IntentPigeonUtils { fun createConnectionError(channelName: String): FlutterError { return FlutterError("channel-error", "Unable to establish connection on channel: '$channelName'.", "") } + + fun wrapResult(result: Any?): List { + return listOf(result) + } + + fun wrapError(exception: Throwable): List { + return if (exception is FlutterError) { + listOf( + exception.code, + exception.message, + exception.details + ) + } else { + listOf( + exception.javaClass.simpleName, + exception.toString(), + "Cause: " + exception.cause + ", Stacktrace: " + Log.getStackTraceString(exception) + ) + } + } fun doubleEquals(a: Double, b: Double): Boolean { // Normalize -0.0 to 0.0 and handle NaN equality. return (if (a == 0.0) 0.0 else a) == (if (b == 0.0) 0.0 else b) || (a.isNaN() && b.isNaN()) @@ -276,3 +296,65 @@ class IntentEvents(private val binaryMessenger: BinaryMessenger, private val mes } } } +/** Generated interface from Pigeon that represents a handler of messages from Flutter. */ +interface IntentGatekeeperHostApi { + /** + * Replicates the blocked-packages policy to the native side so the + * [IntentReceiverActivity] can reject intents without launching Flutter. + */ + fun setConfig(enabled: Boolean, blockedPackages: List) + /** + * Resolves a package name to its user-visible application label via + * [PackageManager]. Returns `null` if the package is not installed or the + * label cannot be resolved. + */ + fun resolvePackageLabel(packageName: String): String? + + companion object { + /** The codec used by IntentGatekeeperHostApi. */ + val codec: MessageCodec by lazy { + IntentPigeonCodec() + } + /** Sets up an instance of `IntentGatekeeperHostApi` to handle messages through the `binaryMessenger`. */ + @JvmOverloads + fun setUp(binaryMessenger: BinaryMessenger, api: IntentGatekeeperHostApi?, messageChannelSuffix: String = "") { + val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else "" + run { + val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.simple_intent_receiver.IntentGatekeeperHostApi.setConfig$separatedMessageChannelSuffix", codec) + if (api != null) { + channel.setMessageHandler { message, reply -> + val args = message as List + val enabledArg = args[0] as Boolean + val blockedPackagesArg = args[1] as List + val wrapped: List = try { + api.setConfig(enabledArg, blockedPackagesArg) + listOf(null) + } catch (exception: Throwable) { + IntentPigeonUtils.wrapError(exception) + } + reply.reply(wrapped) + } + } else { + channel.setMessageHandler(null) + } + } + run { + val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.simple_intent_receiver.IntentGatekeeperHostApi.resolvePackageLabel$separatedMessageChannelSuffix", codec) + if (api != null) { + channel.setMessageHandler { message, reply -> + val args = message as List + val packageNameArg = args[0] as String + val wrapped: List = try { + listOf(api.resolvePackageLabel(packageNameArg)) + } catch (exception: Throwable) { + IntentPigeonUtils.wrapError(exception) + } + reply.reply(wrapped) + } + } else { + channel.setMessageHandler(null) + } + } + } + } +} diff --git a/packages/simple_intent_receiver/lib/simple_intent_receiver.dart b/packages/simple_intent_receiver/lib/simple_intent_receiver.dart index e3c68a4b..fa1aae9f 100644 --- a/packages/simple_intent_receiver/lib/simple_intent_receiver.dart +++ b/packages/simple_intent_receiver/lib/simple_intent_receiver.dart @@ -18,4 +18,4 @@ * along with this program. If not, see . */ export 'src/intent_receiver.dart'; -export 'src/pigeons/intent.g.dart' show Intent; +export 'src/pigeons/intent.g.dart' show Intent, IntentGatekeeperHostApi; diff --git a/packages/simple_intent_receiver/lib/src/pigeons/intent.g.dart b/packages/simple_intent_receiver/lib/src/pigeons/intent.g.dart index 5df0e2cc..f153a03d 100644 --- a/packages/simple_intent_receiver/lib/src/pigeons/intent.g.dart +++ b/packages/simple_intent_receiver/lib/src/pigeons/intent.g.dart @@ -9,6 +9,32 @@ import 'dart:typed_data' show Float64List, Int32List, Int64List; import 'package:flutter/services.dart'; import 'package:meta/meta.dart' show immutable, protected, visibleForTesting; +Object? _extractReplyValueOrThrow( + List? replyList, + String channelName, { + required bool isNullValid, +}) { + if (replyList == null) { + throw PlatformException( + code: 'channel-error', + message: 'Unable to establish connection on channel: "$channelName".', + ); + } else if (replyList.length > 1) { + throw PlatformException( + code: replyList[0]! as String, + message: replyList[1] as String?, + details: replyList[2], + ); + } else if (!isNullValid && (replyList.isNotEmpty && replyList[0] == null)) { + throw PlatformException( + code: 'null-error', + message: 'Host platform returned null value for non-null return value.', + ); + } + return replyList.firstOrNull; +} + + List wrapResponse({Object? result, PlatformException? error, bool empty = false}) { if (empty) { return []; @@ -204,3 +230,59 @@ abstract class IntentEvents { } } } + +class IntentGatekeeperHostApi { + /// Constructor for [IntentGatekeeperHostApi]. The [binaryMessenger] named argument is + /// available for dependency injection. If it is left null, the default + /// BinaryMessenger will be used which routes to the host platform. + IntentGatekeeperHostApi({BinaryMessenger? binaryMessenger, String messageChannelSuffix = ''}) + : pigeonVar_binaryMessenger = binaryMessenger, + pigeonVar_messageChannelSuffix = messageChannelSuffix.isNotEmpty ? '.$messageChannelSuffix' : ''; + final BinaryMessenger? pigeonVar_binaryMessenger; + + static const MessageCodec pigeonChannelCodec = _PigeonCodec(); + + final String pigeonVar_messageChannelSuffix; + + /// Replicates the blocked-packages policy to the native side so the + /// [IntentReceiverActivity] can reject intents without launching Flutter. + Future setConfig(bool enabled, List blockedPackages) async { + final pigeonVar_channelName = 'dev.flutter.pigeon.simple_intent_receiver.IntentGatekeeperHostApi.setConfig$pigeonVar_messageChannelSuffix'; + final pigeonVar_channel = BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, + ); + final Future pigeonVar_sendFuture = pigeonVar_channel.send([enabled, blockedPackages]); + final pigeonVar_replyList = await pigeonVar_sendFuture as List?; + + _extractReplyValueOrThrow( + pigeonVar_replyList, + pigeonVar_channelName, + isNullValid: true, + ) + ; + } + + /// Resolves a package name to its user-visible application label via + /// [PackageManager]. Returns `null` if the package is not installed or the + /// label cannot be resolved. + Future resolvePackageLabel(String packageName) async { + final pigeonVar_channelName = 'dev.flutter.pigeon.simple_intent_receiver.IntentGatekeeperHostApi.resolvePackageLabel$pigeonVar_messageChannelSuffix'; + final pigeonVar_channel = BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, + ); + final Future pigeonVar_sendFuture = pigeonVar_channel.send([packageName]); + final pigeonVar_replyList = await pigeonVar_sendFuture as List?; + + final Object? pigeonVar_replyValue = _extractReplyValueOrThrow( + pigeonVar_replyList, + pigeonVar_channelName, + isNullValid: true, + ) + ; + return pigeonVar_replyValue as String?; + } +} diff --git a/packages/simple_intent_receiver/pigeons/intent.dart b/packages/simple_intent_receiver/pigeons/intent.dart index 9299c715..9f8f2757 100644 --- a/packages/simple_intent_receiver/pigeons/intent.dart +++ b/packages/simple_intent_receiver/pigeons/intent.dart @@ -53,3 +53,15 @@ class Intent { abstract class IntentEvents { void onIntentReceived(int sequence, Intent intent); } + +@HostApi() +abstract class IntentGatekeeperHostApi { + /// Replicates the blocked-packages policy to the native side so the + /// [IntentReceiverActivity] can reject intents without launching Flutter. + void setConfig(bool enabled, List blockedPackages); + + /// Resolves a package name to its user-visible application label via + /// [PackageManager]. Returns `null` if the package is not installed or the + /// label cannot be resolved. + String? resolvePackageLabel(String packageName); +}