Skip to content
Merged

2.6.6 #337

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
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,11 @@ The changelog for `Superwall`. Also see the [releases](https://github.com/superw
- Allows triggering a `transaction_abandon` offer on a `paywall_decline` offer and vice-versa, whereas previously it would trigger a presentation error.
- Add a `Superwall.teardown` method and `Superwall.instance.refreshConfiguration()` for development with hot-reload based frameworks

## ⚠️ Warning ⚠️
If you are using a Purchase Controller and web2app or app2web purchases, you will have to update your purchase controller
to listen to `Superwall.instance.customerInfo` which will provide you with the relevant web entitlements and call
`setSubscriptionStatus` accordingly.

## 2.6.5

## Dependencies
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,10 @@ import com.superwall.sdk.delegate.subscription_controller.PurchaseController
import com.superwall.sdk.models.entitlements.Entitlement
import com.superwall.sdk.models.entitlements.SubscriptionStatus
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch

// Extension function to convert callback to suspend function
suspend fun Purchases.awaitProducts(productIds: List<String>): List<StoreProduct> {
Expand Down Expand Up @@ -109,6 +113,9 @@ class RevenueCatPurchaseController(
val context: Context,
) : PurchaseController,
UpdatedCustomerInfoListener {
private var superwallCustomerInfoJob: Job? = null
private val scope = CoroutineScope(Dispatchers.Main)

init {
Purchases.logLevel = LogLevel.DEBUG
Purchases.configure(
Expand All @@ -124,36 +131,54 @@ class RevenueCatPurchaseController(
}

fun syncSubscriptionStatus() {
// Cancel any existing listener to avoid duplicates
superwallCustomerInfoJob?.cancel()

// Refetch the customer info on load
Purchases.sharedInstance.getCustomerInfoWith {
if (hasAnyActiveEntitlements(it)) {
setSubscriptionStatus(
SubscriptionStatus.Active(
it.entitlements.active
.map {
Entitlement(it.key, Entitlement.Type.SERVICE_LEVEL)
}.toSet(),
),
)
} else {
setSubscriptionStatus(SubscriptionStatus.Inactive)
}
Purchases.sharedInstance.getCustomerInfoWith { rcCustomerInfo ->
updateSubscriptionStatus(rcCustomerInfo)
}

// Listen to Superwall customerInfo changes (for web entitlements)
superwallCustomerInfoJob =
scope.launch {
Superwall.instance.customerInfo.collect {
// When Superwall's customerInfo changes, re-fetch RC state and merge
Purchases.sharedInstance.getCustomerInfoWith { rcCustomerInfo ->
updateSubscriptionStatus(rcCustomerInfo)
}
}
}
}

/**
* Callback for rc customer updated info
* Callback for RC customer updated info
*/
override fun onReceived(customerInfo: CustomerInfo) {
if (hasAnyActiveEntitlements(customerInfo)) {
setSubscriptionStatus(
SubscriptionStatus.Active(
customerInfo.entitlements.active
.map {
Entitlement(it.key, Entitlement.Type.SERVICE_LEVEL)
}.toSet(),
),
)
updateSubscriptionStatus(customerInfo)
}

/**
* Merges RevenueCat entitlements with Superwall web entitlements and updates subscription status
*/
private fun updateSubscriptionStatus(rcCustomerInfo: CustomerInfo) {
val rcEntitlements =
rcCustomerInfo.entitlements.active
.map { Entitlement(it.key, Entitlement.Type.SERVICE_LEVEL) }
.toSet()

// Merge with web entitlements from Superwall
val webEntitlements =
if (Superwall.initialized) {
Superwall.instance.entitlements.web
} else {
emptySet()
}

val allEntitlements = rcEntitlements + webEntitlements

if (allEntitlements.isNotEmpty()) {
setSubscriptionStatus(SubscriptionStatus.Active(allEntitlements))
} else {
setSubscriptionStatus(SubscriptionStatus.Inactive)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,10 @@ import com.superwall.sdk.delegate.subscription_controller.PurchaseController
import com.superwall.sdk.models.entitlements.Entitlement
import com.superwall.sdk.models.entitlements.SubscriptionStatus
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch

suspend fun Purchases.awaitProducts(productIds: List<String>): List<StoreProduct> {
val deferred = CompletableDeferred<List<StoreProduct>>()
Expand Down Expand Up @@ -107,6 +111,9 @@ class RevenueCatPurchaseController(
val context: Context,
) : PurchaseController,
UpdatedCustomerInfoListener {
private var superwallCustomerInfoJob: Job? = null
private val scope = CoroutineScope(Dispatchers.Main)

init {
Purchases.logLevel = LogLevel.DEBUG
Purchases.configure(
Expand All @@ -122,36 +129,54 @@ class RevenueCatPurchaseController(
}

fun syncSubscriptionStatus() {
// Cancel any existing listener to avoid duplicates
superwallCustomerInfoJob?.cancel()

// Refetch the customer info on load
Purchases.sharedInstance.getCustomerInfoWith {
if (hasAnyActiveEntitlements(it)) {
setSubscriptionStatus(
SubscriptionStatus.Active(
it.entitlements.active
.map {
Entitlement(it.key, Entitlement.Type.SERVICE_LEVEL)
}.toSet(),
),
)
} else {
setSubscriptionStatus(SubscriptionStatus.Inactive)
}
Purchases.sharedInstance.getCustomerInfoWith { rcCustomerInfo ->
updateSubscriptionStatus(rcCustomerInfo)
}

// Listen to Superwall customerInfo changes (for web entitlements)
superwallCustomerInfoJob =
scope.launch {
Superwall.instance.customerInfo.collect {
// When Superwall's customerInfo changes, re-fetch RC state and merge
Purchases.sharedInstance.getCustomerInfoWith { rcCustomerInfo ->
updateSubscriptionStatus(rcCustomerInfo)
}
}
}
}

/**
* Callback for rc customer updated info
* Callback for RC customer updated info
*/
override fun onReceived(customerInfo: CustomerInfo) {
if (hasAnyActiveEntitlements(customerInfo)) {
setSubscriptionStatus(
SubscriptionStatus.Active(
customerInfo.entitlements.active
.map {
Entitlement(it.key, Entitlement.Type.SERVICE_LEVEL)
}.toSet(),
),
)
updateSubscriptionStatus(customerInfo)
}

/**
* Merges RevenueCat entitlements with Superwall web entitlements and updates subscription status
*/
private fun updateSubscriptionStatus(rcCustomerInfo: CustomerInfo) {
val rcEntitlements =
rcCustomerInfo.entitlements.active
.map { Entitlement(it.key, Entitlement.Type.SERVICE_LEVEL) }
.toSet()

// Merge with web entitlements from Superwall
val webEntitlements =
if (Superwall.initialized) {
Superwall.instance.entitlements.web
} else {
emptySet()
}

val allEntitlements = rcEntitlements + webEntitlements

if (allEntitlements.isNotEmpty()) {
setSubscriptionStatus(SubscriptionStatus.Active(allEntitlements))
} else {
setSubscriptionStatus(SubscriptionStatus.Inactive)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,23 @@ import com.superwall.sdk.utilities.DateUtils
import com.superwall.sdk.utilities.dateFormat
import kotlinx.serialization.KSerializer
import kotlinx.serialization.Serializer
import kotlinx.serialization.descriptors.PrimitiveKind
import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor
import kotlinx.serialization.descriptors.SerialDescriptor
import kotlinx.serialization.encoding.Decoder
import kotlinx.serialization.encoding.Encoder
import kotlinx.serialization.json.JsonDecoder
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.longOrNull
import java.text.SimpleDateFormat
import java.util.*

@kotlinx.serialization.ExperimentalSerializationApi
@Serializer(forClass = Date::class)
object DateSerializer : KSerializer<Date> {
override val descriptor: SerialDescriptor =
PrimitiveSerialDescriptor("Date", PrimitiveKind.STRING)

private val dateFormat =
object : ThreadLocal<SimpleDateFormat>() {
override fun initialValue() =
Expand All @@ -20,17 +29,57 @@ object DateSerializer : KSerializer<Date> {
}
}

// Date formats to try when deserializing, in order of preference
private val dateFormats =
object : ThreadLocal<List<SimpleDateFormat>>() {
override fun initialValue() =
listOf(
DateUtils.ISO_MILLIS,
DateUtils.ISO_MILLIS + "'Z'",
DateUtils.ISO_SECONDS,
DateUtils.ISO_SECONDS_TIMEZONE,
).map { format ->
dateFormat(format).apply {
timeZone = TimeZone.getTimeZone("UTC")
}
}
}

override fun serialize(
encoder: Encoder,
value: Date,
) {
encoder.encodeString(dateFormat.get()!!.format(value))
}

override fun deserialize(decoder: Decoder): Date =
try {
dateFormat.get()!!.parse(decoder.decodeString())
} catch (e: Throwable) {
throw IllegalArgumentException("Invalid date format", e)
}!!
override fun deserialize(decoder: Decoder): Date {
// Handle both JSON number (epoch millis) and string formats
val jsonDecoder = decoder as? JsonDecoder
if (jsonDecoder != null) {
val element = jsonDecoder.decodeJsonElement()
if (element is JsonPrimitive) {
// Try parsing as epoch milliseconds first (number)
element.longOrNull?.let { millis ->
return Date(millis)
}
// Fall back to string parsing
val dateString = element.content
return parseStringDate(dateString)
}
}
// Fallback for non-JSON decoders
val dateString = decoder.decodeString()
return parseStringDate(dateString)
}

private fun parseStringDate(dateString: String): Date {
for (format in dateFormats.get()!!) {
try {
return format.parse(dateString)!!
} catch (e: Throwable) {
// Try next format
}
}
throw IllegalArgumentException("Invalid date format: $dateString")
}
}
Original file line number Diff line number Diff line change
@@ -1,25 +1,38 @@
package com.superwall.sdk.store.abstractions.product.receipt

import kotlinx.serialization.SerialName
import kotlinx.serialization.KSerializer
import kotlinx.serialization.Serializable
import kotlinx.serialization.descriptors.PrimitiveKind
import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor
import kotlinx.serialization.descriptors.SerialDescriptor
import kotlinx.serialization.encoding.Decoder
import kotlinx.serialization.encoding.Encoder

@Serializable
@Serializable(with = LatestSubscriptionStateSerializer::class)
enum class LatestSubscriptionState {
@SerialName("GRACE_PERIOD")
GRACE_PERIOD,

@SerialName("EXPIRED")
EXPIRED,

@SerialName("SUBSCRIBED")
SUBSCRIBED,

@SerialName("BILLING_RETRY")
BILLING_RETRY,

@SerialName("REVOKED")
REVOKED,

@SerialName("UNKNOWN")
UNKNOWN,
}

object LatestSubscriptionStateSerializer : KSerializer<LatestSubscriptionState> {
override val descriptor: SerialDescriptor =
PrimitiveSerialDescriptor("LatestSubscriptionState", PrimitiveKind.STRING)

override fun serialize(
encoder: Encoder,
value: LatestSubscriptionState,
) {
encoder.encodeString(value.name)
}

override fun deserialize(decoder: Decoder): LatestSubscriptionState {
val value = decoder.decodeString()
return LatestSubscriptionState.entries.find {
it.name.equals(value, ignoreCase = true)
} ?: LatestSubscriptionState.UNKNOWN
}
}
Loading
Loading