diff --git a/local.properties.example b/local.properties.example
index 8f5967d9ee..6e9dbb8d6f 100644
--- a/local.properties.example
+++ b/local.properties.example
@@ -74,3 +74,10 @@
## Debug flags
## ──────────────────────────────────────────────
#ENABLE_EXTRA_REQUEST_LOGGING=true
+
+## ──────────────────────────────────────────────
+## Perf tester app (test-apps:perftester)
+## Test Store API key recommended: the SDK selects SimulatedStoreBillingWrapper
+## from the key type, so products resolve without any Play setup.
+## ──────────────────────────────────────────────
+#PERF_TESTER_API_KEY=your_test_store_key
diff --git a/settings.gradle.kts b/settings.gradle.kts
index 78048168ed..d19ac1895f 100644
--- a/settings.gradle.kts
+++ b/settings.gradle.kts
@@ -74,4 +74,5 @@ include(":detekt-rules")
include(":dokka-hide-internal")
include(":baselineprofile")
include(":test-apps:e2etests")
+include(":test-apps:perftester")
include(":examples:rcttester")
diff --git a/test-apps/perftester/.gitignore b/test-apps/perftester/.gitignore
new file mode 100644
index 0000000000..796b96d1c4
--- /dev/null
+++ b/test-apps/perftester/.gitignore
@@ -0,0 +1 @@
+/build
diff --git a/test-apps/perftester/README.md b/test-apps/perftester/README.md
new file mode 100644
index 0000000000..e0e48578eb
--- /dev/null
+++ b/test-apps/perftester/README.md
@@ -0,0 +1,38 @@
+# perftester
+
+Measures `configure()` + `getOfferings()` latency and post-readiness memory, baseline vs.
+workflows, from a real app.
+
+## Setup
+
+Set `PERF_TESTER_API_KEY` in `local.properties` (a Test Store key: products then resolve through
+`SimulatedStoreBillingWrapper` with no Play setup) and install:
+
+ ./gradlew :test-apps:perftester:installDebug
+
+## Use
+
+- Toggle picks the arm: baseline or workflows (`DangerousSettings.forWorkflows()`).
+- "Configure + getOfferings" runs one measured cycle: `close()` if configured, record `had_*` cache
+ flags, `configure()`, `getOfferings()`, then (workflows arm) `ui_config` and first workflow body
+ readiness. Memory is sampled immediately before `configure()` and after that readiness work,
+ so the `memory_delta_*` fields measure the memory retained by the cycle after workflows have
+ loaded. One JSON line is appended to `results.jsonl` in the app's external files dir per press.
+- "Clear SDK caches" deletes the SDK prefs and cache dirs, so the next press is a cold sample
+ without reinstalling.
+- The first press after install/clear is cold; later presses are warm. The `had_*` flags in the data
+ record which one each sample actually was.
+
+## Scripted runs
+
+ adb shell am force-stop com.revenuecat.perftester
+ adb shell am start -n com.revenuecat.perftester/.MainActivity -e autorun true -e workflows true
+
+Loop that with `sleep` between iterations, then:
+
+ adb pull /sdcard/Android/data/com.revenuecat.perftester/files/results.jsonl .
+
+Each line is one press; aggregate percentiles per arm (`workflows_enabled`) and cache state
+(`had_offerings_cache`, `had_remote_config_cache`). RSS fields are in KiB; `rss_anon` is the
+anonymous resident portion and `rss_file` is the file-backed resident portion. This is a
+post-readiness measurement, not a peak-memory measurement.
diff --git a/test-apps/perftester/build.gradle.kts b/test-apps/perftester/build.gradle.kts
new file mode 100644
index 0000000000..eb68efd9ce
--- /dev/null
+++ b/test-apps/perftester/build.gradle.kts
@@ -0,0 +1,62 @@
+import java.util.Properties
+
+plugins {
+ alias(libs.plugins.android.application)
+ alias(libs.plugins.kotlin.android)
+ alias(libs.plugins.compose.compiler)
+ alias(libs.plugins.kotlin.serialization)
+}
+
+val localProperties = Properties().apply {
+ val localPropsFile = rootProject.file("local.properties")
+ if (localPropsFile.exists()) {
+ localPropsFile.inputStream().use { load(it) }
+ }
+}
+
+android {
+ namespace = "com.revenuecat.perftester"
+ compileSdk = 36
+
+ defaultConfig {
+ applicationId = "com.revenuecat.perftester"
+ minSdk = 24
+ targetSdk = 36
+ versionCode = 1
+ versionName = "1.0"
+ missingDimensionStrategy("apis", "defaults")
+
+ buildConfigField(
+ "String",
+ "PERF_TESTER_API_KEY",
+ "\"${localProperties.getProperty("PERF_TESTER_API_KEY", "")}\"",
+ )
+ }
+
+ buildFeatures {
+ compose = true
+ buildConfig = true
+ }
+ compileOptions {
+ sourceCompatibility = JavaVersion.VERSION_11
+ targetCompatibility = JavaVersion.VERSION_11
+ }
+ kotlinOptions {
+ jvmTarget = "11"
+ }
+}
+
+dependencies {
+ implementation(project(":purchases"))
+
+ implementation(libs.androidx.core)
+ implementation(libs.androidx.lifecycle.runtime.ktx)
+ implementation(libs.activity.compose)
+ implementation(platform(libs.compose.bom))
+ implementation(libs.compose.ui)
+ implementation(libs.compose.material3)
+ implementation(libs.kotlinx.serialization.json)
+ implementation(libs.coroutines.core)
+
+ testImplementation(libs.androidx.junit)
+}
diff --git a/test-apps/perftester/src/main/AndroidManifest.xml b/test-apps/perftester/src/main/AndroidManifest.xml
new file mode 100644
index 0000000000..7f02af6efb
--- /dev/null
+++ b/test-apps/perftester/src/main/AndroidManifest.xml
@@ -0,0 +1,23 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/test-apps/perftester/src/main/java/com/revenuecat/perftester/MainActivity.kt b/test-apps/perftester/src/main/java/com/revenuecat/perftester/MainActivity.kt
new file mode 100644
index 0000000000..929b4518c5
--- /dev/null
+++ b/test-apps/perftester/src/main/java/com/revenuecat/perftester/MainActivity.kt
@@ -0,0 +1,113 @@
+package com.revenuecat.perftester
+
+import android.os.Bundle
+import androidx.activity.ComponentActivity
+import androidx.activity.compose.setContent
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material3.Button
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Switch
+import androidx.compose.material3.Text
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.unit.dp
+import androidx.lifecycle.lifecycleScope
+import com.revenuecat.purchases.Purchases
+import kotlinx.coroutines.launch
+
+class MainActivity : ComponentActivity() {
+
+ private var pressIndex = 0
+ private var autorunHandled = false
+
+ @Suppress("LongMethod")
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ val apiKey = BuildConfig.PERF_TESTER_API_KEY
+ val keyMissing = apiKey.isBlank()
+ val runner = PerfCycleRunner(applicationContext, apiKey)
+
+ setContent {
+ var workflowsEnabled by remember {
+ mutableStateOf(intent.getStringExtra("workflows")?.toBoolean() ?: false)
+ }
+ var status by remember { mutableStateOf("Results: ${runner.resultsFilePath}") }
+ var running by remember { mutableStateOf(false) }
+
+ val runCycle = {
+ if (!running) {
+ running = true
+ pressIndex += 1
+ status = "Running press #$pressIndex..."
+ lifecycleScope.launch {
+ val result = runner.runCycle(workflowsEnabled, pressIndex)
+ running = false
+ status = buildString {
+ appendLine("press #${result.pressIndexInProcess} workflows=${result.workflowsEnabled}")
+ appendLine("had_offerings_cache=${result.hadOfferingsCache}")
+ appendLine("configure_to_offerings_ms=${result.configureToOfferingsMs}")
+ appendLine("get_offerings_ms=${result.getOfferingsMs}")
+ appendLine("success=${result.success}")
+ result.errorMessage?.let { appendLine("error: $it") }
+ append("file: ${runner.resultsFilePath}")
+ }
+ }
+ }
+ }
+
+ LaunchedEffect(Unit) {
+ if (!autorunHandled && !keyMissing && intent.getStringExtra("autorun")?.toBoolean() == true) {
+ autorunHandled = true
+ runCycle()
+ }
+ }
+
+ MaterialTheme {
+ Column(
+ modifier = Modifier.fillMaxSize().padding(24.dp),
+ verticalArrangement = Arrangement.spacedBy(16.dp),
+ ) {
+ Row(
+ horizontalArrangement = Arrangement.spacedBy(8.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Switch(
+ checked = workflowsEnabled,
+ onCheckedChange = { workflowsEnabled = it },
+ enabled = !running,
+ )
+ Text(if (workflowsEnabled) "workflows" else "baseline")
+ }
+ Button(onClick = runCycle, enabled = !running && !keyMissing) {
+ Text("Configure + getOfferings")
+ }
+ Button(
+ onClick = {
+ if (Purchases.isConfigured) {
+ Purchases.sharedInstance.close()
+ }
+ SdkCaches.clearAll(applicationContext)
+ status = "SDK caches cleared."
+ },
+ enabled = !running,
+ ) {
+ Text("Clear SDK caches")
+ }
+ if (keyMissing) {
+ Text("Set PERF_TESTER_API_KEY in local.properties and rebuild.")
+ }
+ Text(status)
+ }
+ }
+ }
+ }
+}
diff --git a/test-apps/perftester/src/main/java/com/revenuecat/perftester/PerfCycleRunner.kt b/test-apps/perftester/src/main/java/com/revenuecat/perftester/PerfCycleRunner.kt
new file mode 100644
index 0000000000..9dba61be18
--- /dev/null
+++ b/test-apps/perftester/src/main/java/com/revenuecat/perftester/PerfCycleRunner.kt
@@ -0,0 +1,176 @@
+package com.revenuecat.perftester
+
+import android.content.Context
+import android.os.Build
+import android.os.Process
+import android.os.SystemClock
+import com.revenuecat.purchases.DangerousSettings
+import com.revenuecat.purchases.InternalRevenueCatAPI
+import com.revenuecat.purchases.Offerings
+import com.revenuecat.purchases.Purchases
+import com.revenuecat.purchases.PurchasesConfiguration
+import com.revenuecat.purchases.getOfferingsWith
+import kotlinx.coroutines.suspendCancellableCoroutine
+import kotlinx.coroutines.withTimeout
+import java.io.File
+import kotlin.coroutines.resume
+
+@OptIn(InternalRevenueCatAPI::class)
+internal class PerfCycleRunner(
+ private val context: Context,
+ private val apiKey: String,
+) {
+
+ private val resultsFile = File(context.getExternalFilesDir(null), "results.jsonl")
+ private val writer = ResultsWriter(resultsFile)
+
+ val resultsFilePath: String get() = resultsFile.absolutePath
+
+ @Suppress("LongMethod")
+ suspend fun runCycle(workflowsEnabled: Boolean, pressIndex: Int): PerfResult {
+ if (Purchases.isConfigured) {
+ Purchases.sharedInstance.close()
+ }
+ val hadOfferings = SdkCaches.hadOfferingsCache(context, apiKey)
+ val hadRemoteConfig = SdkCaches.hadRemoteConfigCache(context)
+ val hadBlobs = SdkCaches.hadBlobsCache(context)
+ val processStartToPressMs = SystemClock.elapsedRealtime() - Process.getStartElapsedRealtime()
+ val memoryBefore = ProcessMemorySnapshot.capture()
+
+ val dangerousSettings = if (workflowsEnabled) {
+ DangerousSettings.forWorkflows()
+ } else {
+ DangerousSettings()
+ }
+ val configuration = PurchasesConfiguration.Builder(context, apiKey)
+ .dangerousSettings(dangerousSettings)
+ .build()
+
+ val t0 = SystemClock.elapsedRealtimeNanos()
+ Purchases.configure(configuration)
+ val t1 = SystemClock.elapsedRealtimeNanos()
+
+ var t2 = 0L
+ var errorMessage: String? = null
+ var offerings: Offerings? = null
+ val timedOut = runCatching {
+ withTimeout(CALL_TIMEOUT_MILLIS) {
+ suspendCancellableCoroutine { continuation ->
+ Purchases.sharedInstance.getOfferingsWith(
+ onError = { error ->
+ t2 = SystemClock.elapsedRealtimeNanos()
+ errorMessage = error.toString()
+ continuation.resume(Unit)
+ },
+ onSuccess = { received ->
+ t2 = SystemClock.elapsedRealtimeNanos()
+ offerings = received
+ continuation.resume(Unit)
+ },
+ )
+ }
+ }
+ }.isFailure
+ if (timedOut && t2 == 0L) {
+ errorMessage = "getOfferings did not complete within ${CALL_TIMEOUT_MILLIS / MILLIS_PER_SECOND}s"
+ }
+
+ val configureToOfferingsMs = if (t2 != 0L) (t2 - t0).nanosToMillis() else null
+ val getOfferingsMs = if (t2 != 0L) (t2 - t1).nanosToMillis() else null
+ val success = errorMessage == null && offerings != null
+
+ var uiConfigMs: Double? = null
+ val uiConfigIsDefault: Boolean? = null
+ var workflowResolutionMs: Double? = null
+ var workflowBodyMs: Double? = null
+ var workflowResolved: Boolean? = null
+ if (workflowsEnabled && success) {
+ runCatching {
+ withTimeout(CALL_TIMEOUT_MILLIS) {
+ val started = SystemClock.elapsedRealtimeNanos()
+ Purchases.sharedInstance.awaitGetUiConfig()
+ uiConfigMs = (SystemClock.elapsedRealtimeNanos() - started).nanosToMillis()
+ }
+ }
+ val offeringIds = offerings?.let { received ->
+ listOfNotNull(received.current?.identifier) +
+ received.all.keys.filter { it != received.current?.identifier }
+ }.orEmpty()
+ var workflowId: String? = null
+ runCatching {
+ withTimeout(CALL_TIMEOUT_MILLIS) {
+ val started = SystemClock.elapsedRealtimeNanos()
+ workflowId = offeringIds.firstNotNullOfOrNull { offeringId ->
+ Purchases.sharedInstance.workflowIdForOfferingId(offeringId)
+ }
+ workflowResolutionMs = (SystemClock.elapsedRealtimeNanos() - started).nanosToMillis()
+ }
+ }
+ workflowId?.let { id ->
+ // NOTE: this withTimeout cannot force-cancel awaitGetWorkflow — that SDK API uses
+ // non-cancellable suspendCoroutine, so a hung workflow-body fetch will keep runCycle
+ // waiting past CALL_TIMEOUT_MILLIS. The offerings measurement above is properly bounded.
+ workflowResolved = runCatching {
+ withTimeout(CALL_TIMEOUT_MILLIS) {
+ val started = SystemClock.elapsedRealtimeNanos()
+ Purchases.sharedInstance.awaitGetWorkflow(id)
+ workflowBodyMs = (SystemClock.elapsedRealtimeNanos() - started).nanosToMillis()
+ }
+ }.isSuccess
+ }
+ }
+
+ // getOfferings plus the workflows readiness calls above are complete here. This is the
+ // retained-state sample, after the workflow data has been parsed and cached.
+ val memoryAfter = ProcessMemorySnapshot.capture()
+
+ val result = PerfResult(
+ timestampEpochMs = System.currentTimeMillis(),
+ sdkVersion = Purchases.frameworkVersion,
+ deviceModel = "${Build.MANUFACTURER} ${Build.MODEL}",
+ androidApiLevel = Build.VERSION.SDK_INT,
+ workflowsEnabled = workflowsEnabled,
+ pressIndexInProcess = pressIndex,
+ processStartToPressMs = processStartToPressMs,
+ hadOfferingsCache = hadOfferings,
+ hadRemoteConfigCache = hadRemoteConfig,
+ hadBlobsCache = hadBlobs,
+ memoryBeforeRssKb = memoryBefore.rssKb,
+ memoryAfterRssKb = memoryAfter.rssKb,
+ memoryDeltaRssKb = memoryAfter.rssKb.deltaFrom(memoryBefore.rssKb),
+ memoryBeforeRssAnonKb = memoryBefore.rssAnonKb,
+ memoryAfterRssAnonKb = memoryAfter.rssAnonKb,
+ memoryDeltaRssAnonKb = memoryAfter.rssAnonKb.deltaFrom(memoryBefore.rssAnonKb),
+ memoryBeforeRssFileKb = memoryBefore.rssFileKb,
+ memoryAfterRssFileKb = memoryAfter.rssFileKb,
+ memoryDeltaRssFileKb = memoryAfter.rssFileKb.deltaFrom(memoryBefore.rssFileKb),
+ memoryBeforeJavaHeapKb = memoryBefore.javaHeapKb,
+ memoryAfterJavaHeapKb = memoryAfter.javaHeapKb,
+ memoryDeltaJavaHeapKb = memoryAfter.javaHeapKb.deltaFrom(memoryBefore.javaHeapKb),
+ memoryBeforeNativeHeapKb = memoryBefore.nativeHeapKb,
+ memoryAfterNativeHeapKb = memoryAfter.nativeHeapKb,
+ memoryDeltaNativeHeapKb = memoryAfter.nativeHeapKb.deltaFrom(memoryBefore.nativeHeapKb),
+ configureToOfferingsMs = configureToOfferingsMs,
+ getOfferingsMs = getOfferingsMs,
+ uiConfigMs = uiConfigMs,
+ uiConfigIsDefault = uiConfigIsDefault,
+ workflowResolutionMs = workflowResolutionMs,
+ workflowBodyMs = workflowBodyMs,
+ workflowResolved = workflowResolved,
+ success = success,
+ errorMessage = errorMessage,
+ offeringsCount = offerings?.all?.size,
+ currentOfferingId = offerings?.current?.identifier,
+ )
+ writer.append(result)
+ return result
+ }
+
+ private fun Long.nanosToMillis(): Double = this / NANOS_PER_MILLI
+
+ private companion object {
+ private const val CALL_TIMEOUT_MILLIS = 120_000L
+ private const val MILLIS_PER_SECOND = 1_000L
+ private const val NANOS_PER_MILLI = 1_000_000.0
+ }
+}
diff --git a/test-apps/perftester/src/main/java/com/revenuecat/perftester/PerfResult.kt b/test-apps/perftester/src/main/java/com/revenuecat/perftester/PerfResult.kt
new file mode 100644
index 0000000000..163d5adfb2
--- /dev/null
+++ b/test-apps/perftester/src/main/java/com/revenuecat/perftester/PerfResult.kt
@@ -0,0 +1,53 @@
+package com.revenuecat.perftester
+
+import kotlinx.serialization.SerialName
+import kotlinx.serialization.Serializable
+import kotlinx.serialization.json.Json
+
+@Serializable
+internal data class PerfResult(
+ @SerialName("schema_version") val schemaVersion: Int = 2,
+ @SerialName("source") val source: String = "perftester-app",
+ @SerialName("timestamp_epoch_ms") val timestampEpochMs: Long,
+ @SerialName("sdk_version") val sdkVersion: String,
+ @SerialName("device_model") val deviceModel: String,
+ @SerialName("android_api_level") val androidApiLevel: Int,
+ @SerialName("workflows_enabled") val workflowsEnabled: Boolean,
+ @SerialName("press_index_in_process") val pressIndexInProcess: Int,
+ @SerialName("process_start_to_press_ms") val processStartToPressMs: Long,
+ @SerialName("had_offerings_cache") val hadOfferingsCache: Boolean,
+ @SerialName("had_remote_config_cache") val hadRemoteConfigCache: Boolean,
+ @SerialName("had_blobs_cache") val hadBlobsCache: Boolean,
+ @SerialName("memory_before_rss_kb") val memoryBeforeRssKb: Long?,
+ @SerialName("memory_after_rss_kb") val memoryAfterRssKb: Long?,
+ @SerialName("memory_delta_rss_kb") val memoryDeltaRssKb: Long?,
+ @SerialName("memory_before_rss_anon_kb") val memoryBeforeRssAnonKb: Long?,
+ @SerialName("memory_after_rss_anon_kb") val memoryAfterRssAnonKb: Long?,
+ @SerialName("memory_delta_rss_anon_kb") val memoryDeltaRssAnonKb: Long?,
+ @SerialName("memory_before_rss_file_kb") val memoryBeforeRssFileKb: Long?,
+ @SerialName("memory_after_rss_file_kb") val memoryAfterRssFileKb: Long?,
+ @SerialName("memory_delta_rss_file_kb") val memoryDeltaRssFileKb: Long?,
+ @SerialName("memory_before_java_heap_kb") val memoryBeforeJavaHeapKb: Long?,
+ @SerialName("memory_after_java_heap_kb") val memoryAfterJavaHeapKb: Long?,
+ @SerialName("memory_delta_java_heap_kb") val memoryDeltaJavaHeapKb: Long?,
+ @SerialName("memory_before_native_heap_kb") val memoryBeforeNativeHeapKb: Long?,
+ @SerialName("memory_after_native_heap_kb") val memoryAfterNativeHeapKb: Long?,
+ @SerialName("memory_delta_native_heap_kb") val memoryDeltaNativeHeapKb: Long?,
+ @SerialName("configure_to_offerings_ms") val configureToOfferingsMs: Double?,
+ @SerialName("get_offerings_ms") val getOfferingsMs: Double?,
+ @SerialName("ui_config_ms") val uiConfigMs: Double?,
+ @SerialName("ui_config_is_default") val uiConfigIsDefault: Boolean?,
+ @SerialName("workflow_resolution_ms") val workflowResolutionMs: Double?,
+ @SerialName("workflow_body_ms") val workflowBodyMs: Double?,
+ @SerialName("workflow_resolved") val workflowResolved: Boolean?,
+ @SerialName("success") val success: Boolean,
+ @SerialName("error_message") val errorMessage: String?,
+ @SerialName("offerings_count") val offeringsCount: Int?,
+ @SerialName("current_offering_id") val currentOfferingId: String?,
+) {
+ fun toJsonLine(): String = json.encodeToString(serializer(), this)
+
+ private companion object {
+ private val json = Json { encodeDefaults = true }
+ }
+}
diff --git a/test-apps/perftester/src/main/java/com/revenuecat/perftester/ProcessMemorySnapshot.kt b/test-apps/perftester/src/main/java/com/revenuecat/perftester/ProcessMemorySnapshot.kt
new file mode 100644
index 0000000000..c058939e16
--- /dev/null
+++ b/test-apps/perftester/src/main/java/com/revenuecat/perftester/ProcessMemorySnapshot.kt
@@ -0,0 +1,50 @@
+package com.revenuecat.perftester
+
+import android.os.Debug
+import java.io.File
+
+/** Process memory sampled before configuration and after all requested readiness work completes. */
+internal data class ProcessMemorySnapshot(
+ val rssKb: Long?,
+ val rssAnonKb: Long?,
+ val rssFileKb: Long?,
+ val javaHeapKb: Long?,
+ val nativeHeapKb: Long?,
+) {
+
+ companion object {
+ fun capture(): ProcessMemorySnapshot {
+ val status = runCatching {
+ File("/proc/self/status").useLines { lines ->
+ lines.mapNotNull { line ->
+ val separator = line.indexOf(':')
+ if (separator < 0) {
+ null
+ } else {
+ val key = line.substring(0, separator)
+ val value = line.substring(separator + 1)
+ .trim()
+ .removeSuffix(" kB")
+ .trim()
+ .toLongOrNull()
+ key to value
+ }
+ }.toMap()
+ }
+ }.getOrDefault(emptyMap())
+
+ val memoryInfo = Debug.MemoryInfo()
+ Debug.getMemoryInfo(memoryInfo)
+ return ProcessMemorySnapshot(
+ rssKb = status["VmRSS"],
+ rssAnonKb = status["RssAnon"],
+ rssFileKb = status["RssFile"],
+ javaHeapKb = memoryInfo.getMemoryStat("summary.java-heap")?.toLongOrNull(),
+ nativeHeapKb = memoryInfo.getMemoryStat("summary.native-heap")?.toLongOrNull(),
+ )
+ }
+ }
+}
+
+internal fun Long?.deltaFrom(before: Long?): Long? =
+ if (this != null && before != null) this - before else null
diff --git a/test-apps/perftester/src/main/java/com/revenuecat/perftester/ResultsWriter.kt b/test-apps/perftester/src/main/java/com/revenuecat/perftester/ResultsWriter.kt
new file mode 100644
index 0000000000..7ffeaef608
--- /dev/null
+++ b/test-apps/perftester/src/main/java/com/revenuecat/perftester/ResultsWriter.kt
@@ -0,0 +1,10 @@
+package com.revenuecat.perftester
+
+import java.io.File
+
+internal class ResultsWriter(private val resultsFile: File) {
+ fun append(result: PerfResult) {
+ resultsFile.parentFile?.mkdirs()
+ resultsFile.appendText(result.toJsonLine() + "\n")
+ }
+}
diff --git a/test-apps/perftester/src/main/java/com/revenuecat/perftester/SdkCaches.kt b/test-apps/perftester/src/main/java/com/revenuecat/perftester/SdkCaches.kt
new file mode 100644
index 0000000000..129c687cf8
--- /dev/null
+++ b/test-apps/perftester/src/main/java/com/revenuecat/perftester/SdkCaches.kt
@@ -0,0 +1,39 @@
+package com.revenuecat.perftester
+
+import android.content.Context
+import java.io.File
+
+/**
+ * Inspects and clears the SDK's on-device caches. Locations mirror the SDK:
+ * - Prefs file: RevenueCatBackupAgent.REVENUECAT_PREFS_FILE_NAME
+ * - Offerings key: DeviceCache offeringsResponseCacheKey ("com.revenuecat.purchases..offeringsResponse")
+ * - Remote config + blobs: RemoteConfigDiskCache/RemoteConfigBlobStore under noBackupFilesDir/RevenueCat
+ */
+internal object SdkCaches {
+
+ private const val RC_PREFS_FILE = "com_revenuecat_purchases_preferences"
+
+ fun hadOfferingsCache(context: Context, apiKey: String): Boolean =
+ context.getSharedPreferences(RC_PREFS_FILE, Context.MODE_PRIVATE)
+ .contains("com.revenuecat.purchases.$apiKey.offeringsResponse")
+
+ fun hadRemoteConfigCache(context: Context): Boolean =
+ File(File(context.noBackupFilesDir, "RevenueCat"), "remote_config")
+ .listFiles()?.isNotEmpty() == true
+
+ fun hadBlobsCache(context: Context): Boolean =
+ File(File(context.noBackupFilesDir, "RevenueCat"), "blobs")
+ .listFiles()?.isNotEmpty() == true
+
+ fun clearAll(context: Context) {
+ clearPrefs(context, RC_PREFS_FILE)
+ clearPrefs(context, "${context.packageName}_preferences_etags")
+ clearPrefs(context, "com_revenuecat_purchases_${context.packageName}_preferences_diagnostics")
+ File(context.filesDir, "RevenueCat").deleteRecursively()
+ File(context.noBackupFilesDir, "RevenueCat").deleteRecursively()
+ }
+
+ private fun clearPrefs(context: Context, name: String) {
+ context.getSharedPreferences(name, Context.MODE_PRIVATE).edit().clear().commit()
+ }
+}
diff --git a/test-apps/perftester/src/main/res/values/strings.xml b/test-apps/perftester/src/main/res/values/strings.xml
new file mode 100644
index 0000000000..467db11f15
--- /dev/null
+++ b/test-apps/perftester/src/main/res/values/strings.xml
@@ -0,0 +1,4 @@
+
+
+ RC Perf Tester
+
diff --git a/test-apps/perftester/src/main/res/values/themes.xml b/test-apps/perftester/src/main/res/values/themes.xml
new file mode 100644
index 0000000000..1e7d1438ed
--- /dev/null
+++ b/test-apps/perftester/src/main/res/values/themes.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/test-apps/perftester/src/test/java/com/revenuecat/perftester/PerfResultTest.kt b/test-apps/perftester/src/test/java/com/revenuecat/perftester/PerfResultTest.kt
new file mode 100644
index 0000000000..73acfc7803
--- /dev/null
+++ b/test-apps/perftester/src/test/java/com/revenuecat/perftester/PerfResultTest.kt
@@ -0,0 +1,91 @@
+package com.revenuecat.perftester
+
+import kotlinx.serialization.json.Json
+import kotlinx.serialization.json.jsonObject
+import kotlinx.serialization.json.jsonPrimitive
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertTrue
+import org.junit.Test
+import java.io.File
+
+class PerfResultTest {
+
+ private val result = PerfResult(
+ timestampEpochMs = 1720000000000L,
+ sdkVersion = "10.14.0-SNAPSHOT",
+ deviceModel = "Google sdk_gphone64_arm64",
+ androidApiLevel = 34,
+ workflowsEnabled = true,
+ pressIndexInProcess = 1,
+ processStartToPressMs = 1234,
+ hadOfferingsCache = false,
+ hadRemoteConfigCache = false,
+ hadBlobsCache = false,
+ memoryBeforeRssKb = 100,
+ memoryAfterRssKb = 120,
+ memoryDeltaRssKb = 20,
+ memoryBeforeRssAnonKb = 80,
+ memoryAfterRssAnonKb = 95,
+ memoryDeltaRssAnonKb = 15,
+ memoryBeforeRssFileKb = 20,
+ memoryAfterRssFileKb = 25,
+ memoryDeltaRssFileKb = 5,
+ memoryBeforeJavaHeapKb = 50,
+ memoryAfterJavaHeapKb = 60,
+ memoryDeltaJavaHeapKb = 10,
+ memoryBeforeNativeHeapKb = 30,
+ memoryAfterNativeHeapKb = 35,
+ memoryDeltaNativeHeapKb = 5,
+ configureToOfferingsMs = 321.5,
+ getOfferingsMs = 300.25,
+ uiConfigMs = 42.0,
+ uiConfigIsDefault = false,
+ workflowResolutionMs = 1.5,
+ workflowBodyMs = 55.0,
+ workflowResolved = true,
+ success = true,
+ errorMessage = null,
+ offeringsCount = 3,
+ currentOfferingId = "default",
+ )
+
+ @Test
+ fun `json line contains exactly the schema field names`() {
+ val parsed = Json.parseToJsonElement(result.toJsonLine()).jsonObject
+ val expectedKeys = setOf(
+ "schema_version", "source", "timestamp_epoch_ms", "sdk_version", "device_model",
+ "android_api_level", "workflows_enabled", "press_index_in_process",
+ "process_start_to_press_ms", "had_offerings_cache", "had_remote_config_cache",
+ "had_blobs_cache", "memory_before_rss_kb", "memory_after_rss_kb", "memory_delta_rss_kb",
+ "memory_before_rss_anon_kb", "memory_after_rss_anon_kb", "memory_delta_rss_anon_kb",
+ "memory_before_rss_file_kb", "memory_after_rss_file_kb", "memory_delta_rss_file_kb",
+ "memory_before_java_heap_kb", "memory_after_java_heap_kb", "memory_delta_java_heap_kb",
+ "memory_before_native_heap_kb", "memory_after_native_heap_kb", "memory_delta_native_heap_kb",
+ "configure_to_offerings_ms", "get_offerings_ms", "ui_config_ms",
+ "ui_config_is_default", "workflow_resolution_ms", "workflow_body_ms",
+ "workflow_resolved", "success", "error_message", "offerings_count",
+ "current_offering_id",
+ )
+ assertEquals(expectedKeys, parsed.keys)
+ assertEquals("2", parsed["schema_version"]!!.jsonPrimitive.content)
+ assertEquals("perftester-app", parsed["source"]!!.jsonPrimitive.content)
+ }
+
+ @Test
+ fun `nulls are written explicitly so every line has every field`() {
+ val parsed = Json.parseToJsonElement(result.copy(errorMessage = null).toJsonLine()).jsonObject
+ assertTrue(parsed.containsKey("error_message"))
+ }
+
+ @Test
+ fun `writer appends one line per result`() {
+ val file = File.createTempFile("results", ".jsonl")
+ val writer = ResultsWriter(file)
+ writer.append(result)
+ writer.append(result.copy(pressIndexInProcess = 2))
+ val lines = file.readLines()
+ assertEquals(2, lines.size)
+ assertTrue(lines[1].contains("\"press_index_in_process\":2"))
+ file.delete()
+ }
+}