Skip to content
Merged
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
6 changes: 3 additions & 3 deletions .github/workflows/native.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
17 changes: 17 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
14 changes: 12 additions & 2 deletions Editor/SuperwallPostBuildProcessor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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))
{
Expand Down
4 changes: 2 additions & 2 deletions Plugins/Android/SuperwallSDK.androidlib/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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'
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -1225,12 +1225,24 @@ class SuperwallUnityBridge {
override fun removeEldestEntry(eldest: MutableMap.MutableEntry<String, Unit>?): 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<PurchaseResult>()
pendingPurchases[callbackId] = deferred
Expand All @@ -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.
Expand Down
22 changes: 22 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<PurchaseResult> 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
Expand Down
14 changes: 14 additions & 0 deletions Runtime/IPurchaseController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,20 @@ public interface IPurchaseController
{
void PurchaseFromAppStore(string productId, Action<PurchaseResult> completion);
void PurchaseFromGooglePlay(string productId, string basePlanId, string offerId, Action<PurchaseResult> completion);

/// <summary>
/// Called on Android for every Google Play purchase. <paramref name="replacement"/> 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 <c>SubscriptionUpdateParams</c> so the user isn't charged for a second subscription.
/// The default implementation ignores <paramref name="replacement"/> and calls
/// <see cref="PurchaseFromGooglePlay(string, string, string, Action{PurchaseResult})"/>,
/// so existing controllers keep working without changes.
/// </summary>
void PurchaseFromGooglePlay(string productId, string basePlanId, string offerId,
ProductReplacement replacement, Action<PurchaseResult> completion)
=> PurchaseFromGooglePlay(productId, basePlanId, offerId, completion);

void RestorePurchases(Action<RestorationResult> completion);
}
}
12 changes: 11 additions & 1 deletion Runtime/Internal/BridgeCallbackHandler.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1002,10 +1002,20 @@ private void HandlePurchaseFromGooglePlay(Dictionary<string, object> 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)
{
Expand Down
10 changes: 9 additions & 1 deletion Runtime/Models/Enums.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
55 changes: 55 additions & 0 deletions Runtime/Models/ProductReplacement.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
using System;

namespace Superwall
{
/// <summary>
/// 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 <see cref="IPurchaseController.PurchaseFromGooglePlay(string, string, string, ProductReplacement, Action{PurchaseResult})"/>
/// 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.
/// </summary>
[Serializable]
public class ProductReplacement
{
public enum ReplacementMode
{
/// <summary>Let Google Play decide, using the replacement mode configured in Play Console.</summary>
Default,
/// <summary>Replace now and charge the prorated price difference immediately (upgrades only).
/// Maps to <c>ReplacementMode.CHARGE_PRORATED_PRICE</c>.</summary>
ChargeProrated,
/// <summary>Replace now and charge the full price of the new product immediately.
/// Maps to <c>ReplacementMode.CHARGE_FULL_PRICE</c>.</summary>
ChargeFullPrice
}

/// <summary>How the store should charge for the switch.</summary>
public ReplacementMode Mode;

/// <summary>The subscription ID of the product being replaced, if known.</summary>
public string OldProductId;

/// <summary>The Google Play purchase token of the subscription being replaced. Pass it as
/// <c>SubscriptionUpdateParams.oldPurchaseToken</c> (RevenueCat: <c>oldProductId</c> +
/// <c>googleReplacementMode</c>).</summary>
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;
}
}
}
}
2 changes: 2 additions & 0 deletions Runtime/Models/ProductReplacement.cs.meta

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
Loading