diff --git a/CHANGELOG.md b/CHANGELOG.md index 9637fbf..4b0819a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ All notable changes to this package will be documented in this file. ## [Unreleased] ### 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. ## [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 619ea2f..9bb77e2 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 @@ -18,6 +18,9 @@ import com.superwall.sdk.identity.setUserAttributes import com.superwall.sdk.logger.LogLevel import com.superwall.sdk.logger.LogScope import com.superwall.sdk.models.entitlements.Entitlement +import com.superwall.sdk.models.product.Store +import com.superwall.sdk.store.abstractions.product.receipt.LatestPeriodType +import com.superwall.sdk.store.abstractions.product.receipt.LatestSubscriptionState import com.superwall.sdk.models.entitlements.SubscriptionStatus import com.superwall.sdk.models.triggers.Experiment import com.superwall.sdk.models.customer.CustomerInfo @@ -692,11 +695,61 @@ class SuperwallUnityBridge { }) } + // Mirrors every field of the C# `Entitlement` model (see Runtime/Models/Entitlement.cs), which + // `BridgeCallbackHandler.DeserializeEntitlement` already reads. Enum values are emitted under the + // C# member names (parsed case-insensitively), so the wire shape matches the iOS bridge and a + // consumer sees one vocabulary. Dates are epoch milliseconds. Optional fields are omitted rather + // than sent as null so the C# nullable stays null. private fun serializeEntitlement(e: Entitlement) = JSONObject().apply { put("id", e.id) - put("type", "serviceLevel") + put("type", serializeEntitlementType(e.type)) put("isActive", e.isActive) put("productIds", JSONArray(e.productIds.toList())) + e.latestProductId?.let { put("latestProductId", it) } + e.store?.let { put("store", serializeStore(it)) } + e.startsAt?.let { put("startsAt", it.time) } + e.renewedAt?.let { put("renewedAt", it.time) } + e.expiresAt?.let { put("expiresAt", it.time) } + e.isLifetime?.let { put("isLifetime", it) } + e.willRenew?.let { put("willRenew", it) } + serializeSubscriptionState(e.state)?.let { put("state", it) } + serializeOfferType(e.offerType)?.let { put("offerType", it) } + } + + private fun serializeEntitlementType(type: Entitlement.Type) = when (type) { + Entitlement.Type.SERVICE_LEVEL -> "serviceLevel" + } + + private fun serializeStore(store: Store) = when (store) { + Store.PLAY_STORE -> "playStore" + Store.APP_STORE -> "appStore" + Store.STRIPE -> "stripe" + Store.PADDLE -> "paddle" + Store.SUPERWALL -> "superwall" + Store.OTHER -> "other" + } + + // `UNKNOWN` has no C# counterpart and is dropped, leaving `Entitlement.State` null. + private fun serializeSubscriptionState(state: LatestSubscriptionState?) = when (state) { + LatestSubscriptionState.GRACE_PERIOD -> "inGracePeriod" + LatestSubscriptionState.SUBSCRIBED -> "subscribed" + LatestSubscriptionState.EXPIRED -> "expired" + LatestSubscriptionState.BILLING_RETRY -> "inBillingRetryPeriod" + LatestSubscriptionState.REVOKED -> "revoked" + LatestSubscriptionState.UNKNOWN, null -> null + } + + // The C# `LatestSubscriptionOfferType` has Trial, Code, Promotional and Winback. The remaining + // Android values are sent through under their own names; the C# parser leaves `OfferType` null + // for them rather than mislabelling a paid period as an offer. + private fun serializeOfferType(type: LatestPeriodType?) = when (type) { + LatestPeriodType.TRIAL -> "trial" + LatestPeriodType.CODE -> "code" + LatestPeriodType.PROMOTIONAL -> "promotional" + LatestPeriodType.WINBACK -> "winback" + LatestPeriodType.SUBSCRIPTION -> "subscription" + LatestPeriodType.REVOKED -> "revoked" + null -> null } private fun serializeEntitlementSet(set: Set) = JSONArray().apply { diff --git a/Plugins/iOS/SuperwallUnityBridge.swift b/Plugins/iOS/SuperwallUnityBridge.swift index 1029555..b358cba 100644 --- a/Plugins/iOS/SuperwallUnityBridge.swift +++ b/Plugins/iOS/SuperwallUnityBridge.swift @@ -157,13 +157,51 @@ private func serializeExperiment(_ experiment: Experiment?) -> [String: Any]? { ] } +private func epochMilliseconds(_ date: Date) -> Int64 { + return Int64((date.timeIntervalSince1970 * 1000).rounded()) +} + +// Mirrors every field of the C# `Entitlement` model (see Runtime/Models/Entitlement.cs), which +// `BridgeCallbackHandler.DeserializeEntitlement` already reads. Enum values are emitted as their +// case names, which the C# side parses case-insensitively (`playStore` -> `ProductStore.PlayStore`, +// `inGracePeriod` -> `LatestSubscriptionState.InGracePeriod`, `trial` -> +// `LatestSubscriptionOfferType.Trial`). Dates are epoch milliseconds. Optional fields are omitted +// rather than sent as null so the C# nullable stays null. private func serializeEntitlement(_ entitlement: Entitlement) -> [String: Any] { - return [ + var dict: [String: Any] = [ "id": entitlement.id, - "type": "serviceLevel", + "type": String(describing: entitlement.type), "isActive": entitlement.isActive, "productIds": Array(entitlement.productIds) ] + if let latestProductId = entitlement.latestProductId { + dict["latestProductId"] = latestProductId + } + if let store = entitlement.store { + dict["store"] = String(describing: store) + } + if let startsAt = entitlement.startsAt { + dict["startsAt"] = epochMilliseconds(startsAt) + } + if let renewedAt = entitlement.renewedAt { + dict["renewedAt"] = epochMilliseconds(renewedAt) + } + if let expiresAt = entitlement.expiresAt { + dict["expiresAt"] = epochMilliseconds(expiresAt) + } + if let isLifetime = entitlement.isLifetime { + dict["isLifetime"] = isLifetime + } + if let willRenew = entitlement.willRenew { + dict["willRenew"] = willRenew + } + if let state = entitlement.state { + dict["state"] = state.rawValue + } + if let offerType = entitlement.offerType { + dict["offerType"] = offerType.rawValue + } + return dict } private func serializeEntitlements(_ entitlements: Set) -> [[String: Any]] {