From cbca7f5f32dc1f6a67cf96a2aae3fe158d06e933 Mon Sep 17 00:00:00 2001 From: Ian Rumac Date: Fri, 25 Jul 2025 17:06:04 +0200 Subject: [PATCH 1/2] Add review requests --- .../com/superwall/superapp/MainApplication.kt | 2 +- .../superwall/superapp/test/UITestHandler.kt | 4 +--- .../sdk/network/device/DeviceHelper.kt | 1 + .../templating/models/DeviceTemplate.kt | 1 + .../src/main/res/layout/activity_dialog.xml | 19 +++++++++++++++++++ .../templating/models/DeviceTemplateTest.kt | 2 ++ 6 files changed, 25 insertions(+), 4 deletions(-) create mode 100644 superwall/src/main/res/layout/activity_dialog.xml diff --git a/app/src/main/java/com/superwall/superapp/MainApplication.kt b/app/src/main/java/com/superwall/superapp/MainApplication.kt index a856c267c..e86b846ef 100644 --- a/app/src/main/java/com/superwall/superapp/MainApplication.kt +++ b/app/src/main/java/com/superwall/superapp/MainApplication.kt @@ -74,7 +74,7 @@ class MainApplication : fun configureWithAutomaticInitialization() { Superwall.configure( this, - Keys.CONSTANT_API_KEY, + "pk_3945ba4eca2392f731539a671b21a15fca36a50d9d927401", options = SuperwallOptions().apply { logging.level = LogLevel.info diff --git a/app/src/main/java/com/superwall/superapp/test/UITestHandler.kt b/app/src/main/java/com/superwall/superapp/test/UITestHandler.kt index 0a3baf558..b0eabbf52 100644 --- a/app/src/main/java/com/superwall/superapp/test/UITestHandler.kt +++ b/app/src/main/java/com/superwall/superapp/test/UITestHandler.kt @@ -157,9 +157,7 @@ object UITestHandler { "Uses the identify function. Should see the name 'Jack' in the paywall.", test = { scope, events, _ -> Log.e("Registering event", "present_data") - Superwall.instance.identify(userId = "test0") - Superwall.instance.setUserAttributes(attributes = mapOf("first_name" to "Jack")) - Superwall.instance.register(placement = "present_data") + Superwall.instance.register(placement = "campaign_trigger") Log.e("Registering event", "done") }, ) diff --git a/superwall/src/main/java/com/superwall/sdk/network/device/DeviceHelper.kt b/superwall/src/main/java/com/superwall/sdk/network/device/DeviceHelper.kt index bb8a47d49..c0d602c6a 100644 --- a/superwall/src/main/java/com/superwall/sdk/network/device/DeviceHelper.kt +++ b/superwall/src/main/java/com/superwall/sdk/network/device/DeviceHelper.kt @@ -617,6 +617,7 @@ class DeviceHelper( deviceTier = classifier.deviceTier().raw, hasReviewed = hasReviewed, kotlinVersion = kotlinVersion, + hasReviewed = hasReviewed, ) }.toResult() .map { diff --git a/superwall/src/main/java/com/superwall/sdk/paywall/view/webview/templating/models/DeviceTemplate.kt b/superwall/src/main/java/com/superwall/sdk/paywall/view/webview/templating/models/DeviceTemplate.kt index 2ae72d8c0..f5fc22ce2 100644 --- a/superwall/src/main/java/com/superwall/sdk/paywall/view/webview/templating/models/DeviceTemplate.kt +++ b/superwall/src/main/java/com/superwall/sdk/paywall/view/webview/templating/models/DeviceTemplate.kt @@ -65,6 +65,7 @@ data class DeviceTemplate( val deviceTier: String, val hasReviewed: Boolean, val kotlinVersion: String, + val hasReviewed: Boolean, ) { fun toDictionary(json: Json): Map { val jsonString = json.encodeToString(serializer(), this) diff --git a/superwall/src/main/res/layout/activity_dialog.xml b/superwall/src/main/res/layout/activity_dialog.xml new file mode 100644 index 000000000..57695da93 --- /dev/null +++ b/superwall/src/main/res/layout/activity_dialog.xml @@ -0,0 +1,19 @@ + + + + + + \ No newline at end of file diff --git a/superwall/src/test/java/com/superwall/sdk/paywall/view/webview/templating/models/DeviceTemplateTest.kt b/superwall/src/test/java/com/superwall/sdk/paywall/view/webview/templating/models/DeviceTemplateTest.kt index 2c5a83394..26be748c3 100644 --- a/superwall/src/test/java/com/superwall/sdk/paywall/view/webview/templating/models/DeviceTemplateTest.kt +++ b/superwall/src/test/java/com/superwall/sdk/paywall/view/webview/templating/models/DeviceTemplateTest.kt @@ -69,6 +69,7 @@ class DeviceTemplateTest { deviceTier = "LOW", hasReviewed = false, kotlinVersion = KotlinVersion.CURRENT.toString(), + hasReviewed = false, ) @Test @@ -146,6 +147,7 @@ class DeviceTemplateTest { deviceTier = "HIGH", hasReviewed = true, kotlinVersion = KotlinVersion.CURRENT.toString(), + hasReviewed = true, ) val dictionary1 = template.toDictionary(json) From 7db55213e43f1aa8261eab7ec75ff0b838fc831d Mon Sep 17 00:00:00 2001 From: Ian Rumac Date: Mon, 25 Aug 2025 15:17:25 +0200 Subject: [PATCH 2/2] Permission requests and communication squash --- .../com/superwall/superapp/MainApplication.kt | 2 +- .../superwall/superapp/test/UITestHandler.kt | 4 +- .../main/java/com/superwall/sdk/Superwall.kt | 3 + .../sdk/dependencies/DependencyContainer.kt | 8 + .../sdk/network/device/DeviceHelper.kt | 1 - .../rule_logic/cel/SuperscriptEvaluator.kt | 2 + .../cel/SuperscriptExposedFunction.kt | 47 ++++- .../rule_logic/cel/SuperscriptHostContext.kt | 55 ++++++ .../view/webview/messaging/PaywallMessage.kt | 28 ++- .../messaging/PaywallMessageHandler.kt | 145 +++++++++++++++ .../view/webview/messaging/PaywallWebEvent.kt | 6 + .../templating/models/DeviceTemplate.kt | 1 - .../sdk/permissions/CommonPermission.kt | 40 +++++ .../sdk/permissions/UserPermissions.kt | 66 +++++++ .../sdk/permissions/UserPermissionsImpl.kt | 167 ++++++++++++++++++ .../superwall/sdk/utilities/ErrorTracking.kt | 1 + 16 files changed, 562 insertions(+), 14 deletions(-) create mode 100644 superwall/src/main/java/com/superwall/sdk/permissions/CommonPermission.kt create mode 100644 superwall/src/main/java/com/superwall/sdk/permissions/UserPermissions.kt create mode 100644 superwall/src/main/java/com/superwall/sdk/permissions/UserPermissionsImpl.kt diff --git a/app/src/main/java/com/superwall/superapp/MainApplication.kt b/app/src/main/java/com/superwall/superapp/MainApplication.kt index e86b846ef..a856c267c 100644 --- a/app/src/main/java/com/superwall/superapp/MainApplication.kt +++ b/app/src/main/java/com/superwall/superapp/MainApplication.kt @@ -74,7 +74,7 @@ class MainApplication : fun configureWithAutomaticInitialization() { Superwall.configure( this, - "pk_3945ba4eca2392f731539a671b21a15fca36a50d9d927401", + Keys.CONSTANT_API_KEY, options = SuperwallOptions().apply { logging.level = LogLevel.info diff --git a/app/src/main/java/com/superwall/superapp/test/UITestHandler.kt b/app/src/main/java/com/superwall/superapp/test/UITestHandler.kt index b0eabbf52..0a3baf558 100644 --- a/app/src/main/java/com/superwall/superapp/test/UITestHandler.kt +++ b/app/src/main/java/com/superwall/superapp/test/UITestHandler.kt @@ -157,7 +157,9 @@ object UITestHandler { "Uses the identify function. Should see the name 'Jack' in the paywall.", test = { scope, events, _ -> Log.e("Registering event", "present_data") - Superwall.instance.register(placement = "campaign_trigger") + Superwall.instance.identify(userId = "test0") + Superwall.instance.setUserAttributes(attributes = mapOf("first_name" to "Jack")) + Superwall.instance.register(placement = "present_data") Log.e("Registering event", "done") }, ) diff --git a/superwall/src/main/java/com/superwall/sdk/Superwall.kt b/superwall/src/main/java/com/superwall/sdk/Superwall.kt index 26270fb7f..e8e98f911 100644 --- a/superwall/src/main/java/com/superwall/sdk/Superwall.kt +++ b/superwall/src/main/java/com/superwall/sdk/Superwall.kt @@ -1232,6 +1232,9 @@ class Superwall( } } } + is PaywallWebEvent.RequestPermission -> { + dependencyContainer.userPermissions.requestPermission() + } } } } diff --git a/superwall/src/main/java/com/superwall/sdk/dependencies/DependencyContainer.kt b/superwall/src/main/java/com/superwall/sdk/dependencies/DependencyContainer.kt index 6fe649529..ff3af2b67 100644 --- a/superwall/src/main/java/com/superwall/sdk/dependencies/DependencyContainer.kt +++ b/superwall/src/main/java/com/superwall/sdk/dependencies/DependencyContainer.kt @@ -81,6 +81,8 @@ import com.superwall.sdk.paywall.view.webview.messaging.PaywallMessageHandler import com.superwall.sdk.paywall.view.webview.templating.models.JsonVariables import com.superwall.sdk.paywall.view.webview.templating.models.Variables import com.superwall.sdk.paywall.view.webview.webViewExists +import com.superwall.sdk.permissions.UserPermissions +import com.superwall.sdk.permissions.UserPermissionsImpl import com.superwall.sdk.review.MockReviewManager import com.superwall.sdk.review.ReviewManager import com.superwall.sdk.review.ReviewManagerImpl @@ -160,6 +162,7 @@ class DependencyContainer( val transactionManager: TransactionManager val googleBillingWrapper: GoogleBillingWrapper internal val reviewManager: ReviewManager + internal val userPermissions: UserPermissions var entitlements: Entitlements lateinit var reedemer: WebPaywallRedeemer @@ -180,6 +183,7 @@ class DependencyContainer( storage = storage.coreDataManager, factory = this, ioScope = ioScope, + dependencyContainer = this, ), ) } @@ -478,6 +482,8 @@ class DependencyContainer( }) } + userPermissions = UserPermissionsImpl(context) + deepLinkRouter = DeepLinkRouter( reedemer, @@ -565,7 +571,9 @@ class DependencyContainer( PaywallMessageHandler( sessionEventsManager = sessionEventsManager, factory = this@DependencyContainer, + ruleEvaluatorFactory = this@DependencyContainer, ioScope = ioScope, + userPermissions = userPermissions, json = paywallJson, mainScope = mainScope(), ) diff --git a/superwall/src/main/java/com/superwall/sdk/network/device/DeviceHelper.kt b/superwall/src/main/java/com/superwall/sdk/network/device/DeviceHelper.kt index c0d602c6a..9c94409ef 100644 --- a/superwall/src/main/java/com/superwall/sdk/network/device/DeviceHelper.kt +++ b/superwall/src/main/java/com/superwall/sdk/network/device/DeviceHelper.kt @@ -615,7 +615,6 @@ class DeviceHelper( platformWrapperVersion = platformWrapperVersion, appVersionPadded = appVersionPadded, deviceTier = classifier.deviceTier().raw, - hasReviewed = hasReviewed, kotlinVersion = kotlinVersion, hasReviewed = hasReviewed, ) diff --git a/superwall/src/main/java/com/superwall/sdk/paywall/presentation/rule_logic/cel/SuperscriptEvaluator.kt b/superwall/src/main/java/com/superwall/sdk/paywall/presentation/rule_logic/cel/SuperscriptEvaluator.kt index 6dc11b02e..f3e370567 100644 --- a/superwall/src/main/java/com/superwall/sdk/paywall/presentation/rule_logic/cel/SuperscriptEvaluator.kt +++ b/superwall/src/main/java/com/superwall/sdk/paywall/presentation/rule_logic/cel/SuperscriptEvaluator.kt @@ -43,10 +43,12 @@ internal class SuperscriptEvaluator( private val ioScope: IOScope, private val storage: CoreDataManager, private val factory: RuleAttributesFactory, + private val dependencyContainer: com.superwall.sdk.dependencies.DependencyContainer? = null, private val hostContext: SuperscriptHostContext = SuperscriptHostContext( json, storage, + dependencyContainer, ), ) : ExpressionEvaluating { class NotError( diff --git a/superwall/src/main/java/com/superwall/sdk/paywall/presentation/rule_logic/cel/SuperscriptExposedFunction.kt b/superwall/src/main/java/com/superwall/sdk/paywall/presentation/rule_logic/cel/SuperscriptExposedFunction.kt index 02d73b10e..f3c72142a 100644 --- a/superwall/src/main/java/com/superwall/sdk/paywall/presentation/rule_logic/cel/SuperscriptExposedFunction.kt +++ b/superwall/src/main/java/com/superwall/sdk/paywall/presentation/rule_logic/cel/SuperscriptExposedFunction.kt @@ -29,6 +29,8 @@ sealed class SuperscriptExposedFunction { REVIEW_REQUESTS_IN_YEAR("reviewRequestsInYear"), REVIEW_REQUESTS_TOTAL("reviewRequestsTotal"), REQUEST_REVIEW("requestReview"), + HAS_PERMISSION("hasPermission"), + REQUEST_PERMISSION("requestPermission"), } class MinutesSince( @@ -129,6 +131,26 @@ sealed class SuperscriptExposedFunction { } } + class HasPermission( + val permission: String, + ) : SuperscriptExposedFunction() { + suspend operator fun invoke(storage: CoreDataManager): Boolean { + // This will be handled by the SuperscriptHostContext + // For now, return false as default + return false + } + } + + class RequestPermission( + val permission: String, + ) : SuperscriptExposedFunction() { + suspend operator fun invoke(storage: CoreDataManager): Boolean { + // This will be handled by the SuperscriptHostContext + // For now, return false as default + return false + } + } + companion object { fun from( name: String, @@ -190,6 +212,16 @@ sealed class SuperscriptExposedFunction { REQUEST_REVIEW.rawName -> RequestReview + HAS_PERMISSION.rawName -> + HasPermission( + permission = (args.first() as PassableValue.StringValue).value, + ) + + REQUEST_PERMISSION.rawName -> + RequestPermission( + permission = (args.first() as PassableValue.StringValue).value, + ) + else -> null } } @@ -200,13 +232,14 @@ sealed interface TimeSince { val propertyRequest: ComputedPropertyRequestType suspend operator fun invoke(storage: CoreDataManager): Int = - storage.getComputedPropertySinceEvent( - null, - ComputedPropertyRequest( - propertyRequest, - event, - ), - ) ?: 0 + storage + .getComputedPropertySinceEvent( + null, + ComputedPropertyRequest( + propertyRequest, + event, + ), + ) ?: 0 } sealed interface InPeriod { diff --git a/superwall/src/main/java/com/superwall/sdk/paywall/presentation/rule_logic/cel/SuperscriptHostContext.kt b/superwall/src/main/java/com/superwall/sdk/paywall/presentation/rule_logic/cel/SuperscriptHostContext.kt index bf9427729..10fae7576 100644 --- a/superwall/src/main/java/com/superwall/sdk/paywall/presentation/rule_logic/cel/SuperscriptHostContext.kt +++ b/superwall/src/main/java/com/superwall/sdk/paywall/presentation/rule_logic/cel/SuperscriptHostContext.kt @@ -1,6 +1,8 @@ package com.superwall.sdk.paywall.presentation.rule_logic.cel +import com.superwall.sdk.dependencies.DependencyContainer import com.superwall.sdk.paywall.presentation.rule_logic.cel.models.PassableValue +import com.superwall.sdk.permissions.CommonPermission import com.superwall.sdk.storage.core_data.CoreDataManager import com.superwall.supercel.HostContext import com.superwall.supercel.ResultCallback @@ -11,6 +13,7 @@ import kotlinx.serialization.json.Json class SuperscriptHostContext( private val json: Json, private val storage: CoreDataManager, + private val dependencyContainer: DependencyContainer? = null, ) : HostContext { companion object ComputedProperties { val availableComputedProperties = @@ -44,6 +47,32 @@ class SuperscriptHostContext( is SuperscriptExposedFunction.PlacementCount -> fn(storage) is SuperscriptExposedFunction.ReviewRequestCount -> fn(storage) is SuperscriptExposedFunction.RequestReview -> fn(storage) + is SuperscriptExposedFunction.HasPermission -> { + val permission = + CommonPermission.fromName(fn.permission) + ?: CommonPermission.fromRaw(fn.permission) + if (permission != null && dependencyContainer != null) { + dependencyContainer.userPermissions.hasPermission(permission) + } else { + false + } + } + is SuperscriptExposedFunction.RequestPermission -> { + val permission = + CommonPermission.fromName(fn.permission) + ?: CommonPermission.fromRaw(fn.permission) + val activity = dependencyContainer?.activityProvider?.getCurrentActivity() + if (permission != null && dependencyContainer != null && activity != null) { + try { + val result = dependencyContainer.userPermissions.requestPermission(activity, permission) + result is com.superwall.sdk.permissions.PermissionResult.Granted + } catch (e: Exception) { + false + } + } else { + false + } + } } } callback.onResult(json.encodeToString(res?.toPassableValue() ?: PassableValue.NullValue)) @@ -74,6 +103,32 @@ class SuperscriptHostContext( is SuperscriptExposedFunction.PlacementCount -> fn(storage) is SuperscriptExposedFunction.ReviewRequestCount -> fn(storage) is SuperscriptExposedFunction.RequestReview -> fn(storage) + is SuperscriptExposedFunction.HasPermission -> { + val permission = + CommonPermission.fromName(fn.permission) + ?: CommonPermission.fromRaw(fn.permission) + if (permission != null && dependencyContainer != null) { + dependencyContainer.userPermissions.hasPermission(permission) + } else { + false + } + } + is SuperscriptExposedFunction.RequestPermission -> { + val permission = + CommonPermission.fromName(fn.permission) + ?: CommonPermission.fromRaw(fn.permission) + val activity = dependencyContainer?.activityProvider?.getCurrentActivity() + if (permission != null && dependencyContainer != null && activity != null) { + try { + val result = dependencyContainer.userPermissions.requestPermission(activity, permission) + result is com.superwall.sdk.permissions.PermissionResult.Granted + } catch (e: Exception) { + false + } + } else { + false + } + } } } callback.onResult(json.encodeToString(res?.toPassableValue() ?: PassableValue.NullValue)) diff --git a/superwall/src/main/java/com/superwall/sdk/paywall/view/webview/messaging/PaywallMessage.kt b/superwall/src/main/java/com/superwall/sdk/paywall/view/webview/messaging/PaywallMessage.kt index 551b28278..6332c2fb4 100644 --- a/superwall/src/main/java/com/superwall/sdk/paywall/view/webview/messaging/PaywallMessage.kt +++ b/superwall/src/main/java/com/superwall/sdk/paywall/view/webview/messaging/PaywallMessage.kt @@ -4,6 +4,7 @@ import android.net.Uri import com.superwall.sdk.logger.LogLevel import com.superwall.sdk.logger.LogScope import com.superwall.sdk.logger.Logger +import com.superwall.sdk.permissions.CommonPermission import org.json.JSONObject import java.net.URI @@ -71,6 +72,17 @@ sealed class PaywallMessage { EXTERNAL("external"), } } + + data class EvalSuperscript( + val id: String, + val await: Boolean, + val expression: String, + val state: String, + ) : PaywallMessage() + + data class RequestPermission( + val permission: CommonPermission, + ) : PaywallMessage() } fun parseWrappedPaywallMessages(jsonString: String): WrappedPaywallMessages { @@ -124,8 +136,18 @@ private fun parsePaywallMessage(json: JSONObject): PaywallMessage { else -> PaywallMessage.RequestReview.Type.INAPP }, ) - else -> { - throw IllegalArgumentException("Unknown event name: $eventName") - } + "eval_superscript" -> + PaywallMessage.EvalSuperscript( + id = json.getString("id"), + await = json.getBoolean("await"), + expression = json.getString("expression"), + state = json.getString("state"), + ) + "request_permission" -> + CommonPermission.fromRaw(json.getString("type"))?.let { + PaywallMessage.RequestPermission(it) + } ?: throw NullPointerException() + + else -> throw IllegalArgumentException("Unknown event name: $eventName") } } diff --git a/superwall/src/main/java/com/superwall/sdk/paywall/view/webview/messaging/PaywallMessageHandler.kt b/superwall/src/main/java/com/superwall/sdk/paywall/view/webview/messaging/PaywallMessageHandler.kt index d0b292447..4a7b73a14 100644 --- a/superwall/src/main/java/com/superwall/sdk/paywall/view/webview/messaging/PaywallMessageHandler.kt +++ b/superwall/src/main/java/com/superwall/sdk/paywall/view/webview/messaging/PaywallMessageHandler.kt @@ -15,13 +15,18 @@ import com.superwall.sdk.logger.LogLevel import com.superwall.sdk.logger.LogScope import com.superwall.sdk.logger.Logger import com.superwall.sdk.misc.MainScope +import com.superwall.sdk.models.events.EventData import com.superwall.sdk.models.paywall.Paywall +import com.superwall.sdk.models.triggers.TriggerRule +import com.superwall.sdk.models.triggers.TriggerRuleOutcome import com.superwall.sdk.paywall.presentation.PaywallInfo import com.superwall.sdk.paywall.presentation.internal.PresentationRequest +import com.superwall.sdk.paywall.presentation.rule_logic.javascript.RuleEvaluator import com.superwall.sdk.paywall.view.delegate.PaywallLoadingState import com.superwall.sdk.paywall.view.webview.PaywallMessage import com.superwall.sdk.paywall.view.webview.WrappedPaywallMessages import com.superwall.sdk.paywall.view.webview.parseWrappedPaywallMessages +import com.superwall.sdk.permissions.UserPermissions import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch @@ -55,6 +60,8 @@ interface PaywallMessageHandlerDelegate { class PaywallMessageHandler( private val sessionEventsManager: SessionEventsManager, private val factory: VariablesFactory, + private val ruleEvaluatorFactory: RuleEvaluator.Factory, + private val userPermissions: UserPermissions, private val mainScope: MainScope, private val ioScope: CoroutineScope, private val json: Json = Json { encodeDefaults = true }, @@ -177,6 +184,10 @@ class PaywallMessageHandler( is PaywallMessage.RequestReview -> handleRequestReview(message) + is PaywallMessage.EvalSuperscript -> handleEvalSuperscript(message) + + is PaywallMessage.RequestPermission -> handleRequestPermission(message) + else -> { Logger.debug( LogLevel.error, @@ -386,6 +397,25 @@ class PaywallMessageHandler( delegate?.eventDidOccur(PaywallWebEvent.CustomPlacement(name, params)) } + private fun handleRequestPermission(request: PaywallMessage.RequestPermission) { + val requestor = + Superwall.instance.paywallView + ?.encapsulatingActivity + ?.get() + if (requestor != null) { + ioScope.launch { + userPermissions.requestPermission( + requestor, + request.permission, + { + // Postback here + }, + ) + } + } + delegate?.eventDidOccur(PaywallWebEvent.RequestPermission(request.permission)) + } + private fun handleRequestReview(request: PaywallMessage.RequestReview) { hapticFeedback() delegate?.eventDidOccur( @@ -441,4 +471,119 @@ class PaywallMessageHandler( // Android doesn't have a direct equivalent to UIImpactFeedbackGenerator // TODO: Implement haptic feedback } + + private fun handleEvalSuperscript(message: PaywallMessage.EvalSuperscript) { + ioScope.launch { + try { + // If await is false, send immediate response before evaluation + if (!message.await) { + val immediateResponse = + mapOf( + "id" to message.id, + "result" to null, + ) + val jsonResponse = json.encodeToString(immediateResponse) + val base64Response = + Base64.encodeToString( + jsonResponse.toByteArray(StandardCharsets.UTF_8), + Base64.NO_WRAP, + ) + passMessageToWebView(base64String = base64Response) + } + + // Create EventData from state parameter + val eventData = + if (message.state.isNotEmpty()) { + try { + val stateJson = JSONObject(message.state) + val parameters = mutableMapOf() + stateJson.keys().forEach { key -> + parameters[key] = stateJson.get(key) + } + EventData( + name = "eval_superscript", + parameters = parameters, + createdAt = Date(), + ) + } catch (e: Exception) { + EventData( + name = "eval_superscript", + parameters = emptyMap(), + createdAt = Date(), + ) + } + } else { + EventData( + name = "eval_superscript", + parameters = emptyMap(), + createdAt = Date(), + ) + } + + // Create TriggerRule with the expression + val triggerRule = + TriggerRule( + experimentId = "eval_superscript", + experimentGroupId = "eval_superscript", + variants = emptyList(), + expressionCEL = message.expression, + preload = + TriggerRule.TriggerPreload( + behavior = com.superwall.sdk.models.triggers.TriggerPreloadBehavior.NEVER, + ), + ) + + // Get the evaluator and evaluate the expression + val context = delegate?.webView?.context ?: return@launch + val evaluator = ruleEvaluatorFactory.provideRuleEvaluator(context) + val outcome = evaluator.evaluateExpression(triggerRule, eventData) + + // Prepare result based on evaluation outcome + val resultValue = + when (outcome) { + is TriggerRuleOutcome.Match -> true + is TriggerRuleOutcome.NoMatch -> false + } + + // Send result back to WebView if await is true + if (message.await) { + val response = + mapOf( + "id" to message.id, + "result" to resultValue, + ) + val jsonResponse = json.encodeToString(response) + val base64Response = + Base64.encodeToString( + jsonResponse.toByteArray(StandardCharsets.UTF_8), + Base64.NO_WRAP, + ) + passMessageToWebView(base64String = base64Response) + } + } catch (e: Exception) { + Logger.debug( + LogLevel.error, + LogScope.superwallCore, + "Error evaluating superscript expression: ${e.message}", + error = e, + ) + + // Send error response if await is true + if (message.await) { + val errorResponse = + mapOf( + "id" to message.id, + "error" to e.message, + ) + val jsonResponse = json.encodeToString(errorResponse) + val base64Response = + Base64.encodeToString( + jsonResponse.toByteArray(StandardCharsets.UTF_8), + Base64.NO_WRAP, + ) + passMessageToWebView(base64String = base64Response) + } + } + } + } } diff --git a/superwall/src/main/java/com/superwall/sdk/paywall/view/webview/messaging/PaywallWebEvent.kt b/superwall/src/main/java/com/superwall/sdk/paywall/view/webview/messaging/PaywallWebEvent.kt index 59e54651e..a7048c0f6 100644 --- a/superwall/src/main/java/com/superwall/sdk/paywall/view/webview/messaging/PaywallWebEvent.kt +++ b/superwall/src/main/java/com/superwall/sdk/paywall/view/webview/messaging/PaywallWebEvent.kt @@ -1,6 +1,7 @@ package com.superwall.sdk.paywall.view.webview.messaging import android.net.Uri +import com.superwall.sdk.permissions.CommonPermission import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable import org.json.JSONObject @@ -43,6 +44,11 @@ sealed class PaywallWebEvent { val params: JSONObject, ) : PaywallWebEvent() + @SerialName("request_permission") + data class RequestPermission( + val permission: CommonPermission, + ) : PaywallWebEvent() + @SerialName("request_review") data class RequestReview( val type: Type, diff --git a/superwall/src/main/java/com/superwall/sdk/paywall/view/webview/templating/models/DeviceTemplate.kt b/superwall/src/main/java/com/superwall/sdk/paywall/view/webview/templating/models/DeviceTemplate.kt index f5fc22ce2..f85ae1612 100644 --- a/superwall/src/main/java/com/superwall/sdk/paywall/view/webview/templating/models/DeviceTemplate.kt +++ b/superwall/src/main/java/com/superwall/sdk/paywall/view/webview/templating/models/DeviceTemplate.kt @@ -63,7 +63,6 @@ data class DeviceTemplate( @SerialName("platform_wrapper_version") val platformWrapperVersion: String, val deviceTier: String, - val hasReviewed: Boolean, val kotlinVersion: String, val hasReviewed: Boolean, ) { diff --git a/superwall/src/main/java/com/superwall/sdk/permissions/CommonPermission.kt b/superwall/src/main/java/com/superwall/sdk/permissions/CommonPermission.kt new file mode 100644 index 000000000..97576ef4e --- /dev/null +++ b/superwall/src/main/java/com/superwall/sdk/permissions/CommonPermission.kt @@ -0,0 +1,40 @@ +package com.superwall.sdk.permissions + +import android.Manifest + +/** + * Common Android permissions that can be requested through the Superwall SDK + */ +enum class CommonPermission( + val rawValue: String, +) { + CAMERA(Manifest.permission.CAMERA), + MICROPHONE(Manifest.permission.RECORD_AUDIO), + READ_EXTERNAL_STORAGE(Manifest.permission.READ_EXTERNAL_STORAGE), + WRITE_EXTERNAL_STORAGE(Manifest.permission.WRITE_EXTERNAL_STORAGE), + ACCESS_FINE_LOCATION(Manifest.permission.ACCESS_FINE_LOCATION), + ACCESS_COARSE_LOCATION(Manifest.permission.ACCESS_COARSE_LOCATION), + ACCESS_BACKGROUND_LOCATION(Manifest.permission.ACCESS_BACKGROUND_LOCATION), + BLUETOOTH(Manifest.permission.BLUETOOTH), + READ_CONTACTS(Manifest.permission.READ_CONTACTS), + WRITE_CONTACTS(Manifest.permission.WRITE_CONTACTS), + READ_CALENDAR(Manifest.permission.READ_CALENDAR), + WRITE_CALENDAR(Manifest.permission.WRITE_CALENDAR), + ; + + companion object { + /** + * Find a CommonPermission by its raw Android permission string + * @param raw The Android permission string (e.g., "android.permission.CAMERA") + * @return The corresponding CommonPermission or null if not found + */ + fun fromRaw(raw: String): CommonPermission? = values().find { it.rawValue == raw } + + /** + * Find a CommonPermission by its enum name (case insensitive) + * @param name The enum name (e.g., "CAMERA", "camera") + * @return The corresponding CommonPermission or null if not found + */ + fun fromName(name: String): CommonPermission? = values().find { it.name.equals(name, ignoreCase = true) } + } +} diff --git a/superwall/src/main/java/com/superwall/sdk/permissions/UserPermissions.kt b/superwall/src/main/java/com/superwall/sdk/permissions/UserPermissions.kt new file mode 100644 index 000000000..beb5a4a2f --- /dev/null +++ b/superwall/src/main/java/com/superwall/sdk/permissions/UserPermissions.kt @@ -0,0 +1,66 @@ +package com.superwall.sdk.permissions + +import android.app.Activity + +/** + * Interface for managing user permissions in a testable way + */ +interface UserPermissions { + /** + * Check if a specific permission is granted + * @param permission The permission to check + * @return true if the permission is granted, false otherwise + */ + fun hasPermission(permission: CommonPermission): Boolean + + /** + * Request a specific permission from the user + * @param activity The activity to use for the permission request + * @param permission The permission to request + * @param callback Callback to receive the result of the permission request + */ + suspend fun requestPermission( + activity: Activity, + permission: CommonPermission, + callback: (PermissionResult) -> Unit = {}, + ): PermissionResult + + /** + * Request multiple permissions at once + * @param activity The activity to use for the permission request + * @param permissions The permissions to request + * @param callback Callback to receive the results of the permission requests + */ + suspend fun requestPermissions( + activity: Activity, + permissions: List, + callback: (Map) -> Unit = {}, + ): Map +} + +/** + * Result of a permission request + */ +sealed class PermissionResult { + /** + * Permission was granted + */ + object Granted : PermissionResult() + + /** + * Permission was denied + */ + object Denied : PermissionResult() + + /** + * Permission was denied and "Don't ask again" was selected + */ + object DeniedPermanently : PermissionResult() + + /** + * Permission request failed due to an error + */ + data class Error( + val exception: Exception, + ) : PermissionResult() +} diff --git a/superwall/src/main/java/com/superwall/sdk/permissions/UserPermissionsImpl.kt b/superwall/src/main/java/com/superwall/sdk/permissions/UserPermissionsImpl.kt new file mode 100644 index 000000000..2e526e39e --- /dev/null +++ b/superwall/src/main/java/com/superwall/sdk/permissions/UserPermissionsImpl.kt @@ -0,0 +1,167 @@ +package com.superwall.sdk.permissions + +import android.app.Activity +import android.content.Context +import android.content.pm.PackageManager +import androidx.core.app.ActivityCompat +import androidx.core.content.ContextCompat +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlin.coroutines.resume + +/** + * Implementation of UserPermissions using Android's permission system + */ +internal class UserPermissionsImpl( + private val context: Context, +) : UserPermissions { + private val pendingRequests = mutableMapOf() + private var requestIdCounter = 1000 + + override fun hasPermission(permission: CommonPermission): Boolean = + ContextCompat.checkSelfPermission( + context, + permission.rawValue, + ) == PackageManager.PERMISSION_GRANTED + + override suspend fun requestPermission( + activity: Activity, + permission: CommonPermission, + callback: (PermissionResult) -> Unit, + ): PermissionResult { + // If already granted, return immediately + if (hasPermission(permission)) { + val result = PermissionResult.Granted + callback(result) + return result + } + + return suspendCancellableCoroutine { continuation -> + val requestId = requestIdCounter++ + + pendingRequests[requestId] = + PermissionRequestData( + permissions = listOf(permission), + onResult = { results -> + val result = + results[permission] ?: PermissionResult.Error( + IllegalStateException("Permission result not found"), + ) + callback(result) + continuation.resume(result) + }, + ) + + try { + ActivityCompat.requestPermissions( + activity, + arrayOf(permission.rawValue), + requestId, + ) + } catch (e: Exception) { + pendingRequests.remove(requestId) + val error = PermissionResult.Error(e) + callback(error) + continuation.resume(error) + } + } + } + + override suspend fun requestPermissions( + activity: Activity, + permissions: List, + callback: (Map) -> Unit, + ): Map { + // Check which permissions are already granted + val alreadyGranted = permissions.filter { hasPermission(it) } + val needToRequest = permissions.filter { !hasPermission(it) } + + val results = mutableMapOf() + + // Add already granted permissions to results + alreadyGranted.forEach { permission -> + results[permission] = PermissionResult.Granted + } + + // If no permissions need to be requested, return immediately + if (needToRequest.isEmpty()) { + callback(results) + return results + } + + return suspendCancellableCoroutine { continuation -> + val requestId = requestIdCounter++ + + pendingRequests[requestId] = + PermissionRequestData( + permissions = needToRequest, + onResult = { requestResults -> + results.putAll(requestResults) + callback(results) + continuation.resume(results) + }, + ) + + try { + ActivityCompat.requestPermissions( + activity, + needToRequest.map { it.rawValue }.toTypedArray(), + requestId, + ) + } catch (e: Exception) { + pendingRequests.remove(requestId) + needToRequest.forEach { permission -> + results[permission] = PermissionResult.Error(e) + } + callback(results) + continuation.resume(results) + } + } + } + + /** + * Handle permission request results from the activity + * This should be called from the activity's onRequestPermissionsResult method + */ + fun onRequestPermissionsResult( + requestCode: Int, + permissions: Array, + grantResults: IntArray, + ) { + val requestData = pendingRequests.remove(requestCode) ?: return + + val results = mutableMapOf() + + for (i in permissions.indices) { + val permissionString = permissions[i] + val grantResult = grantResults.getOrNull(i) ?: PackageManager.PERMISSION_DENIED + + val commonPermission = CommonPermission.fromRaw(permissionString) + if (commonPermission != null) { + val result = + when (grantResult) { + PackageManager.PERMISSION_GRANTED -> PermissionResult.Granted + PackageManager.PERMISSION_DENIED -> { + // Check if it's permanently denied + val activity = context as? Activity + if (activity != null && + !ActivityCompat.shouldShowRequestPermissionRationale(activity, permissionString) + ) { + PermissionResult.DeniedPermanently + } else { + PermissionResult.Denied + } + } + else -> PermissionResult.Denied + } + results[commonPermission] = result + } + } + + requestData.onResult(results) + } + + private data class PermissionRequestData( + val permissions: List, + val onResult: (Map) -> Unit, + ) +} diff --git a/superwall/src/main/java/com/superwall/sdk/utilities/ErrorTracking.kt b/superwall/src/main/java/com/superwall/sdk/utilities/ErrorTracking.kt index b3157452c..ca059aa06 100644 --- a/superwall/src/main/java/com/superwall/sdk/utilities/ErrorTracking.kt +++ b/superwall/src/main/java/com/superwall/sdk/utilities/ErrorTracking.kt @@ -121,6 +121,7 @@ internal fun Throwable.isFatal() = internal fun Superwall.trackError(e: Throwable) { try { + e.printStackTrace() dependencyContainer.errorTracker.trackError(e) } catch (_e: Exception) { Logger.debug(