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.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.
- 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`).

## 0.3.0

- Persist install id, tracked flag, attribution id and offer in SharedPreferences so cold launches do not re-post `/ingestInstall`.
Expand Down
68 changes: 65 additions & 3 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.0")
implementation("com.github.tapapplink:tapapplink-android:0.3.1")
```

`./gradlew` needs JDK 17 or 21. JDK 25 is not supported by this Android Gradle Plugin.
Expand All @@ -44,9 +44,71 @@ TapAppLink.trackInstall(context) { result ->
TapAppLink.trackInstall(context, installReferrer) { }

TapAppLink.setAppUserId(Purchases.sharedInstance.appUserID) { }
TapAppLink.applyCode("SARAH10") { }
```

### Apply a discount code

Show success only on a real success result. Map each outcome to UI like this:

```kotlin
TapAppLink.applyCode(code) { result ->
result.onSuccess { body ->
val offer = TapAppLink.getOffer()
val offerLine = offer?.let { "${it.creatorName}'s offer" }
if (body.optBoolean("alreadyAttributed")) {
// "You're all set" — hide the code; show offerLine if any.
showStatus(title = "You're all set", codeVisible = false, hint = offerLine)
} else {
// "Code applied" — show the code; show offerLine if any.
showStatus(title = "Code applied", codeVisible = true, code = code, hint = offerLine)
}
}.onFailure { error ->
when (error) {
is TapAppLinkRedeemException.UnknownCode -> {
showStatus(
title = "We don't recognise that code. Check it and try again.",
hint = "Codes aren't case sensitive.",
)
}
is TapAppLinkRedeemException.InactiveCode -> {
showStatus(
title = "This code is no longer active.",
hint = "You can still subscribe at the regular price.",
)
}
is TapAppLinkRedeemException.WrongEnvironment -> {
// Same customer copy as unknownCode. Never say "environment" to customers.
Log.w(TAG, TapAppLinkRedeemException.WrongEnvironment.DEVELOPER_WARNING)
showStatus(
title = "We don't recognise that code. Check it and try again.",
hint = "Codes aren't case sensitive.",
)
}
is TapAppLinkRedeemException.Network -> {
showStatus(
title = "We couldn't check your code. Check your connection and try again.",
)
}
is TapAppLinkRedeemException.Other -> {
Log.w(TAG, "applyCode failed status=${error.status} message=${error.message}")
showStatus(
title = "We couldn't check your code. Check your connection and try again.",
)
}
else -> {
Log.w(TAG, "applyCode failed", error)
showStatus(
title = "We couldn't check your code. Check your connection and try again.",
)
}
}
}
}
```

`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`.

Call `resetForTesting(context)` in debug builds before repeating a match test on the same device.
Expand All @@ -57,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.0`, `v0.3.0`, 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.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.

### Local checks

Expand Down
6 changes: 4 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.0"
version = "0.3.1"

android {
namespace = "com.tapapplink.sdk"
Expand Down Expand Up @@ -43,6 +43,8 @@ ktlint {
dependencies {
implementation("com.android.installreferrer:installreferrer:2.2")
testImplementation("junit:junit:4.13.2")
// Real org.json for JVM unit tests (android.jar stubs JSONObject).
testImplementation("org.json:json:20240303")
}

afterEvaluate {
Expand All @@ -52,7 +54,7 @@ afterEvaluate {
from(components["release"])
groupId = "com.tapapplink"
artifactId = "sdk"
version = "0.3.0"
version = "0.3.1"
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.0
VERSION_NAME=0.3.1
91 changes: 60 additions & 31 deletions src/main/java/com/tapapplink/sdk/TapAppLink.kt
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,6 @@ package com.tapapplink.sdk
import android.content.Context
import android.util.Log
import org.json.JSONObject
import java.net.HttpURLConnection
import java.net.URL
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
Expand Down Expand Up @@ -32,10 +30,14 @@ data class TapAppLinkOffer(
object TapAppLink {
private const val TAG = "TapAppLink"

/** Sent on every request as `X-TapAppLink-SDK-Version`. */
const val SDK_VERSION = "0.3.1"

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 val io = Executors.newSingleThreadExecutor()

@JvmStatic
Expand Down Expand Up @@ -80,30 +82,58 @@ object TapAppLink {
.put("firstOpenAt", isoNow())
referrer?.takeIf { it.isNotBlank() }?.let { body.put("installReferrer", it) }
debugLog("trackInstall posting installId=$installId hasReferrer=${body.has("installReferrer")}")
val result = post(cfg, "/ingestInstall", body)
persisted.tracked = true
cacheFromResult(persisted, result)
debugLog("trackInstall response=$result; ${persisted.debugSnapshot()}")
callback(result)
try {
val result = post(cfg, "/ingestInstall", body)
persisted.tracked = true
cacheFromResult(persisted, result)
debugLog("trackInstall response=$result; ${persisted.debugSnapshot()}")
callback(result)
} catch (error: Exception) {
debugLog("trackInstall failed: ${error.message}")
}
}
}

@JvmStatic
fun setAppUserId(appUserId: String, callback: (JSONObject) -> Unit) {
lastAppUserId = appUserId
io.execute { callback(identify(appUserId)) }
io.execute {
try {
callback(identify(appUserId))
} catch (error: Exception) {
debugLog("setAppUserId failed: ${error.message}")
}
}
}

/**
* 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
* [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.
*/
@JvmStatic
fun applyCode(code: String, callback: (JSONObject) -> Unit) {
fun applyCode(code: String, callback: (Result<JSONObject>) -> Unit) {
io.execute {
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) }
callback(result)
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 ->
throw mapNetworkFailure(error)
},
)
}
}

Expand Down Expand Up @@ -145,6 +175,7 @@ object TapAppLink {
store?.clear()
}
store = null
httpClient = DefaultTapAppLinkHttpClient
debugLog("resetForTesting cleared local state")
}

Expand All @@ -156,6 +187,10 @@ object TapAppLink {
store = next
}

internal fun setHttpClientForTesting(client: TapAppLinkHttpClient) {
httpClient = client
}

private fun ensureStore(context: Context): TapAppLinkStore {
val existing = store
if (existing != null) return existing
Expand Down Expand Up @@ -211,21 +246,15 @@ object TapAppLink {
val base = (cfg.ingestUrl ?: "https://us-central1-tapapplink.cloudfunctions.net")
.trimEnd('/')
val url = "$base$path"
debugLog("POST $url body=$body auth=${redactKey(cfg.publicKey)}")
val connection = (URL(url).openConnection() as HttpURLConnection).apply {
requestMethod = "POST"
setRequestProperty("Authorization", "Bearer ${cfg.publicKey}")
setRequestProperty("Content-Type", "application/json")
doOutput = true
connectTimeout = 15_000
readTimeout = 15_000
}
connection.outputStream.use { it.write(body.toString().toByteArray()) }
val stream =
if (connection.responseCode in 200..299) connection.inputStream else connection.errorStream
val text = stream?.bufferedReader()?.readText().orEmpty()
debugLog("POST $path status=${connection.responseCode} response=$text")
return if (text.isBlank()) JSONObject() else JSONObject(text)
val headers = mapOf(
"Authorization" to "Bearer ${cfg.publicKey}",
"Content-Type" to "application/json",
"X-TapAppLink-SDK-Version" to SDK_VERSION,
)
debugLog("POST $url body=$body auth=${redactKey(cfg.publicKey)} sdk=$SDK_VERSION")
val response = httpClient.post(url, headers, body.toString())
debugLog("POST $path status=${response.status} response=${response.body}")
return requireSuccessBody(path, response)
}

private fun debugLog(message: String) {
Expand Down
95 changes: 95 additions & 0 deletions src/main/java/com/tapapplink/sdk/TapAppLinkHttp.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
package com.tapapplink.sdk

import org.json.JSONObject
import java.io.IOException
import java.net.HttpURLConnection
import java.net.URL

internal data class TapAppLinkHttpResponse(
val status: Int,
val body: String,
)

internal fun interface TapAppLinkHttpClient {
fun post(
url: String,
headers: Map<String, String>,
body: String,
): TapAppLinkHttpResponse
}

internal object DefaultTapAppLinkHttpClient : TapAppLinkHttpClient {
override fun post(
url: String,
headers: Map<String, String>,
body: String,
): TapAppLinkHttpResponse {
val connection = (URL(url).openConnection() as HttpURLConnection).apply {
requestMethod = "POST"
headers.forEach { (key, value) -> setRequestProperty(key, value) }
doOutput = true
connectTimeout = 15_000
readTimeout = 15_000
}
connection.outputStream.use { it.write(body.toByteArray()) }
val status = connection.responseCode
val stream =
if (status in 200..299) connection.inputStream else connection.errorStream
val text = stream?.bufferedReader()?.readText().orEmpty()
return TapAppLinkHttpResponse(status = status, body = text)
}
}

/**
* Parses a non-2xx redeem response into a typed [TapAppLinkRedeemException].
*
* Prefers the JSON `error` field, then falls back to HTTP status.
*/
internal fun mapRedeemFailure(
status: Int,
bodyText: String,
): TapAppLinkRedeemException {
val body = bodyText.trim().takeIf { it.isNotEmpty() }?.let {
runCatching { JSONObject(it) }.getOrNull()
}
val errorCode = body?.optString("error")?.takeIf { it.isNotBlank() }
val message = body?.optString("message")?.takeIf { it.isNotBlank() }
?: bodyText.takeIf { it.isNotBlank() }
?: "HTTP $status"

// Prefer the body `error` field; fall back to status (404 unknown, 410 inactive).
// A 400 with `wrong_environment` is handled by the body field above.
return when (errorCode) {
"unknown_code" -> TapAppLinkRedeemException.UnknownCode()
"inactive_code" -> TapAppLinkRedeemException.InactiveCode()
"wrong_environment" -> TapAppLinkRedeemException.WrongEnvironment()
else -> when (status) {
404 -> TapAppLinkRedeemException.UnknownCode()
410 -> TapAppLinkRedeemException.InactiveCode()
else -> TapAppLinkRedeemException.Other(status, message)
}
}
}

internal fun mapNetworkFailure(cause: Throwable): TapAppLinkRedeemException {
// Timeouts, DNS failures, and other IO errors all surface as Network.
if (cause is TapAppLinkRedeemException) return cause
return TapAppLinkRedeemException.Network(cause)
}

/**
* Throws when [response] is not 2xx. Never returns an error body as success.
*/
internal fun requireSuccessBody(
path: String,
response: TapAppLinkHttpResponse,
): JSONObject {
if (response.status !in 200..299) {
if (path == "/redeemCode") {
throw mapRedeemFailure(response.status, response.body)
}
val message = response.body.trim().ifEmpty { "HTTP ${response.status}" }
throw IOException("TapAppLink request $path failed with HTTP ${response.status}: $message")
}
return if (response.body.isBlank()) JSONObject() else JSONObject(response.body)
}
Loading
Loading