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