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 @@ + + + +