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;
@@ -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
}
@@ -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)
}
}
}
@@ -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) {
@@ -60,10 +115,12 @@ object IntentBlockNotifier {
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)
}
@@ -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)
}
}
@@ -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<String>) {
@@ -50,4 +51,24 @@ class IntentGatekeeperHostApiImpl(private val context: Context) : IntentGatekeep
null
}
}
override fun getPendingAlwaysAllows(): List<String> {
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<String>) {
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()
}
}
}
}
@@ -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<String>()
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<String, Any?>()
@@ -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,
@@ -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<String>
/**
* 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<String>)
companion object {
/** The codec used by IntentGatekeeperHostApi. */
@@ -355,6 +368,39 @@ interface IntentGatekeeperHostApi {
channel.setMessageHandler(null)
}
}
run {
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.simple_intent_receiver.IntentGatekeeperHostApi.getPendingAlwaysAllows$separatedMessageChannelSuffix", codec)
if (api != null) {
channel.setMessageHandler { _, reply ->
val wrapped: List<Any?> = try {
listOf(api.getPendingAlwaysAllows())
} 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.ackPendingAlwaysAllows$separatedMessageChannelSuffix", codec)
if (api != null) {
channel.setMessageHandler { message, reply ->
val args = message as List<Any?>
val packageNamesArg = args[0] as List<String>
val wrapped: List<Any?> = try {
api.ackPendingAlwaysAllows(packageNamesArg)
listOf(null)
} catch (exception: Throwable) {
IntentPigeonUtils.wrapError(exception)
}
reply.reply(wrapped)
}
} else {
channel.setMessageHandler(null)
}
}
}
}
}
@@ -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<List<String>> getPendingAlwaysAllows() async {
final pigeonVar_channelName =
'dev.flutter.pigeon.simple_intent_receiver.IntentGatekeeperHostApi.getPendingAlwaysAllows$pigeonVar_messageChannelSuffix';
final pigeonVar_channel = BasicMessageChannel<Object?>(
pigeonVar_channelName,
pigeonChannelCodec,
binaryMessenger: pigeonVar_binaryMessenger,
);
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(null);
final pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
final Object? pigeonVar_replyValue = _extractReplyValueOrThrow(
pigeonVar_replyList,
pigeonVar_channelName,
isNullValid: false,
);
return (pigeonVar_replyValue! as List<Object?>).cast<String>();
}
/// Removes the given packages from the pending "Always allow" set after
/// Flutter has successfully persisted them into its own policy store.
Future<void> ackPendingAlwaysAllows(List<String> packageNames) async {
final pigeonVar_channelName =
'dev.flutter.pigeon.simple_intent_receiver.IntentGatekeeperHostApi.ackPendingAlwaysAllows$pigeonVar_messageChannelSuffix';
final pigeonVar_channel = BasicMessageChannel<Object?>(
pigeonVar_channelName,
pigeonChannelCodec,
binaryMessenger: pigeonVar_binaryMessenger,
);
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(
<Object?>[packageNames],
);
final pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
_extractReplyValueOrThrow(
pigeonVar_replyList,
pigeonVar_channelName,
isNullValid: true,
);
}
}
@@ -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<String> 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<String> packageNames);
}