diff --git a/CHANGELOG.md b/CHANGELOG.md
index 6ceb986e..9a4408e5 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,7 +6,8 @@ The changelog for `Superwall`. Also see the [releases](https://github.com/superw
### Fixes
- Fix handling of deep links when paywall is detached
-C
+- Enables permission granting from paywall and callbacks
+
## 2.6.6
## Enhancements
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 20fe5a62..39e99066 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -7,6 +7,13 @@
+
+
+
+
+
+
+
{
+ Logger.debug(
+ LogLevel.debug,
+ LogScope.paywallView,
+ message = "Permission requested: ${paywallEvent.permissionType.rawValue}",
+ )
+ }
}
}
}
diff --git a/superwall/src/main/java/com/superwall/sdk/analytics/internal/trackable/TrackableSuperwallEvent.kt b/superwall/src/main/java/com/superwall/sdk/analytics/internal/trackable/TrackableSuperwallEvent.kt
index 41efd21d..2fa089dd 100644
--- a/superwall/src/main/java/com/superwall/sdk/analytics/internal/trackable/TrackableSuperwallEvent.kt
+++ b/superwall/src/main/java/com/superwall/sdk/analytics/internal/trackable/TrackableSuperwallEvent.kt
@@ -1175,6 +1175,51 @@ sealed class InternalSuperwallEvent(
}
}
+ data class PermissionRequested(
+ val permissionName: String,
+ val paywallIdentifier: String,
+ ) : InternalSuperwallEvent(
+ SuperwallEvent.PermissionRequested(permissionName, paywallIdentifier),
+ ) {
+ override val audienceFilterParams: Map = emptyMap()
+
+ override suspend fun getSuperwallParameters(): Map =
+ mapOf(
+ "permission_name" to permissionName,
+ "paywall_identifier" to paywallIdentifier,
+ )
+ }
+
+ data class PermissionGranted(
+ val permissionName: String,
+ val paywallIdentifier: String,
+ ) : InternalSuperwallEvent(
+ SuperwallEvent.PermissionGranted(permissionName, paywallIdentifier),
+ ) {
+ override val audienceFilterParams: Map = emptyMap()
+
+ override suspend fun getSuperwallParameters(): Map =
+ mapOf(
+ "permission_name" to permissionName,
+ "paywall_identifier" to paywallIdentifier,
+ )
+ }
+
+ data class PermissionDenied(
+ val permissionName: String,
+ val paywallIdentifier: String,
+ ) : InternalSuperwallEvent(
+ SuperwallEvent.PermissionDenied(permissionName, paywallIdentifier),
+ ) {
+ override val audienceFilterParams: Map = emptyMap()
+
+ override suspend fun getSuperwallParameters(): Map =
+ mapOf(
+ "permission_name" to permissionName,
+ "paywall_identifier" to paywallIdentifier,
+ )
+ }
+
data class PaywallPreload(
val state: State,
val paywallCount: Int,
diff --git a/superwall/src/main/java/com/superwall/sdk/analytics/superwall/SuperwallEvent.kt b/superwall/src/main/java/com/superwall/sdk/analytics/superwall/SuperwallEvent.kt
index 5cc153c1..acd9b6ed 100644
--- a/superwall/src/main/java/com/superwall/sdk/analytics/superwall/SuperwallEvent.kt
+++ b/superwall/src/main/java/com/superwall/sdk/analytics/superwall/SuperwallEvent.kt
@@ -521,6 +521,33 @@ sealed class SuperwallEvent {
get() = SuperwallEvents.CustomerInfoDidChange.rawName
}
+ // / When a permission is requested from a paywall.
+ data class PermissionRequested(
+ val permissionName: String,
+ val paywallIdentifier: String,
+ ) : SuperwallEvent() {
+ override val rawName: String
+ get() = SuperwallEvents.PermissionRequested.rawName
+ }
+
+ // / When a permission is granted after being requested from a paywall.
+ data class PermissionGranted(
+ val permissionName: String,
+ val paywallIdentifier: String,
+ ) : SuperwallEvent() {
+ override val rawName: String
+ get() = SuperwallEvents.PermissionGranted.rawName
+ }
+
+ // / When a permission is denied after being requested from a paywall.
+ data class PermissionDenied(
+ val permissionName: String,
+ val paywallIdentifier: String,
+ ) : SuperwallEvent() {
+ override val rawName: String
+ get() = SuperwallEvents.PermissionDenied.rawName
+ }
+
// / When paywall preloading starts.
data class PaywallPreloadStart(
val paywallCount: Int,
diff --git a/superwall/src/main/java/com/superwall/sdk/analytics/superwall/SuperwallEvents.kt b/superwall/src/main/java/com/superwall/sdk/analytics/superwall/SuperwallEvents.kt
index 0b2adc01..e0c6a3a7 100644
--- a/superwall/src/main/java/com/superwall/sdk/analytics/superwall/SuperwallEvents.kt
+++ b/superwall/src/main/java/com/superwall/sdk/analytics/superwall/SuperwallEvents.kt
@@ -58,6 +58,9 @@ enum class SuperwallEvents(
ReviewDenied("review_denied"),
IntegrationAttributes("integration_attributes"),
CustomerInfoDidChange("customerInfo_didChange"),
+ PermissionRequested("permission_requested"),
+ PermissionGranted("permission_granted"),
+ PermissionDenied("permission_denied"),
PaywallPreloadStart("paywallPreload_start"),
PaywallPreloadComplete("paywallPreload_complete"),
}
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 75876b75..297ec2d7 100644
--- a/superwall/src/main/java/com/superwall/sdk/dependencies/DependencyContainer.kt
+++ b/superwall/src/main/java/com/superwall/sdk/dependencies/DependencyContainer.kt
@@ -98,6 +98,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
@@ -187,6 +189,7 @@ class DependencyContainer(
val transactionManager: TransactionManager
val googleBillingWrapper: GoogleBillingWrapper
internal val reviewManager: ReviewManager
+ internal val userPermissions: UserPermissions
var entitlements: Entitlements
internal lateinit var customerInfoManager: CustomerInfoManager
@@ -593,6 +596,8 @@ class DependencyContainer(
})
}
+ userPermissions = UserPermissionsImpl(context)
+
deepLinkRouter =
DeepLinkRouter(
reedemer,
@@ -702,6 +707,8 @@ class DependencyContainer(
encodeToB64 = {
Base64.encodeToString(it.toByteArray(StandardCharsets.UTF_8), Base64.NO_WRAP)
},
+ userPermissions = userPermissions,
+ getActivity = { activityProvider?.getCurrentActivity() },
)
val state =
diff --git a/superwall/src/main/java/com/superwall/sdk/paywall/view/webview/SWWebView.kt b/superwall/src/main/java/com/superwall/sdk/paywall/view/webview/SWWebView.kt
index 9bbbee7c..9a97608a 100644
--- a/superwall/src/main/java/com/superwall/sdk/paywall/view/webview/SWWebView.kt
+++ b/superwall/src/main/java/com/superwall/sdk/paywall/view/webview/SWWebView.kt
@@ -173,7 +173,7 @@ class SWWebView(
addJavascriptInterface(messageHandler, "SWAndroid")
val webSettings = this.settings
- setWebContentsDebuggingEnabled(false)
+ setWebContentsDebuggingEnabled(true)
webSettings.javaScriptEnabled = true
webSettings.setSupportZoom(false)
webSettings.builtInZoomControls = false
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 63566ff8..dc9a8d38 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 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.permissions.PermissionType
import com.superwall.sdk.storage.core_data.convertFromJsonElement
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonObject
@@ -108,6 +109,11 @@ sealed class PaywallMessage {
val body: String,
val delay: Long,
) : PaywallMessage()
+
+ data class RequestPermission(
+ val permissionType: PermissionType,
+ val requestId: String,
+ ) : PaywallMessage()
}
fun parseWrappedPaywallMessages(jsonString: String): Result =
@@ -198,6 +204,22 @@ private fun parsePaywallMessage(json: JsonObject): PaywallMessage {
delay = json["delay"]?.jsonPrimitive?.longOrNull ?: 0L,
)
+ "request_permission" -> {
+ val permissionTypeRaw =
+ json["permission_type"]?.jsonPrimitive?.contentOrNull
+ ?: throw IllegalArgumentException("request_permission missing permission_type")
+ val permissionType =
+ PermissionType.fromRaw(permissionTypeRaw)
+ ?: throw IllegalArgumentException("Unknown permission_type: $permissionTypeRaw")
+ val requestId =
+ json["request_id"]?.jsonPrimitive?.contentOrNull
+ ?: throw IllegalArgumentException("request_permission missing request_id")
+ PaywallMessage.RequestPermission(
+ permissionType = permissionType,
+ requestId = requestId,
+ )
+ }
+
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 9563a49f..f141b4e2 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
@@ -1,6 +1,7 @@
package com.superwall.sdk.paywall.view.webview.messaging
import TemplateLogic
+import android.app.Activity
import android.webkit.JavascriptInterface
import com.superwall.sdk.analytics.internal.trackable.InternalSuperwallEvent
import com.superwall.sdk.analytics.internal.trackable.TrackableSuperwallEvent
@@ -17,6 +18,8 @@ import com.superwall.sdk.paywall.view.PaywallView
import com.superwall.sdk.paywall.view.PaywallViewState
import com.superwall.sdk.paywall.view.delegate.PaywallLoadingState
import com.superwall.sdk.paywall.view.webview.SendPaywallMessages
+import com.superwall.sdk.permissions.PermissionStatus
+import com.superwall.sdk.permissions.UserPermissions
import com.superwall.sdk.storage.core_data.convertToJsonElement
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
@@ -63,6 +66,8 @@ class PaywallMessageHandler(
private val ioScope: CoroutineScope,
private val json: Json = Json { encodeDefaults = true },
private val encodeToB64: (String) -> String,
+ private val userPermissions: UserPermissions,
+ private val getActivity: () -> Activity?,
) : SendPaywallMessages {
private companion object {
val selectionString =
@@ -226,6 +231,8 @@ class PaywallMessageHandler(
),
)
+ is PaywallMessage.RequestPermission -> handleRequestPermission(message)
+
else -> {
Logger.debug(
LogLevel.error,
@@ -487,6 +494,127 @@ class PaywallMessageHandler(
)
}
+ private fun handleRequestPermission(request: PaywallMessage.RequestPermission) {
+ val activity = getActivity()
+ val paywallIdentifier = messageHandler?.state?.paywall?.identifier ?: ""
+ val permissionName = request.permissionType.rawValue
+
+ messageHandler?.eventDidOccur(
+ PaywallWebEvent.RequestPermission(
+ permissionType = request.permissionType,
+ requestId = request.requestId,
+ ),
+ )
+
+ // Track permission requested event
+ ioScope.launch {
+ track(
+ InternalSuperwallEvent.PermissionRequested(
+ permissionName = permissionName,
+ paywallIdentifier = paywallIdentifier,
+ ),
+ )
+ }
+
+ if (activity == null) {
+ Logger.debug(
+ LogLevel.error,
+ LogScope.superwallCore,
+ "Cannot request permission - no activity available",
+ )
+ // Send unsupported status back to webview since we can't request
+ ioScope.launch {
+ sendPermissionResult(
+ requestId = request.requestId,
+ permissionType = request.permissionType,
+ status = PermissionStatus.UNSUPPORTED,
+ )
+ }
+ return
+ }
+
+ ioScope.launch {
+ val status =
+ try {
+ userPermissions.requestPermission(activity, request.permissionType)
+ } catch (e: Exception) {
+ Logger.debug(
+ LogLevel.error,
+ LogScope.superwallCore,
+ "Error requesting permission: ${e.message}",
+ error = e,
+ )
+ PermissionStatus.UNSUPPORTED
+ }
+
+ // Track permission result event
+ when (status) {
+ PermissionStatus.GRANTED -> {
+ track(
+ InternalSuperwallEvent.PermissionGranted(
+ permissionName = permissionName,
+ paywallIdentifier = paywallIdentifier,
+ ),
+ )
+ }
+ PermissionStatus.DENIED, PermissionStatus.UNSUPPORTED -> {
+ track(
+ InternalSuperwallEvent.PermissionDenied(
+ permissionName = permissionName,
+ paywallIdentifier = paywallIdentifier,
+ ),
+ )
+ }
+ }
+
+ sendPermissionResult(
+ requestId = request.requestId,
+ permissionType = request.permissionType,
+ status = status,
+ )
+ }
+ }
+
+ /**
+ * Send a permission_result message back to the webview
+ */
+ private suspend fun sendPermissionResult(
+ requestId: String,
+ permissionType: com.superwall.sdk.permissions.PermissionType,
+ status: PermissionStatus,
+ ) {
+ val eventList =
+ listOf(
+ mapOf(
+ "event_name" to "permission_result",
+ "permission_type" to permissionType.rawValue,
+ "request_id" to requestId,
+ "status" to status.rawValue,
+ ),
+ )
+
+ val jsonString =
+ try {
+ json.encodeToString(eventList.convertToJsonElement())
+ } catch (e: Throwable) {
+ Logger.debug(
+ LogLevel.error,
+ LogScope.superwallCore,
+ "Error encoding permission result: ${e.message}",
+ error = e,
+ )
+ return
+ }
+
+ Logger.debug(
+ LogLevel.debug,
+ LogScope.superwallCore,
+ "Sending permission_result: $jsonString",
+ )
+
+ passMessageToWebView(base64String = encodeToB64(jsonString))
+ }
+
private fun detectHiddenPaywallEvent(
eventName: String,
userInfo: Map? = null,
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 f0e7e4ee..71b0ca9f 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
@@ -2,6 +2,7 @@ package com.superwall.sdk.paywall.view.webview.messaging
import android.net.Uri
import com.superwall.sdk.models.paywall.LocalNotification
+import com.superwall.sdk.permissions.PermissionType
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.JsonObject
@@ -63,4 +64,10 @@ sealed class PaywallWebEvent {
EXTERNAL("external"),
}
}
+
+ @SerialName("request_permission")
+ data class RequestPermission(
+ val permissionType: PermissionType,
+ val requestId: String,
+ ) : PaywallWebEvent()
}
diff --git a/superwall/src/main/java/com/superwall/sdk/permissions/PermissionStatus.kt b/superwall/src/main/java/com/superwall/sdk/permissions/PermissionStatus.kt
new file mode 100644
index 00000000..2fc89607
--- /dev/null
+++ b/superwall/src/main/java/com/superwall/sdk/permissions/PermissionStatus.kt
@@ -0,0 +1,23 @@
+package com.superwall.sdk.permissions
+
+/**
+ * Permission status values matching OS-level permission states.
+ * Maps to the paywall schema permission_result status values.
+ */
+enum class PermissionStatus(
+ val rawValue: String,
+) {
+ /** User granted permission */
+ GRANTED("granted"),
+
+ /** User denied permission */
+ DENIED("denied"),
+
+ /** Platform doesn't support this permission */
+ UNSUPPORTED("unsupported"),
+ ;
+
+ companion object {
+ fun fromRaw(raw: String): PermissionStatus? = values().find { it.rawValue == raw }
+ }
+}
diff --git a/superwall/src/main/java/com/superwall/sdk/permissions/PermissionType.kt b/superwall/src/main/java/com/superwall/sdk/permissions/PermissionType.kt
new file mode 100644
index 00000000..76c3a32f
--- /dev/null
+++ b/superwall/src/main/java/com/superwall/sdk/permissions/PermissionType.kt
@@ -0,0 +1,65 @@
+package com.superwall.sdk.permissions
+
+import android.Manifest
+import android.os.Build
+
+/**
+ * Permission types that can be requested from the host app.
+ * Maps to the paywall schema permission_type values.
+ */
+enum class PermissionType(
+ val rawValue: String,
+) {
+ NOTIFICATION("notification"),
+ LOCATION("location"),
+ BACKGROUND_LOCATION("background_location"),
+ READ_IMAGES("read_images"),
+ CONTACTS("contacts"),
+ READ_VIDEO("read_video"),
+ CAMERA("camera"),
+ ;
+
+ /**
+ * Get the Android manifest permission string for this permission type.
+ * Returns null if the permission is not available on the current API level.
+ */
+ fun toManifestPermission(): String? =
+ when (this) {
+ NOTIFICATION ->
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
+ Manifest.permission.POST_NOTIFICATIONS
+ } else {
+ null // Notifications don't require runtime permission before API 33
+ }
+ LOCATION -> Manifest.permission.ACCESS_FINE_LOCATION
+ BACKGROUND_LOCATION ->
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
+ Manifest.permission.ACCESS_BACKGROUND_LOCATION
+ } else {
+ null // Background location not available before API 29
+ }
+ READ_IMAGES ->
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
+ Manifest.permission.READ_MEDIA_IMAGES
+ } else {
+ Manifest.permission.READ_EXTERNAL_STORAGE
+ }
+ CONTACTS -> Manifest.permission.READ_CONTACTS
+ READ_VIDEO ->
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
+ Manifest.permission.READ_MEDIA_VIDEO
+ } else {
+ Manifest.permission.READ_EXTERNAL_STORAGE
+ }
+ CAMERA -> Manifest.permission.CAMERA
+ }
+
+ companion object {
+ /**
+ * Find a PermissionType by its raw value from the paywall schema
+ * @param raw The permission type string (e.g., "notification")
+ * @return The corresponding PermissionType or null if not found
+ */
+ fun fromRaw(raw: String): PermissionType? = values().find { it.rawValue == raw }
+ }
+}
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..6d569e65
--- /dev/null
+++ b/superwall/src/main/java/com/superwall/sdk/permissions/UserPermissions.kt
@@ -0,0 +1,27 @@
+package com.superwall.sdk.permissions
+
+import android.app.Activity
+
+/**
+ * Interface for managing user permissions in a testable way.
+ * Handles permission requests from paywalls and returns results to be sent back.
+ */
+interface UserPermissions {
+ /**
+ * Check if a specific permission is granted
+ * @param permission The permission to check
+ * @return The current status of the permission
+ */
+ fun hasPermission(permission: PermissionType): PermissionStatus
+
+ /**
+ * Request a specific permission from the user
+ * @param activity The activity to use for the permission request
+ * @param permission The permission to request
+ * @return The result of the permission request
+ */
+ suspend fun requestPermission(
+ activity: Activity,
+ permission: PermissionType,
+ ): PermissionStatus
+}
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..f4bc19ad
--- /dev/null
+++ b/superwall/src/main/java/com/superwall/sdk/permissions/UserPermissionsImpl.kt
@@ -0,0 +1,230 @@
+package com.superwall.sdk.permissions
+
+import android.app.Activity
+import android.content.Context
+import android.content.pm.PackageManager
+import android.os.Build
+import androidx.activity.ComponentActivity
+import androidx.activity.result.ActivityResultLauncher
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.core.app.ActivityCompat
+import androidx.core.app.NotificationManagerCompat
+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 {
+ override fun hasPermission(permission: PermissionType): PermissionStatus =
+ when (permission) {
+ PermissionType.NOTIFICATION -> checkNotificationPermission()
+ PermissionType.LOCATION,
+ PermissionType.READ_IMAGES,
+ PermissionType.CONTACTS,
+ PermissionType.READ_VIDEO,
+ PermissionType.CAMERA,
+ -> checkRuntimePermission(permission)
+ PermissionType.BACKGROUND_LOCATION -> checkBackgroundLocationPermission()
+ }
+
+ private fun checkNotificationPermission(): PermissionStatus {
+ // On API 33+, check the runtime permission
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
+ val granted =
+ ContextCompat.checkSelfPermission(
+ context,
+ android.Manifest.permission.POST_NOTIFICATIONS,
+ ) == PackageManager.PERMISSION_GRANTED
+ return if (granted) PermissionStatus.GRANTED else PermissionStatus.DENIED
+ }
+
+ // On older APIs, check if notifications are enabled in system settings
+ val notificationManager = NotificationManagerCompat.from(context)
+ return if (notificationManager.areNotificationsEnabled()) {
+ PermissionStatus.GRANTED
+ } else {
+ PermissionStatus.DENIED
+ }
+ }
+
+ private fun checkRuntimePermission(permission: PermissionType): PermissionStatus {
+ val manifestPermission =
+ permission.toManifestPermission()
+ ?: return PermissionStatus.UNSUPPORTED
+
+ val granted =
+ ContextCompat.checkSelfPermission(
+ context,
+ manifestPermission,
+ ) == PackageManager.PERMISSION_GRANTED
+ return if (granted) PermissionStatus.GRANTED else PermissionStatus.DENIED
+ }
+
+ private fun checkBackgroundLocationPermission(): PermissionStatus {
+ // Background location requires API 29+
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) {
+ return PermissionStatus.UNSUPPORTED
+ }
+
+ // First check if foreground location is granted
+ val foregroundGranted =
+ ContextCompat.checkSelfPermission(
+ context,
+ android.Manifest.permission.ACCESS_FINE_LOCATION,
+ ) == PackageManager.PERMISSION_GRANTED
+
+ if (!foregroundGranted) {
+ return PermissionStatus.DENIED
+ }
+
+ // Then check background location
+ val backgroundGranted =
+ ContextCompat.checkSelfPermission(
+ context,
+ android.Manifest.permission.ACCESS_BACKGROUND_LOCATION,
+ ) == PackageManager.PERMISSION_GRANTED
+
+ return if (backgroundGranted) PermissionStatus.GRANTED else PermissionStatus.DENIED
+ }
+
+ override suspend fun requestPermission(
+ activity: Activity,
+ permission: PermissionType,
+ ): PermissionStatus =
+ when (permission) {
+ PermissionType.NOTIFICATION -> requestNotificationPermission(activity)
+ PermissionType.BACKGROUND_LOCATION -> requestBackgroundLocationPermission(activity)
+ PermissionType.LOCATION,
+ PermissionType.READ_IMAGES,
+ PermissionType.CONTACTS,
+ PermissionType.READ_VIDEO,
+ PermissionType.CAMERA,
+ -> requestStandardPermission(activity, permission)
+ }
+
+ private suspend fun requestNotificationPermission(activity: Activity): PermissionStatus {
+ // Check current status first
+ val currentStatus = hasPermission(PermissionType.NOTIFICATION)
+ if (currentStatus == PermissionStatus.GRANTED) {
+ return PermissionStatus.GRANTED
+ }
+
+ // On API < 33, we can't request notification permission at runtime
+ // The user needs to enable it in system settings
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) {
+ // Can't request at runtime - return current status
+ return currentStatus
+ }
+
+ // On API 33+, request the POST_NOTIFICATIONS permission
+ val manifestPermission =
+ PermissionType.NOTIFICATION.toManifestPermission()
+ ?: return PermissionStatus.UNSUPPORTED
+
+ return requestRuntimePermission(activity, manifestPermission)
+ }
+
+ private suspend fun requestStandardPermission(
+ activity: Activity,
+ permission: PermissionType,
+ ): PermissionStatus {
+ // Check current status first
+ val currentStatus = hasPermission(permission)
+ if (currentStatus == PermissionStatus.GRANTED) {
+ return PermissionStatus.GRANTED
+ }
+
+ val manifestPermission =
+ permission.toManifestPermission()
+ ?: return PermissionStatus.UNSUPPORTED
+
+ return requestRuntimePermission(activity, manifestPermission)
+ }
+
+ private suspend fun requestBackgroundLocationPermission(activity: Activity): PermissionStatus {
+ // Background location requires API 29+
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) {
+ return PermissionStatus.UNSUPPORTED
+ }
+
+ // Check if already granted
+ val currentStatus = hasPermission(PermissionType.BACKGROUND_LOCATION)
+ if (currentStatus == PermissionStatus.GRANTED) {
+ return PermissionStatus.GRANTED
+ }
+
+ // First ensure foreground location is granted
+ val foregroundStatus = hasPermission(PermissionType.LOCATION)
+ if (foregroundStatus != PermissionStatus.GRANTED) {
+ // Request foreground location first
+ val foregroundResult = requestStandardPermission(activity, PermissionType.LOCATION)
+ if (foregroundResult != PermissionStatus.GRANTED) {
+ return PermissionStatus.DENIED
+ }
+ }
+
+ // Now request background location
+ val manifestPermission =
+ PermissionType.BACKGROUND_LOCATION.toManifestPermission()
+ ?: return PermissionStatus.UNSUPPORTED
+
+ return requestRuntimePermission(activity, manifestPermission)
+ }
+
+ private suspend fun requestRuntimePermission(
+ activity: Activity,
+ manifestPermission: String,
+ ): PermissionStatus =
+ suspendCancellableCoroutine { continuation ->
+ // Check if we can use the modern ActivityResult API
+ if (activity is ComponentActivity) {
+ // Use a one-shot launcher pattern
+ var launcher: ActivityResultLauncher? = null
+ launcher =
+ activity.activityResultRegistry.register(
+ "permission_request_${System.currentTimeMillis()}",
+ ActivityResultContracts.RequestPermission(),
+ ) { isGranted ->
+ launcher?.unregister()
+ val status = if (isGranted) PermissionStatus.GRANTED else PermissionStatus.DENIED
+ if (continuation.isActive) {
+ continuation.resume(status)
+ }
+ }
+
+ continuation.invokeOnCancellation {
+ launcher.unregister()
+ }
+
+ launcher.launch(manifestPermission)
+ } else {
+ // Fallback for non-ComponentActivity (legacy approach)
+ // This won't wait for result, so we can only check current state
+ ActivityCompat.requestPermissions(
+ activity,
+ arrayOf(manifestPermission),
+ PERMISSION_REQUEST_CODE,
+ )
+
+ // Since we can't wait for result with legacy API, check after a delay
+ // This is not ideal but provides fallback compatibility
+ val isGranted =
+ ContextCompat.checkSelfPermission(
+ activity,
+ manifestPermission,
+ ) == PackageManager.PERMISSION_GRANTED
+
+ if (continuation.isActive) {
+ continuation.resume(if (isGranted) PermissionStatus.GRANTED else PermissionStatus.DENIED)
+ }
+ }
+ }
+
+ companion object {
+ private const val PERMISSION_REQUEST_CODE = 10001
+ }
+}
diff --git a/superwall/src/test/java/com/superwall/sdk/paywall/view/PaywallMessageHandlerTest.kt b/superwall/src/test/java/com/superwall/sdk/paywall/view/PaywallMessageHandlerTest.kt
index 3daa577b..147396cd 100644
--- a/superwall/src/test/java/com/superwall/sdk/paywall/view/PaywallMessageHandlerTest.kt
+++ b/superwall/src/test/java/com/superwall/sdk/paywall/view/PaywallMessageHandlerTest.kt
@@ -1,6 +1,7 @@
package com.superwall.sdk.paywall.view
import TemplateLogic
+import android.app.Activity
import android.view.View
import android.view.ViewGroup
import com.superwall.sdk.Given
@@ -20,6 +21,9 @@ import com.superwall.sdk.paywall.view.webview.messaging.PaywallMessageHandler
import com.superwall.sdk.paywall.view.webview.messaging.PaywallWebEvent
import com.superwall.sdk.paywall.view.webview.templating.models.JsonVariables
import com.superwall.sdk.paywall.view.webview.templating.models.Variables
+import com.superwall.sdk.permissions.PermissionStatus
+import com.superwall.sdk.permissions.PermissionType
+import com.superwall.sdk.permissions.UserPermissions
import com.superwall.sdk.storage.LocalStorage
import com.superwall.sdk.web.WebPaywallRedeemer
import io.mockk.coEvery
@@ -280,6 +284,15 @@ class PaywallMessageHandlerTest {
lateinit var viewRef: PaywallView
val scopeContext = scope.coroutineContext
+ val fakeUserPermissions =
+ object : UserPermissions {
+ override fun hasPermission(permission: PermissionType): PermissionStatus = PermissionStatus.GRANTED
+
+ override suspend fun requestPermission(
+ activity: Activity,
+ permission: PermissionType,
+ ): PermissionStatus = PermissionStatus.GRANTED
+ }
val messageHandler =
PaywallMessageHandler(
factory = TestVariablesFactory,
@@ -296,6 +309,8 @@ class PaywallMessageHandlerTest {
},
json = Json { encodeDefaults = true },
encodeToB64 = { it },
+ userPermissions = fakeUserPermissions,
+ getActivity = { null },
)
val state =
diff --git a/superwall/src/test/java/com/superwall/sdk/paywall/view/PaywallViewTest.kt b/superwall/src/test/java/com/superwall/sdk/paywall/view/PaywallViewTest.kt
index 6b374f8a..dbed7919 100644
--- a/superwall/src/test/java/com/superwall/sdk/paywall/view/PaywallViewTest.kt
+++ b/superwall/src/test/java/com/superwall/sdk/paywall/view/PaywallViewTest.kt
@@ -1,6 +1,7 @@
package com.superwall.sdk.paywall.view
import TemplateLogic
+import android.app.Activity
import android.view.View
import android.view.ViewGroup
import com.superwall.sdk.Given
@@ -25,6 +26,9 @@ import com.superwall.sdk.paywall.view.webview.messaging.PaywallMessageHandler
import com.superwall.sdk.paywall.view.webview.messaging.PaywallWebEvent
import com.superwall.sdk.paywall.view.webview.templating.models.JsonVariables
import com.superwall.sdk.paywall.view.webview.templating.models.Variables
+import com.superwall.sdk.permissions.PermissionStatus
+import com.superwall.sdk.permissions.PermissionType
+import com.superwall.sdk.permissions.UserPermissions
import com.superwall.sdk.storage.LocalStorage
import com.superwall.sdk.web.WebPaywallRedeemer
import io.mockk.Runs
@@ -417,6 +421,15 @@ class PaywallViewTest {
lateinit var viewRef: PaywallView
val scopeContext = scope.coroutineContext
+ val fakeUserPermissions =
+ object : UserPermissions {
+ override fun hasPermission(permission: PermissionType): PermissionStatus = PermissionStatus.GRANTED
+
+ override suspend fun requestPermission(
+ activity: Activity,
+ permission: PermissionType,
+ ): PermissionStatus = PermissionStatus.GRANTED
+ }
val messageHandler =
PaywallMessageHandler(
factory = TestVariablesFactory,
@@ -433,6 +446,8 @@ class PaywallViewTest {
},
json = Json { encodeDefaults = true },
encodeToB64 = { it },
+ userPermissions = fakeUserPermissions,
+ getActivity = { null },
)
val state =
diff --git a/superwall/src/test/java/com/superwall/sdk/paywall/view/webview/PaywallMessageHandlerTest.kt b/superwall/src/test/java/com/superwall/sdk/paywall/view/webview/PaywallMessageHandlerTest.kt
index 6ac2565e..615ac5a5 100644
--- a/superwall/src/test/java/com/superwall/sdk/paywall/view/webview/PaywallMessageHandlerTest.kt
+++ b/superwall/src/test/java/com/superwall/sdk/paywall/view/webview/PaywallMessageHandlerTest.kt
@@ -1,5 +1,6 @@
package com.superwall.sdk.paywall.view.webview
+import android.app.Activity
import com.superwall.sdk.Given
import com.superwall.sdk.Then
import com.superwall.sdk.When
@@ -21,6 +22,10 @@ import com.superwall.sdk.paywall.view.webview.messaging.PaywallWebEvent
import com.superwall.sdk.paywall.view.webview.messaging.parseWrappedPaywallMessages
import com.superwall.sdk.paywall.view.webview.templating.models.JsonVariables
import com.superwall.sdk.paywall.view.webview.templating.models.Variables
+import com.superwall.sdk.permissions.PermissionStatus
+import com.superwall.sdk.permissions.PermissionType
+import com.superwall.sdk.permissions.UserPermissions
+import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.test.StandardTestDispatcher
@@ -86,6 +91,37 @@ class PaywallMessageHandlerTest {
): JsonVariables = JsonVariables("template_variables", Variables(emptyMap(), emptyMap(), emptyMap()))
}
+ private class FakeUserPermissions : UserPermissions {
+ override fun hasPermission(permission: PermissionType): PermissionStatus = PermissionStatus.GRANTED
+
+ override suspend fun requestPermission(
+ activity: Activity,
+ permission: PermissionType,
+ ): PermissionStatus = PermissionStatus.GRANTED
+ }
+
+ private fun createHandler(
+ track: suspend (com.superwall.sdk.analytics.internal.trackable.TrackableSuperwallEvent) -> Unit = { _ -> },
+ setAttributes: (Map) -> Unit = { _ -> },
+ ioScope: CoroutineScope = IOScope(Dispatchers.Unconfined),
+ encodeToB64: (String) -> String = { it },
+ ): PaywallMessageHandler =
+ PaywallMessageHandler(
+ factory = FakeVariablesFactory(),
+ options =
+ object : OptionsFactory {
+ override fun makeSuperwallOptions(): SuperwallOptions = SuperwallOptions()
+ },
+ track = track,
+ setAttributes = setAttributes,
+ getView = { null },
+ mainScope = MainScope(Dispatchers.Unconfined),
+ ioScope = ioScope,
+ encodeToB64 = encodeToB64,
+ userPermissions = FakeUserPermissions(),
+ getActivity = { null },
+ )
+
@Test
fun onReady_setsVersion_via_updateState() =
runTest {
@@ -94,23 +130,12 @@ class PaywallMessageHandlerTest {
val state = PaywallViewState(paywall = paywall, locale = "en-US")
val delegate = FakeDelegate(state)
// Use a cancelled IO scope so didLoadWebView doesn't run (avoids tracking).
- val cancelledIoScope = IOScope(Dispatchers.Unconfined)
val handler =
- PaywallMessageHandler(
- factory = FakeVariablesFactory(),
- options =
- object : OptionsFactory {
- override fun makeSuperwallOptions(): SuperwallOptions = SuperwallOptions()
- },
- track = { _ -> },
- setAttributes = { _ -> },
- getView = { null },
- mainScope = MainScope(Dispatchers.Unconfined),
+ createHandler(
ioScope =
- object : kotlinx.coroutines.CoroutineScope {
+ object : CoroutineScope {
override val coroutineContext = kotlinx.coroutines.Job().apply { cancel() } + Dispatchers.Unconfined
},
- encodeToB64 = { it },
)
handler.messageHandler = delegate
@@ -134,20 +159,7 @@ class PaywallMessageHandlerTest {
val paywall = Paywall.stub()
val state = PaywallViewState(paywall = paywall, locale = "en-US")
val delegate = FakeDelegate(state)
- val handler =
- PaywallMessageHandler(
- factory = FakeVariablesFactory(),
- options =
- object : OptionsFactory {
- override fun makeSuperwallOptions(): SuperwallOptions = SuperwallOptions()
- },
- track = { _ -> },
- setAttributes = { _ -> },
- getView = { null },
- mainScope = MainScope(Dispatchers.Unconfined),
- ioScope = IOScope(Dispatchers.Unconfined),
- encodeToB64 = { it },
- )
+ val handler = createHandler()
handler.messageHandler = delegate
When("OnReady is handled and async flows complete") {
@@ -205,20 +217,7 @@ class PaywallMessageHandlerTest {
}
val tracked = mutableListOf()
- val handler =
- PaywallMessageHandler(
- factory = FakeVariablesFactory(),
- options =
- object : OptionsFactory {
- override fun makeSuperwallOptions(): SuperwallOptions = SuperwallOptions()
- },
- track = { evt -> tracked.add(evt.superwallPlacement.rawName) },
- setAttributes = { _ -> },
- getView = { null },
- mainScope = MainScope(Dispatchers.Unconfined),
- ioScope = IOScope(Dispatchers.Unconfined),
- encodeToB64 = { it },
- )
+ val handler = createHandler(track = { evt -> tracked.add(evt.superwallPlacement.rawName) })
handler.messageHandler = delegate
When("messages are handled before readiness (queue), then after ready") {
@@ -316,20 +315,7 @@ class PaywallMessageHandlerTest {
resultCallback?.invoke(null)
}
}
- val handler =
- PaywallMessageHandler(
- factory = FakeVariablesFactory(),
- options =
- object : OptionsFactory {
- override fun makeSuperwallOptions(): SuperwallOptions = SuperwallOptions()
- },
- track = { _ -> },
- setAttributes = { _ -> },
- getView = { null },
- mainScope = MainScope(Dispatchers.Unconfined),
- ioScope = IOScope(Dispatchers.Unconfined),
- encodeToB64 = { it },
- )
+ val handler = createHandler()
handler.messageHandler = delegate
When("OnReady is handled then RestoreFailed arrives") {
@@ -366,20 +352,7 @@ class PaywallMessageHandlerTest {
deepLinks.add(url)
}
}
- val handler =
- PaywallMessageHandler(
- factory = FakeVariablesFactory(),
- options =
- object : OptionsFactory {
- override fun makeSuperwallOptions(): SuperwallOptions = SuperwallOptions()
- },
- track = { _ -> },
- setAttributes = { _ -> },
- getView = { null },
- mainScope = MainScope(Dispatchers.Unconfined),
- ioScope = IOScope(Dispatchers.Unconfined),
- encodeToB64 = { it },
- )
+ val handler = createHandler()
handler.messageHandler = delegate
When("Custom message arrives") {
@@ -410,20 +383,7 @@ class PaywallMessageHandlerTest {
resultCallback?.invoke(null)
}
}
- val handler =
- PaywallMessageHandler(
- factory = FakeVariablesFactory(),
- options =
- object : OptionsFactory {
- override fun makeSuperwallOptions(): SuperwallOptions = SuperwallOptions()
- },
- track = { _ -> },
- setAttributes = { _ -> },
- getView = { null },
- mainScope = MainScope(Dispatchers.Unconfined),
- ioScope = IOScope(Dispatchers.Unconfined),
- encodeToB64 = { it },
- )
+ val handler = createHandler()
handler.messageHandler = delegate
When("OnReady then PaywallOpen/PaywallClose are handled") {
@@ -459,20 +419,7 @@ class PaywallMessageHandlerTest {
events.add(paywallWebEvent)
}
}
- val handler =
- PaywallMessageHandler(
- factory = FakeVariablesFactory(),
- options =
- object : OptionsFactory {
- override fun makeSuperwallOptions(): SuperwallOptions = SuperwallOptions()
- },
- track = { _ -> },
- setAttributes = { _ -> },
- getView = { null },
- mainScope = MainScope(Dispatchers.Unconfined),
- ioScope = IOScope(Dispatchers.Unconfined),
- encodeToB64 = { it },
- )
+ val handler = createHandler()
handler.messageHandler = delegate
When("OpenUrl, OpenUrlInBrowser, OpenDeepLink arrive") {
@@ -505,20 +452,7 @@ class PaywallMessageHandlerTest {
events.add(paywallWebEvent)
}
}
- val handler =
- PaywallMessageHandler(
- factory = FakeVariablesFactory(),
- options =
- object : OptionsFactory {
- override fun makeSuperwallOptions(): SuperwallOptions = SuperwallOptions()
- },
- track = { _ -> },
- setAttributes = { _ -> },
- getView = { null },
- mainScope = MainScope(Dispatchers.Unconfined),
- ioScope = IOScope(Dispatchers.Unconfined),
- encodeToB64 = { it },
- )
+ val handler = createHandler()
handler.messageHandler = delegate
When("RequestReview external arrives") {
@@ -543,20 +477,7 @@ class PaywallMessageHandlerTest {
val state = PaywallViewState(paywall = paywall, locale = "en-US")
val delegate = FakeDelegate(state)
val capturedAttributes = mutableListOf