Skip to content
Draft
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .circleci/config.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 7 additions & 0 deletions feature/galaxy/consumer-rules.pro
Original file line number Diff line number Diff line change
@@ -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 {
<init>(...);
}
18 changes: 17 additions & 1 deletion purchases/consumer-rules.pro
Original file line number Diff line number Diff line change
@@ -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.** { *; }
Comment thread
tonidero marked this conversation as resolved.
Outdated

# 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.** { *; }
Comment thread
tonidero marked this conversation as resolved.
Outdated

# 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.** {
<fields>;
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
Expand Down Expand Up @@ -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); }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
/**
Expand All @@ -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(

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I do wonder if we would want to end up making this public... But for now, kept it internal

allowTestStoreInReleaseBuild = true,
)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<Application>()
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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -506,7 +506,8 @@ class AppConfigTest {
"autoSyncPurchases=true, " +
"customEntitlementComputation=false, " +
"uiPreviewMode=false, " +
"applyObfuscatedAccountIdToSubscriptionChanges=false), " +
"applyObfuscatedAccountIdToSubscriptionChanges=false, " +
"allowTestStoreInReleaseBuild=false), " +
"languageTag='', " +
"versionName='', " +
"packageName='', " +
Expand Down
6 changes: 5 additions & 1 deletion test-apps/e2etests/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
@@ -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()

Expand All @@ -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(),
)
}
}
5 changes: 5 additions & 0 deletions ui/revenuecatui/consumer-rules.pro
Original file line number Diff line number Diff line change
@@ -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
Comment thread
JayShortway marked this conversation as resolved.