diff --git a/sdk/src/main/kotlin/com/linkforty/sdk/deeplink/DeepLinkHandler.kt b/sdk/src/main/kotlin/com/linkforty/sdk/deeplink/DeepLinkHandler.kt index cc7e4bc..ec45220 100644 --- a/sdk/src/main/kotlin/com/linkforty/sdk/deeplink/DeepLinkHandler.kt +++ b/sdk/src/main/kotlin/com/linkforty/sdk/deeplink/DeepLinkHandler.kt @@ -5,6 +5,7 @@ import com.linkforty.sdk.LinkFortyLogger import com.linkforty.sdk.attribution.AttributionContext import com.linkforty.sdk.fingerprint.FingerprintCollectorProtocol import com.linkforty.sdk.models.DeepLinkData +import com.linkforty.sdk.models.mergingUrlParameters import com.linkforty.sdk.network.HttpMethod import com.linkforty.sdk.network.NetworkManagerProtocol import com.linkforty.sdk.network.request @@ -222,7 +223,9 @@ internal class DeepLinkHandler { method = HttpMethod.GET ) LinkFortyLogger.log("Server-side resolution succeeded for $uri") - resolved + // The resolve returns the link's stored configuration; the + // parameters on the URL that was tapped are known only here. + resolved.mergingUrlParameters(fallback?.customParameters) } catch (e: Exception) { LinkFortyLogger.log("Server-side resolution failed, using local parse: ${e.message}") fallback diff --git a/sdk/src/main/kotlin/com/linkforty/sdk/models/DeepLinkData.kt b/sdk/src/main/kotlin/com/linkforty/sdk/models/DeepLinkData.kt index d8a2b66..c5bd0ea 100644 --- a/sdk/src/main/kotlin/com/linkforty/sdk/models/DeepLinkData.kt +++ b/sdk/src/main/kotlin/com/linkforty/sdk/models/DeepLinkData.kt @@ -53,3 +53,25 @@ data class DeepLinkData( } } } + +/** + * Returns a copy with the parameters carried on the opened URL merged in. + * + * Resolving a short code returns the link's *stored* configuration; the server + * has no way to know what was appended to the URL that was actually tapped. The + * SDK does, having just parsed it. Without this a link shared as + * `?slug=titanic` reaches the app with that value missing on a direct open, + * while the same link after a deferred install carries it — the server merges + * the click's parameters there. + * + * URL values win on a key collision, matching that server-side precedence: what + * a sharer put on the URL is more specific than the link's stored setup. + * + * Only [customParameters] is merged. [linkId], [deepLinkPath], [appScheme], the + * store URLs and [utmParameters] are server truth that a local parse cannot know + * and must not overwrite. + */ +fun DeepLinkData.mergingUrlParameters(fromUrl: Map?): DeepLinkData { + if (fromUrl.isNullOrEmpty()) return this + return copy(customParameters = (customParameters ?: emptyMap()) + fromUrl) +} diff --git a/sdk/src/main/kotlin/com/linkforty/sdk/utilities/UrlParser.kt b/sdk/src/main/kotlin/com/linkforty/sdk/utilities/UrlParser.kt index 10ebfe6..0d1b216 100644 --- a/sdk/src/main/kotlin/com/linkforty/sdk/utilities/UrlParser.kt +++ b/sdk/src/main/kotlin/com/linkforty/sdk/utilities/UrlParser.kt @@ -48,11 +48,10 @@ internal object UrlParser { * @return Map of custom parameters, empty if none found */ fun extractCustomParameters(uri: Uri): Map { - val utmKeys = setOf("utm_source", "utm_medium", "utm_campaign", "utm_term", "utm_content") val customParams = mutableMapOf() uri.queryParameterNames?.forEach { name -> - if (name !in utmKeys) { + if (!isReservedParameter(name)) { uri.getQueryParameter(name)?.let { value -> customParams[name] = value } @@ -62,6 +61,22 @@ internal object UrlParser { return customParams } + /** + * Names LinkForty consumes, which are never a custom parameter: + * utm_* surfaced separately as utmParameters + * fp_* fingerprint signals the SDK appends when resolving a link, and + * which the redirect reads server-side for attribution + * lf_click the click id the redirect appends to a destination URL + * + * Mirrors the server's own filter so a direct open and a deferred install + * agree on what reaches the app. A tapped short link would not normally + * carry the last two, but the URL is public and anyone can append them. + */ + fun isReservedParameter(name: String): Boolean { + val lower = name.lowercase() + return lower.startsWith("utm_") || lower.startsWith("fp_") || lower == "lf_click" + } + /** * Parses a URI into DeepLinkData. * @return DeepLinkData with extracted information, null if no short code found diff --git a/sdk/src/test/kotlin/com/linkforty/sdk/deeplink/UrlParserTest.kt b/sdk/src/test/kotlin/com/linkforty/sdk/deeplink/UrlParserTest.kt index ff928af..cab3b6c 100644 --- a/sdk/src/test/kotlin/com/linkforty/sdk/deeplink/UrlParserTest.kt +++ b/sdk/src/test/kotlin/com/linkforty/sdk/deeplink/UrlParserTest.kt @@ -5,6 +5,7 @@ import com.linkforty.sdk.utilities.UrlParser import io.mockk.every import io.mockk.mockk import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse import org.junit.jupiter.api.Assertions.assertNotNull import org.junit.jupiter.api.Assertions.assertNull import org.junit.jupiter.api.Assertions.assertTrue @@ -171,4 +172,34 @@ class UrlParserTest { assertNull(data?.customParameters) } + + // Reserved parameter names + + @Test + fun `reserved names are the ones LinkForty consumes`() { + // utm_* is surfaced separately as utmParameters; fp_* are fingerprint + // signals the redirect reads server-side; lf_click is the id appended to + // a destination URL. None of them is the app's data, and the server's + // own extractor excludes all three. + assertTrue(UrlParser.isReservedParameter("utm_source")) + assertTrue(UrlParser.isReservedParameter("fp_tz")) + assertTrue(UrlParser.isReservedParameter("lf_click")) + } + + @Test + fun `reserved names are matched case-insensitively`() { + assertTrue(UrlParser.isReservedParameter("UTM_Source")) + assertTrue(UrlParser.isReservedParameter("FP_TZ")) + assertTrue(UrlParser.isReservedParameter("LF_Click")) + } + + @Test + fun `ordinary parameter names are not reserved`() { + assertFalse(UrlParser.isReservedParameter("slug")) + assertFalse(UrlParser.isReservedParameter("promo")) + // Near-misses must not be swept up. + assertFalse(UrlParser.isReservedParameter("utmost")) + assertFalse(UrlParser.isReservedParameter("fps")) + assertFalse(UrlParser.isReservedParameter("lf_clicks")) + } } diff --git a/sdk/src/test/kotlin/com/linkforty/sdk/models/DeepLinkDataTest.kt b/sdk/src/test/kotlin/com/linkforty/sdk/models/DeepLinkDataTest.kt index 30522d0..62ac466 100644 --- a/sdk/src/test/kotlin/com/linkforty/sdk/models/DeepLinkDataTest.kt +++ b/sdk/src/test/kotlin/com/linkforty/sdk/models/DeepLinkDataTest.kt @@ -149,4 +149,54 @@ class DeepLinkDataTest { assertEquals(data1, data2) assert(data1 != data3) } + + // Merging URL parameters on a direct open + + @Test + fun `merge adds URL parameters when the link configures none`() { + val merged = DeepLinkData(shortCode = "abc123") + .mergingUrlParameters(mapOf("slug" to "titanic")) + + assertEquals("titanic", merged.customParameters?.get("slug")) + } + + @Test + fun `URL parameter overrides a configured one of the same name`() { + // Same precedence the server applies on the deferred path: what the + // sharer put on the URL is more specific than the link's stored setup. + val merged = DeepLinkData( + shortCode = "abc123", + customParameters = mapOf("slug" to "default", "keep" to "me") + ).mergingUrlParameters(mapOf("slug" to "titanic")) + + assertEquals("titanic", merged.customParameters?.get("slug")) + assertEquals("me", merged.customParameters?.get("keep")) + } + + @Test + fun `merge is a no-op when the URL carried nothing`() { + val resolved = DeepLinkData(shortCode = "abc123", customParameters = mapOf("a" to "1")) + + assertEquals(resolved, resolved.mergingUrlParameters(null)) + assertEquals(resolved, resolved.mergingUrlParameters(emptyMap())) + } + + @Test + fun `merge never overwrites fields only the server knows`() { + val resolved = DeepLinkData( + shortCode = "abc123", + androidURL = "https://play.google.com/store/apps/details?id=com.app", + utmParameters = UTMParameters(source = "ig"), + deepLinkPath = "/product/1", + appScheme = "myapp", + linkId = "link-1" + ) + val merged = resolved.mergingUrlParameters(mapOf("slug" to "titanic")) + + assertEquals("link-1", merged.linkId) + assertEquals("/product/1", merged.deepLinkPath) + assertEquals("myapp", merged.appScheme) + assertEquals("https://play.google.com/store/apps/details?id=com.app", merged.androidURL) + assertEquals("ig", merged.utmParameters?.source) + } }