From a5a60e9e9f8d6a202cdcccdde6d42207d7eda965 Mon Sep 17 00:00:00 2001 From: Toni Rico Date: Mon, 8 Jun 2026 15:42:36 +0200 Subject: [PATCH 1/6] Narrow consumer R8 rules to keep only what R8 cannot trace The consumer rules kept the entire SDK via `-keep class com.revenuecat.** { *; }`, forcing every consuming app's R8 to retain all classes and members and blocking shrinking/obfuscation of dead code and internals. Replace it with conservative, targeted keeps: stable public API names (`-keepnames`), all `@Serializable` models (kotlinx + custom KSerializers), enum constant names (the EnumDeserializerWithDefault reflective name match fails silently), the Galaxy reflective constructor, and the PaywallKt existence probe. Because R8 only runs in a consuming app, the Maestro e2e job now builds and installs the minified release APK so the purchase flow exercises these rules end-to-end. Co-Authored-By: Claude Opus 4.8 (1M context) --- .circleci/config.yml | 4 ++-- feature/galaxy/consumer-rules.pro | 7 +++++++ purchases/consumer-rules.pro | 18 +++++++++++++++++- test-apps/e2etests/build.gradle.kts | 6 +++++- ui/revenuecatui/consumer-rules.pro | 5 +++++ 5 files changed, 36 insertions(+), 4 deletions(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index 64f6599b83..b29944b57b 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -905,12 +905,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..42201f1fe7 100644 --- a/purchases/consumer-rules.pro +++ b/purchases/consumer-rules.pro @@ -1,4 +1,19 @@ --keep class com.revenuecat.** { *; } +# Keep public API names stable for consumers; members may still be shrunk/optimized by R8. +-keepnames class com.revenuecat.purchases.** { *; } + +# Keep all @Serializable model classes intact so kotlinx (de)serialization and the SDK's custom +# KSerializers (polymorphic/sealed hierarchies, surrogates) keep working under minification. +-keep @kotlinx.serialization.Serializable 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 @@ -49,6 +64,7 @@ # 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); } 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/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 From 24ee3003aaf4c5087db8e46088f3e2cb39e34881 Mon Sep 17 00:00:00 2001 From: Toni Rico Date: Mon, 8 Jun 2026 16:57:57 +0200 Subject: [PATCH 2/6] Allow Test Store in release build for e2e tests via internal opt-in The Maestro e2e job now runs against a minified release build, which is non-debuggable and trips the guard in PurchasesFactory that blocks Test Store keys in release builds (it shows SimulatedStoreErrorDialogActivity, crashing the app on launch). uiPreviewMode bypasses the guard but disables billing, so it can't drive the real purchase flow. Add an internal opt-in, DangerousSettings.forTestStoreInReleaseBuild() (@InternalRevenueCatAPI, mirroring forPreviewMode()), that the e2etests app sets so it can use the Test Store in a true release build. Extend the guard to honor it. For RevenueCat-internal testing only. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../revenuecat/purchases/DangerousSettings.kt | 16 +++++++++- .../revenuecat/purchases/PurchasesFactory.kt | 12 +++++--- .../purchases/PurchasesFactoryTest.kt | 30 +++++++++++++++++++ .../e2etests/E2ETestsApplication.kt | 10 ++++++- 4 files changed, 62 insertions(+), 6 deletions(-) diff --git a/purchases/src/main/kotlin/com/revenuecat/purchases/DangerousSettings.kt b/purchases/src/main/kotlin/com/revenuecat/purchases/DangerousSettings.kt index 72aa1b4400..c904402298 100644 --- a/purchases/src/main/kotlin/com/revenuecat/purchases/DangerousSettings.kt +++ b/purchases/src/main/kotlin/com/revenuecat/purchases/DangerousSettings.kt @@ -22,8 +22,10 @@ public class DangerousSettings internal constructor( internal val uiPreviewMode: Boolean = false, internal val applyObfuscatedAccountIdToSubscriptionChanges: Boolean = false, + + internal val allowTestStoreInReleaseBuild: Boolean = false, ) : Parcelable { - public constructor(autoSyncPurchases: Boolean = true) : this(autoSyncPurchases, false, false, false) + public constructor(autoSyncPurchases: Boolean = true) : this(autoSyncPurchases, false, false, false, false) public companion object { /** @@ -39,5 +41,17 @@ public class DangerousSettings internal constructor( uiPreviewMode = true, applyObfuscatedAccountIdToSubscriptionChanges = false, ) + + /** + * Creates a [DangerousSettings] that allows configuring the SDK with a Test Store API key + * in a release (non-debuggable) build, bypassing the safety check that otherwise blocks it. + * + * For RevenueCat-internal end-to-end testing only. Do not use in production apps. + */ + @InternalRevenueCatAPI + @JvmStatic + public fun forTestStoreInReleaseBuild(): DangerousSettings = DangerousSettings( + allowTestStoreInReleaseBuild = true, + ) } } diff --git a/purchases/src/main/kotlin/com/revenuecat/purchases/PurchasesFactory.kt b/purchases/src/main/kotlin/com/revenuecat/purchases/PurchasesFactory.kt index c313f651c1..ff95f8518c 100644 --- a/purchases/src/main/kotlin/com/revenuecat/purchases/PurchasesFactory.kt +++ b/purchases/src/main/kotlin/com/revenuecat/purchases/PurchasesFactory.kt @@ -518,10 +518,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/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/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(), ) } } From bb7ce447ac1b97b791cfb109f4ad271681b504cb Mon Sep 17 00:00:00 2001 From: Toni Rico Date: Mon, 8 Jun 2026 17:20:11 +0200 Subject: [PATCH 3/6] Fix failing test --- .../test/java/com/revenuecat/purchases/common/AppConfigTest.kt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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 5a360e1c7a..56d6d22e5d 100644 --- a/purchases/src/test/java/com/revenuecat/purchases/common/AppConfigTest.kt +++ b/purchases/src/test/java/com/revenuecat/purchases/common/AppConfigTest.kt @@ -506,7 +506,8 @@ class AppConfigTest { "autoSyncPurchases=true, " + "customEntitlementComputation=false, " + "uiPreviewMode=false, " + - "applyObfuscatedAccountIdToSubscriptionChanges=false), " + + "applyObfuscatedAccountIdToSubscriptionChanges=false, " + + "allowTestStoreInReleaseBuild=false), " + "languageTag='', " + "versionName='', " + "packageName='', " + From 7d13656d064178a8b1d7161cd21893dacc80d4d6 Mon Sep 17 00:00:00 2001 From: Toni Rico Date: Mon, 8 Jun 2026 17:54:48 +0200 Subject: [PATCH 4/6] Remove unnecessary rules --- purchases/consumer-rules.pro | 50 ------------------------------------ 1 file changed, 50 deletions(-) diff --git a/purchases/consumer-rules.pro b/purchases/consumer-rules.pro index 42201f1fe7..526624be2e 100644 --- a/purchases/consumer-rules.pro +++ b/purchases/consumer-rules.pro @@ -1,10 +1,3 @@ -# Keep public API names stable for consumers; members may still be shrunk/optimized by R8. --keepnames class com.revenuecat.purchases.** { *; } - -# Keep all @Serializable model classes intact so kotlinx (de)serialization and the SDK's custom -# KSerializers (polymorphic/sealed hierarchies, surrogates) keep working under minification. --keep @kotlinx.serialization.Serializable 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. @@ -22,54 +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 From cf14371ddf66bd3b11ddc6e5f1d240266f3d73a9 Mon Sep 17 00:00:00 2001 From: Toni Rico Date: Tue, 9 Jun 2026 11:58:34 +0200 Subject: [PATCH 5/6] Write down FQN as serializer name --- .../revenuecat/purchases/EntitlementInfo.kt | 1 + .../common/workflows/WorkflowModels.kt | 1 + .../paywalls/components/ButtonComponent.kt | 3 ++ .../paywalls/components/CarouselComponent.kt | 3 ++ .../paywalls/components/PaywallAnimation.kt | 1 + .../paywalls/components/PaywallTransition.kt | 2 + .../components/PurchaseButtonComponent.kt | 1 + .../paywalls/components/StackComponent.kt | 1 + .../paywalls/components/TimelineComponent.kt | 1 + .../components/properties/Alignment.kt | 3 ++ .../paywalls/components/properties/Badge.kt | 1 + .../paywalls/components/properties/FitMode.kt | 1 + .../components/properties/FlexDistribution.kt | 1 + .../paywalls/components/properties/Font.kt | 2 + .../EnumDeserializerWithDefault.kt | 7 +-- .../EnumDeserializerWithDefaultTest.kt | 54 +++++++++++++++++++ 16 files changed, 80 insertions(+), 3 deletions(-) create mode 100644 purchases/src/test/java/com/revenuecat/purchases/utils/serializers/EnumDeserializerWithDefaultTest.kt 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/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/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) + } +} From 382d4e2da7efe87d2a3fdb1dace4e8229e38cef2 Mon Sep 17 00:00:00 2001 From: Toni Rico Date: Thu, 11 Jun 2026 18:23:50 +0200 Subject: [PATCH 6/6] Fix detekt --- .../main/kotlin/com/revenuecat/purchases/DangerousSettings.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/purchases/src/main/kotlin/com/revenuecat/purchases/DangerousSettings.kt b/purchases/src/main/kotlin/com/revenuecat/purchases/DangerousSettings.kt index 632bced335..434022ed3d 100644 --- a/purchases/src/main/kotlin/com/revenuecat/purchases/DangerousSettings.kt +++ b/purchases/src/main/kotlin/com/revenuecat/purchases/DangerousSettings.kt @@ -28,7 +28,7 @@ public class DangerousSettings internal constructor( */ @InternalRevenueCatAPI public val useWorkflows: Boolean = false, - + internal val allowTestStoreInReleaseBuild: Boolean = false, ) : Parcelable { @OptIn(InternalRevenueCatAPI::class) @@ -69,7 +69,7 @@ public class DangerousSettings internal constructor( applyObfuscatedAccountIdToSubscriptionChanges = false, useWorkflows = true, ) - + @InternalRevenueCatAPI @JvmStatic public fun forTestStoreInReleaseBuild(): DangerousSettings = DangerousSettings(