intent gatekeeper initial

This commit is contained in:
Fabian Freund
2026-04-19 02:23:04 +02:00
parent 5e6cc7a0f1
commit 57c3dcca86
27 changed files with 1413 additions and 12 deletions
@@ -11,12 +11,15 @@ import android.app.AlertDialog
import android.content.Context
import android.content.Intent
import android.content.pm.ShortcutManager
import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.util.Log
import eu.weblibre.flutter_mozilla_components.Components
import eu.weblibre.flutter_mozilla_components.GlobalComponents
import eu.weblibre.flutter_mozilla_components.PwaConstants
import eu.weblibre.flutter_mozilla_components.gatekeeper.IntentBlockNotifier
import eu.weblibre.flutter_mozilla_components.gatekeeper.IntentGatekeeperPreferences
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
@@ -58,9 +61,55 @@ class IntentReceiverActivity : Activity() {
intent.flags = intent.flags and Intent.FLAG_ACTIVITY_NEW_TASK.inv()
intent.flags = intent.flags and Intent.FLAG_ACTIVITY_CLEAR_TASK.inv()
if (shouldBlockIntent(intent)) {
finish()
return
}
processIntent(intent)
}
/**
* Fast native block-check. Only rejects packages explicitly on the blocked
* list; allowed and unknown packages fall through to the Flutter-side
* gatekeeper which can still prompt the user.
*
* PWA launches carrying our trusted profile metadata are never blocked here —
* those are treated as internal launches regardless of the caller.
*/
private fun shouldBlockIntent(intent: Intent): Boolean {
if (!IntentGatekeeperPreferences.isEnabled(applicationContext)) return false
if (intent.hasExtra(PwaConstants.EXTRA_PWA_PROFILE_UUID)) 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)
return true
}
private fun resolveCallerPackage(intent: Intent): String? {
referrer?.let { uri ->
if (uri.scheme == "android-app") {
uri.host?.let { return it }
}
}
@Suppress("DEPRECATION")
val referrerUri: Uri? = intent.getParcelableExtra(Intent.EXTRA_REFERRER)
if (referrerUri?.scheme == "android-app") {
referrerUri.host?.let { return it }
}
intent.getStringExtra(Intent.EXTRA_REFERRER_NAME)?.let { name ->
Uri.parse(name).takeIf { it.scheme == "android-app" }?.host?.let { return it }
}
return callingPackage
}
override fun onDestroy() {
super.onDestroy()
coroutineScope.cancel()
@@ -439,6 +488,12 @@ class IntentReceiverActivity : Activity() {
val mainActivityIntent = Intent(intent).apply {
setClassName(this@IntentReceiverActivity, "eu.weblibre.gecko.MainActivity")
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
// Preserve the original caller so the gatekeeper on the Flutter side
// can identify which app triggered this intent (getReferrer() in the
// forwarded activity would otherwise resolve to ourselves).
if (!hasExtra(Intent.EXTRA_REFERRER) && !hasExtra(Intent.EXTRA_REFERRER_NAME)) {
referrer?.let { putExtra(Intent.EXTRA_REFERRER, it) }
}
}
startActivity(mainActivityIntent)
finish()
@@ -0,0 +1,90 @@
/*
* 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.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.content.Context
import android.content.pm.PackageManager
import android.os.Build
import androidx.core.app.NotificationCompat
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.
*/
object IntentBlockNotifier {
private const val CHANNEL_ID = "intent_gatekeeper_channel"
private const val CHANNEL_NAME = "Blocked app launches"
private const val CHANNEL_DESC = "Informs you when another app is prevented from opening WebLibre."
fun notifyBlocked(context: Context, packageName: String) {
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)
.setSmallIcon(R.drawable.ic_launcher_foreground)
.setContentTitle("Blocked app launch")
.setContentText("Prevented $label from opening WebLibre.")
.setStyle(
NotificationCompat.BigTextStyle()
.bigText("Prevented $label from opening WebLibre.")
)
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
.setSilent(true)
.setAutoCancel(true)
.setShowWhen(true)
.build()
val manager = ContextCompat.getSystemService(appCtx, NotificationManager::class.java)
?: return
manager.notify(notificationId, notification)
}
private fun ensureChannel(context: Context) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
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,
).apply {
description = CHANNEL_DESC
setShowBadge(false)
}
manager.createNotificationChannel(channel)
}
private fun resolveAppLabel(context: Context, packageName: String): String? {
return try {
val pm = context.packageManager
val info = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
pm.getApplicationInfo(
packageName,
PackageManager.ApplicationInfoFlags.of(0),
)
} else {
@Suppress("DEPRECATION")
pm.getApplicationInfo(packageName, 0)
}
pm.getApplicationLabel(info).toString()
} catch (_: PackageManager.NameNotFoundException) {
null
} catch (_: Exception) {
null
}
}
}
@@ -0,0 +1,37 @@
/*
* 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.content.Context
import android.content.SharedPreferences
/**
* Cross-package shared-prefs file used to replicate the Flutter-side intent
* gatekeeper policy to the native side so [IntentReceiverActivity] can block
* intents without launching Flutter.
*
* The file name is a stable constant: other packages (e.g. simple_intent_receiver)
* write to the same file using [Context.getSharedPreferences] with this name.
*/
object IntentGatekeeperPreferences {
const val PREFS_NAME = "weblibre_intent_gatekeeper"
const val KEY_ENABLED = "enabled"
const val KEY_BLOCKED_PACKAGES = "blocked_packages"
fun get(context: Context): SharedPreferences =
context.applicationContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
fun isEnabled(context: Context): Boolean =
get(context).getBoolean(KEY_ENABLED, false)
fun isBlocked(context: Context, packageName: String): Boolean {
val prefs = get(context)
if (!prefs.getBoolean(KEY_ENABLED, false)) return false
val blocked = prefs.getStringSet(KEY_BLOCKED_PACKAGES, emptySet()) ?: return false
return packageName in blocked
}
}