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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

---

## [Unreleased]
### Fixed
- `InstallResponse` no longer fails to decode when the backend returns `deepLinkData: {}` for an organic (unattributed) install, which surfaced as a `LinkFortyError.DecodingError` out of `initialize()`. An empty or otherwise unusable `deepLinkData` object is now treated as "no deep link" (`null`), the same as `null`.

## [1.3.1] - 2026-09-09
### Fixed
- URL parameters appended to a link are now delivered on a **direct open** (app already installed), not just after a deferred install. A link shared as `?slug=titanic` previously returned only the link's stored configuration on a direct open, because `resolveUrl` discarded the local parse of the tapped URL. `customParameters` now carries both, with URL values winning on a collision — the same precedence the server applies on the deferred path. `linkId`, `deepLinkPath`, `appScheme`, the store URLs and `utmParameters` remain server-provided ([#5](https://github.com/LinkForty/mobile-sdk-android/pull/5)).
Expand Down
41 changes: 41 additions & 0 deletions sdk/src/main/kotlin/com/linkforty/sdk/models/DeepLinkData.kt
Original file line number Diff line number Diff line change
@@ -1,7 +1,14 @@
package com.linkforty.sdk.models

import com.linkforty.sdk.LinkFortyLogger
import com.squareup.moshi.FromJson
import com.squareup.moshi.Json
import com.squareup.moshi.JsonAdapter
import com.squareup.moshi.JsonClass
import com.squareup.moshi.JsonDataException
import com.squareup.moshi.JsonReader
import com.squareup.moshi.JsonWriter
import com.squareup.moshi.ToJson
import java.time.Instant
import java.time.format.DateTimeParseException

Expand Down Expand Up @@ -75,3 +82,37 @@ fun DeepLinkData.mergingUrlParameters(fromUrl: Map<String, String>?): DeepLinkDa
if (fromUrl.isNullOrEmpty()) return this
return copy(customParameters = (customParameters ?: emptyMap()) + fromUrl)
}

/**
* Moshi adapter that decodes an unusable [DeepLinkData] payload as null instead
* of throwing.
*
* Organic (unattributed) installs come back as `deepLinkData: {}` rather than
* `null`, and an object without a `shortCode` carries no link to route to.
* Without this, decoding an install response for an organic install fails
* outright — taking SDK initialization down with it — over a field the caller
* never needed.
*
* Only registered on the SDK's network [com.squareup.moshi.Moshi] instance;
* locally stored deep link data is written by the SDK and stays strict.
*/
internal class LenientDeepLinkDataAdapter {

@FromJson
fun fromJson(reader: JsonReader, delegate: JsonAdapter<DeepLinkData>): DeepLinkData? {
val value = reader.readJsonValue()
if (value !is Map<*, *>) return null

return try {
delegate.fromJsonValue(value)
} catch (e: JsonDataException) {
LinkFortyLogger.log("Ignoring undecodable deepLinkData: ${e.message}")
null
}
}

@ToJson
fun toJson(writer: JsonWriter, value: DeepLinkData?, delegate: JsonAdapter<DeepLinkData>) {
if (value == null) writer.nullValue() else delegate.toJson(writer, value)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import com.linkforty.sdk.LinkFortyLogger
import com.linkforty.sdk.SdkInfo
import com.linkforty.sdk.errors.LinkFortyError
import com.linkforty.sdk.models.AnyJsonAdapter
import com.linkforty.sdk.models.LenientDeepLinkDataAdapter
import com.linkforty.sdk.models.LinkFortyConfig
import com.squareup.moshi.Moshi
import kotlinx.coroutines.delay
Expand Down Expand Up @@ -42,6 +43,7 @@ internal class NetworkManager(

private val moshi: Moshi = Moshi.Builder()
.add(AnyJsonAdapter())
.add(LenientDeepLinkDataAdapter())
.build()

/** Maximum number of retry attempts */
Expand Down
174 changes: 174 additions & 0 deletions sdk/src/test/kotlin/com/linkforty/sdk/models/InstallResponseTest.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
package com.linkforty.sdk.models

import com.squareup.moshi.JsonDataException
import com.squareup.moshi.Moshi
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
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.assertThrows

/**
* Decoding tests for [InstallResponse] using the same Moshi configuration the
* SDK's network layer builds.
*/
class InstallResponseTest {

private val moshi = Moshi.Builder()
.add(LenientDeepLinkDataAdapter())
.build()
private val adapter = moshi.adapter(InstallResponse::class.java)

// -- Organic Installs --

/** The backend returns `deepLinkData: {}` (not null) for organic installs. */
@Test
fun `decodes empty deepLinkData object as null`() {
val json = """
{
"installId": "install-123",
"attributed": false,
"confidenceScore": 0,
"matchedFactors": [],
"deepLinkData": {}
}
""".trimIndent()

val response = adapter.fromJson(json)

assertNotNull(response)
assertEquals("install-123", response?.installId)
assertFalse(response!!.attributed)
assertEquals(0.0, response.confidenceScore)
assertTrue(response.matchedFactors.isEmpty())
assertNull(response.deepLinkData)
}

@Test
fun `decodes null deepLinkData as null`() {
val json = """
{
"installId": "install-123",
"attributed": false,
"confidenceScore": 0,
"matchedFactors": [],
"deepLinkData": null
}
""".trimIndent()

assertNull(adapter.fromJson(json)?.deepLinkData)
}

@Test
fun `decodes missing deepLinkData as null`() {
val json = """
{
"installId": "install-123",
"attributed": false,
"confidenceScore": 0,
"matchedFactors": []
}
""".trimIndent()

assertNull(adapter.fromJson(json)?.deepLinkData)
}

/**
* A deep link with no short code can't be routed to, so it is not worth
* failing the whole response over.
*/
@Test
fun `decodes deepLinkData without a shortCode as null`() {
val json = """
{
"installId": "install-123",
"attributed": false,
"confidenceScore": 0,
"matchedFactors": [],
"deepLinkData": {"iosUrl": "myapp://product/456"}
}
""".trimIndent()

assertNull(adapter.fromJson(json)?.deepLinkData)
}

// -- Attributed Installs --

@Test
fun `decodes attributed response with deep link data`() {
val json = """
{
"installId": "install-123",
"attributed": true,
"confidenceScore": 85,
"matchedFactors": ["userAgent", "timezone"],
"deepLinkData": {
"shortCode": "abc123",
"iosUrl": "myapp://product/456",
"deepLinkPath": "/product/456",
"clickedAt": "2026-01-15T10:30:00Z"
}
}
""".trimIndent()

val response = adapter.fromJson(json)

assertNotNull(response)
assertTrue(response!!.attributed)
assertEquals(85.0, response.confidenceScore)
assertEquals(listOf("userAgent", "timezone"), response.matchedFactors)
assertEquals("abc123", response.deepLinkData?.shortCode)
assertEquals("myapp://product/456", response.deepLinkData?.iosURL)
assertEquals("/product/456", response.deepLinkData?.deepLinkPath)
assertNotNull(response.deepLinkData?.clickedAtDate())
}

// -- Required Fields --

@Test
fun `throws when installId is missing`() {
val json = """
{
"attributed": false,
"confidenceScore": 0,
"matchedFactors": []
}
""".trimIndent()

assertThrows<JsonDataException> { adapter.fromJson(json) }
}

// -- Round Trip --

@Test
fun `round trips through encoding`() {
val original = InstallResponse(
installId = "install-123",
attributed = true,
confidenceScore = 85.0,
matchedFactors = listOf("userAgent"),
deepLinkData = DeepLinkData(shortCode = "abc123", iosURL = "myapp://product/456")
)

val decoded = adapter.fromJson(adapter.toJson(original))

assertEquals(original, decoded)
}

@Test
fun `round trips an organic response through encoding`() {
val original = InstallResponse(
installId = "install-123",
attributed = false,
confidenceScore = 0.0,
matchedFactors = emptyList(),
deepLinkData = null
)

val decoded = adapter.fromJson(adapter.toJson(original))

assertEquals(original, decoded)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,9 @@ import com.squareup.moshi.JsonClass
import com.squareup.moshi.Moshi
import kotlinx.coroutines.test.runTest
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
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
Expand Down Expand Up @@ -57,6 +59,34 @@ class NetworkManagerTest {
assertEquals(85.0, result.confidenceScore)
}

/**
* The backend returns `deepLinkData: {}` for organic installs; decoding it
* must not fail the response.
*/
@Test
fun `decodes organic install response with empty deepLinkData`() = runTest {
val responseJson = """
{
"installId": "test-id",
"attributed": false,
"confidenceScore": 0,
"matchedFactors": [],
"deepLinkData": {}
}
""".trimIndent()

mockHttpClient.mockResponse = HttpResponse(200, responseJson.toByteArray())

val result: InstallResponse = sut.request(
endpoint = "/test",
method = HttpMethod.POST
)

assertEquals("test-id", result.installId)
assertFalse(result.attributed)
assertNull(result.deepLinkData)
}

@Test
fun `successful POST request with body`() = runTest {
mockHttpClient.mockResponse = HttpResponse(201, """{"ok": true}""".toByteArray())
Expand Down
Loading