Skip to content
Draft
Show file tree
Hide file tree
Changes from all 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
7 changes: 7 additions & 0 deletions local.properties.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
1 change: 1 addition & 0 deletions settings.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -74,4 +74,5 @@ include(":detekt-rules")
include(":dokka-hide-internal")
include(":baselineprofile")
include(":test-apps:e2etests")
include(":test-apps:perftester")
include(":examples:rcttester")
1 change: 1 addition & 0 deletions test-apps/perftester/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
/build
38 changes: 38 additions & 0 deletions test-apps/perftester/README.md
Original file line number Diff line number Diff line change
@@ -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.
62 changes: 62 additions & 0 deletions test-apps/perftester/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -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)
}
23 changes: 23 additions & 0 deletions test-apps/perftester/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">

<uses-permission android:name="android.permission.INTERNET" />

<application
android:allowBackup="false"
android:icon="@android:drawable/sym_def_app_icon"
android:label="@string/app_name"
android:supportsRtl="true"
android:theme="@style/Theme.PerfTester">
<activity
android:name=".MainActivity"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />

<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>

</manifest>
Original file line number Diff line number Diff line change
@@ -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)
}
}
}
}
}
Loading