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:exported="false"
android:launchMode="singleTop" /> 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 <receiver
android:name="org.ironfoxoss.unifiedpush.PushReceiver" android:name="org.ironfoxoss.unifiedpush.PushReceiver"
tools:node="remove" /> tools:node="remove" />
@@ -20,16 +20,16 @@
import 'dart:async'; import 'dart:async';
import 'package:riverpod_annotation/riverpod_annotation.dart'; 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/intent_source_policy.dart';
import 'package:weblibre/features/intent_gatekeeper/domain/entities/pending_intent_decision.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/settings/presentation/controllers/save_settings.dart';
import 'package:weblibre/features/user/data/models/general_settings.dart'; import 'package:weblibre/features/user/data/models/general_settings.dart';
import 'package:weblibre/features/user/domain/repositories/general_settings.dart'; import 'package:weblibre/features/user/domain/repositories/general_settings.dart';
part 'intent_gatekeeper.g.dart'; part 'intent_gatekeeper.g.dart';
const _ownPackageName = 'eu.weblibre.gecko';
@Riverpod(keepAlive: true) @Riverpod(keepAlive: true)
class IntentGatekeeper extends _$IntentGatekeeper { class IntentGatekeeper extends _$IntentGatekeeper {
late StreamController<PendingIntentDecision> _decisionRequests; late StreamController<PendingIntentDecision> _decisionRequests;
@@ -62,14 +62,24 @@ class IntentGatekeeper extends _$IntentGatekeeper {
required String? fromPackageName, required String? fromPackageName,
required String? url, required String? url,
}) async { }) async {
final settings = ref.read(generalSettingsWithDefaultsProvider); await ref
.read(nativeIntentGatekeeperReplicatorProvider.notifier)
.syncPendingAllows();
final settings = await ref
.read(generalSettingsRepositoryProvider.notifier)
.fetchSettings();
if (!settings.blockExternalAppsEnabled) { if (!settings.blockExternalAppsEnabled) {
return true; return true;
} }
final ownPackageName = (await ref.read(
packageInfoProvider.future,
)).packageName;
// Internal / unknown callers: no package to gate on — let through. // Internal / unknown callers: no package to gate on — let through.
if (fromPackageName == null || fromPackageName == _ownPackageName) { if (fromPackageName == null || fromPackageName == ownPackageName) {
return true; return true;
} }
@@ -26,6 +26,7 @@ import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:simple_intent_receiver/simple_intent_receiver.dart'; import 'package:simple_intent_receiver/simple_intent_receiver.dart';
import 'package:weblibre/core/logger.dart'; import 'package:weblibre/core/logger.dart';
import 'package:weblibre/features/intent_gatekeeper/domain/entities/intent_source_policy.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'; import 'package:weblibre/features/user/domain/repositories/general_settings.dart';
part 'native_gatekeeper_replicator.g.dart'; part 'native_gatekeeper_replicator.g.dart';
@@ -34,10 +35,14 @@ part 'native_gatekeeper_replicator.g.dart';
/// `IntentReceiverActivity` can reject intents without launching Flutter. /// `IntentReceiverActivity` can reject intents without launching Flutter.
/// Only blocked packages are replicated — allow/unknown still fall through to /// Only blocked packages are replicated — allow/unknown still fall through to
/// the Flutter gatekeeper dialog. /// 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) @Riverpod(keepAlive: true)
class NativeIntentGatekeeperReplicator class NativeIntentGatekeeperReplicator
extends _$NativeIntentGatekeeperReplicator { extends _$NativeIntentGatekeeperReplicator {
final _api = IntentGatekeeperHostApi(); final _api = IntentGatekeeperHostApi();
Future<void>? _pendingAllowSync;
Future<void> _push( Future<void> _push(
({bool enabled, Map<String, IntentSourcePolicy> policies}) config, ({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 @override
void build() { void build() {
unawaited(syncPendingAllows());
ref.listen( ref.listen(
generalSettingsWithDefaultsProvider.select( generalSettingsWithDefaultsProvider.select(
(settings) => EquatableValue(( (settings) => EquatableValue((
@@ -80,7 +130,16 @@ class NativeIntentGatekeeperReplicator
)) { )) {
return; 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. /// `IntentReceiverActivity` can reject intents without launching Flutter.
/// Only blocked packages are replicated — allow/unknown still fall through to /// Only blocked packages are replicated — allow/unknown still fall through to
/// the Flutter gatekeeper dialog. /// 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) @ProviderFor(NativeIntentGatekeeperReplicator)
final nativeIntentGatekeeperReplicatorProvider = final nativeIntentGatekeeperReplicatorProvider =
@@ -21,12 +24,18 @@ final nativeIntentGatekeeperReplicatorProvider =
/// `IntentReceiverActivity` can reject intents without launching Flutter. /// `IntentReceiverActivity` can reject intents without launching Flutter.
/// Only blocked packages are replicated — allow/unknown still fall through to /// Only blocked packages are replicated — allow/unknown still fall through to
/// the Flutter gatekeeper dialog. /// 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 final class NativeIntentGatekeeperReplicatorProvider
extends $NotifierProvider<NativeIntentGatekeeperReplicator, void> { extends $NotifierProvider<NativeIntentGatekeeperReplicator, void> {
/// Mirrors the Flutter-side block list to the native side so the /// Mirrors the Flutter-side block list to the native side so the
/// `IntentReceiverActivity` can reject intents without launching Flutter. /// `IntentReceiverActivity` can reject intents without launching Flutter.
/// Only blocked packages are replicated — allow/unknown still fall through to /// Only blocked packages are replicated — allow/unknown still fall through to
/// the Flutter gatekeeper dialog. /// 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._() NativeIntentGatekeeperReplicatorProvider._()
: super( : super(
from: null, from: null,
@@ -56,12 +65,15 @@ final class NativeIntentGatekeeperReplicatorProvider
} }
String _$nativeIntentGatekeeperReplicatorHash() => String _$nativeIntentGatekeeperReplicatorHash() =>
r'ee97dbd489e4e946e0a98cd640300f939f3b0682'; r'ed1ba2a318467317d6fb412495171acb8819e483';
/// Mirrors the Flutter-side block list to the native side so the /// Mirrors the Flutter-side block list to the native side so the
/// `IntentReceiverActivity` can reject intents without launching Flutter. /// `IntentReceiverActivity` can reject intents without launching Flutter.
/// Only blocked packages are replicated — allow/unknown still fall through to /// Only blocked packages are replicated — allow/unknown still fall through to
/// the Flutter gatekeeper dialog. /// 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> { abstract class _$NativeIntentGatekeeperReplicator extends $Notifier<void> {
void build(); 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:uri_to_file/uri_to_file.dart' as uri_to_file;
import 'package:weblibre/core/logger.dart'; import 'package:weblibre/core/logger.dart';
import 'package:weblibre/data/models/received_intent_parameter.dart'; import 'package:weblibre/data/models/received_intent_parameter.dart';
import 'package:weblibre/features/intent_gatekeeper/domain/entities/intent_source_policy.dart';
import 'package:weblibre/features/intent_gatekeeper/domain/services/intent_gatekeeper.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/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'; part 'sharing_intent.g.dart';
const _alwaysAllowPackageExtra = 'eu.weblibre.gatekeeper.always_allow_package';
StreamTransformer<Intent, ReceivedIntentParameter> StreamTransformer<Intent, ReceivedIntentParameter>
_buildSharingIntentTransformer( _buildSharingIntentTransformer(
IntentGatekeeper gatekeeper, IntentGatekeeper gatekeeper,
) => StreamTransformer<Intent, ReceivedIntentParameter>.fromHandlers( ) => StreamTransformer<Intent, ReceivedIntentParameter>.fromHandlers(
handleData: (intent, sink) async { 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' final shortcutContextId = intent.action == 'android.intent.action.VIEW'
? intent.extra['pwa_context_id'] as String? ? intent.extra['pwa_context_id'] as String?
: null; : 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.PwaSessionCreator
import eu.weblibre.flutter_mozilla_components.gatekeeper.IntentBlockNotifier import eu.weblibre.flutter_mozilla_components.gatekeeper.IntentBlockNotifier
import eu.weblibre.flutter_mozilla_components.gatekeeper.IntentGatekeeperPreferences import eu.weblibre.flutter_mozilla_components.gatekeeper.IntentGatekeeperPreferences
import eu.weblibre.flutter_mozilla_components.gatekeeper.GatekeeperNotificationActionReceiver
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.SupervisorJob
@@ -84,13 +85,20 @@ class IntentReceiverActivity : Activity() {
private fun shouldBlockIntent(intent: Intent): Boolean { private fun shouldBlockIntent(intent: Intent): Boolean {
if (!IntentGatekeeperPreferences.isEnabled(applicationContext)) return false if (!IntentGatekeeperPreferences.isEnabled(applicationContext)) return false
if (intent.hasExtra(PwaConstants.EXTRA_PWA_PROFILE_UUID)) 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 val caller = resolveCallerPackage(intent) ?: return false
if (caller == packageName) return false if (caller == packageName) return false
if (!IntentGatekeeperPreferences.isBlocked(applicationContext, caller)) return false if (!IntentGatekeeperPreferences.isBlocked(applicationContext, caller)) return false
Log.i(TAG, "Blocking intent from $caller (native gatekeeper)") Log.i(TAG, "Blocking intent from $caller (native gatekeeper)")
IntentBlockNotifier.notifyBlocked(applicationContext, caller) IntentBlockNotifier.notifyBlocked(applicationContext, caller, intent)
return true 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 package eu.weblibre.flutter_mozilla_components.gatekeeper
import android.app.Notification
import android.app.NotificationChannel import android.app.NotificationChannel
import android.app.NotificationManager import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Context import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager import android.content.pm.PackageManager
import android.os.Build import android.os.Build
import androidx.core.app.NotificationCompat import androidx.core.app.NotificationCompat
@@ -17,22 +18,25 @@ import androidx.core.content.ContextCompat
import eu.weblibre.flutter_mozilla_components.R import eu.weblibre.flutter_mozilla_components.R
/** /**
* Posts a purely informational notification when an intent is blocked by the * Posts an actionable heads-up notification when an intent is blocked by the
* gatekeeper. The notification has no actions and no content intent. * native gatekeeper. The notification shows "Allow once" and "Always allow"
* action buttons, and auto-dismisses after [TIMEOUT_MS] milliseconds.
*/ */
object IntentBlockNotifier { 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_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 val appCtx = context.applicationContext
ensureChannel(appCtx) ensureChannel(appCtx)
val label = resolveAppLabel(appCtx, packageName) ?: packageName val label = resolveAppLabel(appCtx, packageName) ?: packageName
val notificationId = (System.currentTimeMillis() and 0x7FFFFFFF).toInt() 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) .setSmallIcon(R.drawable.ic_launcher_foreground)
.setContentTitle("Blocked app launch") .setContentTitle("Blocked app launch")
.setContentText("Prevented $label from opening WebLibre.") .setContentText("Prevented $label from opening WebLibre.")
@@ -40,15 +44,66 @@ object IntentBlockNotifier {
NotificationCompat.BigTextStyle() NotificationCompat.BigTextStyle()
.bigText("Prevented $label from opening WebLibre.") .bigText("Prevented $label from opening WebLibre.")
) )
.setPriority(NotificationCompat.PRIORITY_DEFAULT) .setPriority(NotificationCompat.PRIORITY_HIGH)
.setSilent(true) .setSilent(true)
.setAutoCancel(true) .setAutoCancel(true)
.setShowWhen(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) val manager = ContextCompat.getSystemService(appCtx, NotificationManager::class.java)
?: return ?: 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) { private fun ensureChannel(context: Context) {
@@ -56,14 +111,16 @@ object IntentBlockNotifier {
val manager = ContextCompat.getSystemService(context, NotificationManager::class.java) val manager = ContextCompat.getSystemService(context, NotificationManager::class.java)
?: return ?: return
if (manager.getNotificationChannel(CHANNEL_ID) != null) return if (manager.getNotificationChannel(CHANNEL_ID) != null) return
val channel = NotificationChannel( val channel = NotificationChannel(
CHANNEL_ID, CHANNEL_ID,
CHANNEL_NAME, CHANNEL_NAME,
NotificationManager.IMPORTANCE_DEFAULT, NotificationManager.IMPORTANCE_HIGH,
).apply { ).apply {
description = CHANNEL_DESC description = CHANNEL_DESC
setShowBadge(false) setShowBadge(false)
enableLights(false)
enableVibration(false)
} }
manager.createNotificationChannel(channel) manager.createNotificationChannel(channel)
} }
@@ -8,6 +8,7 @@ package eu.weblibre.flutter_mozilla_components.gatekeeper
import android.content.Context import android.content.Context
import android.content.SharedPreferences import android.content.SharedPreferences
import java.util.UUID
/** /**
* Cross-package shared-prefs file used to replicate the Flutter-side intent * 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 PREFS_NAME = "weblibre_intent_gatekeeper"
const val KEY_ENABLED = "enabled" const val KEY_ENABLED = "enabled"
const val KEY_BLOCKED_PACKAGES = "blocked_packages" 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 = fun get(context: Context): SharedPreferences =
context.applicationContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) context.applicationContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
@@ -34,4 +42,76 @@ object IntentGatekeeperPreferences {
val blocked = prefs.getStringSet(KEY_BLOCKED_PACKAGES, emptySet()) ?: return false val blocked = prefs.getStringSet(KEY_BLOCKED_PACKAGES, emptySet()) ?: return false
return packageName in blocked 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 PREFS_NAME = "weblibre_intent_gatekeeper"
private const val KEY_ENABLED = "enabled" private const val KEY_ENABLED = "enabled"
private const val KEY_BLOCKED_PACKAGES = "blocked_packages" 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>) { override fun setConfig(enabled: Boolean, blockedPackages: List<String>) {
@@ -50,4 +51,24 @@ class IntentGatekeeperHostApiImpl(private val context: Context) : IntentGatekeep
null 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 import eu.weblibre.simple_intent_receiver.pigeons.IntentGatekeeperHostApi
class SimpleIntentReceiverPlugin: FlutterPlugin, ActivityAware, PluginRegistry.NewIntentListener { 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 lateinit var context: Context
private var intentReceiver: IntentReceiver? = null private var intentReceiver: IntentReceiver? = null
private var lastHandledIntent: String? = null private var lastHandledIntent: String? = null
@@ -125,12 +136,17 @@ class SimpleIntentReceiverPlugin: FlutterPlugin, ActivityAware, PluginRegistry.N
intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP) intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP)
} }
val pigeonIntent = convertToPigeonIntent(intent) val notificationApproval = consumeNotificationApproval(intent)
val pigeonIntent = convertToPigeonIntent(intent, notificationApproval)
intentReceiver?.sendIntent(pigeonIntent) intentReceiver?.sendIntent(pigeonIntent)
return true 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 val raw = resolveRawCallerPackage(intent) ?: return null
// Treat system packages (launcher, shell, SystemUI, etc.) as internal — the // Treat system packages (launcher, shell, SystemUI, etc.) as internal — the
// gatekeeper shouldn't prompt the user when the OS itself forwards an intent. // 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 action = intent.action
val data = intent.dataString val data = intent.dataString
val fromPackageName = resolveCallerPackage(intent) val fromPackageName = resolveCallerPackage(intent, notificationApproval)
val categories = ArrayList<String>() val categories = ArrayList<String>()
intent.categories?.let { intent.categories?.let {
@@ -193,6 +227,13 @@ class SimpleIntentReceiverPlugin: FlutterPlugin, ActivityAware, PluginRegistry.N
intent.extras?.let { bundle -> intent.extras?.let { bundle ->
for (key in bundle.keySet()) { for (key in bundle.keySet()) {
try { try {
if (key == EXTRA_NOTIFICATION_APPROVAL_TOKEN) {
continue
}
if (key == EXTRA_ALWAYS_ALLOW_PACKAGE) {
continue
}
when (val value = bundle.get(key)) { when (val value = bundle.get(key)) {
is Bundle -> { is Bundle -> {
val bundleMap = HashMap<String, Any?>() 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( return PigeonIntent(
fromPackageName = fromPackageName, fromPackageName = fromPackageName,
action = action, action = action,
@@ -309,6 +309,19 @@ interface IntentGatekeeperHostApi {
* label cannot be resolved. * label cannot be resolved.
*/ */
fun resolvePackageLabel(packageName: String): String? 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 { companion object {
/** The codec used by IntentGatekeeperHostApi. */ /** The codec used by IntentGatekeeperHostApi. */
@@ -355,6 +368,39 @@ interface IntentGatekeeperHostApi {
channel.setMessageHandler(null) 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?; 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 /// [PackageManager]. Returns `null` if the package is not installed or the
/// label cannot be resolved. /// label cannot be resolved.
String? resolvePackageLabel(String packageName); 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);
} }