Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .github/workflows/native.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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?) {
Expand Down Expand Up @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
@@ -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"))
}
}
34 changes: 34 additions & 0 deletions ci~/android-harness/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -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')
}
}
}
Loading