diff --git a/.github/workflows/native.yml b/.github/workflows/native.yml index 5a0db6e..c09c968 100644 --- a/.github/workflows/native.yml +++ b/.github/workflows/native.yml @@ -13,13 +13,13 @@ concurrency: jobs: ios: name: iOS bridge (SPM) - runs-on: macos-15 + # SuperwallKit 4.18+ declares Swift tools 6.2, so it needs Xcode 26. On Xcode 16 + # SPM can't resolve it at all (and a `from:` requirement silently falls back to 4.17). + runs-on: macos-26 steps: - uses: actions/checkout@v4 - 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 diff --git a/CHANGELOG.md b/CHANGELOG.md index 079e949..9beea8e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,23 @@ # Changelog All notable changes to this package will be documented in this file. +## [0.4.1] + +### Enhancements +* Android: subscription replacement. When a paywall's Purchase action is set to replace an existing subscription, a custom purchase controller now receives the subscription being replaced through a new `IPurchaseController.PurchaseFromGooglePlay(productId, basePlanId, offerId, ProductReplacement replacement, completion)` overload. Pass `replacement.OldPurchaseToken` and `replacement.Mode` to the billing flow's `SubscriptionUpdateParams` to upgrade, downgrade or switch the user's subscription instead of buying a second one. The overload has a default implementation that calls the existing four-argument method, so existing controllers compile and behave as before. Purchases without a custom controller handle the replacement automatically. +* `EventType` gains the events SuperwallKit 4.18 and superwall-android 2.10 added: `TeleportOpen` and `TeleportReturn` (iOS), and `CustomerCenterOpen`, `CustomerCenterClose`, `CustomerCenterAction`, `CustomerCenterSurveyResponse` and `CustomerCenterRefundRequest`. Before, they reached `HandleSuperwallEvent` with a warning and the default `EventType`. + +### iOS +* SuperwallKit 4.18 and later need Xcode 26 to build. The exported Xcode project no longer compiles with Xcode 16. +* SuperwallKit 4.18 and later need iOS 15. If Target minimum iOS Version is lower, the generated Podfile now uses iOS 15 so `pod install` succeeds, and the build logs a warning to raise the game's own target. + +### Android +* `LogScope.StoreKitManager` now enables the Android SDK's `billing` log scope, which replaces the deprecated `storeKitManager`. Android billing logs reach `SuperwallDelegate.HandleLog` with the scope `"billing"` instead of `"storeKitManager"`. + +### Dependencies +* Android: pins the native SDK to 2.10.0 (was `2.8.7`). View the Android SDK release notes for [2.9.0](https://github.com/superwall/Superwall-Android/releases/tag/2.9.0) and [2.10.0](https://github.com/superwall/Superwall-Android/releases/tag/2.10.0). +* iOS: pins the native SDK to 4.19.1. The generated Podfile already requires SuperwallKit `>= 4.19.1, < 5.0`; the CI harness now builds the bridge against exactly 4.19.1 (was `from: 4.12.10`). [View iOS SDK release notes](https://github.com/superwall/Superwall-iOS/releases/tag/4.19.1). + ## [0.4.0] ### Enhancements diff --git a/Editor/SuperwallPostBuildProcessor.cs b/Editor/SuperwallPostBuildProcessor.cs index 69acc98..2ea8a9e 100644 --- a/Editor/SuperwallPostBuildProcessor.cs +++ b/Editor/SuperwallPostBuildProcessor.cs @@ -18,6 +18,9 @@ public class SuperwallPostBuildProcessor : IPostprocessBuildWithReport // rotated after it closes. public const string SuperwallKitPod = "pod 'SuperwallKit', '>= 4.19.1', '< 5.0'"; + // SuperwallKit 4.18.0 raised its deployment target from iOS 13 to iOS 15. + private const string MinimumIOSVersion = "15.0"; + // Requirements earlier versions of this package wrote. A Podfile still naming one of these // is moved to SuperwallKitPod; any other requirement was set by hand and is kept. Only the // version part is spelled out here so native-sdk-bump.yml's rewrite of SuperwallKitPod @@ -62,9 +65,16 @@ private static void PostProcessIOS(string buildPath) // Honor the deployment target Unity is set to (PlayerSettings → Other Settings → Target minimum iOS Version) // so the Pods build for the same iOS version as the host app — otherwise older sims/devices vanish - // from Xcode's destination list. SuperwallKit 4.x supports iOS 13+, so anything Unity allows is fine. + // from Xcode's destination list. SuperwallKit 4.18+ requires iOS 15, so a lower target is raised + // for the Pods (pod install would fail otherwise) and the game is told to raise its own. string iosTarget = PlayerSettings.iOS.targetOSVersionString; - if (string.IsNullOrEmpty(iosTarget)) iosTarget = "15.0"; + if (string.IsNullOrEmpty(iosTarget)) iosTarget = MinimumIOSVersion; + if (System.Version.TryParse(iosTarget.Contains(".") ? iosTarget : iosTarget + ".0", out var target) + && target < System.Version.Parse(MinimumIOSVersion)) + { + Debug.LogWarning($"[Superwall] SuperwallKit requires iOS {MinimumIOSVersion} or later, but Target minimum iOS Version is {iosTarget}. Raise it in Player Settings → Other Settings, or the Xcode build will fail to import SuperwallKit."); + iosTarget = MinimumIOSVersion; + } if (!File.Exists(podfilePath)) { diff --git a/Plugins/Android/SuperwallSDK.androidlib/build.gradle b/Plugins/Android/SuperwallSDK.androidlib/build.gradle index acb201d..f9475dd 100644 --- a/Plugins/Android/SuperwallSDK.androidlib/build.gradle +++ b/Plugins/Android/SuperwallSDK.androidlib/build.gradle @@ -21,7 +21,7 @@ if (agpMajor < 9) { // Follow the host project's compile SDK (Unity writes unity.compileSdkVersion // into the exported project's gradle.properties from Player Settings) so this -// module never pins consumers to a stale SDK. superwall-android 2.8.7 pulls +// module never pins consumers to a stale SDK. superwall-android 2.10.0 pulls // androidx.activity 1.10.1, which requires compileSdk >= 35 — hence the fallback. def unityCompileSdk = (findProperty('unity.compileSdkVersion') ?: '35') as int @@ -43,7 +43,7 @@ android { dependencies { // Pinned exactly: a floating range (2.+) lets a Maven-side release break // consumer builds overnight. Bump deliberately with each Unity SDK release. - api 'com.superwall.sdk:superwall-android:2.8.7' + api 'com.superwall.sdk:superwall-android:2.10.0' api 'com.android.billingclient:billing:9.1.0' implementation 'com.google.android.material:material:1.12.0' implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0' 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 24a7f03..5b438ed 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 @@ -10,7 +10,6 @@ import com.superwall.sdk.delegate.PurchaseResult import com.superwall.sdk.delegate.RestorationResult import com.superwall.sdk.delegate.subscription_controller.PurchaseController import com.superwall.sdk.models.attribution.AttributionProvider -import com.android.billingclient.api.ProductDetails import kotlinx.coroutines.CompletableDeferred import com.superwall.sdk.identity.IdentityOptions import com.superwall.sdk.identity.identify @@ -45,6 +44,7 @@ import com.superwall.sdk.store.abstractions.product.StoreProductType import com.superwall.sdk.store.abstractions.transactions.StoreTransaction import com.superwall.sdk.store.abstractions.transactions.StoreTransactionType import com.superwall.sdk.store.transactions.RestoreType +import com.superwall.sdk.store.ProductReplacement import kotlinx.coroutines.* import kotlin.time.Duration.Companion.seconds import org.json.JSONArray @@ -1093,7 +1093,7 @@ class SuperwallUnityBridge { "configmanager" -> LogScope.configManager "identitymanager" -> LogScope.identityManager "localizationmanager" -> LogScope.localizationManager - "storekitmanager" -> LogScope.storeKitManager + "storekitmanager", "billing" -> LogScope.billing "bouncebutton" -> LogScope.bounceButton "device" -> LogScope.device else -> null @@ -1225,12 +1225,24 @@ class SuperwallUnityBridge { override fun removeEldestEntry(eldest: MutableMap.MutableEntry?): Boolean = size > 32 } + // The SDK calls this overload for every purchase (2.9.0+); the older ones are never + // reached once it is overridden. `replacement` is non-null only when the paywall's + // Purchase action replaces an existing subscription the user has. override suspend fun purchase( activity: Activity, - productDetails: ProductDetails, + product: StoreProduct, basePlanId: String?, - offerId: String? + offerId: String?, + replacement: ProductReplacement? ): PurchaseResult { + // Custom (non-Play) products have no ProductDetails. The C# controller only + // purchases Play products, so fail as the SDK's default implementation does. + val productDetails = product.rawStoreProduct?.underlyingProductDetails + ?: return PurchaseResult.Failed( + "The Unity purchase controller does not handle custom products " + + "(${product.fullIdentifier})." + ) + val callbackId = java.util.UUID.randomUUID().toString() val deferred = CompletableDeferred() pendingPurchases[callbackId] = deferred @@ -1240,6 +1252,13 @@ class SuperwallUnityBridge { put("basePlanId", basePlanId ?: "") put("offerId", offerId ?: "") put("callbackId", callbackId) + if (replacement != null) { + put("replacement", JSONObject().apply { + put("mode", replacement.mode.rawName) + put("oldProductId", replacement.oldProductId ?: JSONObject.NULL) + put("oldPurchaseToken", replacement.oldPurchaseToken ?: JSONObject.NULL) + }) + } }) if (!sent) { // C# never got the request, so no reply will ever come. diff --git a/README.md b/README.md index b5eda97..d1e09cd 100644 --- a/README.md +++ b/README.md @@ -214,6 +214,28 @@ public class MyPurchaseController : IPurchaseController Superwall.Configure("api-key", purchaseController: new MyPurchaseController()); ``` +### Subscription replacement (Android) + +When a paywall's Purchase action is set to replace an existing subscription, Superwall passes the +subscription being replaced to an overload of `PurchaseFromGooglePlay`. Implement it to upgrade, +downgrade or switch the user's Google Play subscription instead of buying a second one: + +```csharp +public void PurchaseFromGooglePlay(string productId, string basePlanId, string offerId, + ProductReplacement replacement, Action completion) +{ + if (replacement != null) + { + // Pass replacement.OldPurchaseToken and replacement.Mode to the billing flow's + // SubscriptionUpdateParams (RevenueCat: oldProductId + googleReplacementMode). + } + // ...purchase, then call completion(...) +} +``` + +`replacement` is null for normal purchases. Implementing the overload is optional: by default it +calls the four-argument `PurchaseFromGooglePlay` and ignores the replacement. + ## User Identity ```csharp diff --git a/Runtime/IPurchaseController.cs b/Runtime/IPurchaseController.cs index ad3ecd9..356e5cb 100644 --- a/Runtime/IPurchaseController.cs +++ b/Runtime/IPurchaseController.cs @@ -6,6 +6,20 @@ public interface IPurchaseController { void PurchaseFromAppStore(string productId, Action completion); void PurchaseFromGooglePlay(string productId, string basePlanId, string offerId, Action completion); + + /// + /// Called on Android for every Google Play purchase. is + /// non-null when the purchase should replace one of the user's existing subscriptions + /// (upgrade, downgrade or plan switch): pass its purchase token and mode to the billing + /// flow's SubscriptionUpdateParams so the user isn't charged for a second subscription. + /// The default implementation ignores and calls + /// , + /// so existing controllers keep working without changes. + /// + void PurchaseFromGooglePlay(string productId, string basePlanId, string offerId, + ProductReplacement replacement, Action completion) + => PurchaseFromGooglePlay(productId, basePlanId, offerId, completion); + void RestorePurchases(Action completion); } } diff --git a/Runtime/Internal/BridgeCallbackHandler.cs b/Runtime/Internal/BridgeCallbackHandler.cs index c97b995..107cb01 100644 --- a/Runtime/Internal/BridgeCallbackHandler.cs +++ b/Runtime/Internal/BridgeCallbackHandler.cs @@ -1002,10 +1002,20 @@ private void HandlePurchaseFromGooglePlay(Dictionary data) var callbackId = GetString(data, "callbackId"); if (!IsPurchaseRequestPending(callbackId)) return; + ProductReplacement replacement = null; + var replacementData = GetDict(data, "replacement"); + if (replacementData != null) + { + replacement = new ProductReplacement( + ProductReplacement.ParseMode(GetString(replacementData, "mode")), + GetString(replacementData, "oldProductId"), + GetString(replacementData, "oldPurchaseToken")); + } + var reply = CreatePurchaseReply(callbackId); try { - PurchaseController.PurchaseFromGooglePlay(productId, basePlanId, offerId, reply); + PurchaseController.PurchaseFromGooglePlay(productId, basePlanId, offerId, replacement, reply); } catch (Exception e) { diff --git a/Runtime/Models/Enums.cs b/Runtime/Models/Enums.cs index 3d165a8..0f808d9 100644 --- a/Runtime/Models/Enums.cs +++ b/Runtime/Models/Enums.cs @@ -292,7 +292,15 @@ public enum EventType StripeCheckoutStart, StripeCheckoutSubmit, StripeCheckoutComplete, - StripeCheckoutFail + StripeCheckoutFail, + // SuperwallKit 4.18 / superwall-android 2.10 (teleport events are iOS only). + TeleportOpen, + TeleportReturn, + CustomerCenterOpen, + CustomerCenterClose, + CustomerCenterAction, + CustomerCenterSurveyResponse, + CustomerCenterRefundRequest } [Serializable] diff --git a/Runtime/Models/ProductReplacement.cs b/Runtime/Models/ProductReplacement.cs new file mode 100644 index 0000000..e3a57bc --- /dev/null +++ b/Runtime/Models/ProductReplacement.cs @@ -0,0 +1,55 @@ +using System; + +namespace Superwall +{ + /// + /// An existing Google Play subscription that a purchase replaces (an upgrade, downgrade or + /// plan switch), instead of the purchase being bought alongside it. + /// Passed to + /// only when the paywall's Purchase action is set to replace a subscription and the user has an + /// active one that shares an entitlement with the product being purchased. + /// + [Serializable] + public class ProductReplacement + { + public enum ReplacementMode + { + /// Let Google Play decide, using the replacement mode configured in Play Console. + Default, + /// Replace now and charge the prorated price difference immediately (upgrades only). + /// Maps to ReplacementMode.CHARGE_PRORATED_PRICE. + ChargeProrated, + /// Replace now and charge the full price of the new product immediately. + /// Maps to ReplacementMode.CHARGE_FULL_PRICE. + ChargeFullPrice + } + + /// How the store should charge for the switch. + public ReplacementMode Mode; + + /// The subscription ID of the product being replaced, if known. + public string OldProductId; + + /// The Google Play purchase token of the subscription being replaced. Pass it as + /// SubscriptionUpdateParams.oldPurchaseToken (RevenueCat: oldProductId + + /// googleReplacementMode). + public string OldPurchaseToken; + + public ProductReplacement(ReplacementMode mode, string oldProductId, string oldPurchaseToken) + { + Mode = mode; + OldProductId = oldProductId; + OldPurchaseToken = oldPurchaseToken; + } + + internal static ReplacementMode ParseMode(string rawName) + { + switch (rawName) + { + case "charge_prorated": return ReplacementMode.ChargeProrated; + case "charge_full_price": return ReplacementMode.ChargeFullPrice; + default: return ReplacementMode.Default; + } + } + } +} diff --git a/Runtime/Models/ProductReplacement.cs.meta b/Runtime/Models/ProductReplacement.cs.meta new file mode 100644 index 0000000..b52193e --- /dev/null +++ b/Runtime/Models/ProductReplacement.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: b89cc485a2ba4f1faf34957b7ab6c691 \ No newline at end of file diff --git a/Tests/Runtime/BridgeContractTests.cs b/Tests/Runtime/BridgeContractTests.cs index 8f51761..f3ee1fb 100644 --- a/Tests/Runtime/BridgeContractTests.cs +++ b/Tests/Runtime/BridgeContractTests.cs @@ -490,6 +490,75 @@ private static string PurchaseRequest(string method, string callbackId) => } }); + private static string ReplacementPurchaseRequest(string callbackId) => + Json.Serialize(new Dictionary + { + { "method", "purchaseFromGooglePlay" }, + { "data", new Dictionary + { + { "productId", "com.app.annual" }, + { "basePlanId", "annual" }, + { "offerId", "" }, + { "callbackId", callbackId }, + { "replacement", new Dictionary + { + { "mode", "charge_prorated" }, + { "oldProductId", "com.app.monthly" }, + { "oldPurchaseToken", "token-123" } + } + } + } + } + }); + + [UnityTest] + public IEnumerator PurchaseController_ReplacementOverload_ReceivesReplacement() + { + BridgeCallbackHandler.Initialize(); + yield return null; + var controller = new MockReplacementPurchaseController(); + BridgeCallbackHandler.Instance.PurchaseController = controller; + + BridgeCallbackHandler.Instance.OnCallback(ReplacementPurchaseRequest("cb-replace")); + + Assert.AreEqual("com.app.annual", controller.ProductId); + Assert.AreEqual("annual", controller.BasePlanId); + Assert.IsNotNull(controller.Replacement); + Assert.AreEqual(ProductReplacement.ReplacementMode.ChargeProrated, controller.Replacement.Mode); + Assert.AreEqual("com.app.monthly", controller.Replacement.OldProductId); + Assert.AreEqual("token-123", controller.Replacement.OldPurchaseToken); + + // A normal purchase carries no replacement. + BridgeCallbackHandler.Instance.OnCallback(PurchaseRequest("purchaseFromGooglePlay", "cb-plain")); + Assert.AreEqual("com.app.monthly", controller.ProductId); + Assert.IsNull(controller.Replacement); + + controller.PendingPurchase(PurchaseResult.Purchased()); + yield return null; + BridgeCallbackHandler.Instance.PurchaseController = null; + } + + [UnityTest] + public IEnumerator PurchaseController_WithoutReplacementOverload_StillReceivesPurchase() + { + // Controllers written before 2.9.0 implement only the 4-argument method. The + // replacement overload's default implementation must route to it. + BridgeCallbackHandler.Initialize(); + yield return null; + var controller = new MockPurchaseControllerContract(); + BridgeCallbackHandler.Instance.PurchaseController = controller; + + BridgeCallbackHandler.Instance.OnCallback(ReplacementPurchaseRequest("cb-legacy")); + Assert.IsNotNull(controller.PendingPurchase, "Legacy controller must receive the request"); + + LogAssert.Expect(LogType.Log, + new Regex(@"RespondToPurchaseController\(callbackId=cb-legacy,.*""type"":""purchased""")); + controller.PendingPurchase(PurchaseResult.Purchased()); + yield return null; + + BridgeCallbackHandler.Instance.PurchaseController = null; + } + [UnityTest] public IEnumerator PurchaseController_GooglePlayThrows_RepliesFailed() { @@ -614,6 +683,25 @@ public void RestorePurchases(System.Action completion) } } + /// + /// Purchase controller that implements the replacement overload and records what it received. + /// + class MockReplacementPurchaseController : MockPurchaseControllerContract, IPurchaseController + { + public string ProductId; + public string BasePlanId; + public ProductReplacement Replacement; + + public void PurchaseFromGooglePlay(string productId, string basePlanId, string offerId, + ProductReplacement replacement, System.Action completion) + { + ProductId = productId; + BasePlanId = basePlanId; + Replacement = replacement; + PendingPurchase = completion; + } + } + /// /// Local mock that exposes the few hooks our contract tests need without bringing in the /// full surface MockSuperwallDelegate uses elsewhere. diff --git a/ci~/ios-harness/Package.swift b/ci~/ios-harness/Package.swift index 3e1ff53..ac68ec8 100644 --- a/ci~/ios-harness/Package.swift +++ b/ci~/ios-harness/Package.swift @@ -11,7 +11,7 @@ let package = Package( .library(name: "SuperwallBridge", targets: ["SuperwallBridge"]) ], dependencies: [ - .package(url: "https://github.com/superwall/Superwall-iOS", from: "4.12.10") + .package(url: "https://github.com/superwall/Superwall-iOS", exact: "4.19.1") ], targets: [ .target( diff --git a/package.json b/package.json index 6bb4db9..26611b4 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "com.superwall.sdk", "displayName": "Superwall SDK", - "version": "0.4.0", + "version": "0.4.1", "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": {},