From eeddc20bd562f36432d58eb4592cfbdba2a4e5e3 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 8 Jan 2026 17:40:37 +0000 Subject: [PATCH 1/2] feat: Add permission callbacks and preload events to Android SDK Co-authored-by: duncan --- content/docs/android/changelog.mdx | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/content/docs/android/changelog.mdx b/content/docs/android/changelog.mdx index bb4f8dc..57365ca 100644 --- a/content/docs/android/changelog.mdx +++ b/content/docs/android/changelog.mdx @@ -3,6 +3,17 @@ title: "Changelog" description: "Release notes for the Superwall Android SDK" --- +## 2.6.7 + +### Enhancements +- Adds permission granting and callbacks to/from paywalls +- Adds `PaywallPreloadStart` and `PaywallPreloadComplete` events + +### Fixes +- Fix handling of deep links when paywall is detached +- Enables permission granting from paywall and callbacks +- Fix crash when handling drawer style paywalls with 100% height + ## 2.6.6 ## Enhancements From 3f33b123a0a4f0cb9a75af9f3dbd0e1f49592321 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 8 Jan 2026 19:35:25 +0000 Subject: [PATCH 2/2] feat: Add request permissions from paywalls and customer info flow Co-authored-by: duncan --- .../docs/android/guides/advanced/meta.json | 1 + .../request-permissions-from-paywalls.mdx | 74 ++++++++++++++++ content/docs/android/index.mdx | 2 +- .../tracking-subscription-state.mdx | 40 +++++++++ .../docs/android/sdk-reference/Superwall.mdx | 33 ++++++++ .../sdk-reference/SuperwallDelegate.mdx | 84 ++++++++++++++++++- .../android/sdk-reference/SuperwallEvent.mdx | 39 ++++++++- content/docs/android/sdk-reference/index.mdx | 2 +- 8 files changed, 271 insertions(+), 4 deletions(-) create mode 100644 content/docs/android/guides/advanced/request-permissions-from-paywalls.mdx diff --git a/content/docs/android/guides/advanced/meta.json b/content/docs/android/guides/advanced/meta.json index 3b4222e..00ce388 100644 --- a/content/docs/android/guides/advanced/meta.json +++ b/content/docs/android/guides/advanced/meta.json @@ -6,6 +6,7 @@ "using-the-presentation-handler", "viewing-purchased-products", "custom-paywall-actions", + "request-permissions-from-paywalls", "observer-mode", "direct-purchasing", "game-controller-support", diff --git a/content/docs/android/guides/advanced/request-permissions-from-paywalls.mdx b/content/docs/android/guides/advanced/request-permissions-from-paywalls.mdx new file mode 100644 index 0000000..0d2850b --- /dev/null +++ b/content/docs/android/guides/advanced/request-permissions-from-paywalls.mdx @@ -0,0 +1,74 @@ +--- +title: "Request permissions from paywalls" +description: "Trigger Android runtime permission dialogs directly from a Superwall paywall action." +--- + +## Overview + +Use the **Request permission** action in the paywall editor when you want to gate features behind Android permissions without bouncing users back to native screens. When the user taps the element, the SDK: + +- Presents the corresponding Android system dialog. +- Emits analytics events (`permission_requested`, `permission_granted`, `permission_denied`). +- Sends the result back to the paywall so you can branch the UI (for example, swap a checklist item for a success state). + +## Add the action in the editor + +1. Open your paywall, select the button (or any element) that should prompt the permission, and set its action to **Request permission**. +2. Choose the permission you want to request. You can wire multiple buttons if you need to prime several permissions in a single flow. +3. Republish the paywall. No extra SDK configuration is required beyond having the proper `AndroidManifest.xml` entries. + +## Declare the permissions in `AndroidManifest.xml` + +| Editor option | `permission_type` sent from the paywall | Required manifest entries | Notes | +|---------------|-----------------------------------------|---------------------------|-------| +| Notifications | `notification` | `` (API 33+) | Devices below Android 13 do not require a runtime permission; the SDK reports `granted` immediately. | +| Location (Foreground) | `location` | `` | Also covers coarse location because FINE implies COARSE. | +| Location (Background) | `background_location` | Foreground entry above **and** `` (API 29+) | The SDK first ensures foreground access, then escalates to background. | +| Photos / Images | `read_images` | `` (API 33+) or `READ_EXTERNAL_STORAGE` for older OS versions | Automatically picks the right permission at runtime. | +| Videos | `read_video` | `` (API 33+) or `READ_EXTERNAL_STORAGE` pre-33 | | +| Contacts | `contacts` | `` | | +| Camera | `camera` | `` | | + +If a manifest entry is missing—or the permission is unsupported on the current OS level—the SDK responds with an `unsupported` status so you can show fallback copy. + +## Analytics and delegate callbacks + +Forward the new events through `SuperwallDelegate.handleSuperwallEvent` to keep your analytics platform and feature flags in sync: + +```kotlin +override fun handleSuperwallEvent(eventInfo: SuperwallEventInfo) { + when (val event = eventInfo.event) { + is SuperwallEvent.PermissionRequested -> { + analytics.track("permission_requested", mapOf( + "permission" to event.permissionName, + "paywall_id" to event.paywallIdentifier + )) + } + is SuperwallEvent.PermissionGranted -> { + FeatureFlags.unlock(event.permissionName) + } + is SuperwallEvent.PermissionDenied -> { + Alerts.showPermissionDeclinedSheet(event.permissionName) + } + else -> Unit + } +} +``` + +You can also log the newer [`customerInfoDidChange`](/android/sdk-reference/SuperwallDelegate#customerinfodidchangefrom-customerinfo-to-customerinfo) callback if the permission subsequently unlocks new paywalls that grant entitlements. + +## Status values returned to the paywall + +The paywall receives a `permission_result` web event with: + +- `granted` – The system dialog reported success (or no dialog was needed). +- `denied` – The user denied the request or previously denied it. +- `unsupported` – The platform or manifest doesn't allow the requested permission. + +Use Liquid or custom Javascript inside the paywall to branch on these statuses—for example, replace a “Grant notification access” button with a checkmark when the result equals `granted`. + +## Troubleshooting + +- Seeing `unsupported`? Double-check the manifest entries above and confirm the permission exists on the device's API level (for example, notification permissions only apply on Android 13+). +- Nothing happens when you tap the button? Ensure the action is set to **Request permission** in the released paywall version. +- Want to provide next steps after a denial? Listen for `PermissionDenied` in your delegate to deep-link users into Settings or show educational copy. diff --git a/content/docs/android/index.mdx b/content/docs/android/index.mdx index 32fee8f..83c4f0a 100644 --- a/content/docs/android/index.mdx +++ b/content/docs/android/index.mdx @@ -34,4 +34,4 @@ If you have feedback on any of our docs, please leave a rating and message at th If you have any issues with the SDK, please [open an issue on GitHub](https://github.com/superwall/superwall-android/issues). - \ No newline at end of file + \ No newline at end of file diff --git a/content/docs/android/quickstart/tracking-subscription-state.mdx b/content/docs/android/quickstart/tracking-subscription-state.mdx index 14b733c..6d7507f 100644 --- a/content/docs/android/quickstart/tracking-subscription-state.mdx +++ b/content/docs/android/quickstart/tracking-subscription-state.mdx @@ -101,6 +101,46 @@ fun ContentScreen() { } ``` +## Reading detailed purchase history (2.6.6+) + +When you need more context than `SubscriptionStatus` provides (for example, to show the full transaction history or mix web redemptions with Google Play receipts), subscribe to `Superwall.instance.customerInfo`. The flow emits a `CustomerInfo` object that merges device, web, and external purchase controller data. + +```kotlin +class BillingDashboardFragment : Fragment() { + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + viewLifecycleOwner.lifecycleScope.launch { + Superwall.instance.customerInfo.collect { info -> + val subscriptions = info.subscriptions.map { it.productId to it.expiresDate } + val nonSubscriptions = info.nonSubscriptions.map { it.productId to it.purchaseDate } + val entitlementIds = info.entitlements.filter { it.isActive }.map { it.id } + + renderCustomerInfo( + activeProducts = info.activeSubscriptionProductIds, + entitlements = entitlementIds, + subscriptions = subscriptions, + oneTimePurchases = nonSubscriptions + ) + } + } + } +} +``` + +Need the latest value immediately (for example, during cold start)? Call `Superwall.instance.getCustomerInfo()` to synchronously read the most recent snapshot before collecting the flow: + +```kotlin +val cached = Superwall.instance.getCustomerInfo() +renderCustomerInfo( + activeProducts = cached.activeSubscriptionProductIds, + entitlements = cached.entitlements.filter { it.isActive }.map { it.id }, + subscriptions = cached.subscriptions.map { it.productId to it.purchaseDate }, + oneTimePurchases = cached.nonSubscriptions.map { it.productId to it.purchaseDate } +) +``` + +After you start collecting, you can also watch for [`SuperwallDelegate.customerInfoDidChange(from:to:)`](/android/sdk-reference/SuperwallDelegate#customerinfodidchangefrom-customerinfo-to-customerinfo) to run analytics or sync other systems whenever purchases change. + ## Checking for specific entitlements If your app has multiple subscription tiers (e.g., Bronze, Silver, Gold), you can check for specific entitlements: diff --git a/content/docs/android/sdk-reference/Superwall.mdx b/content/docs/android/sdk-reference/Superwall.mdx index d47cf0b..72aaf63 100644 --- a/content/docs/android/sdk-reference/Superwall.mdx +++ b/content/docs/android/sdk-reference/Superwall.mdx @@ -132,6 +132,39 @@ Superwall.instance.setIntegrationAttributes( ) ``` +## Observe customer info (2.6.6+) + +Superwall now exposes purchase history and entitlement snapshots via a `StateFlow`. Each emission contains merged device, web, and external purchase controller data so you can react to subscription changes without wiring up your own polling layer. + +```kotlin +lifecycleScope.launch { + Superwall.instance.customerInfo.collect { info -> + val activeProductIds = info.activeSubscriptionProductIds + val activeEntitlementIds = info.entitlements + .filter { it.isActive } + .map { it.id } + + updateUi( + subscriptions = activeProductIds, + entitlements = activeEntitlementIds + ) + } +} +``` + +Need an immediate snapshot (for example during cold start)? Call `Superwall.instance.getCustomerInfo()` to synchronously read the latest cached value, or wire both together: + +```kotlin +val cachedInfo = Superwall.instance.getCustomerInfo() +render(cachedInfo) + +lifecycleScope.launch { + Superwall.instance.customerInfo.collect { render(it) } +} +``` + +Pair the flow with [`SuperwallDelegate.customerInfoDidChange(from:to:)`](/android/sdk-reference/SuperwallDelegate#customerinfodidchangefrom-customerinfo-to-customerinfo) when you need to mirror changes into analytics. + Java usage: ```java // Access the instance diff --git a/content/docs/android/sdk-reference/SuperwallDelegate.mdx b/content/docs/android/sdk-reference/SuperwallDelegate.mdx index 45cc0ee..8a92719 100644 --- a/content/docs/android/sdk-reference/SuperwallDelegate.mdx +++ b/content/docs/android/sdk-reference/SuperwallDelegate.mdx @@ -21,6 +21,13 @@ interface SuperwallDelegate { from: SubscriptionStatus, to: SubscriptionStatus ) {} + + fun customerInfoDidChange( + from: CustomerInfo, + to: CustomerInfo + ) {} + + fun userAttributesDidChange(newAttributes: Map) {} fun handleSuperwallEvent(eventInfo: SuperwallEventInfo) {} @@ -55,12 +62,37 @@ public interface SuperwallDelegateJava { SubscriptionStatus from, SubscriptionStatus to ) {} + + default void customerInfoDidChange( + CustomerInfo from, + CustomerInfo to + ) {} + + default void userAttributesDidChange(Map newAttributes) {} default void handleSuperwallEvent(SuperwallEventInfo eventInfo) {} default void handleCustomPaywallAction(String name) {} - // ... other methods + default void willDismissPaywall(PaywallInfo paywallInfo) {} + + default void willPresentPaywall(PaywallInfo paywallInfo) {} + + default void didDismissPaywall(PaywallInfo paywallInfo) {} + + default void didPresentPaywall(PaywallInfo paywallInfo) {} + + default void paywallWillOpenURL(String url) {} + + default void paywallWillOpenDeepLink(String url) {} + + default void handleLog( + LogLevel level, + LogScope scope, + String message, + Map info, + Throwable error + ) {} } ``` @@ -84,6 +116,16 @@ All methods are optional to implement. Key methods include: description: "Called when user taps elements with `data-pw-custom` tags.", required: true, }, + userAttributesDidChange: { + type: "newAttributes: Map", + description: "Called whenever paywall actions mutate user attributes (for example, forms or surveys).", + required: true, + }, + customerInfoDidChange: { + type: "from: CustomerInfo, to: CustomerInfo", + description: "Raised when Superwall merges device, web, and external purchase data into a new CustomerInfo snapshot.", + required: true, + }, willPresentPaywall: { type: "paywallInfo: PaywallInfo", description: "Called before paywall presentation.", @@ -135,6 +177,32 @@ override fun subscriptionStatusDidChange( } ``` +Mirror merged purchase history: +```kotlin +override fun customerInfoDidChange( + from: CustomerInfo, + to: CustomerInfo +) { + if (from.activeSubscriptionProductIds != to.activeSubscriptionProductIds) { + Analytics.track("customer_info_updated", mapOf( + "old_products" to from.activeSubscriptionProductIds.joinToString(), + "new_products" to to.activeSubscriptionProductIds.joinToString() + )) + } + + refreshEntitlementBadge(to.entitlements.filter { it.isActive }.map { it.id }) +} +``` + +Capture remote attribute changes: +```kotlin +override fun userAttributesDidChange(newAttributes: Map) { + // Paywall forms or surveys can set attributes directly. + // Forward them to your analytics platform or local cache. + analytics.identify(newAttributes) +} +``` + Forward analytics events: ```kotlin override fun handleSuperwallEvent(eventInfo: SuperwallEventInfo) { @@ -200,5 +268,19 @@ public class MainActivity extends AppCompatActivity implements SuperwallDelegate System.out.println("Subscription changed from " + from + " to " + to); updateUI(to); } + + @Override + public void customerInfoDidChange( + CustomerInfo from, + CustomerInfo to + ) { + Logger.i("Superwall", "Customer info updated: " + to.getActiveSubscriptionProductIds()); + syncUserPurchases(to); + } + + @Override + public void userAttributesDidChange(Map newAttributes) { + analytics.identify(newAttributes); + } } ``` diff --git a/content/docs/android/sdk-reference/SuperwallEvent.mdx b/content/docs/android/sdk-reference/SuperwallEvent.mdx index 3996729..ec91ebb 100644 --- a/content/docs/android/sdk-reference/SuperwallEvent.mdx +++ b/content/docs/android/sdk-reference/SuperwallEvent.mdx @@ -106,4 +106,41 @@ This is a sealed class that represents different event types. Events are receive ## Usage -These events are received via [`SuperwallDelegate.handleSuperwallEvent(eventInfo)`](/android/sdk-reference/SuperwallDelegate) for forwarding to your analytics platform. \ No newline at end of file +These events are received via [`SuperwallDelegate.handleSuperwallEvent(eventInfo)`](/android/sdk-reference/SuperwallDelegate) for forwarding to your analytics platform. + +## New events in 2.6.6+ + +- `CustomerInfoDidChange` fires whenever the SDK merges device, web, and external purchase controller data into a new [`CustomerInfo`](/android/quickstart/tracking-subscription-state#reading-detailed-purchase-history-2-6-6) snapshot. The event includes the previous and next objects so you can diff entitlements or transactions. +- `PermissionRequested`, `PermissionGranted`, and `PermissionDenied` correspond to the new **Request permission** action in the paywall editor. Each event carries the `permissionName` and `paywallIdentifier`. +- `PaywallPreloadStart` and `PaywallPreloadComplete` track when preloading kicks off and how many paywalls finished warming the cache. + +Example handler: + +```kotlin +override fun handleSuperwallEvent(eventInfo: SuperwallEventInfo) { + when (val event = eventInfo.event) { + is SuperwallEvent.CustomerInfoDidChange -> { + analytics.track("customer_info_updated", mapOf( + "old_products" to event.from.activeSubscriptionProductIds.joinToString(), + "new_products" to event.to.activeSubscriptionProductIds.joinToString() + )) + } + is SuperwallEvent.PermissionRequested -> { + analytics.track("permission_requested", mapOf( + "permission" to event.permissionName, + "paywall_id" to event.paywallIdentifier + )) + } + is SuperwallEvent.PermissionGranted -> { + featureFlags.unlock(event.permissionName) + } + is SuperwallEvent.PermissionDenied -> { + showPermissionHelpSheet(event.permissionName) + } + is SuperwallEvent.PaywallPreloadComplete -> { + Logger.i("Superwall", "Preloaded ${event.paywallCount} paywalls") + } + else -> Unit + } +} +``` \ No newline at end of file diff --git a/content/docs/android/sdk-reference/index.mdx b/content/docs/android/sdk-reference/index.mdx index 2004395..4073e85 100644 --- a/content/docs/android/sdk-reference/index.mdx +++ b/content/docs/android/sdk-reference/index.mdx @@ -15,4 +15,4 @@ If you have feedback on any of our docs, please leave a rating and message at th If you have any issues with the SDK, please [open an issue on GitHub](https://github.com/superwall/superwall-android/issues). - \ No newline at end of file + \ No newline at end of file