From 443dc4493565ce6c4ea004c8163adf64a059e60d Mon Sep 17 00:00:00 2001 From: Ian Rumac Date: Mon, 15 Dec 2025 15:43:49 +0100 Subject: [PATCH 1/3] Add permission granting for notifications --- CHANGELOG.md | 3 +- .../main/java/com/superwall/sdk/Superwall.kt | 9 + .../sdk/dependencies/DependencyContainer.kt | 7 + .../sdk/paywall/view/webview/SWWebView.kt | 2 +- .../view/webview/messaging/PaywallMessage.kt | 22 +++ .../messaging/PaywallMessageHandler.kt | 96 ++++++++++ .../view/webview/messaging/PaywallWebEvent.kt | 7 + .../sdk/permissions/PermissionStatus.kt | 23 +++ .../sdk/permissions/PermissionType.kt | 38 ++++ .../sdk/permissions/UserPermissions.kt | 27 +++ .../sdk/permissions/UserPermissionsImpl.kt | 129 +++++++++++++ .../paywall/view/PaywallMessageHandlerTest.kt | 15 ++ .../sdk/paywall/view/PaywallViewTest.kt | 15 ++ .../view/webview/PaywallMessageHandlerTest.kt | 171 +++++------------- .../PaywallMessageHandlerEdgeCasesTest.kt | 126 +++++++++++++ .../sdk/permissions/PermissionStatusTest.kt | 115 ++++++++++++ .../sdk/permissions/PermissionTypeTest.kt | 76 ++++++++ 17 files changed, 754 insertions(+), 127 deletions(-) create mode 100644 superwall/src/main/java/com/superwall/sdk/permissions/PermissionStatus.kt create mode 100644 superwall/src/main/java/com/superwall/sdk/permissions/PermissionType.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 create mode 100644 superwall/src/test/java/com/superwall/sdk/permissions/PermissionStatusTest.kt create mode 100644 superwall/src/test/java/com/superwall/sdk/permissions/PermissionTypeTest.kt 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/superwall/src/main/java/com/superwall/sdk/Superwall.kt b/superwall/src/main/java/com/superwall/sdk/Superwall.kt index 280cdb98..ff7a45d9 100644 --- a/superwall/src/main/java/com/superwall/sdk/Superwall.kt +++ b/superwall/src/main/java/com/superwall/sdk/Superwall.kt @@ -64,6 +64,7 @@ import com.superwall.sdk.paywall.view.webview.messaging.PaywallWebEvent.Initiate import com.superwall.sdk.paywall.view.webview.messaging.PaywallWebEvent.OpenedDeepLink import com.superwall.sdk.paywall.view.webview.messaging.PaywallWebEvent.OpenedURL import com.superwall.sdk.paywall.view.webview.messaging.PaywallWebEvent.OpenedUrlInChrome +import com.superwall.sdk.paywall.view.webview.messaging.PaywallWebEvent.RequestPermission import com.superwall.sdk.storage.LatestCustomerInfo import com.superwall.sdk.storage.ReviewCount import com.superwall.sdk.storage.ReviewData @@ -1408,6 +1409,14 @@ class Superwall( ) } } + + is RequestPermission -> { + Logger.debug( + LogLevel.debug, + LogScope.paywallView, + message = "Permission requested: ${paywallEvent.permissionType.rawValue}", + ) + } } } } 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..b27448df 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,95 @@ class PaywallMessageHandler( ) } + private fun handleRequestPermission(request: PaywallMessage.RequestPermission) { + val activity = getActivity() + + messageHandler?.eventDidOccur( + PaywallWebEvent.RequestPermission( + permissionType = request.permissionType, + requestId = request.requestId, + ), + ) + + 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 + } + + 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..cfeeb578 --- /dev/null +++ b/superwall/src/main/java/com/superwall/sdk/permissions/PermissionType.kt @@ -0,0 +1,38 @@ +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"), + ; + + /** + * 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 + } + } + + 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..e3552c9b --- /dev/null +++ b/superwall/src/main/java/com/superwall/sdk/permissions/UserPermissionsImpl.kt @@ -0,0 +1,129 @@ +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() + } + + 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 + } + } + + override suspend fun requestPermission( + activity: Activity, + permission: PermissionType, + ): PermissionStatus = + when (permission) { + PermissionType.NOTIFICATION -> requestNotificationPermission(activity) + } + + 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 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>() - val handler = - PaywallMessageHandler( - factory = FakeVariablesFactory(), - options = - object : OptionsFactory { - override fun makeSuperwallOptions(): SuperwallOptions = SuperwallOptions() - }, - track = { _ -> }, - setAttributes = { attrs -> capturedAttributes.add(attrs) }, - getView = { null }, - mainScope = MainScope(Dispatchers.Unconfined), - ioScope = IOScope(Dispatchers.Unconfined), - encodeToB64 = { it }, - ) + val handler = createHandler(setAttributes = { attrs -> capturedAttributes.add(attrs) }) handler.messageHandler = delegate When("UserAttributesUpdated message is handled") { diff --git a/superwall/src/test/java/com/superwall/sdk/paywall/view/webview/messaging/PaywallMessageHandlerEdgeCasesTest.kt b/superwall/src/test/java/com/superwall/sdk/paywall/view/webview/messaging/PaywallMessageHandlerEdgeCasesTest.kt index 88862ab0..40eede2b 100644 --- a/superwall/src/test/java/com/superwall/sdk/paywall/view/webview/messaging/PaywallMessageHandlerEdgeCasesTest.kt +++ b/superwall/src/test/java/com/superwall/sdk/paywall/view/webview/messaging/PaywallMessageHandlerEdgeCasesTest.kt @@ -1,5 +1,6 @@ package com.superwall.sdk.paywall.view.webview.messaging +import android.app.Activity import com.superwall.sdk.Given import com.superwall.sdk.Then import com.superwall.sdk.When @@ -15,6 +16,9 @@ import com.superwall.sdk.models.product.ProductVariable import com.superwall.sdk.paywall.view.PaywallViewState 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.Dispatchers import kotlinx.coroutines.test.StandardTestDispatcher import kotlinx.coroutines.test.advanceUntilIdle @@ -83,6 +87,21 @@ class PaywallMessageHandlerEdgeCasesTest { ): JsonVariables = JsonVariables("template_variables", Variables(emptyMap(), emptyMap(), emptyMap())) } + private class FakeUserPermissions : UserPermissions { + var permissionToReturn: PermissionStatus = PermissionStatus.GRANTED + var requestedPermissions = mutableListOf() + + override fun hasPermission(permission: PermissionType): PermissionStatus = permissionToReturn + + override suspend fun requestPermission( + activity: Activity, + permission: PermissionType, + ): PermissionStatus { + requestedPermissions.add(permission) + return permissionToReturn + } + } + @Test fun handleCustom_with_null_messageHandler_does_not_crash() = runTest { @@ -100,6 +119,8 @@ class PaywallMessageHandlerEdgeCasesTest { mainScope = MainScope(Dispatchers.Unconfined), ioScope = IOScope(Dispatchers.Unconfined), encodeToB64 = { it }, + userPermissions = FakeUserPermissions(), + getActivity = { null }, ) // Note: messageHandler is not set @@ -146,6 +167,8 @@ class PaywallMessageHandlerEdgeCasesTest { mainScope = MainScope(Dispatchers.Unconfined), ioScope = IOScope(Dispatchers.Unconfined), encodeToB64 = { it }, + userPermissions = FakeUserPermissions(), + getActivity = { null }, ) handler.messageHandler = delegate @@ -186,6 +209,8 @@ class PaywallMessageHandlerEdgeCasesTest { mainScope = MainScope(Dispatchers.Unconfined), ioScope = IOScope(Dispatchers.Unconfined), encodeToB64 = { it }, + userPermissions = FakeUserPermissions(), + getActivity = { null }, ) handler.messageHandler = delegate @@ -226,6 +251,8 @@ class PaywallMessageHandlerEdgeCasesTest { mainScope = MainScope(Dispatchers.Unconfined), ioScope = IOScope(Dispatchers.Unconfined), encodeToB64 = { it }, + userPermissions = FakeUserPermissions(), + getActivity = { null }, ) handler.messageHandler = delegate @@ -260,6 +287,8 @@ class PaywallMessageHandlerEdgeCasesTest { mainScope = MainScope(Dispatchers.Unconfined), ioScope = IOScope(Dispatchers.Unconfined), encodeToB64 = { it }, + userPermissions = FakeUserPermissions(), + getActivity = { null }, ) handler.messageHandler = delegate @@ -300,6 +329,8 @@ class PaywallMessageHandlerEdgeCasesTest { mainScope = MainScope(Dispatchers.Unconfined), ioScope = IOScope(Dispatchers.Unconfined), encodeToB64 = { it }, + userPermissions = FakeUserPermissions(), + getActivity = { null }, ) handler.messageHandler = delegate @@ -319,4 +350,99 @@ class PaywallMessageHandlerEdgeCasesTest { } } } + + @Test + fun requestPermission_emits_event_with_correct_permission_type() = + runTest { + Given("a handler with a delegate") { + val paywall = Paywall.stub() + val state = PaywallViewState(paywall = paywall, locale = "en-US") + val delegate = RecordingDelegate(state) + val fakePermissions = FakeUserPermissions() + fakePermissions.permissionToReturn = PermissionStatus.GRANTED + + 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 }, + userPermissions = fakePermissions, + getActivity = { null }, + ) + handler.messageHandler = delegate + + When("a RequestPermission message is handled") { + handler.handle( + PaywallMessage.RequestPermission( + permissionType = PermissionType.NOTIFICATION, + requestId = "test-request-123", + ), + ) + advanceUntilIdle() + + Then("it emits RequestPermission event with correct data") { + assertEquals(1, delegate.events.size) + val event = delegate.events[0] as PaywallWebEvent.RequestPermission + assertEquals(PermissionType.NOTIFICATION, event.permissionType) + assertEquals("test-request-123", event.requestId) + } + } + } + } + + @Test + fun requestPermission_without_activity_returns_unsupported() = + runTest { + Given("a handler without an activity provider") { + val paywall = Paywall.stub() + val state = PaywallViewState(paywall = paywall, locale = "en-US") + val delegate = RecordingDelegate(state) + var encodedMessages = mutableListOf() + + 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 = { msg -> + encodedMessages.add(msg) + msg + }, + userPermissions = FakeUserPermissions(), + getActivity = { null }, // No activity available + ) + handler.messageHandler = delegate + + When("a RequestPermission message is handled") { + handler.handle( + PaywallMessage.RequestPermission( + permissionType = PermissionType.NOTIFICATION, + requestId = "test-request-456", + ), + ) + advanceUntilIdle() + + Then("the permission result contains unsupported status") { + // Verify the encoded message contains "unsupported" + val lastMessage = encodedMessages.lastOrNull() ?: "" + assertEquals(true, lastMessage.contains("unsupported")) + } + } + } + } } diff --git a/superwall/src/test/java/com/superwall/sdk/permissions/PermissionStatusTest.kt b/superwall/src/test/java/com/superwall/sdk/permissions/PermissionStatusTest.kt new file mode 100644 index 00000000..e1312faa --- /dev/null +++ b/superwall/src/test/java/com/superwall/sdk/permissions/PermissionStatusTest.kt @@ -0,0 +1,115 @@ +package com.superwall.sdk.permissions + +import com.superwall.sdk.Given +import com.superwall.sdk.Then +import com.superwall.sdk.When +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class PermissionStatusTest { + @Test + fun fromRaw_granted_returns_GRANTED() { + Given("a raw status string 'granted'") { + val raw = "granted" + + When("converting from raw") { + val result = PermissionStatus.fromRaw(raw) + + Then("it returns GRANTED") { + assertEquals(PermissionStatus.GRANTED, result) + } + } + } + } + + @Test + fun fromRaw_denied_returns_DENIED() { + Given("a raw status string 'denied'") { + val raw = "denied" + + When("converting from raw") { + val result = PermissionStatus.fromRaw(raw) + + Then("it returns DENIED") { + assertEquals(PermissionStatus.DENIED, result) + } + } + } + } + + @Test + fun fromRaw_unsupported_returns_UNSUPPORTED() { + Given("a raw status string 'unsupported'") { + val raw = "unsupported" + + When("converting from raw") { + val result = PermissionStatus.fromRaw(raw) + + Then("it returns UNSUPPORTED") { + assertEquals(PermissionStatus.UNSUPPORTED, result) + } + } + } + } + + @Test + fun fromRaw_unknown_returns_null() { + Given("an unknown raw status string") { + val raw = "unknown_status" + + When("converting from raw") { + val result = PermissionStatus.fromRaw(raw) + + Then("it returns null") { + assertNull(result) + } + } + } + } + + @Test + fun rawValue_GRANTED_is_correct() { + Given("the GRANTED status") { + val status = PermissionStatus.GRANTED + + When("getting the raw value") { + val raw = status.rawValue + + Then("it returns 'granted'") { + assertEquals("granted", raw) + } + } + } + } + + @Test + fun rawValue_DENIED_is_correct() { + Given("the DENIED status") { + val status = PermissionStatus.DENIED + + When("getting the raw value") { + val raw = status.rawValue + + Then("it returns 'denied'") { + assertEquals("denied", raw) + } + } + } + } + + @Test + fun rawValue_UNSUPPORTED_is_correct() { + Given("the UNSUPPORTED status") { + val status = PermissionStatus.UNSUPPORTED + + When("getting the raw value") { + val raw = status.rawValue + + Then("it returns 'unsupported'") { + assertEquals("unsupported", raw) + } + } + } + } +} diff --git a/superwall/src/test/java/com/superwall/sdk/permissions/PermissionTypeTest.kt b/superwall/src/test/java/com/superwall/sdk/permissions/PermissionTypeTest.kt new file mode 100644 index 00000000..cd3c2427 --- /dev/null +++ b/superwall/src/test/java/com/superwall/sdk/permissions/PermissionTypeTest.kt @@ -0,0 +1,76 @@ +package com.superwall.sdk.permissions + +import android.Manifest +import android.os.Build +import com.superwall.sdk.Given +import com.superwall.sdk.Then +import com.superwall.sdk.When +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class PermissionTypeTest { + @Test + fun fromRaw_notification_returns_NOTIFICATION() { + Given("a raw permission type string 'notification'") { + val raw = "notification" + + When("converting from raw") { + val result = PermissionType.fromRaw(raw) + + Then("it returns NOTIFICATION") { + assertEquals(PermissionType.NOTIFICATION, result) + } + } + } + } + + @Test + fun fromRaw_unknown_returns_null() { + Given("an unknown raw permission type string") { + val raw = "unknown_permission" + + When("converting from raw") { + val result = PermissionType.fromRaw(raw) + + Then("it returns null") { + assertNull(result) + } + } + } + } + + @Test + fun rawValue_notification_is_correct() { + Given("the NOTIFICATION permission type") { + val permissionType = PermissionType.NOTIFICATION + + When("getting the raw value") { + val raw = permissionType.rawValue + + Then("it returns 'notification'") { + assertEquals("notification", raw) + } + } + } + } + + @Test + fun toManifestPermission_notification_on_api33_plus_returns_POST_NOTIFICATIONS() { + Given("the NOTIFICATION permission type") { + val permissionType = PermissionType.NOTIFICATION + + When("getting the manifest permission") { + val manifestPermission = permissionType.toManifestPermission() + + Then("on API 33+ it should return POST_NOTIFICATIONS or null on older APIs") { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + assertEquals(Manifest.permission.POST_NOTIFICATIONS, manifestPermission) + } else { + assertNull(manifestPermission) + } + } + } + } + } +} From d095a8b22ffad195178c571c918e3f7ce5d7e00f Mon Sep 17 00:00:00 2001 From: Ian Rumac Date: Fri, 19 Dec 2025 14:25:03 +0100 Subject: [PATCH 2/3] Add permission events --- .../trackable/TrackableSuperwallEvent.kt | 45 ++++++++ .../sdk/analytics/superwall/SuperwallEvent.kt | 27 +++++ .../analytics/superwall/SuperwallEvents.kt | 3 + .../messaging/PaywallMessageHandler.kt | 32 ++++++ .../sdk/permissions/PermissionType.kt | 27 +++++ .../sdk/permissions/UserPermissionsImpl.kt | 101 ++++++++++++++++++ 6 files changed, 235 insertions(+) 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/paywall/view/webview/messaging/PaywallMessageHandler.kt b/superwall/src/main/java/com/superwall/sdk/paywall/view/webview/messaging/PaywallMessageHandler.kt index b27448df..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 @@ -496,6 +496,8 @@ 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( @@ -504,6 +506,16 @@ class PaywallMessageHandler( ), ) + // Track permission requested event + ioScope.launch { + track( + InternalSuperwallEvent.PermissionRequested( + permissionName = permissionName, + paywallIdentifier = paywallIdentifier, + ), + ) + } + if (activity == null) { Logger.debug( LogLevel.error, @@ -535,6 +547,26 @@ class PaywallMessageHandler( 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, diff --git a/superwall/src/main/java/com/superwall/sdk/permissions/PermissionType.kt b/superwall/src/main/java/com/superwall/sdk/permissions/PermissionType.kt index cfeeb578..76c3a32f 100644 --- a/superwall/src/main/java/com/superwall/sdk/permissions/PermissionType.kt +++ b/superwall/src/main/java/com/superwall/sdk/permissions/PermissionType.kt @@ -11,6 +11,12 @@ 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"), ; /** @@ -25,6 +31,27 @@ enum class PermissionType( } 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 { diff --git a/superwall/src/main/java/com/superwall/sdk/permissions/UserPermissionsImpl.kt b/superwall/src/main/java/com/superwall/sdk/permissions/UserPermissionsImpl.kt index e3552c9b..f4bc19ad 100644 --- a/superwall/src/main/java/com/superwall/sdk/permissions/UserPermissionsImpl.kt +++ b/superwall/src/main/java/com/superwall/sdk/permissions/UserPermissionsImpl.kt @@ -22,6 +22,13 @@ internal class UserPermissionsImpl( 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 { @@ -44,12 +51,59 @@ internal class UserPermissionsImpl( } } + 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 { @@ -74,6 +128,53 @@ internal class UserPermissionsImpl( 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, From 6990ab964feb1fcda21bda5be22cc16451f7ea81 Mon Sep 17 00:00:00 2001 From: Ian Rumac Date: Tue, 6 Jan 2026 16:14:54 +0100 Subject: [PATCH 3/3] Add to testing app the permission declarations --- app/src/main/AndroidManifest.xml | 7 +++++++ 1 file changed, 7 insertions(+) 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 @@ + + + + + + +