diff --git a/.github/workflows/native.yml b/.github/workflows/native.yml index 39a83ce..b97ba62 100644 --- a/.github/workflows/native.yml +++ b/.github/workflows/native.yml @@ -72,3 +72,10 @@ jobs: - name: assembleRelease working-directory: ci~/android-harness run: gradle :SuperwallSDK:assembleRelease -PagpVersion=${{ matrix.agp }} --no-daemon --stacktrace + + - name: Bridge JSON contract tests (JVM) + working-directory: ci~/android-harness + run: | + gradle :SuperwallSDK:testDebugUnitTest -PagpVersion=${{ matrix.agp }} --no-daemon --stacktrace + # NO-SOURCE is a green build that ran nothing; require the result files. + ls build/SuperwallSDK/test-results/testDebugUnitTest/TEST-*.xml diff --git a/CHANGELOG.md b/CHANGELOG.md index 55437ea..08a1699 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ All notable changes to this package will be documented in this file. ### Fixes * Android purchase controller requests that can't reach Unity now fail straight away instead of waiting for a reply that never comes. A request the native SDK has given up on is cancelled on the C# side and no longer starts a store transaction with no paywall on screen. A purchase controller that throws now replies `Failed`, and each request is answered at most once. +* Android `SubscriptionStatus.Active` getter serialization emitted only `{ type: "active" }` and dropped its entitlement set, although the setter accepted entitlements. Unity therefore read `Active []` after writing `Active [premium]`, causing repeated entitlement pushes and leaving the merge guard blocked. Android now serializes the active entitlement set in the same shape as iOS. * 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. 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 589e231..db64db8 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 @@ -85,13 +85,6 @@ class SuperwallUnityBridge { } catch (_: Exception) {} } - fun serializeSubscriptionStatus(status: SubscriptionStatus) = JSONObject().apply { - when (status) { - is SubscriptionStatus.Active -> put("type", "active") - is SubscriptionStatus.Inactive -> put("type", "inactive") - is SubscriptionStatus.Unknown -> put("type", "unknown") - } - } } fun configure(apiKey: String, optionsJson: String?, hasPurchaseController: Boolean, completionCallbackId: String?) { @@ -244,6 +237,17 @@ class SuperwallUnityBridge { } } + private fun serializeSubscriptionStatus(status: SubscriptionStatus) = JSONObject().apply { + when (status) { + is SubscriptionStatus.Active -> { + put("type", "active") + put("entitlements", serializeEntitlementSet(status.entitlements)) + } + is SubscriptionStatus.Inactive -> put("type", "inactive") + is SubscriptionStatus.Unknown -> put("type", "unknown") + } + } + fun getSubscriptionStatus(): String = serializeSubscriptionStatus(Superwall.instance.subscriptionStatus.value).toString() fun setSubscriptionStatus(statusJson: String) { diff --git a/ci~/android-harness/bridge-tests/src/test/kotlin/com/superwall/sdk/unity/SubscriptionStatusSerializationTest.kt b/ci~/android-harness/bridge-tests/src/test/kotlin/com/superwall/sdk/unity/SubscriptionStatusSerializationTest.kt new file mode 100644 index 0000000..5709a00 --- /dev/null +++ b/ci~/android-harness/bridge-tests/src/test/kotlin/com/superwall/sdk/unity/SubscriptionStatusSerializationTest.kt @@ -0,0 +1,65 @@ +package com.superwall.sdk.unity + +import com.superwall.sdk.models.entitlements.Entitlement +import com.superwall.sdk.models.entitlements.SubscriptionStatus +import org.json.JSONObject +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Test + +/** + * The JSON `getSubscriptionStatus()` hands to C#. `BridgeCallbackHandler.DeserializeSubscriptionStatus` reads + * `type` and, for `active`, the `entitlements` array; without the array C# reads `Active []` straight after + * `setSubscriptionStatus(Active [premium])`, which looks like a lost entitlement to any app that re-checks. + */ +class SubscriptionStatusSerializationTest { + private val bridge = SuperwallUnityBridge() + + private fun serialize(status: SubscriptionStatus): JSONObject { + val method = SuperwallUnityBridge::class.java + .getDeclaredMethod("serializeSubscriptionStatus", SubscriptionStatus::class.java) + method.isAccessible = true + return method.invoke(bridge, status) as JSONObject + } + + @Test + fun active_carriesEveryEntitlement() { + val json = serialize( + SubscriptionStatus.Active( + setOf( + Entitlement(id = "premium", isActive = true, productIds = setOf("pro.yearly")), + Entitlement(id = "extras", isActive = true), + ), + ), + ) + + assertEquals("active", json.getString("type")) + val entitlements = json.getJSONArray("entitlements") + val ids = (0 until entitlements.length()).map { entitlements.getJSONObject(it).getString("id") }.toSet() + assertEquals(setOf("premium", "extras"), ids) + } + + @Test + fun active_entitlementUsesTheFullEntitlementShape() { + val json = serialize( + SubscriptionStatus.Active(setOf(Entitlement(id = "premium", isActive = true, productIds = setOf("pro.yearly")))), + ) + + val premium = json.getJSONArray("entitlements").getJSONObject(0) + assertEquals("premium", premium.getString("id")) + assertEquals("serviceLevel", premium.getString("type")) + assertEquals(true, premium.getBoolean("isActive")) + assertEquals("pro.yearly", premium.getJSONArray("productIds").getString(0)) + } + + @Test + fun inactiveAndUnknown_haveNoEntitlementsKey() { + val inactive = serialize(SubscriptionStatus.Inactive) + val unknown = serialize(SubscriptionStatus.Unknown) + + assertEquals("inactive", inactive.getString("type")) + assertEquals("unknown", unknown.getString("type")) + assertFalse(inactive.has("entitlements")) + assertFalse(unknown.has("entitlements")) + } +} diff --git a/ci~/android-harness/build.gradle b/ci~/android-harness/build.gradle index 8cf9253..50fedda 100644 --- a/ci~/android-harness/build.gradle +++ b/ci~/android-harness/build.gradle @@ -21,3 +21,37 @@ gradle.projectsEvaluated { } } } + +// JVM unit tests for the bridge's JSON contracts, kept in this harness (bridge-tests/) so nothing test-only +// ships inside the Unity package. Run: gradle :SuperwallSDK:testDebugUnitTest +// org.json is put on the test classpath because android.jar only carries stubs of it. +project(':SuperwallSDK') { + // Build outputs stay in the harness (gitignored), never inside the shipped androidlib folder. + layout.buildDirectory = rootProject.layout.projectDirectory.dir('build/SuperwallSDK') + pluginManager.withPlugin('com.android.library') { + android { + // srcDir(), not `srcDirs +=`: AGP 9's source-set DSL ignores the reassignment and the tests went + // NO-SOURCE (green with nothing run). Kotlin dirs are registered too where the DSL has them. + def testSources = rootProject.file('bridge-tests/src/test/kotlin') + sourceSets.test.java.srcDir(testSources) + if (sourceSets.test.hasProperty('kotlin')) { + sourceSets.test.kotlin.srcDir(testSources) + } + testOptions.unitTests.returnDefaultValues = true + } + dependencies { + testImplementation project(':unity-stub') + testImplementation 'junit:junit:4.13.2' + testImplementation 'org.json:json:20240303' + } + } +} + +// Local machines without AGP's default build-tools: -PbuildToolsVersion=36.0.0 (CI installs the default). +subprojects { + pluginManager.withPlugin('com.android.library') { + if (rootProject.findProperty('buildToolsVersion')) { + android.buildToolsVersion = rootProject.findProperty('buildToolsVersion') + } + } +}