From f16ec55a4706d2ec7199bacbeb4e64dd1309d154 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= Date: Mon, 20 Nov 2023 15:19:00 -0500 Subject: [PATCH 01/23] Paywalls are only preloaded if their associated rules can match --- CHANGELOG.md | 6 ++ .../com/superwall/sdk/config/ConfigLogic.kt | 67 +++++++++---- .../com/superwall/sdk/config/ConfigManager.kt | 95 ++++++++++++------- .../sdk/dependencies/DependencyContainer.kt | 1 + .../sdk/models/triggers/TriggerRule.kt | 69 +++++++++++++- .../ExpressionEvaluator.kt | 15 ++- .../superwall/sdk/config/ConfigLogicTest.kt | 6 +- 7 files changed, 199 insertions(+), 60 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 633a430e..11300709 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ The changelog for `Superwall`. Also see the [releases](https://github.com/superwall-me/Superwall-Android/releases) on GitHub. +## 1.0.0-alpha.27 + +### Enhancements + +- Paywalls are only preloaded if their associated rules can match. + ## 1.0.0-alpha.26 ### Fixes diff --git a/superwall/src/main/java/com/superwall/sdk/config/ConfigLogic.kt b/superwall/src/main/java/com/superwall/sdk/config/ConfigLogic.kt index 09f65698..abb86638 100644 --- a/superwall/src/main/java/com/superwall/sdk/config/ConfigLogic.kt +++ b/superwall/src/main/java/com/superwall/sdk/config/ConfigLogic.kt @@ -6,6 +6,7 @@ import com.superwall.sdk.models.config.Config import com.superwall.sdk.models.config.PreloadingDisabled import com.superwall.sdk.models.paywall.Paywall import com.superwall.sdk.models.triggers.* +import com.superwall.sdk.paywall.presentation.rule_logic.expression_evaluator.ExpressionEvaluating import java.util.* object ConfigLogic { @@ -70,7 +71,7 @@ object ConfigLogic { throw TriggerRuleError.InvalidState } - fun getRulesPerTriggerGroup(triggers: Set): Set> { + fun getRulesPerCampaign(triggers: Set): Set> { val groupIds: MutableSet = mutableSetOf() val groupedTriggerRules: MutableSet> = mutableSetOf() for (trigger in triggers) { @@ -97,7 +98,7 @@ object ConfigLogic { var confirmedAssignments = confirmedAssignments.toMutableMap() var unconfirmedAssignments: MutableMap = mutableMapOf() - val groupedTriggerRules = getRulesPerTriggerGroup(fromTriggers) + val groupedTriggerRules = getRulesPerCampaign(fromTriggers) for (ruleGroup in groupedTriggerRules) { for (rule in ruleGroup) { @@ -209,30 +210,60 @@ object ConfigLogic { } } - fun getAllActiveTreatmentPaywallIds( - fromTriggers: Set, - confirmedAssignments: Map, - unconfirmedAssignments: Map + suspend fun getAllActiveTreatmentPaywallIds( + triggers: Set, + confirmedAssignments: Map, + unconfirmedAssignments: Map, + expressionEvaluator: ExpressionEvaluating ): Set { - val triggers = fromTriggers var confirmedAssignments = confirmedAssignments.toMutableMap() + // Getting the set of experiment IDs from confirmed assignments val confirmedExperimentIds = confirmedAssignments.keys.toSet() - val groupedTriggerRules = getRulesPerTriggerGroup(triggers) - val triggerExperimentIds = - groupedTriggerRules.flatMap { it.map { rule -> rule.experiment.id } }.toSet() - val oldExperimentIds = confirmedExperimentIds - triggerExperimentIds + val triggerRulesPerCampaign = getRulesPerCampaign(triggers) + + // Initialize sets to keep track of all experiment IDs and the ones to be skipped + val allExperimentIds = mutableSetOf() + val skippedExperimentIds = mutableSetOf() + + // Loop through all the rules and check their preloading behavior + triggerRulesPerCampaign.forEach { campaignRules -> + campaignRules.forEach { rule -> + allExperimentIds.add(rule.experiment.id) + + // Check the preloading behavior of each rule + when (rule.preload.behavior) { + TriggerPreloadBehavior.IF_TRUE -> { + val outcome = expressionEvaluator.evaluateExpression( + rule = rule, + eventData = null + ) + if (outcome is TriggerRuleOutcome.NoMatch) { + skippedExperimentIds.add(rule.experiment.id) + } + } + TriggerPreloadBehavior.ALWAYS -> {} + TriggerPreloadBehavior.NEVER -> skippedExperimentIds.add(rule.experiment.id) + } + } + } - for (id in oldExperimentIds) { + // Remove any confirmed experiment IDs that are no longer part of a trigger + val unusedExperimentIds = confirmedExperimentIds.subtract(allExperimentIds) + unusedExperimentIds.forEach { id -> confirmedAssignments.remove(id) } - val confirmedVariants = confirmedAssignments.values.toList() - val unconfirmedVariants = unconfirmedAssignments.values.toList() - val mergedVariants = confirmedVariants + unconfirmedVariants - val identifiers = mutableSetOf() + // Combine confirmed and unconfirmed assignments, removing the skipped ones + val mergedAssignments = (confirmedAssignments + unconfirmedAssignments).toMutableMap() + skippedExperimentIds.forEach { id -> + mergedAssignments.remove(id) + } + val preloadableVariants = mergedAssignments.values - for (variant in mergedVariants) { + // Select only the variants that will result in a paywall + val identifiers = mutableSetOf() + preloadableVariants.forEach { variant -> if (variant.type == Experiment.Variant.VariantType.TREATMENT && variant.paywallId != null) { identifiers.add(variant.paywallId) } @@ -248,7 +279,7 @@ object ConfigLogic { ): Set { val triggers = forTriggers val mergedAssignments = confirmedAssignments + unconfirmedAssignments - val groupedTriggerRules = getRulesPerTriggerGroup(triggers) + val groupedTriggerRules = getRulesPerCampaign(triggers) val triggerExperimentIds = groupedTriggerRules.flatMap { it.map { it.experiment.id } } val identifiers = mutableSetOf() 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 9970c7a1..5736bf9f 100644 --- a/superwall/src/main/java/com/superwall/sdk/config/ConfigManager.kt +++ b/superwall/src/main/java/com/superwall/sdk/config/ConfigManager.kt @@ -3,11 +3,13 @@ package com.superwall.sdk.config import LogLevel import LogScope import Logger +import android.content.Context import com.superwall.sdk.config.models.ConfigState import com.superwall.sdk.config.models.getConfig import com.superwall.sdk.config.options.SuperwallOptions import com.superwall.sdk.dependencies.DeviceInfoFactory import com.superwall.sdk.dependencies.RequestFactory +import com.superwall.sdk.dependencies.RuleAttributesFactory import com.superwall.sdk.misc.Result import com.superwall.sdk.misc.awaitFirstValidConfig import com.superwall.sdk.models.assignment.AssignmentPostback @@ -18,17 +20,20 @@ import com.superwall.sdk.models.triggers.ExperimentID import com.superwall.sdk.models.triggers.Trigger import com.superwall.sdk.network.Network import com.superwall.sdk.paywall.manager.PaywallManager +import com.superwall.sdk.paywall.presentation.rule_logic.expression_evaluator.ExpressionEvaluator import com.superwall.sdk.paywall.request.ResponseIdentifiers import com.superwall.sdk.storage.Storage import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.Job import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.flow.* import kotlinx.coroutines.launch // TODO: Re-enable those params open class ConfigManager( + private val context: Context, // private val storeKitManager: StoreKitManager, private val storage: Storage, private val network: Network, @@ -36,7 +41,7 @@ open class ConfigManager( private val paywallManager: PaywallManager, private val factory: Factory ) { - interface Factory: RequestFactory, DeviceInfoFactory {} + interface Factory: RequestFactory, DeviceInfoFactory, RuleAttributesFactory {} var options = SuperwallOptions() // The configuration of the Superwall dashboard @@ -61,6 +66,8 @@ open class ConfigManager( // A memory store of assignments that are yet to be confirmed. private var _unconfirmedAssignments = mutableMapOf() + private var currentPreloadingTask: Job? = null + var unconfirmedAssignments: Map get() = _unconfirmedAssignments set(value) { @@ -202,18 +209,38 @@ open class ConfigManager( preloadAllPaywalls() } + // Preloads paywalls referenced by triggers. - private suspend fun preloadAllPaywalls() = coroutineScope { - val config = configState.awaitFirstValidConfig() ?: return@coroutineScope + suspend fun preloadAllPaywalls() { + if (currentPreloadingTask != null) { + return + } - val triggers = ConfigLogic.filterTriggers(config.triggers, config.preloadingDisabled) - val confirmedAssignments = storage.getConfirmedAssignments() - val paywallIds = ConfigLogic.getAllActiveTreatmentPaywallIds( - triggers, - confirmedAssignments, - unconfirmedAssignments - ) - preloadPaywalls(paywallIds) + currentPreloadingTask = coroutineScope { + launch { + val config = configState.awaitFirstValidConfig() ?: return@launch + + val expressionEvaluator = ExpressionEvaluator( + context = context, + storage = storage, + factory = factory + ) + val triggers = ConfigLogic.filterTriggers( + config.triggers, + preloadingDisabled = config.preloadingDisabled + ) + val confirmedAssignments = storage.getConfirmedAssignments() + val paywallIds = ConfigLogic.getAllActiveTreatmentPaywallIds( + triggers = triggers, + confirmedAssignments = confirmedAssignments, + unconfirmedAssignments = unconfirmedAssignments, + expressionEvaluator = expressionEvaluator + ) + preloadPaywalls(paywallIdentifiers = paywallIds) + + currentPreloadingTask = null + } + } } // Preloads paywalls referenced by the provided triggers. @@ -225,29 +252,31 @@ open class ConfigManager( } // Preloads paywalls referenced by triggers. - private fun preloadPaywalls(paywallIdentifiers: Set) { - paywallIdentifiers.forEach { identifier -> - CoroutineScope(Dispatchers.IO).launch { - val request = factory.makePaywallRequest( - eventData = null, - responseIdentifiers = ResponseIdentifiers( - paywallId = identifier, - experiment = null - ), - overrides = null, - isDebuggerLaunched = false, - presentationSourceType = null, - retryCount = 6 - ) - try { - paywallManager.getPaywallViewController( - request = request, - isForPresentation = true, - isPreloading = true, - delegate = null + private suspend fun preloadPaywalls(paywallIdentifiers: Set) { + coroutineScope { + paywallIdentifiers.forEach { identifier -> + launch { + val request = factory.makePaywallRequest( + eventData = null, + responseIdentifiers = ResponseIdentifiers( + paywallId = identifier, + experiment = null + ), + overrides = null, + isDebuggerLaunched = false, + presentationSourceType = null, + retryCount = 6 ) - } catch (e: Exception) { - null + try { + paywallManager.getPaywallViewController( + request = request, + isForPresentation = true, + isPreloading = true, + delegate = null + ) + } catch (e: Exception) { + null + } } } } 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 d61ee325..c1abbf5c 100644 --- a/superwall/src/main/java/com/superwall/sdk/dependencies/DependencyContainer.kt +++ b/superwall/src/main/java/com/superwall/sdk/dependencies/DependencyContainer.kt @@ -136,6 +136,7 @@ class DependencyContainer( ) configManager = ConfigManager( + context = context, storage = storage, network = network, options = options, diff --git a/superwall/src/main/java/com/superwall/sdk/models/triggers/TriggerRule.kt b/superwall/src/main/java/com/superwall/sdk/models/triggers/TriggerRule.kt index 7f424ed1..7dc09f64 100644 --- a/superwall/src/main/java/com/superwall/sdk/models/triggers/TriggerRule.kt +++ b/superwall/src/main/java/com/superwall/sdk/models/triggers/TriggerRule.kt @@ -1,8 +1,17 @@ package com.superwall.sdk.models.triggers import ComputedPropertyRequest +import kotlinx.serialization.KSerializer import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable +import kotlinx.serialization.SerializationException +import kotlinx.serialization.Serializer +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.descriptors.buildClassSerialDescriptor +import kotlinx.serialization.descriptors.element +import kotlinx.serialization.encoding.CompositeDecoder +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder data class UnmatchedRule( val source: Source, @@ -47,6 +56,13 @@ sealed class TriggerRuleOutcome { } } +@Serializable +enum class TriggerPreloadBehavior(val rawValue: String) { + @SerialName("IF_TRUE") IF_TRUE("IF_TRUE"), + @SerialName("ALWAYS") ALWAYS("ALWAYS"), + @SerialName("NEVER") NEVER("NEVER"); +} + @Serializable data class TriggerRule( var experimentId: String, @@ -56,9 +72,57 @@ data class TriggerRule( val expressionJs: String? = null, val occurrence: TriggerRuleOccurrence? = null, @SerialName("computed_properties") - val computedPropertyRequests: List = emptyList() + val computedPropertyRequests: List = emptyList(), + val preload: TriggerPreload ) { + @Serializable(with = TriggerPreloadSerializer::class) + data class TriggerPreload( + var behavior: TriggerPreloadBehavior, + val requiresReEvaluation: Boolean? = null + ) + + object TriggerPreloadSerializer : KSerializer { + override val descriptor: SerialDescriptor = buildClassSerialDescriptor("TriggerPreload") { + element("behavior") + element("requiresReEvaluation", isOptional = true) + } + + override fun serialize(encoder: Encoder, value: TriggerPreload) { + val compositeOutput = encoder.beginStructure(descriptor) + compositeOutput.encodeStringElement(descriptor, 0, value.behavior.rawValue) + if (value.requiresReEvaluation != null) { + compositeOutput.encodeBooleanElement(descriptor, 1, value.requiresReEvaluation) + } + compositeOutput.endStructure(descriptor) + } + + + override fun deserialize(decoder: Decoder): TriggerPreload { + val dec = decoder.beginStructure(descriptor) + var behavior: TriggerPreloadBehavior? = null + var requiresReevaluation: Boolean? = null + + loop@ while (true) { + when (val i = dec.decodeElementIndex(descriptor)) { + CompositeDecoder.DECODE_DONE -> break@loop + 0 -> behavior = TriggerPreloadBehavior.valueOf(dec.decodeStringElement(descriptor, i).uppercase()) + 1 -> requiresReevaluation = dec.decodeBooleanElement(descriptor, i) + else -> throw SerializationException("Unknown index $i") + } + } + dec.endStructure(descriptor) + + val finalBehavior = behavior ?: throw SerializationException("Behavior is missing") + if (requiresReevaluation == true) { + return TriggerPreload(TriggerPreloadBehavior.ALWAYS, requiresReevaluation) + } + + return TriggerPreload(finalBehavior, requiresReevaluation) + } + + } + val experiment: RawExperiment get() { return RawExperiment( @@ -83,7 +147,8 @@ data class TriggerRule( expression = null, expressionJs = null, occurrence = null, - computedPropertyRequests = emptyList() + computedPropertyRequests = emptyList(), + preload = TriggerPreload(behavior = TriggerPreloadBehavior.ALWAYS) ) } } \ No newline at end of file diff --git a/superwall/src/main/java/com/superwall/sdk/paywall/presentation/rule_logic/expression_evaluator/ExpressionEvaluator.kt b/superwall/src/main/java/com/superwall/sdk/paywall/presentation/rule_logic/expression_evaluator/ExpressionEvaluator.kt index 6c08b01b..7ed650ce 100644 --- a/superwall/src/main/java/com/superwall/sdk/paywall/presentation/rule_logic/expression_evaluator/ExpressionEvaluator.kt +++ b/superwall/src/main/java/com/superwall/sdk/paywall/presentation/rule_logic/expression_evaluator/ExpressionEvaluator.kt @@ -21,11 +21,18 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import org.json.JSONObject +interface ExpressionEvaluating { + suspend fun evaluateExpression( + rule: TriggerRule, + eventData: EventData? + ): TriggerRuleOutcome +} + class ExpressionEvaluator( private val context: Context, private val storage: Storage, private val factory: RuleAttributesFactory -) { +): ExpressionEvaluating { companion object { public var sharedWebView: WebView? = null @@ -47,9 +54,9 @@ class ExpressionEvaluator( } } - suspend fun evaluateExpression( + override suspend fun evaluateExpression( rule: TriggerRule, - eventData: EventData + eventData: EventData? ): TriggerRuleOutcome { // Expression matches all if (rule.expressionJs == null && rule.expression == null) { @@ -83,7 +90,7 @@ class ExpressionEvaluator( private suspend fun getBase64Params( rule: TriggerRule, - eventData: EventData + eventData: EventData? ): String? { val jsonAttributes = factory.makeRuleAttributes(eventData, rule.computedPropertyRequests) diff --git a/superwall/src/test/java/com/superwall/sdk/config/ConfigLogicTest.kt b/superwall/src/test/java/com/superwall/sdk/config/ConfigLogicTest.kt index 28bfcbc9..f1764c69 100644 --- a/superwall/src/test/java/com/superwall/sdk/config/ConfigLogicTest.kt +++ b/superwall/src/test/java/com/superwall/sdk/config/ConfigLogicTest.kt @@ -147,7 +147,7 @@ internal class ConfigLogicTest { @Test fun test_getRulesPerTriggerGroup_noTriggers() { - val rules = ConfigLogic.getRulesPerTriggerGroup( + val rules = ConfigLogic.getRulesPerCampaign( emptySet() ) assertTrue(rules.isEmpty()) @@ -158,7 +158,7 @@ internal class ConfigLogicTest { val trigger = Trigger.stub().apply { rules = emptyList() } - val rules = ConfigLogic.getRulesPerTriggerGroup(setOf(trigger)) + val rules = ConfigLogic.getRulesPerCampaign(setOf(trigger)) assertTrue(rules.isEmpty()) } @@ -180,7 +180,7 @@ internal class ConfigLogicTest { experimentGroupId = "2" }) } - val rules = ConfigLogic.getRulesPerTriggerGroup(setOf(trigger1, trigger2, trigger3)) + val rules = ConfigLogic.getRulesPerCampaign(setOf(trigger1, trigger2, trigger3)) assertEquals(2, rules.size) assertTrue(rules.contains(trigger3.rules)) assertTrue(rules.contains(trigger1.rules)) From eb24d9eb4514f1f9bdfa03bf5dd44c2b6e1265d8 Mon Sep 17 00:00:00 2001 From: Bryan Dubno Date: Mon, 20 Nov 2023 13:04:14 -0800 Subject: [PATCH 02/23] Adds status bar to full screen paywalls --- .../com/superwall/sdk/misc/Color+Helpers.kt | 4 ++ .../superwall/sdk/models/paywall/Paywall.kt | 5 ++ .../sdk/paywall/vc/PaywallViewController.kt | 48 ++++++++++++------- 3 files changed, 41 insertions(+), 16 deletions(-) diff --git a/superwall/src/main/java/com/superwall/sdk/misc/Color+Helpers.kt b/superwall/src/main/java/com/superwall/sdk/misc/Color+Helpers.kt index 93a6087d..f44ec97b 100644 --- a/superwall/src/main/java/com/superwall/sdk/misc/Color+Helpers.kt +++ b/superwall/src/main/java/com/superwall/sdk/misc/Color+Helpers.kt @@ -11,6 +11,10 @@ fun Int.isDarkColor(): Boolean { return lum < 0.50 } +fun Int.isLightColor(): Boolean { + return !isDarkColor() +} + fun Int.readableOverlayColor(): Int { return if (isDarkColor()) Color.WHITE else Color.BLACK } diff --git a/superwall/src/main/java/com/superwall/sdk/models/paywall/Paywall.kt b/superwall/src/main/java/com/superwall/sdk/models/paywall/Paywall.kt index b096a04c..57831d5b 100644 --- a/superwall/src/main/java/com/superwall/sdk/models/paywall/Paywall.kt +++ b/superwall/src/main/java/com/superwall/sdk/models/paywall/Paywall.kt @@ -18,6 +18,7 @@ import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable import java.net.URL import java.util.* +import android.graphics.Color @Serializable data class Paywalls(val paywalls: List) @@ -87,6 +88,10 @@ data class Paywall( var surveys: List = emptyList() ) : SerializableEntity { + val backgroundColor: Int by lazy { + Color.parseColor(this.backgroundColorHex) + } + init { productIds = products.map { it.id } presentation = Presentation( diff --git a/superwall/src/main/java/com/superwall/sdk/paywall/vc/PaywallViewController.kt b/superwall/src/main/java/com/superwall/sdk/paywall/vc/PaywallViewController.kt index fda045ee..32c42b4b 100644 --- a/superwall/src/main/java/com/superwall/sdk/paywall/vc/PaywallViewController.kt +++ b/superwall/src/main/java/com/superwall/sdk/paywall/vc/PaywallViewController.kt @@ -3,23 +3,21 @@ package com.superwall.sdk.paywall.vc import LogLevel import LogScope import Logger -import android.app.Activity import android.R +import android.app.Activity import android.content.Context import android.content.Intent import android.graphics.Color import android.net.Uri +import android.os.Build import android.os.Bundle import android.view.View import android.view.ViewGroup import android.view.ViewTreeObserver import android.view.Window +import android.view.WindowInsetsController import android.view.WindowManager -import android.webkit.WebResourceRequest -import android.webkit.WebResourceResponse import android.webkit.WebSettings -import android.webkit.WebView -import android.webkit.WebViewClient import android.widget.FrameLayout import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatDelegate @@ -28,7 +26,6 @@ import com.superwall.sdk.Superwall import com.superwall.sdk.analytics.internal.track import com.superwall.sdk.analytics.internal.trackable.InternalSuperwallEvent import com.superwall.sdk.analytics.superwall.SuperwallEventObjc -import com.superwall.sdk.analytics.trigger_session.LoadState import com.superwall.sdk.config.models.OnDeviceCaching import com.superwall.sdk.config.options.PaywallOptions import com.superwall.sdk.dependencies.TriggerFactory @@ -38,6 +35,7 @@ import com.superwall.sdk.game.GameControllerEvent import com.superwall.sdk.game.GameControllerManager import com.superwall.sdk.misc.AlertControllerFactory import com.superwall.sdk.misc.isDarkColor +import com.superwall.sdk.misc.isLightColor import com.superwall.sdk.misc.readableOverlayColor import com.superwall.sdk.models.paywall.Paywall import com.superwall.sdk.models.paywall.PaywallPresentationStyle @@ -69,7 +67,6 @@ import com.superwall.sdk.view.fatalAssert import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableSharedFlow -import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.launch import java.net.MalformedURLException import java.net.URL @@ -178,8 +175,8 @@ class PaywallViewController( // Add the webView addView(webView) - val backgroundColor = Color.parseColor(paywall.backgroundColorHex) // Add the shimmer view and hide it + val backgroundColor = paywall.backgroundColor this.shimmerView = ShimmerView( context, backgroundColor, @@ -189,7 +186,6 @@ class PaywallViewController( addView(shimmerView) hideShimmerView() - // Add the loading view and hide it addView(loadingViewController) hideLoadingView() @@ -748,17 +744,20 @@ class SuperwallPaywallActivity : AppCompatActivity() { companion object { private const val VIEW_KEY = "viewKey" private const val PRESENTATION_STYLE_KEY = "presentationStyleKey" + private const val IS_LIGHT_BACKGROUND_KEY = "isLightBackgroundKey" fun startWithView( context: Context, - view: View, - presentationStyleOverride: PaywallPresentationStyle? = null) { + view: PaywallViewController, + presentationStyleOverride: PaywallPresentationStyle? = null + ) { val key = UUID.randomUUID().toString() ViewStorage.storeView(key, view) val intent = Intent(context, SuperwallPaywallActivity::class.java).apply { putExtra(VIEW_KEY, key) putExtra(PRESENTATION_STYLE_KEY, presentationStyleOverride) + putExtra(IS_LIGHT_BACKGROUND_KEY, view.paywall.backgroundColor.isLightColor()) flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP } @@ -775,8 +774,30 @@ class SuperwallPaywallActivity : AppCompatActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) + AppCompatDelegate.setCompatVectorFromResourcesEnabled(true) + // Show content behind the status bar + window.decorView.systemUiVisibility = View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN or + View.SYSTEM_UI_FLAG_LAYOUT_STABLE + window.statusBarColor = Color.TRANSPARENT + window.clearFlags(WindowManager.LayoutParams.FLAG_TRANSLUCENT_STATUS) + window.addFlags(WindowManager.LayoutParams.FLAG_DRAWS_SYSTEM_BAR_BACKGROUNDS) + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + val isLightBackground = intent.getBooleanExtra(IS_LIGHT_BACKGROUND_KEY, false) + if (isLightBackground == true) { + window.insetsController?.let { + it.setSystemBarsAppearance( + WindowInsetsController.APPEARANCE_LIGHT_STATUS_BARS, + WindowInsetsController.APPEARANCE_LIGHT_STATUS_BARS + ) + + } + } + } + + requestWindowFeature(Window.FEATURE_NO_TITLE) val key = intent.getStringExtra(VIEW_KEY) if (key == null) { @@ -793,11 +814,6 @@ class SuperwallPaywallActivity : AppCompatActivity() { view.encapsulatingActivity = this } - requestWindowFeature(Window.FEATURE_NO_TITLE) - window.setFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN, - WindowManager.LayoutParams.FLAG_FULLSCREEN) - - // Now add setContentView(view) try { From fd2662020648fcc7dc57f29ffca857126c192624 Mon Sep 17 00:00:00 2001 From: Bryan Dubno Date: Mon, 20 Nov 2023 13:07:16 -0800 Subject: [PATCH 03/23] Added changelog --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 11300709..f1f5a745 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,8 @@ The changelog for `Superwall`. Also see the [releases](https://github.com/superw ### Enhancements - Paywalls are only preloaded if their associated rules can match. +- Adds status bar to full screen paywalls. + ## 1.0.0-alpha.26 From 3702c4093cac03774e8d24e1ecd3a80cfcbdd65b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= Date: Mon, 20 Nov 2023 18:28:56 -0500 Subject: [PATCH 04/23] Fixes issue where holdouts were still matching even if the limit set for their corresponding rules were exceeded - Adds test83 --- CHANGELOG.md | 3 +++ .../java/com/superwall/superapp/test/UITestActivity.kt | 1 + .../java/com/superwall/superapp/test/UITestHandler.kt | 10 ++++++++++ .../presentation/internal/GetPaywallComponents.kt | 2 +- .../presentation/internal/operators/GetExperiment.kt | 5 +++++ .../presentation/internal/operators/GetPaywallVC.kt | 10 +++++----- 6 files changed, 25 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f1f5a745..3bf5e0aa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,9 @@ The changelog for `Superwall`. Also see the [releases](https://github.com/superw - Paywalls are only preloaded if their associated rules can match. - Adds status bar to full screen paywalls. +### Fixes + +- Fixes issue where holdouts were still matching even if the limit set for their corresponding rules were exceeded. ## 1.0.0-alpha.26 diff --git a/app/src/main/java/com/superwall/superapp/test/UITestActivity.kt b/app/src/main/java/com/superwall/superapp/test/UITestActivity.kt index 70338e91..3fa6a223 100644 --- a/app/src/main/java/com/superwall/superapp/test/UITestActivity.kt +++ b/app/src/main/java/com/superwall/superapp/test/UITestActivity.kt @@ -127,6 +127,7 @@ fun UITestTable() { UITestHandler.test74Info to { CoroutineScope(Dispatchers.IO).launch { UITestHandler.test74() } }, UITestHandler.test75Info to { CoroutineScope(Dispatchers.IO).launch { UITestHandler.test75() } }, UITestHandler.test82Info to { CoroutineScope(Dispatchers.IO).launch { UITestHandler.test82() } }, + UITestHandler.test83Info to { CoroutineScope(Dispatchers.IO).launch { UITestHandler.test83() } }, UITestHandler.testAndroid4Info to { CoroutineScope(Dispatchers.IO).launch { UITestHandler.testAndroid4() } }, UITestHandler.testAndroid9Info to { CoroutineScope(Dispatchers.IO).launch { UITestHandler.testAndroid9() } }, UITestHandler.testAndroid18Info to { CoroutineScope(Dispatchers.IO).launch { UITestHandler.testAndroid18() } }, diff --git a/app/src/main/java/com/superwall/superapp/test/UITestHandler.kt b/app/src/main/java/com/superwall/superapp/test/UITestHandler.kt index c9f654ce..09c2139f 100644 --- a/app/src/main/java/com/superwall/superapp/test/UITestHandler.kt +++ b/app/src/main/java/com/superwall/superapp/test/UITestHandler.kt @@ -1325,6 +1325,16 @@ class UITestHandler { Superwall.instance.register(event = "price_readout") } + var test83Info = UITestInfo( + 83, + "The first time launch is tapped you will land in holdout. Check Skipped paywall " + + "for holdout printed in console. Tapping launch again will present paywall. Will " + + "need to delete app to be able to do it again as it uses limits." + ) + suspend fun test83() { + Superwall.instance.register(event = "holdout_one_time_occurrence") + } + var testAndroid4Info = UITestInfo( 4, "NOTE: Must use `Android Main screen` API key. Launch compose debug screen: " + diff --git a/superwall/src/main/java/com/superwall/sdk/paywall/presentation/internal/GetPaywallComponents.kt b/superwall/src/main/java/com/superwall/sdk/paywall/presentation/internal/GetPaywallComponents.kt index 89e3fecb..7d37f6c3 100644 --- a/superwall/src/main/java/com/superwall/sdk/paywall/presentation/internal/GetPaywallComponents.kt +++ b/superwall/src/main/java/com/superwall/sdk/paywall/presentation/internal/GetPaywallComponents.kt @@ -45,7 +45,7 @@ suspend fun Superwall.getPaywallComponents( confirmHoldoutAssignment(request = request, rulesOutcome = rulesOutcome) - val paywallViewController = getPaywallViewController(request, rulesOutcome, debugInfo, publisher) + val paywallViewController = getPaywallViewController(request, rulesOutcome, debugInfo, publisher, dependencyContainer) val presenter = getPresenterIfNecessary(paywallViewController, rulesOutcome, request, publisher) diff --git a/superwall/src/main/java/com/superwall/sdk/paywall/presentation/internal/operators/GetExperiment.kt b/superwall/src/main/java/com/superwall/sdk/paywall/presentation/internal/operators/GetExperiment.kt index 34df4c89..1660ed1c 100644 --- a/superwall/src/main/java/com/superwall/sdk/paywall/presentation/internal/operators/GetExperiment.kt +++ b/superwall/src/main/java/com/superwall/sdk/paywall/presentation/internal/operators/GetExperiment.kt @@ -10,6 +10,7 @@ import com.superwall.sdk.paywall.presentation.internal.PresentationRequestType import com.superwall.sdk.paywall.presentation.internal.state.PaywallSkippedReason import com.superwall.sdk.paywall.presentation.internal.state.PaywallState import com.superwall.sdk.paywall.presentation.rule_logic.RuleEvaluationOutcome +import com.superwall.sdk.storage.Storage import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow @@ -32,6 +33,7 @@ suspend fun Superwall.getExperiment( rulesOutcome: RuleEvaluationOutcome, debugInfo: Map, paywallStatePublisher: MutableSharedFlow? = null, + storage: Storage ): Experiment { val errorType: PresentationPipelineError @@ -41,6 +43,9 @@ suspend fun Superwall.getExperiment( } is InternalTriggerResult.Holdout -> { activateSession(request, rulesOutcome) + rulesOutcome.unsavedOccurrence?.let { + storage.coreDataManager.save(triggerRuleOccurrence = it) + } errorType = PaywallPresentationRequestStatusReason.Holdout(rulesOutcome.triggerResult.experiment) paywallStatePublisher?.emit(PaywallState.Skipped(PaywallSkippedReason.Holdout(rulesOutcome.triggerResult.experiment))) } diff --git a/superwall/src/main/java/com/superwall/sdk/paywall/presentation/internal/operators/GetPaywallVC.kt b/superwall/src/main/java/com/superwall/sdk/paywall/presentation/internal/operators/GetPaywallVC.kt index 8b1ed713..746ba7f3 100644 --- a/superwall/src/main/java/com/superwall/sdk/paywall/presentation/internal/operators/GetPaywallVC.kt +++ b/superwall/src/main/java/com/superwall/sdk/paywall/presentation/internal/operators/GetPaywallVC.kt @@ -26,16 +26,16 @@ internal suspend fun Superwall.getPaywallViewController( rulesOutcome: RuleEvaluationOutcome, debugInfo: Map, paywallStatePublisher: MutableSharedFlow? = null, - dependencyContainer: DependencyContainer? = null + dependencyContainer: DependencyContainer ): PaywallViewController { val experiment = getExperiment( request = request, rulesOutcome = rulesOutcome, debugInfo = debugInfo, - paywallStatePublisher = paywallStatePublisher + paywallStatePublisher = paywallStatePublisher, + storage = dependencyContainer.storage ) - val container = dependencyContainer ?: this.dependencyContainer val responseIdentifiers = ResponseIdentifiers( paywallId = experiment.variant.paywallId, experiment = experiment @@ -48,7 +48,7 @@ internal suspend fun Superwall.getPaywallViewController( requestRetryCount = 0 } - val paywallRequest = container.makePaywallRequest( + val paywallRequest = dependencyContainer.makePaywallRequest( eventData = request.presentationInfo.eventData, responseIdentifiers = responseIdentifiers, overrides = PaywallRequest.Overrides( @@ -64,7 +64,7 @@ internal suspend fun Superwall.getPaywallViewController( && request.flags.type != PresentationRequestType.GetPresentationResult val delegate = request.flags.type.paywallVcDelegateAdapter - container.paywallManager.getPaywallViewController( + dependencyContainer.paywallManager.getPaywallViewController( request = paywallRequest, isForPresentation = isForPresentation, isPreloading = false, From 43885d1d6db17051294c3050b4e95cd01fd91251 Mon Sep 17 00:00:00 2001 From: Bryan Dubno Date: Wed, 22 Nov 2023 11:24:28 -0800 Subject: [PATCH 05/23] Adds support for localized paywalls. --- CHANGELOG.md | 6 + .../sdk/dependencies/DependencyContainer.kt | 2 +- .../sdk/dependencies/FactoryProtocols.kt | 3 +- .../com/superwall/sdk/network/Endpoint.kt | 28 +- .../product/PriceFormatterProvider.kt | 41 +++ .../abstractions/product/RawStoreProduct.kt | 343 ++++++++++++++++++ .../abstractions/product/StoreProduct.kt | 169 +++------ .../abstractions/product/StoreProductType.kt | 16 +- .../product/SubscriptionPeriod.kt | 205 ++--------- .../store/transactions/TransactionManager.kt | 2 +- 10 files changed, 500 insertions(+), 315 deletions(-) create mode 100644 superwall/src/main/java/com/superwall/sdk/store/abstractions/product/PriceFormatterProvider.kt create mode 100644 superwall/src/main/java/com/superwall/sdk/store/abstractions/product/RawStoreProduct.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index 3bf5e0aa..6e0cc9d3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ The changelog for `Superwall`. Also see the [releases](https://github.com/superwall-me/Superwall-Android/releases) on GitHub. +## 1.0.0-alpha.28 + +### Enhancements + +- Adds support for localized paywalls. + ## 1.0.0-alpha.27 ### Enhancements 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 c1abbf5c..ce027c2d 100644 --- a/superwall/src/main/java/com/superwall/sdk/dependencies/DependencyContainer.kt +++ b/superwall/src/main/java/com/superwall/sdk/dependencies/DependencyContainer.kt @@ -78,7 +78,7 @@ class DependencyContainer( var network: Network override lateinit var api: Api - var deviceHelper: DeviceHelper + override lateinit var deviceHelper: DeviceHelper override lateinit var storage: Storage override lateinit var configManager: ConfigManager override lateinit var identityManager: IdentityManager diff --git a/superwall/src/main/java/com/superwall/sdk/dependencies/FactoryProtocols.kt b/superwall/src/main/java/com/superwall/sdk/dependencies/FactoryProtocols.kt index 3b6272df..f79c13ec 100644 --- a/superwall/src/main/java/com/superwall/sdk/dependencies/FactoryProtocols.kt +++ b/superwall/src/main/java/com/superwall/sdk/dependencies/FactoryProtocols.kt @@ -15,6 +15,7 @@ import com.superwall.sdk.models.events.EventData import com.superwall.sdk.models.paywall.Paywall import com.superwall.sdk.models.product.ProductVariable import com.superwall.sdk.network.Api +import com.superwall.sdk.network.device.DeviceHelper import com.superwall.sdk.network.device.DeviceInfo import com.superwall.sdk.paywall.manager.PaywallViewControllerCache import com.superwall.sdk.paywall.presentation.internal.PresentationRequest @@ -38,7 +39,7 @@ interface ApiFactory { var storage: Storage // var storage: Storage! { get } -// var deviceHelper: DeviceHelper! { get } + var deviceHelper: DeviceHelper var configManager: ConfigManager var identityManager: IdentityManager // swiftlint:enable implicitly_unwrapped_optional diff --git a/superwall/src/main/java/com/superwall/sdk/network/Endpoint.kt b/superwall/src/main/java/com/superwall/sdk/network/Endpoint.kt index 1c37e083..9f077395 100644 --- a/superwall/src/main/java/com/superwall/sdk/network/Endpoint.kt +++ b/superwall/src/main/java/com/superwall/sdk/network/Endpoint.kt @@ -257,37 +257,31 @@ data class Endpoint( // parameters will cause issues val queryItems = mutableListOf(URLQueryItem("pk", factory.storage.apiKey)) - // TODO: Localization - /* - // In the config endpoint we return all the locales, this code will check if: // 1. The device locale (ex: en_US) exists in the locales list // 2. The shortened device locale (ex: en) exists in the locale list // If either exist (preferring the most specific) include the locale in the // the url as a query param. factory.configManager.config?.let { config -> - when { - config.locales.contains(factory.deviceHelper.locale) -> { + if (config.locales.contains(factory.deviceHelper.locale)) { + val localeQuery = URLQueryItem( + name = "locale", + value = factory.deviceHelper.locale + ) + queryItems.add(localeQuery) + } else { + val shortLocale = factory.deviceHelper.locale.split("_")[0] + if (config.locales.contains(shortLocale)) { val localeQuery = URLQueryItem( name = "locale", - value = factory.deviceHelper.locale + value = shortLocale ) queryItems.add(localeQuery) } - else -> { - val shortLocale = factory.deviceHelper.locale.split("_")[0] - if (config.locales.contains(shortLocale)) { - val localeQuery = URLQueryItem( - name = "locale", - value = shortLocale - ) - queryItems.add(localeQuery) - } - } + return@let } } - */ val baseHost = factory.api.base.host return Endpoint( diff --git a/superwall/src/main/java/com/superwall/sdk/store/abstractions/product/PriceFormatterProvider.kt b/superwall/src/main/java/com/superwall/sdk/store/abstractions/product/PriceFormatterProvider.kt new file mode 100644 index 00000000..68936b42 --- /dev/null +++ b/superwall/src/main/java/com/superwall/sdk/store/abstractions/product/PriceFormatterProvider.kt @@ -0,0 +1,41 @@ +package com.superwall.sdk.store.abstractions.product + +import java.text.DecimalFormat +import java.text.NumberFormat +import java.util.* + +class PriceFormatterProvider { + private var cachedPriceFormatter: NumberFormat? = null + + fun priceFormatter(currencyCode: String): NumberFormat { + val currency = currency(currencyCode) + return cachedPriceFormatter?.takeIf { it.currency == currency } + ?: makePriceFormatter(currencyCode).also { + cachedPriceFormatter = it + } + } + + private fun makePriceFormatter(currencyCode: String): NumberFormat { + return DecimalFormat.getCurrencyInstance().also { + it.currencyCode = currencyCode + } + } + + private fun currency(currencyCode: String): Currency? { + return try { + Currency.getInstance(currencyCode) + } catch (e: Exception) { + null + } + } +} + +var NumberFormat.currencyCode: String? + get() = this.currency?.currencyCode + set(value) { + this.currency = try { + if (value != null) Currency.getInstance(value) else null + } catch (e: Exception) { + null + } + } diff --git a/superwall/src/main/java/com/superwall/sdk/store/abstractions/product/RawStoreProduct.kt b/superwall/src/main/java/com/superwall/sdk/store/abstractions/product/RawStoreProduct.kt new file mode 100644 index 00000000..dd333a05 --- /dev/null +++ b/superwall/src/main/java/com/superwall/sdk/store/abstractions/product/RawStoreProduct.kt @@ -0,0 +1,343 @@ +package com.superwall.sdk.store.abstractions.product +import com.android.billingclient.api.SkuDetails +import com.superwall.sdk.contrib.threeteen.AmountFormats +import kotlinx.serialization.Serializable +import kotlinx.serialization.Transient +import java.math.BigDecimal +import java.math.RoundingMode +import java.text.NumberFormat +import java.text.SimpleDateFormat +import java.util.Calendar +import java.util.Currency +import java.util.Date +import java.util.Locale + +@Serializable +class RawStoreProduct( + @Serializable(with = SkuDetailsSerializer::class) val underlyingSkuDetails: SkuDetails +) : StoreProductType { + @Transient + private val priceFormatterProvider = PriceFormatterProvider() + + private val priceFormatter: NumberFormat? + get() = currencyCode?.let { + priceFormatterProvider.priceFormatter(it) + } + + override val productIdentifier: String + get() = underlyingSkuDetails.sku + + override val price: BigDecimal + get() = underlyingSkuDetails.priceValue + + override val localizedPrice: String + get() = priceFormatter?.format(underlyingSkuDetails.priceValue) ?: "" + + override val localizedSubscriptionPeriod: String + get() = subscriptionPeriod?.let { + AmountFormats.wordBased(it.toPeriod(), Locale.getDefault()) + } ?: "" + + override val period: String + get() { + return subscriptionPeriod?.let { + return when (it.unit) { + SubscriptionPeriod.Unit.day -> if (it.value == 7) "week" else "day" + SubscriptionPeriod.Unit.week -> "week" + SubscriptionPeriod.Unit.month -> when (it.value) { + 2 -> "2 months" + 3 -> "quarter" + 6 -> "6 months" + else -> "month" + } + SubscriptionPeriod.Unit.year -> "year" + } + } ?: "" + } + + override val periodly: String + get() { + return subscriptionPeriod?.let { + return when (it.unit) { + SubscriptionPeriod.Unit.month -> when (it.value) { + 2, 6 -> "every $period" + else -> "${period}ly" + } + else -> "${period}ly" + } + } ?: "" + } + + override val periodWeeks: Int + get() = subscriptionPeriod?.let { + val numberOfUnits = it.value + when (it.unit) { + SubscriptionPeriod.Unit.day -> (1 * numberOfUnits) / 7 + SubscriptionPeriod.Unit.week -> numberOfUnits + SubscriptionPeriod.Unit.month -> 4 * numberOfUnits + SubscriptionPeriod.Unit.year -> 52 * numberOfUnits + else -> 0 + } + } ?: 0 + + override val periodWeeksString: String + get() = periodWeeks.toString() + + override val periodMonths: Int + get() = subscriptionPeriod?.let { + val numberOfUnits = it.value + when (it.unit) { + SubscriptionPeriod.Unit.day -> numberOfUnits / 30 + SubscriptionPeriod.Unit.week -> numberOfUnits / 4 + SubscriptionPeriod.Unit.month -> numberOfUnits + SubscriptionPeriod.Unit.year -> 12 * numberOfUnits + else -> 0 + } + } ?: 0 + + override val periodMonthsString: String + get() = periodMonths.toString() + + override val periodYears: Int + get() = subscriptionPeriod?.let { + val numberOfUnits = it.value + when (it.unit) { + SubscriptionPeriod.Unit.day -> numberOfUnits / 365 + SubscriptionPeriod.Unit.week -> numberOfUnits / 52 + SubscriptionPeriod.Unit.month -> numberOfUnits / 12 + SubscriptionPeriod.Unit.year -> numberOfUnits + else -> 0 + } + } ?: 0 + + override val periodYearsString: String + get() = periodYears.toString() + + override val periodDays: Int + get() { + return subscriptionPeriod?.let { + val numberOfUnits = it.value + + return when (it.unit) { + SubscriptionPeriod.Unit.day -> 1 * numberOfUnits + SubscriptionPeriod.Unit.month -> 30 * numberOfUnits // Assumes 30 days in a month + SubscriptionPeriod.Unit.week -> 7 * numberOfUnits // Assumes 7 days in a week + SubscriptionPeriod.Unit.year -> 365 * numberOfUnits // Assumes 365 days in a year + else -> 0 + } + } ?: 0 + } + + override val periodDaysString: String + get() = periodDays.toString() + + override val dailyPrice: String + get() { + if (underlyingSkuDetails.priceValue == BigDecimal.ZERO) { + return priceFormatter?.format(BigDecimal.ZERO) ?: "$0.00" + } + + val subscriptionPeriod = this.subscriptionPeriod ?: return "n/a" + + val inputPrice = underlyingSkuDetails.priceValue + val pricePerDay = subscriptionPeriod.pricePerDay(inputPrice) + + return priceFormatter?.format(pricePerDay) ?: "n/a" + } + + override val weeklyPrice: String + get() { + if (underlyingSkuDetails.priceValue == BigDecimal.ZERO) { + return priceFormatter?.format(BigDecimal.ZERO) ?: "$0.00" + } + + val subscriptionPeriod = this.subscriptionPeriod ?: return "n/a" + + val inputPrice = underlyingSkuDetails.priceValue + val pricePerWeek = subscriptionPeriod.pricePerWeek(inputPrice) + + return priceFormatter?.format(pricePerWeek) ?: "n/a" + } + + override val monthlyPrice: String + get() { + if (underlyingSkuDetails.priceValue == BigDecimal.ZERO) { + return priceFormatter?.format(BigDecimal.ZERO) ?: "$0.00" + } + + val subscriptionPeriod = this.subscriptionPeriod ?: return "n/a" + + val inputPrice = underlyingSkuDetails.priceValue + val pricePerMonth = subscriptionPeriod.pricePerMonth(inputPrice) + + return priceFormatter?.format(pricePerMonth) ?: "n/a" + } + + override val yearlyPrice: String + get() { + if (underlyingSkuDetails.priceValue == BigDecimal.ZERO) { + return priceFormatter?.format(BigDecimal.ZERO) ?: "$0.00" + } + + val subscriptionPeriod = this.subscriptionPeriod ?: return "n/a" + + val inputPrice = underlyingSkuDetails.priceValue + val pricePerYear = subscriptionPeriod.pricePerYear(inputPrice) + + return priceFormatter?.format(pricePerYear) ?: "n/a" + } + + override val hasFreeTrial: Boolean + get() = underlyingSkuDetails.freeTrialPeriod.isNotEmpty() + + override val localizedTrialPeriodPrice: String + get() = priceFormatter?.format(trialPeriodPrice) ?: "$0.00" + + override val trialPeriodPrice: BigDecimal + get() = underlyingSkuDetails.introductoryPriceValue + + override val trialPeriodEndDate: Date? + get() = trialSubscriptionPeriod?.let { + val calendar = Calendar.getInstance() + when (it.unit) { + SubscriptionPeriod.Unit.day -> calendar.add(Calendar.DAY_OF_YEAR, it.value) + SubscriptionPeriod.Unit.week -> calendar.add(Calendar.WEEK_OF_YEAR, it.value) + SubscriptionPeriod.Unit.month -> calendar.add(Calendar.MONTH, it.value) + SubscriptionPeriod.Unit.year -> calendar.add(Calendar.YEAR, it.value) + } + calendar.time + } + + override val trialPeriodEndDateString: String + get() = trialPeriodEndDate?.let { + val dateFormatter = SimpleDateFormat("MMM dd, yyyy", Locale.getDefault()) + dateFormatter.format(it) + } ?: "" + + override val trialPeriodDays: Int + get() { + return trialSubscriptionPeriod?.let { + val numberOfUnits = it.value + + return when (it.unit) { + SubscriptionPeriod.Unit.day -> 1 * numberOfUnits + SubscriptionPeriod.Unit.month -> 30 * numberOfUnits // Assumes 30 days in a month + SubscriptionPeriod.Unit.week -> 7 * numberOfUnits // Assumes 7 days in a week + SubscriptionPeriod.Unit.year -> 365 * numberOfUnits // Assumes 365 days in a year + else -> 0 + } + } ?: 0 + } + + override val trialPeriodDaysString: String + get() = trialPeriodDays.toString() + + override val trialPeriodWeeks: Int + get() { + val trialPeriod = trialSubscriptionPeriod ?: return 0 + val numberOfUnits = trialPeriod.value + + return when (trialPeriod.unit) { + SubscriptionPeriod.Unit.day -> numberOfUnits / 7 + SubscriptionPeriod.Unit.month -> 4 * numberOfUnits // Assumes 4 weeks in a month + SubscriptionPeriod.Unit.week -> 1 * numberOfUnits + SubscriptionPeriod.Unit.year -> 52 * numberOfUnits // Assumes 52 weeks in a year + else -> 0 + } + } + + override val trialPeriodWeeksString: String + get() = trialPeriodWeeks.toString() + + override val trialPeriodMonths: Int + get() = trialSubscriptionPeriod?.let { + val numberOfUnits = it.value + when (it.unit) { + SubscriptionPeriod.Unit.day -> numberOfUnits / 30 + SubscriptionPeriod.Unit.week -> numberOfUnits / 4 + SubscriptionPeriod.Unit.month -> numberOfUnits + SubscriptionPeriod.Unit.year -> 12 * numberOfUnits + else -> 0 + } + } ?: 0 + + override val trialPeriodMonthsString: String + get() = trialPeriodMonths.toString() + + override val trialPeriodYears: Int + get() = trialSubscriptionPeriod?.let { + val numberOfUnits = it.value + when (it.unit) { + SubscriptionPeriod.Unit.day -> numberOfUnits / 365 + SubscriptionPeriod.Unit.week -> numberOfUnits / 52 + SubscriptionPeriod.Unit.month -> numberOfUnits / 12 + SubscriptionPeriod.Unit.year -> numberOfUnits + else -> 0 + } + } ?: 0 + + override val trialPeriodYearsString: String + get() = trialPeriodYears.toString() + + override val trialPeriodText: String + get() { + val trialPeriod = trialSubscriptionPeriod ?: return "" + val units = trialPeriod.value + + return when (trialPeriod.unit) { + SubscriptionPeriod.Unit.day -> "${units}-day" + SubscriptionPeriod.Unit.month -> "${units * 30}-day" + SubscriptionPeriod.Unit.week -> "${units * 7}-day" + SubscriptionPeriod.Unit.year -> "${units * 365}-day" + else -> "" + } + } + + // TODO: Differs from iOS, using device locale here instead of product locale + override val locale: String + get() = Locale.getDefault().toString() + + // TODO: Differs from iOS, using device language code here instead of product language code + override val languageCode: String? + get() = Locale.getDefault().language + + override val currencyCode: String? + get() = underlyingSkuDetails.priceCurrencyCode + + override val currencySymbol: String? + get() = Currency.getInstance(underlyingSkuDetails.priceCurrencyCode).symbol + + override val regionCode: String? + get() = Locale.getDefault().country + + override val subscriptionPeriod: SubscriptionPeriod? + get() { + return try { + SubscriptionPeriod.from(underlyingSkuDetails.subscriptionPeriod) + } catch (e: Exception) { + null + } + } + + override fun trialPeriodPricePerUnit(unit: SubscriptionPeriod.Unit): String { + // TODO: ONLY supporting free trial and similar; see `StoreProductDiscount` + // pricePerUnit for other cases + val introductoryDiscount = underlyingSkuDetails.introductoryPriceValue + return priceFormatter?.format(introductoryDiscount) ?: "$0.00" + } +} + +val SkuDetails.priceValue: BigDecimal + get() = BigDecimal(priceAmountMicros).divide(BigDecimal(1_000_000), 6, RoundingMode.DOWN) + +val SkuDetails.introductoryPriceValue: BigDecimal + get() = BigDecimal(introductoryPriceAmountMicros).divide(BigDecimal(1_000_000), 6, RoundingMode.DOWN) + +val RawStoreProduct.trialSubscriptionPeriod: SubscriptionPeriod? + get() { + return try { + SubscriptionPeriod.from(underlyingSkuDetails.freeTrialPeriod) + } catch (e: Exception) { + null + } + } \ No newline at end of file diff --git a/superwall/src/main/java/com/superwall/sdk/store/abstractions/product/StoreProduct.kt b/superwall/src/main/java/com/superwall/sdk/store/abstractions/product/StoreProduct.kt index c81d1c7a..506b6e41 100644 --- a/superwall/src/main/java/com/superwall/sdk/store/abstractions/product/StoreProduct.kt +++ b/superwall/src/main/java/com/superwall/sdk/store/abstractions/product/StoreProduct.kt @@ -10,7 +10,6 @@ import kotlinx.serialization.encoding.Decoder import kotlinx.serialization.encoding.Encoder import java.math.BigDecimal import java.math.RoundingMode -import java.text.DecimalFormat import java.text.SimpleDateFormat import java.util.* @@ -26,191 +25,128 @@ object SkuDetailsSerializer : KSerializer { } } -@Serializable -data class RawStoreProduct(@Serializable(with = SkuDetailsSerializer::class) val skuDetails: SkuDetails) - -// TODO: Fill in all these with appropirate implementations - @Serializable class StoreProduct( val rawStoreProduct: RawStoreProduct ) : StoreProductType { override val productIdentifier: String - get() = rawStoreProduct.skuDetails.sku + get() = rawStoreProduct.productIdentifier override val price: BigDecimal - get() = BigDecimal(rawStoreProduct.skuDetails.priceAmountMicros).divide(BigDecimal(1_000_000), 6, RoundingMode.DOWN) - - val trialPrice: BigDecimal - get() = BigDecimal(rawStoreProduct.skuDetails.introductoryPriceAmountMicros).divide(BigDecimal(1_000_000), 6, RoundingMode.DOWN) - - override val subscriptionGroupIdentifier: String? - get() = "" -// get() = TODO("This information is not available in SkuDetails") - - override val subscriptionPeriod: SubscriptionPeriod? - get() { - return try { - SubscriptionPeriod.from( - rawStoreProduct.skuDetails.subscriptionPeriod, - Currency.getInstance(rawStoreProduct.skuDetails.priceCurrencyCode) - ) - } catch (e: Exception) { - null - } - } - - /** - * The trial subscription period of the product. - */ - val trialSubscriptionPeriod: SubscriptionPeriod? - get() { - return try { - SubscriptionPeriod.from( - rawStoreProduct.skuDetails.freeTrialPeriod, - Currency.getInstance(rawStoreProduct.skuDetails.priceCurrencyCode) - ) - } catch (e: Exception) { - null - } - } + get() = rawStoreProduct.price override val localizedPrice: String - get() = rawStoreProduct.skuDetails.price + get() = rawStoreProduct.localizedPrice override val localizedSubscriptionPeriod: String - get() = if (subscriptionPeriod != null) AmountFormats.wordBased( - subscriptionPeriod?.toPeriod()!!, Locale.getDefault() - ) else "" + get() = rawStoreProduct.localizedSubscriptionPeriod override val period: String - get() = subscriptionPeriod?.period ?: "" + get() = rawStoreProduct.period override val periodly: String - get() = subscriptionPeriod?.periodly ?: "" + get() = rawStoreProduct.periodly override val periodWeeks: Int - get() = subscriptionPeriod?.periodWeeks ?: 0 + get() = rawStoreProduct.periodWeeks override val periodWeeksString: String - get() = periodWeeks.toString() + get() = rawStoreProduct.periodWeeksString override val periodMonths: Int - get() = subscriptionPeriod?.periodMonths ?: 0 + get() = rawStoreProduct.periodMonths override val periodMonthsString: String - get() = periodMonths.toString() + get() = rawStoreProduct.periodMonthsString override val periodYears: Int - get() = subscriptionPeriod?.periodYears ?: 0 + get() = rawStoreProduct.periodYears override val periodYearsString: String - get() = periodYears.toString() + get() = rawStoreProduct.periodYearsString override val periodDays: Int - get() = subscriptionPeriod?.periodDays ?: 0 + get() = rawStoreProduct.periodDays override val periodDaysString: String - get() = periodDays.toString() + get() = rawStoreProduct.periodDaysString override val dailyPrice: String - get() = subscriptionPeriod?.dailyPrice(price) ?: "n/a" + get() = rawStoreProduct.dailyPrice override val weeklyPrice: String - get() = subscriptionPeriod?.weeklyPrice(price) ?: "n/a" + get() = rawStoreProduct.weeklyPrice override val monthlyPrice: String - get() = subscriptionPeriod?.monthlyPrice(price) ?: "n/a" + get() = rawStoreProduct.monthlyPrice override val yearlyPrice: String - get() = subscriptionPeriod?.yearlyPrice(price) ?: "n/a" - + get() = rawStoreProduct.yearlyPrice override val hasFreeTrial: Boolean - get() = rawStoreProduct.skuDetails.freeTrialPeriod.isNotEmpty() + get() = rawStoreProduct.hasFreeTrial + + override val localizedTrialPeriodPrice: String + get() = rawStoreProduct.localizedTrialPeriodPrice + + override val trialPeriodPrice: BigDecimal + get() = rawStoreProduct.trialPeriodPrice override val trialPeriodEndDate: Date? - get() = if (trialSubscriptionPeriod != null) { - val calendar = Calendar.getInstance() - when (trialSubscriptionPeriod!!.unit) { - SubscriptionPeriod.Unit.day -> calendar.add(Calendar.DAY_OF_YEAR, trialSubscriptionPeriod!!.value) - SubscriptionPeriod.Unit.week -> calendar.add(Calendar.WEEK_OF_YEAR, trialSubscriptionPeriod!!.value) - SubscriptionPeriod.Unit.month -> calendar.add(Calendar.MONTH, trialSubscriptionPeriod!!.value) - SubscriptionPeriod.Unit.year -> calendar.add(Calendar.YEAR, trialSubscriptionPeriod!!.value) - } - calendar.time - } else { - null - } + get() = rawStoreProduct.trialPeriodEndDate override val trialPeriodEndDateString: String - get() = if (trialPeriodEndDate != null) { - val dateFormatter = SimpleDateFormat("MMM dd, yyyy", Locale.getDefault()) - dateFormatter.format(trialPeriodEndDate!!) - } else { - "" - } + get() = rawStoreProduct.trialPeriodEndDateString override val trialPeriodDays: Int - get() = - trialSubscriptionPeriod?.periodDays ?: 0 + get() = rawStoreProduct.trialPeriodDays override val trialPeriodDaysString: String - get() = trialPeriodDays.toString() + get() = rawStoreProduct.trialPeriodDaysString override val trialPeriodWeeks: Int - get() { - val trialPeriod = trialSubscriptionPeriod ?: return 0 - val numberOfUnits = trialPeriod.value - - return when (trialPeriod.unit) { - SubscriptionPeriod.Unit.day -> numberOfUnits / 7 - SubscriptionPeriod.Unit.month -> 4 * numberOfUnits // Assumes 4 weeks in a month - SubscriptionPeriod.Unit.week -> 1 * numberOfUnits - SubscriptionPeriod.Unit.year -> 52 * numberOfUnits // Assumes 52 weeks in a year - else -> 0 - } - } + get() = rawStoreProduct.trialPeriodWeeks override val trialPeriodWeeksString: String - get() = trialPeriodWeeks.toString() + get() = rawStoreProduct.trialPeriodWeeksString override val trialPeriodMonths: Int - get() = - trialSubscriptionPeriod?.periodMonths ?: 0 + get() = rawStoreProduct.trialPeriodMonths override val trialPeriodMonthsString: String - get() = trialPeriodMonths.toString() + get() = rawStoreProduct.trialPeriodMonthsString override val trialPeriodYears: Int - get() = - trialSubscriptionPeriod?.periodYears ?: 0 + get() = rawStoreProduct.trialPeriodYears override val trialPeriodYearsString: String - get() = trialPeriodYears.toString() + get() = rawStoreProduct.trialPeriodYearsString override val trialPeriodText: String - get() = trialSubscriptionPeriod?.period ?: "" - - override val localizedTrialPeriodPrice: String - get() = rawStoreProduct.skuDetails.introductoryPrice + get() = rawStoreProduct.trialPeriodText override val locale: String - get() = Locale.getDefault().toString() + get() = rawStoreProduct.locale override val languageCode: String? - get() = Locale.getDefault().language + get() = rawStoreProduct.languageCode override val currencyCode: String? - get() = rawStoreProduct.skuDetails.priceCurrencyCode + get() = rawStoreProduct.currencyCode override val currencySymbol: String? - get() = Currency.getInstance(rawStoreProduct.skuDetails.priceCurrencyCode).symbol + get() = rawStoreProduct.currencySymbol override val regionCode: String? - get() = Locale.getDefault().country + get() = rawStoreProduct.regionCode + + override val subscriptionPeriod: SubscriptionPeriod? + get() = rawStoreProduct.subscriptionPeriod + override fun trialPeriodPricePerUnit(unit: SubscriptionPeriod.Unit): String { + return rawStoreProduct.trialPeriodPricePerUnit(unit) + } val attributes: Map get() { @@ -226,12 +162,12 @@ class StoreProduct( attributes["dailyPrice"] = dailyPrice attributes["monthlyPrice"] = monthlyPrice attributes["yearlyPrice"] = yearlyPrice - attributes["rawTrialPeriodPrice"] = trialPrice.toString() + attributes["rawTrialPeriodPrice"] = trialPeriodPrice.toString() attributes["trialPeriodPrice"] = localizedTrialPeriodPrice - attributes["trialPeriodDailyPrice"] = trialSubscriptionPeriod?.dailyPrice(trialPrice) ?: "n/a" - attributes["trialPeriodWeeklyPrice"] = trialSubscriptionPeriod?.weeklyPrice(trialPrice) ?: "n/a" - attributes["trialPeriodMonthlyPrice"] = trialSubscriptionPeriod?.monthlyPrice(trialPrice) ?: "n/a" - attributes["trialPeriodYearlyPrice"] = trialSubscriptionPeriod?.yearlyPrice(trialPrice) ?: "n/a" + attributes["trialPeriodDailyPrice"] = trialPeriodPricePerUnit(SubscriptionPeriod.Unit.day) + attributes["trialPeriodWeeklyPrice"] = trialPeriodPricePerUnit(SubscriptionPeriod.Unit.week) + attributes["trialPeriodMonthlyPrice"] = trialPeriodPricePerUnit(SubscriptionPeriod.Unit.month) + attributes["trialPeriodYearlyPrice"] = trialPeriodPricePerUnit(SubscriptionPeriod.Unit.year) attributes["trialPeriodDays"] = trialPeriodDaysString attributes["trialPeriodWeeks"] = trialPeriodWeeksString attributes["trialPeriodMonths"] = trialPeriodMonthsString @@ -250,5 +186,4 @@ class StoreProduct( return attributes } - } \ No newline at end of file diff --git a/superwall/src/main/java/com/superwall/sdk/store/abstractions/product/StoreProductType.kt b/superwall/src/main/java/com/superwall/sdk/store/abstractions/product/StoreProductType.kt index cb4ee48e..2b7e046d 100644 --- a/superwall/src/main/java/com/superwall/sdk/store/abstractions/product/StoreProductType.kt +++ b/superwall/src/main/java/com/superwall/sdk/store/abstractions/product/StoreProductType.kt @@ -3,16 +3,9 @@ package com.superwall.sdk.store.abstractions.product import java.math.BigDecimal import java.util.* -// Define a typealias for List as there is no exact equivalent for Array in Kotlin -//typealias StoreProductDiscountArray = List - interface StoreProductType { val productIdentifier: String val price: BigDecimal - val subscriptionGroupIdentifier: String? - - // val swProductTemplateVariablesJson: JsonObject -// val swProduct: SWProduct val localizedPrice: String val localizedSubscriptionPeriod: String val period: String @@ -31,6 +24,7 @@ interface StoreProductType { val yearlyPrice: String val hasFreeTrial: Boolean val localizedTrialPeriodPrice: String + val trialPeriodPrice: BigDecimal val trialPeriodEndDate: Date? val trialPeriodEndDateString: String val trialPeriodDays: Int @@ -47,11 +41,7 @@ interface StoreProductType { val currencyCode: String? val currencySymbol: String? val regionCode: String? - // Not relevant for Android -// val isFamilyShareable: Boolean - /// The period details for products that are subscriptions. - /// - Returns: `nil` if the product is not a subscription. val subscriptionPeriod: SubscriptionPeriod? -// val introductoryDiscount: StoreProductDiscount? -// val discounts: StoreProductDiscountArray + + fun trialPeriodPricePerUnit(unit: SubscriptionPeriod.Unit): String } diff --git a/superwall/src/main/java/com/superwall/sdk/store/abstractions/product/SubscriptionPeriod.kt b/superwall/src/main/java/com/superwall/sdk/store/abstractions/product/SubscriptionPeriod.kt index 438a11b9..8e2460d4 100644 --- a/superwall/src/main/java/com/superwall/sdk/store/abstractions/product/SubscriptionPeriod.kt +++ b/superwall/src/main/java/com/superwall/sdk/store/abstractions/product/SubscriptionPeriod.kt @@ -7,7 +7,7 @@ import java.time.Period import java.time.temporal.ChronoUnit import java.util.* -data class SubscriptionPeriod(val value: Int, val unit: Unit, val currency: Currency, val pricingFactor: Long = 1_000_000) { +data class SubscriptionPeriod(val value: Int, val unit: Unit) { enum class Unit { day, week, @@ -41,7 +41,7 @@ data class SubscriptionPeriod(val value: Int, val unit: Unit, val currency: Curr } companion object { - fun from(subscriptionPeriodString: String, currency: Currency): SubscriptionPeriod? { + fun from(subscriptionPeriodString: String): SubscriptionPeriod? { val period = try { Period.parse(subscriptionPeriodString) } catch (e: Exception) { @@ -53,187 +53,62 @@ data class SubscriptionPeriod(val value: Int, val unit: Unit, val currency: Curr val days = (totalDays % 7).toInt() return when { - period.years > 0 -> SubscriptionPeriod(period.years, Unit.year, currency) + period.years > 0 -> SubscriptionPeriod(period.years, Unit.year) period.toTotalMonths() > 0 -> SubscriptionPeriod( period.toTotalMonths().toInt(), - Unit.month, - currency + Unit.month ) - weeks > 0 -> SubscriptionPeriod(weeks, Unit.week, currency) - days > 0 -> SubscriptionPeriod(days, Unit.day, currency) + weeks > 0 -> SubscriptionPeriod(weeks, Unit.week) + days > 0 -> SubscriptionPeriod(days, Unit.day) else -> null }?.normalized() } } - val period: String - get() { - return when (unit) { - Unit.day -> if (value == 7) "week" else "day" - Unit.week -> "week" - Unit.month -> when (value) { - 2 -> "2 months" - 3 -> "quarter" - 6 -> "6 months" - else -> "month" - } - Unit.year -> "year" - } - } - - val periodly: String - get() { - val subscriptionPeriod = this - return when (subscriptionPeriod.unit) { - Unit.month -> when (subscriptionPeriod.value) { - 2, 6 -> "every $period" - else -> "${period}ly" - } - else -> "${period}ly" - } - } - - val periodDays: Int - get() { - val subscriptionPeriod = this - val numberOfUnits = subscriptionPeriod.value - - return when (subscriptionPeriod.unit) { - SubscriptionPeriod.Unit.day -> 1 * numberOfUnits - SubscriptionPeriod.Unit.month -> 30 * numberOfUnits // Assumes 30 days in a month - SubscriptionPeriod.Unit.week -> 7 * numberOfUnits // Assumes 7 days in a week - SubscriptionPeriod.Unit.year -> 365 * numberOfUnits // Assumes 365 days in a year - else -> 0 - } - } - - val periodWeeks: Int - get() { - val subscriptionPeriod = this - val numberOfUnits = subscriptionPeriod.value - - return when (subscriptionPeriod.unit) { - Unit.day -> (1 * numberOfUnits) / 7 - Unit.week -> numberOfUnits - Unit.month -> 4 * numberOfUnits - Unit.year -> 52 * numberOfUnits - } - } - - val periodMonths: Int - get() { - val subscriptionPeriod = this - val numberOfUnits = subscriptionPeriod.value - - return when (subscriptionPeriod.unit) { - SubscriptionPeriod.Unit.day -> numberOfUnits / 30 // Assumes 30 days in a month - SubscriptionPeriod.Unit.month -> numberOfUnits - SubscriptionPeriod.Unit.week -> numberOfUnits / 4 // Assumes 4 weeks in a month - SubscriptionPeriod.Unit.year -> numberOfUnits * 12 // Assumes 12 months in a year - else -> 0 - } - } - - val periodYears: Int - get() { - val subscriptionPeriod = this - val numberOfUnits = subscriptionPeriod.value - - return when (subscriptionPeriod.unit) { - Unit.day -> numberOfUnits / 365 - Unit.week -> numberOfUnits / 52 - Unit.month -> numberOfUnits / 12 - Unit.year -> numberOfUnits - } - } - - fun dailyPrice(rawPrice: BigDecimal): String { - if (rawPrice == BigDecimal.ZERO) { - return "$0.00" - } - - val numberFormatter = DecimalFormat.getCurrencyInstance() - numberFormatter.currency = currency - return numberFormatter.format(pricePerDay(rawPrice)) - } - - fun weeklyPrice(price: BigDecimal): String { - if (price == BigDecimal.ZERO) { - return "$0.00" - } - - val numberFormatter = DecimalFormat.getCurrencyInstance() - numberFormatter.currency = currency - return numberFormatter.format(pricePerWeek(price)) - } - - fun monthlyPrice(price: BigDecimal): String { - if (price == BigDecimal.ZERO) { - return "$0.00" - } - - val numberFormatter = DecimalFormat.getCurrencyInstance() - numberFormatter.currency = currency - return numberFormatter.format(pricePerMonth(price)) - } - - fun yearlyPrice(price: BigDecimal): String { - if (price == BigDecimal.ZERO) { - return "$0.00" - } - - val numberFormatter = DecimalFormat.getCurrencyInstance() - numberFormatter.currency = currency - return numberFormatter.format(pricePerYear(price)) - } - + private val roundingMode = RoundingMode.DOWN + private val scale = 2 fun pricePerDay(price: BigDecimal): BigDecimal { - when (unit) { - Unit.day -> return _truncateDecimal(price.divide(BigDecimal(value))) - Unit.week -> return _truncateDecimal(price.divide(BigDecimal(value), RoundingMode.HALF_EVEN).divide(BigDecimal( 7), RoundingMode.HALF_EVEN)) - Unit.month -> return _truncateDecimal(price.divide(BigDecimal(value), RoundingMode.HALF_EVEN).divide(BigDecimal( 30), RoundingMode.HALF_EVEN)) - Unit.year -> return _truncateDecimal(price.divide(BigDecimal(value), RoundingMode.HALF_EVEN).divide( BigDecimal( 365), RoundingMode.HALF_EVEN)) - } + val periodsPerDay: BigDecimal = when (this.unit) { + SubscriptionPeriod.Unit.day -> BigDecimal.ONE + SubscriptionPeriod.Unit.week -> BigDecimal(7) + SubscriptionPeriod.Unit.month -> BigDecimal(30) + SubscriptionPeriod.Unit.year -> BigDecimal(365) + } * BigDecimal(this.value) + + return price.divide(periodsPerDay, scale, roundingMode) } fun pricePerWeek(price: BigDecimal): BigDecimal { - when (unit) { - Unit.day -> return _truncateDecimal(price.divide(BigDecimal(value), RoundingMode.HALF_EVEN).multiply(BigDecimal( 7))) - Unit.week -> return _truncateDecimal(price.divide(BigDecimal(value))) - Unit.month -> return _truncateDecimal(price.divide(BigDecimal(value * 4), RoundingMode.DOWN)) - Unit.year -> return _truncateDecimal(price.divide(BigDecimal(value * 52), RoundingMode.DOWN)) - } + val periodsPerWeek: BigDecimal = when (this.unit) { + SubscriptionPeriod.Unit.day -> BigDecimal.ONE.divide(BigDecimal(7), scale, roundingMode) + SubscriptionPeriod.Unit.week -> BigDecimal.ONE + SubscriptionPeriod.Unit.month -> BigDecimal(4) + SubscriptionPeriod.Unit.year -> BigDecimal(52) + } * BigDecimal(this.value) + + return price.divide(periodsPerWeek, scale, roundingMode) } fun pricePerMonth(price: BigDecimal): BigDecimal { - when (unit) { - Unit.day -> return _truncateDecimal(price.divide(BigDecimal(value), RoundingMode.HALF_EVEN).multiply(BigDecimal( 30))) - Unit.week -> return _truncateDecimal(price.divide(BigDecimal(value), RoundingMode.HALF_EVEN).multiply(BigDecimal( 30.0 / 7.0))) - Unit.month -> return _truncateDecimal(price.divide(BigDecimal(value))) - Unit.year -> return _truncateDecimal(price.divide(BigDecimal(value), RoundingMode.HALF_EVEN).divide(BigDecimal(12), RoundingMode.HALF_EVEN)) - } + val periodsPerMonth: BigDecimal = when (this.unit) { + SubscriptionPeriod.Unit.day -> BigDecimal.ONE.divide(BigDecimal(30), scale, roundingMode) + SubscriptionPeriod.Unit.week -> BigDecimal.ONE.divide(BigDecimal(30.0 / 7.0), scale, roundingMode) + SubscriptionPeriod.Unit.month -> BigDecimal.ONE + SubscriptionPeriod.Unit.year -> BigDecimal(12) + } * BigDecimal(this.value) + + return price.divide(periodsPerMonth, scale, roundingMode) } fun pricePerYear(price: BigDecimal): BigDecimal { - when (unit) { - Unit.day -> return _truncateDecimal(price.divide(BigDecimal(value), RoundingMode.HALF_EVEN).multiply(BigDecimal( 365))) - Unit.week -> return _truncateDecimal(price.divide(BigDecimal(value), RoundingMode.HALF_EVEN).multiply(BigDecimal( 365.0 / 7))) - Unit.month -> return _truncateDecimal(price.divide(BigDecimal(value), RoundingMode.HALF_EVEN).multiply(BigDecimal(12))) - Unit.year -> return _truncateDecimal(price.divide(BigDecimal(value), RoundingMode.HALF_EVEN)) - } + val periodsPerYear: BigDecimal = when (this.unit) { + SubscriptionPeriod.Unit.day -> BigDecimal.ONE.divide(BigDecimal(365), scale, roundingMode) + SubscriptionPeriod.Unit.week -> BigDecimal.ONE.divide(BigDecimal(365.0 / 7), scale, roundingMode) + SubscriptionPeriod.Unit.month -> BigDecimal.ONE.divide(BigDecimal(12), scale, roundingMode) + SubscriptionPeriod.Unit.year -> BigDecimal.ONE + }.multiply(BigDecimal(this.value)) + + return price.divide(periodsPerYear, scale, roundingMode) } - - fun _truncateDecimal(decimal: BigDecimal, places: Int = currency.defaultFractionDigits ?: 2): BigDecimal { - // First we need to divide by the main google product scaling factor - - - val factor = BigDecimal.TEN.pow(places) // Create a factor of 10^decimalPlaces - val result: BigDecimal = - decimal.multiply(factor) // Multiply the original number by the factor - .setScale(0, BigDecimal.ROUND_DOWN) // Set scale to 0 and ROUND_DOWN to truncate - .divide(factor) // Divide back by the factor to get the truncated number - return result - } - } diff --git a/superwall/src/main/java/com/superwall/sdk/store/transactions/TransactionManager.kt b/superwall/src/main/java/com/superwall/sdk/store/transactions/TransactionManager.kt index 6fa2b5d9..1a07d45d 100644 --- a/superwall/src/main/java/com/superwall/sdk/store/transactions/TransactionManager.kt +++ b/superwall/src/main/java/com/superwall/sdk/store/transactions/TransactionManager.kt @@ -45,7 +45,7 @@ class TransactionManager( val result = storeKitManager.purchaseController.purchase( activity, - product.rawStoreProduct.skuDetails + product.rawStoreProduct.underlyingSkuDetails ) when (result) { From 126fc7ea2c470fb4d5eaaa802dd1b45375410f26 Mon Sep 17 00:00:00 2001 From: Bryan Dubno Date: Mon, 27 Nov 2023 15:58:21 -0800 Subject: [PATCH 06/23] #SW-2600: Backport device attributes --- CHANGELOG.md | 6 ++++++ .../config/ConfigManagerInstrumentedTest.kt | 18 ++++++++++------- .../trackable/TrackableSuperwallEvent.kt | 13 ++++++++---- .../analytics/session/AppSessionManager.kt | 20 +++++++++---------- .../sdk/analytics/superwall/SuperwallEvent.kt | 6 ++++++ .../sdk/dependencies/DependencyContainer.kt | 15 +++++++++++++- .../sdk/dependencies/FactoryProtocols.kt | 1 + .../java/com/superwall/sdk/network/Network.kt | 2 +- .../sdk/network/device/DeviceHelper.kt | 2 +- 9 files changed, 59 insertions(+), 24 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6e0cc9d3..1a0f0bbf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ The changelog for `Superwall`. Also see the [releases](https://github.com/superwall-me/Superwall-Android/releases) on GitHub. +## 1.0.0-alpha.29 + +### Enhancements + +- #SW-2600: Backport device attributes + ## 1.0.0-alpha.28 ### Enhancements diff --git a/superwall/src/androidTest/java/com/superwall/sdk/config/ConfigManagerInstrumentedTest.kt b/superwall/src/androidTest/java/com/superwall/sdk/config/ConfigManagerInstrumentedTest.kt index 0093668c..8e0e82cd 100644 --- a/superwall/src/androidTest/java/com/superwall/sdk/config/ConfigManagerInstrumentedTest.kt +++ b/superwall/src/androidTest/java/com/superwall/sdk/config/ConfigManagerInstrumentedTest.kt @@ -1,5 +1,6 @@ package com.superwall.sdk.config +import android.content.Context import androidx.test.platform.app.InstrumentationRegistry import com.superwall.sdk.config.models.ConfigState import com.superwall.sdk.dependencies.DependencyContainer @@ -25,11 +26,13 @@ import org.junit.Test class ConfigManagerUnderTest( + private val context: Context, private val storage: Storage, private val network: Network, private val paywallManager: PaywallManager, private val factory: Factory, ) : ConfigManager( + context = context, storage = storage, network = network, paywallManager = paywallManager, @@ -56,10 +59,11 @@ class ConfigManagerTests { paywallId = "jkl" ) val assignment = ConfirmableAssignment(experimentId = experimentId, variant = variant) - val dependencyContainer = DependencyContainer(context, null, null) + val dependencyContainer = DependencyContainer(context, null, null, activityProvider = null) val network = NetworkMock(factory = dependencyContainer) val storage = StorageMock(context = context) val configManager = ConfigManager( + context = context, options = null, // storeKitManager = dependencyContainer.storeKitManager, storage = storage, @@ -82,12 +86,12 @@ class ConfigManagerTests { // get context val context = InstrumentationRegistry.getInstrumentation().targetContext - val dependencyContainer = DependencyContainer(context, null, null) + val dependencyContainer = DependencyContainer(context, null, null, activityProvider = null) val network = NetworkMock(factory = dependencyContainer) val storage = StorageMock(context = context) val configManager = ConfigManager( + context = context, options = null, -// storeKitManager = dependencyContainer.storeKitManager, storage = storage, network = network, paywallManager = dependencyContainer.paywallManager, @@ -115,11 +119,11 @@ class ConfigManagerTests { // get context val context = InstrumentationRegistry.getInstrumentation().targetContext - val dependencyContainer = DependencyContainer(context, null, null) + val dependencyContainer = DependencyContainer(context, null, null, activityProvider = null) val network = NetworkMock(factory = dependencyContainer) val storage = StorageMock(context = context) val configManager = ConfigManagerUnderTest( -// storeKitManager = dependencyContainer.storeKitManager, + context = context, storage = storage, network = network, paywallManager = dependencyContainer.paywallManager, @@ -141,11 +145,11 @@ class ConfigManagerTests { // get context val context = InstrumentationRegistry.getInstrumentation().targetContext - val dependencyContainer = DependencyContainer(context, null, null) + val dependencyContainer = DependencyContainer(context, null, null, activityProvider = null) val network = NetworkMock(factory = dependencyContainer) val storage = StorageMock(context = context) val configManager = ConfigManagerUnderTest( -// storeKitManager = dependencyContainer.storeKitManager, + context = context, storage = storage, network = network, paywallManager = dependencyContainer.paywallManager, 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 22e20df7..45727bdc 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 @@ -10,10 +10,8 @@ import com.superwall.sdk.delegate.SubscriptionStatus import com.superwall.sdk.dependencies.ComputedPropertyRequestsFactory import com.superwall.sdk.dependencies.FeatureFlagsFactory import com.superwall.sdk.dependencies.RuleAttributesFactory -import com.superwall.sdk.models.config.FeatureFlags import com.superwall.sdk.models.events.EventData import com.superwall.sdk.models.triggers.InternalTriggerResult -import com.superwall.sdk.models.triggers.TriggerResult import com.superwall.sdk.paywall.presentation.PaywallInfo import com.superwall.sdk.paywall.presentation.internal.PaywallPresentationRequestStatus import com.superwall.sdk.paywall.presentation.internal.PaywallPresentationRequestStatusReason @@ -23,8 +21,6 @@ import com.superwall.sdk.store.abstractions.product.StoreProduct import com.superwall.sdk.store.abstractions.transactions.StoreTransaction import com.superwall.sdk.store.abstractions.transactions.StoreTransactionType import com.superwall.sdk.store.transactions.TransactionError -import kotlinx.serialization.json.* -import java.net.URL interface TrackableSuperwallEvent : Trackable { @@ -223,6 +219,15 @@ sealed class InternalSuperwallEvent(override val superwallEvent: SuperwallEvent) } } + class DeviceAttributes( + val deviceAttributes: HashMap, + override var customParameters: HashMap = HashMap() + ) : InternalSuperwallEvent(SuperwallEvent.DeviceAttributes(attributes = deviceAttributes)) { + override suspend fun getSuperwallParameters(): HashMap { + return deviceAttributes + } + } + class TriggerFire( val triggerResult: InternalTriggerResult, val triggerName: String, diff --git a/superwall/src/main/java/com/superwall/sdk/analytics/session/AppSessionManager.kt b/superwall/src/main/java/com/superwall/sdk/analytics/session/AppSessionManager.kt index 7d24cc01..9f7bdf19 100644 --- a/superwall/src/main/java/com/superwall/sdk/analytics/session/AppSessionManager.kt +++ b/superwall/src/main/java/com/superwall/sdk/analytics/session/AppSessionManager.kt @@ -1,22 +1,17 @@ package com.superwall.sdk.analytics.session -import android.content.BroadcastReceiver import android.content.Context -import android.content.Intent -import android.content.IntentFilter import androidx.lifecycle.DefaultLifecycleObserver -import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleOwner import com.superwall.sdk.Superwall import com.superwall.sdk.analytics.internal.track import com.superwall.sdk.analytics.internal.trackable.InternalSuperwallEvent import com.superwall.sdk.config.ConfigManager import com.superwall.sdk.config.models.getConfig +import com.superwall.sdk.dependencies.DeviceHelperFactory import com.superwall.sdk.storage.Storage import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.GlobalScope -import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.mapNotNull import kotlinx.coroutines.launch import java.util.* @@ -30,8 +25,10 @@ class AppSessionManager( private val context: Context, private val configManager: ConfigManager, private val storage: Storage, - private val delegate: AppManagerDelegate + private val delegate: AppSessionManager.Factory ) : DefaultLifecycleObserver { + interface Factory: AppManagerDelegate, DeviceHelperFactory {} + var appSessionTimeout: Long? = null var appSession = AppSession() @@ -90,9 +87,8 @@ class AppSessionManager( private fun sessionCouldRefresh() { detectNewSession() - trackAppLaunch() - // TODO: Figure out if this is the right dispatch queue - GlobalScope.launch { + trackAppLaunch() + CoroutineScope(Dispatchers.IO).launch { storage.recordFirstSeenTracked() } } @@ -106,7 +102,11 @@ class AppSessionManager( if (didStartNewSession) { appSession = AppSession() CoroutineScope(Dispatchers.IO).launch { + val attributes = delegate.makeSessionDeviceAttributes() Superwall.instance.track(InternalSuperwallEvent.SessionStart()) + Superwall.instance.track(InternalSuperwallEvent.DeviceAttributes( + deviceAttributes = attributes + )) } } else { appSession.endAt = null 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 8cbb7486..bfc233de 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 @@ -55,6 +55,12 @@ sealed class SuperwallEvent { get() = "session_start" } + /// When device attributes are sent to the backend. + data class DeviceAttributes(val attributes: Map) : SuperwallEvent() { + override val rawName: String + get() = "device_attributes" + } + /// When the user's subscription status changes. class SubscriptionStatusDidChange() : SuperwallEvent() { override val rawName: String 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 ce027c2d..1188c1b3 100644 --- a/superwall/src/main/java/com/superwall/sdk/dependencies/DependencyContainer.kt +++ b/superwall/src/main/java/com/superwall/sdk/dependencies/DependencyContainer.kt @@ -74,7 +74,7 @@ class DependencyContainer( StoreTransactionFactory, Storage.Factory, InternalSuperwallEvent.PresentationRequest.Factory, ViewControllerFactory, PaywallManager.Factory, OptionsFactory, TriggerFactory, TransactionVerifierFactory, TransactionManager.Factory, PaywallViewController.Factory, - ConfigManager.Factory { + ConfigManager.Factory, AppSessionManager.Factory { var network: Network override lateinit var api: Api @@ -284,6 +284,19 @@ class DependencyContainer( return deviceHelper.isSandbox } + override suspend fun makeSessionDeviceAttributes(): HashMap { + val attributes = deviceHelper.getTemplateDevice().toMutableMap() + + attributes.remove("utcDate") + attributes.remove("localDate") + attributes.remove("localTime") + attributes.remove("utcTime") + attributes.remove("utcDateTime") + attributes.remove("localDateTime") + + return HashMap(attributes) + } + override fun makeHasExternalPurchaseController(): Boolean { return storeKitManager.purchaseController.hasExternalPurchaseController } diff --git a/superwall/src/main/java/com/superwall/sdk/dependencies/FactoryProtocols.kt b/superwall/src/main/java/com/superwall/sdk/dependencies/FactoryProtocols.kt index f79c13ec..9f361c42 100644 --- a/superwall/src/main/java/com/superwall/sdk/dependencies/FactoryProtocols.kt +++ b/superwall/src/main/java/com/superwall/sdk/dependencies/FactoryProtocols.kt @@ -115,6 +115,7 @@ interface TransactionVerifierFactory { interface DeviceHelperFactory { fun makeDeviceInfo(): DeviceInfo fun makeIsSandbox(): Boolean + suspend fun makeSessionDeviceAttributes(): HashMap } interface HasExternalPurchaseControllerFactory { 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 9c3a9f30..492050b4 100644 --- a/superwall/src/main/java/com/superwall/sdk/network/Network.kt +++ b/superwall/src/main/java/com/superwall/sdk/network/Network.kt @@ -152,7 +152,7 @@ open class Network( } } - suspend fun getAssignments(): List { + open suspend fun getAssignments(): List { return try { val result = urlSession.request( Endpoint.assignments(factory = factory) diff --git a/superwall/src/main/java/com/superwall/sdk/network/device/DeviceHelper.kt b/superwall/src/main/java/com/superwall/sdk/network/device/DeviceHelper.kt index 5b8a1267..9e5f44b1 100644 --- a/superwall/src/main/java/com/superwall/sdk/network/device/DeviceHelper.kt +++ b/superwall/src/main/java/com/superwall/sdk/network/device/DeviceHelper.kt @@ -365,7 +365,7 @@ class DeviceHelper( return output } - private suspend fun getTemplateDevice(): Map { + suspend fun getTemplateDevice(): Map { val identityInfo = factory.makeIdentityInfo() val aliases = listOf(identityInfo.aliasId) From 2bf3df81a391bef390fe8c6c64f7caee668ebb3c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= Date: Mon, 27 Nov 2023 19:10:02 -0500 Subject: [PATCH 07/23] Part of SW-2588 - Updates google billing - Still some issues --- .../purchase/RevenueCatPurchaseController.kt | 6 +- gradle/libs.versions.toml | 2 +- .../PurchaseController.kt | 6 +- .../PurchaseControllerJava.kt | 5 +- .../sdk/models/paywall/PaywallProducts.kt | 1 - .../superwall/sdk/models/product/Product.kt | 2 +- .../store/ExternalNativePurchaseController.kt | 10 +- .../sdk/store/InternalPurchaseController.kt | 3 +- .../abstractions/product/RawStoreProduct.kt | 317 +++++++++++++++--- .../product/SerializableProductDetails.kt | 85 +++++ .../abstractions/product/StoreProduct.kt | 19 +- .../GoogleBillingPurchaseTransaction.kt | 2 +- .../transactions/StoreTransaction.kt | 2 +- .../transactions/StoreTransactionType.kt | 2 +- .../products/GooglePlayProductsFetcher.kt | 108 ++++-- .../store/transactions/TransactionManager.kt | 2 +- 16 files changed, 477 insertions(+), 95 deletions(-) create mode 100644 superwall/src/main/java/com/superwall/sdk/store/abstractions/product/SerializableProductDetails.kt diff --git a/app/src/main/java/com/superwall/superapp/purchase/RevenueCatPurchaseController.kt b/app/src/main/java/com/superwall/superapp/purchase/RevenueCatPurchaseController.kt index e84f65f9..e241bce6 100644 --- a/app/src/main/java/com/superwall/superapp/purchase/RevenueCatPurchaseController.kt +++ b/app/src/main/java/com/superwall/superapp/purchase/RevenueCatPurchaseController.kt @@ -2,7 +2,9 @@ package com.superwall.superapp import android.app.Activity import android.content.Context +import com.android.billingclient.api.ProductDetails import com.android.billingclient.api.SkuDetails +import com.android.billingclient.api.UserChoiceDetails.Product import com.revenuecat.purchases.* import com.revenuecat.purchases.interfaces.GetStoreProductsCallback import com.revenuecat.purchases.interfaces.PurchaseCallback @@ -107,8 +109,8 @@ class RevenueCatPurchaseController(val context: Context): PurchaseController, Up /** * Initiate a purchase */ - override suspend fun purchase(activity: Activity, product: SkuDetails): PurchaseResult { - val products = Purchases.sharedInstance.awaitProducts(listOf(product.sku)) + override suspend fun purchase(activity: Activity, product: ProductDetails): PurchaseResult { + val products = Purchases.sharedInstance.awaitProducts(listOf(product.productId)) val product = products.firstOrNull() ?: return PurchaseResult.Failed(Exception("Product not found")) return try { diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index ac0649f3..d0884453 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,5 +1,5 @@ [versions] -billing_version = "5.2.1" +billing_version = "6.1.0" browser_version = "1.5.0" gradle_plugin_version = "7.4.2" revenue_cat_version = "6.0.0" diff --git a/superwall/src/main/java/com/superwall/sdk/delegate/subscription_controller/PurchaseController.kt b/superwall/src/main/java/com/superwall/sdk/delegate/subscription_controller/PurchaseController.kt index 2427d5e8..156e4ad7 100644 --- a/superwall/src/main/java/com/superwall/sdk/delegate/subscription_controller/PurchaseController.kt +++ b/superwall/src/main/java/com/superwall/sdk/delegate/subscription_controller/PurchaseController.kt @@ -2,9 +2,11 @@ package com.superwall.sdk.delegate.subscription_controller import android.app.Activity import androidx.annotation.MainThread +import com.android.billingclient.api.ProductDetails import com.android.billingclient.api.SkuDetails import com.superwall.sdk.delegate.PurchaseResult import com.superwall.sdk.delegate.RestorationResult +import com.superwall.sdk.store.products.ProductIds /** * The interface that handles Superwall's subscription-related logic. @@ -27,12 +29,12 @@ interface PurchaseController { * Add your purchase logic here and return its result. You can use Android's Billing APIs, * or if you use RevenueCat, you can call [`Purchases.shared.purchase(product)`](https://revenuecat.github.io/purchases-android-docs/documentation/revenuecat/purchases/purchase(product,completion)). * - * @param product The `SkuDetails` the user would like to purchase. + * @param product The `ProductDetails` the user would like to purchase. * @return A `PurchaseResult` object, which is the result of your purchase logic. * **Note**: Make sure you handle all cases of `PurchaseResult`. */ @MainThread - suspend fun purchase(activity: Activity, product: SkuDetails): PurchaseResult + suspend fun purchase(activity: Activity, product: ProductDetails): PurchaseResult /** * Called when the user initiates a restore. diff --git a/superwall/src/main/java/com/superwall/sdk/delegate/subscription_controller/PurchaseControllerJava.kt b/superwall/src/main/java/com/superwall/sdk/delegate/subscription_controller/PurchaseControllerJava.kt index 065fdc8a..79eaf05b 100644 --- a/superwall/src/main/java/com/superwall/sdk/delegate/subscription_controller/PurchaseControllerJava.kt +++ b/superwall/src/main/java/com/superwall/sdk/delegate/subscription_controller/PurchaseControllerJava.kt @@ -1,5 +1,6 @@ package com.superwall.sdk.delegate.subscription_controller +import com.android.billingclient.api.ProductDetails import com.android.billingclient.api.SkuDetails import com.superwall.sdk.delegate.PurchaseResult import com.superwall.sdk.delegate.RestorationResult @@ -24,14 +25,14 @@ interface PurchaseControllerJava { * Add your purchase logic here and call the completion lambda with the result. You can use Google's Play Billing APIs, * or if you use RevenueCat, you can call `Purchases.instance.purchase(product)`. * - Parameters: - * - product: The `SKProduct` the user would like to purchase. + * - product: The `ProductDetails` the user would like to purchase. * - completion: A lambda the accepts a `PurchaseResultJava` object and an optional `Throwable`. * Call this with the result of your purchase logic. When you pass a `.failed` result, make sure you also pass * the error. * **Note:** Make sure you handle all cases of `PurchaseResultJava`. */ fun purchase( - product: SkuDetails, + product: ProductDetails, completion: (PurchaseResult) -> Unit ) diff --git a/superwall/src/main/java/com/superwall/sdk/models/paywall/PaywallProducts.kt b/superwall/src/main/java/com/superwall/sdk/models/paywall/PaywallProducts.kt index 00bcec52..b4d6bddd 100644 --- a/superwall/src/main/java/com/superwall/sdk/models/paywall/PaywallProducts.kt +++ b/superwall/src/main/java/com/superwall/sdk/models/paywall/PaywallProducts.kt @@ -7,7 +7,6 @@ import kotlinx.serialization.Serializable //@Serializable //data class StoreProduct(val productIdentifier: String) -@Serializable class PaywallProducts( val primary: StoreProduct? = null, val secondary: StoreProduct? = null, diff --git a/superwall/src/main/java/com/superwall/sdk/models/product/Product.kt b/superwall/src/main/java/com/superwall/sdk/models/product/Product.kt index ba9d26c5..d51df576 100644 --- a/superwall/src/main/java/com/superwall/sdk/models/product/Product.kt +++ b/superwall/src/main/java/com/superwall/sdk/models/product/Product.kt @@ -20,5 +20,5 @@ enum class ProductType { @Serializable data class Product( @SerialName("product") val type: ProductType, - @SerialName("productId") val id: String + @SerialName("product_id_android") val id: String ) diff --git a/superwall/src/main/java/com/superwall/sdk/store/ExternalNativePurchaseController.kt b/superwall/src/main/java/com/superwall/sdk/store/ExternalNativePurchaseController.kt index 9d64ac89..43db496b 100644 --- a/superwall/src/main/java/com/superwall/sdk/store/ExternalNativePurchaseController.kt +++ b/superwall/src/main/java/com/superwall/sdk/store/ExternalNativePurchaseController.kt @@ -63,11 +63,15 @@ class ExternalNativePurchaseController(var context: Context) : PurchaseControlle //region PurchaseController - override suspend fun purchase(activity: Activity, product: SkuDetails): PurchaseResult { - val flowParams = BillingFlowParams.newBuilder() - .setSkuDetails(product) + override suspend fun purchase(activity: Activity, product: ProductDetails): PurchaseResult { + val productDetailsParams = BillingFlowParams.ProductDetailsParams.newBuilder() + .setProductDetails(product) .build() + val flowParams = BillingFlowParams.newBuilder() + .setProductDetailsParamsList(listOf(productDetailsParams)) + .build() + Logger.debug( logLevel = LogLevel.info, scope = LogScope.nativePurchaseController, diff --git a/superwall/src/main/java/com/superwall/sdk/store/InternalPurchaseController.kt b/superwall/src/main/java/com/superwall/sdk/store/InternalPurchaseController.kt index 220eff0a..fc14b3f5 100644 --- a/superwall/src/main/java/com/superwall/sdk/store/InternalPurchaseController.kt +++ b/superwall/src/main/java/com/superwall/sdk/store/InternalPurchaseController.kt @@ -2,6 +2,7 @@ package com.superwall.sdk.store import android.app.Activity import android.content.Context +import com.android.billingclient.api.ProductDetails import com.android.billingclient.api.SkuDetails import com.superwall.sdk.Superwall import com.superwall.sdk.analytics.internal.track @@ -78,7 +79,7 @@ class InternalPurchaseController( } } - override suspend fun purchase(activity: Activity, product: SkuDetails): PurchaseResult { + override suspend fun purchase(activity: Activity, product: ProductDetails): PurchaseResult { // TODO: Await beginPurchase with purchasing coordinator: https://linear.app/superwall/issue/SW-2415/[android]-implement-purchasingcoordinator if (kotlinPurchaseController != null) { diff --git a/superwall/src/main/java/com/superwall/sdk/store/abstractions/product/RawStoreProduct.kt b/superwall/src/main/java/com/superwall/sdk/store/abstractions/product/RawStoreProduct.kt index dd333a05..4daefbd3 100644 --- a/superwall/src/main/java/com/superwall/sdk/store/abstractions/product/RawStoreProduct.kt +++ b/superwall/src/main/java/com/superwall/sdk/store/abstractions/product/RawStoreProduct.kt @@ -1,20 +1,24 @@ package com.superwall.sdk.store.abstractions.product +import com.android.billingclient.api.ProductDetails +import com.android.billingclient.api.ProductDetails.PricingPhase +import com.android.billingclient.api.ProductDetails.SubscriptionOfferDetails import com.android.billingclient.api.SkuDetails import com.superwall.sdk.contrib.threeteen.AmountFormats -import kotlinx.serialization.Serializable import kotlinx.serialization.Transient import java.math.BigDecimal import java.math.RoundingMode import java.text.NumberFormat import java.text.SimpleDateFormat +import java.time.Duration import java.util.Calendar import java.util.Currency import java.util.Date import java.util.Locale -@Serializable class RawStoreProduct( - @Serializable(with = SkuDetailsSerializer::class) val underlyingSkuDetails: SkuDetails + val underlyingProductDetails: ProductDetails, + private val basePlanId: String?, + private val offerType: OfferType? ) : StoreProductType { @Transient private val priceFormatterProvider = PriceFormatterProvider() @@ -25,13 +29,22 @@ class RawStoreProduct( } override val productIdentifier: String - get() = underlyingSkuDetails.sku + get() = underlyingProductDetails.productId override val price: BigDecimal - get() = underlyingSkuDetails.priceValue + get() { + underlyingProductDetails.oneTimePurchaseOfferDetails?.let { offerDetails -> + return BigDecimal(offerDetails.priceAmountMicros).divide(BigDecimal(1_000_000), 6, RoundingMode.DOWN) + } + + return basePriceForSelectedOffer().divide(BigDecimal(1_000_000), 6, RoundingMode.DOWN) + } override val localizedPrice: String - get() = priceFormatter?.format(underlyingSkuDetails.priceValue) ?: "" + get() { + val basePrice = basePriceForSelectedOffer() + return priceFormatter?.format(basePrice) ?: "" + } override val localizedSubscriptionPeriod: String get() = subscriptionPeriod?.let { @@ -123,7 +136,6 @@ class RawStoreProduct( SubscriptionPeriod.Unit.month -> 30 * numberOfUnits // Assumes 30 days in a month SubscriptionPeriod.Unit.week -> 7 * numberOfUnits // Assumes 7 days in a week SubscriptionPeriod.Unit.year -> 365 * numberOfUnits // Assumes 365 days in a year - else -> 0 } } ?: 0 } @@ -133,68 +145,178 @@ class RawStoreProduct( override val dailyPrice: String get() { - if (underlyingSkuDetails.priceValue == BigDecimal.ZERO) { + val basePrice = basePriceForSelectedOffer() + + if (basePrice == BigDecimal.ZERO) { return priceFormatter?.format(BigDecimal.ZERO) ?: "$0.00" } val subscriptionPeriod = this.subscriptionPeriod ?: return "n/a" - val inputPrice = underlyingSkuDetails.priceValue - val pricePerDay = subscriptionPeriod.pricePerDay(inputPrice) + val pricePerDay = subscriptionPeriod.pricePerDay(basePrice) return priceFormatter?.format(pricePerDay) ?: "n/a" } override val weeklyPrice: String get() { - if (underlyingSkuDetails.priceValue == BigDecimal.ZERO) { + val basePrice = basePriceForSelectedOffer() + + if (basePrice == BigDecimal.ZERO) { return priceFormatter?.format(BigDecimal.ZERO) ?: "$0.00" } val subscriptionPeriod = this.subscriptionPeriod ?: return "n/a" - val inputPrice = underlyingSkuDetails.priceValue - val pricePerWeek = subscriptionPeriod.pricePerWeek(inputPrice) + val pricePerWeek = subscriptionPeriod.pricePerWeek(basePrice) return priceFormatter?.format(pricePerWeek) ?: "n/a" } override val monthlyPrice: String get() { - if (underlyingSkuDetails.priceValue == BigDecimal.ZERO) { + val basePrice = basePriceForSelectedOffer() + + if (basePrice == BigDecimal.ZERO) { return priceFormatter?.format(BigDecimal.ZERO) ?: "$0.00" } val subscriptionPeriod = this.subscriptionPeriod ?: return "n/a" - val inputPrice = underlyingSkuDetails.priceValue - val pricePerMonth = subscriptionPeriod.pricePerMonth(inputPrice) + val pricePerMonth = subscriptionPeriod.pricePerMonth(basePrice) return priceFormatter?.format(pricePerMonth) ?: "n/a" } override val yearlyPrice: String get() { - if (underlyingSkuDetails.priceValue == BigDecimal.ZERO) { + val basePrice = basePriceForSelectedOffer() + + if (basePrice == BigDecimal.ZERO) { return priceFormatter?.format(BigDecimal.ZERO) ?: "$0.00" } val subscriptionPeriod = this.subscriptionPeriod ?: return "n/a" - val inputPrice = underlyingSkuDetails.priceValue - val pricePerYear = subscriptionPeriod.pricePerYear(inputPrice) + val pricePerYear = subscriptionPeriod.pricePerYear(basePrice) return priceFormatter?.format(pricePerYear) ?: "n/a" } + private fun basePriceForSelectedOffer(): BigDecimal { + val selectedOffer = getSelectedOffer() ?: return BigDecimal.ZERO + val pricingPhase = selectedOffer.pricingPhases.pricingPhaseList.last().priceAmountMicros + return BigDecimal(pricingPhase).divide(BigDecimal(1_000_000), 6, RoundingMode.DOWN) + } + override val hasFreeTrial: Boolean - get() = underlyingSkuDetails.freeTrialPeriod.isNotEmpty() + get() { + if (underlyingProductDetails.oneTimePurchaseOfferDetails != null) { + return false + } + + val selectedOffer = getSelectedOffer() ?: return false + + // Check for free trial phase in pricing phases, excluding the base pricing + return selectedOffer.pricingPhases.pricingPhaseList + .dropLast(1) + .any { it.priceAmountMicros == 0L } + } override val localizedTrialPeriodPrice: String get() = priceFormatter?.format(trialPeriodPrice) ?: "$0.00" override val trialPeriodPrice: BigDecimal - get() = underlyingSkuDetails.introductoryPriceValue + get() { + + // Handle one-time purchase + // TODO: Handle oneTimePurchaseOfferDetails correctly + if (underlyingProductDetails.oneTimePurchaseOfferDetails != null) { + return BigDecimal.ZERO + } + + val selectedOffer = getSelectedOffer() ?: return BigDecimal.ZERO + + val pricingWithoutBase = selectedOffer.pricingPhases.pricingPhaseList.dropLast(1) + if (pricingWithoutBase.isEmpty()) return BigDecimal.ZERO + + // Check for free trial phase + val freeTrialPhase = pricingWithoutBase.firstOrNull { it.priceAmountMicros == 0L } + if (freeTrialPhase != null) return BigDecimal.ZERO + + // Check for discounted phase + val discountedPhase = pricingWithoutBase.firstOrNull { it.priceAmountMicros > 0 } + return discountedPhase?.let { + BigDecimal(it.priceAmountMicros).divide(BigDecimal(1_000_000), 6, RoundingMode.DOWN) + } ?: BigDecimal.ZERO + } + + private fun getSelectedOffer(): SubscriptionOfferDetails? { + if (underlyingProductDetails.oneTimePurchaseOfferDetails != null) { + return null + } + // Retrieve the subscription offer details from the product details + val subscriptionOfferDetails = underlyingProductDetails.subscriptionOfferDetails ?: return null + + // TODO: Deal with one time offer here + // If there's no base plan ID, just return the first offer (there should only be one). + // TODO: Test that subscriptionOfferDetails only returns one offer when no base plan ID set. + if (basePlanId == null) { + return subscriptionOfferDetails.firstOrNull() + } + + // Get the offers that match the given base plan ID. + val offersForBasePlan = subscriptionOfferDetails.filter { it.basePlanId == basePlanId } + + // In offers that match base plan, if there's only 1 pricing phase then this offer represents the base plan. + val basePlan = offersForBasePlan.firstOrNull { it.pricingPhases.pricingPhaseList.size == 1 } ?: return null + + when (offerType) { + is OfferType.Auto -> { + // For automatically selecting an offer: + // - Filters out offers with "ignore-offer" tag + // - Uses offer with longest free trial or cheapest first phase + // - Falls back to use base plan + val validOffers = offersForBasePlan + // Ignore base plan + .filter { it.pricingPhases.pricingPhaseList.size != 1 } + // Ignore those with a tag that contains "ignore-offer" + .filter { !it.offerTags.contains("-ignore-offer") } + + return findLongestFreeTrial(validOffers) ?: findLowestNonFreeOffer(validOffers) ?: basePlan + } + is OfferType.Offer -> { + // If an offer ID is given, return that one. + return offersForBasePlan.firstOrNull { it.offerId == offerType.id } + } + else -> { + // If no offer specified, return base plan. + return basePlan + } + } + } + + private fun findLongestFreeTrial(offers: List): SubscriptionOfferDetails? { + return offers.mapNotNull { offer -> + offer.pricingPhases.pricingPhaseList + .dropLast(1) + .firstOrNull { + it.priceAmountMicros == 0L + }?.let { pricingPhase -> + Pair(offer, Duration.parse(pricingPhase.billingPeriod).toDays()) + } + }.maxByOrNull { it.second }?.first + } + + private fun findLowestNonFreeOffer(offers: List): SubscriptionOfferDetails? { + return offers.mapNotNull { offer -> + offer.pricingPhases.pricingPhaseList.dropLast(1).firstOrNull { + it.priceAmountMicros > 0L + }?.let { pricingPhase -> + Pair(offer, pricingPhase.priceAmountMicros) + } + }.minByOrNull { it.second }?.first + } override val trialPeriodEndDate: Date? get() = trialSubscriptionPeriod?.let { @@ -289,7 +411,6 @@ class RawStoreProduct( SubscriptionPeriod.Unit.month -> "${units * 30}-day" SubscriptionPeriod.Unit.week -> "${units * 7}-day" SubscriptionPeriod.Unit.year -> "${units * 365}-day" - else -> "" } } @@ -302,29 +423,158 @@ class RawStoreProduct( get() = Locale.getDefault().language override val currencyCode: String? - get() = underlyingSkuDetails.priceCurrencyCode + get() { + val selectedOffer = getSelectedOffer() ?: return null + return selectedOffer.pricingPhases.pricingPhaseList.last().priceCurrencyCode + } override val currencySymbol: String? - get() = Currency.getInstance(underlyingSkuDetails.priceCurrencyCode).symbol + get() { + return currencyCode?.let { Currency.getInstance(it).symbol } + } override val regionCode: String? get() = Locale.getDefault().country override val subscriptionPeriod: SubscriptionPeriod? get() { + if (underlyingProductDetails.oneTimePurchaseOfferDetails != null) { + return null + } + + val selectedOffer = getSelectedOffer() ?: return null + val baseBillingPeriod = selectedOffer.pricingPhases.pricingPhaseList.last().billingPeriod + return try { - SubscriptionPeriod.from(underlyingSkuDetails.subscriptionPeriod) + SubscriptionPeriod.from(baseBillingPeriod) } catch (e: Exception) { null } } override fun trialPeriodPricePerUnit(unit: SubscriptionPeriod.Unit): String { - // TODO: ONLY supporting free trial and similar; see `StoreProductDiscount` - // pricePerUnit for other cases - val introductoryDiscount = underlyingSkuDetails.introductoryPriceValue - return priceFormatter?.format(introductoryDiscount) ?: "$0.00" + val pricingPhase = getSelectedOfferPricingPhase() ?: return priceFormatter?.format(0) ?: "$0.00" + + if (pricingPhase.priceAmountMicros == 0L) { + return priceFormatter?.format(0) ?: "$0.00" + } + + val introMonthlyPrice = pricePerUnit( + unit = unit, + pricingPhase = pricingPhase + ) + + return priceFormatter?.format(introMonthlyPrice) ?: "$0.00" + } + + private fun pricePerUnit( + unit: SubscriptionPeriod.Unit, + pricingPhase: PricingPhase + ): BigDecimal { + if (pricingPhase.priceAmountMicros == 0L) { + return BigDecimal.ZERO + } else { + // The total cost that you'll pay + val trialPeriodPrice = BigDecimal(pricingPhase.priceAmountMicros).divide(BigDecimal(1_000_000), 6, RoundingMode.DOWN) + val introCost = trialPeriodPrice.multiply(BigDecimal(pricingPhase.billingCycleCount)) + + // The number of total units normalized to the unit you want. + val billingPeriod = getSelectedOfferPricingPhase()?.billingPeriod + + // Attempt to create a SubscriptionPeriod from billingPeriod. + // Return null if there's an exception or if billingPeriod is null. + val trialSubscriptionPeriod = try { + billingPeriod?.let { SubscriptionPeriod.from(it) } + } catch (e: Exception) { + null + } + val introPeriods = periodsPerUnit(unit).multiply(BigDecimal(pricingPhase.billingCycleCount)) + .multiply(BigDecimal(trialSubscriptionPeriod?.value ?: 0)) + + val introPayment: BigDecimal + if (introPeriods < BigDecimal.ONE) { + // If less than 1, it means the intro period doesn't exceed a full unit. + introPayment = introCost + } else { + // Otherwise, divide the total cost by the normalized intro periods. + introPayment = introCost.divide(introPeriods, RoundingMode.DOWN) + } + + return introPayment + } } + + private fun periodsPerUnit(unit: SubscriptionPeriod.Unit): BigDecimal { + return when (unit) { + SubscriptionPeriod.Unit.day -> { + when (trialSubscriptionPeriod?.unit) { + SubscriptionPeriod.Unit.day -> BigDecimal(1) + SubscriptionPeriod.Unit.week -> BigDecimal(7) + SubscriptionPeriod.Unit.month -> BigDecimal(30) + SubscriptionPeriod.Unit.year -> BigDecimal(365) + else -> BigDecimal.ZERO + } + } + SubscriptionPeriod.Unit.week -> { + when (trialSubscriptionPeriod?.unit) { + SubscriptionPeriod.Unit.day -> BigDecimal(1) / BigDecimal(7) + SubscriptionPeriod.Unit.week -> BigDecimal(1) + SubscriptionPeriod.Unit.month -> BigDecimal(4) + SubscriptionPeriod.Unit.year -> BigDecimal(52) + else -> BigDecimal.ZERO + } + } + SubscriptionPeriod.Unit.month -> { + when (trialSubscriptionPeriod?.unit) { + SubscriptionPeriod.Unit.day -> BigDecimal(1) / BigDecimal(30) + SubscriptionPeriod.Unit.week -> BigDecimal(1) / BigDecimal(4) + SubscriptionPeriod.Unit.month -> BigDecimal(1) + SubscriptionPeriod.Unit.year -> BigDecimal(12) + else -> BigDecimal.ZERO + } + } + SubscriptionPeriod.Unit.year -> { + when (trialSubscriptionPeriod?.unit) { + SubscriptionPeriod.Unit.day -> BigDecimal(1) / BigDecimal(365) + SubscriptionPeriod.Unit.week -> BigDecimal(1) / BigDecimal(52) + SubscriptionPeriod.Unit.month -> BigDecimal(1) / BigDecimal(12) + SubscriptionPeriod.Unit.year -> BigDecimal(1) + else -> BigDecimal.ZERO + } + } + } + } + + + private fun getSelectedOfferPricingPhase(): PricingPhase? { + // Get the selected offer; return null if it's null. + val selectedOffer = getSelectedOffer() ?: return null + + // Find the first free trial phase or discounted phase. + return selectedOffer.pricingPhases.pricingPhaseList + .firstOrNull { it.priceAmountMicros == 0L } + ?: selectedOffer.pricingPhases.pricingPhaseList + .dropLast(1) + .firstOrNull { it.priceAmountMicros != 0L } + } + + val trialSubscriptionPeriod: SubscriptionPeriod? + get() { + // If oneTimePurchaseOfferDetails is not null, return null for trial subscription period. + if (underlyingProductDetails.oneTimePurchaseOfferDetails != null) { + return null + } + + val billingPeriod = getSelectedOfferPricingPhase()?.billingPeriod + + // Attempt to create a SubscriptionPeriod from billingPeriod. + // Return null if there's an exception or if billingPeriod is null. + return try { + billingPeriod?.let { SubscriptionPeriod.from(it) } + } catch (e: Exception) { + null + } + } } val SkuDetails.priceValue: BigDecimal @@ -332,12 +582,3 @@ val SkuDetails.priceValue: BigDecimal val SkuDetails.introductoryPriceValue: BigDecimal get() = BigDecimal(introductoryPriceAmountMicros).divide(BigDecimal(1_000_000), 6, RoundingMode.DOWN) - -val RawStoreProduct.trialSubscriptionPeriod: SubscriptionPeriod? - get() { - return try { - SubscriptionPeriod.from(underlyingSkuDetails.freeTrialPeriod) - } catch (e: Exception) { - null - } - } \ No newline at end of file diff --git a/superwall/src/main/java/com/superwall/sdk/store/abstractions/product/SerializableProductDetails.kt b/superwall/src/main/java/com/superwall/sdk/store/abstractions/product/SerializableProductDetails.kt new file mode 100644 index 00000000..cabbeac5 --- /dev/null +++ b/superwall/src/main/java/com/superwall/sdk/store/abstractions/product/SerializableProductDetails.kt @@ -0,0 +1,85 @@ +package com.superwall.sdk.store.abstractions.product + +import com.android.billingclient.api.ProductDetails +import com.android.billingclient.api.ProductDetails.OneTimePurchaseOfferDetails +import com.android.billingclient.api.ProductDetails.PricingPhase +import com.android.billingclient.api.ProductDetails.PricingPhases +import com.android.billingclient.api.ProductDetails.SubscriptionOfferDetails +import kotlinx.serialization.Serializable + +@Serializable +data class SerializableOneTimePurchaseOfferDetails( + val priceAmountMicros: Long +) + +@Serializable +data class SerializablePricingPhase( + val billingCycleCount: Int, + val recurrencyMode: Int, + val priceAmountMicros: Long, + val billingPeriod: String, + val formattedPrice: String, + val priceCurrencyCode: String +) + +@Serializable +data class SerializablePricingPhases( + val pricingPhaseList: List +) + +@Serializable +data class SerializableSubscriptionOfferDetails( + val pricingPhases: SerializablePricingPhases, + val offerTags: List, + val basePlanId: String, + val offerId: String? +) + +@Serializable +data class SerializableProductDetails( + val oneTimePurchaseOfferDetails: SerializableOneTimePurchaseOfferDetails?, + val subscriptionOfferDetails: List?, + val productId: String, + val title: String, + val description: String, + val productType: String +) { + companion object { + fun from(productDetails: ProductDetails): SerializableProductDetails { + val oneTimePurchaseOfferDetails = productDetails.oneTimePurchaseOfferDetails?.let { + SerializableOneTimePurchaseOfferDetails( + priceAmountMicros = it.priceAmountMicros + ) + } + + val subscriptionOfferDetails = productDetails.subscriptionOfferDetails?.map { offerDetails -> + SerializableSubscriptionOfferDetails( + pricingPhases = SerializablePricingPhases( + pricingPhaseList = offerDetails.pricingPhases.pricingPhaseList.map { pricingPhase -> + SerializablePricingPhase( + billingCycleCount = pricingPhase.billingCycleCount, + recurrencyMode = pricingPhase.recurrenceMode, + priceAmountMicros = pricingPhase.priceAmountMicros, + billingPeriod = pricingPhase.billingPeriod, + formattedPrice = pricingPhase.formattedPrice, + priceCurrencyCode = pricingPhase.priceCurrencyCode + ) + } + ), + offerTags = offerDetails.offerTags, + basePlanId = offerDetails.basePlanId, + offerId = offerDetails.offerId + ) + } + + return SerializableProductDetails( + oneTimePurchaseOfferDetails = oneTimePurchaseOfferDetails, + subscriptionOfferDetails = subscriptionOfferDetails, + productId = productDetails.productId, + title = productDetails.title, + description = productDetails.description, + productType = productDetails.productType + ) + } + } +} diff --git a/superwall/src/main/java/com/superwall/sdk/store/abstractions/product/StoreProduct.kt b/superwall/src/main/java/com/superwall/sdk/store/abstractions/product/StoreProduct.kt index 506b6e41..2b71d5d6 100644 --- a/superwall/src/main/java/com/superwall/sdk/store/abstractions/product/StoreProduct.kt +++ b/superwall/src/main/java/com/superwall/sdk/store/abstractions/product/StoreProduct.kt @@ -1,6 +1,8 @@ package com.superwall.sdk.store.abstractions.product +import com.android.billingclient.api.BillingClient.ProductType +import com.android.billingclient.api.ProductDetails import com.android.billingclient.api.SkuDetails import com.superwall.sdk.contrib.threeteen.AmountFormats import kotlinx.serialization.KSerializer @@ -13,19 +15,12 @@ import java.math.RoundingMode import java.text.SimpleDateFormat import java.util.* -@Serializer(forClass = SkuDetails::class) -object SkuDetailsSerializer : KSerializer { - override fun serialize(encoder: Encoder, value: SkuDetails) { - encoder.encodeString(value.price) // replace "property" with actual property name - } - - override fun deserialize(decoder: Decoder): SkuDetails { - val property = decoder.decodeString() - return SkuDetails(property) // replace with actual SkuDetails constructor - } +@Serializable +sealed class OfferType { + object Auto : OfferType() + data class Offer(val id: String) : OfferType() } -@Serializable class StoreProduct( val rawStoreProduct: RawStoreProduct ) : StoreProductType { @@ -34,7 +29,7 @@ class StoreProduct( get() = rawStoreProduct.productIdentifier override val price: BigDecimal - get() = rawStoreProduct.price + get() = rawStoreProduct.price override val localizedPrice: String get() = rawStoreProduct.localizedPrice diff --git a/superwall/src/main/java/com/superwall/sdk/store/abstractions/transactions/GoogleBillingPurchaseTransaction.kt b/superwall/src/main/java/com/superwall/sdk/store/abstractions/transactions/GoogleBillingPurchaseTransaction.kt index a6806d3c..4f7cfe1d 100644 --- a/superwall/src/main/java/com/superwall/sdk/store/abstractions/transactions/GoogleBillingPurchaseTransaction.kt +++ b/superwall/src/main/java/com/superwall/sdk/store/abstractions/transactions/GoogleBillingPurchaseTransaction.kt @@ -19,7 +19,7 @@ data class GoogleBillingPurchaseTransaction( override val transactionDate: Date?, @SerialName("original_transaction_identifier") - override val originalTransactionIdentifier: String, + override val originalTransactionIdentifier: String?, @SerialName("state") override val state: StoreTransactionState, diff --git a/superwall/src/main/java/com/superwall/sdk/store/abstractions/transactions/StoreTransaction.kt b/superwall/src/main/java/com/superwall/sdk/store/abstractions/transactions/StoreTransaction.kt index 07cdea27..c2509daa 100644 --- a/superwall/src/main/java/com/superwall/sdk/store/abstractions/transactions/StoreTransaction.kt +++ b/superwall/src/main/java/com/superwall/sdk/store/abstractions/transactions/StoreTransaction.kt @@ -25,7 +25,7 @@ class StoreTransaction( val id = UUID.randomUUID().toString() override val transactionDate: Date? get() = transaction.transactionDate - override val originalTransactionIdentifier: String get() = transaction.originalTransactionIdentifier + override val originalTransactionIdentifier: String? get() = transaction.originalTransactionIdentifier override val state: StoreTransactionState get() = transaction.state override val storeTransactionId: String? get() = transaction.storeTransactionId override val payment: StorePayment? get() = transaction.payment diff --git a/superwall/src/main/java/com/superwall/sdk/store/abstractions/transactions/StoreTransactionType.kt b/superwall/src/main/java/com/superwall/sdk/store/abstractions/transactions/StoreTransactionType.kt index 3c76f2b6..195e7210 100644 --- a/superwall/src/main/java/com/superwall/sdk/store/abstractions/transactions/StoreTransactionType.kt +++ b/superwall/src/main/java/com/superwall/sdk/store/abstractions/transactions/StoreTransactionType.kt @@ -13,7 +13,7 @@ import java.util.* interface StoreTransactionType { val transactionDate: Date? - val originalTransactionIdentifier: String + val originalTransactionIdentifier: String? val state: StoreTransactionState val storeTransactionId: String? val payment: StorePayment? diff --git a/superwall/src/main/java/com/superwall/sdk/store/products/GooglePlayProductsFetcher.kt b/superwall/src/main/java/com/superwall/sdk/store/products/GooglePlayProductsFetcher.kt index 2a6f6815..ec6d99a1 100644 --- a/superwall/src/main/java/com/superwall/sdk/store/products/GooglePlayProductsFetcher.kt +++ b/superwall/src/main/java/com/superwall/sdk/store/products/GooglePlayProductsFetcher.kt @@ -1,10 +1,11 @@ package com.superwall.sdk.store.products import android.content.Context -import android.util.Log import com.android.billingclient.api.* import com.superwall.sdk.billing.GoogleBillingWrapper +import com.superwall.sdk.store.abstractions.product.OfferType import com.superwall.sdk.store.abstractions.product.RawStoreProduct +import com.superwall.sdk.store.abstractions.product.SerializableProductDetails import com.superwall.sdk.store.abstractions.product.StoreProduct import com.superwall.sdk.store.coordinator.ProductsFetcher import kotlinx.coroutines.* @@ -19,6 +20,26 @@ sealed class Result { private const val RECONNECT_TIMER_START_MILLISECONDS = 1L * 1000L private const val RECONNECT_TIMER_MAX_TIME_MILLISECONDS = 1000L * 60L * 15L // 15 minutes +data class ProductIds( + val basePlanId: String?, + val offerType: OfferType? +) { + companion object { + fun from(components: List): ProductIds { + val basePlanId = components.getOrNull(1) + val offerId = components.getOrNull(2) + var offerType: OfferType? = null + + if (offerId == "sw-auto") { + offerType = OfferType.Auto + } else if (offerId != null) { + offerType = OfferType.Offer(id = offerId) + } + return ProductIds(basePlanId, offerType) + } + } +} + open class GooglePlayProductsFetcher(var context: Context, var billingWrapper: GoogleBillingWrapper) : ProductsFetcher, PurchasesUpdatedListener { @@ -32,41 +53,43 @@ open class GooglePlayProductsFetcher(var context: Context, var billingWrapper: G // Create a map with product id to status private val _results = MutableStateFlow>>(emptyMap()) val results: StateFlow>> = _results + var productIdsBySubscriptionId: MutableMap = mutableMapOf() // Create a supervisor job private val scope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) - protected fun request(productIds: List) { scope.launch { - val currentResults = _results.value println("!!! Current results ${currentResults.size}") - var productIdsToLoad: List = emptyList() + var subscriptionIdsToLoad: List = emptyList() productIds.forEach { productId -> val result = currentResults[productId] println("Result for $productId is $result ${Thread.currentThread().name}") if (result == null) { - productIdsToLoad = productIdsToLoad + productId + val components = productId.split(":") + val subscriptionId = components.getOrNull(0) ?: "" + productIdsBySubscriptionId[subscriptionId] = ProductIds.from(components) + subscriptionIdsToLoad = subscriptionIdsToLoad + subscriptionId } } - print("!!! Requesting ${productIdsToLoad.size} products") + print("!!! Requesting ${subscriptionIdsToLoad.size} products") - if (!productIdsToLoad.isEmpty()) { + if (!subscriptionIdsToLoad.isEmpty()) { _results.emit( - _results.value + productIdsToLoad.map { + _results.value + subscriptionIdsToLoad.map { it to Result.Waiting( startedAt = System.currentTimeMillis().toInt() ) } ) - println("!! Querying product details for ${productIdsToLoad.size} products, prodcuts: ${productIdsToLoad} ${Thread.currentThread().name}") + println("!! Querying product details for ${subscriptionIdsToLoad.size} products, prodcuts: ${subscriptionIdsToLoad} ${Thread.currentThread().name}") val networkResult = runBlocking { - queryProductDetails(productIdsToLoad) + queryProductDetails(subscriptionIdsToLoad) } println("!! networkResult: ${networkResult} ${Thread.currentThread().name}") _results.emit( @@ -98,22 +121,43 @@ open class GooglePlayProductsFetcher(var context: Context, var billingWrapper: G val deferredSubs = CompletableDeferred>>() val deferredInApp = CompletableDeferred>>() - val skuList = ArrayList(productIds) - - val subsParams = SkuDetailsParams.newBuilder().setSkusList(skuList).setType(BillingClient.SkuType.SUBS) - val inAppParams = SkuDetailsParams.newBuilder().setSkusList(skuList).setType(BillingClient.SkuType.INAPP) + val subsParams = QueryProductDetailsParams.newBuilder() + .setProductList( + productIds.map { productId -> + QueryProductDetailsParams.Product.newBuilder() + .setProductId(productId) + .setProductType(BillingClient.ProductType.SUBS) + .build() + } + ) + .build() + + val inAppParams = QueryProductDetailsParams.newBuilder() + .setProductList( + productIds.map { productId -> + QueryProductDetailsParams.Product.newBuilder() + .setProductId(productId) + .setProductType(BillingClient.ProductType.INAPP) + .build() + } + ) + .build() println("!! Querying subscription product details for ${productIds.size} products, products: ${productIds}, ${Thread.currentThread().name}") billingWrapper.waitForConnectedClient{ - querySkuDetailsAsync(subsParams.build()) { billingResult, skuDetailsList -> - deferredSubs.complete(handleSkuDetailsResponse(productIds, billingResult, skuDetailsList)) + queryProductDetailsAsync(subsParams) { billingResult, productDetailsList -> + val resultMap = + handleProductDetailsResponse(productIds, billingResult, productDetailsList) + deferredSubs.complete(resultMap) // Or deferredInApp depending on the product type } } println("!! Querying in-app product details for ${productIds.size} products, products: ${productIds}, ${Thread.currentThread().name}") billingWrapper.waitForConnectedClient { - querySkuDetailsAsync(inAppParams.build()) { billingResult, skuDetailsList -> - deferredInApp.complete(handleSkuDetailsResponse(productIds, billingResult, skuDetailsList)) + queryProductDetailsAsync(inAppParams) { billingResult, productDetailsList -> + val resultMap = + handleProductDetailsResponse(productIds, billingResult, productDetailsList) + deferredInApp.complete(resultMap) // Or deferredInApp depending on the product type } } @@ -147,19 +191,27 @@ open class GooglePlayProductsFetcher(var context: Context, var billingWrapper: G return combinedResults } - private fun handleSkuDetailsResponse( + private fun handleProductDetailsResponse( productIds: List, billingResult: BillingResult, - skuDetailsList: List? + productDetailsList: List? ): Map> { - println("!! Got product details for ${productIds.size} products, produtcs: ${productIds}, billingResult: ${billingResult}, skuDetailsList: ${skuDetailsList} ${Thread.currentThread().name}\"") - - if (billingResult.responseCode == BillingClient.BillingResponseCode.OK && skuDetailsList != null) { - var foundProducts = skuDetailsList.map { it.sku } - var missingProducts = productIds.filter { !foundProducts.contains(it) } - - var results = skuDetailsList.associateBy { it.sku } - .mapValues { Result.Success(RawStoreProduct(it.value)) } + println("!! Got product details for ${productIds.size} products, products: ${productIds}, billingResult: ${billingResult}, productDetailsList: ${productDetailsList} ${Thread.currentThread().name}\"") + + if (billingResult.responseCode == BillingClient.BillingResponseCode.OK && productDetailsList != null) { + val foundProducts = productDetailsList.map { it.productId } + val missingProducts = productIds.filter { !foundProducts.contains(it) } + val results = productDetailsList.associateBy { it.productId } + .mapValues { (_, productDetails) -> + val productIds = productIdsBySubscriptionId[productDetails.productId] + Result.Success( + RawStoreProduct( + underlyingProductDetails = productDetails, + basePlanId = productIds?.basePlanId, + offerType = productIds?.offerType + ) + ) + } .toMutableMap() as MutableMap> missingProducts.forEach { missingProductId -> diff --git a/superwall/src/main/java/com/superwall/sdk/store/transactions/TransactionManager.kt b/superwall/src/main/java/com/superwall/sdk/store/transactions/TransactionManager.kt index 1a07d45d..2c9db62a 100644 --- a/superwall/src/main/java/com/superwall/sdk/store/transactions/TransactionManager.kt +++ b/superwall/src/main/java/com/superwall/sdk/store/transactions/TransactionManager.kt @@ -45,7 +45,7 @@ class TransactionManager( val result = storeKitManager.purchaseController.purchase( activity, - product.rawStoreProduct.underlyingSkuDetails + product.rawStoreProduct.underlyingProductDetails ) when (result) { From f9c80302035f1d13c6d3718e1c79191ecd44b8b3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= Date: Wed, 29 Nov 2023 18:50:14 -0500 Subject: [PATCH 08/23] Closes SW-2218, more updates to the way we purchase --- .../purchase/RevenueCatPurchaseController.kt | 77 +++++++++++++---- .../superwall/superapp/test/UITestHandler.kt | 2 +- .../sdk/store/products/ProductFetcherTest.kt | 6 +- .../superwall/sdk/delegate/PurchaseResult.kt | 4 +- .../PurchaseController.kt | 11 ++- .../PurchaseControllerJava.kt | 11 ++- .../sdk/models/paywall/PaywallProducts.kt | 6 +- .../superwall/sdk/models/product/Product.kt | 48 ++++++++++- .../sdk/network/session/CustomURLSession.kt | 2 +- .../sdk/paywall/presentation/PaywallInfo.kt | 2 +- .../sdk/paywall/request/PaywallLogic.kt | 5 +- .../paywall/request/PaywallRequestManager.kt | 3 +- .../sdk/paywall/vc/web_view/SWWebView.kt | 10 ++- .../store/ExternalNativePurchaseController.kt | 21 +++-- .../sdk/store/InternalPurchaseController.kt | 13 ++- .../superwall/sdk/store/StoreKitManager.kt | 11 +-- .../abstractions/product/RawStoreProduct.kt | 15 ++-- .../product/SerializableProductDetails.kt | 85 ------------------- .../abstractions/product/StoreProduct.kt | 11 ++- .../abstractions/product/StoreProductType.kt | 1 + .../product/receipt/ReceiptManager.kt | 14 --- .../products/GooglePlayProductsFetcher.kt | 73 +++++++++++----- .../store/transactions/TransactionManager.kt | 42 +++++---- 23 files changed, 265 insertions(+), 208 deletions(-) delete mode 100644 superwall/src/main/java/com/superwall/sdk/store/abstractions/product/SerializableProductDetails.kt diff --git a/app/src/main/java/com/superwall/superapp/purchase/RevenueCatPurchaseController.kt b/app/src/main/java/com/superwall/superapp/purchase/RevenueCatPurchaseController.kt index e241bce6..2eec61ff 100644 --- a/app/src/main/java/com/superwall/superapp/purchase/RevenueCatPurchaseController.kt +++ b/app/src/main/java/com/superwall/superapp/purchase/RevenueCatPurchaseController.kt @@ -3,8 +3,6 @@ package com.superwall.superapp import android.app.Activity import android.content.Context import com.android.billingclient.api.ProductDetails -import com.android.billingclient.api.SkuDetails -import com.android.billingclient.api.UserChoiceDetails.Product import com.revenuecat.purchases.* import com.revenuecat.purchases.interfaces.GetStoreProductsCallback import com.revenuecat.purchases.interfaces.PurchaseCallback @@ -12,6 +10,7 @@ import com.revenuecat.purchases.interfaces.ReceiveCustomerInfoCallback import com.revenuecat.purchases.interfaces.UpdatedCustomerInfoListener import com.revenuecat.purchases.models.StoreProduct import com.revenuecat.purchases.models.StoreTransaction +import com.revenuecat.purchases.models.SubscriptionOption import com.superwall.sdk.Superwall import com.superwall.sdk.delegate.PurchaseResult import com.superwall.sdk.delegate.RestorationResult @@ -75,7 +74,6 @@ suspend fun Purchases.awaitRestoration(): CustomerInfo { } class RevenueCatPurchaseController(val context: Context): PurchaseController, UpdatedCustomerInfoListener { - init { Purchases.logLevel = LogLevel.DEBUG Purchases.configure(PurchasesConfiguration.Builder(context, "goog_DCSOujJzRNnPmxdgjOwdOOjwilC").build()) @@ -109,23 +107,70 @@ class RevenueCatPurchaseController(val context: Context): PurchaseController, Up /** * Initiate a purchase */ - override suspend fun purchase(activity: Activity, product: ProductDetails): PurchaseResult { - val products = Purchases.sharedInstance.awaitProducts(listOf(product.productId)) - val product = products.firstOrNull() - ?: return PurchaseResult.Failed(Exception("Product not found")) - return try { - Purchases.sharedInstance.awaitPurchase(activity, product) + override suspend fun purchase( + activity: Activity, + productDetails: ProductDetails, + basePlanId: String?, + offerId: String? + ): PurchaseResult { + val products = Purchases.sharedInstance.awaitProducts(listOf(productDetails.productId)) + val product = products.firstOrNull() ?: return PurchaseResult.Failed("Product not found") + + return when (product.type) { + ProductType.SUBS, ProductType.UNKNOWN -> handleSubscription(activity, product, basePlanId, offerId) + ProductType.INAPP -> handleInAppPurchase(activity, product) + } + } + + private fun buildSubscriptionOptionId(basePlanId: String?, offerId: String?): String = + buildString { + basePlanId?.let { append("$it") } + offerId?.let { append(":$it") } + } + + private suspend fun handleSubscription( + activity: Activity, + storeProduct: StoreProduct, + basePlanId: String?, + offerId: String? + ): PurchaseResult { + storeProduct.subscriptionOptions?.let { subscriptionOptions -> + val subscriptionOptionId = buildSubscriptionOptionId(basePlanId, offerId) + val subscriptionOption = subscriptionOptions.firstOrNull { it.id == subscriptionOptionId } + ?: subscriptionOptions.defaultOffer + + println("!!! RevenueCat Subscription Options $subscriptionOptions") + if (subscriptionOption != null) { + return purchaseSubscription(activity, subscriptionOption) + } + } + return PurchaseResult.Failed("Valid subscription option not found for product.") + } + + private suspend fun purchaseSubscription(activity: Activity, subscriptionOption: SubscriptionOption): PurchaseResult { + val deferred = CompletableDeferred() + Purchases.sharedInstance.purchaseWith( + PurchaseParams.Builder(activity, subscriptionOption).build(), + onError = { error, userCancelled -> + deferred.complete(if (userCancelled) PurchaseResult.Cancelled() else PurchaseResult.Failed(error.message)) + }, + onSuccess = { _, _ -> + deferred.complete(PurchaseResult.Purchased()) + } + ) + return deferred.await() + } + + private suspend fun handleInAppPurchase(activity: Activity, storeProduct: StoreProduct): PurchaseResult = + try { + Purchases.sharedInstance.awaitPurchase(activity, storeProduct) PurchaseResult.Purchased() } catch (e: PurchasesException) { - if (e.purchasesError.code === PurchasesErrorCode.PurchaseCancelledError) { - // Purchase was cancelled by the user - PurchaseResult.Cancelled() - } else { - // Some other error occurred - PurchaseResult.Failed(e) + when (e.purchasesError.code) { + PurchasesErrorCode.PurchaseCancelledError -> PurchaseResult.Cancelled() + else -> PurchaseResult.Failed(e.message ?: "Purchase failed due to an unknown error") } } - } /** * Restore purchases diff --git a/app/src/main/java/com/superwall/superapp/test/UITestHandler.kt b/app/src/main/java/com/superwall/superapp/test/UITestHandler.kt index 09c2139f..cfce4790 100644 --- a/app/src/main/java/com/superwall/superapp/test/UITestHandler.kt +++ b/app/src/main/java/com/superwall/superapp/test/UITestHandler.kt @@ -1303,7 +1303,7 @@ class UITestHandler { when (eventInfo.event) { is SuperwallEvent.TransactionComplete -> { val transaction = (eventInfo.event as SuperwallEvent.TransactionComplete).transaction == null - val productId = (eventInfo.event as SuperwallEvent.TransactionComplete).product.productIdentifier + val productId = (eventInfo.event as SuperwallEvent.TransactionComplete).product.fullIdentifier val paywallId = (eventInfo.event as SuperwallEvent.TransactionComplete).paywallInfo.identifier println("!!! TEST 75 !!! TransactionComplete. Transaction nil? $transaction, $productId, $paywallId") diff --git a/superwall/src/androidTest/java/com/superwall/sdk/store/products/ProductFetcherTest.kt b/superwall/src/androidTest/java/com/superwall/sdk/store/products/ProductFetcherTest.kt index c945d63b..0f90d3af 100644 --- a/superwall/src/androidTest/java/com/superwall/sdk/store/products/ProductFetcherTest.kt +++ b/superwall/src/androidTest/java/com/superwall/sdk/store/products/ProductFetcherTest.kt @@ -52,7 +52,11 @@ class ProductFetcherUnderTest(context: Context) : GooglePlayProductsFetcher(cont if (product != null) { productId to product } else { - productId to Result.Success(RawStoreProduct(skuDetails = MockSkuDetails(mockSku))) + productId to Result.Success( + RawStoreProduct( + underlyingProductDetails = MockSkuDetails(mockSku) + ) + ) } }.toMap() return result diff --git a/superwall/src/main/java/com/superwall/sdk/delegate/PurchaseResult.kt b/superwall/src/main/java/com/superwall/sdk/delegate/PurchaseResult.kt index eab3d9cd..35fe1f41 100644 --- a/superwall/src/main/java/com/superwall/sdk/delegate/PurchaseResult.kt +++ b/superwall/src/main/java/com/superwall/sdk/delegate/PurchaseResult.kt @@ -44,14 +44,14 @@ sealed class PurchaseResult { * * Send the `Exception` back to the relevant method to alert the user. */ - data class Failed(val error: Throwable) : PurchaseResult() + data class Failed(val errorMessage: String) : PurchaseResult() override fun equals(other: Any?): Boolean { return when { this is Cancelled && other is Cancelled -> true this is Purchased && other is Purchased -> true this is Pending && other is Pending -> true - this is Failed && other is Failed -> this.error.message == other.error.message + this is Failed && other is Failed -> this.errorMessage == other.errorMessage else -> false } } diff --git a/superwall/src/main/java/com/superwall/sdk/delegate/subscription_controller/PurchaseController.kt b/superwall/src/main/java/com/superwall/sdk/delegate/subscription_controller/PurchaseController.kt index 156e4ad7..c17bb1d1 100644 --- a/superwall/src/main/java/com/superwall/sdk/delegate/subscription_controller/PurchaseController.kt +++ b/superwall/src/main/java/com/superwall/sdk/delegate/subscription_controller/PurchaseController.kt @@ -29,12 +29,19 @@ interface PurchaseController { * Add your purchase logic here and return its result. You can use Android's Billing APIs, * or if you use RevenueCat, you can call [`Purchases.shared.purchase(product)`](https://revenuecat.github.io/purchases-android-docs/documentation/revenuecat/purchases/purchase(product,completion)). * - * @param product The `ProductDetails` the user would like to purchase. + * @param productDefaults The `ProductDetails` the user would like to purchase. + * @param basePlanId An optional base plan identifier of the product that's being purchased. + * @param offerId An optional offer identifier of the product that's being purchased. * @return A `PurchaseResult` object, which is the result of your purchase logic. * **Note**: Make sure you handle all cases of `PurchaseResult`. */ @MainThread - suspend fun purchase(activity: Activity, product: ProductDetails): PurchaseResult + suspend fun purchase( + activity: Activity, + productDetails: ProductDetails, + basePlanId: String?, + offerId: String? + ): PurchaseResult /** * Called when the user initiates a restore. diff --git a/superwall/src/main/java/com/superwall/sdk/delegate/subscription_controller/PurchaseControllerJava.kt b/superwall/src/main/java/com/superwall/sdk/delegate/subscription_controller/PurchaseControllerJava.kt index 79eaf05b..8107149a 100644 --- a/superwall/src/main/java/com/superwall/sdk/delegate/subscription_controller/PurchaseControllerJava.kt +++ b/superwall/src/main/java/com/superwall/sdk/delegate/subscription_controller/PurchaseControllerJava.kt @@ -25,15 +25,18 @@ interface PurchaseControllerJava { * Add your purchase logic here and call the completion lambda with the result. You can use Google's Play Billing APIs, * or if you use RevenueCat, you can call `Purchases.instance.purchase(product)`. * - Parameters: - * - product: The `ProductDetails` the user would like to purchase. - * - completion: A lambda the accepts a `PurchaseResultJava` object and an optional `Throwable`. + * @param productDetails The `ProductDetails` the user would like to purchase. + * @param basePlanId An optional base plan identifier of the product that's being purchased. + * @param offerId An optional offer identifier of the product that's being purchased. * Call this with the result of your purchase logic. When you pass a `.failed` result, make sure you also pass * the error. * **Note:** Make sure you handle all cases of `PurchaseResultJava`. */ fun purchase( - product: ProductDetails, - completion: (PurchaseResult) -> Unit + productDetails: ProductDetails, + basePlanId: String?, + offerId: String?, + completion: (PurchaseResult) -> Unit ) /** diff --git a/superwall/src/main/java/com/superwall/sdk/models/paywall/PaywallProducts.kt b/superwall/src/main/java/com/superwall/sdk/models/paywall/PaywallProducts.kt index b4d6bddd..63cdc1dc 100644 --- a/superwall/src/main/java/com/superwall/sdk/models/paywall/PaywallProducts.kt +++ b/superwall/src/main/java/com/superwall/sdk/models/paywall/PaywallProducts.kt @@ -13,8 +13,8 @@ class PaywallProducts( val tertiary: StoreProduct? = null ) { val ids: List = listOfNotNull( - primary?.productIdentifier, - secondary?.productIdentifier, - tertiary?.productIdentifier + primary?.fullIdentifier, + secondary?.fullIdentifier, + tertiary?.fullIdentifier ) } diff --git a/superwall/src/main/java/com/superwall/sdk/models/product/Product.kt b/superwall/src/main/java/com/superwall/sdk/models/product/Product.kt index d51df576..e7d62dbf 100644 --- a/superwall/src/main/java/com/superwall/sdk/models/product/Product.kt +++ b/superwall/src/main/java/com/superwall/sdk/models/product/Product.kt @@ -1,7 +1,21 @@ package com.superwall.sdk.models.product +import kotlinx.serialization.KSerializer import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable +import kotlinx.serialization.SerializationException +import kotlinx.serialization.Serializer +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.descriptors.buildClassSerialDescriptor +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonDecoder +import kotlinx.serialization.json.JsonEncoder +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive @Serializable enum class ProductType { @@ -17,8 +31,36 @@ enum class ProductType { override fun toString() = name.lowercase() } -@Serializable +@Serializable(with = ProductSerializer::class) data class Product( - @SerialName("product") val type: ProductType, - @SerialName("product_id_android") val id: String + val type: ProductType, + val id: String ) + +@Serializer(forClass = Product::class) +object ProductSerializer : KSerializer { + override fun serialize(encoder: Encoder, value: Product) { + // Create a JSON object with custom field names for serialization + val jsonOutput = encoder as? JsonEncoder + ?: throw SerializationException("This class can be saved only by Json") + val jsonObject = buildJsonObject { + put("product", Json.encodeToJsonElement(ProductType.serializer(), value.type)) + put("productId", JsonPrimitive(value.id)) + } + // Encode the JSON object + jsonOutput.encodeJsonElement(jsonObject) + } + + override fun deserialize(decoder: Decoder): Product { + // Decode the JSON object + val jsonInput = decoder as? JsonDecoder + ?: throw SerializationException("This class can be loaded only by Json") + val jsonObject = jsonInput.decodeJsonElement().jsonObject + + // Extract fields using the expected names during deserialization + val type = Json.decodeFromJsonElement(ProductType.serializer(), jsonObject["product"]!!) + val id = jsonObject["product_id_android"]?.jsonPrimitive?.content ?: "" + + return Product(type, id) + } +} \ No newline at end of file diff --git a/superwall/src/main/java/com/superwall/sdk/network/session/CustomURLSession.kt b/superwall/src/main/java/com/superwall/sdk/network/session/CustomURLSession.kt index f657177a..06923715 100644 --- a/superwall/src/main/java/com/superwall/sdk/network/session/CustomURLSession.kt +++ b/superwall/src/main/java/com/superwall/sdk/network/session/CustomURLSession.kt @@ -116,7 +116,7 @@ class CustomHttpUrlConnection { val requestDuration = (System.currentTimeMillis() - startTime) / 1000.0 val requestId = try { getRequestId(request, auth, requestDuration) - } catch (e: Exception) { + } catch (e: Throwable) { throw NetworkError.Unknown() } diff --git a/superwall/src/main/java/com/superwall/sdk/paywall/presentation/PaywallInfo.kt b/superwall/src/main/java/com/superwall/sdk/paywall/presentation/PaywallInfo.kt index de7f275c..117c8358 100644 --- a/superwall/src/main/java/com/superwall/sdk/paywall/presentation/PaywallInfo.kt +++ b/superwall/src/main/java/com/superwall/sdk/paywall/presentation/PaywallInfo.kt @@ -171,7 +171,7 @@ data class PaywallInfo( ) product?.let { - output["product_id"] = it.productIdentifier + output["product_id"] = it.fullIdentifier for (key in it.attributes.keys) { it.attributes[key]?.let { value -> output["product_${key.camelCaseToSnakeCase()}"] = value diff --git a/superwall/src/main/java/com/superwall/sdk/paywall/request/PaywallLogic.kt b/superwall/src/main/java/com/superwall/sdk/paywall/request/PaywallLogic.kt index a8d16d61..426a72ed 100644 --- a/superwall/src/main/java/com/superwall/sdk/paywall/request/PaywallLogic.kt +++ b/superwall/src/main/java/com/superwall/sdk/paywall/request/PaywallLogic.kt @@ -92,8 +92,7 @@ object PaywallLogic { suspend fun getVariablesAndFreeTrial( products: List, productsById: Map, - isFreeTrialAvailableOverride: Boolean?, - isFreeTrialAvailable: suspend (StoreProduct) -> Boolean + isFreeTrialAvailableOverride: Boolean? ): ProductProcessingOutcome { val productVariables = mutableListOf() val swTemplateProductVariables = mutableListOf() @@ -116,7 +115,7 @@ object PaywallLogic { // swTemplateProductVariables.add(swTemplateProductVariable) if (!hasFreeTrial) { - hasFreeTrial = isFreeTrialAvailable(storeProduct) + hasFreeTrial = storeProduct.hasFreeTrial } } diff --git a/superwall/src/main/java/com/superwall/sdk/paywall/request/PaywallRequestManager.kt b/superwall/src/main/java/com/superwall/sdk/paywall/request/PaywallRequestManager.kt index 6e8063da..a3a27788 100644 --- a/superwall/src/main/java/com/superwall/sdk/paywall/request/PaywallRequestManager.kt +++ b/superwall/src/main/java/com/superwall/sdk/paywall/request/PaywallRequestManager.kt @@ -198,8 +198,7 @@ class PaywallRequestManager( val outcome = PaywallLogic.getVariablesAndFreeTrial( result.products, result.productsById, - request.overrides.isFreeTrial, - { id -> storeKitManager.isFreeTrialAvailable(id) } + request.overrides.isFreeTrial ) paywall.productVariables = outcome.productVariables // paywall.swProductVariablesTemplate = outcome.swProductVariablesTemplate diff --git a/superwall/src/main/java/com/superwall/sdk/paywall/vc/web_view/SWWebView.kt b/superwall/src/main/java/com/superwall/sdk/paywall/vc/web_view/SWWebView.kt index 4509672f..dc556286 100644 --- a/superwall/src/main/java/com/superwall/sdk/paywall/vc/web_view/SWWebView.kt +++ b/superwall/src/main/java/com/superwall/sdk/paywall/vc/web_view/SWWebView.kt @@ -58,8 +58,12 @@ class SWWebView( this.setBackgroundColor(Color.TRANSPARENT) - this.webChromeClient = WebChromeClient() - + this.webChromeClient = object : WebChromeClient() { + override fun onConsoleMessage(consoleMessage: ConsoleMessage): Boolean { + // Don't log anything + return true + } + } // Set a WebViewClient this.webViewClient = object : WebViewClient() { override fun shouldOverrideUrlLoading( @@ -107,7 +111,6 @@ class SWWebView( return true } - override fun loadUrl(url: String) { // Parse the url and add the query parameter val uri = Uri.parse(url) @@ -127,7 +130,6 @@ class SWWebView( super.loadUrl(urlString) } - private suspend fun trackPaywallError() { delegate?.paywall?.webviewLoadingInfo?.failAt = Date() diff --git a/superwall/src/main/java/com/superwall/sdk/store/ExternalNativePurchaseController.kt b/superwall/src/main/java/com/superwall/sdk/store/ExternalNativePurchaseController.kt index 43db496b..49bc1b22 100644 --- a/superwall/src/main/java/com/superwall/sdk/store/ExternalNativePurchaseController.kt +++ b/superwall/src/main/java/com/superwall/sdk/store/ExternalNativePurchaseController.kt @@ -8,6 +8,7 @@ import com.superwall.sdk.delegate.PurchaseResult import com.superwall.sdk.delegate.RestorationResult import com.superwall.sdk.delegate.SubscriptionStatus import com.superwall.sdk.delegate.subscription_controller.PurchaseController +import com.superwall.sdk.store.abstractions.product.StoreProduct import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -16,7 +17,6 @@ import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch class ExternalNativePurchaseController(var context: Context) : PurchaseController, PurchasesUpdatedListener { - private var billingClient: BillingClient = BillingClient.newBuilder(context) .setListener(this) .enablePendingPurchases() @@ -63,9 +63,20 @@ class ExternalNativePurchaseController(var context: Context) : PurchaseControlle //region PurchaseController - override suspend fun purchase(activity: Activity, product: ProductDetails): PurchaseResult { + override suspend fun purchase( + activity: Activity, + productDetails: ProductDetails, + basePlanId: String?, + offerId: String? + ): PurchaseResult { + val offerToken = productDetails.subscriptionOfferDetails + ?.firstOrNull { it.offerId == offerId } + ?.offerToken + ?: "" + val productDetailsParams = BillingFlowParams.ProductDetailsParams.newBuilder() - .setProductDetails(product) + .setProductDetails(productDetails) + .setOfferToken(offerToken) .build() val flowParams = BillingFlowParams.newBuilder() @@ -90,7 +101,7 @@ class ExternalNativePurchaseController(var context: Context) : PurchaseControlle billingClient.launchBillingFlow(activity, flowParams) // Wait until a purchase result is emitted before returning the result - val value = purchaseResults.first { it != null } ?: PurchaseResult.Failed(Exception("Purchase failed")) + val value = purchaseResults.first { it != null } ?: PurchaseResult.Failed("Purchase failed") return value } @@ -120,7 +131,7 @@ class ExternalNativePurchaseController(var context: Context) : PurchaseControlle // For all other response codes, create a Failed result with an exception else -> { - PurchaseResult.Failed(Exception(billingResult.responseCode.toString())) + PurchaseResult.Failed(billingResult.responseCode.toString()) } } diff --git a/superwall/src/main/java/com/superwall/sdk/store/InternalPurchaseController.kt b/superwall/src/main/java/com/superwall/sdk/store/InternalPurchaseController.kt index fc14b3f5..008c26dc 100644 --- a/superwall/src/main/java/com/superwall/sdk/store/InternalPurchaseController.kt +++ b/superwall/src/main/java/com/superwall/sdk/store/InternalPurchaseController.kt @@ -79,14 +79,19 @@ class InternalPurchaseController( } } - override suspend fun purchase(activity: Activity, product: ProductDetails): PurchaseResult { + override suspend fun purchase( + activity: Activity, + productDetails: ProductDetails, + basePlanId: String?, + offerId: String? + ): PurchaseResult { // TODO: Await beginPurchase with purchasing coordinator: https://linear.app/superwall/issue/SW-2415/[android]-implement-purchasingcoordinator if (kotlinPurchaseController != null) { - return kotlinPurchaseController.purchase(activity, product) + return kotlinPurchaseController.purchase(activity, productDetails, basePlanId, offerId) } else if (javaPurchaseController != null) { return suspendCoroutine { continuation -> - javaPurchaseController.purchase(product) { result -> + javaPurchaseController.purchase(productDetails, basePlanId, offerId) { result -> continuation.resume(result) } } @@ -118,7 +123,7 @@ class InternalPurchaseController( if (kotlinPurchaseController != null) { return kotlinPurchaseController.restorePurchases() } else if (javaPurchaseController != null) { - return suspendCoroutine { continuation -> + return suspendCoroutine { continuation -> javaPurchaseController.restorePurchases { result, error -> if (error == null) { continuation.resume(result) diff --git a/superwall/src/main/java/com/superwall/sdk/store/StoreKitManager.kt b/superwall/src/main/java/com/superwall/sdk/store/StoreKitManager.kt index d77e4bf1..b553dc7f 100644 --- a/superwall/src/main/java/com/superwall/sdk/store/StoreKitManager.kt +++ b/superwall/src/main/java/com/superwall/sdk/store/StoreKitManager.kt @@ -139,7 +139,7 @@ class StoreKitManager( val productsById = processingResult.substituteProductsById.toMutableMap() for (product in products) { - val productIdentifier = product.productIdentifier + val productIdentifier = product.fullIdentifier productsById[productIdentifier] = product this.productsById[productIdentifier] = product } @@ -157,7 +157,7 @@ class StoreKitManager( var products: MutableList = responseProducts.toMutableList() fun storeAndSubstitute(product: StoreProduct, type: ProductType, index: Int) { - val id = product.productIdentifier + val id = product.fullIdentifier substituteProductsById[id] = product this.productsById[id] = product val product = Product(type = type, id = id) @@ -206,10 +206,6 @@ class StoreKitManager( receiptManager.loadPurchasedProducts() } - suspend fun isFreeTrialAvailable(product: StoreProduct): Boolean { - return receiptManager.isFreeTrialAvailable(product) - } - override suspend fun products( identifiers: Set, paywallName: String? @@ -219,5 +215,4 @@ class StoreKitManager( paywallName ) } -} - +} \ No newline at end of file diff --git a/superwall/src/main/java/com/superwall/sdk/store/abstractions/product/RawStoreProduct.kt b/superwall/src/main/java/com/superwall/sdk/store/abstractions/product/RawStoreProduct.kt index 4daefbd3..fcf7cd18 100644 --- a/superwall/src/main/java/com/superwall/sdk/store/abstractions/product/RawStoreProduct.kt +++ b/superwall/src/main/java/com/superwall/sdk/store/abstractions/product/RawStoreProduct.kt @@ -17,8 +17,9 @@ import java.util.Locale class RawStoreProduct( val underlyingProductDetails: ProductDetails, - private val basePlanId: String?, - private val offerType: OfferType? + override val fullIdentifier: String, + val basePlanId: String?, + val offerType: OfferType? ) : StoreProductType { @Transient private val priceFormatterProvider = PriceFormatterProvider() @@ -209,7 +210,7 @@ class RawStoreProduct( return BigDecimal(pricingPhase).divide(BigDecimal(1_000_000), 6, RoundingMode.DOWN) } - override val hasFreeTrial: Boolean + override val hasFreeTrial: Boolean get() { if (underlyingProductDetails.oneTimePurchaseOfferDetails != null) { return false @@ -251,18 +252,16 @@ class RawStoreProduct( } ?: BigDecimal.ZERO } - private fun getSelectedOffer(): SubscriptionOfferDetails? { + fun getSelectedOffer(): SubscriptionOfferDetails? { if (underlyingProductDetails.oneTimePurchaseOfferDetails != null) { return null } // Retrieve the subscription offer details from the product details val subscriptionOfferDetails = underlyingProductDetails.subscriptionOfferDetails ?: return null - // TODO: Deal with one time offer here - // If there's no base plan ID, just return the first offer (there should only be one). - // TODO: Test that subscriptionOfferDetails only returns one offer when no base plan ID set. + // If there's no base plan ID, return the first base plan we come across. if (basePlanId == null) { - return subscriptionOfferDetails.firstOrNull() + return subscriptionOfferDetails.firstOrNull { it.pricingPhases.pricingPhaseList.size == 1 } } // Get the offers that match the given base plan ID. diff --git a/superwall/src/main/java/com/superwall/sdk/store/abstractions/product/SerializableProductDetails.kt b/superwall/src/main/java/com/superwall/sdk/store/abstractions/product/SerializableProductDetails.kt deleted file mode 100644 index cabbeac5..00000000 --- a/superwall/src/main/java/com/superwall/sdk/store/abstractions/product/SerializableProductDetails.kt +++ /dev/null @@ -1,85 +0,0 @@ -package com.superwall.sdk.store.abstractions.product - -import com.android.billingclient.api.ProductDetails -import com.android.billingclient.api.ProductDetails.OneTimePurchaseOfferDetails -import com.android.billingclient.api.ProductDetails.PricingPhase -import com.android.billingclient.api.ProductDetails.PricingPhases -import com.android.billingclient.api.ProductDetails.SubscriptionOfferDetails -import kotlinx.serialization.Serializable - -@Serializable -data class SerializableOneTimePurchaseOfferDetails( - val priceAmountMicros: Long -) - -@Serializable -data class SerializablePricingPhase( - val billingCycleCount: Int, - val recurrencyMode: Int, - val priceAmountMicros: Long, - val billingPeriod: String, - val formattedPrice: String, - val priceCurrencyCode: String -) - -@Serializable -data class SerializablePricingPhases( - val pricingPhaseList: List -) - -@Serializable -data class SerializableSubscriptionOfferDetails( - val pricingPhases: SerializablePricingPhases, - val offerTags: List, - val basePlanId: String, - val offerId: String? -) - -@Serializable -data class SerializableProductDetails( - val oneTimePurchaseOfferDetails: SerializableOneTimePurchaseOfferDetails?, - val subscriptionOfferDetails: List?, - val productId: String, - val title: String, - val description: String, - val productType: String -) { - companion object { - fun from(productDetails: ProductDetails): SerializableProductDetails { - val oneTimePurchaseOfferDetails = productDetails.oneTimePurchaseOfferDetails?.let { - SerializableOneTimePurchaseOfferDetails( - priceAmountMicros = it.priceAmountMicros - ) - } - - val subscriptionOfferDetails = productDetails.subscriptionOfferDetails?.map { offerDetails -> - SerializableSubscriptionOfferDetails( - pricingPhases = SerializablePricingPhases( - pricingPhaseList = offerDetails.pricingPhases.pricingPhaseList.map { pricingPhase -> - SerializablePricingPhase( - billingCycleCount = pricingPhase.billingCycleCount, - recurrencyMode = pricingPhase.recurrenceMode, - priceAmountMicros = pricingPhase.priceAmountMicros, - billingPeriod = pricingPhase.billingPeriod, - formattedPrice = pricingPhase.formattedPrice, - priceCurrencyCode = pricingPhase.priceCurrencyCode - ) - } - ), - offerTags = offerDetails.offerTags, - basePlanId = offerDetails.basePlanId, - offerId = offerDetails.offerId - ) - } - - return SerializableProductDetails( - oneTimePurchaseOfferDetails = oneTimePurchaseOfferDetails, - subscriptionOfferDetails = subscriptionOfferDetails, - productId = productDetails.productId, - title = productDetails.title, - description = productDetails.description, - productType = productDetails.productType - ) - } - } -} diff --git a/superwall/src/main/java/com/superwall/sdk/store/abstractions/product/StoreProduct.kt b/superwall/src/main/java/com/superwall/sdk/store/abstractions/product/StoreProduct.kt index 2b71d5d6..f7b91023 100644 --- a/superwall/src/main/java/com/superwall/sdk/store/abstractions/product/StoreProduct.kt +++ b/superwall/src/main/java/com/superwall/sdk/store/abstractions/product/StoreProduct.kt @@ -18,12 +18,21 @@ import java.util.* @Serializable sealed class OfferType { object Auto : OfferType() - data class Offer(val id: String) : OfferType() + data class Offer(override val id: String) : OfferType() + + open val id: String? + get() = when (this) { + is Offer -> id + else -> null + } } + class StoreProduct( val rawStoreProduct: RawStoreProduct ) : StoreProductType { + override val fullIdentifier: String + get() = rawStoreProduct.fullIdentifier override val productIdentifier: String get() = rawStoreProduct.productIdentifier diff --git a/superwall/src/main/java/com/superwall/sdk/store/abstractions/product/StoreProductType.kt b/superwall/src/main/java/com/superwall/sdk/store/abstractions/product/StoreProductType.kt index 2b7e046d..2580b6c6 100644 --- a/superwall/src/main/java/com/superwall/sdk/store/abstractions/product/StoreProductType.kt +++ b/superwall/src/main/java/com/superwall/sdk/store/abstractions/product/StoreProductType.kt @@ -4,6 +4,7 @@ import java.math.BigDecimal import java.util.* interface StoreProductType { + val fullIdentifier: String val productIdentifier: String val price: BigDecimal val localizedPrice: String diff --git a/superwall/src/main/java/com/superwall/sdk/store/abstractions/product/receipt/ReceiptManager.kt b/superwall/src/main/java/com/superwall/sdk/store/abstractions/product/receipt/ReceiptManager.kt index 22243864..85c3fd60 100644 --- a/superwall/src/main/java/com/superwall/sdk/store/abstractions/product/receipt/ReceiptManager.kt +++ b/superwall/src/main/java/com/superwall/sdk/store/abstractions/product/receipt/ReceiptManager.kt @@ -70,20 +70,6 @@ class ReceiptManager( return@coroutineScope emptySet() } - fun isFreeTrialAvailable(product: StoreProduct): Boolean { -// if (!product.hasFreeTrial) { -// return false -// } -// return if (product.subscriptionGroupIdentifier != null && purchasedSubscriptionGroupIds != null) { -// !purchasedSubscriptionGroupIds!!.contains(product.subscriptionGroupIdentifier) -// } else { -// !hasPurchasedProduct(product.productIdentifier) -// } - // SW-2218 - // https://linear.app/superwall/issue/SW-2218/%5Bandroid%5D-%5Bv0%5D-replace-receipt-validation-with-google-play-billing - return true - } - suspend fun refreshReceipt() { Logger.debug( logLevel = LogLevel.debug, diff --git a/superwall/src/main/java/com/superwall/sdk/store/products/GooglePlayProductsFetcher.kt b/superwall/src/main/java/com/superwall/sdk/store/products/GooglePlayProductsFetcher.kt index ec6d99a1..47fdfc10 100644 --- a/superwall/src/main/java/com/superwall/sdk/store/products/GooglePlayProductsFetcher.kt +++ b/superwall/src/main/java/com/superwall/sdk/store/products/GooglePlayProductsFetcher.kt @@ -5,7 +5,6 @@ import com.android.billingclient.api.* import com.superwall.sdk.billing.GoogleBillingWrapper import com.superwall.sdk.store.abstractions.product.OfferType import com.superwall.sdk.store.abstractions.product.RawStoreProduct -import com.superwall.sdk.store.abstractions.product.SerializableProductDetails import com.superwall.sdk.store.abstractions.product.StoreProduct import com.superwall.sdk.store.coordinator.ProductsFetcher import kotlinx.coroutines.* @@ -21,11 +20,15 @@ private const val RECONNECT_TIMER_START_MILLISECONDS = 1L * 1000L private const val RECONNECT_TIMER_MAX_TIME_MILLISECONDS = 1000L * 60L * 15L // 15 minutes data class ProductIds( + val subscriptionId: String, val basePlanId: String?, - val offerType: OfferType? + val offerType: OfferType?, + val fullId: String ) { companion object { - fun from(components: List): ProductIds { + fun from(productId: String): ProductIds { + val components = productId.split(":") + val subscriptionId = components.getOrNull(0) ?: "" val basePlanId = components.getOrNull(1) val offerId = components.getOrNull(2) var offerType: OfferType? = null @@ -35,7 +38,12 @@ data class ProductIds( } else if (offerId != null) { offerType = OfferType.Offer(id = offerId) } - return ProductIds(basePlanId, offerType) + return ProductIds( + subscriptionId = subscriptionId, + basePlanId = basePlanId, + offerType = offerType, + fullId = productId + ) } } } @@ -60,43 +68,62 @@ open class GooglePlayProductsFetcher(var context: Context, var billingWrapper: G protected fun request(productIds: List) { scope.launch { + // Get the current results from _results value val currentResults = _results.value - println("!!! Current results ${currentResults.size}") - var subscriptionIdsToLoad: List = emptyList() + // Initialize a set to hold unique subscription IDs to be loaded + var subscriptionIdsToLoad: Set = emptySet() + + // Iterate through each product ID productIds.forEach { productId -> + // Check if the result for the current product ID is already available val result = currentResults[productId] println("Result for $productId is $result ${Thread.currentThread().name}") + + // If the result is null, process the product ID if (result == null) { - val components = productId.split(":") - val subscriptionId = components.getOrNull(0) ?: "" - productIdsBySubscriptionId[subscriptionId] = ProductIds.from(components) - subscriptionIdsToLoad = subscriptionIdsToLoad + subscriptionId + // Parse the product ID into a ProductIds object + val productIds = ProductIds.from(productId) + // Map the subscription ID to its corresponding ProductIds object + productIdsBySubscriptionId[productIds.subscriptionId] = productIds + // Add the subscription ID to the set of IDs to load + subscriptionIdsToLoad = subscriptionIdsToLoad + productIds.subscriptionId } } - print("!!! Requesting ${subscriptionIdsToLoad.size} products") + println("!!! Requesting ${subscriptionIdsToLoad.size} products") - if (!subscriptionIdsToLoad.isEmpty()) { + // Check if there are any subscription IDs to load + if (subscriptionIdsToLoad.isNotEmpty()) { + // Emit a waiting result for each subscription ID to load _results.emit( - _results.value + subscriptionIdsToLoad.map { - it to Result.Waiting( - startedAt = System.currentTimeMillis().toInt() - ) + _results.value + subscriptionIdsToLoad.associateWith { + Result.Waiting(startedAt = System.currentTimeMillis().toInt()) } ) - println("!! Querying product details for ${subscriptionIdsToLoad.size} products, prodcuts: ${subscriptionIdsToLoad} ${Thread.currentThread().name}") + // Log the querying of product details + println("!! Querying product details for ${subscriptionIdsToLoad.size} products, products: ${subscriptionIdsToLoad} ${Thread.currentThread().name}") + + // Perform the network request to get product details val networkResult = runBlocking { - queryProductDetails(subscriptionIdsToLoad) + queryProductDetails(subscriptionIdsToLoad.toList()) } println("!! networkResult: ${networkResult} ${Thread.currentThread().name}") - _results.emit( - _results.value + networkResult.mapValues { it.value } - ) - } + // Update the results with the network query results, mapped to full product IDs + val updatedResults = networkResult.entries.associate { (subscriptionId, result) -> + // Retrieve the full product ID using the subscription ID + val fullProductId = productIdsBySubscriptionId[subscriptionId]?.fullId ?: subscriptionId + // Associate the full product ID with the query result + println("!!!! ASSOCIATE $fullProductId") + fullProductId to result + } + + // Emit the updated results to _results + _results.emit(_results.value + updatedResults) + } } } @@ -111,7 +138,6 @@ open class GooglePlayProductsFetcher(var context: Context, var billingWrapper: G return _results.value.filterKeys { it in productIds } } - open suspend fun queryProductDetails(productIds: List): Map> { if (productIds.isEmpty()) { return emptyMap() @@ -207,6 +233,7 @@ open class GooglePlayProductsFetcher(var context: Context, var billingWrapper: G Result.Success( RawStoreProduct( underlyingProductDetails = productDetails, + fullIdentifier = productIds?.fullId ?: "", basePlanId = productIds?.basePlanId, offerType = productIds?.offerType ) diff --git a/superwall/src/main/java/com/superwall/sdk/store/transactions/TransactionManager.kt b/superwall/src/main/java/com/superwall/sdk/store/transactions/TransactionManager.kt index 2c9db62a..b14ef87f 100644 --- a/superwall/src/main/java/com/superwall/sdk/store/transactions/TransactionManager.kt +++ b/superwall/src/main/java/com/superwall/sdk/store/transactions/TransactionManager.kt @@ -3,6 +3,7 @@ package com.superwall.sdk.store.transactions import LogLevel import LogScope import Logger +import com.android.billingclient.api.ProductDetails import com.superwall.sdk.Superwall import com.superwall.sdk.analytics.SessionEventsManager import com.superwall.sdk.analytics.internal.track @@ -25,6 +26,7 @@ import com.superwall.sdk.paywall.vc.delegate.PaywallLoadingState import com.superwall.sdk.store.StoreKitManager import com.superwall.sdk.store.abstractions.product.StoreProduct import com.superwall.sdk.store.abstractions.transactions.StoreTransaction +import com.superwall.sdk.store.products.ProductIds import kotlinx.coroutines.* class TransactionManager( @@ -36,16 +38,23 @@ class TransactionManager( interface Factory: OptionsFactory, TriggerFactory, TransactionVerifierFactory, StoreTransactionFactory {} private var lastPaywallViewController: PaywallViewController? = null - suspend fun purchase(productId: String, paywallViewController: PaywallViewController) { + suspend fun purchase( + productId: String, + paywallViewController: PaywallViewController + ) { val product = storeKitManager.productsById[productId] ?: return - + val rawStoreProduct = product.rawStoreProduct + println("!!! Purchasing product ${rawStoreProduct.hasFreeTrial}") + val productDetails = rawStoreProduct.underlyingProductDetails val activity = activityProvider.getCurrentActivity() ?: return prepareToStartTransaction(product, paywallViewController) val result = storeKitManager.purchaseController.purchase( - activity, - product.rawStoreProduct.underlyingProductDetails + activity = activity, + productDetails = productDetails, + offerId = rawStoreProduct.offerType?.id, + basePlanId = rawStoreProduct.basePlanId ) when (result) { @@ -60,18 +69,18 @@ class TransactionManager( if (shouldShowPurchaseFailureAlert && !transactionFailExists) { trackFailure( - result.error, + result.errorMessage, product, paywallViewController ) presentAlert( - Error(result.error), + Error(result.errorMessage), product, paywallViewController ) } else { trackFailure( - result.error, + result.errorMessage, product, paywallViewController ) @@ -88,7 +97,7 @@ class TransactionManager( } private fun trackFailure( - error: Throwable, + errorMessage: String, product: StoreProduct, paywallViewController: PaywallViewController ) { @@ -96,19 +105,18 @@ class TransactionManager( Logger.debug( logLevel = LogLevel.debug, scope = LogScope.paywallTransactions, - message = "Transaction Error", + message = "Transaction Error: $errorMessage", info = mapOf( - "product_id" to product.productIdentifier, + "product_id" to product.fullIdentifier, "paywall_vc" to paywallViewController - ), - error = error + ) ) // Launch a coroutine to handle async tasks CoroutineScope(Dispatchers.Default).launch { val paywallInfo = paywallViewController.info val trackedEvent = InternalSuperwallEvent.Transaction( - state = InternalSuperwallEvent.Transaction.State.Fail(TransactionError.Failure(error.localizedMessage, product)), + state = InternalSuperwallEvent.Transaction.State.Fail(TransactionError.Failure(errorMessage, product)), paywallInfo = paywallInfo, product = product, model = null @@ -160,7 +168,7 @@ class TransactionManager( LogScope.paywallTransactions, "Transaction Succeeded", mapOf( - "product_id" to product.productIdentifier, + "product_id" to product.fullIdentifier, "paywall_vc" to paywallViewController ), null @@ -183,7 +191,7 @@ class TransactionManager( if (Superwall.instance.options.paywalls.automaticallyDismiss) { Superwall.instance.dismiss( paywallViewController, - PaywallResult.Purchased(product.productIdentifier) + PaywallResult.Purchased(product.fullIdentifier) ) } } @@ -198,7 +206,7 @@ class TransactionManager( LogScope.paywallTransactions, "Transaction Abandoned", mapOf( - "product_id" to product.productIdentifier, + "product_id" to product.fullIdentifier, "paywall_vc" to paywallViewController ), null @@ -257,7 +265,7 @@ class TransactionManager( LogScope.paywallTransactions, "Transaction Error", mapOf( - "product_id" to product.productIdentifier, + "product_id" to product.fullIdentifier, "paywall_vc" to paywallViewController ), error From fbd8441791a904af31b18c1255c18e665eaf2682 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= Date: Thu, 30 Nov 2023 15:46:26 -0500 Subject: [PATCH 09/23] Bug fixes for RawStoreProduct output values and RC purchase controller --- .../com/superwall/superapp/MainApplication.kt | 2 +- .../purchase/RevenueCatPurchaseController.kt | 8 ++++++- .../abstractions/product/RawStoreProduct.kt | 8 +++---- .../product/SubscriptionPeriod.kt | 23 ++++++++++--------- 4 files changed, 23 insertions(+), 18 deletions(-) diff --git a/app/src/main/java/com/superwall/superapp/MainApplication.kt b/app/src/main/java/com/superwall/superapp/MainApplication.kt index 918385b5..48e94b59 100644 --- a/app/src/main/java/com/superwall/superapp/MainApplication.kt +++ b/app/src/main/java/com/superwall/superapp/MainApplication.kt @@ -20,7 +20,7 @@ class MainApplication : android.app.Application(), SuperwallDelegate { SessionStart: pk_6c881299e2f8db59f697646e399397be76432fa0968ca254 PaywallDecline: pk_a1071d541642719e2dc854da9ec717ec967b8908854ede74 TransactionAbandon: pk_9c99186b023ae795e0189cf9cdcd3e2d2d174289e0800d66 - TransacionFail: pk_b6cd945401435766da627080a3fbe349adb2dcd69ab767f3 + TransactionFail: pk_b6cd945401435766da627080a3fbe349adb2dcd69ab767f3 SurveyResponse: pk_3698d9fe123f1e4aa8014ceca111096ca06fd68d31d9e662 */ } diff --git a/app/src/main/java/com/superwall/superapp/purchase/RevenueCatPurchaseController.kt b/app/src/main/java/com/superwall/superapp/purchase/RevenueCatPurchaseController.kt index 2eec61ff..0b4af533 100644 --- a/app/src/main/java/com/superwall/superapp/purchase/RevenueCatPurchaseController.kt +++ b/app/src/main/java/com/superwall/superapp/purchase/RevenueCatPurchaseController.kt @@ -11,6 +11,7 @@ import com.revenuecat.purchases.interfaces.UpdatedCustomerInfoListener import com.revenuecat.purchases.models.StoreProduct import com.revenuecat.purchases.models.StoreTransaction import com.revenuecat.purchases.models.SubscriptionOption +import com.revenuecat.purchases.models.googleProduct import com.superwall.sdk.Superwall import com.superwall.sdk.delegate.PurchaseResult import com.superwall.sdk.delegate.RestorationResult @@ -114,7 +115,12 @@ class RevenueCatPurchaseController(val context: Context): PurchaseController, Up offerId: String? ): PurchaseResult { val products = Purchases.sharedInstance.awaitProducts(listOf(productDetails.productId)) - val product = products.firstOrNull() ?: return PurchaseResult.Failed("Product not found") + + // Choose the product which matches the given base plan. + // If no base plan set, select first product + val product = products.firstOrNull { it.googleProduct?.basePlanId == basePlanId } + ?: products.firstOrNull() + ?: return PurchaseResult.Failed("Product not found") return when (product.type) { ProductType.SUBS, ProductType.UNKNOWN -> handleSubscription(activity, product, basePlanId, offerId) diff --git a/superwall/src/main/java/com/superwall/sdk/store/abstractions/product/RawStoreProduct.kt b/superwall/src/main/java/com/superwall/sdk/store/abstractions/product/RawStoreProduct.kt index fcf7cd18..d42fe2c3 100644 --- a/superwall/src/main/java/com/superwall/sdk/store/abstractions/product/RawStoreProduct.kt +++ b/superwall/src/main/java/com/superwall/sdk/store/abstractions/product/RawStoreProduct.kt @@ -38,13 +38,12 @@ class RawStoreProduct( return BigDecimal(offerDetails.priceAmountMicros).divide(BigDecimal(1_000_000), 6, RoundingMode.DOWN) } - return basePriceForSelectedOffer().divide(BigDecimal(1_000_000), 6, RoundingMode.DOWN) + return basePriceForSelectedOffer() } override val localizedPrice: String get() { - val basePrice = basePriceForSelectedOffer() - return priceFormatter?.format(basePrice) ?: "" + return priceFormatter?.format(price) ?: "" } override val localizedSubscriptionPeriod: String @@ -198,7 +197,6 @@ class RawStoreProduct( } val subscriptionPeriod = this.subscriptionPeriod ?: return "n/a" - val pricePerYear = subscriptionPeriod.pricePerYear(basePrice) return priceFormatter?.format(pricePerYear) ?: "n/a" @@ -207,7 +205,7 @@ class RawStoreProduct( private fun basePriceForSelectedOffer(): BigDecimal { val selectedOffer = getSelectedOffer() ?: return BigDecimal.ZERO val pricingPhase = selectedOffer.pricingPhases.pricingPhaseList.last().priceAmountMicros - return BigDecimal(pricingPhase).divide(BigDecimal(1_000_000), 6, RoundingMode.DOWN) + return BigDecimal(pricingPhase).divide(BigDecimal(1_000_000), 2, RoundingMode.DOWN) } override val hasFreeTrial: Boolean diff --git a/superwall/src/main/java/com/superwall/sdk/store/abstractions/product/SubscriptionPeriod.kt b/superwall/src/main/java/com/superwall/sdk/store/abstractions/product/SubscriptionPeriod.kt index 8e2460d4..96fe64ba 100644 --- a/superwall/src/main/java/com/superwall/sdk/store/abstractions/product/SubscriptionPeriod.kt +++ b/superwall/src/main/java/com/superwall/sdk/store/abstractions/product/SubscriptionPeriod.kt @@ -66,7 +66,8 @@ data class SubscriptionPeriod(val value: Int, val unit: Unit) { } private val roundingMode = RoundingMode.DOWN - private val scale = 2 + private val calculationScale = 7 + private val outputScale = 2 fun pricePerDay(price: BigDecimal): BigDecimal { val periodsPerDay: BigDecimal = when (this.unit) { @@ -76,39 +77,39 @@ data class SubscriptionPeriod(val value: Int, val unit: Unit) { SubscriptionPeriod.Unit.year -> BigDecimal(365) } * BigDecimal(this.value) - return price.divide(periodsPerDay, scale, roundingMode) + return price.divide(periodsPerDay, outputScale, roundingMode) } fun pricePerWeek(price: BigDecimal): BigDecimal { val periodsPerWeek: BigDecimal = when (this.unit) { - SubscriptionPeriod.Unit.day -> BigDecimal.ONE.divide(BigDecimal(7), scale, roundingMode) + SubscriptionPeriod.Unit.day -> BigDecimal.ONE.divide(BigDecimal(7)) SubscriptionPeriod.Unit.week -> BigDecimal.ONE SubscriptionPeriod.Unit.month -> BigDecimal(4) SubscriptionPeriod.Unit.year -> BigDecimal(52) } * BigDecimal(this.value) - return price.divide(periodsPerWeek, scale, roundingMode) + return price.divide(periodsPerWeek, outputScale, roundingMode) } fun pricePerMonth(price: BigDecimal): BigDecimal { val periodsPerMonth: BigDecimal = when (this.unit) { - SubscriptionPeriod.Unit.day -> BigDecimal.ONE.divide(BigDecimal(30), scale, roundingMode) - SubscriptionPeriod.Unit.week -> BigDecimal.ONE.divide(BigDecimal(30.0 / 7.0), scale, roundingMode) + SubscriptionPeriod.Unit.day -> BigDecimal.ONE.divide(BigDecimal(30), calculationScale, roundingMode) + SubscriptionPeriod.Unit.week -> BigDecimal.ONE.divide(BigDecimal(30.0 / 7.0), calculationScale, roundingMode) SubscriptionPeriod.Unit.month -> BigDecimal.ONE SubscriptionPeriod.Unit.year -> BigDecimal(12) } * BigDecimal(this.value) - return price.divide(periodsPerMonth, scale, roundingMode) + return price.divide(periodsPerMonth, outputScale, roundingMode) } fun pricePerYear(price: BigDecimal): BigDecimal { val periodsPerYear: BigDecimal = when (this.unit) { - SubscriptionPeriod.Unit.day -> BigDecimal.ONE.divide(BigDecimal(365), scale, roundingMode) - SubscriptionPeriod.Unit.week -> BigDecimal.ONE.divide(BigDecimal(365.0 / 7), scale, roundingMode) - SubscriptionPeriod.Unit.month -> BigDecimal.ONE.divide(BigDecimal(12), scale, roundingMode) + SubscriptionPeriod.Unit.day -> BigDecimal.ONE.divide(BigDecimal(365), calculationScale, roundingMode) + SubscriptionPeriod.Unit.week -> BigDecimal.ONE.divide(BigDecimal(52), calculationScale, roundingMode) + SubscriptionPeriod.Unit.month -> BigDecimal.ONE.divide(BigDecimal(12), calculationScale, roundingMode) SubscriptionPeriod.Unit.year -> BigDecimal.ONE }.multiply(BigDecimal(this.value)) - return price.divide(periodsPerYear, scale, roundingMode) + return price.divide(periodsPerYear, outputScale, roundingMode) } } From 621f8ac5000fcb10309acb22b20e08f6ece0e57c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= Date: Fri, 1 Dec 2023 19:25:24 -0500 Subject: [PATCH 10/23] Added in testing for products and bug fixes --- gradle/libs.versions.toml | 3 + superwall/build.gradle.kts | 5 +- .../ExpressionEvaluatorInstrumentedTest.kt | 27 +- .../com/superwall/sdk/storage/StorageMock.kt | 4 + .../sdk/store/products/ProductFetcherTest.kt | 100 ---- .../paywall/request/PaywallRequestManager.kt | 1 - .../superwall/sdk/store/StoreKitManager.kt | 12 +- .../abstractions/product/RawStoreProduct.kt | 44 +- .../store/coordinator/CoordinatorProtocols.kt | 3 +- .../products/GooglePlayProductsFetcher.kt | 14 +- .../superwall/sdk/config/ConfigLogicTest.kt | 55 +- .../sdk/products/BillingClientStubs.kt | 61 +++ .../sdk/products/ProductFetcherTest.kt | 489 ++++++++++++++++++ 13 files changed, 666 insertions(+), 152 deletions(-) delete mode 100644 superwall/src/androidTest/java/com/superwall/sdk/store/products/ProductFetcherTest.kt create mode 100644 superwall/src/test/java/com/superwall/sdk/products/BillingClientStubs.kt create mode 100644 superwall/src/test/java/com/superwall/sdk/products/ProductFetcherTest.kt diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index d0884453..ba43d788 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -2,6 +2,7 @@ billing_version = "6.1.0" browser_version = "1.5.0" gradle_plugin_version = "7.4.2" +mockk = "1.13.8" revenue_cat_version = "6.0.0" compose_version = "2022.10.00" kotlinx_serialization_json_version = "1.5.1" @@ -21,6 +22,7 @@ test_runner_version = "1.4.0" test_rules_version = "1.4.0" kotlin = "1.8.21" kotlinx_coroutines_core_version = "1.7.1" +mockk_version = "1.12.8" [libraries] @@ -58,6 +60,7 @@ gson = { module = "com.google.code.gson:gson", version.ref = "gson_version" } # Test junit = { module = "junit:junit", version.ref = "junit_version" } kotlinx_coroutines_test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "kotlinx_coroutines_test_version" } +mockk = { module = "io.mockk:mockk", version.ref = "mockk_version" } # Test (Android) test_ext_junit = { module = "androidx.test.ext:junit", version.ref = "test_ext_junit_version" } diff --git a/superwall/build.gradle.kts b/superwall/build.gradle.kts index b4886fb8..c4e9a8e9 100644 --- a/superwall/build.gradle.kts +++ b/superwall/build.gradle.kts @@ -73,7 +73,9 @@ android { jvmTarget = "1.8" } - testOptions { } + packagingOptions { + resources.excludes += "META-INF/LICENSE.md" + } publishing { singleVariant("release") { @@ -161,6 +163,7 @@ dependencies { // Test testImplementation(libs.junit) testImplementation(libs.kotlinx.coroutines.test) + testImplementation(libs.mockk) // ??? Not sure if we need this // testImplementation("org.json:json:20210307") diff --git a/superwall/src/androidTest/java/com/superwall/sdk/paywall/presentation/rule_logic/expression_evaluator/ExpressionEvaluatorInstrumentedTest.kt b/superwall/src/androidTest/java/com/superwall/sdk/paywall/presentation/rule_logic/expression_evaluator/ExpressionEvaluatorInstrumentedTest.kt index eb226fdb..4870e607 100644 --- a/superwall/src/androidTest/java/com/superwall/sdk/paywall/presentation/rule_logic/expression_evaluator/ExpressionEvaluatorInstrumentedTest.kt +++ b/superwall/src/androidTest/java/com/superwall/sdk/paywall/presentation/rule_logic/expression_evaluator/ExpressionEvaluatorInstrumentedTest.kt @@ -6,6 +6,7 @@ import com.superwall.sdk.dependencies.RuleAttributesFactory import com.superwall.sdk.models.events.EventData import com.superwall.sdk.models.triggers.Experiment import com.superwall.sdk.models.triggers.MatchedItem +import com.superwall.sdk.models.triggers.TriggerPreloadBehavior import com.superwall.sdk.models.triggers.TriggerRule import com.superwall.sdk.models.triggers.TriggerRuleOutcome import com.superwall.sdk.models.triggers.UnmatchedRule @@ -58,6 +59,10 @@ class ExpressionEvaluatorInstrumentedTest { ), expression = "user.id == '123'", expressionJs = null, + preload = TriggerRule.TriggerPreload( + behavior = TriggerPreloadBehavior.ALWAYS, + requiresReEvaluation = false + ) ) val result = expressionEvaluator.evaluateExpression( @@ -97,6 +102,10 @@ class ExpressionEvaluatorInstrumentedTest { ), expression = null, expressionJs = "function superwallEvaluator(){ return true }; superwallEvaluator", + preload = TriggerRule.TriggerPreload( + behavior = TriggerPreloadBehavior.ALWAYS, + requiresReEvaluation = false + ) ) val falseRule = TriggerRule( @@ -112,6 +121,10 @@ class ExpressionEvaluatorInstrumentedTest { ), expression = null, expressionJs = "function superwallEvaluator(){ return false }; superwallEvaluator", + preload = TriggerRule.TriggerPreload( + behavior = TriggerPreloadBehavior.ALWAYS, + requiresReEvaluation = false + ) ) var trueResult = expressionEvaluator.evaluateExpression( @@ -166,7 +179,11 @@ class ExpressionEvaluatorInstrumentedTest { ) ), expression = "user.id == '123'", - expressionJs = null + expressionJs = null, + preload = TriggerRule.TriggerPreload( + behavior = TriggerPreloadBehavior.ALWAYS, + requiresReEvaluation = false + ) ) val falseRule = TriggerRule( @@ -182,6 +199,10 @@ class ExpressionEvaluatorInstrumentedTest { ), expression = null, expressionJs = "function() { return false; }", + preload = TriggerRule.TriggerPreload( + behavior = TriggerPreloadBehavior.ALWAYS, + requiresReEvaluation = false + ) ) @@ -247,6 +268,10 @@ class ExpressionEvaluatorInstrumentedTest { ), expression = null, expressionJs = null, + preload = TriggerRule.TriggerPreload( + behavior = TriggerPreloadBehavior.ALWAYS, + requiresReEvaluation = false + ) ) val result = expressionEvaluator.evaluateExpression( diff --git a/superwall/src/androidTest/java/com/superwall/sdk/storage/StorageMock.kt b/superwall/src/androidTest/java/com/superwall/sdk/storage/StorageMock.kt index 6ed46e67..10bad014 100644 --- a/superwall/src/androidTest/java/com/superwall/sdk/storage/StorageMock.kt +++ b/superwall/src/androidTest/java/com/superwall/sdk/storage/StorageMock.kt @@ -14,6 +14,10 @@ class StorageFactoryMock : Storage.Factory { return true } + override suspend fun makeSessionDeviceAttributes(): HashMap { + return hashMapOf() + } + override fun makeHasExternalPurchaseController(): Boolean { return true } diff --git a/superwall/src/androidTest/java/com/superwall/sdk/store/products/ProductFetcherTest.kt b/superwall/src/androidTest/java/com/superwall/sdk/store/products/ProductFetcherTest.kt deleted file mode 100644 index 0f90d3af..00000000 --- a/superwall/src/androidTest/java/com/superwall/sdk/store/products/ProductFetcherTest.kt +++ /dev/null @@ -1,100 +0,0 @@ -package com.superwall.sdk.store.products - -import android.content.Context -import androidx.test.ext.junit.runners.AndroidJUnit4 -import androidx.test.platform.app.InstrumentationRegistry -import com.android.billingclient.api.SkuDetails -import com.superwall.sdk.store.abstractions.product.RawStoreProduct -import kotlinx.coroutines.* -import kotlinx.coroutines.test.runTest -import org.junit.Test -import org.junit.runner.RunWith - -// Mock implementation of SkuDetails from Google Billing 4.0 - -val mockSku = """{ - "productId": "premium_subscription", - "type": "subs", - "price": "$9.99", - "price_amount_micros": 9990000, - "price_currency_code": "USD", - "title": "Premium Subscription", - "description": "Unlock all premium features with this subscription.", - "subscriptionPeriod": "P1M", - "freeTrialPeriod": "P7D", - "introductoryPrice": "$4.99", - "introductoryPriceCycles": 1, - "introductoryPrice_period": "P1M" -} -""" - -class MockSkuDetails(jsonDetails: String) : SkuDetails(jsonDetails) { - -} - -class ProductFetcherUnderTest(context: Context) : GooglePlayProductsFetcher(context = context) { - - // We're going to override the query async method to return a list of products - // that we define in the test - - public var productIdsToReturn: Map> = emptyMap() - - - public var queryProductDetailsCalls: List> = emptyList() - - override suspend fun queryProductDetails(productIds: List): Map> { - queryProductDetailsCalls = queryProductDetailsCalls + listOf(productIds) - delay(1000 + (Math.random() * 1000).toLong()) - - // Filter productIdsToReturn, and add success if not found - val result = productIds.map { productId -> - val product = productIdsToReturn[productId] - if (product != null) { - productId to product - } else { - productId to Result.Success( - RawStoreProduct( - underlyingProductDetails = MockSkuDetails(mockSku) - ) - ) - } - }.toMap() - return result - } - -} - -// TODO: https://linear.app/superwall/issue/SW-2368/[android]-fix-product-fetcher-tests -//@RunWith(AndroidJUnit4::class) -//class ProductFetcherInstrumentedTest { -// -// @Test -// fun test_fetch_products_without_connection() = runTest { -// // get context -// val context = InstrumentationRegistry.getInstrumentation().targetContext -// val productFetcher: ProductFetcherUnderTest = ProductFetcherUnderTest(context) -// -// -// val deffereds = listOf( -// async { productFetcher.requestAndAwait(listOf("1", "2")) }, -// async { productFetcher.requestAndAwait(listOf("1", "2", "3")) } -// ) -// deffereds.awaitAll() -// -// print("!!! Defered resutls ${deffereds.map { it.getCompleted() }}") -// -// println("!!! Calls: ${productFetcher.queryProductDetailsCalls}") -// assert(productFetcher.queryProductDetailsCalls.size == 2) -// -// // Check that the first call is for 1 and 2 -// assert(productFetcher.queryProductDetailsCalls[0].size == 2) -// assert(productFetcher.queryProductDetailsCalls[0][0] == "1") -// assert(productFetcher.queryProductDetailsCalls[0][1] == "2") -// -// // Check that the second call is for 3 -// assert(productFetcher.queryProductDetailsCalls[1].size == 1) -// assert(productFetcher.queryProductDetailsCalls[1][0] == "3") -// -// -// } -//} \ No newline at end of file diff --git a/superwall/src/main/java/com/superwall/sdk/paywall/request/PaywallRequestManager.kt b/superwall/src/main/java/com/superwall/sdk/paywall/request/PaywallRequestManager.kt index a3a27788..83814364 100644 --- a/superwall/src/main/java/com/superwall/sdk/paywall/request/PaywallRequestManager.kt +++ b/superwall/src/main/java/com/superwall/sdk/paywall/request/PaywallRequestManager.kt @@ -188,7 +188,6 @@ class PaywallRequestManager( try { val result = storeKitManager.getProducts( paywall.productIds, - paywall.name, paywall.products, request.overrides.products ) diff --git a/superwall/src/main/java/com/superwall/sdk/store/StoreKitManager.kt b/superwall/src/main/java/com/superwall/sdk/store/StoreKitManager.kt index b553dc7f..447b10cf 100644 --- a/superwall/src/main/java/com/superwall/sdk/store/StoreKitManager.kt +++ b/superwall/src/main/java/com/superwall/sdk/store/StoreKitManager.kt @@ -105,7 +105,7 @@ class StoreKitManager( ) suspend fun getProductVariables(paywall: Paywall): List { - val output = getProducts(paywall.productIds, paywall.name) + val output = getProducts(paywall.productIds) val variables = paywall.products.mapNotNull { product -> output.productsById[product.id]?.let { storeProduct -> @@ -121,7 +121,6 @@ class StoreKitManager( suspend fun getProducts( responseProductIds: List, - paywallName: String? = null, responseProducts: List = emptyList(), substituteProducts: PaywallProducts? = null ): GetProductsResponse { @@ -132,8 +131,7 @@ class StoreKitManager( ) val products = products( - identifiers = processingResult.productIdsToLoad, - paywallName + identifiers = processingResult.productIdsToLoad ) val productsById = processingResult.substituteProductsById.toMutableMap() @@ -207,12 +205,10 @@ class StoreKitManager( } override suspend fun products( - identifiers: Set, - paywallName: String? + identifiers: Set ): Set { return productFetcher.products( - identifiers = identifiers, - paywallName + identifiers = identifiers ) } } \ No newline at end of file diff --git a/superwall/src/main/java/com/superwall/sdk/store/abstractions/product/RawStoreProduct.kt b/superwall/src/main/java/com/superwall/sdk/store/abstractions/product/RawStoreProduct.kt index d42fe2c3..075217f6 100644 --- a/superwall/src/main/java/com/superwall/sdk/store/abstractions/product/RawStoreProduct.kt +++ b/superwall/src/main/java/com/superwall/sdk/store/abstractions/product/RawStoreProduct.kt @@ -10,6 +10,7 @@ import java.math.RoundingMode import java.text.NumberFormat import java.text.SimpleDateFormat import java.time.Duration +import java.time.Period import java.util.Calendar import java.util.Currency import java.util.Date @@ -246,17 +247,16 @@ class RawStoreProduct( // Check for discounted phase val discountedPhase = pricingWithoutBase.firstOrNull { it.priceAmountMicros > 0 } return discountedPhase?.let { - BigDecimal(it.priceAmountMicros).divide(BigDecimal(1_000_000), 6, RoundingMode.DOWN) + BigDecimal(it.priceAmountMicros).divide(BigDecimal(1_000_000), 2, RoundingMode.DOWN) } ?: BigDecimal.ZERO } - fun getSelectedOffer(): SubscriptionOfferDetails? { + private fun getSelectedOffer(): SubscriptionOfferDetails? { if (underlyingProductDetails.oneTimePurchaseOfferDetails != null) { return null } // Retrieve the subscription offer details from the product details val subscriptionOfferDetails = underlyingProductDetails.subscriptionOfferDetails ?: return null - // If there's no base plan ID, return the first base plan we come across. if (basePlanId == null) { return subscriptionOfferDetails.firstOrNull { it.pricingPhases.pricingPhaseList.size == 1 } @@ -278,8 +278,7 @@ class RawStoreProduct( // Ignore base plan .filter { it.pricingPhases.pricingPhaseList.size != 1 } // Ignore those with a tag that contains "ignore-offer" - .filter { !it.offerTags.contains("-ignore-offer") } - + .filter { !it.offerTags.any { it.contains("-ignore-offer") }} return findLongestFreeTrial(validOffers) ?: findLowestNonFreeOffer(validOffers) ?: basePlan } is OfferType.Offer -> { @@ -300,19 +299,22 @@ class RawStoreProduct( .firstOrNull { it.priceAmountMicros == 0L }?.let { pricingPhase -> - Pair(offer, Duration.parse(pricingPhase.billingPeriod).toDays()) + val period = Period.parse(pricingPhase.billingPeriod) + val totalDays = period.toTotalMonths() * 30 + period.days + Pair(offer, totalDays) } }.maxByOrNull { it.second }?.first } private fun findLowestNonFreeOffer(offers: List): SubscriptionOfferDetails? { - return offers.mapNotNull { offer -> + val hi = offers.mapNotNull { offer -> offer.pricingPhases.pricingPhaseList.dropLast(1).firstOrNull { it.priceAmountMicros > 0L }?.let { pricingPhase -> Pair(offer, pricingPhase.priceAmountMicros) } }.minByOrNull { it.second }?.first + return hi } override val trialPeriodEndDate: Date? @@ -456,12 +458,12 @@ class RawStoreProduct( return priceFormatter?.format(0) ?: "$0.00" } - val introMonthlyPrice = pricePerUnit( + val introPrice = pricePerUnit( unit = unit, pricingPhase = pricingPhase ) - return priceFormatter?.format(introMonthlyPrice) ?: "$0.00" + return priceFormatter?.format(introPrice) ?: "$0.00" } private fun pricePerUnit( @@ -485,16 +487,22 @@ class RawStoreProduct( } catch (e: Exception) { null } + // TODO: THE PERIODSPERUNIT IS WRONG + val periodPerUnit2 = periodsPerUnit(unit) val introPeriods = periodsPerUnit(unit).multiply(BigDecimal(pricingPhase.billingCycleCount)) .multiply(BigDecimal(trialSubscriptionPeriod?.value ?: 0)) - + println(unit.toString()) + println(introPeriods) + println(periodPerUnit2) + println(trialSubscriptionPeriod?.value) + println(pricingPhase.billingCycleCount) val introPayment: BigDecimal if (introPeriods < BigDecimal.ONE) { // If less than 1, it means the intro period doesn't exceed a full unit. introPayment = introCost } else { // Otherwise, divide the total cost by the normalized intro periods. - introPayment = introCost.divide(introPeriods, RoundingMode.DOWN) + introPayment = introCost.divide(introPeriods, 2, RoundingMode.DOWN) } return introPayment @@ -514,7 +522,7 @@ class RawStoreProduct( } SubscriptionPeriod.Unit.week -> { when (trialSubscriptionPeriod?.unit) { - SubscriptionPeriod.Unit.day -> BigDecimal(1) / BigDecimal(7) + SubscriptionPeriod.Unit.day -> BigDecimal(1).divide(BigDecimal(7), 6, RoundingMode.DOWN) SubscriptionPeriod.Unit.week -> BigDecimal(1) SubscriptionPeriod.Unit.month -> BigDecimal(4) SubscriptionPeriod.Unit.year -> BigDecimal(52) @@ -523,8 +531,8 @@ class RawStoreProduct( } SubscriptionPeriod.Unit.month -> { when (trialSubscriptionPeriod?.unit) { - SubscriptionPeriod.Unit.day -> BigDecimal(1) / BigDecimal(30) - SubscriptionPeriod.Unit.week -> BigDecimal(1) / BigDecimal(4) + SubscriptionPeriod.Unit.day -> BigDecimal(1).divide(BigDecimal(30), 6, RoundingMode.DOWN) + SubscriptionPeriod.Unit.week -> BigDecimal(1).divide(BigDecimal(4), 6, RoundingMode.DOWN) SubscriptionPeriod.Unit.month -> BigDecimal(1) SubscriptionPeriod.Unit.year -> BigDecimal(12) else -> BigDecimal.ZERO @@ -532,10 +540,10 @@ class RawStoreProduct( } SubscriptionPeriod.Unit.year -> { when (trialSubscriptionPeriod?.unit) { - SubscriptionPeriod.Unit.day -> BigDecimal(1) / BigDecimal(365) - SubscriptionPeriod.Unit.week -> BigDecimal(1) / BigDecimal(52) - SubscriptionPeriod.Unit.month -> BigDecimal(1) / BigDecimal(12) - SubscriptionPeriod.Unit.year -> BigDecimal(1) + SubscriptionPeriod.Unit.day -> BigDecimal(1).divide(BigDecimal(365), 6, RoundingMode.DOWN) + SubscriptionPeriod.Unit.week -> BigDecimal(1).divide(BigDecimal(52), 6, RoundingMode.DOWN) + SubscriptionPeriod.Unit.month -> BigDecimal(1).divide(BigDecimal(12), 6, RoundingMode.DOWN) + SubscriptionPeriod.Unit.year -> BigDecimal.ONE else -> BigDecimal.ZERO } } diff --git a/superwall/src/main/java/com/superwall/sdk/store/coordinator/CoordinatorProtocols.kt b/superwall/src/main/java/com/superwall/sdk/store/coordinator/CoordinatorProtocols.kt index d7c73f3c..fe4089c0 100644 --- a/superwall/src/main/java/com/superwall/sdk/store/coordinator/CoordinatorProtocols.kt +++ b/superwall/src/main/java/com/superwall/sdk/store/coordinator/CoordinatorProtocols.kt @@ -12,8 +12,7 @@ interface ProductPurchaser { interface ProductsFetcher { // Fetches a set of products from their identifiers. suspend fun products( - identifiers: Set, - paywallName: String? = null + identifiers: Set ): Set } diff --git a/superwall/src/main/java/com/superwall/sdk/store/products/GooglePlayProductsFetcher.kt b/superwall/src/main/java/com/superwall/sdk/store/products/GooglePlayProductsFetcher.kt index 47fdfc10..bb415a7b 100644 --- a/superwall/src/main/java/com/superwall/sdk/store/products/GooglePlayProductsFetcher.kt +++ b/superwall/src/main/java/com/superwall/sdk/store/products/GooglePlayProductsFetcher.kt @@ -48,7 +48,10 @@ data class ProductIds( } } -open class GooglePlayProductsFetcher(var context: Context, var billingWrapper: GoogleBillingWrapper) : ProductsFetcher, +open class GooglePlayProductsFetcher( + var context: Context, + var billingWrapper: GoogleBillingWrapper +) : ProductsFetcher, PurchasesUpdatedListener { sealed class Result { @@ -170,7 +173,7 @@ open class GooglePlayProductsFetcher(var context: Context, var billingWrapper: G .build() println("!! Querying subscription product details for ${productIds.size} products, products: ${productIds}, ${Thread.currentThread().name}") - billingWrapper.waitForConnectedClient{ + billingWrapper.waitForConnectedClient { queryProductDetailsAsync(subsParams) { billingResult, productDetailsList -> val resultMap = handleProductDetailsResponse(productIds, billingResult, productDetailsList) @@ -230,6 +233,10 @@ open class GooglePlayProductsFetcher(var context: Context, var billingWrapper: G val results = productDetailsList.associateBy { it.productId } .mapValues { (_, productDetails) -> val productIds = productIdsBySubscriptionId[productDetails.productId] + + if (productDetails.productId == "com.ui_tests.quarterly2") { + println("") + } Result.Success( RawStoreProduct( underlyingProductDetails = productDetails, @@ -258,8 +265,7 @@ open class GooglePlayProductsFetcher(var context: Context, var billingWrapper: G override suspend fun products( - identifiers: Set, - paywallName: String? + identifiers: Set ): Set { val productResults = _products(identifiers.toList()) return productResults.values.mapNotNull { diff --git a/superwall/src/test/java/com/superwall/sdk/config/ConfigLogicTest.kt b/superwall/src/test/java/com/superwall/sdk/config/ConfigLogicTest.kt index f1764c69..6802fa4c 100644 --- a/superwall/src/test/java/com/superwall/sdk/config/ConfigLogicTest.kt +++ b/superwall/src/test/java/com/superwall/sdk/config/ConfigLogicTest.kt @@ -3,13 +3,30 @@ package com.superwall.sdk.config import com.superwall.sdk.models.assignment.Assignment import com.superwall.sdk.models.config.Config import com.superwall.sdk.models.config.PreloadingDisabled +import com.superwall.sdk.models.events.EventData import com.superwall.sdk.models.paywall.Paywall import com.superwall.sdk.models.triggers.* +import com.superwall.sdk.paywall.presentation.rule_logic.expression_evaluator.ExpressionEvaluating import org.junit.Assert.* import org.junit.Test - internal class ConfigLogicTest { + private var expressionEvaluator = ExpressionEvaluatorMock() + class ExpressionEvaluatorMock: ExpressionEvaluating { + override suspend fun evaluateExpression( + rule: TriggerRule, + eventData: EventData? + ): TriggerRuleOutcome { + return TriggerRuleOutcome.match( + rule = TriggerRule( + experimentId = "1", + experimentGroupId = "2", + variants = listOf(), + preload = TriggerRule.TriggerPreload(behavior = TriggerPreloadBehavior.ALWAYS) + ) + ) + } + } @Test fun test_chooseVariant_noVariants() { @@ -231,7 +248,6 @@ internal class ConfigLogicTest { assert(variant.confirmed == confirmedAssignments) } - @Test fun test_chooseAssignments_variantAsOfYetUnconfirmed() { // Given @@ -608,7 +624,7 @@ internal class ConfigLogicTest { } @Test - fun test_getAllActiveTreatmentPaywallIds_onlyConfirmedAssignments_treatment() { + suspend fun test_getAllActiveTreatmentPaywallIds_onlyConfirmedAssignments_treatment() { val paywallId1 = "abc" val experiment1 = "def" @@ -631,16 +647,17 @@ internal class ConfigLogicTest { ) ) val ids = ConfigLogic.getAllActiveTreatmentPaywallIds( - fromTriggers = triggers, + triggers = triggers, confirmedAssignments = confirmedAssignments, - unconfirmedAssignments = emptyMap() + unconfirmedAssignments = emptyMap(), + expressionEvaluator = expressionEvaluator ) assertEquals(ids, setOf(paywallId1)) } @Test - fun test_getAllActiveTreatmentPaywallIds_onlyConfirmedAssignments_treatment_multipleTriggerSameGroupId() { + suspend fun test_getAllActiveTreatmentPaywallIds_onlyConfirmedAssignments_treatment_multipleTriggerSameGroupId() { val paywallId1 = "abc" val experiment1 = "def" @@ -672,15 +689,16 @@ internal class ConfigLogicTest { ) ) val ids = ConfigLogic.getAllActiveTreatmentPaywallIds( - fromTriggers = triggers, + triggers = triggers, confirmedAssignments = confirmedAssignments, - unconfirmedAssignments = emptyMap() + unconfirmedAssignments = emptyMap(), + expressionEvaluator = expressionEvaluator ) assertEquals(ids, setOf(paywallId1)) } @Test - fun test_getAllActiveTreatmentPaywallIds_onlyConfirmedAssignments_holdout() { + suspend fun test_getAllActiveTreatmentPaywallIds_onlyConfirmedAssignments_holdout() { val experiment1 = "def" val triggers = setOf( @@ -702,16 +720,17 @@ internal class ConfigLogicTest { ) ) val ids = ConfigLogic.getAllActiveTreatmentPaywallIds( - fromTriggers = triggers, + triggers = triggers, confirmedAssignments = confirmedAssignments, - unconfirmedAssignments = emptyMap() + unconfirmedAssignments = emptyMap(), + expressionEvaluator = expressionEvaluator ) assertTrue(ids.isEmpty()) } @Test - fun test_getAllActiveTreatmentPaywallIds_onlyConfirmedAssignments_filterOldOnes() { + suspend fun test_getAllActiveTreatmentPaywallIds_onlyConfirmedAssignments_filterOldOnes() { val paywallId1 = "abc" val experiment1 = "def" val paywallId2 = "efg" @@ -741,15 +760,16 @@ internal class ConfigLogicTest { ) ) val ids = ConfigLogic.getAllActiveTreatmentPaywallIds( - fromTriggers = triggers, + triggers = triggers, confirmedAssignments = confirmedAssignments, - unconfirmedAssignments = emptyMap() + unconfirmedAssignments = emptyMap(), + expressionEvaluator = expressionEvaluator ) assertEquals(ids, setOf(paywallId1)) } @Test - fun test_getAllActiveTreatmentPaywallIds_confirmedAndUnconfirmedAssignments_filterOldOnes() { + suspend fun test_getAllActiveTreatmentPaywallIds_confirmedAndUnconfirmedAssignments_filterOldOnes() { val paywallId1 = "abc" val experiment1 = "def" val paywallId2 = "efg" @@ -792,7 +812,7 @@ internal class ConfigLogicTest { ) ) val ids = ConfigLogic.getAllActiveTreatmentPaywallIds( - fromTriggers = triggers, + triggers = triggers, confirmedAssignments = confirmedAssignments, unconfirmedAssignments = mapOf( experiment3 to Experiment.Variant( @@ -800,7 +820,8 @@ internal class ConfigLogicTest { Experiment.Variant.VariantType.TREATMENT, paywallId3 ) - ) + ), + expressionEvaluator = expressionEvaluator ) assertEquals(ids, setOf(paywallId1, paywallId3)) } diff --git a/superwall/src/test/java/com/superwall/sdk/products/BillingClientStubs.kt b/superwall/src/test/java/com/superwall/sdk/products/BillingClientStubs.kt new file mode 100644 index 00000000..7126fadb --- /dev/null +++ b/superwall/src/test/java/com/superwall/sdk/products/BillingClientStubs.kt @@ -0,0 +1,61 @@ +package com.superwall.sdk.products + +import com.android.billingclient.api.BillingClient +import com.android.billingclient.api.ProductDetails +import com.android.billingclient.api.ProductDetails.PricingPhase +import com.android.billingclient.api.ProductDetails.RecurrenceMode +import io.mockk.every +import io.mockk.mockk + +// TODO: Move this to testImplementation rather than androidTestImplementation +fun mockProductDetails( + productId: String = "sample_product_id", + @BillingClient.ProductType type: String = BillingClient.ProductType.SUBS, + oneTimePurchaseOfferDetails: ProductDetails.OneTimePurchaseOfferDetails? = null, + subscriptionOfferDetails: List? = listOf( + mockSubscriptionOfferDetails() + ), + name: String = "subscription_mock_name", + description: String = "subscription_mock_description", + title: String = "subscription_mock_title", +): ProductDetails = mockk().apply { + every { getProductId() } returns productId + every { productType } returns type + every { getName() } returns name + every { getDescription() } returns description + every { getTitle() } returns title + every { getOneTimePurchaseOfferDetails() } returns oneTimePurchaseOfferDetails + every { getSubscriptionOfferDetails() } returns subscriptionOfferDetails + every { zza() } returns "mock-package-name" // This seems to return the packageName property from the response json +} + +fun mockSubscriptionOfferDetails( + tags: List = emptyList(), + token: String = "mock-subscription-offer-token", + offerId: String = "mock-offer-id", + basePlanId: String = "mock-base-plan-id", + pricingPhases: List = listOf(mockPricingPhase()), +): ProductDetails.SubscriptionOfferDetails = mockk().apply { + every { offerTags } returns tags + every { offerToken } returns token + every { getOfferId() } returns offerId + every { getBasePlanId() } returns basePlanId + every { getPricingPhases() } returns mockk().apply { + every { pricingPhaseList } returns pricingPhases + } +} + +fun mockPricingPhase( + price: Double = 4.99, + priceCurrencyCodeValue: String = "USD", + billingPeriod: String = "P1M", + billingCycleCount: Int = 0, + recurrenceMode: Int = RecurrenceMode.INFINITE_RECURRING, +): PricingPhase = mockk().apply { + every { formattedPrice } returns "${'$'}$price" + every { priceAmountMicros } returns price.times(1_000_000).toLong() + every { priceCurrencyCode } returns priceCurrencyCodeValue + every { getBillingPeriod() } returns billingPeriod + every { getBillingCycleCount() } returns billingCycleCount + every { getRecurrenceMode() } returns recurrenceMode +} \ No newline at end of file diff --git a/superwall/src/test/java/com/superwall/sdk/products/ProductFetcherTest.kt b/superwall/src/test/java/com/superwall/sdk/products/ProductFetcherTest.kt new file mode 100644 index 00000000..ffb0eb0c --- /dev/null +++ b/superwall/src/test/java/com/superwall/sdk/products/ProductFetcherTest.kt @@ -0,0 +1,489 @@ +package com.superwall.sdk.products + +import android.content.Context +import com.android.billingclient.api.BillingClient.ProductType +import com.android.billingclient.api.ProductDetails.RecurrenceMode +import com.android.billingclient.api.SkuDetails +import com.superwall.sdk.billing.GoogleBillingWrapper +import com.superwall.sdk.store.abstractions.product.OfferType +import com.superwall.sdk.store.abstractions.product.RawStoreProduct +import com.superwall.sdk.store.abstractions.product.StoreProduct +import com.superwall.sdk.store.abstractions.product.SubscriptionPeriod +import com.superwall.sdk.store.products.GooglePlayProductsFetcher +import kotlinx.coroutines.* +import kotlinx.coroutines.test.runTest +import org.junit.Test +import org.junit.runner.RunWith +import java.math.BigDecimal +import java.text.SimpleDateFormat +import java.time.LocalDate +import java.time.format.DateTimeFormatter +import java.util.Locale + +// Mock implementation of SkuDetails from Google Billing 4.0 + +val mockSku = """{ + "productId": "premium_subscription", + "type": "subs", + "price": "$9.99", + "price_amount_micros": 9990000, + "price_currency_code": "USD", + "title": "Premium Subscription", + "description": "Unlock all premium features with this subscription.", + "subscriptionPeriod": "P1M", + "freeTrialPeriod": "P7D", + "introductoryPrice": "$4.99", + "introductoryPriceCycles": 1, + "introductoryPrice_period": "P1M" +} +""" + +class MockSkuDetails(jsonDetails: String) : SkuDetails(jsonDetails) { + +} + +class ProductFetcherUnderTest( + context: Context, + billingWrapper: GoogleBillingWrapper +) : GooglePlayProductsFetcher( + context = context, + billingWrapper = billingWrapper +) { + // We're going to override the query async method to return a list of products + // that we define in the test +// var productIdsToReturn: Map> = emptyMap() +// var queryProductDetailsCalls: List> = emptyList() +// +// override suspend fun queryProductDetails(productIds: List): Map> { +// queryProductDetailsCalls = queryProductDetailsCalls + listOf(productIds) +// delay(1000 + (Math.random() * 1000).toLong()) +// +// // Filter productIdsToReturn, and add success if not found +// val result = productIds.map { productId -> +// val product = productIdsToReturn[productId] +// if (product != null) { +// productId to product +// } else { +// productId to Result.Success( +// RawStoreProduct( +// underlyingProductDetails = MockSkuDetails(mockSku) +// ) +// ) +// } +// }.toMap() +// return result +// } + +} + +// TODO: https://linear.app/superwall/issue/SW-2368/[android]-fix-product-fetcher-tests + +class ProductFetcherInstrumentedTest { + val productDetails = mockProductDetails( + productId = "com.ui_tests.quarterly2", + type = ProductType.SUBS, + oneTimePurchaseOfferDetails = null, + subscriptionOfferDetails = listOf( + mockSubscriptionOfferDetails( + token = "AUj\\/Yhg80Kaqu23qZ1VFz4JyBXUmtWv3wqIaGosS9ofPc6hdbl8ALUDdn+du4AXMfogPJw6ZFop9MO3oDth6XTtJfURtKTSjapPZJnWbuBUnMK20pVPUm4RGSXD9Ke0fa8AECDQDPCn+UrDQ", + offerId = "free-trial-offer", + basePlanId = "test-2", + pricingPhases = listOf( + mockPricingPhase( + price = 0.0, + billingPeriod = "P1M", + billingCycleCount = 1, + recurrenceMode = RecurrenceMode.FINITE_RECURRING + ), + mockPricingPhase( + price = 9.99, + billingPeriod = "P1M", + billingCycleCount = 0, + recurrenceMode = RecurrenceMode.INFINITE_RECURRING + ) + ) + ), + mockSubscriptionOfferDetails( + token = "AUj\\/Yhg80Kaqu23qZ1VFz4JyBXUmtWv3wqIaGosS9ofPc6hdbl8ALUDdn+du4AXMfogPJw6ZFop9MO3oDth6XTtJfURtKTSjapPZJnWbuBUnMK20pVPUm4RGSXD9Ke0fa8AECDQDPCn+UrDQ", + offerId = "free-trial-one-week", + basePlanId = "test-2", + pricingPhases = listOf( + mockPricingPhase( + price = 0.0, + billingPeriod = "P1W", + billingCycleCount = 1, + recurrenceMode = RecurrenceMode.FINITE_RECURRING + ), + mockPricingPhase( + price = 9.99, + billingPeriod = "P1M", + billingCycleCount = 0, + recurrenceMode = RecurrenceMode.INFINITE_RECURRING + ) + ) + ), + mockSubscriptionOfferDetails( + tags = listOf("sw-ignore-offer"), + token = "AUj\\/Yhg80Kaqu23qZ1VFz4JyBXUmtWv3wqIaGosS9ofPc6hdbl8ALUDdn+du4AXMfogPJw6ZFop9MO3oDth6XTtJfURtKTSjapPZJnWbuBUnMK20pVPUm4RGSXD9Ke0fa8AECDQDPCn+UrDQ", + offerId = "ignored-offer", + basePlanId = "test-2", + pricingPhases = listOf( + mockPricingPhase( + price = 0.0, + billingPeriod = "P1Y", + billingCycleCount = 1, + recurrenceMode = RecurrenceMode.FINITE_RECURRING + ), + mockPricingPhase( + price = 9.99, + billingPeriod = "P1M", + billingCycleCount = 0, + recurrenceMode = RecurrenceMode.INFINITE_RECURRING + ) + ) + ), + mockSubscriptionOfferDetails( + token = "AUj\\/Yhg80Kaqu23qZ1VFz4JyBXUmtWv3wqIaGosS9ofPc6hdbl8ALUDdn+du4AXMfogPJw6ZFop9MO3oDth6XTtJfURtKTSjapPZJnWbuBUnMK20pVPUm4RGSXD9Ke0fa8AECDQDPCn+UrDQ", + offerId = "trial-and-paid-offer", + basePlanId = "test-2", + pricingPhases = listOf( + mockPricingPhase( + price = 0.0, + billingPeriod = "P1M", + billingCycleCount = 1, + recurrenceMode = RecurrenceMode.FINITE_RECURRING + ), + mockPricingPhase( + price = 2.99, + billingPeriod = "P1M", + billingCycleCount = 1, + recurrenceMode = RecurrenceMode.FINITE_RECURRING + ), + mockPricingPhase( + price = 9.99, + billingPeriod = "P1M", + billingCycleCount = 0, + recurrenceMode = RecurrenceMode.INFINITE_RECURRING + ) + ) + ), + + mockSubscriptionOfferDetails( + token = "AUj\\/YhhnamOJsY2iGIxhIw8PAbLGNIPfUt4s4QfSiabWa8hpBx4B84ImQ\\/SL3L8xPpVPUxQ4f3L6wfun5QfZwZNzv0GHrzzIy4wMFdnXUyYOWW8=", + offerId = "", + basePlanId = "test-2", + pricingPhases = listOf( + mockPricingPhase( + price = 9.99, + billingPeriod = "P1M", + billingCycleCount = 0, + recurrenceMode = RecurrenceMode.INFINITE_RECURRING + ) + ) + ), + mockSubscriptionOfferDetails( + token = "AUj\\/YhgfPv4IORV8Jz3HeZYkMDpka2WDPNmqSojJawgy9c4ZuBE5h5osgTVTO3hDwhglT\\/9px8G4qrj508lVbYxCRIA\\/fjw7UM56K7UUNoFEWQQ=", + offerId = "paid-offer", + basePlanId = "test-3", + pricingPhases = listOf( + mockPricingPhase( + price = 1.99, + billingPeriod = "P1M", + billingCycleCount = 1, + recurrenceMode = RecurrenceMode.FINITE_RECURRING + ), + mockPricingPhase( + price = 2.99, + billingPeriod = "P1M", + billingCycleCount = 0, + recurrenceMode = RecurrenceMode.INFINITE_RECURRING + ) + ) + ), + mockSubscriptionOfferDetails( + token = "AUj\\/YhgfPv4IORV8Jz3HeZYkMDpka2WDPNmqSojJawgy9c4ZuBE5h5osgTVTO3hDwhglT\\/9px8G4qrj508lVbYxCRIA\\/fjw7UM56K7UUNoFEWQQ=", + offerId = "paid-offer-2", + basePlanId = "test-3", + pricingPhases = listOf( + mockPricingPhase( + price = 5.99, + billingPeriod = "P1Y", + billingCycleCount = 1, + recurrenceMode = RecurrenceMode.FINITE_RECURRING + ), + mockPricingPhase( + price = 2.99, + billingPeriod = "P1M", + billingCycleCount = 0, + recurrenceMode = RecurrenceMode.INFINITE_RECURRING + ) + ) + ), + mockSubscriptionOfferDetails( + token = "AUj\\/YhhnamOJsY2iGIxhIw8PAbLGNIPfUt4s4QfSiabWa8hpBx4B84ImQ\\/SL3L8xPpVPUxQ4f3L6wfun5QfZwZNzv0GHrzzIy4wMFdnXUyYOWW8=", + offerId = "", + basePlanId = "test-3", + pricingPhases = listOf( + mockPricingPhase( + price = 2.99, + billingPeriod = "P1M", + billingCycleCount = 0, + recurrenceMode = RecurrenceMode.INFINITE_RECURRING + ) + ) + ), + mockSubscriptionOfferDetails( + token = "AUj\\/YhhJLdYaLBKb5nkiptBeLCq18pcvWLRTjF6LHIo+w\\/fgVuzM87Vc4bC+UTY210xCmM\\/EU9BCfUthXRb6EFLwYP6lWbXBZQFJI6443iocMV1nJ5\\/iTE2rQigvbDuAlSX8HW1mG4m8NS0s1R+MWwfd+zbeMLCMbJ7mNTaI8YqZeIyJep\\/riA==", + offerId = "base-plan-free-trial", + basePlanId = "com-ui-tests-quarterly2", + pricingPhases = listOf( + mockPricingPhase( + price = 0.0, + billingPeriod = "P1M", + billingCycleCount = 1, + recurrenceMode = RecurrenceMode.FINITE_RECURRING + ), + mockPricingPhase( + price = 18.99, + billingPeriod = "P3M", + billingCycleCount = 0, + recurrenceMode = RecurrenceMode.INFINITE_RECURRING + ) + ) + ), + mockSubscriptionOfferDetails( + token = "AUj\\/YhhSSehWYpmbL39WoarzWiQVZrd3oQOhYmE0GfLboQEdMUqIyy1yIhf7ZIwrCyh7KqZhwExFD7ZXMW\\/wd6c\\/8xBWK8lsRPs68138G3eAtsHjl2gCsgDhFb6RAnWERF1o+UWi2mdYh4o=", + offerId = "", + basePlanId = "com-ui-tests-quarterly2", + pricingPhases = listOf( + mockPricingPhase( + price = 18.99, + billingPeriod = "P3M", + billingCycleCount = 0, + recurrenceMode = RecurrenceMode.INFINITE_RECURRING + ) + ) + ), + + ), + title = "com.ui_tests.quarterly2 (com.superwall.superapp (unreviewed))" + ) + + /** + * subscription + base plan + offer: Free Trial phase ----> DONE + * subscription + base plan + offer: Free Trial Phase + Paid Phase in one offer -> DONE + * subscription + base plan + offer: Paid Phase + * subscription + base plan + auto-offer: one with free trial 1 year but with sw-ignore-offer tag, + * one with free trial 1 month, + * one with free trial 1 week ---> DONE + * subscription + base plan + auto-offer: one with paid offer 5.99, + * one with paid offer 1.99 + * subscription + base plan + auto-offer: no offers, just base plan + * subscription + base plan + * subscription + * oneTimePurchaseOffer + */ + + @Test + fun test_storeProduct_basePlan_withFreeTrialOffer() = runTest { + // subscription + base plan + offer: Free Trial phase + val storeProduct = StoreProduct( + rawStoreProduct = RawStoreProduct( + underlyingProductDetails = productDetails, + fullIdentifier = "com.ui_tests.quarterly2:test-2:free-trial-offer", + basePlanId = "test-2", + offerType = OfferType.Offer(id = "free-trial-offer") + ) + ) + assert(storeProduct.hasFreeTrial) + assert(storeProduct.productIdentifier == "com.ui_tests.quarterly2") + assert(storeProduct.fullIdentifier == "com.ui_tests.quarterly2:test-2:free-trial-offer") + assert(storeProduct.currencyCode == "USD") + assert(storeProduct.currencySymbol == "US$") // This actually just returns "$" in the main app. + assert(storeProduct.dailyPrice == "US$0.33") + assert(storeProduct.weeklyPrice == "US$2.49") + assert(storeProduct.monthlyPrice == "US$9.99") + assert(storeProduct.yearlyPrice == "US$119.88") + assert(storeProduct.periodDays == 30) + assert(storeProduct.periodMonths == 1) + assert(storeProduct.periodWeeks == 4) + assert(storeProduct.periodYears == 0) + assert(storeProduct.localizedSubscriptionPeriod == "1 month") + assert(storeProduct.periodly == "monthly") + assert(storeProduct.trialPeriodMonths == 1) + assert(storeProduct.trialPeriodWeeks == 4) + assert(storeProduct.trialPeriodText == "30-day") + assert(storeProduct.price == BigDecimal("9.99")) + assert(storeProduct.trialPeriodYears == 0) + assert(storeProduct.languageCode == "en") + val defaultLocale = Locale.getDefault().toString() + assert(storeProduct.locale == defaultLocale) // Change this depending on what your computer is set + assert(storeProduct.trialPeriodPrice == BigDecimal.ZERO) + assert(storeProduct.trialPeriodPricePerUnit(SubscriptionPeriod.Unit.day) == "US$0.00") + assert(storeProduct.trialPeriodPricePerUnit(SubscriptionPeriod.Unit.month) == "US$0.00") + assert(storeProduct.trialPeriodPricePerUnit(SubscriptionPeriod.Unit.week) == "US$0.00") + assert(storeProduct.trialPeriodPricePerUnit(SubscriptionPeriod.Unit.year) == "US$0.00") + assert(storeProduct.localizedTrialPeriodPrice == "US$0.00") + + val currentDate = LocalDate.now() + val dateIn30Days = currentDate.plusMonths(1) + val dateFormatter = DateTimeFormatter.ofPattern("MMM dd, yyyy", Locale.getDefault()) + val formattedDate = dateIn30Days.format(dateFormatter) + println("$formattedDate ${storeProduct.trialPeriodEndDateString}") + assert(storeProduct.trialPeriodEndDateString == formattedDate) + } + + @Test + fun test_storeProduct_basePlan_withFreeTrialOfferAndPaid() = runTest { + // subscription + base plan + offer: Free Trial Phase + Paid Phase in one offer + val storeProduct = StoreProduct( + rawStoreProduct = RawStoreProduct( + underlyingProductDetails = productDetails, + fullIdentifier = "com.ui_tests.quarterly2:test-2:trial-and-paid-offer", + basePlanId = "test-2", + offerType = OfferType.Offer(id = "trial-and-paid-offer") + ) + ) + assert(storeProduct.hasFreeTrial) + assert(storeProduct.productIdentifier == "com.ui_tests.quarterly2") + assert(storeProduct.fullIdentifier == "com.ui_tests.quarterly2:test-2:trial-and-paid-offer") + assert(storeProduct.currencyCode == "USD") + assert(storeProduct.currencySymbol == "US$") // This actually just returns "$" in the main app. + assert(storeProduct.dailyPrice == "US$0.33") + assert(storeProduct.weeklyPrice == "US$2.49") + assert(storeProduct.monthlyPrice == "US$9.99") + assert(storeProduct.yearlyPrice == "US$119.88") + assert(storeProduct.periodDays == 30) + assert(storeProduct.periodMonths == 1) + assert(storeProduct.periodWeeks == 4) + assert(storeProduct.periodYears == 0) + assert(storeProduct.localizedSubscriptionPeriod == "1 month") + assert(storeProduct.periodly == "monthly") + assert(storeProduct.trialPeriodMonths == 1) + assert(storeProduct.trialPeriodWeeks == 4) + assert(storeProduct.trialPeriodText == "30-day") + assert(storeProduct.price == BigDecimal("9.99")) + assert(storeProduct.trialPeriodYears == 0) + assert(storeProduct.languageCode == "en") + val defaultLocale = Locale.getDefault().toString() + assert(storeProduct.locale == defaultLocale) // Change this depending on what your computer is set + assert(storeProduct.trialPeriodPrice == BigDecimal.ZERO) + assert(storeProduct.trialPeriodPricePerUnit(SubscriptionPeriod.Unit.day) == "US$0.00") + assert(storeProduct.trialPeriodPricePerUnit(SubscriptionPeriod.Unit.month) == "US$0.00") + assert(storeProduct.trialPeriodPricePerUnit(SubscriptionPeriod.Unit.week) == "US$0.00") + assert(storeProduct.trialPeriodPricePerUnit(SubscriptionPeriod.Unit.year) == "US$0.00") + assert(storeProduct.localizedTrialPeriodPrice == "US$0.00") + + val currentDate = LocalDate.now() + val dateIn30Days = currentDate.plusMonths(1) + val dateFormatter = DateTimeFormatter.ofPattern("MMM dd, yyyy", Locale.getDefault()) + val formattedDate = dateIn30Days.format(dateFormatter) + println("$formattedDate ${storeProduct.trialPeriodEndDateString}") + assert(storeProduct.trialPeriodEndDateString == formattedDate) + } + + @Test + fun test_storeProduct_basePlan_autoOffer_threeFreeTrials() = runTest { + // subscription + base plan + auto-offer: one with free trial 1 year but with sw-ignore-offer tag, + // one with free trial 1 month, <- Chooses this one + // one with free trial 1 week + val storeProduct = StoreProduct( + rawStoreProduct = RawStoreProduct( + underlyingProductDetails = productDetails, + fullIdentifier = "com.ui_tests.quarterly2:test-2:sw-auto", + basePlanId = "test-2", + offerType = OfferType.Auto + ) + ) + assert(storeProduct.hasFreeTrial) + assert(storeProduct.productIdentifier == "com.ui_tests.quarterly2") + assert(storeProduct.fullIdentifier == "com.ui_tests.quarterly2:test-2:sw-auto") + assert(storeProduct.currencyCode == "USD") + assert(storeProduct.currencySymbol == "US$") // This actually just returns "$" in the main app. + assert(storeProduct.dailyPrice == "US$0.33") + assert(storeProduct.weeklyPrice == "US$2.49") + assert(storeProduct.monthlyPrice == "US$9.99") + assert(storeProduct.yearlyPrice == "US$119.88") + assert(storeProduct.periodDays == 30) + assert(storeProduct.periodMonths == 1) + assert(storeProduct.periodWeeks == 4) + assert(storeProduct.periodYears == 0) + assert(storeProduct.localizedSubscriptionPeriod == "1 month") + assert(storeProduct.periodly == "monthly") + println(storeProduct.trialPeriodMonths) + assert(storeProduct.trialPeriodMonths == 1) + assert(storeProduct.trialPeriodWeeks == 4) + assert(storeProduct.trialPeriodText == "30-day") + assert(storeProduct.price == BigDecimal("9.99")) + assert(storeProduct.trialPeriodYears == 0) + assert(storeProduct.languageCode == "en") + val defaultLocale = Locale.getDefault().toString() + assert(storeProduct.locale == defaultLocale) // Change this depending on what your computer is set + assert(storeProduct.trialPeriodPrice == BigDecimal.ZERO) + assert(storeProduct.trialPeriodPricePerUnit(SubscriptionPeriod.Unit.day) == "US$0.00") + assert(storeProduct.trialPeriodPricePerUnit(SubscriptionPeriod.Unit.month) == "US$0.00") + assert(storeProduct.trialPeriodPricePerUnit(SubscriptionPeriod.Unit.week) == "US$0.00") + assert(storeProduct.trialPeriodPricePerUnit(SubscriptionPeriod.Unit.year) == "US$0.00") + assert(storeProduct.localizedTrialPeriodPrice == "US$0.00") + + val currentDate = LocalDate.now() + val dateIn30Days = currentDate.plusMonths(1) + val dateFormatter = DateTimeFormatter.ofPattern("MMM dd, yyyy", Locale.getDefault()) + val formattedDate = dateIn30Days.format(dateFormatter) + assert(storeProduct.trialPeriodEndDateString == formattedDate) + } + + @Test + fun test_storeProduct_basePlan_autoOffer_twoPaidOffers() = runTest { + // subscription + base plan + auto-offer: one with paid offer 5.99, + // one with paid offer 1.99 <- chooses this one + val storeProduct = StoreProduct( + rawStoreProduct = RawStoreProduct( + underlyingProductDetails = productDetails, + fullIdentifier = "com.ui_tests.quarterly2:test-3:sw-auto", + basePlanId = "test-3", + offerType = OfferType.Auto + ) + ) + assert(!storeProduct.hasFreeTrial) + assert(storeProduct.productIdentifier == "com.ui_tests.quarterly2") + assert(storeProduct.fullIdentifier == "com.ui_tests.quarterly2:test-3:sw-auto") + println("Currency? ${storeProduct.currencyCode}") + assert(storeProduct.currencyCode == "USD") + assert(storeProduct.currencySymbol == "US$") // This actually just returns "$" in the main app. + assert(storeProduct.dailyPrice == "US$0.09") + assert(storeProduct.weeklyPrice == "US$0.74") + assert(storeProduct.monthlyPrice == "US$2.99") + assert(storeProduct.yearlyPrice == "US$35.88") + assert(storeProduct.periodDays == 30) + assert(storeProduct.periodMonths == 1) + assert(storeProduct.periodWeeks == 4) + assert(storeProduct.periodYears == 0) + assert(storeProduct.localizedSubscriptionPeriod == "1 month") + assert(storeProduct.periodly == "monthly") + + assert(storeProduct.trialPeriodMonths == 1) + assert(storeProduct.trialPeriodWeeks == 4) + assert(storeProduct.trialPeriodText == "30-day") + assert(storeProduct.price == BigDecimal("2.99")) + assert(storeProduct.trialPeriodYears == 0) + assert(storeProduct.languageCode == "en") + val defaultLocale = Locale.getDefault().toString() + assert(storeProduct.locale == defaultLocale) // Change this depending on what your computer is set + + assert(storeProduct.trialPeriodPrice == BigDecimal("1.99")) + println(storeProduct.trialPeriodPricePerUnit(SubscriptionPeriod.Unit.year)) + assert(storeProduct.trialPeriodPricePerUnit(SubscriptionPeriod.Unit.day) == "US$0.06") + assert(storeProduct.trialPeriodPricePerUnit(SubscriptionPeriod.Unit.month) == "US$1.99") + assert(storeProduct.trialPeriodPricePerUnit(SubscriptionPeriod.Unit.week) == "US$0.49") + assert(storeProduct.trialPeriodPricePerUnit(SubscriptionPeriod.Unit.year) == "US$23.88") + assert(storeProduct.localizedTrialPeriodPrice == "US$1.99") + + val currentDate = LocalDate.now() + val dateIn30Days = currentDate.plusMonths(1) + val dateFormatter = DateTimeFormatter.ofPattern("MMM dd, yyyy", Locale.getDefault()) + val formattedDate = dateIn30Days.format(dateFormatter) + println("$formattedDate ${storeProduct.trialPeriodEndDateString}") + assert(storeProduct.trialPeriodEndDateString == formattedDate) + } +} \ No newline at end of file From 012a3246aebce9fe5ff07e39c49c2651032a0aa3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= Date: Mon, 4 Dec 2023 15:28:27 -0500 Subject: [PATCH 11/23] Added more tests, handled oneTimePurchaseOfferDetails --- .../sdk/billing/GooglePlayStoreProduct.kt | 174 --------- .../abstractions/product/RawStoreProduct.kt | 21 +- .../products/GooglePlayProductsFetcher.kt | 4 - .../sdk/products/BillingClientStubs.kt | 11 +- .../sdk/products/ProductFetcherTest.kt | 345 +++++++++++++++++- 5 files changed, 347 insertions(+), 208 deletions(-) delete mode 100644 superwall/src/main/java/com/superwall/sdk/billing/GooglePlayStoreProduct.kt diff --git a/superwall/src/main/java/com/superwall/sdk/billing/GooglePlayStoreProduct.kt b/superwall/src/main/java/com/superwall/sdk/billing/GooglePlayStoreProduct.kt deleted file mode 100644 index 7036d2e3..00000000 --- a/superwall/src/main/java/com/superwall/sdk/billing/GooglePlayStoreProduct.kt +++ /dev/null @@ -1,174 +0,0 @@ -package com.superwall.sdk.billing - -import com.android.billingclient.api.SkuDetails -import kotlinx.serialization.json.JsonObject -import java.math.BigDecimal -import java.util.* - -class GooglePlayStoreProduct(private val skuDetails: SkuDetails) : StoreProductType { - override val productIdentifier: String - get() = skuDetails.sku - - override val price: BigDecimal - get() = BigDecimal(skuDetails.priceAmountMicros / 1_000_000.0) - - // These properties might not be applicable or retrievable from SkuDetails. - // Some assumptions have been made here. - override val subscriptionGroupIdentifier: String? - get() = null - - override val swProductTemplateVariablesJson: Map - get() = emptyMap() // TODO: (?) Replace with actual JSON - -// override val swProduct: SWProduct -// get() = SWProduct() // Replace with actual SWProduct - - override val localizedPrice: String - get() = skuDetails.price - - override val localizedSubscriptionPeriod: String - get() = "" // Not available from SkuDetails - - override val period: String - get() = skuDetails.subscriptionPeriod - - override val periodly: String - get() = "" // Not available from SkuDetails - - override val periodWeeks: Int - get() = 0 // Not available from SkuDetails - - override val periodWeeksString: String - get() = "" // Not available from SkuDetails - - override val periodMonths: Int - // get() = skuDetails.subscriptionPeriod?.unit?.let { if (it == SkuDetails.SubscriptionPeriod.Unit.MONTH) skuDetails.subscriptionPeriod.count else 0 } ?: 0 - get() = 0 - - override val periodMonthsString: String - get() = periodMonths.toString() - - override val periodYears: Int - get() = 0 -// get() = skuDetails.subscriptionPeriod?.unit?.let { if (it == SkuDetails.SubscriptionPeriod.Unit.YEAR) skuDetails.subscriptionPeriod.count else 0 } ?: 0 - - override val periodYearsString: String - get() = periodYears.toString() - - override val periodDays: Int - get() = parseBillingPeriod(skuDetails.subscriptionPeriod) - - override val periodDaysString: String - get() = "${periodDays}" - - // Below details are not available from SkuDetails - // TODO: Figure out the proper formatting - override val dailyPrice: String - get() = "${price / BigDecimal(periodDays)}" - - override val weeklyPrice: String - get() = "${price / BigDecimal(periodDays * 7)}}" - - override val monthlyPrice: String - get() = "${price / BigDecimal(periodDays * 30)}" - - override val yearlyPrice: String - get() = "${price / BigDecimal(periodDays * 365)}" - - override val hasFreeTrial: Boolean - get() = trialPeriodDays > 0 - - // TODO: Figure out the syntax for this - override val trialPeriodEndDate: Date? - get() = _trialPeriodEndDate() - - private fun _trialPeriodEndDate(): Date? { - if (!hasFreeTrial) { - return null - } - val calendar = Calendar.getInstance() - val date = Date() - calendar.time = date - calendar.add(Calendar.DATE, trialPeriodDays) - return calendar.time - } - - override val trialPeriodEndDateString: String - get() = _trialPeriodEndDate()?.toString() ?: "" - - override val trialPeriodDays: Int - get() = parseBillingPeriod(skuDetails.freeTrialPeriod) - - override val trialPeriodDaysString: String - get() = "$trialPeriodDays" - - override val trialPeriodWeeks: Int - get() = trialPeriodDays / 7 - - override val trialPeriodWeeksString: String - get() = "${trialPeriodWeeks}" - - override val trialPeriodMonths: Int - get() = trialPeriodDays / 30 - - override val trialPeriodMonthsString: String - get() = "${trialPeriodMonths}" - - override val trialPeriodYears: Int - get() = trialPeriodDays / 365 - - override val trialPeriodYearsString: String - get() = "${trialPeriodYears}" - - override val trialPeriodText: String - get() = when { - trialPeriodYears > 0 -> trialPeriodYearsString - trialPeriodMonths > 0 -> trialPeriodMonthsString - trialPeriodWeeks > 0 -> trialPeriodWeeksString - else -> trialPeriodDaysString - } - - - override val locale: String - get() = - Locale.getDefault().toString() - - override val languageCode: String? - get() = Locale.getDefault().language - - override val currencyCode: String? - get() = skuDetails.priceCurrencyCode - - override val currencySymbol: String? - get() = Currency.getInstance(skuDetails.priceCurrencyCode).symbol - - override val regionCode: String? - get() = "" - - private fun parseBillingPeriod(period: String): Int { - if (period.isEmpty()) { - return 0 - } - - // Default periods in days - val daysInMonth = 30 - val daysInWeek = 7 - - // Remove 'P' prefix and split by remaining identifiers - val splitPeriod = period.removePrefix("P").split("M", "W", "D") - - var totalDays = 0 - - splitPeriod.forEachIndexed { index, part -> - if (part.isNotEmpty()) { - when (index) { - 0 -> totalDays += part.toInt() * daysInMonth // Months - 1 -> totalDays += part.toInt() * daysInWeek // Weeks - 2 -> totalDays += part.toInt() // Days - } - } - } - - return totalDays - } -} diff --git a/superwall/src/main/java/com/superwall/sdk/store/abstractions/product/RawStoreProduct.kt b/superwall/src/main/java/com/superwall/sdk/store/abstractions/product/RawStoreProduct.kt index 075217f6..e786fc7a 100644 --- a/superwall/src/main/java/com/superwall/sdk/store/abstractions/product/RawStoreProduct.kt +++ b/superwall/src/main/java/com/superwall/sdk/store/abstractions/product/RawStoreProduct.kt @@ -36,7 +36,7 @@ class RawStoreProduct( override val price: BigDecimal get() { underlyingProductDetails.oneTimePurchaseOfferDetails?.let { offerDetails -> - return BigDecimal(offerDetails.priceAmountMicros).divide(BigDecimal(1_000_000), 6, RoundingMode.DOWN) + return BigDecimal(offerDetails.priceAmountMicros).divide(BigDecimal(1_000_000), 2, RoundingMode.DOWN) } return basePriceForSelectedOffer() @@ -228,9 +228,6 @@ class RawStoreProduct( override val trialPeriodPrice: BigDecimal get() { - - // Handle one-time purchase - // TODO: Handle oneTimePurchaseOfferDetails correctly if (underlyingProductDetails.oneTimePurchaseOfferDetails != null) { return BigDecimal.ZERO } @@ -258,6 +255,7 @@ class RawStoreProduct( // Retrieve the subscription offer details from the product details val subscriptionOfferDetails = underlyingProductDetails.subscriptionOfferDetails ?: return null // If there's no base plan ID, return the first base plan we come across. + // This may not be the one they want but so be it. if (basePlanId == null) { return subscriptionOfferDetails.firstOrNull { it.pricingPhases.pricingPhaseList.size == 1 } } @@ -282,8 +280,8 @@ class RawStoreProduct( return findLongestFreeTrial(validOffers) ?: findLowestNonFreeOffer(validOffers) ?: basePlan } is OfferType.Offer -> { - // If an offer ID is given, return that one. - return offersForBasePlan.firstOrNull { it.offerId == offerType.id } + // If an offer ID is given, return that one. Otherwise fallback to base plan. + return offersForBasePlan.firstOrNull { it.offerId == offerType.id } ?: basePlan } else -> { // If no offer specified, return base plan. @@ -423,6 +421,9 @@ class RawStoreProduct( override val currencyCode: String? get() { + if (underlyingProductDetails.oneTimePurchaseOfferDetails != null) { + return underlyingProductDetails.oneTimePurchaseOfferDetails?.priceCurrencyCode + } val selectedOffer = getSelectedOffer() ?: return null return selectedOffer.pricingPhases.pricingPhaseList.last().priceCurrencyCode } @@ -487,15 +488,9 @@ class RawStoreProduct( } catch (e: Exception) { null } - // TODO: THE PERIODSPERUNIT IS WRONG - val periodPerUnit2 = periodsPerUnit(unit) val introPeriods = periodsPerUnit(unit).multiply(BigDecimal(pricingPhase.billingCycleCount)) .multiply(BigDecimal(trialSubscriptionPeriod?.value ?: 0)) - println(unit.toString()) - println(introPeriods) - println(periodPerUnit2) - println(trialSubscriptionPeriod?.value) - println(pricingPhase.billingCycleCount) + val introPayment: BigDecimal if (introPeriods < BigDecimal.ONE) { // If less than 1, it means the intro period doesn't exceed a full unit. diff --git a/superwall/src/main/java/com/superwall/sdk/store/products/GooglePlayProductsFetcher.kt b/superwall/src/main/java/com/superwall/sdk/store/products/GooglePlayProductsFetcher.kt index bb415a7b..a90026d8 100644 --- a/superwall/src/main/java/com/superwall/sdk/store/products/GooglePlayProductsFetcher.kt +++ b/superwall/src/main/java/com/superwall/sdk/store/products/GooglePlayProductsFetcher.kt @@ -233,10 +233,6 @@ open class GooglePlayProductsFetcher( val results = productDetailsList.associateBy { it.productId } .mapValues { (_, productDetails) -> val productIds = productIdsBySubscriptionId[productDetails.productId] - - if (productDetails.productId == "com.ui_tests.quarterly2") { - println("") - } Result.Success( RawStoreProduct( underlyingProductDetails = productDetails, diff --git a/superwall/src/test/java/com/superwall/sdk/products/BillingClientStubs.kt b/superwall/src/test/java/com/superwall/sdk/products/BillingClientStubs.kt index 7126fadb..d9b34c93 100644 --- a/superwall/src/test/java/com/superwall/sdk/products/BillingClientStubs.kt +++ b/superwall/src/test/java/com/superwall/sdk/products/BillingClientStubs.kt @@ -2,12 +2,12 @@ package com.superwall.sdk.products import com.android.billingclient.api.BillingClient import com.android.billingclient.api.ProductDetails +import com.android.billingclient.api.ProductDetails.OneTimePurchaseOfferDetails import com.android.billingclient.api.ProductDetails.PricingPhase import com.android.billingclient.api.ProductDetails.RecurrenceMode import io.mockk.every import io.mockk.mockk -// TODO: Move this to testImplementation rather than androidTestImplementation fun mockProductDetails( productId: String = "sample_product_id", @BillingClient.ProductType type: String = BillingClient.ProductType.SUBS, @@ -29,6 +29,15 @@ fun mockProductDetails( every { zza() } returns "mock-package-name" // This seems to return the packageName property from the response json } +fun mockOneTimePurchaseOfferDetails( + price: Double = 4.99, + priceCurrencyCodeValue: String = "USD", +): OneTimePurchaseOfferDetails = mockk().apply { + every { formattedPrice } returns "${'$'}$price" + every { priceAmountMicros } returns price.times(1_000_000).toLong() + every { priceCurrencyCode } returns priceCurrencyCodeValue +} + fun mockSubscriptionOfferDetails( tags: List = emptyList(), token: String = "mock-subscription-offer-token", diff --git a/superwall/src/test/java/com/superwall/sdk/products/ProductFetcherTest.kt b/superwall/src/test/java/com/superwall/sdk/products/ProductFetcherTest.kt index ffb0eb0c..30df4a95 100644 --- a/superwall/src/test/java/com/superwall/sdk/products/ProductFetcherTest.kt +++ b/superwall/src/test/java/com/superwall/sdk/products/ProductFetcherTest.kt @@ -2,6 +2,7 @@ package com.superwall.sdk.products import android.content.Context import com.android.billingclient.api.BillingClient.ProductType +import com.android.billingclient.api.ProductDetails.OneTimePurchaseOfferDetails import com.android.billingclient.api.ProductDetails.RecurrenceMode import com.android.billingclient.api.SkuDetails import com.superwall.sdk.billing.GoogleBillingWrapper @@ -79,7 +80,15 @@ class ProductFetcherUnderTest( // TODO: https://linear.app/superwall/issue/SW-2368/[android]-fix-product-fetcher-tests class ProductFetcherInstrumentedTest { - val productDetails = mockProductDetails( + private val oneTimePurchaseProduct = mockProductDetails( + productId = "pro_test_8999_year", + type = ProductType.INAPP, + oneTimePurchaseOfferDetails = mockOneTimePurchaseOfferDetails( + price = 89.99 + ), + subscriptionOfferDetails = null + ) + private val productDetails = mockProductDetails( productId = "com.ui_tests.quarterly2", type = ProductType.SUBS, oneTimePurchaseOfferDetails = null, @@ -167,7 +176,6 @@ class ProductFetcherInstrumentedTest { ) ) ), - mockSubscriptionOfferDetails( token = "AUj\\/YhhnamOJsY2iGIxhIw8PAbLGNIPfUt4s4QfSiabWa8hpBx4B84ImQ\\/SL3L8xPpVPUxQ4f3L6wfun5QfZwZNzv0GHrzzIy4wMFdnXUyYOWW8=", offerId = "", @@ -264,7 +272,51 @@ class ProductFetcherInstrumentedTest { ) ) ), - + mockSubscriptionOfferDetails( + token = "AUj\\/Yhg80Kaqu23qZ1VFz4JyBXUmtWv3wqIaGosS9ofPc6hdbl8ALUDdn+du4AXMfogPJw6ZFop9MO3oDth6XTtJfURtKTSjapPZJnWbuBUnMK20pVPUm4RGSXD9Ke0fa8AECDQDPCn+UrDQ", + offerId = "paid-offer", + basePlanId = "test-4", + pricingPhases = listOf( + mockPricingPhase( + price = 6.74, + billingPeriod = "P1M", + billingCycleCount = 3, + recurrenceMode = RecurrenceMode.FINITE_RECURRING + ), + mockPricingPhase( + price = 8.99, + billingPeriod = "P1M", + billingCycleCount = 0, + recurrenceMode = RecurrenceMode.INFINITE_RECURRING + ) + ) + ), + mockSubscriptionOfferDetails( + token = "AUj\\/YhhnamOJsY2iGIxhIw8PAbLGNIPfUt4s4QfSiabWa8hpBx4B84ImQ\\/SL3L8xPpVPUxQ4f3L6wfun5QfZwZNzv0GHrzzIy4wMFdnXUyYOWW8=", + offerId = "", + basePlanId = "test-4", + pricingPhases = listOf( + mockPricingPhase( + price = 8.99, + billingPeriod = "P1M", + billingCycleCount = 0, + recurrenceMode = RecurrenceMode.INFINITE_RECURRING + ) + ) + ), + mockSubscriptionOfferDetails( + token = "AUj\\/YhhnamOJsY2iGIxhIw8PAbLGNIPfUt4s4QfSiabWa8hpBx4B84ImQ\\/SL3L8xPpVPUxQ4f3L6wfun5QfZwZNzv0GHrzzIy4wMFdnXUyYOWW8=", + offerId = "", + basePlanId = "test-5", + pricingPhases = listOf( + mockPricingPhase( + price = 3.99, + billingPeriod = "P1Y", + billingCycleCount = 0, + recurrenceMode = RecurrenceMode.INFINITE_RECURRING + ) + ) + ) ), title = "com.ui_tests.quarterly2 (com.superwall.superapp (unreviewed))" ) @@ -272,16 +324,17 @@ class ProductFetcherInstrumentedTest { /** * subscription + base plan + offer: Free Trial phase ----> DONE * subscription + base plan + offer: Free Trial Phase + Paid Phase in one offer -> DONE - * subscription + base plan + offer: Paid Phase + * subscription + base plan + offer: Paid Phase ----> DONE + * subscription + base plan + offer: Offer not found ----> * subscription + base plan + auto-offer: one with free trial 1 year but with sw-ignore-offer tag, * one with free trial 1 month, * one with free trial 1 week ---> DONE * subscription + base plan + auto-offer: one with paid offer 5.99, - * one with paid offer 1.99 - * subscription + base plan + auto-offer: no offers, just base plan - * subscription + base plan - * subscription - * oneTimePurchaseOffer + * one with paid offer 1.99 ---> DONE + * subscription + base plan + auto-offer: no offers, just base plan ---> DONE + * subscription + base plan ---> DONE + * subscription ---> DONE (gives wrong value but to be expected) + * oneTimePurchaseOffer ---> DONE */ @Test @@ -329,7 +382,6 @@ class ProductFetcherInstrumentedTest { val dateIn30Days = currentDate.plusMonths(1) val dateFormatter = DateTimeFormatter.ofPattern("MMM dd, yyyy", Locale.getDefault()) val formattedDate = dateIn30Days.format(dateFormatter) - println("$formattedDate ${storeProduct.trialPeriodEndDateString}") assert(storeProduct.trialPeriodEndDateString == formattedDate) } @@ -378,7 +430,54 @@ class ProductFetcherInstrumentedTest { val dateIn30Days = currentDate.plusMonths(1) val dateFormatter = DateTimeFormatter.ofPattern("MMM dd, yyyy", Locale.getDefault()) val formattedDate = dateIn30Days.format(dateFormatter) - println("$formattedDate ${storeProduct.trialPeriodEndDateString}") + assert(storeProduct.trialPeriodEndDateString == formattedDate) + } + + @Test + fun test_storeProduct_basePlan_withPaidOffer() = runTest { + // subscription + base plan + offer: Free Trial phase + val storeProduct = StoreProduct( + rawStoreProduct = RawStoreProduct( + underlyingProductDetails = productDetails, + fullIdentifier = "com.ui_tests.quarterly2:test-4:paid-offer", + basePlanId = "test-4", + offerType = OfferType.Offer(id = "paid-offer") + ) + ) + assert(!storeProduct.hasFreeTrial) + assert(storeProduct.productIdentifier == "com.ui_tests.quarterly2") + assert(storeProduct.fullIdentifier == "com.ui_tests.quarterly2:test-4:paid-offer") + assert(storeProduct.currencyCode == "USD") + assert(storeProduct.currencySymbol == "US$") // This actually just returns "$" in the main app. + assert(storeProduct.dailyPrice == "US$0.29") + assert(storeProduct.weeklyPrice == "US$2.24") + assert(storeProduct.monthlyPrice == "US$8.99") + assert(storeProduct.yearlyPrice == "US$107.88") + assert(storeProduct.periodDays == 30) + assert(storeProduct.periodMonths == 1) + assert(storeProduct.periodWeeks == 4) + assert(storeProduct.periodYears == 0) + assert(storeProduct.localizedSubscriptionPeriod == "1 month") + assert(storeProduct.periodly == "monthly") + assert(storeProduct.trialPeriodMonths == 1) + assert(storeProduct.trialPeriodWeeks == 4) + assert(storeProduct.trialPeriodText == "30-day") + assert(storeProduct.price == BigDecimal("8.99")) + assert(storeProduct.trialPeriodYears == 0) + assert(storeProduct.languageCode == "en") + val defaultLocale = Locale.getDefault().toString() + assert(storeProduct.locale == defaultLocale) // Change this depending on what your computer is set + assert(storeProduct.trialPeriodPrice == BigDecimal("6.74")) + assert(storeProduct.trialPeriodPricePerUnit(SubscriptionPeriod.Unit.day) == "US$0.22") + assert(storeProduct.trialPeriodPricePerUnit(SubscriptionPeriod.Unit.month) == "US$6.74") + assert(storeProduct.trialPeriodPricePerUnit(SubscriptionPeriod.Unit.week) == "US$1.68") + assert(storeProduct.trialPeriodPricePerUnit(SubscriptionPeriod.Unit.year) == "US$20.22") + assert(storeProduct.localizedTrialPeriodPrice == "US$6.74") + + val currentDate = LocalDate.now() + val dateIn30Days = currentDate.plusMonths(1) + val dateFormatter = DateTimeFormatter.ofPattern("MMM dd, yyyy", Locale.getDefault()) + val formattedDate = dateIn30Days.format(dateFormatter) assert(storeProduct.trialPeriodEndDateString == formattedDate) } @@ -410,7 +509,6 @@ class ProductFetcherInstrumentedTest { assert(storeProduct.periodYears == 0) assert(storeProduct.localizedSubscriptionPeriod == "1 month") assert(storeProduct.periodly == "monthly") - println(storeProduct.trialPeriodMonths) assert(storeProduct.trialPeriodMonths == 1) assert(storeProduct.trialPeriodWeeks == 4) assert(storeProduct.trialPeriodText == "30-day") @@ -448,7 +546,6 @@ class ProductFetcherInstrumentedTest { assert(!storeProduct.hasFreeTrial) assert(storeProduct.productIdentifier == "com.ui_tests.quarterly2") assert(storeProduct.fullIdentifier == "com.ui_tests.quarterly2:test-3:sw-auto") - println("Currency? ${storeProduct.currencyCode}") assert(storeProduct.currencyCode == "USD") assert(storeProduct.currencySymbol == "US$") // This actually just returns "$" in the main app. assert(storeProduct.dailyPrice == "US$0.09") @@ -472,18 +569,234 @@ class ProductFetcherInstrumentedTest { assert(storeProduct.locale == defaultLocale) // Change this depending on what your computer is set assert(storeProduct.trialPeriodPrice == BigDecimal("1.99")) - println(storeProduct.trialPeriodPricePerUnit(SubscriptionPeriod.Unit.year)) assert(storeProduct.trialPeriodPricePerUnit(SubscriptionPeriod.Unit.day) == "US$0.06") assert(storeProduct.trialPeriodPricePerUnit(SubscriptionPeriod.Unit.month) == "US$1.99") assert(storeProduct.trialPeriodPricePerUnit(SubscriptionPeriod.Unit.week) == "US$0.49") - assert(storeProduct.trialPeriodPricePerUnit(SubscriptionPeriod.Unit.year) == "US$23.88") + assert(storeProduct.trialPeriodPricePerUnit(SubscriptionPeriod.Unit.year) == "US$1.99") assert(storeProduct.localizedTrialPeriodPrice == "US$1.99") val currentDate = LocalDate.now() val dateIn30Days = currentDate.plusMonths(1) val dateFormatter = DateTimeFormatter.ofPattern("MMM dd, yyyy", Locale.getDefault()) val formattedDate = dateIn30Days.format(dateFormatter) - println("$formattedDate ${storeProduct.trialPeriodEndDateString}") assert(storeProduct.trialPeriodEndDateString == formattedDate) } + + @Test + fun test_storeProduct_basePlan_autoOffer_noOffers() = runTest { + // subscription + base plan + auto-offer + val storeProduct = StoreProduct( + rawStoreProduct = RawStoreProduct( + underlyingProductDetails = productDetails, + fullIdentifier = "com.ui_tests.quarterly2:test-5:sw-auto", + basePlanId = "test-5", + offerType = OfferType.Auto + ) + ) + assert(!storeProduct.hasFreeTrial) + assert(storeProduct.productIdentifier == "com.ui_tests.quarterly2") + assert(storeProduct.fullIdentifier == "com.ui_tests.quarterly2:test-5:sw-auto") + assert(storeProduct.currencyCode == "USD") + assert(storeProduct.currencySymbol == "US$") // This actually just returns "$" in the main app. + assert(storeProduct.dailyPrice == "US$0.01") + assert(storeProduct.weeklyPrice == "US$0.07") + assert(storeProduct.monthlyPrice == "US$0.33") + assert(storeProduct.yearlyPrice == "US$3.99") + assert(storeProduct.periodDays == 365) + assert(storeProduct.periodMonths == 12) + assert(storeProduct.periodWeeks == 52) + assert(storeProduct.periodYears == 1) + assert(storeProduct.localizedSubscriptionPeriod == "1 year") + assert(storeProduct.periodly == "yearly") + assert(storeProduct.trialPeriodMonths == 0) + assert(storeProduct.trialPeriodWeeks == 0) + assert(storeProduct.trialPeriodText.isEmpty()) + assert(storeProduct.price == BigDecimal("3.99")) + assert(storeProduct.trialPeriodYears == 0) + assert(storeProduct.languageCode == "en") + val defaultLocale = Locale.getDefault().toString() + assert(storeProduct.locale == defaultLocale) // Change this depending on what your computer is set + assert(storeProduct.trialPeriodPrice == BigDecimal.ZERO) + assert(storeProduct.trialPeriodPricePerUnit(SubscriptionPeriod.Unit.day) == "US$0.00") + assert(storeProduct.trialPeriodPricePerUnit(SubscriptionPeriod.Unit.month) == "US$0.00") + assert(storeProduct.trialPeriodPricePerUnit(SubscriptionPeriod.Unit.week) == "US$0.00") + assert(storeProduct.trialPeriodPricePerUnit(SubscriptionPeriod.Unit.year) == "US$0.00") + assert(storeProduct.localizedTrialPeriodPrice == "US$0.00") + assert(storeProduct.trialPeriodEndDateString.isEmpty()) + } + + @Test + fun test_storeProduct_basePlan() = runTest { + // subscription + base plan + val storeProduct = StoreProduct( + rawStoreProduct = RawStoreProduct( + underlyingProductDetails = productDetails, + fullIdentifier = "com.ui_tests.quarterly2:test-5", + basePlanId = "test-5", + offerType = null + ) + ) + assert(!storeProduct.hasFreeTrial) + assert(storeProduct.productIdentifier == "com.ui_tests.quarterly2") + assert(storeProduct.fullIdentifier == "com.ui_tests.quarterly2:test-5") + assert(storeProduct.currencyCode == "USD") + assert(storeProduct.currencySymbol == "US$") // This actually just returns "$" in the main app. + assert(storeProduct.dailyPrice == "US$0.01") + assert(storeProduct.weeklyPrice == "US$0.07") + assert(storeProduct.monthlyPrice == "US$0.33") + assert(storeProduct.yearlyPrice == "US$3.99") + assert(storeProduct.periodDays == 365) + assert(storeProduct.periodMonths == 12) + assert(storeProduct.periodWeeks == 52) + assert(storeProduct.periodYears == 1) + assert(storeProduct.localizedSubscriptionPeriod == "1 year") + assert(storeProduct.periodly == "yearly") + assert(storeProduct.trialPeriodMonths == 0) + assert(storeProduct.trialPeriodWeeks == 0) + assert(storeProduct.trialPeriodText.isEmpty()) + assert(storeProduct.price == BigDecimal("3.99")) + assert(storeProduct.trialPeriodYears == 0) + assert(storeProduct.languageCode == "en") + val defaultLocale = Locale.getDefault().toString() + assert(storeProduct.locale == defaultLocale) // Change this depending on what your computer is set + assert(storeProduct.trialPeriodPrice == BigDecimal.ZERO) + assert(storeProduct.trialPeriodPricePerUnit(SubscriptionPeriod.Unit.day) == "US$0.00") + assert(storeProduct.trialPeriodPricePerUnit(SubscriptionPeriod.Unit.month) == "US$0.00") + assert(storeProduct.trialPeriodPricePerUnit(SubscriptionPeriod.Unit.week) == "US$0.00") + assert(storeProduct.trialPeriodPricePerUnit(SubscriptionPeriod.Unit.year) == "US$0.00") + assert(storeProduct.localizedTrialPeriodPrice == "US$0.00") + assert(storeProduct.trialPeriodEndDateString.isEmpty()) + } + + @Test + fun test_storeProduct_basePlan_invalidOfferId() = runTest { + // subscription + base plan + offer: Offer not found + val storeProduct = StoreProduct( + rawStoreProduct = RawStoreProduct( + underlyingProductDetails = productDetails, + fullIdentifier = "com.ui_tests.quarterly2:test-5", + basePlanId = "test-5", + offerType = OfferType.Offer(id = "doesnt-exist") + ) + ) + assert(!storeProduct.hasFreeTrial) + assert(storeProduct.productIdentifier == "com.ui_tests.quarterly2") + assert(storeProduct.fullIdentifier == "com.ui_tests.quarterly2:test-5") + assert(storeProduct.currencyCode == "USD") + assert(storeProduct.currencySymbol == "US$") // This actually just returns "$" in the main app. + assert(storeProduct.dailyPrice == "US$0.01") + assert(storeProduct.weeklyPrice == "US$0.07") + assert(storeProduct.monthlyPrice == "US$0.33") + assert(storeProduct.yearlyPrice == "US$3.99") + assert(storeProduct.periodDays == 365) + assert(storeProduct.periodMonths == 12) + assert(storeProduct.periodWeeks == 52) + assert(storeProduct.periodYears == 1) + assert(storeProduct.localizedSubscriptionPeriod == "1 year") + assert(storeProduct.periodly == "yearly") + assert(storeProduct.trialPeriodMonths == 0) + assert(storeProduct.trialPeriodWeeks == 0) + assert(storeProduct.trialPeriodText.isEmpty()) + assert(storeProduct.price == BigDecimal("3.99")) + assert(storeProduct.trialPeriodYears == 0) + assert(storeProduct.languageCode == "en") + val defaultLocale = Locale.getDefault().toString() + assert(storeProduct.locale == defaultLocale) // Change this depending on what your computer is set + assert(storeProduct.trialPeriodPrice == BigDecimal.ZERO) + assert(storeProduct.trialPeriodPricePerUnit(SubscriptionPeriod.Unit.day) == "US$0.00") + assert(storeProduct.trialPeriodPricePerUnit(SubscriptionPeriod.Unit.month) == "US$0.00") + assert(storeProduct.trialPeriodPricePerUnit(SubscriptionPeriod.Unit.week) == "US$0.00") + assert(storeProduct.trialPeriodPricePerUnit(SubscriptionPeriod.Unit.year) == "US$0.00") + assert(storeProduct.localizedTrialPeriodPrice == "US$0.00") + assert(storeProduct.trialPeriodEndDateString.isEmpty()) + } + + @Test + fun test_storeProduct_subscriptionOnly() = runTest { + // subscription + // Note: This returns the wrong one. We expect 18.99, as that's the backwards compatible one + // However, there's no way of us knowing this so we just pick the first base plan we come + // across. + val storeProduct = StoreProduct( + rawStoreProduct = RawStoreProduct( + underlyingProductDetails = productDetails, + fullIdentifier = "com.ui_tests.quarterly2", + basePlanId = null, + offerType = null + ) + ) + assert(!storeProduct.hasFreeTrial) + assert(storeProduct.productIdentifier == "com.ui_tests.quarterly2") + assert(storeProduct.fullIdentifier == "com.ui_tests.quarterly2") + assert(storeProduct.currencyCode == "USD") + assert(storeProduct.currencySymbol == "US$") // This actually just returns "$" in the main app. + assert(storeProduct.dailyPrice == "US$0.33") + assert(storeProduct.weeklyPrice == "US$2.49") + assert(storeProduct.monthlyPrice == "US$9.99") + assert(storeProduct.yearlyPrice == "US$119.88") + assert(storeProduct.periodDays == 30) + assert(storeProduct.periodMonths == 1) + assert(storeProduct.periodWeeks == 4) + assert(storeProduct.periodYears == 0) + assert(storeProduct.localizedSubscriptionPeriod == "1 month") + assert(storeProduct.periodly == "monthly") + assert(storeProduct.trialPeriodMonths == 0) + assert(storeProduct.trialPeriodWeeks == 0) + assert(storeProduct.trialPeriodText.isEmpty()) + assert(storeProduct.price == BigDecimal("9.99")) + assert(storeProduct.trialPeriodYears == 0) + assert(storeProduct.languageCode == "en") + val defaultLocale = Locale.getDefault().toString() + assert(storeProduct.locale == defaultLocale) // Change this depending on what your computer is set + assert(storeProduct.trialPeriodPrice == BigDecimal.ZERO) + assert(storeProduct.trialPeriodPricePerUnit(SubscriptionPeriod.Unit.day) == "US$0.00") + assert(storeProduct.trialPeriodPricePerUnit(SubscriptionPeriod.Unit.month) == "US$0.00") + assert(storeProduct.trialPeriodPricePerUnit(SubscriptionPeriod.Unit.week) == "US$0.00") + assert(storeProduct.trialPeriodPricePerUnit(SubscriptionPeriod.Unit.year) == "US$0.00") + assert(storeProduct.localizedTrialPeriodPrice == "US$0.00") + assert(storeProduct.trialPeriodEndDateString.isEmpty()) + } + + @Test + fun test_storeProduct_oneTimePurchase() = runTest { + // One-time purchase + val storeProduct = StoreProduct( + rawStoreProduct = RawStoreProduct( + underlyingProductDetails = oneTimePurchaseProduct, + fullIdentifier = "pro_test_8999_year", + basePlanId = null, + offerType = null + ) + ) + assert(!storeProduct.hasFreeTrial) + assert(storeProduct.productIdentifier == "pro_test_8999_year") + assert(storeProduct.fullIdentifier == "pro_test_8999_year") + assert(storeProduct.currencyCode == "USD") + assert(storeProduct.currencySymbol == "US$") // This actually just returns "$" in the main app. + assert(storeProduct.dailyPrice == "US$0.00") + assert(storeProduct.weeklyPrice == "US$0.00") + assert(storeProduct.monthlyPrice == "US$0.00") + assert(storeProduct.yearlyPrice == "US$0.00") + assert(storeProduct.periodDays == 0) + assert(storeProduct.periodMonths == 0) + assert(storeProduct.periodWeeks == 0) + assert(storeProduct.periodYears == 0) + assert(storeProduct.localizedSubscriptionPeriod.isEmpty()) + assert(storeProduct.periodly.isEmpty()) + assert(storeProduct.trialPeriodMonths == 0) + assert(storeProduct.trialPeriodWeeks == 0) + assert(storeProduct.trialPeriodText.isEmpty()) + assert(storeProduct.price == BigDecimal("89.99")) + assert(storeProduct.trialPeriodYears == 0) + assert(storeProduct.languageCode == "en") + val defaultLocale = Locale.getDefault().toString() + assert(storeProduct.locale == defaultLocale) // Change this depending on what your computer is set + assert(storeProduct.trialPeriodPrice == BigDecimal.ZERO) + assert(storeProduct.trialPeriodPricePerUnit(SubscriptionPeriod.Unit.day) == "US$0.00") + assert(storeProduct.trialPeriodPricePerUnit(SubscriptionPeriod.Unit.month) == "US$0.00") + assert(storeProduct.trialPeriodPricePerUnit(SubscriptionPeriod.Unit.week) == "US$0.00") + assert(storeProduct.trialPeriodPricePerUnit(SubscriptionPeriod.Unit.year) == "US$0.00") + assert(storeProduct.localizedTrialPeriodPrice == "US$0.00") + assert(storeProduct.trialPeriodEndDateString.isEmpty()) + } } \ No newline at end of file From 4e383f48ba7c48850c5ea4a9f5cb5ea2070c6232 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= Date: Mon, 4 Dec 2023 16:25:56 -0500 Subject: [PATCH 12/23] Closes SW-2584, adds comments and updates version --- CHANGELOG.md | 16 ++++++---------- .../purchase/RevenueCatPurchaseController.kt | 10 ++++++++-- superwall/build.gradle.kts | 2 +- .../store/ExternalNativePurchaseController.kt | 2 +- 4 files changed, 16 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1a0f0bbf..f7234a3c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,28 +2,24 @@ The changelog for `Superwall`. Also see the [releases](https://github.com/superwall-me/Superwall-Android/releases) on GitHub. -## 1.0.0-alpha.29 +## 1.0.0-alpha.27 -### Enhancements +### Breaking Changes -- #SW-2600: Backport device attributes - -## 1.0.0-alpha.28 +- Changes the `PurchaseController` purchase function to `purchase(activity:productDetails:basePlanId:offerId:)`. +This adds support for purchasing offers and base plans. ### Enhancements +- #SW-2600: Backport device attributes - Adds support for localized paywalls. - -## 1.0.0-alpha.27 - -### Enhancements - - Paywalls are only preloaded if their associated rules can match. - Adds status bar to full screen paywalls. ### Fixes - Fixes issue where holdouts were still matching even if the limit set for their corresponding rules were exceeded. +- #SW-2584: Fixes issue where prices with non-terminating decimals were causing products to fail to load. ## 1.0.0-alpha.26 diff --git a/app/src/main/java/com/superwall/superapp/purchase/RevenueCatPurchaseController.kt b/app/src/main/java/com/superwall/superapp/purchase/RevenueCatPurchaseController.kt index 0b4af533..659e9c04 100644 --- a/app/src/main/java/com/superwall/superapp/purchase/RevenueCatPurchaseController.kt +++ b/app/src/main/java/com/superwall/superapp/purchase/RevenueCatPurchaseController.kt @@ -114,14 +114,16 @@ class RevenueCatPurchaseController(val context: Context): PurchaseController, Up basePlanId: String?, offerId: String? ): PurchaseResult { + // Find products matching productId from RevenueCat val products = Purchases.sharedInstance.awaitProducts(listOf(productDetails.productId)) // Choose the product which matches the given base plan. - // If no base plan set, select first product + // If no base plan set, select first product or fail. val product = products.firstOrNull { it.googleProduct?.basePlanId == basePlanId } ?: products.firstOrNull() ?: return PurchaseResult.Failed("Product not found") + return when (product.type) { ProductType.SUBS, ProductType.UNKNOWN -> handleSubscription(activity, product, basePlanId, offerId) ProductType.INAPP -> handleInAppPurchase(activity, product) @@ -141,11 +143,15 @@ class RevenueCatPurchaseController(val context: Context): PurchaseController, Up offerId: String? ): PurchaseResult { storeProduct.subscriptionOptions?.let { subscriptionOptions -> + // If subscription option exists, concatenate base + offer ID. val subscriptionOptionId = buildSubscriptionOptionId(basePlanId, offerId) + + // Find first subscription option that matches the subscription option ID or default + // to letting revenuecat choose. val subscriptionOption = subscriptionOptions.firstOrNull { it.id == subscriptionOptionId } ?: subscriptionOptions.defaultOffer - println("!!! RevenueCat Subscription Options $subscriptionOptions") + // Purchase subscription option, otherwise fail. if (subscriptionOption != null) { return purchaseSubscription(activity, subscriptionOption) } diff --git a/superwall/build.gradle.kts b/superwall/build.gradle.kts index c4e9a8e9..0447b316 100644 --- a/superwall/build.gradle.kts +++ b/superwall/build.gradle.kts @@ -19,7 +19,7 @@ plugins { id("maven-publish") } -version = "1.0.0-alpha.26" +version = "1.0.0-alpha.27" android { compileSdk = 33 diff --git a/superwall/src/main/java/com/superwall/sdk/store/ExternalNativePurchaseController.kt b/superwall/src/main/java/com/superwall/sdk/store/ExternalNativePurchaseController.kt index 49bc1b22..953c35f1 100644 --- a/superwall/src/main/java/com/superwall/sdk/store/ExternalNativePurchaseController.kt +++ b/superwall/src/main/java/com/superwall/sdk/store/ExternalNativePurchaseController.kt @@ -101,7 +101,7 @@ class ExternalNativePurchaseController(var context: Context) : PurchaseControlle billingClient.launchBillingFlow(activity, flowParams) // Wait until a purchase result is emitted before returning the result - val value = purchaseResults.first { it != null } ?: PurchaseResult.Failed("Purchase failed") + val value = purchaseResults.first { it != null } ?: PurchaseResult.Failed("Purchase failed") return value } From 73c2037b2465dc1f7cb9c08f12f65601dc60bdc8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= Date: Tue, 5 Dec 2023 10:40:17 -0500 Subject: [PATCH 13/23] Update CHANGELOG.md --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f7234a3c..d2f41fd7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,7 @@ The changelog for `Superwall`. Also see the [releases](https://github.com/superw ### Breaking Changes -- Changes the `PurchaseController` purchase function to `purchase(activity:productDetails:basePlanId:offerId:)`. +- #SW-2218: Changes the `PurchaseController` purchase function to `purchase(activity:productDetails:basePlanId:offerId:)`. This adds support for purchasing offers and base plans. ### Enhancements From ca1ffa0bba2fbd138596e4738b91d473968a1cc9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= Date: Tue, 5 Dec 2023 11:35:28 -0500 Subject: [PATCH 14/23] Fixes SW-2620 - Separated the build+test and build+test+deploy actions so that we don't accidentally deploy when creating a PR from develop to main. - Fixes tests. --- .../{gradle.yml => build+test+deploy.yml} | 2 - .github/workflows/build+test.yml | 41 +++++++++++++++++++ .../superwall/sdk/config/ConfigLogicTest.kt | 19 +++++---- .../superwall/sdk/models/config/ConfigTest.kt | 2 +- .../sdk/models/paywall/PaywallProductTest.kt | 4 +- 5 files changed, 54 insertions(+), 14 deletions(-) rename .github/workflows/{gradle.yml => build+test+deploy.yml} (99%) create mode 100644 .github/workflows/build+test.yml diff --git a/.github/workflows/gradle.yml b/.github/workflows/build+test+deploy.yml similarity index 99% rename from .github/workflows/gradle.yml rename to .github/workflows/build+test+deploy.yml index 8836aa65..70a4ad24 100644 --- a/.github/workflows/gradle.yml +++ b/.github/workflows/build+test+deploy.yml @@ -10,8 +10,6 @@ name: Build, Test & Publish on: push: branches: [ "main" ] - pull_request: - branches: [ "main" ] permissions: contents: write diff --git a/.github/workflows/build+test.yml b/.github/workflows/build+test.yml new file mode 100644 index 00000000..b76d772d --- /dev/null +++ b/.github/workflows/build+test.yml @@ -0,0 +1,41 @@ +# This workflow uses actions that are not certified by GitHub. +# They are provided by a third-party and are governed by +# separate terms of service, privacy policy, and support +# documentation. +# This workflow will build a Java project with Gradle and cache/restore any dependencies to improve the workflow execution time +# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-java-with-gradle + +name: Build, Test + +on: + pull_request: + branches: [ "main" ] + +# Prevents running a bunch of we push right back to back +concurrency: + group: test-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + build: + + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v3 + with: + fetch-depth: 0 + - name: Set Up JDK + uses: actions/setup-java@v3 + with: + distribution: 'zulu' # See 'Supported distributions' for available options + java-version: '17' + cache: 'gradle' + + # Allow us to run the command + - name: Change wrapper permissions + run: chmod +x ./gradlew + + # Run Build & Test the Project + - name: Build gradle project + run: ./gradlew build diff --git a/superwall/src/test/java/com/superwall/sdk/config/ConfigLogicTest.kt b/superwall/src/test/java/com/superwall/sdk/config/ConfigLogicTest.kt index 6802fa4c..28f54a00 100644 --- a/superwall/src/test/java/com/superwall/sdk/config/ConfigLogicTest.kt +++ b/superwall/src/test/java/com/superwall/sdk/config/ConfigLogicTest.kt @@ -7,6 +7,7 @@ import com.superwall.sdk.models.events.EventData import com.superwall.sdk.models.paywall.Paywall import com.superwall.sdk.models.triggers.* import com.superwall.sdk.paywall.presentation.rule_logic.expression_evaluator.ExpressionEvaluating +import kotlinx.coroutines.test.runTest import org.junit.Assert.* import org.junit.Test @@ -208,7 +209,7 @@ internal class ConfigLogicTest { fun test_assignVariants_noTriggers() { // Given val confirmedAssignments = mutableMapOf( - "exp1" as ExperimentID to Experiment.Variant( + "exp1" to Experiment.Variant( id = "1", type = Experiment.Variant.VariantType.TREATMENT, paywallId = "abc" @@ -230,7 +231,7 @@ internal class ConfigLogicTest { fun test_chooseAssignments_noRules() { // Given val confirmedAssignments = mutableMapOf( - "exp1" as ExperimentID to Experiment.Variant( + "exp1" to Experiment.Variant( id = "1", type = Experiment.Variant.VariantType.TREATMENT, paywallId = "abc" @@ -579,7 +580,7 @@ internal class ConfigLogicTest { } @Test - fun `test getStaticPaywall shortLocaleNotContainedInConfig`() { + fun test_getStaticPaywall_shortLocaleNotContainedInConfig() { val paywallId = "abc" val locale = "de_DE" val config: Config = Config.stub().apply { @@ -601,7 +602,7 @@ internal class ConfigLogicTest { } @Test - fun `test getStaticPaywallResponse shortLocaleContainedInConfig`() { + fun test_GetStaticPaywallResponse_ShortLocaleContainedInConfig() { val paywallId = "abc" val locale = "de_DE" val config: Config = Config.stub() @@ -624,7 +625,7 @@ internal class ConfigLogicTest { } @Test - suspend fun test_getAllActiveTreatmentPaywallIds_onlyConfirmedAssignments_treatment() { + fun test_getAllActiveTreatmentPaywallIds_onlyConfirmedAssignments_treatment() = runTest { val paywallId1 = "abc" val experiment1 = "def" @@ -657,7 +658,7 @@ internal class ConfigLogicTest { @Test - suspend fun test_getAllActiveTreatmentPaywallIds_onlyConfirmedAssignments_treatment_multipleTriggerSameGroupId() { + fun test_getAllActiveTreatmentPaywallIds_onlyConfirmedAssignments_treatment_multipleTriggerSameGroupId() = runTest { val paywallId1 = "abc" val experiment1 = "def" @@ -698,7 +699,7 @@ internal class ConfigLogicTest { } @Test - suspend fun test_getAllActiveTreatmentPaywallIds_onlyConfirmedAssignments_holdout() { + fun test_getAllActiveTreatmentPaywallIds_onlyConfirmedAssignments_holdout() = runTest { val experiment1 = "def" val triggers = setOf( @@ -730,7 +731,7 @@ internal class ConfigLogicTest { @Test - suspend fun test_getAllActiveTreatmentPaywallIds_onlyConfirmedAssignments_filterOldOnes() { + fun test_getAllActiveTreatmentPaywallIds_onlyConfirmedAssignments_filterOldOnes() = runTest { val paywallId1 = "abc" val experiment1 = "def" val paywallId2 = "efg" @@ -769,7 +770,7 @@ internal class ConfigLogicTest { } @Test - suspend fun test_getAllActiveTreatmentPaywallIds_confirmedAndUnconfirmedAssignments_filterOldOnes() { + fun test_getAllActiveTreatmentPaywallIds_confirmedAndUnconfirmedAssignments_filterOldOnes() = runTest { val paywallId1 = "abc" val experiment1 = "def" val paywallId2 = "efg" diff --git a/superwall/src/test/java/com/superwall/sdk/models/config/ConfigTest.kt b/superwall/src/test/java/com/superwall/sdk/models/config/ConfigTest.kt index 9de7bd12..f9b3508c 100644 --- a/superwall/src/test/java/com/superwall/sdk/models/config/ConfigTest.kt +++ b/superwall/src/test/java/com/superwall/sdk/models/config/ConfigTest.kt @@ -10,7 +10,7 @@ class ConfigTest { @Test fun `test parsing of config`() { val configString = """ - {"toggles":[],"trigger_options":[{"trigger_version":"V2","event_name":"campaign_trigger","rules":[{"experiment_group_id":"3629","experiment_id":"4516","expression":null,"expression_js":null,"variants":[{"percentage":0,"variant_type":"HOLDOUT","variant_id":"14279"},{"percentage":100,"variant_type":"TREATMENT","variant_id":"14280","paywall_identifier":"example-paywall-3e12-2023-05-02"}]}]}],"product_identifier_groups":[],"paywalls":[],"paywall_responses":[{"id":"7010","url":"https://templates.superwall.com/release/1.48.5/h3/?autoAdjustContentAlignment=true&backgroundColor=%23FFFFFF&badgeRadius=8px&borderColor=rgba%280%2C0%2C0%2C0.3%29&borderWidth=1px&brandColor=%234592E7&brandSecondaryColor=&brandTextColor=%23FFFFFF&buttonBackgroundColor=&cardBackgroundBlur=false&cardPadding=calc%28var%28--sw-spacing%29+*+1.6%29&containerRadius=0px&contentAlignment=top&contentPadding=calc%28var%28--sw-spacing%29+*+1.6%29&direction=vertical&font=&fontSize=calc%28min%284.5vw%2C+1rem%29%29&footer=normal&footerAlignment=spread&footerDivider=show&footerPosition=fixed&forceDarkMode=false&foregroundColor=%23000000&googleFonts=&headingFont=&hideProducts=true&insetsTopContent=false&logoHeight=35px&logoURL=..%2Fpublic%2Fassets%2Flogo.svg&minNavbarHeight=64px&nav=show&navBackgroundBlur=false&navBackgroundColor=clear&navButtonIconsSize=28px&navCenter=hide&navLeft=icon&navLeftIconURL=..%2Fpublic%2Fassets%2Fexit-black.svg&navLeftInnerIconURL=..%2Fpublic%2Fassets%2Fnav-button.svg&navPosition=fixed&navRight=hide&navRightIconURL=..%2Fpublic%2Fassets%2Fnav-button.svg&navRightInnerIconURL=..%2Fpublic%2Fassets%2Fnav-button.svg&paywallBackgroundColor=%23FFFFFF&paywallMaxWidth=480px&pjs=https%3A%2F%2Fcdn.superwall.me%2Fruntime%2Fentrypoint.js&previewDarkMode=false&primaryProductBadge=show&productBackgroundColor=rgba%28255%2C+255%2C+255%2C+0.8%29&productCount=auto&productDescription=show&productDivider=hide&productPadding=15px&productRadius=12px&purchaseRadius=15px&reverseDirection=false&secondaryProductBadge=hide&selectedBorderWidth=2px&spacing=10px&statusBarPadding=34px&tertiaryProductBadge=hide&sw_cache_key=1683069228509","name":"Example Paywall","identifier":"example-paywall-3e12-2023-05-02","slug":"example-paywall-3e12-2023-05-02","paywalljs_event":"W3siZXZlbnRfbmFtZSI6InRlbXBsYXRlX3N1YnN0aXR1dGlvbnMiLCJzdWJzdGl0dXRpb25zIjpbeyJrZXkiOiJjbG9zZS0xIiwidmFsdWUiOiIiLCJza2lwSW5uZXJIVE1MIjp0cnVlLCJ0YWdOYW1lIjoiaW1nIiwic3ViVHlwZSI6ImltZyIsInByb3BlcnRpZXMiOlt7InByZWZpeCI6ImRlZmF1bHQiLCJwcm9wZXJ0eSI6eyJ0eXBlIjoiY2xpY2stYmVoYXZpb3IiLCJjbGlja0JlaGF2aW9yIjp7InR5cGUiOiJjbG9zZSJ9fX0seyJwcmVmaXgiOiJkZWZhdWx0IiwicHJvcGVydHkiOnsidHlwZSI6InNyYyIsInNyYyI6Ii4uL3B1YmxpYy9hc3NldHMvZXhpdC1ibGFjay5zdmciLCJzcmNTZXQiOlsiIl19fV19LHsia2V5IjoiY2xvc2UtMiIsInZhbHVlIjoibGVmdCIsInNraXBJbm5lckhUTUwiOmZhbHNlLCJ0YWdOYW1lIjoiZGl2Iiwic3ViVHlwZSI6InZhciIsInByb3BlcnRpZXMiOlt7InByZWZpeCI6ImRlZmF1bHQiLCJwcm9wZXJ0eSI6eyJ0eXBlIjoiY2xpY2stYmVoYXZpb3IiLCJjbGlja0JlaGF2aW9yIjp7InR5cGUiOiJjbG9zZSJ9fX1dfSx7ImtleSI6ImNsb3NlLTMiLCJ2YWx1ZSI6InJpZ2h0Iiwic2tpcElubmVySFRNTCI6ZmFsc2UsInRhZ05hbWUiOiJkaXYiLCJzdWJUeXBlIjoidmFyIiwicHJvcGVydGllcyI6W3sicHJlZml4IjoiZGVmYXVsdCIsInByb3BlcnR5Ijp7InR5cGUiOiJjbGljay1iZWhhdmlvciIsImNsaWNrQmVoYXZpb3IiOnsidHlwZSI6ImNsb3NlIn19fV19LHsia2V5IjoiY2xvc2UtNCIsInZhbHVlIjoiIiwic2tpcElubmVySFRNTCI6dHJ1ZSwidGFnTmFtZSI6ImltZyIsInN1YlR5cGUiOiJpbWciLCJwcm9wZXJ0aWVzIjpbeyJwcmVmaXgiOiJkZWZhdWx0IiwicHJvcGVydHkiOnsidHlwZSI6ImNsaWNrLWJlaGF2aW9yIiwiY2xpY2tCZWhhdmlvciI6eyJ0eXBlIjoiY2xvc2UifX19LHsicHJlZml4IjoiZGVmYXVsdCIsInByb3BlcnR5Ijp7InR5cGUiOiJzcmMiLCJzcmMiOiIuLi9wdWJsaWMvYXNzZXRzL25hdi1idXR0b24uc3ZnIiwic3JjU2V0IjpbIiJdfX1dfSx7ImtleSI6InJlc3RvcmUtMSIsInZhbHVlIjoiUmVzdG9yZSBQdXJjaGFzZSIsInNraXBJbm5lckhUTUwiOmZhbHNlLCJ0YWdOYW1lIjoicCIsInN1YlR5cGUiOiJ2YXIiLCJwcm9wZXJ0aWVzIjpbeyJwcmVmaXgiOiJkZWZhdWx0IiwicHJvcGVydHkiOnsidHlwZSI6ImNsaWNrLWJlaGF2aW9yIiwiY2xpY2tCZWhhdmlvciI6eyJ0eXBlIjoicmVzdG9yZSJ9fX1dfSx7ImtleSI6Ik5hdiIsInZhbHVlIjoiIiwic2tpcElubmVySFRNTCI6dHJ1ZSwidGFnTmFtZSI6ImRpdiIsInN1YlR5cGUiOiJpbWciLCJwcm9wZXJ0aWVzIjpbeyJwcmVmaXgiOiJkZWZhdWx0IiwicHJvcGVydHkiOnsidHlwZSI6InNyYyIsInNyYyI6Ii4uL3B1YmxpYy9hc3NldHMvZXhpdC1ibGFjay5zdmciLCJzcmNTZXQiOlsiIl19fV19LHsia2V5IjoiTmF2ID4gTmF2YmFyIENvbnRhaW5lciBJbm5lciIsInZhbHVlIjoiIiwic2tpcElubmVySFRNTCI6dHJ1ZSwidGFnTmFtZSI6ImRpdiIsInN1YlR5cGUiOiJpbWciLCJwcm9wZXJ0aWVzIjpbeyJwcmVmaXgiOiJkZWZhdWx0IiwicHJvcGVydHkiOnsidHlwZSI6InNyYyIsInNyYyI6Ii4uL3B1YmxpYy9hc3NldHMvZXhpdC1ibGFjay5zdmciLCJzcmNTZXQiOlsiIl19fV19LHsia2V5IjoiTmF2ID4gTmF2IExlZnQiLCJ2YWx1ZSI6IiIsInNraXBJbm5lckhUTUwiOnRydWUsInRhZ05hbWUiOiJkaXYiLCJzdWJUeXBlIjoiaW1nIiwicHJvcGVydGllcyI6W3sicHJlZml4IjoiZGVmYXVsdCIsInByb3BlcnR5Ijp7InR5cGUiOiJzcmMiLCJzcmMiOiIuLi9wdWJsaWMvYXNzZXRzL2V4aXQtYmxhY2suc3ZnIiwic3JjU2V0IjpbIiJdfX1dfSx7ImtleSI6Ik5hdiA+IE5hdiBMaW5rIiwidmFsdWUiOiIiLCJza2lwSW5uZXJIVE1MIjp0cnVlLCJ0YWdOYW1lIjoiaW1nIiwic3ViVHlwZSI6ImltZyIsInByb3BlcnRpZXMiOlt7InByZWZpeCI6ImRlZmF1bHQiLCJwcm9wZXJ0eSI6eyJ0eXBlIjoic3JjIiwic3JjIjoiLi4vcHVibGljL2Fzc2V0cy9uYXYtYnV0dG9uLnN2ZyIsInNyY1NldCI6WyIiXX19XX0seyJrZXkiOiJOYXYgPiBOYXYgTGluayAyIiwidmFsdWUiOiJleHRyYSIsInNraXBJbm5lckhUTUwiOmZhbHNlLCJ0YWdOYW1lIjoiZGl2Iiwic3ViVHlwZSI6InZhciIsInByb3BlcnRpZXMiOltdfSx7ImtleSI6Ik5hdiA+IE5hdiBDZW50ZXIiLCJ2YWx1ZSI6IiIsInNraXBJbm5lckhUTUwiOnRydWUsInRhZ05hbWUiOiJkaXYiLCJzdWJUeXBlIjoidmFyIiwicHJvcGVydGllcyI6W119LHsia2V5IjoiTmF2ID4gTmF2IExvZ28iLCJ2YWx1ZSI6IiIsInNraXBJbm5lckhUTUwiOnRydWUsInRhZ05hbWUiOiJpbWciLCJzdWJUeXBlIjoiaW1nIiwicHJvcGVydGllcyI6W3sicHJlZml4IjoiZGVmYXVsdCIsInByb3BlcnR5Ijp7InR5cGUiOiJzcmMiLCJzcmMiOiIuLi9wdWJsaWMvYXNzZXRzL2xvZ28uc3ZnIiwic3JjU2V0IjpbIiJdfX1dfSx7ImtleSI6Ik5hdiA+IE5hdiBMaW5rIDMiLCJ2YWx1ZSI6IlRpdGxlIiwic2tpcElubmVySFRNTCI6ZmFsc2UsInRhZ05hbWUiOiJkaXYiLCJzdWJUeXBlIjoidmFyIiwicHJvcGVydGllcyI6W119LHsia2V5IjoiTmF2ID4gTmF2IFJpZ2h0IiwidmFsdWUiOiIiLCJza2lwSW5uZXJIVE1MIjp0cnVlLCJ0YWdOYW1lIjoiZGl2Iiwic3ViVHlwZSI6ImltZyIsInByb3BlcnRpZXMiOlt7InByZWZpeCI6ImRlZmF1bHQiLCJwcm9wZXJ0eSI6eyJ0eXBlIjoic3JjIiwic3JjIjoiLi4vcHVibGljL2Fzc2V0cy9uYXYtYnV0dG9uLnN2ZyIsInNyY1NldCI6WyIiXX19XX0seyJrZXkiOiJOYXYgPiBOYXYgTGluayA0IiwidmFsdWUiOiJleHRyYSIsInNraXBJbm5lckhUTUwiOmZhbHNlLCJ0YWdOYW1lIjoiZGl2Iiwic3ViVHlwZSI6InZhciIsInByb3BlcnRpZXMiOltdfSx7ImtleSI6Ik5hdiA+IE5hdiBMaW5rIDUiLCJ2YWx1ZSI6IiIsInNraXBJbm5lckhUTUwiOnRydWUsInRhZ05hbWUiOiJpbWciLCJzdWJUeXBlIjoiaW1nIiwicHJvcGVydGllcyI6W3sicHJlZml4IjoiZGVmYXVsdCIsInByb3BlcnR5Ijp7InR5cGUiOiJzcmMiLCJzcmMiOiIuLi9wdWJsaWMvYXNzZXRzL25hdi1idXR0b24uc3ZnIiwic3JjU2V0IjpbIiJdfX1dfSx7ImtleSI6IlByaW1hcnkgQmFkZ2UiLCJ2YWx1ZSI6IkZyZWUgVHJpYWwiLCJza2lwSW5uZXJIVE1MIjpmYWxzZSwidGFnTmFtZSI6ImRpdiIsInN1YlR5cGUiOiJ2YXIiLCJwcm9wZXJ0aWVzIjpbXX0seyJrZXkiOiJQcmltYXJ5IExpbmUgMSIsInZhbHVlIjoiJDAuOTkiLCJza2lwSW5uZXJIVE1MIjpmYWxzZSwidGFnTmFtZSI6InAiLCJzdWJUeXBlIjoidmFyIiwicHJvcGVydGllcyI6W119LHsia2V5IjoiUHJpbWFyeSBMaW5lIDIiLCJ2YWx1ZSI6IkxpbmUgMiIsInNraXBJbm5lckhUTUwiOmZhbHNlLCJ0YWdOYW1lIjoicCIsInN1YlR5cGUiOiJ2YXIiLCJwcm9wZXJ0aWVzIjpbXX0seyJrZXkiOiJQcmltYXJ5IExpbmUgMyIsInZhbHVlIjoiTGluZSAzIiwic2tpcElubmVySFRNTCI6ZmFsc2UsInRhZ05hbWUiOiJwIiwic3ViVHlwZSI6InZhciIsInByb3BlcnRpZXMiOltdfSx7ImtleSI6IlByaW1hcnkgTGluZSA0IiwidmFsdWUiOiJMaW5lIDQiLCJza2lwSW5uZXJIVE1MIjpmYWxzZSwidGFnTmFtZSI6InAiLCJzdWJUeXBlIjoidmFyIiwicHJvcGVydGllcyI6W119LHsia2V5IjoiU2Vjb25kYXJ5IEJhZGdlIiwidmFsdWUiOiJGcmVlXG5UcmlhbCIsInNraXBJbm5lckhUTUwiOmZhbHNlLCJ0YWdOYW1lIjoiZGl2Iiwic3ViVHlwZSI6InZhciIsInByb3BlcnRpZXMiOltdfSx7ImtleSI6IlNlY29uZGFyeSBMaW5lIDEiLCJ2YWx1ZSI6IiQwLjk5Iiwic2tpcElubmVySFRNTCI6ZmFsc2UsInRhZ05hbWUiOiJwIiwic3ViVHlwZSI6InZhciIsInByb3BlcnRpZXMiOltdfSx7ImtleSI6IlNlY29uZGFyeSBMaW5lIDIiLCJ2YWx1ZSI6IkxpbmUgMiIsInNraXBJbm5lckhUTUwiOmZhbHNlLCJ0YWdOYW1lIjoicCIsInN1YlR5cGUiOiJ2YXIiLCJwcm9wZXJ0aWVzIjpbXX0seyJrZXkiOiJTZWNvbmRhcnkgTGluZSAzIiwidmFsdWUiOiJMaW5lIDMiLCJza2lwSW5uZXJIVE1MIjpmYWxzZSwidGFnTmFtZSI6InAiLCJzdWJUeXBlIjoidmFyIiwicHJvcGVydGllcyI6W119LHsia2V5IjoiU2Vjb25kYXJ5IExpbmUgNCIsInZhbHVlIjoiTGluZSA0Iiwic2tpcElubmVySFRNTCI6ZmFsc2UsInRhZ05hbWUiOiJwIiwic3ViVHlwZSI6InZhciIsInByb3BlcnRpZXMiOltdfSx7ImtleSI6IlRlcnRpYXJ5IEJhZGdlIiwidmFsdWUiOiJGcmVlIFRyaWFsIiwic2tpcElubmVySFRNTCI6ZmFsc2UsInRhZ05hbWUiOiJkaXYiLCJzdWJUeXBlIjoidmFyIiwicHJvcGVydGllcyI6W119LHsia2V5IjoiVGVydGlhcnkgTGluZSAxIiwidmFsdWUiOiIkMC45OSIsInNraXBJbm5lckhUTUwiOmZhbHNlLCJ0YWdOYW1lIjoicCIsInN1YlR5cGUiOiJ2YXIiLCJwcm9wZXJ0aWVzIjpbXX0seyJrZXkiOiJUZXJ0aWFyeSBMaW5lIDIiLCJ2YWx1ZSI6IkxpbmUgMiIsInNraXBJbm5lckhUTUwiOmZhbHNlLCJ0YWdOYW1lIjoicCIsInN1YlR5cGUiOiJ2YXIiLCJwcm9wZXJ0aWVzIjpbXX0seyJrZXkiOiJUZXJ0aWFyeSBMaW5lIDMiLCJ2YWx1ZSI6IkxpbmUgMyIsInNraXBJbm5lckhUTUwiOmZhbHNlLCJ0YWdOYW1lIjoicCIsInN1YlR5cGUiOiJ2YXIiLCJwcm9wZXJ0aWVzIjpbXX0seyJrZXkiOiJUZXJ0aWFyeSBMaW5lIDQiLCJ2YWx1ZSI6IkxpbmUgNCIsInNraXBJbm5lckhUTUwiOmZhbHNlLCJ0YWdOYW1lIjoicCIsInN1YlR5cGUiOiJ2YXIiLCJwcm9wZXJ0aWVzIjpbXX0seyJrZXkiOiJQcmltYXJ5IFNlbGVjdGVkIERlc2NyaXB0aW9uIiwidmFsdWUiOiJwcmltYXJ5IHNlbGVjdGVkIGRlc2NyaXB0aW9uIiwic2tpcElubmVySFRNTCI6ZmFsc2UsInRhZ05hbWUiOiJwIiwic3ViVHlwZSI6InZhciIsInByb3BlcnRpZXMiOltdfSx7ImtleSI6IlNlY29uZGFyeSBTZWxlY3RlZCBEZXNjcmlwdGlvbiIsInZhbHVlIjoic2Vjb25kYXJ5IHNlbGVjdGVkXG5kZXNjcmlwdGlvbiIsInNraXBJbm5lckhUTUwiOmZhbHNlLCJ0YWdOYW1lIjoicCIsInN1YlR5cGUiOiJ2YXIiLCJwcm9wZXJ0aWVzIjpbXX0seyJrZXkiOiJUZXJ0aWFyeSBTZWxlY3RlZCBEZXNjcmlwdGlvbiIsInZhbHVlIjoidGVydGlhcnkgc2VsZWN0ZWRcbmRlc2NyaXB0aW9uIiwic2tpcElubmVySFRNTCI6ZmFsc2UsInRhZ05hbWUiOiJwIiwic3ViVHlwZSI6InZhciIsInByb3BlcnRpZXMiOltdfSx7ImtleSI6IlByaWNpbmcgT3B0aW9ucyIsInZhbHVlIjoiVmlldyBBbGwgUGxhbnMiLCJza2lwSW5uZXJIVE1MIjpmYWxzZSwidGFnTmFtZSI6InAiLCJzdWJUeXBlIjoidmFyIiwicHJvcGVydGllcyI6W119LHsia2V5IjoiSW50ZXJuYWwgKFByaW1hcnkpIiwidmFsdWUiOiJ7e3ByaW1hcnkucHJpY2V9fSIsInNraXBJbm5lckhUTUwiOmZhbHNlLCJ0YWdOYW1lIjoicCIsInN1YlR5cGUiOiJ2YXIiLCJwcm9wZXJ0aWVzIjpbXX0seyJrZXkiOiJJbnRlcm5hbCAoU2Vjb25kYXJ5KSIsInZhbHVlIjoie3tzZWNvbmRhcnkucHJpY2V9fSIsInNraXBJbm5lckhUTUwiOmZhbHNlLCJ0YWdOYW1lIjoicCIsInN1YlR5cGUiOiJ2YXIiLCJwcm9wZXJ0aWVzIjpbXX0seyJrZXkiOiJJbnRlcm5hbCAoVGVydGlhcnkpIiwidmFsdWUiOiJ7e3RlcnRpYXJ5LnByaWNlfX0iLCJza2lwSW5uZXJIVE1MIjpmYWxzZSwidGFnTmFtZSI6InAiLCJzdWJUeXBlIjoidmFyIiwicHJvcGVydGllcyI6W119LHsia2V5IjoiRm9vdGVyIEZpcnN0IiwidmFsdWUiOiJUZXJtcyBvZiBVc2UiLCJza2lwSW5uZXJIVE1MIjpmYWxzZSwidGFnTmFtZSI6InAiLCJzdWJUeXBlIjoidmFyIiwicHJvcGVydGllcyI6W119LHsia2V5IjoiRm9vdGVyIExhc3QiLCJ2YWx1ZSI6IlByaXZhY3kgUG9saWN5Iiwic2tpcElubmVySFRNTCI6ZmFsc2UsInRhZ05hbWUiOiJwIiwic3ViVHlwZSI6InZhciIsInByb3BlcnRpZXMiOltdfSx7ImtleSI6IkZvb3RlciBDb2x1bW4gMSIsInZhbHVlIjoiVGVybXMiLCJza2lwSW5uZXJIVE1MIjpmYWxzZSwidGFnTmFtZSI6InAiLCJzdWJUeXBlIjoidmFyIiwicHJvcGVydGllcyI6W119LHsia2V5IjoiRm9vdGVyIENvbHVtbiAyIiwidmFsdWUiOiImYW1wOyIsInNraXBJbm5lckhUTUwiOmZhbHNlLCJ0YWdOYW1lIjoicCIsInN1YlR5cGUiOiJ2YXIiLCJwcm9wZXJ0aWVzIjpbXX0seyJrZXkiOiJGb290ZXIgQ29sdW1uIDMiLCJ2YWx1ZSI6IlByaXZhY3kiLCJza2lwSW5uZXJIVE1MIjpmYWxzZSwidGFnTmFtZSI6InAiLCJzdWJUeXBlIjoidmFyIiwicHJvcGVydGllcyI6W119LHsia2V5IjoiRm9vdGVyIENvbHVtbiA0IiwidmFsdWUiOiJ8Iiwic2tpcElubmVySFRNTCI6ZmFsc2UsInRhZ05hbWUiOiJwIiwic3ViVHlwZSI6InZhciIsInByb3BlcnRpZXMiOltdfSx7ImtleSI6IkZvb3RlciBDb2x1bW4gNSIsInZhbHVlIjoiUmVuZXdzIGZvciAkMC4wMCBwZXIgZGF5Iiwic2tpcElubmVySFRNTCI6ZmFsc2UsInRhZ05hbWUiOiJwIiwic3ViVHlwZSI6InZhciIsInByb3BlcnRpZXMiOltdfSx7ImtleSI6IkltYWdlIFdyYXBwZXIiLCJ2YWx1ZSI6IiIsInNraXBJbm5lckhUTUwiOnRydWUsInRhZ05hbWUiOiJkaXYiLCJzdWJUeXBlIjoiaW1nIiwicHJvcGVydGllcyI6W3sicHJlZml4IjoiZGVmYXVsdCIsInByb3BlcnR5Ijp7InR5cGUiOiJzcmMiLCJzcmMiOiIuLi9wdWJsaWMvYXNzZXRzL2hlYWRlci1waC0xLmpwZyIsInNyY1NldCI6WyIiXX19XX0seyJrZXkiOiJJbWFnZSBXcmFwcGVyID4gSW1hZ2UiLCJ2YWx1ZSI6IiIsInNraXBJbm5lckhUTUwiOnRydWUsInRhZ05hbWUiOiJpbWciLCJzdWJUeXBlIjoiaW1nIiwicHJvcGVydGllcyI6W3sicHJlZml4IjoiZGVmYXVsdCIsInByb3BlcnR5Ijp7InR5cGUiOiJzcmMiLCJzcmMiOiIuLi9wdWJsaWMvYXNzZXRzL2hlYWRlci1waC0xLmpwZyIsInNyY1NldCI6WyIiXX19XX0seyJrZXkiOiJUaXRsZSIsInZhbHVlIjoiIiwic2tpcElubmVySFRNTCI6dHJ1ZSwidGFnTmFtZSI6ImRpdiIsInN1YlR5cGUiOiJ2YXIiLCJwcm9wZXJ0aWVzIjpbXX0seyJrZXkiOiJUaXRsZSA+IFRleHQiLCJ2YWx1ZSI6IlRoaXMgaXMgYSB0aXRsZSIsInNraXBJbm5lckhUTUwiOmZhbHNlLCJ0YWdOYW1lIjoiaDEiLCJzdWJUeXBlIjoidmFyIiwicHJvcGVydGllcyI6W119LHsia2V5IjoiU3VidGl0bGUiLCJ2YWx1ZSI6IiIsInNraXBJbm5lckhUTUwiOnRydWUsInRhZ05hbWUiOiJkaXYiLCJzdWJUeXBlIjoidmFyIiwicHJvcGVydGllcyI6W119LHsia2V5IjoiU3VidGl0bGUgPiBUZXh0IiwidmFsdWUiOiJUaGlzIGlzIGEgc3VidGl0bGUiLCJza2lwSW5uZXJIVE1MIjpmYWxzZSwidGFnTmFtZSI6InAiLCJzdWJUeXBlIjoidmFyIiwicHJvcGVydGllcyI6W119LHsia2V5IjoiVGFibGUiLCJ2YWx1ZSI6IiIsInNraXBJbm5lckhUTUwiOnRydWUsInRhZ05hbWUiOiJkaXYiLCJzdWJUeXBlIjoidmFyIiwicHJvcGVydGllcyI6W119LHsia2V5IjoiVGFibGUgPiBSb3ciLCJ2YWx1ZSI6IiIsInNraXBJbm5lckhUTUwiOnRydWUsInRhZ05hbWUiOiJkaXYiLCJzdWJUeXBlIjoidmFyIiwicHJvcGVydGllcyI6W119LHsia2V5IjoiVGFibGUgPiBSb3cgPiBDb2wgTGVmdCIsInZhbHVlIjoiIiwic2tpcElubmVySFRNTCI6dHJ1ZSwidGFnTmFtZSI6ImRpdiIsInN1YlR5cGUiOiJ2YXIiLCJwcm9wZXJ0aWVzIjpbXX0seyJrZXkiOiJUYWJsZSA+IFJvdyA+IFRleHQiLCJ2YWx1ZSI6IlRpdGxlIiwic2tpcElubmVySFRNTCI6ZmFsc2UsInRhZ05hbWUiOiJwIiwic3ViVHlwZSI6InZhciIsInByb3BlcnRpZXMiOltdfSx7ImtleSI6IlRhYmxlID4gUm93ID4gQ29sIFJpZ2h0IiwidmFsdWUiOiIiLCJza2lwSW5uZXJIVE1MIjp0cnVlLCJ0YWdOYW1lIjoiZGl2Iiwic3ViVHlwZSI6InZhciIsInByb3BlcnRpZXMiOltdfSx7ImtleSI6IlRhYmxlID4gUm93ID4gSW1hZ2UiLCJ2YWx1ZSI6IiIsInNraXBJbm5lckhUTUwiOnRydWUsInRhZ05hbWUiOiJpbWciLCJzdWJUeXBlIjoiaW1nIiwicHJvcGVydGllcyI6W3sicHJlZml4IjoiZGVmYXVsdCIsInByb3BlcnR5Ijp7InR5cGUiOiJzcmMiLCJzcmMiOiIuLi9wdWJsaWMvYXNzZXRzL2xvZ29fZ3JheS5zdmciLCJzcmNTZXQiOlsiIl19fV19LHsia2V5IjoiVGFibGUgPiBSb3cgPiBUZXh0IDIiLCJ2YWx1ZSI6ImxhYmVsIiwic2tpcElubmVySFRNTCI6ZmFsc2UsInRhZ05hbWUiOiJwIiwic3ViVHlwZSI6InZhciIsInByb3BlcnRpZXMiOltdfSx7ImtleSI6IlRhYmxlID4gUm93ID4gQ29sIFJpZ2h0IDIiLCJ2YWx1ZSI6IiIsInNraXBJbm5lckhUTUwiOnRydWUsInRhZ05hbWUiOiJkaXYiLCJzdWJUeXBlIjoidmFyIiwicHJvcGVydGllcyI6W119LHsia2V5IjoiVGFibGUgPiBSb3cgPiBJbWFnZSAyIiwidmFsdWUiOiIiLCJza2lwSW5uZXJIVE1MIjp0cnVlLCJ0YWdOYW1lIjoiaW1nIiwic3ViVHlwZSI6ImltZyIsInByb3BlcnRpZXMiOlt7InByZWZpeCI6ImRlZmF1bHQiLCJwcm9wZXJ0eSI6eyJ0eXBlIjoic3JjIiwic3JjIjoiLi4vcHVibGljL2Fzc2V0cy9sb2dvX2dyYXkuc3ZnIiwic3JjU2V0IjpbIiJdfX1dfSx7ImtleSI6IlRhYmxlID4gUm93ID4gVGV4dCAzIiwidmFsdWUiOiJsYWJlbCIsInNraXBJbm5lckhUTUwiOmZhbHNlLCJ0YWdOYW1lIjoicCIsInN1YlR5cGUiOiJ2YXIiLCJwcm9wZXJ0aWVzIjpbXX0seyJrZXkiOiJUYWJsZSA+IFJvdyAyIiwidmFsdWUiOiIiLCJza2lwSW5uZXJIVE1MIjp0cnVlLCJ0YWdOYW1lIjoiZGl2Iiwic3ViVHlwZSI6InZhciIsInByb3BlcnRpZXMiOltdfSx7ImtleSI6IlRhYmxlID4gUm93IDIgPiBDb2wgTGVmdCIsInZhbHVlIjoiIiwic2tpcElubmVySFRNTCI6dHJ1ZSwidGFnTmFtZSI6ImRpdiIsInN1YlR5cGUiOiJ2YXIiLCJwcm9wZXJ0aWVzIjpbXX0seyJrZXkiOiJUYWJsZSA+IFJvdyAyID4gVGV4dCIsInZhbHVlIjoiTGFiZWwgMSIsInNraXBJbm5lckhUTUwiOmZhbHNlLCJ0YWdOYW1lIjoicCIsInN1YlR5cGUiOiJ2YXIiLCJwcm9wZXJ0aWVzIjpbXX0seyJrZXkiOiJUYWJsZSA+IFJvdyAyID4gQ29sIFJpZ2h0IiwidmFsdWUiOiIiLCJza2lwSW5uZXJIVE1MIjp0cnVlLCJ0YWdOYW1lIjoiZGl2Iiwic3ViVHlwZSI6InZhciIsInByb3BlcnRpZXMiOltdfSx7ImtleSI6IlRhYmxlID4gUm93IDIgPiBJY29uIENvbnRhaW5lciIsInZhbHVlIjoiIiwic2tpcElubmVySFRNTCI6dHJ1ZSwidGFnTmFtZSI6ImRpdiIsInN1YlR5cGUiOiJpbWciLCJwcm9wZXJ0aWVzIjpbeyJwcmVmaXgiOiJkZWZhdWx0IiwicHJvcGVydHkiOnsidHlwZSI6InNyYyIsInNyYyI6Ii4uL3B1YmxpYy9hc3NldHMvdGFibGUtbG9jay5zdmciLCJzcmNTZXQiOlsiIl19fV19LHsia2V5IjoiVGFibGUgPiBSb3cgMiA+IEltYWdlIiwidmFsdWUiOiIiLCJza2lwSW5uZXJIVE1MIjp0cnVlLCJ0YWdOYW1lIjoiaW1nIiwic3ViVHlwZSI6ImltZyIsInByb3BlcnRpZXMiOlt7InByZWZpeCI6ImRlZmF1bHQiLCJwcm9wZXJ0eSI6eyJ0eXBlIjoic3JjIiwic3JjIjoiLi4vcHVibGljL2Fzc2V0cy90YWJsZS1sb2NrLnN2ZyIsInNyY1NldCI6WyIiXX19XX0seyJrZXkiOiJUYWJsZSA+IFJvdyAyID4gVGV4dCAyIiwidmFsdWUiOiJsYWJlbCIsInNraXBJbm5lckhUTUwiOmZhbHNlLCJ0YWdOYW1lIjoicCIsInN1YlR5cGUiOiJ2YXIiLCJwcm9wZXJ0aWVzIjpbXX0seyJrZXkiOiJUYWJsZSA+IFJvdyAyID4gQ29sIFJpZ2h0IDIiLCJ2YWx1ZSI6IiIsInNraXBJbm5lckhUTUwiOnRydWUsInRhZ05hbWUiOiJkaXYiLCJzdWJUeXBlIjoidmFyIiwicHJvcGVydGllcyI6W119LHsia2V5IjoiVGFibGUgPiBSb3cgMiA+IEljb24gQ29udGFpbmVyIDIiLCJ2YWx1ZSI6IiIsInNraXBJbm5lckhUTUwiOnRydWUsInRhZ05hbWUiOiJkaXYiLCJzdWJUeXBlIjoiaW1nIiwicHJvcGVydGllcyI6W3sicHJlZml4IjoiZGVmYXVsdCIsInByb3BlcnR5Ijp7InR5cGUiOiJzcmMiLCJzcmMiOiIuLi9wdWJsaWMvYXNzZXRzL3RhYmxlLWNoZWNrLnN2ZyIsInNyY1NldCI6WyIiXX19XX0seyJrZXkiOiJUYWJsZSA+IFJvdyAyID4gSW1hZ2UgMiIsInZhbHVlIjoiIiwic2tpcElubmVySFRNTCI6dHJ1ZSwidGFnTmFtZSI6ImltZyIsInN1YlR5cGUiOiJpbWciLCJwcm9wZXJ0aWVzIjpbeyJwcmVmaXgiOiJkZWZhdWx0IiwicHJvcGVydHkiOnsidHlwZSI6InNyYyIsInNyYyI6Ii4uL3B1YmxpYy9hc3NldHMvdGFibGUtY2hlY2suc3ZnIiwic3JjU2V0IjpbIiJdfX1dfSx7ImtleSI6IlRhYmxlID4gUm93IDIgPiBUZXh0IDMiLCJ2YWx1ZSI6ImxhYmVsIiwic2tpcElubmVySFRNTCI6ZmFsc2UsInRhZ05hbWUiOiJwIiwic3ViVHlwZSI6InZhciIsInByb3BlcnRpZXMiOltdfSx7ImtleSI6IlRhYmxlID4gUm93IDMiLCJ2YWx1ZSI6IiIsInNraXBJbm5lckhUTUwiOnRydWUsInRhZ05hbWUiOiJkaXYiLCJzdWJUeXBlIjoidmFyIiwicHJvcGVydGllcyI6W119LHsia2V5IjoiVGFibGUgPiBSb3cgMyA+IENvbCBMZWZ0IiwidmFsdWUiOiIiLCJza2lwSW5uZXJIVE1MIjp0cnVlLCJ0YWdOYW1lIjoiZGl2Iiwic3ViVHlwZSI6InZhciIsInByb3BlcnRpZXMiOltdfSx7ImtleSI6IlRhYmxlID4gUm93IDMgPiBUZXh0IiwidmFsdWUiOiJMYWJlbCAyIiwic2tpcElubmVySFRNTCI6ZmFsc2UsInRhZ05hbWUiOiJwIiwic3ViVHlwZSI6InZhciIsInByb3BlcnRpZXMiOltdfSx7ImtleSI6IlRhYmxlID4gUm93IDMgPiBDb2wgUmlnaHQiLCJ2YWx1ZSI6IiIsInNraXBJbm5lckhUTUwiOnRydWUsInRhZ05hbWUiOiJkaXYiLCJzdWJUeXBlIjoidmFyIiwicHJvcGVydGllcyI6W119LHsia2V5IjoiVGFibGUgPiBSb3cgMyA+IEljb24gQ29udGFpbmVyIiwidmFsdWUiOiIiLCJza2lwSW5uZXJIVE1MIjp0cnVlLCJ0YWdOYW1lIjoiZGl2Iiwic3ViVHlwZSI6ImltZyIsInByb3BlcnRpZXMiOlt7InByZWZpeCI6ImRlZmF1bHQiLCJwcm9wZXJ0eSI6eyJ0eXBlIjoic3JjIiwic3JjIjoiLi4vcHVibGljL2Fzc2V0cy90YWJsZS1sb2NrLnN2ZyIsInNyY1NldCI6WyIiXX19XX0seyJrZXkiOiJUYWJsZSA+IFJvdyAzID4gSW1hZ2UiLCJ2YWx1ZSI6IiIsInNraXBJbm5lckhUTUwiOnRydWUsInRhZ05hbWUiOiJpbWciLCJzdWJUeXBlIjoiaW1nIiwicHJvcGVydGllcyI6W3sicHJlZml4IjoiZGVmYXVsdCIsInByb3BlcnR5Ijp7InR5cGUiOiJzcmMiLCJzcmMiOiIuLi9wdWJsaWMvYXNzZXRzL3RhYmxlLWxvY2suc3ZnIiwic3JjU2V0IjpbIiJdfX1dfSx7ImtleSI6IlRhYmxlID4gUm93IDMgPiBUZXh0IDIiLCJ2YWx1ZSI6ImxhYmVsIiwic2tpcElubmVySFRNTCI6ZmFsc2UsInRhZ05hbWUiOiJwIiwic3ViVHlwZSI6InZhciIsInByb3BlcnRpZXMiOltdfSx7ImtleSI6IlRhYmxlID4gUm93IDMgPiBDb2wgUmlnaHQgMiIsInZhbHVlIjoiIiwic2tpcElubmVySFRNTCI6dHJ1ZSwidGFnTmFtZSI6ImRpdiIsInN1YlR5cGUiOiJ2YXIiLCJwcm9wZXJ0aWVzIjpbXX0seyJrZXkiOiJUYWJsZSA+IFJvdyAzID4gSWNvbiBDb250YWluZXIgMiIsInZhbHVlIjoiIiwic2tpcElubmVySFRNTCI6dHJ1ZSwidGFnTmFtZSI6ImRpdiIsInN1YlR5cGUiOiJpbWciLCJwcm9wZXJ0aWVzIjpbeyJwcmVmaXgiOiJkZWZhdWx0IiwicHJvcGVydHkiOnsidHlwZSI6InNyYyIsInNyYyI6Ii4uL3B1YmxpYy9hc3NldHMvdGFibGUtY2hlY2suc3ZnIiwic3JjU2V0IjpbIiJdfX1dfSx7ImtleSI6IlRhYmxlID4gUm93IDMgPiBJbWFnZSAyIiwidmFsdWUiOiIiLCJza2lwSW5uZXJIVE1MIjp0cnVlLCJ0YWdOYW1lIjoiaW1nIiwic3ViVHlwZSI6ImltZyIsInByb3BlcnRpZXMiOlt7InByZWZpeCI6ImRlZmF1bHQiLCJwcm9wZXJ0eSI6eyJ0eXBlIjoic3JjIiwic3JjIjoiLi4vcHVibGljL2Fzc2V0cy90YWJsZS1jaGVjay5zdmciLCJzcmNTZXQiOlsiIl19fV19LHsia2V5IjoiVGFibGUgPiBSb3cgMyA+IFRleHQgMyIsInZhbHVlIjoibGFiZWwiLCJza2lwSW5uZXJIVE1MIjpmYWxzZSwidGFnTmFtZSI6InAiLCJzdWJUeXBlIjoidmFyIiwicHJvcGVydGllcyI6W119LHsia2V5IjoiVGFibGUgPiBSb3cgNCIsInZhbHVlIjoiIiwic2tpcElubmVySFRNTCI6dHJ1ZSwidGFnTmFtZSI6ImRpdiIsInN1YlR5cGUiOiJ2YXIiLCJwcm9wZXJ0aWVzIjpbXX0seyJrZXkiOiJUYWJsZSA+IFJvdyA0ID4gQ29sIExlZnQiLCJ2YWx1ZSI6IiIsInNraXBJbm5lckhUTUwiOnRydWUsInRhZ05hbWUiOiJkaXYiLCJzdWJUeXBlIjoidmFyIiwicHJvcGVydGllcyI6W119LHsia2V5IjoiVGFibGUgPiBSb3cgNCA+IFRleHQiLCJ2YWx1ZSI6IkxhYmVsIDMiLCJza2lwSW5uZXJIVE1MIjpmYWxzZSwidGFnTmFtZSI6InAiLCJzdWJUeXBlIjoidmFyIiwicHJvcGVydGllcyI6W119LHsia2V5IjoiVGFibGUgPiBSb3cgNCA+IENvbCBSaWdodCIsInZhbHVlIjoiIiwic2tpcElubmVySFRNTCI6dHJ1ZSwidGFnTmFtZSI6ImRpdiIsInN1YlR5cGUiOiJ2YXIiLCJwcm9wZXJ0aWVzIjpbXX0seyJrZXkiOiJUYWJsZSA+IFJvdyA0ID4gSWNvbiBDb250YWluZXIiLCJ2YWx1ZSI6IiIsInNraXBJbm5lckhUTUwiOnRydWUsInRhZ05hbWUiOiJkaXYiLCJzdWJUeXBlIjoiaW1nIiwicHJvcGVydGllcyI6W3sicHJlZml4IjoiZGVmYXVsdCIsInByb3BlcnR5Ijp7InR5cGUiOiJzcmMiLCJzcmMiOiIuLi9wdWJsaWMvYXNzZXRzL3RhYmxlLWxvY2suc3ZnIiwic3JjU2V0IjpbIiJdfX1dfSx7ImtleSI6IlRhYmxlID4gUm93IDQgPiBJbWFnZSIsInZhbHVlIjoiIiwic2tpcElubmVySFRNTCI6dHJ1ZSwidGFnTmFtZSI6ImltZyIsInN1YlR5cGUiOiJpbWciLCJwcm9wZXJ0aWVzIjpbeyJwcmVmaXgiOiJkZWZhdWx0IiwicHJvcGVydHkiOnsidHlwZSI6InNyYyIsInNyYyI6Ii4uL3B1YmxpYy9hc3NldHMvdGFibGUtbG9jay5zdmciLCJzcmNTZXQiOlsiIl19fV19LHsia2V5IjoiVGFibGUgPiBSb3cgNCA+IFRleHQgMiIsInZhbHVlIjoibGFiZWwiLCJza2lwSW5uZXJIVE1MIjpmYWxzZSwidGFnTmFtZSI6InAiLCJzdWJUeXBlIjoidmFyIiwicHJvcGVydGllcyI6W119LHsia2V5IjoiVGFibGUgPiBSb3cgNCA+IENvbCBSaWdodCAyIiwidmFsdWUiOiIiLCJza2lwSW5uZXJIVE1MIjp0cnVlLCJ0YWdOYW1lIjoiZGl2Iiwic3ViVHlwZSI6InZhciIsInByb3BlcnRpZXMiOltdfSx7ImtleSI6IlRhYmxlID4gUm93IDQgPiBJY29uIENvbnRhaW5lciAyIiwidmFsdWUiOiIiLCJza2lwSW5uZXJIVE1MIjp0cnVlLCJ0YWdOYW1lIjoiZGl2Iiwic3ViVHlwZSI6ImltZyIsInByb3BlcnRpZXMiOlt7InByZWZpeCI6ImRlZmF1bHQiLCJwcm9wZXJ0eSI6eyJ0eXBlIjoic3JjIiwic3JjIjoiLi4vcHVibGljL2Fzc2V0cy90YWJsZS1jaGVjay5zdmciLCJzcmNTZXQiOlsiIl19fV19LHsia2V5IjoiVGFibGUgPiBSb3cgNCA+IEltYWdlIDIiLCJ2YWx1ZSI6IiIsInNraXBJbm5lckhUTUwiOnRydWUsInRhZ05hbWUiOiJpbWciLCJzdWJUeXBlIjoiaW1nIiwicHJvcGVydGllcyI6W3sicHJlZml4IjoiZGVmYXVsdCIsInByb3BlcnR5Ijp7InR5cGUiOiJzcmMiLCJzcmMiOiIuLi9wdWJsaWMvYXNzZXRzL3RhYmxlLWNoZWNrLnN2ZyIsInNyY1NldCI6WyIiXX19XX0seyJrZXkiOiJUYWJsZSA+IFJvdyA0ID4gVGV4dCAzIiwidmFsdWUiOiJsYWJlbCIsInNraXBJbm5lckhUTUwiOmZhbHNlLCJ0YWdOYW1lIjoicCIsInN1YlR5cGUiOiJ2YXIiLCJwcm9wZXJ0aWVzIjpbXX0seyJrZXkiOiJUYWJsZSA+IFJvdyA1IiwidmFsdWUiOiIiLCJza2lwSW5uZXJIVE1MIjp0cnVlLCJ0YWdOYW1lIjoiZGl2Iiwic3ViVHlwZSI6InZhciIsInByb3BlcnRpZXMiOltdfSx7ImtleSI6IlRhYmxlID4gUm93IDUgPiBDb2wgTGVmdCIsInZhbHVlIjoiIiwic2tpcElubmVySFRNTCI6dHJ1ZSwidGFnTmFtZSI6ImRpdiIsInN1YlR5cGUiOiJ2YXIiLCJwcm9wZXJ0aWVzIjpbXX0seyJrZXkiOiJUYWJsZSA+IFJvdyA1ID4gVGV4dCIsInZhbHVlIjoiTGFiZWwgNCIsInNraXBJbm5lckhUTUwiOmZhbHNlLCJ0YWdOYW1lIjoicCIsInN1YlR5cGUiOiJ2YXIiLCJwcm9wZXJ0aWVzIjpbXX0seyJrZXkiOiJUYWJsZSA+IFJvdyA1ID4gQ29sIFJpZ2h0IiwidmFsdWUiOiIiLCJza2lwSW5uZXJIVE1MIjp0cnVlLCJ0YWdOYW1lIjoiZGl2Iiwic3ViVHlwZSI6InZhciIsInByb3BlcnRpZXMiOltdfSx7ImtleSI6IlRhYmxlID4gUm93IDUgPiBJY29uIENvbnRhaW5lciIsInZhbHVlIjoiIiwic2tpcElubmVySFRNTCI6dHJ1ZSwidGFnTmFtZSI6ImRpdiIsInN1YlR5cGUiOiJpbWciLCJwcm9wZXJ0aWVzIjpbeyJwcmVmaXgiOiJkZWZhdWx0IiwicHJvcGVydHkiOnsidHlwZSI6InNyYyIsInNyYyI6Ii4uL3B1YmxpYy9hc3NldHMvdGFibGUtbG9jay5zdmciLCJzcmNTZXQiOlsiIl19fV19LHsia2V5IjoiVGFibGUgPiBSb3cgNSA+IEltYWdlIiwidmFsdWUiOiIiLCJza2lwSW5uZXJIVE1MIjp0cnVlLCJ0YWdOYW1lIjoiaW1nIiwic3ViVHlwZSI6ImltZyIsInByb3BlcnRpZXMiOlt7InByZWZpeCI6ImRlZmF1bHQiLCJwcm9wZXJ0eSI6eyJ0eXBlIjoic3JjIiwic3JjIjoiLi4vcHVibGljL2Fzc2V0cy90YWJsZS1sb2NrLnN2ZyIsInNyY1NldCI6WyIiXX19XX0seyJrZXkiOiJUYWJsZSA+IFJvdyA1ID4gVGV4dCAyIiwidmFsdWUiOiJsYWJlbCIsInNraXBJbm5lckhUTUwiOmZhbHNlLCJ0YWdOYW1lIjoicCIsInN1YlR5cGUiOiJ2YXIiLCJwcm9wZXJ0aWVzIjpbXX0seyJrZXkiOiJUYWJsZSA+IFJvdyA1ID4gQ29sIFJpZ2h0IDIiLCJ2YWx1ZSI6IiIsInNraXBJbm5lckhUTUwiOnRydWUsInRhZ05hbWUiOiJkaXYiLCJzdWJUeXBlIjoidmFyIiwicHJvcGVydGllcyI6W119LHsia2V5IjoiVGFibGUgPiBSb3cgNSA+IEljb24gQ29udGFpbmVyIDIiLCJ2YWx1ZSI6IiIsInNraXBJbm5lckhUTUwiOnRydWUsInRhZ05hbWUiOiJkaXYiLCJzdWJUeXBlIjoiaW1nIiwicHJvcGVydGllcyI6W3sicHJlZml4IjoiZGVmYXVsdCIsInByb3BlcnR5Ijp7InR5cGUiOiJzcmMiLCJzcmMiOiIuLi9wdWJsaWMvYXNzZXRzL3RhYmxlLWNoZWNrLnN2ZyIsInNyY1NldCI6WyIiXX19XX0seyJrZXkiOiJUYWJsZSA+IFJvdyA1ID4gSW1hZ2UgMiIsInZhbHVlIjoiIiwic2tpcElubmVySFRNTCI6dHJ1ZSwidGFnTmFtZSI6ImltZyIsInN1YlR5cGUiOiJpbWciLCJwcm9wZXJ0aWVzIjpbeyJwcmVmaXgiOiJkZWZhdWx0IiwicHJvcGVydHkiOnsidHlwZSI6InNyYyIsInNyYyI6Ii4uL3B1YmxpYy9hc3NldHMvdGFibGUtY2hlY2suc3ZnIiwic3JjU2V0IjpbIiJdfX1dfSx7ImtleSI6IlRhYmxlID4gUm93IDUgPiBUZXh0IDMiLCJ2YWx1ZSI6ImxhYmVsIiwic2tpcElubmVySFRNTCI6ZmFsc2UsInRhZ05hbWUiOiJwIiwic3ViVHlwZSI6InZhciIsInByb3BlcnRpZXMiOltdfSx7ImtleSI6IlRhYmxlID4gUm93IDYiLCJ2YWx1ZSI6IiIsInNraXBJbm5lckhUTUwiOnRydWUsInRhZ05hbWUiOiJkaXYiLCJzdWJUeXBlIjoidmFyIiwicHJvcGVydGllcyI6W119LHsia2V5IjoiVGFibGUgPiBSb3cgNiA+IENvbCBMZWZ0IiwidmFsdWUiOiIiLCJza2lwSW5uZXJIVE1MIjp0cnVlLCJ0YWdOYW1lIjoiZGl2Iiwic3ViVHlwZSI6InZhciIsInByb3BlcnRpZXMiOltdfSx7ImtleSI6IlRhYmxlID4gUm93IDYgPiBUZXh0IiwidmFsdWUiOiJMYWJlbCA1Iiwic2tpcElubmVySFRNTCI6ZmFsc2UsInRhZ05hbWUiOiJwIiwic3ViVHlwZSI6InZhciIsInByb3BlcnRpZXMiOltdfSx7ImtleSI6IlRhYmxlID4gUm93IDYgPiBDb2wgUmlnaHQiLCJ2YWx1ZSI6IiIsInNraXBJbm5lckhUTUwiOnRydWUsInRhZ05hbWUiOiJkaXYiLCJzdWJUeXBlIjoidmFyIiwicHJvcGVydGllcyI6W119LHsia2V5IjoiVGFibGUgPiBSb3cgNiA+IEljb24gQ29udGFpbmVyIiwidmFsdWUiOiIiLCJza2lwSW5uZXJIVE1MIjp0cnVlLCJ0YWdOYW1lIjoiZGl2Iiwic3ViVHlwZSI6ImltZyIsInByb3BlcnRpZXMiOlt7InByZWZpeCI6ImRlZmF1bHQiLCJwcm9wZXJ0eSI6eyJ0eXBlIjoic3JjIiwic3JjIjoiLi4vcHVibGljL2Fzc2V0cy90YWJsZS1sb2NrLnN2ZyIsInNyY1NldCI6WyIiXX19XX0seyJrZXkiOiJUYWJsZSA+IFJvdyA2ID4gSW1hZ2UiLCJ2YWx1ZSI6IiIsInNraXBJbm5lckhUTUwiOnRydWUsInRhZ05hbWUiOiJpbWciLCJzdWJUeXBlIjoiaW1nIiwicHJvcGVydGllcyI6W3sicHJlZml4IjoiZGVmYXVsdCIsInByb3BlcnR5Ijp7InR5cGUiOiJzcmMiLCJzcmMiOiIuLi9wdWJsaWMvYXNzZXRzL3RhYmxlLWxvY2suc3ZnIiwic3JjU2V0IjpbIiJdfX1dfSx7ImtleSI6IlRhYmxlID4gUm93IDYgPiBUZXh0IDIiLCJ2YWx1ZSI6ImxhYmVsIiwic2tpcElubmVySFRNTCI6ZmFsc2UsInRhZ05hbWUiOiJwIiwic3ViVHlwZSI6InZhciIsInByb3BlcnRpZXMiOltdfSx7ImtleSI6IlRhYmxlID4gUm93IDYgPiBDb2wgUmlnaHQgMiIsInZhbHVlIjoiIiwic2tpcElubmVySFRNTCI6dHJ1ZSwidGFnTmFtZSI6ImRpdiIsInN1YlR5cGUiOiJ2YXIiLCJwcm9wZXJ0aWVzIjpbXX0seyJrZXkiOiJUYWJsZSA+IFJvdyA2ID4gSWNvbiBDb250YWluZXIgMiIsInZhbHVlIjoiIiwic2tpcElubmVySFRNTCI6dHJ1ZSwidGFnTmFtZSI6ImRpdiIsInN1YlR5cGUiOiJpbWciLCJwcm9wZXJ0aWVzIjpbeyJwcmVmaXgiOiJkZWZhdWx0IiwicHJvcGVydHkiOnsidHlwZSI6InNyYyIsInNyYyI6Ii4uL3B1YmxpYy9hc3NldHMvdGFibGUtY2hlY2suc3ZnIiwic3JjU2V0IjpbIiJdfX1dfSx7ImtleSI6IlRhYmxlID4gUm93IDYgPiBJbWFnZSAyIiwidmFsdWUiOiIiLCJza2lwSW5uZXJIVE1MIjp0cnVlLCJ0YWdOYW1lIjoiaW1nIiwic3ViVHlwZSI6ImltZyIsInByb3BlcnRpZXMiOlt7InByZWZpeCI6ImRlZmF1bHQiLCJwcm9wZXJ0eSI6eyJ0eXBlIjoic3JjIiwic3JjIjoiLi4vcHVibGljL2Fzc2V0cy90YWJsZS1jaGVjay5zdmciLCJzcmNTZXQiOlsiIl19fV19LHsia2V5IjoiVGFibGUgPiBSb3cgNiA+IFRleHQgMyIsInZhbHVlIjoibGFiZWwiLCJza2lwSW5uZXJIVE1MIjpmYWxzZSwidGFnTmFtZSI6InAiLCJzdWJUeXBlIjoidmFyIiwicHJvcGVydGllcyI6W119LHsia2V5IjoiVGFibGUgPiBSb3cgNyIsInZhbHVlIjoiIiwic2tpcElubmVySFRNTCI6dHJ1ZSwidGFnTmFtZSI6ImRpdiIsInN1YlR5cGUiOiJ2YXIiLCJwcm9wZXJ0aWVzIjpbXX0seyJrZXkiOiJUYWJsZSA+IFJvdyA3ID4gQ29sIExlZnQiLCJ2YWx1ZSI6IiIsInNraXBJbm5lckhUTUwiOnRydWUsInRhZ05hbWUiOiJkaXYiLCJzdWJUeXBlIjoidmFyIiwicHJvcGVydGllcyI6W119LHsia2V5IjoiVGFibGUgPiBSb3cgNyA+IFRleHQiLCJ2YWx1ZSI6IkxhYmVsIDYiLCJza2lwSW5uZXJIVE1MIjpmYWxzZSwidGFnTmFtZSI6InAiLCJzdWJUeXBlIjoidmFyIiwicHJvcGVydGllcyI6W119LHsia2V5IjoiVGFibGUgPiBSb3cgNyA+IENvbCBSaWdodCIsInZhbHVlIjoiIiwic2tpcElubmVySFRNTCI6dHJ1ZSwidGFnTmFtZSI6ImRpdiIsInN1YlR5cGUiOiJ2YXIiLCJwcm9wZXJ0aWVzIjpbXX0seyJrZXkiOiJUYWJsZSA+IFJvdyA3ID4gSWNvbiBDb250YWluZXIiLCJ2YWx1ZSI6IiIsInNraXBJbm5lckhUTUwiOnRydWUsInRhZ05hbWUiOiJkaXYiLCJzdWJUeXBlIjoiaW1nIiwicHJvcGVydGllcyI6W3sicHJlZml4IjoiZGVmYXVsdCIsInByb3BlcnR5Ijp7InR5cGUiOiJzcmMiLCJzcmMiOiIuLi9wdWJsaWMvYXNzZXRzL3RhYmxlLWxvY2suc3ZnIiwic3JjU2V0IjpbIiJdfX1dfSx7ImtleSI6IlRhYmxlID4gUm93IDcgPiBJbWFnZSIsInZhbHVlIjoiIiwic2tpcElubmVySFRNTCI6dHJ1ZSwidGFnTmFtZSI6ImltZyIsInN1YlR5cGUiOiJpbWciLCJwcm9wZXJ0aWVzIjpbeyJwcmVmaXgiOiJkZWZhdWx0IiwicHJvcGVydHkiOnsidHlwZSI6InNyYyIsInNyYyI6Ii4uL3B1YmxpYy9hc3NldHMvdGFibGUtbG9jay5zdmciLCJzcmNTZXQiOlsiIl19fV19LHsia2V5IjoiVGFibGUgPiBSb3cgNyA+IFRleHQgMiIsInZhbHVlIjoibGFiZWwiLCJza2lwSW5uZXJIVE1MIjpmYWxzZSwidGFnTmFtZSI6InAiLCJzdWJUeXBlIjoidmFyIiwicHJvcGVydGllcyI6W119LHsia2V5IjoiVGFibGUgPiBSb3cgNyA+IENvbCBSaWdodCAyIiwidmFsdWUiOiIiLCJza2lwSW5uZXJIVE1MIjp0cnVlLCJ0YWdOYW1lIjoiZGl2Iiwic3ViVHlwZSI6InZhciIsInByb3BlcnRpZXMiOltdfSx7ImtleSI6IlRhYmxlID4gUm93IDcgPiBJY29uIENvbnRhaW5lciAyIiwidmFsdWUiOiIiLCJza2lwSW5uZXJIVE1MIjp0cnVlLCJ0YWdOYW1lIjoiZGl2Iiwic3ViVHlwZSI6ImltZyIsInByb3BlcnRpZXMiOlt7InByZWZpeCI6ImRlZmF1bHQiLCJwcm9wZXJ0eSI6eyJ0eXBlIjoic3JjIiwic3JjIjoiLi4vcHVibGljL2Fzc2V0cy90YWJsZS1jaGVjay5zdmciLCJzcmNTZXQiOlsiIl19fV19LHsia2V5IjoiVGFibGUgPiBSb3cgNyA+IEltYWdlIDIiLCJ2YWx1ZSI6IiIsInNraXBJbm5lckhUTUwiOnRydWUsInRhZ05hbWUiOiJpbWciLCJzdWJUeXBlIjoiaW1nIiwicHJvcGVydGllcyI6W3sicHJlZml4IjoiZGVmYXVsdCIsInByb3BlcnR5Ijp7InR5cGUiOiJzcmMiLCJzcmMiOiIuLi9wdWJsaWMvYXNzZXRzL3RhYmxlLWNoZWNrLnN2ZyIsInNyY1NldCI6WyIiXX19XX0seyJrZXkiOiJUYWJsZSA+IFJvdyA3ID4gVGV4dCAzIiwidmFsdWUiOiJsYWJlbCIsInNraXBJbm5lckhUTUwiOmZhbHNlLCJ0YWdOYW1lIjoicCIsInN1YlR5cGUiOiJ2YXIiLCJwcm9wZXJ0aWVzIjpbXX0seyJrZXkiOiJUaXRsZSAyIiwidmFsdWUiOiIiLCJza2lwSW5uZXJIVE1MIjp0cnVlLCJ0YWdOYW1lIjoiZGl2Iiwic3ViVHlwZSI6InZhciIsInByb3BlcnRpZXMiOltdfSx7ImtleSI6IlRpdGxlIDIgPiBUZXh0IiwidmFsdWUiOiJUaGlzIGlzIGEgdGl0bGUiLCJza2lwSW5uZXJIVE1MIjpmYWxzZSwidGFnTmFtZSI6ImgzIiwic3ViVHlwZSI6InZhciIsInByb3BlcnRpZXMiOltdfSx7ImtleSI6IlJhdGluZyIsInZhbHVlIjoiIiwic2tpcElubmVySFRNTCI6dHJ1ZSwidGFnTmFtZSI6ImRpdiIsInN1YlR5cGUiOiJ2YXIiLCJwcm9wZXJ0aWVzIjpbXX0seyJrZXkiOiJSYXRpbmcgPiBUaXRsZSIsInZhbHVlIjoiNC45Iiwic2tpcElubmVySFRNTCI6ZmFsc2UsInRhZ05hbWUiOiJkaXYiLCJzdWJUeXBlIjoidmFyIiwicHJvcGVydGllcyI6W119LHsia2V5IjoiUmF0aW5nID4gQ29udGVudCIsInZhbHVlIjoiIiwic2tpcElubmVySFRNTCI6dHJ1ZSwidGFnTmFtZSI6ImRpdiIsInN1YlR5cGUiOiJ2YXIiLCJwcm9wZXJ0aWVzIjpbXX0seyJrZXkiOiJSYXRpbmcgPiBTdGFyIENvbnRhaW5lciIsInZhbHVlIjoiIiwic2tpcElubmVySFRNTCI6dHJ1ZSwidGFnTmFtZSI6ImRpdiIsInN1YlR5cGUiOiJ2YXIiLCJwcm9wZXJ0aWVzIjpbXX0seyJrZXkiOiJSYXRpbmcgPiBDYXB0aW9uIiwidmFsdWUiOiJCYXNlZCBvbiAyMDAsMDAwIHJldmlld3MiLCJza2lwSW5uZXJIVE1MIjpmYWxzZSwidGFnTmFtZSI6InAiLCJzdWJUeXBlIjoidmFyIiwicHJvcGVydGllcyI6W119LHsia2V5IjoiVGl0bGUgMyIsInZhbHVlIjoiIiwic2tpcElubmVySFRNTCI6dHJ1ZSwidGFnTmFtZSI6ImRpdiIsInN1YlR5cGUiOiJ2YXIiLCJwcm9wZXJ0aWVzIjpbXX0seyJrZXkiOiJUaXRsZSAzID4gVGV4dCIsInZhbHVlIjoiVGhpcyBpcyBhIHRpdGxlIiwic2tpcElubmVySFRNTCI6ZmFsc2UsInRhZ05hbWUiOiJoMyIsInN1YlR5cGUiOiJ2YXIiLCJwcm9wZXJ0aWVzIjpbXX0seyJrZXkiOiJSZXZpZXciLCJ2YWx1ZSI6IiIsInNraXBJbm5lckhUTUwiOnRydWUsInRhZ05hbWUiOiJkaXYiLCJzdWJUeXBlIjoidmFyIiwicHJvcGVydGllcyI6W119LHsia2V5IjoiUmV2aWV3ID4gQ29udGFpbmVyIiwidmFsdWUiOiIiLCJza2lwSW5uZXJIVE1MIjp0cnVlLCJ0YWdOYW1lIjoiZGl2Iiwic3ViVHlwZSI6InZhciIsInByb3BlcnRpZXMiOltdfSx7ImtleSI6IlJldmlldyA+IEhlYWRlciBDb250YWluZXIiLCJ2YWx1ZSI6IiIsInNraXBJbm5lckhUTUwiOnRydWUsInRhZ05hbWUiOiJkaXYiLCJzdWJUeXBlIjoidmFyIiwicHJvcGVydGllcyI6W119LHsia2V5IjoiUmV2aWV3ID4gVGl0bGUgQ29udGFpbmVyIiwidmFsdWUiOiIiLCJza2lwSW5uZXJIVE1MIjp0cnVlLCJ0YWdOYW1lIjoiZGl2Iiwic3ViVHlwZSI6InZhciIsInByb3BlcnRpZXMiOltdfSx7ImtleSI6IlJldmlldyA+IFRpdGxlIiwidmFsdWUiOiJCZXN0IGFwcCBldmVyLiIsInNraXBJbm5lckhUTUwiOmZhbHNlLCJ0YWdOYW1lIjoicCIsInN1YlR5cGUiOiJ2YXIiLCJwcm9wZXJ0aWVzIjpbXX0seyJrZXkiOiJSZXZpZXcgPiBJbmZvIiwidmFsdWUiOiIiLCJza2lwSW5uZXJIVE1MIjp0cnVlLCJ0YWdOYW1lIjoiZGl2Iiwic3ViVHlwZSI6InZhciIsInByb3BlcnRpZXMiOltdfSx7ImtleSI6IlJldmlldyA+IFN0YXIgQ29udGFpbmVyIiwidmFsdWUiOiIiLCJza2lwSW5uZXJIVE1MIjp0cnVlLCJ0YWdOYW1lIjoiZGl2Iiwic3ViVHlwZSI6InZhciIsInByb3BlcnRpZXMiOltdfSx7ImtleSI6IlJldmlldyA+IEJvZHkiLCJ2YWx1ZSI6IkluIGxhYm9yZSBkZXNlcnVudCBjdWxwYSBxdWkgZXggYW1ldCBhbGlxdWEgYWxpcXVpcCBub24gbnVsbGEgYWQgY3VwaWRhdGF0XG4gICAgICBpcHN1bSBxdWlzIGR1aXMuIiwic2tpcElubmVySFRNTCI6ZmFsc2UsInRhZ05hbWUiOiJwIiwic3ViVHlwZSI6InZhciIsInByb3BlcnRpZXMiOltdfSx7ImtleSI6IlJldmlldyAyIiwidmFsdWUiOiIiLCJza2lwSW5uZXJIVE1MIjp0cnVlLCJ0YWdOYW1lIjoiZGl2Iiwic3ViVHlwZSI6InZhciIsInByb3BlcnRpZXMiOltdfSx7ImtleSI6IlJldmlldyAyID4gQ29udGFpbmVyIiwidmFsdWUiOiIiLCJza2lwSW5uZXJIVE1MIjp0cnVlLCJ0YWdOYW1lIjoiZGl2Iiwic3ViVHlwZSI6InZhciIsInByb3BlcnRpZXMiOltdfSx7ImtleSI6IlJldmlldyAyID4gSGVhZGVyIENvbnRhaW5lciIsInZhbHVlIjoiIiwic2tpcElubmVySFRNTCI6dHJ1ZSwidGFnTmFtZSI6ImRpdiIsInN1YlR5cGUiOiJ2YXIiLCJwcm9wZXJ0aWVzIjpbXX0seyJrZXkiOiJSZXZpZXcgMiA+IFRpdGxlIENvbnRhaW5lciIsInZhbHVlIjoiIiwic2tpcElubmVySFRNTCI6dHJ1ZSwidGFnTmFtZSI6ImRpdiIsInN1YlR5cGUiOiJ2YXIiLCJwcm9wZXJ0aWVzIjpbXX0seyJrZXkiOiJSZXZpZXcgMiA+IFRpdGxlIiwidmFsdWUiOiJCZXN0IGFwcCBldmVyLiIsInNraXBJbm5lckhUTUwiOmZhbHNlLCJ0YWdOYW1lIjoicCIsInN1YlR5cGUiOiJ2YXIiLCJwcm9wZXJ0aWVzIjpbXX0seyJrZXkiOiJSZXZpZXcgMiA+IEluZm8iLCJ2YWx1ZSI6IiIsInNraXBJbm5lckhUTUwiOnRydWUsInRhZ05hbWUiOiJkaXYiLCJzdWJUeXBlIjoidmFyIiwicHJvcGVydGllcyI6W119LHsia2V5IjoiUmV2aWV3IDIgPiBTdGFyIENvbnRhaW5lciIsInZhbHVlIjoiIiwic2tpcElubmVySFRNTCI6dHJ1ZSwidGFnTmFtZSI6ImRpdiIsInN1YlR5cGUiOiJ2YXIiLCJwcm9wZXJ0aWVzIjpbXX0seyJrZXkiOiJSZXZpZXcgMiA+IEJvZHkiLCJ2YWx1ZSI6IkluIGxhYm9yZSBkZXNlcnVudCBjdWxwYSBxdWkgZXggYW1ldCBhbGlxdWEgYWxpcXVpcCBub24gbnVsbGEgYWQgY3VwaWRhdGF0XG4gICAgICBpcHN1bSBxdWlzIGR1aXMuIiwic2tpcElubmVySFRNTCI6ZmFsc2UsInRhZ05hbWUiOiJwIiwic3ViVHlwZSI6InZhciIsInByb3BlcnRpZXMiOltdfSx7ImtleSI6IlJldmlldyAzIiwidmFsdWUiOiIiLCJza2lwSW5uZXJIVE1MIjp0cnVlLCJ0YWdOYW1lIjoiZGl2Iiwic3ViVHlwZSI6InZhciIsInByb3BlcnRpZXMiOltdfSx7ImtleSI6IlJldmlldyAzID4gQ29udGFpbmVyIiwidmFsdWUiOiIiLCJza2lwSW5uZXJIVE1MIjp0cnVlLCJ0YWdOYW1lIjoiZGl2Iiwic3ViVHlwZSI6InZhciIsInByb3BlcnRpZXMiOltdfSx7ImtleSI6IlJldmlldyAzID4gSGVhZGVyIENvbnRhaW5lciIsInZhbHVlIjoiIiwic2tpcElubmVySFRNTCI6dHJ1ZSwidGFnTmFtZSI6ImRpdiIsInN1YlR5cGUiOiJ2YXIiLCJwcm9wZXJ0aWVzIjpbXX0seyJrZXkiOiJSZXZpZXcgMyA+IFRpdGxlIENvbnRhaW5lciIsInZhbHVlIjoiIiwic2tpcElubmVySFRNTCI6dHJ1ZSwidGFnTmFtZSI6ImRpdiIsInN1YlR5cGUiOiJ2YXIiLCJwcm9wZXJ0aWVzIjpbXX0seyJrZXkiOiJSZXZpZXcgMyA+IFRpdGxlIiwidmFsdWUiOiJCZXN0IGFwcCBldmVyLiIsInNraXBJbm5lckhUTUwiOmZhbHNlLCJ0YWdOYW1lIjoicCIsInN1YlR5cGUiOiJ2YXIiLCJwcm9wZXJ0aWVzIjpbXX0seyJrZXkiOiJSZXZpZXcgMyA+IEluZm8iLCJ2YWx1ZSI6IiIsInNraXBJbm5lckhUTUwiOnRydWUsInRhZ05hbWUiOiJkaXYiLCJzdWJUeXBlIjoidmFyIiwicHJvcGVydGllcyI6W119LHsia2V5IjoiUmV2aWV3IDMgPiBTdGFyIENvbnRhaW5lciIsInZhbHVlIjoiIiwic2tpcElubmVySFRNTCI6dHJ1ZSwidGFnTmFtZSI6ImRpdiIsInN1YlR5cGUiOiJ2YXIiLCJwcm9wZXJ0aWVzIjpbXX0seyJrZXkiOiJSZXZpZXcgMyA+IEJvZHkiLCJ2YWx1ZSI6IkluIGxhYm9yZSBkZXNlcnVudCBjdWxwYSBxdWkgZXggYW1ldCBhbGlxdWEgYWxpcXVpcCBub24gbnVsbGEgYWQgY3VwaWRhdGF0XG4gICAgICBpcHN1bSBxdWlzIGR1aXMuIiwic2tpcElubmVySFRNTCI6ZmFsc2UsInRhZ05hbWUiOiJwIiwic3ViVHlwZSI6InZhciIsInByb3BlcnRpZXMiOltdfSx7ImtleSI6IlJldmlldyA0IiwidmFsdWUiOiIiLCJza2lwSW5uZXJIVE1MIjp0cnVlLCJ0YWdOYW1lIjoiZGl2Iiwic3ViVHlwZSI6InZhciIsInByb3BlcnRpZXMiOltdfSx7ImtleSI6IlJldmlldyA0ID4gQ29udGFpbmVyIiwidmFsdWUiOiIiLCJza2lwSW5uZXJIVE1MIjp0cnVlLCJ0YWdOYW1lIjoiZGl2Iiwic3ViVHlwZSI6InZhciIsInByb3BlcnRpZXMiOltdfSx7ImtleSI6IlJldmlldyA0ID4gSGVhZGVyIENvbnRhaW5lciIsInZhbHVlIjoiIiwic2tpcElubmVySFRNTCI6dHJ1ZSwidGFnTmFtZSI6ImRpdiIsInN1YlR5cGUiOiJ2YXIiLCJwcm9wZXJ0aWVzIjpbXX0seyJrZXkiOiJSZXZpZXcgNCA+IFRpdGxlIENvbnRhaW5lciIsInZhbHVlIjoiIiwic2tpcElubmVySFRNTCI6dHJ1ZSwidGFnTmFtZSI6ImRpdiIsInN1YlR5cGUiOiJ2YXIiLCJwcm9wZXJ0aWVzIjpbXX0seyJrZXkiOiJSZXZpZXcgNCA+IFRpdGxlIiwidmFsdWUiOiJCZXN0IGFwcCBldmVyLiIsInNraXBJbm5lckhUTUwiOmZhbHNlLCJ0YWdOYW1lIjoicCIsInN1YlR5cGUiOiJ2YXIiLCJwcm9wZXJ0aWVzIjpbXX0seyJrZXkiOiJSZXZpZXcgNCA+IEluZm8iLCJ2YWx1ZSI6IiIsInNraXBJbm5lckhUTUwiOnRydWUsInRhZ05hbWUiOiJkaXYiLCJzdWJUeXBlIjoidmFyIiwicHJvcGVydGllcyI6W119LHsia2V5IjoiUmV2aWV3IDQgPiBTdGFyIENvbnRhaW5lciIsInZhbHVlIjoiIiwic2tpcElubmVySFRNTCI6dHJ1ZSwidGFnTmFtZSI6ImRpdiIsInN1YlR5cGUiOiJ2YXIiLCJwcm9wZXJ0aWVzIjpbXX0seyJrZXkiOiJSZXZpZXcgNCA+IEJvZHkiLCJ2YWx1ZSI6IkluIGxhYm9yZSBkZXNlcnVudCBjdWxwYSBxdWkgZXggYW1ldCBhbGlxdWEgYWxpcXVpcCBub24gbnVsbGEgYWQgY3VwaWRhdGF0XG4gICAgICBpcHN1bSBxdWlzIGR1aXMuIiwic2tpcElubmVySFRNTCI6ZmFsc2UsInRhZ05hbWUiOiJwIiwic3ViVHlwZSI6InZhciIsInByb3BlcnRpZXMiOltdfSx7ImtleSI6IlRpdGxlIDQiLCJ2YWx1ZSI6IiIsInNraXBJbm5lckhUTUwiOnRydWUsInRhZ05hbWUiOiJkaXYiLCJzdWJUeXBlIjoidmFyIiwicHJvcGVydGllcyI6W119LHsia2V5IjoiVGl0bGUgNCA+IFRleHQiLCJ2YWx1ZSI6IlRoaXMgaXMgYSB0aXRsZSIsInNraXBJbm5lckhUTUwiOmZhbHNlLCJ0YWdOYW1lIjoiaDMiLCJzdWJUeXBlIjoidmFyIiwicHJvcGVydGllcyI6W119LHsia2V5IjoiRHJvcGRvd24iLCJ2YWx1ZSI6IiIsInNraXBJbm5lckhUTUwiOnRydWUsInRhZ05hbWUiOiJkaXYiLCJzdWJUeXBlIjoidmFyIiwicHJvcGVydGllcyI6W119LHsia2V5IjoiRHJvcGRvd24gPiBDYXJkIiwidmFsdWUiOiIiLCJza2lwSW5uZXJIVE1MIjp0cnVlLCJ0YWdOYW1lIjoiZGl2Iiwic3ViVHlwZSI6InZhciIsInByb3BlcnRpZXMiOltdfSx7ImtleSI6IkRyb3Bkb3duID4gVG9wIiwidmFsdWUiOiIiLCJza2lwSW5uZXJIVE1MIjp0cnVlLCJ0YWdOYW1lIjoiZGl2Iiwic3ViVHlwZSI6InZhciIsInByb3BlcnRpZXMiOltdfSx7ImtleSI6IkRyb3Bkb3duID4gUXVlc3Rpb24iLCJ2YWx1ZSI6IlRoaXMgaXMgYSBkcm9wZG93bidzIHRpdGxlLiIsInNraXBJbm5lckhUTUwiOmZhbHNlLCJ0YWdOYW1lIjoicCIsInN1YlR5cGUiOiJ2YXIiLCJwcm9wZXJ0aWVzIjpbXX0seyJrZXkiOiJEcm9wZG93biA+IEljb24iLCJ2YWx1ZSI6IiIsInNraXBJbm5lckhUTUwiOnRydWUsInRhZ05hbWUiOiJkaXYiLCJzdWJUeXBlIjoidmFyIiwicHJvcGVydGllcyI6W119LHsia2V5IjoiRHJvcGRvd24gPiBCb3R0b20iLCJ2YWx1ZSI6IiIsInNraXBJbm5lckhUTUwiOnRydWUsInRhZ05hbWUiOiJkaXYiLCJzdWJUeXBlIjoidmFyIiwicHJvcGVydGllcyI6W119LHsia2V5IjoiRHJvcGRvd24gPiBBbnN3ZXIiLCJ2YWx1ZSI6IlByb2lkZW50IGV4IGV4ZXJjaXRhdGlvbiBkbyBlaXVzbW9kIG5vbi4gTG9yZW0gZXNzZSBlaXVzbW9kIGRvIG9jY2FlY2F0LlxuICAgICAgICBOdWxsYSBjdWxwYSBudWxsYSB1dCBudWxsYSBpcnVyZSBhbGlxdWlwIGFsaXF1aXAgYW1ldCB1dCBtaW5pbSBldSBlc3QuIiwic2tpcElubmVySFRNTCI6ZmFsc2UsInRhZ05hbWUiOiJwIiwic3ViVHlwZSI6InZhciIsInByb3BlcnRpZXMiOltdfSx7ImtleSI6IkRyb3Bkb3duIDIiLCJ2YWx1ZSI6IiIsInNraXBJbm5lckhUTUwiOnRydWUsInRhZ05hbWUiOiJkaXYiLCJzdWJUeXBlIjoidmFyIiwicHJvcGVydGllcyI6W119LHsia2V5IjoiRHJvcGRvd24gMiA+IENhcmQiLCJ2YWx1ZSI6IiIsInNraXBJbm5lckhUTUwiOnRydWUsInRhZ05hbWUiOiJkaXYiLCJzdWJUeXBlIjoidmFyIiwicHJvcGVydGllcyI6W119LHsia2V5IjoiRHJvcGRvd24gMiA+IFRvcCIsInZhbHVlIjoiIiwic2tpcElubmVySFRNTCI6dHJ1ZSwidGFnTmFtZSI6ImRpdiIsInN1YlR5cGUiOiJ2YXIiLCJwcm9wZXJ0aWVzIjpbXX0seyJrZXkiOiJEcm9wZG93biAyID4gUXVlc3Rpb24iLCJ2YWx1ZSI6IlRoaXMgaXMgYSBkcm9wZG93bidzIHRpdGxlLiIsInNraXBJbm5lckhUTUwiOmZhbHNlLCJ0YWdOYW1lIjoicCIsInN1YlR5cGUiOiJ2YXIiLCJwcm9wZXJ0aWVzIjpbXX0seyJrZXkiOiJEcm9wZG93biAyID4gSWNvbiIsInZhbHVlIjoiIiwic2tpcElubmVySFRNTCI6dHJ1ZSwidGFnTmFtZSI6ImRpdiIsInN1YlR5cGUiOiJ2YXIiLCJwcm9wZXJ0aWVzIjpbXX0seyJrZXkiOiJEcm9wZG93biAyID4gQm90dG9tIiwidmFsdWUiOiIiLCJza2lwSW5uZXJIVE1MIjp0cnVlLCJ0YWdOYW1lIjoiZGl2Iiwic3ViVHlwZSI6InZhciIsInByb3BlcnRpZXMiOltdfSx7ImtleSI6IkRyb3Bkb3duIDIgPiBBbnN3ZXIiLCJ2YWx1ZSI6IlByb2lkZW50IGV4IGV4ZXJjaXRhdGlvbiBkbyBlaXVzbW9kIG5vbi4gTG9yZW0gZXNzZSBlaXVzbW9kIGRvIG9jY2FlY2F0LlxuICAgICAgICBOdWxsYSBjdWxwYSBudWxsYSB1dCBudWxsYSBpcnVyZSBhbGlxdWlwIGFsaXF1aXAgYW1ldCB1dCBtaW5pbSBldSBlc3QuIiwic2tpcElubmVySFRNTCI6ZmFsc2UsInRhZ05hbWUiOiJwIiwic3ViVHlwZSI6InZhciIsInByb3BlcnRpZXMiOltdfSx7ImtleSI6IkRyb3Bkb3duIDMiLCJ2YWx1ZSI6IiIsInNraXBJbm5lckhUTUwiOnRydWUsInRhZ05hbWUiOiJkaXYiLCJzdWJUeXBlIjoidmFyIiwicHJvcGVydGllcyI6W119LHsia2V5IjoiRHJvcGRvd24gMyA+IENhcmQiLCJ2YWx1ZSI6IiIsInNraXBJbm5lckhUTUwiOnRydWUsInRhZ05hbWUiOiJkaXYiLCJzdWJUeXBlIjoidmFyIiwicHJvcGVydGllcyI6W119LHsia2V5IjoiRHJvcGRvd24gMyA+IFRvcCIsInZhbHVlIjoiIiwic2tpcElubmVySFRNTCI6dHJ1ZSwidGFnTmFtZSI6ImRpdiIsInN1YlR5cGUiOiJ2YXIiLCJwcm9wZXJ0aWVzIjpbXX0seyJrZXkiOiJEcm9wZG93biAzID4gUXVlc3Rpb24iLCJ2YWx1ZSI6IlRoaXMgaXMgYSBkcm9wZG93bidzIHRpdGxlLiIsInNraXBJbm5lckhUTUwiOmZhbHNlLCJ0YWdOYW1lIjoicCIsInN1YlR5cGUiOiJ2YXIiLCJwcm9wZXJ0aWVzIjpbXX0seyJrZXkiOiJEcm9wZG93biAzID4gSWNvbiIsInZhbHVlIjoiIiwic2tpcElubmVySFRNTCI6dHJ1ZSwidGFnTmFtZSI6ImRpdiIsInN1YlR5cGUiOiJ2YXIiLCJwcm9wZXJ0aWVzIjpbXX0seyJrZXkiOiJEcm9wZG93biAzID4gQm90dG9tIiwidmFsdWUiOiIiLCJza2lwSW5uZXJIVE1MIjp0cnVlLCJ0YWdOYW1lIjoiZGl2Iiwic3ViVHlwZSI6InZhciIsInByb3BlcnRpZXMiOltdfSx7ImtleSI6IkRyb3Bkb3duIDMgPiBBbnN3ZXIiLCJ2YWx1ZSI6IlByb2lkZW50IGV4IGV4ZXJjaXRhdGlvbiBkbyBlaXVzbW9kIG5vbi4gTG9yZW0gZXNzZSBlaXVzbW9kIGRvIG9jY2FlY2F0LlxuICAgICAgIE51bGxhIGN1bHBhIG51bGxhIHV0IG51bGxhIGlydXJlIGFsaXF1aXAgYWxpcXVpcCBhbWV0IHV0IG1pbmltIGV1IGVzdC4iLCJza2lwSW5uZXJIVE1MIjpmYWxzZSwidGFnTmFtZSI6InAiLCJzdWJUeXBlIjoidmFyIiwicHJvcGVydGllcyI6W119LHsia2V5IjoicHVyY2hhc2UtcHJpbWFyeSIsInZhbHVlIjoiUHVyY2hhc2UgUHJpbWFyeSIsInNraXBJbm5lckhUTUwiOmZhbHNlLCJ0YWdOYW1lIjoiZGl2Iiwic3ViVHlwZSI6InZhciIsInByb3BlcnRpZXMiOlt7InByZWZpeCI6ImRlZmF1bHQiLCJwcm9wZXJ0eSI6eyJ0eXBlIjoiY2xpY2stYmVoYXZpb3IiLCJjbGlja0JlaGF2aW9yIjp7InR5cGUiOiJwdXJjaGFzZSIsInByb2R1Y3QiOiJwcmltYXJ5In19fV19LHsia2V5IjoicHVyY2hhc2Utc2Vjb25kYXJ5IiwidmFsdWUiOiJQdXJjaGFzZSBTZWNvbmRhcnkiLCJza2lwSW5uZXJIVE1MIjpmYWxzZSwidGFnTmFtZSI6ImRpdiIsInN1YlR5cGUiOiJ2YXIiLCJwcm9wZXJ0aWVzIjpbeyJwcmVmaXgiOiJkZWZhdWx0IiwicHJvcGVydHkiOnsidHlwZSI6ImNsaWNrLWJlaGF2aW9yIiwiY2xpY2tCZWhhdmlvciI6eyJ0eXBlIjoicHVyY2hhc2UiLCJwcm9kdWN0Ijoic2Vjb25kYXJ5In19fV19LHsia2V5IjoicHVyY2hhc2UtdGVydGlhcnkiLCJ2YWx1ZSI6IlB1cmNoYXNlIFRlcnRpYXJ5Iiwic2tpcElubmVySFRNTCI6ZmFsc2UsInRhZ05hbWUiOiJkaXYiLCJzdWJUeXBlIjoidmFyIiwicHJvcGVydGllcyI6W3sicHJlZml4IjoiZGVmYXVsdCIsInByb3BlcnR5Ijp7InR5cGUiOiJjbGljay1iZWhhdmlvciIsImNsaWNrQmVoYXZpb3IiOnsidHlwZSI6InB1cmNoYXNlIiwicHJvZHVjdCI6InRlcnRpYXJ5In19fV19XX0seyJldmVudF9uYW1lIjoicGFnZV9zdHlsZXMiLCJwYWdlU3R5bGVzIjpbXX1d","substitutions":[],"feature_gating":"NON_GATED","products":[{"product": "primary", "productId": "abc-def", "product_id": "abc-def"}],"presentation_condition":"CHECK_USER_SUBSCRIPTION","presentation_style":"FULLSCREEN","presentation_style_v2":"FULLSCREEN","launch_option":"EXPLICIT","dismissal_option":"NORMAL","background_color_hex":"#FFFFFF"}],"log_level":10,"localization":{"locales":[{"locale":"de"},{"locale":"es"},{"locale":"fr"},{"locale":"hk"},{"locale":"hr"},{"locale":"ka"},{"locale":"zh"}]},"postback":{"delay":5000,"products":[]},"app_session_timeout_ms":3600000,"tests":{"dns_resolution":[]},"disable_preload":{"all":false,"triggers":[]}} + {"toggles":[],"trigger_options":[{"trigger_version":"V2","event_name":"campaign_trigger","rules":[{"experiment_group_id":"3629","experiment_id":"4516","expression":null,"preload":{"behavior":"IF_TRUE"},"expression_js":null,"variants":[{"percentage":0,"variant_type":"HOLDOUT","variant_id":"14279"},{"percentage":100,"variant_type":"TREATMENT","variant_id":"14280","paywall_identifier":"example-paywall-3e12-2023-05-02"}]}]}],"product_identifier_groups":[],"paywalls":[],"paywall_responses":[{"id":"7010","url":"https://templates.superwall.com/release/1.48.5/h3/?autoAdjustContentAlignment=true&backgroundColor=%23FFFFFF&badgeRadius=8px&borderColor=rgba%280%2C0%2C0%2C0.3%29&borderWidth=1px&brandColor=%234592E7&brandSecondaryColor=&brandTextColor=%23FFFFFF&buttonBackgroundColor=&cardBackgroundBlur=false&cardPadding=calc%28var%28--sw-spacing%29+*+1.6%29&containerRadius=0px&contentAlignment=top&contentPadding=calc%28var%28--sw-spacing%29+*+1.6%29&direction=vertical&font=&fontSize=calc%28min%284.5vw%2C+1rem%29%29&footer=normal&footerAlignment=spread&footerDivider=show&footerPosition=fixed&forceDarkMode=false&foregroundColor=%23000000&googleFonts=&headingFont=&hideProducts=true&insetsTopContent=false&logoHeight=35px&logoURL=..%2Fpublic%2Fassets%2Flogo.svg&minNavbarHeight=64px&nav=show&navBackgroundBlur=false&navBackgroundColor=clear&navButtonIconsSize=28px&navCenter=hide&navLeft=icon&navLeftIconURL=..%2Fpublic%2Fassets%2Fexit-black.svg&navLeftInnerIconURL=..%2Fpublic%2Fassets%2Fnav-button.svg&navPosition=fixed&navRight=hide&navRightIconURL=..%2Fpublic%2Fassets%2Fnav-button.svg&navRightInnerIconURL=..%2Fpublic%2Fassets%2Fnav-button.svg&paywallBackgroundColor=%23FFFFFF&paywallMaxWidth=480px&pjs=https%3A%2F%2Fcdn.superwall.me%2Fruntime%2Fentrypoint.js&previewDarkMode=false&primaryProductBadge=show&productBackgroundColor=rgba%28255%2C+255%2C+255%2C+0.8%29&productCount=auto&productDescription=show&productDivider=hide&productPadding=15px&productRadius=12px&purchaseRadius=15px&reverseDirection=false&secondaryProductBadge=hide&selectedBorderWidth=2px&spacing=10px&statusBarPadding=34px&tertiaryProductBadge=hide&sw_cache_key=1683069228509","name":"Example Paywall","identifier":"example-paywall-3e12-2023-05-02","slug":"example-paywall-3e12-2023-05-02","paywalljs_event":"W3siZXZlbnRfbmFtZSI6InRlbXBsYXRlX3N1YnN0aXR1dGlvbnMiLCJzdWJzdGl0dXRpb25zIjpbeyJrZXkiOiJjbG9zZS0xIiwidmFsdWUiOiIiLCJza2lwSW5uZXJIVE1MIjp0cnVlLCJ0YWdOYW1lIjoiaW1nIiwic3ViVHlwZSI6ImltZyIsInByb3BlcnRpZXMiOlt7InByZWZpeCI6ImRlZmF1bHQiLCJwcm9wZXJ0eSI6eyJ0eXBlIjoiY2xpY2stYmVoYXZpb3IiLCJjbGlja0JlaGF2aW9yIjp7InR5cGUiOiJjbG9zZSJ9fX0seyJwcmVmaXgiOiJkZWZhdWx0IiwicHJvcGVydHkiOnsidHlwZSI6InNyYyIsInNyYyI6Ii4uL3B1YmxpYy9hc3NldHMvZXhpdC1ibGFjay5zdmciLCJzcmNTZXQiOlsiIl19fV19LHsia2V5IjoiY2xvc2UtMiIsInZhbHVlIjoibGVmdCIsInNraXBJbm5lckhUTUwiOmZhbHNlLCJ0YWdOYW1lIjoiZGl2Iiwic3ViVHlwZSI6InZhciIsInByb3BlcnRpZXMiOlt7InByZWZpeCI6ImRlZmF1bHQiLCJwcm9wZXJ0eSI6eyJ0eXBlIjoiY2xpY2stYmVoYXZpb3IiLCJjbGlja0JlaGF2aW9yIjp7InR5cGUiOiJjbG9zZSJ9fX1dfSx7ImtleSI6ImNsb3NlLTMiLCJ2YWx1ZSI6InJpZ2h0Iiwic2tpcElubmVySFRNTCI6ZmFsc2UsInRhZ05hbWUiOiJkaXYiLCJzdWJUeXBlIjoidmFyIiwicHJvcGVydGllcyI6W3sicHJlZml4IjoiZGVmYXVsdCIsInByb3BlcnR5Ijp7InR5cGUiOiJjbGljay1iZWhhdmlvciIsImNsaWNrQmVoYXZpb3IiOnsidHlwZSI6ImNsb3NlIn19fV19LHsia2V5IjoiY2xvc2UtNCIsInZhbHVlIjoiIiwic2tpcElubmVySFRNTCI6dHJ1ZSwidGFnTmFtZSI6ImltZyIsInN1YlR5cGUiOiJpbWciLCJwcm9wZXJ0aWVzIjpbeyJwcmVmaXgiOiJkZWZhdWx0IiwicHJvcGVydHkiOnsidHlwZSI6ImNsaWNrLWJlaGF2aW9yIiwiY2xpY2tCZWhhdmlvciI6eyJ0eXBlIjoiY2xvc2UifX19LHsicHJlZml4IjoiZGVmYXVsdCIsInByb3BlcnR5Ijp7InR5cGUiOiJzcmMiLCJzcmMiOiIuLi9wdWJsaWMvYXNzZXRzL25hdi1idXR0b24uc3ZnIiwic3JjU2V0IjpbIiJdfX1dfSx7ImtleSI6InJlc3RvcmUtMSIsInZhbHVlIjoiUmVzdG9yZSBQdXJjaGFzZSIsInNraXBJbm5lckhUTUwiOmZhbHNlLCJ0YWdOYW1lIjoicCIsInN1YlR5cGUiOiJ2YXIiLCJwcm9wZXJ0aWVzIjpbeyJwcmVmaXgiOiJkZWZhdWx0IiwicHJvcGVydHkiOnsidHlwZSI6ImNsaWNrLWJlaGF2aW9yIiwiY2xpY2tCZWhhdmlvciI6eyJ0eXBlIjoicmVzdG9yZSJ9fX1dfSx7ImtleSI6Ik5hdiIsInZhbHVlIjoiIiwic2tpcElubmVySFRNTCI6dHJ1ZSwidGFnTmFtZSI6ImRpdiIsInN1YlR5cGUiOiJpbWciLCJwcm9wZXJ0aWVzIjpbeyJwcmVmaXgiOiJkZWZhdWx0IiwicHJvcGVydHkiOnsidHlwZSI6InNyYyIsInNyYyI6Ii4uL3B1YmxpYy9hc3NldHMvZXhpdC1ibGFjay5zdmciLCJzcmNTZXQiOlsiIl19fV19LHsia2V5IjoiTmF2ID4gTmF2YmFyIENvbnRhaW5lciBJbm5lciIsInZhbHVlIjoiIiwic2tpcElubmVySFRNTCI6dHJ1ZSwidGFnTmFtZSI6ImRpdiIsInN1YlR5cGUiOiJpbWciLCJwcm9wZXJ0aWVzIjpbeyJwcmVmaXgiOiJkZWZhdWx0IiwicHJvcGVydHkiOnsidHlwZSI6InNyYyIsInNyYyI6Ii4uL3B1YmxpYy9hc3NldHMvZXhpdC1ibGFjay5zdmciLCJzcmNTZXQiOlsiIl19fV19LHsia2V5IjoiTmF2ID4gTmF2IExlZnQiLCJ2YWx1ZSI6IiIsInNraXBJbm5lckhUTUwiOnRydWUsInRhZ05hbWUiOiJkaXYiLCJzdWJUeXBlIjoiaW1nIiwicHJvcGVydGllcyI6W3sicHJlZml4IjoiZGVmYXVsdCIsInByb3BlcnR5Ijp7InR5cGUiOiJzcmMiLCJzcmMiOiIuLi9wdWJsaWMvYXNzZXRzL2V4aXQtYmxhY2suc3ZnIiwic3JjU2V0IjpbIiJdfX1dfSx7ImtleSI6Ik5hdiA+IE5hdiBMaW5rIiwidmFsdWUiOiIiLCJza2lwSW5uZXJIVE1MIjp0cnVlLCJ0YWdOYW1lIjoiaW1nIiwic3ViVHlwZSI6ImltZyIsInByb3BlcnRpZXMiOlt7InByZWZpeCI6ImRlZmF1bHQiLCJwcm9wZXJ0eSI6eyJ0eXBlIjoic3JjIiwic3JjIjoiLi4vcHVibGljL2Fzc2V0cy9uYXYtYnV0dG9uLnN2ZyIsInNyY1NldCI6WyIiXX19XX0seyJrZXkiOiJOYXYgPiBOYXYgTGluayAyIiwidmFsdWUiOiJleHRyYSIsInNraXBJbm5lckhUTUwiOmZhbHNlLCJ0YWdOYW1lIjoiZGl2Iiwic3ViVHlwZSI6InZhciIsInByb3BlcnRpZXMiOltdfSx7ImtleSI6Ik5hdiA+IE5hdiBDZW50ZXIiLCJ2YWx1ZSI6IiIsInNraXBJbm5lckhUTUwiOnRydWUsInRhZ05hbWUiOiJkaXYiLCJzdWJUeXBlIjoidmFyIiwicHJvcGVydGllcyI6W119LHsia2V5IjoiTmF2ID4gTmF2IExvZ28iLCJ2YWx1ZSI6IiIsInNraXBJbm5lckhUTUwiOnRydWUsInRhZ05hbWUiOiJpbWciLCJzdWJUeXBlIjoiaW1nIiwicHJvcGVydGllcyI6W3sicHJlZml4IjoiZGVmYXVsdCIsInByb3BlcnR5Ijp7InR5cGUiOiJzcmMiLCJzcmMiOiIuLi9wdWJsaWMvYXNzZXRzL2xvZ28uc3ZnIiwic3JjU2V0IjpbIiJdfX1dfSx7ImtleSI6Ik5hdiA+IE5hdiBMaW5rIDMiLCJ2YWx1ZSI6IlRpdGxlIiwic2tpcElubmVySFRNTCI6ZmFsc2UsInRhZ05hbWUiOiJkaXYiLCJzdWJUeXBlIjoidmFyIiwicHJvcGVydGllcyI6W119LHsia2V5IjoiTmF2ID4gTmF2IFJpZ2h0IiwidmFsdWUiOiIiLCJza2lwSW5uZXJIVE1MIjp0cnVlLCJ0YWdOYW1lIjoiZGl2Iiwic3ViVHlwZSI6ImltZyIsInByb3BlcnRpZXMiOlt7InByZWZpeCI6ImRlZmF1bHQiLCJwcm9wZXJ0eSI6eyJ0eXBlIjoic3JjIiwic3JjIjoiLi4vcHVibGljL2Fzc2V0cy9uYXYtYnV0dG9uLnN2ZyIsInNyY1NldCI6WyIiXX19XX0seyJrZXkiOiJOYXYgPiBOYXYgTGluayA0IiwidmFsdWUiOiJleHRyYSIsInNraXBJbm5lckhUTUwiOmZhbHNlLCJ0YWdOYW1lIjoiZGl2Iiwic3ViVHlwZSI6InZhciIsInByb3BlcnRpZXMiOltdfSx7ImtleSI6Ik5hdiA+IE5hdiBMaW5rIDUiLCJ2YWx1ZSI6IiIsInNraXBJbm5lckhUTUwiOnRydWUsInRhZ05hbWUiOiJpbWciLCJzdWJUeXBlIjoiaW1nIiwicHJvcGVydGllcyI6W3sicHJlZml4IjoiZGVmYXVsdCIsInByb3BlcnR5Ijp7InR5cGUiOiJzcmMiLCJzcmMiOiIuLi9wdWJsaWMvYXNzZXRzL25hdi1idXR0b24uc3ZnIiwic3JjU2V0IjpbIiJdfX1dfSx7ImtleSI6IlByaW1hcnkgQmFkZ2UiLCJ2YWx1ZSI6IkZyZWUgVHJpYWwiLCJza2lwSW5uZXJIVE1MIjpmYWxzZSwidGFnTmFtZSI6ImRpdiIsInN1YlR5cGUiOiJ2YXIiLCJwcm9wZXJ0aWVzIjpbXX0seyJrZXkiOiJQcmltYXJ5IExpbmUgMSIsInZhbHVlIjoiJDAuOTkiLCJza2lwSW5uZXJIVE1MIjpmYWxzZSwidGFnTmFtZSI6InAiLCJzdWJUeXBlIjoidmFyIiwicHJvcGVydGllcyI6W119LHsia2V5IjoiUHJpbWFyeSBMaW5lIDIiLCJ2YWx1ZSI6IkxpbmUgMiIsInNraXBJbm5lckhUTUwiOmZhbHNlLCJ0YWdOYW1lIjoicCIsInN1YlR5cGUiOiJ2YXIiLCJwcm9wZXJ0aWVzIjpbXX0seyJrZXkiOiJQcmltYXJ5IExpbmUgMyIsInZhbHVlIjoiTGluZSAzIiwic2tpcElubmVySFRNTCI6ZmFsc2UsInRhZ05hbWUiOiJwIiwic3ViVHlwZSI6InZhciIsInByb3BlcnRpZXMiOltdfSx7ImtleSI6IlByaW1hcnkgTGluZSA0IiwidmFsdWUiOiJMaW5lIDQiLCJza2lwSW5uZXJIVE1MIjpmYWxzZSwidGFnTmFtZSI6InAiLCJzdWJUeXBlIjoidmFyIiwicHJvcGVydGllcyI6W119LHsia2V5IjoiU2Vjb25kYXJ5IEJhZGdlIiwidmFsdWUiOiJGcmVlXG5UcmlhbCIsInNraXBJbm5lckhUTUwiOmZhbHNlLCJ0YWdOYW1lIjoiZGl2Iiwic3ViVHlwZSI6InZhciIsInByb3BlcnRpZXMiOltdfSx7ImtleSI6IlNlY29uZGFyeSBMaW5lIDEiLCJ2YWx1ZSI6IiQwLjk5Iiwic2tpcElubmVySFRNTCI6ZmFsc2UsInRhZ05hbWUiOiJwIiwic3ViVHlwZSI6InZhciIsInByb3BlcnRpZXMiOltdfSx7ImtleSI6IlNlY29uZGFyeSBMaW5lIDIiLCJ2YWx1ZSI6IkxpbmUgMiIsInNraXBJbm5lckhUTUwiOmZhbHNlLCJ0YWdOYW1lIjoicCIsInN1YlR5cGUiOiJ2YXIiLCJwcm9wZXJ0aWVzIjpbXX0seyJrZXkiOiJTZWNvbmRhcnkgTGluZSAzIiwidmFsdWUiOiJMaW5lIDMiLCJza2lwSW5uZXJIVE1MIjpmYWxzZSwidGFnTmFtZSI6InAiLCJzdWJUeXBlIjoidmFyIiwicHJvcGVydGllcyI6W119LHsia2V5IjoiU2Vjb25kYXJ5IExpbmUgNCIsInZhbHVlIjoiTGluZSA0Iiwic2tpcElubmVySFRNTCI6ZmFsc2UsInRhZ05hbWUiOiJwIiwic3ViVHlwZSI6InZhciIsInByb3BlcnRpZXMiOltdfSx7ImtleSI6IlRlcnRpYXJ5IEJhZGdlIiwidmFsdWUiOiJGcmVlIFRyaWFsIiwic2tpcElubmVySFRNTCI6ZmFsc2UsInRhZ05hbWUiOiJkaXYiLCJzdWJUeXBlIjoidmFyIiwicHJvcGVydGllcyI6W119LHsia2V5IjoiVGVydGlhcnkgTGluZSAxIiwidmFsdWUiOiIkMC45OSIsInNraXBJbm5lckhUTUwiOmZhbHNlLCJ0YWdOYW1lIjoicCIsInN1YlR5cGUiOiJ2YXIiLCJwcm9wZXJ0aWVzIjpbXX0seyJrZXkiOiJUZXJ0aWFyeSBMaW5lIDIiLCJ2YWx1ZSI6IkxpbmUgMiIsInNraXBJbm5lckhUTUwiOmZhbHNlLCJ0YWdOYW1lIjoicCIsInN1YlR5cGUiOiJ2YXIiLCJwcm9wZXJ0aWVzIjpbXX0seyJrZXkiOiJUZXJ0aWFyeSBMaW5lIDMiLCJ2YWx1ZSI6IkxpbmUgMyIsInNraXBJbm5lckhUTUwiOmZhbHNlLCJ0YWdOYW1lIjoicCIsInN1YlR5cGUiOiJ2YXIiLCJwcm9wZXJ0aWVzIjpbXX0seyJrZXkiOiJUZXJ0aWFyeSBMaW5lIDQiLCJ2YWx1ZSI6IkxpbmUgNCIsInNraXBJbm5lckhUTUwiOmZhbHNlLCJ0YWdOYW1lIjoicCIsInN1YlR5cGUiOiJ2YXIiLCJwcm9wZXJ0aWVzIjpbXX0seyJrZXkiOiJQcmltYXJ5IFNlbGVjdGVkIERlc2NyaXB0aW9uIiwidmFsdWUiOiJwcmltYXJ5IHNlbGVjdGVkIGRlc2NyaXB0aW9uIiwic2tpcElubmVySFRNTCI6ZmFsc2UsInRhZ05hbWUiOiJwIiwic3ViVHlwZSI6InZhciIsInByb3BlcnRpZXMiOltdfSx7ImtleSI6IlNlY29uZGFyeSBTZWxlY3RlZCBEZXNjcmlwdGlvbiIsInZhbHVlIjoic2Vjb25kYXJ5IHNlbGVjdGVkXG5kZXNjcmlwdGlvbiIsInNraXBJbm5lckhUTUwiOmZhbHNlLCJ0YWdOYW1lIjoicCIsInN1YlR5cGUiOiJ2YXIiLCJwcm9wZXJ0aWVzIjpbXX0seyJrZXkiOiJUZXJ0aWFyeSBTZWxlY3RlZCBEZXNjcmlwdGlvbiIsInZhbHVlIjoidGVydGlhcnkgc2VsZWN0ZWRcbmRlc2NyaXB0aW9uIiwic2tpcElubmVySFRNTCI6ZmFsc2UsInRhZ05hbWUiOiJwIiwic3ViVHlwZSI6InZhciIsInByb3BlcnRpZXMiOltdfSx7ImtleSI6IlByaWNpbmcgT3B0aW9ucyIsInZhbHVlIjoiVmlldyBBbGwgUGxhbnMiLCJza2lwSW5uZXJIVE1MIjpmYWxzZSwidGFnTmFtZSI6InAiLCJzdWJUeXBlIjoidmFyIiwicHJvcGVydGllcyI6W119LHsia2V5IjoiSW50ZXJuYWwgKFByaW1hcnkpIiwidmFsdWUiOiJ7e3ByaW1hcnkucHJpY2V9fSIsInNraXBJbm5lckhUTUwiOmZhbHNlLCJ0YWdOYW1lIjoicCIsInN1YlR5cGUiOiJ2YXIiLCJwcm9wZXJ0aWVzIjpbXX0seyJrZXkiOiJJbnRlcm5hbCAoU2Vjb25kYXJ5KSIsInZhbHVlIjoie3tzZWNvbmRhcnkucHJpY2V9fSIsInNraXBJbm5lckhUTUwiOmZhbHNlLCJ0YWdOYW1lIjoicCIsInN1YlR5cGUiOiJ2YXIiLCJwcm9wZXJ0aWVzIjpbXX0seyJrZXkiOiJJbnRlcm5hbCAoVGVydGlhcnkpIiwidmFsdWUiOiJ7e3RlcnRpYXJ5LnByaWNlfX0iLCJza2lwSW5uZXJIVE1MIjpmYWxzZSwidGFnTmFtZSI6InAiLCJzdWJUeXBlIjoidmFyIiwicHJvcGVydGllcyI6W119LHsia2V5IjoiRm9vdGVyIEZpcnN0IiwidmFsdWUiOiJUZXJtcyBvZiBVc2UiLCJza2lwSW5uZXJIVE1MIjpmYWxzZSwidGFnTmFtZSI6InAiLCJzdWJUeXBlIjoidmFyIiwicHJvcGVydGllcyI6W119LHsia2V5IjoiRm9vdGVyIExhc3QiLCJ2YWx1ZSI6IlByaXZhY3kgUG9saWN5Iiwic2tpcElubmVySFRNTCI6ZmFsc2UsInRhZ05hbWUiOiJwIiwic3ViVHlwZSI6InZhciIsInByb3BlcnRpZXMiOltdfSx7ImtleSI6IkZvb3RlciBDb2x1bW4gMSIsInZhbHVlIjoiVGVybXMiLCJza2lwSW5uZXJIVE1MIjpmYWxzZSwidGFnTmFtZSI6InAiLCJzdWJUeXBlIjoidmFyIiwicHJvcGVydGllcyI6W119LHsia2V5IjoiRm9vdGVyIENvbHVtbiAyIiwidmFsdWUiOiImYW1wOyIsInNraXBJbm5lckhUTUwiOmZhbHNlLCJ0YWdOYW1lIjoicCIsInN1YlR5cGUiOiJ2YXIiLCJwcm9wZXJ0aWVzIjpbXX0seyJrZXkiOiJGb290ZXIgQ29sdW1uIDMiLCJ2YWx1ZSI6IlByaXZhY3kiLCJza2lwSW5uZXJIVE1MIjpmYWxzZSwidGFnTmFtZSI6InAiLCJzdWJUeXBlIjoidmFyIiwicHJvcGVydGllcyI6W119LHsia2V5IjoiRm9vdGVyIENvbHVtbiA0IiwidmFsdWUiOiJ8Iiwic2tpcElubmVySFRNTCI6ZmFsc2UsInRhZ05hbWUiOiJwIiwic3ViVHlwZSI6InZhciIsInByb3BlcnRpZXMiOltdfSx7ImtleSI6IkZvb3RlciBDb2x1bW4gNSIsInZhbHVlIjoiUmVuZXdzIGZvciAkMC4wMCBwZXIgZGF5Iiwic2tpcElubmVySFRNTCI6ZmFsc2UsInRhZ05hbWUiOiJwIiwic3ViVHlwZSI6InZhciIsInByb3BlcnRpZXMiOltdfSx7ImtleSI6IkltYWdlIFdyYXBwZXIiLCJ2YWx1ZSI6IiIsInNraXBJbm5lckhUTUwiOnRydWUsInRhZ05hbWUiOiJkaXYiLCJzdWJUeXBlIjoiaW1nIiwicHJvcGVydGllcyI6W3sicHJlZml4IjoiZGVmYXVsdCIsInByb3BlcnR5Ijp7InR5cGUiOiJzcmMiLCJzcmMiOiIuLi9wdWJsaWMvYXNzZXRzL2hlYWRlci1waC0xLmpwZyIsInNyY1NldCI6WyIiXX19XX0seyJrZXkiOiJJbWFnZSBXcmFwcGVyID4gSW1hZ2UiLCJ2YWx1ZSI6IiIsInNraXBJbm5lckhUTUwiOnRydWUsInRhZ05hbWUiOiJpbWciLCJzdWJUeXBlIjoiaW1nIiwicHJvcGVydGllcyI6W3sicHJlZml4IjoiZGVmYXVsdCIsInByb3BlcnR5Ijp7InR5cGUiOiJzcmMiLCJzcmMiOiIuLi9wdWJsaWMvYXNzZXRzL2hlYWRlci1waC0xLmpwZyIsInNyY1NldCI6WyIiXX19XX0seyJrZXkiOiJUaXRsZSIsInZhbHVlIjoiIiwic2tpcElubmVySFRNTCI6dHJ1ZSwidGFnTmFtZSI6ImRpdiIsInN1YlR5cGUiOiJ2YXIiLCJwcm9wZXJ0aWVzIjpbXX0seyJrZXkiOiJUaXRsZSA+IFRleHQiLCJ2YWx1ZSI6IlRoaXMgaXMgYSB0aXRsZSIsInNraXBJbm5lckhUTUwiOmZhbHNlLCJ0YWdOYW1lIjoiaDEiLCJzdWJUeXBlIjoidmFyIiwicHJvcGVydGllcyI6W119LHsia2V5IjoiU3VidGl0bGUiLCJ2YWx1ZSI6IiIsInNraXBJbm5lckhUTUwiOnRydWUsInRhZ05hbWUiOiJkaXYiLCJzdWJUeXBlIjoidmFyIiwicHJvcGVydGllcyI6W119LHsia2V5IjoiU3VidGl0bGUgPiBUZXh0IiwidmFsdWUiOiJUaGlzIGlzIGEgc3VidGl0bGUiLCJza2lwSW5uZXJIVE1MIjpmYWxzZSwidGFnTmFtZSI6InAiLCJzdWJUeXBlIjoidmFyIiwicHJvcGVydGllcyI6W119LHsia2V5IjoiVGFibGUiLCJ2YWx1ZSI6IiIsInNraXBJbm5lckhUTUwiOnRydWUsInRhZ05hbWUiOiJkaXYiLCJzdWJUeXBlIjoidmFyIiwicHJvcGVydGllcyI6W119LHsia2V5IjoiVGFibGUgPiBSb3ciLCJ2YWx1ZSI6IiIsInNraXBJbm5lckhUTUwiOnRydWUsInRhZ05hbWUiOiJkaXYiLCJzdWJUeXBlIjoidmFyIiwicHJvcGVydGllcyI6W119LHsia2V5IjoiVGFibGUgPiBSb3cgPiBDb2wgTGVmdCIsInZhbHVlIjoiIiwic2tpcElubmVySFRNTCI6dHJ1ZSwidGFnTmFtZSI6ImRpdiIsInN1YlR5cGUiOiJ2YXIiLCJwcm9wZXJ0aWVzIjpbXX0seyJrZXkiOiJUYWJsZSA+IFJvdyA+IFRleHQiLCJ2YWx1ZSI6IlRpdGxlIiwic2tpcElubmVySFRNTCI6ZmFsc2UsInRhZ05hbWUiOiJwIiwic3ViVHlwZSI6InZhciIsInByb3BlcnRpZXMiOltdfSx7ImtleSI6IlRhYmxlID4gUm93ID4gQ29sIFJpZ2h0IiwidmFsdWUiOiIiLCJza2lwSW5uZXJIVE1MIjp0cnVlLCJ0YWdOYW1lIjoiZGl2Iiwic3ViVHlwZSI6InZhciIsInByb3BlcnRpZXMiOltdfSx7ImtleSI6IlRhYmxlID4gUm93ID4gSW1hZ2UiLCJ2YWx1ZSI6IiIsInNraXBJbm5lckhUTUwiOnRydWUsInRhZ05hbWUiOiJpbWciLCJzdWJUeXBlIjoiaW1nIiwicHJvcGVydGllcyI6W3sicHJlZml4IjoiZGVmYXVsdCIsInByb3BlcnR5Ijp7InR5cGUiOiJzcmMiLCJzcmMiOiIuLi9wdWJsaWMvYXNzZXRzL2xvZ29fZ3JheS5zdmciLCJzcmNTZXQiOlsiIl19fV19LHsia2V5IjoiVGFibGUgPiBSb3cgPiBUZXh0IDIiLCJ2YWx1ZSI6ImxhYmVsIiwic2tpcElubmVySFRNTCI6ZmFsc2UsInRhZ05hbWUiOiJwIiwic3ViVHlwZSI6InZhciIsInByb3BlcnRpZXMiOltdfSx7ImtleSI6IlRhYmxlID4gUm93ID4gQ29sIFJpZ2h0IDIiLCJ2YWx1ZSI6IiIsInNraXBJbm5lckhUTUwiOnRydWUsInRhZ05hbWUiOiJkaXYiLCJzdWJUeXBlIjoidmFyIiwicHJvcGVydGllcyI6W119LHsia2V5IjoiVGFibGUgPiBSb3cgPiBJbWFnZSAyIiwidmFsdWUiOiIiLCJza2lwSW5uZXJIVE1MIjp0cnVlLCJ0YWdOYW1lIjoiaW1nIiwic3ViVHlwZSI6ImltZyIsInByb3BlcnRpZXMiOlt7InByZWZpeCI6ImRlZmF1bHQiLCJwcm9wZXJ0eSI6eyJ0eXBlIjoic3JjIiwic3JjIjoiLi4vcHVibGljL2Fzc2V0cy9sb2dvX2dyYXkuc3ZnIiwic3JjU2V0IjpbIiJdfX1dfSx7ImtleSI6IlRhYmxlID4gUm93ID4gVGV4dCAzIiwidmFsdWUiOiJsYWJlbCIsInNraXBJbm5lckhUTUwiOmZhbHNlLCJ0YWdOYW1lIjoicCIsInN1YlR5cGUiOiJ2YXIiLCJwcm9wZXJ0aWVzIjpbXX0seyJrZXkiOiJUYWJsZSA+IFJvdyAyIiwidmFsdWUiOiIiLCJza2lwSW5uZXJIVE1MIjp0cnVlLCJ0YWdOYW1lIjoiZGl2Iiwic3ViVHlwZSI6InZhciIsInByb3BlcnRpZXMiOltdfSx7ImtleSI6IlRhYmxlID4gUm93IDIgPiBDb2wgTGVmdCIsInZhbHVlIjoiIiwic2tpcElubmVySFRNTCI6dHJ1ZSwidGFnTmFtZSI6ImRpdiIsInN1YlR5cGUiOiJ2YXIiLCJwcm9wZXJ0aWVzIjpbXX0seyJrZXkiOiJUYWJsZSA+IFJvdyAyID4gVGV4dCIsInZhbHVlIjoiTGFiZWwgMSIsInNraXBJbm5lckhUTUwiOmZhbHNlLCJ0YWdOYW1lIjoicCIsInN1YlR5cGUiOiJ2YXIiLCJwcm9wZXJ0aWVzIjpbXX0seyJrZXkiOiJUYWJsZSA+IFJvdyAyID4gQ29sIFJpZ2h0IiwidmFsdWUiOiIiLCJza2lwSW5uZXJIVE1MIjp0cnVlLCJ0YWdOYW1lIjoiZGl2Iiwic3ViVHlwZSI6InZhciIsInByb3BlcnRpZXMiOltdfSx7ImtleSI6IlRhYmxlID4gUm93IDIgPiBJY29uIENvbnRhaW5lciIsInZhbHVlIjoiIiwic2tpcElubmVySFRNTCI6dHJ1ZSwidGFnTmFtZSI6ImRpdiIsInN1YlR5cGUiOiJpbWciLCJwcm9wZXJ0aWVzIjpbeyJwcmVmaXgiOiJkZWZhdWx0IiwicHJvcGVydHkiOnsidHlwZSI6InNyYyIsInNyYyI6Ii4uL3B1YmxpYy9hc3NldHMvdGFibGUtbG9jay5zdmciLCJzcmNTZXQiOlsiIl19fV19LHsia2V5IjoiVGFibGUgPiBSb3cgMiA+IEltYWdlIiwidmFsdWUiOiIiLCJza2lwSW5uZXJIVE1MIjp0cnVlLCJ0YWdOYW1lIjoiaW1nIiwic3ViVHlwZSI6ImltZyIsInByb3BlcnRpZXMiOlt7InByZWZpeCI6ImRlZmF1bHQiLCJwcm9wZXJ0eSI6eyJ0eXBlIjoic3JjIiwic3JjIjoiLi4vcHVibGljL2Fzc2V0cy90YWJsZS1sb2NrLnN2ZyIsInNyY1NldCI6WyIiXX19XX0seyJrZXkiOiJUYWJsZSA+IFJvdyAyID4gVGV4dCAyIiwidmFsdWUiOiJsYWJlbCIsInNraXBJbm5lckhUTUwiOmZhbHNlLCJ0YWdOYW1lIjoicCIsInN1YlR5cGUiOiJ2YXIiLCJwcm9wZXJ0aWVzIjpbXX0seyJrZXkiOiJUYWJsZSA+IFJvdyAyID4gQ29sIFJpZ2h0IDIiLCJ2YWx1ZSI6IiIsInNraXBJbm5lckhUTUwiOnRydWUsInRhZ05hbWUiOiJkaXYiLCJzdWJUeXBlIjoidmFyIiwicHJvcGVydGllcyI6W119LHsia2V5IjoiVGFibGUgPiBSb3cgMiA+IEljb24gQ29udGFpbmVyIDIiLCJ2YWx1ZSI6IiIsInNraXBJbm5lckhUTUwiOnRydWUsInRhZ05hbWUiOiJkaXYiLCJzdWJUeXBlIjoiaW1nIiwicHJvcGVydGllcyI6W3sicHJlZml4IjoiZGVmYXVsdCIsInByb3BlcnR5Ijp7InR5cGUiOiJzcmMiLCJzcmMiOiIuLi9wdWJsaWMvYXNzZXRzL3RhYmxlLWNoZWNrLnN2ZyIsInNyY1NldCI6WyIiXX19XX0seyJrZXkiOiJUYWJsZSA+IFJvdyAyID4gSW1hZ2UgMiIsInZhbHVlIjoiIiwic2tpcElubmVySFRNTCI6dHJ1ZSwidGFnTmFtZSI6ImltZyIsInN1YlR5cGUiOiJpbWciLCJwcm9wZXJ0aWVzIjpbeyJwcmVmaXgiOiJkZWZhdWx0IiwicHJvcGVydHkiOnsidHlwZSI6InNyYyIsInNyYyI6Ii4uL3B1YmxpYy9hc3NldHMvdGFibGUtY2hlY2suc3ZnIiwic3JjU2V0IjpbIiJdfX1dfSx7ImtleSI6IlRhYmxlID4gUm93IDIgPiBUZXh0IDMiLCJ2YWx1ZSI6ImxhYmVsIiwic2tpcElubmVySFRNTCI6ZmFsc2UsInRhZ05hbWUiOiJwIiwic3ViVHlwZSI6InZhciIsInByb3BlcnRpZXMiOltdfSx7ImtleSI6IlRhYmxlID4gUm93IDMiLCJ2YWx1ZSI6IiIsInNraXBJbm5lckhUTUwiOnRydWUsInRhZ05hbWUiOiJkaXYiLCJzdWJUeXBlIjoidmFyIiwicHJvcGVydGllcyI6W119LHsia2V5IjoiVGFibGUgPiBSb3cgMyA+IENvbCBMZWZ0IiwidmFsdWUiOiIiLCJza2lwSW5uZXJIVE1MIjp0cnVlLCJ0YWdOYW1lIjoiZGl2Iiwic3ViVHlwZSI6InZhciIsInByb3BlcnRpZXMiOltdfSx7ImtleSI6IlRhYmxlID4gUm93IDMgPiBUZXh0IiwidmFsdWUiOiJMYWJlbCAyIiwic2tpcElubmVySFRNTCI6ZmFsc2UsInRhZ05hbWUiOiJwIiwic3ViVHlwZSI6InZhciIsInByb3BlcnRpZXMiOltdfSx7ImtleSI6IlRhYmxlID4gUm93IDMgPiBDb2wgUmlnaHQiLCJ2YWx1ZSI6IiIsInNraXBJbm5lckhUTUwiOnRydWUsInRhZ05hbWUiOiJkaXYiLCJzdWJUeXBlIjoidmFyIiwicHJvcGVydGllcyI6W119LHsia2V5IjoiVGFibGUgPiBSb3cgMyA+IEljb24gQ29udGFpbmVyIiwidmFsdWUiOiIiLCJza2lwSW5uZXJIVE1MIjp0cnVlLCJ0YWdOYW1lIjoiZGl2Iiwic3ViVHlwZSI6ImltZyIsInByb3BlcnRpZXMiOlt7InByZWZpeCI6ImRlZmF1bHQiLCJwcm9wZXJ0eSI6eyJ0eXBlIjoic3JjIiwic3JjIjoiLi4vcHVibGljL2Fzc2V0cy90YWJsZS1sb2NrLnN2ZyIsInNyY1NldCI6WyIiXX19XX0seyJrZXkiOiJUYWJsZSA+IFJvdyAzID4gSW1hZ2UiLCJ2YWx1ZSI6IiIsInNraXBJbm5lckhUTUwiOnRydWUsInRhZ05hbWUiOiJpbWciLCJzdWJUeXBlIjoiaW1nIiwicHJvcGVydGllcyI6W3sicHJlZml4IjoiZGVmYXVsdCIsInByb3BlcnR5Ijp7InR5cGUiOiJzcmMiLCJzcmMiOiIuLi9wdWJsaWMvYXNzZXRzL3RhYmxlLWxvY2suc3ZnIiwic3JjU2V0IjpbIiJdfX1dfSx7ImtleSI6IlRhYmxlID4gUm93IDMgPiBUZXh0IDIiLCJ2YWx1ZSI6ImxhYmVsIiwic2tpcElubmVySFRNTCI6ZmFsc2UsInRhZ05hbWUiOiJwIiwic3ViVHlwZSI6InZhciIsInByb3BlcnRpZXMiOltdfSx7ImtleSI6IlRhYmxlID4gUm93IDMgPiBDb2wgUmlnaHQgMiIsInZhbHVlIjoiIiwic2tpcElubmVySFRNTCI6dHJ1ZSwidGFnTmFtZSI6ImRpdiIsInN1YlR5cGUiOiJ2YXIiLCJwcm9wZXJ0aWVzIjpbXX0seyJrZXkiOiJUYWJsZSA+IFJvdyAzID4gSWNvbiBDb250YWluZXIgMiIsInZhbHVlIjoiIiwic2tpcElubmVySFRNTCI6dHJ1ZSwidGFnTmFtZSI6ImRpdiIsInN1YlR5cGUiOiJpbWciLCJwcm9wZXJ0aWVzIjpbeyJwcmVmaXgiOiJkZWZhdWx0IiwicHJvcGVydHkiOnsidHlwZSI6InNyYyIsInNyYyI6Ii4uL3B1YmxpYy9hc3NldHMvdGFibGUtY2hlY2suc3ZnIiwic3JjU2V0IjpbIiJdfX1dfSx7ImtleSI6IlRhYmxlID4gUm93IDMgPiBJbWFnZSAyIiwidmFsdWUiOiIiLCJza2lwSW5uZXJIVE1MIjp0cnVlLCJ0YWdOYW1lIjoiaW1nIiwic3ViVHlwZSI6ImltZyIsInByb3BlcnRpZXMiOlt7InByZWZpeCI6ImRlZmF1bHQiLCJwcm9wZXJ0eSI6eyJ0eXBlIjoic3JjIiwic3JjIjoiLi4vcHVibGljL2Fzc2V0cy90YWJsZS1jaGVjay5zdmciLCJzcmNTZXQiOlsiIl19fV19LHsia2V5IjoiVGFibGUgPiBSb3cgMyA+IFRleHQgMyIsInZhbHVlIjoibGFiZWwiLCJza2lwSW5uZXJIVE1MIjpmYWxzZSwidGFnTmFtZSI6InAiLCJzdWJUeXBlIjoidmFyIiwicHJvcGVydGllcyI6W119LHsia2V5IjoiVGFibGUgPiBSb3cgNCIsInZhbHVlIjoiIiwic2tpcElubmVySFRNTCI6dHJ1ZSwidGFnTmFtZSI6ImRpdiIsInN1YlR5cGUiOiJ2YXIiLCJwcm9wZXJ0aWVzIjpbXX0seyJrZXkiOiJUYWJsZSA+IFJvdyA0ID4gQ29sIExlZnQiLCJ2YWx1ZSI6IiIsInNraXBJbm5lckhUTUwiOnRydWUsInRhZ05hbWUiOiJkaXYiLCJzdWJUeXBlIjoidmFyIiwicHJvcGVydGllcyI6W119LHsia2V5IjoiVGFibGUgPiBSb3cgNCA+IFRleHQiLCJ2YWx1ZSI6IkxhYmVsIDMiLCJza2lwSW5uZXJIVE1MIjpmYWxzZSwidGFnTmFtZSI6InAiLCJzdWJUeXBlIjoidmFyIiwicHJvcGVydGllcyI6W119LHsia2V5IjoiVGFibGUgPiBSb3cgNCA+IENvbCBSaWdodCIsInZhbHVlIjoiIiwic2tpcElubmVySFRNTCI6dHJ1ZSwidGFnTmFtZSI6ImRpdiIsInN1YlR5cGUiOiJ2YXIiLCJwcm9wZXJ0aWVzIjpbXX0seyJrZXkiOiJUYWJsZSA+IFJvdyA0ID4gSWNvbiBDb250YWluZXIiLCJ2YWx1ZSI6IiIsInNraXBJbm5lckhUTUwiOnRydWUsInRhZ05hbWUiOiJkaXYiLCJzdWJUeXBlIjoiaW1nIiwicHJvcGVydGllcyI6W3sicHJlZml4IjoiZGVmYXVsdCIsInByb3BlcnR5Ijp7InR5cGUiOiJzcmMiLCJzcmMiOiIuLi9wdWJsaWMvYXNzZXRzL3RhYmxlLWxvY2suc3ZnIiwic3JjU2V0IjpbIiJdfX1dfSx7ImtleSI6IlRhYmxlID4gUm93IDQgPiBJbWFnZSIsInZhbHVlIjoiIiwic2tpcElubmVySFRNTCI6dHJ1ZSwidGFnTmFtZSI6ImltZyIsInN1YlR5cGUiOiJpbWciLCJwcm9wZXJ0aWVzIjpbeyJwcmVmaXgiOiJkZWZhdWx0IiwicHJvcGVydHkiOnsidHlwZSI6InNyYyIsInNyYyI6Ii4uL3B1YmxpYy9hc3NldHMvdGFibGUtbG9jay5zdmciLCJzcmNTZXQiOlsiIl19fV19LHsia2V5IjoiVGFibGUgPiBSb3cgNCA+IFRleHQgMiIsInZhbHVlIjoibGFiZWwiLCJza2lwSW5uZXJIVE1MIjpmYWxzZSwidGFnTmFtZSI6InAiLCJzdWJUeXBlIjoidmFyIiwicHJvcGVydGllcyI6W119LHsia2V5IjoiVGFibGUgPiBSb3cgNCA+IENvbCBSaWdodCAyIiwidmFsdWUiOiIiLCJza2lwSW5uZXJIVE1MIjp0cnVlLCJ0YWdOYW1lIjoiZGl2Iiwic3ViVHlwZSI6InZhciIsInByb3BlcnRpZXMiOltdfSx7ImtleSI6IlRhYmxlID4gUm93IDQgPiBJY29uIENvbnRhaW5lciAyIiwidmFsdWUiOiIiLCJza2lwSW5uZXJIVE1MIjp0cnVlLCJ0YWdOYW1lIjoiZGl2Iiwic3ViVHlwZSI6ImltZyIsInByb3BlcnRpZXMiOlt7InByZWZpeCI6ImRlZmF1bHQiLCJwcm9wZXJ0eSI6eyJ0eXBlIjoic3JjIiwic3JjIjoiLi4vcHVibGljL2Fzc2V0cy90YWJsZS1jaGVjay5zdmciLCJzcmNTZXQiOlsiIl19fV19LHsia2V5IjoiVGFibGUgPiBSb3cgNCA+IEltYWdlIDIiLCJ2YWx1ZSI6IiIsInNraXBJbm5lckhUTUwiOnRydWUsInRhZ05hbWUiOiJpbWciLCJzdWJUeXBlIjoiaW1nIiwicHJvcGVydGllcyI6W3sicHJlZml4IjoiZGVmYXVsdCIsInByb3BlcnR5Ijp7InR5cGUiOiJzcmMiLCJzcmMiOiIuLi9wdWJsaWMvYXNzZXRzL3RhYmxlLWNoZWNrLnN2ZyIsInNyY1NldCI6WyIiXX19XX0seyJrZXkiOiJUYWJsZSA+IFJvdyA0ID4gVGV4dCAzIiwidmFsdWUiOiJsYWJlbCIsInNraXBJbm5lckhUTUwiOmZhbHNlLCJ0YWdOYW1lIjoicCIsInN1YlR5cGUiOiJ2YXIiLCJwcm9wZXJ0aWVzIjpbXX0seyJrZXkiOiJUYWJsZSA+IFJvdyA1IiwidmFsdWUiOiIiLCJza2lwSW5uZXJIVE1MIjp0cnVlLCJ0YWdOYW1lIjoiZGl2Iiwic3ViVHlwZSI6InZhciIsInByb3BlcnRpZXMiOltdfSx7ImtleSI6IlRhYmxlID4gUm93IDUgPiBDb2wgTGVmdCIsInZhbHVlIjoiIiwic2tpcElubmVySFRNTCI6dHJ1ZSwidGFnTmFtZSI6ImRpdiIsInN1YlR5cGUiOiJ2YXIiLCJwcm9wZXJ0aWVzIjpbXX0seyJrZXkiOiJUYWJsZSA+IFJvdyA1ID4gVGV4dCIsInZhbHVlIjoiTGFiZWwgNCIsInNraXBJbm5lckhUTUwiOmZhbHNlLCJ0YWdOYW1lIjoicCIsInN1YlR5cGUiOiJ2YXIiLCJwcm9wZXJ0aWVzIjpbXX0seyJrZXkiOiJUYWJsZSA+IFJvdyA1ID4gQ29sIFJpZ2h0IiwidmFsdWUiOiIiLCJza2lwSW5uZXJIVE1MIjp0cnVlLCJ0YWdOYW1lIjoiZGl2Iiwic3ViVHlwZSI6InZhciIsInByb3BlcnRpZXMiOltdfSx7ImtleSI6IlRhYmxlID4gUm93IDUgPiBJY29uIENvbnRhaW5lciIsInZhbHVlIjoiIiwic2tpcElubmVySFRNTCI6dHJ1ZSwidGFnTmFtZSI6ImRpdiIsInN1YlR5cGUiOiJpbWciLCJwcm9wZXJ0aWVzIjpbeyJwcmVmaXgiOiJkZWZhdWx0IiwicHJvcGVydHkiOnsidHlwZSI6InNyYyIsInNyYyI6Ii4uL3B1YmxpYy9hc3NldHMvdGFibGUtbG9jay5zdmciLCJzcmNTZXQiOlsiIl19fV19LHsia2V5IjoiVGFibGUgPiBSb3cgNSA+IEltYWdlIiwidmFsdWUiOiIiLCJza2lwSW5uZXJIVE1MIjp0cnVlLCJ0YWdOYW1lIjoiaW1nIiwic3ViVHlwZSI6ImltZyIsInByb3BlcnRpZXMiOlt7InByZWZpeCI6ImRlZmF1bHQiLCJwcm9wZXJ0eSI6eyJ0eXBlIjoic3JjIiwic3JjIjoiLi4vcHVibGljL2Fzc2V0cy90YWJsZS1sb2NrLnN2ZyIsInNyY1NldCI6WyIiXX19XX0seyJrZXkiOiJUYWJsZSA+IFJvdyA1ID4gVGV4dCAyIiwidmFsdWUiOiJsYWJlbCIsInNraXBJbm5lckhUTUwiOmZhbHNlLCJ0YWdOYW1lIjoicCIsInN1YlR5cGUiOiJ2YXIiLCJwcm9wZXJ0aWVzIjpbXX0seyJrZXkiOiJUYWJsZSA+IFJvdyA1ID4gQ29sIFJpZ2h0IDIiLCJ2YWx1ZSI6IiIsInNraXBJbm5lckhUTUwiOnRydWUsInRhZ05hbWUiOiJkaXYiLCJzdWJUeXBlIjoidmFyIiwicHJvcGVydGllcyI6W119LHsia2V5IjoiVGFibGUgPiBSb3cgNSA+IEljb24gQ29udGFpbmVyIDIiLCJ2YWx1ZSI6IiIsInNraXBJbm5lckhUTUwiOnRydWUsInRhZ05hbWUiOiJkaXYiLCJzdWJUeXBlIjoiaW1nIiwicHJvcGVydGllcyI6W3sicHJlZml4IjoiZGVmYXVsdCIsInByb3BlcnR5Ijp7InR5cGUiOiJzcmMiLCJzcmMiOiIuLi9wdWJsaWMvYXNzZXRzL3RhYmxlLWNoZWNrLnN2ZyIsInNyY1NldCI6WyIiXX19XX0seyJrZXkiOiJUYWJsZSA+IFJvdyA1ID4gSW1hZ2UgMiIsInZhbHVlIjoiIiwic2tpcElubmVySFRNTCI6dHJ1ZSwidGFnTmFtZSI6ImltZyIsInN1YlR5cGUiOiJpbWciLCJwcm9wZXJ0aWVzIjpbeyJwcmVmaXgiOiJkZWZhdWx0IiwicHJvcGVydHkiOnsidHlwZSI6InNyYyIsInNyYyI6Ii4uL3B1YmxpYy9hc3NldHMvdGFibGUtY2hlY2suc3ZnIiwic3JjU2V0IjpbIiJdfX1dfSx7ImtleSI6IlRhYmxlID4gUm93IDUgPiBUZXh0IDMiLCJ2YWx1ZSI6ImxhYmVsIiwic2tpcElubmVySFRNTCI6ZmFsc2UsInRhZ05hbWUiOiJwIiwic3ViVHlwZSI6InZhciIsInByb3BlcnRpZXMiOltdfSx7ImtleSI6IlRhYmxlID4gUm93IDYiLCJ2YWx1ZSI6IiIsInNraXBJbm5lckhUTUwiOnRydWUsInRhZ05hbWUiOiJkaXYiLCJzdWJUeXBlIjoidmFyIiwicHJvcGVydGllcyI6W119LHsia2V5IjoiVGFibGUgPiBSb3cgNiA+IENvbCBMZWZ0IiwidmFsdWUiOiIiLCJza2lwSW5uZXJIVE1MIjp0cnVlLCJ0YWdOYW1lIjoiZGl2Iiwic3ViVHlwZSI6InZhciIsInByb3BlcnRpZXMiOltdfSx7ImtleSI6IlRhYmxlID4gUm93IDYgPiBUZXh0IiwidmFsdWUiOiJMYWJlbCA1Iiwic2tpcElubmVySFRNTCI6ZmFsc2UsInRhZ05hbWUiOiJwIiwic3ViVHlwZSI6InZhciIsInByb3BlcnRpZXMiOltdfSx7ImtleSI6IlRhYmxlID4gUm93IDYgPiBDb2wgUmlnaHQiLCJ2YWx1ZSI6IiIsInNraXBJbm5lckhUTUwiOnRydWUsInRhZ05hbWUiOiJkaXYiLCJzdWJUeXBlIjoidmFyIiwicHJvcGVydGllcyI6W119LHsia2V5IjoiVGFibGUgPiBSb3cgNiA+IEljb24gQ29udGFpbmVyIiwidmFsdWUiOiIiLCJza2lwSW5uZXJIVE1MIjp0cnVlLCJ0YWdOYW1lIjoiZGl2Iiwic3ViVHlwZSI6ImltZyIsInByb3BlcnRpZXMiOlt7InByZWZpeCI6ImRlZmF1bHQiLCJwcm9wZXJ0eSI6eyJ0eXBlIjoic3JjIiwic3JjIjoiLi4vcHVibGljL2Fzc2V0cy90YWJsZS1sb2NrLnN2ZyIsInNyY1NldCI6WyIiXX19XX0seyJrZXkiOiJUYWJsZSA+IFJvdyA2ID4gSW1hZ2UiLCJ2YWx1ZSI6IiIsInNraXBJbm5lckhUTUwiOnRydWUsInRhZ05hbWUiOiJpbWciLCJzdWJUeXBlIjoiaW1nIiwicHJvcGVydGllcyI6W3sicHJlZml4IjoiZGVmYXVsdCIsInByb3BlcnR5Ijp7InR5cGUiOiJzcmMiLCJzcmMiOiIuLi9wdWJsaWMvYXNzZXRzL3RhYmxlLWxvY2suc3ZnIiwic3JjU2V0IjpbIiJdfX1dfSx7ImtleSI6IlRhYmxlID4gUm93IDYgPiBUZXh0IDIiLCJ2YWx1ZSI6ImxhYmVsIiwic2tpcElubmVySFRNTCI6ZmFsc2UsInRhZ05hbWUiOiJwIiwic3ViVHlwZSI6InZhciIsInByb3BlcnRpZXMiOltdfSx7ImtleSI6IlRhYmxlID4gUm93IDYgPiBDb2wgUmlnaHQgMiIsInZhbHVlIjoiIiwic2tpcElubmVySFRNTCI6dHJ1ZSwidGFnTmFtZSI6ImRpdiIsInN1YlR5cGUiOiJ2YXIiLCJwcm9wZXJ0aWVzIjpbXX0seyJrZXkiOiJUYWJsZSA+IFJvdyA2ID4gSWNvbiBDb250YWluZXIgMiIsInZhbHVlIjoiIiwic2tpcElubmVySFRNTCI6dHJ1ZSwidGFnTmFtZSI6ImRpdiIsInN1YlR5cGUiOiJpbWciLCJwcm9wZXJ0aWVzIjpbeyJwcmVmaXgiOiJkZWZhdWx0IiwicHJvcGVydHkiOnsidHlwZSI6InNyYyIsInNyYyI6Ii4uL3B1YmxpYy9hc3NldHMvdGFibGUtY2hlY2suc3ZnIiwic3JjU2V0IjpbIiJdfX1dfSx7ImtleSI6IlRhYmxlID4gUm93IDYgPiBJbWFnZSAyIiwidmFsdWUiOiIiLCJza2lwSW5uZXJIVE1MIjp0cnVlLCJ0YWdOYW1lIjoiaW1nIiwic3ViVHlwZSI6ImltZyIsInByb3BlcnRpZXMiOlt7InByZWZpeCI6ImRlZmF1bHQiLCJwcm9wZXJ0eSI6eyJ0eXBlIjoic3JjIiwic3JjIjoiLi4vcHVibGljL2Fzc2V0cy90YWJsZS1jaGVjay5zdmciLCJzcmNTZXQiOlsiIl19fV19LHsia2V5IjoiVGFibGUgPiBSb3cgNiA+IFRleHQgMyIsInZhbHVlIjoibGFiZWwiLCJza2lwSW5uZXJIVE1MIjpmYWxzZSwidGFnTmFtZSI6InAiLCJzdWJUeXBlIjoidmFyIiwicHJvcGVydGllcyI6W119LHsia2V5IjoiVGFibGUgPiBSb3cgNyIsInZhbHVlIjoiIiwic2tpcElubmVySFRNTCI6dHJ1ZSwidGFnTmFtZSI6ImRpdiIsInN1YlR5cGUiOiJ2YXIiLCJwcm9wZXJ0aWVzIjpbXX0seyJrZXkiOiJUYWJsZSA+IFJvdyA3ID4gQ29sIExlZnQiLCJ2YWx1ZSI6IiIsInNraXBJbm5lckhUTUwiOnRydWUsInRhZ05hbWUiOiJkaXYiLCJzdWJUeXBlIjoidmFyIiwicHJvcGVydGllcyI6W119LHsia2V5IjoiVGFibGUgPiBSb3cgNyA+IFRleHQiLCJ2YWx1ZSI6IkxhYmVsIDYiLCJza2lwSW5uZXJIVE1MIjpmYWxzZSwidGFnTmFtZSI6InAiLCJzdWJUeXBlIjoidmFyIiwicHJvcGVydGllcyI6W119LHsia2V5IjoiVGFibGUgPiBSb3cgNyA+IENvbCBSaWdodCIsInZhbHVlIjoiIiwic2tpcElubmVySFRNTCI6dHJ1ZSwidGFnTmFtZSI6ImRpdiIsInN1YlR5cGUiOiJ2YXIiLCJwcm9wZXJ0aWVzIjpbXX0seyJrZXkiOiJUYWJsZSA+IFJvdyA3ID4gSWNvbiBDb250YWluZXIiLCJ2YWx1ZSI6IiIsInNraXBJbm5lckhUTUwiOnRydWUsInRhZ05hbWUiOiJkaXYiLCJzdWJUeXBlIjoiaW1nIiwicHJvcGVydGllcyI6W3sicHJlZml4IjoiZGVmYXVsdCIsInByb3BlcnR5Ijp7InR5cGUiOiJzcmMiLCJzcmMiOiIuLi9wdWJsaWMvYXNzZXRzL3RhYmxlLWxvY2suc3ZnIiwic3JjU2V0IjpbIiJdfX1dfSx7ImtleSI6IlRhYmxlID4gUm93IDcgPiBJbWFnZSIsInZhbHVlIjoiIiwic2tpcElubmVySFRNTCI6dHJ1ZSwidGFnTmFtZSI6ImltZyIsInN1YlR5cGUiOiJpbWciLCJwcm9wZXJ0aWVzIjpbeyJwcmVmaXgiOiJkZWZhdWx0IiwicHJvcGVydHkiOnsidHlwZSI6InNyYyIsInNyYyI6Ii4uL3B1YmxpYy9hc3NldHMvdGFibGUtbG9jay5zdmciLCJzcmNTZXQiOlsiIl19fV19LHsia2V5IjoiVGFibGUgPiBSb3cgNyA+IFRleHQgMiIsInZhbHVlIjoibGFiZWwiLCJza2lwSW5uZXJIVE1MIjpmYWxzZSwidGFnTmFtZSI6InAiLCJzdWJUeXBlIjoidmFyIiwicHJvcGVydGllcyI6W119LHsia2V5IjoiVGFibGUgPiBSb3cgNyA+IENvbCBSaWdodCAyIiwidmFsdWUiOiIiLCJza2lwSW5uZXJIVE1MIjp0cnVlLCJ0YWdOYW1lIjoiZGl2Iiwic3ViVHlwZSI6InZhciIsInByb3BlcnRpZXMiOltdfSx7ImtleSI6IlRhYmxlID4gUm93IDcgPiBJY29uIENvbnRhaW5lciAyIiwidmFsdWUiOiIiLCJza2lwSW5uZXJIVE1MIjp0cnVlLCJ0YWdOYW1lIjoiZGl2Iiwic3ViVHlwZSI6ImltZyIsInByb3BlcnRpZXMiOlt7InByZWZpeCI6ImRlZmF1bHQiLCJwcm9wZXJ0eSI6eyJ0eXBlIjoic3JjIiwic3JjIjoiLi4vcHVibGljL2Fzc2V0cy90YWJsZS1jaGVjay5zdmciLCJzcmNTZXQiOlsiIl19fV19LHsia2V5IjoiVGFibGUgPiBSb3cgNyA+IEltYWdlIDIiLCJ2YWx1ZSI6IiIsInNraXBJbm5lckhUTUwiOnRydWUsInRhZ05hbWUiOiJpbWciLCJzdWJUeXBlIjoiaW1nIiwicHJvcGVydGllcyI6W3sicHJlZml4IjoiZGVmYXVsdCIsInByb3BlcnR5Ijp7InR5cGUiOiJzcmMiLCJzcmMiOiIuLi9wdWJsaWMvYXNzZXRzL3RhYmxlLWNoZWNrLnN2ZyIsInNyY1NldCI6WyIiXX19XX0seyJrZXkiOiJUYWJsZSA+IFJvdyA3ID4gVGV4dCAzIiwidmFsdWUiOiJsYWJlbCIsInNraXBJbm5lckhUTUwiOmZhbHNlLCJ0YWdOYW1lIjoicCIsInN1YlR5cGUiOiJ2YXIiLCJwcm9wZXJ0aWVzIjpbXX0seyJrZXkiOiJUaXRsZSAyIiwidmFsdWUiOiIiLCJza2lwSW5uZXJIVE1MIjp0cnVlLCJ0YWdOYW1lIjoiZGl2Iiwic3ViVHlwZSI6InZhciIsInByb3BlcnRpZXMiOltdfSx7ImtleSI6IlRpdGxlIDIgPiBUZXh0IiwidmFsdWUiOiJUaGlzIGlzIGEgdGl0bGUiLCJza2lwSW5uZXJIVE1MIjpmYWxzZSwidGFnTmFtZSI6ImgzIiwic3ViVHlwZSI6InZhciIsInByb3BlcnRpZXMiOltdfSx7ImtleSI6IlJhdGluZyIsInZhbHVlIjoiIiwic2tpcElubmVySFRNTCI6dHJ1ZSwidGFnTmFtZSI6ImRpdiIsInN1YlR5cGUiOiJ2YXIiLCJwcm9wZXJ0aWVzIjpbXX0seyJrZXkiOiJSYXRpbmcgPiBUaXRsZSIsInZhbHVlIjoiNC45Iiwic2tpcElubmVySFRNTCI6ZmFsc2UsInRhZ05hbWUiOiJkaXYiLCJzdWJUeXBlIjoidmFyIiwicHJvcGVydGllcyI6W119LHsia2V5IjoiUmF0aW5nID4gQ29udGVudCIsInZhbHVlIjoiIiwic2tpcElubmVySFRNTCI6dHJ1ZSwidGFnTmFtZSI6ImRpdiIsInN1YlR5cGUiOiJ2YXIiLCJwcm9wZXJ0aWVzIjpbXX0seyJrZXkiOiJSYXRpbmcgPiBTdGFyIENvbnRhaW5lciIsInZhbHVlIjoiIiwic2tpcElubmVySFRNTCI6dHJ1ZSwidGFnTmFtZSI6ImRpdiIsInN1YlR5cGUiOiJ2YXIiLCJwcm9wZXJ0aWVzIjpbXX0seyJrZXkiOiJSYXRpbmcgPiBDYXB0aW9uIiwidmFsdWUiOiJCYXNlZCBvbiAyMDAsMDAwIHJldmlld3MiLCJza2lwSW5uZXJIVE1MIjpmYWxzZSwidGFnTmFtZSI6InAiLCJzdWJUeXBlIjoidmFyIiwicHJvcGVydGllcyI6W119LHsia2V5IjoiVGl0bGUgMyIsInZhbHVlIjoiIiwic2tpcElubmVySFRNTCI6dHJ1ZSwidGFnTmFtZSI6ImRpdiIsInN1YlR5cGUiOiJ2YXIiLCJwcm9wZXJ0aWVzIjpbXX0seyJrZXkiOiJUaXRsZSAzID4gVGV4dCIsInZhbHVlIjoiVGhpcyBpcyBhIHRpdGxlIiwic2tpcElubmVySFRNTCI6ZmFsc2UsInRhZ05hbWUiOiJoMyIsInN1YlR5cGUiOiJ2YXIiLCJwcm9wZXJ0aWVzIjpbXX0seyJrZXkiOiJSZXZpZXciLCJ2YWx1ZSI6IiIsInNraXBJbm5lckhUTUwiOnRydWUsInRhZ05hbWUiOiJkaXYiLCJzdWJUeXBlIjoidmFyIiwicHJvcGVydGllcyI6W119LHsia2V5IjoiUmV2aWV3ID4gQ29udGFpbmVyIiwidmFsdWUiOiIiLCJza2lwSW5uZXJIVE1MIjp0cnVlLCJ0YWdOYW1lIjoiZGl2Iiwic3ViVHlwZSI6InZhciIsInByb3BlcnRpZXMiOltdfSx7ImtleSI6IlJldmlldyA+IEhlYWRlciBDb250YWluZXIiLCJ2YWx1ZSI6IiIsInNraXBJbm5lckhUTUwiOnRydWUsInRhZ05hbWUiOiJkaXYiLCJzdWJUeXBlIjoidmFyIiwicHJvcGVydGllcyI6W119LHsia2V5IjoiUmV2aWV3ID4gVGl0bGUgQ29udGFpbmVyIiwidmFsdWUiOiIiLCJza2lwSW5uZXJIVE1MIjp0cnVlLCJ0YWdOYW1lIjoiZGl2Iiwic3ViVHlwZSI6InZhciIsInByb3BlcnRpZXMiOltdfSx7ImtleSI6IlJldmlldyA+IFRpdGxlIiwidmFsdWUiOiJCZXN0IGFwcCBldmVyLiIsInNraXBJbm5lckhUTUwiOmZhbHNlLCJ0YWdOYW1lIjoicCIsInN1YlR5cGUiOiJ2YXIiLCJwcm9wZXJ0aWVzIjpbXX0seyJrZXkiOiJSZXZpZXcgPiBJbmZvIiwidmFsdWUiOiIiLCJza2lwSW5uZXJIVE1MIjp0cnVlLCJ0YWdOYW1lIjoiZGl2Iiwic3ViVHlwZSI6InZhciIsInByb3BlcnRpZXMiOltdfSx7ImtleSI6IlJldmlldyA+IFN0YXIgQ29udGFpbmVyIiwidmFsdWUiOiIiLCJza2lwSW5uZXJIVE1MIjp0cnVlLCJ0YWdOYW1lIjoiZGl2Iiwic3ViVHlwZSI6InZhciIsInByb3BlcnRpZXMiOltdfSx7ImtleSI6IlJldmlldyA+IEJvZHkiLCJ2YWx1ZSI6IkluIGxhYm9yZSBkZXNlcnVudCBjdWxwYSBxdWkgZXggYW1ldCBhbGlxdWEgYWxpcXVpcCBub24gbnVsbGEgYWQgY3VwaWRhdGF0XG4gICAgICBpcHN1bSBxdWlzIGR1aXMuIiwic2tpcElubmVySFRNTCI6ZmFsc2UsInRhZ05hbWUiOiJwIiwic3ViVHlwZSI6InZhciIsInByb3BlcnRpZXMiOltdfSx7ImtleSI6IlJldmlldyAyIiwidmFsdWUiOiIiLCJza2lwSW5uZXJIVE1MIjp0cnVlLCJ0YWdOYW1lIjoiZGl2Iiwic3ViVHlwZSI6InZhciIsInByb3BlcnRpZXMiOltdfSx7ImtleSI6IlJldmlldyAyID4gQ29udGFpbmVyIiwidmFsdWUiOiIiLCJza2lwSW5uZXJIVE1MIjp0cnVlLCJ0YWdOYW1lIjoiZGl2Iiwic3ViVHlwZSI6InZhciIsInByb3BlcnRpZXMiOltdfSx7ImtleSI6IlJldmlldyAyID4gSGVhZGVyIENvbnRhaW5lciIsInZhbHVlIjoiIiwic2tpcElubmVySFRNTCI6dHJ1ZSwidGFnTmFtZSI6ImRpdiIsInN1YlR5cGUiOiJ2YXIiLCJwcm9wZXJ0aWVzIjpbXX0seyJrZXkiOiJSZXZpZXcgMiA+IFRpdGxlIENvbnRhaW5lciIsInZhbHVlIjoiIiwic2tpcElubmVySFRNTCI6dHJ1ZSwidGFnTmFtZSI6ImRpdiIsInN1YlR5cGUiOiJ2YXIiLCJwcm9wZXJ0aWVzIjpbXX0seyJrZXkiOiJSZXZpZXcgMiA+IFRpdGxlIiwidmFsdWUiOiJCZXN0IGFwcCBldmVyLiIsInNraXBJbm5lckhUTUwiOmZhbHNlLCJ0YWdOYW1lIjoicCIsInN1YlR5cGUiOiJ2YXIiLCJwcm9wZXJ0aWVzIjpbXX0seyJrZXkiOiJSZXZpZXcgMiA+IEluZm8iLCJ2YWx1ZSI6IiIsInNraXBJbm5lckhUTUwiOnRydWUsInRhZ05hbWUiOiJkaXYiLCJzdWJUeXBlIjoidmFyIiwicHJvcGVydGllcyI6W119LHsia2V5IjoiUmV2aWV3IDIgPiBTdGFyIENvbnRhaW5lciIsInZhbHVlIjoiIiwic2tpcElubmVySFRNTCI6dHJ1ZSwidGFnTmFtZSI6ImRpdiIsInN1YlR5cGUiOiJ2YXIiLCJwcm9wZXJ0aWVzIjpbXX0seyJrZXkiOiJSZXZpZXcgMiA+IEJvZHkiLCJ2YWx1ZSI6IkluIGxhYm9yZSBkZXNlcnVudCBjdWxwYSBxdWkgZXggYW1ldCBhbGlxdWEgYWxpcXVpcCBub24gbnVsbGEgYWQgY3VwaWRhdGF0XG4gICAgICBpcHN1bSBxdWlzIGR1aXMuIiwic2tpcElubmVySFRNTCI6ZmFsc2UsInRhZ05hbWUiOiJwIiwic3ViVHlwZSI6InZhciIsInByb3BlcnRpZXMiOltdfSx7ImtleSI6IlJldmlldyAzIiwidmFsdWUiOiIiLCJza2lwSW5uZXJIVE1MIjp0cnVlLCJ0YWdOYW1lIjoiZGl2Iiwic3ViVHlwZSI6InZhciIsInByb3BlcnRpZXMiOltdfSx7ImtleSI6IlJldmlldyAzID4gQ29udGFpbmVyIiwidmFsdWUiOiIiLCJza2lwSW5uZXJIVE1MIjp0cnVlLCJ0YWdOYW1lIjoiZGl2Iiwic3ViVHlwZSI6InZhciIsInByb3BlcnRpZXMiOltdfSx7ImtleSI6IlJldmlldyAzID4gSGVhZGVyIENvbnRhaW5lciIsInZhbHVlIjoiIiwic2tpcElubmVySFRNTCI6dHJ1ZSwidGFnTmFtZSI6ImRpdiIsInN1YlR5cGUiOiJ2YXIiLCJwcm9wZXJ0aWVzIjpbXX0seyJrZXkiOiJSZXZpZXcgMyA+IFRpdGxlIENvbnRhaW5lciIsInZhbHVlIjoiIiwic2tpcElubmVySFRNTCI6dHJ1ZSwidGFnTmFtZSI6ImRpdiIsInN1YlR5cGUiOiJ2YXIiLCJwcm9wZXJ0aWVzIjpbXX0seyJrZXkiOiJSZXZpZXcgMyA+IFRpdGxlIiwidmFsdWUiOiJCZXN0IGFwcCBldmVyLiIsInNraXBJbm5lckhUTUwiOmZhbHNlLCJ0YWdOYW1lIjoicCIsInN1YlR5cGUiOiJ2YXIiLCJwcm9wZXJ0aWVzIjpbXX0seyJrZXkiOiJSZXZpZXcgMyA+IEluZm8iLCJ2YWx1ZSI6IiIsInNraXBJbm5lckhUTUwiOnRydWUsInRhZ05hbWUiOiJkaXYiLCJzdWJUeXBlIjoidmFyIiwicHJvcGVydGllcyI6W119LHsia2V5IjoiUmV2aWV3IDMgPiBTdGFyIENvbnRhaW5lciIsInZhbHVlIjoiIiwic2tpcElubmVySFRNTCI6dHJ1ZSwidGFnTmFtZSI6ImRpdiIsInN1YlR5cGUiOiJ2YXIiLCJwcm9wZXJ0aWVzIjpbXX0seyJrZXkiOiJSZXZpZXcgMyA+IEJvZHkiLCJ2YWx1ZSI6IkluIGxhYm9yZSBkZXNlcnVudCBjdWxwYSBxdWkgZXggYW1ldCBhbGlxdWEgYWxpcXVpcCBub24gbnVsbGEgYWQgY3VwaWRhdGF0XG4gICAgICBpcHN1bSBxdWlzIGR1aXMuIiwic2tpcElubmVySFRNTCI6ZmFsc2UsInRhZ05hbWUiOiJwIiwic3ViVHlwZSI6InZhciIsInByb3BlcnRpZXMiOltdfSx7ImtleSI6IlJldmlldyA0IiwidmFsdWUiOiIiLCJza2lwSW5uZXJIVE1MIjp0cnVlLCJ0YWdOYW1lIjoiZGl2Iiwic3ViVHlwZSI6InZhciIsInByb3BlcnRpZXMiOltdfSx7ImtleSI6IlJldmlldyA0ID4gQ29udGFpbmVyIiwidmFsdWUiOiIiLCJza2lwSW5uZXJIVE1MIjp0cnVlLCJ0YWdOYW1lIjoiZGl2Iiwic3ViVHlwZSI6InZhciIsInByb3BlcnRpZXMiOltdfSx7ImtleSI6IlJldmlldyA0ID4gSGVhZGVyIENvbnRhaW5lciIsInZhbHVlIjoiIiwic2tpcElubmVySFRNTCI6dHJ1ZSwidGFnTmFtZSI6ImRpdiIsInN1YlR5cGUiOiJ2YXIiLCJwcm9wZXJ0aWVzIjpbXX0seyJrZXkiOiJSZXZpZXcgNCA+IFRpdGxlIENvbnRhaW5lciIsInZhbHVlIjoiIiwic2tpcElubmVySFRNTCI6dHJ1ZSwidGFnTmFtZSI6ImRpdiIsInN1YlR5cGUiOiJ2YXIiLCJwcm9wZXJ0aWVzIjpbXX0seyJrZXkiOiJSZXZpZXcgNCA+IFRpdGxlIiwidmFsdWUiOiJCZXN0IGFwcCBldmVyLiIsInNraXBJbm5lckhUTUwiOmZhbHNlLCJ0YWdOYW1lIjoicCIsInN1YlR5cGUiOiJ2YXIiLCJwcm9wZXJ0aWVzIjpbXX0seyJrZXkiOiJSZXZpZXcgNCA+IEluZm8iLCJ2YWx1ZSI6IiIsInNraXBJbm5lckhUTUwiOnRydWUsInRhZ05hbWUiOiJkaXYiLCJzdWJUeXBlIjoidmFyIiwicHJvcGVydGllcyI6W119LHsia2V5IjoiUmV2aWV3IDQgPiBTdGFyIENvbnRhaW5lciIsInZhbHVlIjoiIiwic2tpcElubmVySFRNTCI6dHJ1ZSwidGFnTmFtZSI6ImRpdiIsInN1YlR5cGUiOiJ2YXIiLCJwcm9wZXJ0aWVzIjpbXX0seyJrZXkiOiJSZXZpZXcgNCA+IEJvZHkiLCJ2YWx1ZSI6IkluIGxhYm9yZSBkZXNlcnVudCBjdWxwYSBxdWkgZXggYW1ldCBhbGlxdWEgYWxpcXVpcCBub24gbnVsbGEgYWQgY3VwaWRhdGF0XG4gICAgICBpcHN1bSBxdWlzIGR1aXMuIiwic2tpcElubmVySFRNTCI6ZmFsc2UsInRhZ05hbWUiOiJwIiwic3ViVHlwZSI6InZhciIsInByb3BlcnRpZXMiOltdfSx7ImtleSI6IlRpdGxlIDQiLCJ2YWx1ZSI6IiIsInNraXBJbm5lckhUTUwiOnRydWUsInRhZ05hbWUiOiJkaXYiLCJzdWJUeXBlIjoidmFyIiwicHJvcGVydGllcyI6W119LHsia2V5IjoiVGl0bGUgNCA+IFRleHQiLCJ2YWx1ZSI6IlRoaXMgaXMgYSB0aXRsZSIsInNraXBJbm5lckhUTUwiOmZhbHNlLCJ0YWdOYW1lIjoiaDMiLCJzdWJUeXBlIjoidmFyIiwicHJvcGVydGllcyI6W119LHsia2V5IjoiRHJvcGRvd24iLCJ2YWx1ZSI6IiIsInNraXBJbm5lckhUTUwiOnRydWUsInRhZ05hbWUiOiJkaXYiLCJzdWJUeXBlIjoidmFyIiwicHJvcGVydGllcyI6W119LHsia2V5IjoiRHJvcGRvd24gPiBDYXJkIiwidmFsdWUiOiIiLCJza2lwSW5uZXJIVE1MIjp0cnVlLCJ0YWdOYW1lIjoiZGl2Iiwic3ViVHlwZSI6InZhciIsInByb3BlcnRpZXMiOltdfSx7ImtleSI6IkRyb3Bkb3duID4gVG9wIiwidmFsdWUiOiIiLCJza2lwSW5uZXJIVE1MIjp0cnVlLCJ0YWdOYW1lIjoiZGl2Iiwic3ViVHlwZSI6InZhciIsInByb3BlcnRpZXMiOltdfSx7ImtleSI6IkRyb3Bkb3duID4gUXVlc3Rpb24iLCJ2YWx1ZSI6IlRoaXMgaXMgYSBkcm9wZG93bidzIHRpdGxlLiIsInNraXBJbm5lckhUTUwiOmZhbHNlLCJ0YWdOYW1lIjoicCIsInN1YlR5cGUiOiJ2YXIiLCJwcm9wZXJ0aWVzIjpbXX0seyJrZXkiOiJEcm9wZG93biA+IEljb24iLCJ2YWx1ZSI6IiIsInNraXBJbm5lckhUTUwiOnRydWUsInRhZ05hbWUiOiJkaXYiLCJzdWJUeXBlIjoidmFyIiwicHJvcGVydGllcyI6W119LHsia2V5IjoiRHJvcGRvd24gPiBCb3R0b20iLCJ2YWx1ZSI6IiIsInNraXBJbm5lckhUTUwiOnRydWUsInRhZ05hbWUiOiJkaXYiLCJzdWJUeXBlIjoidmFyIiwicHJvcGVydGllcyI6W119LHsia2V5IjoiRHJvcGRvd24gPiBBbnN3ZXIiLCJ2YWx1ZSI6IlByb2lkZW50IGV4IGV4ZXJjaXRhdGlvbiBkbyBlaXVzbW9kIG5vbi4gTG9yZW0gZXNzZSBlaXVzbW9kIGRvIG9jY2FlY2F0LlxuICAgICAgICBOdWxsYSBjdWxwYSBudWxsYSB1dCBudWxsYSBpcnVyZSBhbGlxdWlwIGFsaXF1aXAgYW1ldCB1dCBtaW5pbSBldSBlc3QuIiwic2tpcElubmVySFRNTCI6ZmFsc2UsInRhZ05hbWUiOiJwIiwic3ViVHlwZSI6InZhciIsInByb3BlcnRpZXMiOltdfSx7ImtleSI6IkRyb3Bkb3duIDIiLCJ2YWx1ZSI6IiIsInNraXBJbm5lckhUTUwiOnRydWUsInRhZ05hbWUiOiJkaXYiLCJzdWJUeXBlIjoidmFyIiwicHJvcGVydGllcyI6W119LHsia2V5IjoiRHJvcGRvd24gMiA+IENhcmQiLCJ2YWx1ZSI6IiIsInNraXBJbm5lckhUTUwiOnRydWUsInRhZ05hbWUiOiJkaXYiLCJzdWJUeXBlIjoidmFyIiwicHJvcGVydGllcyI6W119LHsia2V5IjoiRHJvcGRvd24gMiA+IFRvcCIsInZhbHVlIjoiIiwic2tpcElubmVySFRNTCI6dHJ1ZSwidGFnTmFtZSI6ImRpdiIsInN1YlR5cGUiOiJ2YXIiLCJwcm9wZXJ0aWVzIjpbXX0seyJrZXkiOiJEcm9wZG93biAyID4gUXVlc3Rpb24iLCJ2YWx1ZSI6IlRoaXMgaXMgYSBkcm9wZG93bidzIHRpdGxlLiIsInNraXBJbm5lckhUTUwiOmZhbHNlLCJ0YWdOYW1lIjoicCIsInN1YlR5cGUiOiJ2YXIiLCJwcm9wZXJ0aWVzIjpbXX0seyJrZXkiOiJEcm9wZG93biAyID4gSWNvbiIsInZhbHVlIjoiIiwic2tpcElubmVySFRNTCI6dHJ1ZSwidGFnTmFtZSI6ImRpdiIsInN1YlR5cGUiOiJ2YXIiLCJwcm9wZXJ0aWVzIjpbXX0seyJrZXkiOiJEcm9wZG93biAyID4gQm90dG9tIiwidmFsdWUiOiIiLCJza2lwSW5uZXJIVE1MIjp0cnVlLCJ0YWdOYW1lIjoiZGl2Iiwic3ViVHlwZSI6InZhciIsInByb3BlcnRpZXMiOltdfSx7ImtleSI6IkRyb3Bkb3duIDIgPiBBbnN3ZXIiLCJ2YWx1ZSI6IlByb2lkZW50IGV4IGV4ZXJjaXRhdGlvbiBkbyBlaXVzbW9kIG5vbi4gTG9yZW0gZXNzZSBlaXVzbW9kIGRvIG9jY2FlY2F0LlxuICAgICAgICBOdWxsYSBjdWxwYSBudWxsYSB1dCBudWxsYSBpcnVyZSBhbGlxdWlwIGFsaXF1aXAgYW1ldCB1dCBtaW5pbSBldSBlc3QuIiwic2tpcElubmVySFRNTCI6ZmFsc2UsInRhZ05hbWUiOiJwIiwic3ViVHlwZSI6InZhciIsInByb3BlcnRpZXMiOltdfSx7ImtleSI6IkRyb3Bkb3duIDMiLCJ2YWx1ZSI6IiIsInNraXBJbm5lckhUTUwiOnRydWUsInRhZ05hbWUiOiJkaXYiLCJzdWJUeXBlIjoidmFyIiwicHJvcGVydGllcyI6W119LHsia2V5IjoiRHJvcGRvd24gMyA+IENhcmQiLCJ2YWx1ZSI6IiIsInNraXBJbm5lckhUTUwiOnRydWUsInRhZ05hbWUiOiJkaXYiLCJzdWJUeXBlIjoidmFyIiwicHJvcGVydGllcyI6W119LHsia2V5IjoiRHJvcGRvd24gMyA+IFRvcCIsInZhbHVlIjoiIiwic2tpcElubmVySFRNTCI6dHJ1ZSwidGFnTmFtZSI6ImRpdiIsInN1YlR5cGUiOiJ2YXIiLCJwcm9wZXJ0aWVzIjpbXX0seyJrZXkiOiJEcm9wZG93biAzID4gUXVlc3Rpb24iLCJ2YWx1ZSI6IlRoaXMgaXMgYSBkcm9wZG93bidzIHRpdGxlLiIsInNraXBJbm5lckhUTUwiOmZhbHNlLCJ0YWdOYW1lIjoicCIsInN1YlR5cGUiOiJ2YXIiLCJwcm9wZXJ0aWVzIjpbXX0seyJrZXkiOiJEcm9wZG93biAzID4gSWNvbiIsInZhbHVlIjoiIiwic2tpcElubmVySFRNTCI6dHJ1ZSwidGFnTmFtZSI6ImRpdiIsInN1YlR5cGUiOiJ2YXIiLCJwcm9wZXJ0aWVzIjpbXX0seyJrZXkiOiJEcm9wZG93biAzID4gQm90dG9tIiwidmFsdWUiOiIiLCJza2lwSW5uZXJIVE1MIjp0cnVlLCJ0YWdOYW1lIjoiZGl2Iiwic3ViVHlwZSI6InZhciIsInByb3BlcnRpZXMiOltdfSx7ImtleSI6IkRyb3Bkb3duIDMgPiBBbnN3ZXIiLCJ2YWx1ZSI6IlByb2lkZW50IGV4IGV4ZXJjaXRhdGlvbiBkbyBlaXVzbW9kIG5vbi4gTG9yZW0gZXNzZSBlaXVzbW9kIGRvIG9jY2FlY2F0LlxuICAgICAgIE51bGxhIGN1bHBhIG51bGxhIHV0IG51bGxhIGlydXJlIGFsaXF1aXAgYWxpcXVpcCBhbWV0IHV0IG1pbmltIGV1IGVzdC4iLCJza2lwSW5uZXJIVE1MIjpmYWxzZSwidGFnTmFtZSI6InAiLCJzdWJUeXBlIjoidmFyIiwicHJvcGVydGllcyI6W119LHsia2V5IjoicHVyY2hhc2UtcHJpbWFyeSIsInZhbHVlIjoiUHVyY2hhc2UgUHJpbWFyeSIsInNraXBJbm5lckhUTUwiOmZhbHNlLCJ0YWdOYW1lIjoiZGl2Iiwic3ViVHlwZSI6InZhciIsInByb3BlcnRpZXMiOlt7InByZWZpeCI6ImRlZmF1bHQiLCJwcm9wZXJ0eSI6eyJ0eXBlIjoiY2xpY2stYmVoYXZpb3IiLCJjbGlja0JlaGF2aW9yIjp7InR5cGUiOiJwdXJjaGFzZSIsInByb2R1Y3QiOiJwcmltYXJ5In19fV19LHsia2V5IjoicHVyY2hhc2Utc2Vjb25kYXJ5IiwidmFsdWUiOiJQdXJjaGFzZSBTZWNvbmRhcnkiLCJza2lwSW5uZXJIVE1MIjpmYWxzZSwidGFnTmFtZSI6ImRpdiIsInN1YlR5cGUiOiJ2YXIiLCJwcm9wZXJ0aWVzIjpbeyJwcmVmaXgiOiJkZWZhdWx0IiwicHJvcGVydHkiOnsidHlwZSI6ImNsaWNrLWJlaGF2aW9yIiwiY2xpY2tCZWhhdmlvciI6eyJ0eXBlIjoicHVyY2hhc2UiLCJwcm9kdWN0Ijoic2Vjb25kYXJ5In19fV19LHsia2V5IjoicHVyY2hhc2UtdGVydGlhcnkiLCJ2YWx1ZSI6IlB1cmNoYXNlIFRlcnRpYXJ5Iiwic2tpcElubmVySFRNTCI6ZmFsc2UsInRhZ05hbWUiOiJkaXYiLCJzdWJUeXBlIjoidmFyIiwicHJvcGVydGllcyI6W3sicHJlZml4IjoiZGVmYXVsdCIsInByb3BlcnR5Ijp7InR5cGUiOiJjbGljay1iZWhhdmlvciIsImNsaWNrQmVoYXZpb3IiOnsidHlwZSI6InB1cmNoYXNlIiwicHJvZHVjdCI6InRlcnRpYXJ5In19fV19XX0seyJldmVudF9uYW1lIjoicGFnZV9zdHlsZXMiLCJwYWdlU3R5bGVzIjpbXX1d","substitutions":[],"feature_gating":"NON_GATED","products":[{"product": "primary", "productId": "abc-def", "product_id": "abc-def"}],"presentation_condition":"CHECK_USER_SUBSCRIPTION","presentation_style":"FULLSCREEN","presentation_style_v2":"FULLSCREEN","launch_option":"EXPLICIT","dismissal_option":"NORMAL","background_color_hex":"#FFFFFF"}],"log_level":10,"localization":{"locales":[{"locale":"de"},{"locale":"es"},{"locale":"fr"},{"locale":"hk"},{"locale":"hr"},{"locale":"ka"},{"locale":"zh"}]},"postback":{"delay":5000,"products":[]},"app_session_timeout_ms":3600000,"tests":{"dns_resolution":[]},"disable_preload":{"all":false,"triggers":[]}} """.trimIndent() diff --git a/superwall/src/test/java/com/superwall/sdk/models/paywall/PaywallProductTest.kt b/superwall/src/test/java/com/superwall/sdk/models/paywall/PaywallProductTest.kt index 5db75253..4cf4ea55 100644 --- a/superwall/src/test/java/com/superwall/sdk/models/paywall/PaywallProductTest.kt +++ b/superwall/src/test/java/com/superwall/sdk/models/paywall/PaywallProductTest.kt @@ -12,7 +12,7 @@ class PaywallProductTest { @Test fun `test parsing of config`() { val productString = """ - {"product": "primary", "product_id": "abc-def"} + {"product": "primary", "product_id": "abc-def:ghi:jkl", "product_id_android": "abc-def:ghi:jkl"} """.trimIndent() @@ -22,7 +22,7 @@ class PaywallProductTest { } val product = json.decodeFromString(productString) assert(product != null) - assert(product.id == "abc-def") + assert(product.id == "abc-def:ghi:jkl") assert(product.type == ProductType.PRIMARY) } } From 5c4c7f7f26ad4a76cc2d57d31504283693de9a33 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= Date: Tue, 5 Dec 2023 11:49:35 -0500 Subject: [PATCH 15/23] Removed redundant parts of product tests --- .../sdk/products/ProductFetcherTest.kt | 59 ------------------- 1 file changed, 59 deletions(-) diff --git a/superwall/src/test/java/com/superwall/sdk/products/ProductFetcherTest.kt b/superwall/src/test/java/com/superwall/sdk/products/ProductFetcherTest.kt index 30df4a95..9ba3948b 100644 --- a/superwall/src/test/java/com/superwall/sdk/products/ProductFetcherTest.kt +++ b/superwall/src/test/java/com/superwall/sdk/products/ProductFetcherTest.kt @@ -4,7 +4,6 @@ import android.content.Context import com.android.billingclient.api.BillingClient.ProductType import com.android.billingclient.api.ProductDetails.OneTimePurchaseOfferDetails import com.android.billingclient.api.ProductDetails.RecurrenceMode -import com.android.billingclient.api.SkuDetails import com.superwall.sdk.billing.GoogleBillingWrapper import com.superwall.sdk.store.abstractions.product.OfferType import com.superwall.sdk.store.abstractions.product.RawStoreProduct @@ -21,64 +20,6 @@ import java.time.LocalDate import java.time.format.DateTimeFormatter import java.util.Locale -// Mock implementation of SkuDetails from Google Billing 4.0 - -val mockSku = """{ - "productId": "premium_subscription", - "type": "subs", - "price": "$9.99", - "price_amount_micros": 9990000, - "price_currency_code": "USD", - "title": "Premium Subscription", - "description": "Unlock all premium features with this subscription.", - "subscriptionPeriod": "P1M", - "freeTrialPeriod": "P7D", - "introductoryPrice": "$4.99", - "introductoryPriceCycles": 1, - "introductoryPrice_period": "P1M" -} -""" - -class MockSkuDetails(jsonDetails: String) : SkuDetails(jsonDetails) { - -} - -class ProductFetcherUnderTest( - context: Context, - billingWrapper: GoogleBillingWrapper -) : GooglePlayProductsFetcher( - context = context, - billingWrapper = billingWrapper -) { - // We're going to override the query async method to return a list of products - // that we define in the test -// var productIdsToReturn: Map> = emptyMap() -// var queryProductDetailsCalls: List> = emptyList() -// -// override suspend fun queryProductDetails(productIds: List): Map> { -// queryProductDetailsCalls = queryProductDetailsCalls + listOf(productIds) -// delay(1000 + (Math.random() * 1000).toLong()) -// -// // Filter productIdsToReturn, and add success if not found -// val result = productIds.map { productId -> -// val product = productIdsToReturn[productId] -// if (product != null) { -// productId to product -// } else { -// productId to Result.Success( -// RawStoreProduct( -// underlyingProductDetails = MockSkuDetails(mockSku) -// ) -// ) -// } -// }.toMap() -// return result -// } - -} - -// TODO: https://linear.app/superwall/issue/SW-2368/[android]-fix-product-fetcher-tests - class ProductFetcherInstrumentedTest { private val oneTimePurchaseProduct = mockProductDetails( productId = "pro_test_8999_year", From a8f79148bef14c684b3dc22bc06de831ab09434e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= Date: Tue, 5 Dec 2023 12:03:51 -0500 Subject: [PATCH 16/23] Removed runTest from ProductFetcherTest --- .../sdk/products/ProductFetcherTest.kt | 20 +++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/superwall/src/test/java/com/superwall/sdk/products/ProductFetcherTest.kt b/superwall/src/test/java/com/superwall/sdk/products/ProductFetcherTest.kt index 9ba3948b..8f39514a 100644 --- a/superwall/src/test/java/com/superwall/sdk/products/ProductFetcherTest.kt +++ b/superwall/src/test/java/com/superwall/sdk/products/ProductFetcherTest.kt @@ -279,7 +279,7 @@ class ProductFetcherInstrumentedTest { */ @Test - fun test_storeProduct_basePlan_withFreeTrialOffer() = runTest { + fun test_storeProduct_basePlan_withFreeTrialOffer() { // subscription + base plan + offer: Free Trial phase val storeProduct = StoreProduct( rawStoreProduct = RawStoreProduct( @@ -327,7 +327,7 @@ class ProductFetcherInstrumentedTest { } @Test - fun test_storeProduct_basePlan_withFreeTrialOfferAndPaid() = runTest { + fun test_storeProduct_basePlan_withFreeTrialOfferAndPaid() { // subscription + base plan + offer: Free Trial Phase + Paid Phase in one offer val storeProduct = StoreProduct( rawStoreProduct = RawStoreProduct( @@ -375,7 +375,7 @@ class ProductFetcherInstrumentedTest { } @Test - fun test_storeProduct_basePlan_withPaidOffer() = runTest { + fun test_storeProduct_basePlan_withPaidOffer() { // subscription + base plan + offer: Free Trial phase val storeProduct = StoreProduct( rawStoreProduct = RawStoreProduct( @@ -423,7 +423,7 @@ class ProductFetcherInstrumentedTest { } @Test - fun test_storeProduct_basePlan_autoOffer_threeFreeTrials() = runTest { + fun test_storeProduct_basePlan_autoOffer_threeFreeTrials() { // subscription + base plan + auto-offer: one with free trial 1 year but with sw-ignore-offer tag, // one with free trial 1 month, <- Chooses this one // one with free trial 1 week @@ -473,7 +473,7 @@ class ProductFetcherInstrumentedTest { } @Test - fun test_storeProduct_basePlan_autoOffer_twoPaidOffers() = runTest { + fun test_storeProduct_basePlan_autoOffer_twoPaidOffers() { // subscription + base plan + auto-offer: one with paid offer 5.99, // one with paid offer 1.99 <- chooses this one val storeProduct = StoreProduct( @@ -524,7 +524,7 @@ class ProductFetcherInstrumentedTest { } @Test - fun test_storeProduct_basePlan_autoOffer_noOffers() = runTest { + fun test_storeProduct_basePlan_autoOffer_noOffers() { // subscription + base plan + auto-offer val storeProduct = StoreProduct( rawStoreProduct = RawStoreProduct( @@ -567,7 +567,7 @@ class ProductFetcherInstrumentedTest { } @Test - fun test_storeProduct_basePlan() = runTest { + fun test_storeProduct_basePlan() { // subscription + base plan val storeProduct = StoreProduct( rawStoreProduct = RawStoreProduct( @@ -610,7 +610,7 @@ class ProductFetcherInstrumentedTest { } @Test - fun test_storeProduct_basePlan_invalidOfferId() = runTest { + fun test_storeProduct_basePlan_invalidOfferId() { // subscription + base plan + offer: Offer not found val storeProduct = StoreProduct( rawStoreProduct = RawStoreProduct( @@ -653,7 +653,7 @@ class ProductFetcherInstrumentedTest { } @Test - fun test_storeProduct_subscriptionOnly() = runTest { + fun test_storeProduct_subscriptionOnly() { // subscription // Note: This returns the wrong one. We expect 18.99, as that's the backwards compatible one // However, there's no way of us knowing this so we just pick the first base plan we come @@ -699,7 +699,7 @@ class ProductFetcherInstrumentedTest { } @Test - fun test_storeProduct_oneTimePurchase() = runTest { + fun test_storeProduct_oneTimePurchase() { // One-time purchase val storeProduct = StoreProduct( rawStoreProduct = RawStoreProduct( From 0dbad2922d6fe5887a5d480c4c0c4e0312d2d769 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= Date: Tue, 5 Dec 2023 12:16:20 -0500 Subject: [PATCH 17/23] Update to use java 8 instead of 17 in github actions --- .github/workflows/build+test.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/build+test.yml b/.github/workflows/build+test.yml index b76d772d..8b0c2082 100644 --- a/.github/workflows/build+test.yml +++ b/.github/workflows/build+test.yml @@ -29,7 +29,7 @@ jobs: uses: actions/setup-java@v3 with: distribution: 'zulu' # See 'Supported distributions' for available options - java-version: '17' + java-version: '8' cache: 'gradle' # Allow us to run the command From 8fdbb704a034e03c8bd16cb6e927c12ae3701dde Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= Date: Tue, 5 Dec 2023 12:24:00 -0500 Subject: [PATCH 18/23] Try java 11 in github actions... --- .github/workflows/build+test.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/build+test.yml b/.github/workflows/build+test.yml index 8b0c2082..aa9cd48c 100644 --- a/.github/workflows/build+test.yml +++ b/.github/workflows/build+test.yml @@ -29,7 +29,7 @@ jobs: uses: actions/setup-java@v3 with: distribution: 'zulu' # See 'Supported distributions' for available options - java-version: '8' + java-version: '11' cache: 'gradle' # Allow us to run the command From f9ec3aeaba85af5bbae9b6b4395d346dcec8061a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= Date: Tue, 5 Dec 2023 13:10:13 -0500 Subject: [PATCH 19/23] Reverting to java 17 in github action --- .github/workflows/build+test.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/build+test.yml b/.github/workflows/build+test.yml index aa9cd48c..b76d772d 100644 --- a/.github/workflows/build+test.yml +++ b/.github/workflows/build+test.yml @@ -29,7 +29,7 @@ jobs: uses: actions/setup-java@v3 with: distribution: 'zulu' # See 'Supported distributions' for available options - java-version: '11' + java-version: '17' cache: 'gradle' # Allow us to run the command From 6e189e3a2ef3c318fea2dc0530be743e094be65a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= Date: Tue, 5 Dec 2023 13:20:44 -0500 Subject: [PATCH 20/23] Change cache key --- .github/workflows/build+test.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/build+test.yml b/.github/workflows/build+test.yml index b76d772d..55db1d2e 100644 --- a/.github/workflows/build+test.yml +++ b/.github/workflows/build+test.yml @@ -30,7 +30,7 @@ jobs: with: distribution: 'zulu' # See 'Supported distributions' for available options java-version: '17' - cache: 'gradle' + cache: 'gradle-1' # Allow us to run the command - name: Change wrapper permissions From f2454d4e0119823f0160a5f49c376f92ccf47c25 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= Date: Tue, 5 Dec 2023 13:24:40 -0500 Subject: [PATCH 21/23] Update build+test.yml --- .github/workflows/build+test.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/build+test.yml b/.github/workflows/build+test.yml index 55db1d2e..b76d772d 100644 --- a/.github/workflows/build+test.yml +++ b/.github/workflows/build+test.yml @@ -30,7 +30,7 @@ jobs: with: distribution: 'zulu' # See 'Supported distributions' for available options java-version: '17' - cache: 'gradle-1' + cache: 'gradle' # Allow us to run the command - name: Change wrapper permissions From 8fd920f5618e65b51ec15196ea68ac8b86e67cfc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= Date: Tue, 5 Dec 2023 18:48:07 -0500 Subject: [PATCH 22/23] Makes all properties of storeproduct lazy and bug fix - Boosts performance so we don't have to compute offers every time. - Fixes issue where the offer ID that the user is purchasing would be nil if a user had selected sw-auto. --- .../purchase/RevenueCatPurchaseController.kt | 1 - .../abstractions/product/RawStoreProduct.kt | 565 +++++++++--------- .../store/transactions/TransactionManager.kt | 2 +- 3 files changed, 288 insertions(+), 280 deletions(-) diff --git a/app/src/main/java/com/superwall/superapp/purchase/RevenueCatPurchaseController.kt b/app/src/main/java/com/superwall/superapp/purchase/RevenueCatPurchaseController.kt index 659e9c04..cce071f9 100644 --- a/app/src/main/java/com/superwall/superapp/purchase/RevenueCatPurchaseController.kt +++ b/app/src/main/java/com/superwall/superapp/purchase/RevenueCatPurchaseController.kt @@ -123,7 +123,6 @@ class RevenueCatPurchaseController(val context: Context): PurchaseController, Up ?: products.firstOrNull() ?: return PurchaseResult.Failed("Product not found") - return when (product.type) { ProductType.SUBS, ProductType.UNKNOWN -> handleSubscription(activity, product, basePlanId, offerId) ProductType.INAPP -> handleInAppPurchase(activity, product) diff --git a/superwall/src/main/java/com/superwall/sdk/store/abstractions/product/RawStoreProduct.kt b/superwall/src/main/java/com/superwall/sdk/store/abstractions/product/RawStoreProduct.kt index e786fc7a..d53f99a2 100644 --- a/superwall/src/main/java/com/superwall/sdk/store/abstractions/product/RawStoreProduct.kt +++ b/superwall/src/main/java/com/superwall/sdk/store/abstractions/product/RawStoreProduct.kt @@ -20,235 +20,247 @@ class RawStoreProduct( val underlyingProductDetails: ProductDetails, override val fullIdentifier: String, val basePlanId: String?, - val offerType: OfferType? + private val offerType: OfferType? ) : StoreProductType { @Transient private val priceFormatterProvider = PriceFormatterProvider() - private val priceFormatter: NumberFormat? - get() = currencyCode?.let { + private val priceFormatter by lazy { + currencyCode?.let { priceFormatterProvider.priceFormatter(it) } + } - override val productIdentifier: String - get() = underlyingProductDetails.productId + internal val offerId: String? by lazy { + selectedOffer?.offerId + } - override val price: BigDecimal - get() { - underlyingProductDetails.oneTimePurchaseOfferDetails?.let { offerDetails -> - return BigDecimal(offerDetails.priceAmountMicros).divide(BigDecimal(1_000_000), 2, RoundingMode.DOWN) - } + private val selectedOffer: SubscriptionOfferDetails? by lazy { + getSelectedOfferDetails() + } - return basePriceForSelectedOffer() - } + private val basePriceForSelectedOffer by lazy { + val selectedOffer = selectedOffer ?: return@lazy BigDecimal.ZERO + val pricingPhase = selectedOffer.pricingPhases.pricingPhaseList.last().priceAmountMicros + BigDecimal(pricingPhase).divide(BigDecimal(1_000_000), 2, RoundingMode.DOWN) + } - override val localizedPrice: String - get() { - return priceFormatter?.format(price) ?: "" - } + private val selectedOfferPricingPhase by lazy { + // Get the selected offer; return null if it's null. + val selectedOffer = selectedOffer ?: return@lazy null + + // Find the first free trial phase or discounted phase. + selectedOffer.pricingPhases.pricingPhaseList + .firstOrNull { it.priceAmountMicros == 0L } + ?: selectedOffer.pricingPhases.pricingPhaseList + .dropLast(1) + .firstOrNull { it.priceAmountMicros != 0L } + + } + + override val productIdentifier by lazy { + underlyingProductDetails.productId + } - override val localizedSubscriptionPeriod: String - get() = subscriptionPeriod?.let { + override val price by lazy { + underlyingProductDetails.oneTimePurchaseOfferDetails?.let { offerDetails -> + BigDecimal(offerDetails.priceAmountMicros).divide(BigDecimal(1_000_000), 2, RoundingMode.DOWN) + } ?: basePriceForSelectedOffer + } + + override val localizedPrice by lazy { + priceFormatter?.format(price) ?: "" + } + + override val localizedSubscriptionPeriod by lazy { + subscriptionPeriod?.let { AmountFormats.wordBased(it.toPeriod(), Locale.getDefault()) } ?: "" + } - override val period: String - get() { - return subscriptionPeriod?.let { - return when (it.unit) { - SubscriptionPeriod.Unit.day -> if (it.value == 7) "week" else "day" - SubscriptionPeriod.Unit.week -> "week" - SubscriptionPeriod.Unit.month -> when (it.value) { - 2 -> "2 months" - 3 -> "quarter" - 6 -> "6 months" - else -> "month" - } - SubscriptionPeriod.Unit.year -> "year" + override val period: String by lazy { + subscriptionPeriod?.let { + when (it.unit) { + SubscriptionPeriod.Unit.day -> if (it.value == 7) "week" else "day" + SubscriptionPeriod.Unit.week -> "week" + SubscriptionPeriod.Unit.month -> when (it.value) { + 2 -> "2 months" + 3 -> "quarter" + 6 -> "6 months" + else -> "month" } - } ?: "" - } - override val periodly: String - get() { - return subscriptionPeriod?.let { - return when (it.unit) { - SubscriptionPeriod.Unit.month -> when (it.value) { - 2, 6 -> "every $period" - else -> "${period}ly" - } + SubscriptionPeriod.Unit.year -> "year" + } + } ?: "" + } + + override val periodly: String by lazy { + subscriptionPeriod?.let { + when (it.unit) { + SubscriptionPeriod.Unit.month -> when (it.value) { + 2, 6 -> "every $period" else -> "${period}ly" } - } ?: "" - } + else -> "${period}ly" + } + } ?: "" + } - override val periodWeeks: Int - get() = subscriptionPeriod?.let { + override val periodWeeks by lazy { + subscriptionPeriod?.let { val numberOfUnits = it.value when (it.unit) { SubscriptionPeriod.Unit.day -> (1 * numberOfUnits) / 7 SubscriptionPeriod.Unit.week -> numberOfUnits SubscriptionPeriod.Unit.month -> 4 * numberOfUnits SubscriptionPeriod.Unit.year -> 52 * numberOfUnits - else -> 0 } } ?: 0 + } - override val periodWeeksString: String - get() = periodWeeks.toString() + override val periodWeeksString by lazy { + periodWeeks.toString() + } - override val periodMonths: Int - get() = subscriptionPeriod?.let { + override val periodMonths by lazy { + subscriptionPeriod?.let { val numberOfUnits = it.value when (it.unit) { SubscriptionPeriod.Unit.day -> numberOfUnits / 30 SubscriptionPeriod.Unit.week -> numberOfUnits / 4 SubscriptionPeriod.Unit.month -> numberOfUnits SubscriptionPeriod.Unit.year -> 12 * numberOfUnits - else -> 0 } } ?: 0 + } - override val periodMonthsString: String - get() = periodMonths.toString() + override val periodMonthsString: String by lazy { + periodMonths.toString() + } - override val periodYears: Int - get() = subscriptionPeriod?.let { + override val periodYears by lazy { + subscriptionPeriod?.let { val numberOfUnits = it.value when (it.unit) { SubscriptionPeriod.Unit.day -> numberOfUnits / 365 SubscriptionPeriod.Unit.week -> numberOfUnits / 52 SubscriptionPeriod.Unit.month -> numberOfUnits / 12 SubscriptionPeriod.Unit.year -> numberOfUnits - else -> 0 } } ?: 0 + } - override val periodYearsString: String - get() = periodYears.toString() - - override val periodDays: Int - get() { - return subscriptionPeriod?.let { - val numberOfUnits = it.value - - return when (it.unit) { - SubscriptionPeriod.Unit.day -> 1 * numberOfUnits - SubscriptionPeriod.Unit.month -> 30 * numberOfUnits // Assumes 30 days in a month - SubscriptionPeriod.Unit.week -> 7 * numberOfUnits // Assumes 7 days in a week - SubscriptionPeriod.Unit.year -> 365 * numberOfUnits // Assumes 365 days in a year - } - } ?: 0 - } - - override val periodDaysString: String - get() = periodDays.toString() - - override val dailyPrice: String - get() { - val basePrice = basePriceForSelectedOffer() + override val periodYearsString by lazy { + periodYears.toString() + } - if (basePrice == BigDecimal.ZERO) { - return priceFormatter?.format(BigDecimal.ZERO) ?: "$0.00" + override val periodDays by lazy { + subscriptionPeriod?.let { + val numberOfUnits = it.value + when (it.unit) { + SubscriptionPeriod.Unit.day -> 1 * numberOfUnits + SubscriptionPeriod.Unit.month -> 30 * numberOfUnits // Assumes 30 days in a month + SubscriptionPeriod.Unit.week -> 7 * numberOfUnits // Assumes 7 days in a week + SubscriptionPeriod.Unit.year -> 365 * numberOfUnits // Assumes 365 days in a year } + } ?: 0 + } - val subscriptionPeriod = this.subscriptionPeriod ?: return "n/a" + override val periodDaysString by lazy { + periodDays.toString() + } - val pricePerDay = subscriptionPeriod.pricePerDay(basePrice) + override val dailyPrice by lazy { + val basePrice = basePriceForSelectedOffer - return priceFormatter?.format(pricePerDay) ?: "n/a" + if (basePrice == BigDecimal.ZERO) { + return@lazy priceFormatter?.format(BigDecimal.ZERO) ?: "$0.00" } + val subscriptionPeriod = this.subscriptionPeriod ?: return@lazy "n/a" - override val weeklyPrice: String - get() { - val basePrice = basePriceForSelectedOffer() + val pricePerDay = subscriptionPeriod.pricePerDay(basePrice) - if (basePrice == BigDecimal.ZERO) { - return priceFormatter?.format(BigDecimal.ZERO) ?: "$0.00" - } + priceFormatter?.format(pricePerDay) ?: "n/a" + } + + override val weeklyPrice by lazy { + val basePrice = basePriceForSelectedOffer - val subscriptionPeriod = this.subscriptionPeriod ?: return "n/a" + if (basePrice == BigDecimal.ZERO) { + return@lazy priceFormatter?.format(BigDecimal.ZERO) ?: "$0.00" + } + val subscriptionPeriod = this.subscriptionPeriod ?: return@lazy "n/a" + val pricePerWeek = subscriptionPeriod.pricePerWeek(basePrice) + priceFormatter?.format(pricePerWeek) ?: "n/a" + } - val pricePerWeek = subscriptionPeriod.pricePerWeek(basePrice) + override val monthlyPrice by lazy { + val basePrice = basePriceForSelectedOffer - return priceFormatter?.format(pricePerWeek) ?: "n/a" + if (basePrice == BigDecimal.ZERO) { + return@lazy priceFormatter?.format(BigDecimal.ZERO) ?: "$0.00" } - override val monthlyPrice: String - get() { - val basePrice = basePriceForSelectedOffer() + val subscriptionPeriod = this.subscriptionPeriod ?: return@lazy "n/a" - if (basePrice == BigDecimal.ZERO) { - return priceFormatter?.format(BigDecimal.ZERO) ?: "$0.00" - } + val pricePerMonth = subscriptionPeriod.pricePerMonth(basePrice) - val subscriptionPeriod = this.subscriptionPeriod ?: return "n/a" + priceFormatter?.format(pricePerMonth) ?: "n/a" + } - val pricePerMonth = subscriptionPeriod.pricePerMonth(basePrice) + override val yearlyPrice by lazy { + val basePrice = basePriceForSelectedOffer - return priceFormatter?.format(pricePerMonth) ?: "n/a" + if (basePrice == BigDecimal.ZERO) { + return@lazy priceFormatter?.format(BigDecimal.ZERO) ?: "$0.00" } - override val yearlyPrice: String - get() { - val basePrice = basePriceForSelectedOffer() - - if (basePrice == BigDecimal.ZERO) { - return priceFormatter?.format(BigDecimal.ZERO) ?: "$0.00" - } + val subscriptionPeriod = this.subscriptionPeriod ?: return@lazy "n/a" + val pricePerYear = subscriptionPeriod.pricePerYear(basePrice) - val subscriptionPeriod = this.subscriptionPeriod ?: return "n/a" - val pricePerYear = subscriptionPeriod.pricePerYear(basePrice) + priceFormatter?.format(pricePerYear) ?: "n/a" + } - return priceFormatter?.format(pricePerYear) ?: "n/a" + override val hasFreeTrial by lazy { + if (underlyingProductDetails.oneTimePurchaseOfferDetails != null) { + return@lazy false } - private fun basePriceForSelectedOffer(): BigDecimal { - val selectedOffer = getSelectedOffer() ?: return BigDecimal.ZERO - val pricingPhase = selectedOffer.pricingPhases.pricingPhaseList.last().priceAmountMicros - return BigDecimal(pricingPhase).divide(BigDecimal(1_000_000), 2, RoundingMode.DOWN) - } + val selectedOffer = selectedOffer ?: return@lazy false - override val hasFreeTrial: Boolean - get() { - if (underlyingProductDetails.oneTimePurchaseOfferDetails != null) { - return false - } + // Check for free trial phase in pricing phases, excluding the base pricing + selectedOffer.pricingPhases.pricingPhaseList + .dropLast(1) + .any { it.priceAmountMicros == 0L } + } - val selectedOffer = getSelectedOffer() ?: return false + override val localizedTrialPeriodPrice by lazy { + priceFormatter?.format(trialPeriodPrice) ?: "$0.00" + } - // Check for free trial phase in pricing phases, excluding the base pricing - return selectedOffer.pricingPhases.pricingPhaseList - .dropLast(1) - .any { it.priceAmountMicros == 0L } + override val trialPeriodPrice by lazy { + if (underlyingProductDetails.oneTimePurchaseOfferDetails != null) { + return@lazy BigDecimal.ZERO } - override val localizedTrialPeriodPrice: String - get() = priceFormatter?.format(trialPeriodPrice) ?: "$0.00" + val selectedOffer = selectedOffer ?: return@lazy BigDecimal.ZERO - override val trialPeriodPrice: BigDecimal - get() { - if (underlyingProductDetails.oneTimePurchaseOfferDetails != null) { - return BigDecimal.ZERO - } - - val selectedOffer = getSelectedOffer() ?: return BigDecimal.ZERO - - val pricingWithoutBase = selectedOffer.pricingPhases.pricingPhaseList.dropLast(1) - if (pricingWithoutBase.isEmpty()) return BigDecimal.ZERO + val pricingWithoutBase = selectedOffer.pricingPhases.pricingPhaseList.dropLast(1) + if (pricingWithoutBase.isEmpty()) return@lazy BigDecimal.ZERO - // Check for free trial phase - val freeTrialPhase = pricingWithoutBase.firstOrNull { it.priceAmountMicros == 0L } - if (freeTrialPhase != null) return BigDecimal.ZERO + // Check for free trial phase + val freeTrialPhase = pricingWithoutBase.firstOrNull { it.priceAmountMicros == 0L } + if (freeTrialPhase != null) return@lazy BigDecimal.ZERO - // Check for discounted phase - val discountedPhase = pricingWithoutBase.firstOrNull { it.priceAmountMicros > 0 } - return discountedPhase?.let { - BigDecimal(it.priceAmountMicros).divide(BigDecimal(1_000_000), 2, RoundingMode.DOWN) - } ?: BigDecimal.ZERO - } + // Check for discounted phase + val discountedPhase = pricingWithoutBase.firstOrNull { it.priceAmountMicros > 0 } + discountedPhase?.let { + BigDecimal(it.priceAmountMicros).divide(BigDecimal(1_000_000), 2, RoundingMode.DOWN) + } ?: BigDecimal.ZERO + } - private fun getSelectedOffer(): SubscriptionOfferDetails? { + private fun getSelectedOfferDetails(): SubscriptionOfferDetails? { if (underlyingProductDetails.oneTimePurchaseOfferDetails != null) { return null } @@ -266,30 +278,43 @@ class RawStoreProduct( // In offers that match base plan, if there's only 1 pricing phase then this offer represents the base plan. val basePlan = offersForBasePlan.firstOrNull { it.pricingPhases.pricingPhaseList.size == 1 } ?: return null - when (offerType) { + return when (offerType) { is OfferType.Auto -> { - // For automatically selecting an offer: - // - Filters out offers with "ignore-offer" tag - // - Uses offer with longest free trial or cheapest first phase - // - Falls back to use base plan - val validOffers = offersForBasePlan - // Ignore base plan - .filter { it.pricingPhases.pricingPhaseList.size != 1 } - // Ignore those with a tag that contains "ignore-offer" - .filter { !it.offerTags.any { it.contains("-ignore-offer") }} - return findLongestFreeTrial(validOffers) ?: findLowestNonFreeOffer(validOffers) ?: basePlan + automaticallySelectOffer() ?: basePlan } is OfferType.Offer -> { // If an offer ID is given, return that one. Otherwise fallback to base plan. - return offersForBasePlan.firstOrNull { it.offerId == offerType.id } ?: basePlan + offersForBasePlan.firstOrNull { it.offerId == offerType.id } ?: basePlan } else -> { // If no offer specified, return base plan. - return basePlan + basePlan } } } + /** + * For automatically selecting an offer: + * - Filters out offers with "-ignore-offer" tag + * - Uses offer with longest free trial or cheapest first phase + * - Falls back to use base plan + */ + private fun automaticallySelectOffer(): SubscriptionOfferDetails? { + // Retrieve the subscription offer details from the product details + val subscriptionOfferDetails = underlyingProductDetails.subscriptionOfferDetails ?: return null + + // Get the offers that match the given base plan ID. + val offersForBasePlan = subscriptionOfferDetails.filter { it.basePlanId == basePlanId } + + val validOffers = offersForBasePlan + // Ignore base plan + .filter { it.pricingPhases.pricingPhaseList.size != 1 } + // Ignore those with a tag that contains "ignore-offer" + .filter { !it.offerTags.any { it.contains("-ignore-offer") }} + + return findLongestFreeTrial(validOffers) ?: findLowestNonFreeOffer(validOffers) + } + private fun findLongestFreeTrial(offers: List): SubscriptionOfferDetails? { return offers.mapNotNull { offer -> offer.pricingPhases.pricingPhaseList @@ -315,8 +340,8 @@ class RawStoreProduct( return hi } - override val trialPeriodEndDate: Date? - get() = trialSubscriptionPeriod?.let { + override val trialPeriodEndDate by lazy { + trialSubscriptionPeriod?.let { val calendar = Calendar.getInstance() when (it.unit) { SubscriptionPeriod.Unit.day -> calendar.add(Calendar.DAY_OF_YEAR, it.value) @@ -326,50 +351,51 @@ class RawStoreProduct( } calendar.time } + } - override val trialPeriodEndDateString: String - get() = trialPeriodEndDate?.let { + override val trialPeriodEndDateString by lazy { + trialPeriodEndDate?.let { val dateFormatter = SimpleDateFormat("MMM dd, yyyy", Locale.getDefault()) dateFormatter.format(it) } ?: "" + } - override val trialPeriodDays: Int - get() { - return trialSubscriptionPeriod?.let { - val numberOfUnits = it.value - - return when (it.unit) { - SubscriptionPeriod.Unit.day -> 1 * numberOfUnits - SubscriptionPeriod.Unit.month -> 30 * numberOfUnits // Assumes 30 days in a month - SubscriptionPeriod.Unit.week -> 7 * numberOfUnits // Assumes 7 days in a week - SubscriptionPeriod.Unit.year -> 365 * numberOfUnits // Assumes 365 days in a year - else -> 0 - } - } ?: 0 - } + override val trialPeriodDays by lazy { + trialSubscriptionPeriod?.let { + val numberOfUnits = it.value - override val trialPeriodDaysString: String - get() = trialPeriodDays.toString() + when (it.unit) { + SubscriptionPeriod.Unit.day -> 1 * numberOfUnits + SubscriptionPeriod.Unit.month -> 30 * numberOfUnits // Assumes 30 days in a month + SubscriptionPeriod.Unit.week -> 7 * numberOfUnits // Assumes 7 days in a week + SubscriptionPeriod.Unit.year -> 365 * numberOfUnits // Assumes 365 days in a year + } + } ?: 0 + } - override val trialPeriodWeeks: Int - get() { - val trialPeriod = trialSubscriptionPeriod ?: return 0 - val numberOfUnits = trialPeriod.value + override val trialPeriodDaysString by lazy { + trialPeriodDays.toString() + } - return when (trialPeriod.unit) { - SubscriptionPeriod.Unit.day -> numberOfUnits / 7 - SubscriptionPeriod.Unit.month -> 4 * numberOfUnits // Assumes 4 weeks in a month - SubscriptionPeriod.Unit.week -> 1 * numberOfUnits - SubscriptionPeriod.Unit.year -> 52 * numberOfUnits // Assumes 52 weeks in a year - else -> 0 - } + override val trialPeriodWeeks by lazy { + val trialPeriod = trialSubscriptionPeriod ?: return@lazy 0 + val numberOfUnits = trialPeriod.value + + when (trialPeriod.unit) { + SubscriptionPeriod.Unit.day -> numberOfUnits / 7 + SubscriptionPeriod.Unit.month -> 4 * numberOfUnits // Assumes 4 weeks in a month + SubscriptionPeriod.Unit.week -> 1 * numberOfUnits + SubscriptionPeriod.Unit.year -> 52 * numberOfUnits // Assumes 52 weeks in a year + else -> 0 } + } - override val trialPeriodWeeksString: String - get() = trialPeriodWeeks.toString() + override val trialPeriodWeeksString by lazy { + trialPeriodWeeks.toString() + } - override val trialPeriodMonths: Int - get() = trialSubscriptionPeriod?.let { + override val trialPeriodMonths by lazy { + trialSubscriptionPeriod?.let { val numberOfUnits = it.value when (it.unit) { SubscriptionPeriod.Unit.day -> numberOfUnits / 30 @@ -379,12 +405,14 @@ class RawStoreProduct( else -> 0 } } ?: 0 + } - override val trialPeriodMonthsString: String - get() = trialPeriodMonths.toString() + override val trialPeriodMonthsString by lazy { + trialPeriodMonths.toString() + } - override val trialPeriodYears: Int - get() = trialSubscriptionPeriod?.let { + override val trialPeriodYears by lazy { + trialSubscriptionPeriod?.let { val numberOfUnits = it.value when (it.unit) { SubscriptionPeriod.Unit.day -> numberOfUnits / 365 @@ -394,66 +422,67 @@ class RawStoreProduct( else -> 0 } } ?: 0 + } - override val trialPeriodYearsString: String - get() = trialPeriodYears.toString() + override val trialPeriodYearsString by lazy { + trialPeriodYears.toString() + } - override val trialPeriodText: String - get() { - val trialPeriod = trialSubscriptionPeriod ?: return "" - val units = trialPeriod.value + override val trialPeriodText by lazy { + val trialPeriod = trialSubscriptionPeriod ?: return@lazy "" + val units = trialPeriod.value - return when (trialPeriod.unit) { - SubscriptionPeriod.Unit.day -> "${units}-day" - SubscriptionPeriod.Unit.month -> "${units * 30}-day" - SubscriptionPeriod.Unit.week -> "${units * 7}-day" - SubscriptionPeriod.Unit.year -> "${units * 365}-day" - } + when (trialPeriod.unit) { + SubscriptionPeriod.Unit.day -> "${units}-day" + SubscriptionPeriod.Unit.month -> "${units * 30}-day" + SubscriptionPeriod.Unit.week -> "${units * 7}-day" + SubscriptionPeriod.Unit.year -> "${units * 365}-day" } + } // TODO: Differs from iOS, using device locale here instead of product locale - override val locale: String - get() = Locale.getDefault().toString() + override val locale by lazy { + Locale.getDefault().toString() + } // TODO: Differs from iOS, using device language code here instead of product language code - override val languageCode: String? - get() = Locale.getDefault().language + override val languageCode: String? by lazy { + Locale.getDefault().language + } - override val currencyCode: String? - get() { - if (underlyingProductDetails.oneTimePurchaseOfferDetails != null) { - return underlyingProductDetails.oneTimePurchaseOfferDetails?.priceCurrencyCode - } - val selectedOffer = getSelectedOffer() ?: return null - return selectedOffer.pricingPhases.pricingPhaseList.last().priceCurrencyCode + override val currencyCode by lazy { + if (underlyingProductDetails.oneTimePurchaseOfferDetails != null) { + return@lazy underlyingProductDetails.oneTimePurchaseOfferDetails?.priceCurrencyCode } + val selectedOffer = selectedOffer ?: return@lazy null + selectedOffer.pricingPhases.pricingPhaseList.last().priceCurrencyCode + } - override val currencySymbol: String? - get() { - return currencyCode?.let { Currency.getInstance(it).symbol } - } + override val currencySymbol by lazy { + currencyCode?.let { Currency.getInstance(it).symbol } + } - override val regionCode: String? - get() = Locale.getDefault().country + override val regionCode: String? by lazy { + Locale.getDefault().country + } - override val subscriptionPeriod: SubscriptionPeriod? - get() { - if (underlyingProductDetails.oneTimePurchaseOfferDetails != null) { - return null - } + override val subscriptionPeriod by lazy { + if (underlyingProductDetails.oneTimePurchaseOfferDetails != null) { + return@lazy null + } - val selectedOffer = getSelectedOffer() ?: return null - val baseBillingPeriod = selectedOffer.pricingPhases.pricingPhaseList.last().billingPeriod + val selectedOffer = selectedOffer ?: return@lazy null + val baseBillingPeriod = selectedOffer.pricingPhases.pricingPhaseList.last().billingPeriod - return try { - SubscriptionPeriod.from(baseBillingPeriod) - } catch (e: Exception) { - null - } + try { + SubscriptionPeriod.from(baseBillingPeriod) + } catch (e: Exception) { + null } + } override fun trialPeriodPricePerUnit(unit: SubscriptionPeriod.Unit): String { - val pricingPhase = getSelectedOfferPricingPhase() ?: return priceFormatter?.format(0) ?: "$0.00" + val pricingPhase = selectedOfferPricingPhase ?: return priceFormatter?.format(0) ?: "$0.00" if (pricingPhase.priceAmountMicros == 0L) { return priceFormatter?.format(0) ?: "$0.00" @@ -479,7 +508,7 @@ class RawStoreProduct( val introCost = trialPeriodPrice.multiply(BigDecimal(pricingPhase.billingCycleCount)) // The number of total units normalized to the unit you want. - val billingPeriod = getSelectedOfferPricingPhase()?.billingPeriod + val billingPeriod = selectedOfferPricingPhase?.billingPeriod // Attempt to create a SubscriptionPeriod from billingPeriod. // Return null if there's an exception or if billingPeriod is null. @@ -545,40 +574,20 @@ class RawStoreProduct( } } - - private fun getSelectedOfferPricingPhase(): PricingPhase? { - // Get the selected offer; return null if it's null. - val selectedOffer = getSelectedOffer() ?: return null - - // Find the first free trial phase or discounted phase. - return selectedOffer.pricingPhases.pricingPhaseList - .firstOrNull { it.priceAmountMicros == 0L } - ?: selectedOffer.pricingPhases.pricingPhaseList - .dropLast(1) - .firstOrNull { it.priceAmountMicros != 0L } - } - - val trialSubscriptionPeriod: SubscriptionPeriod? - get() { - // If oneTimePurchaseOfferDetails is not null, return null for trial subscription period. - if (underlyingProductDetails.oneTimePurchaseOfferDetails != null) { - return null - } - - val billingPeriod = getSelectedOfferPricingPhase()?.billingPeriod - - // Attempt to create a SubscriptionPeriod from billingPeriod. - // Return null if there's an exception or if billingPeriod is null. - return try { - billingPeriod?.let { SubscriptionPeriod.from(it) } - } catch (e: Exception) { - null - } + private val trialSubscriptionPeriod by lazy { + // If oneTimePurchaseOfferDetails is not null, return null for trial subscription period. + if (underlyingProductDetails.oneTimePurchaseOfferDetails != null) { + return@lazy null } -} -val SkuDetails.priceValue: BigDecimal - get() = BigDecimal(priceAmountMicros).divide(BigDecimal(1_000_000), 6, RoundingMode.DOWN) + val billingPeriod = selectedOfferPricingPhase?.billingPeriod -val SkuDetails.introductoryPriceValue: BigDecimal - get() = BigDecimal(introductoryPriceAmountMicros).divide(BigDecimal(1_000_000), 6, RoundingMode.DOWN) + // Attempt to create a SubscriptionPeriod from billingPeriod. + // Return null if there's an exception or if billingPeriod is null. + try { + billingPeriod?.let { SubscriptionPeriod.from(it) } + } catch (e: Exception) { + null + } + } +} \ No newline at end of file diff --git a/superwall/src/main/java/com/superwall/sdk/store/transactions/TransactionManager.kt b/superwall/src/main/java/com/superwall/sdk/store/transactions/TransactionManager.kt index b14ef87f..96281827 100644 --- a/superwall/src/main/java/com/superwall/sdk/store/transactions/TransactionManager.kt +++ b/superwall/src/main/java/com/superwall/sdk/store/transactions/TransactionManager.kt @@ -53,7 +53,7 @@ class TransactionManager( val result = storeKitManager.purchaseController.purchase( activity = activity, productDetails = productDetails, - offerId = rawStoreProduct.offerType?.id, + offerId = rawStoreProduct.offerId, basePlanId = rawStoreProduct.basePlanId ) From 7f24dcb77834f42f74dd922494cf2ad7539590ff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= Date: Tue, 5 Dec 2023 19:31:52 -0500 Subject: [PATCH 23/23] Fix issue with currency failing the tests on the CI --- .../sdk/products/ProductFetcherTest.kt | 214 +++++++++--------- 1 file changed, 107 insertions(+), 107 deletions(-) diff --git a/superwall/src/test/java/com/superwall/sdk/products/ProductFetcherTest.kt b/superwall/src/test/java/com/superwall/sdk/products/ProductFetcherTest.kt index 8f39514a..9a9b54a0 100644 --- a/superwall/src/test/java/com/superwall/sdk/products/ProductFetcherTest.kt +++ b/superwall/src/test/java/com/superwall/sdk/products/ProductFetcherTest.kt @@ -1,25 +1,22 @@ package com.superwall.sdk.products -import android.content.Context +import android.content.res.Resources import com.android.billingclient.api.BillingClient.ProductType -import com.android.billingclient.api.ProductDetails.OneTimePurchaseOfferDetails import com.android.billingclient.api.ProductDetails.RecurrenceMode -import com.superwall.sdk.billing.GoogleBillingWrapper import com.superwall.sdk.store.abstractions.product.OfferType import com.superwall.sdk.store.abstractions.product.RawStoreProduct import com.superwall.sdk.store.abstractions.product.StoreProduct import com.superwall.sdk.store.abstractions.product.SubscriptionPeriod -import com.superwall.sdk.store.products.GooglePlayProductsFetcher import kotlinx.coroutines.* -import kotlinx.coroutines.test.runTest import org.junit.Test -import org.junit.runner.RunWith import java.math.BigDecimal -import java.text.SimpleDateFormat +import java.security.AccessController.getContext import java.time.LocalDate import java.time.format.DateTimeFormatter +import java.util.Currency import java.util.Locale + class ProductFetcherInstrumentedTest { private val oneTimePurchaseProduct = mockProductDetails( productId = "pro_test_8999_year", @@ -261,6 +258,9 @@ class ProductFetcherInstrumentedTest { ), title = "com.ui_tests.quarterly2 (com.superwall.superapp (unreviewed))" ) + val currencySymbol by lazy { + Currency.getInstance("USD").symbol + } /** * subscription + base plan + offer: Free Trial phase ----> DONE @@ -293,11 +293,11 @@ class ProductFetcherInstrumentedTest { assert(storeProduct.productIdentifier == "com.ui_tests.quarterly2") assert(storeProduct.fullIdentifier == "com.ui_tests.quarterly2:test-2:free-trial-offer") assert(storeProduct.currencyCode == "USD") - assert(storeProduct.currencySymbol == "US$") // This actually just returns "$" in the main app. - assert(storeProduct.dailyPrice == "US$0.33") - assert(storeProduct.weeklyPrice == "US$2.49") - assert(storeProduct.monthlyPrice == "US$9.99") - assert(storeProduct.yearlyPrice == "US$119.88") + assert(storeProduct.currencySymbol == currencySymbol) // This actually just returns "$" in the main app. + assert(storeProduct.dailyPrice == "${currencySymbol}0.33") + assert(storeProduct.weeklyPrice == "${currencySymbol}2.49") + assert(storeProduct.monthlyPrice == "${currencySymbol}9.99") + assert(storeProduct.yearlyPrice == "${currencySymbol}119.88") assert(storeProduct.periodDays == 30) assert(storeProduct.periodMonths == 1) assert(storeProduct.periodWeeks == 4) @@ -313,11 +313,11 @@ class ProductFetcherInstrumentedTest { val defaultLocale = Locale.getDefault().toString() assert(storeProduct.locale == defaultLocale) // Change this depending on what your computer is set assert(storeProduct.trialPeriodPrice == BigDecimal.ZERO) - assert(storeProduct.trialPeriodPricePerUnit(SubscriptionPeriod.Unit.day) == "US$0.00") - assert(storeProduct.trialPeriodPricePerUnit(SubscriptionPeriod.Unit.month) == "US$0.00") - assert(storeProduct.trialPeriodPricePerUnit(SubscriptionPeriod.Unit.week) == "US$0.00") - assert(storeProduct.trialPeriodPricePerUnit(SubscriptionPeriod.Unit.year) == "US$0.00") - assert(storeProduct.localizedTrialPeriodPrice == "US$0.00") + assert(storeProduct.trialPeriodPricePerUnit(SubscriptionPeriod.Unit.day) == "${currencySymbol}0.00") + assert(storeProduct.trialPeriodPricePerUnit(SubscriptionPeriod.Unit.month) == "${currencySymbol}0.00") + assert(storeProduct.trialPeriodPricePerUnit(SubscriptionPeriod.Unit.week) == "${currencySymbol}0.00") + assert(storeProduct.trialPeriodPricePerUnit(SubscriptionPeriod.Unit.year) == "${currencySymbol}0.00") + assert(storeProduct.localizedTrialPeriodPrice == "${currencySymbol}0.00") val currentDate = LocalDate.now() val dateIn30Days = currentDate.plusMonths(1) @@ -341,11 +341,11 @@ class ProductFetcherInstrumentedTest { assert(storeProduct.productIdentifier == "com.ui_tests.quarterly2") assert(storeProduct.fullIdentifier == "com.ui_tests.quarterly2:test-2:trial-and-paid-offer") assert(storeProduct.currencyCode == "USD") - assert(storeProduct.currencySymbol == "US$") // This actually just returns "$" in the main app. - assert(storeProduct.dailyPrice == "US$0.33") - assert(storeProduct.weeklyPrice == "US$2.49") - assert(storeProduct.monthlyPrice == "US$9.99") - assert(storeProduct.yearlyPrice == "US$119.88") + assert(storeProduct.currencySymbol == currencySymbol) // This actually just returns "$" in the main app. + assert(storeProduct.dailyPrice == "${currencySymbol}0.33") + assert(storeProduct.weeklyPrice == "${currencySymbol}2.49") + assert(storeProduct.monthlyPrice == "${currencySymbol}9.99") + assert(storeProduct.yearlyPrice == "${currencySymbol}119.88") assert(storeProduct.periodDays == 30) assert(storeProduct.periodMonths == 1) assert(storeProduct.periodWeeks == 4) @@ -361,11 +361,11 @@ class ProductFetcherInstrumentedTest { val defaultLocale = Locale.getDefault().toString() assert(storeProduct.locale == defaultLocale) // Change this depending on what your computer is set assert(storeProduct.trialPeriodPrice == BigDecimal.ZERO) - assert(storeProduct.trialPeriodPricePerUnit(SubscriptionPeriod.Unit.day) == "US$0.00") - assert(storeProduct.trialPeriodPricePerUnit(SubscriptionPeriod.Unit.month) == "US$0.00") - assert(storeProduct.trialPeriodPricePerUnit(SubscriptionPeriod.Unit.week) == "US$0.00") - assert(storeProduct.trialPeriodPricePerUnit(SubscriptionPeriod.Unit.year) == "US$0.00") - assert(storeProduct.localizedTrialPeriodPrice == "US$0.00") + assert(storeProduct.trialPeriodPricePerUnit(SubscriptionPeriod.Unit.day) == "${currencySymbol}0.00") + assert(storeProduct.trialPeriodPricePerUnit(SubscriptionPeriod.Unit.month) == "${currencySymbol}0.00") + assert(storeProduct.trialPeriodPricePerUnit(SubscriptionPeriod.Unit.week) == "${currencySymbol}0.00") + assert(storeProduct.trialPeriodPricePerUnit(SubscriptionPeriod.Unit.year) == "${currencySymbol}0.00") + assert(storeProduct.localizedTrialPeriodPrice == "${currencySymbol}0.00") val currentDate = LocalDate.now() val dateIn30Days = currentDate.plusMonths(1) @@ -389,11 +389,11 @@ class ProductFetcherInstrumentedTest { assert(storeProduct.productIdentifier == "com.ui_tests.quarterly2") assert(storeProduct.fullIdentifier == "com.ui_tests.quarterly2:test-4:paid-offer") assert(storeProduct.currencyCode == "USD") - assert(storeProduct.currencySymbol == "US$") // This actually just returns "$" in the main app. - assert(storeProduct.dailyPrice == "US$0.29") - assert(storeProduct.weeklyPrice == "US$2.24") - assert(storeProduct.monthlyPrice == "US$8.99") - assert(storeProduct.yearlyPrice == "US$107.88") + assert(storeProduct.currencySymbol == "${currencySymbol}") // This actually just returns "$" in the main app. + assert(storeProduct.dailyPrice == "${currencySymbol}0.29") + assert(storeProduct.weeklyPrice == "${currencySymbol}2.24") + assert(storeProduct.monthlyPrice == "${currencySymbol}8.99") + assert(storeProduct.yearlyPrice == "${currencySymbol}107.88") assert(storeProduct.periodDays == 30) assert(storeProduct.periodMonths == 1) assert(storeProduct.periodWeeks == 4) @@ -409,11 +409,11 @@ class ProductFetcherInstrumentedTest { val defaultLocale = Locale.getDefault().toString() assert(storeProduct.locale == defaultLocale) // Change this depending on what your computer is set assert(storeProduct.trialPeriodPrice == BigDecimal("6.74")) - assert(storeProduct.trialPeriodPricePerUnit(SubscriptionPeriod.Unit.day) == "US$0.22") - assert(storeProduct.trialPeriodPricePerUnit(SubscriptionPeriod.Unit.month) == "US$6.74") - assert(storeProduct.trialPeriodPricePerUnit(SubscriptionPeriod.Unit.week) == "US$1.68") - assert(storeProduct.trialPeriodPricePerUnit(SubscriptionPeriod.Unit.year) == "US$20.22") - assert(storeProduct.localizedTrialPeriodPrice == "US$6.74") + assert(storeProduct.trialPeriodPricePerUnit(SubscriptionPeriod.Unit.day) == "${currencySymbol}0.22") + assert(storeProduct.trialPeriodPricePerUnit(SubscriptionPeriod.Unit.month) == "${currencySymbol}6.74") + assert(storeProduct.trialPeriodPricePerUnit(SubscriptionPeriod.Unit.week) == "${currencySymbol}1.68") + assert(storeProduct.trialPeriodPricePerUnit(SubscriptionPeriod.Unit.year) == "${currencySymbol}20.22") + assert(storeProduct.localizedTrialPeriodPrice == "${currencySymbol}6.74") val currentDate = LocalDate.now() val dateIn30Days = currentDate.plusMonths(1) @@ -439,11 +439,11 @@ class ProductFetcherInstrumentedTest { assert(storeProduct.productIdentifier == "com.ui_tests.quarterly2") assert(storeProduct.fullIdentifier == "com.ui_tests.quarterly2:test-2:sw-auto") assert(storeProduct.currencyCode == "USD") - assert(storeProduct.currencySymbol == "US$") // This actually just returns "$" in the main app. - assert(storeProduct.dailyPrice == "US$0.33") - assert(storeProduct.weeklyPrice == "US$2.49") - assert(storeProduct.monthlyPrice == "US$9.99") - assert(storeProduct.yearlyPrice == "US$119.88") + assert(storeProduct.currencySymbol == "${currencySymbol}") // This actually just returns "$" in the main app. + assert(storeProduct.dailyPrice == "${currencySymbol}0.33") + assert(storeProduct.weeklyPrice == "${currencySymbol}2.49") + assert(storeProduct.monthlyPrice == "${currencySymbol}9.99") + assert(storeProduct.yearlyPrice == "${currencySymbol}119.88") assert(storeProduct.periodDays == 30) assert(storeProduct.periodMonths == 1) assert(storeProduct.periodWeeks == 4) @@ -459,11 +459,11 @@ class ProductFetcherInstrumentedTest { val defaultLocale = Locale.getDefault().toString() assert(storeProduct.locale == defaultLocale) // Change this depending on what your computer is set assert(storeProduct.trialPeriodPrice == BigDecimal.ZERO) - assert(storeProduct.trialPeriodPricePerUnit(SubscriptionPeriod.Unit.day) == "US$0.00") - assert(storeProduct.trialPeriodPricePerUnit(SubscriptionPeriod.Unit.month) == "US$0.00") - assert(storeProduct.trialPeriodPricePerUnit(SubscriptionPeriod.Unit.week) == "US$0.00") - assert(storeProduct.trialPeriodPricePerUnit(SubscriptionPeriod.Unit.year) == "US$0.00") - assert(storeProduct.localizedTrialPeriodPrice == "US$0.00") + assert(storeProduct.trialPeriodPricePerUnit(SubscriptionPeriod.Unit.day) == "${currencySymbol}0.00") + assert(storeProduct.trialPeriodPricePerUnit(SubscriptionPeriod.Unit.month) == "${currencySymbol}0.00") + assert(storeProduct.trialPeriodPricePerUnit(SubscriptionPeriod.Unit.week) == "${currencySymbol}0.00") + assert(storeProduct.trialPeriodPricePerUnit(SubscriptionPeriod.Unit.year) == "${currencySymbol}0.00") + assert(storeProduct.localizedTrialPeriodPrice == "${currencySymbol}0.00") val currentDate = LocalDate.now() val dateIn30Days = currentDate.plusMonths(1) @@ -488,11 +488,11 @@ class ProductFetcherInstrumentedTest { assert(storeProduct.productIdentifier == "com.ui_tests.quarterly2") assert(storeProduct.fullIdentifier == "com.ui_tests.quarterly2:test-3:sw-auto") assert(storeProduct.currencyCode == "USD") - assert(storeProduct.currencySymbol == "US$") // This actually just returns "$" in the main app. - assert(storeProduct.dailyPrice == "US$0.09") - assert(storeProduct.weeklyPrice == "US$0.74") - assert(storeProduct.monthlyPrice == "US$2.99") - assert(storeProduct.yearlyPrice == "US$35.88") + assert(storeProduct.currencySymbol == "${currencySymbol}") // This actually just returns "$" in the main app. + assert(storeProduct.dailyPrice == "${currencySymbol}0.09") + assert(storeProduct.weeklyPrice == "${currencySymbol}0.74") + assert(storeProduct.monthlyPrice == "${currencySymbol}2.99") + assert(storeProduct.yearlyPrice == "${currencySymbol}35.88") assert(storeProduct.periodDays == 30) assert(storeProduct.periodMonths == 1) assert(storeProduct.periodWeeks == 4) @@ -510,11 +510,11 @@ class ProductFetcherInstrumentedTest { assert(storeProduct.locale == defaultLocale) // Change this depending on what your computer is set assert(storeProduct.trialPeriodPrice == BigDecimal("1.99")) - assert(storeProduct.trialPeriodPricePerUnit(SubscriptionPeriod.Unit.day) == "US$0.06") - assert(storeProduct.trialPeriodPricePerUnit(SubscriptionPeriod.Unit.month) == "US$1.99") - assert(storeProduct.trialPeriodPricePerUnit(SubscriptionPeriod.Unit.week) == "US$0.49") - assert(storeProduct.trialPeriodPricePerUnit(SubscriptionPeriod.Unit.year) == "US$1.99") - assert(storeProduct.localizedTrialPeriodPrice == "US$1.99") + assert(storeProduct.trialPeriodPricePerUnit(SubscriptionPeriod.Unit.day) == "${currencySymbol}0.06") + assert(storeProduct.trialPeriodPricePerUnit(SubscriptionPeriod.Unit.month) == "${currencySymbol}1.99") + assert(storeProduct.trialPeriodPricePerUnit(SubscriptionPeriod.Unit.week) == "${currencySymbol}0.49") + assert(storeProduct.trialPeriodPricePerUnit(SubscriptionPeriod.Unit.year) == "${currencySymbol}1.99") + assert(storeProduct.localizedTrialPeriodPrice == "${currencySymbol}1.99") val currentDate = LocalDate.now() val dateIn30Days = currentDate.plusMonths(1) @@ -538,11 +538,11 @@ class ProductFetcherInstrumentedTest { assert(storeProduct.productIdentifier == "com.ui_tests.quarterly2") assert(storeProduct.fullIdentifier == "com.ui_tests.quarterly2:test-5:sw-auto") assert(storeProduct.currencyCode == "USD") - assert(storeProduct.currencySymbol == "US$") // This actually just returns "$" in the main app. - assert(storeProduct.dailyPrice == "US$0.01") - assert(storeProduct.weeklyPrice == "US$0.07") - assert(storeProduct.monthlyPrice == "US$0.33") - assert(storeProduct.yearlyPrice == "US$3.99") + assert(storeProduct.currencySymbol == "${currencySymbol}") // This actually just returns "$" in the main app. + assert(storeProduct.dailyPrice == "${currencySymbol}0.01") + assert(storeProduct.weeklyPrice == "${currencySymbol}0.07") + assert(storeProduct.monthlyPrice == "${currencySymbol}0.33") + assert(storeProduct.yearlyPrice == "${currencySymbol}3.99") assert(storeProduct.periodDays == 365) assert(storeProduct.periodMonths == 12) assert(storeProduct.periodWeeks == 52) @@ -558,11 +558,11 @@ class ProductFetcherInstrumentedTest { val defaultLocale = Locale.getDefault().toString() assert(storeProduct.locale == defaultLocale) // Change this depending on what your computer is set assert(storeProduct.trialPeriodPrice == BigDecimal.ZERO) - assert(storeProduct.trialPeriodPricePerUnit(SubscriptionPeriod.Unit.day) == "US$0.00") - assert(storeProduct.trialPeriodPricePerUnit(SubscriptionPeriod.Unit.month) == "US$0.00") - assert(storeProduct.trialPeriodPricePerUnit(SubscriptionPeriod.Unit.week) == "US$0.00") - assert(storeProduct.trialPeriodPricePerUnit(SubscriptionPeriod.Unit.year) == "US$0.00") - assert(storeProduct.localizedTrialPeriodPrice == "US$0.00") + assert(storeProduct.trialPeriodPricePerUnit(SubscriptionPeriod.Unit.day) == "${currencySymbol}0.00") + assert(storeProduct.trialPeriodPricePerUnit(SubscriptionPeriod.Unit.month) == "${currencySymbol}0.00") + assert(storeProduct.trialPeriodPricePerUnit(SubscriptionPeriod.Unit.week) == "${currencySymbol}0.00") + assert(storeProduct.trialPeriodPricePerUnit(SubscriptionPeriod.Unit.year) == "${currencySymbol}0.00") + assert(storeProduct.localizedTrialPeriodPrice == "${currencySymbol}0.00") assert(storeProduct.trialPeriodEndDateString.isEmpty()) } @@ -581,11 +581,11 @@ class ProductFetcherInstrumentedTest { assert(storeProduct.productIdentifier == "com.ui_tests.quarterly2") assert(storeProduct.fullIdentifier == "com.ui_tests.quarterly2:test-5") assert(storeProduct.currencyCode == "USD") - assert(storeProduct.currencySymbol == "US$") // This actually just returns "$" in the main app. - assert(storeProduct.dailyPrice == "US$0.01") - assert(storeProduct.weeklyPrice == "US$0.07") - assert(storeProduct.monthlyPrice == "US$0.33") - assert(storeProduct.yearlyPrice == "US$3.99") + assert(storeProduct.currencySymbol == "${currencySymbol}") // This actually just returns "$" in the main app. + assert(storeProduct.dailyPrice == "${currencySymbol}0.01") + assert(storeProduct.weeklyPrice == "${currencySymbol}0.07") + assert(storeProduct.monthlyPrice == "${currencySymbol}0.33") + assert(storeProduct.yearlyPrice == "${currencySymbol}3.99") assert(storeProduct.periodDays == 365) assert(storeProduct.periodMonths == 12) assert(storeProduct.periodWeeks == 52) @@ -601,11 +601,11 @@ class ProductFetcherInstrumentedTest { val defaultLocale = Locale.getDefault().toString() assert(storeProduct.locale == defaultLocale) // Change this depending on what your computer is set assert(storeProduct.trialPeriodPrice == BigDecimal.ZERO) - assert(storeProduct.trialPeriodPricePerUnit(SubscriptionPeriod.Unit.day) == "US$0.00") - assert(storeProduct.trialPeriodPricePerUnit(SubscriptionPeriod.Unit.month) == "US$0.00") - assert(storeProduct.trialPeriodPricePerUnit(SubscriptionPeriod.Unit.week) == "US$0.00") - assert(storeProduct.trialPeriodPricePerUnit(SubscriptionPeriod.Unit.year) == "US$0.00") - assert(storeProduct.localizedTrialPeriodPrice == "US$0.00") + assert(storeProduct.trialPeriodPricePerUnit(SubscriptionPeriod.Unit.day) == "${currencySymbol}0.00") + assert(storeProduct.trialPeriodPricePerUnit(SubscriptionPeriod.Unit.month) == "${currencySymbol}0.00") + assert(storeProduct.trialPeriodPricePerUnit(SubscriptionPeriod.Unit.week) == "${currencySymbol}0.00") + assert(storeProduct.trialPeriodPricePerUnit(SubscriptionPeriod.Unit.year) == "${currencySymbol}0.00") + assert(storeProduct.localizedTrialPeriodPrice == "${currencySymbol}0.00") assert(storeProduct.trialPeriodEndDateString.isEmpty()) } @@ -624,11 +624,11 @@ class ProductFetcherInstrumentedTest { assert(storeProduct.productIdentifier == "com.ui_tests.quarterly2") assert(storeProduct.fullIdentifier == "com.ui_tests.quarterly2:test-5") assert(storeProduct.currencyCode == "USD") - assert(storeProduct.currencySymbol == "US$") // This actually just returns "$" in the main app. - assert(storeProduct.dailyPrice == "US$0.01") - assert(storeProduct.weeklyPrice == "US$0.07") - assert(storeProduct.monthlyPrice == "US$0.33") - assert(storeProduct.yearlyPrice == "US$3.99") + assert(storeProduct.currencySymbol == "${currencySymbol}") // This actually just returns "$" in the main app. + assert(storeProduct.dailyPrice == "${currencySymbol}0.01") + assert(storeProduct.weeklyPrice == "${currencySymbol}0.07") + assert(storeProduct.monthlyPrice == "${currencySymbol}0.33") + assert(storeProduct.yearlyPrice == "${currencySymbol}3.99") assert(storeProduct.periodDays == 365) assert(storeProduct.periodMonths == 12) assert(storeProduct.periodWeeks == 52) @@ -644,11 +644,11 @@ class ProductFetcherInstrumentedTest { val defaultLocale = Locale.getDefault().toString() assert(storeProduct.locale == defaultLocale) // Change this depending on what your computer is set assert(storeProduct.trialPeriodPrice == BigDecimal.ZERO) - assert(storeProduct.trialPeriodPricePerUnit(SubscriptionPeriod.Unit.day) == "US$0.00") - assert(storeProduct.trialPeriodPricePerUnit(SubscriptionPeriod.Unit.month) == "US$0.00") - assert(storeProduct.trialPeriodPricePerUnit(SubscriptionPeriod.Unit.week) == "US$0.00") - assert(storeProduct.trialPeriodPricePerUnit(SubscriptionPeriod.Unit.year) == "US$0.00") - assert(storeProduct.localizedTrialPeriodPrice == "US$0.00") + assert(storeProduct.trialPeriodPricePerUnit(SubscriptionPeriod.Unit.day) == "${currencySymbol}0.00") + assert(storeProduct.trialPeriodPricePerUnit(SubscriptionPeriod.Unit.month) == "${currencySymbol}0.00") + assert(storeProduct.trialPeriodPricePerUnit(SubscriptionPeriod.Unit.week) == "${currencySymbol}0.00") + assert(storeProduct.trialPeriodPricePerUnit(SubscriptionPeriod.Unit.year) == "${currencySymbol}0.00") + assert(storeProduct.localizedTrialPeriodPrice == "${currencySymbol}0.00") assert(storeProduct.trialPeriodEndDateString.isEmpty()) } @@ -670,11 +670,11 @@ class ProductFetcherInstrumentedTest { assert(storeProduct.productIdentifier == "com.ui_tests.quarterly2") assert(storeProduct.fullIdentifier == "com.ui_tests.quarterly2") assert(storeProduct.currencyCode == "USD") - assert(storeProduct.currencySymbol == "US$") // This actually just returns "$" in the main app. - assert(storeProduct.dailyPrice == "US$0.33") - assert(storeProduct.weeklyPrice == "US$2.49") - assert(storeProduct.monthlyPrice == "US$9.99") - assert(storeProduct.yearlyPrice == "US$119.88") + assert(storeProduct.currencySymbol == "${currencySymbol}") // This actually just returns "$" in the main app. + assert(storeProduct.dailyPrice == "${currencySymbol}0.33") + assert(storeProduct.weeklyPrice == "${currencySymbol}2.49") + assert(storeProduct.monthlyPrice == "${currencySymbol}9.99") + assert(storeProduct.yearlyPrice == "${currencySymbol}119.88") assert(storeProduct.periodDays == 30) assert(storeProduct.periodMonths == 1) assert(storeProduct.periodWeeks == 4) @@ -690,11 +690,11 @@ class ProductFetcherInstrumentedTest { val defaultLocale = Locale.getDefault().toString() assert(storeProduct.locale == defaultLocale) // Change this depending on what your computer is set assert(storeProduct.trialPeriodPrice == BigDecimal.ZERO) - assert(storeProduct.trialPeriodPricePerUnit(SubscriptionPeriod.Unit.day) == "US$0.00") - assert(storeProduct.trialPeriodPricePerUnit(SubscriptionPeriod.Unit.month) == "US$0.00") - assert(storeProduct.trialPeriodPricePerUnit(SubscriptionPeriod.Unit.week) == "US$0.00") - assert(storeProduct.trialPeriodPricePerUnit(SubscriptionPeriod.Unit.year) == "US$0.00") - assert(storeProduct.localizedTrialPeriodPrice == "US$0.00") + assert(storeProduct.trialPeriodPricePerUnit(SubscriptionPeriod.Unit.day) == "${currencySymbol}0.00") + assert(storeProduct.trialPeriodPricePerUnit(SubscriptionPeriod.Unit.month) == "${currencySymbol}0.00") + assert(storeProduct.trialPeriodPricePerUnit(SubscriptionPeriod.Unit.week) == "${currencySymbol}0.00") + assert(storeProduct.trialPeriodPricePerUnit(SubscriptionPeriod.Unit.year) == "${currencySymbol}0.00") + assert(storeProduct.localizedTrialPeriodPrice == "${currencySymbol}0.00") assert(storeProduct.trialPeriodEndDateString.isEmpty()) } @@ -713,11 +713,11 @@ class ProductFetcherInstrumentedTest { assert(storeProduct.productIdentifier == "pro_test_8999_year") assert(storeProduct.fullIdentifier == "pro_test_8999_year") assert(storeProduct.currencyCode == "USD") - assert(storeProduct.currencySymbol == "US$") // This actually just returns "$" in the main app. - assert(storeProduct.dailyPrice == "US$0.00") - assert(storeProduct.weeklyPrice == "US$0.00") - assert(storeProduct.monthlyPrice == "US$0.00") - assert(storeProduct.yearlyPrice == "US$0.00") + assert(storeProduct.currencySymbol == "${currencySymbol}") // This actually just returns "$" in the main app. + assert(storeProduct.dailyPrice == "${currencySymbol}0.00") + assert(storeProduct.weeklyPrice == "${currencySymbol}0.00") + assert(storeProduct.monthlyPrice == "${currencySymbol}0.00") + assert(storeProduct.yearlyPrice == "${currencySymbol}0.00") assert(storeProduct.periodDays == 0) assert(storeProduct.periodMonths == 0) assert(storeProduct.periodWeeks == 0) @@ -733,11 +733,11 @@ class ProductFetcherInstrumentedTest { val defaultLocale = Locale.getDefault().toString() assert(storeProduct.locale == defaultLocale) // Change this depending on what your computer is set assert(storeProduct.trialPeriodPrice == BigDecimal.ZERO) - assert(storeProduct.trialPeriodPricePerUnit(SubscriptionPeriod.Unit.day) == "US$0.00") - assert(storeProduct.trialPeriodPricePerUnit(SubscriptionPeriod.Unit.month) == "US$0.00") - assert(storeProduct.trialPeriodPricePerUnit(SubscriptionPeriod.Unit.week) == "US$0.00") - assert(storeProduct.trialPeriodPricePerUnit(SubscriptionPeriod.Unit.year) == "US$0.00") - assert(storeProduct.localizedTrialPeriodPrice == "US$0.00") + assert(storeProduct.trialPeriodPricePerUnit(SubscriptionPeriod.Unit.day) == "${currencySymbol}0.00") + assert(storeProduct.trialPeriodPricePerUnit(SubscriptionPeriod.Unit.month) == "${currencySymbol}0.00") + assert(storeProduct.trialPeriodPricePerUnit(SubscriptionPeriod.Unit.week) == "${currencySymbol}0.00") + assert(storeProduct.trialPeriodPricePerUnit(SubscriptionPeriod.Unit.year) == "${currencySymbol}0.00") + assert(storeProduct.localizedTrialPeriodPrice == "${currencySymbol}0.00") assert(storeProduct.trialPeriodEndDateString.isEmpty()) } } \ No newline at end of file