diff --git a/CHANGELOG.md b/CHANGELOG.md index 4b0819a..68a99da 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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] diff --git a/Plugins/Android/SuperwallSDK.androidlib/src/main/kotlin/com/superwall/sdk/unity/SuperwallUnityBridge.kt b/Plugins/Android/SuperwallSDK.androidlib/src/main/kotlin/com/superwall/sdk/unity/SuperwallUnityBridge.kt index 9bb77e2..3f9165f 100644 --- a/Plugins/Android/SuperwallSDK.androidlib/src/main/kotlin/com/superwall/sdk/unity/SuperwallUnityBridge.kt +++ b/Plugins/Android/SuperwallSDK.androidlib/src/main/kotlin/com/superwall/sdk/unity/SuperwallUnityBridge.kt @@ -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 @@ -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)) } }) @@ -930,6 +1025,7 @@ class SuperwallUnityBridge { sendToUnity("handleSuperwallEvent", JSONObject().apply { put("eventType", withInfo.event.rawName) put("params", JSONObject(withInfo.params)) + appendEventPayload(withInfo.event, this) }) } diff --git a/Plugins/iOS/SuperwallUnityBridge.swift b/Plugins/iOS/SuperwallUnityBridge.swift index b358cba..2242871 100644 --- a/Plugins/iOS/SuperwallUnityBridge.swift +++ b/Plugins/iOS/SuperwallUnityBridge.swift @@ -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 @@ -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 } diff --git a/Runtime/Internal/BridgeCallbackHandler.cs b/Runtime/Internal/BridgeCallbackHandler.cs index dc20933..7587763 100644 --- a/Runtime/Internal/BridgeCallbackHandler.cs +++ b/Runtime/Internal/BridgeCallbackHandler.cs @@ -573,9 +573,46 @@ private static SuperwallEventInfo DeserializeSuperwallEventInfo(Dictionary 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 data) { if (data == null) return null; diff --git a/Runtime/Superwall.cs b/Runtime/Superwall.cs index 85723af..29fbba3 100644 --- a/Runtime/Superwall.cs +++ b/Runtime/Superwall.cs @@ -735,7 +735,8 @@ private static double ToDoubleInvariant(object value) } } - private static StoreProduct DeserializeStoreProduct(Dictionary dict) + // Also used by BridgeCallbackHandler for the `product` carried on delegate events. + internal static StoreProduct DeserializeStoreProduct(Dictionary dict) { if (dict == null) return new StoreProduct(); var p = new StoreProduct();