From 13ffe82d4e09958ad36453ec59f60694ccec81e7 Mon Sep 17 00:00:00 2001 From: Fabian Freund Date: Sat, 2 May 2026 08:27:19 +0200 Subject: [PATCH] added gatekeeper notification options --- .../android/app/src/main/AndroidManifest.xml | 9 ++ .../domain/services/intent_gatekeeper.dart | 18 ++- .../native_gatekeeper_replicator.dart | 61 +++++++++- .../native_gatekeeper_replicator.g.dart | 14 ++- .../domain/services/sharing_intent.dart | 18 +++ .../activities/IntentReceiverActivity.kt | 10 +- .../GatekeeperNotificationActionReceiver.kt | 112 ++++++++++++++++++ .../gatekeeper/IntentBlockNotifier.kt | 81 +++++++++++-- .../gatekeeper/IntentGatekeeperPreferences.kt | 80 +++++++++++++ .../IntentGatekeeperHostApiImpl.kt | 21 ++++ .../SimpleIntentReceiverPlugin.kt | 53 ++++++++- .../pigeons/Intent.g.kt | 46 +++++++ .../lib/src/pigeons/intent.g.dart | 46 +++++++ .../pigeons/intent.dart | 11 ++ 14 files changed, 557 insertions(+), 23 deletions(-) create mode 100644 packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/gatekeeper/GatekeeperNotificationActionReceiver.kt diff --git a/apps/weblibre/android/app/src/main/AndroidManifest.xml b/apps/weblibre/android/app/src/main/AndroidManifest.xml index 6accd9bf..a3df48d1 100644 --- a/apps/weblibre/android/app/src/main/AndroidManifest.xml +++ b/apps/weblibre/android/app/src/main/AndroidManifest.xml @@ -60,6 +60,15 @@ android:exported="false" android:launchMode="singleTop" /> + + + + + + + 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 index 8acc22d3..3c9111af 100644 --- a/apps/weblibre/lib/features/intent_gatekeeper/domain/services/intent_gatekeeper.dart +++ b/apps/weblibre/lib/features/intent_gatekeeper/domain/services/intent_gatekeeper.dart @@ -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 _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; } 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 index 9e3d79cd..09421c31 100644 --- 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 @@ -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? _pendingAllowSync; Future _push( ({bool enabled, Map 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 _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 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, + )); + }()); }, ); } 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 index 6e4e834a..84efe586 100644 --- 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 @@ -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 { /// 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 build(); 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 1c5352e3..771045a7 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,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 _buildSharingIntentTransformer( IntentGatekeeper gatekeeper, ) => StreamTransformer.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; 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 6b46e03c..418dcfc0 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 @@ -22,6 +22,7 @@ import eu.weblibre.flutter_mozilla_components.PwaConstants import eu.weblibre.flutter_mozilla_components.PwaSessionCreator import eu.weblibre.flutter_mozilla_components.gatekeeper.IntentBlockNotifier import eu.weblibre.flutter_mozilla_components.gatekeeper.IntentGatekeeperPreferences +import eu.weblibre.flutter_mozilla_components.gatekeeper.GatekeeperNotificationActionReceiver import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob @@ -84,13 +85,20 @@ class IntentReceiverActivity : Activity() { private fun shouldBlockIntent(intent: Intent): Boolean { if (!IntentGatekeeperPreferences.isEnabled(applicationContext)) return false if (intent.hasExtra(PwaConstants.EXTRA_PWA_PROFILE_UUID)) return false + intent.getStringExtra( + GatekeeperNotificationActionReceiver.EXTRA_NOTIFICATION_APPROVAL_TOKEN, + )?.let { token -> + if (IntentGatekeeperPreferences.hasNotificationApproval(applicationContext, token)) { + 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) + IntentBlockNotifier.notifyBlocked(applicationContext, caller, intent) return true } diff --git a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/gatekeeper/GatekeeperNotificationActionReceiver.kt b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/gatekeeper/GatekeeperNotificationActionReceiver.kt new file mode 100644 index 00000000..4b40d7e1 --- /dev/null +++ b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/gatekeeper/GatekeeperNotificationActionReceiver.kt @@ -0,0 +1,112 @@ +/* + * 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.NotificationManager +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.os.Build +import android.util.Log +import androidx.core.content.ContextCompat + +/** + * Handles notification action buttons on blocked-intent notifications. + * + * "Allow once" re-fires the original blocked intent to [IntentReceiverActivity] + * with a one-shot approval marker so the already-approved launch bypasses both + * the native and Flutter gatekeepers. + * + * "Always allow" additionally writes a pending decision to + * [IntentGatekeeperPreferences] so that Flutter can persist the policy change + * before the next gatekeeper decision, even if the app process is already up. + */ +class GatekeeperNotificationActionReceiver : BroadcastReceiver() { + + companion object { + private const val TAG = "GatekeeperActionReceiver" + + const val ACTION_ALLOW_ONCE = "eu.weblibre.gecko.gatekeeper.ALLOW_ONCE" + const val ACTION_ALWAYS_ALLOW = "eu.weblibre.gecko.gatekeeper.ALWAYS_ALLOW" + + const val EXTRA_PACKAGE_NAME = "gatekeeper_package" + const val EXTRA_BLOCKED_INTENT = "gatekeeper_blocked_intent" + const val EXTRA_NOTIFICATION_ID = "gatekeeper_notification_id" + const val EXTRA_NOTIFICATION_APPROVAL_TOKEN = "eu.weblibre.gatekeeper.notification_approval_token" + } + + override fun onReceive(context: Context, intent: Intent) { + val packageName = intent.getStringExtra(EXTRA_PACKAGE_NAME) ?: return + val blockedIntent = getBlockedIntent(intent) + cancelNotification(context, intent.getIntExtra(EXTRA_NOTIFICATION_ID, -1)) + + Log.d(TAG, "action=${intent.action} package=$packageName hasIntent=${blockedIntent != null}") + + when (intent.action) { + ACTION_ALLOW_ONCE -> { + blockedIntent?.let { + openIntent( + context = context, + blockedIntent = it, + approvalToken = IntentGatekeeperPreferences.createNotificationApproval( + context, + ), + ) + } + } + ACTION_ALWAYS_ALLOW -> { + IntentGatekeeperPreferences.addPendingAlwaysAllow(context, packageName) + blockedIntent?.let { + openIntent( + context = context, + blockedIntent = it, + approvalToken = IntentGatekeeperPreferences.createNotificationApproval( + context, + alwaysAllowPackage = packageName, + ), + ) + } + } + } + } + + private fun cancelNotification(context: Context, notificationId: Int) { + if (notificationId == -1) return + + ContextCompat.getSystemService(context, NotificationManager::class.java) + ?.cancel(notificationId) + } + + private fun getBlockedIntent(intent: Intent): Intent? { + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + intent.getParcelableExtra(EXTRA_BLOCKED_INTENT, Intent::class.java) + } else { + @Suppress("DEPRECATION") + intent.getParcelableExtra(EXTRA_BLOCKED_INTENT) + } + } + + private fun openIntent( + context: Context, + blockedIntent: Intent, + approvalToken: String, + ) { + try { + val relaunchIntent = Intent(blockedIntent).apply { + setClassName( + context.packageName, + "eu.weblibre.flutter_mozilla_components.activities.IntentReceiverActivity", + ) + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + putExtra(EXTRA_NOTIFICATION_APPROVAL_TOKEN, approvalToken) + } + context.startActivity(relaunchIntent) + } catch (e: Exception) { + Log.e(TAG, "Failed to replay blocked intent after gatekeeper action", e) + } + } +} 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 index 6699771f..0ae5395a 100644 --- 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 @@ -6,10 +6,11 @@ */ package eu.weblibre.flutter_mozilla_components.gatekeeper -import android.app.Notification import android.app.NotificationChannel import android.app.NotificationManager +import android.app.PendingIntent import android.content.Context +import android.content.Intent import android.content.pm.PackageManager import android.os.Build import androidx.core.app.NotificationCompat @@ -17,22 +18,25 @@ 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. + * Posts an actionable heads-up notification when an intent is blocked by the + * native gatekeeper. The notification shows "Allow once" and "Always allow" + * action buttons, and auto-dismisses after [TIMEOUT_MS] milliseconds. */ object IntentBlockNotifier { - private const val CHANNEL_ID = "intent_gatekeeper_channel" + private const val CHANNEL_ID = "intent_gatekeeper_channel_v2" private const val CHANNEL_NAME = "Blocked app launches" - private const val CHANNEL_DESC = "Informs you when another app is prevented from opening WebLibre." + private const val CHANNEL_DESC = "Shown when another app is prevented from opening WebLibre, with options to allow." - fun notifyBlocked(context: Context, packageName: String) { + private const val TIMEOUT_MS = 8_000L + + fun notifyBlocked(context: Context, packageName: String, blockedIntent: Intent) { 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) + val builder = NotificationCompat.Builder(appCtx, CHANNEL_ID) .setSmallIcon(R.drawable.ic_launcher_foreground) .setContentTitle("Blocked app launch") .setContentText("Prevented $label from opening WebLibre.") @@ -40,15 +44,66 @@ object IntentBlockNotifier { NotificationCompat.BigTextStyle() .bigText("Prevented $label from opening WebLibre.") ) - .setPriority(NotificationCompat.PRIORITY_DEFAULT) + .setPriority(NotificationCompat.PRIORITY_HIGH) .setSilent(true) .setAutoCancel(true) .setShowWhen(true) - .build() + .setTimeoutAfter(TIMEOUT_MS) + + builder.addAction( + 0, + "Allow once", + buildActionIntent( + context = appCtx, + action = GatekeeperNotificationActionReceiver.ACTION_ALLOW_ONCE, + packageName = packageName, + blockedIntent = blockedIntent, + notificationId = notificationId, + requestCode = notificationId + 1, + ), + ) + + builder.addAction( + 0, + "Always allow", + buildActionIntent( + context = appCtx, + action = GatekeeperNotificationActionReceiver.ACTION_ALWAYS_ALLOW, + packageName = packageName, + blockedIntent = blockedIntent, + notificationId = notificationId, + requestCode = notificationId + 2, + ), + ) val manager = ContextCompat.getSystemService(appCtx, NotificationManager::class.java) ?: return - manager.notify(notificationId, notification) + manager.notify(notificationId, builder.build()) + } + + private fun buildActionIntent( + context: Context, + action: String, + packageName: String, + blockedIntent: Intent, + notificationId: Int, + requestCode: Int, + ): PendingIntent { + val intent = Intent(action).apply { + setClass(context, GatekeeperNotificationActionReceiver::class.java) + putExtra(GatekeeperNotificationActionReceiver.EXTRA_PACKAGE_NAME, packageName) + putExtra(GatekeeperNotificationActionReceiver.EXTRA_NOTIFICATION_ID, notificationId) + putExtra( + GatekeeperNotificationActionReceiver.EXTRA_BLOCKED_INTENT, + Intent(blockedIntent), + ) + } + val flags = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT + } else { + PendingIntent.FLAG_UPDATE_CURRENT + } + return PendingIntent.getBroadcast(context, requestCode, intent, flags) } private fun ensureChannel(context: Context) { @@ -56,14 +111,16 @@ object IntentBlockNotifier { 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, + NotificationManager.IMPORTANCE_HIGH, ).apply { description = CHANNEL_DESC setShowBadge(false) + enableLights(false) + enableVibration(false) } manager.createNotificationChannel(channel) } 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 index 64f889f7..8a625774 100644 --- 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 @@ -8,6 +8,7 @@ package eu.weblibre.flutter_mozilla_components.gatekeeper import android.content.Context import android.content.SharedPreferences +import java.util.UUID /** * Cross-package shared-prefs file used to replicate the Flutter-side intent @@ -21,6 +22,13 @@ object IntentGatekeeperPreferences { const val PREFS_NAME = "weblibre_intent_gatekeeper" const val KEY_ENABLED = "enabled" const val KEY_BLOCKED_PACKAGES = "blocked_packages" + const val KEY_PENDING_ALWAYS_ALLOW = "pending_always_allow" + private const val KEY_NOTIFICATION_APPROVAL_TOKENS = "notification_approval_tokens" + private const val KEY_NOTIFICATION_APPROVAL_PACKAGE_PREFIX = "notification_approval_package_" + + data class NotificationApproval( + val alwaysAllowPackage: String?, + ) fun get(context: Context): SharedPreferences = context.applicationContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) @@ -34,4 +42,76 @@ object IntentGatekeeperPreferences { val blocked = prefs.getStringSet(KEY_BLOCKED_PACKAGES, emptySet()) ?: return false return packageName in blocked } + + /** + * Records that the user tapped "Always allow" for [packageName] via a + * notification action. Also removes [packageName] from the blocked set so + * future native intents pass through immediately, without waiting for + * Flutter to start and consume the pending decision. + */ + fun addPendingAlwaysAllow(context: Context, packageName: String) { + val prefs = get(context) + val pending = prefs.getStringSet(KEY_PENDING_ALWAYS_ALLOW, emptySet())?.toMutableSet() ?: mutableSetOf() + val blocked = prefs.getStringSet(KEY_BLOCKED_PACKAGES, emptySet())?.toMutableSet() ?: mutableSetOf() + pending.add(packageName) + blocked.remove(packageName) + prefs.edit() + .putStringSet(KEY_PENDING_ALWAYS_ALLOW, pending) + .putStringSet(KEY_BLOCKED_PACKAGES, blocked) + .apply() + } + + fun createNotificationApproval( + context: Context, + alwaysAllowPackage: String? = null, + ): String { + val prefs = get(context) + val tokens = + prefs.getStringSet(KEY_NOTIFICATION_APPROVAL_TOKENS, emptySet()) + ?.toMutableSet() + ?: mutableSetOf() + val token = UUID.randomUUID().toString() + tokens.add(token) + + prefs.edit() + .putStringSet(KEY_NOTIFICATION_APPROVAL_TOKENS, tokens) + .apply { + if (alwaysAllowPackage != null) { + putString( + "$KEY_NOTIFICATION_APPROVAL_PACKAGE_PREFIX$token", + alwaysAllowPackage, + ) + } + } + .apply() + + return token + } + + fun hasNotificationApproval(context: Context, token: String): Boolean { + val prefs = get(context) + val tokens = prefs.getStringSet(KEY_NOTIFICATION_APPROVAL_TOKENS, emptySet()) ?: return false + return token in tokens + } + + fun consumeNotificationApproval(context: Context, token: String): NotificationApproval? { + val prefs = get(context) + val tokens = + prefs.getStringSet(KEY_NOTIFICATION_APPROVAL_TOKENS, emptySet()) + ?.toMutableSet() + ?: return null + if (!tokens.remove(token)) return null + + val alwaysAllowPackage = prefs.getString( + "$KEY_NOTIFICATION_APPROVAL_PACKAGE_PREFIX$token", + null, + ) + + prefs.edit() + .putStringSet(KEY_NOTIFICATION_APPROVAL_TOKENS, tokens) + .remove("$KEY_NOTIFICATION_APPROVAL_PACKAGE_PREFIX$token") + .apply() + + return NotificationApproval(alwaysAllowPackage = alwaysAllowPackage) + } } 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 index 3db7e80f..61c068ec 100644 --- 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 @@ -24,6 +24,7 @@ class IntentGatekeeperHostApiImpl(private val context: Context) : IntentGatekeep private const val PREFS_NAME = "weblibre_intent_gatekeeper" private const val KEY_ENABLED = "enabled" private const val KEY_BLOCKED_PACKAGES = "blocked_packages" + private const val KEY_PENDING_ALWAYS_ALLOW = "pending_always_allow" } override fun setConfig(enabled: Boolean, blockedPackages: List) { @@ -50,4 +51,24 @@ class IntentGatekeeperHostApiImpl(private val context: Context) : IntentGatekeep null } } + + override fun getPendingAlwaysAllows(): List { + val prefs = context.applicationContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + return (prefs.getStringSet(KEY_PENDING_ALWAYS_ALLOW, emptySet()) ?: emptySet()).toList() + } + + override fun ackPendingAlwaysAllows(packageNames: List) { + if (packageNames.isEmpty()) return + + val prefs = context.applicationContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + val pending = prefs.getStringSet(KEY_PENDING_ALWAYS_ALLOW, emptySet())?.toMutableSet() + ?: mutableSetOf() + if (pending.removeAll(packageNames.toSet())) { + if (pending.isEmpty()) { + prefs.edit().remove(KEY_PENDING_ALWAYS_ALLOW).apply() + } else { + prefs.edit().putStringSet(KEY_PENDING_ALWAYS_ALLOW, pending).apply() + } + } + } } 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 c3e8909f..35e45c0b 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 @@ -36,6 +36,17 @@ import eu.weblibre.simple_intent_receiver.pigeons.Intent as PigeonIntent import eu.weblibre.simple_intent_receiver.pigeons.IntentGatekeeperHostApi class SimpleIntentReceiverPlugin: FlutterPlugin, ActivityAware, PluginRegistry.NewIntentListener { + companion object { + // Stable names that must match the notification replay path and shared-prefs schema. + private const val PREFS_NAME = "weblibre_intent_gatekeeper" + private const val KEY_NOTIFICATION_APPROVAL_TOKENS = "notification_approval_tokens" + private const val KEY_NOTIFICATION_APPROVAL_PACKAGE_PREFIX = "notification_approval_package_" + private const val EXTRA_NOTIFICATION_APPROVAL_TOKEN = "eu.weblibre.gatekeeper.notification_approval_token" + private const val EXTRA_ALWAYS_ALLOW_PACKAGE = "eu.weblibre.gatekeeper.always_allow_package" + } + + private data class NotificationApproval(val alwaysAllowPackage: String?) + private lateinit var context: Context private var intentReceiver: IntentReceiver? = null private var lastHandledIntent: String? = null @@ -125,12 +136,17 @@ class SimpleIntentReceiverPlugin: FlutterPlugin, ActivityAware, PluginRegistry.N intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP) } - val pigeonIntent = convertToPigeonIntent(intent) + val notificationApproval = consumeNotificationApproval(intent) + val pigeonIntent = convertToPigeonIntent(intent, notificationApproval) intentReceiver?.sendIntent(pigeonIntent) return true } - private fun resolveCallerPackage(intent: Intent): String? { + private fun resolveCallerPackage(intent: Intent, notificationApproval: NotificationApproval?): String? { + if (notificationApproval != null) { + return null + } + 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. @@ -179,10 +195,28 @@ class SimpleIntentReceiverPlugin: FlutterPlugin, ActivityAware, PluginRegistry.N } } - private fun convertToPigeonIntent(intent: Intent): PigeonIntent { + private fun consumeNotificationApproval(intent: Intent): NotificationApproval? { + val token = intent.getStringExtra(EXTRA_NOTIFICATION_APPROVAL_TOKEN) ?: return null + val prefs = context.applicationContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + val tokens = prefs.getStringSet(KEY_NOTIFICATION_APPROVAL_TOKENS, emptySet())?.toMutableSet() + ?: return null + if (!tokens.remove(token)) { + return null + } + + val alwaysAllowPackage = prefs.getString("$KEY_NOTIFICATION_APPROVAL_PACKAGE_PREFIX$token", null) + prefs.edit() + .putStringSet(KEY_NOTIFICATION_APPROVAL_TOKENS, tokens) + .remove("$KEY_NOTIFICATION_APPROVAL_PACKAGE_PREFIX$token") + .apply() + + return NotificationApproval(alwaysAllowPackage) + } + + private fun convertToPigeonIntent(intent: Intent, notificationApproval: NotificationApproval?): PigeonIntent { val action = intent.action val data = intent.dataString - val fromPackageName = resolveCallerPackage(intent) + val fromPackageName = resolveCallerPackage(intent, notificationApproval) val categories = ArrayList() intent.categories?.let { @@ -193,6 +227,13 @@ class SimpleIntentReceiverPlugin: FlutterPlugin, ActivityAware, PluginRegistry.N intent.extras?.let { bundle -> for (key in bundle.keySet()) { try { + if (key == EXTRA_NOTIFICATION_APPROVAL_TOKEN) { + continue + } + if (key == EXTRA_ALWAYS_ALLOW_PACKAGE) { + continue + } + when (val value = bundle.get(key)) { is Bundle -> { val bundleMap = HashMap() @@ -224,6 +265,10 @@ class SimpleIntentReceiverPlugin: FlutterPlugin, ActivityAware, PluginRegistry.N } } + notificationApproval?.alwaysAllowPackage?.let { + extras[EXTRA_ALWAYS_ALLOW_PACKAGE] = it + } + return PigeonIntent( fromPackageName = fromPackageName, action = action, 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 677bb72e..ae001ca7 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 @@ -309,6 +309,19 @@ interface IntentGatekeeperHostApi { * label cannot be resolved. */ fun resolvePackageLabel(packageName: String): String? + /** + * Returns the list of packages for which the user tapped "Always allow" + * via a blocked-intent notification while WebLibre was not running. + * Callers must acknowledge persisted packages via + * [ackPendingAlwaysAllows] after Flutter settings were updated + * successfully. + */ + fun getPendingAlwaysAllows(): List + /** + * Removes the given packages from the pending "Always allow" set after + * Flutter has successfully persisted them into its own policy store. + */ + fun ackPendingAlwaysAllows(packageNames: List) companion object { /** The codec used by IntentGatekeeperHostApi. */ @@ -355,6 +368,39 @@ interface IntentGatekeeperHostApi { channel.setMessageHandler(null) } } + run { + val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.simple_intent_receiver.IntentGatekeeperHostApi.getPendingAlwaysAllows$separatedMessageChannelSuffix", codec) + if (api != null) { + channel.setMessageHandler { _, reply -> + val wrapped: List = try { + listOf(api.getPendingAlwaysAllows()) + } 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.ackPendingAlwaysAllows$separatedMessageChannelSuffix", codec) + if (api != null) { + channel.setMessageHandler { message, reply -> + val args = message as List + val packageNamesArg = args[0] as List + val wrapped: List = try { + api.ackPendingAlwaysAllows(packageNamesArg) + listOf(null) + } catch (exception: Throwable) { + IntentPigeonUtils.wrapError(exception) + } + reply.reply(wrapped) + } + } else { + channel.setMessageHandler(null) + } + } } } } 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 8673879c..39884780 100644 --- a/packages/simple_intent_receiver/lib/src/pigeons/intent.g.dart +++ b/packages/simple_intent_receiver/lib/src/pigeons/intent.g.dart @@ -312,4 +312,50 @@ class IntentGatekeeperHostApi { ); return pigeonVar_replyValue as String?; } + + /// Returns the list of packages for which the user tapped "Always allow" + /// via a blocked-intent notification while WebLibre was not running. + /// Callers must acknowledge persisted packages via + /// [ackPendingAlwaysAllows] after Flutter settings were updated + /// successfully. + Future> getPendingAlwaysAllows() async { + final pigeonVar_channelName = + 'dev.flutter.pigeon.simple_intent_receiver.IntentGatekeeperHostApi.getPendingAlwaysAllows$pigeonVar_messageChannelSuffix'; + final pigeonVar_channel = BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, + ); + final Future pigeonVar_sendFuture = pigeonVar_channel.send(null); + final pigeonVar_replyList = await pigeonVar_sendFuture as List?; + + final Object? pigeonVar_replyValue = _extractReplyValueOrThrow( + pigeonVar_replyList, + pigeonVar_channelName, + isNullValid: false, + ); + return (pigeonVar_replyValue! as List).cast(); + } + + /// Removes the given packages from the pending "Always allow" set after + /// Flutter has successfully persisted them into its own policy store. + Future ackPendingAlwaysAllows(List packageNames) async { + final pigeonVar_channelName = + 'dev.flutter.pigeon.simple_intent_receiver.IntentGatekeeperHostApi.ackPendingAlwaysAllows$pigeonVar_messageChannelSuffix'; + final pigeonVar_channel = BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, + ); + final Future pigeonVar_sendFuture = pigeonVar_channel.send( + [packageNames], + ); + final pigeonVar_replyList = await pigeonVar_sendFuture as List?; + + _extractReplyValueOrThrow( + pigeonVar_replyList, + pigeonVar_channelName, + isNullValid: true, + ); + } } diff --git a/packages/simple_intent_receiver/pigeons/intent.dart b/packages/simple_intent_receiver/pigeons/intent.dart index 9f8f2757..14347aaa 100644 --- a/packages/simple_intent_receiver/pigeons/intent.dart +++ b/packages/simple_intent_receiver/pigeons/intent.dart @@ -64,4 +64,15 @@ abstract class IntentGatekeeperHostApi { /// [PackageManager]. Returns `null` if the package is not installed or the /// label cannot be resolved. String? resolvePackageLabel(String packageName); + + /// Returns the list of packages for which the user tapped "Always allow" + /// via a blocked-intent notification while WebLibre was not running. + /// Callers must acknowledge persisted packages via + /// [ackPendingAlwaysAllows] after Flutter settings were updated + /// successfully. + List getPendingAlwaysAllows(); + + /// Removes the given packages from the pending "Always allow" set after + /// Flutter has successfully persisted them into its own policy store. + void ackPendingAlwaysAllows(List packageNames); }