diff --git a/superwall/build.gradle.kts b/superwall/build.gradle.kts index d5d6969a..49cf975e 100644 --- a/superwall/build.gradle.kts +++ b/superwall/build.gradle.kts @@ -205,6 +205,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 9533a634..6e4fbbb4 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/analytics/superwall/SuperwallEvents.kt b/superwall/src/main/java/com/superwall/sdk/analytics/superwall/SuperwallEvents.kt index 52d29321..ef6f346f 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 @@ -23,7 +23,7 @@ enum class SuperwallEvents( SubscriptionStart("subscription_start"), SurveyResponse("survey_response"), SurveyClose("survey_close"), - SubscriptionStatusDidChange("subscriptionStatus_didChange"), + EntitlementStatusDidChange("entitlementStatus_didChange"), FreeTrialStart("freeTrial_start"), UserAttributes("user_attributes"), NonRecurringProductPurchase("nonRecurringProduct_purchase"), 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 b0512a2b..a61c4042 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/EntitlementStatus.kt b/superwall/src/main/java/com/superwall/sdk/models/entitlements/EntitlementStatus.kt new file mode 100644 index 00000000..55179b40 --- /dev/null +++ b/superwall/src/main/java/com/superwall/sdk/models/entitlements/EntitlementStatus.kt @@ -0,0 +1,19 @@ +package com.superwall.sdk.models.entitlements + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +sealed class EntitlementStatus { + @Serializable + object Unknown : EntitlementStatus() + + @Serializable + object NoActiveEntitlements : EntitlementStatus() + + @Serializable + data class Active( + @SerialName("entitlements") + val entitlements: Set, + ) : EntitlementStatus() +} 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/paywall/presentation/get_presentation_result/InternalGetPresentationResult.kt b/superwall/src/main/java/com/superwall/sdk/paywall/presentation/get_presentation_result/InternalGetPresentationResult.kt index 7f3e6b4b..3783927e 100644 --- a/superwall/src/main/java/com/superwall/sdk/paywall/presentation/get_presentation_result/InternalGetPresentationResult.kt +++ b/superwall/src/main/java/com/superwall/sdk/paywall/presentation/get_presentation_result/InternalGetPresentationResult.kt @@ -49,7 +49,7 @@ private fun handle( is PaywallPresentationRequestStatusReason.NoPresenter, is PaywallPresentationRequestStatusReason.PaywallAlreadyPresented, is PaywallPresentationRequestStatusReason.NoConfig, - is PaywallPresentationRequestStatusReason.SubscriptionStatusTimeout, + is PaywallPresentationRequestStatusReason.EntitlementStatusTimeout, -> PresentationResult.PaywallNotAvailable() } } diff --git a/superwall/src/main/java/com/superwall/sdk/paywall/presentation/internal/PaywallPresentationRequestStatus.kt b/superwall/src/main/java/com/superwall/sdk/paywall/presentation/internal/PaywallPresentationRequestStatus.kt index e51d68b5..a8ed582c 100644 --- a/superwall/src/main/java/com/superwall/sdk/paywall/presentation/internal/PaywallPresentationRequestStatus.kt +++ b/superwall/src/main/java/com/superwall/sdk/paywall/presentation/internal/PaywallPresentationRequestStatus.kt @@ -48,7 +48,7 @@ sealed class PaywallPresentationRequestStatusReason( * The entitlement status timed out. * This happens when the entitlementStatus stays unknown for more than 5 seconds. */ - class SubscriptionStatusTimeout : PaywallPresentationRequestStatusReason("subscription_status_timeout") + class EntitlementStatusTimeout : PaywallPresentationRequestStatusReason("subscription_status_timeout") } typealias PresentationPipelineError = PaywallPresentationRequestStatusReason diff --git a/superwall/src/main/java/com/superwall/sdk/paywall/view/PaywallShimmer.kt b/superwall/src/main/java/com/superwall/sdk/paywall/view/PaywallShimmer.kt new file mode 100644 index 00000000..dbd1c5fb --- /dev/null +++ b/superwall/src/main/java/com/superwall/sdk/paywall/view/PaywallShimmer.kt @@ -0,0 +1,51 @@ +package com.superwall.sdk.paywall.view + +import android.graphics.Color +import android.view.View +import android.view.ViewGroup +import android.widget.FrameLayout.LayoutParams +import androidx.appcompat.widget.AppCompatImageView.GONE +import androidx.appcompat.widget.AppCompatImageView.VISIBLE +import androidx.core.graphics.BlendModeColorFilterCompat +import androidx.core.graphics.BlendModeCompat +import com.superwall.sdk.misc.isDarkColor +import com.superwall.sdk.misc.readableOverlayColor +import com.superwall.sdk.paywall.view.delegate.PaywallLoadingState + +interface PaywallShimmer { + fun hideShimmer() + + fun showShimmer() + + fun checkForOrientationChanges() +} + +fun T.setupFor( + paywallView: PaywallView, + loadingState: PaywallLoadingState, +) where T : PaywallShimmer, T : View { + (this.parent as? ViewGroup)?.removeView(this) + if (this is ShimmerView && this.background != paywallView.backgroundColor) { + background = paywallView.backgroundColor + setBackgroundColor(background) + isLightBackground = !background.isDarkColor() + tintColor = background.readableOverlayColor() + tintColorFilter = + BlendModeColorFilterCompat.createBlendModeColorFilterCompat( + Color.argb(64, Color.red(tintColor), Color.green(tintColor), Color.blue(tintColor)), + BlendModeCompat.SRC_IN, + ) + } + + visibility = + when (loadingState) { + is PaywallLoadingState.LoadingURL -> + VISIBLE + + else -> GONE + } + paywallView.addView(this) + layoutParams = + LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT) + checkForOrientationChanges() +} diff --git a/superwall/src/main/java/com/superwall/sdk/paywall/view/survey/SurveyManager.kt b/superwall/src/main/java/com/superwall/sdk/paywall/view/survey/SurveyManager.kt deleted file mode 100644 index 37f4b446..00000000 --- a/superwall/src/main/java/com/superwall/sdk/paywall/view/survey/SurveyManager.kt +++ /dev/null @@ -1,289 +0,0 @@ -package com.superwall.sdk.paywall.view.survey - -import android.app.Activity -import android.text.Editable -import android.text.TextWatcher -import android.view.LayoutInflater -import android.widget.ArrayAdapter -import android.widget.EditText -import android.widget.ListView -import android.widget.TextView -import androidx.appcompat.app.AlertDialog -import com.google.android.material.bottomsheet.BottomSheetDialog -import com.superwall.sdk.R -import com.superwall.sdk.Superwall -import com.superwall.sdk.analytics.internal.TrackingLogic -import com.superwall.sdk.analytics.internal.track -import com.superwall.sdk.analytics.internal.trackable.InternalSuperwallEvent -import com.superwall.sdk.config.models.Survey -import com.superwall.sdk.config.models.SurveyOption -import com.superwall.sdk.config.models.SurveyShowCondition -import com.superwall.sdk.dependencies.TriggerFactory -import com.superwall.sdk.logger.LogLevel -import com.superwall.sdk.logger.LogScope -import com.superwall.sdk.logger.Logger -import com.superwall.sdk.paywall.presentation.PaywallCloseReason -import com.superwall.sdk.paywall.presentation.PaywallInfo -import com.superwall.sdk.paywall.presentation.internal.state.PaywallResult -import com.superwall.sdk.paywall.view.PaywallView -import com.superwall.sdk.paywall.view.delegate.PaywallLoadingState -import com.superwall.sdk.storage.LocalStorage -import com.superwall.sdk.storage.SurveyAssignmentKey -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch - -object SurveyManager { - private var otherAlertDialog: AlertDialog? = null - - fun presentSurveyIfAvailable( - surveys: List, - paywallResult: PaywallResult, - paywallCloseReason: PaywallCloseReason, - activity: Activity?, - paywallView: PaywallView, - loadingState: PaywallLoadingState, - isDebuggerLaunched: Boolean, - paywallInfo: PaywallInfo, - storage: LocalStorage, - factory: TriggerFactory, - completion: (SurveyPresentationResult) -> Unit, - ) { - val activity = - activity.let { it } ?: run { - completion(SurveyPresentationResult.NOSHOW) - return - } - - val survey = - selectSurvey(surveys, paywallResult, paywallCloseReason) ?: run { - completion(SurveyPresentationResult.NOSHOW) - return - } - - if (loadingState !is PaywallLoadingState.Ready && loadingState !is PaywallLoadingState.LoadingPurchase) { - completion(SurveyPresentationResult.NOSHOW) - return - } - - if (survey.hasSeenSurvey(storage)) { - completion(SurveyPresentationResult.NOSHOW) - return - } - - val isHoldout = survey.shouldAssignHoldout(isDebuggerLaunched) - - if (!isDebuggerLaunched) { - // Make sure we don't assess this survey with this assignment key again. - storage.write(SurveyAssignmentKey, survey.assignmentKey) - } - - if (isHoldout) { - Logger.debug( - logLevel = LogLevel.info, - scope = LogScope.paywallView, - message = "The survey will not present.", - ) - completion(SurveyPresentationResult.HOLDOUT) - return - } - - val dialog = BottomSheetDialog(activity) - dialog.setCanceledOnTouchOutside(false) - dialog.setCancelable(false) - val surveyView = LayoutInflater.from(activity).inflate(R.layout.survey_bottom_sheet, null) - dialog.setContentView(surveyView) - - val optionsToShow = mutableListOf() - optionsToShow.addAll(survey.options.map { it.title }) - - // Include 'Other' and 'Close' options in the ListView data source if necessary - if (survey.includeOtherOption) { - optionsToShow.add("Other") - } - if (survey.includeCloseOption) { - optionsToShow.add("Close") - } - - val titleTextView = surveyView.findViewById(R.id.title) - val messageTextView = surveyView.findViewById(R.id.message) - - titleTextView.text = survey.title - messageTextView.text = survey.message - - val listView: ListView = surveyView.findViewById(R.id.surveyListView) - val surveyOptionsAdapter = - ArrayAdapter( - activity, - R.layout.list_item, - optionsToShow, - ) - - listView.adapter = surveyOptionsAdapter - listView.setOnItemClickListener { _, _, position, _ -> - if (position < survey.options.size) { - // Standard option selected - dialog.setOnDismissListener { - handleDialogDismissal( - isDebuggerLaunched = isDebuggerLaunched, - survey = survey, - option = survey.options[position], - customResponse = null, - paywallInfo = paywallInfo, - factory = factory, - paywallView = paywallView, - completion = completion, - ) - } - dialog.dismiss() - } else { - // Special case for 'Other' or 'Close' - val selectedItem = optionsToShow[position] - if (selectedItem == "Other") { - val customAlertView = LayoutInflater.from(activity).inflate(R.layout.custom_alert_dialog_layout, null) - - val otherBuilder = AlertDialog.Builder(activity) - otherBuilder.setCancelable(false) - otherBuilder.setView(customAlertView) - - val option = SurveyOption("000", "Other") - - otherBuilder.setPositiveButton("Submit") { _, _ -> - // Intentionally left blank - } - - val otherDialog = otherBuilder.create() - - val customDialogTitle = customAlertView.findViewById(R.id.customDialogTitle) - val customDialogMessage = customAlertView.findViewById(R.id.customDialogMessage) - val customEditText = customAlertView.findViewById(R.id.editText) - - customDialogTitle.text = survey.title - customDialogMessage.text = survey.message - - customEditText.addTextChangedListener( - object : TextWatcher { - override fun afterTextChanged(s: Editable?) { - val text = s?.toString()?.trim() - otherDialog.getButton(AlertDialog.BUTTON_POSITIVE).isEnabled = !text.isNullOrEmpty() - } - - override fun beforeTextChanged( - s: CharSequence?, - start: Int, - count: Int, - after: Int, - ) {} - - override fun onTextChanged( - s: CharSequence?, - start: Int, - before: Int, - count: Int, - ) {} - }, - ) - - // Disable the 'Submit' button initially - otherDialog.setOnShowListener { - otherDialog.getButton(AlertDialog.BUTTON_POSITIVE).isEnabled = false - } - - dialog.setOnDismissListener { - otherDialog.show() - otherDialog.getButton(AlertDialog.BUTTON_POSITIVE).isEnabled = false - - // Auto-select the EditText - customEditText.requestFocus() - } - - otherDialog.setOnShowListener { - // Set OnClickListener here - otherDialog.getButton(AlertDialog.BUTTON_POSITIVE).setOnClickListener { - handleDialogDismissal( - isDebuggerLaunched = isDebuggerLaunched, - survey = survey, - option = option, - customResponse = customEditText.text.toString(), - paywallInfo = paywallInfo, - factory = factory, - paywallView = paywallView, - completion = completion, - ) - otherDialog.dismiss() - } - } - - // Dismiss the Survey - dialog.dismiss() - } else if (selectedItem == "Close") { - CoroutineScope(Dispatchers.IO).launch { - val event = InternalSuperwallEvent.SurveyClose() - Superwall.instance.track(event) - } - dialog.dismiss() - completion(SurveyPresentationResult.SHOW) - } - } - } - - dialog.show() - } - - private fun handleDialogDismissal( - isDebuggerLaunched: Boolean, - survey: Survey, - option: SurveyOption, - customResponse: String?, - paywallInfo: PaywallInfo, - factory: TriggerFactory, - paywallView: PaywallView, - completion: (SurveyPresentationResult) -> Unit, - ) { - if (isDebuggerLaunched) { - completion(SurveyPresentationResult.SHOW) - return - } - - CoroutineScope(Dispatchers.IO).launch { - val event = - InternalSuperwallEvent.SurveyResponse( - survey, - option, - customResponse, - paywallInfo, - ) - - val outcome = - TrackingLogic.canTriggerPaywall( - event, - factory.makeTriggers(), - paywallView, - ) - - Superwall.instance.track(event) - - if (outcome == TrackingLogic.ImplicitTriggerOutcome.DontTriggerPaywall) { - completion(SurveyPresentationResult.SHOW) - } - } - } - - private fun selectSurvey( - surveys: List, - paywallResult: PaywallResult, - paywallCloseReason: PaywallCloseReason, - ): Survey? { - val isPurchased = paywallResult is PaywallResult.Purchased - val isDeclined = paywallResult is PaywallResult.Declined - val isManualClose = paywallCloseReason is PaywallCloseReason.ManualClose - - for (survey in surveys) { - when (survey.presentationCondition) { - SurveyShowCondition.ON_MANUAL_CLOSE -> if (isDeclined && isManualClose) return survey - SurveyShowCondition.ON_PURCHASE -> if (isPurchased) return survey - } - } - return null - } -} diff --git a/superwall/src/main/java/com/superwall/sdk/paywall/view/survey/SurveyPresentationResult.kt b/superwall/src/main/java/com/superwall/sdk/paywall/view/survey/SurveyPresentationResult.kt deleted file mode 100644 index d92f76d5..00000000 --- a/superwall/src/main/java/com/superwall/sdk/paywall/view/survey/SurveyPresentationResult.kt +++ /dev/null @@ -1,9 +0,0 @@ -package com.superwall.sdk.paywall.view.survey - -enum class SurveyPresentationResult( - val rawValue: String, -) { - SHOW("show"), - HOLDOUT("holdout"), - NOSHOW("noShow"), -} 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) +}