From 1071ea6eb55a10bbc37b1006ce76beff370095ac Mon Sep 17 00:00:00 2001 From: Ian Rumac Date: Fri, 29 May 2026 10:26:20 +0200 Subject: [PATCH 1/3] 0.2.4 - Bridge callback fixes, post-build pod install, CI workflows * iOS `LocalNotification` serialization no longer references a non-existent `id` field. --- .github/workflows/native.yml | 55 ++ .github/workflows/unity.yml | 101 ++++ .gitignore | 13 + CHANGELOG.md | 21 + Editor/SuperwallPostBuildProcessor.cs | 94 +++- .../sdk/unity/SuperwallUnityBridge.kt | 20 +- ...uperwallUnityBridge-Bridging-Header.h.meta | 2 +- Plugins/iOS/SuperwallUnityBridge.swift | 33 +- Plugins/iOS/SuperwallUnityBridge.swift.meta | 2 +- Runtime/Internal/BridgeCallbackHandler.cs | 12 +- Tests/Runtime/BridgeContractTests.cs | 504 ++++++++++++++++++ Tests/Runtime/BridgeContractTests.cs.meta | 2 + ci~/android-harness/build.gradle | 21 + ci~/android-harness/gradle.properties | 4 + ci~/android-harness/settings.gradle | 24 + ci~/android-harness/unity-stub/build.gradle | 8 + .../java/com/unity3d/player/UnityPlayer.java | 15 + ci~/ios-harness/Package.swift | 29 + ci~/ios-harness/Sources/SuperwallBridge | 1 + package.json | 5 +- 20 files changed, 935 insertions(+), 31 deletions(-) create mode 100644 .github/workflows/native.yml create mode 100644 .github/workflows/unity.yml create mode 100644 Tests/Runtime/BridgeContractTests.cs create mode 100644 Tests/Runtime/BridgeContractTests.cs.meta create mode 100644 ci~/android-harness/build.gradle create mode 100644 ci~/android-harness/gradle.properties create mode 100644 ci~/android-harness/settings.gradle create mode 100644 ci~/android-harness/unity-stub/build.gradle create mode 100644 ci~/android-harness/unity-stub/src/main/java/com/unity3d/player/UnityPlayer.java create mode 100644 ci~/ios-harness/Package.swift create mode 120000 ci~/ios-harness/Sources/SuperwallBridge diff --git a/.github/workflows/native.yml b/.github/workflows/native.yml new file mode 100644 index 0000000..7c6fbf7 --- /dev/null +++ b/.github/workflows/native.yml @@ -0,0 +1,55 @@ +name: native + +on: + push: + branches: [main] + pull_request: + workflow_dispatch: + +concurrency: + group: native-${{ github.ref }} + cancel-in-progress: true + +jobs: + ios: + name: iOS bridge (SPM) + runs-on: macos-14 + steps: + - uses: actions/checkout@v4 + + - name: Select Xcode + run: sudo xcode-select -s /Applications/Xcode_15.4.app + + - name: Cache SPM + uses: actions/cache@v4 + with: + path: ci~/ios-harness/.build + key: spm-${{ runner.os }}-${{ hashFiles('ci~/ios-harness/Package.swift') }} + restore-keys: spm-${{ runner.os }}- + + - name: swift build + working-directory: ci~/ios-harness + run: swift build -c release + + android: + name: Android lib (Gradle) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: '17' + + - uses: android-actions/setup-android@v3 + with: + packages: 'platforms;android-34 build-tools;34.0.0' + + - uses: gradle/actions/setup-gradle@v3 + with: + gradle-version: '8.7' + + - name: assembleRelease + working-directory: ci~/android-harness + run: gradle :SuperwallSDK:assembleRelease --no-daemon --stacktrace diff --git a/.github/workflows/unity.yml b/.github/workflows/unity.yml new file mode 100644 index 0000000..450fa44 --- /dev/null +++ b/.github/workflows/unity.yml @@ -0,0 +1,101 @@ +# Parked: full Unity build + test pipeline. +# +# This repo is the UPM package only — there is no Assets/ or ProjectSettings/ +# here, so a Unity build needs a wrapping test project. Two ways to wire that: +# +# 1. Clone a separate Superwall-Unity-TestApp repo into ./_test-project, then +# replace its Packages/com.superwall.sdk/ with the current checkout (rsync +# or a symlink) before invoking game-ci. +# 2. Commit a minimal wrapping project to this repo under ./_test-project +# (only Assets/, ProjectSettings/, Packages/manifest.json) and reference +# com.superwall.sdk via a "file:.." path in manifest.json. +# +# Required secrets (https://game.ci/docs/github/activation): +# UNITY_LICENSE - contents of the .ulf produced by manual activation +# UNITY_EMAIL - Unity account email +# UNITY_PASSWORD - Unity account password +# +# To enable: implement the wrapping project step below, add the triggers you +# want (push / pull_request), and verify the unityVersion image exists on +# https://hub.docker.com/r/unityci/editor/tags for the editor pinned in the +# test project's ProjectSettings/ProjectVersion.txt. + +name: unity + +on: + workflow_dispatch: + +concurrency: + group: unity-${{ github.ref }} + cancel-in-progress: true + +jobs: + test: + name: EditMode + PlayMode tests + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + lfs: true + + # TODO: materialize the wrapping Unity project at ./_test-project here + # (clone or rsync), then point projectPath: _test-project on the steps below. + + - uses: actions/cache@v4 + with: + path: _test-project/Library + key: Library-test-${{ hashFiles('_test-project/Assets/**', '_test-project/Packages/**', '_test-project/ProjectSettings/**', '**/*.cs', 'Plugins/**') }} + restore-keys: Library-test- + + - uses: game-ci/unity-test-runner@v4 + env: + UNITY_LICENSE: ${{ secrets.UNITY_LICENSE }} + UNITY_EMAIL: ${{ secrets.UNITY_EMAIL }} + UNITY_PASSWORD: ${{ secrets.UNITY_PASSWORD }} + with: + projectPath: _test-project + unityVersion: auto + testMode: all + githubToken: ${{ secrets.GITHUB_TOKEN }} + + build: + name: Build ${{ matrix.targetPlatform }} + runs-on: ${{ matrix.os }} + needs: test + strategy: + fail-fast: false + matrix: + include: + - targetPlatform: Android + os: ubuntu-latest + - targetPlatform: iOS + os: macos-14 + steps: + - uses: actions/checkout@v4 + with: + lfs: true + + # TODO: same wrapping-project step as above. + + - uses: actions/cache@v4 + with: + path: _test-project/Library + key: Library-${{ matrix.targetPlatform }}-${{ hashFiles('_test-project/Assets/**', '_test-project/Packages/**', '_test-project/ProjectSettings/**', '**/*.cs', 'Plugins/**') }} + restore-keys: | + Library-${{ matrix.targetPlatform }}- + Library- + + - uses: game-ci/unity-builder@v4 + env: + UNITY_LICENSE: ${{ secrets.UNITY_LICENSE }} + UNITY_EMAIL: ${{ secrets.UNITY_EMAIL }} + UNITY_PASSWORD: ${{ secrets.UNITY_PASSWORD }} + with: + projectPath: _test-project + unityVersion: auto + targetPlatform: ${{ matrix.targetPlatform }} + + - uses: actions/upload-artifact@v4 + with: + name: build-${{ matrix.targetPlatform }} + path: _test-project/build/${{ matrix.targetPlatform }} diff --git a/.gitignore b/.gitignore index e43b0f9..f9a0453 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,14 @@ .DS_Store + +# CI harness build outputs — see ci~/ios-harness and ci~/android-harness. +# (The ci~ folder is tilde-suffixed so Unity ignores it during package import.) +ci~/ios-harness/.build/ +ci~/ios-harness/.swiftpm/ +ci~/ios-harness/Package.resolved +ci~/android-harness/.gradle/ +ci~/android-harness/build/ +ci~/android-harness/**/build/ +ci~/android-harness/local.properties + +# Parked Unity workflow materializes a wrapping project here at CI time. +_test-project/ diff --git a/CHANGELOG.md b/CHANGELOG.md index ea94a4e..08bac2f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,27 @@ # Changelog All notable changes to this package will be documented in this file. +## [0.2.4] + +### Fixes +* Fix configure result emitting an unknown error on success +* iOS `_SuperwallBridge_ShowAlert` ABI mismatch (3 pointers vs C# `(string)`) — now takes a single `alertJson` string matching the C# extern. iOS additionally fires the `onCloseCallbackId` immediately so the pending C# callback is cleaned up. +* Android `showAlert` was reading `actionCallbackId` / `closeCallbackId`, but C# emits `onActionCallbackId` / `onCloseCallbackId` — alert callbacks never fired. Fixed. +* iOS bridge: removed access to internal `Superwall.shared.options` — `SetLocalResources` now stashes the resource map and applies it via `SuperwallOptions.localResources` during `Configure` (must be called before `Configure` on iOS; logs a warning otherwise). +* Post-build `pod install` now locates `pod` across common install paths (`/usr/local/bin`, `/opt/homebrew/bin`, rbenv/asdf shims, `which pod` under a login shell) and runs under a login shell so user PATH from `~/.zshrc`/`~/.bash_profile` is honored. Clear manual-install instructions on failure. +* Post-build `pod install` now sets `LANG=en_US.UTF-8` and `LC_ALL=en_US.UTF-8` in the subprocess so CocoaPods no longer crashes with `Encoding::CompatibilityError` when Unity is launched from Finder (inherits launchd's empty `LANG`). + +### Tests +* Added `Tests/Runtime/BridgeContractTests.cs` covering the async-response contract, Configure success/failure paths, and the delegate/handler payload shapes for the regressions fixed in this release. + +### CI +* Added `.github/workflows/native.yml` — builds the iOS Swift bridge via SPM on macOS and the Android `.androidlib` via Gradle on Ubuntu, on every push and PR. No Unity license required. +* Added parked `.github/workflows/unity.yml` for full game-ci Unity build + test runs, documented with the licensing secrets and wrapping-project steps needed to enable it. +* CI harness scaffolding lives under `ci~/` (tilde-suffixed so Unity ignores it during package import — never copied into player builds). + +### Compatibility +* Minimum Unity version lowered from `6000.4` to `6000.3`. + ## [0.2.3] ## Enhancements diff --git a/Editor/SuperwallPostBuildProcessor.cs b/Editor/SuperwallPostBuildProcessor.cs index 8bf432b..8205a15 100644 --- a/Editor/SuperwallPostBuildProcessor.cs +++ b/Editor/SuperwallPostBuildProcessor.cs @@ -97,26 +97,100 @@ private static void PostProcessIOS(string buildPath) project.WriteToFile(projPath); - // Auto-run pod install + RunPodInstall(buildPath); + } + + private static void RunPodInstall(string buildPath) + { + string podPath = LocatePodExecutable(); + if (podPath == null) + { + Debug.LogWarning( + "[Superwall] Could not locate the CocoaPods 'pod' executable. " + + $"Run manually:\n\n cd \"{buildPath}\" && pod install\n\n" + + "If you don't have CocoaPods installed: 'sudo gem install cocoapods' " + + "or 'brew install cocoapods'."); + return; + } + var process = new System.Diagnostics.Process(); process.StartInfo.FileName = "/bin/bash"; - process.StartInfo.Arguments = $"-c \"cd '{buildPath}' && pod install\""; + // Login shell so user PATH from ~/.zshrc / ~/.bash_profile is loaded (rbenv/asdf shims, Homebrew). + process.StartInfo.Arguments = $"-l -c \"cd '{buildPath}' && '{podPath}' install\""; process.StartInfo.UseShellExecute = false; process.StartInfo.RedirectStandardOutput = true; process.StartInfo.RedirectStandardError = true; - process.Start(); - string output = process.StandardOutput.ReadToEnd(); - string error = process.StandardError.ReadToEnd(); - process.WaitForExit(); + // Some Ruby installs (rbenv/asdf) misbehave when these env vars are inherited from Unity. + process.StartInfo.EnvironmentVariables.Remove("MONO_PATH"); + process.StartInfo.EnvironmentVariables.Remove("MONO_CFG_DIR"); + process.StartInfo.EnvironmentVariables.Remove("DYLD_FALLBACK_LIBRARY_PATH"); + process.StartInfo.EnvironmentVariables.Remove("DYLD_LIBRARY_PATH"); + // CocoaPods crashes with Encoding::CompatibilityError under non-UTF-8 locales — + // Unity launched from Finder inherits launchd's empty LANG. + process.StartInfo.EnvironmentVariables["LANG"] = "en_US.UTF-8"; + process.StartInfo.EnvironmentVariables["LC_ALL"] = "en_US.UTF-8"; - if (process.ExitCode == 0) + try { - Debug.Log($"[Superwall] pod install completed successfully.\n{output}"); + process.Start(); + string output = process.StandardOutput.ReadToEnd(); + string error = process.StandardError.ReadToEnd(); + process.WaitForExit(); + + if (process.ExitCode == 0) + { + Debug.Log($"[Superwall] pod install completed successfully.\n{output}"); + } + else + { + Debug.LogWarning( + $"[Superwall] pod install failed (exit code {process.ExitCode}). " + + $"Run manually:\n\n cd \"{buildPath}\" && pod install\n\n{error}"); + } } - else + catch (System.Exception e) { - Debug.LogWarning($"[Superwall] pod install failed (exit code {process.ExitCode}). Run manually in: {buildPath}\n{error}"); + Debug.LogWarning( + $"[Superwall] Could not run pod install automatically ({e.Message}). " + + $"Run manually:\n\n cd \"{buildPath}\" && pod install"); + } + } + + private static string LocatePodExecutable() + { + string home = System.Environment.GetEnvironmentVariable("HOME") ?? ""; + string[] candidates = + { + "/usr/local/bin/pod", + "/opt/homebrew/bin/pod", + Path.Combine(home, ".rbenv/shims/pod"), + Path.Combine(home, ".asdf/shims/pod"), + "/usr/bin/pod", + }; + foreach (var path in candidates) + { + if (!string.IsNullOrEmpty(path) && File.Exists(path)) return path; + } + + // Fall back to `which pod` under a login shell. + try + { + var which = new System.Diagnostics.Process(); + which.StartInfo.FileName = "/bin/bash"; + which.StartInfo.Arguments = "-l -c \"command -v pod\""; + which.StartInfo.UseShellExecute = false; + which.StartInfo.RedirectStandardOutput = true; + which.StartInfo.RedirectStandardError = true; + which.Start(); + string result = which.StandardOutput.ReadToEnd().Trim(); + which.WaitForExit(); + if (which.ExitCode == 0 && !string.IsNullOrEmpty(result) && File.Exists(result)) + { + return result; + } } + catch { } + return null; } #endif } 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 e3b132b..9f27430 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 @@ -220,9 +220,10 @@ class SuperwallUnityBridge { fun setSubscriptionStatus(statusJson: String) { try { - val type = JSONObject(statusJson).optString("type", "unknown") + val json = JSONObject(statusJson) + val type = json.optString("type", "unknown") val status: SubscriptionStatus = when (type.lowercase()) { - "active" -> SubscriptionStatus.Active(emptySet()) + "active" -> SubscriptionStatus.Active(parseEntitlementsFromJson(json.optJSONArray("entitlements"))) "inactive" -> SubscriptionStatus.Inactive else -> SubscriptionStatus.Unknown } @@ -230,6 +231,17 @@ class SuperwallUnityBridge { } catch (_: JSONException) {} } + private fun parseEntitlementsFromJson(arr: JSONArray?): Set { + if (arr == null) return emptySet() + val out = mutableSetOf() + for (i in 0 until arr.length()) { + val o = arr.optJSONObject(i) ?: continue + val id = o.optString("id", null) ?: o.optString("identifier", null) ?: continue + out.add(Entitlement(id = id)) + } + return out + } + fun getConfigurationStatus(): String = when (Superwall.instance.configurationState) { is com.superwall.sdk.config.models.ConfigurationStatus.Configured -> "configured" is com.superwall.sdk.config.models.ConfigurationStatus.Failed -> "failed" @@ -519,8 +531,8 @@ class SuperwallUnityBridge { val message = json.optString("message", null) val actionTitle = json.optString("actionTitle", null) val closeActionTitle = json.optString("closeActionTitle", "Done") - val actionCallbackId = json.optString("actionCallbackId", null) - val closeCallbackId = json.optString("closeCallbackId", null) + val actionCallbackId = json.optString("onActionCallbackId", null) + val closeCallbackId = json.optString("onCloseCallbackId", null) val action: (() -> Unit)? = actionCallbackId?.let { { sendAsyncResponse(it, JSONObject().put("action", "performed")) } diff --git a/Plugins/iOS/SuperwallUnityBridge-Bridging-Header.h.meta b/Plugins/iOS/SuperwallUnityBridge-Bridging-Header.h.meta index b2c68eb..928c9ce 100644 --- a/Plugins/iOS/SuperwallUnityBridge-Bridging-Header.h.meta +++ b/Plugins/iOS/SuperwallUnityBridge-Bridging-Header.h.meta @@ -1,2 +1,2 @@ fileFormatVersion: 2 -guid: c04a4b33bfe634032a05b90b7aba40c8 \ No newline at end of file +guid: d09d90525cd5540cd971b9d613312458 \ No newline at end of file diff --git a/Plugins/iOS/SuperwallUnityBridge.swift b/Plugins/iOS/SuperwallUnityBridge.swift index 77f0ca8..20cb861 100644 --- a/Plugins/iOS/SuperwallUnityBridge.swift +++ b/Plugins/iOS/SuperwallUnityBridge.swift @@ -205,7 +205,6 @@ private func serializePaywallCloseReason(_ reason: PaywallCloseReason) -> String private func serializeLocalNotification(_ n: LocalNotification) -> [String: Any] { var dict: [String: Any] = [ - "id": n.id, "type": n.type == .trialStarted ? "trialStarted" : "unsupported", "title": n.title, "body": n.body, @@ -445,6 +444,8 @@ private class UnityPurchaseController: PurchaseController { private var unityDelegate: SuperwallUnityDelegate? private var unityPurchaseController: UnityPurchaseController? private var pendingFeatureHandlers: [String: () -> Void] = [:] +private var pendingLocalResources: [String: URL] = [:] +private var didConfigure = false // MARK: - Extern C Functions @@ -574,12 +575,20 @@ public func _SuperwallBridge_Configure( purchaseController = controller } + if !pendingLocalResources.isEmpty { + if options == nil { options = SuperwallOptions() } + var resourceMap: [String: AssetResource] = [:] + for (k, v) in pendingLocalResources { resourceMap[k] = v } + options?.localResources = resourceMap + } + let completion: (() -> Void)? = callbackId.map { cbId in return { sendAsyncResponse(callbackId: cbId, data: ["success": true]) } } + didConfigure = true _ = Superwall.configure(apiKey: key, purchaseController: purchaseController, options: options, completion: completion) } @@ -1188,21 +1197,22 @@ public func _SuperwallBridge_RespondToRestorePurchases(_ callbackId: UnsafePoint } @_cdecl("_SuperwallBridge_ShowAlert") -public func _SuperwallBridge_ShowAlert( - _ titlePtr: UnsafePointer?, - _ messagePtr: UnsafePointer?, - _ actionTitlePtr: UnsafePointer? -) { - // No-op on iOS — SuperwallKit does not expose a showAlert API. - // This stub exists to prevent a crash from the missing DllImport symbol. +public func _SuperwallBridge_ShowAlert(_ alertJson: UnsafePointer?) { + // SuperwallKit (iOS) does not expose a public showAlert API. The bridge accepts the + // call so the DllImport symbol resolves, logs a warning, then fires the close + // callback immediately so the C# side cleans up its pending callback entry. NSLog("[SuperwallUnityBridge] ShowAlert called (no-op on iOS)") + guard let json = toSwiftString(alertJson), let dict = parseJson(json) else { return } + if let closeId = dict["onCloseCallbackId"] as? String { + sendAsyncResponse(callbackId: closeId, data: ["action": "closed"]) + } } @_cdecl("_SuperwallBridge_SetLocalResources") public func _SuperwallBridge_SetLocalResources(_ resourcesJson: UnsafePointer) { let json = String(cString: resourcesJson) guard let dict = parseJson(json) else { - Superwall.shared.options.localResources = [:] + pendingLocalResources = [:] return } var resources: [String: URL] = [:] @@ -1218,5 +1228,8 @@ public func _SuperwallBridge_SetLocalResources(_ resourcesJson: UnsafePointer data) var callback = _pendingAsyncCallbacks[callbackId]; _pendingAsyncCallbacks.Remove(callbackId); - var responseData = data.ContainsKey("response") ? data["response"] as string : null; - callback?.Invoke(responseData); + // Native bridges flatten the response fields directly into `data` alongside `callbackId`. + // Re-serialize the whole dict (sans callbackId) so the registered callback can parse it. + string json = null; + if (data != null) + { + var payload = new Dictionary(data); + payload.Remove("callbackId"); + json = Json.Serialize(payload); + } + callback?.Invoke(json); } #endregion diff --git a/Tests/Runtime/BridgeContractTests.cs b/Tests/Runtime/BridgeContractTests.cs new file mode 100644 index 0000000..5922f8b --- /dev/null +++ b/Tests/Runtime/BridgeContractTests.cs @@ -0,0 +1,504 @@ +using UnityEngine; +using UnityEngine.TestTools; +using NUnit.Framework; +using System.Collections; +using System.Collections.Generic; +using Superwall; +using Superwall.Internal; + +namespace Superwall.Tests +{ + /// + /// Verifies the JSON shape contracts the native bridges must adhere to. Each test feeds a + /// payload mirroring what the iOS/Android bridges actually emit and asserts the C# side + /// surfaces it correctly. + /// + /// Add a regression test here whenever a shape divergence between native and C# is found. + /// + class BridgeContractTests + { + // --- AsyncResponse contract --- + + [UnityTest] + public IEnumerator AsyncResponse_PayloadReachesCallback_WithCallbackIdStripped() + { + // Regression: HandleAsyncResponse used to look for data["response"], a key no native + // bridge emits. The result: every async callback received null, breaking Configure + // (always returned ConfigurationResult.Failed("Unknown error")), GetProducts, + // GetCustomerInfo, GetAssignments, etc. + BridgeCallbackHandler.Initialize(); + yield return null; + + string captured = null; + const string id = "test-async-shape"; + BridgeCallbackHandler.Instance.RegisterAsyncCallback(id, (json) => captured = json); + + BridgeCallbackHandler.Instance.OnCallback(Json.Serialize(new Dictionary + { + { "method", "asyncResponse" }, + { "data", new Dictionary + { + { "callbackId", id }, + { "success", true }, + { "error", "" } + } + } + })); + yield return null; + + Assert.IsNotNull(captured, "Callback must receive the response payload as JSON"); + var parsed = Json.Deserialize(captured) as Dictionary; + Assert.IsNotNull(parsed); + Assert.IsTrue((bool)parsed["success"], "success field must survive the round trip"); + Assert.IsFalse(parsed.ContainsKey("callbackId"), + "callbackId must be stripped before reaching the user callback"); + } + + [UnityTest] + public IEnumerator AsyncResponse_UnknownCallbackId_DoesNotInvokeAnyCallback() + { + BridgeCallbackHandler.Initialize(); + yield return null; + + bool fired = false; + BridgeCallbackHandler.Instance.RegisterAsyncCallback("registered-id", + (_) => fired = true); + + BridgeCallbackHandler.Instance.OnCallback(Json.Serialize(new Dictionary + { + { "method", "asyncResponse" }, + { "data", new Dictionary + { + { "callbackId", "some-other-id" }, + { "success", true } + } + } + })); + yield return null; + + Assert.IsFalse(fired, + "Registered callback must not fire for a different callbackId"); + } + + [UnityTest] + public IEnumerator AsyncResponse_CallbackIsRemovedAfterInvocation() + { + BridgeCallbackHandler.Initialize(); + yield return null; + + int invocations = 0; + const string id = "single-use-id"; + BridgeCallbackHandler.Instance.RegisterAsyncCallback(id, (_) => invocations++); + + string payload = Json.Serialize(new Dictionary + { + { "method", "asyncResponse" }, + { "data", new Dictionary + { + { "callbackId", id }, + { "success", true } + } + } + }); + + BridgeCallbackHandler.Instance.OnCallback(payload); + BridgeCallbackHandler.Instance.OnCallback(payload); + yield return null; + + Assert.AreEqual(1, invocations, + "Async callbacks are single-use; a repeated dispatch must not fire them again"); + } + + // --- Configure success/failure paths --- + + [UnityTest] + public IEnumerator ConfigureCallback_NativeSuccess_PropagatesSuccess() + { + // Mirrors the inline closure registered in Superwall.Configure. + BridgeCallbackHandler.Initialize(); + yield return null; + + ConfigurationResult result = null; + const string id = "configure-success"; + + BridgeCallbackHandler.Instance.RegisterAsyncCallback(id, (json) => + { + var data = Json.Deserialize(json) as Dictionary; + if (data != null && data.ContainsKey("success") && (bool)data["success"]) + { + result = ConfigurationResult.Success(); + } + else + { + string error = data != null && data.ContainsKey("error") + ? data["error"] as string : "Unknown error"; + result = ConfigurationResult.Failed(error); + } + }); + + BridgeCallbackHandler.Instance.OnCallback(Json.Serialize(new Dictionary + { + { "method", "asyncResponse" }, + { "data", new Dictionary + { + { "callbackId", id }, + { "success", true } + } + } + })); + yield return null; + + Assert.IsNotNull(result); + Assert.IsTrue(result.IsSuccess); + } + + [UnityTest] + public IEnumerator ConfigureCallback_NativeFailure_PropagatesErrorMessage() + { + BridgeCallbackHandler.Initialize(); + yield return null; + + ConfigurationResult result = null; + const string id = "configure-failure"; + + BridgeCallbackHandler.Instance.RegisterAsyncCallback(id, (json) => + { + var data = Json.Deserialize(json) as Dictionary; + if (data != null && data.ContainsKey("success") && (bool)data["success"]) + { + result = ConfigurationResult.Success(); + } + else + { + string error = data != null && data.ContainsKey("error") + ? data["error"] as string : "Unknown error"; + result = ConfigurationResult.Failed(error); + } + }); + + BridgeCallbackHandler.Instance.OnCallback(Json.Serialize(new Dictionary + { + { "method", "asyncResponse" }, + { "data", new Dictionary + { + { "callbackId", id }, + { "success", false }, + { "error", "bad api key" } + } + } + })); + yield return null; + + Assert.IsNotNull(result); + Assert.IsFalse(result.IsSuccess); + var failed = result as ConfigurationResult.FailedResult; + Assert.IsNotNull(failed); + Assert.AreEqual("bad api key", failed.Error); + } + + // --- Delegate payload shape contracts --- + + [UnityTest] + public IEnumerator Delegate_UserAttributesDidChange_ReadsNewAttributesKey() + { + // Regression: Android previously sent the key "attributes" while the C# handler + // reads "newAttributes" — Unity delegate received null on Android. + BridgeCallbackHandler.Initialize(); + yield return null; + + Dictionary captured = null; + var mockDelegate = new MockSuperwallDelegateContract(); + mockDelegate.OnUserAttributesDidChange = (attrs) => captured = attrs; + BridgeCallbackHandler.Instance.Delegate = mockDelegate; + + BridgeCallbackHandler.Instance.OnCallback(Json.Serialize(new Dictionary + { + { "method", "userAttributesDidChange" }, + { "data", new Dictionary + { + { "newAttributes", new Dictionary + { + { "tier", "gold" }, + { "count", 7 } + } + } + } + } + })); + yield return null; + + Assert.IsNotNull(captured); + Assert.AreEqual("gold", captured["tier"]); + } + + [UnityTest] + public IEnumerator Delegate_DidRedeemLink_DeserializesSuccessShape() + { + // Regression: Android used to wrap the result as {result: {status: ...}} which the + // C# DeserializeRedemptionResult (looking for top-level "type" and "code") couldn't + // parse. The shape is now flat across iOS and Android. + BridgeCallbackHandler.Initialize(); + yield return null; + + RedemptionResult captured = null; + var mockDelegate = new MockSuperwallDelegateContract(); + mockDelegate.OnDidRedeemLink = (r) => captured = r; + BridgeCallbackHandler.Instance.Delegate = mockDelegate; + + BridgeCallbackHandler.Instance.OnCallback(Json.Serialize(new Dictionary + { + { "method", "didRedeemLink" }, + { "data", new Dictionary + { + { "type", "success" }, + { "code", "PROMO123" }, + { "redemptionInfo", new Dictionary + { + { "entitlements", new List() } + } + } + } + } + })); + yield return null; + + Assert.IsNotNull(captured); + Assert.AreEqual(RedemptionResult.ResultType.Success, captured.Type); + var success = captured as RedemptionResult.SuccessResult; + Assert.IsNotNull(success); + Assert.AreEqual("PROMO123", success.Code); + } + + [UnityTest] + public IEnumerator Delegate_DidRedeemLink_DeserializesInvalidCode() + { + BridgeCallbackHandler.Initialize(); + yield return null; + + RedemptionResult captured = null; + var mockDelegate = new MockSuperwallDelegateContract(); + mockDelegate.OnDidRedeemLink = (r) => captured = r; + BridgeCallbackHandler.Instance.Delegate = mockDelegate; + + BridgeCallbackHandler.Instance.OnCallback(Json.Serialize(new Dictionary + { + { "method", "didRedeemLink" }, + { "data", new Dictionary + { + { "type", "invalidCode" }, + { "code", "WRONG" } + } + } + })); + yield return null; + + Assert.IsNotNull(captured); + Assert.AreEqual(RedemptionResult.ResultType.InvalidCode, captured.Type); + Assert.AreEqual("WRONG", ((RedemptionResult.InvalidCodeResult)captured).Code); + } + + [UnityTest] + public IEnumerator Delegate_CustomerInfoDidChange_DeserializesEntitlements() + { + // Regression: Android previously sent only {userId} on both sides, dropping + // entitlements. The shape now matches iOS. + BridgeCallbackHandler.Initialize(); + yield return null; + + CustomerInfo capturedTo = null; + var mockDelegate = new MockSuperwallDelegateContract(); + mockDelegate.OnCustomerInfoDidChange = (from, to) => capturedTo = to; + BridgeCallbackHandler.Instance.Delegate = mockDelegate; + + BridgeCallbackHandler.Instance.OnCallback(Json.Serialize(new Dictionary + { + { "method", "customerInfoDidChange" }, + { "data", new Dictionary + { + { "from", new Dictionary + { + { "userId", "u1" }, + { "entitlements", new List() } + } + }, + { "to", new Dictionary + { + { "userId", "u1" }, + { "entitlements", new List + { + new Dictionary + { + { "id", "premium" }, + { "isActive", true } + } + } + } + } + } + } + } + })); + yield return null; + + Assert.IsNotNull(capturedTo); + Assert.AreEqual("u1", capturedTo.UserId); + Assert.AreEqual(1, capturedTo.Entitlements.Count); + Assert.AreEqual("premium", capturedTo.Entitlements[0].Id); + } + + // --- PresentationHandler payload shape contracts --- + + [UnityTest] + public IEnumerator PresentationHandler_OnDismiss_ReceivesPaywallResult() + { + // Regression: Android previously dropped the PaywallResult argument from onDismiss, + // so Unity could not distinguish purchased/declined/restored on Android. + BridgeCallbackHandler.Initialize(); + yield return null; + + PaywallResult captured = null; + const string handlerId = "h1"; + BridgeCallbackHandler.Instance.RegisterPresentationHandler(handlerId, + new PaywallPresentationHandler + { + OnDismiss = (info, result) => captured = result + }); + + BridgeCallbackHandler.Instance.OnCallback(Json.Serialize(new Dictionary + { + { "method", "onDismiss" }, + { "data", new Dictionary + { + { "handlerId", handlerId }, + { "paywallInfo", new Dictionary { { "identifier", "pw" } } }, + { "result", new Dictionary + { + { "type", "purchased" }, + { "productId", "com.app.monthly" } + } + } + } + } + })); + yield return null; + + Assert.IsNotNull(captured); + Assert.AreEqual(PaywallResult.ResultType.Purchased, captured.Type); + Assert.AreEqual("com.app.monthly", + ((PaywallResult.PurchasedResult)captured).ProductId); + } + + [UnityTest] + public IEnumerator PresentationHandler_OnSkip_AcceptsLowercaseReasonStrings() + { + // Regression: Android used to send PascalCase (`reason::class.simpleName`); both + // platforms now emit camelCase. The deserializer matches case-insensitively, but + // pin the contract. + BridgeCallbackHandler.Initialize(); + yield return null; + + PaywallSkippedReason captured = PaywallSkippedReason.NoAudienceMatch; + const string handlerId = "h-skip"; + BridgeCallbackHandler.Instance.RegisterPresentationHandler(handlerId, + new PaywallPresentationHandler + { + OnSkip = (reason) => captured = reason + }); + + BridgeCallbackHandler.Instance.OnCallback(Json.Serialize(new Dictionary + { + { "method", "onSkip" }, + { "data", new Dictionary + { + { "handlerId", handlerId }, + { "reason", "holdout" } + } + } + })); + yield return null; + + Assert.AreEqual(PaywallSkippedReason.Holdout, captured); + } + + [UnityTest] + public IEnumerator PresentationHandler_OnPresent_DeserializesFullPaywallInfo() + { + // Regression: Android previously emitted only {identifier}; now both platforms emit + // the full PaywallInfo including products, presentedBy*, load times, + // featureGatingBehavior, closeReason, etc. + BridgeCallbackHandler.Initialize(); + yield return null; + + PaywallInfo captured = null; + const string handlerId = "h-present"; + BridgeCallbackHandler.Instance.RegisterPresentationHandler(handlerId, + new PaywallPresentationHandler + { + OnPresent = (info) => captured = info + }); + + BridgeCallbackHandler.Instance.OnCallback(Json.Serialize(new Dictionary + { + { "method", "onPresent" }, + { "data", new Dictionary + { + { "handlerId", handlerId }, + { "paywallInfo", new Dictionary + { + { "identifier", "pw_abc" }, + { "name", "Welcome" }, + { "url", "https://example.com/p/pw_abc" }, + { "productIds", new List { "monthly", "yearly" } }, + { "presentedBy", "register" }, + { "presentedByPlacementWithName", "campaign_open" }, + { "isFreeTrialAvailable", true }, + { "featureGatingBehavior", "gated" }, + { "closeReason", "manualClose" }, + { "responseLoadDuration", 0.123 } + } + } + } + } + })); + yield return null; + + Assert.IsNotNull(captured); + Assert.AreEqual("pw_abc", captured.Identifier); + Assert.AreEqual("Welcome", captured.Name); + Assert.AreEqual(2, captured.ProductIds.Count); + Assert.AreEqual("register", captured.PresentedBy); + Assert.AreEqual("campaign_open", captured.PresentedByPlacementWithName); + Assert.IsTrue(captured.IsFreeTrialAvailable.Value); + Assert.AreEqual(FeatureGatingBehavior.Gated, captured.FeatureGatingBehavior); + Assert.AreEqual(PaywallCloseReason.ManualClose, captured.CloseReason); + Assert.AreEqual(0.123, captured.ResponseLoadDuration.Value, 0.0001); + } + } + + /// + /// Local mock that exposes the few hooks our contract tests need without bringing in the + /// full surface MockSuperwallDelegate uses elsewhere. + /// + class MockSuperwallDelegateContract : ISuperwallDelegate + { + public System.Action> OnUserAttributesDidChange; + public System.Action OnDidRedeemLink; + public System.Action OnCustomerInfoDidChange; + + public void SubscriptionStatusDidChange(SubscriptionStatus from, SubscriptionStatus to) { } + public void HandleSuperwallEvent(SuperwallEventInfo eventInfo) { } + public void HandleCustomPaywallAction(string name) { } + public void WillDismissPaywall(PaywallInfo paywallInfo) { } + public void WillPresentPaywall(PaywallInfo paywallInfo) { } + public void DidDismissPaywall(PaywallInfo paywallInfo) { } + public void DidPresentPaywall(PaywallInfo paywallInfo) { } + public void PaywallWillOpenURL(string url) { } + public void PaywallWillOpenDeepLink(string url) { } + public void HandleLog(string level, string scope, string message, Dictionary info, string error) { } + public void WillRedeemLink() { } + public void DidRedeemLink(RedemptionResult result) => OnDidRedeemLink?.Invoke(result); + public void HandleSuperwallDeepLink(string fullURL, List pathComponents, Dictionary queryParameters) { } + public void CustomerInfoDidChange(CustomerInfo from, CustomerInfo to) => OnCustomerInfoDidChange?.Invoke(from, to); + public void UserAttributesDidChange(Dictionary newAttributes) => OnUserAttributesDidChange?.Invoke(newAttributes); + } +} diff --git a/Tests/Runtime/BridgeContractTests.cs.meta b/Tests/Runtime/BridgeContractTests.cs.meta new file mode 100644 index 0000000..094d45f --- /dev/null +++ b/Tests/Runtime/BridgeContractTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: f2072434a2b5b498490eed5f295b3c0a \ No newline at end of file diff --git a/ci~/android-harness/build.gradle b/ci~/android-harness/build.gradle new file mode 100644 index 0000000..9694ada --- /dev/null +++ b/ci~/android-harness/build.gradle @@ -0,0 +1,21 @@ +buildscript { + repositories { + google() + mavenCentral() + } + dependencies { + classpath 'com.android.tools.build:gradle:8.5.2' + classpath 'org.jetbrains.kotlin:kotlin-gradle-plugin:2.0.21' + } +} + +// Inject the Unity stub jar as a compileOnly dep into the real :SuperwallSDK module +// so its `import com.unity3d.player.UnityPlayer` resolves outside a Unity build. +gradle.projectsEvaluated { + def sdk = rootProject.findProject(':SuperwallSDK') + if (sdk != null) { + sdk.dependencies { + compileOnly project(':unity-stub') + } + } +} diff --git a/ci~/android-harness/gradle.properties b/ci~/android-harness/gradle.properties new file mode 100644 index 0000000..f64d6f4 --- /dev/null +++ b/ci~/android-harness/gradle.properties @@ -0,0 +1,4 @@ +android.useAndroidX=true +android.nonTransitiveRClass=true +org.gradle.jvmargs=-Xmx2g -Dfile.encoding=UTF-8 +kotlin.code.style=official diff --git a/ci~/android-harness/settings.gradle b/ci~/android-harness/settings.gradle new file mode 100644 index 0000000..c9ead2a --- /dev/null +++ b/ci~/android-harness/settings.gradle @@ -0,0 +1,24 @@ +pluginManagement { + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} + +dependencyResolutionManagement { + repositoriesMode.set(RepositoriesMode.PREFER_SETTINGS) + repositories { + google() + mavenCentral() + } +} + +rootProject.name = 'superwall-android-harness' + +include ':unity-stub' + +// Mount the real Unity-shipped androidlib under a friendly name so CI builds +// the same Gradle module that ships in the package. +include ':SuperwallSDK' +project(':SuperwallSDK').projectDir = file('../../Plugins/Android/SuperwallSDK.androidlib') diff --git a/ci~/android-harness/unity-stub/build.gradle b/ci~/android-harness/unity-stub/build.gradle new file mode 100644 index 0000000..c29d2e2 --- /dev/null +++ b/ci~/android-harness/unity-stub/build.gradle @@ -0,0 +1,8 @@ +plugins { + id 'java-library' +} + +java { + sourceCompatibility = JavaVersion.VERSION_1_8 + targetCompatibility = JavaVersion.VERSION_1_8 +} diff --git a/ci~/android-harness/unity-stub/src/main/java/com/unity3d/player/UnityPlayer.java b/ci~/android-harness/unity-stub/src/main/java/com/unity3d/player/UnityPlayer.java new file mode 100644 index 0000000..03cdae6 --- /dev/null +++ b/ci~/android-harness/unity-stub/src/main/java/com/unity3d/player/UnityPlayer.java @@ -0,0 +1,15 @@ +package com.unity3d.player; + +import android.app.Activity; + +// CI-only stub. Mirrors the surface of Unity's real UnityPlayer used by +// SuperwallSDK.androidlib/src/main/kotlin/.../SuperwallUnityBridge.kt so the +// module compiles in isolation. Never shipped — provided as compileOnly only. +public final class UnityPlayer { + public static Activity currentActivity; + + public static void UnitySendMessage(String gameObject, String method, String message) { + } + + private UnityPlayer() {} +} diff --git a/ci~/ios-harness/Package.swift b/ci~/ios-harness/Package.swift new file mode 100644 index 0000000..2fea18f --- /dev/null +++ b/ci~/ios-harness/Package.swift @@ -0,0 +1,29 @@ +// swift-tools-version:5.7 +import PackageDescription + +// Standalone SPM harness so CI can type-check Plugins/iOS/SuperwallUnityBridge.swift +// against the real SuperwallKit API without spinning up Unity. The Sources/SuperwallBridge +// directory is a symlink to ../../Plugins/iOS. +let package = Package( + name: "SuperwallBridgeHarness", + platforms: [.iOS(.v16)], + products: [ + .library(name: "SuperwallBridge", targets: ["SuperwallBridge"]) + ], + dependencies: [ + .package(url: "https://github.com/superwall/Superwall-iOS", from: "4.0.0") + ], + targets: [ + .target( + name: "SuperwallBridge", + dependencies: [ + .product(name: "SuperwallKit", package: "Superwall-iOS") + ], + exclude: [ + "SuperwallUnityBridge-Bridging-Header.h", + "SuperwallUnityBridge-Bridging-Header.h.meta", + "SuperwallUnityBridge.swift.meta", + ] + ) + ] +) diff --git a/ci~/ios-harness/Sources/SuperwallBridge b/ci~/ios-harness/Sources/SuperwallBridge new file mode 120000 index 0000000..3138785 --- /dev/null +++ b/ci~/ios-harness/Sources/SuperwallBridge @@ -0,0 +1 @@ +../../../Plugins/iOS \ No newline at end of file diff --git a/package.json b/package.json index be558dc..3fbd281 100644 --- a/package.json +++ b/package.json @@ -1,9 +1,8 @@ { "name": "com.superwall.sdk", "displayName": "Superwall SDK", - "version": "0.2.3", - "unity": "6000.4", - "unityRelease": "0f1", + "version": "0.2.4", + "unity": "6000.3", "description": "Superwall SDK for Unity. Remotely configure paywalls, run paywall A/B tests, and more — all without shipping an app update.", "dependencies": {}, "author": { From 9c47a9e5ccdc324aeec014a655d26880cd895060 Mon Sep 17 00:00:00 2001 From: Ian Rumac Date: Fri, 29 May 2026 11:03:08 +0200 Subject: [PATCH 2/3] Fix CI: xcodebuild iOS destination, android-library for unity-stub --- .github/workflows/native.yml | 17 ++++++++++++----- ci~/android-harness/unity-stub/build.gradle | 17 +++++++++++++---- .../unity-stub/src/main/AndroidManifest.xml | 1 + 3 files changed, 26 insertions(+), 9 deletions(-) create mode 100644 ci~/android-harness/unity-stub/src/main/AndroidManifest.xml diff --git a/.github/workflows/native.yml b/.github/workflows/native.yml index 7c6fbf7..29d7eed 100644 --- a/.github/workflows/native.yml +++ b/.github/workflows/native.yml @@ -20,16 +20,23 @@ jobs: - name: Select Xcode run: sudo xcode-select -s /Applications/Xcode_15.4.app - - name: Cache SPM + - name: Cache SPM + DerivedData uses: actions/cache@v4 with: path: ci~/ios-harness/.build - key: spm-${{ runner.os }}-${{ hashFiles('ci~/ios-harness/Package.swift') }} - restore-keys: spm-${{ runner.os }}- + key: xcb-ios-${{ runner.os }}-${{ hashFiles('ci~/ios-harness/Package.swift') }} + restore-keys: xcb-ios-${{ runner.os }}- - - name: swift build + - name: xcodebuild (iOS SDK) working-directory: ci~/ios-harness - run: swift build -c release + # swift build would target the macOS SDK and fail on UIKit imports inside + # SuperwallKit. xcodebuild with an iOS destination picks the iOS SDK. + run: | + xcodebuild build \ + -scheme SuperwallBridgeHarness \ + -destination 'generic/platform=iOS' \ + -derivedDataPath .build \ + -skipPackagePluginValidation android: name: Android lib (Gradle) diff --git a/ci~/android-harness/unity-stub/build.gradle b/ci~/android-harness/unity-stub/build.gradle index c29d2e2..052f6e0 100644 --- a/ci~/android-harness/unity-stub/build.gradle +++ b/ci~/android-harness/unity-stub/build.gradle @@ -1,8 +1,17 @@ plugins { - id 'java-library' + id 'com.android.library' } -java { - sourceCompatibility = JavaVersion.VERSION_1_8 - targetCompatibility = JavaVersion.VERSION_1_8 +android { + namespace 'com.unity3d.player.stub' + compileSdkVersion 34 + + defaultConfig { + minSdkVersion 25 + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_1_8 + targetCompatibility = JavaVersion.VERSION_1_8 + } } diff --git a/ci~/android-harness/unity-stub/src/main/AndroidManifest.xml b/ci~/android-harness/unity-stub/src/main/AndroidManifest.xml new file mode 100644 index 0000000..cc947c5 --- /dev/null +++ b/ci~/android-harness/unity-stub/src/main/AndroidManifest.xml @@ -0,0 +1 @@ + From ce36d127731fb587b9d3f9ec99f158d1787866c0 Mon Sep 17 00:00:00 2001 From: Ian Rumac Date: Fri, 29 May 2026 11:22:32 +0200 Subject: [PATCH 3/3] CI: macos-15 runner, drop Xcode 15.4 pin (too old for SuperwallKit 4.15+) --- .github/workflows/native.yml | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/.github/workflows/native.yml b/.github/workflows/native.yml index 29d7eed..5bdb6f9 100644 --- a/.github/workflows/native.yml +++ b/.github/workflows/native.yml @@ -13,12 +13,14 @@ concurrency: jobs: ios: name: iOS bridge (SPM) - runs-on: macos-14 + runs-on: macos-15 steps: - uses: actions/checkout@v4 - - name: Select Xcode - run: sudo xcode-select -s /Applications/Xcode_15.4.app + - name: Show Xcode + iOS SDK + # Float with whatever Xcode the macos-15 image ships (currently 16.x); + # SuperwallKit 4.15+ won't compile on Xcode 15.4. + run: xcodebuild -version && xcodebuild -showsdks | grep -i ios - name: Cache SPM + DerivedData uses: actions/cache@v4