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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
12 changes: 6 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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.

Expand All @@ -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

Expand All @@ -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.

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.2"
version = "0.3.3"

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.2"
version = "0.3.3"
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.2
VERSION_NAME=0.3.3
118 changes: 112 additions & 6 deletions src/main/java/com/tapapplink/sdk/TapAppLink.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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<String, MutableList<(JSONObject) -> Unit>>()

@JvmStatic
fun configure(next: TapAppLinkConfig) {
config = next
Expand Down Expand Up @@ -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)
}
}
}
}
Expand All @@ -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
Expand Down Expand Up @@ -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 {
Expand All @@ -208,6 +289,7 @@ object TapAppLink {

internal fun setStoreForTesting(next: TapAppLinkStore?) {
store = next
hydrateFromStore(next)
}

internal fun setHttpClientForTesting(client: TapAppLinkHttpClient) {
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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)
}

Expand Down
16 changes: 14 additions & 2 deletions src/main/java/com/tapapplink/sdk/TapAppLinkStore.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"
Expand Down
20 changes: 16 additions & 4 deletions src/test/java/com/tapapplink/sdk/TapAppLinkApplyCodeTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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"}}""",
)
}

Expand All @@ -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"])
}

Expand All @@ -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
Expand Down
Loading
Loading