Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 10 additions & 5 deletions sample/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -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')
}
}
}
}
Expand All @@ -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
}
}
Expand Down
35 changes: 35 additions & 0 deletions sample/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,41 @@
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>

<!--
Web2App (DEV-847) — redemption email App Link.

MUST use https + android:autoVerify="true". Pair this with a
hosted /.well-known/assetlinks.json that pins the host package
and signing fingerprint so other apps cannot intercept the
redemption Uri (plan §"RT2-W3" / §"RT-F11").

The companion custom-scheme intent-filter (qonversion://redeem)
is intentionally NOT registered as an email-link transport —
any installed app could claim that scheme and hijack the token.
The custom scheme remains valid only for in-process host→SDK
forwarding by the host itself.
-->
<!--
MUST be path-scoped to THIS project's uid. Sharing
screens.qonversion.io across all merchants means an
unscoped `pathPattern="/r/.*"` triggers an Android
chooser dialog when another merchant's app is installed,
and worse — could redirect a token to the wrong app's
SDK. Production merchants set this to their project's uid
issued in Qonversion Connect Apps onboarding.

Local dev uses project `PcnB70vn` (Openchat).
-->
<intent-filter android:autoVerify="true">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data
android:scheme="https"
android:host="screens.qonversion.io"
android:pathPattern="/r/PcnB70vn/.*" />
</intent-filter>
</activity>
</application>

Expand Down
52 changes: 52 additions & 0 deletions sample/src/main/java/io/qonversion/sample/MainActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down Expand Up @@ -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 {
Expand Down
45 changes: 45 additions & 0 deletions sdk/src/main/java/com/qonversion/android/sdk/Qonversion.kt
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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 `<intent-filter>` 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)
}
Original file line number Diff line number Diff line change
@@ -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,
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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)
}

Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -436,4 +473,8 @@ internal class QonversionInternal(
handler.post(runnable)
}
}

private companion object {
const val REISSUE_DIALOG_TAG = "qonversion.reissue_dialog"
}
}
32 changes: 32 additions & 0 deletions sdk/src/main/java/com/qonversion/android/sdk/internal/api/Api.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -116,4 +121,31 @@ internal interface Api {

@GET("v3/users/{user_id}/properties")
fun getProperties(@Path("user_id") userId: String): Call<List<QUserProperty>>

// --- 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<RedeemResponse>

/**
* 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<RedeemStatusResponse>

/**
* 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<Void>
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -45,4 +46,5 @@ internal interface AppComponent {
fun sharedPreferencesCache(): SharedPreferencesCache
fun exceptionManager(): QExceptionManager
fun fallbacksService(): QFallbacksService
fun api(): Api
}
Loading
Loading