From 044d18359e86e760a37341718008383a76c98ace Mon Sep 17 00:00:00 2001 From: bidetofevil Date: Mon, 9 Sep 2024 15:31:03 -0700 Subject: [PATCH] Add component to map activity lifecycle events to trace creation events --- .../capture/activity/OpenEventEmitter.kt | 151 ++++++++ .../capture/activity/OpenEventEmitterTest.kt | 345 ++++++++++++++++++ .../ClockTickingActivityLifecycleCallbacks.kt | 75 ++++ 3 files changed, 571 insertions(+) create mode 100644 embrace-android-features/src/main/kotlin/io/embrace/android/embracesdk/internal/capture/activity/OpenEventEmitter.kt create mode 100644 embrace-android-features/src/test/java/io/embrace/android/embracesdk/internal/capture/activity/OpenEventEmitterTest.kt create mode 100644 embrace-test-fakes/src/main/kotlin/io/embrace/android/embracesdk/internal/ClockTickingActivityLifecycleCallbacks.kt diff --git a/embrace-android-features/src/main/kotlin/io/embrace/android/embracesdk/internal/capture/activity/OpenEventEmitter.kt b/embrace-android-features/src/main/kotlin/io/embrace/android/embracesdk/internal/capture/activity/OpenEventEmitter.kt new file mode 100644 index 0000000000..5f63705c6d --- /dev/null +++ b/embrace-android-features/src/main/kotlin/io/embrace/android/embracesdk/internal/capture/activity/OpenEventEmitter.kt @@ -0,0 +1,151 @@ +package io.embrace.android.embracesdk.internal.capture.activity + +import android.app.Activity +import android.app.Application.ActivityLifecycleCallbacks +import android.os.Build +import android.os.Bundle +import io.embrace.android.embracesdk.internal.clock.nanosToMillis +import io.embrace.android.embracesdk.internal.session.lifecycle.ActivityLifecycleListener +import io.embrace.android.embracesdk.internal.utils.VersionChecker +import io.opentelemetry.sdk.common.Clock + +/** + * Maps [ActivityLifecycleCallbacks] events to [OpenEvents] depending on the current state of the app and capabilities of the OS. + * + * The purpose of this is to leverage Activity lifecycle events to provide data for the underlying workflow to bring a new Activity on + * screen. Due to the varying capabilities of the APIs available on the different versions of Android, the precise triggering events for + * the start and intermediate steps may differ. + * + * See [OpenTraceEmitter] for details. + */ +class OpenEventEmitter( + private val openEvents: OpenEvents, + private val clock: Clock, + private val versionChecker: VersionChecker, +) : ActivityLifecycleListener { + + override fun onActivityPreCreated(activity: Activity, savedInstanceState: Bundle?) { + create(activity) + } + + override fun onActivityCreated(activity: Activity, bundle: Bundle?) { + if (!versionChecker.firePrePostEvents()) { + create(activity) + } + } + + override fun onActivityPostCreated(activity: Activity, savedInstanceState: Bundle?) { + createEnd(activity) + } + + override fun onActivityPreStarted(activity: Activity) { + start(activity) + } + + override fun onActivityStarted(activity: Activity) { + if (!versionChecker.firePrePostEvents()) { + createEnd(activity) + start(activity) + } + } + + override fun onActivityPostStarted(activity: Activity) { + startEnd(activity) + } + + override fun onActivityPreResumed(activity: Activity) { + resume(activity) + } + + override fun onActivityResumed(activity: Activity) { + if (!versionChecker.firePrePostEvents()) { + startEnd(activity) + resume(activity) + } + } + + override fun onActivityPostResumed(activity: Activity) { + resumeEnd(activity) + } + + override fun onActivityPrePaused(activity: Activity) { + resetTrace(activity) + } + + override fun onActivityPaused(activity: Activity) { + if (!versionChecker.firePrePostEvents()) { + resetTrace(activity) + } + } + + override fun onActivityStopped(activity: Activity) { + hibernate(activity) + } + + private fun resetTrace(activity: Activity) { + openEvents.resetTrace( + instanceId = traceInstanceId(activity), + activityName = activity.localClassName, + timestampMs = nowMs() + ) + } + + private fun hibernate(activity: Activity) { + openEvents.hibernate( + instanceId = traceInstanceId(activity), + activityName = activity.localClassName, + timestampMs = nowMs() + ) + } + + private fun create(activity: Activity) { + openEvents.create( + instanceId = traceInstanceId(activity), + activityName = activity.localClassName, + timestampMs = nowMs() + ) + } + + private fun createEnd(activity: Activity) { + openEvents.createEnd( + instanceId = traceInstanceId(activity), + timestampMs = nowMs() + ) + } + + private fun start(activity: Activity) { + openEvents.start( + instanceId = traceInstanceId(activity), + activityName = activity.localClassName, + timestampMs = nowMs() + ) + } + + private fun startEnd(activity: Activity) { + openEvents.startEnd( + instanceId = traceInstanceId(activity), + timestampMs = nowMs() + ) + } + + private fun resume(activity: Activity) { + openEvents.resume( + instanceId = traceInstanceId(activity), + activityName = activity.localClassName, + timestampMs = nowMs() + ) + } + + private fun resumeEnd(activity: Activity) { + openEvents.resumeEnd( + instanceId = traceInstanceId(activity), + timestampMs = nowMs() + ) + } + + private fun VersionChecker.firePrePostEvents(): Boolean = isAtLeast(Build.VERSION_CODES.Q) + + private fun traceInstanceId(activity: Activity): Int = activity.hashCode() + + private fun nowMs(): Long = clock.now().nanosToMillis() +} diff --git a/embrace-android-features/src/test/java/io/embrace/android/embracesdk/internal/capture/activity/OpenEventEmitterTest.kt b/embrace-android-features/src/test/java/io/embrace/android/embracesdk/internal/capture/activity/OpenEventEmitterTest.kt new file mode 100644 index 0000000000..33af2fdb94 --- /dev/null +++ b/embrace-android-features/src/test/java/io/embrace/android/embracesdk/internal/capture/activity/OpenEventEmitterTest.kt @@ -0,0 +1,345 @@ +package io.embrace.android.embracesdk.internal.capture.activity + +import android.app.Activity +import android.os.Build +import androidx.test.ext.junit.runners.AndroidJUnit4 +import io.embrace.android.embracesdk.fakes.FakeClock +import io.embrace.android.embracesdk.fakes.injection.FakeInitModule +import io.embrace.android.embracesdk.internal.ClockTickingActivityLifecycleCallbacks +import io.embrace.android.embracesdk.internal.ClockTickingActivityLifecycleCallbacks.Companion.POST_DURATION +import io.embrace.android.embracesdk.internal.ClockTickingActivityLifecycleCallbacks.Companion.PRE_DURATION +import io.embrace.android.embracesdk.internal.ClockTickingActivityLifecycleCallbacks.Companion.STATE_DURATION +import io.embrace.android.embracesdk.internal.capture.activity.OpenEventEmitterTest.FakeOpenEvents.EventData +import io.embrace.android.embracesdk.internal.utils.BuildVersionChecker +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.Robolectric +import org.robolectric.RuntimeEnvironment +import org.robolectric.android.controller.ActivityController +import org.robolectric.annotation.Config + +@RunWith(AndroidJUnit4::class) +internal class OpenEventEmitterTest { + private lateinit var clock: FakeClock + private lateinit var openEvents: FakeOpenEvents + private lateinit var eventEmitter: OpenEventEmitter + private lateinit var activityController: ActivityController<*> + private var startTimeMs: Long = 0L + private var instanceId = 0 + private var activityName = "" + + @Before + fun setUp() { + clock = FakeClock() + val initModule = FakeInitModule(clock = clock) + clock.tick(100L) + openEvents = FakeOpenEvents() + eventEmitter = OpenEventEmitter( + openEvents = openEvents, + clock = initModule.openTelemetryModule.openTelemetryClock, + versionChecker = BuildVersionChecker, + ) + activityController = Robolectric.buildActivity(Activity::class.java) + RuntimeEnvironment.getApplication().registerActivityLifecycleCallbacks( + ClockTickingActivityLifecycleCallbacks(clock) + ) + RuntimeEnvironment.getApplication().registerActivityLifecycleCallbacks(eventEmitter) + startTimeMs = clock.now() + instanceId = activityController.get().hashCode() + activityName = activityController.get().localClassName + } + + @Config(sdk = [Build.VERSION_CODES.UPSIDE_DOWN_CAKE]) + @Test + fun `check cold open event stages in U`() { + stepThroughActivityLifecycle() + openEvents.events.assertEventData( + listOf( + createEvent( + stage = "create", + timestampMs = startTimeMs + PRE_DURATION + ), + createEvent( + stage = "createEnd", + timestampMs = startTimeMs + POST_DURATION + STATE_DURATION + PRE_DURATION + ), + createEvent( + stage = "start", + timestampMs = startTimeMs + POST_DURATION + STATE_DURATION + PRE_DURATION * 2 + ), + createEvent( + stage = "startEnd", + timestampMs = startTimeMs + (POST_DURATION + STATE_DURATION + PRE_DURATION) * 2 + ), + createEvent( + stage = "resume", + timestampMs = startTimeMs + (POST_DURATION + STATE_DURATION + PRE_DURATION) * 2 + PRE_DURATION + ), + createEvent( + stage = "resumeEnd", + timestampMs = startTimeMs + (POST_DURATION + STATE_DURATION + PRE_DURATION) * 3 + ), + createEvent( + stage = "resetTrace", + timestampMs = startTimeMs + (POST_DURATION + STATE_DURATION + PRE_DURATION) * 3 + PRE_DURATION + ), + createEvent( + stage = "hibernate", + timestampMs = startTimeMs + (POST_DURATION + STATE_DURATION + PRE_DURATION) * 4 + STATE_DURATION + ), + ) + ) + } + + @Config(sdk = [Build.VERSION_CODES.UPSIDE_DOWN_CAKE]) + @Test + fun `check hot open event stages in U`() { + stepThroughActivityLifecycle(isColdOpen = false) + openEvents.events.assertEventData( + listOf( + createEvent( + stage = "start", + timestampMs = startTimeMs + PRE_DURATION + ), + createEvent( + stage = "startEnd", + timestampMs = startTimeMs + POST_DURATION + STATE_DURATION + PRE_DURATION + ), + createEvent( + stage = "resume", + timestampMs = startTimeMs + POST_DURATION + STATE_DURATION + PRE_DURATION * 2 + ), + createEvent( + stage = "resumeEnd", + timestampMs = startTimeMs + (POST_DURATION + STATE_DURATION + PRE_DURATION) * 2 + ), + createEvent( + stage = "resetTrace", + timestampMs = startTimeMs + (POST_DURATION + STATE_DURATION + PRE_DURATION) * 2 + PRE_DURATION + ), + createEvent( + stage = "hibernate", + timestampMs = startTimeMs + (POST_DURATION + STATE_DURATION + PRE_DURATION) * 3 + STATE_DURATION + ), + ) + ) + } + + @Config(sdk = [Build.VERSION_CODES.LOLLIPOP]) + @Test + fun `check cold open event stages in L`() { + stepThroughActivityLifecycle() + openEvents.events.assertEventData( + listOf( + createEvent( + stage = "create", + timestampMs = startTimeMs + STATE_DURATION + ), + createEvent( + stage = "createEnd", + timestampMs = startTimeMs + STATE_DURATION * 2 + ), + createEvent( + stage = "start", + timestampMs = startTimeMs + STATE_DURATION * 2 + ), + createEvent( + stage = "startEnd", + timestampMs = startTimeMs + STATE_DURATION * 3 + ), + createEvent( + stage = "resume", + timestampMs = startTimeMs + STATE_DURATION * 3 + ), + createEvent( + stage = "resetTrace", + timestampMs = startTimeMs + STATE_DURATION * 4 + ), + createEvent( + stage = "hibernate", + timestampMs = startTimeMs + STATE_DURATION * 5 + ), + ) + ) + } + + @Config(sdk = [Build.VERSION_CODES.LOLLIPOP]) + @Test + fun `check hot open event stages in L`() { + stepThroughActivityLifecycle(isColdOpen = false) + openEvents.events.assertEventData( + listOf( + createEvent( + stage = "createEnd", + timestampMs = startTimeMs + STATE_DURATION + ), + createEvent( + stage = "start", + timestampMs = startTimeMs + STATE_DURATION + ), + createEvent( + stage = "startEnd", + timestampMs = startTimeMs + STATE_DURATION * 2 + ), + createEvent( + stage = "resume", + timestampMs = startTimeMs + STATE_DURATION * 2 + ), + createEvent( + stage = "resetTrace", + timestampMs = startTimeMs + STATE_DURATION * 3 + ), + createEvent( + stage = "hibernate", + timestampMs = startTimeMs + STATE_DURATION * 4 + ), + ) + ) + } + + private fun stepThroughActivityLifecycle( + isColdOpen: Boolean = true + ) { + with(activityController) { + if (isColdOpen) { + create() + } + start() + resume() + pause() + stop() + } + } + + private fun List.assertEventData(expectedEvents: List) { + assertEquals(expectedEvents.map { it.stage to it.timestampMs }, map { it.stage to it.timestampMs }) + } + + private fun createEvent(stage: String, timestampMs: Long) = + EventData( + stage = stage, + instanceId = instanceId, + activityName = activityName, + timestampMs = timestampMs + ) + + class FakeOpenEvents : OpenEvents { + val events = mutableListOf() + + override fun resetTrace(instanceId: Int, activityName: String, timestampMs: Long) { + events.add( + EventData( + stage = "resetTrace", + instanceId = instanceId, + activityName = activityName, + timestampMs = timestampMs + ) + ) + } + + override fun hibernate(instanceId: Int, activityName: String, timestampMs: Long) { + events.add( + EventData( + stage = "hibernate", + instanceId = instanceId, + activityName = activityName, + timestampMs = timestampMs + ) + ) + } + + override fun create(instanceId: Int, activityName: String, timestampMs: Long) { + events.add( + EventData( + stage = "create", + instanceId = instanceId, + activityName = activityName, + timestampMs = timestampMs + ) + ) + } + + override fun createEnd(instanceId: Int, timestampMs: Long) { + events.add( + EventData( + stage = "createEnd", + instanceId = instanceId, + activityName = null, + timestampMs = timestampMs + ) + ) + } + + override fun start(instanceId: Int, activityName: String, timestampMs: Long) { + events.add( + EventData( + stage = "start", + instanceId = instanceId, + activityName = activityName, + timestampMs = timestampMs + ) + ) + } + + override fun startEnd(instanceId: Int, timestampMs: Long) { + events.add( + EventData( + stage = "startEnd", + instanceId = instanceId, + timestampMs = timestampMs + ) + ) + } + + override fun resume(instanceId: Int, activityName: String, timestampMs: Long) { + events.add( + EventData( + stage = "resume", + instanceId = instanceId, + activityName = activityName, + timestampMs = timestampMs + ) + ) + } + + override fun resumeEnd(instanceId: Int, timestampMs: Long) { + events.add( + EventData( + stage = "resumeEnd", + instanceId = instanceId, + timestampMs = timestampMs + ) + ) + } + + override fun render(instanceId: Int, activityName: String, timestampMs: Long) { + events.add( + EventData( + stage = "render", + instanceId = instanceId, + activityName = activityName, + timestampMs = timestampMs + ) + ) + } + + override fun renderEnd(instanceId: Int, timestampMs: Long) { + events.add( + EventData( + stage = "renderEnd", + instanceId = instanceId, + timestampMs = timestampMs + ) + ) + } + + data class EventData( + val stage: String, + val instanceId: Int, + val activityName: String? = null, + val timestampMs: Long, + ) + } +} diff --git a/embrace-test-fakes/src/main/kotlin/io/embrace/android/embracesdk/internal/ClockTickingActivityLifecycleCallbacks.kt b/embrace-test-fakes/src/main/kotlin/io/embrace/android/embracesdk/internal/ClockTickingActivityLifecycleCallbacks.kt new file mode 100644 index 0000000000..fa827844cc --- /dev/null +++ b/embrace-test-fakes/src/main/kotlin/io/embrace/android/embracesdk/internal/ClockTickingActivityLifecycleCallbacks.kt @@ -0,0 +1,75 @@ +package io.embrace.android.embracesdk.internal + +import android.app.Activity +import android.app.Application.ActivityLifecycleCallbacks +import android.os.Bundle +import io.embrace.android.embracesdk.fakes.FakeClock + +class ClockTickingActivityLifecycleCallbacks( + private val clock: FakeClock +) : ActivityLifecycleCallbacks { + + override fun onActivityPreCreated(activity: Activity, savedInstanceState: Bundle?) { + clock.tick(2) + } + + override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) { + clock.tick(10) + } + + override fun onActivityPostCreated(activity: Activity, savedInstanceState: Bundle?) { + clock.tick(1) + } + + override fun onActivityPreStarted(activity: Activity) { + clock.tick(2) + } + + override fun onActivityStarted(activity: Activity) { + clock.tick(10) + } + + override fun onActivityPostStarted(activity: Activity) { + clock.tick(1) + } + + override fun onActivityPreResumed(activity: Activity) { + clock.tick(2) + } + + override fun onActivityResumed(activity: Activity) { + clock.tick(10) + } + + override fun onActivityPostResumed(activity: Activity) { + clock.tick(1) + } + + override fun onActivityPrePaused(activity: Activity) { + clock.tick(2) + } + + override fun onActivityPaused(activity: Activity) { + clock.tick(10) + } + + override fun onActivityPostPaused(activity: Activity) { + clock.tick(1) + } + + override fun onActivityStopped(activity: Activity) { + clock.tick(10) + } + + override fun onActivitySaveInstanceState(activity: Activity, outState: Bundle) { + } + + override fun onActivityDestroyed(activity: Activity) { + } + + companion object { + const val PRE_DURATION = 2 + const val STATE_DURATION = 10 + const val POST_DURATION = 1 + } +}