added gatekeeper notification options
This commit is contained in:
+9
-1
@@ -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
|
||||
}
|
||||
|
||||
|
||||
+112
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
+69
-12
@@ -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)
|
||||
}
|
||||
|
||||
+80
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user