From 976780059311cb7b067c131f6c3e887692ce02fd Mon Sep 17 00:00:00 2001 From: Leonardo Bilck Date: Tue, 6 Oct 2026 12:03:01 -0300 Subject: [PATCH 1/5] Fix Android subscription status entitlements --- CHANGELOG.md | 1 + .../kotlin/com/superwall/sdk/unity/SuperwallUnityBridge.kt | 5 ++++- 2 files changed, 5 insertions(+), 1 deletion(-) 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..5f3e24d 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 @@ -87,7 +87,10 @@ class SuperwallUnityBridge { fun serializeSubscriptionStatus(status: SubscriptionStatus) = JSONObject().apply { when (status) { - is SubscriptionStatus.Active -> put("type", "active") + is SubscriptionStatus.Active -> { + put("type", "active") + put("entitlements", serializeEntitlementSet(status.entitlements)) + } is SubscriptionStatus.Inactive -> put("type", "inactive") is SubscriptionStatus.Unknown -> put("type", "unknown") } From 34887e8f41b4d61bfa4af46a21f23a1ff737ce97 Mon Sep 17 00:00:00 2001 From: Leonardo Bilck Date: Tue, 6 Oct 2026 12:19:03 -0300 Subject: [PATCH 2/5] Move subscription status serialization into bridge scope --- .../sdk/unity/SuperwallUnityBridge.kt | 21 ++++++++++--------- 1 file changed, 11 insertions(+), 10 deletions(-) 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 5f3e24d..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,16 +85,6 @@ class SuperwallUnityBridge { } catch (_: Exception) {} } - 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 configure(apiKey: String, optionsJson: String?, hasPurchaseController: Boolean, completionCallbackId: String?) { @@ -247,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) { From 87026a734cdd16089ae3f7804502721e3bbca5bf Mon Sep 17 00:00:00 2001 From: Leonardo Bilck Date: Thu, 29 Oct 2026 00:43:25 -0300 Subject: [PATCH 3/5] Add JVM contract tests for the Android bridge's subscription status JSON The Android harness gains a test source set (ci~/android-harness/bridge-tests, nothing test-only ships in the package) and the native workflow runs it on both AGP versions. The first test pins what getSubscriptionStatus() hands to C#: an active status carries every entitlement in the full entitlement shape, and inactive/unknown carry no entitlements key. Removing the entitlements from the serializer fails two of the three tests. The harness also keeps the androidlib's build outputs under ci~/android-harness/build instead of inside Plugins/Android/SuperwallSDK.androidlib, and accepts -PbuildToolsVersion for machines without AGP's default build-tools. --- .github/workflows/native.yml | 4 ++ .../SubscriptionStatusSerializationTest.kt | 65 +++++++++++++++++++ ci~/android-harness/build.gradle | 28 ++++++++ 3 files changed, 97 insertions(+) create mode 100644 ci~/android-harness/bridge-tests/src/test/kotlin/com/superwall/sdk/unity/SubscriptionStatusSerializationTest.kt diff --git a/.github/workflows/native.yml b/.github/workflows/native.yml index 39a83ce..8bb7a8f 100644 --- a/.github/workflows/native.yml +++ b/.github/workflows/native.yml @@ -72,3 +72,7 @@ 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:testReleaseUnitTest -PagpVersion=${{ matrix.agp }} --no-daemon --stacktrace 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..fbae4aa 100644 --- a/ci~/android-harness/build.gradle +++ b/ci~/android-harness/build.gradle @@ -21,3 +21,31 @@ 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:testReleaseUnitTest +// 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 { + sourceSets.test.java.srcDirs += rootProject.file('bridge-tests/src/test/kotlin') + 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') + } + } +} From f0b264a1045a25722b58873548233af64dbc4024 Mon Sep 17 00:00:00 2001 From: Leonardo Bilck Date: Thu, 29 Oct 2026 00:56:25 -0300 Subject: [PATCH 4/5] Run the bridge JVM tests as testDebugUnitTest AGP 9 creates unit-test tasks only for the tested build type (debug) by default, so testReleaseUnitTest did not exist on the AGP 9.0.1 job. --- .github/workflows/native.yml | 2 +- ci~/android-harness/build.gradle | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/native.yml b/.github/workflows/native.yml index 8bb7a8f..60409a4 100644 --- a/.github/workflows/native.yml +++ b/.github/workflows/native.yml @@ -75,4 +75,4 @@ jobs: - name: Bridge JSON contract tests (JVM) working-directory: ci~/android-harness - run: gradle :SuperwallSDK:testReleaseUnitTest -PagpVersion=${{ matrix.agp }} --no-daemon --stacktrace + run: gradle :SuperwallSDK:testDebugUnitTest -PagpVersion=${{ matrix.agp }} --no-daemon --stacktrace diff --git a/ci~/android-harness/build.gradle b/ci~/android-harness/build.gradle index fbae4aa..32658a1 100644 --- a/ci~/android-harness/build.gradle +++ b/ci~/android-harness/build.gradle @@ -23,7 +23,7 @@ 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:testReleaseUnitTest +// 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. From 11873205cca750dc90897aa711e01503d7404779 Mon Sep 17 00:00:00 2001 From: Leonardo Bilck Date: Thu, 29 Oct 2026 01:05:08 -0300 Subject: [PATCH 5/5] Register the bridge test sources so AGP 9 runs them, and fail CI when no test ran On AGP 9 the srcDirs reassignment was ignored and testDebugUnitTest went NO-SOURCE, a green job that ran nothing. srcDir() (plus the Kotlin source set where the DSL has one) works on AGP 8.5.2 and 9.0.1 (7 tests each, checked locally). The CI step now also requires the JUnit result files. --- .github/workflows/native.yml | 5 ++++- ci~/android-harness/build.gradle | 8 +++++++- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/.github/workflows/native.yml b/.github/workflows/native.yml index 60409a4..b97ba62 100644 --- a/.github/workflows/native.yml +++ b/.github/workflows/native.yml @@ -75,4 +75,7 @@ jobs: - name: Bridge JSON contract tests (JVM) working-directory: ci~/android-harness - run: gradle :SuperwallSDK:testDebugUnitTest -PagpVersion=${{ matrix.agp }} --no-daemon --stacktrace + 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/ci~/android-harness/build.gradle b/ci~/android-harness/build.gradle index 32658a1..50fedda 100644 --- a/ci~/android-harness/build.gradle +++ b/ci~/android-harness/build.gradle @@ -30,7 +30,13 @@ project(':SuperwallSDK') { layout.buildDirectory = rootProject.layout.projectDirectory.dir('build/SuperwallSDK') pluginManager.withPlugin('com.android.library') { android { - sourceSets.test.java.srcDirs += rootProject.file('bridge-tests/src/test/kotlin') + // 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 {