diff --git a/superwall/src/main/java/com/superwall/sdk/Superwall.kt b/superwall/src/main/java/com/superwall/sdk/Superwall.kt index 26270fb7..e8e98f91 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 6fe64952..ff3af2b6 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 bb8a47d4..9c94409e 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,8 +615,8 @@ class DeviceHelper( platformWrapperVersion = platformWrapperVersion, appVersionPadded = appVersionPadded, deviceTier = classifier.deviceTier().raw, - hasReviewed = hasReviewed, kotlinVersion = kotlinVersion, + hasReviewed = hasReviewed, ) }.toResult() .map { 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 6dc11b02..f3e37056 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 02d73b10..f3c72142 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 bf942772..10fae757 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 551b2827..6332c2fb 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 d0b29244..4a7b73a1 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 59e54651..a7048c0f 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 2ae72d8c..f85ae161 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,8 +63,8 @@ data class DeviceTemplate( @SerialName("platform_wrapper_version") val platformWrapperVersion: String, 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/java/com/superwall/sdk/permissions/CommonPermission.kt b/superwall/src/main/java/com/superwall/sdk/permissions/CommonPermission.kt new file mode 100644 index 00000000..97576ef4 --- /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 00000000..beb5a4a2 --- /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 00000000..2e526e39 --- /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 b3157452..ca059aa0 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( 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 00000000..57695da9 --- /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 2c5a8339..26be748c 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)