diff --git a/CHANGELOG.md b/CHANGELOG.md index d649500..500196d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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)). 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 c5bd0ea..36458da 100644 --- a/sdk/src/main/kotlin/com/linkforty/sdk/models/DeepLinkData.kt +++ b/sdk/src/main/kotlin/com/linkforty/sdk/models/DeepLinkData.kt @@ -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 @@ -75,3 +82,37 @@ fun DeepLinkData.mergingUrlParameters(fromUrl: Map?): 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? { + 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) { + if (value == null) writer.nullValue() else delegate.toJson(writer, value) + } +} diff --git a/sdk/src/main/kotlin/com/linkforty/sdk/network/NetworkManager.kt b/sdk/src/main/kotlin/com/linkforty/sdk/network/NetworkManager.kt index 6733f11..de86f18 100644 --- a/sdk/src/main/kotlin/com/linkforty/sdk/network/NetworkManager.kt +++ b/sdk/src/main/kotlin/com/linkforty/sdk/network/NetworkManager.kt @@ -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 @@ -42,6 +43,7 @@ internal class NetworkManager( private val moshi: Moshi = Moshi.Builder() .add(AnyJsonAdapter()) + .add(LenientDeepLinkDataAdapter()) .build() /** Maximum number of retry attempts */ diff --git a/sdk/src/test/kotlin/com/linkforty/sdk/models/InstallResponseTest.kt b/sdk/src/test/kotlin/com/linkforty/sdk/models/InstallResponseTest.kt new file mode 100644 index 0000000..6f40e7e --- /dev/null +++ b/sdk/src/test/kotlin/com/linkforty/sdk/models/InstallResponseTest.kt @@ -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 { 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) + } +} diff --git a/sdk/src/test/kotlin/com/linkforty/sdk/network/NetworkManagerTest.kt b/sdk/src/test/kotlin/com/linkforty/sdk/network/NetworkManagerTest.kt index bac214f..e7a0b7d 100644 --- a/sdk/src/test/kotlin/com/linkforty/sdk/network/NetworkManagerTest.kt +++ b/sdk/src/test/kotlin/com/linkforty/sdk/network/NetworkManagerTest.kt @@ -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 @@ -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())