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/providers/selected_container.dart';
import 'package:weblibre/features/geckoview/features/tabs/domain/repositories/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/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/share_intent/domain/entities/shared_content.dart';
import 'package:weblibre/features/user/data/models/general_settings.dart'; import 'package:weblibre/features/user/data/models/general_settings.dart';
import 'package:weblibre/features/user/domain/providers/profile_auth.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( ref.listenManual(
engineBoundIntentStreamProvider, engineBoundIntentStreamProvider,
(previous, next) { (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( ref.listenManual(
fireImmediately: true, fireImmediately: true,
selectionActionServiceProvider, 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:nullability/nullability.dart';
import 'package:weblibre/core/routing/routes.dart'; import 'package:weblibre/core/routing/routes.dart';
import 'package:weblibre/features/geckoview/features/browser/presentation/dialogs/delete_data.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/controllers/save_settings.dart';
import 'package:weblibre/features/settings/presentation/widgets/sections.dart'; import 'package:weblibre/features/settings/presentation/widgets/sections.dart';
import 'package:weblibre/features/user/data/models/engine_settings.dart'; import 'package:weblibre/features/user/data/models/engine_settings.dart';
@@ -54,6 +56,7 @@ class PrivacySecuritySettingsScreen extends StatelessWidget {
_ConnectionSecuritySection(), _ConnectionSecuritySection(),
_NetworkProtectionSection(), _NetworkProtectionSection(),
_PrivacySignalsSection(), _PrivacySignalsSection(),
_AppOpeningProtectionSection(),
_DataManagementSection(), _DataManagementSection(),
_SafeBrowsingSection(), _SafeBrowsingSection(),
_AdvancedSecuritySection(), _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 { class _NetworkProtectionSection extends StatelessWidget {
const _NetworkProtectionSection(); 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:uri_to_file/uri_to_file.dart' as uri_to_file;
import 'package:weblibre/core/logger.dart'; import 'package:weblibre/core/logger.dart';
import 'package:weblibre/data/models/received_intent_parameter.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'; part 'sharing_intent.g.dart';
final _sharingIntentTransformer = StreamTransformer<Intent, ReceivedIntentParameter>
_buildSharingIntentTransformer(IntentGatekeeper gatekeeper) =>
StreamTransformer<Intent, ReceivedIntentParameter>.fromHandlers( StreamTransformer<Intent, ReceivedIntentParameter>.fromHandlers(
handleData: (intent, sink) async { 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) { final data = switch (intent.action) {
'android.intent.action.PROCESS_TEXT' => 'android.intent.action.PROCESS_TEXT' =>
intent.extra['android.intent.extra.PROCESS_TEXT'] as String?, intent.extra['android.intent.extra.PROCESS_TEXT'] as String?,
@@ -46,10 +67,7 @@ final _sharingIntentTransformer =
}; };
// Extract container context from shortcut intents // Extract container context from shortcut intents
final contextId = final contextId = pwaContextId;
intent.action == 'android.intent.action.VIEW'
? intent.extra['pwa_context_id'] as String?
: null;
if (data != null) { if (data != null) {
if (uri_to_file.isUriSupported(data)) { if (uri_to_file.isUriSupported(data)) {
@@ -101,6 +119,7 @@ final _sharingIntentTransformer =
@Riverpod(keepAlive: true) @Riverpod(keepAlive: true)
Raw<Stream<ReceivedIntentParameter>> sharingIntentStream(Ref ref) { Raw<Stream<ReceivedIntentParameter>> sharingIntentStream(Ref ref) {
final receiver = IntentReceiver.setUp(); 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() => String _$sharingIntentStreamHash() =>
r'486f994fc0e01a2cffdb19d93f0a332f96a9850c'; r'21b189c5df56f81ed5ab88115c11ddce1189cfb7';
@@ -30,7 +30,9 @@ class SettingDao extends DatabaseAccessor<UserDatabase> with $SettingDaoMixin {
SettingDao(super.attachedDatabase); SettingDao(super.attachedDatabase);
Future<int> updateSetting(String key, String? partitionKey, Object? value) { 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( final driftvalue = normalizedValue.mapNotNull(
(normalizedValue) => DriftAny(normalizedValue), (normalizedValue) => DriftAny(normalizedValue),
@@ -24,6 +24,7 @@ import 'package:json_annotation/json_annotation.dart';
import 'package:weblibre/core/routing/routes.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_group.dart';
import 'package:weblibre/features/bangs/data/models/bang_key.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'; import 'package:weblibre/features/search/domain/entities/abstract/i_search_suggestion_provider.dart';
part 'general_settings.g.dart'; part 'general_settings.g.dart';
@@ -119,6 +120,8 @@ class GeneralSettings with FastEquatable {
final bool unshortenerEnabled; final bool unshortenerEnabled;
final String unshortenerToken; final String unshortenerToken;
final bool allowNonManifestPwaInstall; final bool allowNonManifestPwaInstall;
final bool blockExternalAppsEnabled;
final Map<String, IntentSourcePolicy> externalAppIntentPolicies;
GeneralSettings({ GeneralSettings({
required this.themeMode, required this.themeMode,
@@ -170,6 +173,8 @@ class GeneralSettings with FastEquatable {
required this.unshortenerEnabled, required this.unshortenerEnabled,
required this.unshortenerToken, required this.unshortenerToken,
required this.allowNonManifestPwaInstall, required this.allowNonManifestPwaInstall,
required this.blockExternalAppsEnabled,
required this.externalAppIntentPolicies,
}); });
GeneralSettings.withDefaults({ GeneralSettings.withDefaults({
@@ -222,6 +227,8 @@ class GeneralSettings with FastEquatable {
bool? unshortenerEnabled, bool? unshortenerEnabled,
String? unshortenerToken, String? unshortenerToken,
bool? allowNonManifestPwaInstall, bool? allowNonManifestPwaInstall,
bool? blockExternalAppsEnabled,
Map<String, IntentSourcePolicy>? externalAppIntentPolicies,
}) : themeMode = themeMode ?? ThemeMode.dark, }) : themeMode = themeMode ?? ThemeMode.dark,
uiScaleFactor = uiScaleFactor ?? defaultUiScaleFactor, uiScaleFactor = uiScaleFactor ?? defaultUiScaleFactor,
disableAnimations = disableAnimations ?? false, disableAnimations = disableAnimations ?? false,
@@ -280,7 +287,9 @@ class GeneralSettings with FastEquatable {
tabBarLongPressUrlCopy = tabBarLongPressUrlCopy ?? true, tabBarLongPressUrlCopy = tabBarLongPressUrlCopy ?? true,
unshortenerEnabled = unshortenerEnabled ?? false, unshortenerEnabled = unshortenerEnabled ?? false,
unshortenerToken = unshortenerToken ?? '', unshortenerToken = unshortenerToken ?? '',
allowNonManifestPwaInstall = allowNonManifestPwaInstall ?? false; allowNonManifestPwaInstall = allowNonManifestPwaInstall ?? false,
blockExternalAppsEnabled = blockExternalAppsEnabled ?? false,
externalAppIntentPolicies = externalAppIntentPolicies ?? const {};
factory GeneralSettings.fromJson(Map<String, dynamic> json) => factory GeneralSettings.fromJson(Map<String, dynamic> json) =>
_$GeneralSettingsFromJson(json); _$GeneralSettingsFromJson(json);
@@ -353,5 +362,7 @@ class GeneralSettings with FastEquatable {
unshortenerEnabled, unshortenerEnabled,
unshortenerToken, unshortenerToken,
allowNonManifestPwaInstall, allowNonManifestPwaInstall,
blockExternalAppsEnabled,
externalAppIntentPolicies,
]; ];
} }
@@ -123,6 +123,12 @@ abstract class _$GeneralSettingsCWProxy {
GeneralSettings allowNonManifestPwaInstall(bool allowNonManifestPwaInstall); GeneralSettings allowNonManifestPwaInstall(bool allowNonManifestPwaInstall);
GeneralSettings blockExternalAppsEnabled(bool blockExternalAppsEnabled);
GeneralSettings externalAppIntentPolicies(
Map<String, IntentSourcePolicy> externalAppIntentPolicies,
);
/// Creates a new instance with the provided field values. /// 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)`. /// 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, bool unshortenerEnabled,
String unshortenerToken, String unshortenerToken,
bool allowNonManifestPwaInstall, bool allowNonManifestPwaInstall,
bool blockExternalAppsEnabled,
Map<String, IntentSourcePolicy> externalAppIntentPolicies,
}); });
} }
@@ -398,6 +406,15 @@ class _$GeneralSettingsCWProxyImpl implements _$GeneralSettingsCWProxy {
GeneralSettings allowNonManifestPwaInstall(bool allowNonManifestPwaInstall) => GeneralSettings allowNonManifestPwaInstall(bool allowNonManifestPwaInstall) =>
call(allowNonManifestPwaInstall: allowNonManifestPwaInstall); call(allowNonManifestPwaInstall: allowNonManifestPwaInstall);
@override
GeneralSettings blockExternalAppsEnabled(bool blockExternalAppsEnabled) =>
call(blockExternalAppsEnabled: blockExternalAppsEnabled);
@override
GeneralSettings externalAppIntentPolicies(
Map<String, IntentSourcePolicy> externalAppIntentPolicies,
) => call(externalAppIntentPolicies: externalAppIntentPolicies);
@override @override
/// Creates a new instance with the provided field values. /// 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)`. /// 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? unshortenerEnabled = const $CopyWithPlaceholder(),
Object? unshortenerToken = const $CopyWithPlaceholder(), Object? unshortenerToken = const $CopyWithPlaceholder(),
Object? allowNonManifestPwaInstall = const $CopyWithPlaceholder(), Object? allowNonManifestPwaInstall = const $CopyWithPlaceholder(),
Object? blockExternalAppsEnabled = const $CopyWithPlaceholder(),
Object? externalAppIntentPolicies = const $CopyWithPlaceholder(),
}) { }) {
return GeneralSettings( return GeneralSettings(
themeMode: themeMode == const $CopyWithPlaceholder() || themeMode == null themeMode: themeMode == const $CopyWithPlaceholder() || themeMode == null
@@ -747,6 +766,18 @@ class _$GeneralSettingsCWProxyImpl implements _$GeneralSettingsCWProxy {
? _value.allowNonManifestPwaInstall ? _value.allowNonManifestPwaInstall
// ignore: cast_nullable_to_non_nullable // ignore: cast_nullable_to_non_nullable
: allowNonManifestPwaInstall as bool, : 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?, unshortenerEnabled: json['unshortenerEnabled'] as bool?,
unshortenerToken: json['unshortenerToken'] as String?, unshortenerToken: json['unshortenerToken'] as String?,
allowNonManifestPwaInstall: json['allowNonManifestPwaInstall'] as bool?, 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( Map<String, dynamic> _$GeneralSettingsToJson(
@@ -923,6 +959,10 @@ Map<String, dynamic> _$GeneralSettingsToJson(
'unshortenerEnabled': instance.unshortenerEnabled, 'unshortenerEnabled': instance.unshortenerEnabled,
'unshortenerToken': instance.unshortenerToken, 'unshortenerToken': instance.unshortenerToken,
'allowNonManifestPwaInstall': instance.allowNonManifestPwaInstall, 'allowNonManifestPwaInstall': instance.allowNonManifestPwaInstall,
'blockExternalAppsEnabled': instance.blockExternalAppsEnabled,
'externalAppIntentPolicies': instance.externalAppIntentPolicies.map(
(k, e) => MapEntry(k, _$IntentSourcePolicyEnumMap[e]!),
),
}; };
const _$ThemeModeEnumMap = { const _$ThemeModeEnumMap = {
@@ -985,3 +1025,8 @@ const _$QuickTabSwitcherModeEnumMap = {
QuickTabSwitcherMode.lastUsedTabs: 'lastUsedTabs', QuickTabSwitcherMode.lastUsedTabs: 'lastUsedTabs',
QuickTabSwitcherMode.containerTabs: 'containerTabs', QuickTabSwitcherMode.containerTabs: 'containerTabs',
}; };
const _$IntentSourcePolicyEnumMap = {
IntentSourcePolicy.allow: 'allow',
IntentSourcePolicy.block: 'block',
};
@@ -231,6 +231,13 @@ class GeneralSettingsRepository extends _$GeneralSettingsRepository {
), ),
'allowNonManifestPwaInstall': settings['allowNonManifestPwaInstall'] 'allowNonManifestPwaInstall': settings['allowNonManifestPwaInstall']
?.readAs(DriftSqlType.bool, db.typeMapping), ?.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() => String _$generalSettingsRepositoryHash() =>
r'afc63f4d929ea146f0b8a7c0f6936b06c5a41024'; r'9d34ea4b802d2d1b1c9543f41ad5fdf28f279e06';
abstract class _$GeneralSettingsRepository abstract class _$GeneralSettingsRepository
extends $StreamNotifier<GeneralSettings> { extends $StreamNotifier<GeneralSettings> {
@@ -11,12 +11,15 @@ import android.app.AlertDialog
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.content.pm.ShortcutManager import android.content.pm.ShortcutManager
import android.net.Uri
import android.os.Build import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.util.Log import android.util.Log
import eu.weblibre.flutter_mozilla_components.Components import eu.weblibre.flutter_mozilla_components.Components
import eu.weblibre.flutter_mozilla_components.GlobalComponents import eu.weblibre.flutter_mozilla_components.GlobalComponents
import eu.weblibre.flutter_mozilla_components.PwaConstants 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.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob 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_NEW_TASK.inv()
intent.flags = intent.flags and Intent.FLAG_ACTIVITY_CLEAR_TASK.inv() intent.flags = intent.flags and Intent.FLAG_ACTIVITY_CLEAR_TASK.inv()
if (shouldBlockIntent(intent)) {
finish()
return
}
processIntent(intent) 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() { override fun onDestroy() {
super.onDestroy() super.onDestroy()
coroutineScope.cancel() coroutineScope.cancel()
@@ -439,6 +488,12 @@ class IntentReceiverActivity : Activity() {
val mainActivityIntent = Intent(intent).apply { val mainActivityIntent = Intent(intent).apply {
setClassName(this@IntentReceiverActivity, "eu.weblibre.gecko.MainActivity") setClassName(this@IntentReceiverActivity, "eu.weblibre.gecko.MainActivity")
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) 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) startActivity(mainActivityIntent)
finish() finish()
@@ -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
}
}
}
@@ -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
}
}
@@ -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<String>) {
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
}
}
}
@@ -22,7 +22,10 @@ package eu.weblibre.simple_intent_receiver
import android.app.Activity import android.app.Activity
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.content.pm.ApplicationInfo
import android.content.pm.PackageManager
import android.net.Uri import android.net.Uri
import android.os.Build
import android.os.Bundle import android.os.Bundle
import io.flutter.Log import io.flutter.Log
import io.flutter.embedding.engine.plugins.FlutterPlugin 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.embedding.engine.plugins.activity.ActivityPluginBinding
import io.flutter.plugin.common.PluginRegistry import io.flutter.plugin.common.PluginRegistry
import eu.weblibre.simple_intent_receiver.pigeons.Intent as PigeonIntent import eu.weblibre.simple_intent_receiver.pigeons.Intent as PigeonIntent
import eu.weblibre.simple_intent_receiver.pigeons.IntentGatekeeperHostApi
class SimpleIntentReceiverPlugin: FlutterPlugin, ActivityAware, PluginRegistry.NewIntentListener { class SimpleIntentReceiverPlugin: FlutterPlugin, ActivityAware, PluginRegistry.NewIntentListener {
private lateinit var context: Context private lateinit var context: Context
private var intentReceiver: IntentReceiver? = null private var intentReceiver: IntentReceiver? = null
private var lastHandledIntent: String? = null private var lastHandledIntent: String? = null
private var activity: Activity? = null private var activity: Activity? = null
private var binaryMessenger: io.flutter.plugin.common.BinaryMessenger? = null
override fun onAttachedToEngine(flutterPluginBinding: FlutterPlugin.FlutterPluginBinding) { override fun onAttachedToEngine(flutterPluginBinding: FlutterPlugin.FlutterPluginBinding) {
context = flutterPluginBinding.applicationContext context = flutterPluginBinding.applicationContext
intentReceiver = IntentReceiver(flutterPluginBinding.binaryMessenger) intentReceiver = IntentReceiver(flutterPluginBinding.binaryMessenger)
binaryMessenger = flutterPluginBinding.binaryMessenger
IntentGatekeeperHostApi.setUp(
flutterPluginBinding.binaryMessenger,
IntentGatekeeperHostApiImpl(flutterPluginBinding.applicationContext),
)
} }
override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) { override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) {
intentReceiver = null intentReceiver = null
binaryMessenger?.let { IntentGatekeeperHostApi.setUp(it, null) }
binaryMessenger = null
} }
override fun onAttachedToActivity(binding: ActivityPluginBinding) { override fun onAttachedToActivity(binding: ActivityPluginBinding) {
@@ -118,10 +130,59 @@ class SimpleIntentReceiverPlugin: FlutterPlugin, ActivityAware, PluginRegistry.N
return true 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 { private fun convertToPigeonIntent(intent: Intent): PigeonIntent {
val action = intent.action val action = intent.action
val data = intent.dataString val data = intent.dataString
val fromPackageName = intent.getPackage() val fromPackageName = resolveCallerPackage(intent)
val categories = ArrayList<String>() val categories = ArrayList<String>()
intent.categories?.let { intent.categories?.let {
@@ -17,6 +17,26 @@ private object IntentPigeonUtils {
fun createConnectionError(channelName: String): FlutterError { fun createConnectionError(channelName: String): FlutterError {
return FlutterError("channel-error", "Unable to establish connection on channel: '$channelName'.", "") } return FlutterError("channel-error", "Unable to establish connection on channel: '$channelName'.", "") }
fun wrapResult(result: Any?): List<Any?> {
return listOf(result)
}
fun wrapError(exception: Throwable): List<Any?> {
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 { fun doubleEquals(a: Double, b: Double): Boolean {
// Normalize -0.0 to 0.0 and handle NaN equality. // 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()) 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<String>)
/**
* 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<Any?> 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<Any?>(binaryMessenger, "dev.flutter.pigeon.simple_intent_receiver.IntentGatekeeperHostApi.setConfig$separatedMessageChannelSuffix", codec)
if (api != null) {
channel.setMessageHandler { message, reply ->
val args = message as List<Any?>
val enabledArg = args[0] as Boolean
val blockedPackagesArg = args[1] as List<String>
val wrapped: List<Any?> = try {
api.setConfig(enabledArg, blockedPackagesArg)
listOf(null)
} catch (exception: Throwable) {
IntentPigeonUtils.wrapError(exception)
}
reply.reply(wrapped)
}
} else {
channel.setMessageHandler(null)
}
}
run {
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.simple_intent_receiver.IntentGatekeeperHostApi.resolvePackageLabel$separatedMessageChannelSuffix", codec)
if (api != null) {
channel.setMessageHandler { message, reply ->
val args = message as List<Any?>
val packageNameArg = args[0] as String
val wrapped: List<Any?> = try {
listOf(api.resolvePackageLabel(packageNameArg))
} catch (exception: Throwable) {
IntentPigeonUtils.wrapError(exception)
}
reply.reply(wrapped)
}
} else {
channel.setMessageHandler(null)
}
}
}
}
}
@@ -18,4 +18,4 @@
* along with this program. If not, see <http://www.gnu.org/licenses/>. * along with this program. If not, see <http://www.gnu.org/licenses/>.
*/ */
export 'src/intent_receiver.dart'; export 'src/intent_receiver.dart';
export 'src/pigeons/intent.g.dart' show Intent; export 'src/pigeons/intent.g.dart' show Intent, IntentGatekeeperHostApi;
@@ -9,6 +9,32 @@ import 'dart:typed_data' show Float64List, Int32List, Int64List;
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:meta/meta.dart' show immutable, protected, visibleForTesting; import 'package:meta/meta.dart' show immutable, protected, visibleForTesting;
Object? _extractReplyValueOrThrow(
List<Object?>? 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<Object?> wrapResponse({Object? result, PlatformException? error, bool empty = false}) { List<Object?> wrapResponse({Object? result, PlatformException? error, bool empty = false}) {
if (empty) { if (empty) {
return <Object?>[]; return <Object?>[];
@@ -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<Object?> 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<void> setConfig(bool enabled, List<String> blockedPackages) async {
final pigeonVar_channelName = 'dev.flutter.pigeon.simple_intent_receiver.IntentGatekeeperHostApi.setConfig$pigeonVar_messageChannelSuffix';
final pigeonVar_channel = BasicMessageChannel<Object?>(
pigeonVar_channelName,
pigeonChannelCodec,
binaryMessenger: pigeonVar_binaryMessenger,
);
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(<Object?>[enabled, blockedPackages]);
final pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
_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<String?> resolvePackageLabel(String packageName) async {
final pigeonVar_channelName = 'dev.flutter.pigeon.simple_intent_receiver.IntentGatekeeperHostApi.resolvePackageLabel$pigeonVar_messageChannelSuffix';
final pigeonVar_channel = BasicMessageChannel<Object?>(
pigeonVar_channelName,
pigeonChannelCodec,
binaryMessenger: pigeonVar_binaryMessenger,
);
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(<Object?>[packageName]);
final pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
final Object? pigeonVar_replyValue = _extractReplyValueOrThrow(
pigeonVar_replyList,
pigeonVar_channelName,
isNullValid: true,
)
;
return pigeonVar_replyValue as String?;
}
}
@@ -53,3 +53,15 @@ class Intent {
abstract class IntentEvents { abstract class IntentEvents {
void onIntentReceived(int sequence, Intent intent); 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<String> 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);
}