diff --git a/CHANGELOG.md b/CHANGELOG.md index 65b9199..2d25301 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +## 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. + ## 0.3.1 - **Breaking (Android only):** `applyCode` now returns `Result`, so 0.3.0 call sites won't compile until they're updated. This is deliberate. The 0.3.0 call couldn't report an error, so any app using it may show an invalid code as applied. Update each call to handle the failure case (snippet below). diff --git a/README.md b/README.md index 78839f6..e2faa22 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,7 @@ dependencyResolutionManagement { Then in the app module: ```kotlin -implementation("com.github.tapapplink:tapapplink-android:0.3.1") +implementation("com.github.tapapplink:tapapplink-android:0.3.2") ``` `./gradlew` needs JDK 17 or 21. JDK 25 is not supported by this Android Gradle Plugin. @@ -119,7 +119,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.1`, `v0.3.1`, 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.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. ### Local checks diff --git a/build.gradle.kts b/build.gradle.kts index b85f766..f52930e 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -6,7 +6,7 @@ plugins { } group = "com.tapapplink" -version = "0.3.1" +version = "0.3.2" android { namespace = "com.tapapplink.sdk" @@ -54,7 +54,7 @@ afterEvaluate { from(components["release"]) groupId = "com.tapapplink" artifactId = "sdk" - version = "0.3.1" + version = "0.3.2" 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 47d5eb2..8475b5c 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.1 +VERSION_NAME=0.3.2 diff --git a/src/main/java/com/tapapplink/sdk/RedeemCodeNormalizer.kt b/src/main/java/com/tapapplink/sdk/RedeemCodeNormalizer.kt new file mode 100644 index 0000000..09928e6 --- /dev/null +++ b/src/main/java/com/tapapplink/sdk/RedeemCodeNormalizer.kt @@ -0,0 +1,9 @@ +package com.tapapplink.sdk + +import java.util.Locale + +/** + * Normalises a promo code for pending-redeem requestId reuse: + * uppercase, strip non-alphanumerics, max 24 characters. + */ +internal fun normalizeRedeemCode(code: String): String = code.uppercase(Locale.US).filter { it.isLetterOrDigit() }.take(24) diff --git a/src/main/java/com/tapapplink/sdk/TapAppLink.kt b/src/main/java/com/tapapplink/sdk/TapAppLink.kt index b0bf4a7..e65baf2 100644 --- a/src/main/java/com/tapapplink/sdk/TapAppLink.kt +++ b/src/main/java/com/tapapplink/sdk/TapAppLink.kt @@ -7,6 +7,7 @@ import java.text.SimpleDateFormat import java.util.Date import java.util.Locale import java.util.TimeZone +import java.util.UUID import java.util.concurrent.Executors enum class TapAppLinkEnvironment(val value: String) { @@ -31,13 +32,15 @@ object TapAppLink { private const val TAG = "TapAppLink" /** Sent on every request as `X-TapAppLink-SDK-Version`. */ - const val SDK_VERSION = "0.3.1" + const val SDK_VERSION = "0.3.2" private var config: TapAppLinkConfig? = null private var store: TapAppLinkStore? = null private var lastAppUserId: 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() @JvmStatic @@ -121,18 +124,36 @@ object TapAppLink { @JvmStatic fun applyCode(code: String, callback: (Result) -> Unit) { io.execute { + val normalized = normalizeRedeemCode(code) + val requestId = resolveRedeemRequestId(normalized) callback( runCatching { val cfg = requireConfig() - val body = JSONObject().put("code", code).put("platform", "ANDROID") + val body = JSONObject() + .put("code", code) + .put("platform", "ANDROID") + .put("requestId", requestId) lastAppUserId?.let { body.put("appUserId", it) } store?.attributionId?.let { body.put("attributionId", it) } val result = post(cfg, "/redeemCode", body) + // Definitive success: clear pending so a later code gets a fresh id. + clearPendingRedeem() store?.let { cacheFromResult(it, result) } result - }.recoverCatching { error -> - throw mapNetworkFailure(error) - }, + }.fold( + onSuccess = { Result.success(it) }, + onFailure = { error -> + val mapped = mapNetworkFailure(error) + when (mapped) { + is TapAppLinkRedeemException.UnknownCode, + is TapAppLinkRedeemException.InactiveCode, + is TapAppLinkRedeemException.WrongEnvironment, + -> clearPendingRedeem() + else -> Unit // Network / Other: keep requestId for retries + } + Result.failure(mapped) + }, + ), ) } } @@ -169,6 +190,8 @@ object TapAppLink { @JvmOverloads fun resetForTesting(context: Context? = null) { lastAppUserId = null + memoryPendingRedeemCode = null + memoryPendingRedeemRequestId = null if (context != null) { ensureStore(context).clear() } else { @@ -191,6 +214,26 @@ object TapAppLink { httpClient = client } + private fun resolveRedeemRequestId(normalizedCode: String): String { + val persisted = store + if (persisted != null) { + return persisted.resolveRedeemRequestId(normalizedCode) + } + if (memoryPendingRedeemCode == normalizedCode) { + memoryPendingRedeemRequestId?.let { return it } + } + val created = UUID.randomUUID().toString() + memoryPendingRedeemCode = normalizedCode + memoryPendingRedeemRequestId = created + return created + } + + private fun clearPendingRedeem() { + store?.clearPendingRedeem() + memoryPendingRedeemCode = null + memoryPendingRedeemRequestId = null + } + private fun ensureStore(context: Context): TapAppLinkStore { val existing = store if (existing != null) return existing diff --git a/src/main/java/com/tapapplink/sdk/TapAppLinkStore.kt b/src/main/java/com/tapapplink/sdk/TapAppLinkStore.kt index f7b079c..a886ed9 100644 --- a/src/main/java/com/tapapplink/sdk/TapAppLinkStore.kt +++ b/src/main/java/com/tapapplink/sdk/TapAppLinkStore.kt @@ -61,11 +61,37 @@ internal class TapAppLinkStore( } } + /** + * Returns the pending redeem [requestId] for [normalizedCode], creating and + * persisting a new UUID when the code differs or nothing is pending. + */ + fun resolveRedeemRequestId(normalizedCode: String): String { + val existingCode = prefs.getString(KEY_PENDING_REDEEM_CODE, null) + val existingId = prefs.getString(KEY_PENDING_REDEEM_REQUEST_ID, null) + if (existingCode == normalizedCode && !existingId.isNullOrBlank()) { + return existingId + } + val created = UUID.randomUUID().toString() + prefs.putString(KEY_PENDING_REDEEM_CODE, normalizedCode) + prefs.putString(KEY_PENDING_REDEEM_REQUEST_ID, created) + return created + } + + fun clearPendingRedeem() { + prefs.remove(KEY_PENDING_REDEEM_CODE) + prefs.remove(KEY_PENDING_REDEEM_REQUEST_ID) + } + + fun pendingRedeemNormalizedCode(): String? = prefs.getString(KEY_PENDING_REDEEM_CODE, null)?.takeIf { it.isNotBlank() } + + fun pendingRedeemRequestId(): String? = prefs.getString(KEY_PENDING_REDEEM_REQUEST_ID, null)?.takeIf { it.isNotBlank() } + fun clear() { prefs.clear() } - fun debugSnapshot(): String = "installId=${installId()} tracked=$tracked attributionId=$attributionId offer=$offer" + fun debugSnapshot(): String = "installId=${installId()} tracked=$tracked attributionId=$attributionId offer=$offer " + + "pendingRedeem=${pendingRedeemNormalizedCode()}/${pendingRedeemRequestId()}" companion object { internal const val PREFS_NAME = "tapapplink_sdk" @@ -75,6 +101,8 @@ internal class TapAppLinkStore( 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" + private const val KEY_PENDING_REDEEM_CODE = "pending_redeem_code" + private const val KEY_PENDING_REDEEM_REQUEST_ID = "pending_redeem_request_id" fun from(context: Context): TapAppLinkStore = TapAppLinkStore( SharedPreferencesKeyValueStore( diff --git a/src/test/java/com/tapapplink/sdk/TapAppLinkApplyCodeTest.kt b/src/test/java/com/tapapplink/sdk/TapAppLinkApplyCodeTest.kt index 74b0092..5a04e1f 100644 --- a/src/test/java/com/tapapplink/sdk/TapAppLinkApplyCodeTest.kt +++ b/src/test/java/com/tapapplink/sdk/TapAppLinkApplyCodeTest.kt @@ -43,7 +43,7 @@ class TapAppLinkApplyCodeTest { assertFalse(body.getBoolean("alreadyAttributed")) assertEquals("attr_9", TapAppLink.getAttributionId()) assertEquals("Sarah", TapAppLink.getOffer()?.creatorName) - assertEquals("0.3.1", seenHeaders.get()["X-TapAppLink-SDK-Version"]) + assertEquals("0.3.2", seenHeaders.get()["X-TapAppLink-SDK-Version"]) assertEquals(TapAppLink.SDK_VERSION, seenHeaders.get()["X-TapAppLink-SDK-Version"]) } diff --git a/src/test/java/com/tapapplink/sdk/TapAppLinkRedeemRequestIdTest.kt b/src/test/java/com/tapapplink/sdk/TapAppLinkRedeemRequestIdTest.kt new file mode 100644 index 0000000..f6c125a --- /dev/null +++ b/src/test/java/com/tapapplink/sdk/TapAppLinkRedeemRequestIdTest.kt @@ -0,0 +1,165 @@ +package com.tapapplink.sdk + +import org.json.JSONObject +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import java.io.IOException +import java.net.SocketTimeoutException +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicReference + +class TapAppLinkRedeemRequestIdTest { + private lateinit var prefs: InMemoryKeyValueStore + private lateinit var store: TapAppLinkStore + + @Before + fun setUp() { + TapAppLink.resetForTesting() + prefs = InMemoryKeyValueStore() + store = TapAppLinkStore(prefs) + TapAppLink.setStoreForTesting(store) + TapAppLink.configure( + TapAppLinkConfig( + publicKey = "etk_test_key_123456", + environment = TapAppLinkEnvironment.SANDBOX, + ingestUrl = "https://example.test", + ), + ) + } + + @Test + fun normalizeRedeemCodeUppercasesStripsAndTruncates() { + assertEquals("SARAH10", normalizeRedeemCode("sarah-10")) + assertEquals("ABCDEFGHIJKLMNOPQRSTUVWX", normalizeRedeemCode("abcdefghijklmnopqrstuvwxyz")) + assertEquals("AB12", normalizeRedeemCode(" ab_12!! ")) + } + + @Test + fun retryAfterTimeoutReusesSameRequestId() { + val seenIds = mutableListOf() + var calls = 0 + TapAppLink.setHttpClientForTesting { _, _, body -> + val requestId = JSONObject(body).getString("requestId") + seenIds.add(requestId) + calls += 1 + if (calls == 1) { + throw SocketTimeoutException("timeout") + } + TapAppLinkHttpResponse( + 200, + """{"attributionId":"attr_1","alreadyAttributed":false,"offer":{"creatorName":"Sam","promoCode":"SAM","billingOfferId":"o1"}}""", + ) + } + + val first = awaitApply("SAM") + assertTrue(first.exceptionOrNull() is TapAppLinkRedeemException.Network) + val second = awaitApply("SAM") + assertTrue(second.isSuccess) + + assertEquals(2, seenIds.size) + assertEquals(seenIds[0], seenIds[1]) + assertNull(store.pendingRedeemRequestId()) + } + + @Test + fun restartWithPendingAttemptReusesRequestId() { + val pendingId = store.resolveRedeemRequestId(normalizeRedeemCode("SARAH10")) + assertEquals(pendingId, store.pendingRedeemRequestId()) + + // Simulate process restart: drop the in-memory store without clearing prefs + // (resetForTesting clears SharedPreferences and would wipe the pending id). + TapAppLink.setStoreForTesting(null) + val reloaded = TapAppLinkStore(prefs) + TapAppLink.setStoreForTesting(reloaded) + + val seenId = AtomicReference() + TapAppLink.setHttpClientForTesting { _, _, body -> + seenId.set(JSONObject(body).getString("requestId")) + TapAppLinkHttpResponse( + 200, + """{"attributionId":"attr_9","alreadyAttributed":false,"offer":{"creatorName":"Sarah","promoCode":"SARAH10","billingOfferId":"offer_1"}}""", + ) + } + + val result = awaitApply("sarah-10") + assertTrue(result.isSuccess) + assertEquals(pendingId, seenId.get()) + assertNull(reloaded.pendingRedeemRequestId()) + } + + @Test + fun differentCodeGetsNewRequestId() { + val seenIds = mutableListOf() + TapAppLink.setHttpClientForTesting { _, _, body -> + seenIds.add(JSONObject(body).getString("requestId")) + throw IOException("offline") + } + + awaitApply("CODEA") + awaitApply("CODEB") + + assertEquals(2, seenIds.size) + assertNotEquals(seenIds[0], seenIds[1]) + assertEquals(normalizeRedeemCode("CODEB"), store.pendingRedeemNormalizedCode()) + assertEquals(seenIds[1], store.pendingRedeemRequestId()) + } + + @Test + fun definitiveUnknownCodeClearsPendingRequestId() { + val seenIds = mutableListOf() + TapAppLink.setHttpClientForTesting { _, _, body -> + seenIds.add(JSONObject(body).getString("requestId")) + TapAppLinkHttpResponse(404, """{"error":"unknown_code"}""") + } + + val first = awaitApply("NOPE") + assertTrue(first.exceptionOrNull() is TapAppLinkRedeemException.UnknownCode) + assertNull(store.pendingRedeemRequestId()) + + val second = awaitApply("NOPE") + assertTrue(second.exceptionOrNull() is TapAppLinkRedeemException.UnknownCode) + assertEquals(2, seenIds.size) + assertNotEquals(seenIds[0], seenIds[1]) + } + + @Test + fun definitiveSuccessClearsPendingRequestId() { + TapAppLink.setHttpClientForTesting { _, _, body -> + JSONObject(body).getString("requestId") + TapAppLinkHttpResponse( + 200, + """{"attributionId":"attr_ok","alreadyAttributed":false,"offer":{"creatorName":"Ok","promoCode":"OK","billingOfferId":"o"}}""", + ) + } + assertTrue(awaitApply("OK").isSuccess) + assertNull(store.pendingRedeemRequestId()) + } + + @Test + fun sdkVersionHeaderIs032() { + val headers = AtomicReference>() + TapAppLink.setHttpClientForTesting { _, hdrs, _ -> + headers.set(hdrs) + TapAppLinkHttpResponse(200, """{"attributionId":"a"}""") + } + awaitApply("X") + assertEquals("0.3.2", headers.get()["X-TapAppLink-SDK-Version"]) + assertEquals(TapAppLink.SDK_VERSION, headers.get()["X-TapAppLink-SDK-Version"]) + } + + private fun awaitApply(code: String): Result { + val latch = CountDownLatch(1) + val held = AtomicReference>() + TapAppLink.applyCode(code) { result: Result -> + held.set(result) + latch.countDown() + } + assertTrue("timed out waiting for applyCode", latch.await(5, TimeUnit.SECONDS)) + return held.get() + } +}