diff --git a/CHANGELOG.md b/CHANGELOG.md index 2d25301..32f7823 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## 0.3.3 + +- **Fixed:** `setAppUserId` posted `/ingestIdentify` on every call, so apps that link the billing user id from several places could fire it multiple times per launch. 0.3.3 skips when the id matches the last successfully identified value (persisted like `trackInstall`), and coalesces concurrent identical calls into one request. A different id still posts. +- Add optional `codeMatches` and `offer.creatorName` on the typed `TapAppLinkRedeemResult` from `applyCode` (matches server redeem response fields). Parse the success `JSONObject` with `TapAppLinkRedeemResult.from`. +- Send `X-TapAppLink-SDK-Version: 0.3.3` on all requests. + ## 0.3.2 - **Fixed:** retrying `applyCode` after a timeout could count an install twice. 0.3.2 sends a request ID so the server recognises the retry. No code changes needed. diff --git a/README.md b/README.md index b0eb3aa..6cb6c62 100644 --- a/README.md +++ b/README.md @@ -16,10 +16,10 @@ dependencyResolutionManagement { } ``` -Then in the app module (0.3.2 or later): +Then in the app module (0.3.3 or later): ```kotlin -implementation("com.github.tapapplink:tapapplink-android:0.3.2") +implementation("com.github.tapapplink:tapapplink-android:0.3.3") ``` Upgrading from 0.3.0? 0.3.1 changed `applyCode` on Android. See the [changelog](CHANGELOG.md). @@ -50,7 +50,7 @@ TapAppLink.setAppUserId(Purchases.sharedInstance.appUserID) { } ### Apply a discount code -Show success only on a real success result. Map each outcome to UI like this. On a network or timeout error only (never unknown, inactive or wrongEnvironment), retry `applyCode` once automatically with the same code. From 0.3.2 that retry is safe because the SDK reuses its request ID, so the server won't count the install twice. +Show success only on a real success result. Map each outcome to UI like this. On a network or timeout error only (never unknown, inactive or wrongEnvironment), retry `applyCode` once automatically with the same code. From 0.3.2 that retry is safe because the SDK reuses its request ID, so the server won't count the install twice. From 0.3.3, success results may include optional `codeMatches` and `offer.creatorName` (see `TapAppLinkRedeemResult`). ```kotlin fun redeem(code: String, isRetry: Boolean = false) { @@ -122,7 +122,7 @@ redeem(code) `TapAppLinkRedeemException.WrongEnvironment.DEVELOPER_WARNING` is exactly: `This code belongs to the other environment (Sandbox or Production). Check your API key.` -`trackInstall()` is safe on every launch. It stores an `installId`, the tracked flag, attribution id and offer in SharedPreferences, and only posts `/ingestInstall` once per install. `setAppUserId` / identify send the stored `attributionId`. +`trackInstall()` is safe on every launch. It stores an `installId`, the tracked flag, attribution id and offer in SharedPreferences, and only posts `/ingestInstall` once per install. `setAppUserId` sends that stored `attributionId` after hydrate, posts `/ingestIdentify` only when the app user id changes, and coalesces concurrent identical calls. Call `resetForTesting(context)` in debug builds before repeating a match test on the same device. @@ -132,7 +132,7 @@ Purchases are attributed through billing webhooks. Leave out a client `trackPurc GitHub Actions runs on every pull request and push to `main`: Gradle assemble, unit tests, ktlint, and Android Lint. -Pushing a semver tag (`0.3.2`, `v0.3.2`, or a prerelease suffix) runs the same checks, creates a GitHub Release, and requests a JitPack build for that tag. `jitpack.yml` pins OpenJDK 17 for JitPack. +Pushing a semver tag (`0.3.3`, `v0.3.3`, or a prerelease suffix) runs the same checks, creates a GitHub Release, and requests a JitPack build for that tag. `jitpack.yml` pins OpenJDK 17 for JitPack. ### Local checks @@ -142,7 +142,7 @@ Pushing a semver tag (`0.3.2`, `v0.3.2`, or a prerelease suffix) runs the same c ## Publishing -**JitPack (current):** consumers depend on `com.github.tapapplink:tapapplink-android:0.3.2` (0.3.2 or later). Tag a release; CI triggers JitPack. No extra secrets are required for that path. +**JitPack (current):** consumers depend on `com.github.tapapplink:tapapplink-android:0.3.3` (0.3.3 or later). Tag a release; CI triggers JitPack. No extra secrets are required for that path. **Maven Central (optional, disabled):** the release workflow includes a `maven-central` job left behind `if: false` until a repository admin finishes the one-time setup below. Prefer JitPack until you need Central discovery or a non-`com.github` coordinate. diff --git a/build.gradle.kts b/build.gradle.kts index f52930e..8a3545e 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -6,7 +6,7 @@ plugins { } group = "com.tapapplink" -version = "0.3.2" +version = "0.3.3" android { namespace = "com.tapapplink.sdk" @@ -54,7 +54,7 @@ afterEvaluate { from(components["release"]) groupId = "com.tapapplink" artifactId = "sdk" - version = "0.3.2" + version = "0.3.3" pom { name.set("Tap App Link Android SDK") description.set("Tap App Link attribution SDK for Android") diff --git a/gradle.properties b/gradle.properties index 8475b5c..49b57e1 100644 --- a/gradle.properties +++ b/gradle.properties @@ -3,4 +3,4 @@ android.useAndroidX=true kotlin.code.style=official GROUP=com.tapapplink POM_ARTIFACT_ID=sdk -VERSION_NAME=0.3.2 +VERSION_NAME=0.3.3 diff --git a/src/main/java/com/tapapplink/sdk/TapAppLink.kt b/src/main/java/com/tapapplink/sdk/TapAppLink.kt index e65baf2..86fd35f 100644 --- a/src/main/java/com/tapapplink/sdk/TapAppLink.kt +++ b/src/main/java/com/tapapplink/sdk/TapAppLink.kt @@ -28,21 +28,64 @@ data class TapAppLinkOffer( val billingOfferId: String?, ) +/** + * Success body from `/redeemCode`. New fields from the server are optional so + * older clients keep type-checking when parsing the JSONObject result. + */ +data class TapAppLinkRedeemResult( + val attributionId: String? = null, + val alreadyAttributed: Boolean? = null, + /** True when the submitted code matches the attributed creator offer. */ + val codeMatches: Boolean? = null, + val offer: Offer? = null, +) { + data class Offer( + val creatorName: String? = null, + val promoCode: String? = null, + val billingOfferId: String? = null, + ) + + companion object { + fun from(json: JSONObject): TapAppLinkRedeemResult { + val offerJson = json.optJSONObject("offer") + return TapAppLinkRedeemResult( + attributionId = json.optString("attributionId").takeIf { it.isNotBlank() }, + alreadyAttributed = if (json.has("alreadyAttributed")) json.optBoolean("alreadyAttributed") else null, + codeMatches = if (json.has("codeMatches")) json.optBoolean("codeMatches") else null, + offer = offerJson?.let { offer -> + Offer( + creatorName = offer.optString("creatorName").takeIf { it.isNotBlank() }, + promoCode = offer.optString("promoCode").takeIf { it.isNotBlank() }, + billingOfferId = offer.optString("billingOfferId").takeIf { it.isNotBlank() }, + ) + }, + ) + } + } +} + object TapAppLink { private const val TAG = "TapAppLink" /** Sent on every request as `X-TapAppLink-SDK-Version`. */ - const val SDK_VERSION = "0.3.2" + const val SDK_VERSION = "0.3.3" private var config: TapAppLinkConfig? = null private var store: TapAppLinkStore? = null private var lastAppUserId: String? = null + + /** Last app user id that successfully completed `/ingestIdentify` (persisted). */ + private var identifiedAppUserId: String? = null private var referrerReader: ReferrerReader = PlayInstallReferrerReader() private var httpClient: TapAppLinkHttpClient = DefaultTapAppLinkHttpClient private var memoryPendingRedeemCode: String? = null private var memoryPendingRedeemRequestId: String? = null private val io = Executors.newSingleThreadExecutor() + /** Coalesce concurrent identical `setAppUserId` calls into one POST. */ + private val identifyLock = Any() + private val identifyInFlight = mutableMapOf Unit>>() + @JvmStatic fun configure(next: TapAppLinkConfig) { config = next @@ -100,11 +143,44 @@ object TapAppLink { @JvmStatic fun setAppUserId(appUserId: String, callback: (JSONObject) -> Unit) { lastAppUserId = appUserId + + if (identifiedAppUserId == appUserId) { + val skipped = skippedIdentifyResult(appUserId) + debugLog("setAppUserId skipped; already identified $skipped") + callback(skipped) + return + } + + val shouldPost: Boolean + synchronized(identifyLock) { + val waiters = identifyInFlight[appUserId] + if (waiters != null) { + waiters.add(callback) + debugLog("setAppUserId coalesced with in-flight identify appUserId=$appUserId") + shouldPost = false + } else { + identifyInFlight[appUserId] = mutableListOf(callback) + shouldPost = true + } + } + if (!shouldPost) return + io.execute { try { - callback(identify(appUserId)) + if (identifiedAppUserId == appUserId) { + completeIdentify(appUserId, skippedIdentifyResult(appUserId)) + return@execute + } + val result = identify(appUserId) + identifiedAppUserId = appUserId + store?.appUserId = appUserId + completeIdentify(appUserId, result) } catch (error: Exception) { debugLog("setAppUserId failed: ${error.message}") + // Failed identify is not persisted; drop waiters so a retry can post again. + synchronized(identifyLock) { + identifyInFlight.remove(appUserId) + } } } } @@ -113,8 +189,9 @@ object TapAppLink { * Redeems a discount code via `/redeemCode`. * * On success, [callback] receives [Result.success] with the JSON body - * (including `alreadyAttributed` when the install was already linked). - * On failure, [callback] receives [Result.failure] with a + * (including optional `alreadyAttributed`, `codeMatches`, and `offer` with + * optional `creatorName`). Parse with [TapAppLinkRedeemResult.from] for a + * typed view. On failure, [callback] receives [Result.failure] with a * [TapAppLinkRedeemException] case. * * Signature note: the callback changed from `(JSONObject) -> Unit` in 0.3.0 @@ -190,8 +267,12 @@ object TapAppLink { @JvmOverloads fun resetForTesting(context: Context? = null) { lastAppUserId = null + identifiedAppUserId = null memoryPendingRedeemCode = null memoryPendingRedeemRequestId = null + synchronized(identifyLock) { + identifyInFlight.clear() + } if (context != null) { ensureStore(context).clear() } else { @@ -208,6 +289,7 @@ object TapAppLink { internal fun setStoreForTesting(next: TapAppLinkStore?) { store = next + hydrateFromStore(next) } internal fun setHttpClientForTesting(client: TapAppLinkHttpClient) { @@ -237,7 +319,31 @@ object TapAppLink { private fun ensureStore(context: Context): TapAppLinkStore { val existing = store if (existing != null) return existing - return TapAppLinkStore.from(context).also { store = it } + return TapAppLinkStore.from(context).also { + store = it + hydrateFromStore(it) + } + } + + private fun hydrateFromStore(persisted: TapAppLinkStore?) { + if (persisted == null) return + identifiedAppUserId = persisted.appUserId + persisted.appUserId?.let { lastAppUserId = it } + } + + private fun skippedIdentifyResult(appUserId: String): JSONObject { + val result = JSONObject() + .put("skipped", true) + .put("appUserId", appUserId) + store?.attributionId?.let { result.put("attributionId", it) } + return result + } + + private fun completeIdentify(appUserId: String, result: JSONObject) { + val waiters = synchronized(identifyLock) { + identifyInFlight.remove(appUserId).orEmpty().toList() + } + waiters.forEach { it(result) } } private fun skippedResult(persisted: TapAppLinkStore): JSONObject { @@ -273,7 +379,7 @@ object TapAppLink { val cfg = requireConfig() val body = JSONObject().put("appUserId", appUserId) store?.attributionId?.let { body.put("attributionId", it) } - debugLog("ingestIdentify attributionId=${store?.attributionId}") + debugLog("ingestIdentify appUserId=$appUserId attributionId=${store?.attributionId}") return post(cfg, "/ingestIdentify", body) } diff --git a/src/main/java/com/tapapplink/sdk/TapAppLinkStore.kt b/src/main/java/com/tapapplink/sdk/TapAppLinkStore.kt index a886ed9..2eba9d3 100644 --- a/src/main/java/com/tapapplink/sdk/TapAppLinkStore.kt +++ b/src/main/java/com/tapapplink/sdk/TapAppLinkStore.kt @@ -32,6 +32,17 @@ internal class TapAppLinkStore( } } + /** Last app user id that successfully completed `/ingestIdentify`. */ + var appUserId: String? + get() = prefs.getString(KEY_APP_USER_ID, null)?.takeIf { it.isNotBlank() } + set(value) { + if (value.isNullOrBlank()) { + prefs.remove(KEY_APP_USER_ID) + } else { + prefs.putString(KEY_APP_USER_ID, value) + } + } + var offer: TapAppLinkOffer? get() { val creatorName = prefs.getString(KEY_OFFER_CREATOR, null) ?: return null @@ -90,14 +101,15 @@ internal class TapAppLinkStore( prefs.clear() } - fun debugSnapshot(): String = "installId=${installId()} tracked=$tracked attributionId=$attributionId offer=$offer " + - "pendingRedeem=${pendingRedeemNormalizedCode()}/${pendingRedeemRequestId()}" + fun debugSnapshot(): String = "installId=${installId()} tracked=$tracked attributionId=$attributionId " + + "appUserId=$appUserId offer=$offer pendingRedeem=${pendingRedeemNormalizedCode()}/${pendingRedeemRequestId()}" companion object { internal const val PREFS_NAME = "tapapplink_sdk" private const val KEY_INSTALL_ID = "install_id" private const val KEY_TRACKED = "tracked" private const val KEY_ATTRIBUTION_ID = "attribution_id" + private const val KEY_APP_USER_ID = "app_user_id" private const val KEY_OFFER_CREATOR = "offer_creator_name" private const val KEY_OFFER_PROMO = "offer_promo_code" private const val KEY_OFFER_BILLING = "offer_billing_offer_id" diff --git a/src/test/java/com/tapapplink/sdk/TapAppLinkApplyCodeTest.kt b/src/test/java/com/tapapplink/sdk/TapAppLinkApplyCodeTest.kt index 5a04e1f..48929d9 100644 --- a/src/test/java/com/tapapplink/sdk/TapAppLinkApplyCodeTest.kt +++ b/src/test/java/com/tapapplink/sdk/TapAppLinkApplyCodeTest.kt @@ -32,7 +32,7 @@ class TapAppLinkApplyCodeTest { seenHeaders.set(headers) TapAppLinkHttpResponse( 200, - """{"attributionId":"attr_9","alreadyAttributed":false,"offer":{"creatorName":"Sarah","promoCode":"SARAH10","billingOfferId":"offer_1"}}""", + """{"attributionId":"attr_9","alreadyAttributed":false,"codeMatches":true,"offer":{"creatorName":"Sarah","promoCode":"SARAH10","billingOfferId":"offer_1"}}""", ) } @@ -41,9 +41,15 @@ class TapAppLinkApplyCodeTest { val body = result.getOrThrow() assertEquals("attr_9", body.getString("attributionId")) assertFalse(body.getBoolean("alreadyAttributed")) + assertTrue(body.getBoolean("codeMatches")) assertEquals("attr_9", TapAppLink.getAttributionId()) assertEquals("Sarah", TapAppLink.getOffer()?.creatorName) - assertEquals("0.3.2", seenHeaders.get()["X-TapAppLink-SDK-Version"]) + val typed = TapAppLinkRedeemResult.from(body) + assertEquals("attr_9", typed.attributionId) + assertEquals(false, typed.alreadyAttributed) + assertEquals(true, typed.codeMatches) + assertEquals("Sarah", typed.offer?.creatorName) + assertEquals("0.3.3", seenHeaders.get()["X-TapAppLink-SDK-Version"]) assertEquals(TapAppLink.SDK_VERSION, seenHeaders.get()["X-TapAppLink-SDK-Version"]) } @@ -52,13 +58,19 @@ class TapAppLinkApplyCodeTest { TapAppLink.setHttpClientForTesting { _, _, _ -> TapAppLinkHttpResponse( 200, - """{"attributionId":"attr_1","alreadyAttributed":true,"offer":{"creatorName":"Alex","promoCode":null,"billingOfferId":"b1"}}""", + """{"attributionId":"attr_1","alreadyAttributed":true,"codeMatches":false,"offer":{"creatorName":"Alex","promoCode":null,"billingOfferId":"b1"}}""", ) } val result = awaitApply("ALEX") assertTrue(result.isSuccess) - assertTrue(result.getOrThrow().getBoolean("alreadyAttributed")) + val body = result.getOrThrow() + assertTrue(body.getBoolean("alreadyAttributed")) + assertFalse(body.getBoolean("codeMatches")) + val typed = TapAppLinkRedeemResult.from(body) + assertEquals(true, typed.alreadyAttributed) + assertEquals(false, typed.codeMatches) + assertEquals("Alex", typed.offer?.creatorName) } @Test diff --git a/src/test/java/com/tapapplink/sdk/TapAppLinkIdentifyTest.kt b/src/test/java/com/tapapplink/sdk/TapAppLinkIdentifyTest.kt new file mode 100644 index 0000000..a8ac933 --- /dev/null +++ b/src/test/java/com/tapapplink/sdk/TapAppLinkIdentifyTest.kt @@ -0,0 +1,202 @@ +package com.tapapplink.sdk + +import org.json.JSONObject +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import java.io.IOException +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicInteger +import java.util.concurrent.atomic.AtomicReference + +class TapAppLinkIdentifyTest { + private lateinit var prefs: InMemoryKeyValueStore + + @Before + fun setUp() { + TapAppLink.resetForTesting() + prefs = InMemoryKeyValueStore() + val store = TapAppLinkStore(prefs) + store.tracked = true + store.attributionId = "attr_id" + TapAppLink.setStoreForTesting(store) + TapAppLink.configure( + TapAppLinkConfig( + publicKey = "etk_test_key_123456", + environment = TapAppLinkEnvironment.SANDBOX, + ingestUrl = "https://example.test", + ), + ) + } + + @Test + fun skipsIngestIdentifyWhenAppUserIdMatchesLastSuccessfulIdentify() { + val identifyCalls = AtomicInteger(0) + TapAppLink.setHttpClientForTesting { url, _, _ -> + if (url.contains("/ingestIdentify")) { + identifyCalls.incrementAndGet() + TapAppLinkHttpResponse(200, """{"ok":true}""") + } else { + TapAppLinkHttpResponse(200, "{}") + } + } + + val first = awaitIdentify("user_same") + val second = awaitIdentify("user_same") + + assertEquals(true, first.optBoolean("ok")) + assertTrue(second.getBoolean("skipped")) + assertEquals("user_same", second.getString("appUserId")) + assertEquals("attr_id", second.getString("attributionId")) + assertEquals(1, identifyCalls.get()) + assertEquals("user_same", TapAppLinkStore(prefs).appUserId) + } + + @Test + fun doesNotPostIngestIdentifyAgainAfterColdStartWithSameAppUserId() { + val identifyCalls = AtomicInteger(0) + val mockClient = TapAppLinkHttpClient { url, _, _ -> + if (url.contains("/ingestIdentify")) { + identifyCalls.incrementAndGet() + TapAppLinkHttpResponse(200, """{"ok":true}""") + } else { + TapAppLinkHttpResponse(200, "{}") + } + } + TapAppLink.setHttpClientForTesting(mockClient) + + awaitIdentify("user_persisted") + assertEquals(1, identifyCalls.get()) + + // Simulate process death: new store instance on prefs that kept appUserId. + val coldPrefs = InMemoryKeyValueStore() + coldPrefs.putString("install_id", "install_cold") + coldPrefs.putBoolean("tracked", true) + coldPrefs.putString("attribution_id", "attr_cold_id") + coldPrefs.putString("app_user_id", "user_persisted") + TapAppLink.resetForTesting() + TapAppLink.setStoreForTesting(TapAppLinkStore(coldPrefs)) + TapAppLink.setHttpClientForTesting(mockClient) + TapAppLink.configure( + TapAppLinkConfig( + publicKey = "etk_test_key_123456", + environment = TapAppLinkEnvironment.SANDBOX, + ingestUrl = "https://example.test", + ), + ) + + val resumed = awaitIdentify("user_persisted") + + assertEquals(1, identifyCalls.get()) + assertTrue(resumed.getBoolean("skipped")) + assertEquals("user_persisted", resumed.getString("appUserId")) + assertEquals("attr_cold_id", resumed.getString("attributionId")) + assertEquals("user_persisted", TapAppLink.getAppUserId()) + } + + @Test + fun coalescesConcurrentIdenticalSetAppUserIdCallsIntoOnePost() { + val identifyGate = CountDownLatch(1) + val identifyStarted = CountDownLatch(1) + val identifyCalls = AtomicInteger(0) + TapAppLink.setHttpClientForTesting { url, _, _ -> + if (url.contains("/ingestIdentify")) { + identifyCalls.incrementAndGet() + identifyStarted.countDown() + assertTrue(identifyGate.await(5, TimeUnit.SECONDS)) + TapAppLinkHttpResponse(200, """{"ok":true}""") + } else { + TapAppLinkHttpResponse(200, "{}") + } + } + + val latch = CountDownLatch(3) + val results = arrayOfNulls(3) + TapAppLink.setAppUserId("user_race") { + results[0] = it + latch.countDown() + } + // Wait until the first POST is in flight so A is registered before B/C join. + assertTrue(identifyStarted.await(5, TimeUnit.SECONDS)) + TapAppLink.setAppUserId("user_race") { + results[1] = it + latch.countDown() + } + TapAppLink.setAppUserId("user_race") { + results[2] = it + latch.countDown() + } + identifyGate.countDown() + assertTrue("timed out waiting for coalesced identify", latch.await(5, TimeUnit.SECONDS)) + + assertEquals(1, identifyCalls.get()) + assertEquals(results[0].toString(), results[1].toString()) + assertEquals(results[1].toString(), results[2].toString()) + assertEquals(true, results[0]!!.optBoolean("ok")) + } + + @Test + fun postsIngestIdentifyAgainWhenAppUserIdChanges() { + val identifyBodies = mutableListOf() + TapAppLink.setHttpClientForTesting { url, _, body -> + if (url.contains("/ingestIdentify")) { + identifyBodies.add(body) + TapAppLinkHttpResponse(200, """{"ok":true}""") + } else { + TapAppLinkHttpResponse(200, "{}") + } + } + + awaitIdentify("user_a") + awaitIdentify("user_b") + + assertEquals(2, identifyBodies.size) + assertEquals("user_a", JSONObject(identifyBodies[0]).getString("appUserId")) + assertEquals("user_b", JSONObject(identifyBodies[1]).getString("appUserId")) + assertEquals("user_b", TapAppLink.getAppUserId()) + assertEquals("user_b", TapAppLinkStore(prefs).appUserId) + } + + @Test + fun doesNotSkipIdentifyAfterFailedIngestIdentify() { + val identifyCalls = AtomicInteger(0) + TapAppLink.setHttpClientForTesting { url, _, _ -> + if (url.contains("/ingestIdentify")) { + val n = identifyCalls.incrementAndGet() + if (n == 1) { + throw IOException("identify failed") + } + TapAppLinkHttpResponse(200, """{"ok":true}""") + } else { + TapAppLinkHttpResponse(200, "{}") + } + } + + val failedLatch = CountDownLatch(1) + TapAppLink.setAppUserId("user_retry") { failedLatch.countDown() } + assertFalse( + "failed identify must not invoke callback or persist", + failedLatch.await(400, TimeUnit.MILLISECONDS), + ) + assertEquals(null, TapAppLinkStore(prefs).appUserId) + + val retry = awaitIdentify("user_retry") + assertEquals(true, retry.optBoolean("ok")) + assertEquals(2, identifyCalls.get()) + assertEquals("user_retry", TapAppLinkStore(prefs).appUserId) + } + + private fun awaitIdentify(appUserId: String): JSONObject { + val latch = CountDownLatch(1) + val held = AtomicReference() + TapAppLink.setAppUserId(appUserId) { result -> + held.set(result) + latch.countDown() + } + assertTrue("timed out waiting for setAppUserId", latch.await(5, TimeUnit.SECONDS)) + return held.get() + } +} diff --git a/src/test/java/com/tapapplink/sdk/TapAppLinkRedeemRequestIdTest.kt b/src/test/java/com/tapapplink/sdk/TapAppLinkRedeemRequestIdTest.kt index f6c125a..dce2adf 100644 --- a/src/test/java/com/tapapplink/sdk/TapAppLinkRedeemRequestIdTest.kt +++ b/src/test/java/com/tapapplink/sdk/TapAppLinkRedeemRequestIdTest.kt @@ -148,7 +148,7 @@ class TapAppLinkRedeemRequestIdTest { TapAppLinkHttpResponse(200, """{"attributionId":"a"}""") } awaitApply("X") - assertEquals("0.3.2", headers.get()["X-TapAppLink-SDK-Version"]) + assertEquals("0.3.3", headers.get()["X-TapAppLink-SDK-Version"]) assertEquals(TapAppLink.SDK_VERSION, headers.get()["X-TapAppLink-SDK-Version"]) } diff --git a/src/test/java/com/tapapplink/sdk/TapAppLinkStoreTest.kt b/src/test/java/com/tapapplink/sdk/TapAppLinkStoreTest.kt index f446bf0..1d73a32 100644 --- a/src/test/java/com/tapapplink/sdk/TapAppLinkStoreTest.kt +++ b/src/test/java/com/tapapplink/sdk/TapAppLinkStoreTest.kt @@ -45,6 +45,7 @@ class TapAppLinkStoreTest { @Test fun attributionAndOfferPersistAcrossReload() { store.attributionId = "attr_123" + store.appUserId = "user_42" store.offer = TapAppLinkOffer( creatorName = "Sarah", promoCode = "SARAH10", @@ -53,6 +54,7 @@ class TapAppLinkStoreTest { val reloaded = TapAppLinkStore(prefs) assertEquals("attr_123", reloaded.attributionId) + assertEquals("user_42", reloaded.appUserId) assertEquals("Sarah", reloaded.offer?.creatorName) assertEquals("SARAH10", reloaded.offer?.promoCode) assertEquals("offer_1", reloaded.offer?.billingOfferId) @@ -63,12 +65,14 @@ class TapAppLinkStoreTest { val original = store.installId() store.tracked = true store.attributionId = "attr_123" + store.appUserId = "user_42" store.offer = TapAppLinkOffer("Sarah", "SARAH10", "offer_1") store.clear() assertFalse(store.tracked) assertNull(store.attributionId) + assertNull(store.appUserId) assertNull(store.offer) assertNotEquals(original, store.installId()) } diff --git a/src/test/java/com/tapapplink/sdk/TapAppLinkTest.kt b/src/test/java/com/tapapplink/sdk/TapAppLinkTest.kt index 2fc40d8..b91ebdd 100644 --- a/src/test/java/com/tapapplink/sdk/TapAppLinkTest.kt +++ b/src/test/java/com/tapapplink/sdk/TapAppLinkTest.kt @@ -68,6 +68,21 @@ class TapAppLinkTest { assertEquals("offer_1", offer.billingOfferId) } + @Test + fun redeemResultExposesOptionalCodeMatchesAndCreatorName() { + val typed = TapAppLinkRedeemResult.from( + org.json.JSONObject( + """{"attributionId":"attr_1","alreadyAttributed":true,"codeMatches":true,"offer":{"creatorName":"Ada","promoCode":"ADA10","billingOfferId":"b1"}}""", + ), + ) + assertEquals("attr_1", typed.attributionId) + assertEquals(true, typed.alreadyAttributed) + assertEquals(true, typed.codeMatches) + assertEquals("Ada", typed.offer?.creatorName) + assertEquals("ADA10", typed.offer?.promoCode) + assertEquals("b1", typed.offer?.billingOfferId) + } + @Test fun redactKeyHidesMiddleOfApiKey() { val redacted = TapAppLink.redactKey("etk_live_abcdefghijklmnop")