Donate to e Foundation | Murena handsets with /e/OS | Own a part of Murena! Learn more

Commit a7a2c33c authored by Mike Schneider's avatar Mike Schneider
Browse files

Add [DebugInspector] to [MotionValue] #MotionMechanics

Exposes internal state via an explicit [DebugInspector] class.

This allows to make all internal state `private`, while still exporting
internal state for debug visualization, logging, and testing.

While there is no [DebugInspector] active, there are no runtime costs
associated with this.

When active, it guarantees the debug code does not accidentally change
runtime behavior (for example by reading the `derivedState` at a
different time the production code would). It also guarantees to
generate a consistent state of input and output values.

Flag: NONE Initial commits for new library, currently unused.
Test: atest mechanics_tests
Bug: 379248269
Change-Id: I68db8b6d04659a11fe48e07f66fbde943d2d038f
parent 139a2cb6
Loading
Loading
Loading
Loading
+112 −43
Original line number Diff line number Diff line
@@ -29,6 +29,8 @@ import androidx.compose.ui.util.lerp
import androidx.compose.ui.util.packFloats
import androidx.compose.ui.util.unpackFloat1
import androidx.compose.ui.util.unpackFloat2
import com.android.mechanics.debug.DebugInspector
import com.android.mechanics.debug.FrameData
import com.android.mechanics.spec.Breakpoint
import com.android.mechanics.spec.Guarantee
import com.android.mechanics.spec.InputDirection
@@ -38,6 +40,7 @@ import com.android.mechanics.spec.SegmentData
import com.android.mechanics.spring.SpringParameters
import com.android.mechanics.spring.SpringState
import com.android.mechanics.spring.calculateUpdatedState
import java.util.concurrent.atomic.AtomicInteger
import kotlin.math.max
import kotlinx.coroutines.awaitCancellation
import kotlinx.coroutines.coroutineScope
@@ -150,25 +153,31 @@ class MotionValue(
     * Internally, this method does suspend, unless there are animations ongoing.
     */
    suspend fun keepRunning(): Nothing = coroutineScope {
        check(!isActive) { "keepRunning() invoked while already running" }

        isActive = true
        try {
            while (true) {
                // TODO suspend unless there are input changes or an animation is finishing.

                withFrameNanos { frameTimeNanos ->
                // A new animation frame started. This does not animate anything just yet - if an
                // animation is ongoing, it will be updated because of the `animationTimeNanos` that
                    // A new animation frame started. This does not animate anything just yet - if
                    // an
                    // animation is ongoing, it will be updated because of the `animationTimeNanos`
                    // that
                    // is updated here.
                    currentAnimationTimeNanos = frameTimeNanos
                }

            // At this point, the complete frame is done (including layout, drawing and everything
            // else). What follows next is similar what one would do in a `SideEffect`, were this
            // composable code:
                // At this point, the complete frame is done (including layout, drawing and
                // everything else). What follows next is similar what one would do in a
                // `SideEffect`, were this composable code:
                // If during the last frame, a new animation was started, or a new segment entered,
            // this state is copied over. If nothing changed, the computed `current*` state will be
            // the same, it won't have a side effect.
                // this state is copied over. If nothing changed, the computed `current*` state will
                // be the same, it won't have a side effect.

            // Capturing the state here is required since crossing a breakpoint is an event - the
            // code has to record that this happened.
                // Capturing the state here is required since crossing a breakpoint is an event -
                // the code has to record that this happened.

                // Important - capture all values first, and only afterwards update the state.
                // Interleaving read and update might trigger immediate re-computations.
@@ -181,18 +190,34 @@ class MotionValue(
                lastFrameTimeNanos = currentAnimationTimeNanos
                lastInput = currentInput()
                lastGestureDistance = currentGestureDistance
            // Not capturing currentDirection and spec explicitly, they are included in lastSegment
                // Not capturing currentDirection and spec explicitly, they are included in
                // lastSegment

                // Update the state to the computed `current*` values
                lastSegment = newSegment
                lastGuaranteeState = newGuaranteeState
                lastAnimation = newAnimation
                lastSpringState = newSpringState
                debugInspector?.run {
                    frame =
                        FrameData(
                            lastInput,
                            currentDirection,
                            lastGestureDistance,
                            lastFrameTimeNanos,
                            lastSpringState,
                            lastSegment,
                            lastAnimation,
                        )
                }
            }

            // Keep the compiler happy - the while (true) {} above will not complete, yet the
            // compiler wants a return value.
            @Suppress("UNREACHABLE_CODE") awaitCancellation()
        } finally {
            isActive = false
        }
    }

    companion object {
@@ -225,7 +250,7 @@ class MotionValue(
     * The segment in use, defined by the min/max [Breakpoint]s and the [Mapping] in between. This
     * implicitly also captures the [InputDirection] and [MotionSpec].
     */
    internal var lastSegment: SegmentData by
    private var lastSegment: SegmentData by
        mutableStateOf(
            spec.segmentAtInput(currentInput(), currentDirection),
            referentialEqualityPolicy(),
@@ -267,7 +292,7 @@ class MotionValue(
     * the [SegmentData.mapping]. It might accumulate the target value - it is not required to reset
     * when the animation ends.
     */
    internal var lastAnimation: DiscontinuityAnimation by
    private var lastAnimation: DiscontinuityAnimation by
        mutableStateOf(DiscontinuityAnimation.None, referentialEqualityPolicy())

    // ---- Last frame's input and output ----------------------------------------------------------
@@ -280,7 +305,7 @@ class MotionValue(
     * Last frame's spring state, based on initial origin values in [lastAnimation], carried-forward
     * to [lastFrameTimeNanos].
     */
    internal inline var lastSpringState: SpringState
    private inline var lastSpringState: SpringState
        get() = SpringState(_lastSpringStatePacked)
        set(value) {
            _lastSpringStatePacked = value.packedValue
@@ -728,6 +753,50 @@ class MotionValue(

    private val currentAnimatedDelta: Float
        get() = currentAnimation.targetValue + currentSpringState.displacement

    // ---- Accessor to internals, for inspection and tests ----------------------------------------

    /** Whether a [keepRunning] coroutine is active currently. */
    private var isActive = false
        set(value) {
            field = value
            debugInspector?.isActive = value
        }

    private var debugInspector: DebugInspector? = null
    private var debugInspectorRefCount = AtomicInteger(0)

    private fun onDisposeDebugInspector() {
        if (debugInspectorRefCount.decrementAndGet() == 0) {
            debugInspector = null
        }
    }

    /**
     * Provides access to internal state for debug tooling and tests.
     *
     * The returned [DebugInspector] must be [DebugInspector.dispose]d when no longer needed.
     */
    fun debugInspector(): DebugInspector {
        if (debugInspectorRefCount.getAndIncrement() == 0) {
            debugInspector =
                DebugInspector(
                    FrameData(
                        lastInput,
                        lastSegment.direction,
                        lastGestureDistance,
                        lastFrameTimeNanos,
                        lastSpringState,
                        lastSegment,
                        lastAnimation,
                    ),
                    isActive,
                    ::onDisposeDebugInspector,
                )
        }

        return checkNotNull(debugInspector)
    }
}

/**
+76 −0
Original line number Diff line number Diff line
/*
 * Copyright (C) 2024 The Android Open Source Project
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *      http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package com.android.mechanics.debug

import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import com.android.mechanics.DiscontinuityAnimation
import com.android.mechanics.MotionValue
import com.android.mechanics.spec.InputDirection
import com.android.mechanics.spec.SegmentData
import com.android.mechanics.spec.SegmentKey
import com.android.mechanics.spring.SpringParameters
import com.android.mechanics.spring.SpringState
import kotlinx.coroutines.DisposableHandle

/** Utility to gain inspection access to internal [MotionValue] state. */
class DebugInspector
internal constructor(
    initialFrameData: FrameData,
    isActive: Boolean,
    disposableHandle: DisposableHandle,
) : DisposableHandle by disposableHandle {

    /** The last completed frame's data. */
    var frame: FrameData by mutableStateOf(initialFrameData)
        internal set

    /** Whether a [MotionValue.keepRunning] coroutine is active currently. */
    var isActive: Boolean by mutableStateOf(isActive)
        internal set
}

/** The input, output and internal state of a [MotionValue] for the frame. */
data class FrameData
internal constructor(
    val input: Float,
    val gestureDirection: InputDirection,
    val gestureDistance: Float,
    val frameTimeNanos: Long,
    val springState: SpringState,
    private val segment: SegmentData,
    private val animation: DiscontinuityAnimation,
) {
    val isStable: Boolean
        get() = springState == SpringState.AtRest

    val springParameters: SpringParameters
        get() = animation.springParameters

    val segmentKey: SegmentKey
        get() = segment.key

    val output: Float
        get() = currentDirectMapped + (animation.targetValue + springState.displacement)

    val outputTarget: Float
        get() = currentDirectMapped + animation.targetValue

    private val currentDirectMapped: Float
        get() = segment.mapping.map(input) - animation.targetValue
}
+50 −0
Original line number Diff line number Diff line
@@ -14,8 +14,11 @@
 * limitations under the License.
 */

@file:OptIn(ExperimentalCoroutinesApi::class)

package com.android.mechanics

import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.android.mechanics.spec.BreakpointKey
@@ -34,6 +37,9 @@ import com.android.mechanics.testing.MotionValueToolkit.Companion.isStable
import com.android.mechanics.testing.MotionValueToolkit.Companion.output
import com.android.mechanics.testing.goldenTest
import com.google.common.truth.Truth.assertThat
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.runTest
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
@@ -321,9 +327,53 @@ class MotionValueTest {
        }
    }

    @Test
    fun keepRunning_concurrentInvocationThrows() = runTest {
        val underTest = MotionValue({ 1f }, FakeGestureContext)

        rule.setContent {
            LaunchedEffect(underTest) {
                val firstJob = launch { underTest.keepRunning() }

                val result = kotlin.runCatching { underTest.keepRunning() }

                assertThat(result.isFailure).isTrue()
                assertThat(result.exceptionOrNull()).isInstanceOf(IllegalStateException::class.java)

                assertThat(firstJob.isActive).isTrue()
                firstJob.cancel()
            }
        }
    }

    @Test
    fun debugInspector_sameInstance_whileInUse() {
        val underTest = MotionValue({ 1f }, FakeGestureContext)

        val originalInspector = underTest.debugInspector()
        assertThat(underTest.debugInspector()).isSameInstanceAs(originalInspector)
    }

    @Test
    fun debugInspector_newInstance_afterUnused() {
        val underTest = MotionValue({ 1f }, FakeGestureContext)

        val originalInspector = underTest.debugInspector()
        originalInspector.dispose()
        assertThat(underTest.debugInspector()).isNotSameInstanceAs(originalInspector)
    }

    companion object {
        val B1 = BreakpointKey("breakpoint1")
        val B2 = BreakpointKey("breakpoint2")
        val FakeGestureContext =
            object : GestureContext {
                override val direction: InputDirection
                    get() = InputDirection.Max

                override val distance: Float
                    get() = 0f
            }

        fun specBuilder(firstSegment: Mapping = Mapping.Identity) =
            MotionSpec.builder(
+27 −30
Original line number Diff line number Diff line
@@ -26,10 +26,9 @@ import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.junit4.ComposeContentTestRule
import com.android.mechanics.DistanceGestureContext
import com.android.mechanics.MotionValue
import com.android.mechanics.debug.FrameData
import com.android.mechanics.spec.InputDirection
import com.android.mechanics.spec.MotionSpec
import com.android.mechanics.spring.SpringParameters
import com.android.mechanics.spring.SpringState
import kotlin.math.abs
import kotlin.math.floor
import kotlin.math.sign
@@ -45,7 +44,6 @@ import kotlinx.coroutines.test.runCurrent
import kotlinx.coroutines.test.runTest
import platform.test.motion.MotionTestRule
import platform.test.motion.RecordedMotion.Companion.create
import platform.test.motion.golden.DataPoint
import platform.test.motion.golden.Feature
import platform.test.motion.golden.FrameId
import platform.test.motion.golden.TimeSeries
@@ -126,6 +124,8 @@ fun MotionTestRule<MotionValueToolkit>.goldenTest(
            )
        val underTest = testHarness.underTest

        val debugInspector = underTest.debugInspector()

        var recompositionCount = 0
        var lastOutput = 0f
        var lastOutputTarget = 0f
@@ -149,25 +149,11 @@ fun MotionTestRule<MotionValueToolkit>.goldenTest(
        mainClock.autoAdvance = false

        val frameIds = mutableListOf<FrameId>()
        val input = mutableListOf<DataPoint<Float>>()
        val gesturePosition = mutableListOf<DataPoint<Float>>()
        val gestureDirection = mutableListOf<DataPoint<String>>()
        val output = mutableListOf<DataPoint<Float>>()
        val outputTarget = mutableListOf<DataPoint<Float>>()
        val outputSpring = mutableListOf<DataPoint<SpringParameters>>()
        val isStable = mutableListOf<DataPoint<Boolean>>()
        val frameData = mutableListOf<FrameData>()

        fun recordFrame(frameId: TimestampFrameId) {
            frameIds.add(frameId)

            input.add(testHarness.input.asDataPoint())
            gesturePosition.add(testHarness.gestureContext.distance.asDataPoint())
            gestureDirection.add(testHarness.gestureContext.direction.name.asDataPoint())

            output.add(lastOutput.asDataPoint())
            outputTarget.add(lastOutputTarget.asDataPoint())
            outputSpring.add(underTest.lastAnimation.springParameters.asDataPoint())
            isStable.add(lastIsStable.asDataPoint())
            frameData.add(debugInspector.frame)
        }

        val startFrameTime = mainClock.currentTime
@@ -183,15 +169,20 @@ fun MotionTestRule<MotionValueToolkit>.goldenTest(
            TimeSeries(
                frameIds.toList(),
                listOf(
                    Feature("input", input),
                    Feature("gestureDirection", gestureDirection),
                    Feature("output", output),
                    Feature("outputTarget", outputTarget),
                    Feature("outputSpring", outputSpring),
                    Feature("isStable", isStable),
                    Feature("input", frameData.map { it.input.asDataPoint() }),
                    Feature(
                        "gestureDirection",
                        frameData.map { it.gestureDirection.name.asDataPoint() },
                    ),
                    Feature("output", frameData.map { it.output.asDataPoint() }),
                    Feature("outputTarget", frameData.map { it.outputTarget.asDataPoint() }),
                    Feature("outputSpring", frameData.map { it.springParameters.asDataPoint() }),
                    Feature("isStable", frameData.map { it.isStable.asDataPoint() }),
                ),
            )

        debugInspector.dispose()

        val recordedMotion = create(timeSeries, screenshots = null)
        verifyTimeSeries.invoke(recordedMotion.timeSeries)
        assertThat(recordedMotion).timeSeriesMatchesGolden()
@@ -231,11 +222,17 @@ private class MotionValueTestHarness(
        }

    override suspend fun awaitStable() {
        val debugInspector = underTest.debugInspector()
        try {

            onFrame
                // Since this is a state-flow, the current frame is counted too.
                .drop(1)
            .takeWhile { underTest.lastSpringState != SpringState.AtRest }
                .takeWhile { !debugInspector.frame.isStable }
                .collect {}
        } finally {
            debugInspector.dispose()
        }
    }

    override suspend fun awaitFrames(frames: Int) {