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() + } +}