Skip to content
Merged
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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ All notable changes to this package will be documented in this file.
### Fixes
* Both native bridges serialized only 4 of the 13 fields of an `Entitlement` (`id`, a hardcoded `type`, `isActive`, `productIds`), so `LatestProductId`, `Store`, `StartsAt`, `RenewedAt`, `ExpiresAt`, `IsLifetime`, `WillRenew`, `State` and `OfferType` were always null on the C# side even though `DeserializeEntitlement` reads them. A consumer could not tell a trial from a paid subscription, nor read an expiry or renewal state, from the entitlement. Every field is now emitted on iOS and Android with one shared vocabulary (enum case names matching the C# enums, dates as epoch milliseconds).
* `SuperwallEventInfo.EventType` was never populated: iOS serialized `String(describing: event)`, which on SuperwallKit versions without a `CustomStringConvertible` conformance dumps the case together with its associated values (`transactionComplete(transaction: ..., product: ...)`), and Android sends the snake_case `rawName` (`transaction_complete`). Neither matched the `EventType` enum, so every delegate event arrived as the default `FirstSeen` and any `switch` on it was dead. iOS now emits only the case name, and the C# parser accepts both camelCase and snake_case, logging a warning instead of failing silently when a name is still unknown. `EventType` gains the members both native SDKs already emit but C# lacked (`PaywallPageView`, `TestModeModalOpen`/`Close`, `CelExpressionResult`, `ErrorThrown`, `ReviewGranted`/`Denied`, `AttributionMatch`, `StripeCheckoutStart`/`Submit`/`Complete`/`Fail`), and the two placement names whose member is spelled differently (`deepLink_open` → `DeepLink`, `paywallWebviewLoad_processTerminated` → `PaywallWebviewProcessTerminated`) are mapped explicitly.
* `SuperwallEventInfo.Product`, `.Transaction`, `.PaywallInfo`, `.Error` and `.RestoreType` were never populated on either platform: the bridges forwarded only `eventType` and the flattened `params`. The store transaction (and with it the store transaction id) was therefore unreachable from `handleSuperwallEvent`. Both bridges now serialize the associated values of the paywall and transaction events (`paywallOpen/Close/Decline`, `transactionStart/Abandon/Complete/Fail/Restore/Timeout`, `subscriptionStart`, `freeTrialStart`, `nonRecurringProductPurchase`), and the C# side reads them. Transaction dates are ISO 8601 strings, matching the existing `StoreTransaction` model.

## [0.2.6]

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,10 +30,19 @@ import com.superwall.sdk.paywall.presentation.register
import com.superwall.sdk.paywall.presentation.dismissSync
import com.superwall.sdk.paywall.presentation.get_presentation_result.getPresentationResult
import com.superwall.sdk.paywall.presentation.result.PresentationResult
import com.superwall.sdk.analytics.superwall.SuperwallEvent
import com.superwall.sdk.analytics.superwall.SuperwallEventInfo
import java.net.URI
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
import java.util.TimeZone
import com.superwall.sdk.models.internal.RedemptionResult
import com.superwall.sdk.store.abstractions.product.StoreProduct
import com.superwall.sdk.store.abstractions.product.StoreProductType
import com.superwall.sdk.store.abstractions.transactions.StoreTransaction
import com.superwall.sdk.store.abstractions.transactions.StoreTransactionType
import com.superwall.sdk.store.transactions.RestoreType
import kotlinx.coroutines.*
import kotlin.time.Duration.Companion.seconds
import org.json.JSONArray
Expand Down Expand Up @@ -756,6 +765,92 @@ class SuperwallUnityBridge {
set.forEach { put(serializeEntitlement(it)) }
}

// java.time needs API 26 and this module's minSdk is 25, hence SimpleDateFormat.
private val iso8601Formatter = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'", Locale.US).apply {
timeZone = TimeZone.getTimeZone("UTC")
}

private fun iso8601String(date: Date): String = iso8601Formatter.format(date)

// Mirrors the C# `StoreTransaction` model (Runtime/Models/StoreTransaction.cs). Dates are ISO 8601
// strings, matching the model's string fields. Optional values are omitted rather than sent as null.
private fun serializeStoreTransaction(t: StoreTransactionType) = JSONObject().apply {
if (t is StoreTransaction) {
put("configRequestId", t.configRequestId)
put("appSessionId", t.appSessionId)
}
t.transactionDate?.let { put("transactionDate", iso8601String(it)) }
t.originalTransactionIdentifier?.let { put("originalTransactionIdentifier", it) }
t.storeTransactionId?.let { put("storeTransactionId", it) }
t.originalTransactionDate?.let { put("originalTransactionDate", iso8601String(it)) }
t.webOrderLineItemID?.let { put("webOrderLineItemID", it) }
t.appBundleId?.let { put("appBundleId", it) }
t.subscriptionGroupId?.let { put("subscriptionGroupId", it) }
t.isUpgraded?.let { put("isUpgraded", it) }
t.expirationDate?.let { put("expirationDate", iso8601String(it)) }
t.offerId?.let { put("offerId", it) }
t.revocationDate?.let { put("revocationDate", iso8601String(it)) }
}

// Events carry the `StoreProductType` interface; the full serializer needs the concrete class.
private fun serializeStoreProductType(product: StoreProductType): JSONObject =
(product as? StoreProduct)?.let { serializeStoreProduct(it) }
?: JSONObject().put("productIdentifier", product.productIdentifier)

// The fields of a `SuperwallEvent` are what a consumer needs to act on a purchase (the product,
// the store transaction, the paywall). `params` carries flattened analytics values only, so
// without this the C# `SuperwallEventInfo.Product` / `.Transaction` / `.PaywallInfo` were always
// null. Keys match the fields `BridgeCallbackHandler.DeserializeSuperwallEventInfo` reads.
private fun appendEventPayload(event: SuperwallEvent, json: JSONObject) {
when (event) {
is SuperwallEvent.PaywallOpen -> json.put("paywallInfo", serializePaywallInfo(event.paywallInfo))
is SuperwallEvent.PaywallClose -> json.put("paywallInfo", serializePaywallInfo(event.paywallInfo))
is SuperwallEvent.PaywallDecline -> json.put("paywallInfo", serializePaywallInfo(event.paywallInfo))
is SuperwallEvent.TransactionTimeout -> json.put("paywallInfo", serializePaywallInfo(event.paywallInfo))
is SuperwallEvent.TransactionStart -> {
json.put("product", serializeStoreProductType(event.product))
json.put("paywallInfo", serializePaywallInfo(event.paywallInfo))
}
is SuperwallEvent.TransactionAbandon -> {
json.put("product", serializeStoreProductType(event.product))
json.put("paywallInfo", serializePaywallInfo(event.paywallInfo))
}
is SuperwallEvent.TransactionComplete -> {
event.transaction?.let { json.put("transaction", serializeStoreTransaction(it)) }
json.put("product", serializeStoreProductType(event.product))
json.put("paywallInfo", serializePaywallInfo(event.paywallInfo))
}
is SuperwallEvent.SubscriptionStart -> {
json.put("product", serializeStoreProduct(event.product))
json.put("paywallInfo", serializePaywallInfo(event.paywallInfo))
}
is SuperwallEvent.FreeTrialStart -> {
json.put("product", serializeStoreProduct(event.product))
json.put("paywallInfo", serializePaywallInfo(event.paywallInfo))
}
is SuperwallEvent.TransactionFail -> {
json.put("error", event.error.message ?: "")
json.put("paywallInfo", serializePaywallInfo(event.paywallInfo))
}
is SuperwallEvent.TransactionRestore -> {
when (val restoreType = event.restoreType) {
is RestoreType.ViaPurchase -> {
json.put("restoreType", "viaPurchase")
restoreType.transaction?.let { json.put("transaction", serializeStoreTransaction(it)) }
}
is RestoreType.ViaRestore -> json.put("restoreType", "viaRestore")
}
json.put("paywallInfo", serializePaywallInfo(event.paywallInfo))
}
is SuperwallEvent.NonRecurringProductPurchase -> {
// `TransactionProduct` is a slimmer type than `StoreProduct`; the identifier is what it shares.
json.put("product", JSONObject().put("productIdentifier", event.product.id))
json.put("paywallInfo", serializePaywallInfo(event.paywallInfo))
}
else -> {}
}
}

private fun serializeCustomerInfo(info: CustomerInfo) = JSONObject().apply {
put("userId", info.userId)
put("entitlements", JSONArray().apply { info.entitlements.forEach { put(serializeEntitlement(it)) } })
Expand Down Expand Up @@ -930,6 +1025,7 @@ class SuperwallUnityBridge {
sendToUnity("handleSuperwallEvent", JSONObject().apply {
put("eventType", withInfo.event.rawName)
put("params", JSONObject(withInfo.params))
appendEventPayload(withInfo.event, this)
})
}

Expand Down
98 changes: 98 additions & 0 deletions Plugins/iOS/SuperwallUnityBridge.swift
Original file line number Diff line number Diff line change
Expand Up @@ -326,6 +326,103 @@ private func serializeSubscriptionStatus(_ status: SubscriptionStatus) -> [Strin
}
}

private func iso8601String(_ date: Date) -> String {
return ISO8601DateFormatter().string(from: date)
}

// Mirrors the C# `StoreTransaction` model (Runtime/Models/StoreTransaction.cs). Dates are ISO 8601
// strings, matching the model's string fields. Optional values are omitted rather than sent as null.
private func serializeStoreTransaction(_ transaction: StoreTransaction) -> [String: Any] {
var dict: [String: Any] = [
"configRequestId": transaction.configRequestId,
"appSessionId": transaction.appSessionId,
"originalTransactionIdentifier": transaction.originalTransactionIdentifier
]
if let transactionDate = transaction.transactionDate {
dict["transactionDate"] = iso8601String(transactionDate)
}
if let storeTransactionId = transaction.storeTransactionId {
dict["storeTransactionId"] = storeTransactionId
}
if let originalTransactionDate = transaction.originalTransactionDate {
dict["originalTransactionDate"] = iso8601String(originalTransactionDate)
}
if let webOrderLineItemID = transaction.webOrderLineItemID {
dict["webOrderLineItemID"] = webOrderLineItemID
}
if let appBundleId = transaction.appBundleId {
dict["appBundleId"] = appBundleId
}
if let subscriptionGroupId = transaction.subscriptionGroupId {
dict["subscriptionGroupId"] = subscriptionGroupId
}
if let isUpgraded = transaction.isUpgraded {
dict["isUpgraded"] = isUpgraded
}
if let expirationDate = transaction.expirationDate {
dict["expirationDate"] = iso8601String(expirationDate)
}
if let offerId = transaction.offerId {
dict["offerId"] = offerId
}
if let revocationDate = transaction.revocationDate {
dict["revocationDate"] = iso8601String(revocationDate)
}
return dict
}

// The associated values of a `SuperwallEvent` are what a consumer needs to act on a purchase
// (the product, the store transaction, the paywall). `params` carries flattened analytics values
// only, so without this the C# `SuperwallEventInfo.Product` / `.Transaction` / `.PaywallInfo` were
// always null. Keys match the fields `BridgeCallbackHandler.DeserializeSuperwallEventInfo` reads.
private func appendEventPayload(_ event: SuperwallEvent, to dict: inout [String: Any]) {
switch event {
case .paywallOpen(let paywallInfo),
.paywallClose(let paywallInfo),
.paywallDecline(let paywallInfo),
.transactionTimeout(let paywallInfo):
dict["paywallInfo"] = serializePaywallInfo(paywallInfo)

case .transactionStart(let product, let paywallInfo),
.transactionAbandon(let product, let paywallInfo),
.subscriptionStart(let product, let paywallInfo),
.freeTrialStart(let product, let paywallInfo):
dict["product"] = serializeStoreProduct(product)
dict["paywallInfo"] = serializePaywallInfo(paywallInfo)

case .transactionComplete(let transaction, let product, _, let paywallInfo):
if let transaction = transaction {
dict["transaction"] = serializeStoreTransaction(transaction)
}
dict["product"] = serializeStoreProduct(product)
dict["paywallInfo"] = serializePaywallInfo(paywallInfo)

case .transactionFail(let error, let paywallInfo):
dict["error"] = error.localizedDescription
dict["paywallInfo"] = serializePaywallInfo(paywallInfo)

case .transactionRestore(let restoreType, let paywallInfo):
switch restoreType {
case .viaPurchase(let transaction):
dict["restoreType"] = "viaPurchase"
if let transaction = transaction {
dict["transaction"] = serializeStoreTransaction(transaction)
}
case .viaRestore:
dict["restoreType"] = "viaRestore"
}
dict["paywallInfo"] = serializePaywallInfo(paywallInfo)

case .nonRecurringProductPurchase(let product, let paywallInfo):
// `TransactionProduct` is a slimmer type than `StoreProduct`; the identifier is what it shares.
dict["product"] = ["productIdentifier": product.id]
dict["paywallInfo"] = serializePaywallInfo(paywallInfo)

default:
break
}
}

private func serializeEventInfo(_ info: SuperwallEventInfo) -> [String: Any] {
var dict: [String: Any] = [
"params": info.params
Expand All @@ -342,6 +439,7 @@ private func serializeEventInfo(_ info: SuperwallEventInfo) -> [String: Any] {
// which also normalises Android's snake_case `rawName`.
let described = String(describing: info.event)
dict["eventType"] = String(described.prefix(while: { $0 != "(" }))
appendEventPayload(info.event, to: &dict)
return dict
}

Expand Down
37 changes: 37 additions & 0 deletions Runtime/Internal/BridgeCallbackHandler.cs
Original file line number Diff line number Diff line change
Expand Up @@ -573,9 +573,46 @@ private static SuperwallEventInfo DeserializeSuperwallEventInfo(Dictionary<strin
var paramsDict = GetDict(data, "params");
info.Params = paramsDict;

// Populated by the native bridges only for the events that carry them (paywall open/close/
// decline, the transaction family). Absent keys leave the fields null.
info.PaywallInfo = DeserializePaywallInfo(GetDict(data, "paywallInfo"));

var productDict = GetDict(data, "product");
if (productDict != null)
info.Product = Superwall.DeserializeStoreProduct(productDict);

var transactionDict = GetDict(data, "transaction");
if (transactionDict != null)
info.Transaction = DeserializeStoreTransaction(transactionDict);

info.Error = GetString(data, "error");
info.RestoreType = GetString(data, "restoreType");

return info;
}

private static StoreTransaction DeserializeStoreTransaction(Dictionary<string, object> data)
{
if (data == null) return null;

var transaction = new StoreTransaction();
transaction.ConfigRequestId = GetString(data, "configRequestId");
transaction.AppSessionId = GetString(data, "appSessionId");
transaction.TransactionDate = GetString(data, "transactionDate");
transaction.OriginalTransactionIdentifier = GetString(data, "originalTransactionIdentifier");
transaction.StoreTransactionId = GetString(data, "storeTransactionId");
transaction.OriginalTransactionDate = GetString(data, "originalTransactionDate");
transaction.WebOrderLineItemID = GetString(data, "webOrderLineItemID");
transaction.AppBundleId = GetString(data, "appBundleId");
transaction.SubscriptionGroupId = GetString(data, "subscriptionGroupId");
if (data.ContainsKey("isUpgraded") && data["isUpgraded"] != null)
transaction.IsUpgraded = GetBool(data, "isUpgraded");
transaction.ExpirationDate = GetString(data, "expirationDate");
transaction.OfferId = GetString(data, "offerId");
transaction.RevocationDate = GetString(data, "revocationDate");
return transaction;
}

private static RedemptionResult DeserializeRedemptionResult(Dictionary<string, object> data)
{
if (data == null) return null;
Expand Down
3 changes: 2 additions & 1 deletion Runtime/Superwall.cs
Original file line number Diff line number Diff line change
Expand Up @@ -735,7 +735,8 @@ private static double ToDoubleInvariant(object value)
}
}

private static StoreProduct DeserializeStoreProduct(Dictionary<string, object> dict)
// Also used by BridgeCallbackHandler for the `product` carried on delegate events.
internal static StoreProduct DeserializeStoreProduct(Dictionary<string, object> dict)
{
if (dict == null) return new StoreProduct();
var p = new StoreProduct();
Expand Down
Loading