Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Support web redemption links #289

Draft
wants to merge 3 commits into
base: main
Choose a base branch
from
Draft
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
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,10 @@ private class PurchasesErrorCodeAPI {
PurchasesErrorCode.InvalidPromotionalOfferError,
PurchasesErrorCode.OfflineConnectionError,
PurchasesErrorCode.SignatureVerificationError,
PurchasesErrorCode.FeatureNotAvailableInCustomEntitlementsComputationMode -> {
PurchasesErrorCode.FeatureNotAvailableInCustomEntitlementsComputationMode,
PurchasesErrorCode.InvalidWebPurchaseToken,
PurchasesErrorCode.PurchaseBelongsToOtherUser,
PurchasesErrorCode.ExpiredWebPurchaseToken -> {
}
}.exhaustive
}
Expand Down
7 changes: 7 additions & 0 deletions composeApp/src/androidMain/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,13 @@

<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.BROWSABLE" />
<category android:name="android.intent.category.DEFAULT" />
<!-- Change this to the actual id obtained from your RCBilling dashboard-->
<data android:scheme="rc-39976e83d0" />
</intent-filter>
</activity>
</application>

Expand Down
Original file line number Diff line number Diff line change
@@ -1,23 +1,35 @@
package com.revenuecat.purchases.kmp.sample

import android.content.Intent
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import org.jetbrains.compose.ui.tooling.preview.Preview

class MainActivity : ComponentActivity() {
private var urlString by mutableStateOf<String?>(null)

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
urlString = intent.dataString

setContent {
App()
App(urlString = urlString, urlProcessed = { urlString = null })
}
}

override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
urlString = intent.dataString
}
}

@Preview
@Composable
fun AppAndroidPreview() {
App()
App(urlString = null, urlProcessed = {})
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,11 @@ package com.revenuecat.purchases.kmp.sample
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material.AlertDialog
import androidx.compose.material.Button
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Text
Expand All @@ -17,13 +20,19 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import com.revenuecat.purchases.kmp.Purchases
import com.revenuecat.purchases.kmp.models.RedeemWebPurchaseListener
import com.revenuecat.purchases.kmp.ui.revenuecatui.Paywall
import com.revenuecat.purchases.kmp.ui.revenuecatui.PaywallFooter
import com.revenuecat.purchases.kmp.ui.revenuecatui.PaywallOptions

@Composable
fun App() {
fun App(urlString: String?, urlProcessed: () -> Unit) {
MaterialTheme {
if (urlString != null) {
ProcessDeepLink(urlString, urlProcessed)
}
Column(
modifier = Modifier
.fillMaxSize(),
Expand Down Expand Up @@ -92,3 +101,47 @@ private fun CustomPaywallContent(
}
}
}

@Composable
private fun ProcessDeepLink(urlString: String, urlProcessed: () -> Unit) {
var alertMessage by remember { mutableStateOf<String?>(null) }

val webPurchaseRedemption = Purchases.parseAsWebPurchaseRedemption(urlString)
if (webPurchaseRedemption != null && Purchases.isConfigured) {
Purchases.sharedInstance.redeemWebPurchase(webPurchaseRedemption) { result ->
alertMessage = when (result) {
is RedeemWebPurchaseListener.Result.Error ->
"Error redeeming web purchase: ${result.error.message}"
is RedeemWebPurchaseListener.Result.Expired ->
"Web purchase redemption token expired. Email sent to: ${result.obfuscatedEmail}"
RedeemWebPurchaseListener.Result.InvalidToken ->
"Invalid web purchase redemption token"
RedeemWebPurchaseListener.Result.PurchaseBelongsToOtherUser ->
"Web purchase belongs to another user"
is RedeemWebPurchaseListener.Result.Success ->
"Web purchase redeemed successfully. Entitlements: ${result.customerInfo.entitlements.active}"
}
}
}

alertMessage?.let {
AlertDialog(
onDismissRequest = { urlProcessed() },
title = { Text("Web Purchase Redemption result") },
text = { Text(it) },
buttons = {
Row(
modifier = Modifier. padding(all = 8.dp),
horizontalArrangement = Arrangement. Center
) {
Button(
modifier = Modifier.fillMaxWidth(),
onClick = { urlProcessed() }
) {
Text("Dismiss")
}
}
}
)
}
}
Original file line number Diff line number Diff line change
@@ -1,10 +1,14 @@
package com.revenuecat.purchases.kmp

import android.content.Intent
import android.net.Uri
import com.revenuecat.purchases.ExperimentalPreviewRevenueCatPurchasesAPI
import com.revenuecat.purchases.PurchaseParams
import com.revenuecat.purchases.common.PlatformInfo
import com.revenuecat.purchases.getCustomerInfoWith
import com.revenuecat.purchases.getOfferingsWith
import com.revenuecat.purchases.getProductsWith
import com.revenuecat.purchases.hybridcommon.isWebPurchaseRedemptionURL
import com.revenuecat.purchases.kmp.di.AndroidProvider
import com.revenuecat.purchases.kmp.di.requireActivity
import com.revenuecat.purchases.kmp.di.requireApplication
Expand All @@ -22,6 +26,7 @@ import com.revenuecat.purchases.kmp.mappings.toPurchasesError
import com.revenuecat.purchases.kmp.mappings.toStore
import com.revenuecat.purchases.kmp.mappings.toStoreProduct
import com.revenuecat.purchases.kmp.mappings.toStoreTransaction
import com.revenuecat.purchases.kmp.mappings.toWebPurchaseResult
import com.revenuecat.purchases.kmp.models.BillingFeature
import com.revenuecat.purchases.kmp.models.CacheFetchPolicy
import com.revenuecat.purchases.kmp.models.CustomerInfo
Expand All @@ -32,13 +37,15 @@ import com.revenuecat.purchases.kmp.models.Package
import com.revenuecat.purchases.kmp.models.PromotionalOffer
import com.revenuecat.purchases.kmp.models.PurchasesError
import com.revenuecat.purchases.kmp.models.PurchasesErrorCode
import com.revenuecat.purchases.kmp.models.RedeemWebPurchaseListener
import com.revenuecat.purchases.kmp.models.ReplacementMode
import com.revenuecat.purchases.kmp.models.Store
import com.revenuecat.purchases.kmp.models.StoreMessageType
import com.revenuecat.purchases.kmp.models.StoreProduct
import com.revenuecat.purchases.kmp.models.StoreProductDiscount
import com.revenuecat.purchases.kmp.models.StoreTransaction
import com.revenuecat.purchases.kmp.models.SubscriptionOption
import com.revenuecat.purchases.kmp.models.WebPurchaseRedemption
import com.revenuecat.purchases.kmp.strings.ConfigureStrings
import com.revenuecat.purchases.logInWith
import com.revenuecat.purchases.logOutWith
Expand Down Expand Up @@ -128,6 +135,14 @@ public actual class Purchases private constructor(private val androidPurchases:

private fun DangerousSettings.toAndroidDangerousSettings(): AndroidDangerousSettings =
AndroidDangerousSettings(autoSyncPurchases)

public actual fun parseAsWebPurchaseRedemption(url: String): WebPurchaseRedemption? {
return if (isWebPurchaseRedemptionURL(url)) {
WebPurchaseRedemption(url)
} else {
null
}
}
}

public actual val appUserID: String by androidPurchases::appUserID
Expand Down Expand Up @@ -417,6 +432,38 @@ public actual class Purchases private constructor(private val androidPurchases:
public actual fun setCreative(creative: String?): Unit =
androidPurchases.setCreative(creative)

@OptIn(ExperimentalPreviewRevenueCatPurchasesAPI::class)
public actual fun redeemWebPurchase(
webPurchaseRedemption: WebPurchaseRedemption,
listener: RedeemWebPurchaseListener
) {
val respondInvalidUrlError = {
listener.handleResult(
RedeemWebPurchaseListener.Result.Error(
PurchasesError(
PurchasesErrorCode.ConfigurationError,
"Invalid URL: ${webPurchaseRedemption.redemptionUrl}"
)
))
}
val nativeWebPurchaseRedemption = try {
// Replace this with parseAsWebPurchaseRedemption overload
// accepting strings once it's available.
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(webPurchaseRedemption.redemptionUrl))
AndroidPurchases.parseAsWebPurchaseRedemption(intent)
} catch (@Suppress("TooGenericExceptionCaught") e: Throwable) {
respondInvalidUrlError()
return
}
if (nativeWebPurchaseRedemption == null) {
respondInvalidUrlError()
return
}
androidPurchases.redeemWebPurchase(nativeWebPurchaseRedemption) { result ->
listener.handleResult(result.toWebPurchaseResult())
}
}

private fun StoreMessageType.toInAppMessageTypeOrNull(): InAppMessageType? =
when (this) {
StoreMessageType.BILLING_ISSUES -> InAppMessageType.BILLING_ISSUES
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,15 @@ import com.revenuecat.purchases.kmp.models.Offerings
import com.revenuecat.purchases.kmp.models.Package
import com.revenuecat.purchases.kmp.models.PromotionalOffer
import com.revenuecat.purchases.kmp.models.PurchasesError
import com.revenuecat.purchases.kmp.models.RedeemWebPurchaseListener
import com.revenuecat.purchases.kmp.models.ReplacementMode
import com.revenuecat.purchases.kmp.models.Store
import com.revenuecat.purchases.kmp.models.StoreMessageType
import com.revenuecat.purchases.kmp.models.StoreProduct
import com.revenuecat.purchases.kmp.models.StoreProductDiscount
import com.revenuecat.purchases.kmp.models.StoreTransaction
import com.revenuecat.purchases.kmp.models.SubscriptionOption
import com.revenuecat.purchases.kmp.models.WebPurchaseRedemption
import kotlin.jvm.JvmSynthetic

/**
Expand Down Expand Up @@ -97,6 +99,13 @@ public expect class Purchases {
features: List<BillingFeature> = listOf(),
callback: (Boolean) -> Unit,
)

/**
* Given a url string, parses the link and returns a [WebPurchaseRedemption], which can
* be used to redeem a web purchase using [Purchases.redeemWebPurchase]
* @return A parsed version of the link or null if it's not a valid RevenueCat web purchase redemption link.
*/
public fun parseAsWebPurchaseRedemption(url: String): WebPurchaseRedemption?
}

/**
Expand Down Expand Up @@ -650,6 +659,14 @@ public expect class Purchases {
*/
public fun setCreative(creative: String?)

/**
* Redeem a web purchase using a [WebPurchaseRedemption] object obtained
* through [Purchases.parseAsWebPurchaseRedemption].
*/
public fun redeemWebPurchase(
webPurchaseRedemption: WebPurchaseRedemption,
listener: RedeemWebPurchaseListener,
)
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,10 @@ import cocoapods.PurchasesHybridCommon.RCCommonFunctionality
import cocoapods.PurchasesHybridCommon.RCPurchasesDelegateProtocol
import cocoapods.PurchasesHybridCommon.RCStoreProduct
import cocoapods.PurchasesHybridCommon.configureWithAPIKey
import cocoapods.PurchasesHybridCommon.isWebPurchaseRedemptionURL
import cocoapods.PurchasesHybridCommon.parseAsWebPurchaseRedemptionWithUrlString
import cocoapods.PurchasesHybridCommon.recordPurchaseForProductID
import cocoapods.PurchasesHybridCommon.redeemWebPurchaseWithUrlString
import cocoapods.PurchasesHybridCommon.setAirshipChannelID
import cocoapods.PurchasesHybridCommon.setOnesignalUserID
import cocoapods.PurchasesHybridCommon.showStoreMessagesForTypes
Expand All @@ -30,13 +33,15 @@ import com.revenuecat.purchases.kmp.models.Package
import com.revenuecat.purchases.kmp.models.PromotionalOffer
import com.revenuecat.purchases.kmp.models.PurchasesError
import com.revenuecat.purchases.kmp.models.PurchasesErrorCode
import com.revenuecat.purchases.kmp.models.RedeemWebPurchaseListener
import com.revenuecat.purchases.kmp.models.ReplacementMode
import com.revenuecat.purchases.kmp.models.Store
import com.revenuecat.purchases.kmp.models.StoreMessageType
import com.revenuecat.purchases.kmp.models.StoreProduct
import com.revenuecat.purchases.kmp.models.StoreProductDiscount
import com.revenuecat.purchases.kmp.models.StoreTransaction
import com.revenuecat.purchases.kmp.models.SubscriptionOption
import com.revenuecat.purchases.kmp.models.WebPurchaseRedemption
import com.revenuecat.purchases.kmp.strings.ConfigureStrings
import platform.Foundation.NSURL
import cocoapods.PurchasesHybridCommon.RCDangerousSettings as IosDangerousSettings
Expand Down Expand Up @@ -113,6 +118,19 @@ public actual class Purchases private constructor(private val iosPurchases: IosP

private fun DangerousSettings.toIosDangerousSettings(): IosDangerousSettings =
IosDangerousSettings(autoSyncPurchases)

/**
* Given a url string, parses the link and returns a [WebPurchaseRedemption], which can
* be used to redeem a web purchase using [Purchases.redeemWebPurchase]
* @return A parsed version of the link or null if it's not a valid RevenueCat web purchase redemption link.
*/
public actual fun parseAsWebPurchaseRedemption(url: String): WebPurchaseRedemption? {
return if (RCCommonFunctionality.isWebPurchaseRedemptionURL(url)) {
WebPurchaseRedemption(url)
} else {
null
}
}
}

public actual val appUserID: String
Expand Down Expand Up @@ -446,4 +464,59 @@ public actual class Purchases private constructor(private val iosPurchases: IosP

public actual fun setCreative(creative: String?): Unit =
iosPurchases.setCreative(creative)

public actual fun redeemWebPurchase(
webPurchaseRedemption: WebPurchaseRedemption,
listener: RedeemWebPurchaseListener,
) {
val nativeWebPurchaseRedemption = RCCommonFunctionality.parseAsWebPurchaseRedemptionWithUrlString(
webPurchaseRedemption.redemptionUrl,
)
if (nativeWebPurchaseRedemption == null) {
listener.handleResult(RedeemWebPurchaseListener.Result.Error(
PurchasesError(
code = PurchasesErrorCode.ConfigurationError,
underlyingErrorMessage = "Invalid web purchase redemption URL."
)
))
return
}
iosPurchases.redeemWebPurchaseWithWebPurchaseRedemption(
nativeWebPurchaseRedemption,
) { rcCustomerInfo, nsError ->
if (nsError != null) {
val errorCode = nsError.code.toInt()
val result = when (errorCode) {
PurchasesErrorCode.InvalidWebPurchaseToken.code ->
RedeemWebPurchaseListener.Result.InvalidToken
PurchasesErrorCode.PurchaseBelongsToOtherUser.code ->
RedeemWebPurchaseListener.Result.PurchaseBelongsToOtherUser
PurchasesErrorCode.ExpiredWebPurchaseToken.code ->
RedeemWebPurchaseListener.Result.Expired(
nsError.userInfo["rc_obfuscated_email"] as String? ?: ""
)
else ->
RedeemWebPurchaseListener.Result.Error(nsError.toPurchasesErrorOrThrow())
}
listener.handleResult(result)
return@redeemWebPurchaseWithWebPurchaseRedemption
}
if (rcCustomerInfo == null) {
listener.handleResult(
RedeemWebPurchaseListener.Result.Error(
PurchasesError(
code = PurchasesErrorCode.UnknownError,
underlyingErrorMessage = "Expected a non-null RCCustomerInfo when error is null."
)
)
)
return@redeemWebPurchaseWithWebPurchaseRedemption
}
listener.handleResult(
RedeemWebPurchaseListener.Result.Success(
rcCustomerInfo.toCustomerInfo()
)
)
}
}
}
Loading