From 2805c1e9dfc3520cd3998fbc00e34fd6db584fab Mon Sep 17 00:00:00 2001 From: Ian Rumac Date: Mon, 13 Jan 2025 14:38:58 -0600 Subject: [PATCH 1/4] Prepare basics for web2app --- gradle/libs.versions.toml | 3 +- superwall/build.gradle.kts | 1 + .../main/java/com/superwall/sdk/Superwall.kt | 1 + .../com/superwall/sdk/config/ConfigManager.kt | 30 +++++++ .../sdk/dependencies/DependencyContainer.kt | 19 ++++- .../superwall/sdk/identity/IdentityManager.kt | 1 + .../java/com/superwall/sdk/logger/LogScope.kt | 1 + .../models/entitlements/RedemptionToken.kt | 14 ++++ .../models/entitlements/WebEntitlements.kt | 10 +++ .../superwall/sdk/network/BaseHostService.kt | 19 +++++ .../java/com/superwall/sdk/network/Network.kt | 17 ++++ .../com/superwall/sdk/network/SuperwallAPI.kt | 10 +++ .../com/superwall/sdk/store/Entitlements.kt | 2 +- .../com/superwall/sdk/web/DeepLinkReferrer.kt | 79 +++++++++++++++++++ .../superwall/sdk/web/WebPaywallRedeemer.kt | 55 +++++++++++++ 15 files changed, 259 insertions(+), 3 deletions(-) create mode 100644 superwall/src/main/java/com/superwall/sdk/models/entitlements/RedemptionToken.kt create mode 100644 superwall/src/main/java/com/superwall/sdk/models/entitlements/WebEntitlements.kt create mode 100644 superwall/src/main/java/com/superwall/sdk/web/DeepLinkReferrer.kt create mode 100644 superwall/src/main/java/com/superwall/sdk/web/WebPaywallRedeemer.kt diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 94f629d1..933775ea 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -34,6 +34,7 @@ workRuntimeKtx_version = "2.9.0" serialization_version = "1.6.0" dropshot_version = "0.4.2" ksp = "1.9.0-1.0.13" +install_referrer = "2.2" [libraries] # SQL @@ -73,7 +74,7 @@ lifecycle_runtime_ktx = { module = "androidx.lifecycle:lifecycle-runtime-ktx", v uiautomator = { module = "androidx.test.uiautomator:uiautomator", version.ref = "uiautomator_version" } work-runtime-ktx = { module = "androidx.work:work-runtime-ktx", version.ref = "workRuntimeKtx_version" } lifecycle-process = { module = "androidx.lifecycle:lifecycle-process", version.ref = "lifecycleProcessVersion" } - +install_referrer = { module = "com.android.installreferrer:installreferrer", version.ref = "install_referrer"} # Coroutines kotlinx_coroutines_core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlinx_coroutines_core_version" } kotlinx-coroutines-guava = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-guava", version.ref = "kotlinxCoroutinesGuavaVersion" } diff --git a/superwall/build.gradle.kts b/superwall/build.gradle.kts index 08f20efd..4356feb5 100644 --- a/superwall/build.gradle.kts +++ b/superwall/build.gradle.kts @@ -203,6 +203,7 @@ dependencies { // Serialization implementation(libs.kotlinx.serialization.json) + implementation(libs.install.referrer) // Test testImplementation(libs.junit) diff --git a/superwall/src/main/java/com/superwall/sdk/Superwall.kt b/superwall/src/main/java/com/superwall/sdk/Superwall.kt index 331b10a7..18740c4b 100644 --- a/superwall/src/main/java/com/superwall/sdk/Superwall.kt +++ b/superwall/src/main/java/com/superwall/sdk/Superwall.kt @@ -445,6 +445,7 @@ class Superwall( dependencyContainer.storage.recordAppInstall { track(event = it) } + dependencyContainer.reedemer.checkForRefferal() // Implicitly wait dependencyContainer.configManager.fetchConfiguration() dependencyContainer.identityManager.configure() diff --git a/superwall/src/main/java/com/superwall/sdk/config/ConfigManager.kt b/superwall/src/main/java/com/superwall/sdk/config/ConfigManager.kt index baf2170c..824d006c 100644 --- a/superwall/src/main/java/com/superwall/sdk/config/ConfigManager.kt +++ b/superwall/src/main/java/com/superwall/sdk/config/ConfigManager.kt @@ -1,6 +1,7 @@ package com.superwall.sdk.config import android.content.Context +import com.superwall.sdk.Superwall import com.superwall.sdk.analytics.internal.trackable.InternalSuperwallEvent import com.superwall.sdk.config.models.ConfigState import com.superwall.sdk.config.models.getConfig @@ -21,6 +22,7 @@ import com.superwall.sdk.misc.into import com.superwall.sdk.misc.onError import com.superwall.sdk.misc.then import com.superwall.sdk.models.config.Config +import com.superwall.sdk.models.entitlements.EntitlementStatus import com.superwall.sdk.models.triggers.Experiment import com.superwall.sdk.models.triggers.ExperimentID import com.superwall.sdk.models.triggers.Trigger @@ -35,6 +37,7 @@ import com.superwall.sdk.storage.LatestGeoInfo import com.superwall.sdk.storage.Storage import com.superwall.sdk.store.Entitlements import com.superwall.sdk.store.StoreManager +import com.superwall.sdk.web.WebPaywallRedeemer import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll import kotlinx.coroutines.flow.Flow @@ -56,6 +59,7 @@ open class ConfigManager( private val deviceHelper: DeviceHelper, var options: SuperwallOptions, private val paywallManager: PaywallManager, + private val webPaywallRedeemer: WebPaywallRedeemer, private val factory: Factory, private val assignments: Assignments, private val paywallPreload: PaywallPreload, @@ -313,6 +317,7 @@ open class ConfigManager( } ioScope.launch { storeManager.loadPurchasedProducts() + checkForWebEntitlements() } } @@ -393,4 +398,29 @@ open class ConfigManager( fetchDuration = System.currentTimeMillis() - startTime, ) } + + // This runs only if user does not have all of the entitlements + suspend fun checkForWebEntitlements() { + if (entitlements.all.size != entitlements.active.size) { + webPaywallRedeemer + .checkForWebEntitlements( + Superwall.instance.userId, + ).fold(onSuccess = { + if (it.entitlements.isNotEmpty()) { + val localWithWeb = entitlements.active + it.entitlements.toSet() + entitlements.setEntitlementStatus( + EntitlementStatus.Active(localWithWeb), + ) + } + }, onFailure = { + Logger.debug( + LogLevel.error, + LogScope.webEntitlements, + "Checking for web entitlements failed", + emptyMap(), + it, + ) + }) + } + } } 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 55b06f51..e93544f4 100644 --- a/superwall/src/main/java/com/superwall/sdk/dependencies/DependencyContainer.kt +++ b/superwall/src/main/java/com/superwall/sdk/dependencies/DependencyContainer.kt @@ -84,6 +84,8 @@ import com.superwall.sdk.store.transactions.TransactionManager import com.superwall.sdk.utilities.DateUtils import com.superwall.sdk.utilities.ErrorTracker import com.superwall.sdk.utilities.dateFormat +import com.superwall.sdk.web.DeepLinkReferrer +import com.superwall.sdk.web.WebPaywallRedeemer import kotlinx.coroutines.async import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.launch @@ -145,7 +147,7 @@ class DependencyContainer( val googleBillingWrapper: GoogleBillingWrapper var entitlements: Entitlements - + var reedemer: WebPaywallRedeemer private val uiScope get() = mainScope() private val ioScope @@ -291,6 +293,20 @@ class DependencyContainer( scope = ioScope, ) + reedemer = + WebPaywallRedeemer( + context = context, + ioScope = ioScope, + deepLinkReferrer = DeepLinkReferrer({ context }, ioScope), + network = network, + setEntitlementStatus = { + Superwall.instance.setEntitlementStatus( + EntitlementStatus.Active( + it.toSet(), + ), + ) + }, + ) configManager = ConfigManager( context = context, @@ -308,6 +324,7 @@ class DependencyContainer( Superwall.instance.track(it) }, entitlements = entitlements, + webPaywallRedeemer = reedemer, ) eventsQueue = diff --git a/superwall/src/main/java/com/superwall/sdk/identity/IdentityManager.kt b/superwall/src/main/java/com/superwall/sdk/identity/IdentityManager.kt index 4a75b9cd..f206303b 100644 --- a/superwall/src/main/java/com/superwall/sdk/identity/IdentityManager.kt +++ b/superwall/src/main/java/com/superwall/sdk/identity/IdentityManager.kt @@ -176,6 +176,7 @@ class IdentityManager( Superwall.instance.track(trackableEvent) } + configManager.checkForWebEntitlements() if (options?.restorePaywallAssignments == true) { identityJobs += ioScope.launch { diff --git a/superwall/src/main/java/com/superwall/sdk/logger/LogScope.kt b/superwall/src/main/java/com/superwall/sdk/logger/LogScope.kt index 3e5f6390..129d03e2 100644 --- a/superwall/src/main/java/com/superwall/sdk/logger/LogScope.kt +++ b/superwall/src/main/java/com/superwall/sdk/logger/LogScope.kt @@ -3,6 +3,7 @@ package com.superwall.sdk.logger enum class LogScope { localizationManager, bounceButton, + webEntitlements, coreData, configManager, identityManager, diff --git a/superwall/src/main/java/com/superwall/sdk/models/entitlements/RedemptionToken.kt b/superwall/src/main/java/com/superwall/sdk/models/entitlements/RedemptionToken.kt new file mode 100644 index 00000000..0ae7b70f --- /dev/null +++ b/superwall/src/main/java/com/superwall/sdk/models/entitlements/RedemptionToken.kt @@ -0,0 +1,14 @@ +package com.superwall.sdk.models.entitlements + +import kotlinx.serialization.Serializable + +@Serializable +data class RedemptionToken( + val token: String, + val userId: String, +) + +@Serializable +data class RedemptionEmail( + val email: String, +) diff --git a/superwall/src/main/java/com/superwall/sdk/models/entitlements/WebEntitlements.kt b/superwall/src/main/java/com/superwall/sdk/models/entitlements/WebEntitlements.kt new file mode 100644 index 00000000..8c22edbd --- /dev/null +++ b/superwall/src/main/java/com/superwall/sdk/models/entitlements/WebEntitlements.kt @@ -0,0 +1,10 @@ +package com.superwall.sdk.models.entitlements + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class WebEntitlements( + @SerialName("entitlements") + val entitlements: List, +) diff --git a/superwall/src/main/java/com/superwall/sdk/network/BaseHostService.kt b/superwall/src/main/java/com/superwall/sdk/network/BaseHostService.kt index fcbe5ef7..0e6b2a26 100644 --- a/superwall/src/main/java/com/superwall/sdk/network/BaseHostService.kt +++ b/superwall/src/main/java/com/superwall/sdk/network/BaseHostService.kt @@ -5,6 +5,9 @@ import com.superwall.sdk.misc.Either import com.superwall.sdk.models.assignment.AssignmentPostback import com.superwall.sdk.models.assignment.ConfirmedAssignmentResponse import com.superwall.sdk.models.config.Config +import com.superwall.sdk.models.entitlements.RedemptionEmail +import com.superwall.sdk.models.entitlements.RedemptionToken +import com.superwall.sdk.models.entitlements.WebEntitlements import com.superwall.sdk.models.paywall.Paywall import com.superwall.sdk.models.paywall.Paywalls import com.superwall.sdk.network.session.CustomHttpUrlConnection @@ -75,4 +78,20 @@ class BaseHostService( return get("paywall/$identifier", queryItems = queryItems, isForDebugging = true) } + + suspend fun redeemToken( + token: String, + userId: String, + ) = post( + "redeem", + body = json.encodeToString(RedemptionToken(token, userId)).toByteArray(), + ) + + suspend fun redeemByEmail(email: String) = + post( + "redeem", + body = json.encodeToString(RedemptionEmail(email)).toByteArray(), + ) + + suspend fun webEntitlements(userId: String) = get("users/$userId/entitlements") } diff --git a/superwall/src/main/java/com/superwall/sdk/network/Network.kt b/superwall/src/main/java/com/superwall/sdk/network/Network.kt index 639ae294..38cadfbb 100644 --- a/superwall/src/main/java/com/superwall/sdk/network/Network.kt +++ b/superwall/src/main/java/com/superwall/sdk/network/Network.kt @@ -102,6 +102,23 @@ open class Network( it.assignments }.logError("/assignments") + override suspend fun redeemToken( + token: String, + userId: String, + ) = baseHostService + .redeemToken(token, userId) + .logError("/redeem") + + override suspend fun redeemEmail(email: String) = + baseHostService + .redeemByEmail(email) + .logError("/redeem") + + override suspend fun webEntitlements(userId: String) = + baseHostService + .webEntitlements(userId) + .logError("/redeem") + private suspend fun awaitUntilAppInForeground() { // Wait until the app is not in the background. factory.appLifecycleObserver diff --git a/superwall/src/main/java/com/superwall/sdk/network/SuperwallAPI.kt b/superwall/src/main/java/com/superwall/sdk/network/SuperwallAPI.kt index 60340d32..b7ee3f98 100644 --- a/superwall/src/main/java/com/superwall/sdk/network/SuperwallAPI.kt +++ b/superwall/src/main/java/com/superwall/sdk/network/SuperwallAPI.kt @@ -4,6 +4,7 @@ import com.superwall.sdk.misc.Either import com.superwall.sdk.models.assignment.Assignment import com.superwall.sdk.models.assignment.AssignmentPostback import com.superwall.sdk.models.config.Config +import com.superwall.sdk.models.entitlements.WebEntitlements import com.superwall.sdk.models.events.EventData import com.superwall.sdk.models.events.EventsRequest import com.superwall.sdk.models.geo.GeoInfo @@ -26,4 +27,13 @@ interface SuperwallAPI { suspend fun getGeoInfo(): Either suspend fun getAssignments(): Either, NetworkError> + + suspend fun webEntitlements(userId: String): Either + + suspend fun redeemToken( + token: String, + userId: String, + ): Either + + suspend fun redeemEmail(email: String): Either } diff --git a/superwall/src/main/java/com/superwall/sdk/store/Entitlements.kt b/superwall/src/main/java/com/superwall/sdk/store/Entitlements.kt index b52ff638..2648c27d 100644 --- a/superwall/src/main/java/com/superwall/sdk/store/Entitlements.kt +++ b/superwall/src/main/java/com/superwall/sdk/store/Entitlements.kt @@ -48,7 +48,7 @@ class Entitlements( * All entitlements, regardless of whether they're active or not. */ val all: Set - get() = _all.toSet() + get() = _all.toSet() + _entitlementsByProduct.values.flatten() /** * The active entitlements. diff --git a/superwall/src/main/java/com/superwall/sdk/web/DeepLinkReferrer.kt b/superwall/src/main/java/com/superwall/sdk/web/DeepLinkReferrer.kt new file mode 100644 index 00000000..a49901bf --- /dev/null +++ b/superwall/src/main/java/com/superwall/sdk/web/DeepLinkReferrer.kt @@ -0,0 +1,79 @@ +package com.superwall.sdk.web + +import android.content.Context +import com.android.installreferrer.api.InstallReferrerClient +import com.android.installreferrer.api.InstallReferrerClient.newBuilder +import com.android.installreferrer.api.InstallReferrerStateListener +import com.superwall.sdk.misc.IOScope +import kotlinx.coroutines.launch +import kotlinx.coroutines.withTimeout +import kotlinx.coroutines.withTimeoutOrNull +import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.Duration.Companion.seconds + +interface CheckForReferral { + suspend fun checkForReferral(): Result +} + +class DeepLinkReferrer( + context: () -> Context, + private val scope: IOScope, +) : CheckForReferral { + private var referrerClient: InstallReferrerClient + + init { + referrerClient = newBuilder(context()).build() + tryConnecting() + } + + private class ConnectionListener( + val finished: () -> Unit, + val disconnected: () -> Unit, + ) : InstallReferrerStateListener { + override fun onInstallReferrerSetupFinished(p0: Int) { + finished() + } + + override fun onInstallReferrerServiceDisconnected() { + disconnected() + } + } + + fun tryConnecting(timeout: Int = 0) { + val connect = { + referrerClient.startConnection( + ConnectionListener( + finished = { + referrerClient.installReferrer.installReferrer + }, + disconnected = { + tryConnecting(timeout + 1000) + }, + ), + ) + } + if (timeout == 0) { + connect() + } else { + scope.launch { + withTimeout(timeout.milliseconds) { + connect() + } + } + } + } + + override suspend fun checkForReferral(): Result = + withTimeoutOrNull(30.seconds) { + while (!referrerClient.isReady) { + // no-op + } + referrerClient.installReferrer.installReferrer + }.let { + if (it == null) { + Result.failure(IllegalStateException("Play store cannot connect")) + } else { + Result.success(it) + } + } +} diff --git a/superwall/src/main/java/com/superwall/sdk/web/WebPaywallRedeemer.kt b/superwall/src/main/java/com/superwall/sdk/web/WebPaywallRedeemer.kt new file mode 100644 index 00000000..cc3afd9b --- /dev/null +++ b/superwall/src/main/java/com/superwall/sdk/web/WebPaywallRedeemer.kt @@ -0,0 +1,55 @@ +package com.superwall.sdk.web + +import android.content.Context +import com.superwall.sdk.Superwall +import com.superwall.sdk.logger.LogLevel +import com.superwall.sdk.logger.LogScope +import com.superwall.sdk.logger.Logger +import com.superwall.sdk.misc.IOScope +import com.superwall.sdk.misc.fold +import com.superwall.sdk.models.entitlements.Entitlement +import com.superwall.sdk.network.Network +import com.superwall.sdk.utilities.withErrorTracking +import kotlinx.coroutines.launch + +class WebPaywallRedeemer( + val context: Context, + val ioScope: IOScope, + val deepLinkReferrer: CheckForReferral, + val network: Network, + val setEntitlementStatus: (List) -> Unit, +) { + init { + ioScope.launch { + checkForRefferal() + } + } + + suspend fun checkForRefferal() = + withErrorTracking { + deepLinkReferrer + .checkForReferral() + .fold( + onSuccess = { + redeem(token = it) + }, + onFailure = { throw it }, + ) + } + + suspend fun redeem(token: String) = + network + .redeemToken(token, Superwall.instance.userId) + .fold({ + setEntitlementStatus(it.entitlements) + }, { + Logger.debug( + LogLevel.error, + LogScope.webEntitlements, + "Failed to redeem purchase token", + info = mapOf(), + ) + }) + + suspend fun checkForWebEntitlements(userId: String) = network.webEntitlements(userId) +} From 2f8fdd650ed85171a55ff124ba821bc2be925549 Mon Sep 17 00:00:00 2001 From: Ian Rumac Date: Thu, 27 Feb 2025 18:36:23 +0100 Subject: [PATCH 2/4] Update API --- .../main/java/com/superwall/sdk/Superwall.kt | 3 +++ .../com/superwall/sdk/config/ConfigManager.kt | 6 ++--- .../sdk/dependencies/DependencyContainer.kt | 4 +-- .../sdk/models/entitlements/RedeemRequest.kt | 25 +++++++++++++++++++ .../models/entitlements/RedemptionToken.kt | 14 ----------- .../superwall/sdk/models/internal/UserId.kt | 6 +++++ .../superwall/sdk/models/internal/VendorId.kt | 6 +++++ .../superwall/sdk/network/BaseHostService.kt | 22 +++++++++++++--- .../java/com/superwall/sdk/network/Network.kt | 9 ++++--- .../com/superwall/sdk/network/SuperwallAPI.kt | 7 ++++-- .../com/superwall/sdk/web/DeepLinkReferrer.kt | 6 ++--- .../superwall/sdk/web/WebPaywallRedeemer.kt | 8 +++--- 12 files changed, 82 insertions(+), 34 deletions(-) create mode 100644 superwall/src/main/java/com/superwall/sdk/models/entitlements/RedeemRequest.kt delete mode 100644 superwall/src/main/java/com/superwall/sdk/models/entitlements/RedemptionToken.kt create mode 100644 superwall/src/main/java/com/superwall/sdk/models/internal/UserId.kt create mode 100644 superwall/src/main/java/com/superwall/sdk/models/internal/VendorId.kt diff --git a/superwall/src/main/java/com/superwall/sdk/Superwall.kt b/superwall/src/main/java/com/superwall/sdk/Superwall.kt index 18740c4b..45cfbd2f 100644 --- a/superwall/src/main/java/com/superwall/sdk/Superwall.kt +++ b/superwall/src/main/java/com/superwall/sdk/Superwall.kt @@ -281,6 +281,9 @@ class Superwall( dependencyContainer.entitlements.status } + internal val vendorId: String + get() = dependencyContainer.deviceHelper.vendorId + /** * A property that indicates current configuration state of the SDK. * diff --git a/superwall/src/main/java/com/superwall/sdk/config/ConfigManager.kt b/superwall/src/main/java/com/superwall/sdk/config/ConfigManager.kt index 824d006c..3fb0ec16 100644 --- a/superwall/src/main/java/com/superwall/sdk/config/ConfigManager.kt +++ b/superwall/src/main/java/com/superwall/sdk/config/ConfigManager.kt @@ -22,7 +22,7 @@ import com.superwall.sdk.misc.into import com.superwall.sdk.misc.onError import com.superwall.sdk.misc.then import com.superwall.sdk.models.config.Config -import com.superwall.sdk.models.entitlements.EntitlementStatus +import com.superwall.sdk.models.entitlements.SubscriptionStatus import com.superwall.sdk.models.triggers.Experiment import com.superwall.sdk.models.triggers.ExperimentID import com.superwall.sdk.models.triggers.Trigger @@ -408,8 +408,8 @@ open class ConfigManager( ).fold(onSuccess = { if (it.entitlements.isNotEmpty()) { val localWithWeb = entitlements.active + it.entitlements.toSet() - entitlements.setEntitlementStatus( - EntitlementStatus.Active(localWithWeb), + entitlements.setSubscriptionStatus( + SubscriptionStatus.Active(localWithWeb), ) } }, onFailure = { 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 e93544f4..5345e997 100644 --- a/superwall/src/main/java/com/superwall/sdk/dependencies/DependencyContainer.kt +++ b/superwall/src/main/java/com/superwall/sdk/dependencies/DependencyContainer.kt @@ -300,8 +300,8 @@ class DependencyContainer( deepLinkReferrer = DeepLinkReferrer({ context }, ioScope), network = network, setEntitlementStatus = { - Superwall.instance.setEntitlementStatus( - EntitlementStatus.Active( + Superwall.instance.setSubscriptionStatus( + SubscriptionStatus.Active( it.toSet(), ), ) diff --git a/superwall/src/main/java/com/superwall/sdk/models/entitlements/RedeemRequest.kt b/superwall/src/main/java/com/superwall/sdk/models/entitlements/RedeemRequest.kt new file mode 100644 index 00000000..ed99096c --- /dev/null +++ b/superwall/src/main/java/com/superwall/sdk/models/entitlements/RedeemRequest.kt @@ -0,0 +1,25 @@ +package com.superwall.sdk.models.entitlements + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class RedeemRequest( + @SerialName("deviceId") + val deviceId: String, + @SerialName("appUserId") + val userId: String, + @SerialName("codes") + val codes: List, +) + +@Serializable +data class Reedemable( + @SerialName("code") + val code: String, +) + +@Serializable +data class RedemptionEmail( + val email: String, +) diff --git a/superwall/src/main/java/com/superwall/sdk/models/entitlements/RedemptionToken.kt b/superwall/src/main/java/com/superwall/sdk/models/entitlements/RedemptionToken.kt deleted file mode 100644 index 0ae7b70f..00000000 --- a/superwall/src/main/java/com/superwall/sdk/models/entitlements/RedemptionToken.kt +++ /dev/null @@ -1,14 +0,0 @@ -package com.superwall.sdk.models.entitlements - -import kotlinx.serialization.Serializable - -@Serializable -data class RedemptionToken( - val token: String, - val userId: String, -) - -@Serializable -data class RedemptionEmail( - val email: String, -) diff --git a/superwall/src/main/java/com/superwall/sdk/models/internal/UserId.kt b/superwall/src/main/java/com/superwall/sdk/models/internal/UserId.kt new file mode 100644 index 00000000..5cca9031 --- /dev/null +++ b/superwall/src/main/java/com/superwall/sdk/models/internal/UserId.kt @@ -0,0 +1,6 @@ +package com.superwall.sdk.models.internal + +@JvmInline +value class UserId( + val value: String, +) diff --git a/superwall/src/main/java/com/superwall/sdk/models/internal/VendorId.kt b/superwall/src/main/java/com/superwall/sdk/models/internal/VendorId.kt new file mode 100644 index 00000000..9bbc2726 --- /dev/null +++ b/superwall/src/main/java/com/superwall/sdk/models/internal/VendorId.kt @@ -0,0 +1,6 @@ +package com.superwall.sdk.models.internal + +@JvmInline +value class VendorId( + val value: String, +) diff --git a/superwall/src/main/java/com/superwall/sdk/network/BaseHostService.kt b/superwall/src/main/java/com/superwall/sdk/network/BaseHostService.kt index 0e6b2a26..b673983e 100644 --- a/superwall/src/main/java/com/superwall/sdk/network/BaseHostService.kt +++ b/superwall/src/main/java/com/superwall/sdk/network/BaseHostService.kt @@ -5,9 +5,12 @@ import com.superwall.sdk.misc.Either import com.superwall.sdk.models.assignment.AssignmentPostback import com.superwall.sdk.models.assignment.ConfirmedAssignmentResponse import com.superwall.sdk.models.config.Config +import com.superwall.sdk.models.entitlements.RedeemRequest import com.superwall.sdk.models.entitlements.RedemptionEmail -import com.superwall.sdk.models.entitlements.RedemptionToken +import com.superwall.sdk.models.entitlements.Reedemable import com.superwall.sdk.models.entitlements.WebEntitlements +import com.superwall.sdk.models.internal.UserId +import com.superwall.sdk.models.internal.VendorId import com.superwall.sdk.models.paywall.Paywall import com.superwall.sdk.models.paywall.Paywalls import com.superwall.sdk.network.session.CustomHttpUrlConnection @@ -80,11 +83,22 @@ class BaseHostService( } suspend fun redeemToken( - token: String, - userId: String, + codes: List, + userId: UserId, + vendorId: VendorId, ) = post( "redeem", - body = json.encodeToString(RedemptionToken(token, userId)).toByteArray(), + body = + json + .encodeToString( + RedeemRequest( + vendorId.value, + userId.value, + codes.map { + Reedemable(it) + }, + ), + ).toByteArray(), ) suspend fun redeemByEmail(email: String) = diff --git a/superwall/src/main/java/com/superwall/sdk/network/Network.kt b/superwall/src/main/java/com/superwall/sdk/network/Network.kt index 38cadfbb..7cf8d754 100644 --- a/superwall/src/main/java/com/superwall/sdk/network/Network.kt +++ b/superwall/src/main/java/com/superwall/sdk/network/Network.kt @@ -14,6 +14,8 @@ import com.superwall.sdk.models.events.EventData import com.superwall.sdk.models.events.EventsRequest import com.superwall.sdk.models.events.EventsResponse import com.superwall.sdk.models.geo.GeoInfo +import com.superwall.sdk.models.internal.UserId +import com.superwall.sdk.models.internal.VendorId import com.superwall.sdk.models.paywall.Paywall import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.first @@ -103,10 +105,11 @@ open class Network( }.logError("/assignments") override suspend fun redeemToken( - token: String, - userId: String, + codes: List, + userId: UserId, + vendorId: VendorId, ) = baseHostService - .redeemToken(token, userId) + .redeemToken(codes, userId, vendorId) .logError("/redeem") override suspend fun redeemEmail(email: String) = diff --git a/superwall/src/main/java/com/superwall/sdk/network/SuperwallAPI.kt b/superwall/src/main/java/com/superwall/sdk/network/SuperwallAPI.kt index b7ee3f98..08a1bbf9 100644 --- a/superwall/src/main/java/com/superwall/sdk/network/SuperwallAPI.kt +++ b/superwall/src/main/java/com/superwall/sdk/network/SuperwallAPI.kt @@ -8,6 +8,8 @@ import com.superwall.sdk.models.entitlements.WebEntitlements import com.superwall.sdk.models.events.EventData import com.superwall.sdk.models.events.EventsRequest import com.superwall.sdk.models.geo.GeoInfo +import com.superwall.sdk.models.internal.UserId +import com.superwall.sdk.models.internal.VendorId import com.superwall.sdk.models.paywall.Paywall interface SuperwallAPI { @@ -31,8 +33,9 @@ interface SuperwallAPI { suspend fun webEntitlements(userId: String): Either suspend fun redeemToken( - token: String, - userId: String, + token: List, + userId: UserId, + vendorId: VendorId, ): Either suspend fun redeemEmail(email: String): Either diff --git a/superwall/src/main/java/com/superwall/sdk/web/DeepLinkReferrer.kt b/superwall/src/main/java/com/superwall/sdk/web/DeepLinkReferrer.kt index a49901bf..668ad44e 100644 --- a/superwall/src/main/java/com/superwall/sdk/web/DeepLinkReferrer.kt +++ b/superwall/src/main/java/com/superwall/sdk/web/DeepLinkReferrer.kt @@ -12,7 +12,7 @@ import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.seconds interface CheckForReferral { - suspend fun checkForReferral(): Result + suspend fun checkForReferral(): Result> } class DeepLinkReferrer( @@ -63,7 +63,7 @@ class DeepLinkReferrer( } } - override suspend fun checkForReferral(): Result = + override suspend fun checkForReferral(): Result> = withTimeoutOrNull(30.seconds) { while (!referrerClient.isReady) { // no-op @@ -73,7 +73,7 @@ class DeepLinkReferrer( if (it == null) { Result.failure(IllegalStateException("Play store cannot connect")) } else { - Result.success(it) + Result.success(it.split(",")) } } } diff --git a/superwall/src/main/java/com/superwall/sdk/web/WebPaywallRedeemer.kt b/superwall/src/main/java/com/superwall/sdk/web/WebPaywallRedeemer.kt index cc3afd9b..229a864d 100644 --- a/superwall/src/main/java/com/superwall/sdk/web/WebPaywallRedeemer.kt +++ b/superwall/src/main/java/com/superwall/sdk/web/WebPaywallRedeemer.kt @@ -8,6 +8,8 @@ import com.superwall.sdk.logger.Logger import com.superwall.sdk.misc.IOScope import com.superwall.sdk.misc.fold import com.superwall.sdk.models.entitlements.Entitlement +import com.superwall.sdk.models.internal.UserId +import com.superwall.sdk.models.internal.VendorId import com.superwall.sdk.network.Network import com.superwall.sdk.utilities.withErrorTracking import kotlinx.coroutines.launch @@ -31,15 +33,15 @@ class WebPaywallRedeemer( .checkForReferral() .fold( onSuccess = { - redeem(token = it) + redeem(it) }, onFailure = { throw it }, ) } - suspend fun redeem(token: String) = + suspend fun redeem(codes: List) = network - .redeemToken(token, Superwall.instance.userId) + .redeemToken(codes, UserId(Superwall.instance.userId), VendorId(Superwall.instance.vendorId)) .fold({ setEntitlementStatus(it.entitlements) }, { From 4fe8c6aebec549dccae1b179741acbffa93ea53c Mon Sep 17 00:00:00 2001 From: Ian Rumac Date: Mon, 3 Mar 2025 11:48:16 +0100 Subject: [PATCH 3/4] Update how DeviceID is created and how web entitlements are fetched --- .../main/java/com/superwall/sdk/Superwall.kt | 2 +- .../com/superwall/sdk/config/ConfigManager.kt | 8 +++--- .../superwall/sdk/models/internal/VendorId.kt | 12 ++++++++- .../superwall/sdk/network/BaseHostService.kt | 8 +++--- .../java/com/superwall/sdk/network/Network.kt | 13 +++++++--- .../com/superwall/sdk/network/SuperwallAPI.kt | 8 +++--- .../superwall/sdk/web/WebPaywallRedeemer.kt | 26 ++++++++++++++++--- 7 files changed, 58 insertions(+), 19 deletions(-) diff --git a/superwall/src/main/java/com/superwall/sdk/Superwall.kt b/superwall/src/main/java/com/superwall/sdk/Superwall.kt index 45cfbd2f..986cfba6 100644 --- a/superwall/src/main/java/com/superwall/sdk/Superwall.kt +++ b/superwall/src/main/java/com/superwall/sdk/Superwall.kt @@ -281,7 +281,7 @@ class Superwall( dependencyContainer.entitlements.status } - internal val vendorId: String + internal val vendorId: VendorId get() = dependencyContainer.deviceHelper.vendorId /** diff --git a/superwall/src/main/java/com/superwall/sdk/config/ConfigManager.kt b/superwall/src/main/java/com/superwall/sdk/config/ConfigManager.kt index 3fb0ec16..58297717 100644 --- a/superwall/src/main/java/com/superwall/sdk/config/ConfigManager.kt +++ b/superwall/src/main/java/com/superwall/sdk/config/ConfigManager.kt @@ -23,6 +23,7 @@ import com.superwall.sdk.misc.onError import com.superwall.sdk.misc.then import com.superwall.sdk.models.config.Config import com.superwall.sdk.models.entitlements.SubscriptionStatus +import com.superwall.sdk.models.internal.DeviceVendorId import com.superwall.sdk.models.triggers.Experiment import com.superwall.sdk.models.triggers.ExperimentID import com.superwall.sdk.models.triggers.Trigger @@ -405,9 +406,10 @@ open class ConfigManager( webPaywallRedeemer .checkForWebEntitlements( Superwall.instance.userId, - ).fold(onSuccess = { - if (it.entitlements.isNotEmpty()) { - val localWithWeb = entitlements.active + it.entitlements.toSet() + DeviceVendorId(Superwall.instance.vendorId), + ).fold(onSuccess = { webEntitlements -> + if (webEntitlements.isNotEmpty()) { + val localWithWeb = entitlements.active + webEntitlements.toSet() entitlements.setSubscriptionStatus( SubscriptionStatus.Active(localWithWeb), ) diff --git a/superwall/src/main/java/com/superwall/sdk/models/internal/VendorId.kt b/superwall/src/main/java/com/superwall/sdk/models/internal/VendorId.kt index 9bbc2726..3b1161de 100644 --- a/superwall/src/main/java/com/superwall/sdk/models/internal/VendorId.kt +++ b/superwall/src/main/java/com/superwall/sdk/models/internal/VendorId.kt @@ -3,4 +3,14 @@ package com.superwall.sdk.models.internal @JvmInline value class VendorId( val value: String, -) +) { + override fun toString(): String = value +} + +class DeviceVendorId( + vendorId: VendorId, +) { + val value = "\$SuperwallDevice:${vendorId.value}" + + override fun toString(): String = value +} diff --git a/superwall/src/main/java/com/superwall/sdk/network/BaseHostService.kt b/superwall/src/main/java/com/superwall/sdk/network/BaseHostService.kt index b673983e..743a4af6 100644 --- a/superwall/src/main/java/com/superwall/sdk/network/BaseHostService.kt +++ b/superwall/src/main/java/com/superwall/sdk/network/BaseHostService.kt @@ -9,8 +9,8 @@ import com.superwall.sdk.models.entitlements.RedeemRequest import com.superwall.sdk.models.entitlements.RedemptionEmail import com.superwall.sdk.models.entitlements.Reedemable import com.superwall.sdk.models.entitlements.WebEntitlements +import com.superwall.sdk.models.internal.DeviceVendorId import com.superwall.sdk.models.internal.UserId -import com.superwall.sdk.models.internal.VendorId import com.superwall.sdk.models.paywall.Paywall import com.superwall.sdk.models.paywall.Paywalls import com.superwall.sdk.network.session.CustomHttpUrlConnection @@ -85,7 +85,7 @@ class BaseHostService( suspend fun redeemToken( codes: List, userId: UserId, - vendorId: VendorId, + vendorId: DeviceVendorId, ) = post( "redeem", body = @@ -107,5 +107,7 @@ class BaseHostService( body = json.encodeToString(RedemptionEmail(email)).toByteArray(), ) - suspend fun webEntitlements(userId: String) = get("users/$userId/entitlements") + suspend fun webEntitlementsByUserId(userId: UserId) = get("users/$userId/entitlements") + + suspend fun webEntitlementsByDeviceId(deviceId: DeviceVendorId) = get("devices/$deviceId/entitlements") } diff --git a/superwall/src/main/java/com/superwall/sdk/network/Network.kt b/superwall/src/main/java/com/superwall/sdk/network/Network.kt index 7cf8d754..3969b817 100644 --- a/superwall/src/main/java/com/superwall/sdk/network/Network.kt +++ b/superwall/src/main/java/com/superwall/sdk/network/Network.kt @@ -14,8 +14,8 @@ import com.superwall.sdk.models.events.EventData import com.superwall.sdk.models.events.EventsRequest import com.superwall.sdk.models.events.EventsResponse import com.superwall.sdk.models.geo.GeoInfo +import com.superwall.sdk.models.internal.DeviceVendorId import com.superwall.sdk.models.internal.UserId -import com.superwall.sdk.models.internal.VendorId import com.superwall.sdk.models.paywall.Paywall import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.first @@ -107,7 +107,7 @@ open class Network( override suspend fun redeemToken( codes: List, userId: UserId, - vendorId: VendorId, + vendorId: DeviceVendorId, ) = baseHostService .redeemToken(codes, userId, vendorId) .logError("/redeem") @@ -117,9 +117,14 @@ open class Network( .redeemByEmail(email) .logError("/redeem") - override suspend fun webEntitlements(userId: String) = + override suspend fun webEntitlementsByUserId(userId: UserId) = baseHostService - .webEntitlements(userId) + .webEntitlementsByUserId(userId) + .logError("/redeem") + + override suspend fun webEntitlementsByDeviceID(deviceId: DeviceVendorId) = + baseHostService + .webEntitlementsByDeviceId(deviceId) .logError("/redeem") private suspend fun awaitUntilAppInForeground() { diff --git a/superwall/src/main/java/com/superwall/sdk/network/SuperwallAPI.kt b/superwall/src/main/java/com/superwall/sdk/network/SuperwallAPI.kt index 08a1bbf9..6fe2c450 100644 --- a/superwall/src/main/java/com/superwall/sdk/network/SuperwallAPI.kt +++ b/superwall/src/main/java/com/superwall/sdk/network/SuperwallAPI.kt @@ -8,8 +8,8 @@ import com.superwall.sdk.models.entitlements.WebEntitlements import com.superwall.sdk.models.events.EventData import com.superwall.sdk.models.events.EventsRequest import com.superwall.sdk.models.geo.GeoInfo +import com.superwall.sdk.models.internal.DeviceVendorId import com.superwall.sdk.models.internal.UserId -import com.superwall.sdk.models.internal.VendorId import com.superwall.sdk.models.paywall.Paywall interface SuperwallAPI { @@ -30,12 +30,14 @@ interface SuperwallAPI { suspend fun getAssignments(): Either, NetworkError> - suspend fun webEntitlements(userId: String): Either + suspend fun webEntitlementsByUserId(userId: UserId): Either + + suspend fun webEntitlementsByDeviceID(deviceId: DeviceVendorId): Either suspend fun redeemToken( token: List, userId: UserId, - vendorId: VendorId, + vendorId: DeviceVendorId, ): Either suspend fun redeemEmail(email: String): Either diff --git a/superwall/src/main/java/com/superwall/sdk/web/WebPaywallRedeemer.kt b/superwall/src/main/java/com/superwall/sdk/web/WebPaywallRedeemer.kt index 229a864d..624d7a48 100644 --- a/superwall/src/main/java/com/superwall/sdk/web/WebPaywallRedeemer.kt +++ b/superwall/src/main/java/com/superwall/sdk/web/WebPaywallRedeemer.kt @@ -6,10 +6,11 @@ import com.superwall.sdk.logger.LogLevel import com.superwall.sdk.logger.LogScope import com.superwall.sdk.logger.Logger import com.superwall.sdk.misc.IOScope +import com.superwall.sdk.misc.asEither import com.superwall.sdk.misc.fold import com.superwall.sdk.models.entitlements.Entitlement +import com.superwall.sdk.models.internal.DeviceVendorId import com.superwall.sdk.models.internal.UserId -import com.superwall.sdk.models.internal.VendorId import com.superwall.sdk.network.Network import com.superwall.sdk.utilities.withErrorTracking import kotlinx.coroutines.launch @@ -41,8 +42,11 @@ class WebPaywallRedeemer( suspend fun redeem(codes: List) = network - .redeemToken(codes, UserId(Superwall.instance.userId), VendorId(Superwall.instance.vendorId)) - .fold({ + .redeemToken( + codes, + UserId(Superwall.instance.userId), + DeviceVendorId(Superwall.instance.vendorId), + ).fold({ setEntitlementStatus(it.entitlements) }, { Logger.debug( @@ -53,5 +57,19 @@ class WebPaywallRedeemer( ) }) - suspend fun checkForWebEntitlements(userId: String) = network.webEntitlements(userId) + suspend fun checkForWebEntitlements( + userId: String, + deviceId: DeviceVendorId, + ) = asEither { + val webEntitlementsByUser = network.webEntitlementsByUserId(UserId(userId)) + val webEntitlementsByDevice = network.webEntitlementsByDeviceID(deviceId) + + val entitlements = + (webEntitlementsByUser.getSuccess()?.entitlements ?: listOf()) + .plus( + webEntitlementsByDevice.getSuccess()?.entitlements ?: listOf(), + ) + + entitlements + } } From e7ddf4204dc8cd3c066d49106e5b424359a2ed27 Mon Sep 17 00:00:00 2001 From: Ian Rumac Date: Mon, 3 Mar 2025 15:07:32 +0100 Subject: [PATCH 4/4] Add tests for redeemer, fix issues with dpendencies --- .../main/java/com/superwall/sdk/Superwall.kt | 3 +- .../superwall/sdk/utilities/ErrorTracking.kt | 1 + .../superwall/sdk/web/WebPaywallRedeemer.kt | 15 +- .../sdk/web/WebPaywallRedeemerTest.kt | 362 ++++++++++++++++++ 4 files changed, 374 insertions(+), 7 deletions(-) create mode 100644 superwall/src/test/java/com/superwall/sdk/web/WebPaywallRedeemerTest.kt diff --git a/superwall/src/main/java/com/superwall/sdk/Superwall.kt b/superwall/src/main/java/com/superwall/sdk/Superwall.kt index 986cfba6..f25bd0de 100644 --- a/superwall/src/main/java/com/superwall/sdk/Superwall.kt +++ b/superwall/src/main/java/com/superwall/sdk/Superwall.kt @@ -36,6 +36,7 @@ import com.superwall.sdk.models.assignment.ConfirmedAssignment import com.superwall.sdk.models.entitlements.Entitlement import com.superwall.sdk.models.entitlements.SubscriptionStatus import com.superwall.sdk.models.events.EventData +import com.superwall.sdk.models.internal.VendorId import com.superwall.sdk.network.device.InterfaceStyle import com.superwall.sdk.paywall.presentation.PaywallCloseReason import com.superwall.sdk.paywall.presentation.PaywallInfo @@ -282,7 +283,7 @@ class Superwall( } internal val vendorId: VendorId - get() = dependencyContainer.deviceHelper.vendorId + get() = VendorId(dependencyContainer.deviceHelper.vendorId) /** * A property that indicates current configuration state of the SDK. diff --git a/superwall/src/main/java/com/superwall/sdk/utilities/ErrorTracking.kt b/superwall/src/main/java/com/superwall/sdk/utilities/ErrorTracking.kt index 8039d402..7cf1c84a 100644 --- a/superwall/src/main/java/com/superwall/sdk/utilities/ErrorTracking.kt +++ b/superwall/src/main/java/com/superwall/sdk/utilities/ErrorTracking.kt @@ -135,6 +135,7 @@ internal inline fun withErrorTracking(block: () -> T): Either try { Either.Success(block()) } catch (e: Throwable) { + e.printStackTrace() if (e.shouldLog()) { Superwall.instance.trackError(e) } diff --git a/superwall/src/main/java/com/superwall/sdk/web/WebPaywallRedeemer.kt b/superwall/src/main/java/com/superwall/sdk/web/WebPaywallRedeemer.kt index 624d7a48..7842b2d5 100644 --- a/superwall/src/main/java/com/superwall/sdk/web/WebPaywallRedeemer.kt +++ b/superwall/src/main/java/com/superwall/sdk/web/WebPaywallRedeemer.kt @@ -12,7 +12,6 @@ import com.superwall.sdk.models.entitlements.Entitlement import com.superwall.sdk.models.internal.DeviceVendorId import com.superwall.sdk.models.internal.UserId import com.superwall.sdk.network.Network -import com.superwall.sdk.utilities.withErrorTracking import kotlinx.coroutines.launch class WebPaywallRedeemer( @@ -21,6 +20,8 @@ class WebPaywallRedeemer( val deepLinkReferrer: CheckForReferral, val network: Network, val setEntitlementStatus: (List) -> Unit, + val getUserId: () -> UserId = { UserId(Superwall.instance.userId) }, + val getDeviceId: () -> DeviceVendorId = { DeviceVendorId(Superwall.instance.vendorId) }, ) { init { ioScope.launch { @@ -29,7 +30,7 @@ class WebPaywallRedeemer( } suspend fun checkForRefferal() = - withErrorTracking { + asEither { deepLinkReferrer .checkForReferral() .fold( @@ -44,10 +45,12 @@ class WebPaywallRedeemer( network .redeemToken( codes, - UserId(Superwall.instance.userId), - DeviceVendorId(Superwall.instance.vendorId), + getUserId(), + getDeviceId(), ).fold({ - setEntitlementStatus(it.entitlements) + if (it.entitlements.isNotEmpty()) { + setEntitlementStatus(it.entitlements) + } }, { Logger.debug( LogLevel.error, @@ -70,6 +73,6 @@ class WebPaywallRedeemer( webEntitlementsByDevice.getSuccess()?.entitlements ?: listOf(), ) - entitlements + entitlements.toSet() } } diff --git a/superwall/src/test/java/com/superwall/sdk/web/WebPaywallRedeemerTest.kt b/superwall/src/test/java/com/superwall/sdk/web/WebPaywallRedeemerTest.kt new file mode 100644 index 00000000..5a611d5f --- /dev/null +++ b/superwall/src/test/java/com/superwall/sdk/web/WebPaywallRedeemerTest.kt @@ -0,0 +1,362 @@ +package com.superwall.sdk.web + +import android.content.Context +import com.superwall.sdk.Given +import com.superwall.sdk.Then +import com.superwall.sdk.When +import com.superwall.sdk.misc.Either +import com.superwall.sdk.misc.IOScope +import com.superwall.sdk.models.entitlements.Entitlement +import com.superwall.sdk.models.entitlements.WebEntitlements +import com.superwall.sdk.models.internal.DeviceVendorId +import com.superwall.sdk.models.internal.UserId +import com.superwall.sdk.models.internal.VendorId +import com.superwall.sdk.network.Network +import com.superwall.sdk.network.NetworkError +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.mockk +import io.mockk.verify +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.runTest +import org.junit.Test + +class WebPaywallRedeemerTest { + private val context: Context = mockk() + private val network: Network = + mockk { + coEvery { + redeemToken(any(), any(), any()) + } returns Either.Success(WebEntitlements(listOf())) + } + private val deepLinkReferrer: CheckForReferral = mockk() + private val testDispatcher = StandardTestDispatcher() + private val setEntitlementStatus: (List) -> Unit = mockk(relaxed = true) + private val getUserId: () -> UserId = { UserId("test_user") } + private val getDeviceId: () -> DeviceVendorId = { DeviceVendorId(VendorId("test_vendor")) } + private lateinit var redeemer: WebPaywallRedeemer + + @Test + fun `test successful redemption flow`() = + runTest(testDispatcher) { + Given("a WebPaywallRedeemer with valid redemption codes") { + val codes = listOf("code1", "code2") + val entitlements = listOf(Entitlement("test_entitlement")) + val response = WebEntitlements(entitlements) + + coEvery { deepLinkReferrer.checkForReferral() } returns Result.success(codes) + coEvery { + network.redeemToken( + any(), + any(), + any(), + ) + } returns Either.Success(response) + + redeemer = + WebPaywallRedeemer( + context, + IOScope(testDispatcher), + deepLinkReferrer, + network, + setEntitlementStatus, + getUserId, + getDeviceId, + ) + + When("checking for referral") { + redeemer.checkForRefferal() + + Then("it should redeem the codes and set entitlement status") { + verify { + setEntitlementStatus.invoke(entitlements) + } + } + } + } + } + + @Test + fun `test failed referral check`() = + runTest(testDispatcher) { + Given("a WebPaywallRedeemer with failing referral check") { + val exception = Exception("Referral check failed") + coEvery { deepLinkReferrer.checkForReferral() } returns Result.failure(exception) + + redeemer = + WebPaywallRedeemer( + context, + IOScope(testDispatcher), + deepLinkReferrer, + network, + setEntitlementStatus, + getUserId, + getDeviceId, + ) + + When("checking for referral") { + redeemer.checkForRefferal() + + Then("it should not call redeem") { + coVerify(exactly = 0) { + network.redeemToken(any(), any(), any()) + } + } + } + } + } + + @Test + fun `test failed token redemption`() = + runTest(testDispatcher) { + Given("a WebPaywallRedeemer with failing token redemption") { + val codes = listOf("code1") + val exception = Exception("Token redemption failed") + + coEvery { deepLinkReferrer.checkForReferral() } returns Result.success(codes) + coEvery { + network.redeemToken( + codes, + UserId("test_user"), + DeviceVendorId(VendorId("test_vendor")), + ) + } returns Either.Failure(NetworkError.Unknown(Error())) + + redeemer = + WebPaywallRedeemer( + context, + IOScope(testDispatcher), + deepLinkReferrer, + network, + setEntitlementStatus, + getUserId, + getDeviceId, + ) + + When("checking for referral") { + redeemer.checkForRefferal() + + Then("it should not set entitlement status") { + verify(exactly = 0) { + setEntitlementStatus(any()) + } + } + } + } + } + + @Test + fun `test checkForWebEntitlements with successful responses`() = + runTest(testDispatcher) { + Given("a WebPaywallRedeemer with successful web entitlements responses") { + val userEntitlements = listOf(Entitlement("user_entitlement")) + val deviceEntitlements = listOf(Entitlement("device_entitlement")) + + coEvery { + network.webEntitlementsByUserId(any()) + } returns Either.Success(WebEntitlements(userEntitlements)) + + coEvery { + network.webEntitlementsByDeviceID(any()) + } returns Either.Success(WebEntitlements(deviceEntitlements)) + + redeemer = + WebPaywallRedeemer( + context, + IOScope(testDispatcher), + deepLinkReferrer, + network, + setEntitlementStatus, + getUserId, + getDeviceId, + ) + + When("checking for web entitlements") { + val result = + redeemer.checkForWebEntitlements( + getUserId().value, + getDeviceId(), + ) + + Then("it should combine both entitlements lists") { + assert(result is Either.Success) + val entitlements = (result as Either.Success).value + assert(entitlements.containsAll(userEntitlements + deviceEntitlements)) + } + } + } + } + + @Test + fun `test checkForWebEntitlements with failed responses`() = + runTest(testDispatcher) { + Given("a WebPaywallRedeemer with failed web entitlements responses") { + coEvery { + network.webEntitlementsByUserId(any()) + } returns Either.Failure(NetworkError.Unknown(Error("User entitlements failed"))) + + coEvery { + network.webEntitlementsByDeviceID(any()) + } returns Either.Failure(NetworkError.Unknown(Error("Device entitlements failed"))) + + redeemer = + WebPaywallRedeemer( + context, + IOScope(testDispatcher), + deepLinkReferrer, + network, + setEntitlementStatus, + getUserId, + getDeviceId, + ) + + When("checking for web entitlements") { + val result = + redeemer.checkForWebEntitlements( + "test_user", + DeviceVendorId(VendorId("test_device")), + ) + + Then("it should return an empty list") { + assert(result is Either.Success) + val entitlements = (result as Either.Success).value + assert(entitlements.isEmpty()) + } + } + } + } + + @Test + fun `test checkForWebEntitlements with partially successful responses - user success`() = + runTest(testDispatcher) { + Given("a WebPaywallRedeemer with only user entitlements succeeding") { + val userEntitlements = listOf(Entitlement("user_entitlement")) + + coEvery { + network.webEntitlementsByUserId(UserId("test_user")) + } returns Either.Success(WebEntitlements(userEntitlements)) + + coEvery { + network.webEntitlementsByDeviceID(any()) + } returns Either.Failure(NetworkError.Unknown(Error("Device entitlements failed"))) + + redeemer = + WebPaywallRedeemer( + context, + IOScope(testDispatcher), + deepLinkReferrer, + network, + setEntitlementStatus, + getUserId, + getDeviceId, + ) + + When("checking for web entitlements") { + val result = + redeemer.checkForWebEntitlements( + "test_user", + DeviceVendorId(VendorId("test_device")), + ) + + Then("it should return only user entitlements") { + assert(result is Either.Success) + val entitlements = (result as Either.Success).value + assert(entitlements == userEntitlements) + } + } + } + } + + @Test + fun `test checkForWebEntitlements with partially successful responses - device success`() = + runTest(testDispatcher) { + Given("a WebPaywallRedeemer with only device entitlements succeeding") { + val deviceEntitlements = listOf(Entitlement("device_entitlement")) + + coEvery { + deepLinkReferrer.checkForReferral() + } returns Result.success(emptyList()) + + coEvery { + network.redeemToken(any(), any(), any()) + } returns Either.Failure(NetworkError.Unknown(Error("Token redemption failed"))) + + coEvery { + network.webEntitlementsByUserId(any()) + } returns Either.Failure(NetworkError.Unknown(Error("User entitlements failed"))) + + coEvery { + network.webEntitlementsByDeviceID(any()) + } returns Either.Success(WebEntitlements(deviceEntitlements)) + + redeemer = + WebPaywallRedeemer( + context, + IOScope(testDispatcher), + deepLinkReferrer, + network, + setEntitlementStatus, + getUserId, + getDeviceId, + ) + + When("checking for web entitlements") { + val result = + redeemer.checkForWebEntitlements( + "test_user", + DeviceVendorId(VendorId("test_device")), + ) + + Then("it should return only device entitlements") { + assert(result is Either.Success) + val entitlements = (result as Either.Success).value + assert(entitlements == deviceEntitlements) + } + } + } + } + + @Test + fun `test checkForWebEntitlements with duplicate entitlements`() = + runTest(testDispatcher) { + Given("a WebPaywallRedeemer with overlapping entitlements from user and device") { + val commonEntitlement = Entitlement("common_entitlement") + val userEntitlements = listOf(commonEntitlement, Entitlement("user_specific")) + val deviceEntitlements = listOf(commonEntitlement, Entitlement("device_specific")) + + coEvery { + network.webEntitlementsByUserId(any()) + } returns Either.Success(WebEntitlements(userEntitlements)) + + coEvery { + network.webEntitlementsByDeviceID(any()) + } returns Either.Success(WebEntitlements(deviceEntitlements)) + + redeemer = + WebPaywallRedeemer( + context, + IOScope(testDispatcher), + deepLinkReferrer, + network, + setEntitlementStatus, + getUserId, + getDeviceId, + ) + + When("checking for web entitlements") { + val result = + redeemer.checkForWebEntitlements( + "test_user", + DeviceVendorId(VendorId("test_device")), + ) + + Then("it should return combined entitlements without duplicates") { + assert(result is Either.Success) + val entitlements = (result as Either.Success).value + assert(entitlements.size == 3) // Should only have 3 unique entitlements + assert(entitlements.count { it == commonEntitlement } == 1) // Should only have one copy of the common entitlement + } + } + } + } +}