diff --git a/CHANGELOG.md b/CHANGELOG.md index 9c8b057..79787fc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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) -> 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 -> + 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) -> Unit` and failures are `TapAppLinkRedeemException` (`UnknownCode`, `InactiveCode`, `WrongEnvironment`, `Network`, `Other`). +- `applyCode` gained a `(Result) -> Unit` overload; failures are `TapAppLinkRedeemException` (`UnknownCode`, `InactiveCode`, `WrongEnvironment`, `Network`, `Other`). ## 0.3.0 diff --git a/README.md b/README.md index 78839f6..4fdaec7 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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 -> result.onSuccess { body -> val offer = TapAppLink.getOffer() val offerLine = offer?.let { "${it.creatorName}'s offer" } @@ -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 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/TapAppLink.kt b/src/main/java/com/tapapplink/sdk/TapAppLink.kt index b0bf4a7..0ab0bca 100644 --- a/src/main/java/com/tapapplink/sdk/TapAppLink.kt +++ b/src/main/java/com/tapapplink/sdk/TapAppLink.kt @@ -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"), @@ -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 @@ -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`. * @@ -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) -> 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) -> 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 diff --git a/src/test/java/com/tapapplink/sdk/TapAppLinkApplyCodeTest.kt b/src/test/java/com/tapapplink/sdk/TapAppLinkApplyCodeTest.kt index 74b0092..b84cd0f 100644 --- a/src/test/java/com/tapapplink/sdk/TapAppLinkApplyCodeTest.kt +++ b/src/test/java/com/tapapplink/sdk/TapAppLinkApplyCodeTest.kt @@ -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 { @@ -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() + @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() + 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 { _, _, _ -> @@ -139,7 +189,7 @@ class TapAppLinkApplyCodeTest { private fun awaitApply(code: String): Result { val latch = CountDownLatch(1) val held = AtomicReference>() - TapAppLink.applyCode(code) { result -> + TapAppLink.applyCode(code) { result: Result -> held.set(result) latch.countDown() }