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