diff --git a/sample/build.gradle b/sample/build.gradle
index f2470b5b..ad127945 100644
--- a/sample/build.gradle
+++ b/sample/build.gradle
@@ -24,10 +24,13 @@ android {
if (propertiesFile.exists()) {
Properties properties = new Properties()
properties.load(propertiesFile.newDataInputStream())
- storeFile file(properties.getProperty('storeFile'))
- keyAlias properties.getProperty('keyAlias')
- storePassword properties.getProperty('storePassword')
- keyPassword properties.getProperty('keyPassword')
+ def storeFilePath = properties.getProperty('storeFile')
+ if (storeFilePath != null && !storeFilePath.isEmpty()) {
+ storeFile file(storeFilePath)
+ keyAlias properties.getProperty('keyAlias')
+ storePassword properties.getProperty('storePassword')
+ keyPassword properties.getProperty('keyPassword')
+ }
}
}
}
@@ -40,7 +43,9 @@ android {
signingConfig signingConfigs.release
}
debug {
- signingConfig signingConfigs.release
+ // Use the default debug signing config (auto-generated debug
+ // keystore) when no release keystore is configured locally.
+ signingConfig signingConfigs.debug
debuggable true
}
}
diff --git a/sample/src/main/AndroidManifest.xml b/sample/src/main/AndroidManifest.xml
index dba29383..40729037 100644
--- a/sample/src/main/AndroidManifest.xml
+++ b/sample/src/main/AndroidManifest.xml
@@ -22,6 +22,41 @@
+
+
+
+
+
+
+
+
+
diff --git a/sample/src/main/java/io/qonversion/sample/MainActivity.kt b/sample/src/main/java/io/qonversion/sample/MainActivity.kt
index 523f0e49..82a3cc81 100644
--- a/sample/src/main/java/io/qonversion/sample/MainActivity.kt
+++ b/sample/src/main/java/io/qonversion/sample/MainActivity.kt
@@ -2,14 +2,20 @@ package io.qonversion.sample
import android.content.Context
import android.content.Intent
+import android.net.Uri
import android.os.Bundle
+import android.util.Log
import android.view.View
+import android.widget.Toast
import androidx.appcompat.app.AppCompatActivity
import androidx.navigation.NavController
import androidx.navigation.fragment.NavHostFragment
import androidx.navigation.fragment.findNavController
import androidx.navigation.ui.AppBarConfiguration
import androidx.navigation.ui.setupWithNavController
+import com.qonversion.android.sdk.Qonversion
+import com.qonversion.android.sdk.dto.redemption.RedemptionResult
+import com.qonversion.android.sdk.listeners.QonversionRedemptionCallback
import io.qonversion.sample.databinding.ActivityMainBinding
class MainActivity : AppCompatActivity() {
@@ -47,6 +53,52 @@ class MainActivity : AppCompatActivity() {
// Show toolbar title
binding.toolbar.title = destination.label
}
+
+ // Web2App (DEV-847) — handle redemption deep links.
+ handleRedemptionIntent(intent)
+ handleReissueIntent(intent)
+ }
+
+ override fun onNewIntent(intent: Intent) {
+ super.onNewIntent(intent)
+ setIntent(intent)
+ handleRedemptionIntent(intent)
+ handleReissueIntent(intent)
+ }
+
+ private fun handleReissueIntent(intent: Intent?) {
+ // Triggered via `adb shell am start -a qontest.REISSUE` to exercise
+ // the reissue dialog without needing a UI button.
+ if (intent?.action != "qontest.REISSUE") return
+ Log.i("Web2App", "presentReissueUI triggered via intent")
+ Qonversion.shared.presentReissueUI(this) { success ->
+ Log.i("Web2App", "Reissue completed: success=$success")
+ }
+ }
+
+ private fun handleRedemptionIntent(intent: Intent?) {
+ val uri: Uri = intent?.data ?: return
+ // Accept the App Link host AND a few common local-testing aliases so
+ // adb-fired intents work the same as production email links.
+ val ok = uri.scheme in setOf("https", "http", "qonversion") &&
+ uri.pathSegments.firstOrNull() == "r"
+ if (!ok) {
+ return
+ }
+ Log.i("Web2App", "handleRedemptionLink: $uri")
+ Toast.makeText(this, "Redemption link tapped: $uri", Toast.LENGTH_SHORT).show()
+ Qonversion.shared.handleRedemptionLink(uri, object : QonversionRedemptionCallback {
+ override fun onResult(result: RedemptionResult) {
+ Log.i("Web2App", "Redemption result: $result")
+ runOnUiThread {
+ Toast.makeText(
+ this@MainActivity,
+ "Redemption: $result",
+ Toast.LENGTH_LONG,
+ ).show()
+ }
+ }
+ })
}
override fun onSupportNavigateUp(): Boolean {
diff --git a/sdk/src/main/java/com/qonversion/android/sdk/Qonversion.kt b/sdk/src/main/java/com/qonversion/android/sdk/Qonversion.kt
index 0a930375..f7cd78f3 100644
--- a/sdk/src/main/java/com/qonversion/android/sdk/Qonversion.kt
+++ b/sdk/src/main/java/com/qonversion/android/sdk/Qonversion.kt
@@ -1,7 +1,9 @@
package com.qonversion.android.sdk
import android.app.Activity
+import android.net.Uri
import android.util.Log
+import androidx.fragment.app.FragmentActivity
import com.qonversion.android.sdk.dto.QAttributionProvider
import com.qonversion.android.sdk.dto.QPurchaseOptions
import com.qonversion.android.sdk.dto.QPurchaseResult
@@ -17,6 +19,7 @@ import com.qonversion.android.sdk.listeners.QonversionEligibilityCallback
import com.qonversion.android.sdk.listeners.QonversionEntitlementsCallback
import com.qonversion.android.sdk.listeners.QonversionOfferingsCallback
import com.qonversion.android.sdk.listeners.QonversionProductsCallback
+import com.qonversion.android.sdk.listeners.QonversionRedemptionCallback
import com.qonversion.android.sdk.listeners.QonversionRemoteConfigCallback
import com.qonversion.android.sdk.listeners.QonversionRemoteConfigListCallback
import com.qonversion.android.sdk.listeners.QonversionRemoteConfigurationAttachCallback
@@ -393,4 +396,46 @@ interface Qonversion {
* @param listener listener to be called when a deferred purchase completes.
*/
fun setDeferredPurchasesListener(listener: QDeferredPurchasesListener)
+
+ /**
+ * Web2App (M1): redeem a token delivered to the user via an email App Link.
+ *
+ * The expected Uri shape is `https://screens.qonversion.io/r/{project_uid}/{token}`,
+ * registered in the host AndroidManifest as an `` with
+ * `android:autoVerify="true"` so Android can route directly to the host
+ * activity without an app chooser. See plan §"What host app must do → Android".
+ *
+ * **Security note:** the custom `qonversion://` scheme is intentionally NOT
+ * supported as an email-link transport here — any installed app can claim
+ * a custom scheme intent-filter and hijack the redemption flow (plan §RT2-W3,
+ * §RT-F11). Use https App Links only for email-borne links. Custom schemes
+ * remain allowed for in-process host→SDK forwarding via [identify].
+ *
+ * On [RedemptionResult.Success] the SDK transparently calls [identify] with
+ * the user id returned by the backend, merging the current anon session
+ * with the redeemed account.
+ *
+ * @param uri App Link Uri opened by the host activity.
+ * @param callback delivered once on the main thread with the terminal
+ * [com.qonversion.android.sdk.dto.redemption.RedemptionResult].
+ */
+ fun handleRedemptionLink(uri: Uri, callback: QonversionRedemptionCallback)
+
+ /**
+ * Web2App (M1): present a modal email-input dialog the user can use to
+ * request a new redemption email when their original link is lost or
+ * expired (`/v4/web/redeem/reissue`).
+ *
+ * The dialog is built programmatically — no host-app theme or layout
+ * resources are required. The dialog dismisses itself on a successful
+ * send and invokes [onCompletion] with `true`. If the user cancels (or
+ * the dialog is dismissed without sending), [onCompletion] is invoked
+ * with `false`.
+ *
+ * @param activity a [FragmentActivity] used to host the dialog
+ * fragment. Pass an Activity that has a non-finished
+ * [androidx.fragment.app.FragmentManager].
+ * @param onCompletion main-thread callback invoked exactly once.
+ */
+ fun presentReissueUI(activity: FragmentActivity, onCompletion: (Boolean) -> Unit)
}
diff --git a/sdk/src/main/java/com/qonversion/android/sdk/dto/redemption/RedemptionResult.kt b/sdk/src/main/java/com/qonversion/android/sdk/dto/redemption/RedemptionResult.kt
new file mode 100644
index 00000000..3406b77c
--- /dev/null
+++ b/sdk/src/main/java/com/qonversion/android/sdk/dto/redemption/RedemptionResult.kt
@@ -0,0 +1,40 @@
+package com.qonversion.android.sdk.dto.redemption
+
+/**
+ * Result of attempting to redeem a Web2App token via [com.qonversion.android.sdk.Qonversion.handleRedemptionLink].
+ *
+ * The set of cases mirrors the iOS SDK to keep cross-platform parity.
+ */
+enum class RedemptionResult {
+ /**
+ * Token was consumed and the entitlement was granted to the current user.
+ * The SDK has already called `identify(userId)` for the merge flow, so a
+ * subsequent `checkEntitlements` will reflect the new state.
+ */
+ Success,
+
+ /**
+ * The redemption token has expired (its TTL elapsed before the user opened
+ * the email link). Hosts should prompt the user to request a new email via
+ * [com.qonversion.android.sdk.Qonversion.presentReissueUI].
+ */
+ TokenExpired,
+
+ /**
+ * The token has already been consumed on a previous device or session.
+ * Hosts should typically show a "this link was already used" message and
+ * suggest re-issue / contact support. The SDK still attempts identify-flow
+ * recovery server-side before returning this case (RT4-W2).
+ */
+ AlreadyConsumed,
+
+ /**
+ * The token is not recognized by the server (never existed or was revoked).
+ */
+ InvalidToken,
+
+ /**
+ * Could not reach the Qonversion backend. The host should retry later.
+ */
+ NetworkError,
+}
diff --git a/sdk/src/main/java/com/qonversion/android/sdk/internal/QonversionInternal.kt b/sdk/src/main/java/com/qonversion/android/sdk/internal/QonversionInternal.kt
index 7b1af8f3..db6fc727 100644
--- a/sdk/src/main/java/com/qonversion/android/sdk/internal/QonversionInternal.kt
+++ b/sdk/src/main/java/com/qonversion/android/sdk/internal/QonversionInternal.kt
@@ -2,8 +2,10 @@ package com.qonversion.android.sdk.internal
import android.app.Activity
import android.app.Application
+import android.net.Uri
import android.os.Handler
import android.os.Looper
+import androidx.fragment.app.FragmentActivity
import androidx.lifecycle.ProcessLifecycleOwner
import com.qonversion.android.sdk.Qonversion
import com.qonversion.android.sdk.dto.QAttributionProvider
@@ -23,12 +25,15 @@ import com.qonversion.android.sdk.internal.dto.purchase.PurchaseOptionsInternal
import com.qonversion.android.sdk.internal.logger.ConsoleLogger
import com.qonversion.android.sdk.internal.logger.ExceptionManager
import com.qonversion.android.sdk.internal.provider.AppStateProvider
+import com.qonversion.android.sdk.internal.redemption.RedemptionManager
+import com.qonversion.android.sdk.internal.redemption.ReissueDialogFragment
import com.qonversion.android.sdk.internal.services.QFallbacksService
import com.qonversion.android.sdk.internal.storage.SharedPreferencesCache
import com.qonversion.android.sdk.listeners.QonversionExperimentAttachCallback
import com.qonversion.android.sdk.listeners.QonversionEntitlementsCallback
import com.qonversion.android.sdk.listeners.QonversionOfferingsCallback
import com.qonversion.android.sdk.listeners.QonversionProductsCallback
+import com.qonversion.android.sdk.listeners.QonversionRedemptionCallback
import com.qonversion.android.sdk.listeners.QonversionRemoteConfigCallback
import com.qonversion.android.sdk.listeners.QonversionEligibilityCallback
import com.qonversion.android.sdk.listeners.QonversionUserCallback
@@ -56,6 +61,7 @@ internal class QonversionInternal(
private var exceptionManager: ExceptionManager
private var remoteConfigManager: QRemoteConfigManager
private var fallbackService: QFallbacksService
+ private val redemptionManager: RedemptionManager
override var appState = AppState.Background
@@ -114,6 +120,16 @@ internal class QonversionInternal(
val lifecycleHandler = AppLifecycleHandler(this)
postToMainThread { ProcessLifecycleOwner.get().lifecycle.addObserver(lifecycleHandler) }
+ // RedemptionManager has no DI dependencies of its own beyond Api +
+ // InternalConfig + Logger + the identify entrypoint, so we instantiate
+ // it here rather than adding another DI module just for one class.
+ redemptionManager = RedemptionManager(
+ api = QDependencyInjector.appComponent.api(),
+ internalConfig = internalConfig,
+ logger = logger,
+ identifyUser = { userId -> productCenterManager.identify(userId) },
+ )
+
productCenterManager.launch(RequestTrigger.Init)
}
@@ -386,6 +402,27 @@ internal class QonversionInternal(
productCenterManager.setDeferredPurchasesListener(listener)
}
+ override fun handleRedemptionLink(uri: Uri, callback: QonversionRedemptionCallback) {
+ redemptionManager.handleRedemptionLink(uri, callback)
+ }
+
+ override fun presentReissueUI(activity: FragmentActivity, onCompletion: (Boolean) -> Unit) {
+ // Re-use existing tagged instance if present so back-to-back calls
+ // don't stack multiple dialogs.
+ val manager = activity.supportFragmentManager
+ val existing = manager.findFragmentByTag(REISSUE_DIALOG_TAG)
+ if (existing is ReissueDialogFragment) {
+ existing.redemptionManager = redemptionManager
+ existing.onCompletion = onCompletion
+ return
+ }
+ val dialog = ReissueDialogFragment().also {
+ it.redemptionManager = redemptionManager
+ it.onCompletion = onCompletion
+ }
+ dialog.show(manager, REISSUE_DIALOG_TAG)
+ }
+
// Private functions
private fun mainEntitlementsCallback(callback: QonversionEntitlementsCallback): QonversionEntitlementsCallback =
object : QonversionEntitlementsCallback {
@@ -436,4 +473,8 @@ internal class QonversionInternal(
handler.post(runnable)
}
}
+
+ private companion object {
+ const val REISSUE_DIALOG_TAG = "qonversion.reissue_dialog"
+ }
}
diff --git a/sdk/src/main/java/com/qonversion/android/sdk/internal/api/Api.kt b/sdk/src/main/java/com/qonversion/android/sdk/internal/api/Api.kt
index 722b64a0..683b6f6a 100644
--- a/sdk/src/main/java/com/qonversion/android/sdk/internal/api/Api.kt
+++ b/sdk/src/main/java/com/qonversion/android/sdk/internal/api/Api.kt
@@ -17,6 +17,11 @@ import com.qonversion.android.sdk.internal.dto.request.EligibilityRequest
import com.qonversion.android.sdk.internal.dto.request.IdentityRequest
import com.qonversion.android.sdk.internal.dto.request.InitRequest
import com.qonversion.android.sdk.internal.dto.request.PurchaseRequest
+import com.qonversion.android.sdk.internal.dto.request.RedeemReissueRequest
+import com.qonversion.android.sdk.internal.dto.request.RedeemRequest
+import com.qonversion.android.sdk.internal.dto.request.RedeemStatusRequest
+import com.qonversion.android.sdk.internal.dto.redemption.RedeemResponse
+import com.qonversion.android.sdk.internal.dto.redemption.RedeemStatusResponse
import com.qonversion.android.sdk.internal.dto.request.RestoreRequest
import com.qonversion.android.sdk.internal.dto.request.data.UserPropertyRequestData
import retrofit2.Call
@@ -116,4 +121,31 @@ internal interface Api {
@GET("v3/users/{user_id}/properties")
fun getProperties(@Path("user_id") userId: String): Call>
+
+ // --- Web2App redemption (DEV-847) ---
+
+ /**
+ * Exchanges a redemption token (delivered to the user via App Link email)
+ * for an entitlement on the current SDK user. See plan §"Mobile SDK API
+ * surface → Android" and `RedemptionResult` for the response semantics.
+ */
+ @POST("v4/web/redeem")
+ fun redeem(@Body request: RedeemRequest): Call
+
+ /**
+ * Disambiguates a 409 from [redeem]: tells the SDK whether the token was
+ * already consumed (→ `AlreadyConsumed`) versus some other transient
+ * server condition. Used to surface the identify-flow recovery hint to
+ * the host app (RT4-W2).
+ */
+ @POST("v4/web/redeem/status")
+ fun redeemStatus(@Body request: RedeemStatusRequest): Call
+
+ /**
+ * Requests a new redemption email when the original token is lost or
+ * expired. Backend always responds 200 on a well-formed email (no oracle
+ * leak); 429 / 503 are surfaced to the UI for retry messaging.
+ */
+ @POST("v4/web/redeem/reissue")
+ fun redeemReissue(@Body request: RedeemReissueRequest): Call
}
diff --git a/sdk/src/main/java/com/qonversion/android/sdk/internal/di/component/AppComponent.kt b/sdk/src/main/java/com/qonversion/android/sdk/internal/di/component/AppComponent.kt
index ed84b141..3efa5e65 100644
--- a/sdk/src/main/java/com/qonversion/android/sdk/internal/di/component/AppComponent.kt
+++ b/sdk/src/main/java/com/qonversion/android/sdk/internal/di/component/AppComponent.kt
@@ -6,6 +6,7 @@ import com.qonversion.android.sdk.internal.QHandledPurchasesCache
import com.qonversion.android.sdk.internal.QIdentityManager
import com.qonversion.android.sdk.internal.QRemoteConfigManager
import com.qonversion.android.sdk.internal.QUserPropertiesManager
+import com.qonversion.android.sdk.internal.api.Api
import com.qonversion.android.sdk.internal.di.module.AppModule
import com.qonversion.android.sdk.internal.di.module.RepositoryModule
import com.qonversion.android.sdk.internal.di.module.NetworkModule
@@ -45,4 +46,5 @@ internal interface AppComponent {
fun sharedPreferencesCache(): SharedPreferencesCache
fun exceptionManager(): QExceptionManager
fun fallbacksService(): QFallbacksService
+ fun api(): Api
}
diff --git a/sdk/src/main/java/com/qonversion/android/sdk/internal/di/module/RepositoryModule.kt b/sdk/src/main/java/com/qonversion/android/sdk/internal/di/module/RepositoryModule.kt
index 09e7c4e5..b9760577 100644
--- a/sdk/src/main/java/com/qonversion/android/sdk/internal/di/module/RepositoryModule.kt
+++ b/sdk/src/main/java/com/qonversion/android/sdk/internal/di/module/RepositoryModule.kt
@@ -69,6 +69,17 @@ internal class RepositoryModule {
)
}
+ /**
+ * Exposes the Retrofit-generated [Api] so additional managers
+ * (e.g. [com.qonversion.android.sdk.internal.redemption.RedemptionManager])
+ * can hit endpoints that aren't routed through [DefaultRepository].
+ */
+ @ApplicationScope
+ @Provides
+ fun provideApi(retrofit: Retrofit): Api {
+ return retrofit.create(Api::class.java)
+ }
+
@ApplicationScope
@Provides
fun provideTokenStorage(preferences: SharedPreferences): TokenStorage {
diff --git a/sdk/src/main/java/com/qonversion/android/sdk/internal/dto/redemption/RedeemResponse.kt b/sdk/src/main/java/com/qonversion/android/sdk/internal/dto/redemption/RedeemResponse.kt
new file mode 100644
index 00000000..63b77a56
--- /dev/null
+++ b/sdk/src/main/java/com/qonversion/android/sdk/internal/dto/redemption/RedeemResponse.kt
@@ -0,0 +1,27 @@
+package com.qonversion.android.sdk.internal.dto.redemption
+
+import com.squareup.moshi.Json
+import com.squareup.moshi.JsonClass
+
+/**
+ * Response body for `POST /v4/web/redeem` on success (HTTP 200).
+ *
+ * `user_id` is the Qonversion user id the entitlement was granted to. The SDK
+ * calls `identify(user_id)` after a successful redemption to merge the current
+ * anon session with that account (RT4-W2 / plan §"DEV-847").
+ */
+@JsonClass(generateAdapter = true)
+internal data class RedeemResponse(
+ @Json(name = "user_id") val userId: String?,
+)
+
+/**
+ * Response body for `POST /v4/web/redeem/status` — polled after a 409 from
+ * `/v4/web/redeem` to determine whether the token was already consumed (in
+ * which case the host shows recovery UI suggestion).
+ */
+@JsonClass(generateAdapter = true)
+internal data class RedeemStatusResponse(
+ @Json(name = "consumed") val consumed: Boolean = false,
+ @Json(name = "expired") val expired: Boolean = false,
+)
diff --git a/sdk/src/main/java/com/qonversion/android/sdk/internal/dto/request/RedeemRequest.kt b/sdk/src/main/java/com/qonversion/android/sdk/internal/dto/request/RedeemRequest.kt
new file mode 100644
index 00000000..a9c55fff
--- /dev/null
+++ b/sdk/src/main/java/com/qonversion/android/sdk/internal/dto/request/RedeemRequest.kt
@@ -0,0 +1,29 @@
+package com.qonversion.android.sdk.internal.dto.request
+
+import com.squareup.moshi.Json
+import com.squareup.moshi.JsonClass
+
+/**
+ * Request body for `POST /v4/web/redeem` — exchanges a Web2App token issued
+ * by `/v4/web/payment` for an entitlement grant attached to the current SDK user.
+ *
+ * The default restore behavior is `Transfer` (see plan §"DEV-845"), which is
+ * enforced server-side; the field is included here so the SDK can override it
+ * in future iterations without an API change.
+ */
+@JsonClass(generateAdapter = true)
+internal data class RedeemRequest(
+ @Json(name = "token") val token: String,
+ @Json(name = "anon_user_id") val anonUserId: String?,
+ @Json(name = "restore_behavior") val restoreBehavior: String = "transfer",
+)
+
+@JsonClass(generateAdapter = true)
+internal data class RedeemStatusRequest(
+ @Json(name = "token") val token: String,
+)
+
+@JsonClass(generateAdapter = true)
+internal data class RedeemReissueRequest(
+ @Json(name = "email") val email: String,
+)
diff --git a/sdk/src/main/java/com/qonversion/android/sdk/internal/redemption/RedemptionManager.kt b/sdk/src/main/java/com/qonversion/android/sdk/internal/redemption/RedemptionManager.kt
new file mode 100644
index 00000000..f5150d6b
--- /dev/null
+++ b/sdk/src/main/java/com/qonversion/android/sdk/internal/redemption/RedemptionManager.kt
@@ -0,0 +1,207 @@
+package com.qonversion.android.sdk.internal.redemption
+
+import android.net.Uri
+import android.os.Handler
+import android.os.Looper
+import com.qonversion.android.sdk.dto.redemption.RedemptionResult
+import com.qonversion.android.sdk.internal.InternalConfig
+import com.qonversion.android.sdk.internal.api.Api
+import com.qonversion.android.sdk.internal.dto.request.RedeemReissueRequest
+import com.qonversion.android.sdk.internal.dto.request.RedeemRequest
+import com.qonversion.android.sdk.internal.dto.request.RedeemStatusRequest
+import com.qonversion.android.sdk.internal.enqueue
+import com.qonversion.android.sdk.internal.logger.Logger
+import com.qonversion.android.sdk.listeners.QonversionRedemptionCallback
+
+/**
+ * Coordinates Web2App redemption: parses the email App Link, calls
+ * `POST /v4/web/redeem`, disambiguates 409 via `/v4/web/redeem/status`, and
+ * triggers the SDK identify-flow for the merge (RT4-W2).
+ *
+ * The reissue endpoint is also exposed here so the reissue dialog UI doesn't
+ * need its own networking — it forwards through this manager.
+ *
+ * All public callbacks are dispatched on the main thread.
+ *
+ * Note: the SDK identify call is invoked through the supplied [identifyUser]
+ * lambda rather than a direct reference to the product center manager so this
+ * class stays unit-testable without spinning up the full DI graph.
+ */
+internal class RedemptionManager(
+ private val api: Api,
+ private val internalConfig: InternalConfig,
+ private val logger: Logger,
+ private val identifyUser: (userId: String) -> Unit,
+) {
+
+ private val handler = Handler(Looper.getMainLooper())
+
+ /**
+ * Parses [uri] (expected: `https://screens.qonversion.io/r/{project_uid}/{token}`),
+ * POSTs `/v4/web/redeem`, and invokes [callback] on the main thread with the
+ * terminal [RedemptionResult]. See [RedemptionResult] for case semantics.
+ */
+ fun handleRedemptionLink(uri: Uri, callback: QonversionRedemptionCallback) {
+ val token = extractToken(uri)
+ if (token == null) {
+ logger.error("RedemptionManager: malformed Uri '$uri' — no token segment found.")
+ deliver(callback, RedemptionResult.InvalidToken)
+ return
+ }
+
+ val request = RedeemRequest(
+ token = token,
+ anonUserId = internalConfig.uid.takeIf { it.isNotBlank() },
+ )
+
+ api.redeem(request).enqueue {
+ onResponse = { response ->
+ when {
+ response.isSuccessful -> {
+ val userId = response.body()?.userId
+ if (!userId.isNullOrBlank()) {
+ // Trigger merge: identify the (now anon) device user
+ // with the Qonversion user id returned by redeem.
+ try {
+ identifyUser(userId)
+ } catch (t: Throwable) {
+ logger.error(
+ "RedemptionManager: identify after redeem threw: $t"
+ )
+ }
+ } else {
+ logger.error(
+ "RedemptionManager: redeem 200 with empty user_id — " +
+ "skipping identify, host should call checkEntitlements."
+ )
+ }
+ deliver(callback, RedemptionResult.Success)
+ }
+ response.code() == HTTP_CONFLICT -> {
+ // Disambiguate consumed vs other 409 via /redeem/status.
+ resolveConflict(token, callback)
+ }
+ response.code() == HTTP_NOT_FOUND -> {
+ deliver(callback, RedemptionResult.InvalidToken)
+ }
+ response.code() == HTTP_GONE -> {
+ deliver(callback, RedemptionResult.TokenExpired)
+ }
+ else -> {
+ logger.error(
+ "RedemptionManager: redeem unexpected ${response.code()} — " +
+ "treating as NetworkError."
+ )
+ deliver(callback, RedemptionResult.NetworkError)
+ }
+ }
+ }
+ onFailure = { t ->
+ logger.error("RedemptionManager: redeem network failure: $t")
+ deliver(callback, RedemptionResult.NetworkError)
+ }
+ }
+ }
+
+ /**
+ * POSTs `/v4/web/redeem/reissue` for [email]. Maps HTTP status to one of
+ * the [ReissueResult] cases used by the dialog UI to render the right hint.
+ */
+ fun requestReissue(email: String, callback: (ReissueResult) -> Unit) {
+ api.redeemReissue(RedeemReissueRequest(email)).enqueue {
+ onResponse = { response ->
+ val mapped = when {
+ response.isSuccessful -> ReissueResult.Sent
+ response.code() == HTTP_TOO_MANY_REQUESTS -> ReissueResult.RateLimited
+ response.code() in HTTP_SERVER_ERROR_RANGE -> ReissueResult.ServerError
+ else -> ReissueResult.ServerError
+ }
+ deliverReissue(callback, mapped)
+ }
+ onFailure = { t ->
+ logger.error("RedemptionManager: reissue network failure: $t")
+ deliverReissue(callback, ReissueResult.ServerError)
+ }
+ }
+ }
+
+ private fun resolveConflict(token: String, callback: QonversionRedemptionCallback) {
+ api.redeemStatus(RedeemStatusRequest(token)).enqueue {
+ onResponse = { response ->
+ val body = response.body()
+ val result = when {
+ !response.isSuccessful -> RedemptionResult.AlreadyConsumed
+ body?.consumed == true -> RedemptionResult.AlreadyConsumed
+ body?.expired == true -> RedemptionResult.TokenExpired
+ else -> RedemptionResult.AlreadyConsumed
+ }
+ deliver(callback, result)
+ }
+ onFailure = { t ->
+ // If status check itself fails, surface the original 409 as
+ // AlreadyConsumed — the host UI guidance for both states is
+ // "request a new email", which matches AlreadyConsumed.
+ logger.error("RedemptionManager: redeem/status failure: $t")
+ deliver(callback, RedemptionResult.AlreadyConsumed)
+ }
+ }
+ }
+
+ private fun extractToken(uri: Uri): String? {
+ // Spec rule RT2-W3: only the verified https App Link form
+ // (`android:autoVerify="true"`) is allowed as an email-link
+ // transport. Any installed app can claim a `qonversion://`
+ // intent-filter and hijack the redemption token, so we reject any
+ // non-https scheme up-front — before any token extraction or
+ // network call. The custom scheme is reserved for in-process
+ // host→SDK forwarding and is not exercised by this entry point.
+ if (!uri.scheme.equals(REDEEM_SCHEME_HTTPS, ignoreCase = true)) return null
+
+ val segments = uri.pathSegments ?: return null
+ // Expected: /r/{project_uid}/{token}
+ if (segments.size < REDEEM_PATH_MIN_SEGMENTS) return null
+ if (segments[0] != REDEEM_PATH_PREFIX) return null
+
+ val token = segments.getOrNull(REDEEM_TOKEN_SEGMENT_INDEX)?.trim()
+ return token?.takeIf { it.isNotBlank() }
+ }
+
+ private fun deliver(callback: QonversionRedemptionCallback, result: RedemptionResult) {
+ postToMainThread { callback.onResult(result) }
+ }
+
+ private fun deliverReissue(callback: (ReissueResult) -> Unit, result: ReissueResult) {
+ postToMainThread { callback(result) }
+ }
+
+ private fun postToMainThread(runnable: () -> Unit) {
+ if (Looper.myLooper() == Looper.getMainLooper()) {
+ runnable()
+ } else {
+ handler.post(runnable)
+ }
+ }
+
+ /** Internal-only reissue outcome surfaced to the dialog. */
+ internal enum class ReissueResult {
+ Sent,
+ RateLimited,
+ ServerError,
+ }
+
+ private companion object {
+ const val HTTP_CONFLICT = 409
+ const val HTTP_NOT_FOUND = 404
+ const val HTTP_GONE = 410
+ const val HTTP_TOO_MANY_REQUESTS = 429
+ val HTTP_SERVER_ERROR_RANGE = 500..599
+
+ const val REDEEM_PATH_PREFIX = "r"
+ // Path segments are 0-indexed after the leading "/", so /r/{project}/{token}
+ // → segments = ["r", "{project}", "{token}"], token is at index 2.
+ const val REDEEM_TOKEN_SEGMENT_INDEX = 2
+ const val REDEEM_PATH_MIN_SEGMENTS = 3
+ // RT2-W3: only https App Links are accepted as email-link transport.
+ const val REDEEM_SCHEME_HTTPS = "https"
+ }
+}
diff --git a/sdk/src/main/java/com/qonversion/android/sdk/internal/redemption/ReissueDialogFragment.kt b/sdk/src/main/java/com/qonversion/android/sdk/internal/redemption/ReissueDialogFragment.kt
new file mode 100644
index 00000000..764eaed2
--- /dev/null
+++ b/sdk/src/main/java/com/qonversion/android/sdk/internal/redemption/ReissueDialogFragment.kt
@@ -0,0 +1,159 @@
+package com.qonversion.android.sdk.internal.redemption
+
+import android.app.Dialog
+import android.os.Bundle
+import android.text.InputType
+import android.util.Patterns
+import android.view.Gravity
+import android.view.View
+import android.view.ViewGroup
+import android.widget.Button
+import android.widget.EditText
+import android.widget.LinearLayout
+import android.widget.ProgressBar
+import android.widget.TextView
+import androidx.fragment.app.DialogFragment
+
+/**
+ * Modal email-input dialog used by [com.qonversion.android.sdk.Qonversion.presentReissueUI]
+ * as the fallback when a user cannot complete redemption via the email App Link.
+ *
+ * Built programmatically (no XML resource) to keep the SDK module's resource
+ * surface minimal and avoid host-app theme conflicts. Hosts can wrap this in a
+ * standard fragment transaction.
+ *
+ * The fragment exposes its outcome via [onCompletion]: `true` on a 200 send,
+ * `false` if the user cancels.
+ *
+ * The dialog is not retained across configuration changes — the host is
+ * expected to re-present on rotation if needed. This matches the iOS modal's
+ * single-shot semantics.
+ */
+internal class ReissueDialogFragment : DialogFragment() {
+
+ /**
+ * Wiring set by [com.qonversion.android.sdk.internal.QonversionInternal] before
+ * showing the fragment. Not `Parcelable`, so the dialog cannot survive a
+ * process death — by design (see class docs).
+ */
+ var redemptionManager: RedemptionManager? = null
+ var onCompletion: ((Boolean) -> Unit)? = null
+
+ private lateinit var emailInput: EditText
+ private lateinit var sendButton: Button
+ private lateinit var progress: ProgressBar
+ private lateinit var hintText: TextView
+
+ override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
+ val dialog = Dialog(requireContext())
+ dialog.setTitle("Restore your purchase")
+ dialog.setContentView(buildContent())
+ dialog.setCanceledOnTouchOutside(false)
+ dialog.setOnCancelListener {
+ onCompletion?.invoke(false)
+ }
+ return dialog
+ }
+
+ private fun buildContent(): View {
+ val ctx = requireContext()
+ val padding = (PADDING_DP * resources.displayMetrics.density).toInt()
+
+ val root = LinearLayout(ctx).apply {
+ orientation = LinearLayout.VERTICAL
+ setPadding(padding, padding, padding, padding)
+ layoutParams = ViewGroup.LayoutParams(
+ ViewGroup.LayoutParams.MATCH_PARENT,
+ ViewGroup.LayoutParams.WRAP_CONTENT,
+ )
+ }
+
+ val title = TextView(ctx).apply {
+ text = "Enter the email used at checkout to receive a new restore link."
+ textSize = TITLE_TEXT_SIZE_SP
+ }
+ root.addView(title)
+
+ emailInput = EditText(ctx).apply {
+ hint = "you@example.com"
+ inputType = InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_EMAIL_ADDRESS
+ setSingleLine(true)
+ }
+ root.addView(emailInput)
+
+ progress = ProgressBar(ctx).apply {
+ visibility = View.GONE
+ }
+ root.addView(progress)
+
+ hintText = TextView(ctx).apply {
+ visibility = View.GONE
+ gravity = Gravity.START
+ }
+ root.addView(hintText)
+
+ sendButton = Button(ctx).apply {
+ text = "Send"
+ setOnClickListener { onSendClicked() }
+ }
+ root.addView(sendButton)
+
+ return root
+ }
+
+ private fun onSendClicked() {
+ val email = emailInput.text?.toString()?.trim().orEmpty()
+ if (!isValidEmail(email)) {
+ hintText.text = "Please enter a valid email."
+ hintText.visibility = View.VISIBLE
+ return
+ }
+
+ val manager = redemptionManager
+ if (manager == null) {
+ // Defensive — should never happen because Qonversion wires this
+ // up before show(). Surface as a generic error so the user can
+ // close the dialog and retry.
+ hintText.text = "Something went wrong, please try again."
+ hintText.visibility = View.VISIBLE
+ return
+ }
+
+ setBusy(true)
+ manager.requestReissue(email) { result ->
+ // requestReissue already dispatches to the main thread, so it's
+ // safe to touch views here.
+ setBusy(false)
+ when (result) {
+ RedemptionManager.ReissueResult.Sent -> {
+ onCompletion?.invoke(true)
+ dismissAllowingStateLoss()
+ }
+ RedemptionManager.ReissueResult.RateLimited -> {
+ hintText.text = "Too many attempts, try again later."
+ hintText.visibility = View.VISIBLE
+ }
+ RedemptionManager.ReissueResult.ServerError -> {
+ hintText.text = "Something went wrong, please try again."
+ hintText.visibility = View.VISIBLE
+ }
+ }
+ }
+ }
+
+ private fun setBusy(busy: Boolean) {
+ progress.visibility = if (busy) View.VISIBLE else View.GONE
+ sendButton.isEnabled = !busy
+ emailInput.isEnabled = !busy
+ }
+
+ private fun isValidEmail(value: String): Boolean {
+ if (value.isBlank()) return false
+ return Patterns.EMAIL_ADDRESS.matcher(value).matches()
+ }
+
+ private companion object {
+ const val PADDING_DP = 16
+ const val TITLE_TEXT_SIZE_SP = 14f
+ }
+}
diff --git a/sdk/src/main/java/com/qonversion/android/sdk/listeners/QonversionRedemptionCallback.kt b/sdk/src/main/java/com/qonversion/android/sdk/listeners/QonversionRedemptionCallback.kt
new file mode 100644
index 00000000..b1a80cfd
--- /dev/null
+++ b/sdk/src/main/java/com/qonversion/android/sdk/listeners/QonversionRedemptionCallback.kt
@@ -0,0 +1,13 @@
+package com.qonversion.android.sdk.listeners
+
+import com.qonversion.android.sdk.dto.redemption.RedemptionResult
+
+/**
+ * Callback used by [com.qonversion.android.sdk.Qonversion.handleRedemptionLink].
+ *
+ * Called once with the terminal [RedemptionResult] for the redemption attempt.
+ * All invocations are delivered on the main thread.
+ */
+interface QonversionRedemptionCallback {
+ fun onResult(result: RedemptionResult)
+}
diff --git a/sdk/src/test/java/com/qonversion/android/sdk/internal/QProductCenterManagerIdentifyContractTest.kt b/sdk/src/test/java/com/qonversion/android/sdk/internal/QProductCenterManagerIdentifyContractTest.kt
new file mode 100644
index 00000000..9966d19c
--- /dev/null
+++ b/sdk/src/test/java/com/qonversion/android/sdk/internal/QProductCenterManagerIdentifyContractTest.kt
@@ -0,0 +1,199 @@
+package com.qonversion.android.sdk.internal
+
+import android.app.Application
+import com.android.billingclient.api.Purchase
+import android.os.Build
+import com.qonversion.android.sdk.dto.QonversionError
+import com.qonversion.android.sdk.dto.QonversionErrorCode
+import com.qonversion.android.sdk.internal.api.RequestTrigger
+import com.qonversion.android.sdk.internal.billing.QonversionBillingService
+import com.qonversion.android.sdk.internal.dto.QLaunchResult
+import com.qonversion.android.sdk.internal.logger.Logger
+import com.qonversion.android.sdk.internal.provider.AppStateProvider
+import com.qonversion.android.sdk.internal.repository.QRepository
+import com.qonversion.android.sdk.internal.services.QUserInfoService
+import com.qonversion.android.sdk.internal.storage.LaunchResultCacheWrapper
+import com.qonversion.android.sdk.internal.storage.PurchasesCache
+import io.mockk.*
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.RobolectricTestRunner
+import org.robolectric.annotation.Config
+import java.util.Date
+
+/**
+ * Web 2 App M1 [RT5-N2] — contract tests for [QProductCenterManager.identify].
+ *
+ * The /v4/web/redeem/status recovery UX (DEV-845, plan §"POST
+ * /v4/web/redeem/status") relies on `Qonversion.identify(userID:)`
+ * triggering a fresh entitlement fetch via the merged identity. The
+ * SDK currently honours this implicitly: when the new identity differs
+ * from the current user id, [QProductCenterManager.processIdentity]
+ * calls `launchResultCache.clearPermissionsCache()` followed by
+ * `launch(RequestTrigger.Identify)`.
+ *
+ * If a future SDK change defers or skips the launch step (e.g.,
+ * lazy-fetch on next checkEntitlements call), the web→app recovery
+ * flow silently breaks — the user signs in but the server-side
+ * entitlement never reaches the host app. These tests pin the
+ * contract so any regression fails CI.
+ *
+ * Source of truth — Android: QProductCenterManager.kt:221-257.
+ * Symmetric iOS contract test lives in:
+ * qonversion-ios-sdk/Tests/.../QNProductCenterManagerIdentifyContractTests.swift
+ */
+@RunWith(RobolectricTestRunner::class)
+@Config(sdk = [Build.VERSION_CODES.O_MR1])
+internal class QProductCenterManagerIdentifyContractTest {
+
+ private val mockLogger: Logger = mockk(relaxed = true)
+ private val mockDeviceStorage = mockk(relaxed = true)
+ private val mockHandledPurchasesCache = mockk(relaxed = true)
+ private val mockLaunchResultCacheWrapper = mockk(relaxed = true)
+ private val mockContext = mockk(relaxed = true)
+ private val mockRepository = mockk(relaxed = true)
+ private val mockUserInfoService = mockk(relaxed = true)
+ private val mockIdentityManager = mockk(relaxed = true)
+ private val mockBillingService = mockk(relaxed = true)
+ private val mockConfig = mockk(relaxed = true)
+ private val mockAppStateProvider = mockk(relaxed = true)
+ private val mockRemoteConfigManager = mockk(relaxed = true)
+
+ private lateinit var pcm: QProductCenterManager
+
+ @Before
+ fun setUp() {
+ clearAllMocks()
+
+ // isLaunchingFinished := sessionLaunchResult != null. Set it so
+ // identify() takes the synchronous fast path (no init → identify).
+ val launchResult = QLaunchResult("uid_initial", Date(), offerings = null)
+ every { mockLaunchResultCacheWrapper.sessionLaunchResult } returns launchResult
+
+ // Force kids-mode so launch() skips AdvertisingProvider, which
+ // would otherwise spin up a background Thread and break the
+ // synchronous verifyOrder window.
+ every { mockConfig.primaryConfig.isKidsMode } returns true
+
+ // billingService.queryPurchases is the synchronous entry point
+ // into continueLaunchWithPurchasesInfo → processInit →
+ // repository.init. With the relaxed mock it's a no-op and
+ // repository.init never runs. Invoke the success lambda with an
+ // empty list so processInitDefault() fires on the same thread.
+ every {
+ mockBillingService.queryPurchases(any(), captureLambda())
+ } answers {
+ lambda<(List) -> Unit>().captured.invoke(emptyList())
+ }
+
+ pcm = QProductCenterManager(
+ mockContext,
+ mockRepository,
+ mockLogger,
+ mockDeviceStorage,
+ mockHandledPurchasesCache,
+ mockLaunchResultCacheWrapper,
+ mockUserInfoService,
+ mockIdentityManager,
+ mockConfig,
+ mockAppStateProvider,
+ mockRemoteConfigManager
+ )
+ pcm.billingService = mockBillingService
+ }
+
+ /**
+ * RT5-N2 happy path — different identity successfully linked: the
+ * SDK MUST clear the cached permissions AND issue a fresh launch
+ * with `RequestTrigger.Identify`. This is the wire that the web→app
+ * recovery UX depends on.
+ *
+ * If this test starts failing because someone moved the
+ * clear+launch to a lazy code path, see the [RT5-N2] note in
+ * plan §"SDK contract dependency" — the host app integration
+ * docs MUST then add an explicit `Qonversion.checkEntitlements()`
+ * call after `identify()` returns success.
+ */
+ @Test
+ fun `identify with different uid clears cache and re-launches with Identify trigger`() {
+ val newIdentity = "user@example.com"
+ val mergedUid = "uid_merged_999"
+
+ every { mockUserInfoService.obtainUserId() } returns "uid_initial"
+ // Identity manager succeeds with a DIFFERENT qonversion uid (the
+ // cross-user-success branch in processIdentity).
+ every {
+ mockIdentityManager.identify(eq(newIdentity), any())
+ } answers {
+ val cb = secondArg()
+ cb.onSuccess(mergedUid)
+ }
+
+ pcm.identify(newIdentity)
+
+ // Order matters — clear THEN re-launch. If launch reads the
+ // cache and finds stale permissions before clear, the UX is
+ // broken.
+ verifyOrder {
+ mockConfig.uid = mergedUid
+ mockRemoteConfigManager.onUserUpdate()
+ mockLaunchResultCacheWrapper.clearPermissionsCache()
+ mockRepository.init(match { it.requestTrigger == RequestTrigger.Identify })
+ }
+ }
+
+ /**
+ * Same-uid case is the "user re-identifies themselves" branch.
+ * The contract here is the OPPOSITE: the SDK MUST NOT clear cache
+ * or re-launch (would waste a request and could flash UI). Test
+ * pins the negative branch so a future patch can't accidentally
+ * always-launch.
+ */
+ @Test
+ fun `identify with same uid does NOT clear cache or re-launch`() {
+ val newIdentity = "user@example.com"
+ val sameUid = "uid_initial"
+
+ every { mockUserInfoService.obtainUserId() } returns sameUid
+ every {
+ mockIdentityManager.identify(eq(newIdentity), any())
+ } answers {
+ secondArg().onSuccess(sameUid)
+ }
+
+ pcm.identify(newIdentity)
+
+ verify(exactly = 0) { mockLaunchResultCacheWrapper.clearPermissionsCache() }
+ verify(exactly = 0) {
+ mockRepository.init(match { it.requestTrigger == RequestTrigger.Identify })
+ }
+ }
+
+ /**
+ * Identity error must NOT clear cache or re-launch — silently
+ * keeping prior entitlements is the correct fallback. Pins the
+ * error branch so future error handlers can't accidentally taint
+ * the cache.
+ */
+ @Test
+ fun `identify with identityManager error does NOT touch cache`() {
+ val newIdentity = "user@example.com"
+
+ every { mockUserInfoService.obtainUserId() } returns "uid_initial"
+ every {
+ mockIdentityManager.identify(eq(newIdentity), any())
+ } answers {
+ secondArg().onError(
+ QonversionError(QonversionErrorCode.BackendError)
+ )
+ }
+
+ pcm.identify(newIdentity)
+
+ verify(exactly = 0) { mockLaunchResultCacheWrapper.clearPermissionsCache() }
+ verify(exactly = 0) {
+ mockRepository.init(match { it.requestTrigger == RequestTrigger.Identify })
+ }
+ }
+}
diff --git a/sdk/src/test/java/com/qonversion/android/sdk/internal/redemption/RedemptionManagerTest.kt b/sdk/src/test/java/com/qonversion/android/sdk/internal/redemption/RedemptionManagerTest.kt
new file mode 100644
index 00000000..5f349413
--- /dev/null
+++ b/sdk/src/test/java/com/qonversion/android/sdk/internal/redemption/RedemptionManagerTest.kt
@@ -0,0 +1,277 @@
+package com.qonversion.android.sdk.internal.redemption
+
+import android.net.Uri
+import android.os.Looper
+import com.qonversion.android.sdk.dto.redemption.RedemptionResult
+import com.qonversion.android.sdk.internal.InternalConfig
+import com.qonversion.android.sdk.internal.api.Api
+import com.qonversion.android.sdk.internal.dto.redemption.RedeemResponse
+import com.qonversion.android.sdk.internal.dto.redemption.RedeemStatusResponse
+import com.qonversion.android.sdk.internal.dto.request.RedeemRequest
+import com.qonversion.android.sdk.internal.dto.request.RedeemStatusRequest
+import com.qonversion.android.sdk.internal.logger.Logger
+import com.qonversion.android.sdk.listeners.QonversionRedemptionCallback
+import io.mockk.clearAllMocks
+import io.mockk.every
+import io.mockk.mockk
+import io.mockk.slot
+import io.mockk.verify
+import okhttp3.MediaType
+import okhttp3.ResponseBody
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNull
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.RobolectricTestRunner
+import org.robolectric.Shadows.shadowOf
+import retrofit2.Call
+import retrofit2.Callback
+import retrofit2.Response
+
+/**
+ * Unit tests for [RedemptionManager].
+ *
+ * Uses Robolectric to get a real [Uri] parser and a controllable main looper.
+ * Retrofit [Call]s are hand-mocked because the SDK doesn't ship a
+ * `MockWebServer` test fixture and the surface area is small enough that
+ * inline mocks are clearer than spinning one up.
+ */
+@RunWith(RobolectricTestRunner::class)
+internal class RedemptionManagerTest {
+
+ private lateinit var api: Api
+ private lateinit var internalConfig: InternalConfig
+ private lateinit var logger: Logger
+ private lateinit var identifiedUserIds: MutableList
+
+ private lateinit var manager: RedemptionManager
+
+ @Before
+ fun setUp() {
+ clearAllMocks()
+ api = mockk(relaxed = true)
+ internalConfig = mockk(relaxed = true)
+ logger = mockk(relaxed = true)
+ identifiedUserIds = mutableListOf()
+ every { internalConfig.uid } returns "QON_anon_123"
+ manager = RedemptionManager(
+ api = api,
+ internalConfig = internalConfig,
+ logger = logger,
+ identifyUser = { identifiedUserIds.add(it) },
+ )
+ }
+
+ @Test
+ fun `handleRedemptionLink parses token from valid Uri and posts request`() {
+ val uri = Uri.parse("https://screens.qonversion.io/r/proj_abc/tok_xyz")
+ val captured = slot()
+ every { api.redeem(capture(captured)) } returns alwaysSuccessCall(RedeemResponse(userId = "QON_user_42"))
+
+ val callback = RecordingCallback()
+ manager.handleRedemptionLink(uri, callback)
+
+ flushMainLooper()
+
+ assertEquals("tok_xyz", captured.captured.token)
+ assertEquals("QON_anon_123", captured.captured.anonUserId)
+ assertEquals("transfer", captured.captured.restoreBehavior)
+ assertEquals(RedemptionResult.Success, callback.received)
+ assertEquals(listOf("QON_user_42"), identifiedUserIds)
+ }
+
+ @Test
+ fun `handleRedemptionLink rejects qonversion custom-scheme Uri without network call (RT2-W3)`() {
+ // Spec rule RT2-W3: only the verified https App Link is allowed as an
+ // email-link transport on Android. Any installed app can claim a
+ // `qonversion://` intent-filter and hijack the token, so the SDK MUST
+ // reject the custom scheme before any network call. The custom scheme
+ // is reserved for in-process host→SDK forwarding (see Qonversion.kt
+ // docstring on handleRedemptionLink) and is not exercised by this
+ // public entry point.
+ val uri = Uri.parse("qonversion://screens.qonversion.io/r/proj_abc/tok_xyz")
+
+ val callback = RecordingCallback()
+ manager.handleRedemptionLink(uri, callback)
+
+ flushMainLooper()
+
+ assertEquals(RedemptionResult.InvalidToken, callback.received)
+ // Load-bearing security assertion: the token must NEVER leak over the
+ // wire when the scheme is rejected.
+ verify(exactly = 0) { api.redeem(any()) }
+ assertEquals(emptyList(), identifiedUserIds)
+ }
+
+ @Test
+ fun `handleRedemptionLink returns InvalidToken when Uri is malformed`() {
+ val uri = Uri.parse("https://screens.qonversion.io/some/other/path")
+
+ val callback = RecordingCallback()
+ manager.handleRedemptionLink(uri, callback)
+
+ flushMainLooper()
+
+ assertEquals(RedemptionResult.InvalidToken, callback.received)
+ // Should never have hit the network for a malformed Uri.
+ verify(exactly = 0) { api.redeem(any()) }
+ // No identify should have been triggered either.
+ assertEquals(emptyList(), identifiedUserIds)
+ }
+
+ @Test
+ fun `handleRedemptionLink returns InvalidToken when path is just the prefix`() {
+ val uri = Uri.parse("https://screens.qonversion.io/r/proj_abc")
+
+ val callback = RecordingCallback()
+ manager.handleRedemptionLink(uri, callback)
+
+ flushMainLooper()
+
+ assertEquals(RedemptionResult.InvalidToken, callback.received)
+ verify(exactly = 0) { api.redeem(any()) }
+ }
+
+ @Test
+ fun `handleRedemptionLink maps 200 to Success and triggers identify`() {
+ val uri = Uri.parse("https://screens.qonversion.io/r/proj/tok_ok")
+ every { api.redeem(any()) } returns alwaysSuccessCall(RedeemResponse(userId = "QON_user_99"))
+
+ val callback = RecordingCallback()
+ manager.handleRedemptionLink(uri, callback)
+
+ flushMainLooper()
+
+ assertEquals(RedemptionResult.Success, callback.received)
+ assertEquals(listOf("QON_user_99"), identifiedUserIds)
+ }
+
+ @Test
+ fun `handleRedemptionLink maps 409 with consumed=true to AlreadyConsumed`() {
+ val uri = Uri.parse("https://screens.qonversion.io/r/proj/tok_used")
+ every { api.redeem(any()) } returns alwaysErrorCall(409)
+ val statusCaptured = slot()
+ every { api.redeemStatus(capture(statusCaptured)) } returns
+ alwaysSuccessCall(RedeemStatusResponse(consumed = true, expired = false))
+
+ val callback = RecordingCallback()
+ manager.handleRedemptionLink(uri, callback)
+
+ flushMainLooper()
+
+ assertEquals("tok_used", statusCaptured.captured.token)
+ assertEquals(RedemptionResult.AlreadyConsumed, callback.received)
+ // identify must NOT be called on AlreadyConsumed — the host is in
+ // recovery flow, not merge flow.
+ assertEquals(emptyList(), identifiedUserIds)
+ }
+
+ @Test
+ fun `handleRedemptionLink maps 404 to InvalidToken`() {
+ val uri = Uri.parse("https://screens.qonversion.io/r/proj/tok_404")
+ every { api.redeem(any()) } returns alwaysErrorCall(404)
+
+ val callback = RecordingCallback()
+ manager.handleRedemptionLink(uri, callback)
+
+ flushMainLooper()
+
+ assertEquals(RedemptionResult.InvalidToken, callback.received)
+ }
+
+ @Test
+ fun `handleRedemptionLink maps 410 to TokenExpired`() {
+ val uri = Uri.parse("https://screens.qonversion.io/r/proj/tok_expired")
+ every { api.redeem(any()) } returns alwaysErrorCall(410)
+
+ val callback = RecordingCallback()
+ manager.handleRedemptionLink(uri, callback)
+
+ flushMainLooper()
+
+ assertEquals(RedemptionResult.TokenExpired, callback.received)
+ }
+
+ @Test
+ fun `handleRedemptionLink maps network failure to NetworkError`() {
+ val uri = Uri.parse("https://screens.qonversion.io/r/proj/tok_neterr")
+ every { api.redeem(any()) } returns alwaysNetworkFailureCall()
+
+ val callback = RecordingCallback()
+ manager.handleRedemptionLink(uri, callback)
+
+ flushMainLooper()
+
+ assertEquals(RedemptionResult.NetworkError, callback.received)
+ }
+
+ /**
+ * RT5-N2 contract test: a successful redemption MUST cause `identify` to
+ * be invoked on the product center manager so the SDK runs a new launch
+ * and pulls the freshly-granted entitlements.
+ *
+ * We assert the contract at the [RedemptionManager] boundary (its
+ * `identifyUser` lambda is the seam where it hands off to
+ * `QProductCenterManager.identify`). A full end-to-end contract test that
+ * asserts `identify(newUserId)` triggers a network launch / permissions
+ * fetch should live next to the product center manager and exercise the
+ * real `QProductCenterManager`. See [com.qonversion.android.sdk.internal
+ * .QProductCenterManager.identify] — adding that integration-style test
+ * requires staging the full launch state machine and is tracked
+ * separately (DEV-847 follow-up).
+ */
+ @Test
+ fun `RT5-N2 contract — success path forwards Qonversion user id to identify exactly once`() {
+ val uri = Uri.parse("https://screens.qonversion.io/r/proj/tok_contract")
+ every { api.redeem(any()) } returns alwaysSuccessCall(RedeemResponse(userId = "QON_user_contract"))
+
+ manager.handleRedemptionLink(uri, RecordingCallback())
+ flushMainLooper()
+
+ assertEquals(listOf("QON_user_contract"), identifiedUserIds)
+ }
+
+ // --- Helpers ---------------------------------------------------------
+
+ private class RecordingCallback : QonversionRedemptionCallback {
+ var received: RedemptionResult? = null
+ override fun onResult(result: RedemptionResult) {
+ assertNull("callback invoked more than once", received)
+ received = result
+ }
+ }
+
+ private fun alwaysSuccessCall(body: T): Call {
+ val call = mockk>(relaxed = true)
+ every { call.enqueue(any()) } answers {
+ val cb = arg>(0)
+ cb.onResponse(call, Response.success(body))
+ }
+ return call
+ }
+
+ private fun alwaysErrorCall(code: Int): Call {
+ val call = mockk>(relaxed = true)
+ every { call.enqueue(any()) } answers {
+ val cb = arg>(0)
+ val errBody = ResponseBody.create(MediaType.parse("application/json"), "{}")
+ val resp: Response = Response.error(code, errBody)
+ cb.onResponse(call, resp)
+ }
+ return call
+ }
+
+ private fun alwaysNetworkFailureCall(): Call {
+ val call = mockk>(relaxed = true)
+ every { call.enqueue(any()) } answers {
+ val cb = arg>(0)
+ cb.onFailure(call, RuntimeException("simulated network failure"))
+ }
+ return call
+ }
+
+ private fun flushMainLooper() {
+ shadowOf(Looper.getMainLooper()).idle()
+ }
+}