Skip to content

Commit 015e817

Browse files
authored
feat(deep-link): validate new intent URLs against configured deep links (#3186)
1 parent 2a6d4b4 commit 015e817

2 files changed

Lines changed: 116 additions & 16 deletions

File tree

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
"deep-link": patch
3+
"deep-link-js": patch
4+
---
5+
6+
Validate Android new intent is actually a deep link before triggering the onOpenUrl event.

plugins/deep-link/android/src/main/java/DeepLinkPlugin.kt

Lines changed: 110 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -6,28 +6,44 @@ package app.tauri.deep_link
66

77
import android.app.Activity
88
import android.content.Intent
9-
import android.os.Bundle
9+
import android.os.PatternMatcher
1010
import android.webkit.WebView
11-
import app.tauri.Logger
1211
import app.tauri.annotation.InvokeArg
1312
import app.tauri.annotation.Command
1413
import app.tauri.annotation.TauriPlugin
1514
import app.tauri.plugin.Channel
1615
import app.tauri.plugin.JSObject
1716
import app.tauri.plugin.Plugin
1817
import app.tauri.plugin.Invoke
18+
import androidx.core.net.toUri
1919

2020
@InvokeArg
2121
class SetEventHandlerArgs {
2222
lateinit var handler: Channel
2323
}
2424

25+
@InvokeArg
26+
class AssociatedDomain {
27+
var scheme: List<String> = listOf("https", "http")
28+
var host: String? = null
29+
var path: List<String> = listOf()
30+
var pathPattern: List<String> = listOf()
31+
var pathPrefix: List<String> = listOf()
32+
var pathSuffix: List<String> = listOf()
33+
}
34+
35+
@InvokeArg
36+
class PluginConfig {
37+
var mobile: List<AssociatedDomain> = listOf()
38+
}
39+
2540
@TauriPlugin
2641
class DeepLinkPlugin(private val activity: Activity): Plugin(activity) {
2742
//private val implementation = Example()
2843
private var webView: WebView? = null
2944
private var currentUrl: String? = null
3045
private var channel: Channel? = null
46+
private var config: PluginConfig? = null
3147

3248
companion object {
3349
var instance: DeepLinkPlugin? = null
@@ -51,27 +67,105 @@ class DeepLinkPlugin(private val activity: Activity): Plugin(activity) {
5167

5268
override fun load(webView: WebView) {
5369
instance = this
70+
config = getConfig(PluginConfig::class.java)
71+
72+
super.load(webView)
73+
this.webView = webView
5474

5575
val intent = activity.intent
5676

57-
if (intent.action == Intent.ACTION_VIEW) {
58-
// TODO: check if it makes sense to split up init url and last url
59-
this.currentUrl = intent.data.toString()
60-
val event = JSObject()
61-
event.put("url", this.currentUrl)
62-
this.channel?.send(event)
77+
if (intent.action == Intent.ACTION_VIEW && intent.data != null) {
78+
val url = intent.data.toString()
79+
if (isDeepLink(url)) {
80+
// TODO: check if it makes sense to split up init url and last url
81+
this.currentUrl = url
82+
val event = JSObject()
83+
event.put("url", this.currentUrl)
84+
this.channel?.send(event)
85+
}
6386
}
64-
65-
super.load(webView)
66-
this.webView = webView
6787
}
6888

6989
override fun onNewIntent(intent: Intent) {
70-
if (intent.action == Intent.ACTION_VIEW) {
71-
this.currentUrl = intent.data.toString()
72-
val event = JSObject()
73-
event.put("url", this.currentUrl)
74-
this.channel?.send(event)
90+
if (intent.action == Intent.ACTION_VIEW && intent.data != null) {
91+
val url = intent.data.toString()
92+
if (isDeepLink(url)) {
93+
this.currentUrl = url
94+
val event = JSObject()
95+
event.put("url", this.currentUrl)
96+
this.channel?.send(event)
97+
}
7598
}
7699
}
100+
101+
private fun isDeepLink(url: String): Boolean {
102+
val config = this.config ?: return false
103+
104+
if (config.mobile.isEmpty()) {
105+
return false
106+
}
107+
108+
val uri = try {
109+
url.toUri()
110+
} catch (_: Exception) {
111+
// not a URL
112+
return false
113+
}
114+
115+
val scheme = uri.scheme ?: return false
116+
val host = uri.host
117+
val path = uri.path ?: ""
118+
119+
// Check if URL matches any configured mobile deep link
120+
for (domain in config.mobile) {
121+
// Check scheme
122+
if (!domain.scheme.any { it.equals(scheme, ignoreCase = true) }) {
123+
continue
124+
}
125+
126+
// Check host (if configured)
127+
if (domain.host != null) {
128+
if (!host.equals(domain.host, ignoreCase = true)) {
129+
continue
130+
}
131+
}
132+
133+
// Check path constraints
134+
// According to Android docs:
135+
// - path: exact match, must begin with /
136+
// - pathPrefix: matches initial part of path
137+
// - pathSuffix: matches ending part, doesn't need to begin with /
138+
// - pathPattern: simple glob pattern (., *, .*)
139+
val pathMatches = when {
140+
// Exact path match (must begin with /)
141+
domain.path.isNotEmpty() && domain.path.any { it == path } -> true
142+
// Path pattern match (simple glob: ., *, .*)
143+
domain.pathPattern.isNotEmpty() && domain.pathPattern.any { pattern ->
144+
try {
145+
PatternMatcher(pattern, PatternMatcher.PATTERN_SIMPLE_GLOB).match(path)
146+
} catch (e: Exception) {
147+
false
148+
}
149+
} -> true
150+
// Path prefix match
151+
domain.pathPrefix.isNotEmpty() && domain.pathPrefix.any { prefix ->
152+
path.startsWith(prefix)
153+
} -> true
154+
// Path suffix match
155+
domain.pathSuffix.isNotEmpty() && domain.pathSuffix.any { suffix ->
156+
path.endsWith(suffix)
157+
} -> true
158+
// If no path constraints, any path is allowed
159+
domain.path.isEmpty() && domain.pathPattern.isEmpty() &&
160+
domain.pathPrefix.isEmpty() && domain.pathSuffix.isEmpty() -> true
161+
else -> false
162+
}
163+
164+
if (pathMatches) {
165+
return true
166+
}
167+
}
168+
169+
return false
170+
}
77171
}

0 commit comments

Comments
 (0)