diff --git a/CHANGELOG.md b/CHANGELOG.md index f046cb86..35f06598 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,17 @@ The changelog for `Superwall`. Also see the [releases](https://github.com/superwall/Superwall-Android/releases) on GitHub. +## 2.6.7 + +### Enhancements +- Adds permission granting and callbacks to/from paywalls +- Adds `PaywallPreloadStart` and `PaywallPreloadComplete` events + +### Fixes +- Fix handling of deep links when paywall is detached +- Enables permission granting from paywall and callbacks +- Fix crash when handling drawer style paywalls with 100% height + ## 2.6.6 ## Enhancements diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 20fe5a62..eb4c16a2 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -7,6 +7,16 @@ + + + + + + + + { + 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 1fa5c2c7..0d00fb5b 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 @@ -1174,4 +1174,72 @@ sealed class InternalSuperwallEvent( ) } } + + data class Permission( + val state: State, + val permissionName: String, + val paywallIdentifier: String, + ) : InternalSuperwallEvent( + SuperwallEvent.PermissionRequested(permissionName, paywallIdentifier), + ) { + enum class State { + Requested, + Granted, + Denied, + } + + override val superwallPlacement: SuperwallEvent + get() = + when (state) { + State.Requested -> + SuperwallEvent.PermissionRequested( + permissionName = permissionName, + paywallIdentifier = paywallIdentifier, + ) + State.Granted -> + SuperwallEvent.PermissionGranted( + permissionName = permissionName, + paywallIdentifier = paywallIdentifier, + ) + State.Denied -> + SuperwallEvent.PermissionDenied( + permissionName = permissionName, + paywallIdentifier = 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, + ) : InternalSuperwallEvent( + SuperwallEvent.PaywallPreloadStart(paywallCount), + ) { + enum class State { + Start, + Complete, + } + + override val superwallPlacement: SuperwallEvent + get() = + when (state) { + State.Start -> SuperwallEvent.PaywallPreloadStart(paywallCount) + State.Complete -> SuperwallEvent.PaywallPreloadComplete(paywallCount) + } + + override val audienceFilterParams: Map = emptyMap() + + override suspend fun getSuperwallParameters(): Map = + mapOf( + "paywall_count" to paywallCount, + ) + } } 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 63b93806..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,49 @@ 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, + ) : SuperwallEvent() { + override val rawName: String + get() = SuperwallEvents.PaywallPreloadStart.rawName + } + + // / When paywall preloading completes. + data class PaywallPreloadComplete( + val paywallCount: Int, + ) : SuperwallEvent() { + override val rawName: String + get() = SuperwallEvents.PaywallPreloadComplete.rawName + } + open val rawName: String get() = this.toString() 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 4abc03fd..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,4 +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/config/PaywallPreload.kt b/superwall/src/main/java/com/superwall/sdk/config/PaywallPreload.kt index b2e91c8c..ca328669 100644 --- a/superwall/src/main/java/com/superwall/sdk/config/PaywallPreload.kt +++ b/superwall/src/main/java/com/superwall/sdk/config/PaywallPreload.kt @@ -1,6 +1,7 @@ package com.superwall.sdk.config import android.content.Context +import com.superwall.sdk.analytics.internal.trackable.InternalSuperwallEvent import com.superwall.sdk.dependencies.RequestFactory import com.superwall.sdk.dependencies.RuleAttributesFactory import com.superwall.sdk.misc.IOScope @@ -25,6 +26,7 @@ class PaywallPreload( val storage: LocalStorage, val assignments: Assignments, val paywallManager: PaywallManager, + private val track: suspend (InternalSuperwallEvent) -> Unit, ) { interface Factory : RequestFactory, @@ -57,8 +59,8 @@ class PaywallPreload( unconfirmedAssignments = assignments.unconfirmedAssignments, expressionEvaluator = expressionEvaluator, ) - preloadPaywalls(paywallIdentifiers = paywallIds) + preloadPaywalls(paywallIdentifiers = paywallIds) currentPreloadingTask = null } } @@ -79,6 +81,14 @@ class PaywallPreload( // Preloads paywalls referenced by triggers. private suspend fun preloadPaywalls(paywallIdentifiers: Set) { + val paywallCount = paywallIdentifiers.size + track( + InternalSuperwallEvent.PaywallPreload( + state = InternalSuperwallEvent.PaywallPreload.State.Start, + paywallCount = paywallCount, + ), + ) + val webviewExists = webViewExists() if (webviewExists) { scope.launchWithTracking { @@ -116,6 +126,12 @@ class PaywallPreload( } // Await all tasks tasks.awaitAll() + track( + InternalSuperwallEvent.PaywallPreload( + state = InternalSuperwallEvent.PaywallPreload.State.Complete, + paywallCount = paywallCount, + ), + ) } } } 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 52a0ba48..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 @@ -381,6 +384,9 @@ class DependencyContainer( assignments = assignments, paywallManager = paywallManager, scope = ioScope, + track = { + Superwall.instance.track(it) + }, ) configManager = @@ -590,6 +596,8 @@ class DependencyContainer( }) } + userPermissions = UserPermissionsImpl(context) + deepLinkRouter = DeepLinkRouter( reedemer, @@ -699,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/PaywallView.kt b/superwall/src/main/java/com/superwall/sdk/paywall/view/PaywallView.kt index ce3ba0e5..f05f06e8 100644 --- a/superwall/src/main/java/com/superwall/sdk/paywall/view/PaywallView.kt +++ b/superwall/src/main/java/com/superwall/sdk/paywall/view/PaywallView.kt @@ -1,6 +1,7 @@ package com.superwall.sdk.paywall.view import android.app.Activity +import android.content.ActivityNotFoundException import android.content.Context import android.content.Intent import android.content.res.Configuration @@ -991,11 +992,24 @@ class PaywallView( } override fun openDeepLink(url: String) { - var uri = url.toUri() + val uri = url.toUri() eventDidOccur(PaywallWebEvent.OpenedDeepLink(uri)) - val context = encapsulatingActivity?.get() - val deepLinkIntent = Intent(Intent.ACTION_VIEW, uri) - context?.startActivity(deepLinkIntent) + val activityContext = encapsulatingActivity?.get() + val deepLinkIntent = + Intent(Intent.ACTION_VIEW, uri).apply { + if (activityContext == null) { + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } + } + try { + (activityContext ?: context).startActivity(deepLinkIntent) + } catch (e: ActivityNotFoundException) { + Logger.debug( + logLevel = LogLevel.warn, + scope = LogScope.paywallView, + message = "No activity found to handle deep link: $url", + ) + } } //region GameController diff --git a/superwall/src/main/java/com/superwall/sdk/paywall/view/SuperwallPaywallActivity.kt b/superwall/src/main/java/com/superwall/sdk/paywall/view/SuperwallPaywallActivity.kt index 0829e952..0378e28b 100644 --- a/superwall/src/main/java/com/superwall/sdk/paywall/view/SuperwallPaywallActivity.kt +++ b/superwall/src/main/java/com/superwall/sdk/paywall/view/SuperwallPaywallActivity.kt @@ -520,8 +520,9 @@ class SuperwallPaywallActivity : AppCompatActivity() { val content = contentView as ViewGroup val bottomSheetBehavior = BottomSheetBehavior.from(content.getChildAt(0)) if (!isModal) { - bottomSheetBehavior.halfExpandedRatio = - (if (height > 1.0) height / 100 else height).toFloat() + val normalizedHeight = (if (height > 1.0) height / 100 else height).toFloat() + // Clamp to (0, 1) since 0.0 = STATE_COLLAPSED and 1.0 = STATE_EXPANDED + bottomSheetBehavior.halfExpandedRatio = normalizedHeight.coerceIn(0.01f, 0.99f) } else { // If it's a Modal, we want it to cover only 95% of the screen when expanded content.updateLayoutParams { 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..a919c1e7 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,130 @@ 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.Permission( + state = InternalSuperwallEvent.Permission.State.Requested, + 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.Permission( + state = InternalSuperwallEvent.Permission.State.Granted, + permissionName = permissionName, + paywallIdentifier = paywallIdentifier, + ), + ) + } + PermissionStatus.DENIED, PermissionStatus.UNSUPPORTED -> { + track( + InternalSuperwallEvent.Permission( + state = InternalSuperwallEvent.Permission.State.Denied, + 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/analytics/internal/trackable/InternalSuperwallEventTest.kt b/superwall/src/test/java/com/superwall/sdk/analytics/internal/trackable/InternalSuperwallEventTest.kt index 2cefb3be..6c9f3d0b 100644 --- a/superwall/src/test/java/com/superwall/sdk/analytics/internal/trackable/InternalSuperwallEventTest.kt +++ b/superwall/src/test/java/com/superwall/sdk/analytics/internal/trackable/InternalSuperwallEventTest.kt @@ -850,6 +850,56 @@ class InternalSuperwallEventTest { } } + @Test + fun paywallPreload_startReportsPaywallCount() = + runTest { + Given("a paywall preload start event") { + val paywallCount = 5 + val event = + InternalSuperwallEvent.PaywallPreload( + state = InternalSuperwallEvent.PaywallPreload.State.Start, + paywallCount = paywallCount, + ) + + When("parameters are requested") { + val params = event.getSuperwallParameters() + + Then("paywall count is included") { + assertEquals(paywallCount, params["paywall_count"]) + } + + And("the superwall placement is paywallPreload_start") { + assertEquals("paywallPreload_start", event.superwallPlacement.rawName) + } + } + } + } + + @Test + fun paywallPreload_completeReportsPaywallCount() = + runTest { + Given("a paywall preload complete event") { + val paywallCount = 3 + val event = + InternalSuperwallEvent.PaywallPreload( + state = InternalSuperwallEvent.PaywallPreload.State.Complete, + paywallCount = paywallCount, + ) + + When("parameters are requested") { + val params = event.getSuperwallParameters() + + Then("paywall count is included") { + assertEquals(paywallCount, params["paywall_count"]) + } + + And("the superwall placement is paywallPreload_complete") { + assertEquals("paywallPreload_complete", event.superwallPlacement.rawName) + } + } + } + } + private fun stubPaywallInfo(): PaywallInfo = PaywallInfo.empty().copy( databaseId = "db_1", diff --git a/superwall/src/test/java/com/superwall/sdk/config/PaywallPreloadTest.kt b/superwall/src/test/java/com/superwall/sdk/config/PaywallPreloadTest.kt index 3f36cafd..25d1565f 100644 --- a/superwall/src/test/java/com/superwall/sdk/config/PaywallPreloadTest.kt +++ b/superwall/src/test/java/com/superwall/sdk/config/PaywallPreloadTest.kt @@ -59,6 +59,7 @@ class PaywallPreloadTest { storage = storage, assignments = assignments, paywallManager = paywallManager, + track = {}, ) val config = Config.stub() @@ -121,6 +122,7 @@ class PaywallPreloadTest { storage = storage, assignments = assignments, paywallManager = paywallManager, + track = {}, ) When("removeUnusedPaywallVCsFromCache runs") { 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) + } + } + } + } + } +} diff --git a/version.env b/version.env index 95b12e3a..dc137465 100644 --- a/version.env +++ b/version.env @@ -1 +1 @@ -SUPERWALL_VERSION=2.6.6 +SUPERWALL_VERSION=2.6.7