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
@@ -0,0 +1,53 @@
/*
* Copyright (c) 2024-2026 Fabian Freund.
*
* This file is part of WebLibre
* (see https://weblibre.eu).
*/
package eu.weblibre.simple_intent_receiver
import android.content.Context
import android.content.pm.PackageManager
import android.os.Build
import eu.weblibre.simple_intent_receiver.pigeons.IntentGatekeeperHostApi
/**
* Persists the Flutter-side gatekeeper policy to a shared-prefs file that
* [eu.weblibre.flutter_mozilla_components.activities.IntentReceiverActivity]
* reads on each incoming intent.
*
* The prefs file name MUST match
* [eu.weblibre.flutter_mozilla_components.gatekeeper.IntentGatekeeperPreferences.PREFS_NAME].
*/
class IntentGatekeeperHostApiImpl(private val context: Context) : IntentGatekeeperHostApi {
companion object {
private const val PREFS_NAME = "weblibre_intent_gatekeeper"
private const val KEY_ENABLED = "enabled"
private const val KEY_BLOCKED_PACKAGES = "blocked_packages"
}
override fun setConfig(enabled: Boolean, blockedPackages: List<String>) {
val prefs = context.applicationContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
prefs.edit()
.putBoolean(KEY_ENABLED, enabled)
.putStringSet(KEY_BLOCKED_PACKAGES, blockedPackages.toSet())
.apply()
}
override fun resolvePackageLabel(packageName: String): String? {
return try {
val pm = context.applicationContext.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
}
}
}
@@ -22,7 +22,10 @@ package eu.weblibre.simple_intent_receiver
import android.app.Activity
import android.content.Context
import android.content.Intent
import android.content.pm.ApplicationInfo
import android.content.pm.PackageManager
import android.net.Uri
import android.os.Build
import android.os.Bundle
import io.flutter.Log
import io.flutter.embedding.engine.plugins.FlutterPlugin
@@ -30,20 +33,29 @@ import io.flutter.embedding.engine.plugins.activity.ActivityAware
import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding
import io.flutter.plugin.common.PluginRegistry
import eu.weblibre.simple_intent_receiver.pigeons.Intent as PigeonIntent
import eu.weblibre.simple_intent_receiver.pigeons.IntentGatekeeperHostApi
class SimpleIntentReceiverPlugin: FlutterPlugin, ActivityAware, PluginRegistry.NewIntentListener {
private lateinit var context: Context
private var intentReceiver: IntentReceiver? = null
private var lastHandledIntent: String? = null
private var activity: Activity? = null
private var binaryMessenger: io.flutter.plugin.common.BinaryMessenger? = null
override fun onAttachedToEngine(flutterPluginBinding: FlutterPlugin.FlutterPluginBinding) {
context = flutterPluginBinding.applicationContext
intentReceiver = IntentReceiver(flutterPluginBinding.binaryMessenger)
binaryMessenger = flutterPluginBinding.binaryMessenger
IntentGatekeeperHostApi.setUp(
flutterPluginBinding.binaryMessenger,
IntentGatekeeperHostApiImpl(flutterPluginBinding.applicationContext),
)
}
override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) {
intentReceiver = null
binaryMessenger?.let { IntentGatekeeperHostApi.setUp(it, null) }
binaryMessenger = null
}
override fun onAttachedToActivity(binding: ActivityPluginBinding) {
@@ -118,10 +130,59 @@ class SimpleIntentReceiverPlugin: FlutterPlugin, ActivityAware, PluginRegistry.N
return true
}
private fun resolveCallerPackage(intent: Intent): String? {
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.
if (isSystemPackage(raw)) return null
return raw
}
private fun resolveRawCallerPackage(intent: Intent): String? {
// 1. Try Activity.getReferrer() — handles EXTRA_REFERRER/_NAME and real caller.
activity?.referrer?.let { uri ->
if (uri.scheme == "android-app") {
uri.host?.let { return it }
}
}
// 2. Fallback to explicit referrer extras on the intent itself.
@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 }
}
// 3. Caller for startActivityForResult flows.
return activity?.callingPackage
}
private fun isSystemPackage(packageName: String): Boolean {
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)
}
val systemFlags = ApplicationInfo.FLAG_SYSTEM or ApplicationInfo.FLAG_UPDATED_SYSTEM_APP
(info.flags and systemFlags) != 0
} catch (_: PackageManager.NameNotFoundException) {
false
} catch (_: Exception) {
false
}
}
private fun convertToPigeonIntent(intent: Intent): PigeonIntent {
val action = intent.action
val data = intent.dataString
val fromPackageName = intent.getPackage()
val fromPackageName = resolveCallerPackage(intent)
val categories = ArrayList<String>()
intent.categories?.let {
@@ -17,6 +17,26 @@ private object IntentPigeonUtils {
fun createConnectionError(channelName: String): FlutterError {
return FlutterError("channel-error", "Unable to establish connection on channel: '$channelName'.", "") }
fun wrapResult(result: Any?): List<Any?> {
return listOf(result)
}
fun wrapError(exception: Throwable): List<Any?> {
return if (exception is FlutterError) {
listOf(
exception.code,
exception.message,
exception.details
)
} else {
listOf(
exception.javaClass.simpleName,
exception.toString(),
"Cause: " + exception.cause + ", Stacktrace: " + Log.getStackTraceString(exception)
)
}
}
fun doubleEquals(a: Double, b: Double): Boolean {
// Normalize -0.0 to 0.0 and handle NaN equality.
return (if (a == 0.0) 0.0 else a) == (if (b == 0.0) 0.0 else b) || (a.isNaN() && b.isNaN())
@@ -276,3 +296,65 @@ class IntentEvents(private val binaryMessenger: BinaryMessenger, private val mes
}
}
}
/** Generated interface from Pigeon that represents a handler of messages from Flutter. */
interface IntentGatekeeperHostApi {
/**
* Replicates the blocked-packages policy to the native side so the
* [IntentReceiverActivity] can reject intents without launching Flutter.
*/
fun setConfig(enabled: Boolean, blockedPackages: List<String>)
/**
* Resolves a package name to its user-visible application label via
* [PackageManager]. Returns `null` if the package is not installed or the
* label cannot be resolved.
*/
fun resolvePackageLabel(packageName: String): String?
companion object {
/** The codec used by IntentGatekeeperHostApi. */
val codec: MessageCodec<Any?> by lazy {
IntentPigeonCodec()
}
/** Sets up an instance of `IntentGatekeeperHostApi` to handle messages through the `binaryMessenger`. */
@JvmOverloads
fun setUp(binaryMessenger: BinaryMessenger, api: IntentGatekeeperHostApi?, messageChannelSuffix: String = "") {
val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else ""
run {
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.simple_intent_receiver.IntentGatekeeperHostApi.setConfig$separatedMessageChannelSuffix", codec)
if (api != null) {
channel.setMessageHandler { message, reply ->
val args = message as List<Any?>
val enabledArg = args[0] as Boolean
val blockedPackagesArg = args[1] as List<String>
val wrapped: List<Any?> = try {
api.setConfig(enabledArg, blockedPackagesArg)
listOf(null)
} 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.resolvePackageLabel$separatedMessageChannelSuffix", codec)
if (api != null) {
channel.setMessageHandler { message, reply ->
val args = message as List<Any?>
val packageNameArg = args[0] as String
val wrapped: List<Any?> = try {
listOf(api.resolvePackageLabel(packageNameArg))
} catch (exception: Throwable) {
IntentPigeonUtils.wrapError(exception)
}
reply.reply(wrapped)
}
} else {
channel.setMessageHandler(null)
}
}
}
}
}