added gatekeeper notification options

This commit is contained in:
Fabian Freund
2026-05-02 08:27:19 +02:00
parent 94b9b9cceb
commit 13ffe82d4e
14 changed files with 557 additions and 23 deletions
@@ -60,6 +60,15 @@
android:exported="false"
android:launchMode="singleTop" />
<receiver
android:name="eu.weblibre.flutter_mozilla_components.gatekeeper.GatekeeperNotificationActionReceiver"
android:exported="false">
<intent-filter>
<action android:name="eu.weblibre.gecko.gatekeeper.ALLOW_ONCE" />
<action android:name="eu.weblibre.gecko.gatekeeper.ALWAYS_ALLOW" />
</intent-filter>
</receiver>
<receiver
android:name="org.ironfoxoss.unifiedpush.PushReceiver"
tools:node="remove" />
@@ -20,16 +20,16 @@
import 'dart:async';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:weblibre/features/about/domain/providers.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/native_gatekeeper_replicator.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;
@@ -62,14 +62,24 @@ class IntentGatekeeper extends _$IntentGatekeeper {
required String? fromPackageName,
required String? url,
}) async {
final settings = ref.read(generalSettingsWithDefaultsProvider);
await ref
.read(nativeIntentGatekeeperReplicatorProvider.notifier)
.syncPendingAllows();
final settings = await ref
.read(generalSettingsRepositoryProvider.notifier)
.fetchSettings();
if (!settings.blockExternalAppsEnabled) {
return true;
}
final ownPackageName = (await ref.read(
packageInfoProvider.future,
)).packageName;
// Internal / unknown callers: no package to gate on — let through.
if (fromPackageName == null || fromPackageName == _ownPackageName) {
if (fromPackageName == null || fromPackageName == ownPackageName) {
return true;
}
@@ -26,6 +26,7 @@ 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/data/models/general_settings.dart';
import 'package:weblibre/features/user/domain/repositories/general_settings.dart';
part 'native_gatekeeper_replicator.g.dart';
@@ -34,10 +35,14 @@ part 'native_gatekeeper_replicator.g.dart';
/// `IntentReceiverActivity` can reject intents without launching Flutter.
/// Only blocked packages are replicated — allow/unknown still fall through to
/// the Flutter gatekeeper dialog.
///
/// Also consumes any "always allow" decisions made via notification actions
/// and merges them into Flutter's policy before the next gatekeeper check.
@Riverpod(keepAlive: true)
class NativeIntentGatekeeperReplicator
extends _$NativeIntentGatekeeperReplicator {
final _api = IntentGatekeeperHostApi();
Future<void>? _pendingAllowSync;
Future<void> _push(
({bool enabled, Map<String, IntentSourcePolicy> policies}) config,
@@ -58,8 +63,53 @@ class NativeIntentGatekeeperReplicator
}
}
/// Reads any packages the user approved via notification and persists them
/// as `allow` policies in the Flutter settings.
Future<void> _syncPendingAllowsInternal() async {
try {
final pending = await _api.getPendingAlwaysAllows();
if (pending.isEmpty) return;
await ref
.read(generalSettingsRepositoryProvider.notifier)
.updateSettings(
(current) => current.copyWith.externalAppIntentPolicies({
...current.externalAppIntentPolicies,
for (final pkg in pending) pkg: IntentSourcePolicy.allow,
}),
);
await _api.ackPendingAlwaysAllows(pending);
} catch (error, stackTrace) {
logger.e(
'Failed to consume pending always-allows from native',
error: error,
stackTrace: stackTrace,
);
}
}
/// Deduplicates native pending-allow synchronization across concurrent
/// callers so startup intent handling and config replication observe the same
/// persisted policy state.
Future<void> syncPendingAllows() {
final inFlight = _pendingAllowSync;
if (inFlight != null) {
return inFlight;
}
final future = _syncPendingAllowsInternal();
_pendingAllowSync = future.whenComplete(() {
if (identical(_pendingAllowSync, future)) {
_pendingAllowSync = null;
}
});
return _pendingAllowSync!;
}
@override
void build() {
unawaited(syncPendingAllows());
ref.listen(
generalSettingsWithDefaultsProvider.select(
(settings) => EquatableValue((
@@ -80,7 +130,16 @@ class NativeIntentGatekeeperReplicator
)) {
return;
}
unawaited(_push(next));
unawaited(() async {
await syncPendingAllows();
final settings = await ref
.read(generalSettingsRepositoryProvider.notifier)
.fetchSettings();
await _push((
enabled: settings.blockExternalAppsEnabled,
policies: settings.externalAppIntentPolicies,
));
}());
},
);
}
@@ -12,6 +12,9 @@ part of 'native_gatekeeper_replicator.dart';
/// `IntentReceiverActivity` can reject intents without launching Flutter.
/// Only blocked packages are replicated — allow/unknown still fall through to
/// the Flutter gatekeeper dialog.
///
/// On startup, also consumes any "always allow" decisions made via notification
/// actions while Flutter was not running, and merges them into Flutter's policy.
@ProviderFor(NativeIntentGatekeeperReplicator)
final nativeIntentGatekeeperReplicatorProvider =
@@ -21,12 +24,18 @@ final nativeIntentGatekeeperReplicatorProvider =
/// `IntentReceiverActivity` can reject intents without launching Flutter.
/// Only blocked packages are replicated — allow/unknown still fall through to
/// the Flutter gatekeeper dialog.
///
/// On startup, also consumes any "always allow" decisions made via notification
/// actions while Flutter was not running, and merges them into Flutter's policy.
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.
///
/// On startup, also consumes any "always allow" decisions made via notification
/// actions while Flutter was not running, and merges them into Flutter's policy.
NativeIntentGatekeeperReplicatorProvider._()
: super(
from: null,
@@ -56,12 +65,15 @@ final class NativeIntentGatekeeperReplicatorProvider
}
String _$nativeIntentGatekeeperReplicatorHash() =>
r'ee97dbd489e4e946e0a98cd640300f939f3b0682';
r'ed1ba2a318467317d6fb412495171acb8819e483';
/// 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.
///
/// On startup, also consumes any "always allow" decisions made via notification
/// actions while Flutter was not running, and merges them into Flutter's policy.
abstract class _$NativeIntentGatekeeperReplicator extends $Notifier<void> {
void build();
@@ -27,16 +27,34 @@ 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/entities/intent_source_policy.dart';
import 'package:weblibre/features/intent_gatekeeper/domain/services/intent_gatekeeper.dart';
import 'package:weblibre/features/share_intent/domain/entities/intent_container_mode.dart';
import 'package:weblibre/features/user/data/models/general_settings.dart';
import 'package:weblibre/features/user/domain/repositories/general_settings.dart';
part 'sharing_intent.g.dart';
const _alwaysAllowPackageExtra = 'eu.weblibre.gatekeeper.always_allow_package';
StreamTransformer<Intent, ReceivedIntentParameter>
_buildSharingIntentTransformer(
IntentGatekeeper gatekeeper,
) => StreamTransformer<Intent, ReceivedIntentParameter>.fromHandlers(
handleData: (intent, sink) async {
final alwaysAllowPackage =
intent.extra[_alwaysAllowPackageExtra] as String?;
if (alwaysAllowPackage != null) {
await gatekeeper.ref
.read(generalSettingsRepositoryProvider.notifier)
.updateSettings(
(current) => current.copyWith.externalAppIntentPolicies({
...current.externalAppIntentPolicies,
alwaysAllowPackage: IntentSourcePolicy.allow,
}),
);
}
final shortcutContextId = intent.action == 'android.intent.action.VIEW'
? intent.extra['pwa_context_id'] as String?
: null;