From 015e817cf2d7f66c1b9268606af8318dfe0bc4ee Mon Sep 17 00:00:00 2001 From: Lucas Fernandes Nogueira Date: Mon, 2 Mar 2026 10:33:21 -0300 Subject: [PATCH] feat(deep-link): validate new intent URLs against configured deep links (#3186) --- .changes/validate-android-deep-link.md | 6 + .../android/src/main/java/DeepLinkPlugin.kt | 128 +++++++++++++++--- 2 files changed, 117 insertions(+), 17 deletions(-) create mode 100644 .changes/validate-android-deep-link.md diff --git a/.changes/validate-android-deep-link.md b/.changes/validate-android-deep-link.md new file mode 100644 index 000000000..03d6c2faf --- /dev/null +++ b/.changes/validate-android-deep-link.md @@ -0,0 +1,6 @@ +--- +"deep-link": patch +"deep-link-js": patch +--- + +Validate Android new intent is actually a deep link before triggering the onOpenUrl event. diff --git a/plugins/deep-link/android/src/main/java/DeepLinkPlugin.kt b/plugins/deep-link/android/src/main/java/DeepLinkPlugin.kt index db4e79af7..b51cd7cc0 100644 --- a/plugins/deep-link/android/src/main/java/DeepLinkPlugin.kt +++ b/plugins/deep-link/android/src/main/java/DeepLinkPlugin.kt @@ -6,9 +6,8 @@ package app.tauri.deep_link import android.app.Activity import android.content.Intent -import android.os.Bundle +import android.os.PatternMatcher import android.webkit.WebView -import app.tauri.Logger import app.tauri.annotation.InvokeArg import app.tauri.annotation.Command import app.tauri.annotation.TauriPlugin @@ -16,18 +15,35 @@ import app.tauri.plugin.Channel import app.tauri.plugin.JSObject import app.tauri.plugin.Plugin import app.tauri.plugin.Invoke +import androidx.core.net.toUri @InvokeArg class SetEventHandlerArgs { lateinit var handler: Channel } +@InvokeArg +class AssociatedDomain { + var scheme: List = listOf("https", "http") + var host: String? = null + var path: List = listOf() + var pathPattern: List = listOf() + var pathPrefix: List = listOf() + var pathSuffix: List = listOf() +} + +@InvokeArg +class PluginConfig { + var mobile: List = listOf() +} + @TauriPlugin class DeepLinkPlugin(private val activity: Activity): Plugin(activity) { //private val implementation = Example() private var webView: WebView? = null private var currentUrl: String? = null private var channel: Channel? = null + private var config: PluginConfig? = null companion object { var instance: DeepLinkPlugin? = null @@ -51,27 +67,105 @@ class DeepLinkPlugin(private val activity: Activity): Plugin(activity) { override fun load(webView: WebView) { instance = this - - val intent = activity.intent - - if (intent.action == Intent.ACTION_VIEW) { - // TODO: check if it makes sense to split up init url and last url - this.currentUrl = intent.data.toString() - val event = JSObject() - event.put("url", this.currentUrl) - this.channel?.send(event) - } + config = getConfig(PluginConfig::class.java) super.load(webView) this.webView = webView + + val intent = activity.intent + + if (intent.action == Intent.ACTION_VIEW && intent.data != null) { + val url = intent.data.toString() + if (isDeepLink(url)) { + // TODO: check if it makes sense to split up init url and last url + this.currentUrl = url + val event = JSObject() + event.put("url", this.currentUrl) + this.channel?.send(event) + } + } } override fun onNewIntent(intent: Intent) { - if (intent.action == Intent.ACTION_VIEW) { - this.currentUrl = intent.data.toString() - val event = JSObject() - event.put("url", this.currentUrl) - this.channel?.send(event) + if (intent.action == Intent.ACTION_VIEW && intent.data != null) { + val url = intent.data.toString() + if (isDeepLink(url)) { + this.currentUrl = url + val event = JSObject() + event.put("url", this.currentUrl) + this.channel?.send(event) + } } } + + private fun isDeepLink(url: String): Boolean { + val config = this.config ?: return false + + if (config.mobile.isEmpty()) { + return false + } + + val uri = try { + url.toUri() + } catch (_: Exception) { + // not a URL + return false + } + + val scheme = uri.scheme ?: return false + val host = uri.host + val path = uri.path ?: "" + + // Check if URL matches any configured mobile deep link + for (domain in config.mobile) { + // Check scheme + if (!domain.scheme.any { it.equals(scheme, ignoreCase = true) }) { + continue + } + + // Check host (if configured) + if (domain.host != null) { + if (!host.equals(domain.host, ignoreCase = true)) { + continue + } + } + + // Check path constraints + // According to Android docs: + // - path: exact match, must begin with / + // - pathPrefix: matches initial part of path + // - pathSuffix: matches ending part, doesn't need to begin with / + // - pathPattern: simple glob pattern (., *, .*) + val pathMatches = when { + // Exact path match (must begin with /) + domain.path.isNotEmpty() && domain.path.any { it == path } -> true + // Path pattern match (simple glob: ., *, .*) + domain.pathPattern.isNotEmpty() && domain.pathPattern.any { pattern -> + try { + PatternMatcher(pattern, PatternMatcher.PATTERN_SIMPLE_GLOB).match(path) + } catch (e: Exception) { + false + } + } -> true + // Path prefix match + domain.pathPrefix.isNotEmpty() && domain.pathPrefix.any { prefix -> + path.startsWith(prefix) + } -> true + // Path suffix match + domain.pathSuffix.isNotEmpty() && domain.pathSuffix.any { suffix -> + path.endsWith(suffix) + } -> true + // If no path constraints, any path is allowed + domain.path.isEmpty() && domain.pathPattern.isEmpty() && + domain.pathPrefix.isEmpty() && domain.pathSuffix.isEmpty() -> true + else -> false + } + + if (pathMatches) { + return true + } + } + + return false + } }