Skip to content
Closed
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
30 changes: 28 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,10 +1,36 @@
# Changelog

## 0.3.2

- Restores the 0.3.0 `applyCode(code) { json -> }` signature as `@Deprecated`, because 0.3.1 changed it to `(Result<JSONObject>) -> Unit` and broke source compatibility for a patch. **Upgrade to 0.3.2.**
- The deprecated overload never returns an error body as success; on failure it throws `TapAppLinkRedeemException` on the SDK worker thread. Prefer the `Result` overload so you can handle errors in the callback.
- Migration:

Before (0.3.0):

```kotlin
TapAppLink.applyCode("SARAH10") { json ->
// treated every callback as success, including error bodies in 0.3.0
}
```

After (0.3.2):

```kotlin
TapAppLink.applyCode("SARAH10") { result: Result<JSONObject> ->
result.onSuccess { json ->
// real success only
}.onFailure { error ->
// TapAppLinkRedeemException: UnknownCode, InactiveCode, WrongEnvironment, Network, Other
}
}
```

## 0.3.1

- **Fixed:** Android and React Native 0.3.0 could return an error from `applyCode()` as if it were a normal result, so an app could show a code as applied when it wasn't. Upgrade to 0.3.1, which raises a typed error for unknown, inactive and wrong-environment codes. iOS and Flutter 0.3.0 threw a generic error, and 0.3.1 makes it typed.
- **Fixed:** Android and React Native 0.3.0 could return an error from `applyCode()` as if it were a normal result, so an app could show a code as applied when it wasn't. Android apps: **Upgrade to 0.3.2** (0.3.1 changed the `applyCode` callback shape; 0.3.2 restores the 0.3.0 signature as deprecated alongside `Result`). React Native: upgrade to 0.3.1 for typed errors. iOS and Flutter 0.3.0 threw a generic error, and 0.3.1 makes it typed.
- Send `X-TapAppLink-SDK-Version` on every request so the server can return distinct 410/400 statuses while remaining compatible with legacy 404 bodies.
- `applyCode` callback is now `(Result<JSONObject>) -> Unit` and failures are `TapAppLinkRedeemException` (`UnknownCode`, `InactiveCode`, `WrongEnvironment`, `Network`, `Other`).
- `applyCode` gained a `(Result<JSONObject>) -> Unit` overload; failures are `TapAppLinkRedeemException` (`UnknownCode`, `InactiveCode`, `WrongEnvironment`, `Network`, `Other`).

## 0.3.0

Expand Down
8 changes: 5 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,11 @@ 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")
```

Upgrade to **0.3.2**. Do not stay on 0.3.1: that patch changed the `applyCode` callback to `Result` and broke source compatibility with 0.3.0 call sites. 0.3.2 restores the 0.3.0 signature as `@Deprecated` and keeps the typed `Result` API.

`./gradlew` needs JDK 17 or 21. JDK 25 is not supported by this Android Gradle Plugin.

## Usage
Expand Down Expand Up @@ -51,7 +53,7 @@ TapAppLink.setAppUserId(Purchases.sharedInstance.appUserID) { }
Show success only on a real success result. Map each outcome to UI like this:

```kotlin
TapAppLink.applyCode(code) { result ->
TapAppLink.applyCode(code) { result: Result<JSONObject> ->
result.onSuccess { body ->
val offer = TapAppLink.getOffer()
val offerLine = offer?.let { "${it.creatorName}'s offer" }
Expand Down Expand Up @@ -119,7 +121,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
55 changes: 42 additions & 13 deletions src/main/java/com/tapapplink/sdk/TapAppLink.kt
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import java.util.Date
import java.util.Locale
import java.util.TimeZone
import java.util.concurrent.Executors
import kotlin.jvm.JvmName

enum class TapAppLinkEnvironment(val value: String) {
PRODUCTION("production"),
Expand All @@ -31,7 +32,7 @@ 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
Expand Down Expand Up @@ -106,6 +107,32 @@ object TapAppLink {
}
}

/**
* 0.3.0-compatible redeem callback. Prefer the [Result]-based overload.
*
* On success, [callback] receives the JSON body. On failure this overload
* never delivers an error body as success: it throws
* [TapAppLinkRedeemException] on the SDK worker thread (the 0.3.0 signature
* had no error callback). Migrate to the [Result] overload to handle errors
* in-process.
*/
@Deprecated(
message = "0.3.1 changed applyCode to Result; this overload is restored in 0.3.2 for source compatibility. Prefer applyCode(code) { result -> ... }.",
replaceWith = ReplaceWith(
"applyCode(code) { result -> result.onSuccess(callback).onFailure { /* TapAppLinkRedeemException */ } }",
),
)
@JvmStatic
fun applyCode(code: String, callback: (JSONObject) -> Unit) {
io.execute {
try {
callback(redeemCode(code))
} catch (error: Throwable) {
throw mapNetworkFailure(error)
}
}
}

/**
* Redeems a discount code via `/redeemCode`.
*
Expand All @@ -114,29 +141,31 @@ object TapAppLink {
* On failure, [callback] receives [Result.failure] with a
* [TapAppLinkRedeemException] case.
*
* Signature note: the callback changed from `(JSONObject) -> Unit` in 0.3.0
* to `(Result<JSONObject>) -> Unit` so errors are typed instead of returned
* as a fake success body.
* `@JvmName` avoids a JVM signature clash with the deprecated
* `(JSONObject) -> Unit` overload after type erasure.
*/
@JvmStatic
@JvmName("applyCodeWithResult")
fun applyCode(code: String, callback: (Result<JSONObject>) -> Unit) {
io.execute {
callback(
runCatching {
val cfg = requireConfig()
val body = JSONObject().put("code", code).put("platform", "ANDROID")
lastAppUserId?.let { body.put("appUserId", it) }
store?.attributionId?.let { body.put("attributionId", it) }
val result = post(cfg, "/redeemCode", body)
store?.let { cacheFromResult(it, result) }
result
}.recoverCatching { error ->
runCatching { redeemCode(code) }.recoverCatching { error ->
throw mapNetworkFailure(error)
},
)
}
}

private fun redeemCode(code: String): JSONObject {
val cfg = requireConfig()
val body = JSONObject().put("code", code).put("platform", "ANDROID")
lastAppUserId?.let { body.put("appUserId", it) }
store?.attributionId?.let { body.put("attributionId", it) }
val result = post(cfg, "/redeemCode", body)
store?.let { cacheFromResult(it, result) }
return result
}

@JvmStatic
fun getOffer(): TapAppLinkOffer? = store?.offer

Expand Down
54 changes: 52 additions & 2 deletions src/test/java/com/tapapplink/sdk/TapAppLinkApplyCodeTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,14 @@ package com.tapapplink.sdk
import org.json.JSONObject
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
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.AtomicBoolean
import java.util.concurrent.atomic.AtomicReference

class TapAppLinkApplyCodeTest {
Expand Down Expand Up @@ -43,10 +45,58 @@ 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"])
}

@Test
fun legacyApplyCodeCallSiteCompilesAndSucceeds() {
// 0.3.0-style call site: explicit JSONObject parameter.
TapAppLink.setHttpClientForTesting { _, _, _ ->
TapAppLinkHttpResponse(
200,
"""{"attributionId":"attr_legacy","alreadyAttributed":false,"offer":{"creatorName":"Sam","promoCode":"SAM","billingOfferId":"o1"}}""",
)
}
val latch = CountDownLatch(1)
val body = AtomicReference<JSONObject>()
@Suppress("DEPRECATION")
TapAppLink.applyCode("SAM") { json: JSONObject ->
body.set(json)
latch.countDown()
}
assertTrue(latch.await(5, TimeUnit.SECONDS))
assertEquals("attr_legacy", body.get().getString("attributionId"))
assertEquals("Sam", TapAppLink.getOffer()?.creatorName)
}

@Test
fun legacyApplyCodeThrowsTypedErrorAndNeverDeliversErrorBody() {
TapAppLink.setHttpClientForTesting { _, _, _ ->
TapAppLinkHttpResponse(404, """{"error":"unknown_code"}""")
}
val latch = CountDownLatch(1)
val thrown = AtomicReference<Throwable>()
val callbackCalled = AtomicBoolean(false)
val previous = Thread.getDefaultUncaughtExceptionHandler()
Thread.setDefaultUncaughtExceptionHandler { _, error ->
thrown.set(error)
latch.countDown()
}
try {
@Suppress("DEPRECATION")
TapAppLink.applyCode("NOPE") { _: JSONObject ->
callbackCalled.set(true)
}
assertTrue("timed out waiting for thrown redeem error", latch.await(5, TimeUnit.SECONDS))
} finally {
Thread.setDefaultUncaughtExceptionHandler(previous)
}
assertFalse(callbackCalled.get())
assertTrue(thrown.get() is TapAppLinkRedeemException.UnknownCode)
assertNull(TapAppLink.getAttributionId())
}

@Test
fun applyCodeAlreadyAttributedSuccess() {
TapAppLink.setHttpClientForTesting { _, _, _ ->
Expand Down Expand Up @@ -139,7 +189,7 @@ class TapAppLinkApplyCodeTest {
private fun awaitApply(code: String): Result<JSONObject> {
val latch = CountDownLatch(1)
val held = AtomicReference<Result<JSONObject>>()
TapAppLink.applyCode(code) { result ->
TapAppLink.applyCode(code) { result: Result<JSONObject> ->
held.set(result)
latch.countDown()
}
Expand Down
Loading