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
@@ -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)
}
}
}
}
}