Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
22 changes: 22 additions & 0 deletions sdk/src/main/kotlin/com/linkforty/sdk/models/DeepLinkData.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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<String, String>?): DeepLinkData {
if (fromUrl.isNullOrEmpty()) return this
return copy(customParameters = (customParameters ?: emptyMap()) + fromUrl)
}
19 changes: 17 additions & 2 deletions sdk/src/main/kotlin/com/linkforty/sdk/utilities/UrlParser.kt
Original file line number Diff line number Diff line change
Expand Up @@ -48,11 +48,10 @@ internal object UrlParser {
* @return Map of custom parameters, empty if none found
*/
fun extractCustomParameters(uri: Uri): Map<String, String> {
val utmKeys = setOf("utm_source", "utm_medium", "utm_campaign", "utm_term", "utm_content")
val customParams = mutableMapOf<String, String>()

uri.queryParameterNames?.forEach { name ->
if (name !in utmKeys) {
if (!isReservedParameter(name)) {
uri.getQueryParameter(name)?.let { value ->
customParams[name] = value
}
Expand All @@ -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
Expand Down
31 changes: 31 additions & 0 deletions sdk/src/test/kotlin/com/linkforty/sdk/deeplink/UrlParserTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"))
}
}
50 changes: 50 additions & 0 deletions sdk/src/test/kotlin/com/linkforty/sdk/models/DeepLinkDataTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
Loading