feat(deep-link): validate new intent URLs against configured deep links (#3186)

This commit is contained in:
Lucas Fernandes Nogueira
2026-03-02 14:33:21 +01:00
committed by GitHub
parent 2a6d4b42bb
commit 015e817cf2
2 changed files with 117 additions and 17 deletions
+6
View File
@@ -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.
@@ -6,9 +6,8 @@ package app.tauri.deep_link
import android.app.Activity import android.app.Activity
import android.content.Intent import android.content.Intent
import android.os.Bundle import android.os.PatternMatcher
import android.webkit.WebView import android.webkit.WebView
import app.tauri.Logger
import app.tauri.annotation.InvokeArg import app.tauri.annotation.InvokeArg
import app.tauri.annotation.Command import app.tauri.annotation.Command
import app.tauri.annotation.TauriPlugin import app.tauri.annotation.TauriPlugin
@@ -16,18 +15,35 @@ import app.tauri.plugin.Channel
import app.tauri.plugin.JSObject import app.tauri.plugin.JSObject
import app.tauri.plugin.Plugin import app.tauri.plugin.Plugin
import app.tauri.plugin.Invoke import app.tauri.plugin.Invoke
import androidx.core.net.toUri
@InvokeArg @InvokeArg
class SetEventHandlerArgs { class SetEventHandlerArgs {
lateinit var handler: Channel lateinit var handler: Channel
} }
@InvokeArg
class AssociatedDomain {
var scheme: List<String> = listOf("https", "http")
var host: String? = null
var path: List<String> = listOf()
var pathPattern: List<String> = listOf()
var pathPrefix: List<String> = listOf()
var pathSuffix: List<String> = listOf()
}
@InvokeArg
class PluginConfig {
var mobile: List<AssociatedDomain> = listOf()
}
@TauriPlugin @TauriPlugin
class DeepLinkPlugin(private val activity: Activity): Plugin(activity) { class DeepLinkPlugin(private val activity: Activity): Plugin(activity) {
//private val implementation = Example() //private val implementation = Example()
private var webView: WebView? = null private var webView: WebView? = null
private var currentUrl: String? = null private var currentUrl: String? = null
private var channel: Channel? = null private var channel: Channel? = null
private var config: PluginConfig? = null
companion object { companion object {
var instance: DeepLinkPlugin? = null var instance: DeepLinkPlugin? = null
@@ -51,27 +67,105 @@ class DeepLinkPlugin(private val activity: Activity): Plugin(activity) {
override fun load(webView: WebView) { override fun load(webView: WebView) {
instance = this instance = this
config = getConfig(PluginConfig::class.java)
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)
}
super.load(webView) super.load(webView)
this.webView = 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) { override fun onNewIntent(intent: Intent) {
if (intent.action == Intent.ACTION_VIEW) { if (intent.action == Intent.ACTION_VIEW && intent.data != null) {
this.currentUrl = intent.data.toString() val url = intent.data.toString()
val event = JSObject() if (isDeepLink(url)) {
event.put("url", this.currentUrl) this.currentUrl = url
this.channel?.send(event) 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
}
} }