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
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
32 changes: 32 additions & 0 deletions content/docs/kmp/changelog.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
---
title: "Changelog"
description: "Release notes for the Superwall KMP SDK"
---

# CHANGELOG

The changelog for `Superwall-KMP`. Also see the [releases](https://github.com/superwall/Superwall-KMP/releases) on GitHub.

## 0.1.1

## Enhancements
- Adds threading improvements to reduce main thread load

## 0.1.0

Initial release of the Kotlin Multiplatform SDK for Superwall.

## Enhancements
- Adds `com.superwall.sdk:superwall-kmp`, a Kotlin Multiplatform wrapper over the native Superwall SDKs. The entire public API lives in `commonMain` — no platform types leak into it, and `Superwall.configure` has an identical signature on both platforms (no `Context` parameter on Android).
- Android support (`minSdk 26`) wrapping `com.superwall.sdk:superwall-android` 2.8.0. The dependency is bundled transitively and an `androidx.startup` initializer captures the `Application`, so integration is a single Gradle dependency.
- iOS support (iOS 14+, `iosArm64`/`iosSimulatorArm64`/`iosX64`) forwarding through **SuperwallKMPBridge**, a self-authored `@objc` Swift facade over SuperwallKit iOS 4.16.1 (pinned exactly). The bridge destructures Swift-only constructs — enum associated values, structs, `async` — into ObjC-visible envelopes consumed via cinterop.
- Adds `Superwall.register(placement:params:handler:feature:)` for gating features behind paywalls, with a `PaywallPresentationHandler` exposing `onPresent`, `onDismiss`, `onError` and `onSkip`.
- Adds `Superwall.subscriptionStatusFlow`, a `StateFlow` that is collectable before `configure` (seeded with `SubscriptionStatus.Unknown`) and emits on the main thread.
- Adds `PurchaseController` for apps that own their purchase logic, with separate `purchaseFromAppStore` and `purchaseFromGooglePlay` entry points.
- Adds `SuperwallDelegate` covering the paywall presentation lifecycle, subscription-status changes, deep links, URLs, custom paywall actions, logging, and link redemption.
- Adds `SuperwallOptions` (including `PaywallOptions` and `TestModeBehavior`), user identity (`identify`, `reset`, user attributes), and deep-link handling via `handleDeepLink`.
- Adds `configureAndAwait`, a suspending twin of `configure`, plus the `Superwall.isConfigured` flag for ordering calls.

## Notes
- There is no pre-configure call queue: most members throw `SuperwallError.NotConfigured` before `configure`. Guard-exempt members are `handleDeepLink`, the flows, `delegate`, and the introspection properties.
- iOS integration is two steps — the Gradle dependency plus the `SuperwallKMPBridge` Swift package — and the Kotlin framework must be exported as `isStatic = true`, because it does not embed the bridge binary. See the [README](README.md#ios) for details.
169 changes: 169 additions & 0 deletions content/docs/kmp/guides/3rd-party-analytics.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
---
title: "3rd Party Analytics"
description: "Forward Superwall events to your own analytics stack."
---

<Warning>

**Beta**

The KMP SDK is in beta and its API may change between releases.

</Warning>

Superwall tracks events internally: paywalls opening, transactions completing, placements firing. You can forward all of them to your own analytics provider through `SuperwallDelegate`.

## Forwarding events

Implement `handleSuperwallEvent`:

```kotlin
import com.superwall.sdk.kmp.SuperwallDelegate
import com.superwall.sdk.kmp.models.events.SuperwallEventInfo

class AnalyticsDelegate : SuperwallDelegate {
override fun handleSuperwallEvent(eventInfo: SuperwallEventInfo) {
analytics.track(
name = eventInfo.eventType.name,
properties = eventInfo.params.orEmpty(),
)
}
}

Superwall.delegate = AnalyticsDelegate()
```

<Warning>

On Android, `handleSuperwallEvent` has **no guaranteed thread**. It adds no dispatcher hop, so it runs wherever the SDK tracked the event from. Usually that is a background thread. On iOS it is always main.

Two consequences: do not touch UI from it without hopping to main yourself, and do not assume it is off the main thread either. Keep the body cheap and non-blocking. See [Platform differences](/kmp/guides/platform-differences#delegate-threading).

</Warning>

## The event envelope

`SuperwallEventInfo` is a flat envelope. `eventType` identifies the event, and only the fields relevant to that event are non-null:

```kotlin
import com.superwall.sdk.kmp.models.events.EventType

override fun handleSuperwallEvent(eventInfo: SuperwallEventInfo) {
when (eventInfo.eventType) {
EventType.PAYWALL_OPEN -> {
analytics.track("paywall_open", mapOf(
"paywall_id" to eventInfo.paywallInfo?.identifier,
"paywall_name" to eventInfo.paywallInfo?.name,
))
}
EventType.TRANSACTION_COMPLETE -> {
analytics.track("purchase", mapOf(
"product_id" to eventInfo.product?.productIdentifier,
))
}
else -> analytics.track(eventInfo.eventType.name, eventInfo.params.orEmpty())
}
}
```

Commonly useful fields on the envelope:

<TypeTable
type={{
eventType: {
description: "Which event this is.",
type: "EventType",
required: true,
},
params: {
description: "Parameters associated with the event.",
type: "Map<String, Any?>?",
},
placementName: {
description: "The placement that produced the event, where applicable.",
type: "String?",
},
paywallInfo: {
description: "The paywall involved in the event.",
type: "PaywallInfo?",
},
transaction: {
description: "The store transaction involved in the event.",
type: "StoreTransaction?",
},
product: {
description: "The store product involved in the event.",
type: "StoreProduct?",
},
error: {
description: "A description of the error, for failure events.",
type: "String?",
},
}}
/>

## Sending your identifiers to Superwall

The reverse direction matters too, since Superwall can attribute better if it knows your analytics identifiers:

```kotlin
import com.superwall.sdk.kmp.models.events.IntegrationAttribute

Superwall.setIntegrationAttributes(
mapOf(
IntegrationAttribute.AMPLITUDE_USER_ID to amplitude.userId,
IntegrationAttribute.MIXPANEL_DISTINCT_ID to mixpanel.distinctId,
IntegrationAttribute.APPSFLYER_ID to appsFlyer.uid,
),
)
```

Supported providers include Adjust, Amplitude, AppsFlyer, Braze, OneSignal, Meta, Firebase, Singular, Iterable, Mixpanel, mParticle, CleverTap, Airship, Kochava, Tenjin, PostHog, Customer.io, and Appstack. Passing `null` for a value removes it.

<Note>
`IntegrationAttribute.FIREBASE_INSTALLATION_ID` is **iOS only**. Setting it on Android is skipped
and logs a warning. Every other attribute works on both platforms.
</Note>

## Capturing SDK logs

`handleLog` gives you the SDK's own log stream:

```kotlin
override fun handleLog(
level: LogLevel,
scope: LogScope,
message: String?,
info: Map<String, Any?>?,
error: String?,
) {
if (level == LogLevel.ERROR) {
crashReporter.log("Superwall/${scope.name}: $message")
}
}
```

<Warning>
`handleLog` fires for **every** internal log line, regardless of the configured log level, which is
hundreds of calls for a single `register`. Filter early, keep the body cheap, and never block in
it.
</Warning>

## Controlling what Superwall collects

To limit what leaves the device, set `eventTrackingBehavior`:

```kotlin
Superwall.configure(
apiKey = "pk_your_api_key",
options = SuperwallOptions(
eventTrackingBehavior = EventTrackingBehavior.SUPERWALL_ONLY,
),
)
```

| Value | Effect |
| --- | --- |
| `ALL` | Everything is tracked. The default. |
| `SUPERWALL_ONLY` | Only internal Superwall events; your tracking calls, trigger-fire events, and user-attribute updates are suppressed. |
| `NONE` | Nothing is sent to Superwall's servers. |
172 changes: 172 additions & 0 deletions content/docs/kmp/guides/advanced-configuration.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
---
title: "Purchases and Subscription Status"
description: "Own your purchase logic with a PurchaseController."
---

<Warning>

**Beta**

The KMP SDK is in beta and its API may change between releases.

</Warning>

By default Superwall handles purchases and subscription status for you, and most apps should leave it that way. If you already have purchase logic (your own billing stack, or a provider like RevenueCat), you can take it over with a `PurchaseController`.

<Note>
Passing a `PurchaseController` means **you** own subscription status. Superwall will not set it for
you, so you must set `Superwall.subscriptionStatus` yourself after every purchase, restore, and app
launch.
</Note>

## The interface

One interface covers both stores. Each platform invokes only its own store's method, so you implement all three and only two ever run on a given device.

```kotlin
import com.superwall.sdk.kmp.PurchaseController
import com.superwall.sdk.kmp.models.results.PurchaseResult
import com.superwall.sdk.kmp.models.results.RestorationResult

class MyPurchaseController : PurchaseController {

override suspend fun purchaseFromAppStore(productId: String): PurchaseResult {
// Your StoreKit logic
return PurchaseResult.Purchased
}

override suspend fun purchaseFromGooglePlay(
productId: String,
basePlanId: String?,
offerId: String?,
): PurchaseResult {
// Your Play Billing logic
return PurchaseResult.Purchased
}

override suspend fun restorePurchases(): RestorationResult {
// Your restore logic
return RestorationResult.Restored
}
}
```

All three are `suspend` functions, so you can do the real asynchronous work inline without callbacks.

<Note>
`purchaseFromGooglePlay` takes `basePlanId` and `offerId` alongside the product id, because Play
models subscriptions with base plans and offers. iOS has no equivalent, which is why the two entry
points are separate rather than one method with platform-shaped arguments.
</Note>

## Handling every case

`PurchaseResult` is a sealed interface, so handle all four:

| Result | When |
| --- | --- |
| `Purchased` | The product was purchased |
| `Cancelled` | The user cancelled. StoreKit 2's `.userCancelled`, or RevenueCat's `userCancelled == true` |
| `Pending` | Awaiting action. StoreKit 1's `.deferred`, or RevenueCat's `paymentPendingError` |
| `Failed(error)` | Anything else |

```kotlin
override suspend fun purchaseFromAppStore(productId: String): PurchaseResult {
return try {
when (val outcome = myStore.purchase(productId)) {
is Success -> PurchaseResult.Purchased
is UserCancelled -> PurchaseResult.Cancelled
is Deferred -> PurchaseResult.Pending
is Error -> PurchaseResult.Failed(outcome.message)
}
} catch (e: Exception) {
PurchaseResult.Failed(e.message ?: "Unknown error")
}
}
```

There are convenience factories if you prefer them: `PurchaseResult.purchased()`, `.cancelled()`, `.pending()`, `.failed(error)`.

`RestorationResult` has two cases, `Restored` and `Failed(error)`.

<Warning>
`RestorationResult.Restored` means the restore completed **without errors**, not that the user has
an active subscription. Set subscription status from the entitlements you actually resolved, not
from the fact that restore succeeded.
</Warning>

## Wire it up

Pass the controller at configure time:

```kotlin
Superwall.configure(
apiKey = "pk_your_api_key",
purchaseController = MyPurchaseController(),
)
```

<Note>
A second `configure` call will **not** install a different purchase controller, because repeat calls are
a no-op. Set it on the first call.
</Note>

## Keep subscription status current

This is the part that is easy to forget. After any purchase, restore, or launch-time entitlement check, tell Superwall what you found:

```kotlin
import com.superwall.sdk.kmp.models.entitlements.Entitlement
import com.superwall.sdk.kmp.models.entitlements.SubscriptionStatus

fun syncSubscriptionStatus(activeEntitlementIds: Set<String>) {
Superwall.subscriptionStatus = if (activeEntitlementIds.isEmpty()) {
SubscriptionStatus.Inactive
} else {
SubscriptionStatus.Active(
activeEntitlementIds.map { Entitlement(id = it) }.toSet(),
)
}
}
```

`Entitlement` requires only an `id`; the remaining fields have defaults.

<Warning>
Do not leave the status at `SubscriptionStatus.Unknown` once you know the answer. Gated paywalls
and your own UI both branch on it.
</Warning>

## Consumables on Android

Play Billing requires consuming a purchase before the same product can be bought again:

```kotlin
val token = Superwall.consume(purchaseToken)
```

This is an **Android** operation. On iOS it echoes the token back unchanged, so it is safe to call from shared code without a platform check.

## Restoring

`Superwall.restorePurchases()` routes through your controller's `restorePurchases()` when one is configured, and through the native SDK otherwise:

```kotlin
when (val result = Superwall.restorePurchases()) {
is RestorationResult.Restored -> println("Restored")
is RestorationResult.Failed -> println("Failed: ${result.error}")
}
```

Failure stays in the return type and does not throw.

## Observing purchases you did not make

If you want Superwall to see transactions that happen outside of a paywall without taking over purchasing entirely, skip the controller and set an option instead:

```kotlin
Superwall.configure(
apiKey = "pk_your_api_key",
options = SuperwallOptions(shouldObservePurchases = true),
)
```
Loading
Loading