Skip to content
Open
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
8 changes: 8 additions & 0 deletions superwall/src/main/java/com/superwall/sdk/Superwall.kt
Original file line number Diff line number Diff line change
Expand Up @@ -1417,6 +1417,14 @@ class Superwall(
message = "Permission requested: ${paywallEvent.permissionType.rawValue}",
)
}

is PaywallWebEvent.RequestCallback -> {
Logger.debug(
LogLevel.debug,
LogScope.paywallView,
message = "Custom callback requested: ${paywallEvent.name}",
)
}
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ import com.superwall.sdk.network.device.DeviceInfo
import com.superwall.sdk.network.session.CustomHttpUrlConnection
import com.superwall.sdk.paywall.manager.PaywallManager
import com.superwall.sdk.paywall.manager.PaywallViewCache
import com.superwall.sdk.paywall.presentation.CustomCallbackRegistry
import com.superwall.sdk.paywall.presentation.PaywallInfo
import com.superwall.sdk.paywall.presentation.dismiss
import com.superwall.sdk.paywall.presentation.get_presentation_result.internallyGetPresentationResult
Expand Down Expand Up @@ -190,6 +191,7 @@ class DependencyContainer(
val googleBillingWrapper: GoogleBillingWrapper
internal val reviewManager: ReviewManager
internal val userPermissions: UserPermissions
internal val customCallbackRegistry: CustomCallbackRegistry

var entitlements: Entitlements
internal lateinit var customerInfoManager: CustomerInfoManager
Expand Down Expand Up @@ -597,6 +599,7 @@ class DependencyContainer(
}

userPermissions = UserPermissionsImpl(context)
customCallbackRegistry = CustomCallbackRegistry()

deepLinkRouter =
DeepLinkRouter(
Expand Down Expand Up @@ -709,6 +712,7 @@ class DependencyContainer(
},
userPermissions = userPermissions,
getActivity = { activityProvider?.getCurrentActivity() },
customCallbackRegistry = customCallbackRegistry,
)

val state =
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
package com.superwall.sdk.paywall.presentation

/**
* Defines how the paywall waits for a custom callback response.
*/
enum class CustomCallbackBehavior(
val rawValue: String,
) {
/**
* The paywall waits for the callback to complete before continuing
* the tap action chain.
*/
BLOCKING("blocking"),

/**
* The paywall continues immediately; the response still triggers
* onSuccess/onFailure handlers in the paywall.
*/
NON_BLOCKING("non-blocking"),
;

companion object {
fun fromRaw(rawValue: String): CustomCallbackBehavior? = entries.find { it.rawValue == rawValue }
}
}

/**
* Represents a custom callback request from the paywall.
*
* @property name The name of the callback being requested.
* @property variables Optional key-value pairs passed from the paywall.
* Values are type-preserved (string/number/boolean).
*/
data class CustomCallback(
val name: String,
val variables: Map<String, Any>?,
)

/**
* The result status of a custom callback.
*/
enum class CustomCallbackResultStatus(
val rawValue: String,
) {
SUCCESS("success"),
FAILURE("failure"),
}

/**
* The result to return from a custom callback handler.
*
* @property status Whether the callback succeeded or failed.
* Determines which branch (onSuccess/onFailure) executes in the paywall.
* @property data Optional key-value pairs to return to the paywall.
* Values are type-preserved and accessible as callbacks.<name>.data.<key>.
*/
data class CustomCallbackResult(
val status: CustomCallbackResultStatus,
val data: Map<String, Any>? = null,
) {
companion object {
/**
* Creates a success result with optional data.
*/
fun success(data: Map<String, Any>? = null): CustomCallbackResult = CustomCallbackResult(CustomCallbackResultStatus.SUCCESS, data)

/**
* Creates a failure result with optional data.
*/
fun failure(data: Map<String, Any>? = null): CustomCallbackResult = CustomCallbackResult(CustomCallbackResultStatus.FAILURE, data)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
package com.superwall.sdk.paywall.presentation

import java.util.concurrent.ConcurrentHashMap

/**
* Registry for custom callback handlers associated with paywall presentations.
*
* Handlers are stored by paywall identifier and should be cleaned up when
* the paywall is dismissed to prevent memory leaks.
*/
class CustomCallbackRegistry {
private val handlers = ConcurrentHashMap<String, suspend (CustomCallback) -> CustomCallbackResult>()

/**
* Registers a custom callback handler for a paywall.
*
* @param paywallIdentifier The unique identifier of the paywall
* @param handler The callback handler to register
*/
fun register(
paywallIdentifier: String,
handler: suspend (CustomCallback) -> CustomCallbackResult,
) {
handlers[paywallIdentifier] = handler
}

/**
* Unregisters the custom callback handler for a paywall.
* Should be called when the paywall is dismissed.
*
* @param paywallIdentifier The unique identifier of the paywall
*/
fun unregister(paywallIdentifier: String) {
handlers.remove(paywallIdentifier)
}

/**
* Gets the custom callback handler for a paywall, if registered.
*
* @param paywallIdentifier The unique identifier of the paywall
* @return The handler, or null if not registered
*/
fun getHandler(paywallIdentifier: String): (suspend (CustomCallback) -> CustomCallbackResult)? = handlers[paywallIdentifier]

/**
* Clears all registered handlers.
*/
fun clear() {
handlers.clear()
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,9 @@ class PaywallPresentationHandler {
// A block called when a paywall is skipped, but no error has occurred
internal var onSkipHandler: ((PaywallSkippedReason) -> Unit)? = null

// A block called when the paywall requests a custom callback
internal var onCustomCallbackHandler: (suspend (CustomCallback) -> CustomCallbackResult)? = null

// Sets the handler that will be called when the paywall did present
fun onPresent(handler: (PaywallInfo) -> Unit) {
onPresentHandler = handler
Expand All @@ -35,4 +38,35 @@ class PaywallPresentationHandler {
fun onSkip(handler: (PaywallSkippedReason) -> Unit) {
onSkipHandler = handler
}

/**
* Sets the handler that will be called when the paywall requests a custom callback.
*
* Custom callbacks allow paywalls to request arbitrary actions from the app and
* receive results that determine which branch (onSuccess/onFailure) executes.
*
* @param handler A function that receives a [CustomCallback] containing the callback
* name and optional variables, and returns a [CustomCallbackResult]
* indicating success/failure with optional data.
*
* Example:
* ```
* handler.onCustomCallback { callback ->
* when (callback.name) {
* "validate_email" -> {
* val email = callback.variables?.get("email") as? String
* if (isValidEmail(email)) {
* CustomCallbackResult.success(mapOf("validated" to true))
* } else {
* CustomCallbackResult.failure(mapOf("error" to "Invalid email"))
* }
* }
* else -> CustomCallbackResult.failure()
* }
* }
* ```
*/
fun onCustomCallback(handler: suspend (CustomCallback) -> CustomCallbackResult) {
onCustomCallbackHandler = handler
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,8 @@ internal data class RegisterContext(
val collectionScope: CoroutineScope,
val serialTaskManager: SerialTaskManager,
val trackAndPresentContext: TrackAndPresentContext,
val registerCustomCallback: ((String, suspend (CustomCallback) -> CustomCallbackResult) -> Unit)? = null,
val unregisterCustomCallback: ((String) -> Unit)? = null,
)

internal data class RegisterRequest(
Expand All @@ -142,11 +144,20 @@ internal fun registerPaywall(
withErrorTracking {
when (state) {
is PaywallState.Presented -> {
// Register custom callback handler if provided
request.handler?.onCustomCallbackHandler?.let { callbackHandler ->
context.registerCustomCallback?.invoke(
state.paywallInfo.identifier,
callbackHandler,
)
}
request.handler?.onPresentHandler?.invoke(state.paywallInfo)
}

is PaywallState.Dismissed -> {
val (paywallInfo, paywallResult) = state
// Unregister custom callback handler
context.unregisterCustomCallback?.invoke(paywallInfo.identifier)
request.handler?.onDismissHandler?.invoke(paywallInfo, paywallResult)
when (paywallResult) {
is Purchased, is Restored -> {
Expand Down Expand Up @@ -313,6 +324,12 @@ private fun Superwall.internallyRegister(
isPaywallPresented = { isPaywallPresented },
present = { request, publisher -> internallyPresent(request, publisher) },
),
registerCustomCallback = { paywallId, callbackHandler ->
dependencyContainer.customCallbackRegistry.register(paywallId, callbackHandler)
},
unregisterCustomCallback = { paywallId ->
dependencyContainer.customCallbackRegistry.unregister(paywallId)
},
)

val registerRequest =
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import com.superwall.sdk.logger.LogLevel
import com.superwall.sdk.logger.LogScope
import com.superwall.sdk.logger.Logger
import com.superwall.sdk.models.paywall.LocalNotificationType
import com.superwall.sdk.paywall.presentation.CustomCallbackBehavior
import com.superwall.sdk.permissions.PermissionType
import com.superwall.sdk.storage.core_data.convertFromJsonElement
import kotlinx.serialization.json.Json
Expand Down Expand Up @@ -114,6 +115,13 @@ sealed class PaywallMessage {
val permissionType: PermissionType,
val requestId: String,
) : PaywallMessage()

data class RequestCallback(
val requestId: String,
val name: String,
val behavior: CustomCallbackBehavior,
val variables: Map<String, Any>?,
) : PaywallMessage()
}

fun parseWrappedPaywallMessages(jsonString: String): Result<WrappedPaywallMessages> =
Expand Down Expand Up @@ -220,6 +228,31 @@ private fun parsePaywallMessage(json: JsonObject): PaywallMessage {
)
}

"request_callback" -> {
val requestId =
json["request_id"]?.jsonPrimitive?.contentOrNull
?: throw IllegalArgumentException("request_callback missing request_id")
val name =
json["name"]?.jsonPrimitive?.contentOrNull
?: throw IllegalArgumentException("request_callback missing name")
val behaviorRaw =
json["behavior"]?.jsonPrimitive?.contentOrNull
?: throw IllegalArgumentException("request_callback missing behavior")
val behavior =
CustomCallbackBehavior.fromRaw(behaviorRaw)
?: throw IllegalArgumentException("Unknown behavior: $behaviorRaw")
val variables =
json["variables"]?.jsonObject?.let { variablesJson ->
variablesJson.convertFromJsonElement() as? Map<String, Any>
}
PaywallMessage.RequestCallback(
requestId = requestId,
name = name,
behavior = behavior,
variables = variables,
)
}

else -> {
throw IllegalArgumentException("Unknown event name: $eventName")
}
Expand Down
Loading
Loading