diff --git a/embrace-android-core/src/main/kotlin/io/embrace/android/embracesdk/internal/arch/schema/EmbType.kt b/embrace-android-core/src/main/kotlin/io/embrace/android/embracesdk/internal/arch/schema/EmbType.kt index db4acec94..00dc4b30f 100644 --- a/embrace-android-core/src/main/kotlin/io/embrace/android/embracesdk/internal/arch/schema/EmbType.kt +++ b/embrace-android-core/src/main/kotlin/io/embrace/android/embracesdk/internal/arch/schema/EmbType.kt @@ -26,7 +26,7 @@ sealed class EmbType(type: String, subtype: String?) : TelemetryType { object ThermalState : Performance("thermal_state") - object ActivityOpen : Performance("activity_open") + object UiLoad : Performance("ui_load") } /** diff --git a/embrace-android-core/src/main/kotlin/io/embrace/android/embracesdk/internal/spans/EmbraceSpanBuilder.kt b/embrace-android-core/src/main/kotlin/io/embrace/android/embracesdk/internal/spans/EmbraceSpanBuilder.kt index 6e8d8060f..59dec0024 100644 --- a/embrace-android-core/src/main/kotlin/io/embrace/android/embracesdk/internal/spans/EmbraceSpanBuilder.kt +++ b/embrace-android-core/src/main/kotlin/io/embrace/android/embracesdk/internal/spans/EmbraceSpanBuilder.kt @@ -85,7 +85,7 @@ class EmbraceSpanBuilder( } private fun updateKeySpan() { - if (fixedAttributes.contains(EmbType.Performance.Default) || fixedAttributes.contains(EmbType.Performance.ActivityOpen)) { + if (fixedAttributes.contains(EmbType.Performance.Default) || fixedAttributes.contains(EmbType.Performance.UiLoad)) { if (getParentSpan() == null) { fixedAttributes.add(KeySpan) } else { diff --git a/embrace-android-core/src/test/java/io/embrace/android/embracesdk/internal/spans/EmbraceSpanBuilderTest.kt b/embrace-android-core/src/test/java/io/embrace/android/embracesdk/internal/spans/EmbraceSpanBuilderTest.kt index ec273cee5..a354bf367 100644 --- a/embrace-android-core/src/test/java/io/embrace/android/embracesdk/internal/spans/EmbraceSpanBuilderTest.kt +++ b/embrace-android-core/src/test/java/io/embrace/android/embracesdk/internal/spans/EmbraceSpanBuilderTest.kt @@ -156,7 +156,7 @@ internal class EmbraceSpanBuilderTest { } @Test - fun `perf and activity_open spans are key spans if parent is null`() { + fun `perf and ui_load spans are key spans if parent is null`() { val perfSpanBuilder = EmbraceSpanBuilder( tracer = tracer, name = "test", @@ -171,7 +171,7 @@ internal class EmbraceSpanBuilderTest { val activityOpenSpanBuilder = EmbraceSpanBuilder( tracer = tracer, name = "test", - telemetryType = EmbType.Performance.ActivityOpen, + telemetryType = EmbType.Performance.UiLoad, internal = false, private = false, parentSpan = null, diff --git a/embrace-android-features/src/main/kotlin/io/embrace/android/embracesdk/internal/capture/activity/UiLoadEvents.kt b/embrace-android-features/src/main/kotlin/io/embrace/android/embracesdk/internal/capture/activity/UiLoadEvents.kt new file mode 100644 index 000000000..3953262e0 --- /dev/null +++ b/embrace-android-features/src/main/kotlin/io/embrace/android/embracesdk/internal/capture/activity/UiLoadEvents.kt @@ -0,0 +1,59 @@ +package io.embrace.android.embracesdk.internal.capture.activity + +/** + * Relevant events in during the lifecycle of UI loading. Listeners to these events should gather the data and log + * the appropriate loading traces and spans given the associated Activity. + */ +interface UiLoadEvents { + + /** + * When we no longer wish to observe the loading of the given Activity instance. This may be called during its load + * or after it has loaded. Calls to this for a given Activity instance is idempotent + */ + fun abandon(instanceId: Int, activityName: String, timestampMs: Long) + + /** + * When the app is no longer in a state where it is trying to open up a new Activity + */ + fun reset(instanceId: Int) + + /** + * When the given Activity is entering the CREATE stage of its lifecycle. + */ + fun create(instanceId: Int, activityName: String, timestampMs: Long) + + /** + * When the given Activity has exited the CREATE stage of its lifecycle. + */ + fun createEnd(instanceId: Int, timestampMs: Long) + + /** + * When the given Activity is entering the START stage of its lifecycle. + */ + fun start(instanceId: Int, activityName: String, timestampMs: Long) + + /** + * When the given Activity has exited the START stage of its lifecycle. + */ + fun startEnd(instanceId: Int, timestampMs: Long) + + /** + * When the given Activity is entering the RESUME stage of its lifecycle. + */ + fun resume(instanceId: Int, activityName: String, timestampMs: Long) + + /** + * When the given Activity has exited the RESUME stage of its lifecycle. + */ + fun resumeEnd(instanceId: Int, timestampMs: Long) + + /** + * When the given Activity's first UI frame starts to be rendered. + */ + fun render(instanceId: Int, activityName: String, timestampMs: Long) + + /** + * When the given Activity's first UI frame has been displayed. + */ + fun renderEnd(instanceId: Int, timestampMs: Long) +} diff --git a/embrace-android-features/src/main/kotlin/io/embrace/android/embracesdk/internal/capture/activity/UiLoadTraceEmitter.kt b/embrace-android-features/src/main/kotlin/io/embrace/android/embracesdk/internal/capture/activity/UiLoadTraceEmitter.kt new file mode 100644 index 000000000..c2fc97041 --- /dev/null +++ b/embrace-android-features/src/main/kotlin/io/embrace/android/embracesdk/internal/capture/activity/UiLoadTraceEmitter.kt @@ -0,0 +1,281 @@ +package io.embrace.android.embracesdk.internal.capture.activity + +import android.app.Application.ActivityLifecycleCallbacks +import android.os.Build +import io.embrace.android.embracesdk.internal.arch.schema.EmbType +import io.embrace.android.embracesdk.internal.spans.PersistableEmbraceSpan +import io.embrace.android.embracesdk.internal.spans.SpanService +import io.embrace.android.embracesdk.internal.utils.VersionChecker +import io.embrace.android.embracesdk.spans.ErrorCode +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.atomic.AtomicReference + +/** + * Observes [UiLoadEvents] to create traces that model the workflow for displaying UI on screen. + * This will record traces for all [UiLoadType] but will ignore any UI load that is part of the app startup workflow. + * + * Depending on the version of Android and the state of the app, the start, end, and intermediate stages of the workflow will use + * timestamps from different events, which affects the precision of the measurements as well as the child spans contained in the trace. + * + * An assumption that there can only be one activity going through its the activity lifecycle at a time. If we see events + * that come from a different Activity instance than the last one processed, we assume that last one's loading has been + * interrupted so any load traces associated with it can be abandoned. + * + * The start for [UiLoadType.COLD]: + * + * - On Android 10+, when [ActivityLifecycleCallbacks.onActivityPostPaused] is fired, denoting that the previous activity has completed + * its [ActivityLifecycleCallbacks.onActivityPaused] callbacks and a new Activity is ready to be created. + * + * - Android 9 and lower, when [ActivityLifecycleCallbacks.onActivityPaused] is fired, denoting that the previous activity is in the + * process of exiting. This will possibly result in some cleanup work of exiting the previous activity being included in the duration + * of the next trace that is logged. + * + * The start for [UiLoadType.HOT] + * + * - On Android 10+, when [ActivityLifecycleCallbacks.onActivityPreStarted] is fired, denoting that an existing Activity instance is ready + * to be started + * + * - Android 9 and lower, when [ActivityLifecycleCallbacks.onActivityStarted] is fired, denoting that an existing activity is in the + * process of starting. This will possibly result in some of the work to start the activity already having happened depending on the + * other callbacks that have been registered. + * + * The end for both [UiLoadType.COLD] and [UiLoadType.HOT]: + * + * - Android 10+, when the Activity's first UI frame finishes rendering and is delivered to the screen + * + * - Android 9 and lower, when [ActivityLifecycleCallbacks.onActivityResumed] is fired. + */ +class UiLoadTraceEmitter( + private val spanService: SpanService, + private val versionChecker: VersionChecker, +) : UiLoadEvents { + + private val activeTraces: MutableMap = ConcurrentHashMap() + private val traceZygoteHolder: AtomicReference = AtomicReference(INITIAL) + private var currentTracedInstanceId: Int? = null + + override fun abandon(instanceId: Int, activityName: String, timestampMs: Long) { + currentTracedInstanceId?.let { currentlyTracedInstanceId -> + if (instanceId != currentlyTracedInstanceId) { + endTrace(instanceId = currentlyTracedInstanceId, timestampMs = timestampMs, errorCode = ErrorCode.USER_ABANDON) + } + } + traceZygoteHolder.set( + UiLoadTraceZygote( + lastActivityName = activityName, + lastActivityInstanceId = instanceId, + lastActivityPausedTimeMs = timestampMs + ) + ) + } + + override fun reset(instanceId: Int) { + if (traceZygoteHolder.get().lastActivityInstanceId == instanceId) { + traceZygoteHolder.set(BACKGROUNDED) + } + } + + override fun create(instanceId: Int, activityName: String, timestampMs: Long) { + startTrace( + uiLoadType = UiLoadType.COLD, + instanceId = instanceId, + activityName = activityName, + timestampMs = timestampMs + ) + startChildSpan( + instanceId = instanceId, + timestampMs = timestampMs, + lifecycleEvent = LifecycleEvent.CREATE + ) + } + + override fun createEnd(instanceId: Int, timestampMs: Long) { + endChildSpan( + instanceId = instanceId, + timestampMs = timestampMs, + lifecycleEvent = LifecycleEvent.CREATE + ) + } + + override fun start(instanceId: Int, activityName: String, timestampMs: Long) { + startTrace( + uiLoadType = UiLoadType.HOT, + instanceId = instanceId, + activityName = activityName, + timestampMs = timestampMs + ) + startChildSpan( + instanceId = instanceId, + timestampMs = timestampMs, + lifecycleEvent = LifecycleEvent.START + ) + } + + override fun startEnd(instanceId: Int, timestampMs: Long) { + endChildSpan( + instanceId = instanceId, + timestampMs = timestampMs, + lifecycleEvent = LifecycleEvent.START + ) + } + + override fun resume(instanceId: Int, activityName: String, timestampMs: Long) { + if (!hasRenderEvent()) { + endTrace( + instanceId = instanceId, + timestampMs = timestampMs, + ) + } else { + startChildSpan( + instanceId = instanceId, + timestampMs = timestampMs, + lifecycleEvent = LifecycleEvent.RESUME + ) + } + traceZygoteHolder.set(READY) + } + + override fun resumeEnd(instanceId: Int, timestampMs: Long) { + endChildSpan( + instanceId = instanceId, + timestampMs = timestampMs, + lifecycleEvent = LifecycleEvent.RESUME + ) + } + + override fun render(instanceId: Int, activityName: String, timestampMs: Long) { + startChildSpan( + instanceId = instanceId, + timestampMs = timestampMs, + lifecycleEvent = LifecycleEvent.RENDER + ) + } + + override fun renderEnd(instanceId: Int, timestampMs: Long) { + endChildSpan( + instanceId = instanceId, + timestampMs = timestampMs, + lifecycleEvent = LifecycleEvent.RENDER + ) + endTrace( + instanceId = instanceId, + timestampMs = timestampMs, + ) + } + + private fun startTrace( + uiLoadType: UiLoadType, + instanceId: Int, + activityName: String, + timestampMs: Long + ) { + if (traceZygoteHolder.get() == INITIAL) { + return + } + + if (!activeTraces.containsKey(instanceId)) { + val zygote = traceZygoteHolder.getAndSet(READY) + val startTimeMs = if (zygote.lastActivityPausedTimeMs != -1L) { + zygote.lastActivityPausedTimeMs + } else { + timestampMs + } + + spanService.startSpan( + name = traceName(activityName, uiLoadType), + type = EmbType.Performance.UiLoad, + startTimeMs = startTimeMs, + )?.let { root -> + if (zygote.lastActivityInstanceId != -1) { + root.addSystemAttribute("last_activity", zygote.lastActivityName) + } + activeTraces[instanceId] = UiLoadTrace(root = root, activityName = activityName) + } + } + } + + private fun endTrace(instanceId: Int, timestampMs: Long, errorCode: ErrorCode? = null) { + activeTraces[instanceId]?.let { trace -> + with(trace) { + children.values.filter { it.isRecording }.forEach { span -> + span.stop(endTimeMs = timestampMs, errorCode = errorCode) + } + root.stop(endTimeMs = timestampMs, errorCode = errorCode) + } + activeTraces.remove(instanceId) + } + } + + private fun startChildSpan(instanceId: Int, timestampMs: Long, lifecycleEvent: LifecycleEvent) { + val trace = activeTraces[instanceId] + if (trace != null && !trace.children.containsKey(lifecycleEvent)) { + spanService.startSpan( + name = lifecycleEvent.spanName(trace.activityName), + parent = trace.root, + startTimeMs = timestampMs, + )?.let { newSpan -> + val newChildren = trace.children.plus(lifecycleEvent to newSpan) + activeTraces[instanceId] = trace.copy( + children = newChildren + ) + } + } + } + + private fun endChildSpan(instanceId: Int, timestampMs: Long, lifecycleEvent: LifecycleEvent) { + activeTraces[instanceId]?.let { trace -> + trace.children[lifecycleEvent]?.stop(timestampMs) + } + } + + private fun hasRenderEvent(): Boolean = versionChecker.isAtLeast(Build.VERSION_CODES.Q) + + private fun traceName( + activityName: String, + uiLoadType: UiLoadType + ): String = "$activityName-${uiLoadType.typeName}-time-to-initial-display" + + enum class LifecycleEvent(private val typeName: String) { + CREATE("create"), + START("start"), + RESUME("resume"), + RENDER("render"); + + fun spanName(activityName: String): String = "$activityName-$typeName" + } + + private data class UiLoadTrace( + val activityName: String, + val root: PersistableEmbraceSpan, + val children: Map = ConcurrentHashMap(), + ) + + private data class UiLoadTraceZygote( + val lastActivityName: String, + val lastActivityInstanceId: Int, + val lastActivityPausedTimeMs: Long, + ) + + private companion object { + const val INVALID_INSTANCE: Int = -1 + const val INVALID_TIME: Long = -1L + + val INITIAL = UiLoadTraceZygote( + lastActivityName = "NEW_APP_LAUNCH", + lastActivityInstanceId = INVALID_INSTANCE, + lastActivityPausedTimeMs = INVALID_TIME + ) + + val READY = UiLoadTraceZygote( + lastActivityName = "READY", + lastActivityInstanceId = INVALID_INSTANCE, + lastActivityPausedTimeMs = INVALID_TIME + ) + + val BACKGROUNDED = UiLoadTraceZygote( + lastActivityName = "BACKGROUNDED", + lastActivityInstanceId = INVALID_INSTANCE, + lastActivityPausedTimeMs = INVALID_TIME + ) + } +} diff --git a/embrace-android-features/src/main/kotlin/io/embrace/android/embracesdk/internal/capture/activity/UiLoadType.kt b/embrace-android-features/src/main/kotlin/io/embrace/android/embracesdk/internal/capture/activity/UiLoadType.kt new file mode 100644 index 000000000..853d7d8c4 --- /dev/null +++ b/embrace-android-features/src/main/kotlin/io/embrace/android/embracesdk/internal/capture/activity/UiLoadType.kt @@ -0,0 +1,16 @@ +package io.embrace.android.embracesdk.internal.capture.activity + +/** + * The type of UI load being traced + */ +enum class UiLoadType(val typeName: String) { + /** + * Load where the Activity instance has to be created + */ + COLD("cold"), + + /** + * Load where the instance has already been created and just needs to be started and resumed (e.g. foregrounding) + */ + HOT("hot") +} diff --git a/embrace-android-features/src/test/java/io/embrace/android/embracesdk/internal/capture/activity/UiLoadTraceEmitterTest.kt b/embrace-android-features/src/test/java/io/embrace/android/embracesdk/internal/capture/activity/UiLoadTraceEmitterTest.kt new file mode 100644 index 000000000..fbf5b9d74 --- /dev/null +++ b/embrace-android-features/src/test/java/io/embrace/android/embracesdk/internal/capture/activity/UiLoadTraceEmitterTest.kt @@ -0,0 +1,401 @@ +package io.embrace.android.embracesdk.internal.capture.activity + +import android.os.Build +import androidx.test.ext.junit.runners.AndroidJUnit4 +import io.embrace.android.embracesdk.assertions.assertEmbraceSpanData +import io.embrace.android.embracesdk.fakes.FakeClock +import io.embrace.android.embracesdk.fakes.injection.FakeInitModule +import io.embrace.android.embracesdk.internal.payload.toNewPayload +import io.embrace.android.embracesdk.internal.spans.SpanService +import io.embrace.android.embracesdk.internal.spans.SpanSink +import io.embrace.android.embracesdk.internal.utils.BuildVersionChecker +import io.opentelemetry.api.trace.SpanId +import org.junit.Assert.assertNull +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.annotation.Config +import kotlin.math.max +import kotlin.math.min + +@RunWith(AndroidJUnit4::class) +internal class UiLoadTraceEmitterTest { + private lateinit var clock: FakeClock + private lateinit var spanSink: SpanSink + private lateinit var spanService: SpanService + private lateinit var traceEmitter: UiLoadTraceEmitter + + @Before + fun setUp() { + clock = FakeClock() + val initModule = FakeInitModule(clock = clock) + spanSink = initModule.openTelemetryModule.spanSink + spanService = initModule.openTelemetryModule.spanService + spanService.initializeService(clock.now()) + clock.tick(100L) + traceEmitter = UiLoadTraceEmitter( + spanService = spanService, + versionChecker = BuildVersionChecker, + ) + } + + @Config(sdk = [Build.VERSION_CODES.UPSIDE_DOWN_CAKE]) + @Test + fun `verify cold open trace from another activity in U`() { + verifyOpen( + previousState = PreviousState.FROM_ACTIVITY, + uiLoadType = UiLoadType.COLD, + firePreAndPost = true, + hasRenderEvent = true, + ) + } + + @Config(sdk = [Build.VERSION_CODES.UPSIDE_DOWN_CAKE]) + @Test + fun `verify cold open trace from the same activity in U`() { + verifyOpen( + lastActivityName = ACTIVITY_NAME, + previousState = PreviousState.FROM_ACTIVITY, + uiLoadType = UiLoadType.COLD, + firePreAndPost = true, + hasRenderEvent = true, + ) + } + + @Config(sdk = [Build.VERSION_CODES.UPSIDE_DOWN_CAKE]) + @Test + fun `verify cold open trace from background in U`() { + verifyOpen( + previousState = PreviousState.FROM_BACKGROUND, + uiLoadType = UiLoadType.COLD, + firePreAndPost = true, + hasRenderEvent = true, + ) + } + + @Config(sdk = [Build.VERSION_CODES.UPSIDE_DOWN_CAKE]) + @Test + fun `verify hot open trace in from background in U`() { + verifyOpen( + previousState = PreviousState.FROM_BACKGROUND, + uiLoadType = UiLoadType.HOT, + firePreAndPost = true, + hasRenderEvent = true, + ) + } + + @Config(sdk = [Build.VERSION_CODES.LOLLIPOP]) + @Test + fun `verify cold open trace in from another activity L`() { + verifyOpen( + previousState = PreviousState.FROM_ACTIVITY, + uiLoadType = UiLoadType.COLD, + firePreAndPost = false, + hasRenderEvent = false, + ) + } + + @Config(sdk = [Build.VERSION_CODES.LOLLIPOP]) + @Test + fun `verify cold open trace from background in L`() { + verifyOpen( + previousState = PreviousState.FROM_BACKGROUND, + uiLoadType = UiLoadType.COLD, + firePreAndPost = false, + hasRenderEvent = false, + ) + } + + @Config(sdk = [Build.VERSION_CODES.LOLLIPOP]) + @Test + fun `verify hot open trace in L from background`() { + verifyOpen( + previousState = PreviousState.FROM_BACKGROUND, + uiLoadType = UiLoadType.HOT, + firePreAndPost = false, + hasRenderEvent = false, + ) + } + + private fun verifyOpen( + activityName: String = ACTIVITY_NAME, + instanceId: Int = NEW_INSTANCE_ID, + lastActivityName: String = LAST_ACTIVITY_NAME, + lastInstanceId: Int = LAST_ACTIVITY_INSTANCE_ID, + previousState: PreviousState, + uiLoadType: UiLoadType, + firePreAndPost: Boolean, + hasRenderEvent: Boolean, + ) { + openActivity( + activityName = activityName, + instanceId = instanceId, + lastActivityName = lastActivityName, + lastInstanceId = lastInstanceId, + previousState = previousState, + uiLoadType = uiLoadType, + firePreAndPost = firePreAndPost, + hasRenderEvent = hasRenderEvent + ).let { timestamps -> + val spanMap = spanSink.completedSpans().associateBy { it.name } + val trace = checkNotNull(spanMap["emb-$activityName-${uiLoadType.typeName}-time-to-initial-display"]) + + assertEmbraceSpanData( + span = trace.toNewPayload(), + expectedStartTimeMs = timestamps.first, + expectedEndTimeMs = timestamps.second, + expectedParentId = SpanId.getInvalid(), + key = true, + ) + + val events = timestamps.third + if (uiLoadType == UiLoadType.COLD) { + checkNotNull(events[UiLoadTraceEmitter.LifecycleEvent.CREATE]).run { + assertEmbraceSpanData( + span = checkNotNull(spanMap["emb-$activityName-create"]).toNewPayload(), + expectedStartTimeMs = startMs(), + expectedEndTimeMs = endMs(), + expectedParentId = trace.spanId + ) + } + } else { + assertNull(spanMap["emb-$activityName-create"]) + } + + checkNotNull(events[UiLoadTraceEmitter.LifecycleEvent.START]).run { + assertEmbraceSpanData( + span = checkNotNull(spanMap["emb-$activityName-start"]).toNewPayload(), + expectedStartTimeMs = startMs(), + expectedEndTimeMs = endMs(), + expectedParentId = trace.spanId + ) + } + + if (hasRenderEvent) { + checkNotNull(events[UiLoadTraceEmitter.LifecycleEvent.RESUME]).run { + assertEmbraceSpanData( + span = checkNotNull(spanMap["emb-$activityName-resume"]).toNewPayload(), + expectedStartTimeMs = startMs(), + expectedEndTimeMs = endMs(), + expectedParentId = trace.spanId + ) + } + checkNotNull(events[UiLoadTraceEmitter.LifecycleEvent.RENDER]).run { + assertEmbraceSpanData( + span = checkNotNull(spanMap["emb-$activityName-render"]).toNewPayload(), + expectedStartTimeMs = startMs(), + expectedEndTimeMs = endMs(), + expectedParentId = trace.spanId + ) + } + } else { + assertNull(spanMap["emb-$activityName-resume"]) + assertNull(spanMap["emb-$activityName-render"]) + } + } + } + + @Suppress("CyclomaticComplexMethod", "ComplexMethod") + private fun openActivity( + activityName: String, + instanceId: Int, + lastActivityName: String, + lastInstanceId: Int, + previousState: PreviousState, + uiLoadType: UiLoadType, + firePreAndPost: Boolean, + hasRenderEvent: Boolean, + ): Triple> { + val events = mutableMapOf() + val lastActivityExitMs = clock.now() + clock.tick(100L) + + when (previousState) { + PreviousState.FROM_ACTIVITY -> { + traceEmitter.abandon(lastInstanceId, lastActivityName, lastActivityExitMs) + } + + PreviousState.FROM_BACKGROUND -> { + traceEmitter.abandon(lastInstanceId, lastActivityName, lastActivityExitMs) + traceEmitter.reset(lastInstanceId) + } + + PreviousState.FROM_INTERRUPTED_LOAD -> { + activityCreate( + activityName = lastActivityName, + instanceId = lastInstanceId, + firePreAndPost = firePreAndPost + ) + activityStart( + activityName = lastActivityName, + instanceId = lastInstanceId, + firePreAndPost = firePreAndPost + ) + } + } + + val createEvents = if (uiLoadType == UiLoadType.COLD) { + activityCreate( + activityName = activityName, + instanceId = instanceId, + firePreAndPost = firePreAndPost + ) + } else { + null + }?.apply { + events[UiLoadTraceEmitter.LifecycleEvent.CREATE] = this + } + + val startEvents = activityStart( + activityName = activityName, + instanceId = instanceId, + firePreAndPost = firePreAndPost + ).apply { + events[UiLoadTraceEmitter.LifecycleEvent.START] = this + } + + val resumeEvents = activityResume( + activityName = activityName, + instanceId = instanceId, + firePreAndPost = firePreAndPost + ).apply { + events[UiLoadTraceEmitter.LifecycleEvent.RESUME] = this + } + + val renderEvents = if (hasRenderEvent) { + activityRender( + activityName = activityName, + instanceId = instanceId, + firePreAndPost = firePreAndPost + ) + } else { + null + }?.apply { + events[UiLoadTraceEmitter.LifecycleEvent.RENDER] = this + } + + val traceStartMs = if (previousState != PreviousState.FROM_BACKGROUND) { + lastActivityExitMs + } else { + createEvents?.run { + if (firePreAndPost) { + pre + } else { + eventStart + } + } ?: if (firePreAndPost) { + startEvents.pre + } else { + startEvents.eventStart + } + } + + val traceEndMs = renderEvents?.run { + endMs() + } ?: resumeEvents.startMs() + + return Triple(traceStartMs, traceEndMs, events) + } + + private fun activityCreate( + activityName: String, + instanceId: Int, + firePreAndPost: Boolean = true + ): LifecycleEvents { + return runLifecycleEvent( + activityName = activityName, + instanceId = instanceId, + startCallback = traceEmitter::create, + endCallback = traceEmitter::createEnd, + firePreAndPost = firePreAndPost, + ) + } + + private fun activityStart( + activityName: String, + instanceId: Int, + firePreAndPost: Boolean = true + ): LifecycleEvents { + return runLifecycleEvent( + activityName = activityName, + instanceId = instanceId, + startCallback = traceEmitter::start, + endCallback = traceEmitter::startEnd, + firePreAndPost = firePreAndPost, + ) + } + + private fun activityResume( + activityName: String, + instanceId: Int, + firePreAndPost: Boolean = true + ): LifecycleEvents { + return runLifecycleEvent( + activityName = activityName, + instanceId = instanceId, + startCallback = traceEmitter::resume, + endCallback = traceEmitter::resumeEnd, + firePreAndPost = firePreAndPost, + ) + } + + private fun activityRender( + activityName: String, + instanceId: Int, + firePreAndPost: Boolean = true + ): LifecycleEvents { + return runLifecycleEvent( + activityName = activityName, + instanceId = instanceId, + startCallback = traceEmitter::render, + endCallback = traceEmitter::renderEnd, + firePreAndPost = firePreAndPost, + ) + } + + private fun runLifecycleEvent( + activityName: String, + instanceId: Int, + startCallback: (instanceId: Int, activityName: String, startMs: Long) -> Unit, + endCallback: (instanceId: Int, startMs: Long) -> Unit, + firePreAndPost: Boolean = true + ): LifecycleEvents { + val events = LifecycleEvents() + if (firePreAndPost) { + events.pre = clock.now() + clock.tick() + } + events.eventStart = clock.now() + startCallback(instanceId, activityName, events.startMs()) + events.eventEnd = clock.tick(100L) + if (firePreAndPost) { + events.post = clock.tick() + } + endCallback(instanceId, events.endMs()) + return events + } + + private data class LifecycleEvents( + var pre: Long = Long.MAX_VALUE, + var eventStart: Long = Long.MAX_VALUE, + var eventEnd: Long = Long.MIN_VALUE, + var post: Long = Long.MIN_VALUE, + ) + + private fun LifecycleEvents.startMs(): Long = min(pre, eventStart) + + private fun LifecycleEvents.endMs(): Long = max(post, eventEnd) + + private enum class PreviousState { + FROM_ACTIVITY, + FROM_BACKGROUND, + FROM_INTERRUPTED_LOAD + } + + companion object { + const val NEW_INSTANCE_ID = 1 + const val ACTIVITY_NAME = "com.my.CoolActivity" + const val LAST_ACTIVITY_NAME = "com.my.Activity" + const val LAST_ACTIVITY_INSTANCE_ID = 99 + } +}