diff --git a/common/api/common.api b/common/api/common.api index a3179d0a5..0fb0f581c 100644 --- a/common/api/common.api +++ b/common/api/common.api @@ -7,6 +7,8 @@ public final class io/opentelemetry/android/common/RumConstants { public static final field OTEL_RUM_LOG_TAG Ljava/lang/String; public static final field RUM_SDK_VERSION Lio/opentelemetry/api/common/AttributeKey; public static final field SCREEN_NAME_KEY Lio/opentelemetry/api/common/AttributeKey; + public static final field SCREEN_VIEW_DEPTH_KEY Lio/opentelemetry/api/common/AttributeKey; + public static final field SCREEN_VIEW_NODES_KEY Lio/opentelemetry/api/common/AttributeKey; public static final field START_TYPE_KEY Lio/opentelemetry/api/common/AttributeKey; public static final field STORAGE_SPACE_FREE_KEY Lio/opentelemetry/api/common/AttributeKey; } diff --git a/common/src/main/java/io/opentelemetry/android/common/RumConstants.kt b/common/src/main/java/io/opentelemetry/android/common/RumConstants.kt index 1c23cbfc6..cf1edcab3 100644 --- a/common/src/main/java/io/opentelemetry/android/common/RumConstants.kt +++ b/common/src/main/java/io/opentelemetry/android/common/RumConstants.kt @@ -31,6 +31,12 @@ object RumConstants { @JvmField val BATTERY_PERCENT_KEY: AttributeKey = AttributeKey.doubleKey("battery.percent") + @JvmField + val SCREEN_VIEW_NODES_KEY: AttributeKey = AttributeKey.longKey("screen.view.nodes") + + @JvmField + val SCREEN_VIEW_DEPTH_KEY: AttributeKey = AttributeKey.longKey("screen.view.depth") + const val APP_START_SPAN_NAME: String = "AppStart" object Events { diff --git a/instrumentation/activity/README.md b/instrumentation/activity/README.md index 84989b42d..44b1091d2 100644 --- a/instrumentation/activity/README.md +++ b/instrumentation/activity/README.md @@ -38,6 +38,20 @@ This instrumentation produces the following telemetry: * `screen.name`: name of screen * `last.screen.name`: name of screen, only when span contains the `activityPostResumed` event. +### First Draw + +* Type: Span +* Name: `FirstDraw` +* Description: On activity PreCreated or Created (pre API 29) callback, a span will be created + to represent the time that the UI pipeline took to render that Activity's first frame. The span ends + at the first draw of the window + [DecorView](https://developer.android.com/reference/android/view/Window#getDecorView()) +* Attributes: + * `activity.name`: name of activity + * `screen.name`: name of screen + * `screen.view.nodes`: a recursive count of all View nodes under the window DecorView. This gives a measure of the amount of work being done in the *layout-and-measure* stage of the UI pipeline. A high count correlates with longer FirstDraw. System-defined layers are included in this count. + * `screen.view.depth`: the depth of the deepest-nested View under the window DecorView. This gives a measure of your UI complexity; more work must be done for a deeply-nested View heirarchy. A high count correlates with longer FirstDraw. System-defined layers (usually 4-6) are included when calculating the depth. + ## Installation This instrumentation comes with the [android agent](../../android-agent) out of the box, so diff --git a/instrumentation/activity/api/activity.api b/instrumentation/activity/api/activity.api index f10c8bad2..bd55fdd39 100644 --- a/instrumentation/activity/api/activity.api +++ b/instrumentation/activity/api/activity.api @@ -38,6 +38,7 @@ public class io/opentelemetry/android/instrumentation/activity/ActivityTracer { public class io/opentelemetry/android/instrumentation/activity/ActivityTracerCache { public fun (Lio/opentelemetry/api/trace/Tracer;Lio/opentelemetry/android/internal/services/visiblescreen/VisibleScreenTracker;Lio/opentelemetry/android/instrumentation/activity/startup/AppStartupTimer;Lio/opentelemetry/android/instrumentation/common/ScreenNameExtractor;)V public fun addEvent (Landroid/app/Activity;Ljava/lang/String;)Lio/opentelemetry/android/instrumentation/activity/ActivityTracer; + public fun endInitialDrawSpan (Landroid/app/Activity;)V public fun initiateRestartSpanIfNecessary (Landroid/app/Activity;)Lio/opentelemetry/android/instrumentation/activity/ActivityTracer; public fun startActivityCreation (Landroid/app/Activity;)Lio/opentelemetry/android/instrumentation/activity/ActivityTracer; public fun startSpanIfNoneInProgress (Landroid/app/Activity;Ljava/lang/String;)Lio/opentelemetry/android/instrumentation/activity/ActivityTracer; diff --git a/instrumentation/activity/src/main/java/io/opentelemetry/android/instrumentation/activity/ActivityCallbacks.kt b/instrumentation/activity/src/main/java/io/opentelemetry/android/instrumentation/activity/ActivityCallbacks.kt index 077d61997..0ed46f23e 100644 --- a/instrumentation/activity/src/main/java/io/opentelemetry/android/instrumentation/activity/ActivityCallbacks.kt +++ b/instrumentation/activity/src/main/java/io/opentelemetry/android/instrumentation/activity/ActivityCallbacks.kt @@ -7,6 +7,7 @@ package io.opentelemetry.android.instrumentation.activity import android.app.Activity import android.os.Bundle +import io.opentelemetry.android.instrumentation.activity.draw.FirstDrawListener import io.opentelemetry.android.internal.services.visiblescreen.activities.DefaultingActivityLifecycleCallbacks class ActivityCallbacks( @@ -17,6 +18,9 @@ class ActivityCallbacks( savedInstanceState: Bundle?, ) { tracers.startActivityCreation(activity).addEvent("activityPreCreated") + FirstDrawListener.registerFirstDraw(activity) { + tracers.endInitialDrawSpan(activity) + } } override fun onActivityCreated( diff --git a/instrumentation/activity/src/main/java/io/opentelemetry/android/instrumentation/activity/ActivityTracer.java b/instrumentation/activity/src/main/java/io/opentelemetry/android/instrumentation/activity/ActivityTracer.java index a79c65c0f..9342e6282 100644 --- a/instrumentation/activity/src/main/java/io/opentelemetry/android/instrumentation/activity/ActivityTracer.java +++ b/instrumentation/activity/src/main/java/io/opentelemetry/android/instrumentation/activity/ActivityTracer.java @@ -31,6 +31,7 @@ public class ActivityTracer { private final String screenName; private final AppStartupTimer appStartupTimer; private final ActiveSpan activeSpan; + @Nullable private Span initialDrawSpan; private ActivityTracer(Builder builder) { this.initialAppActivity = builder.initialAppActivity; @@ -50,7 +51,9 @@ ActivityTracer startSpanIfNoneInProgress(String spanName) { } ActivityTracer startActivityCreation() { - activeSpan.startSpan(this::makeCreationSpan); + Span creationSpan = makeCreationSpan(); + activeSpan.startSpan(() -> creationSpan); + startInitialDrawSpan(creationSpan); return this; } @@ -135,6 +138,19 @@ public ActivityTracer addEvent(String eventName) { return this; } + void startInitialDrawSpan(Span parentSpan) { + if (initialDrawSpan == null) { + initialDrawSpan = createSpanWithParent("FirstDraw", parentSpan); + } + } + + void endInitialDrawSpan() { + if (initialDrawSpan != null) { + initialDrawSpan.end(); + initialDrawSpan = null; + } + } + static Builder builder(Activity activity) { return new Builder(activity); } diff --git a/instrumentation/activity/src/main/java/io/opentelemetry/android/instrumentation/activity/ActivityTracerCache.java b/instrumentation/activity/src/main/java/io/opentelemetry/android/instrumentation/activity/ActivityTracerCache.java index bae7fad8d..8eaf5cf91 100644 --- a/instrumentation/activity/src/main/java/io/opentelemetry/android/instrumentation/activity/ActivityTracerCache.java +++ b/instrumentation/activity/src/main/java/io/opentelemetry/android/instrumentation/activity/ActivityTracerCache.java @@ -79,6 +79,10 @@ public ActivityTracer startActivityCreation(Activity activity) { return getTracer(activity).startActivityCreation(); } + public void endInitialDrawSpan(Activity activity) { + getTracer(activity).endInitialDrawSpan(); + } + private ActivityTracer getTracer(Activity activity) { ActivityTracer activityTracer = tracersByActivityClassName.get(activity.getClass().getName()); diff --git a/instrumentation/activity/src/main/java/io/opentelemetry/android/instrumentation/activity/Pre29ActivityCallbacks.java b/instrumentation/activity/src/main/java/io/opentelemetry/android/instrumentation/activity/Pre29ActivityCallbacks.java index bfc6f6448..f60dd5ed4 100644 --- a/instrumentation/activity/src/main/java/io/opentelemetry/android/instrumentation/activity/Pre29ActivityCallbacks.java +++ b/instrumentation/activity/src/main/java/io/opentelemetry/android/instrumentation/activity/Pre29ActivityCallbacks.java @@ -9,6 +9,7 @@ import android.os.Bundle; import androidx.annotation.NonNull; import androidx.annotation.Nullable; +import io.opentelemetry.android.instrumentation.activity.draw.FirstDrawListener; import io.opentelemetry.android.internal.services.visiblescreen.activities.DefaultingActivityLifecycleCallbacks; public class Pre29ActivityCallbacks implements DefaultingActivityLifecycleCallbacks { @@ -21,6 +22,12 @@ public Pre29ActivityCallbacks(ActivityTracerCache tracers) { @Override public void onActivityCreated(@NonNull Activity activity, @Nullable Bundle savedInstanceState) { tracers.startActivityCreation(activity).addEvent("activityCreated"); + FirstDrawListener.INSTANCE.registerFirstDraw( + activity, + () -> { + tracers.endInitialDrawSpan(activity); + return null; + }); } @Override diff --git a/instrumentation/activity/src/main/java/io/opentelemetry/android/instrumentation/activity/draw/FirstDrawListener.kt b/instrumentation/activity/src/main/java/io/opentelemetry/android/instrumentation/activity/draw/FirstDrawListener.kt new file mode 100644 index 000000000..d7918e2e5 --- /dev/null +++ b/instrumentation/activity/src/main/java/io/opentelemetry/android/instrumentation/activity/draw/FirstDrawListener.kt @@ -0,0 +1,71 @@ +/* + * Copyright The OpenTelemetry Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +package io.opentelemetry.android.instrumentation.activity.draw + +import android.app.Activity +import android.os.Build +import android.os.Handler +import android.os.Looper +import android.view.View +import android.view.ViewTreeObserver + +internal object FirstDrawListener { + fun registerFirstDraw( + activity: Activity, + drawDoneCallback: () -> Unit, + ) { + val window = activity.window + + // Wait until the decorView is created until we actually use it + window.onDecorViewReady { + val decorView = window.peekDecorView() + val versionsBeforeBugFix = Build.VERSION.SDK_INT < Build.VERSION_CODES.O + val decorViewAttached = decorView.viewTreeObserver.isAlive && decorView.isAttachedToWindow + + // Before API version 26, draw listeners were not merged back into the real view tree observer + // Workaround is to wait until the view is attached before registering draw listeners + // Source: https://android.googlesource.com/platform/frameworks/base/+/9f8ec54244a5e0343b9748db3329733f259604f3 + if (versionsBeforeBugFix && !decorViewAttached) { + decorView.addOnAttachStateChangeListener( + object : View.OnAttachStateChangeListener { + override fun onViewAttachedToWindow(v: View) { + decorView.viewTreeObserver.addOnDrawListener(NextDrawListener(decorView, drawDoneCallback)) + decorView.removeOnAttachStateChangeListener(this) + } + + override fun onViewDetachedFromWindow(v: View) = Unit + }, + ) + } else { + decorView.viewTreeObserver.addOnDrawListener(NextDrawListener(decorView, drawDoneCallback)) + } + } + } + + /** + * ViewTreeObserver.removeOnDrawListener() cannot be called from the onDraw() callback, + * so remove it in next draw. + */ + internal class NextDrawListener( + val view: View, + val drawDoneCallback: () -> Unit, + val handler: Handler = Handler(Looper.getMainLooper()), + ) : ViewTreeObserver.OnDrawListener { + var invoked = false + + override fun onDraw() { + if (!invoked) { + invoked = true + drawDoneCallback() + handler.post { + if (view.viewTreeObserver.isAlive) { + view.viewTreeObserver.removeOnDrawListener(this) + } + } + } + } + } +} diff --git a/instrumentation/activity/src/main/java/io/opentelemetry/android/instrumentation/activity/draw/WindowUtils.kt b/instrumentation/activity/src/main/java/io/opentelemetry/android/instrumentation/activity/draw/WindowUtils.kt new file mode 100644 index 000000000..6afaea870 --- /dev/null +++ b/instrumentation/activity/src/main/java/io/opentelemetry/android/instrumentation/activity/draw/WindowUtils.kt @@ -0,0 +1,58 @@ +/* + * Copyright The OpenTelemetry Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +package io.opentelemetry.android.instrumentation.activity.draw + +import android.os.Build +import android.view.ActionMode +import android.view.SearchEvent +import android.view.Window +import androidx.annotation.RequiresApi + +internal fun Window.onDecorViewReady(callback: () -> Unit) { + if (peekDecorView() == null) { + onContentChanged { + callback() + return@onContentChanged false + } + } else { + callback() + } +} + +internal fun Window.onContentChanged(callbackInvocation: () -> Boolean) { + val currentCallback = callback + val callback = + if (currentCallback is WindowDelegateCallback) { + currentCallback + } else { + val newCallback = WindowDelegateCallback(currentCallback) + callback = newCallback + newCallback + } + callback.onContentChangedCallbacks += callbackInvocation +} + +internal class WindowDelegateCallback( + private val delegate: Window.Callback, +) : Window.Callback by delegate { + val onContentChangedCallbacks = mutableListOf<() -> Boolean>() + + override fun onContentChanged() { + onContentChangedCallbacks.removeAll { callback -> + !callback() + } + delegate.onContentChanged() + } + + @RequiresApi(Build.VERSION_CODES.M) + override fun onSearchRequested(searchEvent: SearchEvent): Boolean = delegate.onSearchRequested(searchEvent) + + @RequiresApi(Build.VERSION_CODES.M) + override fun onWindowStartingActionMode( + callback: ActionMode.Callback, + type: Int, + ): ActionMode? = delegate.onWindowStartingActionMode(callback, type) +} diff --git a/instrumentation/activity/src/test/java/io/opentelemetry/android/instrumentation/activity/ActivityCallbacksTest.kt b/instrumentation/activity/src/test/java/io/opentelemetry/android/instrumentation/activity/ActivityCallbacksTest.kt index 488923863..894f2769c 100644 --- a/instrumentation/activity/src/test/java/io/opentelemetry/android/instrumentation/activity/ActivityCallbacksTest.kt +++ b/instrumentation/activity/src/test/java/io/opentelemetry/android/instrumentation/activity/ActivityCallbacksTest.kt @@ -8,7 +8,9 @@ package io.opentelemetry.android.instrumentation.activity import android.app.Activity import io.mockk.every import io.mockk.mockk +import io.mockk.mockkObject import io.opentelemetry.android.common.RumConstants +import io.opentelemetry.android.instrumentation.activity.draw.FirstDrawListener import io.opentelemetry.android.instrumentation.activity.startup.AppStartupTimer import io.opentelemetry.android.instrumentation.common.ScreenNameExtractor import io.opentelemetry.android.internal.services.visiblescreen.VisibleScreenTracker @@ -40,6 +42,9 @@ internal class ActivityCallbacksTest { val extractor = mockk(relaxed = true) every { extractor.extract(any()) } returns "Activity" tracers = ActivityTracerCache(tracer, visibleScreenTracker, startupTimer, extractor) + + mockkObject(FirstDrawListener) + every { FirstDrawListener.registerFirstDraw(any(), any()) } returns Unit } @Test diff --git a/instrumentation/activity/src/test/java/io/opentelemetry/android/instrumentation/activity/ActivityTracerCacheTest.kt b/instrumentation/activity/src/test/java/io/opentelemetry/android/instrumentation/activity/ActivityTracerCacheTest.kt index cbc1f15e6..358ff5f97 100644 --- a/instrumentation/activity/src/test/java/io/opentelemetry/android/instrumentation/activity/ActivityTracerCacheTest.kt +++ b/instrumentation/activity/src/test/java/io/opentelemetry/android/instrumentation/activity/ActivityTracerCacheTest.kt @@ -141,4 +141,14 @@ internal class ActivityTracerCacheTest { assertSame(activityTracer, result) Mockito.verify(activityTracer).startActivityCreation() } + + @Test + fun endInitialDrawSpan() { + Mockito.`when`(tracerCreator.apply(activity)).thenReturn(activityTracer) + + val underTest = ActivityTracerCache(tracerCreator) + + underTest.endInitialDrawSpan(activity) + Mockito.verify(activityTracer).endInitialDrawSpan() + } } diff --git a/instrumentation/activity/src/test/java/io/opentelemetry/android/instrumentation/activity/Pre29ActivityLifecycleCallbacksTest.kt b/instrumentation/activity/src/test/java/io/opentelemetry/android/instrumentation/activity/Pre29ActivityLifecycleCallbacksTest.kt index 17fe167b1..dae89e5b1 100644 --- a/instrumentation/activity/src/test/java/io/opentelemetry/android/instrumentation/activity/Pre29ActivityLifecycleCallbacksTest.kt +++ b/instrumentation/activity/src/test/java/io/opentelemetry/android/instrumentation/activity/Pre29ActivityLifecycleCallbacksTest.kt @@ -8,7 +8,9 @@ package io.opentelemetry.android.instrumentation.activity import android.app.Activity import io.mockk.every import io.mockk.mockk +import io.mockk.mockkObject import io.opentelemetry.android.common.RumConstants +import io.opentelemetry.android.instrumentation.activity.draw.FirstDrawListener import io.opentelemetry.android.instrumentation.activity.startup.AppStartupTimer import io.opentelemetry.android.instrumentation.common.ScreenNameExtractor import io.opentelemetry.android.internal.services.visiblescreen.VisibleScreenTracker @@ -39,6 +41,9 @@ internal class Pre29ActivityLifecycleCallbacksTest { val extractor = mockk(relaxed = true) every { extractor.extract(any()) } returns "Activity" tracers = ActivityTracerCache(tracer, visibleScreenTracker, appStartupTimer, extractor) + + mockkObject(FirstDrawListener) + every { FirstDrawListener.registerFirstDraw(any(), any()) } returns Unit } @Test diff --git a/instrumentation/activity/src/test/java/io/opentelemetry/android/instrumentation/activity/draw/FirstDrawListenerTest.kt b/instrumentation/activity/src/test/java/io/opentelemetry/android/instrumentation/activity/draw/FirstDrawListenerTest.kt new file mode 100644 index 000000000..f83309f6d --- /dev/null +++ b/instrumentation/activity/src/test/java/io/opentelemetry/android/instrumentation/activity/draw/FirstDrawListenerTest.kt @@ -0,0 +1,103 @@ +/* + * Copyright The OpenTelemetry Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +package io.opentelemetry.android.instrumentation.activity.draw + +import android.os.Handler +import android.view.View +import android.view.ViewTreeObserver +import io.mockk.every +import io.mockk.mockk +import io.mockk.slot +import io.mockk.verify +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test + +internal class FirstDrawListenerTest { + @Test + fun `NextDrawListener invokes callback on first draw`() { + val view = mockk(relaxed = true) + val viewTreeObserver = mockk(relaxed = true) + val handler = mockk(relaxed = true) + + every { view.viewTreeObserver } returns viewTreeObserver + every { viewTreeObserver.isAlive } returns true + every { handler.post(any()) } returns true + + var callbackInvoked = false + val listener = + FirstDrawListener.NextDrawListener(view, { + callbackInvoked = true + }, handler) + + listener.onDraw() + + assert(callbackInvoked) + } + + @Test + fun `NextDrawListener only invokes callback once`() { + val view = mockk(relaxed = true) + val handler = mockk(relaxed = true) + + every { handler.post(any()) } returns true + + var invocationCount = 0 + val listener = + FirstDrawListener.NextDrawListener(view, { + invocationCount++ + }, handler) + + listener.onDraw() + listener.onDraw() + listener.onDraw() + + assertEquals(1, invocationCount) + } + + @Test + fun `NextDrawListener removes itself via handler post`() { + val view = mockk(relaxed = true) + val viewTreeObserver = mockk(relaxed = true) + val handler = mockk(relaxed = true) + + every { view.viewTreeObserver } returns viewTreeObserver + every { viewTreeObserver.isAlive } returns true + + val runnableSlot = slot() + every { handler.post(capture(runnableSlot)) } returns true + + val listener = FirstDrawListener.NextDrawListener(view, {}, handler) + + listener.onDraw() + + verify { handler.post(any()) } + + runnableSlot.captured.run() + + verify { viewTreeObserver.removeOnDrawListener(listener) } + } + + @Test + fun `NextDrawListener does not remove if viewTreeObserver is not alive`() { + val view = mockk(relaxed = true) + val viewTreeObserver = mockk(relaxed = true) + val handler = mockk(relaxed = true) + + every { view.viewTreeObserver } returns viewTreeObserver + every { viewTreeObserver.isAlive } returns false + + val runnableSlot = slot() + every { handler.post(capture(runnableSlot)) } returns true + + val listener = FirstDrawListener.NextDrawListener(view, {}, handler) + + listener.onDraw() + + runnableSlot.captured.run() + + verify(exactly = 0) { viewTreeObserver.removeOnDrawListener(any()) } + } +} diff --git a/instrumentation/activity/src/test/java/io/opentelemetry/android/instrumentation/activity/draw/WindowUtilsTest.kt b/instrumentation/activity/src/test/java/io/opentelemetry/android/instrumentation/activity/draw/WindowUtilsTest.kt new file mode 100644 index 000000000..fcc313309 --- /dev/null +++ b/instrumentation/activity/src/test/java/io/opentelemetry/android/instrumentation/activity/draw/WindowUtilsTest.kt @@ -0,0 +1,127 @@ +/* + * Copyright The OpenTelemetry Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +package io.opentelemetry.android.instrumentation.activity.draw + +import android.view.View +import android.view.Window +import io.mockk.every +import io.mockk.mockk +import io.mockk.slot +import io.mockk.verify +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +internal class WindowUtilsTest { + @Test + fun `onDecorViewReady calls callback immediately when decorView exists`() { + val window = mockk() + val decorView = mockk() + every { window.peekDecorView() } returns decorView + + var callbackInvoked = false + window.onDecorViewReady { + callbackInvoked = true + } + + assertTrue(callbackInvoked) + } + + @Test + fun `onDecorViewReady waits for decorView when null`() { + val window = mockk(relaxed = true) + val originalCallback = mockk(relaxed = true) + every { window.peekDecorView() } returns null + every { window.callback } returns originalCallback + + var callbackInvoked = false + window.onDecorViewReady { + callbackInvoked = true + } + + assertFalse(callbackInvoked) + + val callbackSlot = slot() + verify { window.callback = capture(callbackSlot) } + + callbackSlot.captured.onContentChanged() + assertTrue(callbackInvoked) + } + + @Test + fun `onContentChanged wraps existing callback`() { + val window = mockk(relaxed = true) + val originalCallback = mockk(relaxed = true) + every { window.callback } returns originalCallback + + window.onContentChanged { false } + + val callbackSlot = slot() + verify { window.callback = capture(callbackSlot) } + + assertTrue(callbackSlot.captured is WindowDelegateCallback) + } + + @Test + fun `onContentChanged delegates to original callback`() { + val window = mockk(relaxed = true) + val originalCallback = mockk(relaxed = true) + every { window.callback } returns originalCallback + + window.onContentChanged { false } + + val callbackSlot = slot() + verify { window.callback = capture(callbackSlot) } + + callbackSlot.captured.onContentChanged() + verify { originalCallback.onContentChanged() } + } + + @Test + fun `onContentChanged removes callback when it returns false`() { + val window = mockk(relaxed = true) + val originalCallback = mockk(relaxed = true) + every { window.callback } returns originalCallback + + var invocationCount = 0 + window.onContentChanged { + invocationCount++ + false + } + + val callbackSlot = slot() + verify { window.callback = capture(callbackSlot) } + + callbackSlot.captured.onContentChanged() + assertEquals(1, invocationCount) + + callbackSlot.captured.onContentChanged() + assertEquals(1, invocationCount) + } + + @Test + fun `onContentChanged keeps callback when it returns true`() { + val window = mockk(relaxed = true) + val originalCallback = mockk(relaxed = true) + every { window.callback } returns originalCallback + + var invocationCount = 0 + window.onContentChanged { + invocationCount++ + true + } + + val callbackSlot = slot() + verify { window.callback = capture(callbackSlot) } + + callbackSlot.captured.onContentChanged() + assertEquals(1, invocationCount) + + callbackSlot.captured.onContentChanged() + assertEquals(2, invocationCount) + } +}