diff --git a/.circleci/config.yml b/.circleci/config.yml index 6435f3110e..2888948e8b 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -873,12 +873,12 @@ jobs: install: true - android/start_emulator: avd_name: test-e2e-tester - post_emulator_launch_assemble_command: ./gradlew test-apps:e2etests:assembleDebug + post_emulator_launch_assemble_command: ./gradlew test-apps:e2etests:assembleRelease - android/restore_gradle_cache - run: name: Install E2E Tester on emulator command: | - adb install test-apps/e2etests/build/outputs/apk/debug/e2etests-debug.apk + adb install test-apps/e2etests/build/outputs/apk/release/e2etests-release.apk - revenuecat/install-maestro - run: name: Run Maestro tests diff --git a/feature/galaxy/consumer-rules.pro b/feature/galaxy/consumer-rules.pro index e69de29bb2..9c90dba24f 100644 --- a/feature/galaxy/consumer-rules.pro +++ b/feature/galaxy/consumer-rules.pro @@ -0,0 +1,7 @@ +# GalaxyBillingWrapper is instantiated reflectively from the purchases module +# (GalaxyBillingWrapperFactory) via Class.forName + getDeclaredConstructor(...). Keep all its +# constructors so the reflective lookup resolves under minification, regardless of which +# constructor signature the factory uses (so changing it doesn't require updating this rule). +-keep class com.revenuecat.purchases.galaxy.GalaxyBillingWrapper { + (...); +} diff --git a/purchases/consumer-rules.pro b/purchases/consumer-rules.pro index d009f7010d..526624be2e 100644 --- a/purchases/consumer-rules.pro +++ b/purchases/consumer-rules.pro @@ -1,4 +1,12 @@ --keep class com.revenuecat.** { *; } +# EnumDeserializerWithDefault matches JSON against enum CONSTANT NAMES read reflectively via +# Class.enumConstants (value.name.lowercase()) and silently falls back to a default on mismatch. +# Keep enum constant names so an obfuscated name can't turn into a silent wrong-value bug. +-keepclassmembers enum com.revenuecat.** { + ; + public static **[] values(); + public static ** valueOf(java.lang.String); +} + -keep class androidx.lifecycle.DefaultLifecycleObserver -dontwarn com.google.errorprone.annotations.CanIgnoreReturnValue -dontwarn com.google.errorprone.annotations.Immutable @@ -7,53 +15,11 @@ # Adding temporarily to fix issue after adding kotlin serialization -dontwarn java.lang.ClassValue -# START Keep kotlinx.serialization annotations. - -# Keep `Companion` object fields of serializable classes. -# This avoids serializer lookup through `getDeclaredClasses` as done for named companion objects. --if @kotlinx.serialization.Serializable class ** --keepclassmembers class <1> { - static <1>$Companion Companion; -} - -# Keep `serializer()` on companion objects (both default and named) of serializable classes. --if @kotlinx.serialization.Serializable class ** { - static **$* *; -} --keepclassmembers class <2>$<3> { - kotlinx.serialization.KSerializer serializer(...); -} - -# Keep `INSTANCE.serializer()` of serializable objects. --if @kotlinx.serialization.Serializable class ** { - public static ** INSTANCE; -} --keepclassmembers class <1> { - public static <1> INSTANCE; - kotlinx.serialization.KSerializer serializer(...); -} - -# @Serializable and @Polymorphic are used at runtime for polymorphic serialization. --keepattributes RuntimeVisibleAnnotations,AnnotationDefault - -# Serializer for classes with named companion objects are retrieved using `getDeclaredClasses`. -# If you have any, uncomment and replace classes with those containing named companion objects. -#-keepattributes InnerClasses # Needed for `getDeclaredClasses`. -#-if @kotlinx.serialization.Serializable class -#com.example.myapplication.HasNamedCompanion, # <-- List serializable classes with named companions. -#com.example.myapplication.HasNamedCompanion2 -#{ -# static **$* *; -#} -#-keepnames class <1>$$serializer { # -keepnames suffices; class is kept when serializer() is kept. -# static <1>$$serializer INSTANCE; -#} - +# These rules target kotlinx.serialization 1.5.1 (Kotlin 2.0.21). # Adding these because when target Android is 14 but compile version is lower than 14 there are r8 issues # https://github.com/RevenueCat/purchases-android/pull/1606 -keep class kotlinx.serialization.internal.ClassValueParametrizedCache$initClassValue$1 { ** computeValue(java.lang.Class); } -keep class kotlinx.serialization.internal.ClassValueCache$initClassValue$1 { ** computeValue(java.lang.Class); } -# END Keep kotlinx.serialization annotations. # The org.json package is part of the Android framework, so the classes are always available. # However, some apps add it to their classpath, either explicitly or transitively. When diff --git a/purchases/src/main/kotlin/com/revenuecat/purchases/DangerousSettings.kt b/purchases/src/main/kotlin/com/revenuecat/purchases/DangerousSettings.kt index 1850b09419..434022ed3d 100644 --- a/purchases/src/main/kotlin/com/revenuecat/purchases/DangerousSettings.kt +++ b/purchases/src/main/kotlin/com/revenuecat/purchases/DangerousSettings.kt @@ -28,6 +28,8 @@ public class DangerousSettings internal constructor( */ @InternalRevenueCatAPI public val useWorkflows: Boolean = false, + + internal val allowTestStoreInReleaseBuild: Boolean = false, ) : Parcelable { @OptIn(InternalRevenueCatAPI::class) public constructor(autoSyncPurchases: Boolean = true) : this( @@ -36,6 +38,7 @@ public class DangerousSettings internal constructor( uiPreviewMode = false, applyObfuscatedAccountIdToSubscriptionChanges = false, useWorkflows = false, + allowTestStoreInReleaseBuild = false, ) public companion object { @@ -66,5 +69,11 @@ public class DangerousSettings internal constructor( applyObfuscatedAccountIdToSubscriptionChanges = false, useWorkflows = true, ) + + @InternalRevenueCatAPI + @JvmStatic + public fun forTestStoreInReleaseBuild(): DangerousSettings = DangerousSettings( + allowTestStoreInReleaseBuild = true, + ) } } diff --git a/purchases/src/main/kotlin/com/revenuecat/purchases/EntitlementInfo.kt b/purchases/src/main/kotlin/com/revenuecat/purchases/EntitlementInfo.kt index 9fdad0dc24..457072be93 100644 --- a/purchases/src/main/kotlin/com/revenuecat/purchases/EntitlementInfo.kt +++ b/purchases/src/main/kotlin/com/revenuecat/purchases/EntitlementInfo.kt @@ -289,6 +289,7 @@ public enum class Store { } internal object StoreSerializer : EnumDeserializerWithDefault( + serialName = "com.revenuecat.purchases.Store", defaultValue = Store.UNKNOWN_STORE, typeForValue = { value -> value.stringValue }, ) diff --git a/purchases/src/main/kotlin/com/revenuecat/purchases/PurchasesFactory.kt b/purchases/src/main/kotlin/com/revenuecat/purchases/PurchasesFactory.kt index a1772d8a40..d234115995 100644 --- a/purchases/src/main/kotlin/com/revenuecat/purchases/PurchasesFactory.kt +++ b/purchases/src/main/kotlin/com/revenuecat/purchases/PurchasesFactory.kt @@ -517,10 +517,14 @@ internal class PurchasesFactory( val apiKeyValidationResult = apiKeyValidator.validateAndLog(apiKey, store) - if (!isDebugBuild() && - apiKeyValidationResult == APIKeyValidator.ValidationResult.SIMULATED_STORE && - !dangerousSettings.uiPreviewMode - ) { + // Test Store keys are only meant for development. uiPreviewMode and + // allowTestStoreInReleaseBuild are internal opt-ins that intentionally bypass this guard. + val isTestStoreKeyInReleaseBuild = !isDebugBuild() && + apiKeyValidationResult == APIKeyValidator.ValidationResult.SIMULATED_STORE + val testStoreReleaseBuildAllowed = dangerousSettings.uiPreviewMode || + dangerousSettings.allowTestStoreInReleaseBuild + + if (isTestStoreKeyInReleaseBuild && !testStoreReleaseBuildAllowed) { val redactedApiKey = apiKeyValidator.redactApiKey(apiKey) errorLog( error = PurchasesError( diff --git a/purchases/src/main/kotlin/com/revenuecat/purchases/common/workflows/WorkflowModels.kt b/purchases/src/main/kotlin/com/revenuecat/purchases/common/workflows/WorkflowModels.kt index 38c82d62b3..c3bb6f845c 100644 --- a/purchases/src/main/kotlin/com/revenuecat/purchases/common/workflows/WorkflowModels.kt +++ b/purchases/src/main/kotlin/com/revenuecat/purchases/common/workflows/WorkflowModels.kt @@ -29,6 +29,7 @@ public enum class WorkflowTriggerType { } internal object WorkflowTriggerTypeDeserializer : EnumDeserializerWithDefault( + serialName = "com.revenuecat.purchases.common.workflows.WorkflowTriggerType", defaultValue = WorkflowTriggerType.UNKNOWN, ) diff --git a/purchases/src/main/kotlin/com/revenuecat/purchases/paywalls/components/ButtonComponent.kt b/purchases/src/main/kotlin/com/revenuecat/purchases/paywalls/components/ButtonComponent.kt index 6e3ba53021..8deec90c7a 100644 --- a/purchases/src/main/kotlin/com/revenuecat/purchases/paywalls/components/ButtonComponent.kt +++ b/purchases/src/main/kotlin/com/revenuecat/purchases/paywalls/components/ButtonComponent.kt @@ -295,14 +295,17 @@ private enum class DestinationSurrogate { private class UrlSurrogate(val url_lid: LocalizationKey, val method: UrlMethod) private object ActionTypeSurrogateDeserializer : EnumDeserializerWithDefault ( + serialName = "com.revenuecat.purchases.paywalls.components.ActionTypeSurrogate", defaultValue = ActionTypeSurrogate.unknown, ) private object DestinationSurrogateDeserializer : EnumDeserializerWithDefault ( + serialName = "com.revenuecat.purchases.paywalls.components.DestinationSurrogate", defaultValue = DestinationSurrogate.unknown, ) @OptIn(InternalRevenueCatAPI::class) private object UrlMethodDeserializer : EnumDeserializerWithDefault ( + serialName = "com.revenuecat.purchases.paywalls.components.ButtonComponent.UrlMethod", defaultValue = UrlMethod.UNKNOWN, ) diff --git a/purchases/src/main/kotlin/com/revenuecat/purchases/paywalls/components/CarouselComponent.kt b/purchases/src/main/kotlin/com/revenuecat/purchases/paywalls/components/CarouselComponent.kt index 44cd8f64dd..fd31958d5a 100644 --- a/purchases/src/main/kotlin/com/revenuecat/purchases/paywalls/components/CarouselComponent.kt +++ b/purchases/src/main/kotlin/com/revenuecat/purchases/paywalls/components/CarouselComponent.kt @@ -195,11 +195,14 @@ public class PartialCarouselComponent( @OptIn(InternalRevenueCatAPI::class) internal object CarouselPageControlPositionDeserializer : EnumDeserializerWithDefault( + serialName = "com.revenuecat.purchases.paywalls.components.CarouselComponent.PageControl.Position", defaultValue = PageControl.Position.BOTTOM, ) @OptIn(InternalRevenueCatAPI::class) internal object CarouselTransitionTypeDeserializer : EnumDeserializerWithDefault( + serialName = "com.revenuecat.purchases.paywalls.components." + + "CarouselComponent.AutoAdvancePages.TransitionType", defaultValue = CarouselComponent.AutoAdvancePages.TransitionType.SLIDE, ) diff --git a/purchases/src/main/kotlin/com/revenuecat/purchases/paywalls/components/PaywallAnimation.kt b/purchases/src/main/kotlin/com/revenuecat/purchases/paywalls/components/PaywallAnimation.kt index 6e53bc593f..19bdfbb043 100644 --- a/purchases/src/main/kotlin/com/revenuecat/purchases/paywalls/components/PaywallAnimation.kt +++ b/purchases/src/main/kotlin/com/revenuecat/purchases/paywalls/components/PaywallAnimation.kt @@ -35,6 +35,7 @@ public class PaywallAnimation( @OptIn(InternalRevenueCatAPI::class) internal object AnimationTypeSerializer : EnumDeserializerWithDefault( + serialName = "com.revenuecat.purchases.paywalls.components.PaywallAnimation.AnimationType", defaultValue = PaywallAnimation.AnimationType.EASE_IN_OUT, typeForValue = { value -> when (value) { diff --git a/purchases/src/main/kotlin/com/revenuecat/purchases/paywalls/components/PaywallTransition.kt b/purchases/src/main/kotlin/com/revenuecat/purchases/paywalls/components/PaywallTransition.kt index 6b191096fd..23423bc9b5 100644 --- a/purchases/src/main/kotlin/com/revenuecat/purchases/paywalls/components/PaywallTransition.kt +++ b/purchases/src/main/kotlin/com/revenuecat/purchases/paywalls/components/PaywallTransition.kt @@ -53,6 +53,7 @@ public class PaywallTransition( @OptIn(InternalRevenueCatAPI::class) internal object DisplacementStrategyDeserializer : EnumDeserializerWithDefault( + serialName = "com.revenuecat.purchases.paywalls.components.PaywallTransition.DisplacementStrategy", defaultValue = PaywallTransition.DisplacementStrategy.GREEDY, typeForValue = { value -> when (value) { @@ -64,6 +65,7 @@ internal object DisplacementStrategyDeserializer : EnumDeserializerWithDefault

( + serialName = "com.revenuecat.purchases.paywalls.components.PaywallTransition.TransitionType", defaultValue = PaywallTransition.TransitionType.FADE, typeForValue = { value -> when (value) { diff --git a/purchases/src/main/kotlin/com/revenuecat/purchases/paywalls/components/PurchaseButtonComponent.kt b/purchases/src/main/kotlin/com/revenuecat/purchases/paywalls/components/PurchaseButtonComponent.kt index a2138121a4..b6275a4b1b 100644 --- a/purchases/src/main/kotlin/com/revenuecat/purchases/paywalls/components/PurchaseButtonComponent.kt +++ b/purchases/src/main/kotlin/com/revenuecat/purchases/paywalls/components/PurchaseButtonComponent.kt @@ -85,6 +85,7 @@ public class PurchaseButtonComponent( @OptIn(InternalRevenueCatAPI::class) private object ActionDeserializer : EnumDeserializerWithDefault ( + serialName = "com.revenuecat.purchases.paywalls.components.PurchaseButtonComponent.Action", defaultValue = PurchaseButtonComponent.Action.IN_APP_CHECKOUT, ) diff --git a/purchases/src/main/kotlin/com/revenuecat/purchases/paywalls/components/StackComponent.kt b/purchases/src/main/kotlin/com/revenuecat/purchases/paywalls/components/StackComponent.kt index f308704319..82cf0f7aaf 100644 --- a/purchases/src/main/kotlin/com/revenuecat/purchases/paywalls/components/StackComponent.kt +++ b/purchases/src/main/kotlin/com/revenuecat/purchases/paywalls/components/StackComponent.kt @@ -112,5 +112,6 @@ public class PartialStackComponent( @OptIn(InternalRevenueCatAPI::class) internal object StackOverflowDeserializer : EnumDeserializerWithDefault( + serialName = "com.revenuecat.purchases.paywalls.components.StackComponent.Overflow", defaultValue = StackComponent.Overflow.NONE, ) diff --git a/purchases/src/main/kotlin/com/revenuecat/purchases/paywalls/components/TimelineComponent.kt b/purchases/src/main/kotlin/com/revenuecat/purchases/paywalls/components/TimelineComponent.kt index 297f223476..d13cfd7942 100644 --- a/purchases/src/main/kotlin/com/revenuecat/purchases/paywalls/components/TimelineComponent.kt +++ b/purchases/src/main/kotlin/com/revenuecat/purchases/paywalls/components/TimelineComponent.kt @@ -127,6 +127,7 @@ public class PartialTimelineComponentItem( @OptIn(InternalRevenueCatAPI::class) internal object TimelineIconAlignmentDeserializer : EnumDeserializerWithDefault( + serialName = "com.revenuecat.purchases.paywalls.components.TimelineComponent.IconAlignment", defaultValue = TimelineComponent.IconAlignment.Title, typeForValue = { value -> when (value) { diff --git a/purchases/src/main/kotlin/com/revenuecat/purchases/paywalls/components/properties/Alignment.kt b/purchases/src/main/kotlin/com/revenuecat/purchases/paywalls/components/properties/Alignment.kt index efb4eb624d..92c1785426 100644 --- a/purchases/src/main/kotlin/com/revenuecat/purchases/paywalls/components/properties/Alignment.kt +++ b/purchases/src/main/kotlin/com/revenuecat/purchases/paywalls/components/properties/Alignment.kt @@ -42,15 +42,18 @@ public enum class TwoDimensionalAlignment { @OptIn(InternalRevenueCatAPI::class) internal object HorizontalAlignmentDeserializer : EnumDeserializerWithDefault( + serialName = "com.revenuecat.purchases.paywalls.components.properties.HorizontalAlignment", defaultValue = HorizontalAlignment.LEADING, ) @OptIn(InternalRevenueCatAPI::class) internal object VerticalAlignmentDeserializer : EnumDeserializerWithDefault( + serialName = "com.revenuecat.purchases.paywalls.components.properties.VerticalAlignment", defaultValue = VerticalAlignment.TOP, ) @OptIn(InternalRevenueCatAPI::class) internal object TwoDimensionalAlignmentDeserializer : EnumDeserializerWithDefault( + serialName = "com.revenuecat.purchases.paywalls.components.properties.TwoDimensionalAlignment", defaultValue = TwoDimensionalAlignment.TOP, ) diff --git a/purchases/src/main/kotlin/com/revenuecat/purchases/paywalls/components/properties/Badge.kt b/purchases/src/main/kotlin/com/revenuecat/purchases/paywalls/components/properties/Badge.kt index 7eb603eb3a..4348154016 100644 --- a/purchases/src/main/kotlin/com/revenuecat/purchases/paywalls/components/properties/Badge.kt +++ b/purchases/src/main/kotlin/com/revenuecat/purchases/paywalls/components/properties/Badge.kt @@ -34,6 +34,7 @@ public class Badge( @OptIn(InternalRevenueCatAPI::class) internal object BadgeStyleSerializer : EnumDeserializerWithDefault( + serialName = "com.revenuecat.purchases.paywalls.components.properties.Badge.Style", defaultValue = Badge.Style.Overlay, typeForValue = { style -> when (style) { diff --git a/purchases/src/main/kotlin/com/revenuecat/purchases/paywalls/components/properties/FitMode.kt b/purchases/src/main/kotlin/com/revenuecat/purchases/paywalls/components/properties/FitMode.kt index 8e91d2537c..d0d9b5ea99 100644 --- a/purchases/src/main/kotlin/com/revenuecat/purchases/paywalls/components/properties/FitMode.kt +++ b/purchases/src/main/kotlin/com/revenuecat/purchases/paywalls/components/properties/FitMode.kt @@ -15,5 +15,6 @@ public enum class FitMode { @OptIn(InternalRevenueCatAPI::class) internal object FitModeDeserializer : EnumDeserializerWithDefault( + serialName = "com.revenuecat.purchases.paywalls.components.properties.FitMode", defaultValue = FitMode.FIT, ) diff --git a/purchases/src/main/kotlin/com/revenuecat/purchases/paywalls/components/properties/FlexDistribution.kt b/purchases/src/main/kotlin/com/revenuecat/purchases/paywalls/components/properties/FlexDistribution.kt index 4da13cc9a4..6e74e35b3f 100644 --- a/purchases/src/main/kotlin/com/revenuecat/purchases/paywalls/components/properties/FlexDistribution.kt +++ b/purchases/src/main/kotlin/com/revenuecat/purchases/paywalls/components/properties/FlexDistribution.kt @@ -19,5 +19,6 @@ public enum class FlexDistribution { @OptIn(InternalRevenueCatAPI::class) internal object FlexDistributionDeserializer : EnumDeserializerWithDefault( + serialName = "com.revenuecat.purchases.paywalls.components.properties.FlexDistribution", defaultValue = FlexDistribution.START, ) diff --git a/purchases/src/main/kotlin/com/revenuecat/purchases/paywalls/components/properties/Font.kt b/purchases/src/main/kotlin/com/revenuecat/purchases/paywalls/components/properties/Font.kt index 31f0d48710..c7d2ed55db 100644 --- a/purchases/src/main/kotlin/com/revenuecat/purchases/paywalls/components/properties/Font.kt +++ b/purchases/src/main/kotlin/com/revenuecat/purchases/paywalls/components/properties/Font.kt @@ -70,6 +70,7 @@ public enum class FontSize { @OptIn(InternalRevenueCatAPI::class) internal object FontWeightDeserializer : EnumDeserializerWithDefault( + serialName = "com.revenuecat.purchases.paywalls.components.properties.FontWeight", defaultValue = FontWeight.REGULAR, typeForValue = { value -> when (value) { @@ -88,6 +89,7 @@ internal object FontWeightDeserializer : EnumDeserializerWithDefault @OptIn(InternalRevenueCatAPI::class) internal object FontStyleDeserializer : EnumDeserializerWithDefault( + serialName = "com.revenuecat.purchases.paywalls.components.properties.FontStyle", defaultValue = FontStyle.NORMAL, typeForValue = { value -> when (value) { diff --git a/purchases/src/main/kotlin/com/revenuecat/purchases/utils/serializers/EnumDeserializerWithDefault.kt b/purchases/src/main/kotlin/com/revenuecat/purchases/utils/serializers/EnumDeserializerWithDefault.kt index 9950cd29cc..1199942bd5 100644 --- a/purchases/src/main/kotlin/com/revenuecat/purchases/utils/serializers/EnumDeserializerWithDefault.kt +++ b/purchases/src/main/kotlin/com/revenuecat/purchases/utils/serializers/EnumDeserializerWithDefault.kt @@ -11,22 +11,23 @@ import kotlinx.serialization.encoding.Encoder * Deserializer for enums with a default value. */ internal abstract class EnumDeserializerWithDefault>( + private val serialName: String, private val valuesByType: Map, private val defaultValue: T, ) : KSerializer { constructor( + serialName: String, defaultValue: T, typeForValue: (T) -> String = { value -> value.name.lowercase() }, ) : this( + serialName = serialName, valuesByType = defaultValue::class.java.enumConstants.associateBy(typeForValue), defaultValue = defaultValue, ) - private val enumName = defaultValue.javaClass.simpleName - override val descriptor: SerialDescriptor = - PrimitiveSerialDescriptor(enumName, PrimitiveKind.STRING) + PrimitiveSerialDescriptor(serialName, PrimitiveKind.STRING) override fun deserialize(decoder: Decoder): T { val key = decoder.decodeString() diff --git a/purchases/src/test/java/com/revenuecat/purchases/PurchasesFactoryTest.kt b/purchases/src/test/java/com/revenuecat/purchases/PurchasesFactoryTest.kt index fba75ad0e5..891414105e 100644 --- a/purchases/src/test/java/com/revenuecat/purchases/PurchasesFactoryTest.kt +++ b/purchases/src/test/java/com/revenuecat/purchases/PurchasesFactoryTest.kt @@ -165,6 +165,36 @@ class PurchasesFactoryTest { verify(exactly = 0) { applicationMock.startActivity(any()) } } + @OptIn(InternalRevenueCatAPI::class) + @Test + fun `configuring SDK with simulated store api key in release mode and allowTestStoreInReleaseBuild does not show error activity`() { + // Arrange + purchasesFactory = PurchasesFactory( + isDebugBuild = { false }, + apiKeyValidator = apiKeyValidatorMock, + ) + val applicationContextMock = mockk() + every { + applicationMock.checkCallingOrSelfPermission(Manifest.permission.INTERNET) + } returns PackageManager.PERMISSION_GRANTED + every { + applicationMock.applicationContext + } returns applicationContextMock + every { + apiKeyValidatorMock.validateAndLog("fakeApiKey", Store.PLAY_STORE) + } returns APIKeyValidator.ValidationResult.SIMULATED_STORE + + // Act + purchasesFactory.validateConfiguration( + createConfiguration( + dangerousSettings = DangerousSettings.forTestStoreInReleaseBuild(), + ), + ) + + // Assert + verify(exactly = 0) { applicationMock.startActivity(any()) } + } + // region shouldInitializeDiagnostics @Test diff --git a/purchases/src/test/java/com/revenuecat/purchases/common/AppConfigTest.kt b/purchases/src/test/java/com/revenuecat/purchases/common/AppConfigTest.kt index 9f21738375..d900fc0f27 100644 --- a/purchases/src/test/java/com/revenuecat/purchases/common/AppConfigTest.kt +++ b/purchases/src/test/java/com/revenuecat/purchases/common/AppConfigTest.kt @@ -508,7 +508,8 @@ class AppConfigTest { "customEntitlementComputation=false, " + "uiPreviewMode=false, " + "applyObfuscatedAccountIdToSubscriptionChanges=false, " + - "useWorkflows=false), " + + "useWorkflows=false, " + + "allowTestStoreInReleaseBuild=false), " + "languageTag='', " + "versionName='', " + "packageName='', " + diff --git a/purchases/src/test/java/com/revenuecat/purchases/utils/serializers/EnumDeserializerWithDefaultTest.kt b/purchases/src/test/java/com/revenuecat/purchases/utils/serializers/EnumDeserializerWithDefaultTest.kt new file mode 100644 index 0000000000..6014eeea3c --- /dev/null +++ b/purchases/src/test/java/com/revenuecat/purchases/utils/serializers/EnumDeserializerWithDefaultTest.kt @@ -0,0 +1,54 @@ +package com.revenuecat.purchases.utils.serializers + +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.json.Json +import org.assertj.core.api.Assertions.assertThat +import org.junit.Test + +@OptIn(ExperimentalSerializationApi::class) +class EnumDeserializerWithDefaultTest { + + private enum class Color { RED, GREEN, BLUE } + + private object ColorDeserializer : EnumDeserializerWithDefault( + serialName = "com.revenuecat.purchases.test.Color", + defaultValue = Color.RED, + ) + + // A second enum whose simple name collides with [Color] once obfuscated would previously have + // produced the same descriptor name. The serial name must come from the explicit parameter so + // these stay distinct. + private enum class OtherColor { CYAN, MAGENTA } + + private object OtherColorDeserializer : EnumDeserializerWithDefault( + serialName = "com.revenuecat.purchases.test.OtherColor", + defaultValue = OtherColor.CYAN, + ) + + @Test + fun `descriptor uses the explicit serial name, not the runtime class name`() { + // In release builds the runtime class name is obfuscated (e.g. "f0"), so deriving the + // descriptor name from it caused kotlinx serial-name collisions at runtime. The name must + // be the explicit, stable value passed in. + assertThat(ColorDeserializer.descriptor.serialName) + .isEqualTo("com.revenuecat.purchases.test.Color") + assertThat(ColorDeserializer.descriptor.serialName) + .isNotEqualTo(Color::class.java.simpleName) + } + + @Test + fun `distinct deserializers expose distinct stable serial names`() { + assertThat(ColorDeserializer.descriptor.serialName) + .isNotEqualTo(OtherColorDeserializer.descriptor.serialName) + } + + @Test + fun `deserializes a known value`() { + assertThat(Json.decodeFromString(ColorDeserializer, "\"green\"")).isEqualTo(Color.GREEN) + } + + @Test + fun `falls back to the default on an unknown value`() { + assertThat(Json.decodeFromString(ColorDeserializer, "\"purple\"")).isEqualTo(Color.RED) + } +} diff --git a/test-apps/e2etests/build.gradle.kts b/test-apps/e2etests/build.gradle.kts index c42ad32a37..0cb0a74655 100644 --- a/test-apps/e2etests/build.gradle.kts +++ b/test-apps/e2etests/build.gradle.kts @@ -20,8 +20,12 @@ android { buildTypes { release { - isMinifyEnabled = false + // Minified so the Maestro e2e flow exercises the SDK's consumer R8 rules end-to-end. + isMinifyEnabled = true proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro") + // Debug signing so CI/Maestro can install the minified APK without release secrets; + // this app is a test target only, never published. + signingConfig = signingConfigs.getByName("debug") } } compileOptions { diff --git a/test-apps/e2etests/src/main/java/com/revenuecat/e2etests/E2ETestsApplication.kt b/test-apps/e2etests/src/main/java/com/revenuecat/e2etests/E2ETestsApplication.kt index 3f0e77cc8d..d2e1dff7f2 100644 --- a/test-apps/e2etests/src/main/java/com/revenuecat/e2etests/E2ETestsApplication.kt +++ b/test-apps/e2etests/src/main/java/com/revenuecat/e2etests/E2ETestsApplication.kt @@ -1,11 +1,14 @@ package com.revenuecat.e2etests import android.app.Application +import com.revenuecat.purchases.DangerousSettings +import com.revenuecat.purchases.InternalRevenueCatAPI import com.revenuecat.purchases.LogLevel import com.revenuecat.purchases.Purchases import com.revenuecat.purchases.PurchasesConfiguration class E2ETestsApplication : Application() { + @OptIn(InternalRevenueCatAPI::class) override fun onCreate() { super.onCreate() @@ -16,7 +19,12 @@ class E2ETestsApplication : Application() { PurchasesConfiguration.Builder( context = this, apiKey = Constants.API_KEY, - ).build(), + ) + // This app runs as a minified release build in the Maestro e2e CI job (to exercise + // the SDK's consumer R8 rules), but uses a Test Store API key. Opt in to allow that + // combination, which the SDK otherwise blocks in non-debuggable builds. + .dangerousSettings(DangerousSettings.forTestStoreInReleaseBuild()) + .build(), ) } } diff --git a/ui/revenuecatui/consumer-rules.pro b/ui/revenuecatui/consumer-rules.pro index cd6f315b10..a2e2893fcf 100644 --- a/ui/revenuecatui/consumer-rules.pro +++ b/ui/revenuecatui/consumer-rules.pro @@ -1,2 +1,7 @@ -dontwarn com.emergetools.snapshots.annotations.IgnoreEmergeSnapshot -dontwarn com.emergetools.snapshots.annotations.EmergeSnapshotConfig + +# Existence is probed via Class.forName from the purchases module (canUsePaywallUI in +# common/utils.kt) to detect whether the paywalls UI module is on the classpath. Force-keep the +# file class so the probe resolves even if the app doesn't otherwise reference it directly. +-keep class com.revenuecat.purchases.ui.revenuecatui.PaywallKt