From 1e44dd2868df91fd1706e55e53f76efdbe00b039 Mon Sep 17 00:00:00 2001 From: Brandon Estrella Date: Sat, 15 Aug 2026 14:14:39 -0700 Subject: [PATCH] fix: tolerate empty deepLinkData object in InstallResponse MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The install endpoint returns `deepLinkData: {}` rather than `null` for organic (unattributed) installs. `DeepLinkData.shortCode` is required, so Moshi threw `JsonDataException` and the whole install response failed to decode, surfacing as a DecodingError out of `initialize()`. Register a lenient `DeepLinkData` adapter on the network Moshi instance: an empty object — or any payload without a usable short code — decodes to null instead of throwing, matching the `null` and absent cases. The failure is logged when debug logging is on, since a deep link we cannot decode is never worth aborting attribution over. Required top-level fields stay strict, and locally stored deep link data (written by the SDK) keeps its strict adapter. Ports the iOS SDK fix (LinkForty/mobile-sdk-ios#4) to Android. --- CHANGELOG.md | 4 + .../com/linkforty/sdk/models/DeepLinkData.kt | 41 +++++ .../linkforty/sdk/network/NetworkManager.kt | 2 + .../sdk/models/InstallResponseTest.kt | 174 ++++++++++++++++++ .../sdk/network/NetworkManagerTest.kt | 30 +++ 5 files changed, 251 insertions(+) create mode 100644 sdk/src/test/kotlin/com/linkforty/sdk/models/InstallResponseTest.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index ee6051a..e7b1a4d 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.0] - 2026-06-11 ### Added - The SDK now identifies itself on every request: a `sdkName` (`"android"`) and `sdkVersion` field is included on the install and event payloads, and an `X-LinkForty-SDK: android/` header is sent on all requests. This lets the backend report which SDKs and versions are in use and flag outdated integrations. The reported version is sourced from `BuildConfig` so it always matches the published artifact. No API or integration changes are required. 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..c9b78b1 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 @@ -53,3 +60,37 @@ data class DeepLinkData( } } } + +/** + * 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())