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
@@ -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<JSONObject>`, 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).
Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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

Expand Down
4 changes: 2 additions & 2 deletions build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ plugins {
}

group = "com.tapapplink"
version = "0.3.1"
version = "0.3.2"

android {
namespace = "com.tapapplink.sdk"
Expand Down Expand Up @@ -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")
Expand Down
2 changes: 1 addition & 1 deletion gradle.properties
Original file line number Diff line number Diff line change
Expand Up @@ -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
9 changes: 9 additions & 0 deletions src/main/java/com/tapapplink/sdk/RedeemCodeNormalizer.kt
Original file line number Diff line number Diff line change
@@ -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)
53 changes: 48 additions & 5 deletions src/main/java/com/tapapplink/sdk/TapAppLink.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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
Expand Down Expand Up @@ -121,18 +124,36 @@ object TapAppLink {
@JvmStatic
fun applyCode(code: String, callback: (Result<JSONObject>) -> 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)
},
),
)
}
}
Expand Down Expand Up @@ -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 {
Expand All @@ -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
Expand Down
30 changes: 29 additions & 1 deletion src/main/java/com/tapapplink/sdk/TapAppLinkStore.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"])
}

Expand Down
165 changes: 165 additions & 0 deletions src/test/java/com/tapapplink/sdk/TapAppLinkRedeemRequestIdTest.kt
Original file line number Diff line number Diff line change
@@ -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<String>()
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<String>()
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<String>()
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<String>()
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<Map<String, String>>()
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<JSONObject> {
val latch = CountDownLatch(1)
val held = AtomicReference<Result<JSONObject>>()
TapAppLink.applyCode(code) { result: Result<JSONObject> ->
held.set(result)
latch.countDown()
}
assertTrue("timed out waiting for applyCode", latch.await(5, TimeUnit.SECONDS))
return held.get()
}
}
Loading