From 92a64219833bd11f9e30030eff9a9f5b1b6e1d84 Mon Sep 17 00:00:00 2001 From: Leonardo Bilck Date: Thu, 3 Sep 2026 22:18:54 -0300 Subject: [PATCH] Serialize every Entitlement field on both native bridges `serializeEntitlement` on iOS and Android emitted four keys: `id`, a hardcoded `type`, `isActive` and `productIds`. The C# `Entitlement` model has thirteen fields and `BridgeCallbackHandler.DeserializeEntitlement` already reads all of them, so `LatestProductId`, `Store`, `StartsAt`, `RenewedAt`, `ExpiresAt`, `IsLifetime`, `WillRenew`, `State` and `OfferType` were permanently null. A consumer could not tell a trial from a paid subscription, nor read an expiry or renewal state, from the entitlement it was handed. Both bridges now emit the full model with one vocabulary: enum values as the case names the C# enums parse case-insensitively (`playStore`, `inGracePeriod`, `trial`), dates as epoch milliseconds, and optional fields omitted rather than sent as null. On Android the SDK's `GRACE_PERIOD` / `BILLING_RETRY` names are mapped to the C# `InGracePeriod` / `InBillingRetryPeriod`; `UNKNOWN` is dropped. The Android-only offer types `SUBSCRIPTION` and `REVOKED` are sent under their own names and parse to null on the C# side, which has no member for them. Field names were checked against SuperwallKit 4.16.3 (`Entitlement.swift`) and superwall-android 2.7.23 (`Entitlement.kt`). Co-Authored-By: Claude Fable 5.1 --- CHANGELOG.md | 5 ++ .../sdk/unity/SuperwallUnityBridge.kt | 55 ++++++++++++++++++- Plugins/iOS/SuperwallUnityBridge.swift | 42 +++++++++++++- 3 files changed, 99 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ffc0bc6..efb1cd0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,11 @@ # Changelog 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). + ## [0.2.6] ### Dependencies 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 03b892a..b1c7a3b 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]] {