From 8309afebd9f7f62999cb8cf2605ab68784597581 Mon Sep 17 00:00:00 2001 From: Ian Rumac Date: Tue, 7 Apr 2026 13:21:08 +0200 Subject: [PATCH 1/2] Add debug event timeline support to example --- app/build.gradle.kts | 22 + .../superapp/utils/EventTimelineRule.kt | 86 +++ .../example/superapp/utils/TestingUtils.kt | 44 ++ app/src/main/AndroidManifest.xml | 6 + .../com/superwall/superapp/MainActivity.kt | 21 + .../com/superwall/superapp/MainApplication.kt | 4 + .../superwall/superapp/test/EventTimeline.kt | 120 ++++ .../superwall/superapp/test/TimelineStore.kt | 38 ++ .../superapp/test/TimelineViewerActivity.kt | 543 ++++++++++++++++++ .../superwall/superapp/test/UITestActivity.kt | 114 ++-- 10 files changed, 953 insertions(+), 45 deletions(-) create mode 100644 app/src/androidTest/java/com/example/superapp/utils/EventTimelineRule.kt create mode 100644 app/src/main/java/com/superwall/superapp/test/EventTimeline.kt create mode 100644 app/src/main/java/com/superwall/superapp/test/TimelineStore.kt create mode 100644 app/src/main/java/com/superwall/superapp/test/TimelineViewerActivity.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index c6fec9c99..024d16ee4 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -110,3 +110,25 @@ dependencies { debugImplementation(libs.ui.tooling) debugImplementation(libs.ui.test.manifest) } + +tasks.register("pullEventTimelines") { + description = "Pull event timeline JSON files from the device after instrumentation tests" + group = "verification" + val outputDir = layout.buildDirectory.dir("outputs/event-timelines").get().asFile + doFirst { + outputDir.mkdirs() + } + commandLine( + "adb", "pull", + "/sdcard/Download/superwall-event-timelines/.", + outputDir.absolutePath, + ) + isIgnoreExitValue = true +} + +tasks.register("clearEventTimelines") { + description = "Clear event timeline files from the device" + group = "verification" + commandLine("adb", "shell", "rm", "-rf", "/sdcard/Download/superwall-event-timelines/") + isIgnoreExitValue = true +} diff --git a/app/src/androidTest/java/com/example/superapp/utils/EventTimelineRule.kt b/app/src/androidTest/java/com/example/superapp/utils/EventTimelineRule.kt new file mode 100644 index 000000000..7b8d606e1 --- /dev/null +++ b/app/src/androidTest/java/com/example/superapp/utils/EventTimelineRule.kt @@ -0,0 +1,86 @@ +package com.example.superapp.utils + +import android.os.Environment +import android.util.Log +import com.superwall.superapp.test.UITestInfo +import org.json.JSONArray +import org.json.JSONObject +import org.junit.rules.TestWatcher +import org.junit.runner.Description +import java.io.File + +private const val TIMELINE_DIR = "superwall-event-timelines" +private const val TAG = "EventTimeline" + +/** + * Writes the event timeline for a [UITestInfo] to a JSON file on device storage. + * + * Output directory: /sdcard/Download/superwall-event-timelines/ + * + * Pull results with: + * adb pull /sdcard/Download/superwall-event-timelines/ app/build/outputs/event-timelines/ + */ +fun writeTimelineToFile( + testInfo: UITestInfo, + testClassName: String, + testMethodName: String, +) { + val timeline = testInfo.timeline + if (timeline.allEvents().isEmpty()) return + + val dir = File( + Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS), + TIMELINE_DIR, + ) + dir.mkdirs() + + val fileName = "${testClassName}_${testMethodName}.json" + val file = File(dir, fileName) + + val json = JSONObject().apply { + put("testClass", testClassName) + put("testMethod", testMethodName) + put("testNumber", testInfo.number) + put("testDescription", testInfo.description) + put("totalDurationMs", timeline.totalDuration().inWholeMilliseconds) + put("eventCount", timeline.allEvents().size) + put("events", JSONArray().apply { + timeline.toSerializableList().forEach { map -> + put(JSONObject().apply { + map.forEach { (k, v) -> + when (v) { + is Map<*, *> -> put(k, JSONObject(v as Map<*, *>)) + else -> put(k, v) + } + } + }) + } + }) + } + + file.writeText(json.toString(2)) + Log.i(TAG, "Wrote timeline to ${file.absolutePath} (${timeline.allEvents().size} events)") +} + +/** + * JUnit TestWatcher that automatically writes the [com.superwall.superapp.test.EventTimeline] + * from a [UITestInfo] to a JSON file on the device after each test finishes (pass or fail). + * + * Add to any test class: + * ``` + * @get:Rule + * val timelineRule = EventTimelineRule { currentTestInfo } + * ``` + */ +class EventTimelineRule( + private val testInfoProvider: () -> UITestInfo?, +) : TestWatcher() { + override fun finished(description: Description) { + val testInfo = testInfoProvider() ?: return + writeTimelineToFile( + testInfo, + description.testClass.simpleName, + description.methodName, + ) + } +} diff --git a/app/src/androidTest/java/com/example/superapp/utils/TestingUtils.kt b/app/src/androidTest/java/com/example/superapp/utils/TestingUtils.kt index fe9009ce9..1ab15ce0b 100644 --- a/app/src/androidTest/java/com/example/superapp/utils/TestingUtils.kt +++ b/app/src/androidTest/java/com/example/superapp/utils/TestingUtils.kt @@ -20,6 +20,7 @@ import com.superwall.sdk.analytics.superwall.SuperwallEvent import com.superwall.sdk.config.models.ConfigurationStatus import com.superwall.sdk.paywall.view.ShimmerView import com.superwall.superapp.MainActivity +import com.superwall.superapp.test.EventTimeline import com.superwall.superapp.test.UITestInfo import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -94,6 +95,17 @@ fun Dropshots.screenshotFlow( val flow = ScreenshotTestFlow(testInfo).apply(flow) val scenario = ActivityScenario.launch(MainActivity::class.java) val testCase = testInfo + // Capture caller info for timeline output + val callerFrame = Thread.currentThread().stackTrace + .firstOrNull { frame -> + frame.methodName != "screenshotFlow" && + !frame.className.startsWith("java.") && + !frame.className.startsWith("dalvik.") && + frame.className.contains("Test") + } + val testClassName = callerFrame?.className?.substringAfterLast('.') ?: "UnknownTest" + val testMethodName = callerFrame?.methodName ?: "unknownMethod" + println("-----------") println("Executing test case: ${testCase.number}") println("Description:\n ${testCase.description}") @@ -129,6 +141,7 @@ fun Dropshots.screenshotFlow( throw e } finally { scope.cancel() + writeTimelineToFile(testCase, testClassName, testMethodName) } } closeActivity() @@ -284,3 +297,34 @@ suspend fun CoroutineScope.delayFor(duration: Duration) = async(Dispatchers.IO) { delay(duration) }.await() + +// --- EventTimeline extensions --- + +/** Access the timeline for this test info. */ +val UITestInfo.eventTimeline: EventTimeline get() = timeline + +/** Assert that the duration from timeline start to event T is under the given limit. */ +inline fun EventTimeline.assertDurationToUnder(limit: Duration) { + val actual = durationTo() + ?: error("Event ${T::class.simpleName} was never recorded") + assert(actual <= limit) { + "Expected ${T::class.simpleName} within $limit but took $actual" + } +} + +/** Assert that the duration between events A and B is under the given limit. */ +inline fun EventTimeline.assertDurationBetweenUnder(limit: Duration) { + val actual = durationBetween() + ?: error("One or both events (${A::class.simpleName}, ${B::class.simpleName}) were never recorded") + assert(actual <= limit) { + "Expected ${A::class.simpleName} -> ${B::class.simpleName} within $limit but took $actual" + } +} + +/** Print a human-readable summary of all captured events to logcat. */ +fun EventTimeline.printSummary(tag: String = "EventTimeline") { + allEvents().forEachIndexed { i, event -> + Log.i(tag, "#$i [${event.elapsed}] ${event.eventName} (${event.eventType}) params=${event.params}") + } + Log.i(tag, "Total duration: ${totalDuration()}") +} diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index c06b25b70..98c8ac2f4 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -39,6 +39,12 @@ android:label="@string/title_activity_uitest" android:theme="@style/Theme.MyApplication" /> + + () + val liveTimeline = EventTimeline().also { TimelineStore.register("Live", it) } fun configureWithAutomaticInitialization() { Superwall.configure( @@ -158,6 +161,7 @@ class MainApplication : "\tEvent name:" + eventInfo.event.rawName + "" + ",\n\tParams:" + eventInfo.params + "\n", ) + liveTimeline.record(eventInfo) } override fun subscriptionStatusDidChange( diff --git a/app/src/main/java/com/superwall/superapp/test/EventTimeline.kt b/app/src/main/java/com/superwall/superapp/test/EventTimeline.kt new file mode 100644 index 000000000..84472d16f --- /dev/null +++ b/app/src/main/java/com/superwall/superapp/test/EventTimeline.kt @@ -0,0 +1,120 @@ +package com.superwall.superapp.test + +import com.superwall.sdk.analytics.superwall.SuperwallEvent +import com.superwall.sdk.analytics.superwall.SuperwallEventInfo +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import java.util.concurrent.CopyOnWriteArrayList +import kotlin.time.Duration +import kotlin.time.TimeMark +import kotlin.time.TimeSource + +/** + * A single captured event with its timestamp relative to timeline start. + */ +data class TimedEvent( + val eventName: String, + val eventType: String, + val event: SuperwallEvent, + val params: Map, + val elapsed: Duration, + val epochMillis: Long, +) + +/** + * Thread-safe timeline that captures all SDK events with timing information. + * Created per-test to track event flow and measure durations. + */ +class EventTimeline( + private val timeSource: TimeSource = TimeSource.Monotonic, +) { + private var startMark: TimeMark = timeSource.markNow() + @PublishedApi + internal val _events = CopyOnWriteArrayList() + + private val _eventsFlow = MutableStateFlow>(emptyList()) + + /** Observable snapshot of all events — emits a new list on every record/clear. */ + val eventsFlow: StateFlow> = _eventsFlow.asStateFlow() + + val events: List get() = _events.toList() + + fun record(eventInfo: SuperwallEventInfo) { + val elapsed = startMark.elapsedNow() + _events.add( + TimedEvent( + eventName = eventInfo.event.rawName, + eventType = eventInfo.event::class.simpleName ?: "Unknown", + event = eventInfo.event, + params = eventInfo.params, + elapsed = elapsed, + epochMillis = System.currentTimeMillis(), + ), + ) + _eventsFlow.value = _events.toList() + } + + fun clear() { + _events.clear() + _eventsFlow.value = emptyList() + startMark = timeSource.markNow() + } + + /** All events in chronological order. */ + fun allEvents(): List = events + + /** Total elapsed time from timeline start to the last recorded event. */ + fun totalDuration(): Duration = + _events.lastOrNull()?.elapsed ?: Duration.ZERO + + /** Time from timeline start to the first occurrence of the given event type. */ + inline fun durationTo(): Duration? = + _events.firstOrNull { it.event is T }?.elapsed + + /** Time from timeline start to the first event matching the given name. */ + fun durationTo(eventName: String): Duration? = + _events.firstOrNull { it.eventName == eventName }?.elapsed + + /** Duration between the first occurrence of event A and event B. */ + inline fun durationBetween(): Duration? { + val a = _events.firstOrNull { it.event is A }?.elapsed ?: return null + val b = _events.firstOrNull { it.event is B }?.elapsed ?: return null + return b - a + } + + /** Duration between two events matched by name. */ + fun durationBetween(from: String, to: String): Duration? { + val a = _events.firstOrNull { it.eventName == from }?.elapsed ?: return null + val b = _events.firstOrNull { it.eventName == to }?.elapsed ?: return null + return b - a + } + + /** All events matching the given type. */ + inline fun eventsOf(): List = + _events.filter { it.event is T } + + /** All events matching the given name. */ + fun eventsOf(eventName: String): List = + _events.filter { it.eventName == eventName } + + /** First event matching the given type, or null. */ + inline fun firstOf(): TimedEvent? = + _events.firstOrNull { it.event is T } + + /** Check whether an event of the given type was recorded. */ + inline fun contains(): Boolean = + _events.any { it.event is T } + + /** Serialize all events to a list of maps for JSON output. */ + fun toSerializableList(): List> = + _events.map { event -> + mapOf( + "eventName" to event.eventName, + "eventType" to event.eventType, + "elapsedMs" to event.elapsed.inWholeMilliseconds, + "epochMillis" to event.epochMillis, + "params" to event.params.mapValues { (_, v) -> v.toString() }, + ) + } +} diff --git a/app/src/main/java/com/superwall/superapp/test/TimelineStore.kt b/app/src/main/java/com/superwall/superapp/test/TimelineStore.kt new file mode 100644 index 000000000..1551529bf --- /dev/null +++ b/app/src/main/java/com/superwall/superapp/test/TimelineStore.kt @@ -0,0 +1,38 @@ +package com.superwall.superapp.test + +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import java.util.concurrent.ConcurrentHashMap + +/** + * Global registry of named [EventTimeline] instances. + * The app delegate records into a "Live" timeline. + * Each UITestInfo registers its timeline here when a test runs. + */ +object TimelineStore { + private val timelines = ConcurrentHashMap() + private val _timelinesFlow = MutableStateFlow>(emptyMap()) + + /** Observable snapshot of all registered timelines. */ + val timelinesFlow: StateFlow> = _timelinesFlow.asStateFlow() + + fun register(name: String, timeline: EventTimeline) { + timelines[name] = timeline + _timelinesFlow.value = timelines.toMap() + } + + fun remove(name: String) { + timelines.remove(name) + _timelinesFlow.value = timelines.toMap() + } + + fun get(name: String): EventTimeline? = timelines[name] + + fun all(): Map = timelines.toMap() + + fun clear() { + timelines.clear() + _timelinesFlow.value = emptyMap() + } +} diff --git a/app/src/main/java/com/superwall/superapp/test/TimelineViewerActivity.kt b/app/src/main/java/com/superwall/superapp/test/TimelineViewerActivity.kt new file mode 100644 index 000000000..1ee1ea7ef --- /dev/null +++ b/app/src/main/java/com/superwall/superapp/test/TimelineViewerActivity.kt @@ -0,0 +1,543 @@ +package com.superwall.superapp.test + +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.animateColorAsState +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.derivedStateOf +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.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.superwall.superapp.ui.theme.MyApplicationTheme + +class TimelineViewerActivity : ComponentActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContent { + MyApplicationTheme { + TimelineViewerRoot() + } + } + } +} + +@Composable +private fun TimelineViewerRoot() { + var selectedTimeline by remember { mutableStateOf?>(null) } + val timelines by TimelineStore.timelinesFlow.collectAsState() + + Surface( + modifier = Modifier.fillMaxSize(), + color = MaterialTheme.colorScheme.background, + ) { + if (selectedTimeline == null) { + TimelineListScreen( + timelines = timelines, + onSelect = { name, timeline -> selectedTimeline = name to timeline }, + ) + } else { + val (name, timeline) = selectedTimeline!! + TimelineDetailScreen( + name = name, + timeline = timeline, + onBack = { selectedTimeline = null }, + ) + } + } +} + +@Composable +private fun TimelineListScreen( + timelines: Map, + onSelect: (String, EventTimeline) -> Unit, +) { + Column(modifier = Modifier.fillMaxSize()) { + Text( + text = "Event Timelines", + style = MaterialTheme.typography.headlineMedium, + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(16.dp), + ) + + if (timelines.isEmpty()) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center, + ) { + Text( + text = "No timelines recorded yet.\nRun a test to capture events.", + color = Color.Gray, + style = MaterialTheme.typography.bodyLarge, + ) + } + } else { + LazyColumn { + timelines.entries.sortedByDescending { it.value.allEvents().size }.forEach { (name, timeline) -> + item(key = name) { + val liveEvents by timeline.eventsFlow.collectAsState() + Card( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 4.dp) + .clickable { onSelect(name, timeline) }, + elevation = CardDefaults.cardElevation(defaultElevation = 2.dp), + ) { + Column(modifier = Modifier.padding(16.dp)) { + Text( + text = name, + fontWeight = FontWeight.Bold, + style = MaterialTheme.typography.titleMedium, + ) + Spacer(modifier = Modifier.height(4.dp)) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text( + text = "${liveEvents.size} events", + color = Color.Gray, + style = MaterialTheme.typography.bodyMedium, + ) + val lastElapsed = liveEvents.lastOrNull()?.elapsed + Text( + text = if (lastElapsed != null) formatDuration(lastElapsed.inWholeMilliseconds) else "--", + color = MaterialTheme.colorScheme.primary, + fontFamily = FontFamily.Monospace, + style = MaterialTheme.typography.bodyMedium, + ) + } + } + } + } + } + } + } + } +} + +@OptIn(ExperimentalFoundationApi::class) +@Composable +private fun TimelineDetailScreen( + name: String, + timeline: EventTimeline, + onBack: () -> Unit, +) { + val events by timeline.eventsFlow.collectAsState() + var firstSelected by remember { mutableStateOf(null) } + var secondSelected by remember { mutableStateOf(null) } + + // Reset selection if events list shrinks (e.g. timeline cleared) + if (firstSelected != null && firstSelected!! >= events.size) firstSelected = null + if (secondSelected != null && secondSelected!! >= events.size) secondSelected = null + + val selectionDuration by remember(firstSelected, secondSelected, events) { + derivedStateOf { + val a = firstSelected + val b = secondSelected + if (a != null && b != null && a < events.size && b < events.size) { + val first = events[minOf(a, b)] + val second = events[maxOf(a, b)] + second.elapsed - first.elapsed + } else { + null + } + } + } + + Column(modifier = Modifier.fillMaxSize()) { + // Header + Column( + modifier = Modifier + .fillMaxWidth() + .background(MaterialTheme.colorScheme.surfaceVariant) + .padding(16.dp), + ) { + Text( + text = "< Back", + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.clickable { onBack() }, + style = MaterialTheme.typography.labelLarge, + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = name, + fontWeight = FontWeight.Bold, + style = MaterialTheme.typography.headlineSmall, + ) + Spacer(modifier = Modifier.height(4.dp)) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text( + text = "Total: ${formatDuration(timeline.totalDuration().inWholeMilliseconds)}", + fontFamily = FontFamily.Monospace, + fontWeight = FontWeight.Bold, + style = MaterialTheme.typography.titleMedium, + ) + Text( + text = "${events.size} events", + color = Color.Gray, + style = MaterialTheme.typography.titleMedium, + ) + } + + // Preload expandable section + PreloadSection(events) + + // Selection duration banner + val currentSelection = selectionDuration + if (currentSelection != null) { + Spacer(modifier = Modifier.height(8.dp)) + Box( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(8.dp)) + .background(MaterialTheme.colorScheme.primaryContainer) + .padding(12.dp), + ) { + val fromIdx = minOf(firstSelected!!, secondSelected!!) + val toIdx = maxOf(firstSelected!!, secondSelected!!) + Column { + Text( + text = "Selection: ${formatDuration(currentSelection.inWholeMilliseconds)}", + fontWeight = FontWeight.Bold, + fontFamily = FontFamily.Monospace, + color = MaterialTheme.colorScheme.onPrimaryContainer, + style = MaterialTheme.typography.titleMedium, + ) + Text( + text = "${events[fromIdx].eventName} -> ${events[toIdx].eventName}", + color = MaterialTheme.colorScheme.onPrimaryContainer, + style = MaterialTheme.typography.bodySmall, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + } else if (firstSelected != null) { + Spacer(modifier = Modifier.height(8.dp)) + Box( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(8.dp)) + .background(MaterialTheme.colorScheme.secondaryContainer) + .padding(12.dp), + ) { + Text( + text = "Long press a second event to measure duration", + color = MaterialTheme.colorScheme.onSecondaryContainer, + style = MaterialTheme.typography.bodyMedium, + ) + } + } + } + + // Event list + LazyColumn(modifier = Modifier.fillMaxSize()) { + itemsIndexed(events, key = { index, _ -> index }) { index, event -> + val isFirst = index == firstSelected + val isSecond = index == secondSelected + val isInRange = run { + val a = firstSelected + val b = secondSelected + a != null && b != null && index in minOf(a, b)..maxOf(a, b) + } + + val bgColor by animateColorAsState( + targetValue = when { + isFirst || isSecond -> MaterialTheme.colorScheme.primaryContainer + isInRange -> MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.3f) + else -> Color.Transparent + }, + label = "eventBg", + ) + + Column( + modifier = Modifier + .fillMaxWidth() + .background(bgColor) + .combinedClickable( + onClick = { + // Clear selection on tap + firstSelected = null + secondSelected = null + }, + onLongClick = { + when { + firstSelected == null -> firstSelected = index + secondSelected == null -> secondSelected = index + else -> { + firstSelected = index + secondSelected = null + } + } + }, + ) + .padding(horizontal = 16.dp, vertical = 10.dp), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = "#$index", + color = Color.Gray, + fontFamily = FontFamily.Monospace, + fontSize = 11.sp, + modifier = Modifier.width(32.dp), + ) + Text( + text = event.eventName, + fontWeight = FontWeight.SemiBold, + style = MaterialTheme.typography.bodyLarge, + ) + } + Text( + text = formatDuration(event.elapsed.inWholeMilliseconds), + fontFamily = FontFamily.Monospace, + color = MaterialTheme.colorScheme.primary, + style = MaterialTheme.typography.bodyMedium, + ) + } + + // Delta from previous event + if (index > 0) { + val delta = event.elapsed - events[index - 1].elapsed + Text( + text = "+${formatDuration(delta.inWholeMilliseconds)}", + fontFamily = FontFamily.Monospace, + color = Color.Gray, + fontSize = 11.sp, + modifier = Modifier.padding(start = 32.dp), + ) + } + + // Show params if non-empty + if (event.params.isNotEmpty()) { + Text( + text = event.params.entries.joinToString(", ") { "${it.key}=${it.value}" }, + color = Color.Gray, + fontSize = 10.sp, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.padding(start = 32.dp, top = 2.dp), + ) + } + } + HorizontalDivider(color = Color.LightGray.copy(alpha = 0.3f)) + } + } + } +} + +private data class LoadCounts(val started: Int, val completed: Int, val failed: Int) + +@Composable +private fun PreloadSection(events: List) { + var expanded by remember { mutableStateOf(false) } + + val preloadDuration by remember(events) { + derivedStateOf { + val configAttr = events.firstOrNull { it.eventName == "config_attributes" } + val lastWebviewComplete = events.lastOrNull { it.eventName == "paywallWebviewLoad_complete" } + if (configAttr != null && lastWebviewComplete != null) { + lastWebviewComplete.elapsed - configAttr.elapsed + } else { + null + } + } + } + + val preloadCounts by remember(events) { + derivedStateOf { + LoadCounts( + started = events.count { it.eventName == "paywallPreload_start" }, + completed = events.count { it.eventName == "paywallPreload_complete" }, + failed = 0, // no preload error event exists + ) + } + } + + val responseCounts by remember(events) { + derivedStateOf { + LoadCounts( + started = events.count { it.eventName == "paywallResponseLoad_start" }, + completed = events.count { it.eventName == "paywallResponseLoad_complete" }, + failed = events.count { + it.eventName == "paywallResponseLoad_fail" || it.eventName == "paywallResponseLoad_notFound" + }, + ) + } + } + + val webviewCounts by remember(events) { + derivedStateOf { + LoadCounts( + started = events.count { it.eventName == "paywallWebviewLoad_start" }, + completed = events.count { it.eventName == "paywallWebviewLoad_complete" }, + failed = events.count { + it.eventName == "paywallWebviewLoad_fail" || it.eventName == "paywallWebviewLoad_timeout" + }, + ) + } + } + + val productsCounts by remember(events) { + derivedStateOf { + LoadCounts( + started = events.count { it.eventName == "paywallProductsLoad_start" }, + completed = events.count { it.eventName == "paywallProductsLoad_complete" }, + failed = events.count { it.eventName == "paywallProductsLoad_fail" }, + ) + } + } + + val hasAnyPreloadEvents = preloadCounts.started > 0 || responseCounts.started > 0 || + webviewCounts.started > 0 || productsCounts.started > 0 + + if (!hasAnyPreloadEvents && preloadDuration == null) return + + Spacer(modifier = Modifier.height(6.dp)) + Column( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(8.dp)) + .background(MaterialTheme.colorScheme.tertiaryContainer.copy(alpha = 0.5f)) + .clickable { expanded = !expanded } + .padding(10.dp), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = if (expanded) "v" else ">", + fontFamily = FontFamily.Monospace, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onTertiaryContainer, + modifier = Modifier.width(20.dp), + ) + Text( + text = "Preload", + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onTertiaryContainer, + style = MaterialTheme.typography.titleMedium, + ) + } + preloadDuration?.let { d -> + Text( + text = formatDuration(d.inWholeMilliseconds), + fontFamily = FontFamily.Monospace, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.tertiary, + style = MaterialTheme.typography.titleMedium, + ) + } + } + + AnimatedVisibility(visible = expanded) { + Column(modifier = Modifier.padding(start = 20.dp, top = 8.dp)) { + if (preloadCounts.started > 0) { + LoadCountRow("Preload", preloadCounts) + } + if (responseCounts.started > 0) { + LoadCountRow("Response", responseCounts) + } + if (webviewCounts.started > 0) { + LoadCountRow("Webview", webviewCounts) + } + if (productsCounts.started > 0) { + LoadCountRow("Products", productsCounts) + } + } + } + } +} + +@Composable +private fun LoadCountRow(label: String, counts: LoadCounts) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 2.dp), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text( + text = label, + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onTertiaryContainer, + ) + Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { + CountBadge(count = counts.started, label = "started", color = Color.Gray) + CountBadge(count = counts.completed, label = "done", color = Color(0xFF4CAF50)) + if (counts.failed > 0) { + CountBadge(count = counts.failed, label = "failed", color = Color(0xFFF44336)) + } + } + } +} + +@Composable +private fun CountBadge(count: Int, label: String, color: Color) { + Text( + text = "$count $label", + fontFamily = FontFamily.Monospace, + fontSize = 11.sp, + color = color, + ) +} + +private fun formatDuration(ms: Long): String = + when { + ms < 1000 -> "${ms}ms" + ms < 60_000 -> "%.2fs".format(ms / 1000.0) + else -> { + val minutes = ms / 60_000 + val seconds = (ms % 60_000) / 1000.0 + "${minutes}m %.1fs".format(seconds) + } + } diff --git a/app/src/main/java/com/superwall/superapp/test/UITestActivity.kt b/app/src/main/java/com/superwall/superapp/test/UITestActivity.kt index 0a9928e55..57b57a241 100644 --- a/app/src/main/java/com/superwall/superapp/test/UITestActivity.kt +++ b/app/src/main/java/com/superwall/superapp/test/UITestActivity.kt @@ -1,14 +1,18 @@ package com.superwall.superapp.test import android.content.Context +import android.content.Intent import android.os.Bundle import android.util.Log import androidx.activity.ComponentActivity import androidx.activity.compose.setContent +import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding @@ -22,6 +26,7 @@ import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontWeight @@ -50,12 +55,16 @@ class UITestInfo( private val events = MutableSharedFlow(extraBufferCapacity = 100, replay = 3) private val message = MutableSharedFlow(extraBufferCapacity = 10, replay = 10) + val timeline = EventTimeline() + fun events() = events fun messages() = message val test: suspend Context.() -> Unit = { val scope = CoroutineScope(Dispatchers.IO) + timeline.clear() + TimelineStore.register("Test #$number", timeline) delay(100) Superwall.instance.delegate = object : SuperwallDelegate { @@ -65,6 +74,7 @@ class UITestInfo( "\tEvent name:" + eventInfo.event.rawName + "" + ",\n\tParams:" + eventInfo.params + "\n", ) + timeline.record(eventInfo) scope.launch { events.emit(eventInfo.event) } @@ -106,60 +116,74 @@ fun UITestTable() { val context = LocalContext.current val mainTextColor = if (isSystemInDarkTheme()) { - Color.White // Set the color for dark mode + Color.White } else { - Color.Black // Set the color for light mode + Color.Black } - LazyColumn { - items(UITestHandler.tests.toList()) { item -> - val index = tests.indexOf(item) - Column { - Row( - modifier = - Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 16.dp), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - Column( + Box( + modifier = Modifier + .fillMaxSize() + .pointerInput(Unit) { + detectTapGestures( + onDoubleTap = { + context.startActivity( + Intent(context, TimelineViewerActivity::class.java), + ) + }, + ) + }, + ) { + LazyColumn(modifier = Modifier.fillMaxSize()) { + items(UITestHandler.tests.toList()) { item -> + val index = tests.indexOf(item) + Column { + Row( modifier = Modifier - .weight(2f) - .padding(horizontal = 8.dp), + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 16.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, ) { - Text( - color = mainTextColor, - text = item.testCaseType.titleText(item.number), - fontWeight = FontWeight.Bold, - modifier = Modifier.padding(bottom = 8.dp), - ) - Text( - text = "${item.description}", - style = TextStyle(color = Color.Gray), - ) - } - Button( - onClick = { - scope.launch { - launch(Dispatchers.IO) { - item.test(context) + Column( + modifier = + Modifier + .weight(2f) + .padding(horizontal = 8.dp), + ) { + Text( + color = mainTextColor, + text = item.testCaseType.titleText(item.number), + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(bottom = 8.dp), + ) + Text( + text = "${item.description}", + style = TextStyle(color = Color.Gray), + ) + } + Button( + onClick = { + scope.launch { + launch(Dispatchers.IO) { + item.test(context) + } } - } - }, - modifier = Modifier.weight(1f), - ) { - Text("Launch $index") + }, + modifier = Modifier.weight(1f), + ) { + Text("Launch $index") + } } + Divider( + modifier = + Modifier + .fillMaxWidth() + .height(0.5.dp), + color = Color.LightGray, + ) } - Divider( - modifier = - Modifier - .fillMaxWidth() - .height(0.5.dp), - color = Color.LightGray, - ) } } } From 3a1bc9f73c5c782ff9781bd9e7149c9a65d18a83 Mon Sep 17 00:00:00 2001 From: Ian Rumac Date: Tue, 7 Apr 2026 14:20:56 +0200 Subject: [PATCH 2/2] Improec timeline --- .../superapp/utils/EventTimelineRule.kt | 13 +++++++ .../example/superapp/utils/TestingUtils.kt | 14 ++------ app/src/main/AndroidManifest.xml | 5 +-- .../com/superwall/superapp/MainActivity.kt | 34 ++++++++++++------- .../com/superwall/superapp/MainApplication.kt | 2 +- .../superapp/test/TimelineViewerActivity.kt | 30 +++++++++------- 6 files changed, 58 insertions(+), 40 deletions(-) diff --git a/app/src/androidTest/java/com/example/superapp/utils/EventTimelineRule.kt b/app/src/androidTest/java/com/example/superapp/utils/EventTimelineRule.kt index 7b8d606e1..6e258476e 100644 --- a/app/src/androidTest/java/com/example/superapp/utils/EventTimelineRule.kt +++ b/app/src/androidTest/java/com/example/superapp/utils/EventTimelineRule.kt @@ -62,9 +62,22 @@ fun writeTimelineToFile( Log.i(TAG, "Wrote timeline to ${file.absolutePath} (${timeline.allEvents().size} events)") } +/** + * Overload that derives the filename from [UITestInfo] directly. + * Used by [screenshotFlow] where no JUnit [Description] is available. + */ +fun writeTimelineToFile(testInfo: UITestInfo) { + writeTimelineToFile( + testInfo, + testClassName = "Test${testInfo.number}", + testMethodName = testInfo.testCaseType.titleText(testInfo.number).replace(" ", "_"), + ) +} + /** * JUnit TestWatcher that automatically writes the [com.superwall.superapp.test.EventTimeline] * from a [UITestInfo] to a JSON file on the device after each test finishes (pass or fail). + * Uses JUnit's [Description] for reliable test class/method naming. * * Add to any test class: * ``` diff --git a/app/src/androidTest/java/com/example/superapp/utils/TestingUtils.kt b/app/src/androidTest/java/com/example/superapp/utils/TestingUtils.kt index 1ab15ce0b..c6cf58431 100644 --- a/app/src/androidTest/java/com/example/superapp/utils/TestingUtils.kt +++ b/app/src/androidTest/java/com/example/superapp/utils/TestingUtils.kt @@ -21,6 +21,7 @@ import com.superwall.sdk.config.models.ConfigurationStatus import com.superwall.sdk.paywall.view.ShimmerView import com.superwall.superapp.MainActivity import com.superwall.superapp.test.EventTimeline +import com.superwall.superapp.test.TimelineStore import com.superwall.superapp.test.UITestInfo import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -95,16 +96,6 @@ fun Dropshots.screenshotFlow( val flow = ScreenshotTestFlow(testInfo).apply(flow) val scenario = ActivityScenario.launch(MainActivity::class.java) val testCase = testInfo - // Capture caller info for timeline output - val callerFrame = Thread.currentThread().stackTrace - .firstOrNull { frame -> - frame.methodName != "screenshotFlow" && - !frame.className.startsWith("java.") && - !frame.className.startsWith("dalvik.") && - frame.className.contains("Test") - } - val testClassName = callerFrame?.className?.substringAfterLast('.') ?: "UnknownTest" - val testMethodName = callerFrame?.methodName ?: "unknownMethod" println("-----------") println("Executing test case: ${testCase.number}") @@ -141,7 +132,8 @@ fun Dropshots.screenshotFlow( throw e } finally { scope.cancel() - writeTimelineToFile(testCase, testClassName, testMethodName) + writeTimelineToFile(testCase) + TimelineStore.remove("Test #${testCase.number}") } } closeActivity() diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 98c8ac2f4..b128a432b 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -5,7 +5,7 @@ - + @@ -29,7 +29,8 @@ android:icon="@mipmap/ic_launcher" android:label="@string/app_name" android:networkSecurityConfig="@xml/network_security_config" - android:sharedUserId="com.superwall.superapp.uid tools:targetApi=" + android:sharedUserId="com.superwall.superapp.uid" + tools:targetApi="28" android:sharedUserMaxSdkVersion="32" android:supportsRtl="true" android:theme="@style/Theme.MyApplication"> diff --git a/app/src/main/java/com/superwall/superapp/MainActivity.kt b/app/src/main/java/com/superwall/superapp/MainActivity.kt index 751d6d7a8..61543d435 100644 --- a/app/src/main/java/com/superwall/superapp/MainActivity.kt +++ b/app/src/main/java/com/superwall/superapp/MainActivity.kt @@ -2,6 +2,7 @@ package com.superwall.superapp import android.app.ComponentCaller import android.content.Intent +import android.content.pm.ApplicationInfo import android.os.Bundle import android.view.GestureDetector import android.view.MotionEvent @@ -9,7 +10,6 @@ import android.widget.Button import androidx.appcompat.app.AppCompatActivity import com.superwall.sdk.Superwall import com.superwall.superapp.debug.SuperwallDebugActivity -import com.superwall.superapp.test.TimelineViewerActivity import com.superwall.superapp.test.UITestActivity import com.superwall.superapp.test.WebTestActivity import java.lang.ref.WeakReference @@ -19,23 +19,31 @@ class MainActivity : AppCompatActivity() { (applicationContext as MainApplication).events } - private lateinit var gestureDetector: GestureDetector + private var gestureDetector: GestureDetector? = null override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.activity_main) (application as MainApplication).activity = WeakReference(this) - // Double-tap anywhere to open timeline viewer - gestureDetector = GestureDetector( - this, - object : GestureDetector.SimpleOnGestureListener() { - override fun onDoubleTap(e: MotionEvent): Boolean { - startActivity(Intent(this@MainActivity, TimelineViewerActivity::class.java)) - return true - } - }, - ) + // Double-tap anywhere to open timeline viewer (debug only) + val isDebug = applicationInfo.flags and ApplicationInfo.FLAG_DEBUGGABLE != 0 + if (isDebug) { + gestureDetector = GestureDetector( + this, + object : GestureDetector.SimpleOnGestureListener() { + override fun onDoubleTap(e: MotionEvent): Boolean { + startActivity( + Intent( + this@MainActivity, + com.superwall.superapp.test.TimelineViewerActivity::class.java, + ), + ) + return true + } + }, + ) + } // Setup deep linking handling respondToDeepLinks() @@ -151,7 +159,7 @@ class MainActivity : AppCompatActivity() { */ override fun dispatchTouchEvent(ev: MotionEvent): Boolean { - gestureDetector.onTouchEvent(ev) + gestureDetector?.onTouchEvent(ev) return super.dispatchTouchEvent(ev) } diff --git a/app/src/main/java/com/superwall/superapp/MainApplication.kt b/app/src/main/java/com/superwall/superapp/MainApplication.kt index 233df75bb..641aa1a88 100644 --- a/app/src/main/java/com/superwall/superapp/MainApplication.kt +++ b/app/src/main/java/com/superwall/superapp/MainApplication.kt @@ -83,7 +83,7 @@ class MainApplication : logging.level = LogLevel.debug paywalls = PaywallOptions().apply { - shouldPreload = false + shouldPreload = true } }, ) diff --git a/app/src/main/java/com/superwall/superapp/test/TimelineViewerActivity.kt b/app/src/main/java/com/superwall/superapp/test/TimelineViewerActivity.kt index 1ee1ea7ef..94b57a1b0 100644 --- a/app/src/main/java/com/superwall/superapp/test/TimelineViewerActivity.kt +++ b/app/src/main/java/com/superwall/superapp/test/TimelineViewerActivity.kt @@ -2,6 +2,7 @@ package com.superwall.superapp.test import android.os.Bundle import androidx.activity.ComponentActivity +import androidx.activity.compose.BackHandler import androidx.activity.compose.setContent import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.animateColorAsState @@ -45,6 +46,7 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import com.superwall.sdk.analytics.superwall.SuperwallEvent import com.superwall.superapp.ui.theme.MyApplicationTheme class TimelineViewerActivity : ComponentActivity() { @@ -159,6 +161,8 @@ private fun TimelineDetailScreen( timeline: EventTimeline, onBack: () -> Unit, ) { + BackHandler { onBack() } + val events by timeline.eventsFlow.collectAsState() var firstSelected by remember { mutableStateOf(null) } var secondSelected by remember { mutableStateOf(null) } @@ -379,8 +383,8 @@ private fun PreloadSection(events: List) { val preloadDuration by remember(events) { derivedStateOf { - val configAttr = events.firstOrNull { it.eventName == "config_attributes" } - val lastWebviewComplete = events.lastOrNull { it.eventName == "paywallWebviewLoad_complete" } + val configAttr = events.firstOrNull { it.event is SuperwallEvent.ConfigAttributes } + val lastWebviewComplete = events.lastOrNull { it.event is SuperwallEvent.PaywallWebviewLoadComplete } if (configAttr != null && lastWebviewComplete != null) { lastWebviewComplete.elapsed - configAttr.elapsed } else { @@ -392,8 +396,8 @@ private fun PreloadSection(events: List) { val preloadCounts by remember(events) { derivedStateOf { LoadCounts( - started = events.count { it.eventName == "paywallPreload_start" }, - completed = events.count { it.eventName == "paywallPreload_complete" }, + started = events.count { it.event is SuperwallEvent.PaywallPreloadStart }, + completed = events.count { it.event is SuperwallEvent.PaywallPreloadComplete }, failed = 0, // no preload error event exists ) } @@ -402,10 +406,10 @@ private fun PreloadSection(events: List) { val responseCounts by remember(events) { derivedStateOf { LoadCounts( - started = events.count { it.eventName == "paywallResponseLoad_start" }, - completed = events.count { it.eventName == "paywallResponseLoad_complete" }, + started = events.count { it.event is SuperwallEvent.PaywallResponseLoadStart }, + completed = events.count { it.event is SuperwallEvent.PaywallResponseLoadComplete }, failed = events.count { - it.eventName == "paywallResponseLoad_fail" || it.eventName == "paywallResponseLoad_notFound" + it.event is SuperwallEvent.PaywallResponseLoadFail || it.event is SuperwallEvent.PaywallResponseLoadNotFound }, ) } @@ -414,10 +418,10 @@ private fun PreloadSection(events: List) { val webviewCounts by remember(events) { derivedStateOf { LoadCounts( - started = events.count { it.eventName == "paywallWebviewLoad_start" }, - completed = events.count { it.eventName == "paywallWebviewLoad_complete" }, + started = events.count { it.event is SuperwallEvent.PaywallWebviewLoadStart }, + completed = events.count { it.event is SuperwallEvent.PaywallWebviewLoadComplete }, failed = events.count { - it.eventName == "paywallWebviewLoad_fail" || it.eventName == "paywallWebviewLoad_timeout" + it.event is SuperwallEvent.PaywallWebviewLoadFail || it.event is SuperwallEvent.PaywallWebviewLoadTimeout }, ) } @@ -426,9 +430,9 @@ private fun PreloadSection(events: List) { val productsCounts by remember(events) { derivedStateOf { LoadCounts( - started = events.count { it.eventName == "paywallProductsLoad_start" }, - completed = events.count { it.eventName == "paywallProductsLoad_complete" }, - failed = events.count { it.eventName == "paywallProductsLoad_fail" }, + started = events.count { it.event is SuperwallEvent.PaywallProductsLoadStart }, + completed = events.count { it.event is SuperwallEvent.PaywallProductsLoadComplete }, + failed = events.count { it.event is SuperwallEvent.PaywallProductsLoadFail }, ) } }