From 5cb1bda6aa7a5946c0a4f0d22a7708b5f80c4c87 Mon Sep 17 00:00:00 2001 From: Pun Butrach Date: Sun, 16 Nov 2025 16:55:52 +0700 Subject: [PATCH 01/30] feat: msdllib Android 16 QPR1 --- .../android/msdl/logging/MSDLHistoryLogger.kt | 2 +- .../msdl/logging/MSDLHistoryLoggerImpl.kt | 24 +++++++++++-------- 2 files changed, 15 insertions(+), 11 deletions(-) diff --git a/msdllib/src/com/google/android/msdl/logging/MSDLHistoryLogger.kt b/msdllib/src/com/google/android/msdl/logging/MSDLHistoryLogger.kt index 6e255af..7ce884c 100644 --- a/msdllib/src/com/google/android/msdl/logging/MSDLHistoryLogger.kt +++ b/msdllib/src/com/google/android/msdl/logging/MSDLHistoryLogger.kt @@ -34,7 +34,7 @@ interface MSDLHistoryLogger { companion object { - const val HISTORY_SIZE = 20 + const val HISTORY_SIZE = 50 val DATE_FORMAT = SimpleDateFormat("MM-dd HH:mm:ss.SSS", Locale.US) } } diff --git a/msdllib/src/com/google/android/msdl/logging/MSDLHistoryLoggerImpl.kt b/msdllib/src/com/google/android/msdl/logging/MSDLHistoryLoggerImpl.kt index bc8a810..30e5fb0 100644 --- a/msdllib/src/com/google/android/msdl/logging/MSDLHistoryLoggerImpl.kt +++ b/msdllib/src/com/google/android/msdl/logging/MSDLHistoryLoggerImpl.kt @@ -18,22 +18,26 @@ package com.google.android.msdl.logging import androidx.annotation.VisibleForTesting import androidx.annotation.VisibleForTesting.Companion.PACKAGE_PRIVATE -import java.util.ArrayDeque -import java.util.Deque @VisibleForTesting(otherwise = PACKAGE_PRIVATE) class MSDLHistoryLoggerImpl(private val maxHistorySize: Int) : MSDLHistoryLogger { - // Use an [ArrayDequeue] with a fixed size as the history structure - private val history: Deque = ArrayDeque(maxHistorySize) + // Use an Array with a fixed size as the history structure. This will work as a ring buffer + private val history: Array = arrayOfNulls(size = maxHistorySize) + // The head will point to the next available position in the structure to add a new event + private var head = 0 override fun addEvent(event: MSDLEvent) { - // Keep the history as a FIFO structure - if (history.size == maxHistorySize) { - history.removeFirst() - } - history.addLast(event) + history[head] = event + // Move the head pointer, wrapping if necessary + head = (head + 1) % maxHistorySize } - override fun getHistory(): List = history.toList() + override fun getHistory(): List { + val result = mutableListOf() + repeat(times = maxHistorySize) { i -> + history[(i + head) % maxHistorySize]?.let { result.add(it) } + } + return result + } } From 6234d2447438becd3c6067af6723dd7a3c9361cd Mon Sep 17 00:00:00 2001 From: Pun Butrach Date: Sun, 16 Nov 2025 16:58:32 +0700 Subject: [PATCH 02/30] feat: animationlib Android 16 QPR1 --- animationlib/build.gradle | 6 +- .../com/android/app/animation/Animations.kt | 1 - .../android/app/animation/Interpolators.java | 46 +++++------ .../app/animation/InterpolatorsAndroidX.java | 46 +++++------ .../robolectric/config/robolectric.properties | 1 - .../android/app/animation/AnimationsTest.kt | 77 +++++++++++++++++++ .../animation/InterpolatorResourcesTest.kt | 6 +- .../animation/InterpolatorsAndroidXTest.kt | 4 +- 8 files changed, 133 insertions(+), 54 deletions(-) create mode 100644 animationlib/tests/src/com/android/app/animation/AnimationsTest.kt diff --git a/animationlib/build.gradle b/animationlib/build.gradle index f40669b..02036e3 100644 --- a/animationlib/build.gradle +++ b/animationlib/build.gradle @@ -15,10 +15,11 @@ android { manifest.srcFile 'AndroidManifest.xml' } androidTest { - java.srcDirs = ["tests/src"] + java.srcDirs = ["tests/src", "tests/robolectric/src"] manifest.srcFile 'tests/AndroidManifest.xml' } } + lintOptions { abortOnError false } @@ -34,4 +35,7 @@ dependencies { implementation libs.kotlin.stdlib.jdk7 implementation libs.androidx.core.animation implementation libs.androidx.core.ktx +// androidTestImplementation libs.robolectric +// androidTestImplementation "androidx.test.ext:junit:1.1.3" +// androidTestImplementation "androidx.test:rules:1.4.0" } diff --git a/animationlib/src/com/android/app/animation/Animations.kt b/animationlib/src/com/android/app/animation/Animations.kt index 140cc20..2b787fe 100644 --- a/animationlib/src/com/android/app/animation/Animations.kt +++ b/animationlib/src/com/android/app/animation/Animations.kt @@ -18,7 +18,6 @@ package com.android.app.animation import android.animation.Animator import android.view.View -import com.android.app.animation.Animations.Companion.setOngoingAnimation /** A static class for general animation-related utilities. */ class Animations { diff --git a/animationlib/src/com/android/app/animation/Interpolators.java b/animationlib/src/com/android/app/animation/Interpolators.java index 871241c..c723a12 100644 --- a/animationlib/src/com/android/app/animation/Interpolators.java +++ b/animationlib/src/com/android/app/animation/Interpolators.java @@ -58,14 +58,14 @@ public class Interpolators { * is disappearing e.g. when moving off screen. */ public static final Interpolator EMPHASIZED_ACCELERATE = new PathInterpolator( - 0.3f, 0f, 0.8f, 0.15f); + 0.3f, 0f, 0.8f, 0.15f); /** * The decelerating emphasized interpolator. Used for hero / emphasized movement of content that * is appearing e.g. when coming from off screen */ public static final Interpolator EMPHASIZED_DECELERATE = new PathInterpolator( - 0.05f, 0.7f, 0.1f, 1f); + 0.05f, 0.7f, 0.1f, 1f); public static final Interpolator EXAGGERATED_EASE; @@ -98,21 +98,21 @@ public class Interpolators { * The standard interpolator that should be used on every normal animation */ public static final Interpolator STANDARD = new PathInterpolator( - 0.2f, 0f, 0f, 1f); + 0.2f, 0f, 0f, 1f); /** * The standard accelerating interpolator that should be used on every regular movement of * content that is disappearing e.g. when moving off screen. */ public static final Interpolator STANDARD_ACCELERATE = new PathInterpolator( - 0.3f, 0f, 1f, 1f); + 0.3f, 0f, 1f, 1f); /** * The standard decelerating interpolator that should be used on every regular movement of * content that is appearing e.g. when coming from off screen. */ public static final Interpolator STANDARD_DECELERATE = new PathInterpolator( - 0f, 0f, 0f, 1f); + 0f, 0f, 0f, 1f); /* * ============================================================================================ @@ -158,7 +158,7 @@ public class Interpolators { * goes from 1 to 0 instead of 0 to 1). */ public static final Interpolator FAST_OUT_SLOW_IN_REVERSE = - new PathInterpolator(0.8f, 0f, 0.6f, 1f); + new PathInterpolator(0.8f, 0f, 0.6f, 1f); public static final Interpolator SLOW_OUT_LINEAR_IN = new PathInterpolator(0.8f, 0f, 1f, 1f); public static final Interpolator AGGRESSIVE_EASE = new PathInterpolator(0.2f, 0f, 0f, 1f); public static final Interpolator AGGRESSIVE_EASE_IN_OUT = new PathInterpolator(0.6f,0, 0.4f, 1); @@ -182,31 +182,31 @@ public class Interpolators { public static final Interpolator CUSTOM_40_40 = new PathInterpolator(0.4f, 0f, 0.6f, 1f); public static final Interpolator ICON_OVERSHOT = new PathInterpolator(0.4f, 0f, 0.2f, 1.4f); public static final Interpolator ICON_OVERSHOT_LESS = new PathInterpolator(0.4f, 0f, 0.2f, - 1.1f); + 1.1f); public static final Interpolator PANEL_CLOSE_ACCELERATED = new PathInterpolator(0.3f, 0, 0.5f, - 1); + 1); public static final Interpolator BOUNCE = new BounceInterpolator(); /** * For state transitions on the control panel that lives in GlobalActions. */ public static final Interpolator CONTROL_STATE = new PathInterpolator(0.4f, 0f, 0.2f, - 1.0f); + 1.0f); /** * Interpolator to be used when animating a move based on a click. Pair with enough duration. */ public static final Interpolator TOUCH_RESPONSE = - new PathInterpolator(0.3f, 0f, 0.1f, 1f); + new PathInterpolator(0.3f, 0f, 0.1f, 1f); /** * Like {@link #TOUCH_RESPONSE}, but used in case the animation is played in reverse (i.e. t * goes from 1 to 0 instead of 0 to 1). */ public static final Interpolator TOUCH_RESPONSE_REVERSE = - new PathInterpolator(0.9f, 0f, 0.7f, 1f); + new PathInterpolator(0.9f, 0f, 0.7f, 1f); public static final Interpolator TOUCH_RESPONSE_ACCEL_DEACCEL = - v -> ACCELERATE_DECELERATE.getInterpolation(TOUCH_RESPONSE.getInterpolation(v)); + v -> ACCELERATE_DECELERATE.getInterpolation(TOUCH_RESPONSE.getInterpolation(v)); /** @@ -236,7 +236,7 @@ public float getInterpolation(float v) { */ private float zInterpolate(float input) { return (1.0f - FOCAL_LENGTH / (FOCAL_LENGTH + input)) / - (1.0f - FOCAL_LENGTH / (FOCAL_LENGTH + 1.0f)); + (1.0f - FOCAL_LENGTH / (FOCAL_LENGTH + 1.0f)); } }; @@ -297,13 +297,13 @@ public static Interpolator overshootInterpolatorForVelocity(float velocity) { * @return the interpolated overshoot */ public static float getOvershootInterpolation(float progress, float overshootAmount, - float overshootStart) { + float overshootStart) { if (overshootAmount == 0.0f || overshootStart == 0.0f) { throw new IllegalArgumentException("Invalid values for overshoot"); } float b = MathUtils.log((overshootAmount + 1) / (overshootAmount)) / overshootStart; return MathUtils.max(0.0f, - (float) (1.0f - Math.exp(-b * progress)) * (overshootAmount + 1.0f)); + (float) (1.0f - Math.exp(-b * progress)) * (overshootAmount + 1.0f)); } /** @@ -344,11 +344,11 @@ private static PathInterpolator createEmphasizedComplement() { * 1 by upperBound. */ public static Interpolator clampToProgress(Interpolator interpolator, float lowerBound, - float upperBound) { + float upperBound) { if (upperBound < lowerBound) { throw new IllegalArgumentException( - String.format("upperBound (%f) must be greater than lowerBound (%f)", - upperBound, lowerBound)); + String.format("upperBound (%f) must be greater than lowerBound (%f)", + upperBound, lowerBound)); } return t -> clampToProgress(interpolator, t, lowerBound, upperBound); } @@ -361,11 +361,11 @@ public static Interpolator clampToProgress(Interpolator interpolator, float lowe * interpolator. */ public static float clampToProgress( - Interpolator interpolator, float progress, float lowerBound, float upperBound) { + Interpolator interpolator, float progress, float lowerBound, float upperBound) { if (upperBound < lowerBound) { throw new IllegalArgumentException( - String.format("upperBound (%f) must be greater than lowerBound (%f)", - upperBound, lowerBound)); + String.format("upperBound (%f) must be greater than lowerBound (%f)", + upperBound, lowerBound)); } if (progress == lowerBound && progress == upperBound) { @@ -398,7 +398,7 @@ private static float mapRange(float value, float min, float max) { * such as to take over a user-controlled animation when they let go. */ public static Interpolator mapToProgress(Interpolator interpolator, float lowerBound, - float upperBound) { + float upperBound) { return t -> mapRange(interpolator.getInterpolation(t), lowerBound, upperBound); } @@ -411,4 +411,4 @@ public static Interpolator mapToProgress(Interpolator interpolator, float lowerB public static Interpolator reverse(Interpolator interpolator) { return t -> 1 - interpolator.getInterpolation(1 - t); } -} +} \ No newline at end of file diff --git a/animationlib/src/com/android/app/animation/InterpolatorsAndroidX.java b/animationlib/src/com/android/app/animation/InterpolatorsAndroidX.java index 73c95f8..47f4d74 100644 --- a/animationlib/src/com/android/app/animation/InterpolatorsAndroidX.java +++ b/animationlib/src/com/android/app/animation/InterpolatorsAndroidX.java @@ -65,14 +65,14 @@ public class InterpolatorsAndroidX { * is disappearing e.g. when moving off screen. */ public static final Interpolator EMPHASIZED_ACCELERATE = new PathInterpolator( - 0.3f, 0f, 0.8f, 0.15f); + 0.3f, 0f, 0.8f, 0.15f); /** * The decelerating emphasized interpolator. Used for hero / emphasized movement of content that * is appearing e.g. when coming from off screen */ public static final Interpolator EMPHASIZED_DECELERATE = new PathInterpolator( - 0.05f, 0.7f, 0.1f, 1f); + 0.05f, 0.7f, 0.1f, 1f); public static final Interpolator EXAGGERATED_EASE; static { @@ -104,21 +104,21 @@ public class InterpolatorsAndroidX { * The standard interpolator that should be used on every normal animation */ public static final Interpolator STANDARD = new PathInterpolator( - 0.2f, 0f, 0f, 1f); + 0.2f, 0f, 0f, 1f); /** * The standard accelerating interpolator that should be used on every regular movement of * content that is disappearing e.g. when moving off screen. */ public static final Interpolator STANDARD_ACCELERATE = new PathInterpolator( - 0.3f, 0f, 1f, 1f); + 0.3f, 0f, 1f, 1f); /** * The standard decelerating interpolator that should be used on every regular movement of * content that is appearing e.g. when coming from off screen. */ public static final Interpolator STANDARD_DECELERATE = new PathInterpolator( - 0f, 0f, 0f, 1f); + 0f, 0f, 0f, 1f); /* * ============================================================================================ @@ -164,7 +164,7 @@ public class InterpolatorsAndroidX { * goes from 1 to 0 instead of 0 to 1). */ public static final Interpolator FAST_OUT_SLOW_IN_REVERSE = - new PathInterpolator(0.8f, 0f, 0.6f, 1f); + new PathInterpolator(0.8f, 0f, 0.6f, 1f); public static final Interpolator SLOW_OUT_LINEAR_IN = new PathInterpolator(0.8f, 0f, 1f, 1f); public static final Interpolator AGGRESSIVE_EASE = new PathInterpolator(0.2f, 0f, 0f, 1f); public static final Interpolator AGGRESSIVE_EASE_IN_OUT = new PathInterpolator(0.6f,0, 0.4f, 1); @@ -188,31 +188,31 @@ public class InterpolatorsAndroidX { public static final Interpolator CUSTOM_40_40 = new PathInterpolator(0.4f, 0f, 0.6f, 1f); public static final Interpolator ICON_OVERSHOT = new PathInterpolator(0.4f, 0f, 0.2f, 1.4f); public static final Interpolator ICON_OVERSHOT_LESS = new PathInterpolator(0.4f, 0f, 0.2f, - 1.1f); + 1.1f); public static final Interpolator PANEL_CLOSE_ACCELERATED = new PathInterpolator(0.3f, 0, 0.5f, - 1); + 1); public static final Interpolator BOUNCE = new BounceInterpolator(); /** * For state transitions on the control panel that lives in GlobalActions. */ public static final Interpolator CONTROL_STATE = new PathInterpolator(0.4f, 0f, 0.2f, - 1.0f); + 1.0f); /** * Interpolator to be used when animating a move based on a click. Pair with enough duration. */ public static final Interpolator TOUCH_RESPONSE = - new PathInterpolator(0.3f, 0f, 0.1f, 1f); + new PathInterpolator(0.3f, 0f, 0.1f, 1f); /** * Like {@link #TOUCH_RESPONSE}, but used in case the animation is played in reverse (i.e. t * goes from 1 to 0 instead of 0 to 1). */ public static final Interpolator TOUCH_RESPONSE_REVERSE = - new PathInterpolator(0.9f, 0f, 0.7f, 1f); + new PathInterpolator(0.9f, 0f, 0.7f, 1f); public static final Interpolator TOUCH_RESPONSE_ACCEL_DEACCEL = - v -> ACCELERATE_DECELERATE.getInterpolation(TOUCH_RESPONSE.getInterpolation(v)); + v -> ACCELERATE_DECELERATE.getInterpolation(TOUCH_RESPONSE.getInterpolation(v)); /** @@ -242,7 +242,7 @@ public float getInterpolation(float v) { */ private float zInterpolate(float input) { return (1.0f - FOCAL_LENGTH / (FOCAL_LENGTH + input)) / - (1.0f - FOCAL_LENGTH / (FOCAL_LENGTH + 1.0f)); + (1.0f - FOCAL_LENGTH / (FOCAL_LENGTH + 1.0f)); } }; @@ -303,13 +303,13 @@ public static Interpolator overshootInterpolatorForVelocity(float velocity) { * @return the interpolated overshoot */ public static float getOvershootInterpolation(float progress, float overshootAmount, - float overshootStart) { + float overshootStart) { if (overshootAmount == 0.0f || overshootStart == 0.0f) { throw new IllegalArgumentException("Invalid values for overshoot"); } float b = MathUtils.log((overshootAmount + 1) / (overshootAmount)) / overshootStart; return MathUtils.max(0.0f, - (float) (1.0f - Math.exp(-b * progress)) * (overshootAmount + 1.0f)); + (float) (1.0f - Math.exp(-b * progress)) * (overshootAmount + 1.0f)); } /** @@ -350,11 +350,11 @@ private static PathInterpolator createEmphasizedComplement() { * 1 by upperBound. */ public static Interpolator clampToProgress(Interpolator interpolator, float lowerBound, - float upperBound) { + float upperBound) { if (upperBound < lowerBound) { throw new IllegalArgumentException( - String.format("upperBound (%f) must be greater than lowerBound (%f)", - upperBound, lowerBound)); + String.format("upperBound (%f) must be greater than lowerBound (%f)", + upperBound, lowerBound)); } return t -> clampToProgress(interpolator, t, lowerBound, upperBound); } @@ -367,11 +367,11 @@ public static Interpolator clampToProgress(Interpolator interpolator, float lowe * interpolator. */ public static float clampToProgress( - Interpolator interpolator, float progress, float lowerBound, float upperBound) { + Interpolator interpolator, float progress, float lowerBound, float upperBound) { if (upperBound < lowerBound) { throw new IllegalArgumentException( - String.format("upperBound (%f) must be greater than lowerBound (%f)", - upperBound, lowerBound)); + String.format("upperBound (%f) must be greater than lowerBound (%f)", + upperBound, lowerBound)); } if (progress == lowerBound && progress == upperBound) { @@ -404,7 +404,7 @@ private static float mapRange(float value, float min, float max) { * such as to take over a user-controlled animation when they let go. */ public static Interpolator mapToProgress(Interpolator interpolator, float lowerBound, - float upperBound) { + float upperBound) { return t -> mapRange(interpolator.getInterpolation(t), lowerBound, upperBound); } @@ -417,4 +417,4 @@ public static Interpolator mapToProgress(Interpolator interpolator, float lowerB public static Interpolator reverse(Interpolator interpolator) { return t -> 1 - interpolator.getInterpolation(1 - t); } -} +} \ No newline at end of file diff --git a/animationlib/tests/robolectric/config/robolectric.properties b/animationlib/tests/robolectric/config/robolectric.properties index 527eab6..fab7251 100644 --- a/animationlib/tests/robolectric/config/robolectric.properties +++ b/animationlib/tests/robolectric/config/robolectric.properties @@ -1,2 +1 @@ sdk=NEWEST_SDK -shadows=com.android.app.animation.robolectric.ShadowAnimationUtils2 diff --git a/animationlib/tests/src/com/android/app/animation/AnimationsTest.kt b/animationlib/tests/src/com/android/app/animation/AnimationsTest.kt new file mode 100644 index 0000000..decc503 --- /dev/null +++ b/animationlib/tests/src/com/android/app/animation/AnimationsTest.kt @@ -0,0 +1,77 @@ +/* + * 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.app.animation + +import android.animation.ValueAnimator +import android.content.Context +import android.view.View +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.filters.SmallTest +import androidx.test.platform.app.InstrumentationRegistry +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith + +@SmallTest +@RunWith(AndroidJUnit4::class) +class AnimationsTest { + companion object { + const val TEST_DURATION = 1000L + } + + private val context: Context = InstrumentationRegistry.getInstrumentation().context + + @Test + fun ongoingAnimationsAreStoredAndCancelledCorrectly() { + val view = View(context) + + val oldAnimation = FakeAnimator() + Animations.setOngoingAnimation(view, oldAnimation) + oldAnimation.start() + assertEquals(oldAnimation, view.getTag(R.id.ongoing_animation)) + assertTrue(oldAnimation.started) + + val newAnimation = FakeAnimator() + Animations.setOngoingAnimation(view, newAnimation) + newAnimation.start() + assertEquals(newAnimation, view.getTag(R.id.ongoing_animation)) + assertTrue(oldAnimation.cancelled) + assertTrue(newAnimation.started) + + Animations.cancelOngoingAnimation(view) + assertNull(view.getTag(R.id.ongoing_animation)) + assertTrue(newAnimation.cancelled) + } +} + +/** Test animator for tracking start and cancel signals. */ +private class FakeAnimator : ValueAnimator() { + var started = false + var cancelled = false + + override fun start() { + started = true + cancelled = false + } + + override fun cancel() { + started = false + cancelled = true + } +} diff --git a/animationlib/tests/src/com/android/app/animation/InterpolatorResourcesTest.kt b/animationlib/tests/src/com/android/app/animation/InterpolatorResourcesTest.kt index ed4670e..f54493e 100644 --- a/animationlib/tests/src/com/android/app/animation/InterpolatorResourcesTest.kt +++ b/animationlib/tests/src/com/android/app/animation/InterpolatorResourcesTest.kt @@ -3,23 +3,23 @@ package com.android.app.animation import android.annotation.InterpolatorRes import android.content.Context import android.view.animation.AnimationUtils +import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.filters.SmallTest import androidx.test.platform.app.InstrumentationRegistry import junit.framework.Assert.assertEquals import org.junit.Before import org.junit.Test import org.junit.runner.RunWith -import org.junit.runners.JUnit4 @SmallTest -@RunWith(JUnit4::class) +@RunWith(AndroidJUnit4::class) class InterpolatorResourcesTest { private lateinit var context: Context @Before fun setup() { - context = InstrumentationRegistry.getInstrumentation().context + context = InstrumentationRegistry.getInstrumentation().targetContext } @Test diff --git a/animationlib/tests/src/com/android/app/animation/InterpolatorsAndroidXTest.kt b/animationlib/tests/src/com/android/app/animation/InterpolatorsAndroidXTest.kt index ffa706e..bed06cd 100644 --- a/animationlib/tests/src/com/android/app/animation/InterpolatorsAndroidXTest.kt +++ b/animationlib/tests/src/com/android/app/animation/InterpolatorsAndroidXTest.kt @@ -16,18 +16,18 @@ package com.android.app.animation +import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.filters.SmallTest import java.lang.reflect.Modifier import junit.framework.Assert.assertEquals import org.junit.Test import org.junit.runner.RunWith -import org.junit.runners.JUnit4 private const val ANDROIDX_ANIM_PACKAGE_NAME = "androidx.core.animation." private const val ANDROID_ANIM_PACKAGE_NAME = "android.view.animation." @SmallTest -@RunWith(JUnit4::class) +@RunWith(AndroidJUnit4::class) class InterpolatorsAndroidXTest { @Test From bc4dbec3595a486fdfa72043345ed2b98a357368 Mon Sep 17 00:00:00 2001 From: Pun Butrach Date: Sun, 16 Nov 2025 16:59:10 +0700 Subject: [PATCH 03/30] feat: contextualeducation Android 16 QPR1 --- contextualeducationlib/build.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/contextualeducationlib/build.gradle b/contextualeducationlib/build.gradle index 558b313..909304d 100644 --- a/contextualeducationlib/build.gradle +++ b/contextualeducationlib/build.gradle @@ -27,4 +27,4 @@ android { manifest.srcFile 'AndroidManifest.xml' } } -} +} \ No newline at end of file From cb3cfd418c8ec277d802b27fcffb1e78d8ea72d9 Mon Sep 17 00:00:00 2001 From: Pun Butrach Date: Sun, 16 Nov 2025 17:15:36 +0700 Subject: [PATCH 04/30] feat: iconloaderlib Android 16 QPR1 --- iconloaderlib/Android.bp | 15 +- iconloaderlib/res/values/config.xml | 3 - .../launcher3/icons/BaseIconFactory.java | 125 +++--- .../com/android/launcher3/icons/BitmapInfo.kt | 272 +++++++++++++ .../launcher3/icons/ClockDrawableWrapper.java | 34 +- .../launcher3/icons/FastBitmapDrawable.kt | 369 ++++++++++++++++++ .../android/launcher3/icons/IconProvider.java | 49 ++- .../icons/MonochromeIconFactory.java | 4 +- .../icons/PlaceHolderIconDrawable.java | 6 +- .../android/launcher3/icons/ThemedBitmap.kt | 20 +- .../launcher3/icons/cache/BaseIconCache.kt | 85 ++-- .../launcher3/icons/cache/CacheLookupFlag.kt | 24 +- .../cache/LauncherActivityCachingLogic.kt | 3 +- .../icons/mono/MonoIconThemeController.kt | 26 +- .../icons/mono/ThemedIconDrawable.kt | 15 +- 15 files changed, 900 insertions(+), 150 deletions(-) create mode 100644 iconloaderlib/src/com/android/launcher3/icons/BitmapInfo.kt create mode 100644 iconloaderlib/src/com/android/launcher3/icons/FastBitmapDrawable.kt diff --git a/iconloaderlib/Android.bp b/iconloaderlib/Android.bp index 6867e6b..e991888 100644 --- a/iconloaderlib/Android.bp +++ b/iconloaderlib/Android.bp @@ -19,30 +19,41 @@ package { android_library { name: "iconloader_base", sdk_version: "current", - min_sdk_version: "26", + min_sdk_version: "31", static_libs: [ "androidx.core_core", + "com_android_launcher3_flags_lib", + "com_android_systemui_shared_flags_lib", ], resource_dirs: [ "res", ], srcs: [ "src/**/*.java", + "src/**/*.kt", ], } android_library { name: "iconloader", sdk_version: "system_current", - min_sdk_version: "26", + min_sdk_version: "31", static_libs: [ "androidx.core_core", + "com_android_launcher3_flags_lib", + "com_android_systemui_shared_flags_lib", ], resource_dirs: [ "res", ], srcs: [ "src/**/*.java", + "src/**/*.kt", "src_full_lib/**/*.java", + "src_full_lib/**/*.kt", + ], + apex_available: [ + "//apex_available:platform", + "com.android.permission", ], } diff --git a/iconloaderlib/res/values/config.xml b/iconloaderlib/res/values/config.xml index 71a38f2..893f955 100644 --- a/iconloaderlib/res/values/config.xml +++ b/iconloaderlib/res/values/config.xml @@ -27,7 +27,4 @@ - - false - \ No newline at end of file diff --git a/iconloaderlib/src/com/android/launcher3/icons/BaseIconFactory.java b/iconloaderlib/src/com/android/launcher3/icons/BaseIconFactory.java index f5a16dc..d48ff18 100644 --- a/iconloaderlib/src/com/android/launcher3/icons/BaseIconFactory.java +++ b/iconloaderlib/src/com/android/launcher3/icons/BaseIconFactory.java @@ -7,6 +7,7 @@ import static android.graphics.drawable.AdaptiveIconDrawable.getExtraInsetFraction; import static com.android.launcher3.icons.BitmapInfo.FLAG_INSTANT; +import static com.android.launcher3.icons.IconNormalizer.ICON_VISIBLE_AREA_FACTOR; import static com.android.launcher3.icons.ShadowGenerator.BLUR_FACTOR; import static com.android.launcher3.icons.ShadowGenerator.ICON_SCALE_FOR_SHADOWS; @@ -19,21 +20,21 @@ import android.content.res.Resources; import android.graphics.Bitmap; import android.graphics.Bitmap.Config; +import android.graphics.BitmapShader; import android.graphics.Canvas; import android.graphics.Color; import android.graphics.Paint; import android.graphics.PaintFlagsDrawFilter; import android.graphics.Path; import android.graphics.Rect; +import android.graphics.Shader.TileMode; import android.graphics.drawable.AdaptiveIconDrawable; import android.graphics.drawable.BitmapDrawable; import android.graphics.drawable.ColorDrawable; import android.graphics.drawable.Drawable; -import android.graphics.drawable.DrawableWrapper; import android.graphics.drawable.InsetDrawable; import android.os.Build; import android.os.UserHandle; -import android.util.Log; import android.util.SparseArray; import androidx.annotation.ColorInt; @@ -43,7 +44,6 @@ import com.android.launcher3.Flags; import com.android.launcher3.icons.BitmapInfo.Extender; -import com.android.launcher3.icons.mono.ThemedIconDrawable; import com.android.launcher3.util.FlagOp; import com.android.launcher3.util.UserIconInfo; @@ -60,8 +60,8 @@ */ public class BaseIconFactory implements AutoCloseable { - public static final int DEFAULT_WRAPPER_BACKGROUND = Color.WHITE; - public static final float LEGACY_ICON_SCALE = .7f * (1f / (1 + 2 * getExtraInsetFraction())); + private static final int DEFAULT_WRAPPER_BACKGROUND = Color.WHITE; + private static final float LEGACY_ICON_SCALE = .7f * (1f / (1 + 2 * getExtraInsetFraction())); public static final int MODE_DEFAULT = 0; public static final int MODE_ALPHA = 1; @@ -99,15 +99,15 @@ public class BaseIconFactory implements AutoCloseable { @Nullable private ShadowGenerator mShadowGenerator; - // Shadow bitmap used as background for theme icons + /** Shadow bitmap used as background for theme icons */ private Bitmap mWhiteShadowLayer; + /** Bitmap used for {@link BitmapShader} to mask Adaptive Icons when drawing */ + private Bitmap mShaderBitmap; private int mWrapperBackgroundColor = DEFAULT_WRAPPER_BACKGROUND; private static int PLACEHOLDER_BACKGROUND_COLOR = Color.rgb(245, 245, 245); - private final boolean mShouldForceThemeIcon; - protected BaseIconFactory(Context context, int fullResIconDpi, int iconBitmapSize, boolean unused) { this(context, fullResIconDpi, iconBitmapSize); @@ -123,9 +123,6 @@ public BaseIconFactory(Context context, int fullResIconDpi, int iconBitmapSize) mCanvas = new Canvas(); mCanvas.setDrawFilter(new PaintFlagsDrawFilter(DITHER_FLAG, FILTER_BITMAP_FLAG)); clear(); - - mShouldForceThemeIcon = mContext.getResources().getBoolean( - R.bool.enable_forced_themed_icon); } protected void clear() { @@ -178,7 +175,7 @@ public BitmapInfo createIconBitmap(String placeholder, int color) { AdaptiveIconDrawable drawable = new AdaptiveIconDrawable( new ColorDrawable(PLACEHOLDER_BACKGROUND_COLOR), new CenterTextDrawable(placeholder, color)); - Bitmap icon = createIconBitmap(drawable, IconNormalizer.ICON_VISIBLE_AREA_FACTOR); + Bitmap icon = createIconBitmap(drawable, ICON_VISIBLE_AREA_FACTOR); return BitmapInfo.of(icon, color); } @@ -198,8 +195,9 @@ public AdaptiveIconDrawable createShapedAdaptiveIcon(Bitmap iconBitmap) { Drawable drawable = new FixedSizeBitmapDrawable(iconBitmap); float inset = getExtraInsetFraction(); inset = inset / (1 + 2 * inset); - return new AdaptiveIconDrawable(new ColorDrawable(Color.BLACK), - new InsetDrawable(drawable, inset, inset, inset, inset)); + return new AdaptiveIconDrawable(new ColorDrawable(BLACK), + new InsetDrawable(drawable, inset, inset, inset, inset) + ); } @NonNull @@ -231,7 +229,6 @@ public BitmapInfo createBadgedIconBitmap(@NonNull Drawable icon, AdaptiveIconDrawable adaptiveIcon = normalizeAndWrapToAdaptiveIcon(tempIcon, scale); Bitmap bitmap = createIconBitmap(adaptiveIcon, scale[0], options == null ? MODE_WITH_SHADOW : options.mGenerationMode); - int color = (options != null && options.mExtractedColor != null) ? options.mExtractedColor : ColorExtractor.findDominantColorByHue(bitmap); BitmapInfo info = BitmapInfo.of(bitmap, color); @@ -248,7 +245,11 @@ public BitmapInfo createBadgedIconBitmap(@NonNull Drawable icon, ) ); } - info = info.withFlags(getBitmapFlagOp(options)); + FlagOp flagOp = getBitmapFlagOp(options); + if (adaptiveIcon instanceof WrappedAdaptiveIcon) { + flagOp = flagOp.addFlag(BitmapInfo.FLAG_WRAPPED_NON_ADAPTIVE); + } + info = info.withFlags(flagOp); return info; } @@ -271,13 +272,6 @@ public FlagOp getBitmapFlagOp(@Nullable IconOptions options) { return op; } - /** - * @return True if forced theme icon is enabled - */ - public boolean shouldForceThemeIcon() { - return mShouldForceThemeIcon; - } - @NonNull protected UserIconInfo getUserInfo(@NonNull UserHandle user) { int key = user.hashCode(); @@ -301,10 +295,6 @@ public Path getShapePath(AdaptiveIconDrawable drawable, Rect iconBounds) { return drawable.getIconMask(); } - public float getIconScale() { - return 1f; - } - @NonNull public Bitmap getWhiteShadowLayer() { if (mWhiteShadowLayer == null) { @@ -315,6 +305,42 @@ public Bitmap getWhiteShadowLayer() { return mWhiteShadowLayer; } + /** + * Takes an {@link AdaptiveIconDrawable} and uses it to create a new Shader Bitmap. + * {@link mShaderBitmap} will be used to create a {@link BitmapShader} for masking, + * such as for icon shapes. Will reuse underlying Bitmap where possible. + * + * @param adaptiveIcon AdaptiveIconDrawable to draw with shader + */ + @NonNull + private Bitmap getAdaptiveShaderBitmap(AdaptiveIconDrawable adaptiveIcon) { + Rect bounds = adaptiveIcon.getBounds(); + int iconWidth = bounds.width(); + int iconHeight = bounds.width(); + + BitmapRenderer shaderRenderer = new BitmapRenderer() { + @Override + public void draw(Canvas canvas) { + canvas.translate(-bounds.left, -bounds.top); + canvas.drawColor(BLACK); + if (adaptiveIcon.getBackground() != null) { + adaptiveIcon.getBackground().draw(canvas); + } + if (adaptiveIcon.getForeground() != null) { + adaptiveIcon.getForeground().draw(canvas); + } + } + }; + if (mShaderBitmap == null || iconWidth != mShaderBitmap.getWidth() + || iconHeight != mShaderBitmap.getHeight()) { + mShaderBitmap = BitmapRenderer.createSoftwareBitmap(iconWidth, iconHeight, + shaderRenderer); + } else { + shaderRenderer.draw(new Canvas(mShaderBitmap)); + } + return mShaderBitmap; + } + @NonNull public Bitmap createScaledBitmap(@NonNull Drawable icon, @BitmapGenerationMode int mode) { float[] scale = new float[1]; @@ -395,6 +421,8 @@ public AdaptiveIconDrawable wrapToAdaptiveIcon(@NonNull Drawable icon) { int wrapperBackgroundColor = IconPreferencesKt.getWrapperBackgroundColor(mContext, icon); FixedScaleDrawable foreground = new FixedScaleDrawable(); + // pE-TODO(QPR1): Investigate + // foreground = createScaledDrawable(icon, scale * LEGACY_ICON_SCALE) CustomAdaptiveIconDrawable dr = new CustomAdaptiveIconDrawable( new ColorDrawable(wrapperBackgroundColor), foreground); dr.setBounds(0, 0, 1, 1); @@ -423,7 +451,7 @@ public Bitmap createIconBitmap(@Nullable final Drawable icon, final float scale, case MODE_HARDWARE: case MODE_HARDWARE_WITH_SHADOW: { return BitmapRenderer.createHardwareBitmap(size, size, canvas -> - drawIconBitmap(canvas, icon, scale, bitmapGenerationMode, null)); + drawIconBitmap(canvas, icon, scale, bitmapGenerationMode, null)); } case MODE_WITH_SHADOW: default: @@ -465,6 +493,7 @@ private void drawIconBitmap(@NonNull Canvas canvas, @Nullable Drawable icon, } else { drawAdaptiveIcon(canvas, aid, shapePath); } + canvas.restoreToCount(count); } else { if (icon instanceof BitmapDrawable) { @@ -512,28 +541,28 @@ private void drawIconBitmap(@NonNull Canvas canvas, @Nullable Drawable icon, } /** - * Draws AdaptiveIconDrawable onto canvas. - * @param canvas canvas to draw on - * @param drawable AdaptiveIconDrawable to draw - * @param overridePath path to clip icon with for shapes + * Draws AdaptiveIconDrawable onto canvas using provided Path + * and {@link mShaderBitmap} as a shader. + * + * @param canvas canvas to draw on + * @param drawable AdaptiveIconDrawable to draw + * @param shapePath path to clip icon with for shapes */ protected void drawAdaptiveIcon( @NonNull Canvas canvas, @NonNull AdaptiveIconDrawable drawable, - @NonNull Path overridePath + @NonNull Path shapePath ) { - if (!Flags.enableLauncherIconShapes()) { + Drawable background = drawable.getBackground(); + Drawable foreground = drawable.getForeground(); + if (!Flags.enableLauncherIconShapes() || (background == null && foreground == null)) { drawable.draw(canvas); return; } - canvas.clipPath(overridePath); - canvas.drawColor(BLACK); - if (drawable.getBackground() != null) { - drawable.getBackground().draw(canvas); - } - if (drawable.getForeground() != null) { - drawable.getForeground().draw(canvas); - } + Bitmap shaderBitmap = getAdaptiveShaderBitmap(drawable); + Paint paint = new Paint(); + paint.setShader(new BitmapShader(shaderBitmap, TileMode.CLAMP, TileMode.CLAMP)); + canvas.drawPath(shapePath, paint); } @Override @@ -700,16 +729,10 @@ public void draw(Canvas canvas) { } } - private static class EmptyWrapper extends DrawableWrapper { + private static class WrappedAdaptiveIcon extends AdaptiveIconDrawable { - EmptyWrapper() { - super(new ColorDrawable()); - } - - @Override - public ConstantState getConstantState() { - Drawable d = getDrawable(); - return d == null ? null : d.getConstantState(); + WrappedAdaptiveIcon(Drawable backgroundDrawable, Drawable foregroundDrawable) { + super(backgroundDrawable, foregroundDrawable); } } } diff --git a/iconloaderlib/src/com/android/launcher3/icons/BitmapInfo.kt b/iconloaderlib/src/com/android/launcher3/icons/BitmapInfo.kt new file mode 100644 index 0000000..1b3f0fa --- /dev/null +++ b/iconloaderlib/src/com/android/launcher3/icons/BitmapInfo.kt @@ -0,0 +1,272 @@ +/* + * Copyright (C) 2017 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.launcher3.icons + +import android.content.Context +import android.graphics.Bitmap +import android.graphics.Canvas +import android.graphics.Path +import android.graphics.drawable.Drawable +import androidx.annotation.ColorRes +import androidx.annotation.DrawableRes +import androidx.annotation.IntDef +import com.android.launcher3.icons.cache.CacheLookupFlag +import com.android.launcher3.util.FlagOp + +open class BitmapInfo( + @JvmField val icon: Bitmap, + @JvmField val color: Int, + @BitmapInfoFlags @JvmField var flags: Int = 0, + var themedBitmap: ThemedBitmap? = null, +) { + @IntDef( + flag = true, + value = [FLAG_WORK, FLAG_INSTANT, FLAG_CLONE, FLAG_PRIVATE, FLAG_WRAPPED_NON_ADAPTIVE], + ) + internal annotation class BitmapInfoFlags + + @IntDef(flag = true, value = [FLAG_THEMED, FLAG_NO_BADGE, FLAG_SKIP_USER_BADGE]) + annotation class DrawableCreationFlags + + // b/377618519: These are saved to debug why work badges sometimes don't show up on work apps + @DrawableCreationFlags @JvmField var creationFlags: Int = 0 + + private var badgeInfo: BitmapInfo? = null + + fun withBadgeInfo(badgeInfo: BitmapInfo?) = clone().also { it.badgeInfo = badgeInfo } + + /** Returns a bitmapInfo with the flagOP applied */ + fun withFlags(op: FlagOp): BitmapInfo { + if (op === FlagOp.NO_OP) { + return this + } + return clone().also { it.flags = op.apply(it.flags) } + } + + @Override + open fun clone(): BitmapInfo { + return copyInternalsTo(BitmapInfo(icon, color)) + } + + protected fun copyInternalsTo(target: BitmapInfo): BitmapInfo { + target.themedBitmap = themedBitmap + target.flags = flags + target.badgeInfo = badgeInfo + return target + } + + // TODO: rename or remove because icon can no longer be null? + val isNullOrLowRes: Boolean + get() = icon == LOW_RES_ICON + + val isLowRes: Boolean + get() = matchingLookupFlag.useLowRes() + + open val matchingLookupFlag: CacheLookupFlag + /** Returns the lookup flag to match this current state of this info */ + get() = + CacheLookupFlag.DEFAULT_LOOKUP_FLAG.withUseLowRes(LOW_RES_ICON == icon) + .withThemeIcon(themedBitmap != null) + + /** BitmapInfo can be stored on disk or other persistent storage */ + open fun canPersist(): Boolean { + return !isNullOrLowRes + } + + /** Creates a drawable for the provided BitmapInfo */ + @JvmOverloads + fun newIcon( + context: Context, + @DrawableCreationFlags creationFlags: Int = 0, + ): FastBitmapDrawable { + return newIcon(context, creationFlags, null) + } + + /** + * Creates a drawable for the provided BitmapInfo + * + * @param context Context + * @param creationFlags Flags for creating the FastBitmapDrawable + * @param badgeShape Optional Path for masking icon badges to a shape. Should be 100x100. + * @return FastBitmapDrawable + */ + open fun newIcon( + context: Context, + @DrawableCreationFlags creationFlags: Int, + badgeShape: Path?, + ): FastBitmapDrawable { + val drawable: FastBitmapDrawable = + if (isLowRes) { + PlaceHolderIconDrawable(this, context) + } else if ( + (creationFlags and FLAG_THEMED) != 0 && + themedBitmap != null && + themedBitmap !== ThemedBitmap.NOT_SUPPORTED + ) { + themedBitmap!!.newDrawable(this, context) + } else { + FastBitmapDrawable(this) + } + applyFlags(context, drawable, creationFlags, badgeShape) + return drawable + } + + protected fun applyFlags( + context: Context, drawable: FastBitmapDrawable, + @DrawableCreationFlags creationFlags: Int, badgeShape: Path? + ) { + this.creationFlags = creationFlags + drawable.disabledAlpha = GraphicsUtils.getFloat(context, R.attr.disabledIconAlpha, 1f) + drawable.creationFlags = creationFlags + if ((creationFlags and FLAG_NO_BADGE) == 0) { + val badge = getBadgeDrawable( + context, (creationFlags and FLAG_THEMED) != 0, + (creationFlags and FLAG_SKIP_USER_BADGE) != 0, badgeShape + ) + if (badge != null) { + drawable.badge = badge + } + } + } + + /** + * Gets Badge drawable based on current flags + * + * @param context Context + * @param isThemed If Drawable is themed. + * @param badgeShape Optional Path to mask badges to a shape. Should be 100x100. + * @return Drawable for the badge. + */ + fun getBadgeDrawable(context: Context, isThemed: Boolean, badgeShape: Path?): Drawable? { + return getBadgeDrawable(context, isThemed, false, badgeShape) + } + + /** + * Creates a Drawable for an icon badge for this BitmapInfo + * @param context Context + * @param isThemed If the drawable is themed. + * @param skipUserBadge If should skip User Profile badging. + * @param badgeShape Optional Path to mask badge Drawable to a shape. Should be 100x100. + * @return Drawable for an icon Badge. + */ + private fun getBadgeDrawable( + context: Context, isThemed: Boolean, skipUserBadge: Boolean, badgeShape: Path? + ): Drawable? { + if (badgeInfo != null) { + var creationFlag = if (isThemed) FLAG_THEMED else 0 + if (skipUserBadge) { + creationFlag = creationFlag or FLAG_SKIP_USER_BADGE + } + return badgeInfo!!.newIcon(context, creationFlag, badgeShape) + } + if (skipUserBadge) { + return null + } else { + getBadgeDrawableInfo()?.let { + return UserBadgeDrawable( + context, + it.drawableRes, + it.colorRes, + isThemed, + badgeShape + ) + } + } + return null + } + + /** + * Returns information about the badge to apply based on current flags. + */ + fun getBadgeDrawableInfo(): BadgeDrawableInfo? { + return when { + (flags and FLAG_INSTANT) != 0 -> BadgeDrawableInfo( + R.drawable.ic_instant_app_badge, + R.color.badge_tint_instant + ) + (flags and FLAG_WORK) != 0 -> BadgeDrawableInfo( + R.drawable.ic_work_app_badge, + R.color.badge_tint_work + ) + (flags and FLAG_CLONE) != 0 -> BadgeDrawableInfo( + R.drawable.ic_clone_app_badge, + R.color.badge_tint_clone + ) + (flags and FLAG_PRIVATE) != 0 -> BadgeDrawableInfo( + R.drawable.ic_private_profile_app_badge, + R.color.badge_tint_private + ) + else -> null + } + } + + + /** Interface to be implemented by drawables to provide a custom BitmapInfo */ + interface Extender { + /** Called for creating a custom BitmapInfo */ + fun getExtendedInfo( + bitmap: Bitmap?, + color: Int, + iconFactory: BaseIconFactory?, + normalizationScale: Float, + ): BitmapInfo? + + /** Called to draw the UI independent of any runtime configurations like time or theme */ + fun drawForPersistence(canvas: Canvas?) + } + + /** + * Drawables backing a specific badge shown on app icons. + * @param drawableRes Drawable resource for the badge. + * @param colorRes Color resource to tint the badge. + */ + @JvmRecord + data class BadgeDrawableInfo( + @field:DrawableRes @param:DrawableRes val drawableRes: Int, + @field:ColorRes @param:ColorRes val colorRes: Int + ) + + companion object { + const val TAG: String = "BitmapInfo" + + // BitmapInfo flags + const val FLAG_WORK: Int = 1 shl 0 + const val FLAG_INSTANT: Int = 1 shl 1 + const val FLAG_CLONE: Int = 1 shl 2 + const val FLAG_PRIVATE: Int = 1 shl 3 + const val FLAG_WRAPPED_NON_ADAPTIVE: Int = 1 shl 4 + + // Drawable creation flags + const val FLAG_THEMED: Int = 1 shl 0 + const val FLAG_NO_BADGE: Int = 1 shl 1 + const val FLAG_SKIP_USER_BADGE: Int = 1 shl 2 + + @JvmField + val LOW_RES_ICON: Bitmap = Bitmap.createBitmap(1, 1, Bitmap.Config.ALPHA_8) + @JvmField + val LOW_RES_INFO: BitmapInfo = fromBitmap(LOW_RES_ICON) + + @JvmStatic + fun fromBitmap(bitmap: Bitmap): BitmapInfo { + return of(bitmap, 0) + } + + @JvmStatic + fun of(bitmap: Bitmap, color: Int): BitmapInfo { + return BitmapInfo(bitmap, color) + } + } +} diff --git a/iconloaderlib/src/com/android/launcher3/icons/ClockDrawableWrapper.java b/iconloaderlib/src/com/android/launcher3/icons/ClockDrawableWrapper.java index 3e8874a..12a76dd 100644 --- a/iconloaderlib/src/com/android/launcher3/icons/ClockDrawableWrapper.java +++ b/iconloaderlib/src/com/android/launcher3/icons/ClockDrawableWrapper.java @@ -16,6 +16,7 @@ package com.android.launcher3.icons; import static com.android.launcher3.icons.IconProvider.ATLEAST_T; +import static com.android.launcher3.icons.cache.CacheLookupFlag.DEFAULT_LOOKUP_FLAG; import android.annotation.TargetApi; import android.content.Context; @@ -40,10 +41,7 @@ import android.os.SystemClock; import android.util.Log; -import androidx.annotation.NonNull; -import androidx.core.util.Supplier; -import app.lawnchair.icons.ClockMetadata; -import app.lawnchair.icons.CustomAdaptiveIconDrawable; +import com.android.launcher3.icons.cache.CacheLookupFlag; import com.android.launcher3.icons.mono.ThemedIconDrawable; import java.util.Calendar; @@ -51,6 +49,9 @@ import java.util.concurrent.TimeUnit; import java.util.function.IntFunction; +import app.lawnchair.icons.ClockMetadata; +import app.lawnchair.icons.CustomAdaptiveIconDrawable; + /** * Wrapper over {@link AdaptiveIconDrawable} to intercept icon flattening logic for dynamic * clock icons @@ -302,7 +303,7 @@ static class ClockBitmapInfo extends BitmapInfo { ClockBitmapInfo(Bitmap icon, int color, float scale, AnimationInfo animInfo, Bitmap background, AnimationInfo themeInfo, Bitmap themeBackground) { - super(icon, color); + super(icon, color, /* flags */ 0, /* themedBitmap */ null); this.boundsOffset = Math.max(ShadowGenerator.BLUR_FACTOR, (1 - scale) / 2); this.animInfo = animInfo; this.mFlattenedBackground = background; @@ -313,7 +314,7 @@ static class ClockBitmapInfo extends BitmapInfo { @Override @TargetApi(Build.VERSION_CODES.TIRAMISU) public FastBitmapDrawable newIcon(Context context, - @DrawableCreationFlags int creationFlags, Path badgeShape) { + @DrawableCreationFlags int creationFlags, Path badgeShape) { AnimationInfo info; Bitmap bg; int themedFgColor; @@ -349,8 +350,14 @@ public boolean canPersist() { @Override public BitmapInfo clone() { - return copyInternalsTo(new ClockBitmapInfo(icon, color, 1 - 2 * boundsOffset, animInfo, - mFlattenedBackground, themeData, themeBackground)); + return copyInternalsTo(new ClockBitmapInfo(icon, color, + 1 - 2 * boundsOffset, animInfo, mFlattenedBackground, + themeData, themeBackground)); + } + + @Override + public CacheLookupFlag getMatchingLookupFlag() { + return DEFAULT_LOOKUP_FLAG.withThemeIcon(themeData != null); } } @@ -371,7 +378,7 @@ private static class ClockIconDrawable extends FastBitmapDrawable implements Run private final float mCanvasScale; ClockIconDrawable(ClockConstantState cs) { - super(cs.mBitmapInfo); + super(cs.getBitmapInfo()); mBoundsOffset = cs.mBoundsOffset; mAnimInfo = cs.mAnimInfo; @@ -434,10 +441,11 @@ public boolean isThemed() { @Override protected void updateFilter() { super.updateFilter(); - int alpha = mIsDisabled ? (int) (mDisabledAlpha * FULLY_OPAQUE) : FULLY_OPAQUE; + boolean isDisabled = isDisabled(); + int alpha = isDisabled ? (int) (disabledAlpha * FULLY_OPAQUE) : FULLY_OPAQUE; setAlpha(alpha); - mBgPaint.setColorFilter(mIsDisabled ? getDisabledColorFilter() : mBgFilter); - mFG.setColorFilter(mIsDisabled ? getDisabledColorFilter() : null); + mBgPaint.setColorFilter(isDisabled ? getDisabledColorFilter() : mBgFilter); + mFG.setColorFilter(isDisabled ? getDisabledColorFilter() : null); } @Override @@ -477,7 +485,7 @@ private void reschedule() { @Override public FastBitmapConstantState newConstantState() { - return new ClockConstantState(mBitmapInfo, mThemedFgColor, mBoundsOffset, + return new ClockConstantState(bitmapInfo, mThemedFgColor, mBoundsOffset, mAnimInfo, mBG, mBgPaint.getColorFilter()); } diff --git a/iconloaderlib/src/com/android/launcher3/icons/FastBitmapDrawable.kt b/iconloaderlib/src/com/android/launcher3/icons/FastBitmapDrawable.kt new file mode 100644 index 0000000..670915a --- /dev/null +++ b/iconloaderlib/src/com/android/launcher3/icons/FastBitmapDrawable.kt @@ -0,0 +1,369 @@ +/* + * Copyright (C) 2008 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.launcher3.icons + +import android.R +import android.animation.ObjectAnimator +import android.graphics.Bitmap +import android.graphics.Canvas +import android.graphics.Color +import android.graphics.ColorFilter +import android.graphics.ColorMatrix +import android.graphics.ColorMatrixColorFilter +import android.graphics.Paint +import android.graphics.Paint.ANTI_ALIAS_FLAG +import android.graphics.Paint.FILTER_BITMAP_FLAG +import android.graphics.PixelFormat +import android.graphics.Rect +import android.graphics.drawable.Drawable +import android.graphics.drawable.Drawable.Callback +import android.util.FloatProperty +import android.view.animation.AccelerateInterpolator +import android.view.animation.DecelerateInterpolator +import android.view.animation.Interpolator +import android.view.animation.PathInterpolator +import androidx.annotation.VisibleForTesting +import androidx.core.graphics.ColorUtils +import com.android.launcher3.icons.BitmapInfo.DrawableCreationFlags +import kotlin.math.min + +open class FastBitmapDrawable(info: BitmapInfo?) : Drawable(), Callback { + + @JvmOverloads constructor(b: Bitmap, iconColor: Int = 0) : this(BitmapInfo.of(b, iconColor)) + + @JvmField val bitmapInfo: BitmapInfo = info ?: BitmapInfo.LOW_RES_INFO + var isAnimationEnabled: Boolean = true + + @JvmField protected val paint: Paint = Paint(FILTER_BITMAP_FLAG or ANTI_ALIAS_FLAG) + + @JvmField @VisibleForTesting var isPressed: Boolean = false + @JvmField @VisibleForTesting var isHovered: Boolean = false + + @JvmField var disabledAlpha: Float = 1f + + var isDisabled: Boolean = false + set(value) { + if (field != value) { + field = value + badge.let { if (it is FastBitmapDrawable) it.isDisabled = value } + updateFilter() + } + } + + @JvmField @DrawableCreationFlags var creationFlags: Int = 0 + @JvmField @VisibleForTesting var scaleAnimation: ObjectAnimator? = null + var hoverScaleEnabledForDisplay = true + + private var scale = 1f + + private var paintAlpha = 255 + private var paintFilter: ColorFilter? = null + + init { + isFilterBitmap = true + } + + var badge: Drawable? = null + set(value) { + field?.callback = null + field = value + field?.let { + it.callback = this + it.setBadgeBounds(bounds) + } + updateFilter() + } + + /** Returns true if the drawable points to the same bitmap icon object */ + fun isSameInfo(info: BitmapInfo): Boolean = bitmapInfo === info + + override fun onBoundsChange(bounds: Rect) { + super.onBoundsChange(bounds) + badge?.setBadgeBounds(bounds) + } + + override fun draw(canvas: Canvas) { + if (scale != 1f) { + val count = canvas.save() + val bounds = bounds + canvas.scale(scale, scale, bounds.exactCenterX(), bounds.exactCenterY()) + drawInternal(canvas, bounds) + badge?.draw(canvas) + canvas.restoreToCount(count) + } else { + drawInternal(canvas, bounds) + badge?.draw(canvas) + } + } + + protected open fun drawInternal(canvas: Canvas, bounds: Rect) { + canvas.drawBitmap(bitmapInfo.icon, null, bounds, paint) + } + + /** Returns the primary icon color, slightly tinted white */ + open fun getIconColor(): Int = + ColorUtils.compositeColors( + GraphicsUtils.setColorAlphaBound(Color.WHITE, WHITE_SCRIM_ALPHA), + bitmapInfo.color, + ) + + /** Returns if this represents a themed icon */ + open fun isThemed(): Boolean = false + + /** + * Returns true if the drawable was created with theme, even if it doesn't support theming + * itself. + */ + fun isCreatedForTheme(): Boolean = isThemed() || (creationFlags and BitmapInfo.FLAG_THEMED) != 0 + + override fun setColorFilter(cf: ColorFilter?) { + paintFilter = cf + updateFilter() + } + + override fun getColorFilter(): ColorFilter? = paint.colorFilter + + @Deprecated("This method is no longer used in graphics optimizations") + override fun getOpacity(): Int = PixelFormat.TRANSLUCENT + + override fun setAlpha(alpha: Int) { + if (paintAlpha != alpha) { + paintAlpha = alpha + paint.alpha = alpha + invalidateSelf() + badge?.alpha = alpha + } + } + + override fun getAlpha(): Int = paintAlpha + + override fun setFilterBitmap(filterBitmap: Boolean) { + paint.isFilterBitmap = filterBitmap + paint.isAntiAlias = filterBitmap + } + + fun resetScale() { + scaleAnimation?.cancel() + scaleAnimation = null + scale = 1f + invalidateSelf() + } + + fun getAnimatedScale(): Float = if (scaleAnimation == null) 1f else scale + + override fun getIntrinsicWidth(): Int = bitmapInfo.icon.width + + override fun getIntrinsicHeight(): Int = bitmapInfo.icon.height + + override fun getMinimumWidth(): Int = bounds.width() + + override fun getMinimumHeight(): Int = bounds.height() + + override fun isStateful(): Boolean = true + + public override fun onStateChange(state: IntArray): Boolean { + if (!isAnimationEnabled) { + return false + } + + var isPressed = false + var isHovered = false + for (s in state) { + if (s == R.attr.state_pressed) { + isPressed = true + break + } else if (s == R.attr.state_hovered && hoverScaleEnabledForDisplay) { + isHovered = true + // Do not break on hovered state, as pressed state should take precedence. + } + } + if (this.isPressed != isPressed || this.isHovered != isHovered) { + scaleAnimation?.cancel() + + val endScale = + when { + isPressed -> PRESSED_SCALE + isHovered -> HOVERED_SCALE + else -> 1f + } + if (scale != endScale) { + if (isVisible) { + scaleAnimation = + ObjectAnimator.ofFloat(this, SCALE, endScale).apply { + duration = + if (isPressed != this@FastBitmapDrawable.isPressed) + CLICK_FEEDBACK_DURATION.toLong() + else HOVER_FEEDBACK_DURATION.toLong() + + interpolator = + if (isPressed != this@FastBitmapDrawable.isPressed) + (if (isPressed) ACCEL else DEACCEL) + else HOVER_EMPHASIZED_DECELERATE_INTERPOLATOR + } + scaleAnimation?.start() + } else { + scale = endScale + invalidateSelf() + } + } + this.isPressed = isPressed + this.isHovered = isHovered + return true + } + return false + } + + /** Updates the paint to reflect the current brightness and saturation. */ + protected open fun updateFilter() { + paint.setColorFilter(if (isDisabled) getDisabledColorFilter(disabledAlpha) else paintFilter) + badge?.colorFilter = colorFilter + invalidateSelf() + } + + protected open fun newConstantState(): FastBitmapConstantState { + return FastBitmapConstantState(bitmapInfo) + } + + override fun getConstantState(): ConstantState { + val cs = newConstantState() + cs.mIsDisabled = isDisabled + cs.mBadgeConstantState = badge?.constantState + cs.mCreationFlags = creationFlags + return cs + } + + // Returns if the FastBitmapDrawable contains a badge. + fun hasBadge(): Boolean = (creationFlags and BitmapInfo.FLAG_NO_BADGE) == 0 + + override fun invalidateDrawable(who: Drawable) { + if (who === badge) { + invalidateSelf() + } + } + + override fun scheduleDrawable(who: Drawable, what: Runnable, time: Long) { + if (who === badge) { + scheduleSelf(what, time) + } + } + + override fun unscheduleDrawable(who: Drawable, what: Runnable) { + unscheduleSelf(what) + } + + open class FastBitmapConstantState(val bitmapInfo: BitmapInfo) : ConstantState() { + // These are initialized later so that subclasses don't need to + // pass everything in constructor + var mIsDisabled: Boolean = false + var mBadgeConstantState: ConstantState? = null + + @DrawableCreationFlags var mCreationFlags: Int = 0 + + constructor(bitmap: Bitmap, color: Int) : this(BitmapInfo.of(bitmap, color)) + + protected open fun createDrawable(): FastBitmapDrawable { + return FastBitmapDrawable(bitmapInfo) + } + + override fun newDrawable(): FastBitmapDrawable { + val drawable = createDrawable() + drawable.isDisabled = mIsDisabled + if (mBadgeConstantState != null) { + drawable.badge = mBadgeConstantState!!.newDrawable() + } + drawable.creationFlags = mCreationFlags + return drawable + } + + override fun getChangingConfigurations(): Int = 0 + } + + companion object { + private val ACCEL: Interpolator = AccelerateInterpolator() + private val DEACCEL: Interpolator = DecelerateInterpolator() + private val HOVER_EMPHASIZED_DECELERATE_INTERPOLATOR: Interpolator = + PathInterpolator(0.05f, 0.7f, 0.1f, 1.0f) + + @VisibleForTesting const val PRESSED_SCALE: Float = 1.1f + + @VisibleForTesting const val HOVERED_SCALE: Float = 1.1f + const val WHITE_SCRIM_ALPHA: Int = 138 + + private const val DISABLED_DESATURATION = 1f + private const val DISABLED_BRIGHTNESS = 0.5f + const val FULLY_OPAQUE: Int = 255 + + const val CLICK_FEEDBACK_DURATION: Int = 200 + const val HOVER_FEEDBACK_DURATION: Int = 300 + + // Animator and properties for the fast bitmap drawable's scale + @VisibleForTesting + @JvmField + val SCALE: FloatProperty = + object : FloatProperty("scale") { + override fun get(fastBitmapDrawable: FastBitmapDrawable): Float { + return fastBitmapDrawable.scale + } + + override fun setValue(fastBitmapDrawable: FastBitmapDrawable, value: Float) { + fastBitmapDrawable.scale = value + fastBitmapDrawable.invalidateSelf() + } + } + + @JvmStatic + @JvmOverloads + fun getDisabledColorFilter(disabledAlpha: Float = 1f): ColorFilter { + val tempBrightnessMatrix = ColorMatrix() + val tempFilterMatrix = ColorMatrix() + + tempFilterMatrix.setSaturation(1f - DISABLED_DESATURATION) + val scale = 1 - DISABLED_BRIGHTNESS + val brightnessI = (255 * DISABLED_BRIGHTNESS).toInt() + val mat = tempBrightnessMatrix.array + mat[0] = scale + mat[6] = scale + mat[12] = scale + mat[4] = brightnessI.toFloat() + mat[9] = brightnessI.toFloat() + mat[14] = brightnessI.toFloat() + mat[18] = disabledAlpha + tempFilterMatrix.preConcat(tempBrightnessMatrix) + return ColorMatrixColorFilter(tempFilterMatrix) + } + + @JvmStatic + fun getDisabledColor(color: Int): Int { + val avgComponent = (Color.red(color) + Color.green(color) + Color.blue(color)) / 3 + val scale = 1 - DISABLED_BRIGHTNESS + val brightnessI = (255 * DISABLED_BRIGHTNESS).toInt() + val component = min(Math.round(scale * avgComponent + brightnessI), FULLY_OPAQUE) + return Color.rgb(component, component, component) + } + + /** Sets the bounds for the badge drawable based on the main icon bounds */ + @JvmStatic + fun Drawable.setBadgeBounds(iconBounds: Rect) { + val size = BaseIconFactory.getBadgeSizeForIconSize(iconBounds.width()) + setBounds( + iconBounds.right - size, + iconBounds.bottom - size, + iconBounds.right, + iconBounds.bottom, + ) + } + } +} diff --git a/iconloaderlib/src/com/android/launcher3/icons/IconProvider.java b/iconloaderlib/src/com/android/launcher3/icons/IconProvider.java index 23eed3b..66057e6 100644 --- a/iconloaderlib/src/com/android/launcher3/icons/IconProvider.java +++ b/iconloaderlib/src/com/android/launcher3/icons/IconProvider.java @@ -53,6 +53,8 @@ import androidx.annotation.Nullable; import androidx.core.os.BuildCompat; +import com.android.launcher3.icons.cache.CachingLogic; +import com.android.launcher3.util.ComponentKey; import com.android.launcher3.util.SafeCloseable; import java.util.Calendar; @@ -155,7 +157,7 @@ public Drawable getIcon(PackageItemInfo info, ApplicationInfo appInfo, int iconD icon = ClockDrawableWrapper.forPackage(mContext, mClock.getPackageName(), iconDpi); } if (icon == null) { - icon = loadPackageIcon(info, appInfo, iconDpi); + icon = loadPackageIconWithFallback(info, appInfo, iconDpi); if (ATLEAST_T && icon instanceof AdaptiveIconDrawable && td != null) { AdaptiveIconDrawable aid = (AdaptiveIconDrawable) icon; if (aid.getMonochrome() == null) { @@ -171,36 +173,39 @@ protected ThemeData getThemeDataForPackage(String packageName) { return null; } - private Drawable loadPackageIcon(PackageItemInfo info, ApplicationInfo appInfo, int density) { + private Drawable loadPackageIconWithFallback( + PackageItemInfo info, ApplicationInfo appInfo, int density) { Drawable icon = null; if (BuildCompat.isAtLeastV() && info.isArchived) { // Icons for archived apps com from system service, let the default impl handle that icon = info.loadIcon(mContext.getPackageManager()); } if (icon == null && density != 0 && (info.icon != 0 || appInfo.icon != 0)) { - try { - final Resources resources = mContext.getPackageManager() - .getResourcesForApplication(appInfo); - // Try to load the package item icon first - if (info != appInfo && info.icon != 0) { - try { - icon = resources.getDrawableForDensity(info.icon, density); - } catch (Resources.NotFoundException exc) { } - } - if (icon == null && appInfo.icon != 0) { - // Load the fallback app icon - icon = loadAppInfoIcon(appInfo, resources, density); - } - } catch (NameNotFoundException | Resources.NotFoundException exc) { } + icon = loadPackageIcon(info, appInfo, density); } return icon != null ? icon : getFullResDefaultActivityIcon(density); } @Nullable - protected Drawable loadAppInfoIcon(ApplicationInfo info, Resources resources, int density) { + protected Drawable loadPackageIcon( + @NonNull PackageItemInfo info, @NonNull ApplicationInfo appInfo, int density) { try { - return resources.getDrawableForDensity(info.icon, density); - } catch (Resources.NotFoundException exc) { } + final Resources resources = mContext.getPackageManager() + .getResourcesForApplication(appInfo); + // Try to load the package item icon first + if (info != appInfo && info.icon != 0) { + try { + Drawable icon = resources.getDrawableForDensity(info.icon, density); + if (icon != null) return icon; + } catch (Resources.NotFoundException exc) { } + } + if (appInfo.icon != 0) { + // Load the fallback app icon + try { + return resources.getDrawableForDensity(appInfo.icon, density); + } catch (Resources.NotFoundException exc) { } + } + } catch (NameNotFoundException | Resources.NotFoundException exc) { } return null; } @@ -306,6 +311,12 @@ public SafeCloseable registerIconChangeListener(IconChangeListener listener, Han return new IconChangeReceiver(listener, handler); } + /** + * Notifies the provider when an icon is loaded from cache + */ + public void notifyIconLoaded( + @NonNull BitmapInfo icon, @NonNull ComponentKey key, @NonNull CachingLogic logic) { } + public static class ThemeData { final Resources mResources; diff --git a/iconloaderlib/src/com/android/launcher3/icons/MonochromeIconFactory.java b/iconloaderlib/src/com/android/launcher3/icons/MonochromeIconFactory.java index ae71236..e6ae124 100644 --- a/iconloaderlib/src/com/android/launcher3/icons/MonochromeIconFactory.java +++ b/iconloaderlib/src/com/android/launcher3/icons/MonochromeIconFactory.java @@ -100,12 +100,12 @@ private void drawDrawable(Drawable drawable) { * Creates a monochrome version of the provided drawable */ @WorkerThread - public Drawable wrap(AdaptiveIconDrawable icon, Path shapePath, Float iconScale) { + public Drawable wrap(AdaptiveIconDrawable icon, Path shapePath) { mFlatCanvas.drawColor(Color.BLACK); drawDrawable(icon.getBackground()); drawDrawable(icon.getForeground()); generateMono(); - return new ClippedMonoDrawable(this, shapePath, iconScale); + return new ClippedMonoDrawable(this, shapePath); } @WorkerThread diff --git a/iconloaderlib/src/com/android/launcher3/icons/PlaceHolderIconDrawable.java b/iconloaderlib/src/com/android/launcher3/icons/PlaceHolderIconDrawable.java index 00f1942..531c35a 100644 --- a/iconloaderlib/src/com/android/launcher3/icons/PlaceHolderIconDrawable.java +++ b/iconloaderlib/src/com/android/launcher3/icons/PlaceHolderIconDrawable.java @@ -42,7 +42,7 @@ public class PlaceHolderIconDrawable extends FastBitmapDrawable { public PlaceHolderIconDrawable(BitmapInfo info, Context context) { super(info); mProgressPath = getDefaultPath(); - mPaint.setColor(ColorUtils.compositeColors( + paint.setColor(ColorUtils.compositeColors( GraphicsUtils.getAttrColor(context, R.attr.loadingIconColor), info.color)); } @@ -62,13 +62,13 @@ protected void drawInternal(Canvas canvas, Rect bounds) { int saveCount = canvas.save(); canvas.translate(bounds.left, bounds.top); canvas.scale(bounds.width() / 100f, bounds.height() / 100f); - canvas.drawPath(mProgressPath, mPaint); + canvas.drawPath(mProgressPath, paint); canvas.restoreToCount(saveCount); } /** Updates this placeholder to {@code newIcon} with animation. */ public void animateIconUpdate(Drawable newIcon) { - int placeholderColor = mPaint.getColor(); + int placeholderColor = paint.getColor(); int originalAlpha = Color.alpha(placeholderColor); ValueAnimator iconUpdateAnimation = ValueAnimator.ofInt(originalAlpha, 0); diff --git a/iconloaderlib/src/com/android/launcher3/icons/ThemedBitmap.kt b/iconloaderlib/src/com/android/launcher3/icons/ThemedBitmap.kt index 6c937db..77b34ac 100644 --- a/iconloaderlib/src/com/android/launcher3/icons/ThemedBitmap.kt +++ b/iconloaderlib/src/com/android/launcher3/icons/ThemedBitmap.kt @@ -28,6 +28,18 @@ interface ThemedBitmap { fun newDrawable(info: BitmapInfo, context: Context): FastBitmapDrawable fun serialize(): ByteArray + + companion object { + + @JvmField + /** ThemedBitmap to be used when theming is not supported for a particular bitmap */ + val NOT_SUPPORTED = + object : ThemedBitmap { + override fun newDrawable(info: BitmapInfo, context: Context) = info.newIcon(context) + + override fun serialize() = ByteArray(0) + } + } } interface IconThemeController { @@ -46,8 +58,14 @@ interface IconThemeController { info: BitmapInfo, factory: BaseIconFactory, sourceHint: SourceHint, - ): ThemedBitmap? + ): ThemedBitmap + /** + * Creates an adaptive icon representation of the themed bitmap for various surface effects. The + * controller can return the [originalIcon] for using an un-themed icon for these effects or + * null to disable any surface effects in which can the static themed icon will be used without + * any additional effects. + */ fun createThemedAdaptiveIcon( context: Context, originalIcon: AdaptiveIconDrawable, diff --git a/iconloaderlib/src/com/android/launcher3/icons/cache/BaseIconCache.kt b/iconloaderlib/src/com/android/launcher3/icons/cache/BaseIconCache.kt index c305607..0e4544e 100644 --- a/iconloaderlib/src/com/android/launcher3/icons/cache/BaseIconCache.kt +++ b/iconloaderlib/src/com/android/launcher3/icons/cache/BaseIconCache.kt @@ -42,12 +42,15 @@ import android.util.SparseArray import androidx.annotation.VisibleForTesting import androidx.annotation.WorkerThread import com.android.launcher3.Flags +import com.android.systemui.shared.Flags.extendibleThemeManager import com.android.launcher3.icons.BaseIconFactory import com.android.launcher3.icons.BaseIconFactory.IconOptions import com.android.launcher3.icons.BitmapInfo +import com.android.launcher3.icons.BitmapInfo.Companion.LOW_RES_ICON import com.android.launcher3.icons.GraphicsUtils import com.android.launcher3.icons.IconProvider import com.android.launcher3.icons.SourceHint +import com.android.launcher3.icons.ThemedBitmap import com.android.launcher3.icons.cache.CacheLookupFlag.Companion.DEFAULT_LOOKUP_FLAG import com.android.launcher3.util.ComponentKey import com.android.launcher3.util.FlagOp @@ -223,9 +226,11 @@ constructor( } // Only add an entry in memory, if there was already something previously - if (cache[key] != null) { + val existingEntry = cache[key] + if (existingEntry != null) { val entry = CacheEntry() - entry.bitmap = bitmapInfo + entry.bitmap = + bitmapInfo.downSampleToLookupFlag(existingEntry.bitmap.matchingLookupFlag) entry.title = entryTitle entry.contentDescription = getUserBadgedLabel(entryTitle, user) cache[key] = entry @@ -292,7 +297,7 @@ constructor( obj, entry, cachingLogic, - lookupFlags.usePackageIcon(), + lookupFlags, /* usePackageTitle= */ true, componentName, user, @@ -311,7 +316,7 @@ constructor( obj: T?, entry: CacheEntry, cachingLogic: CachingLogic, - usePackageIcon: Boolean, + lookupFlag: CacheLookupFlag, usePackageTitle: Boolean, componentName: ComponentName, user: UserHandle, @@ -319,8 +324,9 @@ constructor( if (obj != null) { entry.bitmap = cachingLogic.loadIcon(context, this, obj) } else { - if (usePackageIcon) { - val packageEntry = getEntryForPackageLocked(componentName.packageName, user) + if (lookupFlag.usePackageIcon()) { + val packageEntry = + getEntryForPackageLocked(componentName.packageName, user, lookupFlag) if (DEBUG) { Log.d(TAG, "using package default icon for " + componentName.toShortString()) } @@ -331,6 +337,7 @@ constructor( entry.title = packageEntry.title } } + entry.bitmap = entry.bitmap.downSampleToLookupFlag(lookupFlag) } } @@ -442,8 +449,7 @@ constructor( // only keep the low resolution icon instead of the larger full-sized icon val iconInfo = appInfoCachingLogic.loadIcon(context, this, appInfo) entry.bitmap = - if (lookupFlags.useLowRes()) - BitmapInfo.of(BitmapInfo.LOW_RES_ICON, iconInfo.color) + if (lookupFlags.useLowRes()) BitmapInfo.of(LOW_RES_ICON, iconInfo.color) else iconInfo loadFallbackTitle(appInfo, entry, appInfoCachingLogic, user) @@ -516,7 +522,7 @@ constructor( // Set the alpha to be 255, so that we never have a wrong color entry.bitmap = BitmapInfo.of( - BitmapInfo.LOW_RES_ICON, + LOW_RES_ICON, GraphicsUtils.setColorAlphaBound(c.getInt(INDEX_COLOR), 255), ) c.getString(INDEX_TITLE).let { @@ -546,23 +552,29 @@ constructor( return false } - iconFactory.use { factory -> - val themeController = factory.themeController - val monoIconData = c.getBlob(INDEX_MONO_ICON) - if (themeController != null && monoIconData != null) { - entry.bitmap.themedBitmap = - themeController.decode( - data = monoIconData, - info = entry.bitmap, - factory = factory, - sourceHint = - SourceHint(cacheKey, logic, c.getString(INDEX_FRESHNESS_ID)), - ) + if (!extendibleThemeManager() || lookupFlags.hasThemeIcon()) { + // Always set a non-null theme bitmap if theming was requested + entry.bitmap.themedBitmap = ThemedBitmap.NOT_SUPPORTED + + iconFactory.use { factory -> + val themeController = factory.themeController + val monoIconData = c.getBlob(INDEX_MONO_ICON) + if (themeController != null && monoIconData != null) { + entry.bitmap.themedBitmap = + themeController.decode( + data = monoIconData, + info = entry.bitmap, + factory = factory, + sourceHint = + SourceHint(cacheKey, logic, c.getString(INDEX_FRESHNESS_ID)), + ) + } } } } entry.bitmap.flags = c.getInt(INDEX_FLAGS) entry.bitmap = entry.bitmap.withFlags(getUserFlagOpLocked(cacheKey.user)) + iconProvider.notifyIconLoaded(entry.bitmap, cacheKey, logic) return true } @@ -645,7 +657,7 @@ constructor( ComponentKey(ComponentName(packageName, packageName + EMPTY_CLASS_NAME), user) // Ensures themed bitmaps in the icon cache are invalidated - @JvmField val RELEASE_VERSION = if (Flags.forceMonochromeAppIcons()) 10 else 9 + @JvmField val RELEASE_VERSION = if (Flags.enableLauncherIconShapes()) 11 else 10 @JvmField val TABLE_NAME = "icons" @JvmField val COLUMN_ROWID = "rowid" @@ -662,12 +674,17 @@ constructor( val COLUMNS_LOW_RES = arrayOf(COLUMN_COMPONENT, COLUMN_LABEL, COLUMN_ICON_COLOR, COLUMN_FLAGS) + @JvmField + val COLUMNS_HIGH_RES_NO_THEME = + COLUMNS_LOW_RES.copyOf(COLUMNS_LOW_RES.size + 2).apply { + this[size - 1] = COLUMN_ICON + this[size - 2] = COLUMN_FRESHNESS_ID + } + @JvmField val COLUMNS_HIGH_RES = - COLUMNS_LOW_RES.copyOf(COLUMNS_LOW_RES.size + 3).apply { - this[size - 3] = COLUMN_ICON - this[size - 2] = COLUMN_MONO_ICON - this[size - 1] = COLUMN_FRESHNESS_ID + COLUMNS_HIGH_RES_NO_THEME.copyOf(COLUMNS_HIGH_RES_NO_THEME.size + 1).apply { + this[size - 1] = COLUMN_MONO_ICON } @JvmField val INDEX_TITLE = COLUMNS_HIGH_RES.indexOf(COLUMN_LABEL) @@ -679,6 +696,20 @@ constructor( @JvmStatic fun CacheLookupFlag.toLookupColumns() = - if (useLowRes()) COLUMNS_LOW_RES else COLUMNS_HIGH_RES + when { + useLowRes() -> COLUMNS_LOW_RES + extendibleThemeManager() && !hasThemeIcon() -> COLUMNS_HIGH_RES_NO_THEME + else -> COLUMNS_HIGH_RES + } + + @JvmStatic + protected fun BitmapInfo.downSampleToLookupFlag(flag: CacheLookupFlag) = + when { + !extendibleThemeManager() -> this + flag.useLowRes() -> BitmapInfo.of(LOW_RES_ICON, color) + !flag.hasThemeIcon() && themedBitmap != null -> + clone().apply { themedBitmap = null } + else -> this + } } } diff --git a/iconloaderlib/src/com/android/launcher3/icons/cache/CacheLookupFlag.kt b/iconloaderlib/src/com/android/launcher3/icons/cache/CacheLookupFlag.kt index 42fda24..9e56dbe 100644 --- a/iconloaderlib/src/com/android/launcher3/icons/cache/CacheLookupFlag.kt +++ b/iconloaderlib/src/com/android/launcher3/icons/cache/CacheLookupFlag.kt @@ -16,6 +16,7 @@ package com.android.launcher3.icons.cache import androidx.annotation.IntDef +import com.android.systemui.shared.Flags.extendibleThemeManager import kotlin.annotation.AnnotationRetention.SOURCE /** Flags to control cache lookup behavior */ @@ -45,18 +46,30 @@ data class CacheLookupFlag private constructor(@LookupFlag private val flag: Int fun withSkipAddToMemCache(skipAddToMemCache: Boolean = true) = updateMask(SKIP_ADD_TO_MEM_CACHE, skipAddToMemCache) + /** Entry will include theme icon. Note that theme icon is only loaded for high-res icons */ + fun hasThemeIcon() = hasFlag(LOAD_THEME_ICON) + + @JvmOverloads + fun withThemeIcon(addThemeIcon: Boolean = true) = updateMask(LOAD_THEME_ICON, addThemeIcon) + private fun hasFlag(@LookupFlag mask: Int) = flag.and(mask) != 0 private fun updateMask(@LookupFlag mask: Int, addMask: Boolean) = if (addMask) flagCache[flag.or(mask)] else flagCache[flag.and(mask.inv())] /** Returns `true` if this flag has less UI information then [other] */ - fun isVisuallyLessThan(other: CacheLookupFlag): Boolean { - return useLowRes() && !other.useLowRes() - } + fun isVisuallyLessThan(other: CacheLookupFlag) = + when { + useLowRes() && !other.useLowRes() -> true + extendibleThemeManager() && !hasThemeIcon() && other.hasThemeIcon() -> true + else -> false + } @Retention(SOURCE) - @IntDef(value = [USE_LOW_RES, USE_PACKAGE_ICON, SKIP_ADD_TO_MEM_CACHE], flag = true) + @IntDef( + value = [USE_LOW_RES, USE_PACKAGE_ICON, SKIP_ADD_TO_MEM_CACHE, LOAD_THEME_ICON], + flag = true, + ) /** Various options to control cache lookup */ private annotation class LookupFlag @@ -64,8 +77,9 @@ data class CacheLookupFlag private constructor(@LookupFlag private val flag: Int private const val USE_LOW_RES: Int = 1 shl 0 private const val USE_PACKAGE_ICON: Int = 1 shl 1 private const val SKIP_ADD_TO_MEM_CACHE: Int = 1 shl 2 + private const val LOAD_THEME_ICON: Int = 1 shl 3 - private val flagCache = Array(8) { CacheLookupFlag(it) } + private val flagCache = Array(1 shl 4) { CacheLookupFlag(it) } @JvmField val DEFAULT_LOOKUP_FLAG = CacheLookupFlag(0) } diff --git a/iconloaderlib/src/com/android/launcher3/icons/cache/LauncherActivityCachingLogic.kt b/iconloaderlib/src/com/android/launcher3/icons/cache/LauncherActivityCachingLogic.kt index 8457628..c7dd470 100644 --- a/iconloaderlib/src/com/android/launcher3/icons/cache/LauncherActivityCachingLogic.kt +++ b/iconloaderlib/src/com/android/launcher3/icons/cache/LauncherActivityCachingLogic.kt @@ -22,12 +22,13 @@ import android.content.pm.LauncherActivityInfo import android.os.Build.VERSION import android.os.UserHandle import android.util.Log -import app.lawnchair.icons.getCustomAppNameForComponent import com.android.launcher3.Flags.useNewIconForArchivedApps import com.android.launcher3.icons.BaseIconFactory.IconOptions import com.android.launcher3.icons.BitmapInfo import com.android.launcher3.icons.IconProvider +import app.lawnchair.icons.getCustomAppNameForComponent + object LauncherActivityCachingLogic : CachingLogic { const val TAG = "LauncherActivityCachingLogic" diff --git a/iconloaderlib/src/com/android/launcher3/icons/mono/MonoIconThemeController.kt b/iconloaderlib/src/com/android/launcher3/icons/mono/MonoIconThemeController.kt index 411d714..8af8fd9 100644 --- a/iconloaderlib/src/com/android/launcher3/icons/mono/MonoIconThemeController.kt +++ b/iconloaderlib/src/com/android/launcher3/icons/mono/MonoIconThemeController.kt @@ -46,7 +46,8 @@ import java.nio.ByteBuffer @TargetApi(Build.VERSION_CODES.TIRAMISU) class MonoIconThemeController( - private val colorProvider: (Context) -> IntArray = ThemedIconDrawable.Companion::getColors + private val shouldForceThemeIcon: Boolean = false, + private val colorProvider: (Context) -> IntArray = ThemedIconDrawable.Companion::getColors, ) : IconThemeController { override val themeID = "with-theme" @@ -63,9 +64,8 @@ class MonoIconThemeController( icon, info, factory.getShapePath(icon, Rect(0, 0, info.icon.width, info.icon.height)), - factory.iconScale, sourceHint?.isFileDrawable ?: false, - factory.shouldForceThemeIcon(), + shouldForceThemeIcon, ) if (mono != null) { return MonoThemedBitmap( @@ -86,16 +86,15 @@ class MonoIconThemeController( base: AdaptiveIconDrawable, info: BitmapInfo, shapePath: Path, - iconScale: Float, isFileDrawable: Boolean, shouldForceThemeIcon: Boolean, ): Drawable? { val mono = base.monochrome if (mono != null) { - return ClippedMonoDrawable(mono, shapePath, iconScale) + return ClippedMonoDrawable(mono, shapePath) } if (Flags.forceMonochromeAppIcons() && shouldForceThemeIcon && !isFileDrawable) { - return MonochromeIconFactory(info.icon.width).wrap(base, shapePath, iconScale) + return MonochromeIconFactory(info.icon.width).wrap(base, shapePath) } return null } @@ -105,9 +104,9 @@ class MonoIconThemeController( info: BitmapInfo, factory: BaseIconFactory, sourceHint: SourceHint, - ): ThemedBitmap? { + ): ThemedBitmap { val icon = info.icon - if (data.size != icon.height * icon.width) return null + if (data.size != icon.height * icon.width) return ThemedBitmap.NOT_SUPPORTED var monoBitmap = Bitmap.createBitmap(icon.width, icon.height, ALPHA_8) monoBitmap.copyPixelsFromBuffer(ByteBuffer.wrap(data)) @@ -124,7 +123,7 @@ class MonoIconThemeController( context: Context, originalIcon: AdaptiveIconDrawable, info: BitmapInfo?, - ): AdaptiveIconDrawable? { + ): AdaptiveIconDrawable { val colors = colorProvider(context) originalIcon.mutate() var monoDrawable = originalIcon.monochrome?.apply { setTint(colors[1]) } @@ -148,13 +147,11 @@ class MonoIconThemeController( } return monoDrawable?.let { AdaptiveIconDrawable(ColorDrawable(colors[0]), it) } + ?: originalIcon } - class ClippedMonoDrawable( - base: Drawable?, - private val shapePath: Path, - private val iconScale: Float, - ) : InsetDrawable(base, -AdaptiveIconDrawable.getExtraInsetFraction()) { + class ClippedMonoDrawable(base: Drawable?, private val shapePath: Path) : + InsetDrawable(base, -AdaptiveIconDrawable.getExtraInsetFraction()) { // TODO(b/399666950): remove this after launcher icon shapes is fully enabled private val mCrop = AdaptiveIconDrawable(ColorDrawable(Color.BLACK), null) @@ -163,7 +160,6 @@ class MonoIconThemeController( val saveCount = canvas.save() if (Flags.enableLauncherIconShapes()) { canvas.clipPath(shapePath) - canvas.scale(iconScale, iconScale, bounds.width() / 2f, bounds.height() / 2f) } else { canvas.clipPath(mCrop.iconMask) } diff --git a/iconloaderlib/src/com/android/launcher3/icons/mono/ThemedIconDrawable.kt b/iconloaderlib/src/com/android/launcher3/icons/mono/ThemedIconDrawable.kt index a0cabf1..4ed5017 100644 --- a/iconloaderlib/src/com/android/launcher3/icons/mono/ThemedIconDrawable.kt +++ b/iconloaderlib/src/com/android/launcher3/icons/mono/ThemedIconDrawable.kt @@ -29,14 +29,15 @@ import android.graphics.PorterDuffColorFilter import android.graphics.Rect import android.os.Build import androidx.core.graphics.ColorUtils -import app.lawnchair.icons.shouldTransparentBGIcons import com.android.launcher3.icons.BitmapInfo import com.android.launcher3.icons.FastBitmapDrawable import com.android.launcher3.icons.R +import app.lawnchair.icons.shouldTransparentBGIcons + /** Class to handle monochrome themed app icons */ class ThemedIconDrawable(constantState: ThemedConstantState) : - FastBitmapDrawable(constantState.getBitmapInfo()) { + FastBitmapDrawable(constantState.bitmapInfo) { private val colorFg = constantState.colorFg private val colorBg = constantState.colorBg @@ -66,10 +67,10 @@ class ThemedIconDrawable(constantState: ThemedConstantState) : override fun updateFilter() { super.updateFilter() - val alpha = if (mIsDisabled) (mDisabledAlpha * FULLY_OPAQUE).toInt() else FULLY_OPAQUE + val alpha = if (isDisabled) (disabledAlpha * FULLY_OPAQUE).toInt() else FULLY_OPAQUE mBgPaint.alpha = alpha mBgPaint.setColorFilter( - if (mIsDisabled) if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + if (isDisabled) if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { BlendModeColorFilter(getDisabledColor(colorBg), SRC_IN) } else { PorterDuffColorFilter(getDisabledColor(colorBg), PorterDuff.Mode.SRC_IN) @@ -78,7 +79,7 @@ class ThemedIconDrawable(constantState: ThemedConstantState) : monoPaint.alpha = alpha monoPaint.setColorFilter( - if (mIsDisabled) if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + if (isDisabled) if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { BlendModeColorFilter( getDisabledColor(colorFg), SRC_IN, @@ -92,7 +93,7 @@ class ThemedIconDrawable(constantState: ThemedConstantState) : override fun isThemed() = true override fun newConstantState() = - ThemedConstantState(mBitmapInfo, monoIcon, bgBitmap, colorBg, colorFg) + ThemedConstantState(bitmapInfo, monoIcon, bgBitmap, colorBg, colorFg) override fun getIconColor() = colorFg @@ -105,8 +106,6 @@ class ThemedIconDrawable(constantState: ThemedConstantState) : ) : FastBitmapConstantState(bitmapInfo) { public override fun createDrawable() = ThemedIconDrawable(this) - - fun getBitmapInfo(): BitmapInfo = mBitmapInfo } companion object { From bb5f5584fac4e74c46ac41210bfdeebeec04943d Mon Sep 17 00:00:00 2001 From: Pun Butrach Date: Sun, 16 Nov 2025 17:16:14 +0700 Subject: [PATCH 05/30] feat: searchuilib Android 16 QPR1 --- searchuilib/Android.bp | 28 ---- searchuilib/build.gradle | 10 -- .../com/android/app/search/LayoutType.java | 155 ------------------ .../com/android/app/search/ResultType.java | 96 ----------- 4 files changed, 289 deletions(-) delete mode 100644 searchuilib/Android.bp delete mode 100644 searchuilib/build.gradle delete mode 100644 searchuilib/src/com/android/app/search/LayoutType.java delete mode 100644 searchuilib/src/com/android/app/search/ResultType.java diff --git a/searchuilib/Android.bp b/searchuilib/Android.bp deleted file mode 100644 index 2b25616..0000000 --- a/searchuilib/Android.bp +++ /dev/null @@ -1,28 +0,0 @@ -// Copyright (C) 2020 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 { - default_applicable_licenses: ["Android-Apache-2.0"], -} - -android_library { - name: "search_ui", - - sdk_version: "current", - min_sdk_version: "26", - - srcs: [ - "src/**/*.java", - ], -} diff --git a/searchuilib/build.gradle b/searchuilib/build.gradle deleted file mode 100644 index 76551cf..0000000 --- a/searchuilib/build.gradle +++ /dev/null @@ -1,10 +0,0 @@ -apply plugin: 'com.android.library' - -android { - namespace "com.android.app.search" - sourceSets { - main { - java.srcDirs = ['src'] - } - } -} diff --git a/searchuilib/src/com/android/app/search/LayoutType.java b/searchuilib/src/com/android/app/search/LayoutType.java deleted file mode 100644 index 1adb0ea..0000000 --- a/searchuilib/src/com/android/app/search/LayoutType.java +++ /dev/null @@ -1,155 +0,0 @@ -/* - * Copyright (C) 2020 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.app.search; - -import androidx.annotation.StringDef; - -import java.lang.annotation.Retention; -import java.lang.annotation.RetentionPolicy; - -/** - * Constants to be used with {@link SearchTarget}. - */ -public class LayoutType { - - @StringDef(value = { - ICON_SINGLE_VERTICAL_TEXT, - ICON_HORIZONTAL_TEXT, - HORIZONTAL_MEDIUM_TEXT, - EXTRA_TALL_ICON_ROW, - SMALL_ICON_HORIZONTAL_TEXT, - SMALL_ICON_HORIZONTAL_TEXT_THUMBNAIL, - ICON_CONTAINER, - THUMBNAIL_CONTAINER, - BIG_ICON_MEDIUM_HEIGHT_ROW, - THUMBNAIL, - ICON_SLICE, - WIDGET_PREVIEW, - WIDGET_LIVE, - PEOPLE_TILE, - TEXT_HEADER, - DIVIDER, - EMPTY_DIVIDER, - CALCULATOR, - SECTION_HEADER, - TALL_CARD_WITH_IMAGE_NO_ICON, - TEXT_HEADER_ROW, - QS_TILE, - PLACEHOLDER, - RICHANSWER_PLACEHOLDER, - EMPTY_STATE, - SEARCH_SETTINGS, - }) - @Retention(RetentionPolicy.SOURCE) - public @interface SearchLayoutType {} - - // ------ - // | icon | - // ------ - // text - public static final String ICON_SINGLE_VERTICAL_TEXT = "icon"; - - // Below three layouts (to be deprecated) and two layouts render - // {@link SearchTarget}s in following layout. - // ------ ------ ------ - // | | title |(opt)| |(opt)| - // | icon | subtitle (optional) | icon| | icon| - // ------ ------ ------ - @Deprecated - public static final String ICON_SINGLE_HORIZONTAL_TEXT = "icon_text_row"; - @Deprecated - public static final String ICON_DOUBLE_HORIZONTAL_TEXT = "icon_texts_row"; - @Deprecated - public static final String ICON_DOUBLE_HORIZONTAL_TEXT_BUTTON = "icon_texts_button"; - - // will replace ICON_DOUBLE_* ICON_SINGLE_* layouts - public static final String ICON_HORIZONTAL_TEXT = "icon_row"; - public static final String HORIZONTAL_MEDIUM_TEXT = "icon_row_medium"; - public static final String EXTRA_TALL_ICON_ROW = "extra_tall_icon_row"; - public static final String SMALL_ICON_HORIZONTAL_TEXT = "short_icon_row"; - public static final String SMALL_ICON_HORIZONTAL_TEXT_THUMBNAIL = "short_icon_row_thumbnail"; - - // This layout contains a series of icon results (currently up to 4 per row). - // The container does not support stretching for its children, and can only contain - // {@link #ICON_SINGLE_VERTICAL_TEXT} layout types. - public static final String ICON_CONTAINER = "icon_container"; - - // This layout contains a series of thumbnails (currently up to 3 per row). - // The container supports stretching for its children, and can only contain {@link #THUMBNAIL} - // layout types. - public static final String THUMBNAIL_CONTAINER = "thumbnail_container"; - - // This layout creates a container for people grouping - // Only available above version code 2 - public static final String BIG_ICON_MEDIUM_HEIGHT_ROW = "big_icon_medium_row"; - - // This layout creates square thumbnail image (currently 3 column) - public static final String THUMBNAIL = "thumbnail"; - - // This layout contains an icon and slice - public static final String ICON_SLICE = "slice"; - - // Widget bitmap preview - public static final String WIDGET_PREVIEW = "widget_preview"; - - // Live widget search result - public static final String WIDGET_LIVE = "widget_live"; - - // Layout type used to display people tiles using shortcut info - public static final String PEOPLE_TILE = "people_tile"; - - // Deprecated - // text based header to group various layouts in low confidence section of the results. - public static final String TEXT_HEADER = "header"; - - // horizontal bar to be inserted between fallback search results and low confidence section - public static final String EMPTY_DIVIDER = "empty_divider"; - - @Deprecated(since = "Use EMPTY_DIVIDER instead") - public static final String DIVIDER = EMPTY_DIVIDER; - - // layout representing quick calculations - public static final String CALCULATOR = "calculator"; - - // From version code 4, if TEXT_HEADER_ROW is used, no need to insert this on-device - // section header. - public static final String SECTION_HEADER = "section_header"; - - // layout for a tall card with header and image, and no icon. - public static final String TALL_CARD_WITH_IMAGE_NO_ICON = "tall_card_with_image_no_icon"; - - // Layout for a text header - // Available for SearchUiManager proxy service to use above version code 3 - public static final String TEXT_HEADER_ROW = "text_header_row"; - - // Layout for a quick settings tile - public static final String QS_TILE = "qs_tile"; - - // Placeholder for web suggest. - public static final String PLACEHOLDER = "placeholder"; - - // Placeholder for rich answer cards. - // Only available on or above version code 3. - public static final String RICHANSWER_PLACEHOLDER = "richanswer_placeholder"; - - - // layout representing the empty, no query state - public static final String EMPTY_STATE = "empty_state"; - - // layout representing search settings - public static final String SEARCH_SETTINGS = "launcher_settings"; -} diff --git a/searchuilib/src/com/android/app/search/ResultType.java b/searchuilib/src/com/android/app/search/ResultType.java deleted file mode 100644 index 5340220..0000000 --- a/searchuilib/src/com/android/app/search/ResultType.java +++ /dev/null @@ -1,96 +0,0 @@ -/* - * Copyright (C) 2020 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.app.search; - -/** - * Constants to be used with {@link android.app.search.SearchContext} and - * {@link android.app.search.SearchTarget}. - * - * Note, a result type could be a of two types. - * For example, unpublished settings result type could be in slices: - * resultType = SETTING | SLICE - */ -public class ResultType { - - // published corpus by 3rd party app, supported by SystemService - public static final int APPLICATION = 1 << 0; - public static final int SHORTCUT = 1 << 1; - public static final int SLICE = 1 << 6; - public static final int WIDGETS = 1 << 7; - - // Not extracted from any of the SystemService - public static final int PEOPLE = 1 << 2; - public static final int ACTION = 1 << 3; - public static final int SETTING = 1 << 4; - public static final int IMAGE = 1 << 5; - - @Deprecated(since = "Use IMAGE") - public static final int SCREENSHOT = IMAGE; - - public static final int PLAY = 1 << 8; - public static final int SUGGEST = 1 << 9; - public static final int ASSISTANT = 1 << 10; - public static final int CHROMETAB = 1 << 11; - public static final int NAVVYSITE = 1 << 12; - public static final int TIPS = 1 << 13; - public static final int PEOPLE_TILE = 1 << 14; - public static final int LEGACY_SHORTCUT = 1 << 15; - public static final int MEMORY = 1 << 16; - public static final int WEB_SUGGEST = 1 << 17; - public static final int NO_FULFILLMENT = 1 << 18; - public static final int EDUCARD = 1 << 19; - public static final int SYSTEM_POINTER = 1 << 20; - public static final int VIDEO = 1 << 21; - - public static final int PUBLIC_DATA_TYPE = APPLICATION | SETTING | PLAY | WEB_SUGGEST; - public static final int PRIMITIVE_TYPE = APPLICATION | SLICE | SHORTCUT | WIDGETS | ACTION | - LEGACY_SHORTCUT; - public static final int CORPUS_TYPE = - PEOPLE | SETTING | IMAGE | PLAY | SUGGEST | ASSISTANT | CHROMETAB | NAVVYSITE | TIPS - | PEOPLE_TILE | MEMORY | WEB_SUGGEST | VIDEO; - public static final int RANK_TYPE = SYSTEM_POINTER; - public static final int UI_TYPE = EDUCARD | NO_FULFILLMENT; - - public static boolean isSlice(int resultType) { - return (resultType & SLICE) != 0; - } - - public static boolean isSystemPointer(int resultType) { - return (resultType & SYSTEM_POINTER) != 0; - } - - /** - * Returns result type integer where only {@code #CORPUS_TYPE} bit will turned on. - */ - public static int getCorpusType(int resultType) { - return (resultType & CORPUS_TYPE); - } - - /** - * Returns result type integer where only {@code #PRIMITIVE_TYPE} bit will be turned on. - */ - public static int getPrimitiveType(int resultType) { - return (resultType & PRIMITIVE_TYPE); - } - - /** - * Returns whether the given result type is privacy safe or not. - */ - public static boolean isPrivacySafe(int resultType) { - return (resultType & PUBLIC_DATA_TYPE) != 0; - } -} From c7ff98fc02b280a677cb04f070a25cd7c28c7db4 Mon Sep 17 00:00:00 2001 From: Pun Butrach Date: Sun, 16 Nov 2025 17:19:52 +0700 Subject: [PATCH 06/30] docs: Remove searchuilib from README.md --- README.md | 1 - 1 file changed, 1 deletion(-) diff --git a/README.md b/README.md index 5797480..00535d5 100644 --- a/README.md +++ b/README.md @@ -7,4 +7,3 @@ A brief explanation of what each library does: * `contextualeducationlib`: Store "education" type * `iconloaderlib`: Handling all of Launcher3 and Lawnchair icons * `msdllib`: Multi-Sensory-Design-Language, handling all new vibrations in Launcher3 Android 16 -* `searchuilib`: Store search-related layout type From 2297e189faf1bf16ee1fa2467f63e343779b4ea3 Mon Sep 17 00:00:00 2001 From: Pun Butrach Date: Sun, 16 Nov 2025 19:20:20 +0700 Subject: [PATCH 07/30] fix: Duplicated Iconloaderlib file --- .../launcher3/icons/BaseIconFactory.java | 2 +- .../android/launcher3/icons/BitmapInfo.java | 268 ---------- .../launcher3/icons/FastBitmapDrawable.java | 471 ------------------ 3 files changed, 1 insertion(+), 740 deletions(-) delete mode 100644 iconloaderlib/src/com/android/launcher3/icons/BitmapInfo.java delete mode 100644 iconloaderlib/src/com/android/launcher3/icons/FastBitmapDrawable.java diff --git a/iconloaderlib/src/com/android/launcher3/icons/BaseIconFactory.java b/iconloaderlib/src/com/android/launcher3/icons/BaseIconFactory.java index d48ff18..17fa5e8 100644 --- a/iconloaderlib/src/com/android/launcher3/icons/BaseIconFactory.java +++ b/iconloaderlib/src/com/android/launcher3/icons/BaseIconFactory.java @@ -60,7 +60,7 @@ */ public class BaseIconFactory implements AutoCloseable { - private static final int DEFAULT_WRAPPER_BACKGROUND = Color.WHITE; + public static final int DEFAULT_WRAPPER_BACKGROUND = Color.WHITE; private static final float LEGACY_ICON_SCALE = .7f * (1f / (1 + 2 * getExtraInsetFraction())); public static final int MODE_DEFAULT = 0; diff --git a/iconloaderlib/src/com/android/launcher3/icons/BitmapInfo.java b/iconloaderlib/src/com/android/launcher3/icons/BitmapInfo.java deleted file mode 100644 index 62ca2ed..0000000 --- a/iconloaderlib/src/com/android/launcher3/icons/BitmapInfo.java +++ /dev/null @@ -1,268 +0,0 @@ -/* - * Copyright (C) 2017 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.launcher3.icons; - -import static com.android.launcher3.icons.cache.CacheLookupFlag.DEFAULT_LOOKUP_FLAG; - -import android.content.Context; -import android.graphics.Bitmap; -import android.graphics.Bitmap.Config; -import android.graphics.Canvas; -import android.graphics.Path; -import android.graphics.drawable.Drawable; - -import androidx.annotation.IntDef; -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; - -import com.android.launcher3.icons.cache.CacheLookupFlag; -import com.android.launcher3.util.FlagOp; - -public class BitmapInfo { - - public static final int FLAG_WORK = 1 << 0; - public static final int FLAG_INSTANT = 1 << 1; - public static final int FLAG_CLONE = 1 << 2; - public static final int FLAG_PRIVATE = 1 << 3; - @IntDef(flag = true, value = { - FLAG_WORK, - FLAG_INSTANT, - FLAG_CLONE, - FLAG_PRIVATE - }) - @interface BitmapInfoFlags {} - - public static final int FLAG_THEMED = 1 << 0; - public static final int FLAG_NO_BADGE = 1 << 1; - public static final int FLAG_SKIP_USER_BADGE = 1 << 2; - @IntDef(flag = true, value = { - FLAG_THEMED, - FLAG_NO_BADGE, - FLAG_SKIP_USER_BADGE, - }) - public @interface DrawableCreationFlags {} - - public static final Bitmap LOW_RES_ICON = Bitmap.createBitmap(1, 1, Config.ALPHA_8); - public static final BitmapInfo LOW_RES_INFO = fromBitmap(LOW_RES_ICON); - - public static final String TAG = "BitmapInfo"; - - @NonNull - public final Bitmap icon; - public final int color; - - @Nullable - private ThemedBitmap mThemedBitmap; - - public @BitmapInfoFlags int flags; - - // b/377618519: These are saved to debug why work badges sometimes don't show up on work apps - public @DrawableCreationFlags int creationFlags; - - private BitmapInfo badgeInfo; - - public BitmapInfo(@NonNull Bitmap icon, int color) { - this.icon = icon; - this.color = color; - } - - public BitmapInfo withBadgeInfo(BitmapInfo badgeInfo) { - BitmapInfo result = clone(); - result.badgeInfo = badgeInfo; - return result; - } - - /** - * Returns a bitmapInfo with the flagOP applied - */ - public BitmapInfo withFlags(@NonNull FlagOp op) { - if (op == FlagOp.NO_OP) { - return this; - } - BitmapInfo result = clone(); - result.flags = op.apply(result.flags); - return result; - } - - protected BitmapInfo copyInternalsTo(BitmapInfo target) { - target.mThemedBitmap = mThemedBitmap; - target.flags = flags; - target.badgeInfo = badgeInfo; - return target; - } - - @Override - public BitmapInfo clone() { - return copyInternalsTo(new BitmapInfo(icon, color)); - } - - public void setThemedBitmap(@Nullable ThemedBitmap themedBitmap) { - mThemedBitmap = themedBitmap; - } - - @Nullable - public ThemedBitmap getThemedBitmap() { - return mThemedBitmap; - } - - /** - * Ideally icon should not be null, except in cases when generating hardware bitmap failed - */ - public final boolean isNullOrLowRes() { - return icon == null || icon == LOW_RES_ICON; - } - - public final boolean isLowRes() { - return LOW_RES_ICON == icon; - } - - /** - * Returns the lookup flag to match this current state of this info - */ - public CacheLookupFlag getMatchingLookupFlag() { - return DEFAULT_LOOKUP_FLAG.withUseLowRes(isLowRes()); - } - - /** - * BitmapInfo can be stored on disk or other persistent storage - */ - public boolean canPersist() { - return !isNullOrLowRes(); - } - - /** - * Creates a drawable for the provided BitmapInfo - */ - public FastBitmapDrawable newIcon(Context context) { - return newIcon(context, 0); - } - - /** - * Creates a drawable for the provided BitmapInfo - */ - public FastBitmapDrawable newIcon(Context context, @DrawableCreationFlags int creationFlags) { - return newIcon(context, creationFlags, null); - } - - /** - * Creates a drawable for the provided BitmapInfo - * - * @param context Context - * @param creationFlags Flags for creating the FastBitmapDrawable - * @param badgeShape Optional Path for masking icon badges to a shape. Should be 100x100. - * @return FastBitmapDrawable - */ - public FastBitmapDrawable newIcon(Context context, @DrawableCreationFlags int creationFlags, - @Nullable Path badgeShape) { - FastBitmapDrawable drawable; - if (isLowRes()) { - drawable = new PlaceHolderIconDrawable(this, context); - } else if ((creationFlags & FLAG_THEMED) != 0 && mThemedBitmap != null) { - drawable = mThemedBitmap.newDrawable(this, context); - } else { - drawable = new FastBitmapDrawable(this); - } - applyFlags(context, drawable, creationFlags, badgeShape); - return drawable; - } - - protected void applyFlags(Context context, FastBitmapDrawable drawable, - @DrawableCreationFlags int creationFlags, @Nullable Path badgeShape) { - this.creationFlags = creationFlags; - drawable.mDisabledAlpha = GraphicsUtils.getFloat(context, R.attr.disabledIconAlpha, 1f); - drawable.mCreationFlags = creationFlags; - if ((creationFlags & FLAG_NO_BADGE) == 0) { - Drawable badge = getBadgeDrawable(context, (creationFlags & FLAG_THEMED) != 0, - (creationFlags & FLAG_SKIP_USER_BADGE) != 0, badgeShape); - if (badge != null) { - drawable.setBadge(badge); - } - } - } - - /** - * Gets Badge drawable based on current flags - * @param context Context - * @param isThemed If Drawable is themed. - * @param badgeShape Optional Path to mask badges to a shape. Should be 100x100. - * @return Drawable for the badge. - */ - public Drawable getBadgeDrawable(Context context, boolean isThemed, @Nullable Path badgeShape) { - return getBadgeDrawable(context, isThemed, false, badgeShape); - } - - - /** - * Creates a Drawable for an icon badge for this BitmapInfo - * @param context Context - * @param isThemed If the drawable is themed. - * @param skipUserBadge If should skip User Profile badging. - * @param badgeShape Optional Path to mask badge Drawable to a shape. Should be 100x100. - * @return Drawable for an icon Badge. - */ - @Nullable - private Drawable getBadgeDrawable(Context context, boolean isThemed, boolean skipUserBadge, - @Nullable Path badgeShape) { - if (badgeInfo != null) { - int creationFlag = isThemed ? FLAG_THEMED : 0; - if (skipUserBadge) { - creationFlag |= FLAG_SKIP_USER_BADGE; - } - return badgeInfo.newIcon(context, creationFlag, badgeShape); - } - if (skipUserBadge) { - return null; - } else if ((flags & FLAG_INSTANT) != 0) { - return new UserBadgeDrawable(context, R.drawable.ic_instant_app_badge, - R.color.badge_tint_instant, isThemed, badgeShape); - } else if ((flags & FLAG_WORK) != 0) { - return new UserBadgeDrawable(context, R.drawable.ic_work_app_badge, - R.color.badge_tint_work, isThemed, badgeShape); - } else if ((flags & FLAG_CLONE) != 0) { - return new UserBadgeDrawable(context, R.drawable.ic_clone_app_badge, - R.color.badge_tint_clone, isThemed, badgeShape); - } else if ((flags & FLAG_PRIVATE) != 0) { - return new UserBadgeDrawable(context, R.drawable.ic_private_profile_app_badge, - R.color.badge_tint_private, isThemed, badgeShape); - } - return null; - } - - public static BitmapInfo fromBitmap(@NonNull Bitmap bitmap) { - return of(bitmap, 0); - } - - public static BitmapInfo of(@NonNull Bitmap bitmap, int color) { - return new BitmapInfo(bitmap, color); - } - - /** - * Interface to be implemented by drawables to provide a custom BitmapInfo - */ - public interface Extender { - - /** - * Called for creating a custom BitmapInfo - */ - BitmapInfo getExtendedInfo(Bitmap bitmap, int color, - BaseIconFactory iconFactory, float normalizationScale); - - /** - * Called to draw the UI independent of any runtime configurations like time or theme - */ - void drawForPersistence(Canvas canvas); - } -} diff --git a/iconloaderlib/src/com/android/launcher3/icons/FastBitmapDrawable.java b/iconloaderlib/src/com/android/launcher3/icons/FastBitmapDrawable.java deleted file mode 100644 index f6ad4d1..0000000 --- a/iconloaderlib/src/com/android/launcher3/icons/FastBitmapDrawable.java +++ /dev/null @@ -1,471 +0,0 @@ -/* - * Copyright (C) 2008 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.launcher3.icons; - -import static com.android.launcher3.icons.BaseIconFactory.getBadgeSizeForIconSize; -import static com.android.launcher3.icons.BitmapInfo.FLAG_NO_BADGE; -import static com.android.launcher3.icons.BitmapInfo.FLAG_THEMED; -import static com.android.launcher3.icons.GraphicsUtils.setColorAlphaBound; - -import android.animation.ObjectAnimator; -import android.graphics.Bitmap; -import android.graphics.Canvas; -import android.graphics.Color; -import android.graphics.ColorFilter; -import android.graphics.ColorMatrix; -import android.graphics.ColorMatrixColorFilter; -import android.graphics.Paint; -import android.graphics.PixelFormat; -import android.graphics.Rect; -import android.graphics.drawable.Drawable; -import android.util.FloatProperty; -import android.view.animation.AccelerateInterpolator; -import android.view.animation.DecelerateInterpolator; -import android.view.animation.Interpolator; -import android.view.animation.PathInterpolator; - -import androidx.annotation.Nullable; -import androidx.annotation.VisibleForTesting; -import androidx.core.graphics.ColorUtils; - -import com.android.launcher3.icons.BitmapInfo.DrawableCreationFlags; - -public class FastBitmapDrawable extends Drawable implements Drawable.Callback { - - private static final Interpolator ACCEL = new AccelerateInterpolator(); - private static final Interpolator DEACCEL = new DecelerateInterpolator(); - private static final Interpolator HOVER_EMPHASIZED_DECELERATE_INTERPOLATOR = - new PathInterpolator(0.05f, 0.7f, 0.1f, 1.0f); - - @VisibleForTesting protected static final float PRESSED_SCALE = 1.1f; - @VisibleForTesting protected static final float HOVERED_SCALE = 1.1f; - public static final int WHITE_SCRIM_ALPHA = 138; - - private static final float DISABLED_DESATURATION = 1f; - private static final float DISABLED_BRIGHTNESS = 0.5f; - protected static final int FULLY_OPAQUE = 255; - - public static final int CLICK_FEEDBACK_DURATION = 200; - public static final int HOVER_FEEDBACK_DURATION = 300; - - private static boolean sFlagHoverEnabled = false; - - protected final Paint mPaint = new Paint(Paint.FILTER_BITMAP_FLAG | Paint.ANTI_ALIAS_FLAG); - public final BitmapInfo mBitmapInfo; - - @Nullable private ColorFilter mColorFilter; - - @VisibleForTesting protected boolean mIsPressed; - @VisibleForTesting protected boolean mIsHovered; - protected boolean mIsDisabled; - protected float mDisabledAlpha = 1f; - - @DrawableCreationFlags int mCreationFlags = 0; - - // Animator and properties for the fast bitmap drawable's scale - @VisibleForTesting protected static final FloatProperty SCALE - = new FloatProperty("scale") { - @Override - public Float get(FastBitmapDrawable fastBitmapDrawable) { - return fastBitmapDrawable.mScale; - } - - @Override - public void setValue(FastBitmapDrawable fastBitmapDrawable, float value) { - fastBitmapDrawable.mScale = value; - fastBitmapDrawable.invalidateSelf(); - } - }; - @VisibleForTesting protected ObjectAnimator mScaleAnimation; - private float mScale = 1; - private int mAlpha = 255; - - private Drawable mBadge; - - private boolean mHoverScaleEnabledForDisplay = true; - - protected FastBitmapDrawable(Bitmap b, int iconColor) { - this(BitmapInfo.of(b, iconColor)); - } - - public FastBitmapDrawable(Bitmap b) { - this(BitmapInfo.fromBitmap(b)); - } - - public FastBitmapDrawable(BitmapInfo info) { - mBitmapInfo = info; - setFilterBitmap(true); - } - - /** - * Returns true if the drawable points to the same bitmap icon object - */ - public boolean isSameInfo(BitmapInfo info) { - return mBitmapInfo == info; - } - - @Override - protected void onBoundsChange(Rect bounds) { - super.onBoundsChange(bounds); - updateBadgeBounds(bounds); - } - - private void updateBadgeBounds(Rect bounds) { - if (mBadge != null) { - setBadgeBounds(mBadge, bounds); - } - } - - @Override - public final void draw(Canvas canvas) { - if (mScale != 1f) { - int count = canvas.save(); - Rect bounds = getBounds(); - canvas.scale(mScale, mScale, bounds.exactCenterX(), bounds.exactCenterY()); - drawInternal(canvas, bounds); - if (mBadge != null) { - mBadge.draw(canvas); - } - canvas.restoreToCount(count); - } else { - drawInternal(canvas, getBounds()); - if (mBadge != null) { - mBadge.draw(canvas); - } - } - } - - protected void drawInternal(Canvas canvas, Rect bounds) { - canvas.drawBitmap(mBitmapInfo.icon, null, bounds, mPaint); - } - - /** - * Returns the primary icon color, slightly tinted white - */ - public int getIconColor() { - int whiteScrim = setColorAlphaBound(Color.WHITE, WHITE_SCRIM_ALPHA); - return ColorUtils.compositeColors(whiteScrim, mBitmapInfo.color); - } - - /** - * Returns if this represents a themed icon - */ - public boolean isThemed() { - return false; - } - - /** - * Returns true if the drawable was created with theme, even if it doesn't - * support theming itself. - */ - public boolean isCreatedForTheme() { - return isThemed() || (mCreationFlags & FLAG_THEMED) != 0; - } - - @Override - public void setColorFilter(ColorFilter cf) { - mColorFilter = cf; - updateFilter(); - } - - @Override - public int getOpacity() { - return PixelFormat.TRANSLUCENT; - } - - @Override - public void setAlpha(int alpha) { - if (mAlpha != alpha) { - mAlpha = alpha; - mPaint.setAlpha(alpha); - invalidateSelf(); - if (mBadge != null) { - mBadge.setAlpha(alpha); - } - } - } - - @Override - public void setFilterBitmap(boolean filterBitmap) { - mPaint.setFilterBitmap(filterBitmap); - mPaint.setAntiAlias(filterBitmap); - } - - @Override - public int getAlpha() { - return mAlpha; - } - - public void resetScale() { - if (mScaleAnimation != null) { - mScaleAnimation.cancel(); - mScaleAnimation = null; - } - mScale = 1; - invalidateSelf(); - } - - public float getAnimatedScale() { - return mScaleAnimation == null ? 1 : mScale; - } - - @Override - public int getIntrinsicWidth() { - return mBitmapInfo.icon.getWidth(); - } - - @Override - public int getIntrinsicHeight() { - return mBitmapInfo.icon.getHeight(); - } - - @Override - public int getMinimumWidth() { - return getBounds().width(); - } - - @Override - public int getMinimumHeight() { - return getBounds().height(); - } - - @Override - public boolean isStateful() { - return true; - } - - @Override - public ColorFilter getColorFilter() { - return mPaint.getColorFilter(); - } - - @Override - protected boolean onStateChange(int[] state) { - boolean isPressed = false; - boolean isHovered = false; - for (int s : state) { - if (s == android.R.attr.state_pressed) { - isPressed = true; - break; - } else if (sFlagHoverEnabled - && s == android.R.attr.state_hovered - && mHoverScaleEnabledForDisplay) { - isHovered = true; - // Do not break on hovered state, as pressed state should take precedence. - } - } - if (mIsPressed != isPressed || mIsHovered != isHovered) { - if (mScaleAnimation != null) { - mScaleAnimation.cancel(); - } - - float endScale = isPressed ? PRESSED_SCALE : (isHovered ? HOVERED_SCALE : 1f); - if (mScale != endScale) { - if (isVisible()) { - Interpolator interpolator = - isPressed != mIsPressed ? (isPressed ? ACCEL : DEACCEL) - : HOVER_EMPHASIZED_DECELERATE_INTERPOLATOR; - int duration = - isPressed != mIsPressed ? CLICK_FEEDBACK_DURATION - : HOVER_FEEDBACK_DURATION; - mScaleAnimation = ObjectAnimator.ofFloat(this, SCALE, endScale); - mScaleAnimation.setDuration(duration); - mScaleAnimation.setInterpolator(interpolator); - mScaleAnimation.start(); - } else { - mScale = endScale; - invalidateSelf(); - } - } - mIsPressed = isPressed; - mIsHovered = isHovered; - return true; - } - return false; - } - - public void setIsDisabled(boolean isDisabled) { - if (mIsDisabled != isDisabled) { - mIsDisabled = isDisabled; - if (mBadge instanceof FastBitmapDrawable fbd) { - fbd.setIsDisabled(isDisabled); - } - updateFilter(); - } - } - - protected boolean isDisabled() { - return mIsDisabled; - } - - public void setBadge(Drawable badge) { - if (mBadge != null) { - mBadge.setCallback(null); - } - mBadge = badge; - if (mBadge != null) { - mBadge.setCallback(this); - } - updateBadgeBounds(getBounds()); - updateFilter(); - } - - @VisibleForTesting - public Drawable getBadge() { - return mBadge; - } - - /** - * Updates the paint to reflect the current brightness and saturation. - */ - protected void updateFilter() { - mPaint.setColorFilter(mIsDisabled ? getDisabledColorFilter(mDisabledAlpha) : mColorFilter); - if (mBadge != null) { - mBadge.setColorFilter(getColorFilter()); - } - invalidateSelf(); - } - - protected FastBitmapConstantState newConstantState() { - return new FastBitmapConstantState(mBitmapInfo); - } - - @Override - public final ConstantState getConstantState() { - FastBitmapConstantState cs = newConstantState(); - cs.mIsDisabled = mIsDisabled; - if (mBadge != null) { - cs.mBadgeConstantState = mBadge.getConstantState(); - } - cs.mCreationFlags = mCreationFlags; - return cs; - } - - public static ColorFilter getDisabledColorFilter() { - return getDisabledColorFilter(1); - } - - // Returns if the FastBitmapDrawable contains a badge. - public boolean hasBadge() { - return (mCreationFlags & FLAG_NO_BADGE) == 0; - } - - private static ColorFilter getDisabledColorFilter(float disabledAlpha) { - ColorMatrix tempBrightnessMatrix = new ColorMatrix(); - ColorMatrix tempFilterMatrix = new ColorMatrix(); - - tempFilterMatrix.setSaturation(1f - DISABLED_DESATURATION); - float scale = 1 - DISABLED_BRIGHTNESS; - int brightnessI = (int) (255 * DISABLED_BRIGHTNESS); - float[] mat = tempBrightnessMatrix.getArray(); - mat[0] = scale; - mat[6] = scale; - mat[12] = scale; - mat[4] = brightnessI; - mat[9] = brightnessI; - mat[14] = brightnessI; - mat[18] = disabledAlpha; - tempFilterMatrix.preConcat(tempBrightnessMatrix); - return new ColorMatrixColorFilter(tempFilterMatrix); - } - - protected static final int getDisabledColor(int color) { - int component = (Color.red(color) + Color.green(color) + Color.blue(color)) / 3; - float scale = 1 - DISABLED_BRIGHTNESS; - int brightnessI = (int) (255 * DISABLED_BRIGHTNESS); - component = Math.min(Math.round(scale * component + brightnessI), FULLY_OPAQUE); - return Color.rgb(component, component, component); - } - - /** - * Sets the bounds for the badge drawable based on the main icon bounds - */ - public static void setBadgeBounds(Drawable badge, Rect iconBounds) { - int size = getBadgeSizeForIconSize(iconBounds.width()); - badge.setBounds(iconBounds.right - size, iconBounds.bottom - size, - iconBounds.right, iconBounds.bottom); - } - - @Override - public void invalidateDrawable(Drawable who) { - if (who == mBadge) { - invalidateSelf(); - } - } - - @Override - public void scheduleDrawable(Drawable who, Runnable what, long when) { - if (who == mBadge) { - scheduleSelf(what, when); - } - } - - @Override - public void unscheduleDrawable(Drawable who, Runnable what) { - unscheduleSelf(what); - } - - /** - * Sets whether hover state functionality is enabled. - */ - public static void setFlagHoverEnabled(boolean isFlagHoverEnabled) { - sFlagHoverEnabled = isFlagHoverEnabled; - } - - public void setHoverScaleEnabledForDisplay(boolean hoverScaleEnabledForDisplay) { - mHoverScaleEnabledForDisplay = hoverScaleEnabledForDisplay; - } - - public static class FastBitmapConstantState extends ConstantState { - protected final BitmapInfo mBitmapInfo; - - // These are initialized later so that subclasses don't need to - // pass everything in constructor - protected boolean mIsDisabled; - private ConstantState mBadgeConstantState; - - @DrawableCreationFlags int mCreationFlags = 0; - - public FastBitmapConstantState(Bitmap bitmap, int color) { - this(BitmapInfo.of(bitmap, color)); - } - - public FastBitmapConstantState(BitmapInfo info) { - mBitmapInfo = info; - } - - protected FastBitmapDrawable createDrawable() { - return new FastBitmapDrawable(mBitmapInfo); - } - - @Override - public final FastBitmapDrawable newDrawable() { - FastBitmapDrawable drawable = createDrawable(); - drawable.setIsDisabled(mIsDisabled); - if (mBadgeConstantState != null) { - drawable.setBadge(mBadgeConstantState.newDrawable()); - } - drawable.mCreationFlags = mCreationFlags; - return drawable; - } - - @Override - public int getChangingConfigurations() { - return 0; - } - } -} From f4395d4718efa70358b9b38dbaef5b73c77b94fc Mon Sep 17 00:00:00 2001 From: Pun Butrach Date: Sun, 16 Nov 2025 19:24:26 +0700 Subject: [PATCH 08/30] feat: ViewCaptureLib --- viewcapturelib/.gitignore | 13 + viewcapturelib/Android.bp | 79 ++ viewcapturelib/AndroidManifest.xml | 18 + viewcapturelib/OWNERS | 3 + viewcapturelib/README.md | 11 + viewcapturelib/TEST_MAPPING | 15 + viewcapturelib/build.gradle | 42 + .../app/viewcapture/LooperExecutor.java | 59 ++ .../app/viewcapture/NoOpViewCapture.kt | 21 + .../app/viewcapture/PerfettoViewCapture.kt | 335 ++++++++ .../app/viewcapture/SimpleViewCapture.kt | 6 + .../android/app/viewcapture/ViewCapture.java | 777 ++++++++++++++++++ .../ViewCaptureAwareWindowManager.kt | 68 ++ .../ViewCaptureAwareWindowManagerFactory.kt | 63 ++ .../viewcapture/ViewCaptureDataSource.java | 78 ++ .../app/viewcapture/ViewCaptureFactory.kt | 56 ++ .../app/viewcapture/proto/view_capture.proto | 83 ++ viewcapturelib/tests/AndroidManifest.xml | 41 + .../android/app/viewcapture/TestActivity.kt | 45 + .../ViewCaptureAwareWindowManagerTest.kt | 143 ++++ .../app/viewcapture/ViewCaptureTest.kt | 111 +++ 21 files changed, 2067 insertions(+) create mode 100644 viewcapturelib/.gitignore create mode 100644 viewcapturelib/Android.bp create mode 100644 viewcapturelib/AndroidManifest.xml create mode 100644 viewcapturelib/OWNERS create mode 100644 viewcapturelib/README.md create mode 100644 viewcapturelib/TEST_MAPPING create mode 100644 viewcapturelib/build.gradle create mode 100644 viewcapturelib/src/com/android/app/viewcapture/LooperExecutor.java create mode 100644 viewcapturelib/src/com/android/app/viewcapture/NoOpViewCapture.kt create mode 100644 viewcapturelib/src/com/android/app/viewcapture/PerfettoViewCapture.kt create mode 100644 viewcapturelib/src/com/android/app/viewcapture/SimpleViewCapture.kt create mode 100644 viewcapturelib/src/com/android/app/viewcapture/ViewCapture.java create mode 100644 viewcapturelib/src/com/android/app/viewcapture/ViewCaptureAwareWindowManager.kt create mode 100644 viewcapturelib/src/com/android/app/viewcapture/ViewCaptureAwareWindowManagerFactory.kt create mode 100644 viewcapturelib/src/com/android/app/viewcapture/ViewCaptureDataSource.java create mode 100644 viewcapturelib/src/com/android/app/viewcapture/ViewCaptureFactory.kt create mode 100644 viewcapturelib/src/com/android/app/viewcapture/proto/view_capture.proto create mode 100644 viewcapturelib/tests/AndroidManifest.xml create mode 100644 viewcapturelib/tests/com/android/app/viewcapture/TestActivity.kt create mode 100644 viewcapturelib/tests/com/android/app/viewcapture/ViewCaptureAwareWindowManagerTest.kt create mode 100644 viewcapturelib/tests/com/android/app/viewcapture/ViewCaptureTest.kt diff --git a/viewcapturelib/.gitignore b/viewcapturelib/.gitignore new file mode 100644 index 0000000..6213826 --- /dev/null +++ b/viewcapturelib/.gitignore @@ -0,0 +1,13 @@ +*.iml +.project +.classpath +.project.properties +gen/ +bin/ +.idea/ +.gradle/ +local.properties +gradle/ +build/ +gradlew* +.DS_Store diff --git a/viewcapturelib/Android.bp b/viewcapturelib/Android.bp new file mode 100644 index 0000000..899f60e --- /dev/null +++ b/viewcapturelib/Android.bp @@ -0,0 +1,79 @@ +// Copyright (C) 2022 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 { + default_team: "trendy_team_launcher", + default_applicable_licenses: ["Android-Apache-2.0"], +} + +java_library { + name: "view_capture_proto", + srcs: ["src/com/android/app/viewcapture/proto/*.proto"], + proto: { + type: "lite", + local_include_dirs: [ + "src/com/android/app/viewcapture/proto", + ], + }, + static_libs: ["libprotobuf-java-lite"], + java_version: "1.8", +} + +android_library { + name: "view_capture", + manifest: "AndroidManifest.xml", + platform_apis: true, + min_sdk_version: "30", + + static_libs: [ + "androidx.core_core", + "view_capture_proto", + ], + + srcs: [ + "src/**/*.java", + "src/**/*.kt", + ], +} + +android_test { + name: "view_capture_tests", + manifest: "tests/AndroidManifest.xml", + platform_apis: true, + min_sdk_version: "30", + + static_libs: [ + "androidx.core_core", + "view_capture", + "androidx.test.ext.junit", + "androidx.test.rules", + "flag-junit", + "testables", + "mockito-kotlin2", + "mockito-target-extended-minus-junit4", + ], + srcs: [ + "**/*.java", + "**/*.kt", + ], + libs: [ + "android.test.runner.stubs.system", + "android.test.base.stubs.system", + "android.test.mock.stubs.system", + ], + jni_libs: [ + "libdexmakerjvmtiagent", + ], + test_suites: ["device-tests"], +} diff --git a/viewcapturelib/AndroidManifest.xml b/viewcapturelib/AndroidManifest.xml new file mode 100644 index 0000000..d86f1c5 --- /dev/null +++ b/viewcapturelib/AndroidManifest.xml @@ -0,0 +1,18 @@ + + + + \ No newline at end of file diff --git a/viewcapturelib/OWNERS b/viewcapturelib/OWNERS new file mode 100644 index 0000000..2f30b7c --- /dev/null +++ b/viewcapturelib/OWNERS @@ -0,0 +1,3 @@ +sunnygoyal@google.com +andonian@google.com +include platform/development:/tools/winscope/OWNERS diff --git a/viewcapturelib/README.md b/viewcapturelib/README.md new file mode 100644 index 0000000..4a6993f --- /dev/null +++ b/viewcapturelib/README.md @@ -0,0 +1,11 @@ +###ViewCapture Library Readme + +ViewCapture.java is extremely performance sensitive. Any changes should be carried out with great caution not to hurt performance. + +The following measurements should serve as a performance baseline (as of 02.10.2022): + + +The onDraw() function invocation time in WindowListener within ViewCapture is measured with System.nanoTime(). The following scenario was measured: + +1. Capturing the notification shade window root view on a freshly rebooted bluejay device (2 notifications present) -> avg. time = 204237ns (0.2ms) + diff --git a/viewcapturelib/TEST_MAPPING b/viewcapturelib/TEST_MAPPING new file mode 100644 index 0000000..ecd3e96 --- /dev/null +++ b/viewcapturelib/TEST_MAPPING @@ -0,0 +1,15 @@ +{ + "presubmit": [ + { + "name": "view_capture_tests", + "options": [ + { + "exclude-annotation": "org.junit.Ignore" + }, + { + "exclude-annotation": "androidx.test.filters.FlakyTest" + } + ] + } + ] +} diff --git a/viewcapturelib/build.gradle b/viewcapturelib/build.gradle new file mode 100644 index 0000000..87b7efd --- /dev/null +++ b/viewcapturelib/build.gradle @@ -0,0 +1,42 @@ +plugins { + alias(libs.plugins.android.library) + alias(libs.plugins.kotlin.android) + alias(libs.plugins.google.protobuf) +} + +android { + namespace = "com.android.app.viewcapture" + testNamespace = "com.android.app.viewcapture.test" + defaultConfig { + testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" + } + + sourceSets { + main { + java.srcDirs = ['src'] + manifest.srcFile 'AndroidManifest.xml' + proto.srcDirs = ['src'] + } + androidTest { + java.srcDirs = ["tests"] + manifest.srcFile "tests/AndroidManifest.xml" + } + } + lint { + abortOnError false + } + +} + + +addFrameworkJar('framework-16.jar') +compileOnlyCommonJars() + +dependencies { + implementation libs.androidx.core +// implementation project(":frameworks:libs:systemui:viewcapturelib:view_capture_proto") +// api project(":frameworks:base:core:FrameworkFlags") +// androidTestImplementation project(':SharedTestLib') +// androidTestImplementation 'androidx.test.ext:junit:1.1.3' +// androidTestImplementation "androidx.test:rules:1.4.0" +} diff --git a/viewcapturelib/src/com/android/app/viewcapture/LooperExecutor.java b/viewcapturelib/src/com/android/app/viewcapture/LooperExecutor.java new file mode 100644 index 0000000..e3450f6 --- /dev/null +++ b/viewcapturelib/src/com/android/app/viewcapture/LooperExecutor.java @@ -0,0 +1,59 @@ +/* + * Copyright (C) 2022 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.app.viewcapture; + +import android.os.Handler; +import android.os.Looper; + +import java.util.concurrent.Callable; +import java.util.concurrent.Executor; +import java.util.concurrent.Future; +import java.util.concurrent.FutureTask; +import java.util.concurrent.RejectedExecutionException; +import java.util.concurrent.RunnableFuture; + +/** + * Implementation of {@link Executor} which executes on a provided looper. + */ +public class LooperExecutor implements Executor { + + private final Handler mHandler; + + public LooperExecutor(Looper looper) { + mHandler = new Handler(looper); + } + + @Override + public void execute(Runnable runnable) { + if (mHandler.getLooper() == Looper.myLooper()) { + runnable.run(); + } else { + mHandler.post(runnable); + } + } + + /** + * @throws RejectedExecutionException {@inheritDoc} + * @throws NullPointerException {@inheritDoc} + */ + public Future submit(Callable task) { + if (task == null) throw new NullPointerException(); + RunnableFuture ftask = new FutureTask(task); + execute(ftask); + return ftask; + } + +} diff --git a/viewcapturelib/src/com/android/app/viewcapture/NoOpViewCapture.kt b/viewcapturelib/src/com/android/app/viewcapture/NoOpViewCapture.kt new file mode 100644 index 0000000..795212d --- /dev/null +++ b/viewcapturelib/src/com/android/app/viewcapture/NoOpViewCapture.kt @@ -0,0 +1,21 @@ +package com.android.app.viewcapture + +import android.media.permission.SafeCloseable +import android.os.HandlerThread +import android.view.View +import android.view.Window + +/** + * We don't want to enable the ViewCapture for release builds, since it currently only serves + * 1p apps, and has memory / cpu load that we don't want to risk negatively impacting release builds + */ +class NoOpViewCapture: ViewCapture(0, 0, + createAndStartNewLooperExecutor("NoOpViewCapture", HandlerThread.MIN_PRIORITY)) { + override fun startCapture(view: View, name: String): SafeCloseable { + return SafeCloseable { } + } + + override fun startCapture(window: Window): SafeCloseable { + return SafeCloseable { } + } +} \ No newline at end of file diff --git a/viewcapturelib/src/com/android/app/viewcapture/PerfettoViewCapture.kt b/viewcapturelib/src/com/android/app/viewcapture/PerfettoViewCapture.kt new file mode 100644 index 0000000..9154e50 --- /dev/null +++ b/viewcapturelib/src/com/android/app/viewcapture/PerfettoViewCapture.kt @@ -0,0 +1,335 @@ +/* + * 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.app.viewcapture + +import android.content.Context +import android.internal.perfetto.protos.InternedDataOuterClass.InternedData +import android.internal.perfetto.protos.ProfileCommon.InternedString +import android.internal.perfetto.protos.TracePacketOuterClass.TracePacket +import android.internal.perfetto.protos.Viewcapture.ViewCapture as ViewCaptureMessage +import android.internal.perfetto.protos.WinscopeExtensionsImplOuterClass.WinscopeExtensionsImpl +import android.os.Trace +import android.tracing.perfetto.DataSourceParams +import android.tracing.perfetto.InitArguments +import android.tracing.perfetto.Producer +import android.util.proto.ProtoOutputStream +import androidx.annotation.WorkerThread +import java.util.concurrent.Executor +import java.util.concurrent.atomic.AtomicInteger + +/** + * ViewCapture that listens to Perfetto events (OnStart, OnStop, OnFlush) and continuously writes + * captured frames to the Perfetto service (traced). + */ +internal class PerfettoViewCapture +internal constructor(private val context: Context, executor: Executor) : + ViewCapture(RING_BUFFER_SIZE, DEFAULT_INIT_POOL_SIZE, executor) { + + private val mDataSource = ViewCaptureDataSource({ onStart() }, {}, { onStop() }) + + private val mActiveSessions = AtomicInteger(0) + + private val mViewIdProvider = ViewIdProvider(context.getResources()) + + private var mSerializationCurrentId: Int = 0 + private var mSerializationCurrentView: ViewPropertyRef? = null + + inner class NewInternedStrings { + val packageNames = mutableListOf() + val windowNames = mutableListOf() + val viewIds = mutableListOf() + val classNames = mutableListOf() + } + + init { + enableOrDisableWindowListeners(false) + + Producer.init(InitArguments.DEFAULTS) + + val dataSourceParams = + DataSourceParams.Builder() + .setBufferExhaustedPolicy( + DataSourceParams.PERFETTO_DS_BUFFER_EXHAUSTED_POLICY_STALL_AND_ABORT + ) + .setNoFlush(true) + .setWillNotifyOnStop(false) + .build() + mDataSource.register(dataSourceParams) + } + + fun onStart() { + if (mActiveSessions.incrementAndGet() == 1) { + enableOrDisableWindowListeners(true) + } + } + + fun onStop() { + if (mActiveSessions.decrementAndGet() == 0) { + enableOrDisableWindowListeners(false) + } + } + + @WorkerThread + override fun onCapturedViewPropertiesBg( + elapsedRealtimeNanos: Long, + windowName: String, + startFlattenedTree: ViewPropertyRef, + ) { + Trace.beginSection("vc#onCapturedViewPropertiesBg") + + mDataSource.trace { ctx -> + val newInternedStrings = NewInternedStrings() + val os = ctx.newTracePacket() + os.write(TracePacket.TIMESTAMP, elapsedRealtimeNanos) + serializeViews( + os, + windowName, + startFlattenedTree, + ctx.incrementalState, + newInternedStrings, + ) + serializeIncrementalState(os, ctx.incrementalState, newInternedStrings) + } + + Trace.endSection() + } + + private fun serializeViews( + os: ProtoOutputStream, + windowName: String, + startFlattenedTree: ViewPropertyRef, + incrementalState: ViewCaptureDataSource.IncrementalState, + newInternedStrings: NewInternedStrings, + ) { + mSerializationCurrentView = startFlattenedTree + mSerializationCurrentId = 0 + + val tokenExtensions = os.start(TracePacket.WINSCOPE_EXTENSIONS) + val tokenViewCapture = os.start(WinscopeExtensionsImpl.VIEWCAPTURE) + os.write( + ViewCaptureMessage.PACKAGE_NAME_IID, + internPackageName(context.packageName, incrementalState, newInternedStrings), + ) + os.write( + ViewCaptureMessage.WINDOW_NAME_IID, + internWindowName(windowName, incrementalState, newInternedStrings), + ) + serializeViewsRec(os, -1, incrementalState, newInternedStrings) + os.end(tokenViewCapture) + os.end(tokenExtensions) + } + + private fun serializeViewsRec( + os: ProtoOutputStream, + parentId: Int, + incrementalState: ViewCaptureDataSource.IncrementalState, + newInternedStrings: NewInternedStrings, + ) { + if (mSerializationCurrentView == null) { + return + } + + val id = mSerializationCurrentId + val childCount = mSerializationCurrentView!!.childCount + + serializeView( + os, + mSerializationCurrentView!!, + mSerializationCurrentId, + parentId, + incrementalState, + newInternedStrings, + ) + + ++mSerializationCurrentId + mSerializationCurrentView = mSerializationCurrentView!!.next + + for (i in 0..childCount - 1) { + serializeViewsRec(os, id, incrementalState, newInternedStrings) + } + } + + private fun serializeView( + os: ProtoOutputStream, + view: ViewPropertyRef, + id: Int, + parentId: Int, + incrementalState: ViewCaptureDataSource.IncrementalState, + newInternedStrings: NewInternedStrings, + ) { + val token = os.start(ViewCaptureMessage.VIEWS) + + os.write(ViewCaptureMessage.View.ID, id) + os.write(ViewCaptureMessage.View.PARENT_ID, parentId) + os.write(ViewCaptureMessage.View.HASHCODE, view.hashCode) + os.write( + ViewCaptureMessage.View.VIEW_ID_IID, + internViewId(mViewIdProvider.getName(view.id), incrementalState, newInternedStrings), + ) + os.write( + ViewCaptureMessage.View.CLASS_NAME_IID, + internClassName(view.clazz.name, incrementalState, newInternedStrings), + ) + + os.write(ViewCaptureMessage.View.LEFT, view.left) + os.write(ViewCaptureMessage.View.TOP, view.top) + os.write(ViewCaptureMessage.View.WIDTH, view.right - view.left) + os.write(ViewCaptureMessage.View.HEIGHT, view.bottom - view.top) + os.write(ViewCaptureMessage.View.SCROLL_X, view.scrollX) + os.write(ViewCaptureMessage.View.SCROLL_Y, view.scrollY) + + os.write(ViewCaptureMessage.View.TRANSLATION_X, view.translateX) + os.write(ViewCaptureMessage.View.TRANSLATION_Y, view.translateY) + os.write(ViewCaptureMessage.View.SCALE_X, view.scaleX) + os.write(ViewCaptureMessage.View.SCALE_Y, view.scaleY) + os.write(ViewCaptureMessage.View.ALPHA, view.alpha) + + os.write(ViewCaptureMessage.View.WILL_NOT_DRAW, view.willNotDraw) + os.write(ViewCaptureMessage.View.CLIP_CHILDREN, view.clipChildren) + os.write(ViewCaptureMessage.View.VISIBILITY, view.visibility) + + os.write(ViewCaptureMessage.View.ELEVATION, view.elevation) + + os.end(token) + } + + private fun internClassName( + string: String, + incrementalState: ViewCaptureDataSource.IncrementalState, + newInternedStrings: NewInternedStrings, + ): Int { + return internString( + string, + incrementalState.mInternMapClassName, + newInternedStrings.classNames, + ) + } + + private fun internPackageName( + string: String, + incrementalState: ViewCaptureDataSource.IncrementalState, + newInternedStrings: NewInternedStrings, + ): Int { + return internString( + string, + incrementalState.mInternMapPackageName, + newInternedStrings.packageNames, + ) + } + + private fun internViewId( + string: String, + incrementalState: ViewCaptureDataSource.IncrementalState, + newInternedStrings: NewInternedStrings, + ): Int { + return internString(string, incrementalState.mInternMapViewId, newInternedStrings.viewIds) + } + + private fun internWindowName( + string: String, + incrementalState: ViewCaptureDataSource.IncrementalState, + newInternedStrings: NewInternedStrings, + ): Int { + return internString( + string, + incrementalState.mInternMapWindowName, + newInternedStrings.windowNames, + ) + } + + private fun internString( + string: String, + internMap: MutableMap, + newInternedStrings: MutableList, + ): Int { + if (internMap.containsKey(string)) { + return internMap[string]!! + } + + // +1 to avoid intern ID = 0, because javastream optimizes out zero values + // and the perfetto trace processor would not de-intern that string. + val internId = internMap.size + 1 + + internMap.put(string, internId) + newInternedStrings.add(string) + return internId + } + + private fun serializeIncrementalState( + os: ProtoOutputStream, + incrementalState: ViewCaptureDataSource.IncrementalState, + newInternedStrings: NewInternedStrings, + ) { + var flags = TracePacket.SEQ_NEEDS_INCREMENTAL_STATE + if (!incrementalState.mHasNotifiedClearedState) { + flags = flags or TracePacket.SEQ_INCREMENTAL_STATE_CLEARED + incrementalState.mHasNotifiedClearedState = true + } + os.write(TracePacket.SEQUENCE_FLAGS, flags) + + val token = os.start(TracePacket.INTERNED_DATA) + serializeInternMap( + os, + InternedData.VIEWCAPTURE_CLASS_NAME, + incrementalState.mInternMapClassName, + newInternedStrings.classNames, + ) + serializeInternMap( + os, + InternedData.VIEWCAPTURE_PACKAGE_NAME, + incrementalState.mInternMapPackageName, + newInternedStrings.packageNames, + ) + serializeInternMap( + os, + InternedData.VIEWCAPTURE_VIEW_ID, + incrementalState.mInternMapViewId, + newInternedStrings.viewIds, + ) + serializeInternMap( + os, + InternedData.VIEWCAPTURE_WINDOW_NAME, + incrementalState.mInternMapWindowName, + newInternedStrings.windowNames, + ) + os.end(token) + } + + private fun serializeInternMap( + os: ProtoOutputStream, + fieldId: Long, + map: Map, + newInternedStrings: List, + ) { + if (newInternedStrings.isEmpty()) { + return + } + + var currentInternId = map.size - newInternedStrings.size + 1 + for (internedString in newInternedStrings) { + val token = os.start(fieldId) + os.write(InternedString.IID, currentInternId++) + os.write(InternedString.STR, internedString.toByteArray()) + os.end(token) + } + } + + companion object { + // Keep two frames in the base class' ring buffer. + // This is the minimum required by the current implementation to work. + private val RING_BUFFER_SIZE = 2 + } +} diff --git a/viewcapturelib/src/com/android/app/viewcapture/SimpleViewCapture.kt b/viewcapturelib/src/com/android/app/viewcapture/SimpleViewCapture.kt new file mode 100644 index 0000000..420faca --- /dev/null +++ b/viewcapturelib/src/com/android/app/viewcapture/SimpleViewCapture.kt @@ -0,0 +1,6 @@ +package com.android.app.viewcapture + +import android.os.Process + +open class SimpleViewCapture(threadName: String) : ViewCapture(DEFAULT_MEMORY_SIZE, DEFAULT_INIT_POOL_SIZE, + createAndStartNewLooperExecutor(threadName, Process.THREAD_PRIORITY_FOREGROUND)) \ No newline at end of file diff --git a/viewcapturelib/src/com/android/app/viewcapture/ViewCapture.java b/viewcapturelib/src/com/android/app/viewcapture/ViewCapture.java new file mode 100644 index 0000000..32bced0 --- /dev/null +++ b/viewcapturelib/src/com/android/app/viewcapture/ViewCapture.java @@ -0,0 +1,777 @@ +/* + * Copyright (C) 2022 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.app.viewcapture; + +import static com.android.app.viewcapture.data.ExportedData.MagicNumber.MAGIC_NUMBER_H; +import static com.android.app.viewcapture.data.ExportedData.MagicNumber.MAGIC_NUMBER_L; + +import android.content.ComponentCallbacks2; +import android.content.Context; +import android.content.res.Configuration; +import android.content.res.Resources; +import android.media.permission.SafeCloseable; +import android.os.Handler; +import android.os.HandlerThread; +import android.os.Looper; +import android.os.SystemClock; +import android.os.Trace; +import android.text.TextUtils; +import android.util.Log; +import android.util.SparseArray; +import android.view.View; +import android.view.ViewGroup; +import android.view.ViewTreeObserver; +import android.view.Window; + +import androidx.annotation.AnyThread; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.UiThread; +import androidx.annotation.VisibleForTesting; +import androidx.annotation.WorkerThread; + +import com.android.app.viewcapture.data.ExportedData; +import com.android.app.viewcapture.data.FrameData; +import com.android.app.viewcapture.data.MotionWindowData; +import com.android.app.viewcapture.data.ViewNode; +import com.android.app.viewcapture.data.WindowData; + +import java.io.DataOutputStream; +import java.io.IOException; +import java.io.OutputStream; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Optional; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.Executor; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.Consumer; +import java.util.function.Predicate; +import java.util.stream.Collectors; + +/** + * Utility class for capturing view data every frame + */ +public abstract class ViewCapture { + + private static final String TAG = "ViewCapture"; + + // These flags are copies of two private flags in the View class. + private static final int PFLAG_INVALIDATED = 0x80000000; + private static final int PFLAG_DIRTY_MASK = 0x00200000; + + private static final long MAGIC_NUMBER_FOR_WINSCOPE = + ((long) MAGIC_NUMBER_H.getNumber() << 32) | MAGIC_NUMBER_L.getNumber(); + + // Number of frames to keep in memory + private final int mMemorySize; + + // Number of ViewPropertyRef to preallocate per window + private final int mInitPoolSize; + + protected static final int DEFAULT_MEMORY_SIZE = 2000; + // Initial size of the reference pool. This is at least be 5 * total number of views in + // Launcher. This allows the first free frames avoid object allocation during view capture. + protected static final int DEFAULT_INIT_POOL_SIZE = 300; + + public static final LooperExecutor MAIN_EXECUTOR = new LooperExecutor(Looper.getMainLooper()); + + private final List mListeners = Collections.synchronizedList(new ArrayList<>()); + + protected final Executor mBgExecutor; + + private boolean mIsEnabled = true; + + @VisibleForTesting + public boolean mIsStarted = false; + + protected ViewCapture(int memorySize, int initPoolSize, Executor bgExecutor) { + mMemorySize = memorySize; + mBgExecutor = bgExecutor; + mInitPoolSize = initPoolSize; + } + + public static LooperExecutor createAndStartNewLooperExecutor(String name, int priority) { + HandlerThread thread = new HandlerThread(name, priority); + thread.start(); + return new LooperExecutor(thread.getLooper()); + } + + /** + * Attaches the ViewCapture to the provided window and returns a handle to detach the listener + */ + @AnyThread + @NonNull + public SafeCloseable startCapture(@NonNull Window window) { + String title = window.getAttributes().getTitle().toString(); + String name = TextUtils.isEmpty(title) ? window.toString() : title; + return startCapture(window.getDecorView(), name); + } + + /** + * Attaches the ViewCapture to the provided window and returns a handle to detach the listener. + * Verifies that ViewCapture is enabled before actually attaching an onDrawListener. + */ + @AnyThread + @NonNull + public SafeCloseable startCapture(@NonNull View view, @NonNull String name) { + mIsStarted = true; + WindowListener listener = new WindowListener(view, name); + + if (mIsEnabled) { + listener.attachToRoot(); + } + + mListeners.add(listener); + + view.getContext().registerComponentCallbacks(listener); + + return () -> { + if (listener.mRoot != null && listener.mRoot.getContext() != null) { + listener.mRoot.getContext().unregisterComponentCallbacks(listener); + } + mListeners.remove(listener); + + listener.detachFromRoot(); + }; + } + + /** + * Launcher checks for leaks in many spots during its instrumented tests. The WindowListeners + * appear to have leaks because they store mRoot views. In reality, attached views close their + * respective window listeners when they are destroyed. + *

+ * This method deletes detaches and deletes mRoot views from windowListeners. This makes the + * WindowListeners unusable for anything except dumping previously captured information. They + * are still technically enabled to allow for dumping. + */ + @VisibleForTesting + @AnyThread + public void stopCapture(@NonNull View rootView) { + mIsStarted = false; + mListeners.forEach(it -> { + if (rootView == it.mRoot) { + runOnUiThread(() -> { + if (it.mRoot != null) { + it.mRoot.getViewTreeObserver().removeOnDrawListener(it); + it.mRoot = null; + } + }, it.mRoot); + } + }); + } + + @AnyThread + protected void enableOrDisableWindowListeners(boolean isEnabled) { + mIsEnabled = isEnabled; + mListeners.forEach(WindowListener::detachFromRoot); + if (mIsEnabled) mListeners.forEach(WindowListener::attachToRoot); + } + + @AnyThread + protected void dumpTo(OutputStream os, Context context) + throws InterruptedException, ExecutionException, IOException { + if (mIsEnabled) { + DataOutputStream dataOutputStream = new DataOutputStream(os); + ExportedData ex = getExportedData(context); + dataOutputStream.writeInt(ex.getSerializedSize()); + ex.writeTo(dataOutputStream); + } + } + + @VisibleForTesting + public ExportedData getExportedData(Context context) + throws InterruptedException, ExecutionException { + ArrayList classList = new ArrayList<>(); + return ExportedData.newBuilder() + .setMagicNumber(MAGIC_NUMBER_FOR_WINSCOPE) + .setPackage(context.getPackageName()) + .addAllWindowData(getWindowData(context, classList, l -> l.mIsActive).get()) + .addAllClassname(toStringList(classList)) + .setRealToElapsedTimeOffsetNanos(TimeUnit.MILLISECONDS + .toNanos(System.currentTimeMillis()) - SystemClock.elapsedRealtimeNanos()) + .build(); + } + + private static List toStringList(List classList) { + return classList.stream().map(Class::getName).collect(Collectors.toList()); + } + + public CompletableFuture> getDumpTask(View view) { + ArrayList classList = new ArrayList<>(); + return getWindowData(view.getContext().getApplicationContext(), classList, + l -> l.mRoot.equals(view)).thenApply(list -> list.stream().findFirst().map(w -> + MotionWindowData.newBuilder() + .addAllFrameData(w.getFrameDataList()) + .addAllClassname(toStringList(classList)) + .build())); + } + + @AnyThread + private CompletableFuture> getWindowData(Context context, + ArrayList outClassList, Predicate filter) { + ViewIdProvider idProvider = new ViewIdProvider(context.getResources()); + return CompletableFuture.supplyAsync( + () -> mListeners.stream() + .filter(filter) + .collect(Collectors.toList()), + MAIN_EXECUTOR).thenApplyAsync( + it -> it.stream() + .map(l -> l.dumpToProto(idProvider, outClassList)) + .collect(Collectors.toList()), + mBgExecutor); + } + + @WorkerThread + protected void onCapturedViewPropertiesBg(long elapsedRealtimeNanos, String windowName, + ViewPropertyRef startFlattenedViewTree) { + } + + @AnyThread + void runOnUiThread(Runnable action, View view) { + if (view == null) { + // Corner case. E.g.: the capture is stopped (root view set to null), + // but the bg thread is still processing work. + Log.i(TAG, "Skipping run on UI thread. Provided view == null."); + return; + } + + Handler handlerUi = view.getHandler(); + if (handlerUi != null && handlerUi.getLooper().getThread() == Thread.currentThread()) { + action.run(); + return; + } + + view.post(action); + } + + /** + * Once this window listener is attached to a window's root view, it traverses the entire + * view tree on the main thread every time onDraw is called. It then saves the state of the view + * tree traversed in a local list of nodes, so that this list of nodes can be processed on a + * background thread, and prepared for being dumped into a bugreport. + *

+ * Since some of the work needs to be done on the main thread after every draw, this piece of + * code needs to be hyper optimized. That is why we are recycling ViewPropertyRef objects + * and storing the list of nodes as a flat LinkedList, rather than as a tree. This data + * structure allows recycling to happen in O(1) time via pointer assignment. Without this + * optimization, a lot of time is wasted creating ViewPropertyRef objects, or finding + * ViewPropertyRef objects to recycle. + *

+ * Another optimization is to only traverse view nodes on the main thread that have potentially + * changed since the last frame was drawn. This can be determined via a combination of private + * flags inside the View class. + *

+ * Another optimization is to not store or manipulate any string objects on the main thread. + * While this might seem trivial, using Strings in any form causes the ViewCapture to hog the + * main thread for up to an additional 6-7ms. It must be avoided at all costs. + *

+ * Another optimization is to only store the class names of the Views in the view hierarchy one + * time. They are then referenced via a classNameIndex value stored in each ViewPropertyRef. + *

+ * TODO: b/262585897: If further memory optimization is required, an effective one would be to + * only store the changes between frames, rather than the entire node tree for each frame. + * The go/web-hv UX already does this, and has reaped significant memory improves because of it. + *

+ * TODO: b/262585897: Another memory optimization could be to store all integer, float, and + * boolean information via single integer values via the Chinese remainder theorem, or a similar + * algorithm, which enables multiple numerical values to be stored inside 1 number. Doing this + * would allow each ViewPropertyRef to slim down its memory footprint significantly. + *

+ * One important thing to remember is that bugs related to recycling will usually only appear + * after at least 2000 frames have been rendered. If that code is changed, the tester can + * use hard-coded logs to verify that recycling is happening, and test view capturing at least + * ~8000 frames or so to verify the recycling functionality is working properly. + *

+ * Each WindowListener is memory aware and will both stop collecting view capture information, + * as well as delete their current stash of information upon a signal from the system that + * memory resources are scarce. The user will need to restart the app process before + * more ViewCapture information is captured. + */ + private class WindowListener implements ViewTreeObserver.OnDrawListener, ComponentCallbacks2 { + + @Nullable + public View mRoot; + public final String name; + + // Pool used for capturing view tree on the UI thread. + private ViewPropertyRef mPool = new ViewPropertyRef(); + private final ViewPropertyRef mViewPropertyRef = new ViewPropertyRef(); + + private int mFrameIndexBg = -1; + private boolean mIsFirstFrame = true; + private AtomicReference mFrameTimesNanosBg = + new AtomicReference<>(new long[mMemorySize]); + private AtomicReference mNodesBg = + new AtomicReference<>(new ViewPropertyRef[mMemorySize]); + + private boolean mIsActive = true; + private final Consumer mCaptureCallback = + this::copyCleanViewsFromLastFrameBg; + + WindowListener(View view, String name) { + mRoot = view; + this.name = name; + initPool(mInitPoolSize); + } + + /** + * Every time onDraw is called, it does the minimal set of work required on the main thread, + * i.e. capturing potentially dirty / invalidated views, and then immediately offloads the + * rest of the processing work (extracting the captured view properties) to a background + * thread via mExecutor. + */ + @Override + @UiThread + public void onDraw() { + Trace.beginSection("vc#onDraw"); + try { + View root = mRoot; + if (root == null) { + // Handle the corner case where another (non-UI) thread + // concurrently stopped the capture and set mRoot = null + return; + } + captureViewTree(root, mViewPropertyRef); + ViewPropertyRef captured = mViewPropertyRef.next; + if (captured != null) { + captured.callback = mCaptureCallback; + captured.elapsedRealtimeNanos = SystemClock.elapsedRealtimeNanos(); + mBgExecutor.execute(captured); + } + mIsFirstFrame = false; + } finally { + Trace.endSection(); + } + } + + /** + * Copy clean views from the last frame on the background thread. Clean views are + * the remaining part of the view hierarchy that was not already copied by the UI thread. + * Then transfer the received ViewPropertyRef objects back to the UI thread's pool. + */ + @WorkerThread + private void copyCleanViewsFromLastFrameBg(ViewPropertyRef start) { + Trace.beginSection("vc#copyCleanViewsFromLastFrameBg"); + + // onTrimMemory() might concurrently modify mFrameTimesNanosBg and mNodesBg (set new + // arrays with length = 0). So let's atomically acquire the array references and if any + // of the array lengths is 0, then a memory trim has been performed and this method + // must do nothing. + long[] frameTimesNanosBg = mFrameTimesNanosBg.get(); + ViewPropertyRef[] nodesBg = mNodesBg.get(); + if (frameTimesNanosBg.length == 0 || nodesBg.length == 0) { + Trace.endSection(); + return; + } + + long elapsedRealtimeNanos = start.elapsedRealtimeNanos; + mFrameIndexBg++; + if (mFrameIndexBg >= mMemorySize) { + mFrameIndexBg = 0; + } + frameTimesNanosBg[mFrameIndexBg] = elapsedRealtimeNanos; + + ViewPropertyRef recycle = nodesBg[mFrameIndexBg]; + + ViewPropertyRef resultStart = null; + ViewPropertyRef resultEnd = null; + + ViewPropertyRef end = start; + + while (end != null) { + end.completeTransferFromViewBg(); + + ViewPropertyRef propertyRef = recycle; + if (propertyRef == null) { + propertyRef = new ViewPropertyRef(); + } else { + recycle = recycle.next; + propertyRef.next = null; + } + + ViewPropertyRef copy = null; + if (end.childCount < 0) { + copy = findInLastFrame(nodesBg, end.hashCode); + if (copy != null) { + copy.transferTo(end); + } else { + end.childCount = 0; + } + } + end.transferTo(propertyRef); + + if (resultStart == null) { + resultStart = propertyRef; + resultEnd = resultStart; + } else { + resultEnd.next = propertyRef; + resultEnd = resultEnd.next; + } + + if (copy != null) { + int pending = copy.childCount; + while (pending > 0) { + copy = copy.next; + pending = pending - 1 + copy.childCount; + + propertyRef = recycle; + if (propertyRef == null) { + propertyRef = new ViewPropertyRef(); + } else { + recycle = recycle.next; + propertyRef.next = null; + } + + copy.transferTo(propertyRef); + + resultEnd.next = propertyRef; + resultEnd = resultEnd.next; + } + } + + if (end.next == null) { + // The compiler will complain about using a non-final variable from + // an outer class in a lambda if we pass in 'end' directly. + final ViewPropertyRef finalEnd = end; + runOnUiThread(() -> addToPool(start, finalEnd), mRoot); + break; + } + end = end.next; + } + nodesBg[mFrameIndexBg] = resultStart; + + onCapturedViewPropertiesBg(elapsedRealtimeNanos, name, resultStart); + + Trace.endSection(); + } + + @WorkerThread + private @Nullable ViewPropertyRef findInLastFrame(ViewPropertyRef[] nodesBg, int hashCode) { + int lastFrameIndex = (mFrameIndexBg == 0) ? mMemorySize - 1 : mFrameIndexBg - 1; + ViewPropertyRef viewPropertyRef = nodesBg[lastFrameIndex]; + while (viewPropertyRef != null && viewPropertyRef.hashCode != hashCode) { + viewPropertyRef = viewPropertyRef.next; + } + return viewPropertyRef; + } + + private void initPool(int initPoolSize) { + ViewPropertyRef start = new ViewPropertyRef(); + ViewPropertyRef current = start; + + for (int i = 0; i < initPoolSize; i++) { + current.next = new ViewPropertyRef(); + current = current.next; + } + + ViewPropertyRef finalCurrent = current; + addToPool(start, finalCurrent); + } + + private void addToPool(ViewPropertyRef start, ViewPropertyRef end) { + end.next = mPool; + mPool = start; + } + + @UiThread + private ViewPropertyRef getFromPool() { + ViewPropertyRef ref = mPool; + if (ref != null) { + mPool = ref.next; + ref.next = null; + } else { + ref = new ViewPropertyRef(); + } + return ref; + } + + @AnyThread + void attachToRoot() { + if (mRoot == null) return; + mIsActive = true; + runOnUiThread(() -> { + if (mRoot.isAttachedToWindow()) { + safelyEnableOnDrawListener(); + } else { + mRoot.addOnAttachStateChangeListener(new View.OnAttachStateChangeListener() { + @Override + public void onViewAttachedToWindow(View v) { + if (mIsActive) { + safelyEnableOnDrawListener(); + } + mRoot.removeOnAttachStateChangeListener(this); + } + + @Override + public void onViewDetachedFromWindow(View v) { + } + }); + } + }, mRoot); + } + + @AnyThread + void detachFromRoot() { + mIsActive = false; + runOnUiThread(() -> { + if (mRoot != null) { + mRoot.getViewTreeObserver().removeOnDrawListener(this); + } + }, mRoot); + } + + @UiThread + private void safelyEnableOnDrawListener() { + if (mRoot != null) { + mRoot.getViewTreeObserver().removeOnDrawListener(this); + mRoot.getViewTreeObserver().addOnDrawListener(this); + } + } + + @WorkerThread + private WindowData dumpToProto(ViewIdProvider idProvider, ArrayList classList) { + ViewPropertyRef[] nodesBg = mNodesBg.get(); + long[] frameTimesNanosBg = mFrameTimesNanosBg.get(); + + WindowData.Builder builder = WindowData.newBuilder().setTitle(name); + int size = (nodesBg[mMemorySize - 1] == null) ? mFrameIndexBg + 1 : mMemorySize; + for (int i = size - 1; i >= 0; i--) { + int index = (mMemorySize + mFrameIndexBg - i) % mMemorySize; + ViewNode.Builder nodeBuilder = ViewNode.newBuilder(); + nodesBg[index].toProto(idProvider, classList, nodeBuilder); + FrameData.Builder frameDataBuilder = FrameData.newBuilder() + .setNode(nodeBuilder) + .setTimestamp(frameTimesNanosBg[index]); + builder.addFrameData(frameDataBuilder); + } + return builder.build(); + } + + @UiThread + private ViewPropertyRef captureViewTree(View view, ViewPropertyRef start) { + ViewPropertyRef ref = getFromPool(); + start.next = ref; + if (view instanceof ViewGroup) { + ViewGroup parent = (ViewGroup) view; + // If a view has not changed since the last frame, we will copy + // its children from the last processed frame's data. + if ((view.mPrivateFlags & (PFLAG_INVALIDATED | PFLAG_DIRTY_MASK)) == 0 + && !mIsFirstFrame) { + // A negative child count is the signal to copy this view from the last frame. + ref.childCount = -1; + ref.view = view; + return ref; + } + ViewPropertyRef result = ref; + int childCount = ref.childCount = parent.getChildCount(); + ref.transferFrom(view); + for (int i = 0; i < childCount; i++) { + result = captureViewTree(parent.getChildAt(i), result); + } + return result; + } else { + ref.childCount = 0; + ref.transferFrom(view); + return ref; + } + } + + @Override + public void onTrimMemory(int level) { + if (level >= ComponentCallbacks2.TRIM_MEMORY_BACKGROUND) { + mNodesBg.set(new ViewPropertyRef[0]); + mFrameTimesNanosBg.set(new long[0]); + if (mRoot != null && mRoot.getContext() != null) { + mRoot.getContext().unregisterComponentCallbacks(this); + } + detachFromRoot(); + mRoot = null; + } + } + + @Override + public void onConfigurationChanged(Configuration configuration) { + // No Operation + } + + @Override + public void onLowMemory() { + onTrimMemory(ComponentCallbacks2.TRIM_MEMORY_BACKGROUND); + } + } + + protected static class ViewPropertyRef implements Runnable { + public View view; + + // We store reference in memory to avoid generating and storing too many strings + public Class clazz; + public int hashCode; + + public int id; + public int left, top, right, bottom; + public int scrollX, scrollY; + + public float translateX, translateY; + public float scaleX, scaleY; + public float alpha; + public float elevation; + + public int visibility; + public boolean willNotDraw; + public boolean clipChildren; + public int childCount = 0; + + public ViewPropertyRef next; + + public Consumer callback = null; + public long elapsedRealtimeNanos = 0; + + + public void transferFrom(View in) { + view = in; + + left = in.getLeft(); + top = in.getTop(); + right = in.getRight(); + bottom = in.getBottom(); + scrollX = in.getScrollX(); + scrollY = in.getScrollY(); + + translateX = in.getTranslationX(); + translateY = in.getTranslationY(); + scaleX = in.getScaleX(); + scaleY = in.getScaleY(); + alpha = in.getAlpha(); + elevation = in.getElevation(); + + visibility = in.getVisibility(); + willNotDraw = in.willNotDraw(); + } + + /** + * Transfer in backgroup thread view properties that remain unchanged between frames. + */ + public void completeTransferFromViewBg() { + clazz = view.getClass(); + hashCode = view.hashCode(); + id = view.getId(); + view = null; + } + + public void transferTo(ViewPropertyRef out) { + out.clazz = this.clazz; + out.hashCode = this.hashCode; + out.childCount = this.childCount; + out.id = this.id; + out.left = this.left; + out.top = this.top; + out.right = this.right; + out.bottom = this.bottom; + out.scrollX = this.scrollX; + out.scrollY = this.scrollY; + out.scaleX = this.scaleX; + out.scaleY = this.scaleY; + out.translateX = this.translateX; + out.translateY = this.translateY; + out.alpha = this.alpha; + out.visibility = this.visibility; + out.willNotDraw = this.willNotDraw; + out.clipChildren = this.clipChildren; + out.elevation = this.elevation; + } + + /** + * Converts the data to the proto representation and returns the next property ref + * at the end of the iteration. + */ + public ViewPropertyRef toProto(ViewIdProvider idProvider, ArrayList classList, + ViewNode.Builder viewNode) { + int classnameIndex = classList.indexOf(clazz); + if (classnameIndex < 0) { + classnameIndex = classList.size(); + classList.add(clazz); + } + + viewNode.setClassnameIndex(classnameIndex) + .setHashcode(hashCode) + .setId(idProvider.getName(id)) + .setLeft(left) + .setTop(top) + .setWidth(right - left) + .setHeight(bottom - top) + .setTranslationX(translateX) + .setTranslationY(translateY) + .setScrollX(scrollX) + .setScrollY(scrollY) + .setScaleX(scaleX) + .setScaleY(scaleY) + .setAlpha(alpha) + .setVisibility(visibility) + .setWillNotDraw(willNotDraw) + .setElevation(elevation) + .setClipChildren(clipChildren); + + ViewPropertyRef result = next; + for (int i = 0; (i < childCount) && (result != null); i++) { + ViewNode.Builder childViewNode = ViewNode.newBuilder(); + result = result.toProto(idProvider, classList, childViewNode); + viewNode.addChildren(childViewNode); + } + return result; + } + + @Override + public void run() { + Consumer oldCallback = callback; + callback = null; + if (oldCallback != null) { + oldCallback.accept(this); + } + } + } + + protected static final class ViewIdProvider { + + private final SparseArray mNames = new SparseArray<>(); + private final Resources mRes; + + ViewIdProvider(Resources res) { + mRes = res; + } + + String getName(int id) { + String name = mNames.get(id); + if (name == null) { + if (id >= 0) { + try { + name = mRes.getResourceTypeName(id) + '/' + mRes.getResourceEntryName(id); + } catch (Resources.NotFoundException e) { + name = "id/" + "0x" + Integer.toHexString(id).toUpperCase(); + } + } else { + name = "NO_ID"; + } + mNames.put(id, name); + } + return name; + } + } +} diff --git a/viewcapturelib/src/com/android/app/viewcapture/ViewCaptureAwareWindowManager.kt b/viewcapturelib/src/com/android/app/viewcapture/ViewCaptureAwareWindowManager.kt new file mode 100644 index 0000000..578c89c --- /dev/null +++ b/viewcapturelib/src/com/android/app/viewcapture/ViewCaptureAwareWindowManager.kt @@ -0,0 +1,68 @@ +/* + * 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.app.viewcapture + +import android.content.Context +import android.media.permission.SafeCloseable +import android.view.View +import android.view.ViewGroup +import android.view.Window +import android.view.WindowManager +import android.view.WindowManagerWrapper + +/** + * [WindowManager] implementation to enable view tracing. Adds [ViewCapture] to associated window + * when it is added to view hierarchy. Use [ViewCaptureAwareWindowManagerFactory] to create an + * instance of this class. + */ +internal class ViewCaptureAwareWindowManager( + private val context: Context, + private val base: WindowManager, +) : WindowManagerWrapper(base) { + + private var viewCaptureCloseableMap: MutableMap = mutableMapOf() + + override fun addView(view: View, params: ViewGroup.LayoutParams) { + super.addView(view, params) + val viewCaptureCloseable: SafeCloseable = + ViewCaptureFactory.getInstance(context).startCapture(view, getViewName(view)) + viewCaptureCloseableMap[view] = viewCaptureCloseable + } + + override fun removeView(view: View?) { + removeViewFromCloseableMap(view) + super.removeView(view) + } + + override fun removeViewImmediate(view: View?) { + removeViewFromCloseableMap(view) + super.removeViewImmediate(view) + } + + override fun createLocalWindowManager(parentWindow: Window): WindowManager { + return ViewCaptureAwareWindowManager(context, base.createLocalWindowManager(parentWindow)) + } + + private fun getViewName(view: View) = "." + view.javaClass.name + + private fun removeViewFromCloseableMap(view: View?) { + if (viewCaptureCloseableMap.containsKey(view)) { + viewCaptureCloseableMap[view]?.close() + viewCaptureCloseableMap.remove(view) + } + } +} diff --git a/viewcapturelib/src/com/android/app/viewcapture/ViewCaptureAwareWindowManagerFactory.kt b/viewcapturelib/src/com/android/app/viewcapture/ViewCaptureAwareWindowManagerFactory.kt new file mode 100644 index 0000000..1125d4d --- /dev/null +++ b/viewcapturelib/src/com/android/app/viewcapture/ViewCaptureAwareWindowManagerFactory.kt @@ -0,0 +1,63 @@ +/* + * Copyright (C) 2025 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.app.viewcapture + +import android.content.Context +import android.os.Trace +import android.os.Trace.TRACE_TAG_APP +import android.view.WindowManager +import java.lang.ref.WeakReference +import java.util.Collections +import java.util.WeakHashMap + +/** Factory to create [Context] specific instances of [ViewCaptureAwareWindowManager]. */ +object ViewCaptureAwareWindowManagerFactory { + + /** + * Keeps track of [ViewCaptureAwareWindowManager] instance for a [Context]. It is a + * [WeakHashMap] to ensure that if a [Context] mapped in the [instanceMap] is destroyed, the map + * entry is garbage collected as well. + */ + private val instanceMap = + Collections.synchronizedMap(WeakHashMap>()) + + /** + * Returns the weakly cached [ViewCaptureAwareWindowManager] instance for a given [Context]. If + * no instance is cached; it creates, caches and returns a new instance. + */ + @JvmStatic + fun getInstance(context: Context): WindowManager { + Trace.traceCounter( + TRACE_TAG_APP, + "ViewCaptureAwareWindowManagerFactory#instanceMap.size", + instanceMap.size, + ) + + val cachedWindowManager = instanceMap[context]?.get() + if (cachedWindowManager != null) { + return cachedWindowManager + } else { + val windowManager = + ViewCaptureAwareWindowManager( + context, + context.getSystemService(WindowManager::class.java), + ) + instanceMap[context] = WeakReference(windowManager) + return windowManager + } + } +} diff --git a/viewcapturelib/src/com/android/app/viewcapture/ViewCaptureDataSource.java b/viewcapturelib/src/com/android/app/viewcapture/ViewCaptureDataSource.java new file mode 100644 index 0000000..61a2c03 --- /dev/null +++ b/viewcapturelib/src/com/android/app/viewcapture/ViewCaptureDataSource.java @@ -0,0 +1,78 @@ +/* + * 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.app.viewcapture; + +import android.tracing.perfetto.CreateIncrementalStateArgs; +import android.tracing.perfetto.DataSource; +import android.tracing.perfetto.DataSourceInstance; +import android.tracing.perfetto.FlushCallbackArguments; +import android.tracing.perfetto.StartCallbackArguments; +import android.tracing.perfetto.StopCallbackArguments; +import android.util.proto.ProtoInputStream; + +import java.util.HashMap; +import java.util.Map; + +class ViewCaptureDataSource + extends DataSource { + public static String DATA_SOURCE_NAME = "android.viewcapture"; + + private final Runnable mOnStartStaticCallback; + private final Runnable mOnFlushStaticCallback; + private final Runnable mOnStopStaticCallback; + + ViewCaptureDataSource(Runnable onStart, Runnable onFlush, Runnable onStop) { + super(DATA_SOURCE_NAME); + this.mOnStartStaticCallback = onStart; + this.mOnFlushStaticCallback = onFlush; + this.mOnStopStaticCallback = onStop; + } + + @Override + public IncrementalState createIncrementalState( + CreateIncrementalStateArgs args) { + return new IncrementalState(); + } + + public static class IncrementalState { + public final Map mInternMapPackageName = new HashMap<>(); + public final Map mInternMapWindowName = new HashMap<>(); + public final Map mInternMapViewId = new HashMap<>(); + public final Map mInternMapClassName = new HashMap<>(); + public boolean mHasNotifiedClearedState = false; + } + + @Override + public DataSourceInstance createInstance(ProtoInputStream configStream, int instanceIndex) { + return new DataSourceInstance(this, instanceIndex) { + @Override + protected void onStart(StartCallbackArguments args) { + mOnStartStaticCallback.run(); + } + + @Override + protected void onFlush(FlushCallbackArguments args) { + mOnFlushStaticCallback.run(); + } + + @Override + protected void onStop(StopCallbackArguments args) { + mOnStopStaticCallback.run(); + } + }; + } +} diff --git a/viewcapturelib/src/com/android/app/viewcapture/ViewCaptureFactory.kt b/viewcapturelib/src/com/android/app/viewcapture/ViewCaptureFactory.kt new file mode 100644 index 0000000..b0ec569 --- /dev/null +++ b/viewcapturelib/src/com/android/app/viewcapture/ViewCaptureFactory.kt @@ -0,0 +1,56 @@ +/* + * 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.app.viewcapture + +import android.content.Context +import android.os.Process +import android.util.Log + +/** + * Factory to create polymorphic instances of ViewCapture according to build configurations and + * flags. + */ +object ViewCaptureFactory { + private val TAG = ViewCaptureFactory::class.java.simpleName + private val instance: ViewCapture by lazy { createInstance() } + private lateinit var appContext: Context + + private fun createInstance(): ViewCapture { + return if (!android.os.Build.IS_DEBUGGABLE) { + Log.i(TAG, "instantiating ${NoOpViewCapture::class.java.simpleName}") + NoOpViewCapture() + } else { + Log.i(TAG, "instantiating ${PerfettoViewCapture::class.java.simpleName}") + PerfettoViewCapture( + appContext, + ViewCapture.createAndStartNewLooperExecutor( + "PerfettoViewCapture", + Process.THREAD_PRIORITY_FOREGROUND, + ), + ) + } + } + + /** Returns an instance of [ViewCapture]. */ + @JvmStatic + fun getInstance(context: Context): ViewCapture { + if (!this::appContext.isInitialized) { + synchronized(this) { appContext = context.applicationContext } + } + return instance + } +} diff --git a/viewcapturelib/src/com/android/app/viewcapture/proto/view_capture.proto b/viewcapturelib/src/com/android/app/viewcapture/proto/view_capture.proto new file mode 100644 index 0000000..c73ce8b --- /dev/null +++ b/viewcapturelib/src/com/android/app/viewcapture/proto/view_capture.proto @@ -0,0 +1,83 @@ +/* + * Copyright (C) 2022 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. + */ + +syntax = "proto2"; + +package com.android.app.viewcapture.data; + +option java_multiple_files = true; + +message ExportedData { + /* constant; MAGIC_NUMBER = (long) MAGIC_NUMBER_H << 32 | MagicNumber.MAGIC_NUMBER_L + (this is needed because enums have to be 32 bits and there's no nice way to put 64bit + constants into .proto files. */ + enum MagicNumber { + INVALID = 0; + MAGIC_NUMBER_L = 0x65906578; /* AZAN (ASCII) */ + MAGIC_NUMBER_H = 0x68658273; /* DARI (ASCII) */ + } + + optional fixed64 magic_number = 1; /* Must be the first field, set to value in MagicNumber */ + repeated WindowData windowData = 2; + optional string package = 3; + repeated string classname = 4; + + /* offset between real-time clock and elapsed time clock in nanoseconds. + Calculated as: 1000000 * System.currentTimeMillis() - SystemClock.elapsedRealtimeNanos() */ + optional fixed64 real_to_elapsed_time_offset_nanos = 5; +} + +message WindowData { + repeated FrameData frameData = 1; + optional string title = 2; +} + +message MotionWindowData { + repeated FrameData frameData = 1; + repeated string classname = 2; +} + +message FrameData { + optional int64 timestamp = 1; // unit is elapsed realtime nanos + optional ViewNode node = 2; +} + +message ViewNode { + optional int32 classname_index = 1; + optional int32 hashcode = 2; + + repeated ViewNode children = 3; + + optional string id = 4; + optional int32 left = 5; + optional int32 top = 6; + optional int32 width = 7; + optional int32 height = 8; + optional int32 scrollX = 9; + optional int32 scrollY = 10; + + optional float translationX = 11; + optional float translationY = 12; + optional float scaleX = 13 [default = 1]; + optional float scaleY = 14 [default = 1]; + optional float alpha = 15 [default = 1]; + + optional bool willNotDraw = 16; + optional bool clipChildren = 17; + optional int32 visibility = 18; + + optional float elevation = 19; +} diff --git a/viewcapturelib/tests/AndroidManifest.xml b/viewcapturelib/tests/AndroidManifest.xml new file mode 100644 index 0000000..f32f93c --- /dev/null +++ b/viewcapturelib/tests/AndroidManifest.xml @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + + + + diff --git a/viewcapturelib/tests/com/android/app/viewcapture/TestActivity.kt b/viewcapturelib/tests/com/android/app/viewcapture/TestActivity.kt new file mode 100644 index 0000000..749327e --- /dev/null +++ b/viewcapturelib/tests/com/android/app/viewcapture/TestActivity.kt @@ -0,0 +1,45 @@ +/* + * Copyright (C) 2023 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.app.viewcapture + +import android.app.Activity +import android.os.Bundle +import android.widget.LinearLayout +import android.widget.TextView + +/** + * Activity with the content set to a [LinearLayout] with [TextView] children. + */ +class TestActivity : Activity() { + + companion object { + const val TEXT_VIEW_COUNT = 1000 + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContentView(createContentView()) + } + + private fun createContentView(): LinearLayout { + val root = LinearLayout(this) + for (i in 0 until TEXT_VIEW_COUNT) { + root.addView(TextView(this)) + } + return root + } +} \ No newline at end of file diff --git a/viewcapturelib/tests/com/android/app/viewcapture/ViewCaptureAwareWindowManagerTest.kt b/viewcapturelib/tests/com/android/app/viewcapture/ViewCaptureAwareWindowManagerTest.kt new file mode 100644 index 0000000..378f355 --- /dev/null +++ b/viewcapturelib/tests/com/android/app/viewcapture/ViewCaptureAwareWindowManagerTest.kt @@ -0,0 +1,143 @@ +/* + * 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.app.viewcapture + +import android.content.Context +import android.content.Intent +import android.hardware.display.DisplayManager +import android.platform.test.annotations.EnableFlags +import android.platform.test.flag.junit.SetFlagsRule +import android.testing.AndroidTestingRunner +import android.view.Display.DEFAULT_DISPLAY +import android.view.View +import android.view.WindowManager +import android.view.WindowManager.LayoutParams.TYPE_APPLICATION +import android.view.WindowManager.LayoutParams.TYPE_APPLICATION_ATTACHED_DIALOG +import android.window.WindowContext +import androidx.test.ext.junit.rules.ActivityScenarioRule +import androidx.test.filters.SmallTest +import androidx.test.platform.app.InstrumentationRegistry +import com.android.window.flags.Flags +import com.google.common.truth.Truth.assertWithMessage +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidTestingRunner::class) +@SmallTest +class ViewCaptureAwareWindowManagerTest { + private val mContext: Context = InstrumentationRegistry.getInstrumentation().context + private lateinit var mViewCaptureAwareWindowManager: ViewCaptureAwareWindowManager + + private val activityIntent = Intent(mContext, TestActivity::class.java) + + @get:Rule val activityScenarioRule = ActivityScenarioRule(activityIntent) + + @get:Rule val mSetFlagsRule: SetFlagsRule = SetFlagsRule() + + @Test + fun testAddView_verifyStartCaptureCall() { + activityScenarioRule.scenario.onActivity { activity -> + mViewCaptureAwareWindowManager = + ViewCaptureAwareWindowManager( + mContext, + mContext.getSystemService(WindowManager::class.java), + ) + + val activityDecorView = activity.window.decorView + // removing view since it is already added to view hierarchy on declaration + mViewCaptureAwareWindowManager.removeView(activityDecorView) + val viewCapture = ViewCaptureFactory.getInstance(mContext) + + mViewCaptureAwareWindowManager.addView( + activityDecorView, + activityDecorView.layoutParams as WindowManager.LayoutParams, + ) + assertTrue(viewCapture.mIsStarted) + } + } + + @EnableFlags(Flags.FLAG_ENABLE_WINDOW_CONTEXT_OVERRIDE_TYPE) + @Test + fun useWithWindowContext_attachWindow_attachToViewCaptureAwareWm() { + val windowContext = + mContext.createWindowContext( + mContext.getSystemService(DisplayManager::class.java).getDisplay(DEFAULT_DISPLAY), + TYPE_APPLICATION, + null, /* options */ + ) as WindowContext + + // Obtain ViewCaptureAwareWindowManager with WindowContext. + mViewCaptureAwareWindowManager = + ViewCaptureAwareWindowManagerFactory.getInstance(windowContext) + as ViewCaptureAwareWindowManager + + // Attach to an Activity so that we can add an application parent window. + val params = WindowManager.LayoutParams() + activityScenarioRule.scenario.onActivity { activity -> + params.token = activity.activityToken + } + + // Create and attach an application window, and listen to OnAttachStateChangeListener. + // We need to know when the parent window is attached and then we can add the attached + // dialog. + val listener = AttachStateListener() + val parentWindow = View(windowContext) + parentWindow.addOnAttachStateChangeListener(listener) + windowContext.attachWindow(parentWindow) + + // Attach the parent window to ViewCaptureAwareWm + activityScenarioRule.scenario.onActivity { + mViewCaptureAwareWindowManager.addView(parentWindow, params) + } + + // Wait for parent window to be attached. + listener.mLatch.await(TIMEOUT_IN_SECONDS, TimeUnit.SECONDS) + assertWithMessage("The WindowContext token must be attached.") + .that(params.mWindowContextToken) + .isEqualTo(windowContext.windowContextToken) + + val subWindow = View(windowContext) + val subParams = WindowManager.LayoutParams(TYPE_APPLICATION_ATTACHED_DIALOG) + + // Attach the sub-window + activityScenarioRule.scenario.onActivity { + mViewCaptureAwareWindowManager.addView(subWindow, subParams) + } + + assertWithMessage("The sub-window must be attached to the parent window") + .that(subParams.token) + .isEqualTo(parentWindow.windowToken) + } + + private class AttachStateListener : View.OnAttachStateChangeListener { + val mLatch: CountDownLatch = CountDownLatch(1) + + override fun onViewAttachedToWindow(v: View) { + mLatch.countDown() + } + + override fun onViewDetachedFromWindow(v: View) {} + } + + companion object { + private const val TIMEOUT_IN_SECONDS = 4L + } +} diff --git a/viewcapturelib/tests/com/android/app/viewcapture/ViewCaptureTest.kt b/viewcapturelib/tests/com/android/app/viewcapture/ViewCaptureTest.kt new file mode 100644 index 0000000..e3272c4 --- /dev/null +++ b/viewcapturelib/tests/com/android/app/viewcapture/ViewCaptureTest.kt @@ -0,0 +1,111 @@ +/* + * Copyright (C) 2022 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.app.viewcapture + +import android.content.Intent +import android.media.permission.SafeCloseable +import android.testing.AndroidTestingRunner +import android.view.View +import android.widget.LinearLayout +import android.widget.TextView +import androidx.test.ext.junit.rules.ActivityScenarioRule +import androidx.test.filters.SmallTest +import androidx.test.platform.app.InstrumentationRegistry +import com.android.app.viewcapture.TestActivity.Companion.TEXT_VIEW_COUNT +import com.android.app.viewcapture.data.MotionWindowData +import junit.framework.Assert.assertEquals +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@SmallTest +@RunWith(AndroidTestingRunner::class) +class ViewCaptureTest { + + private val memorySize = 100 + private val initPoolSize = 15 + private val viewCapture by lazy { + object : + ViewCapture(memorySize, initPoolSize, MAIN_EXECUTOR) {} + } + + private val activityIntent = + Intent(InstrumentationRegistry.getInstrumentation().context, TestActivity::class.java) + + @get:Rule val activityScenarioRule = ActivityScenarioRule(activityIntent) + + @Test + fun testWindowListenerDumpsOneFrameAfterInvalidate() { + activityScenarioRule.scenario.onActivity { activity -> + val closeable = startViewCaptureAndInvalidateNTimes(1, activity) + val rootView = activity.requireViewById(android.R.id.content) + val data = viewCapture.getDumpTask(rootView).get().get() + + assertEquals(1, data.frameDataList.size) + verifyTestActivityViewHierarchy(data) + closeable.close() + } + } + + @Test + fun testWindowListenerDumpsCorrectlyAfterRecyclingStarted() { + activityScenarioRule.scenario.onActivity { activity -> + val closeable = startViewCaptureAndInvalidateNTimes(memorySize + 5, activity) + val rootView = activity.requireViewById(android.R.id.content) + val data = viewCapture.getDumpTask(rootView).get().get() + + // since ViewCapture MEMORY_SIZE is [viewCaptureMemorySize], only + // [viewCaptureMemorySize] frames are exported, although the view is invalidated + // [viewCaptureMemorySize + 5] times + assertEquals(memorySize, data.frameDataList.size) + verifyTestActivityViewHierarchy(data) + closeable.close() + } + } + + private fun startViewCaptureAndInvalidateNTimes(n: Int, activity: TestActivity): SafeCloseable { + val rootView: View = activity.requireViewById(android.R.id.content) + val closeable: SafeCloseable = viewCapture.startCapture(rootView, "rootViewId") + dispatchOnDraw(rootView, times = n) + return closeable + } + + private fun dispatchOnDraw(view: View, times: Int) { + if (times > 0) { + view.viewTreeObserver.dispatchOnDraw() + dispatchOnDraw(view, times - 1) + } + } + + private fun verifyTestActivityViewHierarchy(exportedData: MotionWindowData) { + for (frame in exportedData.frameDataList) { + val testActivityRoot = + frame.node // FrameLayout (android.R.id.content) + .childrenList + .first() // LinearLayout (set by setContentView()) + assertEquals(TEXT_VIEW_COUNT, testActivityRoot.childrenList.size) + assertEquals( + LinearLayout::class.qualifiedName, + exportedData.getClassname(testActivityRoot.classnameIndex) + ) + assertEquals( + TextView::class.qualifiedName, + exportedData.getClassname(testActivityRoot.childrenList.first().classnameIndex) + ) + } + } +} From 17fb63135b2e9508b3e7ef2c71c60c54332c5fb9 Mon Sep 17 00:00:00 2001 From: Pun Butrach Date: Sun, 16 Nov 2025 19:25:40 +0700 Subject: [PATCH 09/30] fix: Classes in iconloaderlib --- .../src/com/android/launcher3/icons/BaseIconFactory.java | 2 +- .../src/com/android/launcher3/icons/ClockDrawableWrapper.java | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/iconloaderlib/src/com/android/launcher3/icons/BaseIconFactory.java b/iconloaderlib/src/com/android/launcher3/icons/BaseIconFactory.java index 17fa5e8..5523ecb 100644 --- a/iconloaderlib/src/com/android/launcher3/icons/BaseIconFactory.java +++ b/iconloaderlib/src/com/android/launcher3/icons/BaseIconFactory.java @@ -61,7 +61,7 @@ public class BaseIconFactory implements AutoCloseable { public static final int DEFAULT_WRAPPER_BACKGROUND = Color.WHITE; - private static final float LEGACY_ICON_SCALE = .7f * (1f / (1 + 2 * getExtraInsetFraction())); + public static final float LEGACY_ICON_SCALE = .7f * (1f / (1 + 2 * getExtraInsetFraction())); public static final int MODE_DEFAULT = 0; public static final int MODE_ALPHA = 1; diff --git a/iconloaderlib/src/com/android/launcher3/icons/ClockDrawableWrapper.java b/iconloaderlib/src/com/android/launcher3/icons/ClockDrawableWrapper.java index 12a76dd..33fc4ee 100644 --- a/iconloaderlib/src/com/android/launcher3/icons/ClockDrawableWrapper.java +++ b/iconloaderlib/src/com/android/launcher3/icons/ClockDrawableWrapper.java @@ -41,6 +41,8 @@ import android.os.SystemClock; import android.util.Log; +import androidx.annotation.NonNull; +import androidx.core.util.Supplier; import com.android.launcher3.icons.cache.CacheLookupFlag; import com.android.launcher3.icons.mono.ThemedIconDrawable; From 7a21c06e3f8290078854f1e14431b6e6d236b6a8 Mon Sep 17 00:00:00 2001 From: Pun Butrach Date: Sun, 16 Nov 2025 20:15:36 +0700 Subject: [PATCH 10/30] feat: Displaylib Android 16 QPR1 --- displaylib/Android.bp | 30 ++ displaylib/AndroidManifest.xml | 20 + displaylib/README.MD | 4 + displaylib/TEST_MAPPING | 7 + .../app/displaylib/DisplayLibComponent.kt | 88 ++++ .../app/displaylib/DisplayRepository.kt | 489 ++++++++++++++++++ .../DisplaysWithDecorationsRepository.kt | 119 +++++ ...DisplaysWithDecorationsRepositoryCompat.kt | 131 +++++ .../displaylib/InstanceLifecycleManager.kt | 37 ++ .../app/displaylib/PerDisplayRepository.kt | 291 +++++++++++ .../fakes/FakePerDisplayRepository.kt | 50 ++ displaylib/tests/Android.bp | 34 ++ displaylib/tests/AndroidManifest.xml | 23 + .../app/displaylib/DisplayRepositoryTest.kt | 31 ++ 14 files changed, 1354 insertions(+) create mode 100644 displaylib/Android.bp create mode 100644 displaylib/AndroidManifest.xml create mode 100644 displaylib/README.MD create mode 100644 displaylib/TEST_MAPPING create mode 100644 displaylib/src/com/android/app/displaylib/DisplayLibComponent.kt create mode 100644 displaylib/src/com/android/app/displaylib/DisplayRepository.kt create mode 100644 displaylib/src/com/android/app/displaylib/DisplaysWithDecorationsRepository.kt create mode 100644 displaylib/src/com/android/app/displaylib/DisplaysWithDecorationsRepositoryCompat.kt create mode 100644 displaylib/src/com/android/app/displaylib/InstanceLifecycleManager.kt create mode 100644 displaylib/src/com/android/app/displaylib/PerDisplayRepository.kt create mode 100644 displaylib/src/com/android/app/displaylib/fakes/FakePerDisplayRepository.kt create mode 100644 displaylib/tests/Android.bp create mode 100644 displaylib/tests/AndroidManifest.xml create mode 100644 displaylib/tests/src/com/android/app/displaylib/DisplayRepositoryTest.kt diff --git a/displaylib/Android.bp b/displaylib/Android.bp new file mode 100644 index 0000000..85eefb8 --- /dev/null +++ b/displaylib/Android.bp @@ -0,0 +1,30 @@ +// Copyright (C) 2025 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 { + default_applicable_licenses: ["Android-Apache-2.0"], +} + +java_library { + name: "displaylib", + manifest: "AndroidManifest.xml", + static_libs: [ + "kotlinx_coroutines_android", + "dagger2", + "jsr330", + "//frameworks/libs/systemui:tracinglib-platform", + ], + plugins: ["dagger2-compiler"], + srcs: ["src/**/*.kt"], +} diff --git a/displaylib/AndroidManifest.xml b/displaylib/AndroidManifest.xml new file mode 100644 index 0000000..4f3234b --- /dev/null +++ b/displaylib/AndroidManifest.xml @@ -0,0 +1,20 @@ + + + + + diff --git a/displaylib/README.MD b/displaylib/README.MD new file mode 100644 index 0000000..2739a46 --- /dev/null +++ b/displaylib/README.MD @@ -0,0 +1,4 @@ +# displaylib + +This library contains utilities that make the management of multiple displays easier, more +performant and elegant. \ No newline at end of file diff --git a/displaylib/TEST_MAPPING b/displaylib/TEST_MAPPING new file mode 100644 index 0000000..31260e9 --- /dev/null +++ b/displaylib/TEST_MAPPING @@ -0,0 +1,7 @@ +{ + "presubmit": [ + { + "name": "displaylib_tests" + } + ] +} diff --git a/displaylib/src/com/android/app/displaylib/DisplayLibComponent.kt b/displaylib/src/com/android/app/displaylib/DisplayLibComponent.kt new file mode 100644 index 0000000..e40c1ca --- /dev/null +++ b/displaylib/src/com/android/app/displaylib/DisplayLibComponent.kt @@ -0,0 +1,88 @@ +/* + * Copyright (C) 2025 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.app.displaylib + +import android.hardware.display.DisplayManager +import android.os.Handler +import android.view.IWindowManager +import dagger.Binds +import dagger.BindsInstance +import dagger.Component +import dagger.Module +import javax.inject.Singleton +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope + +/** + * Component that creates all classes in displaylib. + * + * Each user of this library will bind the required element in the factory constructor. It's advised + * to use this component through [createDisplayLibComponent], which wraps the dagger generated + * method. + */ +@Component(modules = [DisplayLibModule::class]) +@Singleton +interface DisplayLibComponent { + + @Component.Factory + interface Factory { + fun create( + @BindsInstance displayManager: DisplayManager, + @BindsInstance windowManager: IWindowManager, + @BindsInstance bgHandler: Handler, + @BindsInstance bgApplicationScope: CoroutineScope, + @BindsInstance backgroundCoroutineDispatcher: CoroutineDispatcher, + ): DisplayLibComponent + } + + val displayRepository: DisplayRepository + val displaysWithDecorationsRepository: DisplaysWithDecorationsRepository + val displaysWithDecorationsRepositoryCompat: DisplaysWithDecorationsRepositoryCompat +} + +@Module +interface DisplayLibModule { + @Binds fun bindDisplayManagerImpl(impl: DisplayRepositoryImpl): DisplayRepository + + @Binds + fun bindDisplaysWithDecorationsRepositoryImpl( + impl: DisplaysWithDecorationsRepositoryImpl + ): DisplaysWithDecorationsRepository +} + +/** + * Just a wrapper to make the generated code to create the component more explicit. + * + * This should be called only once per process. Note that [bgHandler], [bgApplicationScope] and + * [backgroundCoroutineDispatcher] are expected to be backed by background threads. In the future + * this might throw an exception if they are tied to the main thread! + */ +fun createDisplayLibComponent( + displayManager: DisplayManager, + windowManager: IWindowManager, + bgHandler: Handler, + bgApplicationScope: CoroutineScope, + backgroundCoroutineDispatcher: CoroutineDispatcher, +): DisplayLibComponent { + return DaggerDisplayLibComponent.factory() + .create( + displayManager, + windowManager, + bgHandler, + bgApplicationScope, + backgroundCoroutineDispatcher, + ) +} diff --git a/displaylib/src/com/android/app/displaylib/DisplayRepository.kt b/displaylib/src/com/android/app/displaylib/DisplayRepository.kt new file mode 100644 index 0000000..7b43355 --- /dev/null +++ b/displaylib/src/com/android/app/displaylib/DisplayRepository.kt @@ -0,0 +1,489 @@ +/* + * Copyright (C) 2025 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.app.displaylib + +import android.hardware.display.DisplayManager +import android.hardware.display.DisplayManager.DISPLAY_CATEGORY_ALL_INCLUDING_DISABLED +import android.hardware.display.DisplayManager.DisplayListener +import android.hardware.display.DisplayManager.EVENT_TYPE_DISPLAY_ADDED +import android.hardware.display.DisplayManager.EVENT_TYPE_DISPLAY_CHANGED +import android.hardware.display.DisplayManager.EVENT_TYPE_DISPLAY_REMOVED +import android.os.Handler +import android.util.Log +import android.view.Display +import javax.inject.Inject +import javax.inject.Singleton +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asFlow +import kotlinx.coroutines.flow.callbackFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.conflate +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.filterIsInstance +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.onStart +import kotlinx.coroutines.flow.scan +import kotlinx.coroutines.flow.stateIn + +/** Repository for providing access to display related information and events. */ +interface DisplayRepository { + /** Provides the current set of displays. */ + val displays: StateFlow> + + /** Display change event indicating a change to the given displayId has occurred. */ + val displayChangeEvent: Flow + + /** Display addition event indicating a new display has been added. */ + val displayAdditionEvent: Flow + + /** Display removal event indicating a display has been removed. */ + val displayRemovalEvent: Flow + + /** + * Provides the current set of display ids. + * + * Note that it is preferred to use this instead of [displays] if only the + * [Display.getDisplayId] is needed. + */ + val displayIds: StateFlow> + + /** + * Pending display id that can be enabled/disabled. + * + * When `null`, it means there is no pending display waiting to be enabled. + */ + val pendingDisplay: Flow + + /** Whether the default display is currently off. */ + val defaultDisplayOff: Flow + + /** + * Given a display ID int, return the corresponding Display object, or null if none exist. + * + * This method will not result in a binder call in most cases. The only exception is if there is + * an existing binder call ongoing to get the [Display] instance already. In that case, this + * will wait for the end of the binder call. + */ + fun getDisplay(displayId: Int): Display? + + /** + * As [getDisplay], but it's always guaranteed to not block on any binder call. + * + * This might return null if the display id was not mapped to a [Display] object yet. + */ + fun getCachedDisplay(displayId: Int): Display? = + displays.value.firstOrNull { it.displayId == displayId } + + /** + * Returns whether the given displayId is in the set of enabled displays. + * + * This is guaranteed to not cause a binder call. Use this instead of [getDisplay] (see its docs + * for why) + */ + fun containsDisplay(displayId: Int): Boolean = displayIds.value.contains(displayId) + + /** Represents a connected display that has not been enabled yet. */ + interface PendingDisplay { + /** Id of the pending display. */ + val id: Int + + /** Enables the display, making it available to the system. */ + suspend fun enable() + + /** + * Ignores the pending display. When called, this specific display id doesn't appear as + * pending anymore until the display is disconnected and reconnected again. + */ + suspend fun ignore() + + /** Disables the display, making it unavailable to the system. */ + suspend fun disable() + } +} + +@Singleton +class DisplayRepositoryImpl +@Inject +constructor( + private val displayManager: DisplayManager, + backgroundHandler: Handler, + bgApplicationScope: CoroutineScope, + backgroundCoroutineDispatcher: CoroutineDispatcher, +) : DisplayRepository { + private val allDisplayEvents: Flow = + callbackFlow { + val callback = + object : DisplayListener { + override fun onDisplayAdded(displayId: Int) { + trySend(DisplayEvent.Added(displayId)) + } + + override fun onDisplayRemoved(displayId: Int) { + trySend(DisplayEvent.Removed(displayId)) + } + + override fun onDisplayChanged(displayId: Int) { + trySend(DisplayEvent.Changed(displayId)) + } + } + displayManager.registerDisplayListener( + callback, + backgroundHandler, + EVENT_TYPE_DISPLAY_ADDED or + EVENT_TYPE_DISPLAY_CHANGED or + EVENT_TYPE_DISPLAY_REMOVED, + ) + awaitClose { displayManager.unregisterDisplayListener(callback) } + } + .conflate() + .onStart { emit(DisplayEvent.Changed(Display.DEFAULT_DISPLAY)) } + .debugLog("allDisplayEvents") + .flowOn(backgroundCoroutineDispatcher) + + override val displayChangeEvent: Flow = + allDisplayEvents.filterIsInstance().map { event -> event.displayId } + + override val displayRemovalEvent: Flow = + allDisplayEvents.filterIsInstance().map { it.displayId } + + // This is necessary because there might be multiple displays, and we could + // have missed events for those added before this process or flow started. + // Note it causes a binder call from the main thread (it's traced). + private val initialDisplays: Set = displayManager.displays?.toSet() ?: emptySet() + private val initialDisplayIds = initialDisplays.map { display -> display.displayId }.toSet() + + /** Propagate to the listeners only enabled displays */ + private val enabledDisplayIds: StateFlow> = + allDisplayEvents + .scan(initial = initialDisplayIds) { previousIds: Set, event: DisplayEvent -> + val id = event.displayId + when (event) { + is DisplayEvent.Removed -> previousIds - id + is DisplayEvent.Added, + is DisplayEvent.Changed -> previousIds + id + } + } + .distinctUntilChanged() + .debugLog("enabledDisplayIds") + .stateIn(bgApplicationScope, SharingStarted.WhileSubscribed(), initialDisplayIds) + + private val defaultDisplay by lazy { + getDisplayFromDisplayManager(Display.DEFAULT_DISPLAY) + ?: error("Unable to get default display.") + } + /** + * Represents displays that went though the [DisplayListener.onDisplayAdded] callback. + * + * Those are commonly the ones provided by [DisplayManager.getDisplays] by default. + */ + private val enabledDisplays: StateFlow> = + enabledDisplayIds + .mapElementsLazily { displayId -> getDisplayFromDisplayManager(displayId) } + .onEach { + if (it.isEmpty()) Log.wtf(TAG, "No enabled displays. This should never happen.") + } + .flowOn(backgroundCoroutineDispatcher) + .debugLog("enabledDisplays") + .stateIn( + bgApplicationScope, + started = SharingStarted.WhileSubscribed(), + // This triggers a single binder call on the UI thread per process. The + // alternative would be to use sharedFlows, but they are prohibited due to + // performance concerns. + // Ultimately, this is a trade-off between a one-time UI thread binder call and + // the constant overhead of sharedFlows. + initialValue = initialDisplays, + ) + + /** + * Represents displays that went though the [DisplayListener.onDisplayAdded] callback. + * + * Those are commonly the ones provided by [DisplayManager.getDisplays] by default. + */ + override val displays: StateFlow> = enabledDisplays + + override val displayIds: StateFlow> = enabledDisplayIds + + /** + * Implementation that maps from [displays], instead of [allDisplayEvents] for 2 reasons: + * 1. Guarantee that it emits __after__ [displays] emitted. This way it is guaranteed that + * calling [getDisplay] for the newly added display will be non-null. + * 2. Reuse the existing instance of [Display] without a new call to [DisplayManager]. + */ + override val displayAdditionEvent: Flow = + displays + .pairwiseBy { previousDisplays, currentDisplays -> currentDisplays - previousDisplays } + .flatMapLatest { it.asFlow() } + + val _ignoredDisplayIds = MutableStateFlow>(emptySet()) + private val ignoredDisplayIds: Flow> = _ignoredDisplayIds.debugLog("ignoredDisplayIds") + + private fun getInitialConnectedDisplays(): Set = + displayManager + .getDisplays(DISPLAY_CATEGORY_ALL_INCLUDING_DISABLED) + .map { it.displayId } + .toSet() + .also { + if (DEBUG) { + Log.d(TAG, "getInitialConnectedDisplays: $it") + } + } + + /* keeps connected displays until they are disconnected. */ + private val connectedDisplayIds: StateFlow> = + callbackFlow { + val connectedIds = getInitialConnectedDisplays().toMutableSet() + val callback = + object : DisplayConnectionListener { + override fun onDisplayConnected(id: Int) { + if (DEBUG) { + Log.d(TAG, "display with id=$id connected.") + } + connectedIds += id + _ignoredDisplayIds.value -= id + trySend(connectedIds.toSet()) + } + + override fun onDisplayDisconnected(id: Int) { + connectedIds -= id + if (DEBUG) { + Log.d(TAG, "display with id=$id disconnected.") + } + _ignoredDisplayIds.value -= id + trySend(connectedIds.toSet()) + } + } + trySend(connectedIds.toSet()) + displayManager.registerDisplayListener( + callback, + backgroundHandler, + /* eventFlags */ 0, + DisplayManager.PRIVATE_EVENT_TYPE_DISPLAY_CONNECTION_CHANGED, + ) + awaitClose { displayManager.unregisterDisplayListener(callback) } + } + .conflate() + .distinctUntilChanged() + .debugLog("connectedDisplayIds") + .stateIn( + bgApplicationScope, + started = SharingStarted.WhileSubscribed(), + // The initial value is set to empty, but connected displays are gathered as soon as + // the flow starts being collected. This is to ensure the call to get displays (an + // IPC) happens in the background instead of when this object + // is instantiated. + initialValue = emptySet(), + ) + + private val connectedExternalDisplayIds: Flow> = + connectedDisplayIds + .map { connectedDisplayIds -> + connectedDisplayIds + .filter { id -> getDisplayType(id) == Display.TYPE_EXTERNAL } + .toSet() + } + .flowOn(backgroundCoroutineDispatcher) + .debugLog("connectedExternalDisplayIds") + + private fun getDisplayType(displayId: Int): Int? = displayManager.getDisplay(displayId)?.type + + private fun getDisplayFromDisplayManager(displayId: Int): Display? = displayManager.getDisplay(displayId) + + /** + * Pending displays are the ones connected, but not enabled and not ignored. + * + * A connected display is ignored after the user makes the decision to use it or not. For now, + * the initial decision from the user is final and not reversible. + */ + private val pendingDisplayIds: Flow> = + combine(enabledDisplayIds, connectedExternalDisplayIds, ignoredDisplayIds) { + enabledDisplaysIds, + connectedExternalDisplayIds, + ignoredDisplayIds -> + if (DEBUG) { + Log.d( + TAG, + "combining enabled=$enabledDisplaysIds, " + + "connectedExternalDisplayIds=$connectedExternalDisplayIds, " + + "ignored=$ignoredDisplayIds", + ) + } + connectedExternalDisplayIds - enabledDisplaysIds - ignoredDisplayIds + } + .debugLog("allPendingDisplayIds") + + /** Which display id should be enabled among the pending ones. */ + private val pendingDisplayId: Flow = + pendingDisplayIds.map { it.maxOrNull() }.distinctUntilChanged().debugLog("pendingDisplayId") + + override val pendingDisplay: Flow = + pendingDisplayId + .map { displayId -> + val id = displayId ?: return@map null + object : DisplayRepository.PendingDisplay { + override val id = id + + override suspend fun enable() { + if (DEBUG) { + Log.d(TAG, "Enabling display with id=$id") + } + displayManager.enableConnectedDisplay(id) + // After the display has been enabled, it is automatically ignored. + ignore() + } + + override suspend fun ignore() { + _ignoredDisplayIds.value += id + } + + override suspend fun disable() { + ignore() + if (DEBUG) { + Log.d(TAG, "Disabling display with id=$id") + } + displayManager.disableConnectedDisplay(id) + } + } + } + .debugLog("pendingDisplay") + + override val defaultDisplayOff: Flow = + displayChangeEvent + .filter { it == Display.DEFAULT_DISPLAY } + .map { defaultDisplay.state == Display.STATE_OFF } + .distinctUntilChanged() + + override fun getDisplay(displayId: Int): Display? { + val cachedDisplay = getCachedDisplay(displayId) + if (cachedDisplay != null) return cachedDisplay + // cachedDisplay could be null for 2 reasons: + // 1. the displayId is being mapped to a display in the background, but the binder call is + // not done + // 2. the display is not there + // In case of option one, let's get it synchronously from display manager to make sure for + // this to be consistent. + return if (displayIds.value.contains(displayId)) { + getDisplayFromDisplayManager(displayId) + } else { + null + } + } + + private fun Flow.debugLog(flowName: String): Flow { + return if (DEBUG) { + // LC-Ignored + this + } else { + this + } + } + + /** + * Maps a set of T to a set of V, minimizing the number of `createValue` calls taking into + * account the diff between each root flow emission. + * + * This is needed to minimize the number of [getDisplayFromDisplayManager] in this class. Note + * that if the [createValue] returns a null element, it will not be added in the output set. + */ + private fun Flow>.mapElementsLazily(createValue: (T) -> V?): Flow> { + data class State( + val previousSet: Set, + // Caches T values from the previousSet that were already converted to V + val valueMap: Map, + val resultSet: Set, + ) + + val emptyInitialState = State(emptySet(), emptyMap(), emptySet()) + return this.scan(emptyInitialState) { state, currentSet -> + if (currentSet == state.previousSet) { + state + } else { + val removed = state.previousSet - currentSet + val added = currentSet - state.previousSet + val newMap = state.valueMap.toMutableMap() + + added.forEach { key -> createValue(key)?.let { newMap[key] = it } } + removed.forEach { key -> newMap.remove(key) } + + val resultSet = newMap.values.toSet() + State(currentSet, newMap, resultSet) + } + } + .filter { it != emptyInitialState } + .map { it.resultSet } + } + + private companion object { + const val TAG = "DisplayRepository" + val DEBUG = Log.isLoggable(TAG, Log.DEBUG) + } +} + +/** Used to provide default implementations for all methods. */ +private interface DisplayConnectionListener : DisplayListener { + + override fun onDisplayConnected(id: Int) {} + + override fun onDisplayDisconnected(id: Int) {} + + override fun onDisplayAdded(id: Int) {} + + override fun onDisplayRemoved(id: Int) {} + + override fun onDisplayChanged(id: Int) {} +} + +private sealed interface DisplayEvent { + val displayId: Int + + data class Added(override val displayId: Int) : DisplayEvent + + data class Removed(override val displayId: Int) : DisplayEvent + + data class Changed(override val displayId: Int) : DisplayEvent +} + +/** + * Returns a new [Flow] that combines the two most recent emissions from [this] using [transform]. + * Note that the new Flow will not start emitting until it has received two emissions from the + * upstream Flow. + * + * Useful for code that needs to compare the current value to the previous value. + * + * Note this has been taken from com.android.systemui.util.kotlin. It was copied to keep deps of + * displaylib minimal (and avoid creating a new shared lib for it). + */ +fun Flow.pairwiseBy(transform: suspend (old: T, new: T) -> R): Flow = flow { + val noVal = Any() + var previousValue: Any? = noVal + collect { newVal -> + if (previousValue != noVal) { + @Suppress("UNCHECKED_CAST") emit(transform(previousValue as T, newVal)) + } + previousValue = newVal + } +} diff --git a/displaylib/src/com/android/app/displaylib/DisplaysWithDecorationsRepository.kt b/displaylib/src/com/android/app/displaylib/DisplaysWithDecorationsRepository.kt new file mode 100644 index 0000000..b184bd9 --- /dev/null +++ b/displaylib/src/com/android/app/displaylib/DisplaysWithDecorationsRepository.kt @@ -0,0 +1,119 @@ +/* + * Copyright (C) 2025 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.app.displaylib + +import android.content.res.Configuration +import android.graphics.Rect +import android.view.IDisplayWindowListener +import android.view.IWindowManager +import javax.inject.Inject +import javax.inject.Singleton +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.callbackFlow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.merge +import kotlinx.coroutines.flow.scan +import kotlinx.coroutines.flow.stateIn + +/** Provides the displays with decorations. */ +interface DisplaysWithDecorationsRepository { + /** A [StateFlow] that maintains a set of display IDs that should have system decorations. */ + val displayIdsWithSystemDecorations: StateFlow> +} + +@Singleton +class DisplaysWithDecorationsRepositoryImpl +@Inject +constructor( + private val windowManager: IWindowManager, + bgApplicationScope: CoroutineScope, + displayRepository: DisplayRepository, +) : DisplaysWithDecorationsRepository { + + private val decorationEvents: Flow = callbackFlow { + val callback = + object : IDisplayWindowListener.Stub() { + override fun onDisplayAddSystemDecorations(displayId: Int) { + trySend(Event.Add(displayId)) + } + + override fun onDisplayRemoveSystemDecorations(displayId: Int) { + trySend(Event.Remove(displayId)) + } + + override fun onDesktopModeEligibleChanged(displayId: Int) {} + + override fun onDisplayAdded(p0: Int) {} + + override fun onDisplayConfigurationChanged(p0: Int, p1: Configuration?) {} + + override fun onDisplayRemoved(p0: Int) {} + + override fun onFixedRotationStarted(p0: Int, p1: Int) {} + + override fun onFixedRotationFinished(p0: Int) {} + + override fun onKeepClearAreasChanged( + p0: Int, + p1: MutableList?, + p2: MutableList?, + ) {} + } + windowManager.registerDisplayWindowListener(callback) + awaitClose { windowManager.unregisterDisplayWindowListener(callback) } + } + + private val initialDisplayIdsWithDecorations: Set = + displayRepository.displayIds.value + .filter { windowManager.shouldShowSystemDecors(it) } + .toSet() + + /** + * A [StateFlow] that maintains a set of display IDs that should have system decorations. + * + * Updates to the set are triggered by: + * - Removing displays via [displayRemovalEvent] emissions. + * + * The set is initialized with displays that qualify for system decorations based on + * [WindowManager.shouldShowSystemDecors]. + */ + override val displayIdsWithSystemDecorations: StateFlow> = + merge(decorationEvents, displayRepository.displayRemovalEvent.map { Event.Remove(it) }) + .scan(initialDisplayIdsWithDecorations) { displayIds: Set, event: Event -> + when (event) { + is Event.Add -> displayIds + event.displayId + is Event.Remove -> displayIds - event.displayId + } + } + .distinctUntilChanged() + .stateIn( + scope = bgApplicationScope, + started = SharingStarted.WhileSubscribed(), + initialValue = initialDisplayIdsWithDecorations, + ) + + private sealed class Event(val displayId: Int) { + class Add(displayId: Int) : Event(displayId) + + class Remove(displayId: Int) : Event(displayId) + } +} diff --git a/displaylib/src/com/android/app/displaylib/DisplaysWithDecorationsRepositoryCompat.kt b/displaylib/src/com/android/app/displaylib/DisplaysWithDecorationsRepositoryCompat.kt new file mode 100644 index 0000000..66aa7cc --- /dev/null +++ b/displaylib/src/com/android/app/displaylib/DisplaysWithDecorationsRepositoryCompat.kt @@ -0,0 +1,131 @@ +/* + * Copyright (C) 2025 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.app.displaylib + +import com.android.internal.annotations.GuardedBy +import java.util.concurrent.ConcurrentHashMap +import javax.inject.Inject +import javax.inject.Singleton +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext + +/** Listener for display system decorations changes. */ +interface DisplayDecorationListener { + /** Called when system decorations should be added to the display.* */ + fun onDisplayAddSystemDecorations(displayId: Int) + + /** Called when a display is removed. */ + fun onDisplayRemoved(displayId: Int) + + /** Called when system decorations should be removed from the display. */ + fun onDisplayRemoveSystemDecorations(displayId: Int) +} + +/** + * This class is a compatibility layer that allows to register and unregister listeners for display + * decorations changes. It uses a [DisplaysWithDecorationsRepository] to get the current list of + * displays with decorations and notifies the listeners when the list changes. + */ +@Singleton +class DisplaysWithDecorationsRepositoryCompat +@Inject +constructor( + private val bgApplicationScope: CoroutineScope, + private val displayRepository: DisplaysWithDecorationsRepository, +) { + private val mutex = Mutex() + private var collectorJob: Job? = null + private val displayDecorationListenersWithDispatcher = + ConcurrentHashMap() + + /** + * Registers a [DisplayDecorationListener] to be notified when the list of displays with + * decorations changes. + * + * @param listener The listener to register. + * @param dispatcher The dispatcher to use when notifying the listener. + */ + fun registerDisplayDecorationListener( + listener: DisplayDecorationListener, + dispatcher: CoroutineDispatcher, + ) { + var initialDisplayIdsForListener: Set = emptySet() + bgApplicationScope.launch { + mutex.withLock { + displayDecorationListenersWithDispatcher[listener] = dispatcher + initialDisplayIdsForListener = + displayRepository.displayIdsWithSystemDecorations.value + startCollectingIfNeeded(initialDisplayIdsForListener) + } + // Emit all the existing displays with decorations when registering. + initialDisplayIdsForListener.forEach { displayId -> + withContext(dispatcher) { listener.onDisplayAddSystemDecorations(displayId) } + } + } + } + + /** + * Unregisters a [DisplayDecorationListener]. + * + * @param listener The listener to unregister. + */ + fun unregisterDisplayDecorationListener(listener: DisplayDecorationListener) { + bgApplicationScope.launch { + mutex.withLock { + displayDecorationListenersWithDispatcher.remove(listener) + // stop collecting if no listeners + if (displayDecorationListenersWithDispatcher.isEmpty()) { + collectorJob?.cancel() + collectorJob = null + } + } + } + } + + @GuardedBy("mutex") + private fun startCollectingIfNeeded(lastDisplaysWithDecorations: Set) { + if (collectorJob?.isActive == true) { + return + } + var oldDisplays: Set = lastDisplaysWithDecorations + collectorJob = + bgApplicationScope.launch { + displayRepository.displayIdsWithSystemDecorations.collect { currentDisplays -> + val previous = oldDisplays + oldDisplays = currentDisplays + + val newDisplaysWithDecorations = currentDisplays - previous + val removedDisplays = previous - currentDisplays + displayDecorationListenersWithDispatcher.forEach { (listener, dispatcher) -> + withContext(dispatcher) { + newDisplaysWithDecorations.forEach { displayId -> + listener.onDisplayAddSystemDecorations(displayId) + } + removedDisplays.forEach { displayId -> + listener.onDisplayRemoveSystemDecorations(displayId) + } + } + } + } + } + } +} diff --git a/displaylib/src/com/android/app/displaylib/InstanceLifecycleManager.kt b/displaylib/src/com/android/app/displaylib/InstanceLifecycleManager.kt new file mode 100644 index 0000000..c80315b --- /dev/null +++ b/displaylib/src/com/android/app/displaylib/InstanceLifecycleManager.kt @@ -0,0 +1,37 @@ +/* + * Copyright (C) 2025 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.app.displaylib + +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow + +/** + * Reports the display ids that should have a per-display instance, if any. + * + * This can be overridden to support different policies (e.g. display being connected, display + * having decorations, etc..). A [PerDisplayRepository] instance is expected to be cleaned up when a + * displayId is removed from this set. + */ +interface DisplayInstanceLifecycleManager { + /** Set of display ids that are allowed to have an instance. */ + val displayIds: StateFlow> +} + +/** Meant to be used in tests. */ +class FakeDisplayInstanceLifecycleManager : DisplayInstanceLifecycleManager { + override val displayIds = MutableStateFlow>(emptySet()) +} diff --git a/displaylib/src/com/android/app/displaylib/PerDisplayRepository.kt b/displaylib/src/com/android/app/displaylib/PerDisplayRepository.kt new file mode 100644 index 0000000..74bc572 --- /dev/null +++ b/displaylib/src/com/android/app/displaylib/PerDisplayRepository.kt @@ -0,0 +1,291 @@ +/* + * Copyright (C) 2025 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.app.displaylib + +import android.util.Log +import android.view.Display +import android.view.Display.DEFAULT_DISPLAY +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import java.util.concurrent.ConcurrentHashMap +import java.util.function.Consumer +import javax.inject.Qualifier +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.launch + +/** + * Used to create instances of type `T` for a specific display. + * + * This is useful for resources or objects that need to be managed independently for each connected + * display (e.g., UI state, rendering contexts, or display-specific configurations). + * + * Note that in most cases this can be implemented by a simple `@AssistedFactory` with `displayId` + * parameter + * + * ```kotlin + * class SomeType @AssistedInject constructor(@Assisted displayId: Int,..) + * @AssistedFactory + * interface Factory { + * fun create(displayId: Int): SomeType + * } + * } + * ``` + * + * Then it can be used to create a [PerDisplayRepository] as follows: + * ```kotlin + * // Injected: + * val repositoryFactory: PerDisplayRepositoryImpl.Factory + * val instanceFactory: PerDisplayRepositoryImpl.Factory + * // repository creation: + * repositoryFactory.create(instanceFactory::create) + * ``` + * + * @see PerDisplayRepository For how to retrieve and manage instances created by this factory. + */ +fun interface PerDisplayInstanceProvider { + /** Creates an instance for a display. */ + fun createInstance(displayId: Int): T? +} + +/** + * Extends [PerDisplayInstanceProvider], adding support for destroying the instance. + * + * This is useful for releasing resources associated with a display when it is disconnected or when + * the per-display instance is no longer needed. + */ +interface PerDisplayInstanceProviderWithTeardown : PerDisplayInstanceProvider { + /** Destroys a previously created instance of `T` forever. */ + fun destroyInstance(instance: T) +} + +/** + * Provides access to per-display instances of type `T`. + * + * Acts as a repository, managing the caching and retrieval of instances created by a + * [PerDisplayInstanceProvider]. It ensures that only one instance of `T` exists per display ID. + */ +interface PerDisplayRepository { + /** Gets the cached instance or create a new one for a given display. */ + operator fun get(displayId: Int): T? + + /** Debug name for this repository, mainly for tracing and logging. */ + val debugName: String + + /** + * Callback to run when a given repository is initialized. + * + * This allows the caller to perform custom logic when the repository is ready to be used, e.g. + * register to dumpManager. + * + * Note that the instance is *leaked* outside of this class, so it should only be done when + * repository is meant to live as long as the caller. In systemUI this is ok because the + * repository lives as long as the process itself. + */ + fun interface InitCallback { + fun onInit(debugName: String, instance: Any) + } + + /** + * Iterate over all the available displays performing the action on each object of type T. + * + * @param createIfAbsent If true, create instances of T if they are not already created. If + * false, do not and skip calling action.. + * @param action The action to perform on each instance. + */ + fun forEach(createIfAbsent: Boolean, action: Consumer) +} + +/** Qualifier for [CoroutineScope] used for displaylib background tasks. */ +@Qualifier @Retention(AnnotationRetention.RUNTIME) annotation class DisplayLibBackground + +/** + * Default implementation of [PerDisplayRepository]. + * + * This class manages a cache of per-display instances of type `T`, creating them using a provided + * [PerDisplayInstanceProvider] and optionally tearing them down using a + * [PerDisplayInstanceProviderWithTeardown] when based on [lifecycleManager]. + * + * An instance will be destroyed when either + * - The display is not connected anymore + * - or based on [lifecycleManager]. If no lifecycle manager is provided, instances are destroyed + * when the display is disconnected. + * + * [DisplayInstanceLifecycleManager] can decide to delete instances for a display even before it is + * disconnected. An example of usecase for it, is to delete instances when screen decorations are + * removed. + * + * Note that this is a [PerDisplayStoreImpl] 2.0 that doesn't require [CoreStartable] bindings, + * providing all args in the constructor. + */ +class PerDisplayInstanceRepositoryImpl +@AssistedInject +constructor( + @Assisted override val debugName: String, + @Assisted private val instanceProvider: PerDisplayInstanceProvider, + @Assisted lifecycleManager: DisplayInstanceLifecycleManager? = null, + @DisplayLibBackground bgApplicationScope: CoroutineScope, + private val displayRepository: DisplayRepository, + private val initCallback: PerDisplayRepository.InitCallback, +) : PerDisplayRepository { + + private val perDisplayInstances = ConcurrentHashMap() + + private val allowedDisplays: StateFlow> = + (if (lifecycleManager == null) { + displayRepository.displayIds + } else { + // If there is a lifecycle manager, we still consider the smallest subset between + // the ones connected and the ones from the lifecycle. This is to safeguard against + // leaks, in case of lifecycle manager misbehaving (as it's provided by clients, and + // we can't guarantee it's correct). + combine(lifecycleManager.displayIds, displayRepository.displayIds) { + lifecycleAllowedDisplayIds, + connectedDisplays -> + lifecycleAllowedDisplayIds.intersect(connectedDisplays) + } + }) as StateFlow> + + init { + bgApplicationScope.launch { start() } + } + + private suspend fun start() { + initCallback.onInit(debugName, this) + allowedDisplays.collectLatest { displayIds -> + val toRemove = perDisplayInstances.keys - displayIds + toRemove.forEach { displayId -> + Log.d(TAG, "<$debugName> destroying instance for displayId=$displayId.") + perDisplayInstances.remove(displayId)?.let { instance -> + (instanceProvider as? PerDisplayInstanceProviderWithTeardown)?.destroyInstance( + instance + ) + } + } + } + } + + override fun get(displayId: Int): T? { + if (!displayRepository.containsDisplay(displayId)) { + Log.e(TAG, "<$debugName: Display with id $displayId doesn't exist.") + return null + } + + if (displayId !in allowedDisplays.value) { + Log.e( + TAG, + "<$debugName: Display with id $displayId exists but it's not " + + "allowed by lifecycle manager.", + ) + return null + } + + // If it doesn't exist, create it and put it in the map. + return perDisplayInstances.computeIfAbsent(displayId) { key -> + Log.d(TAG, "<$debugName> creating instance for displayId=$key, as it wasn't available.") + val instance = instanceProvider.createInstance(key) + if (instance == null) { + Log.e( + TAG, + "<$debugName> returning null because createInstance($key) returned null.", + ) + } + instance + } + } + + @AssistedFactory + interface Factory { + fun create( + debugName: String, + instanceProvider: PerDisplayInstanceProvider, + overrideLifecycleManager: DisplayInstanceLifecycleManager? = null, + ): PerDisplayInstanceRepositoryImpl + } + + companion object { + private const val TAG = "PerDisplayInstanceRepo" + } + + override fun toString(): String { + return "PerDisplayInstanceRepositoryImpl(" + + "debugName='$debugName', instances=$perDisplayInstances)" + } + + override fun forEach(createIfAbsent: Boolean, action: Consumer) { + if (createIfAbsent) { + allowedDisplays.value.forEach { displayId -> get(displayId)?.let { action.accept(it) } } + } else { + perDisplayInstances.forEach { (_, instance) -> instance?.let { action.accept(it) } } + } + } +} + +/** + * Provides an instance of a given class **only** for the default display, even if asked for another + * display. + * + * This is useful in case of **flag refactors**: it can be provided instead of an instance of + * [PerDisplayInstanceRepositoryImpl] when a flag related to multi display refactoring is off. + * + * Note that this still requires all instances to be provided by a [PerDisplayInstanceProvider]. If + * you want to provide an existing instance instead for the default display, either implement it in + * a custom [PerDisplayInstanceProvider] (e.g. inject it in the constructor and return it if the + * displayId is zero), or use [SingleInstanceRepositoryImpl]. + */ +class DefaultDisplayOnlyInstanceRepositoryImpl( + override val debugName: String, + private val instanceProvider: PerDisplayInstanceProvider, +) : PerDisplayRepository { + private val lazyDefaultDisplayInstanceDelegate = lazy { + instanceProvider.createInstance(Display.DEFAULT_DISPLAY) + } + private val lazyDefaultDisplayInstance by lazyDefaultDisplayInstanceDelegate + + override fun get(displayId: Int): T? = lazyDefaultDisplayInstance + + override fun forEach(createIfAbsent: Boolean, action: Consumer) { + if (createIfAbsent) { + get(DEFAULT_DISPLAY)?.let { action.accept(it) } + } else { + if (lazyDefaultDisplayInstanceDelegate.isInitialized()) { + lazyDefaultDisplayInstance?.let { action.accept(it) } + } + } + } +} + +/** + * Always returns [instance] for any display. + * + * This can be used to provide a single instance based on a flag value during a refactor. Similar to + * [DefaultDisplayOnlyInstanceRepositoryImpl], but also avoids creating the + * [PerDisplayInstanceProvider]. This is useful when you want to provide an existing instance only, + * without even instantiating a [PerDisplayInstanceProvider]. + */ +class SingleInstanceRepositoryImpl(override val debugName: String, private val instance: T) : + PerDisplayRepository { + override fun get(displayId: Int): T? = instance + + override fun forEach(createIfAbsent: Boolean, action: Consumer) { + action.accept(instance) + } +} diff --git a/displaylib/src/com/android/app/displaylib/fakes/FakePerDisplayRepository.kt b/displaylib/src/com/android/app/displaylib/fakes/FakePerDisplayRepository.kt new file mode 100644 index 0000000..c832462 --- /dev/null +++ b/displaylib/src/com/android/app/displaylib/fakes/FakePerDisplayRepository.kt @@ -0,0 +1,50 @@ +/* + * Copyright (C) 2025 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.app.displaylib.fakes + +import com.android.app.displaylib.PerDisplayRepository +import java.util.function.Consumer + +/** Fake version of [PerDisplayRepository], to be used in tests. */ +class FakePerDisplayRepository(private val defaultIfAbsent: ((Int) -> T)? = null) : + PerDisplayRepository { + + private val instances = mutableMapOf() + + fun add(displayId: Int, instance: T) { + instances[displayId] = instance + } + + fun remove(displayId: Int) { + instances.remove(displayId) + } + + override fun get(displayId: Int): T? { + return if (defaultIfAbsent != null) { + instances.getOrPut(displayId) { defaultIfAbsent(displayId) } + } else { + instances[displayId] + } + } + + override val debugName: String + get() = "FakePerDisplayRepository" + + override fun forEach(createIfAbsent: Boolean, action: Consumer) { + instances.forEach { (_, t) -> action.accept(t) } + } +} diff --git a/displaylib/tests/Android.bp b/displaylib/tests/Android.bp new file mode 100644 index 0000000..2c7d115 --- /dev/null +++ b/displaylib/tests/Android.bp @@ -0,0 +1,34 @@ +// Copyright (C) 2025 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 { + default_applicable_licenses: ["Android-Apache-2.0"], +} + +android_test { + name: "displaylib_tests", + manifest: "AndroidManifest.xml", + static_libs: [ + "displaylib", + "androidx.test.ext.junit", + "androidx.test.rules", + "truth", + "//frameworks/libs/systemui:tracinglib-platform", + ], + srcs: [ + "tests/src/**/*.kt", + ], + kotlincflags: ["-Xjvm-default=all"], + test_suites: ["device-tests"], +} diff --git a/displaylib/tests/AndroidManifest.xml b/displaylib/tests/AndroidManifest.xml new file mode 100644 index 0000000..b45a4ec --- /dev/null +++ b/displaylib/tests/AndroidManifest.xml @@ -0,0 +1,23 @@ + + + + + diff --git a/displaylib/tests/src/com/android/app/displaylib/DisplayRepositoryTest.kt b/displaylib/tests/src/com/android/app/displaylib/DisplayRepositoryTest.kt new file mode 100644 index 0000000..81a26cb --- /dev/null +++ b/displaylib/tests/src/com/android/app/displaylib/DisplayRepositoryTest.kt @@ -0,0 +1,31 @@ +/* + * Copyright (C) 2025 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.app.displaylib + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.filters.SmallTest +import org.junit.runner.RunWith + +/** + * Tests for display repository are in SystemUI: + * frameworks/base/packages/SystemUI/multivalentTestsForDevice/src/com/android/systemui/display/data/repository/DisplayRepositoryTest.kt + * + * This is because the repository was initially there, and tests depend on kosmos for dependency + * injection (which is sysui-specific). + * + * In case of changes, update tests in sysui. + */ +@SmallTest @RunWith(AndroidJUnit4::class) class DisplayRepositoryTest From 8721978d35e71f631199f961ff79ddcc275cfdb3 Mon Sep 17 00:00:00 2001 From: Pun Butrach Date: Sun, 16 Nov 2025 20:15:50 +0700 Subject: [PATCH 11/30] feat: Displaylib Android 16 QPR1 --- README.md | 1 + displaylib/build.gradle | 28 ++++++++++++++++++++++++++++ 2 files changed, 29 insertions(+) create mode 100644 displaylib/build.gradle diff --git a/README.md b/README.md index 00535d5..47ceb44 100644 --- a/README.md +++ b/README.md @@ -5,5 +5,6 @@ This repository contains multiple libraries in SystemUI used by Lawnchair. A brief explanation of what each library does: * `animationlib`: Handling playback and interpolator of the animations * `contextualeducationlib`: Store "education" type +* `displaylib`: Handling presumably desktop displays * `iconloaderlib`: Handling all of Launcher3 and Lawnchair icons * `msdllib`: Multi-Sensory-Design-Language, handling all new vibrations in Launcher3 Android 16 diff --git a/displaylib/build.gradle b/displaylib/build.gradle new file mode 100644 index 0000000..1135b1e --- /dev/null +++ b/displaylib/build.gradle @@ -0,0 +1,28 @@ +plugins { + id 'com.android.library' + id 'org.jetbrains.kotlin.android' + alias(libs.plugins.google.ksp) +} + +android { + namespace "com.android.app.displaylib" + sourceSets { + main { + java.srcDirs = ['src'] + } + } +} + +addFrameworkJar('framework-16.jar') + +dependencies { + implementation libs.javax.inject + implementation libs.kotlinx.coroutines.android + ksp libs.dagger.compiler + implementation libs.dagger.hilt.android + ksp libs.dagger.hilt.compiler +} + +ksp { + arg("dagger.hilt.disableModulesHaveInstallInCheck", "true") +} From 012c074584fef2cdd2816bfa9c7f1c4ca3e99410 Mon Sep 17 00:00:00 2001 From: Pun Butrach Date: Tue, 18 Nov 2025 21:11:56 +0700 Subject: [PATCH 12/30] feat: Mechanics --- mechanics/Android.bp | 35 + mechanics/AndroidManifest.xml | 19 + mechanics/TEST_MAPPING | 38 + mechanics/benchmark/AndroidManifest.xml | 15 + .../benchmark/benchmark-proguard-rules.pro | 37 + .../benchmark/ComposeBaselineBenchmark.kt | 105 + .../mechanics/benchmark/ComposeStateTest.kt | 168 ++ .../benchmark/MotionValueBenchmark.kt | 260 ++ mechanics/build.gradle | 50 + mechanics/compose/Android.bp | 33 + mechanics/compose/AndroidManifest.xml | 20 + .../VerticalFadeContentRevealModifier.kt | 229 ++ .../VerticalTactileSurfaceRevealModifier.kt | 250 ++ mechanics/compose/tests/AndroidManifest.xml | 28 + .../com/android/mechanics/GestureContext.kt | 205 ++ .../src/com/android/mechanics/MotionValue.kt | 466 ++++ .../VerticalExpandContainerBackground.kt | 196 ++ .../behavior/VerticalExpandContainerSpec.kt | 161 ++ .../android/mechanics/debug/DebugInspector.kt | 90 + .../mechanics/debug/DebugVisualization.kt | 510 ++++ .../mechanics/debug/MotionValueDebugger.kt | 130 + .../com/android/mechanics/effects/Fixed.kt | 55 + .../mechanics/effects/MagneticDetach.kt | 259 ++ .../com/android/mechanics/effects/Overdrag.kt | 69 + .../mechanics/effects/RevealOnThreshold.kt | 56 + .../mechanics/impl/ComputationInput.kt | 101 + .../android/mechanics/impl/Computations.kt | 576 +++++ .../mechanics/impl/DiscontinuityAnimation.kt | 44 + .../android/mechanics/impl/GuaranteeState.kt | 77 + .../mechanics/impl/SegmentChangeType.kt | 67 + .../com/android/mechanics/spec/Breakpoint.kt | 120 + .../com/android/mechanics/spec/Guarantee.kt | 45 + .../android/mechanics/spec/InputDirection.kt | 31 + .../com/android/mechanics/spec/MotionSpec.kt | 239 ++ .../spec/MotionSpecDebugFormatter.kt | 121 + .../src/com/android/mechanics/spec/Segment.kt | 155 ++ .../mechanics/spec/SegmentChangeHandler.kt | 48 + .../android/mechanics/spec/SemanticValue.kt | 74 + .../spec/builder/DirectionalBuilderImpl.kt | 388 +++ .../spec/builder/DirectionalBuilderScope.kt | 273 +++ .../spec/builder/DirectionalSpecBuilder.kt | 128 + .../android/mechanics/spec/builder/Effect.kt | 68 + .../spec/builder/EffectApplyScope.kt | 182 ++ .../mechanics/spec/builder/EffectPlacement.kt | 111 + .../spec/builder/MotionBuilderContext.kt | 104 + .../spec/builder/MotionSpecBuilder.kt | 162 ++ .../spec/builder/MotionSpecBuilderImpl.kt | 589 +++++ .../spring/MaterialSpringParameters.kt | 49 + .../mechanics/spring/SpringParameters.kt | 79 + .../android/mechanics/spring/SpringState.kt | 134 ++ .../mechanics/view/ViewGestureContext.kt | 135 ++ .../view/ViewMotionBuilderContext.kt | 127 + .../android/mechanics/view/ViewMotionValue.kt | 342 +++ mechanics/testing/Android.bp | 37 + mechanics/testing/AndroidManifest.xml | 20 + .../testing/ComposeMotionValueToolkit.kt | 176 ++ .../mechanics/testing/DataPointTypes.kt | 69 + .../testing/FakeMotionSpecBuilderContext.kt | 54 + .../mechanics/testing/FeatureCaptures.kt | 71 + .../mechanics/testing/MotionSpecSubject.kt | 308 +++ .../mechanics/testing/MotionValueToolkit.kt | 230 ++ .../android/mechanics/testing/TimeSeries.kt | 55 + .../testing/ViewMotionValueToolkit.kt | 167 ++ mechanics/tests/Android.bp | 50 + mechanics/tests/AndroidManifest.xml | 28 + ...placedAfter_afterAttach_detachesAgain.json | 662 +++++ .../placedAfter_attach_snapsToOrigin.json | 392 +++ ...foreAttach_suppressesDirectionReverse.json | 162 ++ ...foreDetach_suppressesDirectionReverse.json | 162 ++ .../placedAfter_detach_animatesDetach.json | 432 ++++ ...dAfter_placedWithDifferentBaseMapping.json | 392 +++ ...lacedBefore_afterAttach_detachesAgain.json | 662 +++++ .../placedBefore_attach_snapsToOrigin.json | 392 +++ ...foreAttach_suppressesDirectionReverse.json | 162 ++ ...foreDetach_suppressesDirectionReverse.json | 202 ++ .../placedBefore_detach_animatesDetach.json | 432 ++++ ...Before_placedWithDifferentBaseMapping.json | 392 +++ ...ction_flipsBetweenDirectionalSegments.json | 192 ++ ..._addsAnimationToMapping_becomesStable.json | 72 + .../criticallyDamped_matchesGolden.json | 814 +++++++ ...edValue_hasAnimationLifecycleOnItsOwn.json | 534 ++++ ...dValue_reflectsInputChangeInSameFrame.json | 458 ++++ ...appliesGuarantee_afterDirectionChange.json | 172 ++ ...Min_changesSegmentWithDirectionChange.json | 252 ++ ...Max_changesSegmentWithDirectionChange.json | 252 ++ .../goldens/doNothingBeforeThreshold.json | 92 + ...c_outputMatchesInput_withoutAnimation.json | 102 + mechanics/tests/goldens/hideAnimation.json | 322 +++ .../goldens/hideAnimationOnThreshold.json | 322 +++ .../goldens/overDamped_matchesGolden.json | 2142 +++++++++++++++++ ..._maxDirection_neverExceedsMaxOverdrag.json | 279 +++ ..._minDirection_neverExceedsMaxOverdrag.json | 279 +++ .../overdrag_nonStandardBaseFunction.json | 268 +++ mechanics/tests/goldens/revealAnimation.json | 292 +++ .../revealAnimation_afterFixedValue.json | 292 +++ ...animationAtRest_doesNotAffectVelocity.json | 312 +++ ...e_appliesOutputVelocity_atSpringStart.json | 272 +++ ...ocity_springVelocityIsNotAppliedTwice.json | 312 +++ ...y_velocityAddedOnDiscontinuousSegment.json | 372 +++ ...y_velocityNotAddedOnContinuousSegment.json | 262 ++ ...agDelta_springCompletesWithinDistance.json | 122 + ...utDelta_springCompletesWithinDistance.json | 132 + ...None_springAnimatesIndependentOfInput.json | 212 ++ ...ection_animatedWhenReachingBreakpoint.json | 212 ++ ...n_springAnimationStartedRetroactively.json | 202 ++ ...egmentChange_inMaxDirection_zeroDelta.json | 82 + ...ection_animatedWhenReachingBreakpoint.json | 212 ++ ...n_springAnimationStartedRetroactively.json | 202 ++ ...ntics_flipsBetweenDirectionalSegments.json | 323 +++ ...ring_updatesImmediately_matchesGolden.json | 54 + ...teWithinSegment_animatesSegmentChange.json | 182 ++ ...teWithinSegment_animatesSegmentChange.json | 162 ++ .../stiffeningSpring_matchesGolden.json | 182 ++ ...ame_noGuarantee_combinesDiscontinuity.json | 202 ++ ...withDirectionChange_appliesGuarantees.json | 142 ++ ...Frame_withGuarantee_appliesGuarantees.json | 172 ++ ...e_addsDiscontinuityToOngoingAnimation.json | 292 +++ .../goldens/underDamped_matchesGolden.json | 1870 ++++++++++++++ ...c_outputMatchesInput_withoutAnimation.json | 102 + ...ontext_listensToGestureContextUpdates.json | 122 + ...Change_animatedWhenReachingBreakpoint.json | 212 ++ .../view/specChange_triggersAnimation.json | 152 ++ ...acement_initialVelocity_matchesGolden.json | 1318 ++++++++++ .../mechanics/DistanceGestureContextTest.kt | 151 ++ .../mechanics/MotionValueLifecycleTest.kt | 176 ++ .../com/android/mechanics/MotionValueTest.kt | 675 ++++++ .../debug/MotionValueDebuggerTest.kt | 94 + .../mechanics/effects/MagneticDetachTest.kt | 252 ++ .../android/mechanics/effects/OverdragTest.kt | 129 + .../effects/RevealOnThresholdTest.kt | 118 + .../spec/DirectionalMotionSpecTest.kt | 200 ++ .../spec/MotionSpecDebugFormatterTest.kt | 145 ++ .../android/mechanics/spec/MotionSpecTest.kt | 320 +++ .../com/android/mechanics/spec/SegmentTest.kt | 106 + .../builder/DirectionalBuilderImplTest.kt | 295 +++ .../spec/builder/MotionSpecBuilderTest.kt | 740 ++++++ ...poseAndMechanicsSpringCompatibilityTest.kt | 152 ++ .../mechanics/spring/SpringParameterTest.kt | 58 + .../mechanics/spring/SpringStateTest.kt | 144 ++ .../mechanics/view/ViewGestureContextTest.kt | 202 ++ .../view/ViewMotionBuilderContextTest.kt | 77 + .../mechanics/view/ViewMotionValueTest.kt | 261 ++ 142 files changed, 33586 insertions(+) create mode 100644 mechanics/Android.bp create mode 100644 mechanics/AndroidManifest.xml create mode 100644 mechanics/TEST_MAPPING create mode 100644 mechanics/benchmark/AndroidManifest.xml create mode 100644 mechanics/benchmark/benchmark-proguard-rules.pro create mode 100644 mechanics/benchmark/tests/src/com/android/mechanics/benchmark/ComposeBaselineBenchmark.kt create mode 100644 mechanics/benchmark/tests/src/com/android/mechanics/benchmark/ComposeStateTest.kt create mode 100644 mechanics/benchmark/tests/src/com/android/mechanics/benchmark/MotionValueBenchmark.kt create mode 100644 mechanics/build.gradle create mode 100644 mechanics/compose/Android.bp create mode 100644 mechanics/compose/AndroidManifest.xml create mode 100644 mechanics/compose/src/com/android/mechanics/compose/modifier/VerticalFadeContentRevealModifier.kt create mode 100644 mechanics/compose/src/com/android/mechanics/compose/modifier/VerticalTactileSurfaceRevealModifier.kt create mode 100644 mechanics/compose/tests/AndroidManifest.xml create mode 100644 mechanics/src/com/android/mechanics/GestureContext.kt create mode 100644 mechanics/src/com/android/mechanics/MotionValue.kt create mode 100644 mechanics/src/com/android/mechanics/behavior/VerticalExpandContainerBackground.kt create mode 100644 mechanics/src/com/android/mechanics/behavior/VerticalExpandContainerSpec.kt create mode 100644 mechanics/src/com/android/mechanics/debug/DebugInspector.kt create mode 100644 mechanics/src/com/android/mechanics/debug/DebugVisualization.kt create mode 100644 mechanics/src/com/android/mechanics/debug/MotionValueDebugger.kt create mode 100644 mechanics/src/com/android/mechanics/effects/Fixed.kt create mode 100644 mechanics/src/com/android/mechanics/effects/MagneticDetach.kt create mode 100644 mechanics/src/com/android/mechanics/effects/Overdrag.kt create mode 100644 mechanics/src/com/android/mechanics/effects/RevealOnThreshold.kt create mode 100644 mechanics/src/com/android/mechanics/impl/ComputationInput.kt create mode 100644 mechanics/src/com/android/mechanics/impl/Computations.kt create mode 100644 mechanics/src/com/android/mechanics/impl/DiscontinuityAnimation.kt create mode 100644 mechanics/src/com/android/mechanics/impl/GuaranteeState.kt create mode 100644 mechanics/src/com/android/mechanics/impl/SegmentChangeType.kt create mode 100644 mechanics/src/com/android/mechanics/spec/Breakpoint.kt create mode 100644 mechanics/src/com/android/mechanics/spec/Guarantee.kt create mode 100644 mechanics/src/com/android/mechanics/spec/InputDirection.kt create mode 100644 mechanics/src/com/android/mechanics/spec/MotionSpec.kt create mode 100644 mechanics/src/com/android/mechanics/spec/MotionSpecDebugFormatter.kt create mode 100644 mechanics/src/com/android/mechanics/spec/Segment.kt create mode 100644 mechanics/src/com/android/mechanics/spec/SegmentChangeHandler.kt create mode 100644 mechanics/src/com/android/mechanics/spec/SemanticValue.kt create mode 100644 mechanics/src/com/android/mechanics/spec/builder/DirectionalBuilderImpl.kt create mode 100644 mechanics/src/com/android/mechanics/spec/builder/DirectionalBuilderScope.kt create mode 100644 mechanics/src/com/android/mechanics/spec/builder/DirectionalSpecBuilder.kt create mode 100644 mechanics/src/com/android/mechanics/spec/builder/Effect.kt create mode 100644 mechanics/src/com/android/mechanics/spec/builder/EffectApplyScope.kt create mode 100644 mechanics/src/com/android/mechanics/spec/builder/EffectPlacement.kt create mode 100644 mechanics/src/com/android/mechanics/spec/builder/MotionBuilderContext.kt create mode 100644 mechanics/src/com/android/mechanics/spec/builder/MotionSpecBuilder.kt create mode 100644 mechanics/src/com/android/mechanics/spec/builder/MotionSpecBuilderImpl.kt create mode 100644 mechanics/src/com/android/mechanics/spring/MaterialSpringParameters.kt create mode 100644 mechanics/src/com/android/mechanics/spring/SpringParameters.kt create mode 100644 mechanics/src/com/android/mechanics/spring/SpringState.kt create mode 100644 mechanics/src/com/android/mechanics/view/ViewGestureContext.kt create mode 100644 mechanics/src/com/android/mechanics/view/ViewMotionBuilderContext.kt create mode 100644 mechanics/src/com/android/mechanics/view/ViewMotionValue.kt create mode 100644 mechanics/testing/Android.bp create mode 100644 mechanics/testing/AndroidManifest.xml create mode 100644 mechanics/testing/src/com/android/mechanics/testing/ComposeMotionValueToolkit.kt create mode 100644 mechanics/testing/src/com/android/mechanics/testing/DataPointTypes.kt create mode 100644 mechanics/testing/src/com/android/mechanics/testing/FakeMotionSpecBuilderContext.kt create mode 100644 mechanics/testing/src/com/android/mechanics/testing/FeatureCaptures.kt create mode 100644 mechanics/testing/src/com/android/mechanics/testing/MotionSpecSubject.kt create mode 100644 mechanics/testing/src/com/android/mechanics/testing/MotionValueToolkit.kt create mode 100644 mechanics/testing/src/com/android/mechanics/testing/TimeSeries.kt create mode 100644 mechanics/testing/src/com/android/mechanics/testing/ViewMotionValueToolkit.kt create mode 100644 mechanics/tests/Android.bp create mode 100644 mechanics/tests/AndroidManifest.xml create mode 100644 mechanics/tests/goldens/MagneticDetach/placedAfter_afterAttach_detachesAgain.json create mode 100644 mechanics/tests/goldens/MagneticDetach/placedAfter_attach_snapsToOrigin.json create mode 100644 mechanics/tests/goldens/MagneticDetach/placedAfter_beforeAttach_suppressesDirectionReverse.json create mode 100644 mechanics/tests/goldens/MagneticDetach/placedAfter_beforeDetach_suppressesDirectionReverse.json create mode 100644 mechanics/tests/goldens/MagneticDetach/placedAfter_detach_animatesDetach.json create mode 100644 mechanics/tests/goldens/MagneticDetach/placedAfter_placedWithDifferentBaseMapping.json create mode 100644 mechanics/tests/goldens/MagneticDetach/placedBefore_afterAttach_detachesAgain.json create mode 100644 mechanics/tests/goldens/MagneticDetach/placedBefore_attach_snapsToOrigin.json create mode 100644 mechanics/tests/goldens/MagneticDetach/placedBefore_beforeAttach_suppressesDirectionReverse.json create mode 100644 mechanics/tests/goldens/MagneticDetach/placedBefore_beforeDetach_suppressesDirectionReverse.json create mode 100644 mechanics/tests/goldens/MagneticDetach/placedBefore_detach_animatesDetach.json create mode 100644 mechanics/tests/goldens/MagneticDetach/placedBefore_placedWithDifferentBaseMapping.json create mode 100644 mechanics/tests/goldens/changeDirection_flipsBetweenDirectionalSegments.json create mode 100644 mechanics/tests/goldens/changingInput_addsAnimationToMapping_becomesStable.json create mode 100644 mechanics/tests/goldens/criticallyDamped_matchesGolden.json create mode 100644 mechanics/tests/goldens/derivedValue_hasAnimationLifecycleOnItsOwn.json create mode 100644 mechanics/tests/goldens/derivedValue_reflectsInputChangeInSameFrame.json create mode 100644 mechanics/tests/goldens/directionChange_maxToMin_appliesGuarantee_afterDirectionChange.json create mode 100644 mechanics/tests/goldens/directionChange_maxToMin_changesSegmentWithDirectionChange.json create mode 100644 mechanics/tests/goldens/directionChange_minToMax_changesSegmentWithDirectionChange.json create mode 100644 mechanics/tests/goldens/doNothingBeforeThreshold.json create mode 100644 mechanics/tests/goldens/emptySpec_outputMatchesInput_withoutAnimation.json create mode 100644 mechanics/tests/goldens/hideAnimation.json create mode 100644 mechanics/tests/goldens/hideAnimationOnThreshold.json create mode 100644 mechanics/tests/goldens/overDamped_matchesGolden.json create mode 100644 mechanics/tests/goldens/overdrag_maxDirection_neverExceedsMaxOverdrag.json create mode 100644 mechanics/tests/goldens/overdrag_minDirection_neverExceedsMaxOverdrag.json create mode 100644 mechanics/tests/goldens/overdrag_nonStandardBaseFunction.json create mode 100644 mechanics/tests/goldens/revealAnimation.json create mode 100644 mechanics/tests/goldens/revealAnimation_afterFixedValue.json create mode 100644 mechanics/tests/goldens/segmentChange_animationAtRest_doesNotAffectVelocity.json create mode 100644 mechanics/tests/goldens/segmentChange_appliesOutputVelocity_atSpringStart.json create mode 100644 mechanics/tests/goldens/segmentChange_appliesOutputVelocity_springVelocityIsNotAppliedTwice.json create mode 100644 mechanics/tests/goldens/segmentChange_appliesOutputVelocity_velocityAddedOnDiscontinuousSegment.json create mode 100644 mechanics/tests/goldens/segmentChange_appliesOutputVelocity_velocityNotAddedOnContinuousSegment.json create mode 100644 mechanics/tests/goldens/segmentChange_guaranteeGestureDragDelta_springCompletesWithinDistance.json create mode 100644 mechanics/tests/goldens/segmentChange_guaranteeInputDelta_springCompletesWithinDistance.json create mode 100644 mechanics/tests/goldens/segmentChange_guaranteeNone_springAnimatesIndependentOfInput.json create mode 100644 mechanics/tests/goldens/segmentChange_inMaxDirection_animatedWhenReachingBreakpoint.json create mode 100644 mechanics/tests/goldens/segmentChange_inMaxDirection_springAnimationStartedRetroactively.json create mode 100644 mechanics/tests/goldens/segmentChange_inMaxDirection_zeroDelta.json create mode 100644 mechanics/tests/goldens/segmentChange_inMinDirection_animatedWhenReachingBreakpoint.json create mode 100644 mechanics/tests/goldens/segmentChange_inMinDirection_springAnimationStartedRetroactively.json create mode 100644 mechanics/tests/goldens/semantics_flipsBetweenDirectionalSegments.json create mode 100644 mechanics/tests/goldens/snapSpring_updatesImmediately_matchesGolden.json create mode 100644 mechanics/tests/goldens/specChange_shiftSegmentBackwards_doesNotAnimateWithinSegment_animatesSegmentChange.json create mode 100644 mechanics/tests/goldens/specChange_shiftSegmentForward_doesNotAnimateWithinSegment_animatesSegmentChange.json create mode 100644 mechanics/tests/goldens/stiffeningSpring_matchesGolden.json create mode 100644 mechanics/tests/goldens/traverseSegmentsInOneFrame_noGuarantee_combinesDiscontinuity.json create mode 100644 mechanics/tests/goldens/traverseSegmentsInOneFrame_withDirectionChange_appliesGuarantees.json create mode 100644 mechanics/tests/goldens/traverseSegmentsInOneFrame_withGuarantee_appliesGuarantees.json create mode 100644 mechanics/tests/goldens/traverseSegments_maxDirection_noGuarantee_addsDiscontinuityToOngoingAnimation.json create mode 100644 mechanics/tests/goldens/underDamped_matchesGolden.json create mode 100644 mechanics/tests/goldens/view/emptySpec_outputMatchesInput_withoutAnimation.json create mode 100644 mechanics/tests/goldens/view/gestureContext_listensToGestureContextUpdates.json create mode 100644 mechanics/tests/goldens/view/segmentChange_animatedWhenReachingBreakpoint.json create mode 100644 mechanics/tests/goldens/view/specChange_triggersAnimation.json create mode 100644 mechanics/tests/goldens/zeroDisplacement_initialVelocity_matchesGolden.json create mode 100644 mechanics/tests/src/com/android/mechanics/DistanceGestureContextTest.kt create mode 100644 mechanics/tests/src/com/android/mechanics/MotionValueLifecycleTest.kt create mode 100644 mechanics/tests/src/com/android/mechanics/MotionValueTest.kt create mode 100644 mechanics/tests/src/com/android/mechanics/debug/MotionValueDebuggerTest.kt create mode 100644 mechanics/tests/src/com/android/mechanics/effects/MagneticDetachTest.kt create mode 100644 mechanics/tests/src/com/android/mechanics/effects/OverdragTest.kt create mode 100644 mechanics/tests/src/com/android/mechanics/effects/RevealOnThresholdTest.kt create mode 100644 mechanics/tests/src/com/android/mechanics/spec/DirectionalMotionSpecTest.kt create mode 100644 mechanics/tests/src/com/android/mechanics/spec/MotionSpecDebugFormatterTest.kt create mode 100644 mechanics/tests/src/com/android/mechanics/spec/MotionSpecTest.kt create mode 100644 mechanics/tests/src/com/android/mechanics/spec/SegmentTest.kt create mode 100644 mechanics/tests/src/com/android/mechanics/spec/builder/DirectionalBuilderImplTest.kt create mode 100644 mechanics/tests/src/com/android/mechanics/spec/builder/MotionSpecBuilderTest.kt create mode 100644 mechanics/tests/src/com/android/mechanics/spring/ComposeAndMechanicsSpringCompatibilityTest.kt create mode 100644 mechanics/tests/src/com/android/mechanics/spring/SpringParameterTest.kt create mode 100644 mechanics/tests/src/com/android/mechanics/spring/SpringStateTest.kt create mode 100644 mechanics/tests/src/com/android/mechanics/view/ViewGestureContextTest.kt create mode 100644 mechanics/tests/src/com/android/mechanics/view/ViewMotionBuilderContextTest.kt create mode 100644 mechanics/tests/src/com/android/mechanics/view/ViewMotionValueTest.kt diff --git a/mechanics/Android.bp b/mechanics/Android.bp new file mode 100644 index 0000000..d683892 --- /dev/null +++ b/mechanics/Android.bp @@ -0,0 +1,35 @@ +// 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 { + default_team: "trendy_team_motion", + default_applicable_licenses: ["Android-Apache-2.0"], +} + +android_library { + name: "mechanics", + manifest: "AndroidManifest.xml", + sdk_version: "system_current", + min_sdk_version: "31", + static_libs: [ + "androidx.compose.runtime_runtime", + "androidx.compose.material3_material3", + "androidx.compose.ui_ui-util", + "androidx.compose.foundation_foundation-layout", + ], + srcs: [ + "src/**/*.kt", + ], + kotlincflags: ["-Xjvm-default=all"], +} diff --git a/mechanics/AndroidManifest.xml b/mechanics/AndroidManifest.xml new file mode 100644 index 0000000..29874f3 --- /dev/null +++ b/mechanics/AndroidManifest.xml @@ -0,0 +1,19 @@ + + + + diff --git a/mechanics/TEST_MAPPING b/mechanics/TEST_MAPPING new file mode 100644 index 0000000..4dd86b9 --- /dev/null +++ b/mechanics/TEST_MAPPING @@ -0,0 +1,38 @@ +{ + "presubmit": [ + { + "name": "mechanics_tests", + "options": [ + {"exclude-annotation": "org.junit.Ignore"}, + {"exclude-annotation": "androidx.test.filters.FlakyTest"} + ] + }, + { + "name": "SystemUIGoogleTests", + "options": [ + {"exclude-annotation": "org.junit.Ignore"}, + {"exclude-annotation": "androidx.test.filters.FlakyTest"} + ] + }, + { + "name": "PlatformComposeSceneTransitionLayoutTests" + }, + { + "name": "PlatformComposeCoreTests" + } + ], + "presubmit-large": [ + { + "name": "SystemUITests", + "options": [ + {"exclude-annotation": "org.junit.Ignore"}, + {"exclude-annotation": "androidx.test.filters.FlakyTest"} + ] + } + ], + "wm-cf": [ + { + "name": "WMShellUnitTests" + } + ] +} diff --git a/mechanics/benchmark/AndroidManifest.xml b/mechanics/benchmark/AndroidManifest.xml new file mode 100644 index 0000000..405595c --- /dev/null +++ b/mechanics/benchmark/AndroidManifest.xml @@ -0,0 +1,15 @@ + + + + + + \ No newline at end of file diff --git a/mechanics/benchmark/benchmark-proguard-rules.pro b/mechanics/benchmark/benchmark-proguard-rules.pro new file mode 100644 index 0000000..e4061d2 --- /dev/null +++ b/mechanics/benchmark/benchmark-proguard-rules.pro @@ -0,0 +1,37 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile + +-dontobfuscate + +-ignorewarnings + +-keepattributes *Annotation* + +-dontnote junit.framework.** +-dontnote junit.runner.** + +-dontwarn androidx.test.** +-dontwarn org.junit.** +-dontwarn org.hamcrest.** +-dontwarn com.squareup.javawriter.JavaWriter + +-keepclasseswithmembers @org.junit.runner.RunWith public class * \ No newline at end of file diff --git a/mechanics/benchmark/tests/src/com/android/mechanics/benchmark/ComposeBaselineBenchmark.kt b/mechanics/benchmark/tests/src/com/android/mechanics/benchmark/ComposeBaselineBenchmark.kt new file mode 100644 index 0000000..c000dfe --- /dev/null +++ b/mechanics/benchmark/tests/src/com/android/mechanics/benchmark/ComposeBaselineBenchmark.kt @@ -0,0 +1,105 @@ +/* + * Copyright (C) 2025 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.benchmark + +import androidx.benchmark.junit4.BenchmarkRule +import androidx.benchmark.junit4.measureRepeated +import androidx.compose.animation.core.Animatable +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.snapshotFlow +import androidx.compose.runtime.snapshots.Snapshot +import androidx.compose.ui.util.fastForEach +import androidx.test.ext.junit.runners.AndroidJUnit4 +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.launch +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import platform.test.motion.compose.runMonotonicClockTest + +/** Benchmark, which will execute on an Android device. Previous results: go/mm-microbenchmarks */ +@RunWith(AndroidJUnit4::class) +class ComposeBaselineBenchmark { + @get:Rule val benchmarkRule = BenchmarkRule() + + // Compose specific + + @Test + fun writeState_1snapshotFlow() = runMonotonicClockTest { + val composeState = mutableFloatStateOf(0f) + + var lastRead = 0f + snapshotFlow { composeState.floatValue }.onEach { lastRead = it }.launchIn(backgroundScope) + + benchmarkRule.measureRepeated { + composeState.floatValue++ + Snapshot.sendApplyNotifications() + testScheduler.advanceTimeBy(16) + } + + check(lastRead == composeState.floatValue) { + "snapshotFlow lastRead $lastRead != ${composeState.floatValue} (current composeState)" + } + } + + @Test + fun writeState_100snapshotFlow() = runMonotonicClockTest { + val composeState = mutableFloatStateOf(0f) + + repeat(100) { snapshotFlow { composeState.floatValue }.launchIn(backgroundScope) } + + benchmarkRule.measureRepeated { + composeState.floatValue++ + Snapshot.sendApplyNotifications() + testScheduler.advanceTimeBy(16) + } + } + + @Test + fun readAnimatableValue_100animatables_keepRunning() = runMonotonicClockTest { + val anim = List(100) { Animatable(0f) } + + benchmarkRule.measureRepeated { + testScheduler.advanceTimeBy(16) + anim.fastForEach { + it.value + + if (!it.isRunning) { + launch { it.animateTo(if (it.targetValue != 0f) 0f else 1f) } + } + } + } + + testScheduler.advanceTimeBy(2000) + } + + @Test + fun readAnimatableValue_100animatables_restartEveryFrame() = runMonotonicClockTest { + val animatables = List(100) { Animatable(0f) } + + benchmarkRule.measureRepeated { + testScheduler.advanceTimeBy(16) + animatables.fastForEach { animatable -> + animatable.value + launch { animatable.animateTo(if (animatable.targetValue != 0f) 0f else 1f) } + } + } + + testScheduler.advanceTimeBy(2000) + } +} diff --git a/mechanics/benchmark/tests/src/com/android/mechanics/benchmark/ComposeStateTest.kt b/mechanics/benchmark/tests/src/com/android/mechanics/benchmark/ComposeStateTest.kt new file mode 100644 index 0000000..e70bc2b --- /dev/null +++ b/mechanics/benchmark/tests/src/com/android/mechanics/benchmark/ComposeStateTest.kt @@ -0,0 +1,168 @@ +/* + * Copyright (C) 2025 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.benchmark + +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.snapshotFlow +import androidx.compose.runtime.snapshots.Snapshot +import androidx.test.ext.junit.runners.AndroidJUnit4 +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import org.junit.Test +import org.junit.runner.RunWith +import platform.test.motion.compose.runMonotonicClockTest + +@RunWith(AndroidJUnit4::class) +class ComposeStateTest { + @Test + fun mutableState_sendApplyNotifications() = runMonotonicClockTest { + val mutableState = mutableStateOf(0f) + + var lastRead = -1f + snapshotFlow { mutableState.value }.onEach { lastRead = it }.launchIn(backgroundScope) + check(lastRead == -1f) { "[1] lastRead $lastRead, snapshotFlow launchIn" } + + // snapshotFlow will emit the first value (0f). + testScheduler.advanceTimeBy(1) + check(lastRead == 0f) { "[2] lastRead $lastRead, first advanceTimeBy()" } + + // update composeState x5. + repeat(5) { + mutableState.value++ + check(lastRead == 0f) { "[3 loop] lastRead $lastRead, composeState.floatValue++" } + + testScheduler.advanceTimeBy(1) + check(lastRead == 0f) { "[4 loop] lastRead $lastRead, advanceTimeBy()" } + } + + // Try to wait with a delay. It does nothing (lastRead == 0f). + delay(1) + check(mutableState.value == 5f) { "[5] mutableState ${mutableState.value}, after loop" } + check(lastRead == 0f) { "[5] lastRead $lastRead, after loop" } + + // This should trigger the flow. + Snapshot.sendApplyNotifications() + check(lastRead == 0f) { "[6] lastRead $lastRead, Snapshot.sendApplyNotifications()" } + + // lastRead will be updated (5f) after advanceTimeBy (or a delay). + testScheduler.advanceTimeBy(1) + check(lastRead == 5f) { "[7] lastRead $lastRead, advanceTimeBy" } + } + + @Test + fun derivedState_readNotRequireASendApplyNotifications() = runMonotonicClockTest { + val mutableState = mutableStateOf(0f) + + var derivedRuns = 0 + val derived = derivedStateOf { + derivedRuns++ + mutableState.value * 2f + } + check(derivedRuns == 0) { "[1] derivedRuns: $derivedRuns, should be 0" } + + var lastRead = -1f + snapshotFlow { derived.value }.onEach { lastRead = it }.launchIn(backgroundScope) + check(lastRead == -1f) { "[2] lastRead $lastRead, snapshotFlow launchIn" } + check(derivedRuns == 0) { "[2] derivedRuns: $derivedRuns, should be 0" } + + // snapshotFlow will emit the first value (0f * 2f = 0f). + testScheduler.advanceTimeBy(16) + check(lastRead == 0f) { "[3] lastRead $lastRead, first advanceTimeBy()" } + check(derivedRuns == 1) { "[3] derivedRuns: $derivedRuns, should be 1" } + + // update composeState x5. + repeat(5) { + mutableState.value++ + check(lastRead == 0f) { "[4 loop] lastRead $lastRead, composeState.floatValue++" } + + testScheduler.advanceTimeBy(16) + check(lastRead == 0f) { "[5 loop] lastRead $lastRead, advanceTimeBy()" } + } + + // Try to wait with a delay. It does nothing (lastRead == 0f). + delay(1) + check(mutableState.value == 5f) { "[6] mutableState ${mutableState.value}, after loop" } + check(lastRead == 0f) { "[6] lastRead $lastRead, after loop" } + check(derivedRuns == 1) { "[6] derivedRuns $derivedRuns, after loop" } + + // Reading a derived state, this will trigger the flow. + // NOTE: We are not using Snapshot.sendApplyNotifications() + derived.value + check(lastRead == 0f) { "[7] lastRead $lastRead, read derivedDouble" } + check(derivedRuns == 2) { "[7] derivedRuns $derivedRuns, read derived" } // Triggered + + // lastRead will be updated (5f * 2f = 10f) after advanceTimeBy (or a delay) + testScheduler.advanceTimeBy(16) + check(lastRead == 5f * 2f) { "[8] lastRead $lastRead, advanceTimeBy" } // New value + check(derivedRuns == 2) { "[8] derivedRuns $derivedRuns, read derived" } + } + + @Test + fun derivedState_readADerivedStateTriggerOthersDerivedState() = runMonotonicClockTest { + val mutableState = mutableStateOf(0f) + + var derivedRuns = 0 + val derived = derivedStateOf { + derivedRuns++ + mutableState.value + } + + var otherRuns = 0 + repeat(100) { + val otherState = derivedStateOf { + otherRuns++ + mutableState.value + } + // Observer all otherStates. + snapshotFlow { otherState.value }.launchIn(backgroundScope) + } + check(derivedRuns == 0) { "[1] derivedRuns: $derivedRuns" } + check(otherRuns == 0) { "[1] otherRuns: $otherRuns" } + + // Wait for snapshotFlow. + testScheduler.advanceTimeBy(16) + check(derivedRuns == 0) { "[2] derivedRuns: $derivedRuns" } + check(otherRuns == 100) { "[2] otherRuns: $otherRuns" } + + // This write might trigger all otherStates observed, but it does not. + mutableState.value++ + check(derivedRuns == 0) { "[3] derivedRuns: $derivedRuns" } + check(otherRuns == 100) { "[3] otherRuns: $otherRuns" } + + // Wait for several frames, but still doesn't trigger otherStates. + repeat(10) { testScheduler.advanceTimeBy(16) } + check(derivedRuns == 0) { "[4] derivedRuns: $derivedRuns" } + check(otherRuns == 100) { "[4] otherRuns: $otherRuns" } + + // Reading derived state will trigger all otherStates. + // This behavior is causing us some problems, because reading a derived state causes all + // the + // dirty derived states to be reread, and this can happen multiple times per frame, + // making + // derived states much more expensive than one might expect. + derived.value + check(derivedRuns == 1) { "[5] derivedRuns: $derivedRuns" } + check(otherRuns == 100) { "[5] otherRuns: $otherRuns" } + + // Now we pay the cost of those derived states. + testScheduler.advanceTimeBy(1) + check(derivedRuns == 1) { "[6] derivedRuns: $derivedRuns" } + check(otherRuns == 200) { "[6] otherRuns: $otherRuns" } + } +} diff --git a/mechanics/benchmark/tests/src/com/android/mechanics/benchmark/MotionValueBenchmark.kt b/mechanics/benchmark/tests/src/com/android/mechanics/benchmark/MotionValueBenchmark.kt new file mode 100644 index 0000000..f5eab76 --- /dev/null +++ b/mechanics/benchmark/tests/src/com/android/mechanics/benchmark/MotionValueBenchmark.kt @@ -0,0 +1,260 @@ +/* + * Copyright (C) 2025 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.benchmark + +import androidx.benchmark.junit4.BenchmarkRule +import androidx.benchmark.junit4.measureRepeated +import androidx.compose.runtime.MutableFloatState +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.snapshotFlow +import androidx.compose.ui.util.fastForEach +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.android.mechanics.DistanceGestureContext +import com.android.mechanics.MotionValue +import com.android.mechanics.spec.Guarantee +import com.android.mechanics.spec.InputDirection +import com.android.mechanics.spec.Mapping +import com.android.mechanics.spec.MotionSpec +import com.android.mechanics.spec.builder.directionalMotionSpec +import com.android.mechanics.spring.SpringParameters +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.launch +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import platform.test.motion.compose.MonotonicClockTestScope + +/** Benchmark, which will execute on an Android device. Previous results: go/mm-microbenchmarks */ +@RunWith(AndroidJUnit4::class) +class MotionValueBenchmark { + @get:Rule val benchmarkRule = BenchmarkRule() + + private val tearDownOperations = mutableListOf<() -> Unit>() + + /** + * Runs a test block within a [MonotonicClockTestScope] provided by the underlying + * [platform.test.motion.compose.runMonotonicClockTest] and ensures automatic cleanup. + * + * This mechanism provides a convenient way to register cleanup actions (e.g., stopping + * coroutines, resetting states) that should reliably run at the end of the test, simplifying + * test setup and teardown. + */ + private fun runMonotonicClockTest(block: suspend MonotonicClockTestScope.() -> Unit) { + return platform.test.motion.compose.runMonotonicClockTest { + try { + block() + } finally { + tearDownOperations.fastForEach { it.invoke() } + } + } + } + + private data class TestData( + val motionValue: MotionValue, + val gestureContext: DistanceGestureContext, + val input: MutableFloatState, + val spec: MotionSpec, + ) + + private fun testData( + gestureContext: DistanceGestureContext = DistanceGestureContext(0f, InputDirection.Max, 2f), + input: Float = 0f, + spec: MotionSpec = MotionSpec.Empty, + ): TestData { + val inputState = mutableFloatStateOf(input) + return TestData( + motionValue = MotionValue(inputState::floatValue, gestureContext, spec), + gestureContext = gestureContext, + input = inputState, + spec = spec, + ) + } + + // Fundamental operations on MotionValue: create, read, update. + + @Test + fun createMotionValue() { + val gestureContext = DistanceGestureContext(0f, InputDirection.Max, 2f) + val input = { 0f } + + benchmarkRule.measureRepeated { MotionValue(input, gestureContext) } + } + + @Test + fun stable_readOutput_noChanges() { + val data = testData() + + // The first read may cost more than the others, it is not interesting for this test. + data.motionValue.floatValue + + benchmarkRule.measureRepeated { data.motionValue.floatValue } + } + + @Test + fun stable_readOutput_afterWriteInput() { + val data = testData() + + benchmarkRule.measureRepeated { + runWithMeasurementDisabled { data.input.floatValue += 1f } + data.motionValue.floatValue + } + } + + @Test + fun stable_writeInput_AND_readOutput() { + val data = testData() + + benchmarkRule.measureRepeated { + data.input.floatValue += 1f + data.motionValue.floatValue + } + } + + @Test + fun stable_writeInput_AND_readOutput_keepRunning() = runMonotonicClockTest { + val data = testData() + keepRunningDuringTest(data.motionValue) + + benchmarkRule.measureRepeated { + data.input.floatValue += 1f + testScheduler.advanceTimeBy(16) + data.motionValue.floatValue + } + } + + @Test + fun stable_writeInput_AND_readOutput_100motionValues_keepRunning() = runMonotonicClockTest { + val dataList = List(100) { testData() } + dataList.forEach { keepRunningDuringTest(it.motionValue) } + + benchmarkRule.measureRepeated { + dataList.fastForEach { it.input.floatValue += 1f } + testScheduler.advanceTimeBy(16) + dataList.fastForEach { it.motionValue.floatValue } + } + } + + @Test + fun stable_readOutput_100motionValues_keepRunning() = runMonotonicClockTest { + val dataList = List(100) { testData() } + dataList.forEach { keepRunningDuringTest(it.motionValue) } + + benchmarkRule.measureRepeated { + testScheduler.advanceTimeBy(16) + dataList.fastForEach { it.motionValue.floatValue } + } + } + + // Animations + + private fun MonotonicClockTestScope.keepRunningDuringTest(motionValue: MotionValue) { + val keepRunningJob = launch { motionValue.keepRunning() } + tearDownOperations += { keepRunningJob.cancel() } + } + + private val MotionSpec.Companion.ZeroToOne_AtOne + get() = + MotionSpec( + directionalMotionSpec( + defaultSpring = SpringParameters(stiffness = 300f, dampingRatio = .9f), + initialMapping = Mapping.Zero, + ) { + fixedValue(breakpoint = 1f, value = 1f) + } + ) + + private val InputDirection.opposite + get() = if (this == InputDirection.Min) InputDirection.Max else InputDirection.Min + + @Test + fun unstable_resetGestureContext_readOutput() = runMonotonicClockTest { + val data = testData(input = 1f, spec = MotionSpec.ZeroToOne_AtOne) + keepRunningDuringTest(data.motionValue) + + benchmarkRule.measureRepeated { + if (data.motionValue.isStable) { + data.gestureContext.reset(0f, data.gestureContext.direction.opposite) + } + testScheduler.advanceTimeBy(16) + data.motionValue.floatValue + } + } + + @Test + fun unstable_resetGestureContext_readOutput_100motionValues() = runMonotonicClockTest { + val dataList = List(100) { testData(input = 1f, spec = MotionSpec.ZeroToOne_AtOne) } + dataList.forEach { keepRunningDuringTest(it.motionValue) } + + benchmarkRule.measureRepeated { + dataList.fastForEach { data -> + if (data.motionValue.isStable) { + data.gestureContext.reset(0f, data.gestureContext.direction.opposite) + } + } + testScheduler.advanceTimeBy(16) + dataList.fastForEach { it.motionValue.floatValue } + } + } + + @Test + fun unstable_resetGestureContext_snapshotFlowOutput() = runMonotonicClockTest { + val data = testData(input = 1f, spec = MotionSpec.ZeroToOne_AtOne) + keepRunningDuringTest(data.motionValue) + + snapshotFlow { data.motionValue.floatValue }.launchIn(backgroundScope) + + benchmarkRule.measureRepeated { + if (data.motionValue.isStable) { + data.gestureContext.reset(0f, data.gestureContext.direction.opposite) + } + testScheduler.advanceTimeBy(16) + } + } + + private val MotionSpec.Companion.ZeroToOne_AtOne_WithGuarantee + get() = + MotionSpec( + directionalMotionSpec( + defaultSpring = SpringParameters(stiffness = 300f, dampingRatio = .9f), + initialMapping = Mapping.Zero, + ) { + fixedValue( + breakpoint = 1f, + value = 1f, + guarantee = Guarantee.GestureDragDelta(1f), + ) + } + ) + + @Test + fun unstable_resetGestureContext_guarantee_readOutput() = runMonotonicClockTest { + val data = testData(input = 1f, spec = MotionSpec.ZeroToOne_AtOne_WithGuarantee) + keepRunningDuringTest(data.motionValue) + + benchmarkRule.measureRepeated { + if (data.motionValue.isStable) { + data.gestureContext.reset(0f, data.gestureContext.direction.opposite) + } else { + val isMax = data.gestureContext.direction == InputDirection.Max + data.gestureContext.dragOffset += if (isMax) 0.01f else -0.01f + } + + testScheduler.advanceTimeBy(16) + data.motionValue.floatValue + } + } +} diff --git a/mechanics/build.gradle b/mechanics/build.gradle new file mode 100644 index 0000000..d28e0db --- /dev/null +++ b/mechanics/build.gradle @@ -0,0 +1,50 @@ +apply plugin: 'com.android.library' +apply plugin: 'kotlin-android' +apply plugin: 'org.jetbrains.kotlin.plugin.compose' + +android { + namespace = "com.android.mechanics" + testNamespace = "com.android.mechanics.tests" + + defaultConfig { + testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" + } + + buildFeatures { + compose = true + } + + sourceSets { + main { + java.srcDirs = ['src', 'compose'] + manifest.srcFile 'AndroidManifest.xml' + } + } + + lintOptions { + abortOnError false + } + + tasks.lint.enabled = false + + tasks.withType(JavaCompile) { + options.compilerArgs << "-Xlint:unchecked" << "-Xlint:deprecation" + } +} + +addFrameworkJar('framework-16.jar') + +dependencies { + implementation libs.kotlin.stdlib.jdk7 + implementation libs.androidx.core.animation + implementation libs.androidx.core.ktx + + // Compose dependencies for compose source files + implementation platform(libs.compose.bom) + implementation libs.compose.ui + implementation libs.compose.ui.util + implementation libs.compose.ui.graphics + implementation libs.compose.runtime + implementation libs.kotlinx.coroutines.android + implementation libs.compose.material3 +} diff --git a/mechanics/compose/Android.bp b/mechanics/compose/Android.bp new file mode 100644 index 0000000..bc852eb --- /dev/null +++ b/mechanics/compose/Android.bp @@ -0,0 +1,33 @@ +// Copyright (C) 2025 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 { + default_team: "trendy_team_motion", + default_applicable_licenses: ["Android-Apache-2.0"], +} + +android_library { + name: "mechanics-compose", + manifest: "AndroidManifest.xml", + srcs: [ + "src/**/*.kt", + ], + static_libs: [ + "PlatformComposeCore", + "PlatformComposeSceneTransitionLayout", + "//frameworks/libs/systemui/mechanics:mechanics", + "androidx.compose.runtime_runtime", + ], + kotlincflags: ["-Xjvm-default=all"], +} diff --git a/mechanics/compose/AndroidManifest.xml b/mechanics/compose/AndroidManifest.xml new file mode 100644 index 0000000..b84f740 --- /dev/null +++ b/mechanics/compose/AndroidManifest.xml @@ -0,0 +1,20 @@ + + + + + diff --git a/mechanics/compose/src/com/android/mechanics/compose/modifier/VerticalFadeContentRevealModifier.kt b/mechanics/compose/src/com/android/mechanics/compose/modifier/VerticalFadeContentRevealModifier.kt new file mode 100644 index 0000000..6428d9d --- /dev/null +++ b/mechanics/compose/src/com/android/mechanics/compose/modifier/VerticalFadeContentRevealModifier.kt @@ -0,0 +1,229 @@ +///* +// * Copyright (C) 2025 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.compose.modifier +// +//import androidx.compose.ui.Modifier +//import androidx.compose.ui.geometry.Rect +//import androidx.compose.ui.graphics.CompositingStrategy +//import androidx.compose.ui.layout.ApproachLayoutModifierNode +//import androidx.compose.ui.layout.ApproachMeasureScope +//import androidx.compose.ui.layout.LayoutCoordinates +//import androidx.compose.ui.layout.Measurable +//import androidx.compose.ui.layout.MeasureResult +//import androidx.compose.ui.layout.MeasureScope +//import androidx.compose.ui.layout.Placeable +//import androidx.compose.ui.layout.boundsInParent +//import androidx.compose.ui.node.ModifierNodeElement +//import androidx.compose.ui.platform.InspectorInfo +//import androidx.compose.ui.unit.Constraints +//import androidx.compose.ui.unit.IntOffset +//import androidx.compose.ui.unit.IntSize +//import androidx.compose.ui.util.fastCoerceAtLeast +//import com.android.compose.animation.scene.ContentScope +//import com.android.compose.animation.scene.ElementKey +//import com.android.compose.animation.scene.mechanics.gestureContextOrDefault +//import com.android.mechanics.MotionValue +//import com.android.mechanics.debug.findMotionValueDebugger +//import com.android.mechanics.effects.FixedValue +//import com.android.mechanics.spec.Mapping +//import com.android.mechanics.spec.builder.MotionBuilderContext +//import com.android.mechanics.spec.builder.effectsMotionSpec +//import kotlinx.coroutines.Job +//import kotlinx.coroutines.launch +// +///** +// * This component remains hidden until it reach its target height. +// * +// * TODO: Once b/413283893 is done, [motionBuilderContext] can be read internally via +// * CompositionLocalConsumerModifierNode, instead of passing it. +// */ +//fun Modifier.verticalFadeContentReveal( +// contentScope: ContentScope, +// motionBuilderContext: MotionBuilderContext, +// container: ElementKey, +// deltaY: Float = 0f, +// label: String? = null, +// debug: Boolean = false, +//): Modifier = +// this then +// FadeContentRevealElement( +// contentScope = contentScope, +// motionBuilderContext = motionBuilderContext, +// container = container, +// deltaY = deltaY, +// label = label, +// debug = debug, +// ) +// +//private data class FadeContentRevealElement( +// val contentScope: ContentScope, +// val motionBuilderContext: MotionBuilderContext, +// val container: ElementKey, +// val deltaY: Float, +// val label: String?, +// val debug: Boolean, +//) : ModifierNodeElement() { +// override fun create(): FadeContentRevealNode = +// FadeContentRevealNode( +// contentScope = contentScope, +// motionBuilderContext = motionBuilderContext, +// container = container, +// deltaY = deltaY, +// label = label, +// debug = debug, +// ) +// +// override fun update(node: FadeContentRevealNode) { +// node.update( +// contentScope = contentScope, +// motionBuilderContext = motionBuilderContext, +// container = container, +// deltaY = deltaY, +// ) +// } +// +// override fun InspectorInfo.inspectableProperties() { +// name = "fadeContentReveal" +// properties["container"] = container +// properties["deltaY"] = deltaY +// properties["label"] = label +// properties["debug"] = debug +// } +//} +// +//internal class FadeContentRevealNode( +// private var contentScope: ContentScope, +// private var motionBuilderContext: MotionBuilderContext, +// private var container: ElementKey, +// private var deltaY: Float, +// label: String?, +// private val debug: Boolean, +//) : Modifier.Node(), ApproachLayoutModifierNode { +// +// private val motionValue = +// MotionValue( +// currentInput = { +// with(contentScope) { +// val containerHeight = +// container.lastSize(contentKey)?.height ?: return@MotionValue 0f +// val containerCoordinates = +// container.targetCoordinates(contentKey) ?: return@MotionValue 0f +// val localCoordinates = lastCoordinates ?: return@MotionValue 0f +// +// val offsetY = containerCoordinates.localPositionOf(localCoordinates).y +// containerHeight - offsetY + deltaY +// } +// }, +// gestureContext = contentScope.gestureContextOrDefault(), +// label = "FadeContentReveal(${label.orEmpty()})", +// ) +// +// fun update( +// contentScope: ContentScope, +// motionBuilderContext: MotionBuilderContext, +// container: ElementKey, +// deltaY: Float, +// ) { +// this.contentScope = contentScope +// this.motionBuilderContext = motionBuilderContext +// this.container = container +// this.deltaY = deltaY +// updateMotionSpec() +// } +// +// private var motionValueJob: Job? = null +// +// override fun onAttach() { +// motionValueJob = +// coroutineScope.launch { +// val disposableHandle = +// if (debug) { +// findMotionValueDebugger()?.register(motionValue) +// } else { +// null +// } +// try { +// motionValue.keepRunning() +// } finally { +// disposableHandle?.dispose() +// } +// } +// } +// +// override fun onDetach() { +// motionValueJob?.cancel() +// } +// +// private fun isAnimating(): Boolean { +// return contentScope.layoutState.currentTransition != null || !motionValue.isStable +// } +// +// override fun isMeasurementApproachInProgress(lookaheadSize: IntSize) = isAnimating() +// +// override fun Placeable.PlacementScope.isPlacementApproachInProgress( +// lookaheadCoordinates: LayoutCoordinates +// ) = isAnimating() +// +// private var targetBounds = Rect.Zero +// +// private var lastCoordinates: LayoutCoordinates? = null +// +// private fun updateMotionSpec() { +// motionValue.spec = +// motionBuilderContext.effectsMotionSpec(Mapping.Zero) { +// after(targetBounds.bottom, FixedValue.One) +// } +// } +// +// override fun MeasureScope.measure( +// measurable: Measurable, +// constraints: Constraints, +// ): MeasureResult { +// val placeable = measurable.measure(constraints) +// return layout(placeable.width, placeable.height) { +// val coordinates = coordinates +// if (isLookingAhead && coordinates != null) { +// lastCoordinates = coordinates +// val bounds = coordinates.boundsInParent() +// if (targetBounds != bounds) { +// targetBounds = bounds +// updateMotionSpec() +// } +// } +// placeable.place(IntOffset.Zero) +// } +// } +// +// override fun ApproachMeasureScope.approachMeasure( +// measurable: Measurable, +// constraints: Constraints, +// ): MeasureResult { +// return measurable.measure(constraints).run { +// layout(width, height) { +// val revealAlpha = motionValue.output +// if (revealAlpha < 1) { +// placeWithLayer(IntOffset.Zero) { +// alpha = revealAlpha.fastCoerceAtLeast(0f) +// compositingStrategy = CompositingStrategy.ModulateAlpha +// } +// } else { +// place(IntOffset.Zero) +// } +// } +// } +// } +//} diff --git a/mechanics/compose/src/com/android/mechanics/compose/modifier/VerticalTactileSurfaceRevealModifier.kt b/mechanics/compose/src/com/android/mechanics/compose/modifier/VerticalTactileSurfaceRevealModifier.kt new file mode 100644 index 0000000..9bfd3db --- /dev/null +++ b/mechanics/compose/src/com/android/mechanics/compose/modifier/VerticalTactileSurfaceRevealModifier.kt @@ -0,0 +1,250 @@ +///* +// * Copyright (C) 2025 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.compose.modifier +// +//import androidx.compose.ui.Modifier +//import androidx.compose.ui.geometry.Rect +//import androidx.compose.ui.graphics.CompositingStrategy +//import androidx.compose.ui.layout.ApproachLayoutModifierNode +//import androidx.compose.ui.layout.ApproachMeasureScope +//import androidx.compose.ui.layout.LayoutCoordinates +//import androidx.compose.ui.layout.Measurable +//import androidx.compose.ui.layout.MeasureResult +//import androidx.compose.ui.layout.MeasureScope +//import androidx.compose.ui.layout.Placeable +//import androidx.compose.ui.layout.boundsInParent +//import androidx.compose.ui.node.ModifierNodeElement +//import androidx.compose.ui.platform.InspectorInfo +//import androidx.compose.ui.unit.Constraints +//import androidx.compose.ui.unit.IntOffset +//import androidx.compose.ui.unit.IntSize +//import androidx.compose.ui.util.fastCoerceAtLeast +//import androidx.compose.ui.util.fastCoerceIn +//import com.android.compose.animation.scene.ContentScope +//import com.android.compose.animation.scene.ElementKey +//import com.android.compose.animation.scene.mechanics.gestureContextOrDefault +//import com.android.mechanics.MotionValue +//import com.android.mechanics.debug.findMotionValueDebugger +//import com.android.mechanics.effects.RevealOnThreshold +//import com.android.mechanics.spec.Mapping +//import com.android.mechanics.spec.builder.MotionBuilderContext +//import com.android.mechanics.spec.builder.spatialMotionSpec +//import kotlin.math.roundToInt +//import kotlinx.coroutines.Job +//import kotlinx.coroutines.launch +// +///** +// * This component remains hidden until its target height meets a minimum threshold. At that point, +// * it reveals itself by animating its height from 0 to the current target height. +// * +// * TODO: Once b/413283893 is done, [motionBuilderContext] can be read internally via +// * CompositionLocalConsumerModifierNode, instead of passing it. +// */ +//fun Modifier.verticalTactileSurfaceReveal( +// contentScope: ContentScope, +// motionBuilderContext: MotionBuilderContext, +// container: ElementKey, +// deltaY: Float = 0f, +// revealOnThreshold: RevealOnThreshold = DefaultRevealOnThreshold, +// label: String? = null, +// debug: Boolean = false, +//): Modifier = +// this then +// VerticalTactileSurfaceRevealElement( +// contentScope = contentScope, +// motionBuilderContext = motionBuilderContext, +// container = container, +// deltaY = deltaY, +// revealOnThreshold = revealOnThreshold, +// label = label, +// debug = debug, +// ) +// +//private val DefaultRevealOnThreshold = RevealOnThreshold() +// +//private data class VerticalTactileSurfaceRevealElement( +// val contentScope: ContentScope, +// val motionBuilderContext: MotionBuilderContext, +// val container: ElementKey, +// val deltaY: Float, +// val revealOnThreshold: RevealOnThreshold, +// val label: String?, +// val debug: Boolean, +//) : ModifierNodeElement() { +// override fun create(): VerticalTactileSurfaceRevealNode = +// VerticalTactileSurfaceRevealNode( +// contentScope = contentScope, +// motionBuilderContext = motionBuilderContext, +// container = container, +// deltaY = deltaY, +// revealOnThreshold = revealOnThreshold, +// label = label, +// debug = debug, +// ) +// +// override fun update(node: VerticalTactileSurfaceRevealNode) { +// node.update( +// contentScope = contentScope, +// motionBuilderContext = motionBuilderContext, +// container = container, +// deltaY = deltaY, +// revealOnThreshold = revealOnThreshold, +// ) +// } +// +// override fun InspectorInfo.inspectableProperties() { +// name = "tactileSurfaceReveal" +// properties["container"] = container +// properties["deltaY"] = deltaY +// properties["revealOnThreshold"] = revealOnThreshold +// properties["label"] = label +// properties["debug"] = debug +// } +//} +// +//private class VerticalTactileSurfaceRevealNode( +// private var contentScope: ContentScope, +// private var motionBuilderContext: MotionBuilderContext, +// private var container: ElementKey, +// private var deltaY: Float, +// private var revealOnThreshold: RevealOnThreshold, +// label: String?, +// private val debug: Boolean, +//) : Modifier.Node(), ApproachLayoutModifierNode { +// +// private val motionValue = +// MotionValue( +// currentInput = { +// with(contentScope) { +// val containerHeight = +// container.lastSize(contentKey)?.height ?: return@MotionValue 0f +// val containerCoordinates = +// container.targetCoordinates(contentKey) ?: return@MotionValue 0f +// val localCoordinates = lastCoordinates ?: return@MotionValue 0f +// +// val offsetY = containerCoordinates.localPositionOf(localCoordinates).y +// containerHeight - offsetY + deltaY +// } +// }, +// gestureContext = contentScope.gestureContextOrDefault(), +// label = "TactileSurfaceReveal(${label.orEmpty()})", +// stableThreshold = MotionBuilderContext.StableThresholdSpatial, +// ) +// +// fun update( +// contentScope: ContentScope, +// motionBuilderContext: MotionBuilderContext, +// container: ElementKey, +// deltaY: Float, +// revealOnThreshold: RevealOnThreshold, +// ) { +// this.contentScope = contentScope +// this.motionBuilderContext = motionBuilderContext +// this.container = container +// this.deltaY = deltaY +// this.revealOnThreshold = revealOnThreshold +// updateMotionSpec() +// } +// +// private var motionValueJob: Job? = null +// +// override fun onAttach() { +// motionValueJob = +// coroutineScope.launch { +// val disposableHandle = +// if (debug) { +// findMotionValueDebugger()?.register(motionValue) +// } else { +// null +// } +// try { +// motionValue.keepRunning() +// } finally { +// disposableHandle?.dispose() +// } +// } +// } +// +// override fun onDetach() { +// motionValueJob?.cancel() +// } +// +// private fun isAnimating(): Boolean { +// return contentScope.layoutState.currentTransition != null || !motionValue.isStable +// } +// +// override fun isMeasurementApproachInProgress(lookaheadSize: IntSize) = isAnimating() +// +// override fun Placeable.PlacementScope.isPlacementApproachInProgress( +// lookaheadCoordinates: LayoutCoordinates +// ) = isAnimating() +// +// private var targetBounds = Rect.Zero +// +// private var lastCoordinates: LayoutCoordinates? = null +// +// private fun updateMotionSpec() { +// motionValue.spec = +// motionBuilderContext.spatialMotionSpec(Mapping.Zero) { +// between( +// start = targetBounds.top, +// end = targetBounds.bottom, +// effect = revealOnThreshold, +// ) +// } +// } +// +// override fun MeasureScope.measure( +// measurable: Measurable, +// constraints: Constraints, +// ): MeasureResult { +// val placeable = measurable.measure(constraints) +// return layout(placeable.width, placeable.height) { +// val coordinates = coordinates +// if (isLookingAhead && coordinates != null) { +// lastCoordinates = coordinates +// val bounds = coordinates.boundsInParent() +// if (targetBounds != bounds) { +// targetBounds = bounds +// updateMotionSpec() +// } +// } +// placeable.place(IntOffset.Zero) +// } +// } +// +// override fun ApproachMeasureScope.approachMeasure( +// measurable: Measurable, +// constraints: Constraints, +// ): MeasureResult { +// val height = motionValue.output.roundToInt().fastCoerceAtLeast(0) +// val animatedConstraints = Constraints.fixed(width = constraints.maxWidth, height = height) +// return measurable.measure(animatedConstraints).run { +// layout(width, height) { +// val revealAlpha = (height / revealOnThreshold.minSize.toPx()).fastCoerceIn(0f, 1f) +// if (revealAlpha < 1) { +// placeWithLayer(IntOffset.Zero) { +// alpha = revealAlpha +// compositingStrategy = CompositingStrategy.ModulateAlpha +// } +// } else { +// place(IntOffset.Zero) +// } +// } +// } +// } +//} diff --git a/mechanics/compose/tests/AndroidManifest.xml b/mechanics/compose/tests/AndroidManifest.xml new file mode 100644 index 0000000..182f244 --- /dev/null +++ b/mechanics/compose/tests/AndroidManifest.xml @@ -0,0 +1,28 @@ + + + + + + + + + + diff --git a/mechanics/src/com/android/mechanics/GestureContext.kt b/mechanics/src/com/android/mechanics/GestureContext.kt new file mode 100644 index 0000000..f1fb3ee --- /dev/null +++ b/mechanics/src/com/android/mechanics/GestureContext.kt @@ -0,0 +1,205 @@ +/* + * 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 + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Stable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.platform.LocalViewConfiguration +import com.android.mechanics.spec.InputDirection +import kotlin.math.max +import kotlin.math.min + +/** + * Remembers [DistanceGestureContext] with the given initial distance / direction. + * + * Providing update [initDistance] or [initialDirection] will not re-create the + * [DistanceGestureContext]. + * + * The `directionChangeSlop` is derived from `ViewConfiguration.touchSlop` and kept current without + * re-creating, should it ever change. + */ +@Composable +fun rememberDistanceGestureContext( + initDistance: Float = 0f, + initialDirection: InputDirection = InputDirection.Max, +): DistanceGestureContext { + val touchSlop = LocalViewConfiguration.current.touchSlop + return remember { DistanceGestureContext(initDistance, initialDirection, touchSlop) } + .also { it.directionChangeSlop = touchSlop } +} + +/** + * Gesture-specific context to augment [MotionValue.currentInput]. + * + * This context helps to capture the user's intent, and should be provided to [MotionValue]s that + * respond to a user gesture. + */ +@Stable +interface GestureContext { + + /** + * The intrinsic direction of the [MotionValue.currentInput]. + * + * This property determines which of the [DirectionalMotionSpec] from the [MotionSpec] is used, + * and also prevents flip-flopping of the output value on tiny input-changes around a + * breakpoint. + * + * If the [MotionValue.currentInput] is driven - directly or indirectly - by a user gesture, + * this property should only change direction after the gesture travelled a significant distance + * in the opposite direction. + * + * @see DistanceGestureContext for a default implementation. + */ + val direction: InputDirection + + /** + * The gesture distance of the current gesture, in pixels. + * + * Used solely for the [GestureDragDelta] [Guarantee]. Can be hard-coded to a static value if + * this type of [Guarantee] is not used. + */ + val dragOffset: Float +} + +/** + * [GestureContext] with a mutable [dragOffset]. + * + * The implementation class defines whether the [direction] is updated accordingly. + */ +interface MutableDragOffsetGestureContext : GestureContext { + /** The gesture distance of the current gesture, in pixels. */ + override var dragOffset: Float +} + +/** [GestureContext] implementation for manually set values. */ +class ProvidedGestureContext(dragOffset: Float, direction: InputDirection) : + MutableDragOffsetGestureContext { + override var direction by mutableStateOf(direction) + override var dragOffset by mutableFloatStateOf(dragOffset) +} + +/** + * [GestureContext] driven by a gesture distance. + * + * The direction is determined from the gesture input, where going further than + * [directionChangeSlop] in the opposite direction toggles the direction. + * + * @param initialDragOffset The initial [dragOffset] of the [GestureContext] + * @param initialDirection The initial [direction] of the [GestureContext] + * @param directionChangeSlop the amount [dragOffset] must be moved in the opposite direction for + * the [direction] to flip. + */ +class DistanceGestureContext( + initialDragOffset: Float, + initialDirection: InputDirection, + directionChangeSlop: Float, +) : MutableDragOffsetGestureContext { + init { + require(directionChangeSlop > 0) { + "directionChangeSlop must be greater than 0, was $directionChangeSlop" + } + } + + override var direction by mutableStateOf(initialDirection) + private set + + private var furthestDragOffset by mutableFloatStateOf(initialDragOffset) + + private var _dragOffset by mutableFloatStateOf(initialDragOffset) + + override var dragOffset: Float + get() = _dragOffset + /** + * Updates the [dragOffset]. + * + * This flips the [direction], if the [value] is further than [directionChangeSlop] away + * from the furthest recorded value regarding to the current [direction]. + */ + set(value) { + _dragOffset = value + this.direction = + when (direction) { + InputDirection.Max -> { + if (furthestDragOffset - value > directionChangeSlop) { + furthestDragOffset = value + InputDirection.Min + } else { + furthestDragOffset = max(value, furthestDragOffset) + InputDirection.Max + } + } + + InputDirection.Min -> { + if (value - furthestDragOffset > directionChangeSlop) { + furthestDragOffset = value + InputDirection.Max + } else { + furthestDragOffset = min(value, furthestDragOffset) + InputDirection.Min + } + } + } + } + + private var _directionChangeSlop by mutableFloatStateOf(directionChangeSlop) + + var directionChangeSlop: Float + get() = _directionChangeSlop + + /** + * This flips the [direction], if the current [direction] is further than the new + * directionChangeSlop [value] away from the furthest recorded value regarding to the + * current [direction]. + */ + set(value) { + require(value > 0) { "directionChangeSlop must be greater than 0, was $value" } + + _directionChangeSlop = value + + when (direction) { + InputDirection.Max -> { + if (furthestDragOffset - dragOffset > directionChangeSlop) { + furthestDragOffset = dragOffset + direction = InputDirection.Min + } + } + InputDirection.Min -> { + if (dragOffset - furthestDragOffset > directionChangeSlop) { + furthestDragOffset = value + direction = InputDirection.Max + } + } + } + } + + /** + * Sets [dragOffset] and [direction] to the specified values. + * + * This also resets memoized [furthestDragOffset], which is used to determine the direction + * change. + */ + fun reset(dragOffset: Float, direction: InputDirection) { + this.dragOffset = dragOffset + this.direction = direction + this.furthestDragOffset = dragOffset + } +} diff --git a/mechanics/src/com/android/mechanics/MotionValue.kt b/mechanics/src/com/android/mechanics/MotionValue.kt new file mode 100644 index 0000000..9d01c10 --- /dev/null +++ b/mechanics/src/com/android/mechanics/MotionValue.kt @@ -0,0 +1,466 @@ +/* + * 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 + +import androidx.compose.runtime.FloatState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableLongStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.referentialEqualityPolicy +import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow +import androidx.compose.runtime.withFrameNanos +import com.android.mechanics.debug.DebugInspector +import com.android.mechanics.debug.FrameData +import com.android.mechanics.impl.Computations +import com.android.mechanics.impl.DiscontinuityAnimation +import com.android.mechanics.impl.GuaranteeState +import com.android.mechanics.spec.Breakpoint +import com.android.mechanics.spec.Guarantee +import com.android.mechanics.spec.InputDirection +import com.android.mechanics.spec.Mapping +import com.android.mechanics.spec.MotionSpec +import com.android.mechanics.spec.SegmentData +import com.android.mechanics.spec.SegmentKey +import com.android.mechanics.spec.SemanticKey +import com.android.mechanics.spring.SpringState +import java.util.concurrent.atomic.AtomicInteger +import kotlinx.coroutines.CoroutineName +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.withContext + +/** + * Computes an animated [output] value, by mapping the [currentInput] according to the [spec]. + * + * A [MotionValue] represents a single animated value within a larger animation. It takes a + * numerical [currentInput] value, typically a spatial value like width, height, or gesture length, + * and transforms it into an [output] value using a [MotionSpec]. + * + * ## Mapping Input to Output + * + * The [MotionSpec] defines the relationship between the input and output values. It does this by + * specifying a series of [Mapping] functions and [Breakpoint]s. Breakpoints divide the input domain + * into segments. Each segment has an associated [Mapping] function, which determines how input + * values within that segment are transformed into output values. + * + * These [Mapping] functions can be arbitrary, as long as they are + * 1. deterministic: When invoked repeatedly for the same input, they must produce the same output. + * 2. continuous: meaning infinitesimally small changes in input result in infinitesimally small + * changes in output + * + * A valid [Mapping] function is one whose graph could be drawn without lifting your pen from the + * paper, meaning there are no abrupt jumps or breaks. + * + * ## Animating Discontinuities + * + * When the input value crosses a breakpoint, there might be a discontinuity in the output value due + * to the switch between mapping functions. `MotionValue` automatically animates these + * discontinuities using a spring animation. The spring parameters are defined for each + * [Breakpoint]. + * + * ## Guarantees for Choreography + * + * Breakpoints can also define [Guarantee]s. These guarantees can make the spring animation finish + * faster, in response to quick input value changes. Thus, [Guarantee]s allows to maintain a + * predictable choreography, even as the input is unpredictably changed by a user's gesture. + * + * ## Updating the MotionSpec + * + * The [spec] property can be changed at any time. If the new spec produces a different output for + * the current input, the difference will be animated using the spring parameters defined in + * [MotionSpec.resetSpring]. + * + * ## Gesture Context + * + * The [GestureContext] augments the [currentInput] value with the user's intent. The + * [GestureContext] is created wherever gesture input is handled. If the motion value is not driven + * by a gesture, it is OK for the [GestureContext] to return static values. + * + * ## Usage + * + * The [MotionValue] does animate the [output] implicitly, whenever a change in [currentInput], + * [spec], or [gestureContext] requires it. The animated value is computed whenever the [output] + * property is read, or the latest once the animation frame is complete. + * 1. Create an instance, providing the input value, gesture context, and an initial spec. + * 2. Call [keepRunning] in a coroutine scope, and keep the coroutine running while the + * `MotionValue` is in use. + * 3. Access the animated output value through the [output] property. + * + * Internally, the [keepRunning] coroutine is automatically suspended if there is nothing to + * animate. + * + * @param currentInput Provides the current input value. + * @param gestureContext The [GestureContext] augmenting the [currentInput]. + * @param label An optional label to aid in debugging. + * @param stableThreshold A threshold value (in output units) that determines when the + * [MotionValue]'s internal spring animation is considered stable. + */ +class MotionValue( + currentInput: () -> Float, + gestureContext: GestureContext, + initialSpec: MotionSpec = MotionSpec.Empty, + label: String? = null, + stableThreshold: Float = StableThresholdEffect, +) : FloatState { + private val impl = + ObservableComputations(currentInput, gestureContext, initialSpec, stableThreshold, label) + + /** The [MotionSpec] describing the mapping of this [MotionValue]'s input to the output. */ + var spec: MotionSpec by impl::spec + + /** Animated [output] value. */ + val output: Float by impl::output + + /** + * [output] value, but without animations. + * + * This value always reports the target value, even before a animation is finished. + * + * While [isStable], [outputTarget] and [output] are the same value. + */ + val outputTarget: Float by impl::outputTarget + + /** The [output] exposed as [FloatState]. */ + override val floatValue: Float by impl::output + + /** Whether an animation is currently running. */ + val isStable: Boolean by impl::isStable + + /** + * The current value for the [SemanticKey]. + * + * `null` if not defined in the spec. + */ + operator fun get(key: SemanticKey): T? { + return impl.semanticState(key) + } + + /** The current segment used to compute the output. */ + val segmentKey: SegmentKey + get() = impl.currentComputedValues.segment.key + + /** + * Keeps the [MotionValue]'s animated output running. + * + * Clients must call [keepRunning], and keep the coroutine running while the [MotionValue] is in + * use. When disposing this [MotionValue], cancel the coroutine. + * + * Internally, this method does suspend, unless there are animations ongoing. + */ + suspend fun keepRunning(): Nothing { + withContext(CoroutineName("MotionValue($label)")) { impl.keepRunning { true } } + + // `keepRunning` above will never finish, + throw AssertionError("Unreachable code") + } + + /** + * Keeps the [MotionValue]'s animated output running while [continueRunning] returns `true`. + * + * When [continueRunning] returns `false`, the coroutine will end by the next frame. + * + * To keep the [MotionValue] running until the current animations are complete, check for + * `isStable` as well. + * + * ```kotlin + * motionValue.keepRunningWhile { !shouldEnd() || !isStable } + * ``` + */ + suspend fun keepRunningWhile(continueRunning: MotionValue.() -> Boolean) = + withContext(CoroutineName("MotionValue($label)")) { + impl.keepRunning { continueRunning.invoke(this@MotionValue) } + } + + val label: String? by impl::label + + companion object { + /** Creates a [MotionValue] whose [currentInput] is the animated [output] of [source]. */ + fun createDerived( + source: MotionValue, + initialSpec: MotionSpec = MotionSpec.Empty, + label: String? = null, + stableThreshold: Float = 0.01f, + ): MotionValue { + return MotionValue( + currentInput = source::output, + gestureContext = source.impl.gestureContext, + initialSpec = initialSpec, + label = label, + stableThreshold = stableThreshold, + ) + } + + const val StableThresholdEffect = 0.01f + const val StableThresholdSpatial = 1f + + internal const val TAG = "MotionValue" + } + + private var debugInspectorRefCount = AtomicInteger(0) + + private fun onDisposeDebugInspector() { + if (debugInspectorRefCount.decrementAndGet() == 0) { + impl.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) { + impl.debugInspector = + DebugInspector( + FrameData( + impl.lastInput, + impl.lastSegment.direction, + impl.lastGestureDragOffset, + impl.lastFrameTimeNanos, + impl.lastSpringState, + impl.lastSegment, + impl.lastAnimation, + ), + impl.isActive, + impl.debugIsAnimating, + ::onDisposeDebugInspector, + ) + } + + return checkNotNull(impl.debugInspector) + } +} + +private class ObservableComputations( + val input: () -> Float, + val gestureContext: GestureContext, + initialSpec: MotionSpec = MotionSpec.Empty, + override val stableThreshold: Float, + override val label: String?, +) : Computations() { + + // ---- CurrentFrameInput --------------------------------------------------------------------- + + override var spec by mutableStateOf(initialSpec) + override val currentInput: Float + get() = input.invoke() + + override val currentDirection: InputDirection + get() = gestureContext.direction + + override val currentGestureDragOffset: Float + get() = gestureContext.dragOffset + + override var currentAnimationTimeNanos by mutableLongStateOf(-1L) + + // ---- LastFrameState --------------------------------------------------------------------- + + override var lastSegment: SegmentData by + mutableStateOf( + spec.segmentAtInput(currentInput, currentDirection), + referentialEqualityPolicy(), + ) + + override var lastGuaranteeState: GuaranteeState + get() = GuaranteeState(_lastGuaranteeStatePacked) + set(value) { + _lastGuaranteeStatePacked = value.packedValue + } + + private var _lastGuaranteeStatePacked: Long by + mutableLongStateOf(GuaranteeState.Inactive.packedValue) + + override var lastAnimation: DiscontinuityAnimation by + mutableStateOf(DiscontinuityAnimation.None, referentialEqualityPolicy()) + + override var directMappedVelocity: Float = 0f + + override var lastSpringState: SpringState + get() = SpringState(_lastSpringStatePacked) + set(value) { + _lastSpringStatePacked = value.packedValue + } + + private var _lastSpringStatePacked: Long by + mutableLongStateOf(lastAnimation.springStartState.packedValue) + + override var lastFrameTimeNanos by mutableLongStateOf(-1L) + + override var lastInput by mutableFloatStateOf(currentInput) + + override var lastGestureDragOffset by mutableFloatStateOf(currentGestureDragOffset) + + // ---- Computations --------------------------------------------------------------------------- + + suspend fun keepRunning(continueRunning: () -> Boolean) { + check(!isActive) { "MotionValue($label) is already running" } + isActive = true + + // These `captured*` values will be applied to the `last*` values, at the beginning + // of the each new frame. + // TODO(b/397837971): Encapsulate the state in a StateRecord. + val initialValues = currentComputedValues + var capturedSegment = initialValues.segment + var capturedGuaranteeState = initialValues.guarantee + var capturedAnimation = initialValues.animation + var capturedSpringState = currentSpringState + var capturedFrameTimeNanos = currentAnimationTimeNanos + var capturedInput = currentInput + var capturedGestureDragOffset = currentGestureDragOffset + var capturedDirection = currentDirection + + try { + debugIsAnimating = true + + // indicates whether withFrameNanos is called continuously (as opposed to being + // suspended for an undetermined amount of time in between withFrameNanos). + // This is essential after `withFrameNanos` returned: if true at this point, + // currentAnimationTimeNanos - lastFrameNanos is the duration of the last frame. + var isAnimatingUninterrupted = false + + while (continueRunning()) { + + withFrameNanos { frameTimeNanos -> + currentAnimationTimeNanos = frameTimeNanos + + // With the new frame started, copy + + lastSegment = capturedSegment + lastGuaranteeState = capturedGuaranteeState + lastAnimation = capturedAnimation + lastSpringState = capturedSpringState + lastFrameTimeNanos = capturedFrameTimeNanos + lastInput = capturedInput + lastGestureDragOffset = capturedGestureDragOffset + } + + // At this point, the complete frame is done (including layout, drawing and + // everything else), and this MotionValue has been updated. + + // Capture the `current*` MotionValue state, so that it can be applied as the + // `last*` state when the next frame starts. Its imperative to capture at this point + // already (since the input could change before the next frame starts), while at the + // same time not already applying the `last*` state (as this would cause a + // re-computation if the current state is being read before the next frame). + if (isAnimatingUninterrupted) { + directMappedVelocity = + computeDirectMappedVelocity(currentAnimationTimeNanos - lastFrameTimeNanos) + } else { + directMappedVelocity = 0f + } + + var scheduleNextFrame = false + if (!isSameSegmentAndAtRest) { + // Read currentComputedValues only once and update it, if necessary + val currentValues = currentComputedValues + + if (capturedSegment != currentValues.segment) { + capturedSegment = currentValues.segment + scheduleNextFrame = true + } + + if (capturedGuaranteeState != currentValues.guarantee) { + capturedGuaranteeState = currentValues.guarantee + scheduleNextFrame = true + } + + if (capturedAnimation != currentValues.animation) { + capturedAnimation = currentValues.animation + scheduleNextFrame = true + } + + if (capturedSpringState != currentSpringState) { + capturedSpringState = currentSpringState + scheduleNextFrame = true + } + } + + if (capturedInput != currentInput) { + capturedInput = currentInput + scheduleNextFrame = true + } + + if (capturedGestureDragOffset != currentGestureDragOffset) { + capturedGestureDragOffset = currentGestureDragOffset + scheduleNextFrame = true + } + + if (capturedDirection != currentDirection) { + capturedDirection = currentDirection + scheduleNextFrame = true + } + + capturedFrameTimeNanos = currentAnimationTimeNanos + + debugInspector?.run { + frame = + FrameData( + capturedInput, + capturedDirection, + capturedGestureDragOffset, + capturedFrameTimeNanos, + capturedSpringState, + capturedSegment, + capturedAnimation, + ) + } + + isAnimatingUninterrupted = scheduleNextFrame + if (scheduleNextFrame) { + continue + } + + debugIsAnimating = false + snapshotFlow { + val wakeup = + !continueRunning() || + spec != capturedSegment.spec || + currentInput != capturedInput || + currentDirection != capturedDirection || + currentGestureDragOffset != capturedGestureDragOffset + wakeup + } + .first { it } + debugIsAnimating = true + } + } finally { + isActive = false + debugIsAnimating = false + } + } + + /** Whether a [keepRunning] coroutine is active currently. */ + var isActive = false + set(value) { + field = value + debugInspector?.isActive = value + } + + /** + * `false` whenever the [keepRunning] coroutine is suspended while no animation is running and + * the input is not changing. + */ + var debugIsAnimating = false + set(value) { + field = value + debugInspector?.isAnimating = value + } + + var debugInspector: DebugInspector? = null +} diff --git a/mechanics/src/com/android/mechanics/behavior/VerticalExpandContainerBackground.kt b/mechanics/src/com/android/mechanics/behavior/VerticalExpandContainerBackground.kt new file mode 100644 index 0000000..9738424 --- /dev/null +++ b/mechanics/src/com/android/mechanics/behavior/VerticalExpandContainerBackground.kt @@ -0,0 +1,196 @@ +/* + * Copyright (C) 2025 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.behavior + +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawWithCache +import androidx.compose.ui.geometry.CornerRadius +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.drawscope.ContentDrawScope +import androidx.compose.ui.graphics.drawscope.clipRect +import androidx.compose.ui.graphics.layer.GraphicsLayer +import androidx.compose.ui.graphics.layer.drawLayer +import androidx.compose.ui.node.DrawModifierNode +import androidx.compose.ui.node.ModifierNodeElement +import androidx.compose.ui.node.requireGraphicsContext +import androidx.compose.ui.util.fastCoerceAtLeast +import androidx.compose.ui.util.fastCoerceIn +import androidx.compose.ui.util.lerp +import kotlin.math.min +import kotlin.math.round + +/** + * Draws the background of a vertically container, and applies clipping to it. + * + * Intended to be used with a [VerticalExpandContainerSpec] motion. + */ +fun Modifier.verticalExpandContainerBackground( + backgroundColor: Color, + spec: VerticalExpandContainerSpec, +): Modifier = + this.then( + if (spec.isFloating) { + Modifier.verticalFloatingExpandContainerBackground(backgroundColor, spec) + } else { + Modifier.verticalEdgeExpandContainerBackground(backgroundColor, spec) + } + ) + +/** + * Draws the background of an floating container, and applies clipping to it. + * + * Intended to be used with a [VerticalExpandContainerSpec] motion. + */ +internal fun Modifier.verticalFloatingExpandContainerBackground( + backgroundColor: Color, + spec: VerticalExpandContainerSpec, +): Modifier = + this.drawWithCache { + val targetRadiusPx = spec.radius.toPx() + val currentRadiusPx = min(targetRadiusPx, min(size.width, size.height) / 2f) + val horizontalInset = targetRadiusPx - currentRadiusPx + val shapeTopLeft = Offset(horizontalInset, 0f) + val shapeSize = Size(size.width - (horizontalInset * 2f), size.height) + + val layer = + obtainGraphicsLayer().apply { + clip = true + setRoundRectOutline(shapeTopLeft, shapeSize, cornerRadius = currentRadiusPx) + } + + onDrawWithContent { + layer.record { this@onDrawWithContent.drawContent() } + drawRoundRect( + color = backgroundColor, + topLeft = shapeTopLeft, + size = shapeSize, + cornerRadius = CornerRadius(currentRadiusPx), + ) + + drawLayer(layer) + } + } + +/** + * Draws the background of an edge container, and applies clipping to it. + * + * Intended to be used with a [VerticalExpandContainerSpec] motion. + */ +internal fun Modifier.verticalEdgeExpandContainerBackground( + backgroundColor: Color, + spec: VerticalExpandContainerSpec, +): Modifier = this.then(EdgeContainerExpansionBackgroundElement(backgroundColor, spec)) + +internal class EdgeContainerExpansionBackgroundNode( + var backgroundColor: Color, + var spec: VerticalExpandContainerSpec, +) : Modifier.Node(), DrawModifierNode { + + private var graphicsLayer: GraphicsLayer? = null + private var lastOutlineSize = Size.Zero + + fun invalidateOutline() { + lastOutlineSize = Size.Zero + } + + override fun onAttach() { + graphicsLayer = requireGraphicsContext().createGraphicsLayer().apply { clip = true } + } + + override fun onDetach() { + requireGraphicsContext().releaseGraphicsLayer(checkNotNull(graphicsLayer)) + } + + override fun ContentDrawScope.draw() { + val height = size.height + + // The width is growing between visibleHeight and detachHeight + val visibleHeight = spec.visibleHeight.toPx() + val widthFraction = + ((height - visibleHeight) / (spec.detachHeight.toPx() - visibleHeight)).fastCoerceIn( + 0f, + 1f, + ) + val width = size.width - lerp(spec.widthOffset.toPx(), 0f, widthFraction) + val horizontalInset = (size.width - width) / 2f + + // The radius is growing at the beginning of the transition + val radius = height.fastCoerceIn(spec.minRadius.toPx(), spec.radius.toPx()) + + // Draw (at most) the bottom half of the rounded corner rectangle, aligned to the bottom. + // Round upper height to the closest integer to avoid to avoid a hairline gap being visible + // due to the two rectangles overlapping. + val upperHeight = round((height - radius)).fastCoerceAtLeast(0f) + + // The rounded rect is drawn at 2x the radius height, to avoid smaller corner radii. + // The clipRect limits this to the relevant part between this and the fill below. + clipRect(top = upperHeight) { + drawRoundRect( + color = backgroundColor, + cornerRadius = CornerRadius(radius), + size = Size(width, radius * 2f), + topLeft = Offset(horizontalInset, size.height - radius * 2f), + ) + } + + if (upperHeight > 0) { + // Fill the space above the bottom shape. + drawRect( + color = backgroundColor, + topLeft = Offset(horizontalInset, 0f), + size = Size(width, upperHeight), + ) + } + + // Draw the node's content in a separate layer. + val graphicsLayer = checkNotNull(graphicsLayer) + graphicsLayer.record { this@draw.drawContent() } + + if (size != lastOutlineSize) { + // The clip outline is a rounded corner shape matching the bottom of the shape. + // At the top, the rounded corner shape extends by radiusPx above top. + // This clipping thus would not prevent the containers content to overdraw at the top, + // however this is off-screen anyways. + val top = min(-radius, height - radius * 2f) + + val rect = Rect(left = horizontalInset, top = top, right = width, bottom = height) + graphicsLayer.setRoundRectOutline(rect.topLeft, rect.size, radius) + lastOutlineSize = size + } + + this.drawLayer(graphicsLayer) + } +} + +private data class EdgeContainerExpansionBackgroundElement( + val backgroundColor: Color, + val spec: VerticalExpandContainerSpec, +) : ModifierNodeElement() { + override fun create(): EdgeContainerExpansionBackgroundNode = + EdgeContainerExpansionBackgroundNode(backgroundColor, spec) + + override fun update(node: EdgeContainerExpansionBackgroundNode) { + node.backgroundColor = backgroundColor + if (node.spec != spec) { + node.spec = spec + node.invalidateOutline() + } + } +} diff --git a/mechanics/src/com/android/mechanics/behavior/VerticalExpandContainerSpec.kt b/mechanics/src/com/android/mechanics/behavior/VerticalExpandContainerSpec.kt new file mode 100644 index 0000000..3bc264a --- /dev/null +++ b/mechanics/src/com/android/mechanics/behavior/VerticalExpandContainerSpec.kt @@ -0,0 +1,161 @@ +/* + * Copyright (C) 2025 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. + */ + +@file:OptIn(ExperimentalMaterial3ExpressiveApi::class) + +package com.android.mechanics.behavior + +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.MotionScheme +import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.compose.ui.util.fastCoerceIn +import androidx.compose.ui.util.lerp +import com.android.mechanics.spec.Breakpoint +import com.android.mechanics.spec.BreakpointKey +import com.android.mechanics.spec.InputDirection +import com.android.mechanics.spec.Mapping +import com.android.mechanics.spec.MotionSpec +import com.android.mechanics.spec.OnChangeSegmentHandler +import com.android.mechanics.spec.SegmentData +import com.android.mechanics.spec.SegmentKey +import com.android.mechanics.spec.builder.directionalMotionSpec +import com.android.mechanics.spring.SpringParameters + +/** Motion spec for a vertically expandable container. */ +class VerticalExpandContainerSpec( + val isFloating: Boolean, + val minRadius: Dp = Defaults.MinRadius, + val radius: Dp = Defaults.Radius, + val visibleHeight: Dp = Defaults.VisibleHeight, + val preDetachRatio: Float = Defaults.PreDetachRatio, + val detachHeight: Dp = if (isFloating) radius * 3 else Defaults.DetachHeight, + val attachHeight: Dp = if (isFloating) radius * 2 else Defaults.AttachHeight, + val widthOffset: Dp = Defaults.WidthOffset, + val attachSpring: SpringParameters = Defaults.AttachSpring, + val detachSpring: SpringParameters = Defaults.DetachSpring, + val opacitySpring: SpringParameters = Defaults.OpacitySpring, +) { + fun createHeightSpec(motionScheme: MotionScheme, density: Density): MotionSpec { + // TODO: michschn@ - replace with MagneticDetach + return with(density) { + val spatialSpring = SpringParameters(motionScheme.defaultSpatialSpec()) + + val detachSpec = + directionalMotionSpec( + initialMapping = Mapping.Zero, + defaultSpring = spatialSpring, + ) { + fractionalInputFromCurrent( + breakpoint = 0f, + key = Breakpoints.Attach, + fraction = preDetachRatio, + ) + identity( + breakpoint = detachHeight.toPx(), + key = Breakpoints.Detach, + spring = detachSpring, + ) + } + + val attachSpec = + directionalMotionSpec( + initialMapping = Mapping.Zero, + defaultSpring = spatialSpring, + ) { + identity( + breakpoint = attachHeight.toPx(), + key = Breakpoints.Detach, + spring = attachSpring, + ) + } + + val segmentHandlers = + mapOf( + SegmentKey(Breakpoints.Detach, Breakpoint.maxLimit.key, InputDirection.Min) to + { currentSegment, _, newDirection -> + if (newDirection != currentSegment.direction) currentSegment else null + }, + SegmentKey(Breakpoints.Attach, Breakpoints.Detach, InputDirection.Max) to + { currentSegment: SegmentData, newInput: Float, newDirection: InputDirection + -> + if (newDirection != currentSegment.direction && newInput >= 0) + currentSegment + else null + }, + ) + + MotionSpec( + maxDirection = detachSpec, + minDirection = attachSpec, + segmentHandlers = segmentHandlers, + ) + } + } + + fun createWidthSpec( + intrinsicWidth: Float, + motionScheme: MotionScheme, + density: Density, + ): MotionSpec { + return with(density) { + if (isFloating) { + MotionSpec(directionalMotionSpec(Mapping.Fixed(intrinsicWidth))) + } else { + MotionSpec( + directionalMotionSpec({ input -> + val fraction = (input / detachHeight.toPx()).fastCoerceIn(0f, 1f) + intrinsicWidth - lerp(widthOffset.toPx(), 0f, fraction) + }) + ) + } + } + } + + fun createAlphaSpec(motionScheme: MotionScheme, density: Density): MotionSpec { + return with(density) { + MotionSpec( + directionalMotionSpec(opacitySpring, initialMapping = Mapping.Zero) { + fixedValue(breakpoint = visibleHeight.toPx(), value = 1f) + } + ) + } + } + + companion object { + object Breakpoints { + val Attach = BreakpointKey("EdgeContainerExpansion::Attach") + val Detach = BreakpointKey("EdgeContainerExpansion::Detach") + } + + object Defaults { + val VisibleHeight = 24.dp + val PreDetachRatio = .25f + val DetachHeight = 80.dp + val AttachHeight = 40.dp + + val WidthOffset = 28.dp + + val MinRadius = 28.dp + val Radius = 46.dp + + val AttachSpring = SpringParameters(stiffness = 380f, dampingRatio = 0.9f) + val DetachSpring = SpringParameters(stiffness = 380f, dampingRatio = 0.9f) + val OpacitySpring = SpringParameters(stiffness = 1200f, dampingRatio = 0.99f) + } + } +} diff --git a/mechanics/src/com/android/mechanics/debug/DebugInspector.kt b/mechanics/src/com/android/mechanics/debug/DebugInspector.kt new file mode 100644 index 0000000..088c78b --- /dev/null +++ b/mechanics/src/com/android/mechanics/debug/DebugInspector.kt @@ -0,0 +1,90 @@ +/* + * 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.MotionValue +import com.android.mechanics.impl.DiscontinuityAnimation +import com.android.mechanics.spec.InputDirection +import com.android.mechanics.spec.SegmentData +import com.android.mechanics.spec.SegmentKey +import com.android.mechanics.spec.SemanticKey +import com.android.mechanics.spec.SemanticValue +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, + initialIsActive: Boolean, + initialIsAnimating: 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(initialIsActive) + internal set + + /** + * `false` whenever the [MotionValue.keepRunning] coroutine internally is suspended while no + * animation is running and the input is not changing. + */ + var isAnimating: Boolean by mutableStateOf(initialIsAnimating) + 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 gestureDragOffset: 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() = segment.mapping.map(input) + springState.displacement + + val outputTarget: Float + get() = segment.mapping.map(input) + + fun semantic(semanticKey: SemanticKey): T? { + return segment.semantic(semanticKey) + } + + val semantics: List> + get() = with(segment) { spec.semantics(key) } +} diff --git a/mechanics/src/com/android/mechanics/debug/DebugVisualization.kt b/mechanics/src/com/android/mechanics/debug/DebugVisualization.kt new file mode 100644 index 0000000..b89728b --- /dev/null +++ b/mechanics/src/com/android/mechanics/debug/DebugVisualization.kt @@ -0,0 +1,510 @@ +/* + * 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.foundation.layout.Spacer +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow +import androidx.compose.runtime.withFrameNanos +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.drawscope.ContentDrawScope +import androidx.compose.ui.graphics.drawscope.DrawScope +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.graphics.drawscope.scale +import androidx.compose.ui.graphics.drawscope.translate +import androidx.compose.ui.node.DrawModifierNode +import androidx.compose.ui.node.ModifierNodeElement +import androidx.compose.ui.node.ObserverModifierNode +import androidx.compose.ui.node.observeReads +import androidx.compose.ui.platform.InspectorInfo +import androidx.compose.ui.unit.dp +import androidx.compose.ui.util.fastCoerceAtLeast +import androidx.compose.ui.util.fastCoerceAtMost +import androidx.compose.ui.util.fastForEachIndexed +import com.android.mechanics.MotionValue +import com.android.mechanics.spec.DirectionalMotionSpec +import com.android.mechanics.spec.Guarantee +import com.android.mechanics.spec.InputDirection +import com.android.mechanics.spec.Mapping +import com.android.mechanics.spec.MotionSpec +import com.android.mechanics.spec.SegmentKey +import kotlin.math.ceil +import kotlin.math.max +import kotlin.math.min +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch + +/** + * A debug visualization of the [motionValue]. + * + * Draws both the [MotionValue.spec], as well as the input and output. + * + * NOTE: This is a debug tool, do not enable in production. + * + * @param motionValue The [MotionValue] to inspect. + * @param inputRange The relevant range of the input (x) axis, for which to draw the graph. + * @param maxAgeMillis Max age of the elements in the history trail. + */ +@Composable +fun DebugMotionValueVisualization( + motionValue: MotionValue, + inputRange: ClosedFloatingPointRange, + modifier: Modifier = Modifier, + maxAgeMillis: Long = 1000L, +) { + val spec = motionValue.spec + val outputRange = remember(spec, inputRange) { spec.computeOutputValueRange(inputRange) } + + val inspector = remember(motionValue) { motionValue.debugInspector() } + + DisposableEffect(inspector) { onDispose { inspector.dispose() } } + + val colorScheme = MaterialTheme.colorScheme + val axisColor = colorScheme.outline + val specColor = colorScheme.tertiary + val valueColor = colorScheme.primary + + val primarySpec = motionValue.spec.get(inspector.frame.gestureDirection) + val activeSegment = inspector.frame.segmentKey + + Spacer( + modifier = + modifier + .debugMotionSpecGraph( + primarySpec, + inputRange, + outputRange, + axisColor, + specColor, + activeSegment, + ) + .debugMotionValueGraph( + motionValue, + valueColor, + inputRange, + outputRange, + maxAgeMillis, + ) + ) +} + +/** + * Draws a full-sized debug visualization of [spec]. + * + * NOTE: This is a debug tool, do not enable in production. + * + * @param inputRange The range of the input (x) axis + * @param outputRange The range of the output (y) axis. + */ +fun Modifier.debugMotionSpecGraph( + spec: DirectionalMotionSpec, + inputRange: ClosedFloatingPointRange, + outputRange: ClosedFloatingPointRange, + axisColor: Color = Color.Gray, + specColor: Color = Color.Blue, + activeSegment: SegmentKey? = null, +): Modifier = drawBehind { + drawAxis(axisColor) + drawDirectionalSpec(spec, inputRange, outputRange, specColor, activeSegment) +} + +/** + * Draws a full-sized debug visualization of the [motionValue] state. + * + * This can be combined with [debugMotionSpecGraph], when [inputRange] and [outputRange] are the + * same. + * + * NOTE: This is a debug tool, do not enable in production. + * + * @param color Color for the dots indicating the value + * @param inputRange The range of the input (x) axis + * @param outputRange The range of the output (y) axis. + * @param maxAgeMillis Max age of the elements in the history trail. + */ +@Composable +fun Modifier.debugMotionValueGraph( + motionValue: MotionValue, + color: Color, + inputRange: ClosedFloatingPointRange, + outputRange: ClosedFloatingPointRange, + maxAgeMillis: Long = 1000L, +): Modifier = + this then + DebugMotionValueGraphElement(motionValue, color, inputRange, outputRange, maxAgeMillis) + +/** + * Utility to compute the min/max output values of the spec for the given input. + * + * Note: this only samples at breakpoint locations. For segment mappings that produce smaller/larger + * values in between two breakpoints, this method might might not produce a correct result. + */ +fun MotionSpec.computeOutputValueRange( + inputRange: ClosedFloatingPointRange +): ClosedFloatingPointRange { + return if (isUnidirectional) { + maxDirection.computeOutputValueRange(inputRange) + } else { + val maxRange = maxDirection.computeOutputValueRange(inputRange) + val minRange = minDirection.computeOutputValueRange(inputRange) + + val start = min(minRange.start, maxRange.start) + val endInclusive = max(minRange.endInclusive, maxRange.endInclusive) + + start..endInclusive + } +} + +/** + * Utility to compute the min/max output values of the spec for the given input. + * + * Note: this only samples at breakpoint locations. For segment mappings that produce smaller/larger + * values in between two breakpoints, this method might might not produce a correct result. + */ +fun DirectionalMotionSpec.computeOutputValueRange( + inputRange: ClosedFloatingPointRange +): ClosedFloatingPointRange { + + val start = findBreakpointIndex(inputRange.start) + val end = findBreakpointIndex(inputRange.endInclusive) + + val samples = buildList { + add(mappings[start].map(inputRange.start)) + + for (breakpointIndex in (start + 1)..end) { + + val position = breakpoints[breakpointIndex].position + + add(mappings[breakpointIndex - 1].map(position)) + add(mappings[breakpointIndex].map(position)) + } + + add(mappings[end].map(inputRange.endInclusive)) + } + + return samples.min()..samples.max() +} + +private data class DebugMotionValueGraphElement( + val motionValue: MotionValue, + val color: Color, + val inputRange: ClosedFloatingPointRange, + val outputRange: ClosedFloatingPointRange, + val maxAgeMillis: Long, +) : ModifierNodeElement() { + + init { + require(maxAgeMillis > 0) + } + + override fun create() = + DebugMotionValueGraphNode(motionValue, color, inputRange, outputRange, maxAgeMillis) + + override fun update(node: DebugMotionValueGraphNode) { + node.motionValue = motionValue + node.color = color + node.inputRange = inputRange + node.outputRange = outputRange + node.maxAgeMillis = maxAgeMillis + } + + override fun InspectorInfo.inspectableProperties() { + // intentionally empty + } +} + +private class DebugMotionValueGraphNode( + motionValue: MotionValue, + var color: Color, + var inputRange: ClosedFloatingPointRange, + var outputRange: ClosedFloatingPointRange, + var maxAgeMillis: Long, +) : DrawModifierNode, ObserverModifierNode, Modifier.Node() { + + private var debugInspector by mutableStateOf(null) + private val history = mutableStateListOf() + + var motionValue = motionValue + set(value) { + if (value != field) { + disposeDebugInspector() + field = value + + if (isAttached) { + acquireDebugInspector() + } + } + } + + override fun onAttach() { + acquireDebugInspector() + + coroutineScope.launch { + while (true) { + if (history.size > 1) { + + withFrameNanos { thisFrameTime -> + while ( + history.size > 1 && + (thisFrameTime - history.first().frameTimeNanos) > + maxAgeMillis * 1_000_000 + ) { + history.removeFirst() + } + } + } + + snapshotFlow { history.size > 1 }.first { it } + } + } + } + + override fun onDetach() { + disposeDebugInspector() + } + + private fun acquireDebugInspector() { + debugInspector = motionValue.debugInspector() + observeFrameAndAddToHistory() + } + + private fun disposeDebugInspector() { + debugInspector?.dispose() + debugInspector = null + history.clear() + } + + override fun ContentDrawScope.draw() { + if (history.isNotEmpty()) { + drawDirectionAndAnimationStatus(history.last()) + } + drawInputOutputTrail(history, inputRange, outputRange, color) + drawContent() + } + + private fun observeFrameAndAddToHistory() { + var lastFrame: FrameData? = null + + observeReads { lastFrame = debugInspector?.frame } + + lastFrame?.also { history.add(it) } + } + + override fun onObservedReadsChanged() { + observeFrameAndAddToHistory() + } +} + +private val MotionSpec.isUnidirectional: Boolean + get() = maxDirection == minDirection + +private fun DrawScope.mapPointInInputToX( + input: Float, + inputRange: ClosedFloatingPointRange, +): Float { + val inputExtent = (inputRange.endInclusive - inputRange.start) + return ((input - inputRange.start) / (inputExtent)) * size.width +} + +private fun DrawScope.mapPointInOutputToY( + output: Float, + outputRange: ClosedFloatingPointRange, +): Float { + val outputExtent = (outputRange.endInclusive - outputRange.start) + return (1 - (output - outputRange.start) / (outputExtent)) * size.height +} + +private fun DrawScope.drawDirectionalSpec( + spec: DirectionalMotionSpec, + inputRange: ClosedFloatingPointRange, + outputRange: ClosedFloatingPointRange, + color: Color, + activeSegment: SegmentKey?, +) { + + val startSegment = spec.findBreakpointIndex(inputRange.start) + val endSegment = spec.findBreakpointIndex(inputRange.endInclusive) + + for (segmentIndex in startSegment..endSegment) { + val isActiveSegment = + activeSegment?.let { spec.findSegmentIndex(it) == segmentIndex } ?: false + + val mapping = spec.mappings[segmentIndex] + val startBreakpoint = spec.breakpoints[segmentIndex] + val segmentStart = startBreakpoint.position + val fromInput = segmentStart.fastCoerceAtLeast(inputRange.start) + val endBreakpoint = spec.breakpoints[segmentIndex + 1] + val segmentEnd = endBreakpoint.position + val toInput = segmentEnd.fastCoerceAtMost(inputRange.endInclusive) + + val strokeWidth = if (isActiveSegment) 2.dp.toPx() else Stroke.HairlineWidth + val dotSize = if (isActiveSegment) 4.dp.toPx() else 2.dp.toPx() + val fromY = mapPointInOutputToY(mapping.map(fromInput), outputRange) + val toY = mapPointInOutputToY(mapping.map(toInput), outputRange) + + val start = Offset(mapPointInInputToX(fromInput, inputRange), fromY) + val end = Offset(mapPointInInputToX(toInput, inputRange), toY) + if (mapping is Mapping.Fixed || mapping is Mapping.Identity || mapping is Mapping.Linear) { + drawLine(color, start, end, strokeWidth = strokeWidth) + } else { + val xStart = mapPointInInputToX(fromInput, inputRange) + val xEnd = mapPointInInputToX(toInput, inputRange) + + val oneDpInPx = 1.dp.toPx() + val numberOfLines = ceil((xEnd - xStart) / oneDpInPx).toInt() + val inputLength = (toInput - fromInput) / numberOfLines + + repeat(numberOfLines) { + val lineStart = fromInput + inputLength * it + val lineEnd = lineStart + inputLength + + val partialFromY = mapPointInOutputToY(mapping.map(lineStart), outputRange) + val partialToY = mapPointInOutputToY(mapping.map(lineEnd), outputRange) + + val partialStart = Offset(mapPointInInputToX(lineStart, inputRange), partialFromY) + val partialEnd = Offset(mapPointInInputToX(lineEnd, inputRange), partialToY) + + drawLine(color, partialStart, partialEnd, strokeWidth = strokeWidth) + } + } + + if (segmentStart == fromInput) { + drawCircle(color, dotSize, start) + } + + if (segmentEnd == toInput) { + drawCircle(color, dotSize, end) + } + + val guarantee = startBreakpoint.guarantee + if (guarantee is Guarantee.InputDelta) { + val guaranteePos = segmentStart + guarantee.delta + if (guaranteePos > inputRange.start) { + + val guaranteeOffset = + Offset( + mapPointInInputToX(guaranteePos, inputRange), + mapPointInOutputToY(mapping.map(guaranteePos), outputRange), + ) + + val arrowSize = 4.dp.toPx() + + drawLine( + color, + guaranteeOffset, + guaranteeOffset.plus(Offset(arrowSize, -arrowSize)), + ) + drawLine(color, guaranteeOffset, guaranteeOffset.plus(Offset(arrowSize, arrowSize))) + } + } + } +} + +private fun DrawScope.drawDirectionAndAnimationStatus(currentFrame: FrameData) { + val indicatorSize = min(this.size.height, 24.dp.toPx()) + + this.scale( + scaleX = if (currentFrame.gestureDirection == InputDirection.Max) 1f else -1f, + scaleY = 1f, + ) { + val color = if (currentFrame.isStable) Color.Green else Color.Red + val strokeWidth = 1.dp.toPx() + val d1 = indicatorSize / 2f + val d2 = indicatorSize / 3f + + translate(left = 2.dp.toPx()) { + drawLine( + color, + Offset(center.x - d2, center.y - d1), + center, + strokeWidth = strokeWidth, + cap = StrokeCap.Round, + ) + drawLine( + color, + Offset(center.x - d2, center.y + d1), + center, + strokeWidth = strokeWidth, + cap = StrokeCap.Round, + ) + } + translate(left = -2.dp.toPx()) { + drawLine( + color, + Offset(center.x - d2, center.y - d1), + center, + strokeWidth = strokeWidth, + cap = StrokeCap.Round, + ) + drawLine( + color, + Offset(center.x - d2, center.y + d1), + center, + strokeWidth = strokeWidth, + cap = StrokeCap.Round, + ) + } + } +} + +private fun DrawScope.drawInputOutputTrail( + history: List, + inputRange: ClosedFloatingPointRange, + outputRange: ClosedFloatingPointRange, + color: Color, +) { + history.fastForEachIndexed { index, frame -> + val x = mapPointInInputToX(frame.input, inputRange) + val y = mapPointInOutputToY(frame.output, outputRange) + + drawCircle(color, 2.dp.toPx(), Offset(x, y), alpha = index / history.size.toFloat()) + } +} + +private fun DrawScope.drawAxis(color: Color) { + + drawXAxis(color) + drawYAxis(color) +} + +private fun DrawScope.drawYAxis(color: Color, atX: Float = 0f) { + + val arrowSize = 4.dp.toPx() + + drawLine(color, Offset(atX, size.height), Offset(atX, 0f)) + drawLine(color, Offset(atX, 0f), Offset(atX + arrowSize, arrowSize)) + drawLine(color, Offset(atX, 0f), Offset(atX - arrowSize, arrowSize)) +} + +private fun DrawScope.drawXAxis(color: Color, atY: Float = size.height) { + + val arrowSize = 4.dp.toPx() + + drawLine(color, Offset(0f, atY), Offset(size.width, atY)) + drawLine(color, Offset(size.width, atY), Offset(size.width - arrowSize, atY + arrowSize)) + drawLine(color, Offset(size.width, atY), Offset(size.width - arrowSize, atY - arrowSize)) +} diff --git a/mechanics/src/com/android/mechanics/debug/MotionValueDebugger.kt b/mechanics/src/com/android/mechanics/debug/MotionValueDebugger.kt new file mode 100644 index 0000000..3c0109d --- /dev/null +++ b/mechanics/src/com/android/mechanics/debug/MotionValueDebugger.kt @@ -0,0 +1,130 @@ +/* + * Copyright (C) 2025 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.mutableStateListOf +import androidx.compose.ui.Modifier +import androidx.compose.ui.node.DelegatableNode +import androidx.compose.ui.node.ModifierNodeElement +import androidx.compose.ui.node.TraversableNode +import androidx.compose.ui.node.findNearestAncestor +import androidx.compose.ui.platform.InspectorInfo +import com.android.mechanics.MotionValue +import com.android.mechanics.debug.MotionValueDebuggerNode.Companion.TRAVERSAL_NODE_KEY +import kotlinx.coroutines.DisposableHandle + +/** State for the [MotionValueDebugger]. */ +sealed interface MotionValueDebuggerState { + val observedMotionValues: List +} + +/** Factory for [MotionValueDebugger]. */ +fun MotionValueDebuggerState(): MotionValueDebuggerState { + return MotionValueDebuggerStateImpl() +} + +/** Collector for [MotionValue]s in the Node subtree that should be observed for debug purposes. */ +fun Modifier.motionValueDebugger(state: MotionValueDebuggerState): Modifier = + this.then(MotionValueDebuggerElement(state as MotionValueDebuggerStateImpl)) + +/** + * [motionValueDebugger]'s interface, nodes in the subtree of a [motionValueDebugger] can retrieve + * it using [findMotionValueDebugger]. + */ +sealed interface MotionValueDebugger { + fun register(motionValue: MotionValue): DisposableHandle +} + +/** Finds a [MotionValueDebugger] that was registered via a [motionValueDebugger] modifier. */ +fun DelegatableNode.findMotionValueDebugger(): MotionValueDebugger? { + return findNearestAncestor(TRAVERSAL_NODE_KEY) as? MotionValueDebugger +} + +/** Registers the motion value for debugging with the parent [MotionValue]. */ +fun Modifier.debugMotionValue(motionValue: MotionValue): Modifier = + this.then(DebugMotionValueElement(motionValue)) + +internal class MotionValueDebuggerNode(internal var state: MotionValueDebuggerStateImpl) : + Modifier.Node(), TraversableNode, MotionValueDebugger { + + override val traverseKey = TRAVERSAL_NODE_KEY + + override fun register(motionValue: MotionValue): DisposableHandle { + val state = state + state.observedMotionValues.add(motionValue) + return DisposableHandle { state.observedMotionValues.remove(motionValue) } + } + + companion object { + const val TRAVERSAL_NODE_KEY = "com.android.mechanics.debug.DEBUG_CONNECTOR_NODE_KEY" + } +} + +private data class MotionValueDebuggerElement(val state: MotionValueDebuggerStateImpl) : + ModifierNodeElement() { + override fun create(): MotionValueDebuggerNode = MotionValueDebuggerNode(state) + + override fun InspectorInfo.inspectableProperties() { + // Intentionally empty + } + + override fun update(node: MotionValueDebuggerNode) { + check(node.state === state) + } +} + +internal class DebugMotionValueNode(motionValue: MotionValue) : Modifier.Node() { + + private var debugger: MotionValueDebugger? = null + + internal var motionValue = motionValue + set(value) { + registration?.dispose() + registration = debugger?.register(value) + field = value + } + + internal var registration: DisposableHandle? = null + + override fun onAttach() { + debugger = findMotionValueDebugger() + registration = debugger?.register(motionValue) + } + + override fun onDetach() { + debugger = null + registration?.dispose() + registration = null + } +} + +private data class DebugMotionValueElement(val motionValue: MotionValue) : + ModifierNodeElement() { + override fun create(): DebugMotionValueNode = DebugMotionValueNode(motionValue) + + override fun InspectorInfo.inspectableProperties() { + // Intentionally empty + } + + override fun update(node: DebugMotionValueNode) { + node.motionValue = motionValue + } +} + +internal class MotionValueDebuggerStateImpl : MotionValueDebuggerState { + override val observedMotionValues: MutableList = mutableStateListOf() +} diff --git a/mechanics/src/com/android/mechanics/effects/Fixed.kt b/mechanics/src/com/android/mechanics/effects/Fixed.kt new file mode 100644 index 0000000..b1c5fb2 --- /dev/null +++ b/mechanics/src/com/android/mechanics/effects/Fixed.kt @@ -0,0 +1,55 @@ +/* + * Copyright (C) 2025 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.effects + +import com.android.mechanics.spec.BreakpointKey +import com.android.mechanics.spec.Mapping +import com.android.mechanics.spec.builder.Effect +import com.android.mechanics.spec.builder.EffectApplyScope +import com.android.mechanics.spec.builder.EffectPlacement +import com.android.mechanics.spec.builder.MotionBuilderContext +import com.android.mechanics.spec.builder.MotionSpecBuilderScope + +/** Creates a [FixedValue] effect with the given [value]. */ +fun MotionSpecBuilderScope.fixed(value: Float) = FixedValue(value) + +val MotionSpecBuilderScope.zero: FixedValue + get() = FixedValue.Zero +val MotionSpecBuilderScope.one: FixedValue + get() = FixedValue.One + +/** Produces a fixed [value]. */ +class FixedValue(val value: Float) : + Effect.PlaceableAfter, Effect.PlaceableBefore, Effect.PlaceableBetween { + + override fun MotionBuilderContext.intrinsicSize(): Float = Float.NaN + + override fun EffectApplyScope.createSpec( + minLimit: Float, + minLimitKey: BreakpointKey, + maxLimit: Float, + maxLimitKey: BreakpointKey, + placement: EffectPlacement, + ) { + return unidirectional(Mapping.Fixed(value)) + } + + companion object { + val Zero = FixedValue(0f) + val One = FixedValue(1f) + } +} diff --git a/mechanics/src/com/android/mechanics/effects/MagneticDetach.kt b/mechanics/src/com/android/mechanics/effects/MagneticDetach.kt new file mode 100644 index 0000000..1e4e38b --- /dev/null +++ b/mechanics/src/com/android/mechanics/effects/MagneticDetach.kt @@ -0,0 +1,259 @@ +/* + * Copyright (C) 2025 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. + */ + +@file:OptIn(ExperimentalMaterial3ExpressiveApi::class) + +package com.android.mechanics.effects + +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.compose.ui.util.lerp +import com.android.mechanics.spec.BreakpointKey +import com.android.mechanics.spec.ChangeSegmentHandlers.PreventDirectionChangeWithinCurrentSegment +import com.android.mechanics.spec.InputDirection +import com.android.mechanics.spec.Mapping +import com.android.mechanics.spec.SegmentKey +import com.android.mechanics.spec.SemanticKey +import com.android.mechanics.spec.builder.Effect +import com.android.mechanics.spec.builder.EffectApplyScope +import com.android.mechanics.spec.builder.EffectPlacemenType +import com.android.mechanics.spec.builder.EffectPlacement +import com.android.mechanics.spec.builder.MotionBuilderContext +import com.android.mechanics.spec.with +import com.android.mechanics.spring.SpringParameters + +/** + * Gesture effect that emulates effort to detach an element from its resting position. + * + * @param semanticState semantic state used to check the state of this effect. + * @param detachPosition distance from the origin to detach + * @param attachPosition distance from the origin to re-attach + * @param detachScale fraction of input changes propagated during detach. + * @param attachScale fraction of input changes propagated after re-attach. + * @param detachSpring spring used during detach + * @param attachSpring spring used during attach + */ +class MagneticDetach( + private val semanticState: SemanticKey = Defaults.AttachDetachState, + private val semanticAttachedValue: SemanticKey = Defaults.AttachedValue, + private val detachPosition: Dp = Defaults.DetachPosition, + private val attachPosition: Dp = Defaults.AttachPosition, + private val detachScale: Float = Defaults.AttachDetachScale, + private val attachScale: Float = Defaults.AttachDetachScale * (attachPosition / detachPosition), + private val detachSpring: SpringParameters = Defaults.Spring, + private val attachSpring: SpringParameters = Defaults.Spring, +) : Effect.PlaceableAfter, Effect.PlaceableBefore { + + init { + require(attachPosition <= detachPosition) + } + + enum class State { + Attached, + Detached, + } + + override fun MotionBuilderContext.intrinsicSize(): Float { + return detachPosition.toPx() + } + + override fun EffectApplyScope.createSpec( + minLimit: Float, + minLimitKey: BreakpointKey, + maxLimit: Float, + maxLimitKey: BreakpointKey, + placement: EffectPlacement, + ) { + if (placement.type == EffectPlacemenType.Before) { + createPlacedBeforeSpec(minLimit, minLimitKey, maxLimit, maxLimitKey) + } else { + assert(placement.type == EffectPlacemenType.After) + createPlacedAfterSpec(minLimit, minLimitKey, maxLimit, maxLimitKey) + } + } + + object Defaults { + val AttachDetachState = SemanticKey() + val AttachedValue = SemanticKey() + val AttachDetachScale = .3f + val DetachPosition = 80.dp + val AttachPosition = 40.dp + val Spring = SpringParameters(stiffness = 800f, dampingRatio = 0.95f) + } + + /* Effect is attached at minLimit, and detaches at maxLimit. */ + private fun EffectApplyScope.createPlacedAfterSpec( + minLimit: Float, + minLimitKey: BreakpointKey, + maxLimit: Float, + maxLimitKey: BreakpointKey, + ) { + val attachedValue = baseValue(minLimit) + val detachedValue = baseValue(maxLimit) + val reattachPos = minLimit + attachPosition.toPx() + val reattachValue = baseValue(reattachPos) + + val attachedSemantics = + listOf(semanticState with State.Attached, semanticAttachedValue with attachedValue) + val detachedSemantics = + listOf(semanticState with State.Detached, semanticAttachedValue with null) + + val scaledDetachValue = attachedValue + (detachedValue - attachedValue) * detachScale + val scaledReattachValue = attachedValue + (reattachValue - attachedValue) * attachScale + + val attachKey = BreakpointKey("attach") + forward( + initialMapping = Mapping.Linear(minLimit, attachedValue, maxLimit, scaledDetachValue), + semantics = attachedSemantics, + ) { + after(spring = detachSpring, semantics = detachedSemantics) + before(semantics = listOf(semanticAttachedValue with null)) + } + + backward( + initialMapping = + Mapping.Linear(minLimit, attachedValue, reattachPos, scaledReattachValue), + semantics = attachedSemantics, + ) { + mapping( + breakpoint = reattachPos, + key = attachKey, + spring = attachSpring, + semantics = detachedSemantics, + mapping = baseMapping, + ) + before(semantics = listOf(semanticAttachedValue with null)) + after(semantics = listOf(semanticAttachedValue with null)) + } + + addSegmentHandlers( + beforeDetachSegment = SegmentKey(minLimitKey, maxLimitKey, InputDirection.Max), + beforeAttachSegment = SegmentKey(attachKey, maxLimitKey, InputDirection.Min), + afterAttachSegment = SegmentKey(minLimitKey, attachKey, InputDirection.Min), + minLimit = minLimit, + maxLimit = maxLimit, + ) + } + + /* Effect is attached at maxLimit, and detaches at minLimit. */ + private fun EffectApplyScope.createPlacedBeforeSpec( + minLimit: Float, + minLimitKey: BreakpointKey, + maxLimit: Float, + maxLimitKey: BreakpointKey, + ) { + val attachedValue = baseValue(maxLimit) + val detachedValue = baseValue(minLimit) + val reattachPos = maxLimit - attachPosition.toPx() + val reattachValue = baseValue(reattachPos) + + val attachedSemantics = + listOf(semanticState with State.Attached, semanticAttachedValue with attachedValue) + val detachedSemantics = + listOf(semanticState with State.Detached, semanticAttachedValue with null) + + val scaledDetachValue = attachedValue + (detachedValue - attachedValue) * detachScale + val scaledReattachValue = attachedValue + (reattachValue - attachedValue) * attachScale + + val attachKey = BreakpointKey("attach") + + backward( + initialMapping = Mapping.Linear(minLimit, scaledDetachValue, maxLimit, attachedValue), + semantics = attachedSemantics, + ) { + before(spring = detachSpring, semantics = detachedSemantics) + after(semantics = listOf(semanticAttachedValue with null)) + } + + forward(initialMapping = baseMapping, semantics = detachedSemantics) { + target( + breakpoint = reattachPos, + key = attachKey, + from = scaledReattachValue, + to = attachedValue, + spring = attachSpring, + semantics = attachedSemantics, + ) + after(semantics = listOf(semanticAttachedValue with null)) + } + + addSegmentHandlers( + beforeDetachSegment = SegmentKey(minLimitKey, maxLimitKey, InputDirection.Min), + beforeAttachSegment = SegmentKey(minLimitKey, attachKey, InputDirection.Max), + afterAttachSegment = SegmentKey(attachKey, maxLimitKey, InputDirection.Max), + minLimit = minLimit, + maxLimit = maxLimit, + ) + } + + private fun EffectApplyScope.addSegmentHandlers( + beforeDetachSegment: SegmentKey, + beforeAttachSegment: SegmentKey, + afterAttachSegment: SegmentKey, + minLimit: Float, + maxLimit: Float, + ) { + // Suppress direction change during detach. This prevents snapping to the origin when + // changing the direction while detaching. + addSegmentHandler(beforeDetachSegment, PreventDirectionChangeWithinCurrentSegment) + // Suppress direction when approaching attach. This prevents the detach effect when changing + // direction just before reattaching. + addSegmentHandler(beforeAttachSegment, PreventDirectionChangeWithinCurrentSegment) + + // When changing direction after re-attaching, the pre-detach ratio is tweaked to + // interpolate between the direction change-position and the detach point. + addSegmentHandler(afterAttachSegment) { currentSegment, newInput, newDirection -> + val nextSegment = segmentAtInput(newInput, newDirection) + if (nextSegment.key == beforeDetachSegment) { + nextSegment.copy( + mapping = + switchMappingWithSamePivotValue( + currentSegment.mapping, + nextSegment.mapping, + minLimit, + newInput, + maxLimit, + ) + ) + } else { + nextSegment + } + } + } + + private fun switchMappingWithSamePivotValue( + source: Mapping, + target: Mapping, + minLimit: Float, + pivot: Float, + maxLimit: Float, + ): Mapping { + val minValue = target.map(minLimit) + val pivotValue = source.map(pivot) + val maxValue = target.map(maxLimit) + + return Mapping { input -> + if (input <= pivot) { + val t = (input - minLimit) / (pivot - minLimit) + lerp(minValue, pivotValue, t) + } else { + val t = (input - pivot) / (maxLimit - pivot) + lerp(pivotValue, maxValue, t) + } + } + } +} diff --git a/mechanics/src/com/android/mechanics/effects/Overdrag.kt b/mechanics/src/com/android/mechanics/effects/Overdrag.kt new file mode 100644 index 0000000..af1dca6 --- /dev/null +++ b/mechanics/src/com/android/mechanics/effects/Overdrag.kt @@ -0,0 +1,69 @@ +/* + * Copyright (C) 2025 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.effects + +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import com.android.mechanics.spec.BreakpointKey +import com.android.mechanics.spec.Mapping +import com.android.mechanics.spec.SemanticKey +import com.android.mechanics.spec.builder.Effect +import com.android.mechanics.spec.builder.EffectApplyScope +import com.android.mechanics.spec.builder.EffectPlacement +import com.android.mechanics.spec.builder.MotionBuilderContext +import com.android.mechanics.spec.with + +/** Gesture effect to soft-limit. */ +class Overdrag( + private val overdragLimit: SemanticKey = Defaults.OverdragLimit, + private val maxOverdrag: Dp = Defaults.MaxOverdrag, + private val tilt: Float = Defaults.tilt, +) : Effect.PlaceableBefore, Effect.PlaceableAfter { + + override fun MotionBuilderContext.intrinsicSize() = Float.POSITIVE_INFINITY + + override fun EffectApplyScope.createSpec( + minLimit: Float, + minLimitKey: BreakpointKey, + maxLimit: Float, + maxLimitKey: BreakpointKey, + placement: EffectPlacement, + ) { + + val maxOverdragPx = maxOverdrag.toPx() + + val limitValue = baseValue(placement.start) + val mapping = Mapping { input -> + val baseMapped = baseMapping.map(input) + + maxOverdragPx * kotlin.math.tanh((baseMapped - limitValue) / (maxOverdragPx * tilt)) + + limitValue + } + + unidirectional(mapping, listOf(overdragLimit with limitValue)) { + if (!placement.isForward) { + after(semantics = listOf(overdragLimit with null)) + } + } + } + + object Defaults { + val OverdragLimit = SemanticKey() + val MaxOverdrag = 30.dp + val tilt = 3f + } +} diff --git a/mechanics/src/com/android/mechanics/effects/RevealOnThreshold.kt b/mechanics/src/com/android/mechanics/effects/RevealOnThreshold.kt new file mode 100644 index 0000000..124f031 --- /dev/null +++ b/mechanics/src/com/android/mechanics/effects/RevealOnThreshold.kt @@ -0,0 +1,56 @@ +/* + * Copyright (C) 2025 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.effects + +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.compose.ui.util.fastCoerceAtMost +import com.android.mechanics.spec.BreakpointKey +import com.android.mechanics.spec.Mapping +import com.android.mechanics.spec.builder.Effect +import com.android.mechanics.spec.builder.EffectApplyScope +import com.android.mechanics.spec.builder.EffectPlacement + +/** An effect that reveals a component when the available space reaches a certain threshold. */ +data class RevealOnThreshold(val minSize: Dp = Defaults.MinSize) : Effect.PlaceableBetween { + init { + require(minSize >= 0.dp) + } + + override fun EffectApplyScope.createSpec( + minLimit: Float, + minLimitKey: BreakpointKey, + maxLimit: Float, + maxLimitKey: BreakpointKey, + placement: EffectPlacement, + ) { + val maxSize = maxLimit - minLimit + val minSize = minSize.toPx().fastCoerceAtMost(maxSize) + + unidirectional(initialMapping = Mapping.Zero) { + before(mapping = Mapping.Zero) + + target(breakpoint = minLimit + minSize, from = minSize, to = maxSize) + + after(mapping = Mapping.Fixed(maxSize)) + } + } + + object Defaults { + val MinSize: Dp = 8.dp + } +} diff --git a/mechanics/src/com/android/mechanics/impl/ComputationInput.kt b/mechanics/src/com/android/mechanics/impl/ComputationInput.kt new file mode 100644 index 0000000..23ac183 --- /dev/null +++ b/mechanics/src/com/android/mechanics/impl/ComputationInput.kt @@ -0,0 +1,101 @@ +/* + * Copyright (C) 2025 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.impl + +import com.android.mechanics.MotionValue +import com.android.mechanics.spec.Breakpoint +import com.android.mechanics.spec.Guarantee +import com.android.mechanics.spec.InputDirection +import com.android.mechanics.spec.Mapping +import com.android.mechanics.spec.MotionSpec +import com.android.mechanics.spec.SegmentData +import com.android.mechanics.spring.SpringState + +/** Static configuration that remains constant over a MotionValue's lifecycle. */ +internal interface StaticConfig { + /** + * A threshold value (in output units) that determines when the [MotionValue]'s internal spring + * animation is considered stable. + */ + val stableThreshold: Float + + /** Optional label for identifying a MotionValue for debugging purposes. */ + val label: String? +} + +/** The up-to-date [MotionValue] input, used by [Computations] to calculate the updated output. */ +internal interface CurrentFrameInput { + val spec: MotionSpec + val currentInput: Float + val currentAnimationTimeNanos: Long + val currentDirection: InputDirection + val currentGestureDragOffset: Float +} + +/** + * The [MotionValue] state of the last completed frame. + * + * The values must be published at the start of the frame, together with the + * [CurrentFrameInput.currentAnimationTimeNanos]. + */ +internal interface LastFrameState { + /** + * The segment in use, defined by the min/max [Breakpoint]s and the [Mapping] in between. This + * implicitly also captures the [InputDirection] and [MotionSpec]. + */ + val lastSegment: SegmentData + /** + * State of the [Guarantee]. Its interpretation is defined by the [lastSegment]'s + * [SegmentData.entryBreakpoint]'s [Breakpoint.guarantee]. If that breakpoint has no guarantee, + * this value will be [GuaranteeState.Inactive]. + * + * This is the maximal guarantee value seen so far, as well as the guarantee's start value, and + * is used to compute the spring-tightening fraction. + */ + val lastGuaranteeState: GuaranteeState + /** + * The state of an ongoing animation of a discontinuity. + * + * The spring animation is described by the [DiscontinuityAnimation.springStartState], which + * tracks the oscillation of the spring until the displacement is guaranteed not to exceed + * [stableThreshold] anymore. The spring animation started at + * [DiscontinuityAnimation.springStartTimeNanos], and uses the + * [DiscontinuityAnimation.springParameters]. The displacement's origin is at + * [DiscontinuityAnimation.targetValue]. + * + * This state does not have to be updated every frame, even as an animation is ongoing: the + * spring animation can be computed with the same start parameters, and as time progresses, the + * [SpringState.calculateUpdatedState] is passed an ever larger `elapsedNanos` on each frame. + * + * The [DiscontinuityAnimation.targetValue] is a delta to the direct mapped output value from + * the [SegmentData.mapping]. It might accumulate the target value - it is not required to reset + * when the animation ends. + */ + val lastAnimation: DiscontinuityAnimation + /** + * Last frame's spring state, based on initial origin values in [lastAnimation], carried-forward + * to [lastFrameTimeNanos]. + */ + val lastSpringState: SpringState + /** The time of the last frame, in nanoseconds. */ + val lastFrameTimeNanos: Long + /** The [currentInput] of the last frame */ + val lastInput: Float + val lastGestureDragOffset: Float + + val directMappedVelocity: Float +} diff --git a/mechanics/src/com/android/mechanics/impl/Computations.kt b/mechanics/src/com/android/mechanics/impl/Computations.kt new file mode 100644 index 0000000..2ac9574 --- /dev/null +++ b/mechanics/src/com/android/mechanics/impl/Computations.kt @@ -0,0 +1,576 @@ +/* + * Copyright (C) 2025 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.impl + +import android.util.Log +import androidx.compose.ui.util.fastCoerceAtLeast +import androidx.compose.ui.util.fastCoerceIn +import androidx.compose.ui.util.fastIsFinite +import androidx.compose.ui.util.lerp +import com.android.mechanics.MotionValue.Companion.TAG +import com.android.mechanics.spec.Guarantee +import com.android.mechanics.spec.InputDirection +import com.android.mechanics.spec.Mapping +import com.android.mechanics.spec.MotionSpec +import com.android.mechanics.spec.SegmentData +import com.android.mechanics.spec.SemanticKey +import com.android.mechanics.spring.SpringState +import com.android.mechanics.spring.calculateUpdatedState + +internal abstract class Computations : CurrentFrameInput, LastFrameState, StaticConfig { + internal class ComputedValues( + val segment: SegmentData, + val guarantee: GuaranteeState, + val animation: DiscontinuityAnimation, + ) + + // currentComputedValues input + private var memoizedSpec: MotionSpec? = null + private var memoizedInput: Float = Float.MIN_VALUE + private var memoizedAnimationTimeNanos: Long = Long.MIN_VALUE + private var memoizedDirection: InputDirection = InputDirection.Min + + // currentComputedValues output + private lateinit var memoizedComputedValues: ComputedValues + + internal val currentComputedValues: ComputedValues + get() { + val currentSpec: MotionSpec = spec + val currentInput: Float = currentInput + val currentAnimationTimeNanos: Long = currentAnimationTimeNanos + val currentDirection: InputDirection = currentDirection + + if ( + memoizedSpec == currentSpec && + memoizedInput == currentInput && + memoizedAnimationTimeNanos == currentAnimationTimeNanos && + memoizedDirection == currentDirection + ) { + return memoizedComputedValues + } + + memoizedSpec = currentSpec + memoizedInput = currentInput + memoizedAnimationTimeNanos = currentAnimationTimeNanos + memoizedDirection = currentDirection + + val segment: SegmentData = + computeSegmentData( + spec = currentSpec, + input = currentInput, + direction = currentDirection, + ) + + val segmentChange: SegmentChangeType = + getSegmentChangeType( + segment = segment, + input = currentInput, + direction = currentDirection, + ) + + val guarantee: GuaranteeState = + computeGuaranteeState( + segment = segment, + segmentChange = segmentChange, + input = currentInput, + ) + + val animation: DiscontinuityAnimation = + computeAnimation( + segment = segment, + guarantee = guarantee, + segmentChange = segmentChange, + spec = currentSpec, + input = currentInput, + animationTimeNanos = currentAnimationTimeNanos, + ) + + return ComputedValues(segment, guarantee, animation).also { + memoizedComputedValues = it + } + } + + // currentSpringState input + private var memoizedAnimation: DiscontinuityAnimation? = null + private var memoizedTimeNanos: Long = Long.MIN_VALUE + + // currentSpringState output + private var memoizedSpringState: SpringState = SpringState.AtRest + + val currentSpringState: SpringState + get() { + val animation = currentComputedValues.animation + val timeNanos = currentAnimationTimeNanos + if (memoizedAnimation == animation && memoizedTimeNanos == timeNanos) { + return memoizedSpringState + } + memoizedAnimation = animation + memoizedTimeNanos = timeNanos + return computeSpringState(animation, timeNanos).also { memoizedSpringState = it } + } + + val isSameSegmentAndAtRest: Boolean + get() = + lastSpringState == SpringState.AtRest && + lastSegment.spec == spec && + lastSegment.isValidForInput(currentInput, currentDirection) + + val output: Float + get() = + if (isSameSegmentAndAtRest) { + lastSegment.mapping.map(currentInput) + } else { + outputTarget + currentSpringState.displacement + } + + val outputTarget: Float + get() = + if (isSameSegmentAndAtRest) { + lastSegment.mapping.map(currentInput) + } else { + currentComputedValues.segment.mapping.map(currentInput) + } + + val isStable: Boolean + get() = + if (isSameSegmentAndAtRest) { + true + } else { + currentSpringState == SpringState.AtRest + } + + fun semanticState(semanticKey: SemanticKey): T? { + return with(if (isSameSegmentAndAtRest) lastSegment else currentComputedValues.segment) { + spec.semanticState(semanticKey, key) + } + } + + fun computeDirectMappedVelocity(frameDurationNanos: Long): Float { + val directMappedDelta = + if ( + lastSegment.spec == spec && + lastSegment.isValidForInput(currentInput, currentDirection) + ) { + lastSegment.mapping.map(currentInput) - lastSegment.mapping.map(lastInput) + } else { + val springChange = currentSpringState.displacement - lastSpringState.displacement + + currentComputedValues.segment.mapping.map(currentInput) - + lastSegment.mapping.map(lastInput) + springChange + } + + val frameDuration = frameDurationNanos / 1_000_000_000.0 + return (directMappedDelta / frameDuration).toFloat() + } + + /** + * The current segment, which defines the [Mapping] function used to transform the input to the + * output. + * + * While both [spec] and [direction] remain the same, and [input] is within the segment (see + * [SegmentData.isValidForInput]), this is [LastFrameState.lastSegment]. + * + * Otherwise, [MotionSpec.onChangeSegment] is queried for an up-dated segment. + */ + private fun computeSegmentData( + spec: MotionSpec, + input: Float, + direction: InputDirection, + ): SegmentData { + val specChanged = lastSegment.spec != spec + return if (specChanged || !lastSegment.isValidForInput(input, direction)) { + spec.onChangeSegment(lastSegment, input, direction) + } else { + lastSegment + } + } + + /** Computes the [SegmentChangeType] between [LastFrameState.lastSegment] and [segment]. */ + private fun getSegmentChangeType( + segment: SegmentData, + input: Float, + direction: InputDirection, + ): SegmentChangeType { + if (segment.key == lastSegment.key) { + return SegmentChangeType.Same + } + + if ( + segment.key.minBreakpoint == lastSegment.key.minBreakpoint && + segment.key.maxBreakpoint == lastSegment.key.maxBreakpoint + ) { + return SegmentChangeType.SameOppositeDirection + } + + val currentSpec = segment.spec + val lastSpec = lastSegment.spec + if (currentSpec !== lastSpec) { + // Determine/guess whether the segment change was due to the changed spec, or + // whether lastSpec would return the same segment key for the update input. + val lastSpecSegmentForSameInput = lastSpec.segmentAtInput(input, direction).key + if (segment.key != lastSpecSegmentForSameInput) { + // Note: this might not be correct if the new [MotionSpec.segmentHandlers] were + // involved. + return SegmentChangeType.Spec + } + } + + return if (segment.direction == lastSegment.direction) { + SegmentChangeType.Traverse + } else { + SegmentChangeType.Direction + } + } + + /** + * Computes the fraction of [position] between [lastInput] and [currentInput]. + * + * Essentially, this determines fractionally when [position] was crossed, between the current + * frame and the last frame. + * + * Since frames are updated periodically, not continuously, crossing a breakpoint happened + * sometime between the last frame's start and this frame's start. + * + * This fraction is used to estimate the time when a breakpoint was crossed since last frame, + * and simplifies the logic of crossing multiple breakpoints in one frame, as it offers the + * springs and guarantees time to be updated correctly. + * + * Of course, this is a simplification that assumes the input velocity was uniform during the + * last frame, but that is likely good enough. + */ + private fun lastFrameFractionOfPosition( + position: Float, + lastInput: Float, + input: Float, + ): Float { + return ((position - lastInput) / (input - lastInput)).fastCoerceIn(0f, 1f) + } + + /** + * The [GuaranteeState] for [segment]. + * + * Without a segment change, this carries forward [lastGuaranteeState], adjusted to the new + * input if needed. + * + * If a segment change happened, this is a new [GuaranteeState] for the [segment]. Any remaining + * [LastFrameState.lastGuaranteeState] will be consumed in [currentAnimation]. + */ + private fun computeGuaranteeState( + segment: SegmentData, + segmentChange: SegmentChangeType, + input: Float, + ): GuaranteeState { + val entryBreakpoint = segment.entryBreakpoint + + // First, determine the origin of the guarantee computations + val guaranteeOriginState = + when (segmentChange) { + // Still in the segment, the origin is carried over from the last frame + SegmentChangeType.Same -> lastGuaranteeState + // The direction changed within the same segment, no guarantee to enforce. + SegmentChangeType.SameOppositeDirection -> return GuaranteeState.Inactive + // The spec changes, there is no guarantee associated with the animation. + SegmentChangeType.Spec -> return GuaranteeState.Inactive + SegmentChangeType.Direction -> { + // Direction changed over a segment boundary. To make up for the + // directionChangeSlop, the guarantee starts at the current input. + GuaranteeState.withStartValue( + when (entryBreakpoint.guarantee) { + is Guarantee.InputDelta -> input + is Guarantee.GestureDragDelta -> currentGestureDragOffset + is Guarantee.None -> return GuaranteeState.Inactive + } + ) + } + + SegmentChangeType.Traverse -> { + // Traversed over a segment boundary, the guarantee going forward is determined + // by the [entryBreakpoint]. + GuaranteeState.withStartValue( + when (entryBreakpoint.guarantee) { + is Guarantee.InputDelta -> entryBreakpoint.position + is Guarantee.GestureDragDelta -> { + // Guess the GestureDragDelta origin - since the gesture dragOffset + // is sampled, interpolate it according to when the breakpoint was + // crossed in the last frame. + val fractionalBreakpointPos = + lastFrameFractionOfPosition( + entryBreakpoint.position, + lastInput, + input, + ) + + lerp( + lastGestureDragOffset, + currentGestureDragOffset, + fractionalBreakpointPos, + ) + } + + // No guarantee to enforce. + is Guarantee.None -> return GuaranteeState.Inactive + } + ) + } + } + + // Finally, update the origin state with the current guarantee value. + return guaranteeOriginState.withCurrentValue( + when (entryBreakpoint.guarantee) { + is Guarantee.InputDelta -> input + is Guarantee.GestureDragDelta -> currentGestureDragOffset + is Guarantee.None -> return GuaranteeState.Inactive + }, + segment.direction, + ) + } + + /** + * The [DiscontinuityAnimation] in effect for the current frame. + * + * This describes the starting condition of the spring animation, and is only updated if the + * spring animation must restarted: that is, if yet another discontinuity must be animated as a + * result of a segment change, or if the [guarantee] requires the spring to be tightened. + * + * See [currentSpringState] for the continuously updated, animated spring values. + */ + private fun computeAnimation( + segment: SegmentData, + guarantee: GuaranteeState, + segmentChange: SegmentChangeType, + spec: MotionSpec, + input: Float, + animationTimeNanos: Long, + ): DiscontinuityAnimation { + return when (segmentChange) { + SegmentChangeType.Same -> { + if (lastSpringState == SpringState.AtRest) { + // Nothing to update if no animation is ongoing + DiscontinuityAnimation.None + } else if (lastGuaranteeState == guarantee) { + // Nothing to update if the spring must not be tightened. + lastAnimation + } else { + // Compute the updated spring parameters + val tightenedSpringParameters = + guarantee.updatedSpringParameters(segment.entryBreakpoint) + + lastAnimation.copy( + springStartState = lastSpringState, + springParameters = tightenedSpringParameters, + springStartTimeNanos = lastFrameTimeNanos, + ) + } + } + + SegmentChangeType.SameOppositeDirection, + SegmentChangeType.Direction, + SegmentChangeType.Spec -> { + // Determine the delta in the output, as produced by the old and new mapping. + val currentMapping = segment.mapping.map(input) + val lastMapping = lastSegment.mapping.map(input) + val delta = currentMapping - lastMapping + + val deltaIsFinite = delta.fastIsFinite() + if (!deltaIsFinite) { + Log.wtf( + TAG, + "Delta between mappings is undefined!\n" + + " MotionValue: $label\n" + + " input: $input\n" + + " lastMapping: $lastMapping (lastSegment: $lastSegment)\n" + + " currentMapping: $currentMapping (currentSegment: $segment)", + ) + } + + if (delta == 0f || !deltaIsFinite) { + // Nothing new to animate. + lastAnimation + } else { + val springParameters = + if (segmentChange == SegmentChangeType.Direction) { + segment.entryBreakpoint.spring + } else { + spec.resetSpring + } + + val newTarget = delta - lastSpringState.displacement + DiscontinuityAnimation( + SpringState(-newTarget, lastSpringState.velocity + directMappedVelocity), + springParameters, + lastFrameTimeNanos, + ) + } + } + + SegmentChangeType.Traverse -> { + // Process all breakpoints traversed, in order. + // This is involved due to the guarantees - they have to be applied, one after the + // other, before crossing the next breakpoint. + val currentDirection = segment.direction + + with(spec[currentDirection]) { + val targetIndex = findSegmentIndex(segment.key) + val sourceIndex = findSegmentIndex(lastSegment.key) + check(targetIndex != sourceIndex) + + val directionOffset = if (targetIndex > sourceIndex) 1 else -1 + + var lastBreakpoint = lastSegment.entryBreakpoint + var lastAnimationTime = lastFrameTimeNanos + var guaranteeState = lastGuaranteeState + var springState = lastSpringState + var springParameters = lastAnimation.springParameters + var initialSpringVelocity = directMappedVelocity + + var segmentIndex = sourceIndex + while (segmentIndex != targetIndex) { + val nextBreakpoint = + breakpoints[segmentIndex + directionOffset.fastCoerceAtLeast(0)] + + val nextBreakpointFrameFraction = + lastFrameFractionOfPosition(nextBreakpoint.position, lastInput, input) + + val nextBreakpointCrossTime = + lerp( + lastFrameTimeNanos, + animationTimeNanos, + nextBreakpointFrameFraction, + ) + if ( + guaranteeState != GuaranteeState.Inactive && + springState != SpringState.AtRest + ) { + val guaranteeValueAtNextBreakpoint = + when (lastBreakpoint.guarantee) { + is Guarantee.InputDelta -> nextBreakpoint.position + is Guarantee.GestureDragDelta -> + lerp( + lastGestureDragOffset, + currentGestureDragOffset, + nextBreakpointFrameFraction, + ) + + is Guarantee.None -> + error( + "guaranteeState ($guaranteeState) is not Inactive, guarantee is missing" + ) + } + + guaranteeState = + guaranteeState.withCurrentValue( + guaranteeValueAtNextBreakpoint, + currentDirection, + ) + + springParameters = + guaranteeState.updatedSpringParameters(lastBreakpoint) + } + + springState = + springState.calculateUpdatedState( + nextBreakpointCrossTime - lastAnimationTime, + springParameters, + ) + lastAnimationTime = nextBreakpointCrossTime + + val mappingBefore = mappings[segmentIndex] + val beforeBreakpoint = mappingBefore.map(nextBreakpoint.position) + val mappingAfter = mappings[segmentIndex + directionOffset] + val afterBreakpoint = mappingAfter.map(nextBreakpoint.position) + + val delta = afterBreakpoint - beforeBreakpoint + val deltaIsFinite = delta.fastIsFinite() + if (deltaIsFinite) { + if (delta != 0f) { + // There is a discontinuity on this breakpoint, that needs to be + // animated. The delta is pushed to the spring, to consume the + // discontinuity over time. + springState = + springState.nudge( + displacementDelta = -delta, + velocityDelta = initialSpringVelocity, + ) + + // When *first* crossing a discontinuity in a given frame, the + // static mapped velocity observed during previous frame is added as + // initial velocity to the spring. This is done ot most once per + // frame, and only if there is an actual discontinuity. + initialSpringVelocity = 0f + } + } else { + // The before and / or after mapping produced an non-finite number, + // which is not allowed. This intentionally crashes eng-builds, since + // it's a bug in the Mapping implementation that must be fixed. On + // regular builds, it will likely cause a jumpcut. + Log.wtf( + TAG, + "Delta between breakpoints is undefined!\n" + + " MotionValue: ${label}\n" + + " position: ${nextBreakpoint.position}\n" + + " before: $beforeBreakpoint (mapping: $mappingBefore)\n" + + " after: $afterBreakpoint (mapping: $mappingAfter)", + ) + } + + segmentIndex += directionOffset + lastBreakpoint = nextBreakpoint + guaranteeState = + when (nextBreakpoint.guarantee) { + is Guarantee.InputDelta -> + GuaranteeState.withStartValue(nextBreakpoint.position) + + is Guarantee.GestureDragDelta -> + GuaranteeState.withStartValue( + lerp( + lastGestureDragOffset, + currentGestureDragOffset, + nextBreakpointFrameFraction, + ) + ) + + is Guarantee.None -> GuaranteeState.Inactive + } + } + + val tightened = guarantee.updatedSpringParameters(segment.entryBreakpoint) + + DiscontinuityAnimation(springState, tightened, lastAnimationTime) + } + } + } + } + + private fun computeSpringState( + animation: DiscontinuityAnimation, + timeNanos: Long, + ): SpringState { + with(animation) { + if (isAtRest) return SpringState.AtRest + + val nanosSinceAnimationStart = timeNanos - springStartTimeNanos + val updatedSpringState = + springStartState.calculateUpdatedState(nanosSinceAnimationStart, springParameters) + + return if (updatedSpringState.isStable(springParameters, stableThreshold)) { + SpringState.AtRest + } else { + updatedSpringState + } + } + } +} diff --git a/mechanics/src/com/android/mechanics/impl/DiscontinuityAnimation.kt b/mechanics/src/com/android/mechanics/impl/DiscontinuityAnimation.kt new file mode 100644 index 0000000..b0deb75 --- /dev/null +++ b/mechanics/src/com/android/mechanics/impl/DiscontinuityAnimation.kt @@ -0,0 +1,44 @@ +/* + * Copyright (C) 2025 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.impl + +import com.android.mechanics.spring.SpringParameters +import com.android.mechanics.spring.SpringState + +/** + * Captures the start-state of a spring-animation to smooth over a discontinuity. + * + * Discontinuities are caused by segment changes, where the new and old segment produce different + * output values for the same input. + */ +internal data class DiscontinuityAnimation( + val springStartState: SpringState, + val springParameters: SpringParameters, + val springStartTimeNanos: Long, +) { + val isAtRest: Boolean + get() = springStartState == SpringState.AtRest + + companion object { + val None = + DiscontinuityAnimation( + springStartState = SpringState.AtRest, + springParameters = SpringParameters.Snap, + springStartTimeNanos = 0L, + ) + } +} diff --git a/mechanics/src/com/android/mechanics/impl/GuaranteeState.kt b/mechanics/src/com/android/mechanics/impl/GuaranteeState.kt new file mode 100644 index 0000000..0c4f291 --- /dev/null +++ b/mechanics/src/com/android/mechanics/impl/GuaranteeState.kt @@ -0,0 +1,77 @@ +/* + * Copyright (C) 2025 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.impl + +import androidx.compose.ui.util.fastCoerceAtLeast +import androidx.compose.ui.util.packFloats +import androidx.compose.ui.util.unpackFloat1 +import androidx.compose.ui.util.unpackFloat2 +import com.android.mechanics.spec.Breakpoint +import com.android.mechanics.spec.Guarantee +import com.android.mechanics.spec.InputDirection +import com.android.mechanics.spring.SpringParameters +import kotlin.math.max + +/** + * Captures the origin of a guarantee, and the maximal distance the input has been away from the + * origin at most. + */ +@JvmInline +internal value class GuaranteeState(val packedValue: Long) { + private val start: Float + get() = unpackFloat1(packedValue) + + private val maxDelta: Float + get() = unpackFloat2(packedValue) + + private val isInactive: Boolean + get() = this == Inactive + + fun withCurrentValue(value: Float, direction: InputDirection): GuaranteeState { + if (isInactive) return Inactive + + val delta = ((value - start) * direction.sign).fastCoerceAtLeast(0f) + return GuaranteeState(start, max(delta, maxDelta)) + } + + fun updatedSpringParameters(breakpoint: Breakpoint): SpringParameters { + if (isInactive) return breakpoint.spring + + val denominator = + when (val guarantee = breakpoint.guarantee) { + is Guarantee.None -> return breakpoint.spring + is Guarantee.InputDelta -> guarantee.delta + is Guarantee.GestureDragDelta -> guarantee.delta + } + + val springTighteningFraction = maxDelta / denominator + return com.android.mechanics.spring.lerp( + breakpoint.spring, + SpringParameters.Snap, + springTighteningFraction, + ) + } + + companion object { + val Inactive = GuaranteeState(packFloats(Float.NaN, Float.NaN)) + + fun withStartValue(start: Float) = GuaranteeState(packFloats(start, 0f)) + } +} + +internal fun GuaranteeState(start: Float, maxDelta: Float) = + GuaranteeState(packFloats(start, maxDelta)) diff --git a/mechanics/src/com/android/mechanics/impl/SegmentChangeType.kt b/mechanics/src/com/android/mechanics/impl/SegmentChangeType.kt new file mode 100644 index 0000000..b8c68bc --- /dev/null +++ b/mechanics/src/com/android/mechanics/impl/SegmentChangeType.kt @@ -0,0 +1,67 @@ +/* + * Copyright (C) 2025 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.impl + +/** + * Describes how the [currentSegment] is different from last frame's [lastSegment]. + * + * This affects how the discontinuities are animated and [Guarantee]s applied. + */ +internal enum class SegmentChangeType { + /** + * The segment has the same key, this is considered equivalent. + * + * Only the [GuaranteeState] needs to be kept updated. + */ + Same, + + /** + * The segment's direction changed, however the min / max breakpoints remain the same: This is a + * direction change within a segment. + * + * The delta between the mapping must be animated with the reset spring, and there is no + * guarantee associated with the change. + */ + SameOppositeDirection, + + /** + * The segment and its direction change. This is a direction change that happened over a segment + * boundary. + * + * The direction change might have happened outside the [lastSegment] already, since a segment + * can't be exited at the entry side. + */ + Direction, + + /** + * The segment changed, due to the [currentInput] advancing in the [currentDirection], crossing + * one or more breakpoints. + * + * The guarantees of all crossed breakpoints have to be applied. The [GuaranteeState] must be + * reset, and a new [DiscontinuityAnimation] is started. + */ + Traverse, + + /** + * The spec was changed and added or removed the previous and/or current segment. + * + * The [MotionValue] does not have a semantic understanding of this change, hence the difference + * output produced by the previous and current mapping are animated with the + * [MotionSpec.resetSpring] + */ + Spec, +} diff --git a/mechanics/src/com/android/mechanics/spec/Breakpoint.kt b/mechanics/src/com/android/mechanics/spec/Breakpoint.kt new file mode 100644 index 0000000..5ff18ed --- /dev/null +++ b/mechanics/src/com/android/mechanics/spec/Breakpoint.kt @@ -0,0 +1,120 @@ +/* + * 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.spec + +import androidx.compose.ui.util.fastIsFinite +import com.android.mechanics.spring.SpringParameters + +/** + * Key to identify a breakpoint in a [DirectionalMotionSpec]. + * + * @param debugLabel name of the breakpoint, for tooling and debugging. + * @param identity is used to check the equality of two key instances. + */ +class BreakpointKey(val debugLabel: String? = null, val identity: Any = Object()) { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as BreakpointKey + + return identity == other.identity + } + + override fun hashCode(): Int { + return identity.hashCode() + } + + override fun toString(): String { + return "BreakpointKey(${debugLabel ?: ""}" + + "@${System.identityHashCode(identity).toString(16).padStart(8,'0')})" + } + + internal companion object { + val MinLimit = BreakpointKey("built-in::min") + val MaxLimit = BreakpointKey("built-in::max") + } +} + +/** + * Specification of a breakpoint, in the context of a [DirectionalMotionSpec]. + * + * The [spring] and [guarantee] define the physics animation for the discontinuity at this + * breakpoint.They are applied in the direction of the containing [DirectionalMotionSpec]. + * + * This [Breakpoint]'s animation definition is valid while the input is within the next segment. If + * the animation is still in progress when the input value reaches the next breakpoint, the + * remaining animation will be blended with the animation starting at the next breakpoint. + * + * @param key Identity of the [Breakpoint], unique within a [DirectionalMotionSpec]. + * @param position The position of the [Breakpoint], in the domain of the `MotionValue`'s input. + * @param spring Parameters of the spring used to animate the breakpoints discontinuity. + * @param guarantee Optional constraints to accelerate the completion of the spring motion, based on + * `MotionValue`'s input or other non-time signals. + */ +data class Breakpoint( + val key: BreakpointKey, + val position: Float, + val spring: SpringParameters, + val guarantee: Guarantee, +) : Comparable { + + init { + when (key) { + BreakpointKey.MinLimit -> require(position == Float.NEGATIVE_INFINITY) + BreakpointKey.MaxLimit -> require(position == Float.POSITIVE_INFINITY) + else -> require(position.fastIsFinite()) + } + } + + companion object { + /** First breakpoint of each spec. */ + val minLimit = + Breakpoint( + BreakpointKey.MinLimit, + Float.NEGATIVE_INFINITY, + SpringParameters.Snap, + Guarantee.None, + ) + + /** Last breakpoint of each spec. */ + val maxLimit = + Breakpoint( + BreakpointKey.MaxLimit, + Float.POSITIVE_INFINITY, + SpringParameters.Snap, + Guarantee.None, + ) + + internal fun create( + breakpointKey: BreakpointKey, + breakpointPosition: Float, + springSpec: SpringParameters, + guarantee: Guarantee, + ): Breakpoint { + return when (breakpointKey) { + BreakpointKey.MinLimit -> minLimit + BreakpointKey.MaxLimit -> maxLimit + else -> Breakpoint(breakpointKey, breakpointPosition, springSpec, guarantee) + } + } + } + + override fun compareTo(other: Breakpoint): Int { + return position.compareTo(other.position) + } +} diff --git a/mechanics/src/com/android/mechanics/spec/Guarantee.kt b/mechanics/src/com/android/mechanics/spec/Guarantee.kt new file mode 100644 index 0000000..12981cc --- /dev/null +++ b/mechanics/src/com/android/mechanics/spec/Guarantee.kt @@ -0,0 +1,45 @@ +/* + * 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.spec + +/** + * Describes the condition by which a discontinuity at a breakpoint must have finished animating. + * + * With a guarantee in effect, the spring parameters will be continuously adjusted, ensuring the + * guarantee's target will be met. + */ +sealed class Guarantee { + /** + * No guarantee is provided. + * + * The spring animation will proceed at its natural pace, regardless of the input or gesture's + * progress. + */ + data object None : Guarantee() + + /** + * Guarantees that the animation will be complete before the input value is [delta] away from + * the [Breakpoint] position. + */ + data class InputDelta(val delta: Float) : Guarantee() + + /** + * Guarantees to complete the animation before the gesture is [delta] away from the gesture + * position captured when the breakpoint was crossed. + */ + data class GestureDragDelta(val delta: Float) : Guarantee() +} diff --git a/mechanics/src/com/android/mechanics/spec/InputDirection.kt b/mechanics/src/com/android/mechanics/spec/InputDirection.kt new file mode 100644 index 0000000..58fa590 --- /dev/null +++ b/mechanics/src/com/android/mechanics/spec/InputDirection.kt @@ -0,0 +1,31 @@ +/* + * 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.spec + +/** + * The intrinsic direction of the input value. + * + * It reflects the user's intent, that is its meant to be derived from a gesture. If the input is + * driven by an animation, the direction is expected to not change. + * + * The directions are labelled [Min] and [Max] to reflect descending and ascending input values + * respectively, but it does not imply an spatial direction. + */ +enum class InputDirection(val sign: Int) { + Min(sign = -1), + Max(sign = +1), +} diff --git a/mechanics/src/com/android/mechanics/spec/MotionSpec.kt b/mechanics/src/com/android/mechanics/spec/MotionSpec.kt new file mode 100644 index 0000000..4628804 --- /dev/null +++ b/mechanics/src/com/android/mechanics/spec/MotionSpec.kt @@ -0,0 +1,239 @@ +/* + * 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.spec + +import androidx.compose.ui.util.fastFirstOrNull +import com.android.mechanics.spring.SpringParameters + +/** + * Specification for the mapping of input values to output values. + * + * The spec consists of two independent directional spec's, while only one the one matching + * `MotionInput`'s `direction` is used at any given time. + * + * @param maxDirection spec used when the MotionInput's direction is [InputDirection.Max] + * @param minDirection spec used when the MotionInput's direction is [InputDirection.Min] + * @param resetSpring spring parameters to animate a difference in output, if the difference is + * caused by setting this new spec. + * @param segmentHandlers allow for custom segment-change logic, when the `MotionValue` runtime + * would leave the [SegmentKey]. + */ +data class MotionSpec( + val maxDirection: DirectionalMotionSpec, + val minDirection: DirectionalMotionSpec = maxDirection, + val resetSpring: SpringParameters = DefaultResetSpring, + val segmentHandlers: Map = emptyMap(), +) { + + /** The [DirectionalMotionSpec] for the specified [direction]. */ + operator fun get(direction: InputDirection): DirectionalMotionSpec { + return when (direction) { + InputDirection.Min -> minDirection + InputDirection.Max -> maxDirection + } + } + + /** Whether this spec contains a segment with the specified [segmentKey]. */ + fun containsSegment(segmentKey: SegmentKey): Boolean { + return get(segmentKey.direction).findSegmentIndex(segmentKey) != -1 + } + + /** + * The semantic state for [key] at segment with [segmentKey]. + * + * Returns `null` if no semantic value with [key] is defined. Throws [NoSuchElementException] if + * [segmentKey] does not exist in this [MotionSpec]. + */ + fun semanticState(key: SemanticKey, segmentKey: SegmentKey): T? { + with(get(segmentKey.direction)) { + val semanticValues = semantics.fastFirstOrNull { it.key == key } ?: return null + val segmentIndex = findSegmentIndex(segmentKey) + if (segmentIndex < 0) throw NoSuchElementException() + + @Suppress("UNCHECKED_CAST") + return semanticValues.values[segmentIndex] as T + } + } + + /** + * All [SemanticValue]s associated with the segment identified with [segmentKey]. + * + * Throws [NoSuchElementException] if [segmentKey] does not exist in this [MotionSpec]. + */ + fun semantics(segmentKey: SegmentKey): List> { + with(get(segmentKey.direction)) { + val segmentIndex = findSegmentIndex(segmentKey) + if (segmentIndex < 0) throw NoSuchElementException() + + return semantics.map { it[segmentIndex] } + } + } + + /** + * The [SegmentData] for an input with the specified [position] and [direction]. + * + * The returned [SegmentData] will be cached while [SegmentData.isValidForInput] returns `true`. + */ + fun segmentAtInput(position: Float, direction: InputDirection): SegmentData { + require(position.isFinite()) + + return with(get(direction)) { + var idx = findBreakpointIndex(position) + if (direction == InputDirection.Min && breakpoints[idx].position == position) { + // The segment starts at `position`. Since the breakpoints are sorted ascending, no + // matter the spec's direction, need to return the previous segment in the min + // direction. + idx-- + } + + SegmentData( + this@MotionSpec, + breakpoints[idx], + breakpoints[idx + 1], + direction, + mappings[idx], + ) + } + } + + /** + * Looks up the new [SegmentData] once the [currentSegment] is not valid for an input with + * [newPosition] and [newDirection]. + * + * This will delegate to the [segmentHandlers], if registered for the [currentSegment]'s key. + */ + internal fun onChangeSegment( + currentSegment: SegmentData, + newPosition: Float, + newDirection: InputDirection, + ): SegmentData { + val segmentChangeHandler = segmentHandlers[currentSegment.key] + return segmentChangeHandler?.invoke(this, currentSegment, newPosition, newDirection) + ?: segmentAtInput(newPosition, newDirection) + } + + override fun toString() = toDebugString() + + companion object { + /** + * Default spring parameters for the reset spring. Matches the Fast Spatial spring of the + * standard motion scheme. + */ + private val DefaultResetSpring = SpringParameters(stiffness = 1400f, dampingRatio = 1f) + + /* Empty motion spec, the output is the same as the input. */ + val Empty = MotionSpec(DirectionalMotionSpec.Empty) + } +} + +/** + * Defines the [breakpoints], as well as the [mappings] in-between adjacent [Breakpoint] pairs. + * + * This [DirectionalMotionSpec] is applied in the direction defined by the containing [MotionSpec]: + * especially the direction in which the `breakpoint` [Guarantee] are applied depend on how this is + * used; this type does not have an inherit direction. + * + * All [breakpoints] are sorted in ascending order by their `position`, with the first and last + * breakpoints are guaranteed to be sentinel values for negative and positive infinity respectively. + * + * @param breakpoints All breakpoints in the spec, must contain [Breakpoint.minLimit] as the first + * element, and [Breakpoint.maxLimit] as the last element. + * @param mappings All mappings in between the breakpoints, thus must always contain + * `breakpoints.size - 1` elements. + * @param semantics semantics provided by this spec, must only reference to breakpoint keys included + * in [breakpoints]. + */ +data class DirectionalMotionSpec( + val breakpoints: List, + val mappings: List, + val semantics: List> = emptyList(), +) { + /** Maps all [BreakpointKey]s used in this spec to its index in [breakpoints]. */ + private val breakpointIndexByKey: Map + + init { + require(breakpoints.size >= 2) + require(breakpoints.first() == Breakpoint.minLimit) + require(breakpoints.last() == Breakpoint.maxLimit) + require(breakpoints.zipWithNext { a, b -> a <= b }.all { it }) { + "Breakpoints are not sorted ascending ${breakpoints.map { "${it.key}@${it.position}" }}" + } + require(mappings.size == breakpoints.size - 1) + + breakpointIndexByKey = + breakpoints.mapIndexed { index, breakpoint -> breakpoint.key to index }.toMap() + + semantics.forEach { + require(it.values.size == mappings.size) { + "Semantics ${it.key} contains ${it.values.size} values vs ${mappings.size} expected" + } + } + } + + /** + * Returns the index of the closest breakpoint where `Breakpoint.position <= position`. + * + * Guaranteed to be a valid index into [breakpoints], and guaranteed to be neither the first nor + * the last element. + * + * @param position the position in the input domain. + * @return Index into [breakpoints], guaranteed to be in range `1..breakpoints.size - 2` + */ + fun findBreakpointIndex(position: Float): Int { + require(position.isFinite()) + val breakpointPosition = breakpoints.binarySearchBy(position) { it.position } + + val result = + when { + // position is between two anchors, return the min one. + breakpointPosition < 0 -> -breakpointPosition - 2 + else -> breakpointPosition + } + + check(result >= 0) + check(result < breakpoints.size - 1) + + return result + } + + /** + * The index of the breakpoint with the specified [breakpointKey], or `-1` if no such breakpoint + * exists. + */ + fun findBreakpointIndex(breakpointKey: BreakpointKey): Int { + return breakpointIndexByKey[breakpointKey] ?: -1 + } + + /** Index into [mappings] for the specified [segmentKey], or `-1` if no such segment exists. */ + fun findSegmentIndex(segmentKey: SegmentKey): Int { + val result = breakpointIndexByKey[segmentKey.minBreakpoint] ?: return -1 + if (breakpoints[result + 1].key != segmentKey.maxBreakpoint) return -1 + + return result + } + + override fun toString() = toDebugString() + + companion object { + /* Empty spec, the full input domain is mapped to output using [Mapping.identity]. */ + val Empty = + DirectionalMotionSpec( + listOf(Breakpoint.minLimit, Breakpoint.maxLimit), + listOf(Mapping.Identity), + ) + } +} diff --git a/mechanics/src/com/android/mechanics/spec/MotionSpecDebugFormatter.kt b/mechanics/src/com/android/mechanics/spec/MotionSpecDebugFormatter.kt new file mode 100644 index 0000000..9c7f9bd --- /dev/null +++ b/mechanics/src/com/android/mechanics/spec/MotionSpecDebugFormatter.kt @@ -0,0 +1,121 @@ +/* + * Copyright (C) 2025 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.spec + +/** Returns a string representation of the [MotionSpec] for debugging by humans. */ +fun MotionSpec.toDebugString(): String { + return buildString { + if (minDirection == maxDirection) { + appendLine("unidirectional:") + appendLine(minDirection.toDebugString().prependIndent(" ")) + } else { + appendLine("maxDirection:") + appendLine(maxDirection.toDebugString().prependIndent(" ")) + appendLine("minDirection:") + appendLine(minDirection.toDebugString().prependIndent(" ")) + } + + if (segmentHandlers.isNotEmpty()) { + appendLine("segmentHandlers:") + segmentHandlers.keys.forEach { + appendIndent(2) + appendSegmentKey(it) + appendLine() + } + } + } + .trim() +} + +/** Returns a string representation of the [DirectionalMotionSpec] for debugging by humans. */ +fun DirectionalMotionSpec.toDebugString(): String { + return buildString { + appendBreakpointLine(breakpoints.first()) + for (i in mappings.indices) { + appendMappingLine(mappings[i], indent = 2) + semantics.forEach { appendSemanticsLine(it.key, it.values[i], indent = 4) } + appendBreakpointLine(breakpoints[i + 1]) + } + } + .trim() +} + +private fun StringBuilder.appendIndent(indent: Int) { + repeat(indent) { append(' ') } +} + +private fun StringBuilder.appendBreakpointLine(breakpoint: Breakpoint, indent: Int = 0) { + appendIndent(indent) + append("@") + append(breakpoint.position) + + append(" [") + appendBreakpointKey(breakpoint.key) + append("]") + + if (breakpoint.guarantee != Guarantee.None) { + append(" guarantee=") + append(breakpoint.key.debugLabel) + } + + if (!breakpoint.spring.isSnapSpring) { + append(" spring=") + append(breakpoint.spring.stiffness) + append("/") + append(breakpoint.spring.dampingRatio) + } + + appendLine() +} + +private fun StringBuilder.appendBreakpointKey(key: BreakpointKey) { + if (key.debugLabel != null) { + append(key.debugLabel) + append("|") + } + append("id:0x") + append(System.identityHashCode(key.identity).toString(16).padStart(8, '0')) +} + +private fun StringBuilder.appendSegmentKey(key: SegmentKey) { + appendBreakpointKey(key.minBreakpoint) + if (key.direction == InputDirection.Min) append(" << ") else append(" >> ") + appendBreakpointKey(key.maxBreakpoint) +} + +private fun StringBuilder.appendMappingLine(mapping: Mapping, indent: Int = 0) { + appendIndent(indent) + append(mapping.toString()) + appendLine() +} + +private fun StringBuilder.appendSemanticsLine( + semanticKey: SemanticKey<*>, + value: Any?, + indent: Int = 0, +) { + appendIndent(indent) + + append(semanticKey.debugLabel) + append("[id:0x") + append(System.identityHashCode(semanticKey.identity).toString(16).padStart(8, '0')) + append("]") + + append("=") + append(value) + appendLine() +} diff --git a/mechanics/src/com/android/mechanics/spec/Segment.kt b/mechanics/src/com/android/mechanics/spec/Segment.kt new file mode 100644 index 0000000..d3bce7b --- /dev/null +++ b/mechanics/src/com/android/mechanics/spec/Segment.kt @@ -0,0 +1,155 @@ +/* + * 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.spec + +/** + * Identifies a segment in a [MotionSpec]. + * + * A segment only exists between two adjacent [Breakpoint]s; it cannot span multiple breakpoints. + * The [direction] indicates to the relevant [DirectionalMotionSpec] of the [MotionSpec]. + * + * The position of the [minBreakpoint] must be less or equal to the position of the [maxBreakpoint]. + */ +data class SegmentKey( + val minBreakpoint: BreakpointKey, + val maxBreakpoint: BreakpointKey, + val direction: InputDirection, +) { + override fun toString(): String { + return "SegmentKey(min=$minBreakpoint, max=$maxBreakpoint, direction=$direction)" + } +} + +/** + * Captures denormalized segment data from a [MotionSpec]. + * + * Instances are created by the [MotionSpec] and used by the [MotionValue] runtime to compute the + * output value. By default, the [SegmentData] is cached while [isValidForInput] returns true. + * + * The [SegmentData] has an intrinsic direction, thus the segment has an entry and exit side, at the + * respective breakpoint. + */ +data class SegmentData( + val spec: MotionSpec, + val minBreakpoint: Breakpoint, + val maxBreakpoint: Breakpoint, + val direction: InputDirection, + val mapping: Mapping, +) { + val key = SegmentKey(minBreakpoint.key, maxBreakpoint.key, direction) + + /** + * Whether the given [inputPosition] and [inputDirection] should be handled by this segment. + * + * The input is considered invalid only if the direction changes or the input is *at or outside* + * the segment on the exit-side. The input remains intentionally valid outside the segment on + * the entry-side, to avoid flip-flopping. + */ + fun isValidForInput(inputPosition: Float, inputDirection: InputDirection): Boolean { + if (inputDirection != direction) return false + + return when (inputDirection) { + InputDirection.Max -> inputPosition < maxBreakpoint.position + InputDirection.Min -> inputPosition > minBreakpoint.position + } + } + + /** + * The breakpoint at the side of the segment's start. + * + * The [entryBreakpoint]'s [Guarantee] is the relevant guarantee for this segment. + */ + val entryBreakpoint: Breakpoint + get() = + when (direction) { + InputDirection.Max -> minBreakpoint + InputDirection.Min -> maxBreakpoint + } + + /** Semantic value for the given [semanticKey]. */ + fun semantic(semanticKey: SemanticKey): T? { + return spec.semanticState(semanticKey, key) + } + + val range: ClosedFloatingPointRange + get() = minBreakpoint.position..maxBreakpoint.position + + override fun toString(): String { + return "SegmentData(key=$key, range=$range, mapping=$mapping)" + } +} + +/** + * Maps the `input` of a [MotionValue] to the desired output value. + * + * The mapping implementation can be arbitrary, but must not produce discontinuities. + */ +fun interface Mapping { + /** Computes the [MotionValue]'s target output, given the input. */ + fun map(input: Float): Float + + /** `f(x) = x` */ + object Identity : Mapping { + override fun map(input: Float): Float { + return input + } + + override fun toString(): String { + return "Identity" + } + } + + /** `f(x) = value` */ + data class Fixed(val value: Float) : Mapping { + init { + require(value.isFinite()) + } + + override fun map(input: Float): Float { + return value + } + } + + /** `f(x) = factor*x + offset` */ + data class Linear(val factor: Float, val offset: Float = 0f) : Mapping { + init { + require(factor.isFinite()) + require(offset.isFinite()) + } + + override fun map(input: Float): Float { + return input * factor + offset + } + } + + companion object { + val Zero = Fixed(0f) + val One = Fixed(1f) + val Two = Fixed(2f) + + /** Create a linear mapping defined as a line between {in0,out0} and {in1,out1}. */ + fun Linear(in0: Float, out0: Float, in1: Float, out1: Float): Linear { + require(in0 != in1) { + "Cannot define a linear function with both inputs being the same ($in0)." + } + + val factor = (out1 - out0) / (in1 - in0) + val offset = out0 - factor * in0 + return Linear(factor, offset) + } + } +} diff --git a/mechanics/src/com/android/mechanics/spec/SegmentChangeHandler.kt b/mechanics/src/com/android/mechanics/spec/SegmentChangeHandler.kt new file mode 100644 index 0000000..b6ce6ab --- /dev/null +++ b/mechanics/src/com/android/mechanics/spec/SegmentChangeHandler.kt @@ -0,0 +1,48 @@ +/* + * Copyright (C) 2025 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.spec + +/** + * Handler to allow for custom segment-change logic. + * + * This handler is called whenever the new input (position or direction) does not match + * [currentSegment] anymore (see [SegmentData.isValidForInput]). + * + * This is intended to implement custom effects on direction-change. + * + * Implementations can return: + * 1. [currentSegment] to delay/suppress segment change. + * 2. `null` to use the default segment lookup based on [newPosition] and [newDirection] + * 3. manually looking up segments on this [MotionSpec] + * 4. create a [SegmentData] that is not in the spec. + */ +typealias OnChangeSegmentHandler = + MotionSpec.( + currentSegment: SegmentData, newPosition: Float, newDirection: InputDirection, + ) -> SegmentData? + +/** Generic change segment handlers. */ +object ChangeSegmentHandlers { + /** Prevents direction changes, as long as the input is still valid on the current segment. */ + val PreventDirectionChangeWithinCurrentSegment: OnChangeSegmentHandler = + { currentSegment, newInput, newDirection -> + currentSegment.takeIf { + newDirection != currentSegment.direction && + it.isValidForInput(newInput, currentSegment.direction) + } + } +} diff --git a/mechanics/src/com/android/mechanics/spec/SemanticValue.kt b/mechanics/src/com/android/mechanics/spec/SemanticValue.kt new file mode 100644 index 0000000..8adf61a --- /dev/null +++ b/mechanics/src/com/android/mechanics/spec/SemanticValue.kt @@ -0,0 +1,74 @@ +/* + * Copyright (C) 2025 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.spec + +/** + * Identifies a "semantic state" of a [MotionValue]. + * + * Semantic states can be supplied by a [MotionSpec], and allows expose semantic information on the + * logical state a [MotionValue] is in. + */ +class SemanticKey(val type: Class, val debugLabel: String, val identity: Any = Object()) { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as SemanticKey<*> + + return identity == other.identity + } + + override fun hashCode(): Int { + return identity.hashCode() + } + + override fun toString(): String { + return "Semantics($debugLabel)" + } +} + +/** Creates a new semantic key of type [T], identified by [identity]. */ +inline fun SemanticKey( + debugLabel: String = T::class.java.simpleName, + identity: Any = Object(), +) = SemanticKey(T::class.java, debugLabel, identity) + +/** Pair of semantic [key] and [value]. */ +data class SemanticValue(val key: SemanticKey, val value: T) + +/** + * Creates a [SemanticValue] tuple from [SemanticKey] `this` with [value]. + * + * This can be useful for creating [SemanticValue] literals with less noise. + */ +infix fun SemanticKey.with(value: T) = SemanticValue(this, value) + +/** + * Defines semantics values for [key], one per segment. + * + * This [values] are required to align with the segments of the [DirectionalMotionSpec] the instance + * will be passed to. The class has no particular value outside of a [DirectionalMotionSpec]. + */ +class SegmentSemanticValues(val key: SemanticKey, val values: List) { + + /** Retrieves the [SemanticValue] at [segmentIndex]. */ + operator fun get(segmentIndex: Int): SemanticValue { + return SemanticValue(key, values[segmentIndex]) + } + + override fun toString() = "Semantics($key): [$values]" +} diff --git a/mechanics/src/com/android/mechanics/spec/builder/DirectionalBuilderImpl.kt b/mechanics/src/com/android/mechanics/spec/builder/DirectionalBuilderImpl.kt new file mode 100644 index 0000000..994927f --- /dev/null +++ b/mechanics/src/com/android/mechanics/spec/builder/DirectionalBuilderImpl.kt @@ -0,0 +1,388 @@ +/* + * Copyright (C) 2025 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.spec.builder + +import com.android.mechanics.spec.Breakpoint +import com.android.mechanics.spec.BreakpointKey +import com.android.mechanics.spec.DirectionalMotionSpec +import com.android.mechanics.spec.Guarantee +import com.android.mechanics.spec.Mapping +import com.android.mechanics.spec.SegmentSemanticValues +import com.android.mechanics.spec.SemanticKey +import com.android.mechanics.spec.SemanticValue +import com.android.mechanics.spring.SpringParameters + +/** + * Internal, reusable implementation of the [DirectionalBuilderScope]. + * + * Clients must use [directionalMotionSpec] instead. + */ +internal open class DirectionalBuilderImpl( + override val defaultSpring: SpringParameters, + baseSemantics: List>, +) : DirectionalBuilderScope { + internal val breakpoints = mutableListOf(Breakpoint.minLimit) + internal val semantics = mutableListOf>() + internal val mappings = mutableListOf() + private var sourceValue: Float = Float.NaN + private var targetValue: Float = Float.NaN + private var fractionalMapping: Float = Float.NaN + private var breakpointPosition: Float = Float.NaN + private var breakpointKey: BreakpointKey? = null + + init { + baseSemantics.forEach { getSemantics(it.key).apply { set(0, it.value) } } + } + + /** Prepares the builder for invoking the [DirectionalBuilderFn] on it. */ + fun prepareBuilderFn( + initialMapping: Mapping = Mapping.Identity, + initialSemantics: List> = emptyList(), + ) { + check(mappings.size == breakpoints.size - 1) + + mappings.add(initialMapping) + val semanticIndex = mappings.size - 1 + initialSemantics.forEach { semantic -> + getSemantics(semantic.key).apply { set(semanticIndex, semantic.value) } + } + } + + internal fun getSemantics(key: SemanticKey): SegmentSemanticValuesBuilder { + @Suppress("UNCHECKED_CAST") + var builder = semantics.firstOrNull { it.key == key } as SegmentSemanticValuesBuilder? + if (builder == null) { + builder = SegmentSemanticValuesBuilder(key).also { semantics.add(it) } + } + return builder + } + + /** + * Finalizes open segments, after invoking a [DirectionalBuilderFn]. + * + * Afterwards, either [build] or another pair of {[prepareBuilderFn], [finalizeBuilderFn]} calls + * can be done. + */ + fun finalizeBuilderFn( + atPosition: Float, + key: BreakpointKey, + springSpec: SpringParameters, + guarantee: Guarantee, + semantics: List>, + ) { + if (!(targetValue.isNaN() && fractionalMapping.isNaN())) { + // Finalizing will produce the mapping and breakpoint + check(mappings.size == breakpoints.size - 1) + } else { + // Mapping is already added, this will add the breakpoint + check(mappings.size == breakpoints.size) + } + + if (key == BreakpointKey.MaxLimit) { + check(targetValue.isNaN()) { "cant specify target value for last segment" } + check(semantics.isEmpty()) { "cant specify semantics for last breakpoint" } + } else { + check(atPosition.isFinite()) + check(atPosition > breakpoints.last().position) { + "Breakpoint ${breakpoints.last()} placed after partial sequence (end=$atPosition)" + } + } + + toBreakpointImpl(atPosition, key, semantics) + doAddBreakpointImpl(springSpec, guarantee) + } + + fun finalizeBuilderFn(breakpoint: Breakpoint) = + finalizeBuilderFn( + breakpoint.position, + breakpoint.key, + breakpoint.spring, + breakpoint.guarantee, + emptyList(), + ) + + /* Creates the [DirectionalMotionSpec] from the current builder state. */ + fun build(): DirectionalMotionSpec { + require(mappings.size == breakpoints.size - 1) + check(breakpoints.last() == Breakpoint.maxLimit) + + val segmentCount = mappings.size + + val semantics = semantics.map { builder -> with(builder) { build(segmentCount) } } + + return DirectionalMotionSpec(breakpoints.toList(), mappings.toList(), semantics) + } + + override fun target( + breakpoint: Float, + from: Float, + to: Float, + spring: SpringParameters, + guarantee: Guarantee, + key: BreakpointKey, + semantics: List>, + ) { + toBreakpointImpl(breakpoint, key, semantics) + jumpToImpl(from, spring, guarantee) + continueWithTargetValueImpl(to) + } + + override fun targetFromCurrent( + breakpoint: Float, + to: Float, + delta: Float, + spring: SpringParameters, + guarantee: Guarantee, + key: BreakpointKey, + semantics: List>, + ) { + toBreakpointImpl(breakpoint, key, semantics) + jumpByImpl(delta, spring, guarantee) + continueWithTargetValueImpl(to) + } + + override fun fractionalInput( + breakpoint: Float, + from: Float, + fraction: Float, + spring: SpringParameters, + guarantee: Guarantee, + key: BreakpointKey, + semantics: List>, + ): CanBeLastSegment { + toBreakpointImpl(breakpoint, key, semantics) + jumpToImpl(from, spring, guarantee) + continueWithFractionalInputImpl(fraction) + return CanBeLastSegmentImpl + } + + override fun fractionalInputFromCurrent( + breakpoint: Float, + fraction: Float, + delta: Float, + spring: SpringParameters, + guarantee: Guarantee, + key: BreakpointKey, + semantics: List>, + ): CanBeLastSegment { + toBreakpointImpl(breakpoint, key, semantics) + jumpByImpl(delta, spring, guarantee) + continueWithFractionalInputImpl(fraction) + return CanBeLastSegmentImpl + } + + override fun fixedValue( + breakpoint: Float, + value: Float, + spring: SpringParameters, + guarantee: Guarantee, + key: BreakpointKey, + semantics: List>, + ): CanBeLastSegment { + toBreakpointImpl(breakpoint, key, semantics) + jumpToImpl(value, spring, guarantee) + continueWithFixedValueImpl() + return CanBeLastSegmentImpl + } + + override fun fixedValueFromCurrent( + breakpoint: Float, + delta: Float, + spring: SpringParameters, + guarantee: Guarantee, + key: BreakpointKey, + semantics: List>, + ): CanBeLastSegment { + toBreakpointImpl(breakpoint, key, semantics) + jumpByImpl(delta, spring, guarantee) + continueWithFixedValueImpl() + return CanBeLastSegmentImpl + } + + override fun mapping( + breakpoint: Float, + spring: SpringParameters, + guarantee: Guarantee, + key: BreakpointKey, + semantics: List>, + mapping: Mapping, + ): CanBeLastSegment { + toBreakpointImpl(breakpoint, key, semantics) + continueWithImpl(mapping, spring, guarantee) + return CanBeLastSegmentImpl + } + + private fun continueWithTargetValueImpl(target: Float) { + check(sourceValue.isFinite()) + + targetValue = target + } + + private fun continueWithFractionalInputImpl(fraction: Float) { + check(sourceValue.isFinite()) + + fractionalMapping = fraction + } + + private fun continueWithFixedValueImpl() { + check(sourceValue.isFinite()) + + mappings.add(Mapping.Fixed(sourceValue)) + sourceValue = Float.NaN + } + + private fun jumpToImpl(value: Float, spring: SpringParameters, guarantee: Guarantee) { + check(sourceValue.isNaN()) + + doAddBreakpointImpl(spring, guarantee) + sourceValue = value + } + + private fun jumpByImpl(delta: Float, spring: SpringParameters, guarantee: Guarantee) { + check(sourceValue.isNaN()) + + val breakpoint = doAddBreakpointImpl(spring, guarantee) + sourceValue = mappings.last().map(breakpoint.position) + delta + } + + private fun continueWithImpl(mapping: Mapping, spring: SpringParameters, guarantee: Guarantee) { + check(sourceValue.isNaN()) + + doAddBreakpointImpl(spring, guarantee) + mappings.add(mapping) + } + + private fun toBreakpointImpl( + atPosition: Float, + key: BreakpointKey, + semantics: List>, + ) { + check(breakpointPosition.isNaN()) + check(breakpointKey == null) + + check(atPosition >= breakpoints.last().position) { + "Breakpoint position specified is before last breakpoint" + } + + if (!targetValue.isNaN() || !fractionalMapping.isNaN()) { + check(!sourceValue.isNaN()) + + val sourcePosition = breakpoints.last().position + val breakpointDistance = atPosition - sourcePosition + val mapping = + if (breakpointDistance == 0f) { + Mapping.Fixed(sourceValue) + } else { + + if (fractionalMapping.isNaN()) { + val delta = targetValue - sourceValue + fractionalMapping = delta / (atPosition - sourcePosition) + } else { + val delta = (atPosition - sourcePosition) * fractionalMapping + targetValue = sourceValue + delta + } + + val offset = sourceValue - (sourcePosition * fractionalMapping) + Mapping.Linear(fractionalMapping, offset) + } + + mappings.add(mapping) + targetValue = Float.NaN + sourceValue = Float.NaN + fractionalMapping = Float.NaN + } + + breakpointPosition = atPosition + breakpointKey = key + + semantics.forEach { (key, value) -> + getSemantics(key).apply { + // Last segment is guaranteed to be completed + set(mappings.size, value) + } + } + } + + private fun doAddBreakpointImpl( + springSpec: SpringParameters, + guarantee: Guarantee, + ): Breakpoint { + val breakpoint = + Breakpoint.create( + checkNotNull(breakpointKey), + breakpointPosition, + springSpec, + guarantee, + ) + + breakpoints.add(breakpoint) + breakpointPosition = Float.NaN + breakpointKey = null + + return breakpoint + } +} + +internal class SegmentSemanticValuesBuilder(val key: SemanticKey) { + private val values = mutableListOf>() + private val unspecified = SemanticValueHolder.Unspecified() + + @Suppress("UNCHECKED_CAST") + fun set(segmentIndex: Int, value: V) { + if (segmentIndex < values.size) { + values[segmentIndex] = SemanticValueHolder.Specified(value as T) + } else { + backfill(segmentCount = segmentIndex) + values.add(SemanticValueHolder.Specified(value as T)) + } + } + + @Suppress("UNCHECKED_CAST") + fun updateBefore(segmentIndex: Int, value: V) { + require(segmentIndex < values.size) + + val specified = SemanticValueHolder.Specified(value as T) + + for (i in segmentIndex downTo 0) { + if (values[i] is SemanticValueHolder.Specified) break + values[i] = specified + } + } + + fun build(segmentCount: Int): SegmentSemanticValues { + backfill(segmentCount) + val firstValue = values.firstNotNullOf { it as? SemanticValueHolder.Specified }.value + return SegmentSemanticValues( + key, + values.drop(1).runningFold(firstValue) { lastValue, thisHolder -> + if (thisHolder is SemanticValueHolder.Specified) thisHolder.value else lastValue + }, + ) + } + + private fun backfill(segmentCount: Int) { + repeat(segmentCount - values.size) { values.add(unspecified) } + } +} + +internal sealed interface SemanticValueHolder { + class Specified(val value: T) : SemanticValueHolder + + class Unspecified() : SemanticValueHolder +} + +private data object CanBeLastSegmentImpl : CanBeLastSegment diff --git a/mechanics/src/com/android/mechanics/spec/builder/DirectionalBuilderScope.kt b/mechanics/src/com/android/mechanics/spec/builder/DirectionalBuilderScope.kt new file mode 100644 index 0000000..9eacd8f --- /dev/null +++ b/mechanics/src/com/android/mechanics/spec/builder/DirectionalBuilderScope.kt @@ -0,0 +1,273 @@ +/* + * Copyright (C) 2025 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.spec.builder + +import com.android.mechanics.spec.BreakpointKey +import com.android.mechanics.spec.DirectionalMotionSpec +import com.android.mechanics.spec.Guarantee +import com.android.mechanics.spec.Mapping +import com.android.mechanics.spec.SemanticKey +import com.android.mechanics.spec.SemanticValue +import com.android.mechanics.spring.SpringParameters + +/** Builder function signature. */ +typealias DirectionalBuilderFn = DirectionalBuilderScope.() -> CanBeLastSegment + +/** + * Defines the contract for building a [DirectionalMotionSpec]. + * + * Provides methods to define breakpoints and mappings for the motion specification. + */ +interface DirectionalBuilderScope { + /** The default [SpringParameters] used for breakpoints. */ + val defaultSpring: SpringParameters + + /** + * Ends the current segment at the [breakpoint] position and defines the next segment to + * linearly interpolate from a starting value ([from]) to the desired target value ([to]). + * + * Note: This segment cannot be used as the last segment in the specification, as it requires a + * subsequent breakpoint to define the target value. + * + * @param breakpoint The breakpoint defining the end of the current segment and the start of the + * next. + * @param from The output value at the previous breakpoint, explicitly setting the starting + * point for the linear mapping. + * @param to The desired output value at the new breakpoint. + * @param spring The [SpringParameters] for the transition to this breakpoint. Defaults to + * [defaultSpring]. + * @param guarantee The animation guarantee for this transition. Defaults to [Guarantee.None]. + * @param key A unique [BreakpointKey] for this breakpoint. Defaults to a newly generated key. + * @param semantics Updated semantics values to be applied. Must be a subset of the + * [SemanticKey]s used when first creating this builder. + */ + fun target( + breakpoint: Float, + from: Float, + to: Float, + spring: SpringParameters = defaultSpring, + guarantee: Guarantee = Guarantee.None, + key: BreakpointKey = BreakpointKey(), + semantics: List> = emptyList(), + ) + + /** + * Ends the current segment at the [breakpoint] position and defines the next segment to + * linearly interpolate from the current output value (optionally with an offset of [delta]) to + * the desired target value ([to]). + * + * Note: This segment cannot be used as the last segment in the specification, as it requires a + * subsequent breakpoint to define the target value. + * + * @param breakpoint The breakpoint defining the end of the current segment and the start of the + * next. + * @param to The desired output value at the new breakpoint. + * @param delta An optional offset to apply to the calculated starting value. Defaults to 0f. + * @param spring The [SpringParameters] for the transition to this breakpoint. Defaults to + * [defaultSpring]. + * @param guarantee The animation guarantee for this transition. Defaults to [Guarantee.None]. + * @param key A unique [BreakpointKey] for this breakpoint. Defaults to a newly generated key. + * @param semantics Updated semantics values to be applied. Must be a subset of the + * [SemanticKey]s used when first creating this builder. + */ + fun targetFromCurrent( + breakpoint: Float, + to: Float, + delta: Float = 0f, + spring: SpringParameters = defaultSpring, + guarantee: Guarantee = Guarantee.None, + key: BreakpointKey = BreakpointKey(), + semantics: List> = emptyList(), + ) + + /** + * Ends the current segment at the [breakpoint] position and defines the next segment to + * linearly interpolate from a starting value ([from]) and then continue with a fractional input + * ([fraction]). + * + * Note: This segment can be used as the last segment in the specification. + * + * @param breakpoint The breakpoint defining the end of the current segment and the start of the + * next. + * @param from The output value at the previous breakpoint, explicitly setting the starting + * point for the linear mapping. + * @param fraction The fractional multiplier applied to the input difference between + * breakpoints. + * @param spring The [SpringParameters] for the transition to this breakpoint. Defaults to + * [defaultSpring]. + * @param guarantee The animation guarantee for this transition. Defaults to [Guarantee.None]. + * @param key A unique [BreakpointKey] for this breakpoint. Defaults to a newly generated key. + * @param semantics Updated semantics values to be applied. Must be a subset of the + * [SemanticKey]s used when first creating this builder. + */ + fun fractionalInput( + breakpoint: Float, + from: Float, + fraction: Float, + spring: SpringParameters = defaultSpring, + guarantee: Guarantee = Guarantee.None, + key: BreakpointKey = BreakpointKey(), + semantics: List> = emptyList(), + ): CanBeLastSegment + + /** + * Ends the current segment at the [breakpoint] position and defines the next segment to + * linearly interpolate from the current output value (optionally with an offset of [delta]) and + * then continue with a fractional input ([fraction]). + * + * Note: This segment can be used as the last segment in the specification. + * + * @param breakpoint The breakpoint defining the end of the current segment and the start of the + * next. + * @param fraction The fractional multiplier applied to the input difference between + * breakpoints. + * @param delta An optional offset to apply to the calculated starting value. Defaults to 0f. + * @param spring The [SpringParameters] for the transition to this breakpoint. Defaults to + * [defaultSpring]. + * @param guarantee The animation guarantee for this transition. Defaults to [Guarantee.None]. + * @param key A unique [BreakpointKey] for this breakpoint. Defaults to a newly generated key. + * @param semantics Updated semantics values to be applied. Must be a subset of the + * [SemanticKey]s used when first creating this builder. + */ + fun fractionalInputFromCurrent( + breakpoint: Float, + fraction: Float, + delta: Float = 0f, + spring: SpringParameters = defaultSpring, + guarantee: Guarantee = Guarantee.None, + key: BreakpointKey = BreakpointKey(), + semantics: List> = emptyList(), + ): CanBeLastSegment + + /** + * Ends the current segment at the [breakpoint] position and defines the next segment to output + * a fixed value ([value]). + * + * Note: This segment can be used as the last segment in the specification. + * + * @param breakpoint The breakpoint defining the end of the current segment and the start of the + * next. + * @param value The constant output value for this segment. + * @param spring The [SpringParameters] for the transition to this breakpoint. Defaults to + * [defaultSpring]. + * @param guarantee The animation guarantee for this transition. Defaults to [Guarantee.None]. + * @param key A unique [BreakpointKey] for this breakpoint. Defaults to a newly generated key. + * @param semantics Updated semantics values to be applied. Must be a subset of the + * [SemanticKey]s used when first creating this builder. + */ + fun fixedValue( + breakpoint: Float, + value: Float, + spring: SpringParameters = defaultSpring, + guarantee: Guarantee = Guarantee.None, + key: BreakpointKey = BreakpointKey(), + semantics: List> = emptyList(), + ): CanBeLastSegment + + /** + * Ends the current segment at the [breakpoint] position and defines the next segment to output + * a constant value derived from the current output value (optionally with an offset of + * [delta]). + * + * Note: This segment can be used as the last segment in the specification. + * + * @param breakpoint The breakpoint defining the end of the current segment and the start of the + * next. + * @param delta An optional offset to apply to the mapped value to determine the fixed value. + * Defaults to 0f. + * @param spring The [SpringParameters] for the transition to this breakpoint. Defaults to + * [defaultSpring]. + * @param guarantee The animation guarantee for this transition. Defaults to [Guarantee.None]. + * @param key A unique [BreakpointKey] for this breakpoint. Defaults to a newly generated key. + * @param semantics Updated semantics values to be applied. Must be a subset of the + * [SemanticKey]s used when first creating this builder. + */ + fun fixedValueFromCurrent( + breakpoint: Float, + delta: Float = 0f, + spring: SpringParameters = defaultSpring, + guarantee: Guarantee = Guarantee.None, + key: BreakpointKey = BreakpointKey(), + semantics: List> = emptyList(), + ): CanBeLastSegment + + /** + * Ends the current segment at the [breakpoint] position and defines the next segment using the + * provided [mapping]. + * + * Note: This segment can be used as the last segment in the specification. + * + * @param breakpoint The breakpoint defining the end of the current segment and the start of the + * next. + * @param spring The [SpringParameters] for the transition to this breakpoint. Defaults to + * [defaultSpring]. + * @param guarantee The animation guarantee for this transition. Defaults to [Guarantee.None]. + * @param key A unique [BreakpointKey] for this breakpoint. Defaults to a newly generated key. + * @param semantics Updated semantics values to be applied. Must be a subset of the + * [SemanticKey]s used when first creating this builder. + * @param mapping The custom [Mapping] to use. + */ + fun mapping( + breakpoint: Float, + spring: SpringParameters = defaultSpring, + guarantee: Guarantee = Guarantee.None, + key: BreakpointKey = BreakpointKey(), + semantics: List> = emptyList(), + mapping: Mapping, + ): CanBeLastSegment + + /** + * Ends the current segment at the [breakpoint] position and defines the next segment to produce + * the input value as output (optionally with an offset of [delta]). + * + * Note: This segment can be used as the last segment in the specification. + * + * @param breakpoint The breakpoint defining the end of the current segment and the start of the + * next. + * @param delta An optional offset to apply to the mapped value to determine the fixed value. + * @param spring The [SpringParameters] for the transition to this breakpoint. + * @param guarantee The animation guarantee for this transition. + * @param key A unique [BreakpointKey] for this breakpoint. + * @param semantics Updated semantics values to be applied. Must be a subset of the + * [SemanticKey]s used when first creating this builder. + */ + fun identity( + breakpoint: Float, + delta: Float = 0f, + spring: SpringParameters = defaultSpring, + guarantee: Guarantee = Guarantee.None, + key: BreakpointKey = BreakpointKey(), + semantics: List> = emptyList(), + ): CanBeLastSegment { + return if (delta == 0f) { + mapping(breakpoint, spring, guarantee, key, semantics, Mapping.Identity) + } else { + fractionalInput( + breakpoint, + fraction = 1f, + from = breakpoint + delta, + spring = spring, + guarantee = guarantee, + key = key, + semantics = semantics, + ) + } + } +} + +/** Marker interface to indicate that a segment can be the last one in a [DirectionalMotionSpec]. */ +sealed interface CanBeLastSegment diff --git a/mechanics/src/com/android/mechanics/spec/builder/DirectionalSpecBuilder.kt b/mechanics/src/com/android/mechanics/spec/builder/DirectionalSpecBuilder.kt new file mode 100644 index 0000000..b4483b7 --- /dev/null +++ b/mechanics/src/com/android/mechanics/spec/builder/DirectionalSpecBuilder.kt @@ -0,0 +1,128 @@ +/* + * Copyright (C) 2025 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.spec.builder + +import com.android.mechanics.spec.Breakpoint +import com.android.mechanics.spec.DirectionalMotionSpec +import com.android.mechanics.spec.Mapping +import com.android.mechanics.spec.SegmentSemanticValues +import com.android.mechanics.spec.SemanticValue +import com.android.mechanics.spring.SpringParameters + +/** + * Builds a [DirectionalMotionSpec] for spatial values by defining a sequence of ([Breakpoint], + * [Mapping]) pairs + * + * The [initialMapping] is [Mapping.Identity], and the Material spatial.default spring is used, + * unless otherwise specified. + * + * @see directionalMotionSpec + */ +fun MotionBuilderContext.spatialDirectionalMotionSpec( + initialMapping: Mapping = Mapping.Identity, + semantics: List> = emptyList(), + defaultSpring: SpringParameters = this.spatial.default, + init: DirectionalBuilderFn, +) = directionalMotionSpec(defaultSpring, initialMapping, semantics, init) + +/** + * Builds a [DirectionalMotionSpec] for effects values by defining a sequence of ([Breakpoint], + * [Mapping]) pairs + * + * The [initialMapping] is [Mapping.Zero], and the Material effects.default spring is used, unless + * otherwise specified. + * + * @see directionalMotionSpec + */ +fun MotionBuilderContext.effectsDirectionalMotionSpec( + initialMapping: Mapping = Mapping.Zero, + semantics: List> = emptyList(), + defaultSpring: SpringParameters = this.effects.default, + init: DirectionalBuilderFn, +) = directionalMotionSpec(defaultSpring, initialMapping, semantics, init) + +/** + * Builds a [DirectionalMotionSpec] by defining a sequence of ([Breakpoint], [Mapping]) pairs. + * + * This function simplifies the creation of complex motion specifications. It allows you to define a + * series of motion segments, each with its own behavior, separated by breakpoints. The breakpoints + * and their corresponding segments will always be ordered from min to max value, regardless of how + * the `DirectionalMotionSpec` is applied. + * + * Example Usage: + * ```kotlin + * val motionSpec = directionalMotionSpec( + * defaultSpring = materialSpatial, + * + * // Start as a constant transition, always 0. + * initialMapping = Mapping.Zero + * ) { + * // At breakpoint 10: Linear transition from 0 to 50. + * target(breakpoint = 10f, from = 0f, to = 50f) + * + * // At breakpoint 20: Jump +5, and constant value 55. + * fixedValueFromCurrent(breakpoint = 20f, delta = 5f) + * + * // At breakpoint 30: Jump to 40. Linear mapping using: progress_since_breakpoint * fraction. + * fractionalInput(breakpoint = 30f, from = 40f, fraction = 2f) + * } + * ``` + * + * @param defaultSpring The default [SpringParameters] to use for all breakpoints. + * @param initialMapping The initial [Mapping] for the first segment (defaults to + * [Mapping.Identity]). + * @param init A lambda function that configures the spec using the [DirectionalBuilderScope]. The + * lambda should return a [CanBeLastSegment] to indicate the end of the spec. + * @param semantics Semantics specified in this spec, including the initial value applied for + * [initialMapping]. + * @return The constructed [DirectionalMotionSpec]. + */ +fun directionalMotionSpec( + defaultSpring: SpringParameters, + initialMapping: Mapping = Mapping.Identity, + semantics: List> = emptyList(), + init: DirectionalBuilderFn, +): DirectionalMotionSpec { + return DirectionalBuilderImpl(defaultSpring, semantics) + .apply { + prepareBuilderFn(initialMapping) + init() + finalizeBuilderFn(Breakpoint.maxLimit) + } + .build() +} + +/** + * Builds a simple [DirectionalMotionSpec] with a single segment. + * + * @param mapping The [Mapping] to apply to the segment. Defaults to [Mapping.Identity]. + * @param semantics Semantics values for this spec. + * @return A new [DirectionalMotionSpec] instance configured with the provided parameters. + */ +fun directionalMotionSpec( + mapping: Mapping = Mapping.Identity, + semantics: List> = emptyList(), +): DirectionalMotionSpec { + fun toSegmentSemanticValues(semanticValue: SemanticValue) = + SegmentSemanticValues(semanticValue.key, listOf(semanticValue.value)) + + return DirectionalMotionSpec( + listOf(Breakpoint.minLimit, Breakpoint.maxLimit), + listOf(mapping), + semantics.map { toSegmentSemanticValues(it) }, + ) +} diff --git a/mechanics/src/com/android/mechanics/spec/builder/Effect.kt b/mechanics/src/com/android/mechanics/spec/builder/Effect.kt new file mode 100644 index 0000000..93314c0 --- /dev/null +++ b/mechanics/src/com/android/mechanics/spec/builder/Effect.kt @@ -0,0 +1,68 @@ +/* + * Copyright (C) 2025 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.spec.builder + +import com.android.mechanics.spec.BreakpointKey + +/** + * Blueprint for a reusable behavior in a [MotionSpec]. + * + * [Effect] instances are reusable for building multiple + */ +sealed interface Effect { + + /** + * Applies the effect to the motion spec. + * + * The boundaries of the effect are defined by the [minLimit] and [maxLimit] properties, and + * extend in both, the min and max direction by the same amount. + * + * Implementations must invoke either [EffectApplyScope.unidirectional] or both, + * [EffectApplyScope.forward] and [EffectApplyScope.backward]. The motion spec builder will + * throw if neither is called. + */ + fun EffectApplyScope.createSpec( + minLimit: Float, + minLimitKey: BreakpointKey, + maxLimit: Float, + maxLimitKey: BreakpointKey, + placement: EffectPlacement, + ) + + interface PlaceableAfter : Effect { + fun MotionBuilderContext.intrinsicSize(): Float + } + + interface PlaceableBefore : Effect { + fun MotionBuilderContext.intrinsicSize(): Float + } + + interface PlaceableBetween : Effect + + interface PlaceableAt : Effect { + fun MotionBuilderContext.minExtent(): Float + + fun MotionBuilderContext.maxExtent(): Float + } +} + +/** + * Handle for an [Effect] that was placed within a [MotionSpecBuilderScope]. + * + * Used to place effects relative to each other. + */ +@JvmInline value class PlacedEffect internal constructor(internal val id: Int) diff --git a/mechanics/src/com/android/mechanics/spec/builder/EffectApplyScope.kt b/mechanics/src/com/android/mechanics/spec/builder/EffectApplyScope.kt new file mode 100644 index 0000000..920b58b --- /dev/null +++ b/mechanics/src/com/android/mechanics/spec/builder/EffectApplyScope.kt @@ -0,0 +1,182 @@ +/* + * Copyright (C) 2025 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.spec.builder + +import com.android.mechanics.spec.Guarantee +import com.android.mechanics.spec.Mapping +import com.android.mechanics.spec.OnChangeSegmentHandler +import com.android.mechanics.spec.SegmentKey +import com.android.mechanics.spec.SemanticValue +import com.android.mechanics.spring.SpringParameters + +/** + * Defines the contract for applying [Effect]s within a [MotionSpecBuilder] + * + * Provides methods to define breakpoints and mappings for the motion specification. + * + * Breakpoints for [minLimit] and [maxLimit] will be created, with the specified key and parameters. + */ +interface EffectApplyScope : MotionBuilderContext { + /** Default spring in use when not otherwise specified. */ + val defaultSpring: SpringParameters + + /** Mapping used outside of the defined effects. */ + val baseMapping: Mapping + + /** + * Defines spec simultaneously for both, the min and max direction. + * + * The behavior is the same as for `directionalMotionSpec`, with the notable exception that the + * spec to be defined is confined within [minLimit] and [maxLimit]. Specifying breakpoints + * outside of this range will throw. + * + * Will throw if [forward] or [unidirectional] has been called in this scope before. + * + * The first / last semantic value will implicitly extend to the start / end of the resulting + * spec, unless redefined in another spec. + * + * @param initialMapping [Mapping] for the first segment after [minLimit]. + * @param semantics Initial semantics for the effect. + * @param init Configures the effect's spec using [DirectionalBuilderScope]. + * @see com.android.mechanics.spec.directionalMotionSpec for in-depth documentation. + */ + fun unidirectional( + initialMapping: Mapping, + semantics: List> = emptyList(), + init: DirectionalEffectBuilderScope.() -> Unit, + ) + + /** + * Defines spec simultaneously for both, the min and max direction, using a single segment only. + * + * The behavior is the same as for `directionalMotionSpec`, with the notable exception that the + * spec to be defined is confined within [minLimit] and [maxLimit]. + * + * Will throw if [forward] or [unidirectional] has been called in this scope before. + * + * The first / last semantic value will implicitly extend to the start / end of the resulting + * spec, unless redefined in another spec. + * + * @param mapping [Mapping] to be used between [minLimit] and [maxLimit]. + * @param semantics Initial semantics for the effect. + * @see com.android.mechanics.spec.directionalMotionSpec for in depth documentation. + */ + fun unidirectional(mapping: Mapping, semantics: List> = emptyList()) + + /** + * Defines the spec for max direction. + * + * The behavior is the same as for `directionalMotionSpec`, with the notable exception that the + * spec to be defined is confined within [minLimit] and [maxLimit]. Specifying breakpoints + * outside of this range will throw. + * + * Will throw if [forward] or [unidirectional] has been called in this scope before. + * + * The first / last semantic value will implicitly extend to the start / end of the resulting + * spec, unless redefined in another spec. + * + * @param initialMapping [Mapping] for the first segment after [minLimit]. + * @param semantics Initial semantics for the effect. + * @param init Configures the effect's spec using [DirectionalBuilderScope]. + * @see com.android.mechanics.spec.directionalMotionSpec for in-depth documentation. + */ + fun forward( + initialMapping: Mapping, + semantics: List> = emptyList(), + init: DirectionalEffectBuilderScope.() -> Unit, + ) + + /** + * Defines the spec for max direction, using a single segment only. + * + * The behavior is the same as for `directionalMotionSpec`, with the notable exception that the + * spec to be defined is confined within [minLimit] and [maxLimit]. + * + * Will throw if [forward] or [unidirectional] has been called in this scope before. + * + * The first / last semantic value will implicitly extend to the start / end of the resulting + * spec, unless redefined in another spec. + * + * @param mapping [Mapping] to be used between [minLimit] and [maxLimit]. + * @param semantics Initial semantics for the effect. + * @see com.android.mechanics.spec.directionalMotionSpec for in depth documentation. + */ + fun forward(mapping: Mapping, semantics: List> = emptyList()) + + /** + * Defines the spec for min direction. + * + * The behavior is the same as for `directionalMotionSpec`, with the notable exception that the + * spec to be defined is confined within [minLimit] and [maxLimit]. Specifying breakpoints + * outside of this range will throw. + * + * Will throw if [forward] or [unidirectional] has been called in this scope before. + * + * The first / last semantic value will implicitly extend to the start / end of the resulting + * spec, unless redefined in another spec. + * + * @param initialMapping [Mapping] for the first segment after [minLimit]. + * @param semantics Initial semantics for the effect. + * @param init Configures the effect's spec using [DirectionalBuilderScope]. + * @see com.android.mechanics.spec.directionalMotionSpec for in-depth documentation. + */ + fun backward( + initialMapping: Mapping, + semantics: List> = emptyList(), + init: DirectionalEffectBuilderScope.() -> Unit, + ) + + /** + * Defines the spec for min direction, using a single segment only. + * + * The behavior is the same as for `directionalMotionSpec`, with the notable exception that the + * spec to be defined is confined within [minLimit] and [maxLimit]. + * + * Will throw if [forward] or [unidirectional] has been called in this scope before. + * + * The first / last semantic value will implicitly extend to the start / end of the resulting + * spec, unless redefined in another spec. + * + * @param mapping [Mapping] to be used between [minLimit] and [maxLimit]. + * @param semantics Initial semantics for the effect. + * @see com.android.mechanics.spec.directionalMotionSpec for in depth documentation. + */ + fun backward(mapping: Mapping, semantics: List> = emptyList()) + + /** Adds a segment handler to the resulting [MotionSpec]. */ + fun addSegmentHandler(key: SegmentKey, handler: OnChangeSegmentHandler) + + /** Returns the value of [baseValue] at [position]. */ + fun baseValue(position: Float): Float +} + +interface DirectionalEffectBuilderScope : DirectionalBuilderScope { + + fun before( + spring: SpringParameters? = null, + guarantee: Guarantee? = null, + semantics: List>? = null, + mapping: Mapping? = null, + ) + + fun after( + spring: SpringParameters? = null, + guarantee: Guarantee? = null, + semantics: List>? = null, + mapping: Mapping? = null, + ) +} diff --git a/mechanics/src/com/android/mechanics/spec/builder/EffectPlacement.kt b/mechanics/src/com/android/mechanics/spec/builder/EffectPlacement.kt new file mode 100644 index 0000000..00f4f10 --- /dev/null +++ b/mechanics/src/com/android/mechanics/spec/builder/EffectPlacement.kt @@ -0,0 +1,111 @@ +/* + * Copyright (C) 2025 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.spec.builder + +import androidx.compose.ui.util.packFloats +import androidx.compose.ui.util.unpackFloat1 +import androidx.compose.ui.util.unpackFloat2 +import kotlin.math.max +import kotlin.math.min +import kotlin.math.nextDown +import kotlin.math.nextUp + +/** + * Describes the desired placement of an effect within the input domain of a [MotionSpec]. + * + * [start] is always finite, and denotes a specific position in the input where the effects starts. + * + * [end] is either finite, describing a specific range in the input where the [Effect] applies. + * Alternatively, the [end] can be either [Float.NEGATIVE_INFINITY] or [Float.POSITIVE_INFINITY], + * indicating that the effect extends either + * - for the effects intrinsic extent + * - the boundaries of the next placed effect + * - the specs' min/max limit + * + * Thus, [start] and [end] define an implicit direction of the effect. If not [isForward], the + * [Effect] will be reversed when applied. + */ +@JvmInline +value class EffectPlacement internal constructor(val value: Long) { + + init { + require(start.isFinite()) + } + + val start: Float + get() = unpackFloat1(value) + + val end: Float + get() = unpackFloat2(value) + + val type: EffectPlacemenType + get() { + return when { + end.isNaN() -> EffectPlacemenType.At + end == Float.NEGATIVE_INFINITY -> EffectPlacemenType.Before + end == Float.POSITIVE_INFINITY -> EffectPlacemenType.After + else -> EffectPlacemenType.Between + } + } + + val isForward: Boolean + get() { + return when (type) { + EffectPlacemenType.At -> true + EffectPlacemenType.Before -> false + EffectPlacemenType.After -> true + EffectPlacemenType.Between -> end >= start + } + } + + internal val sortOrder: Float + get() { + return when (type) { + EffectPlacemenType.At -> start + EffectPlacemenType.Before -> start.nextDown() + EffectPlacemenType.After -> start.nextUp() + EffectPlacemenType.Between -> (start + end) / 2 + } + } + + internal val min: Float + get() = min(start, end) + + internal val max: Float + get() = max(start, end) + + override fun toString(): String { + return "EffectPlacement(start=$start, end=$end)" + } + + companion object { + fun at(position: Float) = EffectPlacement(packFloats(position, Float.NaN)) + + fun after(position: Float) = EffectPlacement(packFloats(position, Float.POSITIVE_INFINITY)) + + fun before(position: Float) = EffectPlacement(packFloats(position, Float.NEGATIVE_INFINITY)) + + fun between(start: Float, end: Float) = EffectPlacement(packFloats(start, end)) + } +} + +enum class EffectPlacemenType { + At, + Before, + After, + Between, +} diff --git a/mechanics/src/com/android/mechanics/spec/builder/MotionBuilderContext.kt b/mechanics/src/com/android/mechanics/spec/builder/MotionBuilderContext.kt new file mode 100644 index 0000000..989d481 --- /dev/null +++ b/mechanics/src/com/android/mechanics/spec/builder/MotionBuilderContext.kt @@ -0,0 +1,104 @@ +/* + * Copyright (C) 2025 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. + */ + +@file:OptIn(ExperimentalMaterial3ExpressiveApi::class) + +package com.android.mechanics.spec.builder + +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.MotionScheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.Density +import com.android.mechanics.spring.SpringParameters + +/** + * Device / scheme specific context for building motion specs. + * + * See go/motion-system. + * + * @see rememberMotionBuilderContext for Compose + * @see standardViewMotionBuilderContext for Views + * @see expressiveViewMotionBuilderContext for Views + */ +interface MotionBuilderContext : Density { + /** + * Spatial spring tokens. + * + * Used for animations that move something on screen, for example the x and y position, + * rotation, size, rounded corners. + * + * See go/motion-system#b99b0d12-e9c8-4605-96dd-e3f17bfe9538 + */ + val spatial: MaterialSprings + + /** + * Effects spring tokens. + * + * Used to animate properties such as color and opacity animations. + * + * See go/motion-system#142c8835-7474-4f74-b2eb-e1187051ec1f + */ + val effects: MaterialSprings + + companion object { + /** Default threshold for effect springs. */ + const val StableThresholdEffects = 0.01f + /** + * Default threshold for spatial springs. + * + * Cuts off when remaining oscillations are below 1px + */ + const val StableThresholdSpatial = 1f + } +} + +/** Material spring tokens, see go/motion-system##63b14c00-d049-4d3e-b8b6-83d8f524a8db for usage. */ +data class MaterialSprings( + val default: SpringParameters, + val fast: SpringParameters, + val slow: SpringParameters, + val stabilityThreshold: Float, +) + +/** [MotionBuilderContext] based on the current [Density] and [MotionScheme]. */ +@Composable +fun rememberMotionBuilderContext(): MotionBuilderContext { + val density = LocalDensity.current + val motionScheme = MaterialTheme.motionScheme + return remember(density, motionScheme) { ComposeMotionBuilderContext(motionScheme, density) } +} + +class ComposeMotionBuilderContext(motionScheme: MotionScheme, density: Density) : + MotionBuilderContext, Density by density { + + override val spatial = + MaterialSprings( + SpringParameters(motionScheme.defaultSpatialSpec()), + SpringParameters(motionScheme.fastSpatialSpec()), + SpringParameters(motionScheme.slowSpatialSpec()), + MotionBuilderContext.StableThresholdSpatial, + ) + override val effects = + MaterialSprings( + SpringParameters(motionScheme.defaultEffectsSpec()), + SpringParameters(motionScheme.fastEffectsSpec()), + SpringParameters(motionScheme.slowEffectsSpec()), + MotionBuilderContext.StableThresholdEffects, + ) +} diff --git a/mechanics/src/com/android/mechanics/spec/builder/MotionSpecBuilder.kt b/mechanics/src/com/android/mechanics/spec/builder/MotionSpecBuilder.kt new file mode 100644 index 0000000..de62c44 --- /dev/null +++ b/mechanics/src/com/android/mechanics/spec/builder/MotionSpecBuilder.kt @@ -0,0 +1,162 @@ +/* + * Copyright (C) 2025 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.spec.builder + +import com.android.mechanics.spec.Mapping +import com.android.mechanics.spec.MotionSpec +import com.android.mechanics.spec.SemanticValue +import com.android.mechanics.spring.SpringParameters + +/** + * Creates a [MotionSpec] for a spatial value. + * + * The [baseMapping] is [Mapping.Identity], and the Material spatial.default spring is used unless + * otherwise specified. + * + * @see motionSpec + */ +fun MotionBuilderContext.spatialMotionSpec( + baseMapping: Mapping = Mapping.Identity, + defaultSpring: SpringParameters = this.spatial.default, + resetSpring: SpringParameters = defaultSpring, + baseSemantics: List> = emptyList(), + init: MotionSpecBuilderScope.() -> Unit, +) = motionSpec(baseMapping, defaultSpring, resetSpring, baseSemantics, init) + +/** + * Creates a [MotionSpec] for an effects value. + * + * The [baseMapping] is [Mapping.Zero], and the Material effects.default spring is used unless + * otherwise specified. + * + * @see motionSpec + */ +fun MotionBuilderContext.effectsMotionSpec( + baseMapping: Mapping = Mapping.Zero, + defaultSpring: SpringParameters = this.effects.default, + resetSpring: SpringParameters = defaultSpring, + baseSemantics: List> = emptyList(), + init: MotionSpecBuilderScope.() -> Unit, +) = motionSpec(baseMapping, defaultSpring, resetSpring, baseSemantics, init) + +/** + * Creates a [MotionSpec], based on reusable effects. + * + * @param baseMapping The mapping in used for segments where no [Effect] is specified. + * @param defaultSpring The [DirectionalBuilderScope.defaultSpring], used for all discontinuities + * unless otherwise specified. + * @param resetSpring spring parameters to animate a difference in output, if the difference is + * caused by setting this new spec. + * @param baseSemantics initial semantics that apply before of effects override them. + * @param init + */ +fun MotionBuilderContext.motionSpec( + baseMapping: Mapping, + defaultSpring: SpringParameters, + resetSpring: SpringParameters = defaultSpring, + baseSemantics: List> = emptyList(), + init: MotionSpecBuilderScope.() -> Unit, +): MotionSpec { + return MotionSpecBuilderImpl( + baseMapping, + defaultSpring, + resetSpring, + baseSemantics, + motionBuilderContext = this, + ) + .apply(init) + .build() +} + +/** + * Creates a [MotionSpec] producing a fixed output value, no matter the [MotionValues]'s input. + * + * The Material spatial.default spring is used to animate to the fixed output value. + * + * @see fixedValueSpec + */ +fun MotionBuilderContext.fixedSpatialValueSpec( + value: Float, + resetSpring: SpringParameters = this.spatial.default, + semantics: List> = emptyList(), +) = fixedValueSpec(value, resetSpring, semantics) + +/** + * Creates a [MotionSpec] producing a fixed output value, no matter the [MotionValues]'s input. + * + * The Material effects.default spring is used to animate to the fixed output value. + * + * @see fixedValueSpec + */ +fun MotionBuilderContext.fixedEffectsValueSpec( + value: Float, + resetSpring: SpringParameters = this.effects.default, + semantics: List> = emptyList(), +) = fixedValueSpec(value, resetSpring, semantics) + +/** + * Creates a [MotionSpec] producing a fixed output value, no matter the [MotionValues]'s input. + * + * @param value The fixed output value. + * @param resetSpring spring parameters to animate to the fixed output value. + * @param semantics for this spec. + */ +fun MotionBuilderContext.fixedValueSpec( + value: Float, + resetSpring: SpringParameters, + semantics: List> = emptyList(), +): MotionSpec { + return MotionSpec( + directionalMotionSpec(Mapping.Fixed(value), semantics), + resetSpring = resetSpring, + ) +} + +/** Defines the contract placing [Effect]s within a [MotionSpecBuilder] */ +interface MotionSpecBuilderScope : MotionBuilderContext { + + /** + * Places [effect] between [start] and [end]. + * + * If `start > end`, the effect will be reversed when applied. The [effect] can overrule the + * `end` position with [Effect.measure]. + */ + fun between(start: Float, end: Float, effect: Effect.PlaceableBetween): PlacedEffect + + /** + * Places [effect] at position, extending backwards. + * + * The effect will be reversed when applied. + */ + fun before(position: Float, effect: Effect.PlaceableBefore): PlacedEffect + + /** Places [effect] at position, extending forward. */ + fun after(position: Float, effect: Effect.PlaceableAfter): PlacedEffect + + /** + * Places [effect] at [otherEffect]'s min position, extending backwards. + * + * The effect will be reversed when applied. + */ + fun before(otherEffect: PlacedEffect, effect: Effect.PlaceableBefore): PlacedEffect + + /** Places [effect] after the end of [otherEffect], extending forward. */ + fun after(otherEffect: PlacedEffect, effect: Effect.PlaceableAfter): PlacedEffect + + /** Places [effect] at position. */ + fun at(position: Float, effect: Effect.PlaceableAt): PlacedEffect +} diff --git a/mechanics/src/com/android/mechanics/spec/builder/MotionSpecBuilderImpl.kt b/mechanics/src/com/android/mechanics/spec/builder/MotionSpecBuilderImpl.kt new file mode 100644 index 0000000..75b9953 --- /dev/null +++ b/mechanics/src/com/android/mechanics/spec/builder/MotionSpecBuilderImpl.kt @@ -0,0 +1,589 @@ +/* + * Copyright (C) 2025 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.spec.builder + +import androidx.collection.MutableIntIntMap +import androidx.collection.MutableIntList +import androidx.collection.MutableIntLongMap +import androidx.collection.MutableIntObjectMap +import androidx.collection.MutableLongList +import androidx.collection.ObjectList +import androidx.collection.mutableObjectListOf +import com.android.mechanics.spec.Breakpoint +import com.android.mechanics.spec.BreakpointKey +import com.android.mechanics.spec.Guarantee +import com.android.mechanics.spec.Mapping +import com.android.mechanics.spec.MotionSpec +import com.android.mechanics.spec.OnChangeSegmentHandler +import com.android.mechanics.spec.SegmentKey +import com.android.mechanics.spec.SemanticValue +import com.android.mechanics.spring.SpringParameters + +internal class MotionSpecBuilderImpl( + override val baseMapping: Mapping, + override val defaultSpring: SpringParameters, + private val resetSpring: SpringParameters, + private val baseSemantics: List>, + motionBuilderContext: MotionBuilderContext, +) : MotionSpecBuilderScope, MotionBuilderContext by motionBuilderContext, EffectApplyScope { + + private val placedEffects = MutableIntObjectMap() + private val absoluteEffectPlacements = MutableIntLongMap() + private val relativeEffectPlacements = MutableIntIntMap() + + private lateinit var builders: ObjectList + private val forwardBuilder: DirectionalEffectBuilderScopeImpl + get() = builders[0] + + private val reverseBuilder: DirectionalEffectBuilderScopeImpl + get() = builders[1] + + private lateinit var segmentHandlers: MutableMap + + fun build(): MotionSpec { + if (placedEffects.isEmpty()) { + return MotionSpec(directionalMotionSpec(baseMapping), resetSpring = resetSpring) + } + + builders = + mutableObjectListOf( + DirectionalEffectBuilderScopeImpl(defaultSpring, baseSemantics), + DirectionalEffectBuilderScopeImpl(defaultSpring, baseSemantics), + ) + segmentHandlers = mutableMapOf() + + val capacity = placedEffects.size * 2 + 1 + val sortedEffects = MutableIntList(capacity) + val specifiedPlacements = MutablePlacementList(MutableLongList(capacity)) + val actualPlacements = MutablePlacementList(MutableLongList(capacity)) + + placeEffects(sortedEffects, specifiedPlacements, actualPlacements) + check(sortedEffects.size >= 2) + + var minLimitKey = BreakpointKey.MinLimit + lateinit var maxLimitKey: BreakpointKey + + for (i in 0 until sortedEffects.lastIndex) { + maxLimitKey = BreakpointKey() + applyEffect( + sortedEffects[i], + specifiedPlacements[i], + actualPlacements[i], + minLimitKey, + maxLimitKey, + ) + minLimitKey = maxLimitKey + } + + maxLimitKey = BreakpointKey.MaxLimit + + applyEffect( + sortedEffects.last(), + specifiedPlacements.last(), + actualPlacements.last(), + minLimitKey, + maxLimitKey, + ) + + return MotionSpec( + builders[0].build(), + builders[1].build(), + resetSpring, + segmentHandlers.toMap(), + ) + } + + private fun placeEffects( + sortedEffects: MutableIntList, + specifiedPlacements: MutablePlacementList, + actualPlacements: MutablePlacementList, + ) { + + // To place the effects, do the following + // - sort all `absoluteEffectPlacements` in ascending order + // - use the sorted absolutely placed effects as seeds. For each of them, do the following: + // - measure the effect + // - recursively walk the relatively effects placed before, tracking the min boundary + // (this requires effects that have a defined extend to the min side) + // - upon reaching the beginning, start placing the effects in the forward direction. + // continue up to the seed effects, t + // - recursively continue placing effects relatively placed afterwards. + + fun appendEffect( + effectId: Int, + specifiedPlacement: EffectPlacement, + measuredPlacement: EffectPlacement, + ) { + var actualPlacement = measuredPlacement + var prependNoPlaceholderEffect = false + + if (actualPlacements.isEmpty()) { + // placing first effect. + if (measuredPlacement.min.isFinite()) { + prependNoPlaceholderEffect = true + } + } else { + + val previousPlacement = actualPlacements.last() + if (previousPlacement.max.isFinite()) { + // The previous effect has a defined end-point. + + if (measuredPlacement.min == Float.NEGATIVE_INFINITY) { + // The current effect wants to extend to the end of the previous effect. + require(measuredPlacement.max.isFinite()) + actualPlacement = + EffectPlacement.between(previousPlacement.max, measuredPlacement.max) + } else if (measuredPlacement.min > previousPlacement.max) { + // There's a gap between the last and the current effect, will need to + // insert a placeholder + require(measuredPlacement.min.isFinite()) + prependNoPlaceholderEffect = true + } else { + // In all other cases, the previous end has to match the current start. + // In all other cases, effects are overlapping, which is not supported. + require(measuredPlacement.min == previousPlacement.max) { + "Effects are overlapping" + } + } + } else { + // The previous effect wants to extend to the beginning of the next effect + assert(previousPlacement.max == Float.POSITIVE_INFINITY) + + // Therefore the current effect is required to have a defined start-point + require(measuredPlacement.min.isFinite()) { + "Only one of the effects can extend to the boundary, not both:\n" + + " this: $actualPlacement (${placedEffects[effectId]})\n" + + " previous: $previousPlacement (${placedEffects[effectId]}])\n" + } + + actualPlacements[actualPlacements.lastIndex] = + EffectPlacement.between(previousPlacement.min, measuredPlacement.min) + } + } + + if (prependNoPlaceholderEffect) { + assert(actualPlacement.min.isFinite()) + // Adding a placeholder that will be skipped, but simplifies the algorithm by + // ensuring all effects are back-to-back. The NoEffectPlaceholderId is used to + + sortedEffects.add(NoEffectPlaceholderId) + val placeholderPlacement = EffectPlacement.before(actualPlacement.min) + specifiedPlacements.add(placeholderPlacement) + actualPlacements.add(placeholderPlacement) + } + + sortedEffects.add(effectId) + specifiedPlacements.add(specifiedPlacement) + + actualPlacements.add(actualPlacement) + } + + fun processEffectsPlacedBefore( + anchorEffectId: Int, + anchorEffectPlacement: EffectPlacement, + ) { + val beforeEffectKey = -anchorEffectId + if (relativeEffectPlacements.containsKey(beforeEffectKey)) { + val effectId = relativeEffectPlacements[beforeEffectKey] + val effect = checkNotNull(placedEffects[effectId]) + + require(anchorEffectPlacement.min.isFinite()) + val specifiedPlacement = EffectPlacement.before(anchorEffectPlacement.min) + + val measuredPlacement = measureEffect(effect, specifiedPlacement) + processEffectsPlacedBefore(effectId, measuredPlacement) + appendEffect(effectId, specifiedPlacement, measuredPlacement) + } + } + + fun processEffectsPlacedAfter(anchorEffectId: Int, anchorEffectPlacement: EffectPlacement) { + val afterEffectKey = anchorEffectId + if (relativeEffectPlacements.containsKey(afterEffectKey)) { + val effectId = relativeEffectPlacements[afterEffectKey] + val effect = checkNotNull(placedEffects[effectId]) + + require(anchorEffectPlacement.max.isFinite()) + val specifiedPlacement = EffectPlacement.after(anchorEffectPlacement.max) + + val measuredPlacement = measureEffect(effect, specifiedPlacement) + appendEffect(effectId, specifiedPlacement, measuredPlacement) + processEffectsPlacedAfter(effectId, measuredPlacement) + } + } + + check(absoluteEffectPlacements.isNotEmpty()) + // Implementation note: sortedAbsolutePlacedEffects should be an IntArray, but that cannot + // be sorted with a custom comparator, hence using a typed array. + val sortedAbsolutePlacedEffects = + Array(absoluteEffectPlacements.size) { 0 } + .also { array -> + var index = 0 + absoluteEffectPlacements.forEachKey { array[index++] = it } + array.sortBy { EffectPlacement(absoluteEffectPlacements[it]).sortOrder } + } + + sortedAbsolutePlacedEffects.forEach { effectId -> + val effect = checkNotNull(placedEffects[effectId]) + val specifiedPlacement = EffectPlacement(absoluteEffectPlacements[effectId]) + val measuredPlacement = measureEffect(effect, specifiedPlacement) + processEffectsPlacedBefore(effectId, measuredPlacement) + appendEffect(effectId, specifiedPlacement, measuredPlacement) + processEffectsPlacedAfter(effectId, measuredPlacement) + } + + if (actualPlacements.last().max != Float.POSITIVE_INFINITY) { + sortedEffects.add(NoEffectPlaceholderId) + val placeholderPlacement = EffectPlacement.after(actualPlacements.last().max) + specifiedPlacements.add(placeholderPlacement) + actualPlacements.add(placeholderPlacement) + } + } + + // ---- MotionSpecBuilderScope implementation -------------------------------------------------- + + override fun at(position: Float, effect: Effect.PlaceableAt): PlacedEffect { + return addEffect(effect).also { + absoluteEffectPlacements[it.id] = EffectPlacement.after(position).value + } + } + + override fun between(start: Float, end: Float, effect: Effect.PlaceableBetween): PlacedEffect { + return addEffect(effect).also { + absoluteEffectPlacements[it.id] = EffectPlacement.between(start, end).value + } + } + + override fun before(position: Float, effect: Effect.PlaceableBefore): PlacedEffect { + return addEffect(effect).also { + absoluteEffectPlacements[it.id] = EffectPlacement.before(position).value + } + } + + override fun before(otherEffect: PlacedEffect, effect: Effect.PlaceableBefore): PlacedEffect { + require(placedEffects.containsKey(otherEffect.id)) + require(!relativeEffectPlacements.containsKey(-otherEffect.id)) + return addEffect(effect).also { relativeEffectPlacements[-otherEffect.id] = it.id } + } + + override fun after(position: Float, effect: Effect.PlaceableAfter): PlacedEffect { + return addEffect(effect).also { + absoluteEffectPlacements[it.id] = EffectPlacement.after(position).value + } + } + + override fun after(otherEffect: PlacedEffect, effect: Effect.PlaceableAfter): PlacedEffect { + require(placedEffects.containsKey(otherEffect.id)) + require(!relativeEffectPlacements.containsKey(otherEffect.id)) + + relativeEffectPlacements.forEach { key, value -> + if (value == otherEffect.id) { + require(key > 0) { + val other = placedEffects[otherEffect.id] + "Cannot place effect [$effect] *after* [$other], since the latter was placed" + + "*before* an effect" + } + } + } + + require(!relativeEffectPlacements.containsKey(otherEffect.id)) + return addEffect(effect).also { relativeEffectPlacements[otherEffect.id] = it.id } + } + + private fun addEffect(effect: Effect): PlacedEffect { + return PlacedEffect(placedEffects.size + 1).also { placedEffects[it.id] = effect } + } + + // ----- EffectApplyScope implementation ------------------------------------------------------- + + override fun addSegmentHandler(key: SegmentKey, handler: OnChangeSegmentHandler) { + require(!segmentHandlers.containsKey(key)) + segmentHandlers[key] = handler + } + + override fun baseValue(position: Float): Float { + return baseMapping.map(position) + } + + override fun unidirectional( + initialMapping: Mapping, + semantics: List>, + init: DirectionalEffectBuilderScope.() -> Unit, + ) { + forward(initialMapping, semantics, init) + backward(initialMapping, semantics, init) + } + + override fun unidirectional(mapping: Mapping, semantics: List>) { + forward(mapping, semantics) + backward(mapping, semantics) + } + + override fun forward( + initialMapping: Mapping, + semantics: List>, + init: DirectionalEffectBuilderScope.() -> Unit, + ) { + check(!forwardInvoked) { "Cannot define forward spec more than once" } + forwardInvoked = true + + forwardBuilder.prepareBuilderFn(initialMapping, semantics) + forwardBuilder.init() + } + + override fun forward(mapping: Mapping, semantics: List>) { + check(!forwardInvoked) { "Cannot define forward spec more than once" } + forwardInvoked = true + + forwardBuilder.prepareBuilderFn(mapping, semantics) + } + + override fun backward( + initialMapping: Mapping, + semantics: List>, + init: DirectionalEffectBuilderScope.() -> Unit, + ) { + check(!backwardInvoked) { "Cannot define backward spec more than once" } + backwardInvoked = true + + reverseBuilder.prepareBuilderFn(initialMapping, semantics) + reverseBuilder.init() + } + + override fun backward(mapping: Mapping, semantics: List>) { + check(!backwardInvoked) { "Cannot define backward spec more than once" } + backwardInvoked = true + + reverseBuilder.prepareBuilderFn(mapping, semantics) + } + + private var forwardInvoked = false + private var backwardInvoked = false + + private fun applyEffect( + effectId: Int, + specifiedPlacement: EffectPlacement, + actualPlacement: EffectPlacement, + minLimitKey: BreakpointKey, + maxLimitKey: BreakpointKey, + ) { + require(minLimitKey != maxLimitKey) + + if (effectId == NoEffectPlaceholderId) { + val maxBreakpoint = + Breakpoint.create(maxLimitKey, actualPlacement.max, defaultSpring, Guarantee.None) + builders.forEach { builder -> + builder.mappings += builder.afterMapping ?: baseMapping + builder.breakpoints += maxBreakpoint + } + return + } + + val initialForwardSize = forwardBuilder.breakpoints.size + val initialReverseSize = reverseBuilder.breakpoints.size + + val effect = checkNotNull(placedEffects[effectId]) + + forwardInvoked = false + backwardInvoked = false + + builders.forEach { it.resetBeforeAfter() } + with(effect) { + createSpec( + actualPlacement.min, + minLimitKey, + actualPlacement.max, + maxLimitKey, + specifiedPlacement, + ) + } + + check(forwardInvoked) { "forward() spec not defined during createSpec()" } + check(backwardInvoked) { "backward() spec not defined during createSpec()" } + + builders.forEachIndexed { index, builder -> + val initialSize = if (index == 0) initialForwardSize else initialReverseSize + + require(builder.breakpoints[initialSize - 1].key == minLimitKey) + + builder.finalizeBuilderFn( + actualPlacement.max, + maxLimitKey, + builder.afterSpring ?: defaultSpring, + builder.afterGuarantee ?: Guarantee.None, + builder.afterSemantics ?: emptyList(), + ) + check(builder.breakpoints.size > initialSize) + + if (builder.beforeSpring != null || builder.beforeGuarantee != null) { + val oldMinBreakpoint = builder.breakpoints[initialSize - 1] + builder.breakpoints[initialSize - 1] = + oldMinBreakpoint.copy( + spring = builder.beforeSpring ?: oldMinBreakpoint.spring, + guarantee = builder.beforeGuarantee ?: oldMinBreakpoint.guarantee, + ) + } + + builder.beforeMapping + ?.takeIf { initialSize >= 2 && builder.mappings[initialSize - 2] === baseMapping } + ?.also { builder.mappings[initialSize - 2] = it } + + builder.beforeSemantics?.forEach { + builder.getSemantics(it.key).updateBefore(initialSize - 2, it.value) + } + } + } + + companion object { + private val NoEffectPlaceholderId = -1 + } +} + +private class DirectionalEffectBuilderScopeImpl( + defaultSpring: SpringParameters, + baseSemantics: List>, +) : DirectionalBuilderImpl(defaultSpring, baseSemantics), DirectionalEffectBuilderScope { + + var beforeGuarantee: Guarantee? = null + var beforeSpring: SpringParameters? = null + var beforeSemantics: List>? = null + var beforeMapping: Mapping? = null + + override fun before( + spring: SpringParameters?, + guarantee: Guarantee?, + semantics: List>?, + mapping: Mapping?, + ) { + beforeGuarantee = guarantee + beforeSpring = spring + beforeSemantics = semantics + beforeMapping = mapping + } + + var afterGuarantee: Guarantee? = null + var afterSpring: SpringParameters? = null + var afterSemantics: List>? = null + var afterMapping: Mapping? = null + + override fun after( + spring: SpringParameters?, + guarantee: Guarantee?, + semantics: List>?, + mapping: Mapping?, + ) { + afterGuarantee = guarantee + afterSpring = spring + afterSemantics = semantics + afterMapping = mapping + } + + fun resetBeforeAfter() { + beforeGuarantee = null + beforeSpring = null + beforeSemantics = null + beforeMapping = null + afterGuarantee = null + afterSpring = null + afterSemantics = null + afterMapping = null + } +} + +private fun MotionBuilderContext.measureEffect( + effect: Effect, + specifiedPlacement: EffectPlacement, +): EffectPlacement { + return when (specifiedPlacement.type) { + EffectPlacemenType.At -> { + require(effect is Effect.PlaceableAt) + with(effect) { + val minExtend = minExtent() + require(minExtend.isFinite() && minExtend >= 0) + val maxExtend = maxExtent() + require(maxExtend.isFinite() && maxExtend >= 0) + + EffectPlacement.between( + specifiedPlacement.start - minExtend, + specifiedPlacement.start + maxExtend, + ) + } + } + + EffectPlacemenType.Before -> { + require(effect is Effect.PlaceableBefore) + with(effect) { + val intrinsicSize = intrinsicSize() + if (intrinsicSize.isFinite()) { + require(intrinsicSize >= 0) + + EffectPlacement.between( + specifiedPlacement.start, + specifiedPlacement.start - intrinsicSize, + ) + } else { + specifiedPlacement + } + } + } + + EffectPlacemenType.After -> { + require(effect is Effect.PlaceableAfter) + with(effect) { + val intrinsicSize = intrinsicSize() + if (intrinsicSize.isFinite()) { + + require(intrinsicSize >= 0) + + EffectPlacement.between( + specifiedPlacement.start, + specifiedPlacement.start + intrinsicSize, + ) + } else { + specifiedPlacement + } + } + } + + EffectPlacemenType.Between -> specifiedPlacement + } +} + +@JvmInline +value class MutablePlacementList(val storage: MutableLongList) { + + val size: Int + get() = storage.size + + val lastIndex: Int + get() = storage.lastIndex + + val indices: IntRange + get() = storage.indices + + fun isEmpty() = storage.isEmpty() + + fun isNotEmpty() = storage.isNotEmpty() + + operator fun get(index: Int) = EffectPlacement(storage.get(index)) + + fun last() = EffectPlacement(storage.last()) + + fun add(element: EffectPlacement) = storage.add(element.value) + + operator fun set(index: Int, element: EffectPlacement) = + EffectPlacement(storage.set(index, element.value)) +} diff --git a/mechanics/src/com/android/mechanics/spring/MaterialSpringParameters.kt b/mechanics/src/com/android/mechanics/spring/MaterialSpringParameters.kt new file mode 100644 index 0000000..81af8a4 --- /dev/null +++ b/mechanics/src/com/android/mechanics/spring/MaterialSpringParameters.kt @@ -0,0 +1,49 @@ +/* + * Copyright (C) 2025 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. + */ +@file:OptIn(ExperimentalMaterial3ExpressiveApi::class) + +package com.android.mechanics.spring + +import androidx.compose.animation.core.FiniteAnimationSpec +import androidx.compose.animation.core.SpringSpec +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable + +/** Converts a [SpringSpec] into its [SpringParameters] equivalent. */ +fun SpringParameters(springSpec: SpringSpec) = + with(springSpec) { SpringParameters(stiffness, dampingRatio) } + +/** + * Converts a [FiniteAnimationSpec] from the [MotionScheme] into its [SpringParameters] equivalent. + */ +@ExperimentalMaterial3ExpressiveApi +fun SpringParameters(animationSpec: FiniteAnimationSpec): SpringParameters { + check(animationSpec is SpringSpec) { + "animationSpec is expected to be a SpringSpec, but is $animationSpec" + } + return SpringParameters(animationSpec) +} + +@Composable +fun defaultSpatialSpring(): SpringParameters { + return SpringParameters(MaterialTheme.motionScheme.defaultSpatialSpec()) +} + +@Composable +fun defaultEffectSpring(): SpringParameters { + return SpringParameters(MaterialTheme.motionScheme.defaultEffectsSpec()) +} diff --git a/mechanics/src/com/android/mechanics/spring/SpringParameters.kt b/mechanics/src/com/android/mechanics/spring/SpringParameters.kt new file mode 100644 index 0000000..828527a --- /dev/null +++ b/mechanics/src/com/android/mechanics/spring/SpringParameters.kt @@ -0,0 +1,79 @@ +/* + * 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.spring + +import androidx.compose.ui.util.fastCoerceIn +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 kotlin.math.pow + +/** + * Describes the parameters of a spring. + * + * Note: This is conceptually compatible with the Compose [SpringSpec]. In contrast to the compose + * implementation, these [SpringParameters] are intended to be continuously updated. + * + * @see SpringParameters function to create this value. + */ +@JvmInline +value class SpringParameters(val packedValue: Long) { + val stiffness: Float + get() = unpackFloat1(packedValue) + + val dampingRatio: Float + get() = unpackFloat2(packedValue) + + /** Whether the spring is expected to immediately end movement. */ + val isSnapSpring: Boolean + get() = stiffness >= snapStiffness && dampingRatio == snapDamping + + override fun toString(): String { + return "MechanicsSpringSpec(stiffness=$stiffness, dampingRatio=$dampingRatio)" + } + + companion object { + private val snapStiffness = 100_000f + private val snapDamping = 1f + + /** A spring so stiff it completes the motion almost immediately. */ + val Snap = SpringParameters(snapStiffness, snapDamping) + } +} + +/** Creates a [SpringParameters] with the given [stiffness] and [dampingRatio]. */ +fun SpringParameters(stiffness: Float, dampingRatio: Float): SpringParameters { + require(stiffness > 0) { "Spring stiffness constant must be positive." } + require(dampingRatio >= 0) { "Spring damping constant must be positive." } + return SpringParameters(packFloats(stiffness, dampingRatio)) +} + +/** + * Return interpolated [SpringParameters], based on the [fraction] between [start] and [stop]. + * + * The [SpringParameters.dampingRatio] is interpolated linearly, the [SpringParameters.stiffness] is + * interpolated logarithmically. + * + * The [fraction] is clamped to a `0..1` range. + */ +fun lerp(start: SpringParameters, stop: SpringParameters, fraction: Float): SpringParameters { + val f = fraction.fastCoerceIn(0f, 1f) + val stiffness = start.stiffness.pow(1 - f) * stop.stiffness.pow(f) + val dampingRatio = lerp(start.dampingRatio, stop.dampingRatio, f) + return SpringParameters(packFloats(stiffness, dampingRatio)) +} diff --git a/mechanics/src/com/android/mechanics/spring/SpringState.kt b/mechanics/src/com/android/mechanics/spring/SpringState.kt new file mode 100644 index 0000000..bdf7c33 --- /dev/null +++ b/mechanics/src/com/android/mechanics/spring/SpringState.kt @@ -0,0 +1,134 @@ +/* + * 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.spring + +import androidx.compose.ui.util.packFloats +import androidx.compose.ui.util.unpackFloat1 +import androidx.compose.ui.util.unpackFloat2 +import kotlin.math.cos +import kotlin.math.exp +import kotlin.math.sin +import kotlin.math.sqrt + +/** + * Describes the motion state of a spring. + * + * @see calculateUpdatedState to simulate the springs movement + * @see SpringState function to create this value. + */ +@JvmInline +value class SpringState(val packedValue: Long) { + val displacement: Float + get() = unpackFloat1(packedValue) + + val velocity: Float + get() = unpackFloat2(packedValue) + + /** + * Whether the state is considered stable. + * + * The amplitude of the remaining movement, for a spring with [parameters] is less than + * [stableThreshold] + */ + fun isStable(parameters: SpringParameters, stableThreshold: Float): Boolean { + if (this == AtRest) return true + val currentEnergy = parameters.stiffness * displacement * displacement + velocity * velocity + val maxStableEnergy = parameters.stiffness * stableThreshold * stableThreshold + return currentEnergy <= maxStableEnergy + } + + /** Adds the specified [displacementDelta] and [velocityDelta] to the returned state. */ + fun nudge(displacementDelta: Float = 0f, velocityDelta: Float = 0f): SpringState { + return SpringState(displacement + displacementDelta, velocity + velocityDelta) + } + + override fun toString(): String { + return "MechanicsSpringState(displacement=$displacement, velocity=$velocity)" + } + + companion object { + /** Spring at rest. */ + val AtRest = SpringState(displacement = 0f, velocity = 0f) + } +} + +/** Creates a [SpringState] given [displacement] and [velocity] */ +fun SpringState(displacement: Float, velocity: Float = 0f) = + SpringState(packFloats(displacement, velocity)) + +/** + * Computes the updated [SpringState], after letting the spring with the specified [parameters] + * settle for [elapsedNanos]. + * + * This implementation is based on Compose's [SpringSimulation]. + */ +fun SpringState.calculateUpdatedState( + elapsedNanos: Long, + parameters: SpringParameters, +): SpringState { + if (parameters.isSnapSpring || this == SpringState.AtRest) { + return SpringState.AtRest + } + + val stiffness = parameters.stiffness.toDouble() + val naturalFreq = sqrt(stiffness) + + val dampingRatio = parameters.dampingRatio + val displacement = displacement + val velocity = velocity + val deltaT = elapsedNanos / 1_000_000_000.0 // unit: seconds + val dampingRatioSquared = dampingRatio * dampingRatio.toDouble() + val r = -dampingRatio * naturalFreq + + val currentDisplacement: Double + val currentVelocity: Double + + if (dampingRatio > 1) { + // Over damping + val s = naturalFreq * sqrt(dampingRatioSquared - 1) + val gammaPlus = r + s + val gammaMinus = r - s + + val coeffB = (gammaMinus * displacement - velocity) / (gammaMinus - gammaPlus) + val coeffA = displacement - coeffB + currentDisplacement = (coeffA * exp(gammaMinus * deltaT) + coeffB * exp(gammaPlus * deltaT)) + currentVelocity = + (coeffA * gammaMinus * exp(gammaMinus * deltaT) + + coeffB * gammaPlus * exp(gammaPlus * deltaT)) + } else if (dampingRatio == 1.0f) { + // Critically damped + val coeffA = displacement + val coeffB = velocity + naturalFreq * displacement + val nFdT = -naturalFreq * deltaT + currentDisplacement = (coeffA + coeffB * deltaT) * exp(nFdT) + currentVelocity = + (((coeffA + coeffB * deltaT) * exp(nFdT) * (-naturalFreq)) + coeffB * exp(nFdT)) + } else { + // Underdamped + val dampedFreq = naturalFreq * sqrt(1 - dampingRatioSquared) + val cosCoeff = displacement + val sinCoeff = ((1 / dampedFreq) * (((-r * displacement) + velocity))) + val dFdT = dampedFreq * deltaT + currentDisplacement = (exp(r * deltaT) * ((cosCoeff * cos(dFdT) + sinCoeff * sin(dFdT)))) + currentVelocity = + (currentDisplacement * r + + (exp(r * deltaT) * + ((-dampedFreq * cosCoeff * sin(dFdT) + dampedFreq * sinCoeff * cos(dFdT))))) + } + + return SpringState(currentDisplacement.toFloat(), currentVelocity.toFloat()) +} diff --git a/mechanics/src/com/android/mechanics/view/ViewGestureContext.kt b/mechanics/src/com/android/mechanics/view/ViewGestureContext.kt new file mode 100644 index 0000000..140fe75 --- /dev/null +++ b/mechanics/src/com/android/mechanics/view/ViewGestureContext.kt @@ -0,0 +1,135 @@ +/* + * Copyright (C) 2025 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.view + +import android.content.Context +import android.view.ViewConfiguration +import androidx.compose.ui.util.fastForEach +import com.android.mechanics.spec.InputDirection +import kotlin.math.max +import kotlin.math.min + +fun interface GestureContextUpdateListener { + fun onGestureContextUpdated() +} + +interface ViewGestureContext { + val direction: InputDirection + val dragOffset: Float + + fun addUpdateCallback(listener: GestureContextUpdateListener) + + fun removeUpdateCallback(listener: GestureContextUpdateListener) +} + +/** + * [ViewGestureContext] driven by a gesture distance. + * + * The direction is determined from the gesture input, where going further than + * [directionChangeSlop] in the opposite direction toggles the direction. + * + * @param initialDragOffset The initial [dragOffset] of the [ViewGestureContext] + * @param initialDirection The initial [direction] of the [ViewGestureContext] + * @param directionChangeSlop the amount [dragOffset] must be moved in the opposite direction for + * the [direction] to flip. + */ +class DistanceGestureContext( + initialDragOffset: Float, + initialDirection: InputDirection, + private val directionChangeSlop: Float, +) : ViewGestureContext { + init { + require(directionChangeSlop > 0) { + "directionChangeSlop must be greater than 0, was $directionChangeSlop" + } + } + + companion object { + @JvmStatic + fun create( + context: Context, + initialDragOffset: Float = 0f, + initialDirection: InputDirection = InputDirection.Max, + ): DistanceGestureContext { + val directionChangeSlop = ViewConfiguration.get(context).scaledTouchSlop.toFloat() + return DistanceGestureContext(initialDragOffset, initialDirection, directionChangeSlop) + } + } + + private val callbacks = mutableListOf() + + override var dragOffset: Float = initialDragOffset + set(value) { + if (field == value) return + + field = value + direction = + when (direction) { + InputDirection.Max -> { + if (furthestDragOffset - value > directionChangeSlop) { + furthestDragOffset = value + InputDirection.Min + } else { + furthestDragOffset = max(value, furthestDragOffset) + InputDirection.Max + } + } + + InputDirection.Min -> { + if (value - furthestDragOffset > directionChangeSlop) { + furthestDragOffset = value + InputDirection.Max + } else { + furthestDragOffset = min(value, furthestDragOffset) + InputDirection.Min + } + } + } + invokeCallbacks() + } + + override var direction = initialDirection + private set + + private var furthestDragOffset = initialDragOffset + + /** + * Sets [dragOffset] and [direction] to the specified values. + * + * This also resets memoized [furthestDragOffset], which is used to determine the direction + * change. + */ + fun reset(dragOffset: Float, direction: InputDirection) { + this.dragOffset = dragOffset + this.direction = direction + this.furthestDragOffset = dragOffset + + invokeCallbacks() + } + + override fun addUpdateCallback(listener: GestureContextUpdateListener) { + callbacks.add(listener) + } + + override fun removeUpdateCallback(listener: GestureContextUpdateListener) { + callbacks.remove(listener) + } + + private fun invokeCallbacks() { + callbacks.fastForEach { it.onGestureContextUpdated() } + } +} diff --git a/mechanics/src/com/android/mechanics/view/ViewMotionBuilderContext.kt b/mechanics/src/com/android/mechanics/view/ViewMotionBuilderContext.kt new file mode 100644 index 0000000..5d1a21a --- /dev/null +++ b/mechanics/src/com/android/mechanics/view/ViewMotionBuilderContext.kt @@ -0,0 +1,127 @@ +/* + * Copyright (C) 2025 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.view + +import android.content.Context +import androidx.compose.ui.unit.Density +import com.android.mechanics.spec.builder.MaterialSprings +import com.android.mechanics.spec.builder.MotionBuilderContext +import com.android.mechanics.spring.SpringParameters +import com.android.mechanics.view.ViewMaterialSprings.Default + +/** + * Creates a [MotionBuilderContext] using the **standard** motion spec. + * + * See go/motion-system. + * + * @param context The context to derive the density from. + */ +fun standardViewMotionBuilderContext(context: Context): MotionBuilderContext { + return standardViewMotionBuilderContext(context.resources.displayMetrics.density) +} + +/** + * Creates a [MotionBuilderContext] using the **standard** motion spec. + * + * See go/motion-system. + * + * @param density The density of the display, as a scaling factor for the dp to px conversion. + */ +fun standardViewMotionBuilderContext(density: Float): MotionBuilderContext { + return with(ViewMaterialSprings.Default) { + ViewMotionBuilderContext(Spatial, Effects, Density(density)) + } +} + +/** + * Creates a [MotionBuilderContext] using the **expressive** motion spec. + * + * See go/motion-system. + * + * @param context The context to derive the density from. + */ +fun expressiveViewMotionBuilderContext(context: Context): MotionBuilderContext { + return expressiveViewMotionBuilderContext(context.resources.displayMetrics.density) +} + +/** + * Creates a [MotionBuilderContext] using the **expressive** motion spec. + * + * See go/motion-system. + * + * @param density The density of the display, as a scaling factor for the dp to px conversion. + */ +fun expressiveViewMotionBuilderContext(density: Float): MotionBuilderContext { + return with(ViewMaterialSprings.Expressive) { + ViewMotionBuilderContext(Spatial, Effects, Density(density)) + } +} + +/** + * Material motion system spring definitions. + * + * See go/motion-system. + * + * NOTE: These are only defined here since material spring parameters are not available for View + * based APIs. There might be a delay in updating these values, should the material tokens be + * updated in the future. + * + * @see rememberMotionBuilderContext for Compose + */ +object ViewMaterialSprings { + object Default { + val Spatial = + MaterialSprings( + SpringParameters(700.0f, 0.9f), + SpringParameters(1400.0f, 0.9f), + SpringParameters(300.0f, 0.9f), + MotionBuilderContext.StableThresholdSpatial, + ) + + val Effects = + MaterialSprings( + SpringParameters(1600.0f, 1.0f), + SpringParameters(3800.0f, 1.0f), + SpringParameters(800.0f, 1.0f), + MotionBuilderContext.StableThresholdEffects, + ) + } + + object Expressive { + val Spatial = + MaterialSprings( + SpringParameters(380.0f, 0.8f), + SpringParameters(800.0f, 0.6f), + SpringParameters(200.0f, 0.8f), + MotionBuilderContext.StableThresholdSpatial, + ) + + val Effects = + MaterialSprings( + SpringParameters(1600.0f, 1.0f), + SpringParameters(3800.0f, 1.0f), + SpringParameters(800.0f, 1.0f), + MotionBuilderContext.StableThresholdEffects, + ) + } +} + +internal class ViewMotionBuilderContext( + override val spatial: MaterialSprings, + override val effects: MaterialSprings, + density: Density, +) : MotionBuilderContext, Density by density diff --git a/mechanics/src/com/android/mechanics/view/ViewMotionValue.kt b/mechanics/src/com/android/mechanics/view/ViewMotionValue.kt new file mode 100644 index 0000000..617e363 --- /dev/null +++ b/mechanics/src/com/android/mechanics/view/ViewMotionValue.kt @@ -0,0 +1,342 @@ +/* + * Copyright (C) 2025 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.view + +import android.animation.ValueAnimator +import androidx.compose.ui.util.fastForEach +import com.android.mechanics.MotionValue.Companion.StableThresholdEffect +import com.android.mechanics.debug.DebugInspector +import com.android.mechanics.debug.FrameData +import com.android.mechanics.impl.Computations +import com.android.mechanics.impl.DiscontinuityAnimation +import com.android.mechanics.impl.GuaranteeState +import com.android.mechanics.spec.InputDirection +import com.android.mechanics.spec.MotionSpec +import com.android.mechanics.spec.SegmentData +import com.android.mechanics.spec.SegmentKey +import com.android.mechanics.spec.SemanticKey +import com.android.mechanics.spring.SpringState +import java.util.concurrent.atomic.AtomicInteger +import kotlinx.coroutines.DisposableHandle + +/** Observe MotionValue output changes. */ +fun interface ViewMotionValueListener { + /** Invoked whenever the ViewMotionValue computed a new output. */ + fun onMotionValueUpdated(motionValue: ViewMotionValue) +} + +/** + * [MotionValue] implementation for View-based UIs. + * + * See the documentation of [MotionValue]. + */ +class ViewMotionValue +@JvmOverloads +constructor( + initialInput: Float, + gestureContext: ViewGestureContext, + initialSpec: MotionSpec = MotionSpec.Empty, + label: String? = null, + stableThreshold: Float = StableThresholdEffect, +) : DisposableHandle { + + private val impl = + ImperativeComputations( + this, + initialInput, + gestureContext, + initialSpec, + stableThreshold, + label, + ) + + var input: Float by impl::currentInput + + var spec: MotionSpec by impl::spec + + /** Animated [output] value. */ + val output: Float by impl::output + + /** + * [output] value, but without animations. + * + * This value always reports the target value, even before a animation is finished. + * + * While [isStable], [outputTarget] and [output] are the same value. + */ + val outputTarget: Float by impl::outputTarget + + /** Whether an animation is currently running. */ + val isStable: Boolean by impl::isStable + + /** + * The current value for the [SemanticKey]. + * + * `null` if not defined in the spec. + */ + operator fun get(key: SemanticKey): T? { + return impl.semanticState(key) + } + + /** The current segment used to compute the output. */ + val segmentKey: SegmentKey + get() = impl.currentComputedValues.segment.key + + val label: String? by impl::label + + fun addUpdateCallback(listener: ViewMotionValueListener) { + check(impl.isActive) + impl.listeners.add(listener) + } + + fun removeUpdateCallback(listener: ViewMotionValueListener) { + impl.listeners.remove(listener) + } + + override fun dispose() { + impl.dispose() + } + + companion object { + internal const val TAG = "ViewMotionValue" + } + + private var debugInspectorRefCount = AtomicInteger(0) + + private fun onDisposeDebugInspector() { + if (debugInspectorRefCount.decrementAndGet() == 0) { + impl.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) { + impl.debugInspector = + DebugInspector( + FrameData( + impl.lastInput, + impl.lastSegment.direction, + impl.lastGestureDragOffset, + impl.lastFrameTimeNanos, + impl.lastSpringState, + impl.lastSegment, + impl.lastAnimation, + ), + impl.isActive, + impl.animationFrameDriver.isRunning, + ::onDisposeDebugInspector, + ) + } + + return checkNotNull(impl.debugInspector) + } +} + +private class ImperativeComputations( + private val motionValue: ViewMotionValue, + initialInput: Float, + val gestureContext: ViewGestureContext, + initialSpec: MotionSpec, + override val stableThreshold: Float, + override val label: String?, +) : Computations(), GestureContextUpdateListener { + + init { + gestureContext.addUpdateCallback(this) + } + + override fun onGestureContextUpdated() { + ensureFrameRequested() + } + + // ---- CurrentFrameInput --------------------------------------------------------------------- + + override var spec: MotionSpec = initialSpec + set(value) { + if (field != value) { + field = value + ensureFrameRequested() + } + } + + override var currentInput: Float = initialInput + set(value) { + if (field != value) { + field = value + ensureFrameRequested() + } + } + + override val currentDirection: InputDirection + get() = gestureContext.direction + + override val currentGestureDragOffset: Float + get() = gestureContext.dragOffset + + override var currentAnimationTimeNanos: Long = -1L + + // ---- LastFrameState --------------------------------------------------------------------- + + override var lastSegment: SegmentData = spec.segmentAtInput(currentInput, currentDirection) + override var lastGuaranteeState: GuaranteeState = GuaranteeState.Inactive + override var lastAnimation: DiscontinuityAnimation = DiscontinuityAnimation.None + override var lastSpringState: SpringState = lastAnimation.springStartState + override var lastFrameTimeNanos: Long = -1L + override var lastInput: Float = currentInput + override var lastGestureDragOffset: Float = currentGestureDragOffset + override var directMappedVelocity: Float = 0f + var lastDirection: InputDirection = currentDirection + + // ---- Lifecycle ------------------------------------------------------------------------------ + + // HACK: Use a ValueAnimator to listen to animation frames without using Choreographer directly. + // This is done solely for testability - because the AnimationHandler is not usable directly[1], + // this resumes/pauses a - for all practical purposes - infinite animation. + // + // [1] the android one is hidden API, the androidx one is package private, and the + // dynamicanimation one is not controllable from tests). + val animationFrameDriver = + ValueAnimator().apply { + setFloatValues(Float.MIN_VALUE, Float.MAX_VALUE) + duration = Long.MAX_VALUE + repeatMode = ValueAnimator.RESTART + repeatCount = ValueAnimator.INFINITE + start() + pause() + addUpdateListener { + val isAnimationFinished = updateOutputValue(currentPlayTime) + if (isAnimationFinished) { + pause() + } + } + } + + fun ensureFrameRequested() { + if (animationFrameDriver.isPaused) { + animationFrameDriver.resume() + debugInspector?.isAnimating = true + } + } + + fun pauseFrameRequests() { + if (animationFrameDriver.isRunning) { + animationFrameDriver.pause() + debugInspector?.isAnimating = false + } + } + + /** `true` until disposed with [MotionValue.dispose]. */ + var isActive = true + set(value) { + field = value + debugInspector?.isActive = value + } + + var debugInspector: DebugInspector? = null + + val listeners = mutableListOf() + + fun dispose() { + check(isActive) { "ViewMotionValue[$label] is already disposed" } + pauseFrameRequests() + animationFrameDriver.end() + isActive = false + listeners.clear() + } + + // indicates whether doAnimationFrame is called continuously (as opposed to being + // suspended for an undetermined amount of time in between frames). + var isAnimatingUninterrupted = false + + fun updateOutputValue(frameTimeMillis: Long): Boolean { + check(isActive) { "ViewMotionValue($label) is already disposed." } + + currentAnimationTimeNanos = frameTimeMillis * 1_000_000L + + // Read currentComputedValues only once and update it, if necessary + val currentValues = currentComputedValues + + debugInspector?.run { + frame = + FrameData( + currentInput, + currentDirection, + currentGestureDragOffset, + currentAnimationTimeNanos, + currentSpringState, + currentValues.segment, + currentValues.animation, + ) + } + + listeners.fastForEach { it.onMotionValueUpdated(motionValue) } + + // Prepare last* state + if (isAnimatingUninterrupted) { + directMappedVelocity = + computeDirectMappedVelocity(currentAnimationTimeNanos - lastFrameTimeNanos) + } else { + directMappedVelocity = 0f + } + + var isAnimationFinished = isStable + if (lastSegment != currentValues.segment) { + lastSegment = currentValues.segment + isAnimationFinished = false + } + + if (lastGuaranteeState != currentValues.guarantee) { + lastGuaranteeState = currentValues.guarantee + isAnimationFinished = false + } + + if (lastAnimation != currentValues.animation) { + lastAnimation = currentValues.animation + isAnimationFinished = false + } + + if (lastSpringState != currentSpringState) { + lastSpringState = currentSpringState + isAnimationFinished = false + } + + if (lastInput != currentInput) { + lastInput = currentInput + isAnimationFinished = false + } + + if (lastGestureDragOffset != currentGestureDragOffset) { + lastGestureDragOffset = currentGestureDragOffset + isAnimationFinished = false + } + + if (lastDirection != currentDirection) { + lastDirection = currentDirection + isAnimationFinished = false + } + + lastFrameTimeNanos = currentAnimationTimeNanos + isAnimatingUninterrupted = !isAnimationFinished + + return isAnimationFinished + } +} diff --git a/mechanics/testing/Android.bp b/mechanics/testing/Android.bp new file mode 100644 index 0000000..ac49d16 --- /dev/null +++ b/mechanics/testing/Android.bp @@ -0,0 +1,37 @@ +// Copyright (C) 2025 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 { + default_applicable_licenses: ["Android-Apache-2.0"], + default_team: "trendy_team_motion", +} + +android_library { + name: "mechanics-testing", + manifest: "AndroidManifest.xml", + srcs: [ + "src/**/*.kt", + ], + static_libs: [ + "//frameworks/libs/systemui/mechanics:mechanics", + "platform-test-annotations", + "PlatformMotionTestingCompose", + "androidx.compose.runtime_runtime", + "androidx.compose.ui_ui-test-junit4", + "testables", + "truth", + ], + kotlincflags: ["-Xjvm-default=all"], +} diff --git a/mechanics/testing/AndroidManifest.xml b/mechanics/testing/AndroidManifest.xml new file mode 100644 index 0000000..20c40b0 --- /dev/null +++ b/mechanics/testing/AndroidManifest.xml @@ -0,0 +1,20 @@ + + + + + diff --git a/mechanics/testing/src/com/android/mechanics/testing/ComposeMotionValueToolkit.kt b/mechanics/testing/src/com/android/mechanics/testing/ComposeMotionValueToolkit.kt new file mode 100644 index 0000000..0144a16 --- /dev/null +++ b/mechanics/testing/src/com/android/mechanics/testing/ComposeMotionValueToolkit.kt @@ -0,0 +1,176 @@ +/* + * 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. + */ + +@file:OptIn(ExperimentalCoroutinesApi::class) + +package com.android.mechanics.testing + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshots.Snapshot +import com.android.mechanics.DistanceGestureContext +import com.android.mechanics.MotionValue +import com.android.mechanics.spec.InputDirection +import com.android.mechanics.spec.MotionSpec +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.drop +import kotlinx.coroutines.flow.take +import kotlinx.coroutines.flow.takeWhile +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import platform.test.motion.MotionTestRule +import platform.test.motion.compose.runMonotonicClockTest +import platform.test.motion.golden.FrameId +import platform.test.motion.golden.TimeSeries +import platform.test.motion.golden.TimestampFrameId + +/** Toolkit to support [MotionValue] motion tests. */ +data object ComposeMotionValueToolkit : MotionValueToolkit() { + + override fun goldenTest( + motionTestRule: MotionTestRule<*>, + spec: MotionSpec, + createDerived: (underTest: MotionValue) -> List, + initialValue: Float, + initialDirection: InputDirection, + directionChangeSlop: Float, + stableThreshold: Float, + verifyTimeSeries: TimeSeries.() -> VerifyTimeSeriesResult, + capture: CaptureTimeSeriesFn, + testInput: suspend InputScope.() -> Unit, + ) = runMonotonicClockTest { + val frameEmitter = MutableStateFlow(0) + + val testHarness = + ComposeMotionValueTestHarness( + initialValue, + initialDirection, + spec, + stableThreshold, + directionChangeSlop, + frameEmitter.asStateFlow(), + createDerived, + ) + val underTest = testHarness.underTest + val derived = testHarness.derived + + val motionValueCaptures = buildList { + add(MotionValueCapture(underTest.debugInspector())) + derived.forEach { add(MotionValueCapture(it.debugInspector(), "${it.label}-")) } + } + + val keepRunningJobs = (derived + underTest).map { launch { it.keepRunning() } } + + val recordingJob = launch { testInput.invoke(testHarness) } + + val frameIds = mutableListOf() + + fun recordFrame(frameId: TimestampFrameId) { + frameIds.add(frameId) + motionValueCaptures.forEach { it.captureCurrentFrame(capture) } + } + runBlocking(Dispatchers.Main) { + val startFrameTime = testScheduler.currentTime + while (!recordingJob.isCompleted) { + recordFrame(TimestampFrameId(testScheduler.currentTime - startFrameTime)) + + // Emulate setting input *before* the frame advances. This ensures the `testInput` + // coroutine will continue if needed. The specific value for frameEmitter is + // irrelevant, it only requires to be unique per frame. + frameEmitter.tryEmit(testScheduler.currentTime) + testScheduler.runCurrent() + // Whenever keepRunning was suspended, allow the snapshotFlow to wake up + Snapshot.sendApplyNotifications() + + // Now advance the test clock + testScheduler.advanceTimeBy(FrameDuration) + // Since the tests capture the debugInspector output, make sure keepRunning() + // was able to complete the frame. + testScheduler.runCurrent() + } + } + + val timeSeries = createTimeSeries(frameIds, motionValueCaptures) + motionValueCaptures.forEach { it.debugger.dispose() } + keepRunningJobs.forEach { it.cancel() } + verifyTimeSeries(motionTestRule, timeSeries, verifyTimeSeries) + } +} + +private class ComposeMotionValueTestHarness( + initialInput: Float, + initialDirection: InputDirection, + spec: MotionSpec, + stableThreshold: Float, + directionChangeSlop: Float, + val onFrame: StateFlow, + createDerived: (underTest: MotionValue) -> List, +) : InputScope { + + override var input by mutableFloatStateOf(initialInput) + override val gestureContext: DistanceGestureContext = + DistanceGestureContext(initialInput, initialDirection, directionChangeSlop) + + override val underTest = + MotionValue( + { input }, + gestureContext, + stableThreshold = stableThreshold, + initialSpec = spec, + ) + + val derived = createDerived(underTest) + + override fun updateInput(value: Float) { + input = value + gestureContext.dragOffset = value + } + + override suspend fun awaitStable() { + val debugInspectors = buildList { + add(underTest.debugInspector()) + addAll(derived.map { it.debugInspector() }) + } + try { + + onFrame + // Since this is a state-flow, the current frame is counted too. + .drop(1) + .takeWhile { debugInspectors.any { !it.frame.isStable } } + .collect {} + } finally { + debugInspectors.forEach { it.dispose() } + } + } + + override suspend fun awaitFrames(frames: Int) { + onFrame + // Since this is a state-flow, the current frame is counted too. + .drop(1) + .take(frames) + .collect {} + } + + override fun reset(position: Float, direction: InputDirection) { + input = position + gestureContext.reset(position, direction) + } +} diff --git a/mechanics/testing/src/com/android/mechanics/testing/DataPointTypes.kt b/mechanics/testing/src/com/android/mechanics/testing/DataPointTypes.kt new file mode 100644 index 0000000..013a0dd --- /dev/null +++ b/mechanics/testing/src/com/android/mechanics/testing/DataPointTypes.kt @@ -0,0 +1,69 @@ +/* + * 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.testing + +import com.android.mechanics.spring.SpringParameters +import com.android.mechanics.spring.SpringState +import com.android.mechanics.testing.DataPointTypes.springParameters +import com.android.mechanics.testing.DataPointTypes.springState +import org.json.JSONObject +import platform.test.motion.golden.DataPointType +import platform.test.motion.golden.UnknownTypeException + +fun SpringParameters.asDataPoint() = springParameters.makeDataPoint(this) + +fun SpringState.asDataPoint() = springState.makeDataPoint(this) + +object DataPointTypes { + val springParameters: DataPointType = + DataPointType( + "springParameters", + jsonToValue = { + with(it as? JSONObject ?: throw UnknownTypeException()) { + SpringParameters( + getDouble("stiffness").toFloat(), + getDouble("dampingRatio").toFloat(), + ) + } + }, + valueToJson = { + JSONObject().apply { + put("stiffness", it.stiffness) + put("dampingRatio", it.dampingRatio) + } + }, + ) + + val springState: DataPointType = + DataPointType( + "springState", + jsonToValue = { + with(it as? JSONObject ?: throw UnknownTypeException()) { + SpringState( + getDouble("displacement").toFloat(), + getDouble("velocity").toFloat(), + ) + } + }, + valueToJson = { + JSONObject().apply { + put("displacement", it.displacement) + put("velocity", it.velocity) + } + }, + ) +} diff --git a/mechanics/testing/src/com/android/mechanics/testing/FakeMotionSpecBuilderContext.kt b/mechanics/testing/src/com/android/mechanics/testing/FakeMotionSpecBuilderContext.kt new file mode 100644 index 0000000..93855f4 --- /dev/null +++ b/mechanics/testing/src/com/android/mechanics/testing/FakeMotionSpecBuilderContext.kt @@ -0,0 +1,54 @@ +/* + * Copyright (C) 2025 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.testing + +import androidx.compose.ui.unit.Density +import com.android.mechanics.spec.builder.MaterialSprings +import com.android.mechanics.spec.builder.MotionBuilderContext +import com.android.mechanics.spring.SpringParameters + +/** + * [MotionBuilderContext] implementation for unit tests. + * + * Only use when the specifics of the spring parameters do not matter for the test. + * + * While the values are copied from the current material motion tokens, this can (and likely will) + * get out of sync with the material tokens, and is not intended reflect the up-to-date tokens, but + * provide a stable definitions of "some" spring parameters. + */ +class FakeMotionSpecBuilderContext(density: Float = 1f) : + MotionBuilderContext, Density by Density(density) { + override val spatial = + MaterialSprings( + SpringParameters(700.0f, 0.9f), + SpringParameters(1400.0f, 0.9f), + SpringParameters(300.0f, 0.9f), + MotionBuilderContext.StableThresholdSpatial, + ) + + override val effects = + MaterialSprings( + SpringParameters(1600.0f, 1.0f), + SpringParameters(3800.0f, 1.0f), + SpringParameters(800.0f, 1.0f), + MotionBuilderContext.StableThresholdEffects, + ) + + companion object { + val Default = FakeMotionSpecBuilderContext() + } +} diff --git a/mechanics/testing/src/com/android/mechanics/testing/FeatureCaptures.kt b/mechanics/testing/src/com/android/mechanics/testing/FeatureCaptures.kt new file mode 100644 index 0000000..d8ef1cf --- /dev/null +++ b/mechanics/testing/src/com/android/mechanics/testing/FeatureCaptures.kt @@ -0,0 +1,71 @@ +/* + * Copyright (C) 2025 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.testing + +import com.android.mechanics.debug.DebugInspector +import com.android.mechanics.spec.SemanticKey +import com.android.mechanics.spring.SpringParameters +import com.android.mechanics.spring.SpringState +import platform.test.motion.golden.DataPointType +import platform.test.motion.golden.FeatureCapture +import platform.test.motion.golden.asDataPoint + +/** Feature captures on MotionValue's [DebugInspector] */ +object FeatureCaptures { + /** Input value of the current frame. */ + val input = FeatureCapture("input") { it.frame.input.asDataPoint() } + + /** Gesture direction of the current frame. */ + val gestureDirection = + FeatureCapture("gestureDirection") { + it.frame.gestureDirection.name.asDataPoint() + } + + /** Animated output value of the current frame. */ + val output = FeatureCapture("output") { it.frame.output.asDataPoint() } + + /** Output target value of the current frame. */ + val outputTarget = + FeatureCapture("outputTarget") { + it.frame.outputTarget.asDataPoint() + } + + /** Spring parameters currently in use. */ + val springParameters = + FeatureCapture("springParameters") { + it.frame.springParameters.asDataPoint() + } + + /** Spring state currently in use. */ + val springState = + FeatureCapture("springState") { + it.frame.springState.asDataPoint() + } + + /** Whether the spring is currently stable. */ + val isStable = + FeatureCapture("isStable") { it.frame.isStable.asDataPoint() } + + /** A semantic value to capture in the golden. */ + fun semantics( + key: SemanticKey, + dataPointType: DataPointType, + name: String = key.debugLabel, + ): FeatureCapture { + return FeatureCapture(name) { dataPointType.makeDataPoint(it.frame.semantic(key)) } + } +} diff --git a/mechanics/testing/src/com/android/mechanics/testing/MotionSpecSubject.kt b/mechanics/testing/src/com/android/mechanics/testing/MotionSpecSubject.kt new file mode 100644 index 0000000..9816d01 --- /dev/null +++ b/mechanics/testing/src/com/android/mechanics/testing/MotionSpecSubject.kt @@ -0,0 +1,308 @@ +/* + * Copyright (C) 2025 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.testing + +import com.android.mechanics.spec.Breakpoint +import com.android.mechanics.spec.BreakpointKey +import com.android.mechanics.spec.DirectionalMotionSpec +import com.android.mechanics.spec.Mapping +import com.android.mechanics.spec.MotionSpec +import com.android.mechanics.spec.SemanticKey +import com.android.mechanics.testing.BreakpointSubject.Companion.BreakpointKeys +import com.android.mechanics.testing.BreakpointSubject.Companion.BreakpointPositions +import com.google.common.truth.Correspondence.transforming +import com.google.common.truth.FailureMetadata +import com.google.common.truth.FloatSubject +import com.google.common.truth.IterableSubject +import com.google.common.truth.Subject +import com.google.common.truth.Subject.Factory +import com.google.common.truth.Truth + +/** Subject to verify the definition of a [MotionSpec]. */ +class MotionSpecSubject +internal constructor(failureMetadata: FailureMetadata, private val actual: MotionSpec?) : + Subject(failureMetadata, actual) { + + fun minDirection(): DirectionalMotionSpecSubject { + isNotNull() + + return check("min") + .about(DirectionalMotionSpecSubject.SubjectFactory) + .that(actual?.minDirection) + } + + fun maxDirection(): DirectionalMotionSpecSubject { + isNotNull() + + return check("max") + .about(DirectionalMotionSpecSubject.SubjectFactory) + .that(actual?.maxDirection) + } + + fun bothDirections(): DirectionalMotionSpecSubject { + isNotNull() + check("both directions same").that(actual?.minDirection).isEqualTo(actual?.maxDirection) + return check("both") + .about(DirectionalMotionSpecSubject.SubjectFactory) + .that(actual?.maxDirection) + } + + companion object { + + /** Returns a factory to be used with [Truth.assertAbout]. */ + val SubjectFactory = Factory { failureMetadata: FailureMetadata, subject: MotionSpec? -> + MotionSpecSubject(failureMetadata, subject) + } + + /** Shortcut for `Truth.assertAbout(motionSpec()).that(spec)`. */ + fun assertThat(spec: MotionSpec): MotionSpecSubject = + Truth.assertAbout(SubjectFactory).that(spec) + } +} + +/** Subject to verify the definition of a [DirectionalMotionSpec]. */ +class DirectionalMotionSpecSubject +internal constructor(failureMetadata: FailureMetadata, private val actual: DirectionalMotionSpec?) : + Subject(failureMetadata, actual) { + + /** Assert on breakpoints, excluding the implicit start and end breakpoints. */ + fun breakpoints(): BreakpointsSubject { + isNotNull() + + return check("breakpoints").about(BreakpointsSubject.SubjectFactory).that(actual) + } + + fun breakpointsPositionsMatch(vararg positions: Float) { + isNotNull() + + return check("breakpoints") + .about(BreakpointsSubject.SubjectFactory) + .that(actual) + .positions() + .containsExactlyElementsIn(positions.toTypedArray()) + .inOrder() + } + + /** Assert on the mappings. */ + fun mappings(): MappingsSubject { + isNotNull() + + return check("mappings").about(MappingsSubject.SubjectFactory).that(actual) + } + + /** Assert that the mappings contain exactly the specified mappings, in order . */ + fun mappingsMatch(vararg mappings: Mapping) { + isNotNull() + + check("mappings") + .about(MappingsSubject.SubjectFactory) + .that(actual) + .containsExactlyElementsIn(mappings) + .inOrder() + } + + /** Assert that the mappings contain exactly the specified [Fixed] mappings, in order . */ + fun fixedMappingsMatch(vararg fixedMappingValues: Float) { + isNotNull() + + check("fixed mappings") + .about(MappingsSubject.SubjectFactory) + .that(actual) + .comparingElementsUsing( + transforming({ (it as? Mapping.Fixed)?.value }, "Fixed.value") + ) + .containsExactlyElementsIn(fixedMappingValues.toTypedArray()) + .inOrder() + } + + /** Assert on the semantics. */ + fun semantics(): SemanticsSubject { + isNotNull() + + return check("semantics").about(SemanticsSubject.SubjectFactory).that(actual) + } + + companion object { + + /** Returns a factory to be used with [Truth.assertAbout]. */ + val SubjectFactory = + Factory { failureMetadata: FailureMetadata, subject: DirectionalMotionSpec? -> + DirectionalMotionSpecSubject(failureMetadata, subject) + } + + /** Shortcut for `Truth.assertAbout(directionalMotionSpec()).that(spec)`. */ + fun assertThat(spec: DirectionalMotionSpec): DirectionalMotionSpecSubject = + Truth.assertAbout(SubjectFactory).that(spec) + } +} + +/** Subject to assert on the list of breakpoints of a [DirectionalMotionSpec]. */ +class BreakpointsSubject( + failureMetadata: FailureMetadata, + private val actual: DirectionalMotionSpec?, +) : IterableSubject(failureMetadata, actual?.breakpoints?.let { it.slice(1 until it.size - 1) }) { + + fun keys() = comparingElementsUsing(BreakpointKeys) + + fun positions() = comparingElementsUsing(BreakpointPositions) + + fun atPosition(position: Float): BreakpointSubject { + return check("breakpoint @ $position") + .about(BreakpointSubject.SubjectFactory) + .that(actual?.breakpoints?.find { it.position == position }) + } + + fun withKey(key: BreakpointKey): BreakpointSubject { + return check("breakpoint with $key]") + .about(BreakpointSubject.SubjectFactory) + .that(actual?.breakpoints?.find { it.key == key }) + } + + companion object { + + /** Returns a factory to be used with [Truth.assertAbout]. */ + val SubjectFactory = + Factory { failureMetadata, subject -> + BreakpointsSubject(failureMetadata, subject) + } + } +} + +/** Subject to assert on a [Breakpoint] definition. */ +class BreakpointSubject +internal constructor(failureMetadata: FailureMetadata, private val actual: Breakpoint?) : + Subject(failureMetadata, actual) { + + fun exists() { + isNotNull() + } + + fun key(): Subject { + return check("key").that(actual?.key) + } + + fun position(): FloatSubject { + return check("position").that(actual?.position) + } + + fun guarantee(): Subject { + return check("guarantee").that(actual?.guarantee) + } + + fun spring(): Subject { + return check("spring").that(actual?.spring) + } + + fun isAt(position: Float) = position().isEqualTo(position) + + fun hasKey(key: BreakpointKey) = key().isEqualTo(key) + + companion object { + val BreakpointKeys = transforming({ it?.key }, "key") + val BreakpointPositions = transforming({ it?.position }, "position") + + /** Returns a factory to be used with [Truth.assertAbout]. */ + val SubjectFactory = + Factory { failureMetadata, subject -> + BreakpointSubject(failureMetadata, subject) + } + + /** Shortcut for `Truth.assertAbout(subjectFactory).that(breakpoint)`. */ + fun assertThat(breakpoint: Breakpoint): BreakpointSubject = + Truth.assertAbout(SubjectFactory).that(breakpoint) + } +} + +/** Subject to assert on the list of mappings of a [DirectionalMotionSpec]. */ +class MappingsSubject( + failureMetadata: FailureMetadata, + private val actual: DirectionalMotionSpec?, +) : IterableSubject(failureMetadata, actual?.mappings) { + + /** Assert on the mapping at or after the specified position. */ + fun atOrAfter(position: Float): MappingSubject { + return check("mapping @ $position") + .about(MappingSubject.SubjectFactory) + .that(actual?.run { mappings[findBreakpointIndex(position)] }) + } + + companion object { + /** Returns a factory to be used with [Truth.assertAbout]. */ + val SubjectFactory = + Factory { failureMetadata, subject -> + MappingsSubject(failureMetadata, subject) + } + } +} + +/** Subject to assert on a [Mapping] function. */ +class MappingSubject +internal constructor(failureMetadata: FailureMetadata, private val actual: Mapping?) : + Subject(failureMetadata, actual) { + + fun matchesLinearMapping(in1: Float, out1: Float, in2: Float, out2: Float) { + isNotNull() + + check("input @ $in1").that(actual?.map(in1)).isEqualTo(out1) + check("input @ $in2").that(actual?.map(in2)).isEqualTo(out2) + } + + fun isFixedValue(value: Float) { + when (actual) { + is Mapping.Fixed -> check("fixed value").that(actual.value).isEqualTo(value) + is Mapping.Linear -> { + check("linear factor").that(actual.factor).isZero() + check("linear offset").that(actual.offset).isEqualTo(value) + } + + else -> failWithActual("Unexpected mapping type", actual) + } + } + + companion object { + /** Returns a factory to be used with [Truth.assertAbout]. */ + val SubjectFactory = + Factory { failureMetadata, subject -> + MappingSubject(failureMetadata, subject) + } + + /** Shortcut for `Truth.assertAbout(subjectFactory).that(mapping)`. */ + fun assertThat(mapping: Mapping): MappingSubject = + Truth.assertAbout(SubjectFactory).that(mapping) + } +} + +/** Subject to assert on the list of semantic values of a [DirectionalMotionSpec]. */ +class SemanticsSubject( + failureMetadata: FailureMetadata, + private val actual: DirectionalMotionSpec?, +) : IterableSubject(failureMetadata, actual?.semantics?.map { it.key }) { + + /** Assert on the semantic values of the. */ + fun withKey(key: SemanticKey<*>): IterableSubject { + return check("semantic $key") + .that(actual?.run { semantics.firstOrNull { it.key == key }?.values }) + } + + companion object { + /** Returns a factory to be used with [Truth.assertAbout]. */ + val SubjectFactory = + Factory { failureMetadata, subject -> + SemanticsSubject(failureMetadata, subject) + } + } +} diff --git a/mechanics/testing/src/com/android/mechanics/testing/MotionValueToolkit.kt b/mechanics/testing/src/com/android/mechanics/testing/MotionValueToolkit.kt new file mode 100644 index 0000000..a96ca99 --- /dev/null +++ b/mechanics/testing/src/com/android/mechanics/testing/MotionValueToolkit.kt @@ -0,0 +1,230 @@ +/* + * Copyright (C) 2025 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.testing + +import com.android.mechanics.MotionValue +import com.android.mechanics.debug.DebugInspector +import com.android.mechanics.spec.InputDirection +import com.android.mechanics.spec.MotionSpec +import kotlin.math.abs +import kotlin.math.floor +import kotlin.math.sign +import kotlin.time.Duration.Companion.milliseconds +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 +import platform.test.motion.golden.TimeSeriesCaptureScope + +/** + * Records and verifies a timeseries of the [MotionValue]'s output. + * + * Tests provide at a minimum the initial [spec], and a [testInput] function, which defines the + * [MotionValue] input over time. + * + * @param spec The initial [MotionSpec] + * @param initialValue The initial value of the [MotionValue] + * @param initialDirection The initial [InputDirection] of the [MotionValue] + * @param directionChangeSlop the minimum distance for the input to change in the opposite direction + * before the underlying GestureContext changes direction. + * @param stableThreshold The maximum remaining oscillation amplitude for the springs to be + * considered stable. + * @param verifyTimeSeries Custom verification function to write assertions on the captured time + * series. If the function returns `SkipGoldenVerification`, the timeseries won`t be compared to a + * golden. + * @param createDerived (experimental) Creates derived MotionValues + * @param capture The features to capture on each motion value. See [defaultFeatureCaptures] for + * defaults. + * @param testInput Controls the MotionValue during the test. The timeseries is being recorded until + * the function completes. + * @see ComposeMotionValueToolkit + * @see ViewMotionValueToolkit + */ +fun < + T : MotionValueToolkit, + MotionValueType, + GestureContextType, +> MotionTestRule.goldenTest( + spec: MotionSpec, + initialValue: Float = 0f, + initialDirection: InputDirection = InputDirection.Max, + directionChangeSlop: Float = 5f, + stableThreshold: Float = 0.01f, + verifyTimeSeries: VerifyTimeSeriesFn = { + VerifyTimeSeriesResult.AssertTimeSeriesMatchesGolden() + }, + createDerived: (underTest: MotionValueType) -> List = { emptyList() }, + capture: CaptureTimeSeriesFn = defaultFeatureCaptures, + testInput: suspend (InputScope).() -> Unit, +) { + toolkit.goldenTest( + this, + spec, + createDerived, + initialValue, + initialDirection, + directionChangeSlop, + stableThreshold, + verifyTimeSeries, + capture, + testInput, + ) +} + +/** Scope to control the MotionValue during a test. */ +interface InputScope { + /** Current input of the `MotionValue` */ + val input: Float + /** GestureContext created for the `MotionValue` */ + val gestureContext: GestureContextType + /** MotionValue being tested. */ + val underTest: MotionValueType + + /** Updates the input value *and* the `gestureContext.dragOffset`. */ + fun updateInput(value: Float) + + /** Resets the input value *and* the `gestureContext.dragOffset`, inclusive of direction. */ + fun reset(position: Float, direction: InputDirection) + + /** Waits for `underTest` and derived `MotionValues` to become stable. */ + suspend fun awaitStable() + + /** Waits for the next "frame" (16ms). */ + suspend fun awaitFrames(frames: Int = 1) +} + +/** Animates the input linearly from the current [input] to the [targetValue]. */ +suspend fun InputScope<*, *>.animateValueTo( + targetValue: Float, + changePerFrame: Float = abs(input - targetValue) / 5f, +) { + require(changePerFrame > 0f) + var currentValue = input + val delta = targetValue - currentValue + val step = changePerFrame * delta.sign + + val stepCount = floor((abs(delta) / changePerFrame) - 1).toInt() + repeat(stepCount) { + currentValue += step + updateInput(currentValue) + awaitFrames() + } + + updateInput(targetValue) + awaitFrames() +} + +/** Sets the input to the [values], one value per frame. */ +suspend fun InputScope<*, *>.animatedInputSequence(vararg values: Float) { + values.forEach { + updateInput(it) + awaitFrames() + } +} + +/** Custom functions to write assertions on the recorded [TimeSeries] */ +typealias VerifyTimeSeriesFn = TimeSeries.() -> VerifyTimeSeriesResult + +/** [VerifyTimeSeriesFn] indicating whether the timeseries should be verified the golden file. */ +interface VerifyTimeSeriesResult { + data object SkipGoldenVerification : VerifyTimeSeriesResult + + data class AssertTimeSeriesMatchesGolden(val goldenName: String? = null) : + VerifyTimeSeriesResult +} + +typealias CaptureTimeSeriesFn = TimeSeriesCaptureScope.() -> Unit + +/** Default feature captures. */ +val defaultFeatureCaptures: CaptureTimeSeriesFn = { + feature(FeatureCaptures.input) + feature(FeatureCaptures.gestureDirection) + feature(FeatureCaptures.output) + feature(FeatureCaptures.outputTarget) + feature(FeatureCaptures.springParameters, name = "outputSpring") + feature(FeatureCaptures.isStable) +} + +sealed class MotionValueToolkit { + internal abstract fun goldenTest( + motionTestRule: MotionTestRule<*>, + spec: MotionSpec, + createDerived: (underTest: MotionValueType) -> List, + initialValue: Float, + initialDirection: InputDirection, + directionChangeSlop: Float, + stableThreshold: Float, + verifyTimeSeries: TimeSeries.() -> VerifyTimeSeriesResult, + capture: CaptureTimeSeriesFn, + testInput: suspend (InputScope).() -> Unit, + ) + + internal fun createTimeSeries( + frameIds: List, + motionValueCaptures: List, + ): TimeSeries { + return TimeSeries( + frameIds.toList(), + motionValueCaptures.flatMap { motionValueCapture -> + motionValueCapture.propertyCollector.entries.map { (name, dataPoints) -> + Feature("${motionValueCapture.prefix}$name", dataPoints) + } + }, + ) + } + + internal fun verifyTimeSeries( + motionTestRule: MotionTestRule<*>, + timeSeries: TimeSeries, + verificationFn: TimeSeries.() -> VerifyTimeSeriesResult, + ) { + val recordedMotion = motionTestRule.create(timeSeries, screenshots = null) + var assertTimeseriesMatchesGolden = false + var goldenName: String? = null + try { + + val result = verificationFn.invoke(recordedMotion.timeSeries) + if (result is VerifyTimeSeriesResult.AssertTimeSeriesMatchesGolden) { + assertTimeseriesMatchesGolden = true + goldenName = result.goldenName + } + } finally { + try { + motionTestRule.assertThat(recordedMotion).timeSeriesMatchesGolden(goldenName) + } catch (e: AssertionError) { + if (assertTimeseriesMatchesGolden) { + throw e + } + } + } + } + + companion object { + val FrameDuration = 16.milliseconds + } +} + +internal class MotionValueCapture(val debugger: DebugInspector, val prefix: String = "") { + val propertyCollector = mutableMapOf>>() + val captureScope = TimeSeriesCaptureScope(debugger, propertyCollector) + + fun captureCurrentFrame(captureFn: CaptureTimeSeriesFn) { + captureFn(captureScope) + } +} diff --git a/mechanics/testing/src/com/android/mechanics/testing/TimeSeries.kt b/mechanics/testing/src/com/android/mechanics/testing/TimeSeries.kt new file mode 100644 index 0000000..d72d6d1 --- /dev/null +++ b/mechanics/testing/src/com/android/mechanics/testing/TimeSeries.kt @@ -0,0 +1,55 @@ +/* + * Copyright (C) 2025 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.testing + +import platform.test.motion.golden.Feature +import platform.test.motion.golden.TimeSeries +import platform.test.motion.golden.ValueDataPoint + +val TimeSeries.input: List + get() = dataPoints("input") + +val TimeSeries.output: List + get() = dataPoints("output") + +val TimeSeries.outputTarget: List + get() = dataPoints("outputTarget") + +val TimeSeries.isStable: List + get() = dataPoints("isStable") + +/** + * Returns data points for the given [featureName]. + * + * Throws a [ClassCastException] if any data point is not a [ValueDataPoint] of type [T]. + */ +inline fun TimeSeries.dataPoints(featureName: String): List { + return (features[featureName] as Feature<*>).dataPoints.map { + (it as ValueDataPoint).value as T + } +} + +/** + * Returns data points for the given [featureName]. + * + * Returns `null` for all data points that are not a [ValueDataPoint] of type [T]. + */ +inline fun TimeSeries.nullableDataPoints(featureName: String): List { + return (features[featureName] as Feature<*>).dataPoints.map { + (it as? ValueDataPoint)?.value as T? + } +} diff --git a/mechanics/testing/src/com/android/mechanics/testing/ViewMotionValueToolkit.kt b/mechanics/testing/src/com/android/mechanics/testing/ViewMotionValueToolkit.kt new file mode 100644 index 0000000..cbe18d5 --- /dev/null +++ b/mechanics/testing/src/com/android/mechanics/testing/ViewMotionValueToolkit.kt @@ -0,0 +1,167 @@ +/* + * 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. + */ + +@file:OptIn(ExperimentalCoroutinesApi::class) + +package com.android.mechanics.testing + +import android.animation.AnimatorTestRule +import com.android.mechanics.spec.InputDirection +import com.android.mechanics.spec.MotionSpec +import com.android.mechanics.view.DistanceGestureContext +import com.android.mechanics.view.ViewMotionValue +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.drop +import kotlinx.coroutines.flow.take +import kotlinx.coroutines.flow.takeWhile +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import platform.test.motion.MotionTestRule +import platform.test.motion.golden.FrameId +import platform.test.motion.golden.TimeSeries +import platform.test.motion.golden.TimestampFrameId + +/** Toolkit to support [ViewMotionValue] motion tests. */ +class ViewMotionValueToolkit(private val animatorTestRule: AnimatorTestRule) : + MotionValueToolkit() { + + override fun goldenTest( + motionTestRule: MotionTestRule<*>, + spec: MotionSpec, + createDerived: (underTest: ViewMotionValue) -> List, + initialValue: Float, + initialDirection: InputDirection, + directionChangeSlop: Float, + stableThreshold: Float, + verifyTimeSeries: TimeSeries.() -> VerifyTimeSeriesResult, + capture: CaptureTimeSeriesFn, + testInput: suspend InputScope.() -> Unit, + ) = runTest { + val frameEmitter = MutableStateFlow(0) + + val testHarness = + runBlocking(Dispatchers.Main) { + ViewMotionValueTestHarness( + initialValue, + initialDirection, + spec, + stableThreshold, + directionChangeSlop, + frameEmitter.asStateFlow(), + createDerived, + ) + .also { animatorTestRule.initNewAnimators() } + } + + val underTest = testHarness.underTest + val motionValueCapture = MotionValueCapture(underTest.debugInspector()) + val recordingJob = launch { testInput.invoke(testHarness) } + + val frameIds = mutableListOf() + + fun recordFrame(frameId: TimestampFrameId) { + frameIds.add(frameId) + motionValueCapture.captureCurrentFrame(capture) + } + + runBlocking(Dispatchers.Main) { + val startFrameTime = animatorTestRule.currentTime + while (!recordingJob.isCompleted) { + recordFrame(TimestampFrameId(animatorTestRule.currentTime - startFrameTime)) + + frameEmitter.tryEmit(animatorTestRule.currentTime) + runCurrent() + + animatorTestRule.advanceTimeBy(FrameDuration.inWholeMilliseconds) + runCurrent() + } + + val timeSeries = createTimeSeries(frameIds, listOf(motionValueCapture)) + + motionValueCapture.debugger.dispose() + underTest.dispose() + verifyTimeSeries(motionTestRule, timeSeries, verifyTimeSeries) + } + } +} + +private class ViewMotionValueTestHarness( + initialInput: Float, + initialDirection: InputDirection, + spec: MotionSpec, + stableThreshold: Float, + directionChangeSlop: Float, + val onFrame: StateFlow, + createDerived: (underTest: ViewMotionValue) -> List, +) : InputScope { + + override val gestureContext = + DistanceGestureContext(initialInput, initialDirection, directionChangeSlop) + + override val underTest = + ViewMotionValue( + initialInput, + gestureContext, + stableThreshold = stableThreshold, + initialSpec = spec, + ) + + override var input by underTest::input + + init { + require(createDerived(underTest).isEmpty()) { + "testing derived values is not yet supported" + } + } + + override fun updateInput(value: Float) { + input = value + gestureContext.dragOffset = value + } + + override suspend fun awaitStable() { + val debugInspectors = buildList { add(underTest.debugInspector()) } + try { + + onFrame + // Since this is a state-flow, the current frame is counted too. + .drop(1) + .takeWhile { debugInspectors.any { !it.frame.isStable } } + .collect {} + } finally { + debugInspectors.forEach { it.dispose() } + } + } + + override suspend fun awaitFrames(frames: Int) { + onFrame + // Since this is a state-flow, the current frame is counted too. + .drop(1) + .take(frames) + .collect {} + } + + override fun reset(position: Float, direction: InputDirection) { + input = position + gestureContext.reset(position, direction) + } +} diff --git a/mechanics/tests/Android.bp b/mechanics/tests/Android.bp new file mode 100644 index 0000000..1fa3b2c --- /dev/null +++ b/mechanics/tests/Android.bp @@ -0,0 +1,50 @@ +// 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 { + default_team: "trendy_team_motion", + default_applicable_licenses: ["Android-Apache-2.0"], +} + +android_test { + name: "mechanics_tests", + manifest: "AndroidManifest.xml", + defaults: ["MotionTestDefaults"], + test_suites: ["device-tests"], + + srcs: [ + "src/**/*.kt", + ], + + static_libs: [ + "//frameworks/libs/systemui/mechanics:mechanics", + "//frameworks/libs/systemui/mechanics:mechanics-testing", + "platform-test-annotations", + "PlatformMotionTestingCompose", + "androidx.compose.runtime_runtime", + "androidx.compose.animation_animation-core", + "androidx.compose.ui_ui-test-junit4", + "androidx.compose.ui_ui-test-manifest", + "androidx.test.runner", + "androidx.test.ext.junit", + "kotlin-test", + "testables", + "truth", + ], + associates: [ + "mechanics", + ], + asset_dirs: ["goldens"], + kotlincflags: ["-Xjvm-default=all"], +} diff --git a/mechanics/tests/AndroidManifest.xml b/mechanics/tests/AndroidManifest.xml new file mode 100644 index 0000000..049cfe2 --- /dev/null +++ b/mechanics/tests/AndroidManifest.xml @@ -0,0 +1,28 @@ + + + + + + + + + + diff --git a/mechanics/tests/goldens/MagneticDetach/placedAfter_afterAttach_detachesAgain.json b/mechanics/tests/goldens/MagneticDetach/placedAfter_afterAttach_detachesAgain.json new file mode 100644 index 0000000..f18ea96 --- /dev/null +++ b/mechanics/tests/goldens/MagneticDetach/placedAfter_afterAttach_detachesAgain.json @@ -0,0 +1,662 @@ +{ + "frame_ids": [ + 0, + 16, + 32, + 48, + 64, + 80, + 96, + 112, + 128, + 144, + 160, + 176, + 192, + 208, + 224, + 240, + 256, + 272, + 288, + 304, + 320, + 336, + 352, + 368, + 384, + 400, + 416, + 432, + 448, + 464, + 480, + 496, + 512, + 528, + 544, + 560, + 576, + 592, + 608, + 624, + 640, + 656, + 672, + 688, + 704, + 720, + 736, + 752, + 768, + 784, + 800, + 816, + 832, + 848, + 864, + 880, + 896, + 912, + 928, + 944, + 960, + 976 + ], + "features": [ + { + "name": "input", + "type": "float", + "data_points": [ + 100, + 95, + 90, + 85, + 80, + 75, + 70, + 65, + 60, + 55, + 50, + 45, + 40, + 35, + 30, + 30, + 30, + 30, + 30, + 30, + 30, + 30, + 30, + 30, + 30, + 30, + 30, + 30, + 30, + 30, + 35, + 40, + 45, + 50, + 55, + 60, + 65, + 70, + 75, + 80, + 85, + 90, + 95, + 100, + 100, + 100, + 100, + 100, + 100, + 100, + 100, + 100, + 100, + 100, + 100, + 100, + 100, + 100, + 100, + 100, + 100, + 100 + ] + }, + { + "name": "gestureDirection", + "type": "string", + "data_points": [ + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max" + ] + }, + { + "name": "output", + "type": "float", + "data_points": [ + 100, + 95, + 90, + 85, + 80, + 75, + 70, + 65, + 60, + 55, + 50, + 43.38443, + 36.351646, + 29.990938, + 24.672552, + 21.162388, + 18.574236, + 16.725906, + 15.440355, + 14.566638, + 13.985239, + 13.6060915, + 13.363756, + 13.212058, + 13.11921, + 13.063812, + 13.031747, + 13.013887, + 13.004453, + 13, + 13.75, + 14.5, + 16.449999, + 18.400002, + 20.35, + 22.300001, + 24.25, + 26.2, + 28.15, + 30.1, + 32.05, + 34, + 44.585567, + 58.759357, + 68.21262, + 76.507256, + 83.19111, + 88.2904, + 92.03026, + 94.689606, + 96.532425, + 97.780754, + 98.60885, + 99.14723, + 99.49028, + 99.70432, + 99.83485, + 99.9124, + 99.957054, + 99.98176, + 99.994675, + 100 + ] + }, + { + "name": "outputTarget", + "type": "float", + "data_points": [ + 100, + 95, + 90, + 85, + 80, + 75, + 70, + 65, + 60, + 55, + 16, + 15.25, + 14.5, + 13.75, + 13, + 13, + 13, + 13, + 13, + 13, + 13, + 13, + 13, + 13, + 13, + 13, + 13, + 13, + 13, + 13, + 13.75, + 14.5, + 16.449999, + 18.400002, + 20.35, + 22.300001, + 24.25, + 26.2, + 28.15, + 30.1, + 32.05, + 90, + 95, + 100, + 100, + 100, + 100, + 100, + 100, + 100, + 100, + 100, + 100, + 100, + 100, + 100, + 100, + 100, + 100, + 100, + 100, + 100 + ] + }, + { + "name": "outputSpring", + "type": "springParameters", + "data_points": [ + { + "stiffness": 100000, + "dampingRatio": 1 + }, + { + "stiffness": 100000, + "dampingRatio": 1 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + } + ] + }, + { + "name": "isStable", + "type": "boolean", + "data_points": [ + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + true + ] + } + ] +} \ No newline at end of file diff --git a/mechanics/tests/goldens/MagneticDetach/placedAfter_attach_snapsToOrigin.json b/mechanics/tests/goldens/MagneticDetach/placedAfter_attach_snapsToOrigin.json new file mode 100644 index 0000000..7d668f6 --- /dev/null +++ b/mechanics/tests/goldens/MagneticDetach/placedAfter_attach_snapsToOrigin.json @@ -0,0 +1,392 @@ +{ + "frame_ids": [ + 0, + 16, + 32, + 48, + 64, + 80, + 96, + 112, + 128, + 144, + 160, + 176, + 192, + 208, + 224, + 240, + 256, + 272, + 288, + 304, + 320, + 336, + 352, + 368, + 384, + 400, + 416, + 432, + 448, + 464, + 480, + 496, + 512, + 528, + 544 + ], + "features": [ + { + "name": "input", + "type": "float", + "data_points": [ + 100, + 95, + 90, + 85, + 80, + 75, + 70, + 65, + 60, + 55, + 50, + 45, + 40, + 35, + 30, + 25, + 20, + 15, + 10, + 5, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ] + }, + { + "name": "gestureDirection", + "type": "string", + "data_points": [ + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min" + ] + }, + { + "name": "output", + "type": "float", + "data_points": [ + 100, + 95, + 90, + 85, + 80, + 75, + 70, + 65, + 60, + 55, + 50, + 43.38443, + 36.351646, + 29.990938, + 24.672552, + 20.412388, + 17.074236, + 14.475905, + 12.440355, + 6.552413, + 0.9461464, + 0.54626375, + 0.29212147, + 0.13740596, + 0.048214816, + 0.0006277391, + -0.021660766, + -0.02938723, + -0.029362231, + -0.02572238, + -0.020845085, + -0.015992891, + -0.01175198, + -0.008320414, + 0 + ] + }, + { + "name": "outputTarget", + "type": "float", + "data_points": [ + 100, + 95, + 90, + 85, + 80, + 75, + 70, + 65, + 60, + 55, + 16, + 15.25, + 14.5, + 13.75, + 13, + 12.25, + 11.5, + 10.75, + 10, + 5, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ] + }, + { + "name": "outputSpring", + "type": "springParameters", + "data_points": [ + { + "stiffness": 100000, + "dampingRatio": 1 + }, + { + "stiffness": 100000, + "dampingRatio": 1 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + } + ] + }, + { + "name": "isStable", + "type": "boolean", + "data_points": [ + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + true + ] + } + ] +} \ No newline at end of file diff --git a/mechanics/tests/goldens/MagneticDetach/placedAfter_beforeAttach_suppressesDirectionReverse.json b/mechanics/tests/goldens/MagneticDetach/placedAfter_beforeAttach_suppressesDirectionReverse.json new file mode 100644 index 0000000..f65c772 --- /dev/null +++ b/mechanics/tests/goldens/MagneticDetach/placedAfter_beforeAttach_suppressesDirectionReverse.json @@ -0,0 +1,162 @@ +{ + "frame_ids": [ + 0, + 16, + 32, + 48, + 64, + 80, + 96, + 112, + 128, + 144, + 160, + 176 + ], + "features": [ + { + "name": "input", + "type": "float", + "data_points": [ + 100, + 90.2, + 80.399994, + 70.59999, + 60.79999, + 51, + 60.8, + 70.6, + 80.4, + 90.200005, + 100, + 100 + ] + }, + { + "name": "gestureDirection", + "type": "string", + "data_points": [ + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max" + ] + }, + { + "name": "output", + "type": "float", + "data_points": [ + 100, + 90.2, + 80.399994, + 70.59999, + 60.79999, + 51, + 60.8, + 70.6, + 80.4, + 90.200005, + 100, + 100 + ] + }, + { + "name": "outputTarget", + "type": "float", + "data_points": [ + 100, + 90.2, + 80.399994, + 70.59999, + 60.79999, + 51, + 60.8, + 70.6, + 80.4, + 90.200005, + 100, + 100 + ] + }, + { + "name": "outputSpring", + "type": "springParameters", + "data_points": [ + { + "stiffness": 100000, + "dampingRatio": 1 + }, + { + "stiffness": 100000, + "dampingRatio": 1 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 100000, + "dampingRatio": 1 + }, + { + "stiffness": 100000, + "dampingRatio": 1 + }, + { + "stiffness": 100000, + "dampingRatio": 1 + }, + { + "stiffness": 100000, + "dampingRatio": 1 + }, + { + "stiffness": 100000, + "dampingRatio": 1 + }, + { + "stiffness": 100000, + "dampingRatio": 1 + } + ] + }, + { + "name": "isStable", + "type": "boolean", + "data_points": [ + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true + ] + } + ] +} \ No newline at end of file diff --git a/mechanics/tests/goldens/MagneticDetach/placedAfter_beforeDetach_suppressesDirectionReverse.json b/mechanics/tests/goldens/MagneticDetach/placedAfter_beforeDetach_suppressesDirectionReverse.json new file mode 100644 index 0000000..fcc6339 --- /dev/null +++ b/mechanics/tests/goldens/MagneticDetach/placedAfter_beforeDetach_suppressesDirectionReverse.json @@ -0,0 +1,162 @@ +{ + "frame_ids": [ + 0, + 16, + 32, + 48, + 64, + 80, + 96, + 112, + 128, + 144, + 160, + 176 + ], + "features": [ + { + "name": "input", + "type": "float", + "data_points": [ + 0, + 14.2, + 28.4, + 42.6, + 56.8, + 71, + 56.8, + 42.6, + 28.399998, + 14.199998, + 0, + 0 + ] + }, + { + "name": "gestureDirection", + "type": "string", + "data_points": [ + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min" + ] + }, + { + "name": "output", + "type": "float", + "data_points": [ + 0, + 11.26, + 15.52, + 19.779999, + 24.04, + 28.300001, + 24.04, + 19.779999, + 15.5199995, + 11.26, + 7, + 7 + ] + }, + { + "name": "outputTarget", + "type": "float", + "data_points": [ + 0, + 11.26, + 15.52, + 19.779999, + 24.04, + 28.300001, + 24.04, + 19.779999, + 15.5199995, + 11.26, + 7, + 7 + ] + }, + { + "name": "outputSpring", + "type": "springParameters", + "data_points": [ + { + "stiffness": 100000, + "dampingRatio": 1 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 100000, + "dampingRatio": 1 + }, + { + "stiffness": 100000, + "dampingRatio": 1 + }, + { + "stiffness": 100000, + "dampingRatio": 1 + }, + { + "stiffness": 100000, + "dampingRatio": 1 + }, + { + "stiffness": 100000, + "dampingRatio": 1 + }, + { + "stiffness": 100000, + "dampingRatio": 1 + } + ] + }, + { + "name": "isStable", + "type": "boolean", + "data_points": [ + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true + ] + } + ] +} \ No newline at end of file diff --git a/mechanics/tests/goldens/MagneticDetach/placedAfter_detach_animatesDetach.json b/mechanics/tests/goldens/MagneticDetach/placedAfter_detach_animatesDetach.json new file mode 100644 index 0000000..af8198e --- /dev/null +++ b/mechanics/tests/goldens/MagneticDetach/placedAfter_detach_animatesDetach.json @@ -0,0 +1,432 @@ +{ + "frame_ids": [ + 0, + 16, + 32, + 48, + 64, + 80, + 96, + 112, + 128, + 144, + 160, + 176, + 192, + 208, + 224, + 240, + 256, + 272, + 288, + 304, + 320, + 336, + 352, + 368, + 384, + 400, + 416, + 432, + 448, + 464, + 480, + 496, + 512, + 528, + 544, + 560, + 576, + 592, + 608 + ], + "features": [ + { + "name": "input", + "type": "float", + "data_points": [ + 0, + 5, + 10, + 15, + 20, + 25, + 30, + 35, + 40, + 45, + 50, + 55, + 60, + 65, + 70, + 75, + 80, + 85, + 90, + 90, + 90, + 90, + 90, + 90, + 90, + 90, + 90, + 90, + 90, + 90, + 90, + 90, + 90, + 90, + 90, + 90, + 90, + 90, + 90 + ] + }, + { + "name": "gestureDirection", + "type": "string", + "data_points": [ + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max" + ] + }, + { + "name": "output", + "type": "float", + "data_points": [ + 0, + 5, + 10, + 11.5, + 13, + 14.5, + 16, + 17.5, + 19, + 20.5, + 22, + 23.5, + 25, + 26.5, + 28, + 29.5, + 31, + 32.5, + 34, + 39.29379, + 48.383503, + 57.851955, + 66.20173, + 72.95019, + 78.10937, + 81.899025, + 84.597176, + 86.4689, + 87.738045, + 88.58072, + 89.129074, + 89.4788, + 89.69721, + 89.83055, + 89.909874, + 89.95562, + 89.98097, + 89.99428, + 90 + ] + }, + { + "name": "outputTarget", + "type": "float", + "data_points": [ + 0, + 5, + 10, + 11.5, + 13, + 14.5, + 16, + 17.5, + 19, + 20.5, + 22, + 23.5, + 25, + 26.5, + 28, + 29.5, + 31, + 32.5, + 90, + 90, + 90, + 90, + 90, + 90, + 90, + 90, + 90, + 90, + 90, + 90, + 90, + 90, + 90, + 90, + 90, + 90, + 90, + 90, + 90 + ] + }, + { + "name": "outputSpring", + "type": "springParameters", + "data_points": [ + { + "stiffness": 100000, + "dampingRatio": 1 + }, + { + "stiffness": 100000, + "dampingRatio": 1 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + } + ] + }, + { + "name": "isStable", + "type": "boolean", + "data_points": [ + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + true + ] + } + ] +} \ No newline at end of file diff --git a/mechanics/tests/goldens/MagneticDetach/placedAfter_placedWithDifferentBaseMapping.json b/mechanics/tests/goldens/MagneticDetach/placedAfter_placedWithDifferentBaseMapping.json new file mode 100644 index 0000000..a52fa05 --- /dev/null +++ b/mechanics/tests/goldens/MagneticDetach/placedAfter_placedWithDifferentBaseMapping.json @@ -0,0 +1,392 @@ +{ + "frame_ids": [ + 0, + 16, + 32, + 48, + 64, + 80, + 96, + 112, + 128, + 144, + 160, + 176, + 192, + 208, + 224, + 240, + 256, + 272, + 288, + 304, + 320, + 336, + 352, + 368, + 384, + 400, + 416, + 432, + 448, + 464, + 480, + 496, + 512, + 528, + 544 + ], + "features": [ + { + "name": "input", + "type": "float", + "data_points": [ + -10, + 6, + 22, + 38, + 54, + 70, + 70, + 70, + 70, + 70, + 70, + 70, + 70, + 70, + 70, + 70, + 70, + 70, + 70, + 70, + 70, + 70, + 70, + 70, + 70, + 70, + 70, + 70, + 70, + 70, + 70, + 70, + 70, + 70, + 70 + ] + }, + { + "name": "gestureDirection", + "type": "string", + "data_points": [ + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max" + ] + }, + { + "name": "output", + "type": "float", + "data_points": [ + 100, + 52, + 3.9999924, + -44.000008, + -92.000015, + -140, + -214.33502, + -311.3978, + -404.9687, + -484.4225, + -547.1691, + -594.3692, + -628.61395, + -652.7499, + -669.3473, + -680.51227, + -687.87, + -692.62244, + -695.6304, + -697.49365, + -698.6208, + -699.2841, + -699.66156, + -699.867, + -699.9719, + -700.02014, + -700.03784, + -700.04016, + -700.03577, + -700.02905, + -700.0223, + -700.0164, + -700.0117, + -700.0081, + -700 + ] + }, + { + "name": "outputTarget", + "type": "float", + "data_points": [ + 100, + 52, + 3.9999924, + -44.000008, + -92.000015, + -700, + -700, + -700, + -700, + -700, + -700, + -700, + -700, + -700, + -700, + -700, + -700, + -700, + -700, + -700, + -700, + -700, + -700, + -700, + -700, + -700, + -700, + -700, + -700, + -700, + -700, + -700, + -700, + -700, + -700 + ] + }, + { + "name": "outputSpring", + "type": "springParameters", + "data_points": [ + { + "stiffness": 100000, + "dampingRatio": 1 + }, + { + "stiffness": 100000, + "dampingRatio": 1 + }, + { + "stiffness": 100000, + "dampingRatio": 1 + }, + { + "stiffness": 100000, + "dampingRatio": 1 + }, + { + "stiffness": 100000, + "dampingRatio": 1 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + } + ] + }, + { + "name": "isStable", + "type": "boolean", + "data_points": [ + true, + true, + true, + true, + true, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + true + ] + } + ] +} \ No newline at end of file diff --git a/mechanics/tests/goldens/MagneticDetach/placedBefore_afterAttach_detachesAgain.json b/mechanics/tests/goldens/MagneticDetach/placedBefore_afterAttach_detachesAgain.json new file mode 100644 index 0000000..846fb16 --- /dev/null +++ b/mechanics/tests/goldens/MagneticDetach/placedBefore_afterAttach_detachesAgain.json @@ -0,0 +1,662 @@ +{ + "frame_ids": [ + 0, + 16, + 32, + 48, + 64, + 80, + 96, + 112, + 128, + 144, + 160, + 176, + 192, + 208, + 224, + 240, + 256, + 272, + 288, + 304, + 320, + 336, + 352, + 368, + 384, + 400, + 416, + 432, + 448, + 464, + 480, + 496, + 512, + 528, + 544, + 560, + 576, + 592, + 608, + 624, + 640, + 656, + 672, + 688, + 704, + 720, + 736, + 752, + 768, + 784, + 800, + 816, + 832, + 848, + 864, + 880, + 896, + 912, + 928, + 944, + 960, + 976 + ], + "features": [ + { + "name": "input", + "type": "float", + "data_points": [ + -100, + -95, + -90, + -85, + -80, + -75, + -70, + -65, + -60, + -55, + -50, + -45, + -40, + -35, + -30, + -30, + -30, + -30, + -30, + -30, + -30, + -30, + -30, + -30, + -30, + -30, + -30, + -30, + -30, + -30, + -35, + -40, + -45, + -50, + -55, + -60, + -65, + -70, + -75, + -80, + -85, + -90, + -95, + -100, + -100, + -100, + -100, + -100, + -100, + -100, + -100, + -100, + -100, + -100, + -100, + -100, + -100, + -100, + -100, + -100, + -100, + -100 + ] + }, + { + "name": "gestureDirection", + "type": "string", + "data_points": [ + "Min", + "Min", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min" + ] + }, + { + "name": "output", + "type": "float", + "data_points": [ + -100, + -95, + -90, + -85, + -80, + -75, + -70, + -65, + -60, + -55, + -50, + -43.38443, + -36.351646, + -29.990938, + -24.672552, + -21.162388, + -18.574236, + -16.725906, + -15.440355, + -14.566638, + -13.985239, + -13.6060915, + -13.363756, + -13.212058, + -13.11921, + -13.063812, + -13.031747, + -13.013887, + -13.004453, + -13, + -13.75, + -14.5, + -16.45, + -18.4, + -20.35, + -22.3, + -24.25, + -26.2, + -28.15, + -30.1, + -32.05, + -34, + -44.585567, + -58.759357, + -68.21262, + -76.507256, + -83.19111, + -88.2904, + -92.03026, + -94.689606, + -96.532425, + -97.780754, + -98.60885, + -99.14723, + -99.49028, + -99.70432, + -99.83485, + -99.9124, + -99.957054, + -99.98176, + -99.994675, + -100 + ] + }, + { + "name": "outputTarget", + "type": "float", + "data_points": [ + -100, + -95, + -90, + -85, + -80, + -75, + -70, + -65, + -60, + -55, + -16, + -15.25, + -14.5, + -13.75, + -13, + -13, + -13, + -13, + -13, + -13, + -13, + -13, + -13, + -13, + -13, + -13, + -13, + -13, + -13, + -13, + -13.75, + -14.5, + -16.45, + -18.4, + -20.35, + -22.3, + -24.25, + -26.2, + -28.15, + -30.1, + -32.05, + -90, + -95, + -100, + -100, + -100, + -100, + -100, + -100, + -100, + -100, + -100, + -100, + -100, + -100, + -100, + -100, + -100, + -100, + -100, + -100, + -100 + ] + }, + { + "name": "outputSpring", + "type": "springParameters", + "data_points": [ + { + "stiffness": 100000, + "dampingRatio": 1 + }, + { + "stiffness": 100000, + "dampingRatio": 1 + }, + { + "stiffness": 100000, + "dampingRatio": 1 + }, + { + "stiffness": 100000, + "dampingRatio": 1 + }, + { + "stiffness": 100000, + "dampingRatio": 1 + }, + { + "stiffness": 100000, + "dampingRatio": 1 + }, + { + "stiffness": 100000, + "dampingRatio": 1 + }, + { + "stiffness": 100000, + "dampingRatio": 1 + }, + { + "stiffness": 100000, + "dampingRatio": 1 + }, + { + "stiffness": 100000, + "dampingRatio": 1 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + } + ] + }, + { + "name": "isStable", + "type": "boolean", + "data_points": [ + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + true + ] + } + ] +} \ No newline at end of file diff --git a/mechanics/tests/goldens/MagneticDetach/placedBefore_attach_snapsToOrigin.json b/mechanics/tests/goldens/MagneticDetach/placedBefore_attach_snapsToOrigin.json new file mode 100644 index 0000000..8e6d26a --- /dev/null +++ b/mechanics/tests/goldens/MagneticDetach/placedBefore_attach_snapsToOrigin.json @@ -0,0 +1,392 @@ +{ + "frame_ids": [ + 0, + 16, + 32, + 48, + 64, + 80, + 96, + 112, + 128, + 144, + 160, + 176, + 192, + 208, + 224, + 240, + 256, + 272, + 288, + 304, + 320, + 336, + 352, + 368, + 384, + 400, + 416, + 432, + 448, + 464, + 480, + 496, + 512, + 528, + 544 + ], + "features": [ + { + "name": "input", + "type": "float", + "data_points": [ + -100, + -95, + -90, + -85, + -80, + -75, + -70, + -65, + -60, + -55, + -50, + -45, + -40, + -35, + -30, + -25, + -20, + -15, + -10, + -5, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ] + }, + { + "name": "gestureDirection", + "type": "string", + "data_points": [ + "Min", + "Min", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max" + ] + }, + { + "name": "output", + "type": "float", + "data_points": [ + -100, + -95, + -90, + -85, + -80, + -75, + -70, + -65, + -60, + -55, + -50, + -43.38443, + -36.351646, + -29.990938, + -24.672552, + -20.412388, + -17.074236, + -14.475905, + -12.440355, + -6.552413, + -0.9461464, + -0.54626375, + -0.29212147, + -0.13740596, + -0.048214816, + -0.0006277391, + 0.021660766, + 0.02938723, + 0.029362231, + 0.02572238, + 0.020845085, + 0.015992891, + 0.01175198, + 0.008320414, + 0 + ] + }, + { + "name": "outputTarget", + "type": "float", + "data_points": [ + -100, + -95, + -90, + -85, + -80, + -75, + -70, + -65, + -60, + -55, + -16, + -15.25, + -14.5, + -13.75, + -13, + -12.25, + -11.5, + -10.75, + -10, + -5, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ] + }, + { + "name": "outputSpring", + "type": "springParameters", + "data_points": [ + { + "stiffness": 100000, + "dampingRatio": 1 + }, + { + "stiffness": 100000, + "dampingRatio": 1 + }, + { + "stiffness": 100000, + "dampingRatio": 1 + }, + { + "stiffness": 100000, + "dampingRatio": 1 + }, + { + "stiffness": 100000, + "dampingRatio": 1 + }, + { + "stiffness": 100000, + "dampingRatio": 1 + }, + { + "stiffness": 100000, + "dampingRatio": 1 + }, + { + "stiffness": 100000, + "dampingRatio": 1 + }, + { + "stiffness": 100000, + "dampingRatio": 1 + }, + { + "stiffness": 100000, + "dampingRatio": 1 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + } + ] + }, + { + "name": "isStable", + "type": "boolean", + "data_points": [ + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + true + ] + } + ] +} \ No newline at end of file diff --git a/mechanics/tests/goldens/MagneticDetach/placedBefore_beforeAttach_suppressesDirectionReverse.json b/mechanics/tests/goldens/MagneticDetach/placedBefore_beforeAttach_suppressesDirectionReverse.json new file mode 100644 index 0000000..80f6813 --- /dev/null +++ b/mechanics/tests/goldens/MagneticDetach/placedBefore_beforeAttach_suppressesDirectionReverse.json @@ -0,0 +1,162 @@ +{ + "frame_ids": [ + 0, + 16, + 32, + 48, + 64, + 80, + 96, + 112, + 128, + 144, + 160, + 176 + ], + "features": [ + { + "name": "input", + "type": "float", + "data_points": [ + -100, + -90.2, + -80.399994, + -70.59999, + -60.79999, + -51, + -60.8, + -70.6, + -80.4, + -90.200005, + -100, + -100 + ] + }, + { + "name": "gestureDirection", + "type": "string", + "data_points": [ + "Min", + "Max", + "Max", + "Max", + "Max", + "Max", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min" + ] + }, + { + "name": "output", + "type": "float", + "data_points": [ + -100, + -90.2, + -80.399994, + -70.59999, + -60.79999, + -51, + -60.8, + -70.6, + -80.4, + -90.200005, + -100, + -100 + ] + }, + { + "name": "outputTarget", + "type": "float", + "data_points": [ + -100, + -90.2, + -80.399994, + -70.59999, + -60.79999, + -51, + -60.8, + -70.6, + -80.4, + -90.200005, + -100, + -100 + ] + }, + { + "name": "outputSpring", + "type": "springParameters", + "data_points": [ + { + "stiffness": 100000, + "dampingRatio": 1 + }, + { + "stiffness": 100000, + "dampingRatio": 1 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 100000, + "dampingRatio": 1 + }, + { + "stiffness": 100000, + "dampingRatio": 1 + }, + { + "stiffness": 100000, + "dampingRatio": 1 + }, + { + "stiffness": 100000, + "dampingRatio": 1 + }, + { + "stiffness": 100000, + "dampingRatio": 1 + }, + { + "stiffness": 100000, + "dampingRatio": 1 + } + ] + }, + { + "name": "isStable", + "type": "boolean", + "data_points": [ + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true + ] + } + ] +} \ No newline at end of file diff --git a/mechanics/tests/goldens/MagneticDetach/placedBefore_beforeDetach_suppressesDirectionReverse.json b/mechanics/tests/goldens/MagneticDetach/placedBefore_beforeDetach_suppressesDirectionReverse.json new file mode 100644 index 0000000..0a08476 --- /dev/null +++ b/mechanics/tests/goldens/MagneticDetach/placedBefore_beforeDetach_suppressesDirectionReverse.json @@ -0,0 +1,202 @@ +{ + "frame_ids": [ + 0, + 16, + 32, + 48, + 64, + 80, + 96, + 112, + 128, + 144, + 160, + 176, + 192, + 208, + 224, + 240 + ], + "features": [ + { + "name": "input", + "type": "float", + "data_points": [ + 0, + -14.2, + -28.4, + -42.6, + -56.8, + -71, + -56.8, + -42.6, + -28.399998, + -14.199998, + 0, + 0, + 0, + 0, + 0, + 0 + ] + }, + { + "name": "gestureDirection", + "type": "string", + "data_points": [ + "Max", + "Min", + "Min", + "Min", + "Min", + "Min", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max" + ] + }, + { + "name": "output", + "type": "float", + "data_points": [ + 0, + -13.995375, + -17.821867, + -21.595097, + -25.403616, + -29.284393, + -24.725756, + -20.24163, + -15.81998, + -11.447464, + -7.1117826, + -7.0626464, + -7.031971, + -7.013703, + -7.003483, + -6.999998 + ] + }, + { + "name": "outputTarget", + "type": "float", + "data_points": [ + 0, + -11.259998, + -15.519999, + -19.779999, + -24.039999, + -28.3, + -24.039999, + -19.779999, + -15.519998, + -11.259998, + -6.999998, + -6.999998, + -6.999998, + -6.999998, + -6.999998, + -6.999998 + ] + }, + { + "name": "outputSpring", + "type": "springParameters", + "data_points": [ + { + "stiffness": 100000, + "dampingRatio": 1 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + } + ] + }, + { + "name": "isStable", + "type": "boolean", + "data_points": [ + true, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + true + ] + } + ] +} \ No newline at end of file diff --git a/mechanics/tests/goldens/MagneticDetach/placedBefore_detach_animatesDetach.json b/mechanics/tests/goldens/MagneticDetach/placedBefore_detach_animatesDetach.json new file mode 100644 index 0000000..6f9df8e --- /dev/null +++ b/mechanics/tests/goldens/MagneticDetach/placedBefore_detach_animatesDetach.json @@ -0,0 +1,432 @@ +{ + "frame_ids": [ + 0, + 16, + 32, + 48, + 64, + 80, + 96, + 112, + 128, + 144, + 160, + 176, + 192, + 208, + 224, + 240, + 256, + 272, + 288, + 304, + 320, + 336, + 352, + 368, + 384, + 400, + 416, + 432, + 448, + 464, + 480, + 496, + 512, + 528, + 544, + 560, + 576, + 592, + 608 + ], + "features": [ + { + "name": "input", + "type": "float", + "data_points": [ + 0, + -5, + -10, + -15, + -20, + -25, + -30, + -35, + -40, + -45, + -50, + -55, + -60, + -65, + -70, + -75, + -80, + -85, + -90, + -90, + -90, + -90, + -90, + -90, + -90, + -90, + -90, + -90, + -90, + -90, + -90, + -90, + -90, + -90, + -90, + -90, + -90, + -90, + -90 + ] + }, + { + "name": "gestureDirection", + "type": "string", + "data_points": [ + "Max", + "Max", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min" + ] + }, + { + "name": "output", + "type": "float", + "data_points": [ + 0, + -5, + -9.999998, + -11.499998, + -12.999998, + -14.499998, + -15.999998, + -17.499998, + -18.999998, + -20.5, + -22, + -23.499998, + -24.999998, + -26.499998, + -27.999998, + -29.499998, + -30.999998, + -32.5, + -34, + -39.29379, + -48.383503, + -57.851955, + -66.20174, + -72.950195, + -78.10937, + -81.89903, + -84.597176, + -86.4689, + -87.738045, + -88.58072, + -89.129074, + -89.4788, + -89.69721, + -89.83055, + -89.909874, + -89.95562, + -89.98097, + -89.99428, + -90 + ] + }, + { + "name": "outputTarget", + "type": "float", + "data_points": [ + 0, + -5, + -9.999998, + -11.499998, + -12.999998, + -14.499998, + -15.999998, + -17.499998, + -18.999998, + -20.5, + -22, + -23.499998, + -24.999998, + -26.499998, + -27.999998, + -29.499998, + -30.999998, + -32.5, + -90, + -90, + -90, + -90, + -90, + -90, + -90, + -90, + -90, + -90, + -90, + -90, + -90, + -90, + -90, + -90, + -90, + -90, + -90, + -90, + -90 + ] + }, + { + "name": "outputSpring", + "type": "springParameters", + "data_points": [ + { + "stiffness": 100000, + "dampingRatio": 1 + }, + { + "stiffness": 100000, + "dampingRatio": 1 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + } + ] + }, + { + "name": "isStable", + "type": "boolean", + "data_points": [ + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + true + ] + } + ] +} \ No newline at end of file diff --git a/mechanics/tests/goldens/MagneticDetach/placedBefore_placedWithDifferentBaseMapping.json b/mechanics/tests/goldens/MagneticDetach/placedBefore_placedWithDifferentBaseMapping.json new file mode 100644 index 0000000..3ae3c28 --- /dev/null +++ b/mechanics/tests/goldens/MagneticDetach/placedBefore_placedWithDifferentBaseMapping.json @@ -0,0 +1,392 @@ +{ + "frame_ids": [ + 0, + 16, + 32, + 48, + 64, + 80, + 96, + 112, + 128, + 144, + 160, + 176, + 192, + 208, + 224, + 240, + 256, + 272, + 288, + 304, + 320, + 336, + 352, + 368, + 384, + 400, + 416, + 432, + 448, + 464, + 480, + 496, + 512, + 528, + 544 + ], + "features": [ + { + "name": "input", + "type": "float", + "data_points": [ + 10, + -6, + -22, + -38, + -54, + -70, + -70, + -70, + -70, + -70, + -70, + -70, + -70, + -70, + -70, + -70, + -70, + -70, + -70, + -70, + -70, + -70, + -70, + -70, + -70, + -70, + -70, + -70, + -70, + -70, + -70, + -70, + -70, + -70, + -70 + ] + }, + { + "name": "gestureDirection", + "type": "string", + "data_points": [ + "Max", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min" + ] + }, + { + "name": "output", + "type": "float", + "data_points": [ + -100, + 52.204758, + 83.69019, + 113.146576, + 143.9473, + 177.50067, + 240.57437, + 329.32672, + 416.95862, + 492.2793, + 552.2154, + 597.5444, + 630.5683, + 653.9236, + 670.03204, + 680.8976, + 688.07654, + 692.7254, + 695.6756, + 697.5083, + 698.62054, + 699.2776, + 699.65326, + 699.85913, + 699.9653, + 700.0149, + 700.0339, + 700.0373, + 700.03375, + 700.02765, + 700.02136, + 700.0158, + 700.01135, + 700.0079, + 700 + ] + }, + { + "name": "outputTarget", + "type": "float", + "data_points": [ + -100, + -52, + -3.9999924, + 44.000008, + 92.000015, + 700, + 700, + 700, + 700, + 700, + 700, + 700, + 700, + 700, + 700, + 700, + 700, + 700, + 700, + 700, + 700, + 700, + 700, + 700, + 700, + 700, + 700, + 700, + 700, + 700, + 700, + 700, + 700, + 700, + 700 + ] + }, + { + "name": "outputSpring", + "type": "springParameters", + "data_points": [ + { + "stiffness": 100000, + "dampingRatio": 1 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + } + ] + }, + { + "name": "isStable", + "type": "boolean", + "data_points": [ + true, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + true + ] + } + ] +} \ No newline at end of file diff --git a/mechanics/tests/goldens/changeDirection_flipsBetweenDirectionalSegments.json b/mechanics/tests/goldens/changeDirection_flipsBetweenDirectionalSegments.json new file mode 100644 index 0000000..c3f2364 --- /dev/null +++ b/mechanics/tests/goldens/changeDirection_flipsBetweenDirectionalSegments.json @@ -0,0 +1,192 @@ +{ + "frame_ids": [ + 0, + 16, + 32, + 48, + 64, + 80, + 96, + 112, + 128, + 144, + 160, + 176, + 192, + 208, + 224 + ], + "features": [ + { + "name": "input", + "type": "float", + "data_points": [ + 2, + 1.6, + 1.2, + 0.8000001, + 0.40000007, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ] + }, + { + "name": "gestureDirection", + "type": "string", + "data_points": [ + "Max", + "Max", + "Max", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min" + ] + }, + { + "name": "output", + "type": "float", + "data_points": [ + 0, + 0, + 0, + 0.12146205, + 0.3364076, + 0.53597057, + 0.69039464, + 0.79985267, + 0.8735208, + 0.92143244, + 0.95184386, + 0.97079945, + 0.9824491, + 0.98952854, + 1 + ] + }, + { + "name": "outputTarget", + "type": "float", + "data_points": [ + 0, + 0, + 0, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1 + ] + }, + { + "name": "outputSpring", + "type": "springParameters", + "data_points": [ + { + "stiffness": 100000, + "dampingRatio": 1 + }, + { + "stiffness": 100000, + "dampingRatio": 1 + }, + { + "stiffness": 100000, + "dampingRatio": 1 + }, + { + "stiffness": 1400, + "dampingRatio": 1 + }, + { + "stiffness": 1400, + "dampingRatio": 1 + }, + { + "stiffness": 1400, + "dampingRatio": 1 + }, + { + "stiffness": 1400, + "dampingRatio": 1 + }, + { + "stiffness": 1400, + "dampingRatio": 1 + }, + { + "stiffness": 1400, + "dampingRatio": 1 + }, + { + "stiffness": 1400, + "dampingRatio": 1 + }, + { + "stiffness": 1400, + "dampingRatio": 1 + }, + { + "stiffness": 1400, + "dampingRatio": 1 + }, + { + "stiffness": 1400, + "dampingRatio": 1 + }, + { + "stiffness": 1400, + "dampingRatio": 1 + }, + { + "stiffness": 1400, + "dampingRatio": 1 + } + ] + }, + { + "name": "isStable", + "type": "boolean", + "data_points": [ + true, + true, + true, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + true + ] + } + ] +} \ No newline at end of file diff --git a/mechanics/tests/goldens/changingInput_addsAnimationToMapping_becomesStable.json b/mechanics/tests/goldens/changingInput_addsAnimationToMapping_becomesStable.json new file mode 100644 index 0000000..167e4ea --- /dev/null +++ b/mechanics/tests/goldens/changingInput_addsAnimationToMapping_becomesStable.json @@ -0,0 +1,72 @@ +{ + "frame_ids": [ + 0, + 16, + 32 + ], + "features": [ + { + "name": "input", + "type": "float", + "data_points": [ + 0, + 0.5, + 1.1 + ] + }, + { + "name": "gestureDirection", + "type": "string", + "data_points": [ + "Max", + "Max", + "Max" + ] + }, + { + "name": "output", + "type": "float", + "data_points": [ + 0, + 0, + 0.05119291 + ] + }, + { + "name": "outputTarget", + "type": "float", + "data_points": [ + 0, + 0, + 0.55 + ] + }, + { + "name": "outputSpring", + "type": "springParameters", + "data_points": [ + { + "stiffness": 100000, + "dampingRatio": 1 + }, + { + "stiffness": 100000, + "dampingRatio": 1 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + } + ] + }, + { + "name": "isStable", + "type": "boolean", + "data_points": [ + true, + true, + false + ] + } + ] +} \ No newline at end of file diff --git a/mechanics/tests/goldens/criticallyDamped_matchesGolden.json b/mechanics/tests/goldens/criticallyDamped_matchesGolden.json new file mode 100644 index 0000000..5dbf8b0 --- /dev/null +++ b/mechanics/tests/goldens/criticallyDamped_matchesGolden.json @@ -0,0 +1,814 @@ +{ + "frame_ids": [ + 0, + 10, + 20, + 30, + 40, + 50, + 60, + 70, + 80, + 90, + 100, + 110, + 120, + 130, + 140, + 150, + 160, + 170, + 180, + 190, + 200, + 210, + 220, + 230, + 240, + 250, + 260, + 270, + 280, + 290, + 300, + 310, + 320, + 330, + 340, + 350, + 360, + 370, + 380, + 390, + 400, + 410, + 420, + 430, + 440, + 450, + 460, + 470, + 480, + 490, + 500, + 510, + 520, + 530, + 540, + 550, + 560, + 570, + 580, + 590, + 600, + 610, + 620, + 630, + 640, + 650, + 660, + 670, + 680, + 690, + 700, + 710, + 720, + 730, + 740, + 750, + 760, + 770, + 780, + 790, + 800, + 810, + 820, + 830, + 840, + 850, + 860, + 870, + 880, + 890, + 900, + 910, + 920, + 930, + 940, + 950, + 960, + 970 + ], + "features": [ + { + "name": "displacement", + "type": "float", + "data_points": [ + 10, + 9.953212, + 9.824769, + 9.630637, + 9.38448, + 9.0979595, + 8.780986, + 8.44195, + 8.087921, + 7.7248235, + 7.357589, + 6.9902925, + 6.6262727, + 6.2682314, + 5.9183273, + 5.578254, + 5.2493095, + 4.932455, + 4.628369, + 4.33749, + 4.0600586, + 3.7961493, + 3.545701, + 3.3085418, + 3.0844104, + 2.8729749, + 2.6738486, + 2.4866037, + 2.3107822, + 2.1459055, + 1.9914826, + 1.8470172, + 1.7120124, + 1.585976, + 1.4684237, + 1.3588821, + 1.256891, + 1.1620055, + 1.0737969, + 0.9918535, + 0.91578174, + 0.84520626, + 0.77976984, + 0.7191335, + 0.6629762, + 0.61099464, + 0.5629026, + 0.51843065, + 0.4773252, + 0.43934828, + 0.4042767, + 0.37190142, + 0.3420269, + 0.31447032, + 0.2890611, + 0.26564008, + 0.24405895, + 0.22417964, + 0.20587368, + 0.18902166, + 0.17351262, + 0.15924358, + 0.14611898, + 0.13405024, + 0.122955225, + 0.11275793, + 0.10338796, + 0.09478021, + 0.086874455, + 0.07961504, + 0.07295055, + 0.06683349, + 0.061220028, + 0.05606971, + 0.051345225, + 0.047012165, + 0.04303882, + 0.039395962, + 0.036056675, + 0.032996174, + 0.030191636, + 0.02762206, + 0.025268128, + 0.023112064, + 0.021137528, + 0.019329496, + 0.017674157, + 0.016158825, + 0.014771842, + 0.013502505, + 0.0123409815, + 0.011278247, + 0.01030602, + 0.009416698, + 0.008603305, + 0.007859444, + 0.007179248, + 0.006557336 + ] + }, + { + "name": "velocity", + "type": "float", + "data_points": [ + 0, + -9.048374, + -16.374615, + -22.224546, + -26.812801, + -30.326532, + -32.928696, + -34.760967, + -35.946312, + -36.591267, + -36.78794, + -36.615818, + -36.143303, + -35.42913, + -34.523575, + -33.469524, + -32.303444, + -31.0562, + -29.753801, + -28.41804, + -27.067059, + -25.71585, + -24.376696, + -23.059534, + -21.772308, + -20.52125, + -19.31113, + -18.145489, + -17.026817, + -15.956734, + -14.93612, + -13.965252, + -13.043904, + -12.171444, + -11.34691, + -10.569083, + -9.836539, + -9.147704, + -8.500893, + -7.894345, + -7.326255, + -6.794796, + -6.2981415, + -5.83448, + -5.402029, + -4.9990478, + -4.6238437, + -4.2747793, + -3.9502778, + -3.648825, + -3.3689728, + -3.10934, + -2.8686128, + -2.645544, + -2.438953, + -2.2477236, + -2.070803, + -1.9071996, + -1.7559812, + -1.616272, + -1.4872509, + -1.3681489, + -1.2582467, + -1.1568717, + -1.0633963, + -0.9772352, + -0.89784265, + -0.8247107, + -0.7573669, + -0.69537175, + -0.6383172, + -0.5858244, + -0.5375417, + -0.49314323, + -0.45232698, + -0.41481322, + -0.38034305, + -0.3486769, + -0.31959325, + -0.29288736, + -0.26837006, + -0.24586667, + -0.2252159, + -0.20626894, + -0.18888852, + -0.17294809, + -0.15833096, + -0.14492963, + -0.13264509, + -0.121386126, + -0.11106881, + -0.101615876, + -0.092956245, + -0.085024536, + -0.07776062, + -0.07110924, + -0.06501959, + -0.059444997 + ] + }, + { + "name": "stable", + "type": "boolean", + "data_points": [ + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + true, + true + ] + }, + { + "name": "parameters", + "type": "springParameters", + "data_points": [ + { + "stiffness": 100, + "dampingRatio": 1 + }, + { + "stiffness": 100, + "dampingRatio": 1 + }, + { + "stiffness": 100, + "dampingRatio": 1 + }, + { + "stiffness": 100, + "dampingRatio": 1 + }, + { + "stiffness": 100, + "dampingRatio": 1 + }, + { + "stiffness": 100, + "dampingRatio": 1 + }, + { + "stiffness": 100, + "dampingRatio": 1 + }, + { + "stiffness": 100, + "dampingRatio": 1 + }, + { + "stiffness": 100, + "dampingRatio": 1 + }, + { + "stiffness": 100, + "dampingRatio": 1 + }, + { + "stiffness": 100, + "dampingRatio": 1 + }, + { + "stiffness": 100, + "dampingRatio": 1 + }, + { + "stiffness": 100, + "dampingRatio": 1 + }, + { + "stiffness": 100, + "dampingRatio": 1 + }, + { + "stiffness": 100, + "dampingRatio": 1 + }, + { + "stiffness": 100, + "dampingRatio": 1 + }, + { + "stiffness": 100, + "dampingRatio": 1 + }, + { + "stiffness": 100, + "dampingRatio": 1 + }, + { + "stiffness": 100, + "dampingRatio": 1 + }, + { + "stiffness": 100, + "dampingRatio": 1 + }, + { + "stiffness": 100, + "dampingRatio": 1 + }, + { + "stiffness": 100, + "dampingRatio": 1 + }, + { + "stiffness": 100, + "dampingRatio": 1 + }, + { + "stiffness": 100, + "dampingRatio": 1 + }, + { + "stiffness": 100, + "dampingRatio": 1 + }, + { + "stiffness": 100, + "dampingRatio": 1 + }, + { + "stiffness": 100, + "dampingRatio": 1 + }, + { + "stiffness": 100, + "dampingRatio": 1 + }, + { + "stiffness": 100, + "dampingRatio": 1 + }, + { + "stiffness": 100, + "dampingRatio": 1 + }, + { + "stiffness": 100, + "dampingRatio": 1 + }, + { + "stiffness": 100, + "dampingRatio": 1 + }, + { + "stiffness": 100, + "dampingRatio": 1 + }, + { + "stiffness": 100, + "dampingRatio": 1 + }, + { + "stiffness": 100, + "dampingRatio": 1 + }, + { + "stiffness": 100, + "dampingRatio": 1 + }, + { + "stiffness": 100, + "dampingRatio": 1 + }, + { + "stiffness": 100, + "dampingRatio": 1 + }, + { + "stiffness": 100, + "dampingRatio": 1 + }, + { + "stiffness": 100, + "dampingRatio": 1 + }, + { + "stiffness": 100, + "dampingRatio": 1 + }, + { + "stiffness": 100, + "dampingRatio": 1 + }, + { + "stiffness": 100, + "dampingRatio": 1 + }, + { + "stiffness": 100, + "dampingRatio": 1 + }, + { + "stiffness": 100, + "dampingRatio": 1 + }, + { + "stiffness": 100, + "dampingRatio": 1 + }, + { + "stiffness": 100, + "dampingRatio": 1 + }, + { + "stiffness": 100, + "dampingRatio": 1 + }, + { + "stiffness": 100, + "dampingRatio": 1 + }, + { + "stiffness": 100, + "dampingRatio": 1 + }, + { + "stiffness": 100, + "dampingRatio": 1 + }, + { + "stiffness": 100, + "dampingRatio": 1 + }, + { + "stiffness": 100, + "dampingRatio": 1 + }, + { + "stiffness": 100, + "dampingRatio": 1 + }, + { + "stiffness": 100, + "dampingRatio": 1 + }, + { + "stiffness": 100, + "dampingRatio": 1 + }, + { + "stiffness": 100, + "dampingRatio": 1 + }, + { + "stiffness": 100, + "dampingRatio": 1 + }, + { + "stiffness": 100, + "dampingRatio": 1 + }, + { + "stiffness": 100, + "dampingRatio": 1 + }, + { + "stiffness": 100, + "dampingRatio": 1 + }, + { + "stiffness": 100, + "dampingRatio": 1 + }, + { + "stiffness": 100, + "dampingRatio": 1 + }, + { + "stiffness": 100, + "dampingRatio": 1 + }, + { + "stiffness": 100, + "dampingRatio": 1 + }, + { + "stiffness": 100, + "dampingRatio": 1 + }, + { + "stiffness": 100, + "dampingRatio": 1 + }, + { + "stiffness": 100, + "dampingRatio": 1 + }, + { + "stiffness": 100, + "dampingRatio": 1 + }, + { + "stiffness": 100, + "dampingRatio": 1 + }, + { + "stiffness": 100, + "dampingRatio": 1 + }, + { + "stiffness": 100, + "dampingRatio": 1 + }, + { + "stiffness": 100, + "dampingRatio": 1 + }, + { + "stiffness": 100, + "dampingRatio": 1 + }, + { + "stiffness": 100, + "dampingRatio": 1 + }, + { + "stiffness": 100, + "dampingRatio": 1 + }, + { + "stiffness": 100, + "dampingRatio": 1 + }, + { + "stiffness": 100, + "dampingRatio": 1 + }, + { + "stiffness": 100, + "dampingRatio": 1 + }, + { + "stiffness": 100, + "dampingRatio": 1 + }, + { + "stiffness": 100, + "dampingRatio": 1 + }, + { + "stiffness": 100, + "dampingRatio": 1 + }, + { + "stiffness": 100, + "dampingRatio": 1 + }, + { + "stiffness": 100, + "dampingRatio": 1 + }, + { + "stiffness": 100, + "dampingRatio": 1 + }, + { + "stiffness": 100, + "dampingRatio": 1 + }, + { + "stiffness": 100, + "dampingRatio": 1 + }, + { + "stiffness": 100, + "dampingRatio": 1 + }, + { + "stiffness": 100, + "dampingRatio": 1 + }, + { + "stiffness": 100, + "dampingRatio": 1 + }, + { + "stiffness": 100, + "dampingRatio": 1 + }, + { + "stiffness": 100, + "dampingRatio": 1 + }, + { + "stiffness": 100, + "dampingRatio": 1 + }, + { + "stiffness": 100, + "dampingRatio": 1 + }, + { + "stiffness": 100, + "dampingRatio": 1 + }, + { + "stiffness": 100, + "dampingRatio": 1 + }, + { + "stiffness": 100, + "dampingRatio": 1 + }, + { + "stiffness": 100, + "dampingRatio": 1 + } + ] + } + ] +} \ No newline at end of file diff --git a/mechanics/tests/goldens/derivedValue_hasAnimationLifecycleOnItsOwn.json b/mechanics/tests/goldens/derivedValue_hasAnimationLifecycleOnItsOwn.json new file mode 100644 index 0000000..f33165d --- /dev/null +++ b/mechanics/tests/goldens/derivedValue_hasAnimationLifecycleOnItsOwn.json @@ -0,0 +1,534 @@ +{ + "frame_ids": [ + 0, + 16, + 32, + 48, + 64, + 80, + 96, + 112, + 128, + 144, + 160, + 176, + 192, + 208, + 224, + 240, + 256, + 272, + 288, + 304, + 320, + 336, + 352, + 368 + ], + "features": [ + { + "name": "input", + "type": "float", + "data_points": [ + 0, + 0.1, + 0.2, + 0.3, + 0.4, + 0.5, + 0.6, + 0.70000005, + 0.8000001, + 0.9000001, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1 + ] + }, + { + "name": "gestureDirection", + "type": "string", + "data_points": [ + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max" + ] + }, + { + "name": "output", + "type": "float", + "data_points": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0.0696004, + 0.21705192, + 0.38261998, + 0.536185, + 0.6651724, + 0.7667498, + 0.8429822, + 0.89796525, + 0.93623626, + 0.9619781, + 0.9786911, + 0.98912483, + 0.9953385, + 1, + 1, + 1, + 1, + 1 + ] + }, + { + "name": "outputTarget", + "type": "float", + "data_points": [ + 0, + 0, + 0, + 0, + 0, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1 + ] + }, + { + "name": "outputSpring", + "type": "springParameters", + "data_points": [ + { + "stiffness": 100000, + "dampingRatio": 1 + }, + { + "stiffness": 100000, + "dampingRatio": 1 + }, + { + "stiffness": 100000, + "dampingRatio": 1 + }, + { + "stiffness": 100000, + "dampingRatio": 1 + }, + { + "stiffness": 100000, + "dampingRatio": 1 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + } + ] + }, + { + "name": "isStable", + "type": "boolean", + "data_points": [ + true, + true, + true, + true, + true, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + true, + true, + true, + true, + true + ] + }, + { + "name": "derived-input", + "type": "float", + "data_points": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0.0696004, + 0.21705192, + 0.38261998, + 0.536185, + 0.6651724, + 0.7667498, + 0.8429822, + 0.89796525, + 0.93623626, + 0.9619781, + 0.9786911, + 0.98912483, + 0.9953385, + 1, + 1, + 1, + 1, + 1 + ] + }, + { + "name": "derived-gestureDirection", + "type": "string", + "data_points": [ + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max" + ] + }, + { + "name": "derived-output", + "type": "float", + "data_points": [ + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 0.9953138, + 0.89982635, + 0.74407, + 0.5794298, + 0.4309871, + 0.308447, + 0.21313861, + 0.14231732, + 0.09167635, + 0.056711916, + 0.033384826, + 0.018371828, + 0.009094476, + 0.0036405649, + 0 + ] + }, + { + "name": "derived-outputTarget", + "type": "float", + "data_points": [ + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ] + }, + { + "name": "derived-outputSpring", + "type": "springParameters", + "data_points": [ + { + "stiffness": 100000, + "dampingRatio": 1 + }, + { + "stiffness": 100000, + "dampingRatio": 1 + }, + { + "stiffness": 100000, + "dampingRatio": 1 + }, + { + "stiffness": 100000, + "dampingRatio": 1 + }, + { + "stiffness": 100000, + "dampingRatio": 1 + }, + { + "stiffness": 100000, + "dampingRatio": 1 + }, + { + "stiffness": 100000, + "dampingRatio": 1 + }, + { + "stiffness": 100000, + "dampingRatio": 1 + }, + { + "stiffness": 100000, + "dampingRatio": 1 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + } + ] + }, + { + "name": "derived-isStable", + "type": "boolean", + "data_points": [ + true, + true, + true, + true, + true, + true, + true, + true, + true, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + true + ] + } + ] +} \ No newline at end of file diff --git a/mechanics/tests/goldens/derivedValue_reflectsInputChangeInSameFrame.json b/mechanics/tests/goldens/derivedValue_reflectsInputChangeInSameFrame.json new file mode 100644 index 0000000..28014eb --- /dev/null +++ b/mechanics/tests/goldens/derivedValue_reflectsInputChangeInSameFrame.json @@ -0,0 +1,458 @@ +{ + "frame_ids": [ + 0, + 16, + 32, + 48, + 64, + 80, + 96, + 112, + 128, + 144, + 160, + 176, + 192, + 208, + 224, + 240, + 256, + 272, + 288, + 304 + ], + "features": [ + { + "name": "input", + "type": "float", + "data_points": [ + 0, + 0.1, + 0.2, + 0.3, + 0.4, + 0.5, + 0.6, + 0.70000005, + 0.8000001, + 0.9000001, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1 + ] + }, + { + "name": "gestureDirection", + "type": "string", + "data_points": [ + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max" + ] + }, + { + "name": "output", + "type": "float", + "data_points": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0.0696004, + 0.21705192, + 0.38261998, + 0.536185, + 0.6651724, + 0.7667498, + 0.8429822, + 0.89796525, + 0.93623626, + 0.9619781, + 0.9786911, + 0.98912483, + 0.9953385, + 1 + ] + }, + { + "name": "outputTarget", + "type": "float", + "data_points": [ + 0, + 0, + 0, + 0, + 0, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1 + ] + }, + { + "name": "outputSpring", + "type": "springParameters", + "data_points": [ + { + "stiffness": 100000, + "dampingRatio": 1 + }, + { + "stiffness": 100000, + "dampingRatio": 1 + }, + { + "stiffness": 100000, + "dampingRatio": 1 + }, + { + "stiffness": 100000, + "dampingRatio": 1 + }, + { + "stiffness": 100000, + "dampingRatio": 1 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + } + ] + }, + { + "name": "isStable", + "type": "boolean", + "data_points": [ + true, + true, + true, + true, + true, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + true + ] + }, + { + "name": "derived-input", + "type": "float", + "data_points": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0.0696004, + 0.21705192, + 0.38261998, + 0.536185, + 0.6651724, + 0.7667498, + 0.8429822, + 0.89796525, + 0.93623626, + 0.9619781, + 0.9786911, + 0.98912483, + 0.9953385, + 1 + ] + }, + { + "name": "derived-gestureDirection", + "type": "string", + "data_points": [ + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max" + ] + }, + { + "name": "derived-output", + "type": "float", + "data_points": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0.0696004, + 0.21705192, + 0.38261998, + 0.536185, + 0.6651724, + 0.7667498, + 0.8429822, + 0.89796525, + 0.93623626, + 0.9619781, + 0.9786911, + 0.98912483, + 0.9953385, + 1 + ] + }, + { + "name": "derived-outputTarget", + "type": "float", + "data_points": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0.0696004, + 0.21705192, + 0.38261998, + 0.536185, + 0.6651724, + 0.7667498, + 0.8429822, + 0.89796525, + 0.93623626, + 0.9619781, + 0.9786911, + 0.98912483, + 0.9953385, + 1 + ] + }, + { + "name": "derived-outputSpring", + "type": "springParameters", + "data_points": [ + { + "stiffness": 100000, + "dampingRatio": 1 + }, + { + "stiffness": 100000, + "dampingRatio": 1 + }, + { + "stiffness": 100000, + "dampingRatio": 1 + }, + { + "stiffness": 100000, + "dampingRatio": 1 + }, + { + "stiffness": 100000, + "dampingRatio": 1 + }, + { + "stiffness": 100000, + "dampingRatio": 1 + }, + { + "stiffness": 100000, + "dampingRatio": 1 + }, + { + "stiffness": 100000, + "dampingRatio": 1 + }, + { + "stiffness": 100000, + "dampingRatio": 1 + }, + { + "stiffness": 100000, + "dampingRatio": 1 + }, + { + "stiffness": 100000, + "dampingRatio": 1 + }, + { + "stiffness": 100000, + "dampingRatio": 1 + }, + { + "stiffness": 100000, + "dampingRatio": 1 + }, + { + "stiffness": 100000, + "dampingRatio": 1 + }, + { + "stiffness": 100000, + "dampingRatio": 1 + }, + { + "stiffness": 100000, + "dampingRatio": 1 + }, + { + "stiffness": 100000, + "dampingRatio": 1 + }, + { + "stiffness": 100000, + "dampingRatio": 1 + }, + { + "stiffness": 100000, + "dampingRatio": 1 + }, + { + "stiffness": 100000, + "dampingRatio": 1 + } + ] + }, + { + "name": "derived-isStable", + "type": "boolean", + "data_points": [ + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true + ] + } + ] +} \ No newline at end of file diff --git a/mechanics/tests/goldens/directionChange_maxToMin_appliesGuarantee_afterDirectionChange.json b/mechanics/tests/goldens/directionChange_maxToMin_appliesGuarantee_afterDirectionChange.json new file mode 100644 index 0000000..def9a2b --- /dev/null +++ b/mechanics/tests/goldens/directionChange_maxToMin_appliesGuarantee_afterDirectionChange.json @@ -0,0 +1,172 @@ +{ + "frame_ids": [ + 0, + 16, + 32, + 48, + 64, + 80, + 96, + 112, + 128, + 144, + 160, + 176, + 192 + ], + "features": [ + { + "name": "input", + "type": "float", + "data_points": [ + 2, + 1.5, + 1, + 0.5, + 0, + -0.5, + -1, + -1.5, + -2, + -2, + -2, + -2, + -2 + ] + }, + { + "name": "gestureDirection", + "type": "string", + "data_points": [ + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min" + ] + }, + { + "name": "output", + "type": "float", + "data_points": [ + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 0.9303996, + 0.48961937, + 0.1611222, + 0.04164828, + 0.008622912, + 0 + ] + }, + { + "name": "outputTarget", + "type": "float", + "data_points": [ + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 0, + 0, + 0, + 0, + 0, + 0 + ] + }, + { + "name": "outputSpring", + "type": "springParameters", + "data_points": [ + { + "stiffness": 100000, + "dampingRatio": 1 + }, + { + "stiffness": 100000, + "dampingRatio": 1 + }, + { + "stiffness": 100000, + "dampingRatio": 1 + }, + { + "stiffness": 100000, + "dampingRatio": 1 + }, + { + "stiffness": 100000, + "dampingRatio": 1 + }, + { + "stiffness": 100000, + "dampingRatio": 1 + }, + { + "stiffness": 100000, + "dampingRatio": 1 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 8366.601, + "dampingRatio": 0.95 + }, + { + "stiffness": 8366.601, + "dampingRatio": 0.95 + }, + { + "stiffness": 8366.601, + "dampingRatio": 0.95 + }, + { + "stiffness": 8366.601, + "dampingRatio": 0.95 + }, + { + "stiffness": 8366.601, + "dampingRatio": 0.95 + } + ] + }, + { + "name": "isStable", + "type": "boolean", + "data_points": [ + true, + true, + true, + true, + true, + true, + true, + false, + false, + false, + false, + false, + true + ] + } + ] +} \ No newline at end of file diff --git a/mechanics/tests/goldens/directionChange_maxToMin_changesSegmentWithDirectionChange.json b/mechanics/tests/goldens/directionChange_maxToMin_changesSegmentWithDirectionChange.json new file mode 100644 index 0000000..a4dea67 --- /dev/null +++ b/mechanics/tests/goldens/directionChange_maxToMin_changesSegmentWithDirectionChange.json @@ -0,0 +1,252 @@ +{ + "frame_ids": [ + 0, + 16, + 32, + 48, + 64, + 80, + 96, + 112, + 128, + 144, + 160, + 176, + 192, + 208, + 224, + 240, + 256, + 272, + 288, + 304, + 320 + ], + "features": [ + { + "name": "input", + "type": "float", + "data_points": [ + 2, + 1.5, + 1, + 0.5, + 0, + -0.5, + -1, + -1.5, + -2, + -2, + -2, + -2, + -2, + -2, + -2, + -2, + -2, + -2, + -2, + -2, + -2 + ] + }, + { + "name": "gestureDirection", + "type": "string", + "data_points": [ + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min" + ] + }, + { + "name": "output", + "type": "float", + "data_points": [ + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 0.9303996, + 0.7829481, + 0.61738, + 0.46381494, + 0.3348276, + 0.23325022, + 0.15701781, + 0.10203475, + 0.06376373, + 0.038021944, + 0.021308914, + 0.010875157, + 0.004661471, + 0 + ] + }, + { + "name": "outputTarget", + "type": "float", + "data_points": [ + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ] + }, + { + "name": "outputSpring", + "type": "springParameters", + "data_points": [ + { + "stiffness": 100000, + "dampingRatio": 1 + }, + { + "stiffness": 100000, + "dampingRatio": 1 + }, + { + "stiffness": 100000, + "dampingRatio": 1 + }, + { + "stiffness": 100000, + "dampingRatio": 1 + }, + { + "stiffness": 100000, + "dampingRatio": 1 + }, + { + "stiffness": 100000, + "dampingRatio": 1 + }, + { + "stiffness": 100000, + "dampingRatio": 1 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + } + ] + }, + { + "name": "isStable", + "type": "boolean", + "data_points": [ + true, + true, + true, + true, + true, + true, + true, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + true + ] + } + ] +} \ No newline at end of file diff --git a/mechanics/tests/goldens/directionChange_minToMax_changesSegmentWithDirectionChange.json b/mechanics/tests/goldens/directionChange_minToMax_changesSegmentWithDirectionChange.json new file mode 100644 index 0000000..c338c01 --- /dev/null +++ b/mechanics/tests/goldens/directionChange_minToMax_changesSegmentWithDirectionChange.json @@ -0,0 +1,252 @@ +{ + "frame_ids": [ + 0, + 16, + 32, + 48, + 64, + 80, + 96, + 112, + 128, + 144, + 160, + 176, + 192, + 208, + 224, + 240, + 256, + 272, + 288, + 304, + 320 + ], + "features": [ + { + "name": "input", + "type": "float", + "data_points": [ + 0, + 0.5, + 1, + 1.5, + 2, + 2.5, + 3, + 3.5, + 4, + 4, + 4, + 4, + 4, + 4, + 4, + 4, + 4, + 4, + 4, + 4, + 4 + ] + }, + { + "name": "gestureDirection", + "type": "string", + "data_points": [ + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max" + ] + }, + { + "name": "output", + "type": "float", + "data_points": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0.0696004, + 0.21705192, + 0.38261998, + 0.536185, + 0.6651724, + 0.7667498, + 0.8429822, + 0.89796525, + 0.93623626, + 0.9619781, + 0.9786911, + 0.98912483, + 0.9953385, + 1 + ] + }, + { + "name": "outputTarget", + "type": "float", + "data_points": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1 + ] + }, + { + "name": "outputSpring", + "type": "springParameters", + "data_points": [ + { + "stiffness": 100000, + "dampingRatio": 1 + }, + { + "stiffness": 100000, + "dampingRatio": 1 + }, + { + "stiffness": 100000, + "dampingRatio": 1 + }, + { + "stiffness": 100000, + "dampingRatio": 1 + }, + { + "stiffness": 100000, + "dampingRatio": 1 + }, + { + "stiffness": 100000, + "dampingRatio": 1 + }, + { + "stiffness": 100000, + "dampingRatio": 1 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + } + ] + }, + { + "name": "isStable", + "type": "boolean", + "data_points": [ + true, + true, + true, + true, + true, + true, + true, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + true + ] + } + ] +} \ No newline at end of file diff --git a/mechanics/tests/goldens/doNothingBeforeThreshold.json b/mechanics/tests/goldens/doNothingBeforeThreshold.json new file mode 100644 index 0000000..15b6249 --- /dev/null +++ b/mechanics/tests/goldens/doNothingBeforeThreshold.json @@ -0,0 +1,92 @@ +{ + "frame_ids": [ + 0, + 16, + 32, + 48, + 64 + ], + "features": [ + { + "name": "input", + "type": "float", + "data_points": [ + 0, + 3, + 6, + 10, + 10 + ] + }, + { + "name": "gestureDirection", + "type": "string", + "data_points": [ + "Max", + "Max", + "Max", + "Max", + "Max" + ] + }, + { + "name": "output", + "type": "float", + "data_points": [ + 0, + 0, + 0, + 0, + 0 + ] + }, + { + "name": "outputTarget", + "type": "float", + "data_points": [ + 0, + 0, + 0, + 0, + 0 + ] + }, + { + "name": "outputSpring", + "type": "springParameters", + "data_points": [ + { + "stiffness": 100000, + "dampingRatio": 1 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + } + ] + }, + { + "name": "isStable", + "type": "boolean", + "data_points": [ + true, + true, + true, + true, + true + ] + } + ] +} \ No newline at end of file diff --git a/mechanics/tests/goldens/emptySpec_outputMatchesInput_withoutAnimation.json b/mechanics/tests/goldens/emptySpec_outputMatchesInput_withoutAnimation.json new file mode 100644 index 0000000..f5f7612 --- /dev/null +++ b/mechanics/tests/goldens/emptySpec_outputMatchesInput_withoutAnimation.json @@ -0,0 +1,102 @@ +{ + "frame_ids": [ + 0, + 16, + 32, + 48, + 64, + 80 + ], + "features": [ + { + "name": "input", + "type": "float", + "data_points": [ + 0, + 20, + 40, + 60, + 80, + 100 + ] + }, + { + "name": "gestureDirection", + "type": "string", + "data_points": [ + "Max", + "Max", + "Max", + "Max", + "Max", + "Max" + ] + }, + { + "name": "output", + "type": "float", + "data_points": [ + 0, + 20, + 40, + 60, + 80, + 100 + ] + }, + { + "name": "outputTarget", + "type": "float", + "data_points": [ + 0, + 20, + 40, + 60, + 80, + 100 + ] + }, + { + "name": "outputSpring", + "type": "springParameters", + "data_points": [ + { + "stiffness": 100000, + "dampingRatio": 1 + }, + { + "stiffness": 100000, + "dampingRatio": 1 + }, + { + "stiffness": 100000, + "dampingRatio": 1 + }, + { + "stiffness": 100000, + "dampingRatio": 1 + }, + { + "stiffness": 100000, + "dampingRatio": 1 + }, + { + "stiffness": 100000, + "dampingRatio": 1 + } + ] + }, + { + "name": "isStable", + "type": "boolean", + "data_points": [ + true, + true, + true, + true, + true, + true + ] + } + ] +} \ No newline at end of file diff --git a/mechanics/tests/goldens/hideAnimation.json b/mechanics/tests/goldens/hideAnimation.json new file mode 100644 index 0000000..1ed61bf --- /dev/null +++ b/mechanics/tests/goldens/hideAnimation.json @@ -0,0 +1,322 @@ +{ + "frame_ids": [ + 0, + 16, + 32, + 48, + 64, + 80, + 96, + 112, + 128, + 144, + 160, + 176, + 192, + 208, + 224, + 240, + 256, + 272, + 288, + 304, + 320, + 336, + 352, + 368, + 384, + 400, + 416, + 432 + ], + "features": [ + { + "name": "input", + "type": "float", + "data_points": [ + 36, + 33, + 30, + 27, + 24, + 21, + 18, + 15, + 12, + 9, + 6, + 3, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ] + }, + { + "name": "gestureDirection", + "type": "string", + "data_points": [ + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min" + ] + }, + { + "name": "output", + "type": "float", + "data_points": [ + 27, + 27, + 27, + 24, + 21, + 18, + 15, + 12, + 9, + 6.1834006, + 4.0823064, + 2.5971997, + 1.58311, + 0.9141716, + 0.4889884, + 0.2301146, + 0.08085263, + 0.001194974, + -0.03613234, + -0.049089864, + -0.049071845, + -0.042999808, + -0.034852397, + -0.026743067, + -0.019653551, + -0.013916051, + -0.009518558, + 0 + ] + }, + { + "name": "outputTarget", + "type": "float", + "data_points": [ + 27, + 27, + 27, + 24, + 21, + 18, + 15, + 12, + 9, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ] + }, + { + "name": "outputSpring", + "type": "springParameters", + "data_points": [ + { + "stiffness": 100000, + "dampingRatio": 1 + }, + { + "stiffness": 100000, + "dampingRatio": 1 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + } + ] + }, + { + "name": "isStable", + "type": "boolean", + "data_points": [ + true, + true, + true, + true, + true, + true, + true, + true, + true, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + true + ] + } + ] +} \ No newline at end of file diff --git a/mechanics/tests/goldens/hideAnimationOnThreshold.json b/mechanics/tests/goldens/hideAnimationOnThreshold.json new file mode 100644 index 0000000..aa75de7 --- /dev/null +++ b/mechanics/tests/goldens/hideAnimationOnThreshold.json @@ -0,0 +1,322 @@ +{ + "frame_ids": [ + 0, + 16, + 32, + 48, + 64, + 80, + 96, + 112, + 128, + 144, + 160, + 176, + 192, + 208, + 224, + 240, + 256, + 272, + 288, + 304, + 320, + 336, + 352, + 368, + 384, + 400, + 416, + 432 + ], + "features": [ + { + "name": "input", + "type": "float", + "data_points": [ + 36, + 33, + 30, + 27, + 24, + 21, + 18, + 15, + 11, + 11, + 11, + 11, + 11, + 11, + 11, + 11, + 11, + 11, + 11, + 11, + 11, + 11, + 11, + 11, + 11, + 11, + 11, + 11 + ] + }, + { + "name": "gestureDirection", + "type": "string", + "data_points": [ + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min" + ] + }, + { + "name": "output", + "type": "float", + "data_points": [ + 27, + 27, + 27, + 24, + 21, + 18, + 15, + 12, + 8, + 5.40525, + 3.5262613, + 2.2135293, + 1.327303, + 0.74965316, + 0.38740715, + 0.17046088, + 0.04813948, + -0.014901603, + -0.042484254, + -0.05010864, + -0.04747553, + -0.04038017, + -0.03207883, + -0.02424037, + -0.017586533, + -0.012307796, + -0.0083231535, + 0 + ] + }, + { + "name": "outputTarget", + "type": "float", + "data_points": [ + 27, + 27, + 27, + 24, + 21, + 18, + 15, + 12, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ] + }, + { + "name": "outputSpring", + "type": "springParameters", + "data_points": [ + { + "stiffness": 100000, + "dampingRatio": 1 + }, + { + "stiffness": 100000, + "dampingRatio": 1 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + } + ] + }, + { + "name": "isStable", + "type": "boolean", + "data_points": [ + true, + true, + true, + true, + true, + true, + true, + true, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + true + ] + } + ] +} \ No newline at end of file diff --git a/mechanics/tests/goldens/overDamped_matchesGolden.json b/mechanics/tests/goldens/overDamped_matchesGolden.json new file mode 100644 index 0000000..2fcf21a --- /dev/null +++ b/mechanics/tests/goldens/overDamped_matchesGolden.json @@ -0,0 +1,2142 @@ +{ + "frame_ids": [ + 0, + 10, + 20, + 30, + 40, + 50, + 60, + 70, + 80, + 90, + 100, + 110, + 120, + 130, + 140, + 150, + 160, + 170, + 180, + 190, + 200, + 210, + 220, + 230, + 240, + 250, + 260, + 270, + 280, + 290, + 300, + 310, + 320, + 330, + 340, + 350, + 360, + 370, + 380, + 390, + 400, + 410, + 420, + 430, + 440, + 450, + 460, + 470, + 480, + 490, + 500, + 510, + 520, + 530, + 540, + 550, + 560, + 570, + 580, + 590, + 600, + 610, + 620, + 630, + 640, + 650, + 660, + 670, + 680, + 690, + 700, + 710, + 720, + 730, + 740, + 750, + 760, + 770, + 780, + 790, + 800, + 810, + 820, + 830, + 840, + 850, + 860, + 870, + 880, + 890, + 900, + 910, + 920, + 930, + 940, + 950, + 960, + 970, + 980, + 990, + 1000, + 1010, + 1020, + 1030, + 1040, + 1050, + 1060, + 1070, + 1080, + 1090, + 1100, + 1110, + 1120, + 1130, + 1140, + 1150, + 1160, + 1170, + 1180, + 1190, + 1200, + 1210, + 1220, + 1230, + 1240, + 1250, + 1260, + 1270, + 1280, + 1290, + 1300, + 1310, + 1320, + 1330, + 1340, + 1350, + 1360, + 1370, + 1380, + 1390, + 1400, + 1410, + 1420, + 1430, + 1440, + 1450, + 1460, + 1470, + 1480, + 1490, + 1500, + 1510, + 1520, + 1530, + 1540, + 1550, + 1560, + 1570, + 1580, + 1590, + 1600, + 1610, + 1620, + 1630, + 1640, + 1650, + 1660, + 1670, + 1680, + 1690, + 1700, + 1710, + 1720, + 1730, + 1740, + 1750, + 1760, + 1770, + 1780, + 1790, + 1800, + 1810, + 1820, + 1830, + 1840, + 1850, + 1860, + 1870, + 1880, + 1890, + 1900, + 1910, + 1920, + 1930, + 1940, + 1950, + 1960, + 1970, + 1980, + 1990, + 2000, + 2010, + 2020, + 2030, + 2040, + 2050, + 2060, + 2070, + 2080, + 2090, + 2100, + 2110, + 2120, + 2130, + 2140, + 2150, + 2160, + 2170, + 2180, + 2190, + 2200, + 2210, + 2220, + 2230, + 2240, + 2250, + 2260, + 2270, + 2280, + 2290, + 2300, + 2310, + 2320, + 2330, + 2340, + 2350, + 2360, + 2370, + 2380, + 2390, + 2400, + 2410, + 2420, + 2430, + 2440, + 2450, + 2460, + 2470, + 2480, + 2490, + 2500, + 2510, + 2520, + 2530, + 2540, + 2550, + 2560, + 2570, + 2580, + 2590, + 2600, + 2610, + 2620, + 2630 + ], + "features": [ + { + "name": "displacement", + "type": "float", + "data_points": [ + 10, + 9.956085, + 9.844659, + 9.688895, + 9.504694, + 9.302948, + 9.091103, + 8.874231, + 8.655778, + 8.438063, + 8.222635, + 8.010515, + 7.802359, + 7.598574, + 7.399398, + 7.204951, + 7.015275, + 6.83036, + 6.6501584, + 6.474601, + 6.3036017, + 6.1370664, + 5.9748945, + 5.8169837, + 5.663229, + 5.5135264, + 5.367773, + 5.2258673, + 5.0877094, + 4.9532013, + 4.8222475, + 4.6947546, + 4.5706315, + 4.4497895, + 4.332142, + 4.2176046, + 4.1060953, + 3.997534, + 3.891843, + 3.7889464, + 3.68877, + 3.5912423, + 3.496293, + 3.4038541, + 3.3138592, + 3.2262437, + 3.1409447, + 3.057901, + 2.9770527, + 2.8983421, + 2.8217125, + 2.747109, + 2.6744778, + 2.603767, + 2.5349257, + 2.4679046, + 2.4026554, + 2.3391314, + 2.2772868, + 2.2170773, + 2.1584597, + 2.1013918, + 2.0458329, + 1.9917428, + 1.939083, + 1.8878154, + 1.8379031, + 1.7893106, + 1.7420027, + 1.6959457, + 1.6511065, + 1.6074526, + 1.564953, + 1.523577, + 1.483295, + 1.444078, + 1.4058979, + 1.3687272, + 1.3325393, + 1.2973082, + 1.2630085, + 1.2296157, + 1.1971058, + 1.1654553, + 1.1346418, + 1.1046429, + 1.0754371, + 1.0470035, + 1.0193217, + 0.99237174, + 0.9661343, + 0.94059056, + 0.9157222, + 0.8915113, + 0.86794055, + 0.84499294, + 0.82265204, + 0.80090183, + 0.7797267, + 0.7591114, + 0.73904115, + 0.71950155, + 0.70047855, + 0.6819585, + 0.6639281, + 0.6463744, + 0.62928486, + 0.6126471, + 0.59644926, + 0.58067966, + 0.565327, + 0.55038023, + 0.53582865, + 0.5216618, + 0.50786954, + 0.49444193, + 0.48136932, + 0.46864232, + 0.45625183, + 0.44418892, + 0.43244496, + 0.4210115, + 0.40988034, + 0.39904347, + 0.38849312, + 0.3782217, + 0.36822185, + 0.35848638, + 0.34900832, + 0.33978084, + 0.33079734, + 0.32205135, + 0.31353658, + 0.30524695, + 0.29717648, + 0.2893194, + 0.28167003, + 0.27422294, + 0.26697272, + 0.2599142, + 0.25304228, + 0.24635206, + 0.23983873, + 0.2334976, + 0.22732413, + 0.22131388, + 0.21546254, + 0.2097659, + 0.20421988, + 0.19882049, + 0.19356385, + 0.1884462, + 0.18346384, + 0.17861322, + 0.17389084, + 0.16929333, + 0.16481736, + 0.16045974, + 0.15621732, + 0.15208708, + 0.14806603, + 0.1441513, + 0.14034006, + 0.1366296, + 0.13301723, + 0.12950037, + 0.1260765, + 0.12274315, + 0.11949793, + 0.116338514, + 0.11326262, + 0.11026806, + 0.10735267, + 0.10451435, + 0.10175108, + 0.09906087, + 0.09644179, + 0.093891956, + 0.091409534, + 0.088992745, + 0.08663985, + 0.08434917, + 0.082119055, + 0.0799479, + 0.077834144, + 0.07577628, + 0.07377282, + 0.07182233, + 0.06992341, + 0.068074696, + 0.06627486, + 0.06452261, + 0.06281669, + 0.06115587, + 0.059538964, + 0.057964806, + 0.056432266, + 0.054940246, + 0.053487673, + 0.052073505, + 0.050696727, + 0.04935635, + 0.04805141, + 0.046780974, + 0.045544125, + 0.044339977, + 0.043167666, + 0.042026352, + 0.040915214, + 0.039833453, + 0.03878029, + 0.037754975, + 0.036756765, + 0.03578495, + 0.034838825, + 0.033917718, + 0.033020962, + 0.032147918, + 0.031297956, + 0.030470464, + 0.029664852, + 0.028880538, + 0.028116962, + 0.027373575, + 0.026649842, + 0.025945244, + 0.025259275, + 0.024591442, + 0.023941265, + 0.023308279, + 0.022692028, + 0.02209207, + 0.021507975, + 0.020939322, + 0.020385705, + 0.019846724, + 0.019321995, + 0.018811138, + 0.018313788, + 0.017829588, + 0.01735819, + 0.016899254, + 0.016452452, + 0.016017463, + 0.015593976, + 0.015181685, + 0.014780294, + 0.014389516, + 0.01400907, + 0.013638682, + 0.013278087, + 0.012927026, + 0.012585246, + 0.012252503, + 0.011928557, + 0.011613177, + 0.011306135, + 0.011007211, + 0.010716191, + 0.010432864, + 0.010157028, + 0.009888485, + 0.009627042, + 0.009372512 + ] + }, + { + "name": "velocity", + "type": "float", + "data_points": [ + 0, + -8.228306, + -13.676142, + -17.215311, + -19.445917, + -20.780996, + -21.504791, + -21.812872, + -21.839752, + -21.677917, + -21.390915, + -21.022373, + -20.602211, + -20.150906, + -19.68244, + -19.206331, + -18.72902, + -18.254831, + -17.786642, + -17.32633, + -16.875088, + -16.433643, + -16.0024, + -15.581547, + -15.171124, + -14.771073, + -14.3812685, + -14.001543, + -13.6317005, + -13.271528, + -12.920805, + -12.579304, + -12.246796, + -11.923055, + -11.607857, + -11.300981, + -11.00221, + -10.711333, + -10.4281435, + -10.152438, + -9.884021, + -9.622699, + -9.368285, + -9.120597, + -8.879457, + -8.644693, + -8.416136, + -8.193621, + -7.976989, + -7.766084, + -7.5607557, + -7.360856, + -7.1662416, + -6.9767723, + -6.7923126, + -6.61273, + -6.4378953, + -6.267683, + -6.101971, + -5.9406404, + -5.783575, + -5.6306624, + -5.4817924, + -5.3368587, + -5.195757, + -5.058386, + -4.924647, + -4.7944436, + -4.6676826, + -4.544273, + -4.424126, + -4.307156, + -4.193279, + -4.0824122, + -3.9744768, + -3.8693953, + -3.7670918, + -3.667493, + -3.570528, + -3.4761264, + -3.3842208, + -3.294745, + -3.207635, + -3.122828, + -3.0402632, + -2.9598813, + -2.8816247, + -2.805437, + -2.7312639, + -2.6590517, + -2.5887487, + -2.5203047, + -2.45367, + -2.3887973, + -2.3256397, + -2.2641518, + -2.2042897, + -2.1460102, + -2.0892715, + -2.034033, + -1.980255, + -1.9278988, + -1.8769268, + -1.8273025, + -1.7789901, + -1.7319552, + -1.6861638, + -1.6415831, + -1.598181, + -1.5559264, + -1.5147891, + -1.4747394, + -1.4357486, + -1.3977886, + -1.3608323, + -1.3248532, + -1.2898252, + -1.2557234, + -1.2225231, + -1.1902007, + -1.1587328, + -1.1280969, + -1.098271, + -1.0692337, + -1.0409641, + -1.0134419, + -0.9866474, + -0.96056134, + -0.9351649, + -0.91044, + -0.8863688, + -0.862934, + -0.84011877, + -0.81790674, + -0.796282, + -0.775229, + -0.7547326, + -0.7347781, + -0.7153512, + -0.69643795, + -0.67802477, + -0.6600984, + -0.64264596, + -0.62565494, + -0.60911316, + -0.59300876, + -0.5773301, + -0.562066, + -0.5472055, + -0.53273785, + -0.51865274, + -0.50494003, + -0.49158987, + -0.47859266, + -0.4659391, + -0.45362008, + -0.44162676, + -0.42995054, + -0.41858304, + -0.40751606, + -0.39674172, + -0.38625222, + -0.37604007, + -0.3660979, + -0.35641858, + -0.34699517, + -0.33782095, + -0.32888928, + -0.32019374, + -0.3117281, + -0.3034863, + -0.29546237, + -0.28765061, + -0.2800454, + -0.27264124, + -0.26543283, + -0.258415, + -0.25158274, + -0.24493112, + -0.23845536, + -0.23215081, + -0.22601293, + -0.22003734, + -0.21421975, + -0.20855597, + -0.20304193, + -0.19767368, + -0.19244736, + -0.18735923, + -0.1824056, + -0.17758296, + -0.17288782, + -0.16831681, + -0.16386665, + -0.15953417, + -0.15531623, + -0.1512098, + -0.14721195, + -0.1433198, + -0.13953055, + -0.13584149, + -0.13224995, + -0.12875338, + -0.12534925, + -0.12203512, + -0.11880862, + -0.115667425, + -0.112609275, + -0.109631985, + -0.10673341, + -0.103911474, + -0.10116415, + -0.098489456, + -0.09588548, + -0.09335035, + -0.09088225, + -0.0884794, + -0.08614008, + -0.08386262, + -0.08164536, + -0.07948673, + -0.07738517, + -0.075339176, + -0.07334727, + -0.07140803, + -0.06952007, + -0.06768202, + -0.06589257, + -0.06415043, + -0.062454347, + -0.060803108, + -0.059195526, + -0.05763045, + -0.05610675, + -0.054623336, + -0.05317914, + -0.05177313, + -0.050404295, + -0.04907165, + -0.047774237, + -0.04651113, + -0.045281414, + -0.044084214, + -0.042918667, + -0.041783933, + -0.0406792, + -0.03960368, + -0.038556594, + -0.03753719, + -0.03654474, + -0.03557853, + -0.034637865, + -0.03372207, + -0.032830484, + -0.031962473, + -0.031117413, + -0.030294696, + -0.02949373, + -0.028713943, + -0.027954772, + -0.027215673, + -0.026496114, + -0.02579558, + -0.02511357 + ] + }, + { + "name": "stable", + "type": "boolean", + "data_points": [ + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + true, + true + ] + }, + { + "name": "parameters", + "type": "springParameters", + "data_points": [ + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + } + ] + } + ] +} \ No newline at end of file diff --git a/mechanics/tests/goldens/overdrag_maxDirection_neverExceedsMaxOverdrag.json b/mechanics/tests/goldens/overdrag_maxDirection_neverExceedsMaxOverdrag.json new file mode 100644 index 0000000..ef27af8 --- /dev/null +++ b/mechanics/tests/goldens/overdrag_maxDirection_neverExceedsMaxOverdrag.json @@ -0,0 +1,279 @@ +{ + "frame_ids": [ + 0, + 16, + 32, + 48, + 64, + 80, + 96, + 112, + 128, + 144, + 160, + 176, + 192, + 208, + 224, + 240, + 256, + 272, + 288, + 304, + 320 + ], + "features": [ + { + "name": "input", + "type": "float", + "data_points": [ + 0, + 5, + 10, + 15, + 20, + 25, + 30, + 35, + 40, + 45, + 50, + 55, + 60, + 65, + 70, + 75, + 80, + 85, + 90, + 95, + 100 + ] + }, + { + "name": "gestureDirection", + "type": "string", + "data_points": [ + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max" + ] + }, + { + "name": "output", + "type": "float", + "data_points": [ + 0, + 5, + 10, + 11.662819, + 13.302809, + 14.898373, + 16.430256, + 17.88237, + 19.242344, + 20.501678, + 21.655659, + 22.70298, + 23.645235, + 24.486334, + 25.231884, + 25.88864, + 26.464012, + 26.965673, + 27.401234, + 27.77803, + 28.102966 + ] + }, + { + "name": "outputTarget", + "type": "float", + "data_points": [ + 0, + 5, + 10, + 11.662819, + 13.302809, + 14.898373, + 16.430256, + 17.88237, + 19.242344, + 20.501678, + 21.655659, + 22.70298, + 23.645235, + 24.486334, + 25.231884, + 25.88864, + 26.464012, + 26.965673, + 27.401234, + 27.77803, + 28.102966 + ] + }, + { + "name": "outputSpring", + "type": "springParameters", + "data_points": [ + { + "stiffness": 100000, + "dampingRatio": 1 + }, + { + "stiffness": 100000, + "dampingRatio": 1 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + } + ] + }, + { + "name": "isStable", + "type": "boolean", + "data_points": [ + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true + ] + }, + { + "name": "overdragLimit", + "type": "float", + "data_points": [ + 10, + 10, + 10, + 10, + 10, + 10, + 10, + 10, + 10, + 10, + 10, + 10, + 10, + 10, + 10, + 10, + 10, + 10, + 10, + 10, + 10 + ] + } + ] +} \ No newline at end of file diff --git a/mechanics/tests/goldens/overdrag_minDirection_neverExceedsMaxOverdrag.json b/mechanics/tests/goldens/overdrag_minDirection_neverExceedsMaxOverdrag.json new file mode 100644 index 0000000..6039a70 --- /dev/null +++ b/mechanics/tests/goldens/overdrag_minDirection_neverExceedsMaxOverdrag.json @@ -0,0 +1,279 @@ +{ + "frame_ids": [ + 0, + 16, + 32, + 48, + 64, + 80, + 96, + 112, + 128, + 144, + 160, + 176, + 192, + 208, + 224, + 240, + 256, + 272, + 288, + 304, + 320 + ], + "features": [ + { + "name": "input", + "type": "float", + "data_points": [ + 0, + -5, + -10, + -15, + -20, + -25, + -30, + -35, + -40, + -45, + -50, + -55, + -60, + -65, + -70, + -75, + -80, + -85, + -90, + -95, + -100 + ] + }, + { + "name": "gestureDirection", + "type": "string", + "data_points": [ + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min" + ] + }, + { + "name": "output", + "type": "float", + "data_points": [ + 0, + -5, + -10, + -11.662819, + -13.302809, + -14.898373, + -16.430256, + -17.88237, + -19.242344, + -20.501678, + -21.655659, + -22.70298, + -23.645235, + -24.486334, + -25.231884, + -25.88864, + -26.464012, + -26.965673, + -27.401234, + -27.77803, + -28.102966 + ] + }, + { + "name": "outputTarget", + "type": "float", + "data_points": [ + 0, + -5, + -10, + -11.662819, + -13.302809, + -14.898373, + -16.430256, + -17.88237, + -19.242344, + -20.501678, + -21.655659, + -22.70298, + -23.645235, + -24.486334, + -25.231884, + -25.88864, + -26.464012, + -26.965673, + -27.401234, + -27.77803, + -28.102966 + ] + }, + { + "name": "outputSpring", + "type": "springParameters", + "data_points": [ + { + "stiffness": 100000, + "dampingRatio": 1 + }, + { + "stiffness": 100000, + "dampingRatio": 1 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + } + ] + }, + { + "name": "isStable", + "type": "boolean", + "data_points": [ + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true + ] + }, + { + "name": "overdragLimit", + "type": "float", + "data_points": [ + null, + null, + -10, + -10, + -10, + -10, + -10, + -10, + -10, + -10, + -10, + -10, + -10, + -10, + -10, + -10, + -10, + -10, + -10, + -10, + -10 + ] + } + ] +} \ No newline at end of file diff --git a/mechanics/tests/goldens/overdrag_nonStandardBaseFunction.json b/mechanics/tests/goldens/overdrag_nonStandardBaseFunction.json new file mode 100644 index 0000000..f3823d9 --- /dev/null +++ b/mechanics/tests/goldens/overdrag_nonStandardBaseFunction.json @@ -0,0 +1,268 @@ +{ + "frame_ids": [ + 0, + 16, + 32, + 48, + 64, + 80, + 96, + 112, + 128, + 144, + 160, + 176, + 192, + 208, + 224, + 240, + 256, + 272, + 288, + 304 + ], + "features": [ + { + "name": "input", + "type": "float", + "data_points": [ + 5, + 10, + 15, + 20, + 25, + 30, + 35, + 40, + 45, + 50, + 55, + 60, + 65, + 70, + 75, + 80, + 85, + 90, + 95, + 100 + ] + }, + { + "name": "gestureDirection", + "type": "string", + "data_points": [ + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max" + ] + }, + { + "name": "output", + "type": "float", + "data_points": [ + -5, + -10, + -11.662819, + -13.302809, + -14.898373, + -16.430256, + -17.88237, + -19.242344, + -20.501678, + -21.655659, + -22.70298, + -23.645235, + -24.486334, + -25.231884, + -25.88864, + -26.464012, + -26.965673, + -27.401234, + -27.77803, + -28.102966 + ] + }, + { + "name": "outputTarget", + "type": "float", + "data_points": [ + -5, + -10, + -11.662819, + -13.302809, + -14.898373, + -16.430256, + -17.88237, + -19.242344, + -20.501678, + -21.655659, + -22.70298, + -23.645235, + -24.486334, + -25.231884, + -25.88864, + -26.464012, + -26.965673, + -27.401234, + -27.77803, + -28.102966 + ] + }, + { + "name": "outputSpring", + "type": "springParameters", + "data_points": [ + { + "stiffness": 100000, + "dampingRatio": 1 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + } + ] + }, + { + "name": "isStable", + "type": "boolean", + "data_points": [ + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true + ] + }, + { + "name": "overdragLimit", + "type": "float", + "data_points": [ + -10, + -10, + -10, + -10, + -10, + -10, + -10, + -10, + -10, + -10, + -10, + -10, + -10, + -10, + -10, + -10, + -10, + -10, + -10, + -10 + ] + } + ] +} \ No newline at end of file diff --git a/mechanics/tests/goldens/revealAnimation.json b/mechanics/tests/goldens/revealAnimation.json new file mode 100644 index 0000000..f18dacc --- /dev/null +++ b/mechanics/tests/goldens/revealAnimation.json @@ -0,0 +1,292 @@ +{ + "frame_ids": [ + 0, + 16, + 32, + 48, + 64, + 80, + 96, + 112, + 128, + 144, + 160, + 176, + 192, + 208, + 224, + 240, + 256, + 272, + 288, + 304, + 320, + 336, + 352, + 368, + 384 + ], + "features": [ + { + "name": "input", + "type": "float", + "data_points": [ + 0, + 3, + 6, + 9, + 12, + 15, + 18, + 21, + 24, + 27, + 30, + 33, + 36, + 36, + 36, + 36, + 36, + 36, + 36, + 36, + 36, + 36, + 36, + 36, + 36 + ] + }, + { + "name": "gestureDirection", + "type": "string", + "data_points": [ + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max" + ] + }, + { + "name": "output", + "type": "float", + "data_points": [ + 0, + 0, + 0, + 0, + 1.0731893, + 4.910625, + 9.1775, + 13.488271, + 17.657562, + 21.616333, + 25.358374, + 25.907566, + 26.298885, + 26.568165, + 26.74721, + 26.862015, + 26.93265, + 26.97394, + 26.996431, + 27.00737, + 27.011566, + 27.012094, + 27.010847, + 27.008924, + 27 + ] + }, + { + "name": "outputTarget", + "type": "float", + "data_points": [ + 0, + 0, + 0, + 0, + 9, + 12, + 15, + 18, + 21, + 24, + 27, + 27, + 27, + 27, + 27, + 27, + 27, + 27, + 27, + 27, + 27, + 27, + 27, + 27, + 27 + ] + }, + { + "name": "outputSpring", + "type": "springParameters", + "data_points": [ + { + "stiffness": 100000, + "dampingRatio": 1 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + } + ] + }, + { + "name": "isStable", + "type": "boolean", + "data_points": [ + true, + true, + true, + true, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + true + ] + } + ] +} \ No newline at end of file diff --git a/mechanics/tests/goldens/revealAnimation_afterFixedValue.json b/mechanics/tests/goldens/revealAnimation_afterFixedValue.json new file mode 100644 index 0000000..f18dacc --- /dev/null +++ b/mechanics/tests/goldens/revealAnimation_afterFixedValue.json @@ -0,0 +1,292 @@ +{ + "frame_ids": [ + 0, + 16, + 32, + 48, + 64, + 80, + 96, + 112, + 128, + 144, + 160, + 176, + 192, + 208, + 224, + 240, + 256, + 272, + 288, + 304, + 320, + 336, + 352, + 368, + 384 + ], + "features": [ + { + "name": "input", + "type": "float", + "data_points": [ + 0, + 3, + 6, + 9, + 12, + 15, + 18, + 21, + 24, + 27, + 30, + 33, + 36, + 36, + 36, + 36, + 36, + 36, + 36, + 36, + 36, + 36, + 36, + 36, + 36 + ] + }, + { + "name": "gestureDirection", + "type": "string", + "data_points": [ + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max" + ] + }, + { + "name": "output", + "type": "float", + "data_points": [ + 0, + 0, + 0, + 0, + 1.0731893, + 4.910625, + 9.1775, + 13.488271, + 17.657562, + 21.616333, + 25.358374, + 25.907566, + 26.298885, + 26.568165, + 26.74721, + 26.862015, + 26.93265, + 26.97394, + 26.996431, + 27.00737, + 27.011566, + 27.012094, + 27.010847, + 27.008924, + 27 + ] + }, + { + "name": "outputTarget", + "type": "float", + "data_points": [ + 0, + 0, + 0, + 0, + 9, + 12, + 15, + 18, + 21, + 24, + 27, + 27, + 27, + 27, + 27, + 27, + 27, + 27, + 27, + 27, + 27, + 27, + 27, + 27, + 27 + ] + }, + { + "name": "outputSpring", + "type": "springParameters", + "data_points": [ + { + "stiffness": 100000, + "dampingRatio": 1 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + } + ] + }, + { + "name": "isStable", + "type": "boolean", + "data_points": [ + true, + true, + true, + true, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + true + ] + } + ] +} \ No newline at end of file diff --git a/mechanics/tests/goldens/segmentChange_animationAtRest_doesNotAffectVelocity.json b/mechanics/tests/goldens/segmentChange_animationAtRest_doesNotAffectVelocity.json new file mode 100644 index 0000000..84c7d82 --- /dev/null +++ b/mechanics/tests/goldens/segmentChange_animationAtRest_doesNotAffectVelocity.json @@ -0,0 +1,312 @@ +{ + "frame_ids": [ + 0, + 16, + 32, + 48, + 64, + 80, + 96, + 112, + 128, + 144, + 160, + 176, + 192, + 208, + 224, + 240, + 256, + 272, + 288, + 304, + 320, + 336, + 352, + 368, + 384, + 400, + 416 + ], + "features": [ + { + "name": "input", + "type": "float", + "data_points": [ + 0, + 1.5, + 1.5, + 1.5, + 1.5, + 1.5, + 1.5, + 1.5, + 1.5, + 1.5, + 1.5, + 1.5, + 1.5, + 1.8, + 2.1, + 2.3999999, + 2.6999998, + 3, + 3, + 3, + 3, + 3, + 3, + 3, + 3, + 3, + 3 + ] + }, + { + "name": "gestureDirection", + "type": "string", + "data_points": [ + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max" + ] + }, + { + "name": "output", + "type": "float", + "data_points": [ + 0, + 0.18297386, + 2.2765617, + 5.4437504, + 8.720676, + 11.643903, + 14.040831, + 15.895933, + 17.268913, + 18.247213, + 18.920414, + 19.368025, + 20, + 20, + 20, + 20, + 20, + 20, + 19.303997, + 17.829481, + 16.173801, + 14.638149, + 13.348276, + 12.332502, + 11.570178, + 11.020348, + 10 + ] + }, + { + "name": "outputTarget", + "type": "float", + "data_points": [ + 0, + 20, + 20, + 20, + 20, + 20, + 20, + 20, + 20, + 20, + 20, + 20, + 20, + 20, + 20, + 20, + 20, + 10, + 10, + 10, + 10, + 10, + 10, + 10, + 10, + 10, + 10 + ] + }, + { + "name": "outputSpring", + "type": "springParameters", + "data_points": [ + { + "stiffness": 100000, + "dampingRatio": 1 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + } + ] + }, + { + "name": "isStable", + "type": "boolean", + "data_points": [ + true, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + true, + true, + true, + true, + true, + false, + false, + false, + false, + false, + false, + false, + false, + false, + true + ] + } + ] +} \ No newline at end of file diff --git a/mechanics/tests/goldens/segmentChange_appliesOutputVelocity_atSpringStart.json b/mechanics/tests/goldens/segmentChange_appliesOutputVelocity_atSpringStart.json new file mode 100644 index 0000000..964d834 --- /dev/null +++ b/mechanics/tests/goldens/segmentChange_appliesOutputVelocity_atSpringStart.json @@ -0,0 +1,272 @@ +{ + "frame_ids": [ + 0, + 16, + 32, + 48, + 64, + 80, + 96, + 112, + 128, + 144, + 160, + 176, + 192, + 208, + 224, + 240, + 256, + 272, + 288, + 304, + 320, + 336, + 352 + ], + "features": [ + { + "name": "input", + "type": "float", + "data_points": [ + 0, + 3, + 6, + 11, + 11, + 11, + 11, + 11, + 11, + 11, + 11, + 11, + 11, + 11, + 11, + 11, + 11, + 11, + 11, + 11, + 11, + 11, + 11 + ] + }, + { + "name": "gestureDirection", + "type": "string", + "data_points": [ + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max" + ] + }, + { + "name": "output", + "type": "float", + "data_points": [ + 0, + 3, + 6, + 10.58992, + 13.213701, + 15.27689, + 16.823486, + 17.93786, + 18.712797, + 19.23355, + 19.571314, + 19.781935, + 19.907185, + 19.977114, + 20.01258, + 20.02758, + 20.031174, + 20.028997, + 20.024399, + 20.019238, + 20.014452, + 20.010433, + 20 + ] + }, + { + "name": "outputTarget", + "type": "float", + "data_points": [ + 0, + 3, + 6, + 20, + 20, + 20, + 20, + 20, + 20, + 20, + 20, + 20, + 20, + 20, + 20, + 20, + 20, + 20, + 20, + 20, + 20, + 20, + 20 + ] + }, + { + "name": "outputSpring", + "type": "springParameters", + "data_points": [ + { + "stiffness": 100000, + "dampingRatio": 1 + }, + { + "stiffness": 100000, + "dampingRatio": 1 + }, + { + "stiffness": 100000, + "dampingRatio": 1 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + } + ] + }, + { + "name": "isStable", + "type": "boolean", + "data_points": [ + true, + true, + true, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + true + ] + } + ] +} \ No newline at end of file diff --git a/mechanics/tests/goldens/segmentChange_appliesOutputVelocity_springVelocityIsNotAppliedTwice.json b/mechanics/tests/goldens/segmentChange_appliesOutputVelocity_springVelocityIsNotAppliedTwice.json new file mode 100644 index 0000000..fce12cb --- /dev/null +++ b/mechanics/tests/goldens/segmentChange_appliesOutputVelocity_springVelocityIsNotAppliedTwice.json @@ -0,0 +1,312 @@ +{ + "frame_ids": [ + 0, + 16, + 32, + 48, + 64, + 80, + 96, + 112, + 128, + 144, + 160, + 176, + 192, + 208, + 224, + 240, + 256, + 272, + 288, + 304, + 320, + 336, + 352, + 368, + 384, + 400, + 416 + ], + "features": [ + { + "name": "input", + "type": "float", + "data_points": [ + 0, + 3, + 6, + 9, + 12, + 15, + 18, + 21, + 21, + 21, + 21, + 21, + 21, + 21, + 21, + 21, + 21, + 21, + 21, + 21, + 21, + 21, + 21, + 21, + 21, + 21, + 21 + ] + }, + { + "name": "gestureDirection", + "type": "string", + "data_points": [ + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max" + ] + }, + { + "name": "output", + "type": "float", + "data_points": [ + 0, + 3, + 6, + 9, + 14.22027, + 20.881996, + 27.33559, + 32.265293, + 34.588463, + 36.34147, + 37.611744, + 38.499744, + 39.099594, + 39.49083, + 39.73635, + 39.883526, + 39.96661, + 40.009514, + 40.028366, + 40.033657, + 40.03197, + 40.027233, + 40.021656, + 40.016376, + 40.01189, + 40.008324, + 40 + ] + }, + { + "name": "outputTarget", + "type": "float", + "data_points": [ + 0, + 3, + 6, + 9, + 32, + 35, + 38, + 40, + 40, + 40, + 40, + 40, + 40, + 40, + 40, + 40, + 40, + 40, + 40, + 40, + 40, + 40, + 40, + 40, + 40, + 40, + 40 + ] + }, + { + "name": "outputSpring", + "type": "springParameters", + "data_points": [ + { + "stiffness": 100000, + "dampingRatio": 1 + }, + { + "stiffness": 100000, + "dampingRatio": 1 + }, + { + "stiffness": 100000, + "dampingRatio": 1 + }, + { + "stiffness": 100000, + "dampingRatio": 1 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + } + ] + }, + { + "name": "isStable", + "type": "boolean", + "data_points": [ + true, + true, + true, + true, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + true + ] + } + ] +} \ No newline at end of file diff --git a/mechanics/tests/goldens/segmentChange_appliesOutputVelocity_velocityAddedOnDiscontinuousSegment.json b/mechanics/tests/goldens/segmentChange_appliesOutputVelocity_velocityAddedOnDiscontinuousSegment.json new file mode 100644 index 0000000..e22cde1 --- /dev/null +++ b/mechanics/tests/goldens/segmentChange_appliesOutputVelocity_velocityAddedOnDiscontinuousSegment.json @@ -0,0 +1,372 @@ +{ + "frame_ids": [ + 0, + 16, + 32, + 48, + 64, + 80, + 96, + 112, + 128, + 144, + 160, + 176, + 192, + 208, + 224, + 240, + 256, + 272, + 288, + 304, + 320, + 336, + 352, + 368, + 384, + 400, + 416, + 432, + 448, + 464, + 480, + 496, + 512 + ], + "features": [ + { + "name": "input", + "type": "float", + "data_points": [ + 0, + 3, + 6, + 9, + 12, + 15, + 18, + 21, + 24, + 27, + 30, + 30, + 30, + 30, + 30, + 30, + 30, + 30, + 30, + 30, + 30, + 30, + 30, + 30, + 30, + 30, + 30, + 30, + 30, + 30, + 30, + 30, + 30 + ] + }, + { + "name": "gestureDirection", + "type": "string", + "data_points": [ + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max" + ] + }, + { + "name": "output", + "type": "float", + "data_points": [ + 0, + 3, + 6, + 9, + 21.715681, + 38.426617, + 54.4196, + 69.30995, + 76.55414, + 77.88977, + 76.3026, + 73.54735, + 70.58736, + 67.89756, + 65.666245, + 63.924633, + 62.626797, + 61.696457, + 61.052605, + 60.62202, + 60.344193, + 60.171986, + 60.07036, + 60.01423, + 59.986275, + 59.974922, + 59.97272, + 59.975067, + 59.97924, + 59.983753, + 59.98787, + 59.991287, + 60 + ] + }, + { + "name": "outputTarget", + "type": "float", + "data_points": [ + 0, + 3, + 6, + 9, + 25, + 40, + 55, + 60, + 60, + 60, + 60, + 60, + 60, + 60, + 60, + 60, + 60, + 60, + 60, + 60, + 60, + 60, + 60, + 60, + 60, + 60, + 60, + 60, + 60, + 60, + 60, + 60, + 60 + ] + }, + { + "name": "outputSpring", + "type": "springParameters", + "data_points": [ + { + "stiffness": 100000, + "dampingRatio": 1 + }, + { + "stiffness": 100000, + "dampingRatio": 1 + }, + { + "stiffness": 100000, + "dampingRatio": 1 + }, + { + "stiffness": 100000, + "dampingRatio": 1 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + } + ] + }, + { + "name": "isStable", + "type": "boolean", + "data_points": [ + true, + true, + true, + true, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + true + ] + } + ] +} \ No newline at end of file diff --git a/mechanics/tests/goldens/segmentChange_appliesOutputVelocity_velocityNotAddedOnContinuousSegment.json b/mechanics/tests/goldens/segmentChange_appliesOutputVelocity_velocityNotAddedOnContinuousSegment.json new file mode 100644 index 0000000..6f04279 --- /dev/null +++ b/mechanics/tests/goldens/segmentChange_appliesOutputVelocity_velocityNotAddedOnContinuousSegment.json @@ -0,0 +1,262 @@ +{ + "frame_ids": [ + 0, + 16, + 32, + 48, + 64, + 80, + 96, + 112, + 128, + 144, + 160, + 176, + 192, + 208, + 224, + 240, + 256, + 272, + 288, + 304, + 320, + 336 + ], + "features": [ + { + "name": "input", + "type": "float", + "data_points": [ + 0, + 3, + 6, + 9, + 12, + 15, + 18, + 21, + 24, + 27, + 30, + 30, + 30, + 30, + 30, + 30, + 30, + 30, + 30, + 30, + 30, + 30 + ] + }, + { + "name": "gestureDirection", + "type": "string", + "data_points": [ + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max" + ] + }, + { + "name": "output", + "type": "float", + "data_points": [ + 0, + 3, + 6, + 9, + 21.715681, + 38.426617, + 54.4196, + 64.95479, + 65.21017, + 65.3034, + 65.30942, + 65.274, + 65.22361, + 65.172455, + 65.12727, + 65.09046, + 65.062096, + 65.04118, + 65.02634, + 65.01615, + 65.0094, + 65 + ] + }, + { + "name": "outputTarget", + "type": "float", + "data_points": [ + 0, + 3, + 6, + 9, + 25, + 40, + 55, + 65, + 65, + 65, + 65, + 65, + 65, + 65, + 65, + 65, + 65, + 65, + 65, + 65, + 65, + 65 + ] + }, + { + "name": "outputSpring", + "type": "springParameters", + "data_points": [ + { + "stiffness": 100000, + "dampingRatio": 1 + }, + { + "stiffness": 100000, + "dampingRatio": 1 + }, + { + "stiffness": 100000, + "dampingRatio": 1 + }, + { + "stiffness": 100000, + "dampingRatio": 1 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + } + ] + }, + { + "name": "isStable", + "type": "boolean", + "data_points": [ + true, + true, + true, + true, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + true + ] + } + ] +} \ No newline at end of file diff --git a/mechanics/tests/goldens/segmentChange_guaranteeGestureDragDelta_springCompletesWithinDistance.json b/mechanics/tests/goldens/segmentChange_guaranteeGestureDragDelta_springCompletesWithinDistance.json new file mode 100644 index 0000000..755ff78 --- /dev/null +++ b/mechanics/tests/goldens/segmentChange_guaranteeGestureDragDelta_springCompletesWithinDistance.json @@ -0,0 +1,122 @@ +{ + "frame_ids": [ + 0, + 16, + 32, + 48, + 64, + 80, + 96, + 112 + ], + "features": [ + { + "name": "input", + "type": "float", + "data_points": [ + 0, + 0.5, + 1, + 1, + 1, + 1, + 1, + 1 + ] + }, + { + "name": "gestureDirection", + "type": "string", + "data_points": [ + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max" + ] + }, + { + "name": "output", + "type": "float", + "data_points": [ + 0, + 0, + 0, + 0.13920438, + 0.45275474, + 0.772992, + 0.9506903, + 1 + ] + }, + { + "name": "outputTarget", + "type": "float", + "data_points": [ + 0, + 0, + 1, + 1, + 1, + 1, + 1, + 1 + ] + }, + { + "name": "outputSpring", + "type": "springParameters", + "data_points": [ + { + "stiffness": 100000, + "dampingRatio": 1 + }, + { + "stiffness": 100000, + "dampingRatio": 1 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 1600.4729, + "dampingRatio": 0.9166666 + }, + { + "stiffness": 3659.3052, + "dampingRatio": 0.9333333 + }, + { + "stiffness": 8366.601, + "dampingRatio": 0.95 + }, + { + "stiffness": 19129.314, + "dampingRatio": 0.9666667 + }, + { + "stiffness": 43737.062, + "dampingRatio": 0.98333335 + } + ] + }, + { + "name": "isStable", + "type": "boolean", + "data_points": [ + true, + true, + false, + false, + false, + false, + false, + true + ] + } + ] +} \ No newline at end of file diff --git a/mechanics/tests/goldens/segmentChange_guaranteeInputDelta_springCompletesWithinDistance.json b/mechanics/tests/goldens/segmentChange_guaranteeInputDelta_springCompletesWithinDistance.json new file mode 100644 index 0000000..003f74b --- /dev/null +++ b/mechanics/tests/goldens/segmentChange_guaranteeInputDelta_springCompletesWithinDistance.json @@ -0,0 +1,132 @@ +{ + "frame_ids": [ + 0, + 16, + 32, + 48, + 64, + 80, + 96, + 112, + 128 + ], + "features": [ + { + "name": "input", + "type": "float", + "data_points": [ + 0, + 0.5, + 1, + 1.5, + 2, + 2.5, + 3, + 3.5, + 4 + ] + }, + { + "name": "gestureDirection", + "type": "string", + "data_points": [ + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max" + ] + }, + { + "name": "output", + "type": "float", + "data_points": [ + 0, + 0, + 0, + 0.13920438, + 0.45275474, + 0.772992, + 0.9506903, + 1, + 1 + ] + }, + { + "name": "outputTarget", + "type": "float", + "data_points": [ + 0, + 0, + 1, + 1, + 1, + 1, + 1, + 1, + 1 + ] + }, + { + "name": "outputSpring", + "type": "springParameters", + "data_points": [ + { + "stiffness": 100000, + "dampingRatio": 1 + }, + { + "stiffness": 100000, + "dampingRatio": 1 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 1600.4729, + "dampingRatio": 0.9166666 + }, + { + "stiffness": 3659.3052, + "dampingRatio": 0.9333333 + }, + { + "stiffness": 8366.601, + "dampingRatio": 0.95 + }, + { + "stiffness": 19129.314, + "dampingRatio": 0.9666667 + }, + { + "stiffness": 43737.062, + "dampingRatio": 0.98333335 + }, + { + "stiffness": 43737.062, + "dampingRatio": 0.98333335 + } + ] + }, + { + "name": "isStable", + "type": "boolean", + "data_points": [ + true, + true, + false, + false, + false, + false, + false, + true, + true + ] + } + ] +} \ No newline at end of file diff --git a/mechanics/tests/goldens/segmentChange_guaranteeNone_springAnimatesIndependentOfInput.json b/mechanics/tests/goldens/segmentChange_guaranteeNone_springAnimatesIndependentOfInput.json new file mode 100644 index 0000000..58706ef --- /dev/null +++ b/mechanics/tests/goldens/segmentChange_guaranteeNone_springAnimatesIndependentOfInput.json @@ -0,0 +1,212 @@ +{ + "frame_ids": [ + 0, + 16, + 32, + 48, + 64, + 80, + 96, + 112, + 128, + 144, + 160, + 176, + 192, + 208, + 224, + 240, + 256 + ], + "features": [ + { + "name": "input", + "type": "float", + "data_points": [ + 0, + 0.5, + 1, + 1.5, + 2, + 2.5, + 3, + 3.5, + 4, + 4.5, + 5, + 5, + 5, + 5, + 5, + 5, + 5 + ] + }, + { + "name": "gestureDirection", + "type": "string", + "data_points": [ + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max" + ] + }, + { + "name": "output", + "type": "float", + "data_points": [ + 0, + 0, + 0, + 0.0696004, + 0.21705192, + 0.38261998, + 0.536185, + 0.6651724, + 0.7667498, + 0.8429822, + 0.89796525, + 0.93623626, + 0.9619781, + 0.9786911, + 0.98912483, + 0.9953385, + 1 + ] + }, + { + "name": "outputTarget", + "type": "float", + "data_points": [ + 0, + 0, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1 + ] + }, + { + "name": "outputSpring", + "type": "springParameters", + "data_points": [ + { + "stiffness": 100000, + "dampingRatio": 1 + }, + { + "stiffness": 100000, + "dampingRatio": 1 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + } + ] + }, + { + "name": "isStable", + "type": "boolean", + "data_points": [ + true, + true, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + true + ] + } + ] +} \ No newline at end of file diff --git a/mechanics/tests/goldens/segmentChange_inMaxDirection_animatedWhenReachingBreakpoint.json b/mechanics/tests/goldens/segmentChange_inMaxDirection_animatedWhenReachingBreakpoint.json new file mode 100644 index 0000000..f93fc6f --- /dev/null +++ b/mechanics/tests/goldens/segmentChange_inMaxDirection_animatedWhenReachingBreakpoint.json @@ -0,0 +1,212 @@ +{ + "frame_ids": [ + 0, + 16, + 32, + 48, + 64, + 80, + 96, + 112, + 128, + 144, + 160, + 176, + 192, + 208, + 224, + 240, + 256 + ], + "features": [ + { + "name": "input", + "type": "float", + "data_points": [ + 0, + 0.5, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1 + ] + }, + { + "name": "gestureDirection", + "type": "string", + "data_points": [ + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max" + ] + }, + { + "name": "output", + "type": "float", + "data_points": [ + 0, + 0, + 0, + 0.0696004, + 0.21705192, + 0.38261998, + 0.536185, + 0.6651724, + 0.7667498, + 0.8429822, + 0.89796525, + 0.93623626, + 0.9619781, + 0.9786911, + 0.98912483, + 0.9953385, + 1 + ] + }, + { + "name": "outputTarget", + "type": "float", + "data_points": [ + 0, + 0, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1 + ] + }, + { + "name": "outputSpring", + "type": "springParameters", + "data_points": [ + { + "stiffness": 100000, + "dampingRatio": 1 + }, + { + "stiffness": 100000, + "dampingRatio": 1 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + } + ] + }, + { + "name": "isStable", + "type": "boolean", + "data_points": [ + true, + true, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + true + ] + } + ] +} \ No newline at end of file diff --git a/mechanics/tests/goldens/segmentChange_inMaxDirection_springAnimationStartedRetroactively.json b/mechanics/tests/goldens/segmentChange_inMaxDirection_springAnimationStartedRetroactively.json new file mode 100644 index 0000000..2355188 --- /dev/null +++ b/mechanics/tests/goldens/segmentChange_inMaxDirection_springAnimationStartedRetroactively.json @@ -0,0 +1,202 @@ +{ + "frame_ids": [ + 0, + 16, + 32, + 48, + 64, + 80, + 96, + 112, + 128, + 144, + 160, + 176, + 192, + 208, + 224, + 240 + ], + "features": [ + { + "name": "input", + "type": "float", + "data_points": [ + 0, + 0.5, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1 + ] + }, + { + "name": "gestureDirection", + "type": "string", + "data_points": [ + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max" + ] + }, + { + "name": "output", + "type": "float", + "data_points": [ + 0, + 0, + 0.01973492, + 0.1381998, + 0.29998195, + 0.4619913, + 0.6040878, + 0.71933174, + 0.80780226, + 0.8728444, + 0.9189145, + 0.95043683, + 0.971274, + 0.9845492, + 0.9926545, + 1 + ] + }, + { + "name": "outputTarget", + "type": "float", + "data_points": [ + 0, + 0, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1 + ] + }, + { + "name": "outputSpring", + "type": "springParameters", + "data_points": [ + { + "stiffness": 100000, + "dampingRatio": 1 + }, + { + "stiffness": 100000, + "dampingRatio": 1 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + } + ] + }, + { + "name": "isStable", + "type": "boolean", + "data_points": [ + true, + true, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + true + ] + } + ] +} \ No newline at end of file diff --git a/mechanics/tests/goldens/segmentChange_inMaxDirection_zeroDelta.json b/mechanics/tests/goldens/segmentChange_inMaxDirection_zeroDelta.json new file mode 100644 index 0000000..f68e961 --- /dev/null +++ b/mechanics/tests/goldens/segmentChange_inMaxDirection_zeroDelta.json @@ -0,0 +1,82 @@ +{ + "frame_ids": [ + 0, + 16, + 32, + 48 + ], + "features": [ + { + "name": "input", + "type": "float", + "data_points": [ + 0, + 0.5, + 1, + 1 + ] + }, + { + "name": "gestureDirection", + "type": "string", + "data_points": [ + "Max", + "Max", + "Max", + "Max" + ] + }, + { + "name": "output", + "type": "float", + "data_points": [ + 0, + 0, + 0, + 0 + ] + }, + { + "name": "outputTarget", + "type": "float", + "data_points": [ + 0, + 0, + 0, + 0 + ] + }, + { + "name": "outputSpring", + "type": "springParameters", + "data_points": [ + { + "stiffness": 100000, + "dampingRatio": 1 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + } + ] + }, + { + "name": "isStable", + "type": "boolean", + "data_points": [ + true, + true, + true, + true + ] + } + ] +} \ No newline at end of file diff --git a/mechanics/tests/goldens/segmentChange_inMinDirection_animatedWhenReachingBreakpoint.json b/mechanics/tests/goldens/segmentChange_inMinDirection_animatedWhenReachingBreakpoint.json new file mode 100644 index 0000000..f00535c --- /dev/null +++ b/mechanics/tests/goldens/segmentChange_inMinDirection_animatedWhenReachingBreakpoint.json @@ -0,0 +1,212 @@ +{ + "frame_ids": [ + 0, + 16, + 32, + 48, + 64, + 80, + 96, + 112, + 128, + 144, + 160, + 176, + 192, + 208, + 224, + 240, + 256 + ], + "features": [ + { + "name": "input", + "type": "float", + "data_points": [ + 2, + 1.5, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1 + ] + }, + { + "name": "gestureDirection", + "type": "string", + "data_points": [ + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min" + ] + }, + { + "name": "output", + "type": "float", + "data_points": [ + 1, + 1, + 1, + 0.9303996, + 0.7829481, + 0.61738, + 0.46381494, + 0.3348276, + 0.23325022, + 0.15701781, + 0.10203475, + 0.06376373, + 0.038021944, + 0.021308914, + 0.010875157, + 0.004661471, + 0 + ] + }, + { + "name": "outputTarget", + "type": "float", + "data_points": [ + 1, + 1, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ] + }, + { + "name": "outputSpring", + "type": "springParameters", + "data_points": [ + { + "stiffness": 100000, + "dampingRatio": 1 + }, + { + "stiffness": 100000, + "dampingRatio": 1 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + } + ] + }, + { + "name": "isStable", + "type": "boolean", + "data_points": [ + true, + true, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + true + ] + } + ] +} \ No newline at end of file diff --git a/mechanics/tests/goldens/segmentChange_inMinDirection_springAnimationStartedRetroactively.json b/mechanics/tests/goldens/segmentChange_inMinDirection_springAnimationStartedRetroactively.json new file mode 100644 index 0000000..5efbd83 --- /dev/null +++ b/mechanics/tests/goldens/segmentChange_inMinDirection_springAnimationStartedRetroactively.json @@ -0,0 +1,202 @@ +{ + "frame_ids": [ + 0, + 16, + 32, + 48, + 64, + 80, + 96, + 112, + 128, + 144, + 160, + 176, + 192, + 208, + 224, + 240 + ], + "features": [ + { + "name": "input", + "type": "float", + "data_points": [ + 2, + 1.5, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1 + ] + }, + { + "name": "gestureDirection", + "type": "string", + "data_points": [ + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min" + ] + }, + { + "name": "output", + "type": "float", + "data_points": [ + 1, + 1, + 0.9802651, + 0.8618002, + 0.70001805, + 0.5380087, + 0.3959122, + 0.28066823, + 0.19219775, + 0.1271556, + 0.08108548, + 0.04956314, + 0.028725967, + 0.0154508, + 0.007345509, + 0 + ] + }, + { + "name": "outputTarget", + "type": "float", + "data_points": [ + 1, + 1, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ] + }, + { + "name": "outputSpring", + "type": "springParameters", + "data_points": [ + { + "stiffness": 100000, + "dampingRatio": 1 + }, + { + "stiffness": 100000, + "dampingRatio": 1 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + } + ] + }, + { + "name": "isStable", + "type": "boolean", + "data_points": [ + true, + true, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + true + ] + } + ] +} \ No newline at end of file diff --git a/mechanics/tests/goldens/semantics_flipsBetweenDirectionalSegments.json b/mechanics/tests/goldens/semantics_flipsBetweenDirectionalSegments.json new file mode 100644 index 0000000..2b2107e --- /dev/null +++ b/mechanics/tests/goldens/semantics_flipsBetweenDirectionalSegments.json @@ -0,0 +1,323 @@ +{ + "frame_ids": [ + 0, + 16, + 32, + 48, + 64, + 80, + 96, + 112, + 128, + 144, + 160, + 176, + 192, + 208, + 224, + 240, + 256, + 272, + 288, + 304, + 320, + 336, + 352, + 368, + 384 + ], + "features": [ + { + "name": "input", + "type": "float", + "data_points": [ + 0, + 0.2, + 0.4, + 0.6, + 0.8, + 1, + 1.2, + 1.4000001, + 1.6000001, + 1.8000002, + 2.0000002, + 2.2000003, + 2.4000003, + 2.6000004, + 2.8000004, + 3, + 3, + 3, + 3, + 3, + 3, + 3, + 3, + 3, + 3 + ] + }, + { + "name": "gestureDirection", + "type": "string", + "data_points": [ + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max" + ] + }, + { + "name": "output", + "type": "float", + "data_points": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0.0696004, + 0.21705192, + 0.38261998, + 0.536185, + 0.66517246, + 0.8363503, + 1.0600344, + 1.2805854, + 1.4724215, + 1.6271507, + 1.745441, + 1.8321071, + 1.8933039, + 1.9350511, + 1.9625617, + 1.9800283, + 1.9906485, + 1.996761, + 2 + ] + }, + { + "name": "outputTarget", + "type": "float", + "data_points": [ + 0, + 0, + 0, + 0, + 0, + 1, + 1, + 1, + 1, + 1, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2 + ] + }, + { + "name": "outputSpring", + "type": "springParameters", + "data_points": [ + { + "stiffness": 100000, + "dampingRatio": 1 + }, + { + "stiffness": 100000, + "dampingRatio": 1 + }, + { + "stiffness": 100000, + "dampingRatio": 1 + }, + { + "stiffness": 100000, + "dampingRatio": 1 + }, + { + "stiffness": 100000, + "dampingRatio": 1 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + } + ] + }, + { + "name": "isStable", + "type": "boolean", + "data_points": [ + true, + true, + true, + true, + true, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + true + ] + }, + { + "name": "Foo", + "type": "string", + "data_points": [ + "zero", + "zero", + "zero", + "zero", + "zero", + "one", + "one", + "one", + "one", + "one", + "two", + "two", + "two", + "two", + "two", + "two", + "two", + "two", + "two", + "two", + "two", + "two", + "two", + "two", + "two" + ] + } + ] +} \ No newline at end of file diff --git a/mechanics/tests/goldens/snapSpring_updatesImmediately_matchesGolden.json b/mechanics/tests/goldens/snapSpring_updatesImmediately_matchesGolden.json new file mode 100644 index 0000000..a772271 --- /dev/null +++ b/mechanics/tests/goldens/snapSpring_updatesImmediately_matchesGolden.json @@ -0,0 +1,54 @@ +{ + "frame_ids": [ + 0, + 10, + 20 + ], + "features": [ + { + "name": "displacement", + "type": "float", + "data_points": [ + 10, + 0, + 0 + ] + }, + { + "name": "velocity", + "type": "float", + "data_points": [ + -10, + 0, + 0 + ] + }, + { + "name": "stable", + "type": "boolean", + "data_points": [ + false, + true, + true + ] + }, + { + "name": "parameters", + "type": "springParameters", + "data_points": [ + { + "stiffness": 100000, + "dampingRatio": 1 + }, + { + "stiffness": 100000, + "dampingRatio": 1 + }, + { + "stiffness": 100000, + "dampingRatio": 1 + } + ] + } + ] +} \ No newline at end of file diff --git a/mechanics/tests/goldens/specChange_shiftSegmentBackwards_doesNotAnimateWithinSegment_animatesSegmentChange.json b/mechanics/tests/goldens/specChange_shiftSegmentBackwards_doesNotAnimateWithinSegment_animatesSegmentChange.json new file mode 100644 index 0000000..4b61281 --- /dev/null +++ b/mechanics/tests/goldens/specChange_shiftSegmentBackwards_doesNotAnimateWithinSegment_animatesSegmentChange.json @@ -0,0 +1,182 @@ +{ + "frame_ids": [ + 0, + 16, + 32, + 48, + 64, + 80, + 96, + 112, + 128, + 144, + 160, + 176, + 192, + 208 + ], + "features": [ + { + "name": "input", + "type": "float", + "data_points": [ + 0.5, + 0.5, + 0.5, + 0.5, + 0.5, + 0.5, + 0.5, + 0.5, + 0.5, + 0.5, + 0.5, + 0.5, + 0.5, + 0.5 + ] + }, + { + "name": "gestureDirection", + "type": "string", + "data_points": [ + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max" + ] + }, + { + "name": "output", + "type": "float", + "data_points": [ + 1.5, + 1.7, + 1.9, + 1.7769231, + 1.356505, + 0.92442614, + 0.5804193, + 0.3396739, + 0.18536365, + 0.09334825, + 0.042139973, + 0.015731357, + 0.003390434, + 0 + ] + }, + { + "name": "outputTarget", + "type": "float", + "data_points": [ + 1.5, + 1.7, + 1.9, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ] + }, + { + "name": "outputSpring", + "type": "springParameters", + "data_points": [ + { + "stiffness": 100000, + "dampingRatio": 1 + }, + { + "stiffness": 100000, + "dampingRatio": 1 + }, + { + "stiffness": 100000, + "dampingRatio": 1 + }, + { + "stiffness": 1400, + "dampingRatio": 0.9 + }, + { + "stiffness": 1400, + "dampingRatio": 0.9 + }, + { + "stiffness": 1400, + "dampingRatio": 0.9 + }, + { + "stiffness": 1400, + "dampingRatio": 0.9 + }, + { + "stiffness": 1400, + "dampingRatio": 0.9 + }, + { + "stiffness": 1400, + "dampingRatio": 0.9 + }, + { + "stiffness": 1400, + "dampingRatio": 0.9 + }, + { + "stiffness": 1400, + "dampingRatio": 0.9 + }, + { + "stiffness": 1400, + "dampingRatio": 0.9 + }, + { + "stiffness": 1400, + "dampingRatio": 0.9 + }, + { + "stiffness": 1400, + "dampingRatio": 0.9 + } + ] + }, + { + "name": "isStable", + "type": "boolean", + "data_points": [ + true, + true, + true, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + true + ] + } + ] +} \ No newline at end of file diff --git a/mechanics/tests/goldens/specChange_shiftSegmentForward_doesNotAnimateWithinSegment_animatesSegmentChange.json b/mechanics/tests/goldens/specChange_shiftSegmentForward_doesNotAnimateWithinSegment_animatesSegmentChange.json new file mode 100644 index 0000000..faf1211 --- /dev/null +++ b/mechanics/tests/goldens/specChange_shiftSegmentForward_doesNotAnimateWithinSegment_animatesSegmentChange.json @@ -0,0 +1,162 @@ +{ + "frame_ids": [ + 0, + 16, + 32, + 48, + 64, + 80, + 96, + 112, + 128, + 144, + 160, + 176 + ], + "features": [ + { + "name": "input", + "type": "float", + "data_points": [ + 0.5, + 0.5, + 0.5, + 0.5, + 0.5, + 0.5, + 0.5, + 0.5, + 0.5, + 0.5, + 0.5, + 0.5 + ] + }, + { + "name": "gestureDirection", + "type": "string", + "data_points": [ + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max" + ] + }, + { + "name": "output", + "type": "float", + "data_points": [ + 1.5, + 1.3, + 1.1, + 0.84658206, + 0.579976, + 0.36567324, + 0.21482599, + 0.11771336, + 0.059579656, + 0.027098525, + 0.010269003, + 0 + ] + }, + { + "name": "outputTarget", + "type": "float", + "data_points": [ + 1.5, + 1.3, + 1.1, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ] + }, + { + "name": "outputSpring", + "type": "springParameters", + "data_points": [ + { + "stiffness": 100000, + "dampingRatio": 1 + }, + { + "stiffness": 100000, + "dampingRatio": 1 + }, + { + "stiffness": 100000, + "dampingRatio": 1 + }, + { + "stiffness": 1400, + "dampingRatio": 0.9 + }, + { + "stiffness": 1400, + "dampingRatio": 0.9 + }, + { + "stiffness": 1400, + "dampingRatio": 0.9 + }, + { + "stiffness": 1400, + "dampingRatio": 0.9 + }, + { + "stiffness": 1400, + "dampingRatio": 0.9 + }, + { + "stiffness": 1400, + "dampingRatio": 0.9 + }, + { + "stiffness": 1400, + "dampingRatio": 0.9 + }, + { + "stiffness": 1400, + "dampingRatio": 0.9 + }, + { + "stiffness": 1400, + "dampingRatio": 0.9 + } + ] + }, + { + "name": "isStable", + "type": "boolean", + "data_points": [ + true, + true, + true, + false, + false, + false, + false, + false, + false, + false, + false, + true + ] + } + ] +} \ No newline at end of file diff --git a/mechanics/tests/goldens/stiffeningSpring_matchesGolden.json b/mechanics/tests/goldens/stiffeningSpring_matchesGolden.json new file mode 100644 index 0000000..78ef84e --- /dev/null +++ b/mechanics/tests/goldens/stiffeningSpring_matchesGolden.json @@ -0,0 +1,182 @@ +{ + "frame_ids": [ + 0, + 10, + 20, + 30, + 40, + 50, + 60, + 70, + 80, + 90, + 100, + 110, + 120, + 130, + 140, + 150, + 160, + 170, + 180 + ], + "features": [ + { + "name": "displacement", + "type": "float", + "data_points": [ + 10, + 9.854129, + 9.603651, + 9.21957, + 8.671186, + 7.931126, + 6.9840484, + 5.839193, + 4.544642, + 3.1971405, + 1.936856, + 0.9162949, + 0.24406782, + -0.070853025, + -0.12870777, + -0.07792437, + -0.02628906, + -0.004520869, + -0.00013658773 + ] + }, + { + "name": "velocity", + "type": "float", + "data_points": [ + -10, + -19.059385, + -30.821104, + -45.60961, + -63.405827, + -83.52585, + -104.22404, + -122.36138, + -133.45284, + -132.56017, + -116.37609, + -86.074936, + -49.053444, + -16.799938, + 1.6967986, + 6.1317945, + 3.6371715, + 1.0193218, + 0.10724213 + ] + }, + { + "name": "stable", + "type": "boolean", + "data_points": [ + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + true, + true + ] + }, + { + "name": "parameters", + "type": "springParameters", + "data_points": [ + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 141.25374, + "dampingRatio": 0.335 + }, + { + "stiffness": 199.52621, + "dampingRatio": 0.37 + }, + { + "stiffness": 281.83835, + "dampingRatio": 0.40500003 + }, + { + "stiffness": 398.10718, + "dampingRatio": 0.44 + }, + { + "stiffness": 562.3414, + "dampingRatio": 0.47500002 + }, + { + "stiffness": 794.3283, + "dampingRatio": 0.51 + }, + { + "stiffness": 1122.0183, + "dampingRatio": 0.545 + }, + { + "stiffness": 1584.8934, + "dampingRatio": 0.58000004 + }, + { + "stiffness": 2238.7207, + "dampingRatio": 0.615 + }, + { + "stiffness": 3162.2776, + "dampingRatio": 0.65 + }, + { + "stiffness": 4466.8364, + "dampingRatio": 0.685 + }, + { + "stiffness": 6309.574, + "dampingRatio": 0.72 + }, + { + "stiffness": 8912.508, + "dampingRatio": 0.755 + }, + { + "stiffness": 12589.254, + "dampingRatio": 0.78999996 + }, + { + "stiffness": 17782.793, + "dampingRatio": 0.825 + }, + { + "stiffness": 25118.865, + "dampingRatio": 0.86 + }, + { + "stiffness": 35481.344, + "dampingRatio": 0.89500004 + }, + { + "stiffness": 50118.715, + "dampingRatio": 0.93 + } + ] + } + ] +} \ No newline at end of file diff --git a/mechanics/tests/goldens/traverseSegmentsInOneFrame_noGuarantee_combinesDiscontinuity.json b/mechanics/tests/goldens/traverseSegmentsInOneFrame_noGuarantee_combinesDiscontinuity.json new file mode 100644 index 0000000..7420c91 --- /dev/null +++ b/mechanics/tests/goldens/traverseSegmentsInOneFrame_noGuarantee_combinesDiscontinuity.json @@ -0,0 +1,202 @@ +{ + "frame_ids": [ + 0, + 16, + 32, + 48, + 64, + 80, + 96, + 112, + 128, + 144, + 160, + 176, + 192, + 208, + 224, + 240 + ], + "features": [ + { + "name": "input", + "type": "float", + "data_points": [ + 0, + 2.5, + 2.5, + 2.5, + 2.5, + 2.5, + 2.5, + 2.5, + 2.5, + 2.5, + 2.5, + 2.5, + 2.5, + 2.5, + 2.5, + 2.5 + ] + }, + { + "name": "gestureDirection", + "type": "string", + "data_points": [ + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max" + ] + }, + { + "name": "output", + "type": "float", + "data_points": [ + 0, + 1.0034066, + 1.0953252, + 1.250015, + 1.4148881, + 1.5641418, + 1.6876622, + 1.783909, + 1.855533, + 1.9068143, + 1.9422642, + 1.9659443, + 1.981205, + 1.9906502, + 1.996214, + 2 + ] + }, + { + "name": "outputTarget", + "type": "float", + "data_points": [ + 0, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2 + ] + }, + { + "name": "outputSpring", + "type": "springParameters", + "data_points": [ + { + "stiffness": 100000, + "dampingRatio": 1 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + } + ] + }, + { + "name": "isStable", + "type": "boolean", + "data_points": [ + true, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + true + ] + } + ] +} \ No newline at end of file diff --git a/mechanics/tests/goldens/traverseSegmentsInOneFrame_withDirectionChange_appliesGuarantees.json b/mechanics/tests/goldens/traverseSegmentsInOneFrame_withDirectionChange_appliesGuarantees.json new file mode 100644 index 0000000..6f35e23 --- /dev/null +++ b/mechanics/tests/goldens/traverseSegmentsInOneFrame_withDirectionChange_appliesGuarantees.json @@ -0,0 +1,142 @@ +{ + "frame_ids": [ + 0, + 16, + 32, + 48, + 64, + 80, + 96, + 112, + 128, + 144 + ], + "features": [ + { + "name": "input", + "type": "float", + "data_points": [ + 2.5, + 0.4, + 0.3, + 0.20000002, + 0.10000002, + 0, + 0, + 0, + 0, + 0 + ] + }, + { + "name": "gestureDirection", + "type": "string", + "data_points": [ + "Max", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min" + ] + }, + { + "name": "output", + "type": "float", + "data_points": [ + 2, + 1.8607992, + 1.5158144, + 1.0649259, + 0.62475336, + 0.291457, + 0.111323975, + 0.03634805, + 0.009979475, + 0 + ] + }, + { + "name": "outputTarget", + "type": "float", + "data_points": [ + 2, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ] + }, + { + "name": "outputSpring", + "type": "springParameters", + "data_points": [ + { + "stiffness": 100000, + "dampingRatio": 1 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 1149.7095, + "dampingRatio": 0.90999997 + }, + { + "stiffness": 1888.3324, + "dampingRatio": 0.91999996 + }, + { + "stiffness": 3101.4778, + "dampingRatio": 0.93000007 + }, + { + "stiffness": 5094, + "dampingRatio": 0.94000006 + }, + { + "stiffness": 5094, + "dampingRatio": 0.94000006 + }, + { + "stiffness": 5094, + "dampingRatio": 0.94000006 + }, + { + "stiffness": 5094, + "dampingRatio": 0.94000006 + }, + { + "stiffness": 5094, + "dampingRatio": 0.94000006 + } + ] + }, + { + "name": "isStable", + "type": "boolean", + "data_points": [ + true, + false, + false, + false, + false, + false, + false, + false, + false, + true + ] + } + ] +} \ No newline at end of file diff --git a/mechanics/tests/goldens/traverseSegmentsInOneFrame_withGuarantee_appliesGuarantees.json b/mechanics/tests/goldens/traverseSegmentsInOneFrame_withGuarantee_appliesGuarantees.json new file mode 100644 index 0000000..ea0b8f6 --- /dev/null +++ b/mechanics/tests/goldens/traverseSegmentsInOneFrame_withGuarantee_appliesGuarantees.json @@ -0,0 +1,172 @@ +{ + "frame_ids": [ + 0, + 16, + 32, + 48, + 64, + 80, + 96, + 112, + 128, + 144, + 160, + 176, + 192 + ], + "features": [ + { + "name": "input", + "type": "float", + "data_points": [ + 0, + 2.1, + 2.1, + 2.1, + 2.1, + 2.1, + 2.1, + 2.1, + 2.1, + 2.1, + 2.1, + 2.1, + 2.1 + ] + }, + { + "name": "gestureDirection", + "type": "string", + "data_points": [ + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max" + ] + }, + { + "name": "output", + "type": "float", + "data_points": [ + 0, + 5.000347, + 5.12011, + 5.3309407, + 5.534604, + 5.6969075, + 5.8133464, + 5.8910213, + 5.93988, + 5.969008, + 5.9854507, + 5.9941716, + 6 + ] + }, + { + "name": "outputTarget", + "type": "float", + "data_points": [ + 0, + 6, + 6, + 6, + 6, + 6, + 6, + 6, + 6, + 6, + 6, + 6, + 6 + ] + }, + { + "name": "outputSpring", + "type": "springParameters", + "data_points": [ + { + "stiffness": 100000, + "dampingRatio": 1 + }, + { + "stiffness": 1214.8745, + "dampingRatio": 0.91111106 + }, + { + "stiffness": 1214.8745, + "dampingRatio": 0.91111106 + }, + { + "stiffness": 1214.8745, + "dampingRatio": 0.91111106 + }, + { + "stiffness": 1214.8745, + "dampingRatio": 0.91111106 + }, + { + "stiffness": 1214.8745, + "dampingRatio": 0.91111106 + }, + { + "stiffness": 1214.8745, + "dampingRatio": 0.91111106 + }, + { + "stiffness": 1214.8745, + "dampingRatio": 0.91111106 + }, + { + "stiffness": 1214.8745, + "dampingRatio": 0.91111106 + }, + { + "stiffness": 1214.8745, + "dampingRatio": 0.91111106 + }, + { + "stiffness": 1214.8745, + "dampingRatio": 0.91111106 + }, + { + "stiffness": 1214.8745, + "dampingRatio": 0.91111106 + }, + { + "stiffness": 1214.8745, + "dampingRatio": 0.91111106 + } + ] + }, + { + "name": "isStable", + "type": "boolean", + "data_points": [ + true, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + true + ] + } + ] +} \ No newline at end of file diff --git a/mechanics/tests/goldens/traverseSegments_maxDirection_noGuarantee_addsDiscontinuityToOngoingAnimation.json b/mechanics/tests/goldens/traverseSegments_maxDirection_noGuarantee_addsDiscontinuityToOngoingAnimation.json new file mode 100644 index 0000000..7baaf1d --- /dev/null +++ b/mechanics/tests/goldens/traverseSegments_maxDirection_noGuarantee_addsDiscontinuityToOngoingAnimation.json @@ -0,0 +1,292 @@ +{ + "frame_ids": [ + 0, + 16, + 32, + 48, + 64, + 80, + 96, + 112, + 128, + 144, + 160, + 176, + 192, + 208, + 224, + 240, + 256, + 272, + 288, + 304, + 320, + 336, + 352, + 368, + 384 + ], + "features": [ + { + "name": "input", + "type": "float", + "data_points": [ + 0, + 0.2, + 0.4, + 0.6, + 0.8, + 1, + 1.2, + 1.4000001, + 1.6000001, + 1.8000002, + 2.0000002, + 2.2000003, + 2.4000003, + 2.6000004, + 2.8000004, + 3, + 3, + 3, + 3, + 3, + 3, + 3, + 3, + 3, + 3 + ] + }, + { + "name": "gestureDirection", + "type": "string", + "data_points": [ + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max" + ] + }, + { + "name": "output", + "type": "float", + "data_points": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0.0696004, + 0.21705192, + 0.38261998, + 0.536185, + 0.66517246, + 0.8363503, + 1.0600344, + 1.2805854, + 1.4724215, + 1.6271507, + 1.745441, + 1.8321071, + 1.8933039, + 1.9350511, + 1.9625617, + 1.9800283, + 1.9906485, + 1.996761, + 2 + ] + }, + { + "name": "outputTarget", + "type": "float", + "data_points": [ + 0, + 0, + 0, + 0, + 0, + 1, + 1, + 1, + 1, + 1, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2 + ] + }, + { + "name": "outputSpring", + "type": "springParameters", + "data_points": [ + { + "stiffness": 100000, + "dampingRatio": 1 + }, + { + "stiffness": 100000, + "dampingRatio": 1 + }, + { + "stiffness": 100000, + "dampingRatio": 1 + }, + { + "stiffness": 100000, + "dampingRatio": 1 + }, + { + "stiffness": 100000, + "dampingRatio": 1 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + } + ] + }, + { + "name": "isStable", + "type": "boolean", + "data_points": [ + true, + true, + true, + true, + true, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + true + ] + } + ] +} \ No newline at end of file diff --git a/mechanics/tests/goldens/underDamped_matchesGolden.json b/mechanics/tests/goldens/underDamped_matchesGolden.json new file mode 100644 index 0000000..32413ec --- /dev/null +++ b/mechanics/tests/goldens/underDamped_matchesGolden.json @@ -0,0 +1,1870 @@ +{ + "frame_ids": [ + 0, + 10, + 20, + 30, + 40, + 50, + 60, + 70, + 80, + 90, + 100, + 110, + 120, + 130, + 140, + 150, + 160, + 170, + 180, + 190, + 200, + 210, + 220, + 230, + 240, + 250, + 260, + 270, + 280, + 290, + 300, + 310, + 320, + 330, + 340, + 350, + 360, + 370, + 380, + 390, + 400, + 410, + 420, + 430, + 440, + 450, + 460, + 470, + 480, + 490, + 500, + 510, + 520, + 530, + 540, + 550, + 560, + 570, + 580, + 590, + 600, + 610, + 620, + 630, + 640, + 650, + 660, + 670, + 680, + 690, + 700, + 710, + 720, + 730, + 740, + 750, + 760, + 770, + 780, + 790, + 800, + 810, + 820, + 830, + 840, + 850, + 860, + 870, + 880, + 890, + 900, + 910, + 920, + 930, + 940, + 950, + 960, + 970, + 980, + 990, + 1000, + 1010, + 1020, + 1030, + 1040, + 1050, + 1060, + 1070, + 1080, + 1090, + 1100, + 1110, + 1120, + 1130, + 1140, + 1150, + 1160, + 1170, + 1180, + 1190, + 1200, + 1210, + 1220, + 1230, + 1240, + 1250, + 1260, + 1270, + 1280, + 1290, + 1300, + 1310, + 1320, + 1330, + 1340, + 1350, + 1360, + 1370, + 1380, + 1390, + 1400, + 1410, + 1420, + 1430, + 1440, + 1450, + 1460, + 1470, + 1480, + 1490, + 1500, + 1510, + 1520, + 1530, + 1540, + 1550, + 1560, + 1570, + 1580, + 1590, + 1600, + 1610, + 1620, + 1630, + 1640, + 1650, + 1660, + 1670, + 1680, + 1690, + 1700, + 1710, + 1720, + 1730, + 1740, + 1750, + 1760, + 1770, + 1780, + 1790, + 1800, + 1810, + 1820, + 1830, + 1840, + 1850, + 1860, + 1870, + 1880, + 1890, + 1900, + 1910, + 1920, + 1930, + 1940, + 1950, + 1960, + 1970, + 1980, + 1990, + 2000, + 2010, + 2020, + 2030, + 2040, + 2050, + 2060, + 2070, + 2080, + 2090, + 2100, + 2110, + 2120, + 2130, + 2140, + 2150, + 2160, + 2170, + 2180, + 2190, + 2200, + 2210, + 2220, + 2230, + 2240, + 2250, + 2260, + 2270, + 2280, + 2290 + ], + "features": [ + { + "name": "displacement", + "type": "float", + "data_points": [ + 10, + 9.951026, + 9.8084, + 9.57896, + 9.269987, + 8.889109, + 8.444205, + 7.9433208, + 7.3945727, + 6.80607, + 6.185835, + 5.5417304, + 4.881393, + 4.2121716, + 3.5410736, + 2.8747165, + 2.2192867, + 1.5805038, + 0.9635933, + 0.3732641, + -0.18630688, + -0.7114842, + -1.1991777, + -1.6468408, + -2.0524633, + -2.4145596, + -2.7321532, + -3.0047555, + -3.2323432, + -3.4153304, + -3.5545402, + -3.6511714, + -3.706767, + -3.7231774, + -3.7025254, + -3.6471696, + -3.5596678, + -3.4427407, + -3.2992358, + -3.1320927, + -2.9443088, + -2.738907, + -2.5189056, + -2.2872882, + -2.0469773, + -1.8008097, + -1.5515139, + -1.3016896, + -1.0537905, + -0.81010836, + -0.57276094, + -0.34368098, + -0.12460864, + 0.08291435, + 0.27754804, + 0.45815554, + 0.6238022, + 0.7737528, + 0.90746725, + 1.0245943, + 1.1249641, + 1.2085791, + 1.2756041, + 1.3263553, + 1.3612883, + 1.3809854, + 1.3861428, + 1.3775574, + 1.3561126, + 1.3227652, + 1.2785319, + 1.2244756, + 1.1616926, + 1.0912999, + 1.0144233, + 0.9321859, + 0.84569746, + 0.7560443, + 0.6642802, + 0.57141787, + 0.47842193, + 0.386202, + 0.29560724, + 0.20742154, + 0.12235984, + 0.04106513, + -0.03589359, + -0.108022496, + -0.17490335, + -0.23619318, + -0.2916232, + -0.34099713, + -0.3841888, + -0.4211394, + -0.45185402, + -0.47639796, + -0.49489257, + -0.5075107, + -0.5144723, + -0.51603925, + -0.5125105, + -0.50421697, + -0.49151662, + -0.47478923, + -0.45443153, + -0.43085238, + -0.40446806, + -0.37569776, + -0.34495947, + -0.31266588, + -0.27922073, + -0.24501546, + -0.21042602, + -0.17581023, + -0.14150535, + -0.10782593, + -0.07506216, + -0.043478478, + -0.013312436, + 0.015225975, + 0.041954778, + 0.06672015, + 0.08939626, + 0.109884866, + 0.12811466, + 0.14404039, + 0.15764181, + 0.16892236, + 0.17790781, + 0.18464467, + 0.18919855, + 0.19165242, + 0.19210477, + 0.19066778, + 0.18746541, + 0.18263154, + 0.17630802, + 0.16864295, + 0.1597888, + 0.14990067, + 0.13913468, + 0.12764633, + 0.11558912, + 0.10311311, + 0.09036367, + 0.07748037, + 0.064595945, + 0.05183541, + 0.0393153, + 0.02714303, + 0.01541639, + 0.0042231516, + -0.0063591995, + -0.016263612, + -0.025433514, + -0.033822753, + -0.041395433, + -0.04812567, + -0.053997252, + -0.059003245, + -0.06314551, + -0.066434175, + -0.068887055, + -0.070529036, + -0.07139142, + -0.07151124, + -0.07093058, + -0.06969586, + -0.067857146, + -0.06546744, + -0.06258201, + -0.059257705, + -0.055552322, + -0.051523987, + -0.047230575, + -0.042729158, + -0.038075503, + -0.033323605, + -0.028525269, + -0.023729734, + -0.018983342, + -0.014329261, + -0.00980725, + -0.005453472, + -0.0013003512, + 0.0026235213, + 0.0062934426, + 0.009688612, + 0.012792103, + 0.015590806, + 0.018075326, + 0.02023987, + 0.022082077, + 0.023602854, + 0.024806172, + 0.025698848, + 0.026290316, + 0.026592381, + 0.026618967, + 0.026385859, + 0.025910439, + 0.025211431, + 0.024308635, + 0.023222672, + 0.02197474, + 0.020586377, + 0.019079221, + 0.01747481, + 0.015794363, + 0.014058607, + 0.012287595, + 0.010500557, + 0.008715754, + 0.0069503672, + 0.005220385, + 0.0035405224, + 0.0019241521, + 0.00038325193, + -0.0010716299, + -0.0024313936, + -0.0036883915, + -0.004836418, + -0.0058706864, + -0.0067877905, + -0.0075856596, + -0.0082635, + -0.008821729, + -0.009261897, + -0.009586611, + -0.009799446 + ] + }, + { + "name": "velocity", + "type": "float", + "data_points": [ + 0, + -9.689744, + -18.721231, + -27.04521, + -34.622158, + -41.4221, + -47.42434, + -52.617123, + -56.99723, + -60.56951, + -63.346367, + -65.34719, + -66.59777, + -67.12967, + -66.979576, + -66.18867, + -64.80193, + -62.86752, + -60.436077, + -57.560135, + -54.293465, + -50.690502, + -46.80577, + -42.693356, + -38.406395, + -33.99663, + -29.513977, + -25.006151, + -20.518335, + -16.092886, + -11.769089, + -7.582956, + -3.567066, + 0.24954945, + 3.8414824, + 7.186983, + 10.26796, + 13.069937, + 15.581989, + 17.79664, + 19.70973, + 21.32027, + 22.630259, + 23.644495, + 24.370367, + 24.817629, + 24.998167, + 24.925762, + 24.615849, + 24.085262, + 23.352001, + 22.434978, + 21.353788, + 20.128475, + 18.779318, + 17.32661, + 15.790471, + 14.190657, + 12.54639, + 10.8762045, + 9.197807, + 7.527954, + 5.882341, + 4.2755146, + 2.7207994, + 1.230238, + -0.1854506, + -1.5168974, + -2.7560964, + -3.8964016, + -4.932514, + -5.8604536, + -6.6775203, + -7.3822474, + -7.974343, + -8.454623, + -8.824943, + -9.088114, + -9.247824, + -9.30855, + -9.275467, + -9.154358, + -8.951525, + -8.673694, + -8.327926, + -7.921531, + -7.461982, + -6.956829, + -6.413628, + -5.8398623, + -5.2428765, + -4.629812, + -4.0075502, + -3.3826616, + -2.7613592, + -2.1494596, + -1.5523491, + -0.97495717, + -0.42173502, + 0.10335989, + 0.59687334, + 1.0558584, + 1.4778748, + 1.8609827, + 2.2037325, + 2.5051508, + 2.7647214, + 2.982364, + 3.1584096, + 3.2935734, + 3.3889253, + 3.4458592, + 3.4660602, + 3.4514713, + 3.4042604, + 3.3267848, + 3.2215586, + 3.091218, + 2.93849, + 2.766159, + 2.5770383, + 2.3739393, + 2.1596458, + 1.936888, + 1.708319, + 1.4764937, + 1.2438502, + 1.0126922, + 0.78517485, + 0.56329256, + 0.34886897, + 0.14354916, + -0.051205866, + -0.23412266, + -0.40411672, + -0.56029207, + -0.70193887, + -0.8285295, + -0.93971306, + -1.0353087, + -1.1152971, + -1.1798112, + -1.2291268, + -1.2636507, + -1.2839093, + -1.290537, + -1.2842634, + -1.2659005, + -1.2363305, + -1.1964928, + -1.1473718, + -1.0899843, + -1.0253683, + -0.9545714, + -0.8786402, + -0.7986099, + -0.71549547, + -0.63028246, + -0.5439195, + -0.4573111, + -0.37131146, + -0.28671914, + -0.20427254, + -0.12464625, + -0.048448235, + 0.023782196, + 0.09157562, + 0.15453298, + 0.21232536, + 0.26469308, + 0.3114442, + 0.3524524, + 0.38765445, + 0.41704708, + 0.44068357, + 0.4586699, + 0.4711607, + 0.47835487, + 0.48049107, + 0.47784314, + 0.47071537, + 0.4594378, + 0.44436142, + 0.42585367, + 0.4042939, + 0.38006887, + 0.35356876, + 0.32518306, + 0.29529685, + 0.26428732, + 0.23252065, + 0.20034899, + 0.1681079, + 0.1361141, + 0.10466347, + 0.07402937, + 0.04446134, + 0.016184038, + -0.0106034735, + -0.03572817, + -0.059043236, + -0.080427945, + -0.09978733, + -0.117051594, + -0.13217531, + -0.14513649, + -0.15593536, + -0.16459312, + -0.17115049, + -0.1756662, + -0.17821535, + -0.17888775, + -0.17778617, + -0.17502461, + -0.17072651, + -0.16502303, + -0.15805133, + -0.14995287, + -0.14087182, + -0.13095346, + -0.12034274, + -0.10918287, + -0.097614065, + -0.085772336, + -0.073788404, + -0.06178678, + -0.049884874, + -0.03819231, + -0.026810283, + -0.015831092 + ] + }, + { + "name": "stable", + "type": "boolean", + "data_points": [ + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + true, + true + ] + }, + { + "name": "parameters", + "type": "springParameters", + "data_points": [ + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + } + ] + } + ] +} \ No newline at end of file diff --git a/mechanics/tests/goldens/view/emptySpec_outputMatchesInput_withoutAnimation.json b/mechanics/tests/goldens/view/emptySpec_outputMatchesInput_withoutAnimation.json new file mode 100644 index 0000000..f5f7612 --- /dev/null +++ b/mechanics/tests/goldens/view/emptySpec_outputMatchesInput_withoutAnimation.json @@ -0,0 +1,102 @@ +{ + "frame_ids": [ + 0, + 16, + 32, + 48, + 64, + 80 + ], + "features": [ + { + "name": "input", + "type": "float", + "data_points": [ + 0, + 20, + 40, + 60, + 80, + 100 + ] + }, + { + "name": "gestureDirection", + "type": "string", + "data_points": [ + "Max", + "Max", + "Max", + "Max", + "Max", + "Max" + ] + }, + { + "name": "output", + "type": "float", + "data_points": [ + 0, + 20, + 40, + 60, + 80, + 100 + ] + }, + { + "name": "outputTarget", + "type": "float", + "data_points": [ + 0, + 20, + 40, + 60, + 80, + 100 + ] + }, + { + "name": "outputSpring", + "type": "springParameters", + "data_points": [ + { + "stiffness": 100000, + "dampingRatio": 1 + }, + { + "stiffness": 100000, + "dampingRatio": 1 + }, + { + "stiffness": 100000, + "dampingRatio": 1 + }, + { + "stiffness": 100000, + "dampingRatio": 1 + }, + { + "stiffness": 100000, + "dampingRatio": 1 + }, + { + "stiffness": 100000, + "dampingRatio": 1 + } + ] + }, + { + "name": "isStable", + "type": "boolean", + "data_points": [ + true, + true, + true, + true, + true, + true + ] + } + ] +} \ No newline at end of file diff --git a/mechanics/tests/goldens/view/gestureContext_listensToGestureContextUpdates.json b/mechanics/tests/goldens/view/gestureContext_listensToGestureContextUpdates.json new file mode 100644 index 0000000..755ff78 --- /dev/null +++ b/mechanics/tests/goldens/view/gestureContext_listensToGestureContextUpdates.json @@ -0,0 +1,122 @@ +{ + "frame_ids": [ + 0, + 16, + 32, + 48, + 64, + 80, + 96, + 112 + ], + "features": [ + { + "name": "input", + "type": "float", + "data_points": [ + 0, + 0.5, + 1, + 1, + 1, + 1, + 1, + 1 + ] + }, + { + "name": "gestureDirection", + "type": "string", + "data_points": [ + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max" + ] + }, + { + "name": "output", + "type": "float", + "data_points": [ + 0, + 0, + 0, + 0.13920438, + 0.45275474, + 0.772992, + 0.9506903, + 1 + ] + }, + { + "name": "outputTarget", + "type": "float", + "data_points": [ + 0, + 0, + 1, + 1, + 1, + 1, + 1, + 1 + ] + }, + { + "name": "outputSpring", + "type": "springParameters", + "data_points": [ + { + "stiffness": 100000, + "dampingRatio": 1 + }, + { + "stiffness": 100000, + "dampingRatio": 1 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 1600.4729, + "dampingRatio": 0.9166666 + }, + { + "stiffness": 3659.3052, + "dampingRatio": 0.9333333 + }, + { + "stiffness": 8366.601, + "dampingRatio": 0.95 + }, + { + "stiffness": 19129.314, + "dampingRatio": 0.9666667 + }, + { + "stiffness": 43737.062, + "dampingRatio": 0.98333335 + } + ] + }, + { + "name": "isStable", + "type": "boolean", + "data_points": [ + true, + true, + false, + false, + false, + false, + false, + true + ] + } + ] +} \ No newline at end of file diff --git a/mechanics/tests/goldens/view/segmentChange_animatedWhenReachingBreakpoint.json b/mechanics/tests/goldens/view/segmentChange_animatedWhenReachingBreakpoint.json new file mode 100644 index 0000000..f93fc6f --- /dev/null +++ b/mechanics/tests/goldens/view/segmentChange_animatedWhenReachingBreakpoint.json @@ -0,0 +1,212 @@ +{ + "frame_ids": [ + 0, + 16, + 32, + 48, + 64, + 80, + 96, + 112, + 128, + 144, + 160, + 176, + 192, + 208, + 224, + 240, + 256 + ], + "features": [ + { + "name": "input", + "type": "float", + "data_points": [ + 0, + 0.5, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1 + ] + }, + { + "name": "gestureDirection", + "type": "string", + "data_points": [ + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max" + ] + }, + { + "name": "output", + "type": "float", + "data_points": [ + 0, + 0, + 0, + 0.0696004, + 0.21705192, + 0.38261998, + 0.536185, + 0.6651724, + 0.7667498, + 0.8429822, + 0.89796525, + 0.93623626, + 0.9619781, + 0.9786911, + 0.98912483, + 0.9953385, + 1 + ] + }, + { + "name": "outputTarget", + "type": "float", + "data_points": [ + 0, + 0, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1 + ] + }, + { + "name": "outputSpring", + "type": "springParameters", + "data_points": [ + { + "stiffness": 100000, + "dampingRatio": 1 + }, + { + "stiffness": 100000, + "dampingRatio": 1 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + } + ] + }, + { + "name": "isStable", + "type": "boolean", + "data_points": [ + true, + true, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + true + ] + } + ] +} \ No newline at end of file diff --git a/mechanics/tests/goldens/view/specChange_triggersAnimation.json b/mechanics/tests/goldens/view/specChange_triggersAnimation.json new file mode 100644 index 0000000..b237f39 --- /dev/null +++ b/mechanics/tests/goldens/view/specChange_triggersAnimation.json @@ -0,0 +1,152 @@ +{ + "frame_ids": [ + 0, + 16, + 32, + 48, + 64, + 80, + 96, + 112, + 128, + 144, + 160 + ], + "features": [ + { + "name": "input", + "type": "float", + "data_points": [ + 0.5, + 0.5, + 0.5, + 0.5, + 0.5, + 0.5, + 0.5, + 0.5, + 0.5, + 0.5, + 0.5 + ] + }, + { + "name": "gestureDirection", + "type": "string", + "data_points": [ + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max" + ] + }, + { + "name": "output", + "type": "float", + "data_points": [ + 1.5, + 1.3117526, + 0.96824056, + 0.6450497, + 0.39762264, + 0.22869362, + 0.122471645, + 0.060223386, + 0.026204487, + 0.009041936, + 0 + ] + }, + { + "name": "outputTarget", + "type": "float", + "data_points": [ + 1.5, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ] + }, + { + "name": "outputSpring", + "type": "springParameters", + "data_points": [ + { + "stiffness": 100000, + "dampingRatio": 1 + }, + { + "stiffness": 1400, + "dampingRatio": 0.9 + }, + { + "stiffness": 1400, + "dampingRatio": 0.9 + }, + { + "stiffness": 1400, + "dampingRatio": 0.9 + }, + { + "stiffness": 1400, + "dampingRatio": 0.9 + }, + { + "stiffness": 1400, + "dampingRatio": 0.9 + }, + { + "stiffness": 1400, + "dampingRatio": 0.9 + }, + { + "stiffness": 1400, + "dampingRatio": 0.9 + }, + { + "stiffness": 1400, + "dampingRatio": 0.9 + }, + { + "stiffness": 1400, + "dampingRatio": 0.9 + }, + { + "stiffness": 1400, + "dampingRatio": 0.9 + } + ] + }, + { + "name": "isStable", + "type": "boolean", + "data_points": [ + true, + false, + false, + false, + false, + false, + false, + false, + false, + false, + true + ] + } + ] +} \ No newline at end of file diff --git a/mechanics/tests/goldens/zeroDisplacement_initialVelocity_matchesGolden.json b/mechanics/tests/goldens/zeroDisplacement_initialVelocity_matchesGolden.json new file mode 100644 index 0000000..062143b --- /dev/null +++ b/mechanics/tests/goldens/zeroDisplacement_initialVelocity_matchesGolden.json @@ -0,0 +1,1318 @@ +{ + "frame_ids": [ + 0, + 10, + 20, + 30, + 40, + 50, + 60, + 70, + 80, + 90, + 100, + 110, + 120, + 130, + 140, + 150, + 160, + 170, + 180, + 190, + 200, + 210, + 220, + 230, + 240, + 250, + 260, + 270, + 280, + 290, + 300, + 310, + 320, + 330, + 340, + 350, + 360, + 370, + 380, + 390, + 400, + 410, + 420, + 430, + 440, + 450, + 460, + 470, + 480, + 490, + 500, + 510, + 520, + 530, + 540, + 550, + 560, + 570, + 580, + 590, + 600, + 610, + 620, + 630, + 640, + 650, + 660, + 670, + 680, + 690, + 700, + 710, + 720, + 730, + 740, + 750, + 760, + 770, + 780, + 790, + 800, + 810, + 820, + 830, + 840, + 850, + 860, + 870, + 880, + 890, + 900, + 910, + 920, + 930, + 940, + 950, + 960, + 970, + 980, + 990, + 1000, + 1010, + 1020, + 1030, + 1040, + 1050, + 1060, + 1070, + 1080, + 1090, + 1100, + 1110, + 1120, + 1130, + 1140, + 1150, + 1160, + 1170, + 1180, + 1190, + 1200, + 1210, + 1220, + 1230, + 1240, + 1250, + 1260, + 1270, + 1280, + 1290, + 1300, + 1310, + 1320, + 1330, + 1340, + 1350, + 1360, + 1370, + 1380, + 1390, + 1400, + 1410, + 1420, + 1430, + 1440, + 1450, + 1460, + 1470, + 1480, + 1490, + 1500, + 1510, + 1520, + 1530, + 1540, + 1550, + 1560, + 1570, + 1580, + 1590, + 1600 + ], + "features": [ + { + "name": "displacement", + "type": "float", + "data_points": [ + 0, + 0.09689744, + 0.18721232, + 0.2704521, + 0.3462216, + 0.41422102, + 0.4742434, + 0.52617127, + 0.56997234, + 0.6056951, + 0.6334637, + 0.6534719, + 0.66597766, + 0.6712966, + 0.6697957, + 0.66188663, + 0.6480193, + 0.62867516, + 0.60436076, + 0.57560134, + 0.54293466, + 0.506905, + 0.46805772, + 0.42693356, + 0.38406396, + 0.3399663, + 0.29513976, + 0.2500615, + 0.20518336, + 0.16092888, + 0.117690906, + 0.07582957, + 0.035670675, + -0.0024954774, + -0.038414806, + -0.07186981, + -0.10267957, + -0.13069934, + -0.15581986, + -0.17796637, + -0.19709727, + -0.21320267, + -0.22630256, + -0.23644494, + -0.24370365, + -0.24817626, + -0.24998164, + -0.2492576, + -0.24615847, + -0.24085261, + -0.23352, + -0.22434977, + -0.21353786, + -0.20128474, + -0.18779317, + -0.1732661, + -0.15790471, + -0.14190657, + -0.1254639, + -0.108762056, + -0.09197809, + -0.075279556, + -0.058823425, + -0.042755164, + -0.027208013, + -0.012302401, + 0.0018544839, + 0.015168951, + 0.027560938, + 0.038963992, + 0.049325116, + 0.05860451, + 0.06677517, + 0.073822446, + 0.0797434, + 0.08454621, + 0.08824941, + 0.09088112, + 0.09247822, + 0.09308548, + 0.09275465, + 0.09154356, + 0.08951523, + 0.08673692, + 0.083279245, + 0.0792153, + 0.07461981, + 0.069568284, + 0.064136274, + 0.05839862, + 0.05242876, + 0.046298113, + 0.040075496, + 0.033826612, + 0.02761359, + 0.021494593, + 0.015523489, + 0.009749569, + 0.0042173467, + -0.0010336027, + -0.0059687374, + -0.010558588, + -0.014778752, + -0.01860983, + -0.022037327, + -0.02505151, + -0.027647214, + -0.029823638, + -0.031584095, + -0.03293573, + -0.03388925, + -0.03445859, + -0.034660596, + -0.03451471, + -0.0340426, + -0.033267844, + -0.03221558, + -0.030912176, + -0.029384894, + -0.027661586, + -0.025770377, + -0.023739388, + -0.021596454, + -0.019368876, + -0.017083187, + -0.014764935, + -0.0124385, + -0.01012692, + -0.007851747, + -0.0056329244, + -0.003488689, + -0.0014354915, + 0.00051205826, + 0.0023412257, + 0.004041166, + 0.005602919, + 0.007019386, + 0.008285292, + 0.009397129, + 0.010353085, + 0.011152968, + 0.01179811, + 0.012291266, + 0.012636503, + 0.012839089, + 0.012905367, + 0.01284263, + 0.012659001, + 0.012363302, + 0.011964925, + 0.011473714, + 0.01089984, + 0.01025368, + 0.009545711, + 0.008786398, + 0.007986096, + 0.0071549513, + 0.0063028214, + 0.005439192, + 0.0045731086, + 0.0037131126 + ] + }, + { + "name": "velocity", + "type": "float", + "data_points": [ + 10, + 9.369641, + 8.685126, + 7.956248, + 7.1926575, + 6.4037824, + 5.598745, + 4.7862935, + 3.9747388, + 3.171899, + 2.3850527, + 1.6208987, + 0.8855264, + 0.18439122, + -0.47770125, + -1.0966038, + -1.6688296, + -2.1915476, + -2.6625717, + -3.0803442, + -3.443915, + -3.7529144, + -4.007524, + -4.208442, + -4.356847, + -4.454357, + -4.5029917, + -4.5051246, + -4.4634433, + -4.3809037, + -4.2606854, + -4.1061487, + -3.920791, + -3.7082043, + -3.4720364, + -3.2159505, + -2.9435902, + -2.6585445, + -2.3643165, + -2.0642943, + -1.7617248, + -1.4596908, + -1.16109, + -0.8686183, + -0.5847551, + -0.311752, + -0.051623996, + 0.19385597, + 0.4231603, + 0.63500726, + 0.828359, + 1.0024176, + 1.1566185, + 1.2906227, + 1.4043069, + 1.497752, + 1.5712303, + 1.625192, + 1.6602504, + 1.6771663, + 1.6768323, + 1.6602561, + 1.6285444, + 1.5828861, + 1.5245361, + 1.4547995, + 1.3750156, + 1.2865434, + 1.1907467, + 1.088981, + 0.982581, + 0.8728484, + 0.76104134, + 0.648365, + 0.5359627, + 0.42490852, + 0.31620094, + 0.21075752, + 0.10941076, + 0.012904932, + -0.078105986, + -0.16305938, + -0.24148415, + -0.31299993, + -0.37731555, + -0.43422657, + -0.48361233, + -0.5254321, + -0.55972093, + -0.58658487, + -0.60619575, + -0.6187858, + -0.6246418, + -0.6240991, + -0.6175356, + -0.6053656, + -0.58803356, + -0.5660082, + -0.53977644, + -0.5098377, + -0.47669807, + -0.44086543, + -0.40284407, + -0.3631302, + -0.32220754, + -0.2805433, + -0.23858473, + -0.19675589, + -0.15545486, + -0.11505145, + -0.0758852, + -0.038263895, + -0.0024624073, + 0.03127804, + 0.06275027, + 0.09178116, + 0.11823134, + 0.1419946, + 0.16299695, + 0.1811955, + 0.19657704, + 0.20915647, + 0.21897496, + 0.22609809, + 0.23061374, + 0.23262997, + 0.23227277, + 0.22968385, + 0.22501825, + 0.21844217, + 0.21013063, + 0.20026532, + 0.18903238, + 0.17662038, + 0.16321836, + 0.14901397, + 0.13419166, + 0.11893117, + 0.103406, + 0.08778213, + 0.07221683, + 0.056857638, + 0.0418415, + 0.027294062, + 0.013329109, + 4.8148875e-05, + -0.0124598555, + -0.024118617, + -0.034864526, + -0.04464653, + -0.053425904, + -0.06117589, + -0.06788128, + -0.07353788, + -0.078151904, + -0.08173932, + -0.084325135, + -0.08594259, + -0.08663239, + -0.08644188, + -0.08542417 + ] + }, + { + "name": "stable", + "type": "boolean", + "data_points": [ + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + true, + true + ] + }, + { + "name": "parameters", + "type": "springParameters", + "data_points": [ + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + } + ] + } + ] +} \ No newline at end of file diff --git a/mechanics/tests/src/com/android/mechanics/DistanceGestureContextTest.kt b/mechanics/tests/src/com/android/mechanics/DistanceGestureContextTest.kt new file mode 100644 index 0000000..20b49a8 --- /dev/null +++ b/mechanics/tests/src/com/android/mechanics/DistanceGestureContextTest.kt @@ -0,0 +1,151 @@ +/* + * 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 + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.android.mechanics.spec.InputDirection +import com.google.common.truth.Truth.assertThat +import kotlin.math.nextDown +import kotlin.math.nextUp +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class DistanceGestureContextTest { + + @Test + fun setDistance_maxDirection_increasingInput_keepsDirection() { + val underTest = + DistanceGestureContext( + initialDragOffset = 0f, + initialDirection = InputDirection.Max, + directionChangeSlop = 5f, + ) + + for (value in 0..6) { + underTest.dragOffset = value.toFloat() + assertThat(underTest.direction).isEqualTo(InputDirection.Max) + } + } + + @Test + fun setDistance_minDirection_decreasingInput_keepsDirection() { + val underTest = + DistanceGestureContext( + initialDragOffset = 0f, + initialDirection = InputDirection.Min, + directionChangeSlop = 5f, + ) + + for (value in 0 downTo -6) { + underTest.dragOffset = value.toFloat() + assertThat(underTest.direction).isEqualTo(InputDirection.Min) + } + } + + @Test + fun setDistance_maxDirection_decreasingInput_keepsDirection_belowDirectionChangeSlop() { + val underTest = + DistanceGestureContext( + initialDragOffset = 0f, + initialDirection = InputDirection.Max, + directionChangeSlop = 5f, + ) + + underTest.dragOffset = -5f + assertThat(underTest.direction).isEqualTo(InputDirection.Max) + } + + @Test + fun setDistance_maxDirection_decreasingInput_switchesDirection_aboveDirectionChangeSlop() { + val underTest = + DistanceGestureContext( + initialDragOffset = 0f, + initialDirection = InputDirection.Max, + directionChangeSlop = 5f, + ) + + underTest.dragOffset = (-5f).nextDown() + assertThat(underTest.direction).isEqualTo(InputDirection.Min) + } + + @Test + fun setDistance_minDirection_increasingInput_keepsDirection_belowDirectionChangeSlop() { + val underTest = + DistanceGestureContext( + initialDragOffset = 0f, + initialDirection = InputDirection.Min, + directionChangeSlop = 5f, + ) + + underTest.dragOffset = 5f + assertThat(underTest.direction).isEqualTo(InputDirection.Min) + } + + @Test + fun setDistance_minDirection_decreasingInput_switchesDirection_aboveDirectionChangeSlop() { + val underTest = + DistanceGestureContext( + initialDragOffset = 0f, + initialDirection = InputDirection.Min, + directionChangeSlop = 5f, + ) + + underTest.dragOffset = 5f.nextUp() + assertThat(underTest.direction).isEqualTo(InputDirection.Max) + } + + @Test + fun reset_resetsFurthestValue() { + val underTest = + DistanceGestureContext( + initialDragOffset = 10f, + initialDirection = InputDirection.Max, + directionChangeSlop = 1f, + ) + + underTest.reset(5f, direction = InputDirection.Max) + assertThat(underTest.direction).isEqualTo(InputDirection.Max) + assertThat(underTest.dragOffset).isEqualTo(5f) + + underTest.dragOffset -= 1f + assertThat(underTest.direction).isEqualTo(InputDirection.Max) + assertThat(underTest.dragOffset).isEqualTo(4f) + + underTest.dragOffset = underTest.dragOffset.nextDown() + assertThat(underTest.direction).isEqualTo(InputDirection.Min) + assertThat(underTest.dragOffset).isWithin(0.0001f).of(4f) + } + + @Test + fun setDirectionChangeSlop_smallerThanCurrentDelta_switchesDirection() { + val underTest = + DistanceGestureContext( + initialDragOffset = 10f, + initialDirection = InputDirection.Max, + directionChangeSlop = 5f, + ) + + underTest.dragOffset -= 2f + assertThat(underTest.direction).isEqualTo(InputDirection.Max) + assertThat(underTest.dragOffset).isEqualTo(8f) + + underTest.directionChangeSlop = 1f + assertThat(underTest.direction).isEqualTo(InputDirection.Min) + assertThat(underTest.dragOffset).isEqualTo(8f) + } +} diff --git a/mechanics/tests/src/com/android/mechanics/MotionValueLifecycleTest.kt b/mechanics/tests/src/com/android/mechanics/MotionValueLifecycleTest.kt new file mode 100644 index 0000000..72ba985 --- /dev/null +++ b/mechanics/tests/src/com/android/mechanics/MotionValueLifecycleTest.kt @@ -0,0 +1,176 @@ +/* + * Copyright (C) 2025 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 + +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.snapshotFlow +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.android.mechanics.MotionValueTest.Companion.FakeGestureContext +import com.android.mechanics.MotionValueTest.Companion.specBuilder +import com.android.mechanics.spec.Mapping +import com.google.common.truth.Truth.assertThat +import com.google.common.truth.Truth.assertWithMessage +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.runTest +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class MotionValueLifecycleTest { + + @get:Rule(order = 0) val rule = createComposeRule() + + @Test + fun keepRunning_suspendsWithoutAnAnimation() = runTest { + val input = mutableFloatStateOf(0f) + val spec = specBuilder(Mapping.Zero) { fixedValue(breakpoint = 1f, value = 1f) } + val underTest = MotionValue(input::value, FakeGestureContext, spec) + rule.setContent { LaunchedEffect(Unit) { underTest.keepRunning() } } + + val inspector = underTest.debugInspector() + var framesCount = 0 + backgroundScope.launch { snapshotFlow { inspector.frame }.collect { framesCount++ } } + + rule.awaitIdle() + framesCount = 0 + rule.mainClock.autoAdvance = false + + assertThat(inspector.isActive).isTrue() + assertThat(inspector.isAnimating).isFalse() + + // Update the value, but WITHOUT causing an animation + input.floatValue = 0.5f + rule.awaitIdle() + + // Still on the old frame.. + assertThat(framesCount).isEqualTo(0) + // ... [underTest] is now waiting for an animation frame + assertThat(inspector.isAnimating).isTrue() + + rule.mainClock.advanceTimeByFrame() + rule.awaitIdle() + + // Produces the frame.. + assertThat(framesCount).isEqualTo(1) + // ... and is suspended again. + assertThat(inspector.isAnimating).isTrue() + + rule.mainClock.advanceTimeByFrame() + rule.awaitIdle() + + // Produces the frame.. + assertThat(framesCount).isEqualTo(2) + // ... and is suspended again. + assertThat(inspector.isAnimating).isFalse() + + rule.mainClock.autoAdvance = true + rule.awaitIdle() + // Ensure that no more frames are produced + assertThat(framesCount).isEqualTo(2) + } + + @Test + fun keepRunning_remainsActiveWhileAnimating() = runTest { + val input = mutableFloatStateOf(0f) + val spec = specBuilder(Mapping.Zero) { fixedValue(breakpoint = 1f, value = 1f) } + val underTest = MotionValue(input::value, FakeGestureContext, spec) + rule.setContent { LaunchedEffect(Unit) { underTest.keepRunning() } } + + val inspector = underTest.debugInspector() + var framesCount = 0 + backgroundScope.launch { snapshotFlow { inspector.frame }.collect { framesCount++ } } + + rule.awaitIdle() + framesCount = 0 + rule.mainClock.autoAdvance = false + + assertThat(inspector.isActive).isTrue() + assertThat(inspector.isAnimating).isFalse() + + // Update the value, WITH triggering an animation + input.floatValue = 1.5f + rule.awaitIdle() + + // Still on the old frame.. + assertThat(framesCount).isEqualTo(0) + // ... [underTest] is now waiting for an animation frame + assertThat(inspector.isAnimating).isTrue() + + // A couple frames should be generated without pausing + repeat(5) { + rule.mainClock.advanceTimeByFrame() + rule.awaitIdle() + + // The spring is still settling... + assertThat(inspector.frame.isStable).isFalse() + // ... animation keeps going ... + assertThat(inspector.isAnimating).isTrue() + // ... and frames are produces... + assertThat(framesCount).isEqualTo(it + 1) + } + + val timeBeforeAutoAdvance = rule.mainClock.currentTime + + // But this will stop as soon as the animation is finished. Skip forward. + rule.mainClock.autoAdvance = true + rule.awaitIdle() + + // At which point the spring is stable again... + assertThat(inspector.frame.isStable).isTrue() + // ... and animations are suspended again. + assertThat(inspector.isAnimating).isFalse() + + rule.awaitIdle() + + // Stabilizing the spring during awaitIdle() took 160ms (obtained from looking at reference + // test runs). That time is expected to be 100% reproducible, given the starting + // state/configuration of the spring before awaitIdle(). + assertThat(rule.mainClock.currentTime).isEqualTo(timeBeforeAutoAdvance + 160) + } + + @Test + fun keepRunningWhile_stopRunningWhileStable_endsImmediately() = runTest { + val input = mutableFloatStateOf(0f) + val spec = specBuilder(Mapping.Zero) { fixedValue(breakpoint = 1f, value = 1f) } + val underTest = MotionValue(input::value, FakeGestureContext, spec) + + val continueRunning = mutableStateOf(true) + + rule.setContent { + LaunchedEffect(Unit) { underTest.keepRunningWhile { continueRunning.value } } + } + + val inspector = underTest.debugInspector() + + rule.awaitIdle() + + assertWithMessage("isActive").that(inspector.isActive).isTrue() + assertWithMessage("isAnimating").that(inspector.isAnimating).isFalse() + + val timeBeforeStopRunning = rule.mainClock.currentTime + continueRunning.value = false + rule.awaitIdle() + + assertWithMessage("isActive").that(inspector.isActive).isFalse() + assertWithMessage("isAnimating").that(inspector.isAnimating).isFalse() + assertThat(rule.mainClock.currentTime).isEqualTo(timeBeforeStopRunning) + } +} diff --git a/mechanics/tests/src/com/android/mechanics/MotionValueTest.kt b/mechanics/tests/src/com/android/mechanics/MotionValueTest.kt new file mode 100644 index 0000000..ffb8e87 --- /dev/null +++ b/mechanics/tests/src/com/android/mechanics/MotionValueTest.kt @@ -0,0 +1,675 @@ +/* + * 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 + +import android.util.Log +import android.util.Log.TerribleFailureHandler +import androidx.compose.runtime.mutableFloatStateOf +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.android.mechanics.spec.Breakpoint +import com.android.mechanics.spec.BreakpointKey +import com.android.mechanics.spec.Guarantee.GestureDragDelta +import com.android.mechanics.spec.Guarantee.InputDelta +import com.android.mechanics.spec.Guarantee.None +import com.android.mechanics.spec.InputDirection +import com.android.mechanics.spec.Mapping +import com.android.mechanics.spec.MotionSpec +import com.android.mechanics.spec.SegmentKey +import com.android.mechanics.spec.SemanticKey +import com.android.mechanics.spec.SemanticValue +import com.android.mechanics.spec.builder.CanBeLastSegment +import com.android.mechanics.spec.builder.DirectionalBuilderScope +import com.android.mechanics.spec.builder.MotionBuilderContext +import com.android.mechanics.spec.builder.directionalMotionSpec +import com.android.mechanics.spec.with +import com.android.mechanics.testing.ComposeMotionValueToolkit +import com.android.mechanics.testing.FakeMotionSpecBuilderContext +import com.android.mechanics.testing.FeatureCaptures +import com.android.mechanics.testing.VerifyTimeSeriesResult.AssertTimeSeriesMatchesGolden +import com.android.mechanics.testing.VerifyTimeSeriesResult.SkipGoldenVerification +import com.android.mechanics.testing.animateValueTo +import com.android.mechanics.testing.animatedInputSequence +import com.android.mechanics.testing.dataPoints +import com.android.mechanics.testing.defaultFeatureCaptures +import com.android.mechanics.testing.goldenTest +import com.android.mechanics.testing.input +import com.android.mechanics.testing.isStable +import com.android.mechanics.testing.output +import com.google.common.truth.Truth.assertThat +import kotlinx.coroutines.launch +import org.junit.Rule +import org.junit.Test +import org.junit.rules.ExternalResource +import org.junit.runner.RunWith +import platform.test.motion.MotionTestRule +import platform.test.motion.compose.runMonotonicClockTest +import platform.test.motion.golden.DataPointTypes +import platform.test.motion.testing.createGoldenPathManager + +@RunWith(AndroidJUnit4::class) +class MotionValueTest : MotionBuilderContext by FakeMotionSpecBuilderContext.Default { + private val goldenPathManager = + createGoldenPathManager("frameworks/libs/systemui/mechanics/tests/goldens") + + @get:Rule(order = 1) val motion = MotionTestRule(ComposeMotionValueToolkit, goldenPathManager) + @get:Rule(order = 2) val wtfLog = WtfLogRule() + + @Test + fun emptySpec_outputMatchesInput_withoutAnimation() = + motion.goldenTest( + spec = MotionSpec.Empty, + verifyTimeSeries = { + // Output always matches the input + assertThat(output).containsExactlyElementsIn(input).inOrder() + // There must never be an ongoing animation. + assertThat(isStable).doesNotContain(false) + + AssertTimeSeriesMatchesGolden() + }, + ) { + animateValueTo(100f) + } + + // TODO the tests should describe the expected values not only in terms of goldens, but + // also explicitly in verifyTimeSeries + + @Test + fun changingInput_addsAnimationToMapping_becomesStable() = + motion.goldenTest( + spec = + specBuilder(Mapping.Zero) { + mapping(breakpoint = 1f, mapping = Mapping.Linear(factor = 0.5f)) + } + ) { + animateValueTo(1.1f, changePerFrame = 0.5f) + while (underTest.isStable) { + updateInput(input + 0.5f) + awaitFrames() + } + } + + @Test + fun segmentChange_inMaxDirection_animatedWhenReachingBreakpoint() = + motion.goldenTest( + spec = specBuilder(Mapping.Zero) { fixedValue(breakpoint = 1f, value = 1f) } + ) { + animateValueTo(1f, changePerFrame = 0.5f) + awaitStable() + } + + @Test + fun segmentChange_inMaxDirection_zeroDelta() = + motion.goldenTest(spec = specBuilder(Mapping.Zero) { fixedValueFromCurrent(0.5f) }) { + animateValueTo(1f, changePerFrame = 0.5f) + awaitStable() + } + + @Test + fun segmentChange_inMinDirection_animatedWhenReachingBreakpoint() = + motion.goldenTest( + initialValue = 2f, + initialDirection = InputDirection.Min, + spec = specBuilder(Mapping.Zero) { fixedValue(breakpoint = 1f, value = 1f) }, + ) { + animateValueTo(1f, changePerFrame = 0.5f) + awaitStable() + } + + @Test + fun segmentChange_inMaxDirection_springAnimationStartedRetroactively() = + motion.goldenTest( + spec = specBuilder(Mapping.Zero) { mapping(breakpoint = .75f, mapping = Mapping.One) } + ) { + animateValueTo(1f, changePerFrame = 0.5f) + awaitStable() + } + + @Test + fun segmentChange_inMinDirection_springAnimationStartedRetroactively() = + motion.goldenTest( + initialValue = 2f, + initialDirection = InputDirection.Min, + spec = specBuilder(Mapping.Zero) { mapping(breakpoint = 1.25f, mapping = Mapping.One) }, + ) { + animateValueTo(1f, changePerFrame = 0.5f) + awaitStable() + } + + @Test + fun segmentChange_guaranteeNone_springAnimatesIndependentOfInput() = + motion.goldenTest( + spec = + specBuilder(Mapping.Zero) { + fixedValue(breakpoint = 1f, guarantee = None, value = 1f) + } + ) { + animateValueTo(5f, changePerFrame = 0.5f) + awaitStable() + } + + @Test + fun segmentChange_guaranteeInputDelta_springCompletesWithinDistance() = + motion.goldenTest( + spec = + specBuilder(Mapping.Zero) { + fixedValue(breakpoint = 1f, guarantee = InputDelta(3f), value = 1f) + } + ) { + animateValueTo(4f, changePerFrame = 0.5f) + } + + @Test + fun segmentChange_guaranteeGestureDragDelta_springCompletesWithinDistance() = + motion.goldenTest( + spec = + specBuilder(Mapping.Zero) { + fixedValue(breakpoint = 1f, guarantee = GestureDragDelta(3f), value = 1f) + } + ) { + animateValueTo(1f, changePerFrame = 0.5f) + while (!underTest.isStable) { + gestureContext.dragOffset += 0.5f + awaitFrames() + } + } + + @Test + fun segmentChange_appliesOutputVelocity_atSpringStart() = + motion.goldenTest(spec = specBuilder { fixedValue(breakpoint = 10f, value = 20f) }) { + animateValueTo(11f, changePerFrame = 3f) + awaitStable() + } + + @Test + fun segmentChange_appliesOutputVelocity_springVelocityIsNotAppliedTwice() = + motion.goldenTest( + spec = + specBuilder { + fractionalInputFromCurrent(breakpoint = 10f, fraction = 1f, delta = 20f) + fixedValueFromCurrent(breakpoint = 20f) + } + ) { + animateValueTo(21f, changePerFrame = 3f) + awaitStable() + } + + @Test + fun segmentChange_appliesOutputVelocity_velocityNotAddedOnContinuousSegment() = + motion.goldenTest( + spec = + specBuilder { + fractionalInputFromCurrent(breakpoint = 10f, fraction = 5f, delta = 5f) + fixedValueFromCurrent(breakpoint = 20f) + } + ) { + animateValueTo(30f, changePerFrame = 3f) + awaitStable() + } + + @Test + fun segmentChange_appliesOutputVelocity_velocityAddedOnDiscontinuousSegment() = + motion.goldenTest( + spec = + specBuilder { + fractionalInputFromCurrent(breakpoint = 10f, fraction = 5f, delta = 5f) + fixedValueFromCurrent(breakpoint = 20f, delta = -5f) + } + ) { + animateValueTo(30f, changePerFrame = 3f) + awaitStable() + } + + @Test + // Regression test for b/409726626 + fun segmentChange_animationAtRest_doesNotAffectVelocity() = + motion.goldenTest( + spec = + specBuilder(Mapping.Zero) { + fixedValue(breakpoint = 1f, value = 20f) + fixedValue(breakpoint = 2f, value = 20f) + fixedValue(breakpoint = 3f, value = 10f) + }, + stableThreshold = 1f, + ) { + this.updateInput(1.5f) + awaitStable() + animateValueTo(3f) + awaitStable() + } + + @Test + fun specChange_shiftSegmentBackwards_doesNotAnimateWithinSegment_animatesSegmentChange() { + fun generateSpec(offset: Float) = + specBuilder(Mapping.Zero) { + targetFromCurrent(breakpoint = offset, key = B1, delta = 1f, to = 2f) + fixedValue(breakpoint = offset + 1f, key = B2, value = 0f) + } + + motion.goldenTest(spec = generateSpec(0f), initialValue = .5f) { + var offset = 0f + repeat(4) { + offset -= .2f + underTest.spec = generateSpec(offset) + awaitFrames() + } + awaitStable() + } + } + + @Test + fun specChange_shiftSegmentForward_doesNotAnimateWithinSegment_animatesSegmentChange() { + fun generateSpec(offset: Float) = + specBuilder(Mapping.Zero) { + targetFromCurrent(breakpoint = offset, key = B1, delta = 1f, to = 2f) + fixedValue(breakpoint = offset + 1f, key = B2, value = 0f) + } + + motion.goldenTest(spec = generateSpec(0f), initialValue = .5f) { + var offset = 0f + repeat(4) { + offset += .2f + underTest.spec = generateSpec(offset) + awaitFrames() + } + awaitStable() + } + } + + @Test + fun directionChange_maxToMin_changesSegmentWithDirectionChange() = + motion.goldenTest( + spec = specBuilder(Mapping.Zero) { fixedValue(breakpoint = 1f, value = 1f) }, + initialValue = 2f, + initialDirection = InputDirection.Max, + directionChangeSlop = 3f, + ) { + animateValueTo(-2f, changePerFrame = 0.5f) + awaitStable() + } + + @Test + fun directionChange_minToMax_changesSegmentWithDirectionChange() = + motion.goldenTest( + spec = specBuilder(Mapping.Zero) { fixedValue(breakpoint = 1f, value = 1f) }, + initialValue = 0f, + initialDirection = InputDirection.Min, + directionChangeSlop = 3f, + ) { + animateValueTo(4f, changePerFrame = 0.5f) + awaitStable() + } + + @Test + fun directionChange_maxToMin_appliesGuarantee_afterDirectionChange() = + motion.goldenTest( + spec = + specBuilder(Mapping.Zero) { + fixedValue(breakpoint = 1f, value = 1f, guarantee = InputDelta(1f)) + }, + initialValue = 2f, + initialDirection = InputDirection.Max, + directionChangeSlop = 3f, + ) { + animateValueTo(-2f, changePerFrame = 0.5f) + awaitStable() + } + + @Test + fun traverseSegments_maxDirection_noGuarantee_addsDiscontinuityToOngoingAnimation() = + motion.goldenTest( + spec = + specBuilder(Mapping.Zero) { + fixedValue(breakpoint = 1f, value = 1f) + fixedValue(breakpoint = 2f, value = 2f) + } + ) { + animateValueTo(3f, changePerFrame = 0.2f) + awaitStable() + } + + @Test + fun traverseSegmentsInOneFrame_noGuarantee_combinesDiscontinuity() = + motion.goldenTest( + spec = + specBuilder(Mapping.Zero) { + fixedValue(breakpoint = 1f, value = 1f) + fixedValue(breakpoint = 2f, value = 2f) + } + ) { + updateInput(2.5f) + awaitStable() + } + + @Test + fun traverseSegmentsInOneFrame_withGuarantee_appliesGuarantees() = + motion.goldenTest( + spec = + specBuilder(Mapping.Zero) { + fixedValueFromCurrent(breakpoint = 1f, delta = 5f, guarantee = InputDelta(.9f)) + fixedValueFromCurrent(breakpoint = 2f, delta = 1f, guarantee = InputDelta(.9f)) + } + ) { + updateInput(2.1f) + awaitStable() + } + + @Test + fun traverseSegmentsInOneFrame_withDirectionChange_appliesGuarantees() = + motion.goldenTest( + spec = + specBuilder(Mapping.Zero) { + fixedValue(breakpoint = 1f, value = 1f, guarantee = InputDelta(1f)) + fixedValue(breakpoint = 2f, value = 2f) + }, + initialValue = 2.5f, + initialDirection = InputDirection.Max, + directionChangeSlop = 1f, + ) { + updateInput(.5f) + animateValueTo(0f) + awaitStable() + } + + @Test + fun changeDirection_flipsBetweenDirectionalSegments() { + val spec = + MotionSpec( + maxDirection = directionalMotionSpec(Mapping.Zero), + minDirection = directionalMotionSpec(Mapping.One), + ) + + motion.goldenTest( + spec = spec, + initialValue = 2f, + initialDirection = InputDirection.Max, + directionChangeSlop = 1f, + ) { + animateValueTo(0f) + awaitStable() + } + } + + @Test + fun semantics_flipsBetweenDirectionalSegments() { + val s1 = SemanticKey("Foo") + val spec = + specBuilder(Mapping.Zero, semantics = listOf(s1 with "zero")) { + fixedValue(1f, 1f, semantics = listOf(s1 with "one")) + fixedValue(2f, 2f, semantics = listOf(s1 with "two")) + } + + motion.goldenTest( + spec = spec, + capture = { + defaultFeatureCaptures() + feature(FeatureCaptures.semantics(s1, DataPointTypes.string)) + }, + ) { + animateValueTo(3f, changePerFrame = .2f) + awaitStable() + } + } + + @Test + fun semantics_returnsNullForUnknownKey() { + val underTest = MotionValue({ 1f }, FakeGestureContext) + + val s1 = SemanticKey("Foo") + + assertThat(underTest[s1]).isNull() + } + + @Test + fun semantics_returnsValueMatchingSegment() { + val s1 = SemanticKey("Foo") + val spec = + specBuilder(Mapping.Zero, semantics = listOf(s1 with "zero")) { + fixedValue(1f, 1f, semantics = listOf(s1 with "one")) + fixedValue(2f, 2f, semantics = listOf(s1 with "two")) + } + + val input = mutableFloatStateOf(0f) + val underTest = MotionValue(input::value, FakeGestureContext, spec) + + assertThat(underTest[s1]).isEqualTo("zero") + input.floatValue = 2f + assertThat(underTest[s1]).isEqualTo("two") + } + + @Test + fun segment_returnsCurrentSegmentKey() { + val spec = + specBuilder(Mapping.Zero) { + fixedValue(1f, 1f, key = B1) + fixedValue(2f, 2f, key = B2) + } + + val input = mutableFloatStateOf(1f) + val underTest = MotionValue(input::value, FakeGestureContext, spec) + + assertThat(underTest.segmentKey).isEqualTo(SegmentKey(B1, B2, InputDirection.Max)) + input.floatValue = 2f + assertThat(underTest.segmentKey) + .isEqualTo(SegmentKey(B2, Breakpoint.maxLimit.key, InputDirection.Max)) + } + + @Test + fun derivedValue_reflectsInputChangeInSameFrame() { + motion.goldenTest( + spec = specBuilder(Mapping.Zero) { fixedValue(breakpoint = 0.5f, value = 1f) }, + createDerived = { primary -> + listOf(MotionValue.createDerived(primary, MotionSpec.Empty, label = "derived")) + }, + verifyTimeSeries = { + // the output of the derived value must match the primary value + assertThat(output) + .containsExactlyElementsIn(dataPoints("derived-output")) + .inOrder() + // and its never animated. + assertThat(dataPoints("derived-isStable")).doesNotContain(false) + + AssertTimeSeriesMatchesGolden() + }, + ) { + animateValueTo(1f, changePerFrame = 0.1f) + awaitStable() + } + } + + @Test + fun derivedValue_hasAnimationLifecycleOnItsOwn() { + motion.goldenTest( + spec = specBuilder(Mapping.Zero) { fixedValue(breakpoint = 0.5f, value = 1f) }, + createDerived = { primary -> + listOf( + MotionValue.createDerived( + primary, + specBuilder(Mapping.One) { fixedValue(breakpoint = 0.5f, value = 0f) }, + label = "derived", + ) + ) + }, + ) { + animateValueTo(1f, changePerFrame = 0.1f) + awaitStable() + } + } + + @Test + fun nonFiniteNumbers_producesNaN_recoversOnSubsequentFrames() { + motion.goldenTest( + spec = MotionSpec(directionalMotionSpec({ if (it >= 1f) Float.NaN else 0f })), + verifyTimeSeries = { + assertThat(output.drop(1).take(5)) + .containsExactlyElementsIn(listOf(0f, Float.NaN, Float.NaN, 0f, 0f)) + .inOrder() + SkipGoldenVerification + }, + ) { + animatedInputSequence(0f, 1f, 1f, 0f, 0f) + } + + assertThat(wtfLog.hasLoggedFailures()).isFalse() + } + + @Test + fun nonFiniteNumbers_segmentChange_skipsAnimation() { + motion.goldenTest( + spec = MotionSpec.Empty, + verifyTimeSeries = { + // The mappings produce a non-finite number during a segment change. + // The animation thereof is skipped to avoid poisoning the state with non-finite + // numbers + assertThat(output.drop(1).take(5)) + .containsExactlyElementsIn(listOf(0f, 1f, Float.NaN, 0f, 0f)) + .inOrder() + SkipGoldenVerification + }, + ) { + animatedInputSequence(0f, 1f) + underTest.spec = specBuilder { + mapping(breakpoint = 0f) { if (it >= 1f) Float.NaN else 0f } + } + + awaitFrames() + + animatedInputSequence(0f, 0f) + } + + val loggedFailures = wtfLog.removeLoggedFailures() + assertThat(loggedFailures).hasSize(1) + assertThat(loggedFailures.first()).startsWith("Delta between mappings is undefined") + } + + @Test + fun nonFiniteNumbers_segmentTraverse_skipsAnimation() { + motion.goldenTest( + spec = + specBuilder(Mapping.Zero) { + mapping(breakpoint = 1f) { if (it < 2f) Float.NaN else 2f } + }, + verifyTimeSeries = { + // The mappings produce a non-finite number during a breakpoint traversal. + // The animation thereof is skipped to avoid poisoning the state with non-finite + // numbers + assertThat(output.drop(1).take(6)) + .containsExactlyElementsIn(listOf(0f, 0f, Float.NaN, Float.NaN, 2f, 2f)) + .inOrder() + SkipGoldenVerification + }, + ) { + animatedInputSequence(0f, 0.5f, 1f, 1.5f, 2f, 3f) + } + val loggedFailures = wtfLog.removeLoggedFailures() + assertThat(loggedFailures).hasSize(1) + assertThat(loggedFailures.first()).startsWith("Delta between breakpoints is undefined") + } + + @Test + fun keepRunning_concurrentInvocationThrows() = runMonotonicClockTest { + val underTest = MotionValue({ 1f }, FakeGestureContext, label = "Foo") + val realJob = launch { underTest.keepRunning() } + testScheduler.runCurrent() + + assertThat(realJob.isActive).isTrue() + try { + underTest.keepRunning() + // keepRunning returns Nothing, will never get here + } catch (e: Throwable) { + assertThat(e).isInstanceOf(IllegalStateException::class.java) + assertThat(e).hasMessageThat().contains("MotionValue(Foo) is already running") + } + assertThat(realJob.isActive).isTrue() + realJob.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) + } + + class WtfLogRule : ExternalResource() { + private val loggedFailures = mutableListOf() + + private lateinit var oldHandler: TerribleFailureHandler + + override fun before() { + oldHandler = + Log.setWtfHandler { tag, what, _ -> + if (tag == MotionValue.TAG) { + loggedFailures.add(checkNotNull(what.message)) + } + } + } + + override fun after() { + Log.setWtfHandler(oldHandler) + + // In eng-builds, some misconfiguration in a MotionValue would cause a crash. However, + // in tests (and in production), we want animations to proceed even with such errors. + // When a test ends, we should check loggedFailures, if they were expected. + assertThat(loggedFailures).isEmpty() + } + + fun hasLoggedFailures() = loggedFailures.isNotEmpty() + + fun removeLoggedFailures(): List { + if (loggedFailures.isEmpty()) error("loggedFailures is empty") + val list = loggedFailures.toList() + loggedFailures.clear() + return list + } + } + + companion object { + val B1 = BreakpointKey("breakpoint1") + val B2 = BreakpointKey("breakpoint2") + val FakeGestureContext = + object : GestureContext { + override val direction: InputDirection + get() = InputDirection.Max + + override val dragOffset: Float + get() = 0f + } + + private val Springs = FakeMotionSpecBuilderContext.Default.spatial + + fun specBuilder( + initialMapping: Mapping = Mapping.Identity, + semantics: List> = emptyList(), + init: DirectionalBuilderScope.() -> CanBeLastSegment, + ): MotionSpec { + return MotionSpec( + directionalMotionSpec(Springs.default, initialMapping, semantics, init), + resetSpring = Springs.fast, + ) + } + } +} diff --git a/mechanics/tests/src/com/android/mechanics/debug/MotionValueDebuggerTest.kt b/mechanics/tests/src/com/android/mechanics/debug/MotionValueDebuggerTest.kt new file mode 100644 index 0000000..dfe69b8 --- /dev/null +++ b/mechanics/tests/src/com/android/mechanics/debug/MotionValueDebuggerTest.kt @@ -0,0 +1,94 @@ +/* + * Copyright (C) 2025 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.foundation.layout.Box +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.android.mechanics.MotionValue +import com.android.mechanics.ProvidedGestureContext +import com.android.mechanics.spec.InputDirection +import com.google.common.truth.Truth.assertThat +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class MotionValueDebuggerTest { + + private val input: () -> Float = { 0f } + private val gestureContext = + ProvidedGestureContext(dragOffset = 0f, direction = InputDirection.Max) + + @get:Rule(order = 0) val rule = createComposeRule() + + @Test + fun debugMotionValue_registersMotionValue_whenAddingToComposition() { + val debuggerState = MotionValueDebuggerState() + var hasValue by mutableStateOf(false) + + rule.setContent { + Box(modifier = Modifier.motionValueDebugger(debuggerState)) { + if (hasValue) { + val toDebug = remember { MotionValue(input, gestureContext) } + Box(modifier = Modifier.debugMotionValue(toDebug)) + } + } + } + + assertThat(debuggerState.observedMotionValues).isEmpty() + + hasValue = true + rule.waitForIdle() + + assertThat(debuggerState.observedMotionValues).hasSize(1) + } + + @Test + fun debugMotionValue_unregistersMotionValue_whenLeavingComposition() { + val debuggerState = MotionValueDebuggerState() + var hasValue by mutableStateOf(true) + + rule.setContent { + Box(modifier = Modifier.motionValueDebugger(debuggerState)) { + if (hasValue) { + val toDebug = remember { MotionValue(input, gestureContext) } + Box(modifier = Modifier.debugMotionValue(toDebug)) + } + } + } + + assertThat(debuggerState.observedMotionValues).hasSize(1) + + hasValue = false + rule.waitForIdle() + assertThat(debuggerState.observedMotionValues).isEmpty() + } + + @Test + fun debugMotionValue_noDebugger_isNoOp() { + rule.setContent { + val toDebug = remember { MotionValue(input, gestureContext) } + Box(modifier = Modifier.debugMotionValue(toDebug)) + } + } +} diff --git a/mechanics/tests/src/com/android/mechanics/effects/MagneticDetachTest.kt b/mechanics/tests/src/com/android/mechanics/effects/MagneticDetachTest.kt new file mode 100644 index 0000000..988f72e --- /dev/null +++ b/mechanics/tests/src/com/android/mechanics/effects/MagneticDetachTest.kt @@ -0,0 +1,252 @@ +/* + * Copyright (C) 2025 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.effects + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.android.mechanics.effects.MagneticDetach.Defaults.AttachPosition +import com.android.mechanics.effects.MagneticDetach.Defaults.DetachPosition +import com.android.mechanics.effects.MagneticDetach.State.Attached +import com.android.mechanics.effects.MagneticDetach.State.Detached +import com.android.mechanics.spec.InputDirection +import com.android.mechanics.spec.Mapping +import com.android.mechanics.spec.builder.EffectPlacemenType +import com.android.mechanics.spec.builder.MotionBuilderContext +import com.android.mechanics.spec.builder.spatialMotionSpec +import com.android.mechanics.testing.ComposeMotionValueToolkit +import com.android.mechanics.testing.FakeMotionSpecBuilderContext +import com.android.mechanics.testing.MotionSpecSubject.Companion.assertThat +import com.android.mechanics.testing.VerifyTimeSeriesResult.AssertTimeSeriesMatchesGolden +import com.android.mechanics.testing.animateValueTo +import com.android.mechanics.testing.goldenTest +import kotlin.test.fail +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.junit.runners.Parameterized +import platform.test.motion.MotionTestRule +import platform.test.motion.testing.createGoldenPathManager +import platform.test.screenshot.PathConfig +import platform.test.screenshot.PathElementNoContext + +@RunWith(AndroidJUnit4::class) +class MagneticDetachSpecTest : MotionBuilderContext by FakeMotionSpecBuilderContext.Default { + + @Test + fun magneticDetach_matchesSpec() { + val underTests = spatialMotionSpec { after(10f, MagneticDetach()) } + + assertThat(underTests).maxDirection().breakpoints().positions().containsExactly(10f, 90f) + assertThat(underTests) + .minDirection() + .breakpoints() + .positions() + .containsExactly(10f, 50f, 90f) + } + + @Test + fun attachDetachSemantics_placedAfter_isAppliedOutside() { + val underTests = spatialMotionSpec { after(10f, MagneticDetach()) } + + assertThat(underTests) + .maxDirection() + .semantics() + .withKey(MagneticDetach.Defaults.AttachDetachState) + .containsExactly(Attached, Attached, Detached) + + assertThat(underTests) + .minDirection() + .semantics() + .withKey(MagneticDetach.Defaults.AttachDetachState) + .containsExactly(Attached, Attached, Detached, Detached) + } + + @Test + fun attachValueSemantics_placedAfter_isAppliedInside() { + val underTests = spatialMotionSpec { after(10f, MagneticDetach()) } + + assertThat(underTests) + .maxDirection() + .semantics() + .withKey(MagneticDetach.Defaults.AttachedValue) + .containsExactly(null, 10f, null) + + assertThat(underTests) + .minDirection() + .semantics() + .withKey(MagneticDetach.Defaults.AttachedValue) + .containsExactly(null, 10f, null, null) + } + + @Test + fun attachDetachSemantics_placedBefore_isAppliedOutside() { + val underTests = spatialMotionSpec { before(10f, MagneticDetach()) } + + assertThat(underTests) + .maxDirection() + .semantics() + .withKey(MagneticDetach.Defaults.AttachDetachState) + .containsExactly(Detached, Detached, Attached, Attached) + + assertThat(underTests) + .minDirection() + .semantics() + .withKey(MagneticDetach.Defaults.AttachDetachState) + .containsExactly(Detached, Attached, Attached) + } + + @Test + fun attachValueSemantics_placedBefore_isAppliedInside() { + val underTests = spatialMotionSpec { before(10f, MagneticDetach()) } + + assertThat(underTests) + .maxDirection() + .semantics() + .withKey(MagneticDetach.Defaults.AttachedValue) + .containsExactly(null, null, 10f, null) + + assertThat(underTests) + .minDirection() + .semantics() + .withKey(MagneticDetach.Defaults.AttachedValue) + .containsExactly(null, 10f, null) + } +} + +@RunWith(Parameterized::class) +class MagneticDetachGoldenTest(private val placement: EffectPlacemenType) : + MotionBuilderContext by FakeMotionSpecBuilderContext.Default { + + companion object { + @Parameterized.Parameters(name = "{0}") + @JvmStatic + fun placements() = listOf(EffectPlacemenType.After, EffectPlacemenType.Before) + } + + private val goldenPathManager = + createGoldenPathManager( + "frameworks/libs/systemui/mechanics/tests/goldens", + PathConfig( + PathElementNoContext("effect", isDir = true) { "MagneticDetach" }, + PathElementNoContext("placement", isDir = false) { "placed${placement.name}" }, + ), + ) + + @get:Rule val motion = MotionTestRule(ComposeMotionValueToolkit, goldenPathManager) + + private val directionSign: Float + get() = + when (placement) { + EffectPlacemenType.After -> 1f + EffectPlacemenType.Before -> -1f + else -> fail() + } + + private fun createTestSpec() = spatialMotionSpec { + if (placement == EffectPlacemenType.After) { + after(10f, MagneticDetach()) + } else if (placement == EffectPlacemenType.Before) { + before(-10f, MagneticDetach()) + } + } + + @Test + fun detach_animatesDetach() { + motion.goldenTest( + createTestSpec(), + verifyTimeSeries = { AssertTimeSeriesMatchesGolden("detach_animatesDetach") }, + ) { + animateValueTo((DetachPosition.toPx() + 10f) * directionSign, changePerFrame = 5f) + awaitStable() + } + } + + @Test + fun attach_snapsToOrigin() { + motion.goldenTest( + createTestSpec(), + initialValue = (DetachPosition.toPx() + 20f) * directionSign, + initialDirection = InputDirection.Min, + verifyTimeSeries = { AssertTimeSeriesMatchesGolden("attach_snapsToOrigin") }, + ) { + animateValueTo(0f, changePerFrame = 5f) + awaitStable() + } + } + + @Test + fun beforeAttach_suppressesDirectionReverse() { + motion.goldenTest( + createTestSpec(), + initialValue = (DetachPosition.toPx() + 20f) * directionSign, + initialDirection = InputDirection.Min, + verifyTimeSeries = { + AssertTimeSeriesMatchesGolden("beforeAttach_suppressesDirectionReverse") + }, + ) { + animateValueTo((AttachPosition.toPx() + 11f) * directionSign) + animateValueTo((DetachPosition.toPx() + 20f) * directionSign) + awaitStable() + } + } + + @Test + fun afterAttach_detachesAgain() { + motion.goldenTest( + createTestSpec(), + initialValue = (DetachPosition.toPx() + 20f) * directionSign, + initialDirection = InputDirection.Min, + verifyTimeSeries = { AssertTimeSeriesMatchesGolden("afterAttach_detachesAgain") }, + ) { + animateValueTo((AttachPosition.toPx() / 2f + 10f) * directionSign, changePerFrame = 5f) + awaitStable() + animateValueTo((DetachPosition.toPx() + 20f) * directionSign, changePerFrame = 5f) + awaitStable() + } + } + + @Test + fun beforeDetach_suppressesDirectionReverse() { + motion.goldenTest( + createTestSpec(), + verifyTimeSeries = { + AssertTimeSeriesMatchesGolden("beforeDetach_suppressesDirectionReverse") + }, + ) { + animateValueTo((DetachPosition.toPx() - 9f) * directionSign) + animateValueTo(0f) + awaitStable() + } + } + + @Test + fun placedWithDifferentBaseMapping() { + motion.goldenTest( + spatialMotionSpec(baseMapping = Mapping.Linear(factor = -10f)) { + if (placement == EffectPlacemenType.After) { + after(-10f, MagneticDetach()) + } else if (placement == EffectPlacemenType.Before) { + before(10f, MagneticDetach()) + } + }, + initialValue = (-10f) * directionSign, + verifyTimeSeries = { AssertTimeSeriesMatchesGolden("placedWithDifferentBaseMapping") }, + ) { + animateValueTo((DetachPosition.toPx() - 10f) * directionSign) + awaitStable() + } + } +} diff --git a/mechanics/tests/src/com/android/mechanics/effects/OverdragTest.kt b/mechanics/tests/src/com/android/mechanics/effects/OverdragTest.kt new file mode 100644 index 0000000..c0bd8cc --- /dev/null +++ b/mechanics/tests/src/com/android/mechanics/effects/OverdragTest.kt @@ -0,0 +1,129 @@ +/* + * Copyright (C) 2025 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.effects + +import androidx.compose.ui.unit.dp +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.android.mechanics.spec.InputDirection +import com.android.mechanics.spec.builder.MotionBuilderContext +import com.android.mechanics.spec.builder.spatialMotionSpec +import com.android.mechanics.testing.CaptureTimeSeriesFn +import com.android.mechanics.testing.ComposeMotionValueToolkit +import com.android.mechanics.testing.FakeMotionSpecBuilderContext +import com.android.mechanics.testing.FeatureCaptures +import com.android.mechanics.testing.VerifyTimeSeriesResult +import com.android.mechanics.testing.animateValueTo +import com.android.mechanics.testing.defaultFeatureCaptures +import com.android.mechanics.testing.goldenTest +import com.android.mechanics.testing.input +import com.android.mechanics.testing.nullableDataPoints +import com.android.mechanics.testing.output +import com.google.common.truth.Truth.assertThat +import kotlin.math.abs +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import platform.test.motion.MotionTestRule +import platform.test.motion.golden.DataPointTypes +import platform.test.motion.testing.createGoldenPathManager + +@RunWith(AndroidJUnit4::class) +class OverdragTest : MotionBuilderContext by FakeMotionSpecBuilderContext.Default { + private val goldenPathManager = + createGoldenPathManager("frameworks/libs/systemui/mechanics/tests/goldens") + + @get:Rule val motion = MotionTestRule(ComposeMotionValueToolkit, goldenPathManager) + + @Test + fun overdrag_maxDirection_neverExceedsMaxOverdrag() { + motion.goldenTest( + spatialMotionSpec { after(10f, Overdrag(maxOverdrag = 20.dp)) }, + capture = captureOverdragFeatures, + verifyTimeSeries = { + assertThat(output.filter { it > 30 }).isEmpty() + VerifyTimeSeriesResult.AssertTimeSeriesMatchesGolden() + }, + ) { + animateValueTo(100f, changePerFrame = 5f) + } + } + + @Test + fun overdrag_minDirection_neverExceedsMaxOverdrag() { + motion.goldenTest( + spatialMotionSpec { before(-10f, Overdrag(maxOverdrag = 20.dp)) }, + capture = captureOverdragFeatures, + initialDirection = InputDirection.Min, + verifyTimeSeries = { + assertThat(output.filter { it < -30 }).isEmpty() + + VerifyTimeSeriesResult.AssertTimeSeriesMatchesGolden() + }, + ) { + animateValueTo(-100f, changePerFrame = 5f) + } + } + + @Test + fun overdrag_nonStandardBaseFunction() { + motion.goldenTest( + spatialMotionSpec(baseMapping = { -it }) { after(10f, Overdrag(maxOverdrag = 20.dp)) }, + capture = captureOverdragFeatures, + initialValue = 5f, + verifyTimeSeries = { + assertThat(output.filter { it < -30 }).isEmpty() + VerifyTimeSeriesResult.AssertTimeSeriesMatchesGolden() + }, + ) { + animateValueTo(100f, changePerFrame = 5f) + } + } + + @Test + fun semantics_exposesOverdragLimitWhileOverdragging() { + motion.goldenTest( + spatialMotionSpec { + before(-10f, Overdrag()) + after(10f, Overdrag()) + }, + capture = captureOverdragFeatures, + verifyTimeSeries = { + val isOverdragging = input.map { abs(it) >= 10 } + val hasOverdragLimit = nullableDataPoints("overdragLimit").map { it != null } + assertThat(hasOverdragLimit).isEqualTo(isOverdragging) + VerifyTimeSeriesResult.SkipGoldenVerification + }, + ) { + animateValueTo(20f, changePerFrame = 5f) + reset(0f, InputDirection.Min) + animateValueTo(-20f, changePerFrame = 5f) + } + } + + companion object { + val captureOverdragFeatures: CaptureTimeSeriesFn = { + defaultFeatureCaptures() + feature( + FeatureCaptures.semantics( + Overdrag.Defaults.OverdragLimit, + DataPointTypes.float, + "overdragLimit", + ) + ) + } + } +} diff --git a/mechanics/tests/src/com/android/mechanics/effects/RevealOnThresholdTest.kt b/mechanics/tests/src/com/android/mechanics/effects/RevealOnThresholdTest.kt new file mode 100644 index 0000000..c0b5eb7 --- /dev/null +++ b/mechanics/tests/src/com/android/mechanics/effects/RevealOnThresholdTest.kt @@ -0,0 +1,118 @@ +/* + * Copyright (C) 2025 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.effects + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.android.mechanics.spec.InputDirection +import com.android.mechanics.spec.Mapping +import com.android.mechanics.spec.builder.MotionBuilderContext +import com.android.mechanics.spec.builder.spatialMotionSpec +import com.android.mechanics.testing.ComposeMotionValueToolkit +import com.android.mechanics.testing.FakeMotionSpecBuilderContext +import com.android.mechanics.testing.MotionSpecSubject.Companion.assertThat +import com.android.mechanics.testing.animateValueTo +import com.android.mechanics.testing.goldenTest +import com.google.common.truth.Truth.assertThat +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import platform.test.motion.MotionTestRule +import platform.test.motion.testing.createGoldenPathManager + +@RunWith(AndroidJUnit4::class) +class RevealOnThresholdTest : MotionBuilderContext by FakeMotionSpecBuilderContext.Default { + + private val goldenPathManager = + createGoldenPathManager("frameworks/libs/systemui/mechanics/tests/goldens") + + @get:Rule val motion = MotionTestRule(ComposeMotionValueToolkit, goldenPathManager) + + @Test + fun matchesSpec() { + val underTests = spatialMotionSpec(Mapping.Zero) { between(3f, 30f, RevealOnThreshold()) } + + val minSize = RevealOnThreshold.Defaults.MinSize.toPx() + + assertThat(3f + minSize).isLessThan(30f) + + assertThat(underTests) + .maxDirection() + .breakpoints() + .positions() + .containsExactly(3f, 3f + minSize, 30f) + + assertThat(underTests) + .minDirection() + .breakpoints() + .positions() + .containsExactly(3f, 3f + minSize, 30f) + } + + @Test + fun revealAnimation() { + motion.goldenTest( + spatialMotionSpec(Mapping.Zero) { between(3f, 30f, RevealOnThreshold()) } + ) { + animateValueTo(36f, changePerFrame = 3f) + awaitStable() + } + } + + @Test + fun revealAnimation_afterFixedValue() { + motion.goldenTest( + spatialMotionSpec(Mapping.Zero) { between(3f, 30f, RevealOnThreshold()) } + ) { + animateValueTo(36f, changePerFrame = 3f) + awaitStable() + } + } + + @Test + fun hideAnimation() { + motion.goldenTest( + spatialMotionSpec(Mapping.Zero) { between(3f, 30f, RevealOnThreshold()) }, + initialValue = 36f, + initialDirection = InputDirection.Min, + ) { + animateValueTo(0f, changePerFrame = 3f) + awaitStable() + } + } + + @Test + fun doNothingBeforeThreshold() { + motion.goldenTest( + spatialMotionSpec(Mapping.Zero) { between(3f, 30f, RevealOnThreshold()) } + ) { + animateValueTo(2f + RevealOnThreshold.Defaults.MinSize.toPx(), changePerFrame = 3f) + awaitStable() + } + } + + @Test + fun hideAnimationOnThreshold() { + motion.goldenTest( + spatialMotionSpec(Mapping.Zero) { between(3f, 30f, RevealOnThreshold()) }, + initialValue = 36f, + initialDirection = InputDirection.Min, + ) { + animateValueTo(3f + RevealOnThreshold.Defaults.MinSize.toPx(), changePerFrame = 3f) + awaitStable() + } + } +} diff --git a/mechanics/tests/src/com/android/mechanics/spec/DirectionalMotionSpecTest.kt b/mechanics/tests/src/com/android/mechanics/spec/DirectionalMotionSpecTest.kt new file mode 100644 index 0000000..30c8513 --- /dev/null +++ b/mechanics/tests/src/com/android/mechanics/spec/DirectionalMotionSpecTest.kt @@ -0,0 +1,200 @@ +/* + * 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.spec + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.android.mechanics.spec.builder.directionalMotionSpec +import com.android.mechanics.spring.SpringParameters +import com.google.common.truth.Truth.assertThat +import kotlin.math.nextDown +import kotlin.math.nextUp +import kotlin.test.assertFailsWith +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class DirectionalMotionSpecTest { + + @Test + fun noBreakpoints_throws() { + assertFailsWith { + DirectionalMotionSpec(emptyList(), emptyList()) + } + } + + @Test + fun wrongSentinelBreakpoints_throws() { + val breakpoint1 = Breakpoint(B1, position = 10f, Spring, Guarantee.None) + val breakpoint2 = Breakpoint(B2, position = 20f, Spring, Guarantee.None) + + assertFailsWith { + DirectionalMotionSpec(listOf(breakpoint1, breakpoint2), listOf(Mapping.Identity)) + } + } + + @Test + fun tooFewMappings_throws() { + assertFailsWith { + DirectionalMotionSpec(listOf(Breakpoint.minLimit, Breakpoint.maxLimit), emptyList()) + } + } + + @Test + fun tooManyMappings_throws() { + assertFailsWith { + DirectionalMotionSpec( + listOf(Breakpoint.minLimit, Breakpoint.maxLimit), + listOf(Mapping.One, Mapping.Two), + ) + } + } + + @Test + fun breakpointsOutOfOrder_throws() { + val breakpoint1 = Breakpoint(B1, position = 10f, Spring, Guarantee.None) + val breakpoint2 = Breakpoint(B2, position = 20f, Spring, Guarantee.None) + assertFailsWith { + DirectionalMotionSpec( + listOf(Breakpoint.minLimit, breakpoint2, breakpoint1, Breakpoint.maxLimit), + listOf(Mapping.Zero, Mapping.One, Mapping.Two), + ) + } + } + + @Test + fun findBreakpointIndex_returnsMinForEmptySpec() { + val underTest = DirectionalMotionSpec.Empty + + assertThat(underTest.findBreakpointIndex(0f)).isEqualTo(0) + assertThat(underTest.findBreakpointIndex(Float.MAX_VALUE)).isEqualTo(0) + assertThat(underTest.findBreakpointIndex(-Float.MAX_VALUE)).isEqualTo(0) + } + + @Test + fun findBreakpointIndex_throwsForNonFiniteInput() { + val underTest = DirectionalMotionSpec.Empty + + assertFailsWith { underTest.findBreakpointIndex(Float.NaN) } + assertFailsWith { + underTest.findBreakpointIndex(Float.NEGATIVE_INFINITY) + } + assertFailsWith { + underTest.findBreakpointIndex(Float.POSITIVE_INFINITY) + } + } + + @Test + fun findBreakpointIndex_atBreakpoint_returnsIndex() { + val underTest = + directionalMotionSpec(Spring) { mapping(breakpoint = 10f, mapping = Mapping.Identity) } + + assertThat(underTest.findBreakpointIndex(10f)).isEqualTo(1) + } + + @Test + fun findBreakpointIndex_afterBreakpoint_returnsPreviousIndex() { + val underTest = + directionalMotionSpec(Spring) { mapping(breakpoint = 10f, mapping = Mapping.Identity) } + + assertThat(underTest.findBreakpointIndex(10f.nextUp())).isEqualTo(1) + } + + @Test + fun findBreakpointIndex_beforeBreakpoint_returnsIndex() { + val underTest = + directionalMotionSpec(Spring) { mapping(breakpoint = 10f, mapping = Mapping.Identity) } + + assertThat(underTest.findBreakpointIndex(10f.nextDown())).isEqualTo(0) + } + + @Test + fun findBreakpointIndexByKey_returnsIndex() { + val underTest = + directionalMotionSpec(Spring) { + mapping(breakpoint = 10f, key = B1, mapping = Mapping.Identity) + } + + assertThat(underTest.findBreakpointIndex(B1)).isEqualTo(1) + } + + @Test + fun findBreakpointIndexByKey_unknown_returnsMinusOne() { + val underTest = + directionalMotionSpec(Spring) { + mapping(breakpoint = 10f, key = B1, mapping = Mapping.Identity) + } + + assertThat(underTest.findBreakpointIndex(B2)).isEqualTo(-1) + } + + @Test + fun findSegmentIndex_returnsIndexForSegment_ignoringDirection() { + val underTest = + directionalMotionSpec(Spring) { + mapping(breakpoint = 10f, key = B1, mapping = Mapping.One) + mapping(breakpoint = 20f, key = B2, mapping = Mapping.Identity) + } + + assertThat(underTest.findSegmentIndex(SegmentKey(B1, B2, InputDirection.Max))).isEqualTo(1) + assertThat(underTest.findSegmentIndex(SegmentKey(B1, B2, InputDirection.Min))).isEqualTo(1) + } + + @Test + fun findSegmentIndex_forInvalidKeys_returnsMinusOne() { + val underTest = + directionalMotionSpec(Spring) { + mapping(breakpoint = 10f, key = B1, mapping = Mapping.One) + mapping(breakpoint = 20f, key = B2, mapping = Mapping.One) + mapping(breakpoint = 30f, key = B3, mapping = Mapping.Identity) + } + + assertThat(underTest.findSegmentIndex(SegmentKey(B2, B1, InputDirection.Max))).isEqualTo(-1) + assertThat(underTest.findSegmentIndex(SegmentKey(B1, B3, InputDirection.Max))).isEqualTo(-1) + } + + @Test + fun semantics_tooFewValues_throws() { + assertFailsWith { + DirectionalMotionSpec( + listOf(Breakpoint.minLimit, Breakpoint.maxLimit), + listOf(Mapping.Identity), + listOf(SegmentSemanticValues(Semantic1, emptyList())), + ) + } + } + + @Test + fun semantics_tooManyValues_throws() { + assertFailsWith { + DirectionalMotionSpec( + listOf(Breakpoint.minLimit, Breakpoint.maxLimit), + listOf(Mapping.Identity), + listOf(SegmentSemanticValues(Semantic1, listOf("One", "Two"))), + ) + } + } + + companion object { + val B1 = BreakpointKey("one") + val B2 = BreakpointKey("two") + val B3 = BreakpointKey("three") + val Semantic1 = SemanticKey("Foo") + val Semantic2 = SemanticKey("Bar") + + val Spring = SpringParameters(stiffness = 100f, dampingRatio = 1f) + } +} diff --git a/mechanics/tests/src/com/android/mechanics/spec/MotionSpecDebugFormatterTest.kt b/mechanics/tests/src/com/android/mechanics/spec/MotionSpecDebugFormatterTest.kt new file mode 100644 index 0000000..1777a72 --- /dev/null +++ b/mechanics/tests/src/com/android/mechanics/spec/MotionSpecDebugFormatterTest.kt @@ -0,0 +1,145 @@ +/* + * Copyright (C) 2025 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.spec + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.android.mechanics.spec.ChangeSegmentHandlers.PreventDirectionChangeWithinCurrentSegment +import com.android.mechanics.spec.builder.MotionBuilderContext +import com.android.mechanics.spec.builder.effectsDirectionalMotionSpec +import com.android.mechanics.spec.builder.spatialDirectionalMotionSpec +import com.android.mechanics.testing.FakeMotionSpecBuilderContext +import com.google.common.truth.Truth.assertThat +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class MotionSpecDebugFormatterTest : MotionBuilderContext by FakeMotionSpecBuilderContext.Default { + + @Test + fun motionSpec_unidirectionalSpec_formatIsUseful() { + val spec = MotionSpec(effectsDirectionalMotionSpec { fixedValue(0f, value = 1f) }) + + assertThat(formatForTest(spec.toDebugString())) + .isEqualTo( + """ +unidirectional: + @-Infinity [built-in::min|id:0x1234cdef] + Fixed(value=0.0) + @0.0 [id:0x1234cdef] spring=1600.0/1.0 + Fixed(value=1.0) + @Infinity [built-in::max|id:0x1234cdef]""" + .trimIndent() + ) + } + + @Test + fun motionSpec_bidirectionalSpec_formatIsUseful() { + val spec = + MotionSpec( + spatialDirectionalMotionSpec(Mapping.Zero) { fixedValue(0f, value = 1f) }, + spatialDirectionalMotionSpec(Mapping.One) { fixedValue(0f, value = 0f) }, + ) + + assertThat(formatForTest(spec.toDebugString())) + .isEqualTo( + """ +maxDirection: + @-Infinity [built-in::min|id:0x1234cdef] + Fixed(value=0.0) + @0.0 [id:0x1234cdef] spring=700.0/0.9 + Fixed(value=1.0) + @Infinity [built-in::max|id:0x1234cdef] +minDirection: + @-Infinity [built-in::min|id:0x1234cdef] + Fixed(value=1.0) + @0.0 [id:0x1234cdef] spring=700.0/0.9 + Fixed(value=0.0) + @Infinity [built-in::max|id:0x1234cdef]""" + .trimIndent() + ) + } + + @Test + fun motionSpec_semantics_formatIsUseful() { + val semanticKey = SemanticKey("foo") + + val spec = + MotionSpec( + effectsDirectionalMotionSpec(semantics = listOf(semanticKey with 42f)) { + fixedValue(0f, value = 1f, semantics = listOf(semanticKey with 43f)) + } + ) + + assertThat(formatForTest(spec.toDebugString())) + .isEqualTo( + """ +unidirectional: + @-Infinity [built-in::min|id:0x1234cdef] + Fixed(value=0.0) + foo[id:0x1234cdef]=42.0 + @0.0 [id:0x1234cdef] spring=1600.0/1.0 + Fixed(value=1.0) + foo[id:0x1234cdef]=43.0 + @Infinity [built-in::max|id:0x1234cdef]""" + .trimIndent() + ) + } + + @Test + fun motionSpec_segmentHandlers_formatIsUseful() { + val key1 = BreakpointKey("1") + val key2 = BreakpointKey("2") + val spec = + MotionSpec( + effectsDirectionalMotionSpec { + fixedValue(0f, value = 1f, key = key1) + fixedValue(2f, value = 2f, key = key1) + }, + segmentHandlers = + mapOf( + SegmentKey(key1, key2, InputDirection.Max) to + PreventDirectionChangeWithinCurrentSegment, + SegmentKey(key1, key2, InputDirection.Min) to + PreventDirectionChangeWithinCurrentSegment, + ), + ) + + assertThat(formatForTest(spec.toDebugString())) + .isEqualTo( + """ +unidirectional: + @-Infinity [built-in::min|id:0x1234cdef] + Fixed(value=0.0) + @0.0 [1|id:0x1234cdef] spring=1600.0/1.0 + Fixed(value=1.0) + @2.0 [1|id:0x1234cdef] spring=1600.0/1.0 + Fixed(value=2.0) + @Infinity [built-in::max|id:0x1234cdef] +segmentHandlers: + 1|id:0x1234cdef >> 2|id:0x1234cdef + 1|id:0x1234cdef << 2|id:0x1234cdef""" + .trimIndent() + ) + } + + companion object { + private val idMatcher = Regex("id:0x[0-9a-f]{8}") + + fun formatForTest(debugString: String) = + debugString.replace(idMatcher, "id:0x1234cdef").trim() + } +} diff --git a/mechanics/tests/src/com/android/mechanics/spec/MotionSpecTest.kt b/mechanics/tests/src/com/android/mechanics/spec/MotionSpecTest.kt new file mode 100644 index 0000000..260a8a7 --- /dev/null +++ b/mechanics/tests/src/com/android/mechanics/spec/MotionSpecTest.kt @@ -0,0 +1,320 @@ +/* + * 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.spec + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.android.mechanics.spec.builder.directionalMotionSpec +import com.android.mechanics.spring.SpringParameters +import com.android.mechanics.testing.BreakpointSubject.Companion.assertThat +import com.google.common.truth.Truth.assertThat +import kotlin.test.assertFailsWith +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class MotionSpecTest { + + @Test + fun containsSegment_unknownSegment_returnsFalse() { + val underTest = MotionSpec.Empty + assertThat(underTest.containsSegment(SegmentKey(B1, B2, InputDirection.Max))).isFalse() + } + + @Test + fun containsSegment_symmetricSpec_knownSegment_returnsTrue() { + val underTest = + MotionSpec( + directionalMotionSpec(Spring) { + fixedValue(breakpoint = 10f, key = B1, value = 1f) + identity(breakpoint = 20f, key = B2) + } + ) + + assertThat(underTest.containsSegment(SegmentKey(B1, B2, InputDirection.Max))).isTrue() + assertThat(underTest.containsSegment(SegmentKey(B1, B2, InputDirection.Min))).isTrue() + } + + @Test + fun containsSegment_asymmetricSpec_knownMaxDirectionSegment_trueOnlyInMaxDirection() { + val underTest = + MotionSpec( + maxDirection = + directionalMotionSpec(Spring) { + fixedValue(breakpoint = 10f, key = B1, value = 1f) + identity(breakpoint = 20f, key = B2) + }, + minDirection = DirectionalMotionSpec.Empty, + ) + + assertThat(underTest.containsSegment(SegmentKey(B1, B2, InputDirection.Max))).isTrue() + assertThat(underTest.containsSegment(SegmentKey(B1, B2, InputDirection.Min))).isFalse() + } + + @Test + fun containsSegment_asymmetricSpec_knownMinDirectionSegment_trueOnlyInMinDirection() { + val underTest = + MotionSpec( + maxDirection = DirectionalMotionSpec.Empty, + minDirection = + directionalMotionSpec(Spring) { + fixedValue(breakpoint = 10f, key = B1, value = 1f) + identity(breakpoint = 20f, key = B2) + }, + ) + + assertThat(underTest.containsSegment(SegmentKey(B1, B2, InputDirection.Max))).isFalse() + assertThat(underTest.containsSegment(SegmentKey(B1, B2, InputDirection.Min))).isTrue() + } + + @Test + fun segmentAtInput_emptySpec_maxDirection_segmentDataIsCorrect() { + val underTest = MotionSpec.Empty + + val segmentAtInput = underTest.segmentAtInput(0f, InputDirection.Max) + + assertThat(segmentAtInput.spec).isSameInstanceAs(underTest) + assertThat(segmentAtInput.minBreakpoint).isSameInstanceAs(Breakpoint.minLimit) + assertThat(segmentAtInput.maxBreakpoint).isSameInstanceAs(Breakpoint.maxLimit) + assertThat(segmentAtInput.direction).isEqualTo(InputDirection.Max) + assertThat(segmentAtInput.mapping).isEqualTo(Mapping.Identity) + } + + @Test + fun segmentAtInput_emptySpec_minDirection_segmentDataIsCorrect() { + val underTest = MotionSpec.Empty + + val segmentAtInput = underTest.segmentAtInput(0f, InputDirection.Min) + + assertThat(segmentAtInput.spec).isSameInstanceAs(underTest) + assertThat(segmentAtInput.minBreakpoint).isSameInstanceAs(Breakpoint.minLimit) + assertThat(segmentAtInput.maxBreakpoint).isSameInstanceAs(Breakpoint.maxLimit) + assertThat(segmentAtInput.direction).isEqualTo(InputDirection.Min) + assertThat(segmentAtInput.mapping).isEqualTo(Mapping.Identity) + } + + @Test + fun segmentAtInput_atBreakpointPosition() { + val underTest = + MotionSpec( + directionalMotionSpec(Spring) { + fixedValue(breakpoint = 10f, key = B1, value = 1f) + identity(breakpoint = 20f, key = B2) + } + ) + + val segmentAtInput = underTest.segmentAtInput(10f, InputDirection.Max) + + assertThat(segmentAtInput.key).isEqualTo(SegmentKey(B1, B2, InputDirection.Max)) + assertThat(segmentAtInput.minBreakpoint).isAt(10f) + assertThat(segmentAtInput.maxBreakpoint).isAt(20f) + assertThat(segmentAtInput.mapping).isEqualTo(Mapping.One) + } + + @Test + fun segmentAtInput_reverse_atBreakpointPosition() { + val underTest = + MotionSpec( + directionalMotionSpec(Spring) { + fixedValue(breakpoint = 10f, key = B1, value = 1f) + identity(breakpoint = 20f, key = B2) + } + ) + + val segmentAtInput = underTest.segmentAtInput(20f, InputDirection.Min) + + assertThat(segmentAtInput.key).isEqualTo(SegmentKey(B1, B2, InputDirection.Min)) + assertThat(segmentAtInput.minBreakpoint).isAt(10f) + assertThat(segmentAtInput.maxBreakpoint).isAt(20f) + assertThat(segmentAtInput.mapping).isEqualTo(Mapping.One) + } + + @Test + fun containsSegment_asymmetricSpec_readsFromIndicatedDirection() { + val underTest = + MotionSpec( + maxDirection = + directionalMotionSpec(Spring) { + fixedValue(breakpoint = 10f, key = B1, value = 1f) + identity(breakpoint = 20f, key = B2) + }, + minDirection = + directionalMotionSpec(Spring) { + fixedValue(breakpoint = 5f, key = B1, value = 2f) + identity(breakpoint = 25f, key = B2) + }, + ) + + val segmentAtInputMax = underTest.segmentAtInput(15f, InputDirection.Max) + assertThat(segmentAtInputMax.key).isEqualTo(SegmentKey(B1, B2, InputDirection.Max)) + assertThat(segmentAtInputMax.minBreakpoint).isAt(10f) + assertThat(segmentAtInputMax.maxBreakpoint).isAt(20f) + assertThat(segmentAtInputMax.mapping).isEqualTo(Mapping.One) + + val segmentAtInputMin = underTest.segmentAtInput(15f, InputDirection.Min) + assertThat(segmentAtInputMin.key).isEqualTo(SegmentKey(B1, B2, InputDirection.Min)) + assertThat(segmentAtInputMin.minBreakpoint).isAt(5f) + assertThat(segmentAtInputMin.maxBreakpoint).isAt(25f) + assertThat(segmentAtInputMin.mapping).isEqualTo(Mapping.Two) + } + + @Test + fun onSegmentChanged_noHandler_returnsEqualSegmentForSameInput() { + val underTest = + MotionSpec( + directionalMotionSpec(Spring) { + fixedValue(breakpoint = 10f, key = B1, value = 1f) + identity(breakpoint = 20f, key = B2) + } + ) + + val segmentAtInput = underTest.segmentAtInput(15f, InputDirection.Max) + val onChangedResult = underTest.onChangeSegment(segmentAtInput, 15f, InputDirection.Max) + assertThat(segmentAtInput).isEqualTo(onChangedResult) + } + + @Test + fun onSegmentChanged_noHandler_returnsNewSegmentForNewInput() { + val underTest = + MotionSpec( + directionalMotionSpec(Spring) { + fixedValue(breakpoint = 10f, key = B1, value = 1f) + identity(breakpoint = 20f, key = B2) + } + ) + + val segmentAtInput = underTest.segmentAtInput(15f, InputDirection.Max) + val onChangedResult = underTest.onChangeSegment(segmentAtInput, 15f, InputDirection.Min) + assertThat(segmentAtInput).isNotEqualTo(onChangedResult) + + assertThat(onChangedResult.key).isEqualTo(SegmentKey(B1, B2, InputDirection.Min)) + } + + @Test + fun onSegmentChanged_withHandlerReturningNull_returnsSegmentAtInput() { + val underTest = + MotionSpec( + directionalMotionSpec(Spring) { + fixedValue(breakpoint = 10f, key = B1, value = 1f) + identity(breakpoint = 20f, key = B2) + } + ) + .copy( + segmentHandlers = + mapOf(SegmentKey(B1, B2, InputDirection.Max) to { _, _, _ -> null }) + ) + + val segmentAtInput = underTest.segmentAtInput(15f, InputDirection.Max) + val onChangedResult = underTest.onChangeSegment(segmentAtInput, 15f, InputDirection.Min) + + assertThat(segmentAtInput).isNotEqualTo(onChangedResult) + assertThat(onChangedResult.key).isEqualTo(SegmentKey(B1, B2, InputDirection.Min)) + } + + @Test + fun onSegmentChanged_withHandlerReturningSegment_returnsHandlerResult() { + val underTest = + MotionSpec( + directionalMotionSpec(Spring) { + fixedValue(breakpoint = 10f, key = B1, value = 1f) + identity(breakpoint = 20f, key = B2) + } + ) + .copy( + segmentHandlers = + mapOf( + SegmentKey(B1, B2, InputDirection.Max) to + { _, _, _ -> + segmentAtInput(0f, InputDirection.Min) + } + ) + ) + + val segmentAtInput = underTest.segmentAtInput(15f, InputDirection.Max) + val onChangedResult = underTest.onChangeSegment(segmentAtInput, 15f, InputDirection.Min) + + assertThat(segmentAtInput).isNotEqualTo(onChangedResult) + assertThat(onChangedResult.key) + .isEqualTo(SegmentKey(Breakpoint.minLimit.key, B1, InputDirection.Min)) + } + + @Test + fun semanticState_returnsStateFromSegment() { + val underTest = + MotionSpec( + maxDirection = directionalMotionSpec(semantics = listOf(S1 with "One")), + minDirection = directionalMotionSpec(semantics = listOf(S1 with "Two")), + ) + + val maxDirectionSegment = SegmentKey(BMin, BMax, InputDirection.Max) + assertThat(underTest.semanticState(S1, maxDirectionSegment)).isEqualTo("One") + + val minDirectionSegment = SegmentKey(BMin, BMax, InputDirection.Min) + assertThat(underTest.semanticState(S1, minDirectionSegment)).isEqualTo("Two") + } + + @Test + fun semanticState_unknownSegment_throws() { + val underTest = MotionSpec(directionalMotionSpec(semantics = listOf(S1 with "One"))) + + val unknownSegment = SegmentKey(BMin, B1, InputDirection.Max) + assertFailsWith { underTest.semanticState(S1, unknownSegment) } + } + + @Test + fun semanticState_unknownSemantics_returnsNull() { + val underTest = MotionSpec(directionalMotionSpec(semantics = listOf(S1 with "One"))) + + val maxDirectionSegment = SegmentKey(BMin, BMax, InputDirection.Max) + assertThat(underTest.semanticState(S2, maxDirectionSegment)).isNull() + } + + @Test + fun semantics_returnsAllValuesForSegment() { + val underTest = + MotionSpec( + directionalMotionSpec(Spring, semantics = listOf(S1 with "One", S2 with "AAA")) { + identity(breakpoint = 0f, key = B1, semantics = listOf(S2 with "BBB")) + identity(breakpoint = 2f, key = B2, semantics = listOf(S1 with "Two")) + } + ) + + assertThat(underTest.semantics(SegmentKey(BMin, B1, InputDirection.Max))) + .containsExactly(S1 with "One", S2 with "AAA") + assertThat(underTest.semantics(SegmentKey(B1, B2, InputDirection.Max))) + .containsExactly(S1 with "One", S2 with "BBB") + assertThat(underTest.semantics(SegmentKey(B2, BMax, InputDirection.Max))) + .containsExactly(S1 with "Two", S2 with "BBB") + } + + @Test + fun semantics_unknownSegment_throws() { + val underTest = MotionSpec.Empty + val unknownSegment = SegmentKey(BMin, B1, InputDirection.Max) + assertFailsWith { underTest.semantics(unknownSegment) } + } + + companion object { + val BMin = Breakpoint.minLimit.key + val B1 = BreakpointKey("one") + val B2 = BreakpointKey("two") + val BMax = Breakpoint.maxLimit.key + val S1 = SemanticKey("Foo") + val S2 = SemanticKey("Bar") + + val Spring = SpringParameters(stiffness = 100f, dampingRatio = 1f) + } +} diff --git a/mechanics/tests/src/com/android/mechanics/spec/SegmentTest.kt b/mechanics/tests/src/com/android/mechanics/spec/SegmentTest.kt new file mode 100644 index 0000000..f66991c --- /dev/null +++ b/mechanics/tests/src/com/android/mechanics/spec/SegmentTest.kt @@ -0,0 +1,106 @@ +/* + * 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.spec + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.android.mechanics.spring.SpringParameters +import com.google.common.truth.Truth.assertThat +import com.google.common.truth.Truth.assertWithMessage +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class SegmentTest { + + private val fakeSpec = MotionSpec.Empty + + @Test + fun segmentData_isValidForInput_betweenBreakpointsSameDirection_isTrue() { + val breakpoint1 = Breakpoint(B1, position = 10f, Spring, Guarantee.None) + val breakpoint2 = Breakpoint(B2, position = 20f, Spring, Guarantee.None) + val underTest = + SegmentData(fakeSpec, breakpoint1, breakpoint2, InputDirection.Max, Mapping.Identity) + + assertThat(underTest.isValidForInput(15f, InputDirection.Max)).isTrue() + } + + @Test + fun segmentData_isValidForInput_betweenBreakpointsOppositeDirection_isFalse() { + val breakpoint1 = Breakpoint(B1, position = 10f, Spring, Guarantee.None) + val breakpoint2 = Breakpoint(B2, position = 20f, Spring, Guarantee.None) + val underTest = + SegmentData(fakeSpec, breakpoint1, breakpoint2, InputDirection.Max, Mapping.Identity) + + assertThat(underTest.isValidForInput(15f, InputDirection.Min)).isFalse() + } + + @Test + fun segmentData_isValidForInput_inMaxDirection_sampledAtVariousPositions_matchesExpectation() { + val breakpoint1 = Breakpoint(B1, position = 10f, Spring, Guarantee.None) + val breakpoint2 = Breakpoint(B2, position = 20f, Spring, Guarantee.None) + val underTest = + SegmentData(fakeSpec, breakpoint1, breakpoint2, InputDirection.Max, Mapping.Identity) + + for ((samplePosition, expectedResult) in + listOf(5f to true, 10f to true, 15f to true, 20f to false, 25f to false)) { + assertWithMessage("at $samplePosition") + .that(underTest.isValidForInput(samplePosition, InputDirection.Max)) + .isEqualTo(expectedResult) + } + } + + @Test + fun segmentData_isValidForInput_inMinDirection_sampledAtVariousPositions_matchesExpectation() { + val breakpoint1 = Breakpoint(B1, position = 10f, Spring, Guarantee.None) + val breakpoint2 = Breakpoint(B2, position = 20f, Spring, Guarantee.None) + val underTest = + SegmentData(fakeSpec, breakpoint1, breakpoint2, InputDirection.Min, Mapping.Identity) + + for ((samplePosition, expectedResult) in + listOf(5f to false, 10f to false, 15f to true, 20f to true, 25f to true)) { + assertWithMessage("at $samplePosition") + .that(underTest.isValidForInput(samplePosition, InputDirection.Min)) + .isEqualTo(expectedResult) + } + } + + @Test + fun segmentData_entryBreakpoint_maxDirection_returnsMinBreakpoint() { + val breakpoint1 = Breakpoint(B1, position = 10f, Spring, Guarantee.None) + val breakpoint2 = Breakpoint(B2, position = 20f, Spring, Guarantee.None) + val underTest = + SegmentData(fakeSpec, breakpoint1, breakpoint2, InputDirection.Max, Mapping.Identity) + + assertThat(underTest.entryBreakpoint).isSameInstanceAs(breakpoint1) + } + + @Test + fun segmentData_entryBreakpoint_minDirection_returnsMaxBreakpoint() { + val breakpoint1 = Breakpoint(B1, position = 10f, Spring, Guarantee.None) + val breakpoint2 = Breakpoint(B2, position = 20f, Spring, Guarantee.None) + val underTest = + SegmentData(fakeSpec, breakpoint1, breakpoint2, InputDirection.Min, Mapping.Identity) + + assertThat(underTest.entryBreakpoint).isSameInstanceAs(breakpoint2) + } + + companion object { + val B1 = BreakpointKey("one") + val B2 = BreakpointKey("two") + val Spring = SpringParameters(stiffness = 100f, dampingRatio = 1f) + } +} diff --git a/mechanics/tests/src/com/android/mechanics/spec/builder/DirectionalBuilderImplTest.kt b/mechanics/tests/src/com/android/mechanics/spec/builder/DirectionalBuilderImplTest.kt new file mode 100644 index 0000000..b399731 --- /dev/null +++ b/mechanics/tests/src/com/android/mechanics/spec/builder/DirectionalBuilderImplTest.kt @@ -0,0 +1,295 @@ +/* + * Copyright (C) 2025 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.spec.builder + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.android.mechanics.spec.BreakpointKey +import com.android.mechanics.spec.Guarantee +import com.android.mechanics.spec.Mapping +import com.android.mechanics.spec.SemanticKey +import com.android.mechanics.spec.with +import com.android.mechanics.spring.SpringParameters +import com.android.mechanics.testing.DirectionalMotionSpecSubject.Companion.assertThat +import com.android.mechanics.testing.FakeMotionSpecBuilderContext +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class DirectionalBuilderImplTest { + + @Test + fun directionalSpec_buildEmptySpec() { + val result = directionalMotionSpec() + + assertThat(result).breakpoints().isEmpty() + assertThat(result).mappings().containsExactly(Mapping.Identity) + } + + @Test + fun directionalSpec_addBreakpointsAndMappings() { + val result = + directionalMotionSpec(Spring, Mapping.Zero) { + mapping(breakpoint = 0f, mapping = Mapping.One, key = B1) + mapping(breakpoint = 10f, mapping = Mapping.Two, key = B2) + } + + assertThat(result).breakpoints().keys().containsExactly(B1, B2).inOrder() + assertThat(result).breakpoints().withKey(B1).isAt(0f) + assertThat(result).breakpoints().withKey(B2).isAt(10f) + assertThat(result) + .mappings() + .containsExactly(Mapping.Zero, Mapping.One, Mapping.Two) + .inOrder() + } + + @Test + fun directionalSpec_mappingBuilder_setsDefaultSpring() { + val result = directionalMotionSpec(Spring) { fixedValue(breakpoint = 10f, value = 20f) } + + assertThat(result).breakpoints().atPosition(10f).spring().isEqualTo(Spring) + } + + @Test + fun directionalSpec_mappingBuilder_canOverrideDefaultSpring() { + val otherSpring = SpringParameters(stiffness = 10f, dampingRatio = 0.1f) + val result = + directionalMotionSpec(Spring) { + fixedValue(breakpoint = 10f, value = 20f, spring = otherSpring) + } + + assertThat(result).breakpoints().atPosition(10f).spring().isEqualTo(otherSpring) + } + + @Test + fun directionalSpec_mappingBuilder_defaultsToNoGuarantee() { + val result = directionalMotionSpec(Spring) { fixedValue(breakpoint = 10f, value = 20f) } + + assertThat(result).breakpoints().atPosition(10f).guarantee().isEqualTo(Guarantee.None) + } + + @Test + fun directionalSpec_mappingBuilder_canSetGuarantee() { + val guarantee = Guarantee.InputDelta(10f) + val result = + directionalMotionSpec(Spring) { + fixedValue(breakpoint = 10f, value = 20f, guarantee = guarantee) + } + + assertThat(result).breakpoints().atPosition(10f).guarantee().isEqualTo(guarantee) + } + + @Test + fun directionalSpec_mappingBuilder_jumpTo_setsAbsoluteValue() { + val result = + directionalMotionSpec(Spring, Mapping.Fixed(99f)) { + fixedValue(breakpoint = 10f, value = 20f) + } + + assertThat(result).breakpoints().positions().containsExactly(10f) + assertThat(result).mappings().atOrAfter(10f).isFixedValue(20f) + } + + @Test + fun directionalSpec_mappingBuilder_jumpBy_setsRelativeValue() { + val result = + directionalMotionSpec(Spring, Mapping.Linear(factor = 0.5f)) { + // At 10f the current value is 5f (10f * 0.5f) + fixedValueFromCurrent(breakpoint = 10f, delta = 30f) + } + + assertThat(result).breakpoints().positions().containsExactly(10f) + assertThat(result).mappings().atOrAfter(10f).isFixedValue(35f) + } + + @Test + fun directionalSpec_mappingBuilder_continueWithFixedValue_usesSourceValue() { + val result = + directionalMotionSpec(Spring, Mapping.Linear(factor = 0.5f)) { + // At 5f the current value is 2.5f (5f * 0.5f) + fixedValueFromCurrent(breakpoint = 5f) + } + + assertThat(result).mappings().atOrAfter(5f).isFixedValue(2.5f) + } + + @Test + fun directionalSpec_mappingBuilder_continueWithFractionalInput_matchesLinearMapping() { + val result = + directionalMotionSpec(Spring) { + fractionalInput(breakpoint = 5f, from = 1f, fraction = .1f) + } + + assertThat(result) + .mappings() + .atOrAfter(5f) + .matchesLinearMapping(in1 = 5f, out1 = 1f, in2 = 15f, out2 = 2f) + } + + @Test + fun directionalSpec_mappingBuilder_continueWithTargetValue_matchesLinearMapping() { + val result = + directionalMotionSpec(Spring) { + target(breakpoint = 5f, from = 1f, to = 20f) + mapping(breakpoint = 30f, mapping = Mapping.Identity) + } + + assertThat(result) + .mappings() + .atOrAfter(5f) + .matchesLinearMapping(in1 = 5f, out1 = 1f, in2 = 30f, out2 = 20f) + } + + @Test + fun directionalSpec_mappingBuilder_breakpointsAtSamePosition_producesValidSegment() { + val result = + directionalMotionSpec(Spring) { + target(breakpoint = 5f, from = 1f, to = 20f) + mapping(breakpoint = 5f, mapping = Mapping.Identity) + } + assertThat(result) + .mappings() + .containsExactly(Mapping.Identity, Mapping.Fixed(1f), Mapping.Identity) + .inOrder() + } + + @Test + fun directionalSpec_mappingBuilder_identity_addsIdentityMapping() { + val result = directionalMotionSpec(Spring, Mapping.Zero) { identity(breakpoint = 10f) } + assertThat(result).mappings().containsExactly(Mapping.Zero, Mapping.Identity).inOrder() + assertThat(result).breakpoints().positions().containsExactly(10f) + } + + @Test + fun directionalSpec_mappingBuilder_identityWithDelta_producesLinearMapping() { + val result = + directionalMotionSpec(Spring, Mapping.Zero) { identity(breakpoint = 10f, delta = 2f) } + + assertThat(result) + .mappings() + .atOrAfter(10f) + .matchesLinearMapping(in1 = 10f, out1 = 12f, in2 = 20f, out2 = 22f) + } + + @Test + fun semantics_appliedForSingleSegment() { + val result = directionalMotionSpec(Mapping.Identity, listOf(S1 with "One", S2 with "Two")) + + assertThat(result).semantics().containsExactly(S1, S2) + assertThat(result).semantics().withKey(S1).containsExactly("One") + assertThat(result).semantics().withKey(S2).containsExactly("Two") + } + + @Test + fun directionalSpec_semantics_appliedForAllSegments() { + val result = + directionalMotionSpec(Spring, semantics = listOf(S1 with "One")) { + mapping(breakpoint = 0f, mapping = Mapping.Identity) + } + assertThat(result).mappings().hasSize(2) + assertThat(result).semantics().containsExactly(S1) + assertThat(result).semantics().withKey(S1).containsExactly("One", "One") + } + + @Test + fun directionalSpec_semantics_appliedForCurrentSegment() { + val result = + directionalMotionSpec(Spring, semantics = listOf(S1 with "One")) { + mapping(breakpoint = 0f, mapping = Mapping.Identity) + mapping( + breakpoint = 2f, + mapping = Mapping.Identity, + semantics = listOf(S1 with "Two"), + ) + } + assertThat(result).mappings().hasSize(3) + assertThat(result).semantics().withKey(S1).containsExactly("One", "One", "Two").inOrder() + } + + @Test + fun directionalSpec_semantics_changingUndeclaredSemantics_backfills() { + val result = + directionalMotionSpec(Spring) { + mapping( + breakpoint = 0f, + mapping = Mapping.Identity, + semantics = listOf(S1 with "Two"), + ) + } + + assertThat(result).mappings().hasSize(2) + assertThat(result).semantics().withKey(S1).containsExactly("Two", "Two").inOrder() + } + + @Test + fun directionalSpec_semantics_changeableIndividually() { + val result = + directionalMotionSpec(Spring, semantics = listOf(S1 with "One", S2 with "AAA")) { + mapping( + breakpoint = 0f, + mapping = Mapping.Identity, + semantics = listOf(S2 with "BBB"), + ) + mapping( + breakpoint = 2f, + mapping = Mapping.Identity, + semantics = listOf(S1 with "Two"), + ) + } + assertThat(result).mappings().hasSize(3) + assertThat(result).semantics().withKey(S1).containsExactly("One", "One", "Two").inOrder() + assertThat(result).semantics().withKey(S2).containsExactly("AAA", "BBB", "BBB").inOrder() + } + + @Test + fun directionalSpec_semantics_lateCompletedSegmentsRetainSemantics() { + val result = + directionalMotionSpec(Spring, semantics = listOf(S1 with "One")) { + targetFromCurrent(breakpoint = 0f, to = 10f, semantics = listOf(S1 with "Two")) + identity(breakpoint = 1f, semantics = listOf(S1 with "Three")) + } + assertThat(result).mappings().hasSize(3) + assertThat(result).semantics().withKey(S1).containsExactly("One", "Two", "Three").inOrder() + } + + @Test + fun builderContext_spatialDirectionalMotionSpec_defaultsToSpatialSpringAndIdentityMapping() { + val context = FakeMotionSpecBuilderContext.Default + + val result = with(context) { spatialDirectionalMotionSpec { fixedValue(0f, value = 1f) } } + + assertThat(result).mappings().containsExactly(Mapping.Identity, Mapping.One).inOrder() + assertThat(result).breakpoints().atPosition(0f).spring().isEqualTo(context.spatial.default) + } + + @Test + fun builderContext_effectsDirectionalMotionSpec_defaultsToEffectsSpringAndZeroMapping() { + val context = FakeMotionSpecBuilderContext.Default + + val result = with(context) { effectsDirectionalMotionSpec { fixedValue(0f, value = 1f) } } + + assertThat(result).mappings().containsExactly(Mapping.Zero, Mapping.One).inOrder() + assertThat(result).breakpoints().atPosition(0f).spring().isEqualTo(context.effects.default) + } + + companion object { + val Spring = SpringParameters(stiffness = 100f, dampingRatio = 1f) + val B1 = BreakpointKey("One") + val B2 = BreakpointKey("Two") + val S1 = SemanticKey("Foo") + val S2 = SemanticKey("Bar") + } +} diff --git a/mechanics/tests/src/com/android/mechanics/spec/builder/MotionSpecBuilderTest.kt b/mechanics/tests/src/com/android/mechanics/spec/builder/MotionSpecBuilderTest.kt new file mode 100644 index 0000000..2b6760a --- /dev/null +++ b/mechanics/tests/src/com/android/mechanics/spec/builder/MotionSpecBuilderTest.kt @@ -0,0 +1,740 @@ +/* + * Copyright (C) 2025 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.spec.builder + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.android.mechanics.effects.FixedValue +import com.android.mechanics.spec.BreakpointKey +import com.android.mechanics.spec.Guarantee +import com.android.mechanics.spec.Mapping +import com.android.mechanics.spec.SemanticKey +import com.android.mechanics.spec.SemanticValue +import com.android.mechanics.spec.with +import com.android.mechanics.spring.SpringParameters +import com.android.mechanics.testing.FakeMotionSpecBuilderContext +import com.android.mechanics.testing.MotionSpecSubject.Companion.assertThat +import com.google.common.truth.Truth.assertThat +import kotlin.test.assertFailsWith +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class MotionSpecBuilderTest : MotionBuilderContext by FakeMotionSpecBuilderContext.Default { + + // placement & ordering + // placement types + // placement issues + // before & after mapping, springs etc + + @Test + fun motionSpec_empty_usesBaseMapping() { + val result = spatialMotionSpec {} + + assertThat(result).bothDirections().mappingsMatch(Mapping.Identity) + assertThat(result).bothDirections().breakpoints().isEmpty() + } + + @Test + fun placement_absoluteAfter_createsTwoSegments() { + val result = + motionSpec(baseMapping = Mapping.Zero, defaultSpring = spatial.default) { + after(42f, FixedValue(1f)) + } + + assertThat(result).bothDirections().mappingsMatch(Mapping.Zero, Mapping.One) + assertThat(result).bothDirections().breakpointsPositionsMatch(42f) + } + + @Test + fun placement_absoluteBefore_createsTwoSegments() { + val result = + motionSpec(baseMapping = Mapping.Zero, defaultSpring = spatial.default) { + before(42f, FixedValue(1f)) + } + + assertThat(result).bothDirections().mappingsMatch(Mapping.One, Mapping.Zero) + assertThat(result).bothDirections().breakpointsPositionsMatch(42f) + } + + @Test + fun placement_absoluteBetween_createsThreeSegments() { + val result = + motionSpec(baseMapping = Mapping.Zero, defaultSpring = spatial.default) { + between(42f, 43f, FixedValue(1f)) + } + + assertThat(result).bothDirections().mappingsMatch(Mapping.Zero, Mapping.One, Mapping.Zero) + assertThat(result).bothDirections().breakpointsPositionsMatch(42f, 43f) + } + + @Test + fun placement_absoluteBetweenReverse_createsThreeSegments() { + val result = + motionSpec(baseMapping = Mapping.Zero, defaultSpring = spatial.default) { + between(43f, 42f, FixedValue(1f)) + } + + assertThat(result).bothDirections().mappingsMatch(Mapping.Zero, Mapping.One, Mapping.Zero) + assertThat(result).bothDirections().breakpointsPositionsMatch(42f, 43f) + } + + @Test + fun placement_adjacent_sharesBreakpoint() { + val result = + motionSpec(baseMapping = Mapping.Zero, defaultSpring = spatial.default) { + between(1f, 2f, FixedValue(1f)) + between(2f, 3f, FixedValue(2f)) + } + + assertThat(result).bothDirections().fixedMappingsMatch(0f, 1f, 2f, 0f) + assertThat(result).bothDirections().breakpointsPositionsMatch(1f, 2f, 3f) + } + + @Test + fun placement_multiple_baseMappingInBetween() { + val result = + motionSpec(baseMapping = Mapping.Zero, defaultSpring = spatial.default) { + between(1f, 2f, FixedValue(1f)) + // Implicit baseMapping between 2 & 3 + between(3f, 4f, FixedValue(2f)) + } + + assertThat(result).bothDirections().fixedMappingsMatch(0f, 1f, 0f, 2f, 0f) + assertThat(result).bothDirections().breakpointsPositionsMatch(1f, 2f, 3f, 4f) + } + + @Test + fun placement_overlapping_throws() { + val exception = + assertFailsWith { + motionSpec(baseMapping = Mapping.Zero, defaultSpring = spatial.default) { + between(1f, 2f, FixedValue(1f)) + between(1.5f, 2.5f, FixedValue(2f)) + } + } + assertThat(exception).hasMessageThat().contains("overlap") + } + + @Test + fun placement_embedded_throws() { + val exception = + assertFailsWith { + motionSpec(baseMapping = Mapping.Zero, defaultSpring = spatial.default) { + between(1f, 3f, FixedValue(1f)) + between(1.5f, 2.5f, FixedValue(2f)) + } + } + assertThat(exception).hasMessageThat().contains("overlap") + } + + @Test + fun placement_subsequent_extendsToNext() { + val result = + motionSpec(baseMapping = Mapping.Zero, defaultSpring = spatial.default) { + after(1f, FixedValue(1f)) + between(3f, 4f, FixedValue(2f)) + } + + assertThat(result).bothDirections().fixedMappingsMatch(0f, 1f, 2f, 0f) + assertThat(result).bothDirections().breakpointsPositionsMatch(1f, 3f, 4f) + } + + @Test + fun placement_subsequent_extendsToPrevious() { + val result = + motionSpec(baseMapping = Mapping.Zero, defaultSpring = spatial.default) { + between(1f, 2f, FixedValue(1f)) + before(4f, FixedValue(2f)) + } + + assertThat(result).bothDirections().fixedMappingsMatch(0f, 1f, 2f, 0f) + assertThat(result).bothDirections().breakpointsPositionsMatch(1f, 2f, 4f) + } + + @Test + fun placement_subsequent_bothExtend_throws() { + val exception = + assertFailsWith { + motionSpec(baseMapping = Mapping.Zero, defaultSpring = spatial.default) { + after(1f, FixedValue(1f)) + before(3f, FixedValue(2f)) + } + } + assertThat(exception).hasMessageThat().contains("extend") + } + + @Test + fun placement_withFixedExtent_after_limitsEffect() { + val result = + motionSpec(baseMapping = Mapping.Zero, defaultSpring = spatial.default) { + after(1f, FixedValueWithExtent(1f, 2f)) + } + + assertThat(result).bothDirections().fixedMappingsMatch(0f, 1f, 0f) + assertThat(result).bothDirections().breakpointsPositionsMatch(1f, 3f) + } + + @Test + fun placement_withFixedExtent_before_limitsEffect() { + val result = + motionSpec(baseMapping = Mapping.Zero, defaultSpring = spatial.default) { + before(1f, FixedValueWithExtent(1f, 2f)) + } + + assertThat(result).bothDirections().fixedMappingsMatch(0f, 1f, 0f) + assertThat(result).bothDirections().breakpointsPositionsMatch(-1f, 1f) + } + + @Test + fun placement_relative_afterEffect() { + val result = + motionSpec(baseMapping = Mapping.Zero, defaultSpring = spatial.default) { + val effect1 = between(1f, 2f, FixedValue(1f)) + after(effect1, FixedValue(2f)) + } + + assertThat(result).bothDirections().fixedMappingsMatch(0f, 1f, 2f) + assertThat(result).bothDirections().breakpointsPositionsMatch(1f, 2f) + } + + @Test + fun placement_relative_beforeEffect() { + val result = + motionSpec(baseMapping = Mapping.Zero, defaultSpring = spatial.default) { + val effect1 = between(1f, 2f, FixedValue(1f)) + before(effect1, FixedValue(2f)) + } + + assertThat(result).bothDirections().fixedMappingsMatch(2f, 1f, 0f) + assertThat(result).bothDirections().breakpointsPositionsMatch(1f, 2f) + } + + @Test + fun placement_relative_chainOfMappings() { + val result = + motionSpec(baseMapping = Mapping.Zero, defaultSpring = spatial.default) { + val rootEffect = after(1f, FixedValueWithExtent(-1f, 2f)) + + val left = before(rootEffect, FixedValueWithExtent(-2f, 3f)) + before(left, FixedValueWithExtent(-3f, 4f)) + + val right = after(rootEffect, FixedValueWithExtent(-4f, 3f)) + after(right, FixedValueWithExtent(-5f, 4f)) + } + + assertThat(result).bothDirections().fixedMappingsMatch(0f, -3f, -2f, -1f, -4f, -5f, 0f) + assertThat(result).bothDirections().breakpointsPositionsMatch(-6f, -2f, 1f, 3f, 6f, 10f) + } + + @Test + fun placement_relative_overlappingChain_throws() { + assertFailsWith { + motionSpec(baseMapping = Mapping.Zero, defaultSpring = spatial.default) { + val rootEffect = between(1f, 3f, FixedValue(-1f)) + val left = before(rootEffect, FixedValue(-2f)) + after(left, FixedValue(-3f)) + } + } + } + + @Test + fun effect_differentReverseSpec() { + val effect = SimpleEffect { + forward(Mapping.One) + backward(Mapping.Two) + } + + val result = + motionSpec(baseMapping = Mapping.Zero, defaultSpring = spatial.default) { + between(1f, 2f, effect) + } + + assertThat(result).maxDirection().fixedMappingsMatch(0f, 1f, 0f) + assertThat(result).maxDirection().breakpointsPositionsMatch(1f, 2f) + + assertThat(result).minDirection().fixedMappingsMatch(0f, 2f, 0f) + assertThat(result).minDirection().breakpointsPositionsMatch(1f, 2f) + } + + @Test + fun effect_separateReverseSpec_withBuilder_canProduceDifferentSegmentCount() { + val effect = + object : Effect.PlaceableBetween { + override fun EffectApplyScope.createSpec( + minLimit: Float, + minLimitKey: BreakpointKey, + maxLimit: Float, + maxLimitKey: BreakpointKey, + placement: EffectPlacement, + ) { + forward(Mapping.One) { fixedValue(breakpoint = minLimit + 0.5f, 10f) } + backward(Mapping.Two) + } + } + + val result = + motionSpec(baseMapping = Mapping.Zero, defaultSpring = spatial.default) { + between(1f, 2f, effect) + } + + assertThat(result).maxDirection().fixedMappingsMatch(0f, 1f, 10f, 0f) + assertThat(result).maxDirection().breakpointsPositionsMatch(1f, 1.5f, 2f) + + assertThat(result).minDirection().fixedMappingsMatch(0f, 2f, 0f) + assertThat(result).minDirection().breakpointsPositionsMatch(1f, 2f) + } + + @Test + fun effect_identicalBackward_withBuilder_producesSameSpecInBothDirections() { + val breakpointKey = BreakpointKey("foo") + val effect = + UnidirectionalEffect(Mapping.One) { + fixedValue(breakpoint = 1.5f, value = 10f, key = breakpointKey) + } + + val result = + motionSpec(baseMapping = Mapping.Zero, defaultSpring = spatial.default) { + between(1f, 2f, effect) + } + + assertThat(result).bothDirections().fixedMappingsMatch(0f, 1f, 10f, 0f) + assertThat(result).bothDirections().breakpointsPositionsMatch(1f, 1.5f, 2f) + } + + @Test + fun effect_setBreakpointBeforeMinLimit_throws() { + val rogueEffect = + UnidirectionalEffect(Mapping.One) { this.fixedValue(breakpoint = 0.5f, value = 0f) } + + assertFailsWith { + motionSpec(baseMapping = Mapping.Zero, defaultSpring = spatial.default) { + between(1f, 2f, rogueEffect) + } + } + } + + @Test + fun effect_setBreakpointAfterMinLimit_throws() { + val rogueEffect = + UnidirectionalEffect(Mapping.One) { this.fixedValue(breakpoint = 2.5f, value = 0f) } + + assertFailsWith { + motionSpec(baseMapping = Mapping.Zero, defaultSpring = spatial.default) { + between(1f, 2f, rogueEffect) + } + } + } + + @Test + fun effect_semantics_applyToFullInputRange() { + val semanticKey = SemanticKey("foo") + val effect = + UnidirectionalEffect( + Mapping.One, + semantics = listOf(SemanticValue(semanticKey, "initial")), + ) { + fixedValue( + breakpoint = 1.5f, + value = 2f, + semantics = listOf(SemanticValue(semanticKey, "second")), + ) + } + + val result = + motionSpec(baseMapping = Mapping.Zero, defaultSpring = spatial.default) { + between(1f, 2f, effect) + } + + assertThat(result) + .maxDirection() + .semantics() + .withKey(semanticKey) + .containsExactly("initial", "initial", "second", "second") + .inOrder() + } + + @Test + fun beforeAfter_minSpring_isChangeable() { + val spring = SpringParameters(stiffness = 1f, dampingRatio = 2f) + val effect = UnidirectionalEffect(Mapping.One) { before(spring = spring) } + + val result = + motionSpec(baseMapping = Mapping.Zero, defaultSpring = spatial.default) { + between(1f, 2f, effect) + } + + assertThat(result).bothDirections().breakpoints().atPosition(1f).spring().isEqualTo(spring) + } + + @Test + fun beforeAfter_maxSpring_isChangeable() { + val spring = SpringParameters(stiffness = 1f, dampingRatio = 2f) + val effect = UnidirectionalEffect(Mapping.One) { after(spring = spring) } + + val result = + motionSpec(baseMapping = Mapping.Zero, defaultSpring = spatial.default) { + between(1f, 2f, effect) + } + + assertThat(result).bothDirections().breakpoints().atPosition(2f).spring().isEqualTo(spring) + } + + @Test + fun beforeAfter_conflictingSpring_secondEffectWins() { + val spring1 = SpringParameters(stiffness = 1f, dampingRatio = 2f) + val spring2 = SpringParameters(stiffness = 2f, dampingRatio = 2f) + + val result = + motionSpec(baseMapping = Mapping.Zero, defaultSpring = spatial.default) { + between(1f, 2f, UnidirectionalEffect(Mapping.One) { after(spring = spring1) }) + between(2f, 3f, UnidirectionalEffect(Mapping.One) { before(spring = spring2) }) + } + + assertThat(result).bothDirections().breakpoints().atPosition(2f).spring().isEqualTo(spring2) + } + + @Test + fun beforeAfter_minGuarantee_isChangeable() { + val guarantee = Guarantee.InputDelta(1f) + val effect = UnidirectionalEffect(Mapping.One) { before(guarantee = guarantee) } + + val result = + motionSpec(baseMapping = Mapping.Zero, defaultSpring = spatial.default) { + between(1f, 2f, effect) + } + + assertThat(result) + .bothDirections() + .breakpoints() + .atPosition(1f) + .guarantee() + .isEqualTo(guarantee) + } + + @Test + fun beforeAfter_maxGuarantee_isChangeable() { + val guarantee = Guarantee.InputDelta(1f) + val effect = UnidirectionalEffect(Mapping.One) { after(guarantee = guarantee) } + + val result = + motionSpec(baseMapping = Mapping.Zero, defaultSpring = spatial.default) { + between(1f, 2f, effect) + } + + assertThat(result) + .bothDirections() + .breakpoints() + .atPosition(2f) + .guarantee() + .isEqualTo(guarantee) + } + + @Test + fun beforeAfter_conflictingGuarantee_secondEffectWins() { + val guarantee1 = Guarantee.InputDelta(1f) + val guarantee2 = Guarantee.InputDelta(2f) + + val result = + motionSpec(baseMapping = Mapping.Zero, defaultSpring = spatial.default) { + between(1f, 2f, UnidirectionalEffect(Mapping.One) { after(guarantee = guarantee1) }) + between( + 2f, + 3f, + UnidirectionalEffect(Mapping.One) { before(guarantee = guarantee2) }, + ) + } + + assertThat(result) + .bothDirections() + .breakpoints() + .atPosition(2f) + .guarantee() + .isEqualTo(guarantee2) + } + + @Test + fun beforeAfter_maxSemantics_applyAfterEffect() { + val effect = + UnidirectionalEffect(Mapping.One, testSemantics("s1")) { + after(semantics = testSemantics("s1+")) + } + + val result = + motionSpec(baseMapping = Mapping.Zero, defaultSpring = spatial.default) { + val effect1 = between(1f, 2f, effect) + after(effect1, FixedValue(2f)) + } + + assertThat(result) + .maxDirection() + .semantics() + .withKey(TestSemantics) + .containsExactly("s1", "s1", "s1+") + .inOrder() + } + + @Test + fun beforeAfter_minSemantics_applyBeforeEffect() { + val effect = + UnidirectionalEffect(Mapping.One, testSemantics("s1")) { + before(semantics = testSemantics("s1-")) + } + + val result = + motionSpec(baseMapping = Mapping.Zero, defaultSpring = spatial.default) { + between(1f, 2f, effect) + before(1f, FixedValue(2f)) + } + + assertThat(result) + .maxDirection() + .semantics() + .withKey(TestSemantics) + .containsExactly("s1-", "s1", "s1") + .inOrder() + } + + @Test + fun beforeAfter_conflictingSemantics_firstEffectWins() { + val effect1 = + UnidirectionalEffect(Mapping.One, testSemantics("s1")) { + after(semantics = testSemantics("s1+")) + } + val effect2 = + UnidirectionalEffect(Mapping.One, testSemantics("s2")) { + before(semantics = testSemantics("s2-")) + } + + val result = + motionSpec(baseMapping = Mapping.Zero, defaultSpring = spatial.default) { + between(1f, 2f, effect1) + between(3f, 4f, effect2) + } + + assertThat(result) + .maxDirection() + .semantics() + .withKey(TestSemantics) + .containsExactly("s1", "s1", "s1+", "s2", "s2") + .inOrder() + } + + @Test + fun beforeAfter_semantics_specifiedByNextEffect_afterSemanticsIgnored() { + val effect1 = + UnidirectionalEffect(Mapping.One, testSemantics("s1")) { + after(semantics = testSemantics("s1+")) + } + + val effect2 = UnidirectionalEffect(Mapping.One, semantics = testSemantics("s2")) + + val result = + motionSpec(baseMapping = Mapping.Zero, defaultSpring = spatial.default) { + between(1f, 2f, effect1) + between(2f, 3f, effect2) + } + + assertThat(result) + .maxDirection() + .semantics() + .withKey(TestSemantics) + .containsExactly("s1", "s1", "s2", "s2") + .inOrder() + } + + @Test + fun beforeAfter_semantics_specifiedByPreviousEffect_beforeSemanticsIgnored() { + val effect1 = UnidirectionalEffect(Mapping.One, testSemantics("s1")) + + val effect2 = + UnidirectionalEffect(Mapping.One, semantics = testSemantics("s2")) { + before(semantics = testSemantics("s2-")) + } + + val result = + motionSpec(baseMapping = Mapping.Zero, defaultSpring = spatial.default) { + between(1f, 2f, effect1) + between(2f, 3f, effect2) + } + + assertThat(result) + .maxDirection() + .semantics() + .withKey(TestSemantics) + .containsExactly("s1", "s1", "s2", "s2") + .inOrder() + } + + @Test + fun beforeAfter_maxMapping_applyAfterEffect() { + val effect = UnidirectionalEffect(Mapping.One) { after(mapping = Mapping.Two) } + + val result = + motionSpec(baseMapping = Mapping.Zero, defaultSpring = spatial.default) { + between(1f, 2f, effect) + } + + assertThat(result).bothDirections().fixedMappingsMatch(0f, 1f, 2f) + } + + @Test + fun beforeAfter_minMapping_applyBeforeEffect() { + val effect = UnidirectionalEffect(Mapping.One) { before(mapping = Mapping.Two) } + + val result = + motionSpec(baseMapping = Mapping.Zero, defaultSpring = spatial.default) { + between(1f, 2f, effect) + } + + assertThat(result).bothDirections().fixedMappingsMatch(2f, 1f, 0f) + } + + @Test + fun beforeAfter_minMapping_ignoredWhenEffectBeforeSpecified() { + val effect = UnidirectionalEffect(Mapping.One) { before(mapping = Mapping.Two) } + + val result = + motionSpec(baseMapping = Mapping.Zero, defaultSpring = spatial.default) { + between(1f, 2f, effect) + before(1f, FixedValue(3f)) + } + + assertThat(result).bothDirections().fixedMappingsMatch(3f, 1f, 0f) + } + + @Test + fun beforeAfter_maxMapping_ignoredWhenEffectAfterSpecified() { + val effect = UnidirectionalEffect(Mapping.One) { after(mapping = Mapping.Two) } + + val result = + motionSpec(baseMapping = Mapping.Zero, defaultSpring = spatial.default) { + between(1f, 2f, effect) + after(2f, FixedValue(3f)) + } + + assertThat(result).bothDirections().fixedMappingsMatch(0f, 1f, 3f) + } + + @Test + fun beforeAfter_minMapping_ignoredWhenFirstEffect() { + val effect = UnidirectionalEffect(Mapping.One) { before(mapping = Mapping.Two) } + + val result = + motionSpec(baseMapping = Mapping.Zero, defaultSpring = spatial.default) { + before(0f, effect) + } + + assertThat(result).bothDirections().fixedMappingsMatch(1f, 0f) + } + + @Test + fun beforeAfter_maxMapping_ignoredWhenLastEffect() { + val effect = UnidirectionalEffect(Mapping.One) { after(mapping = Mapping.Two) } + + val result = + motionSpec(baseMapping = Mapping.Zero, defaultSpring = spatial.default) { + after(0f, effect) + } + + assertThat(result).bothDirections().fixedMappingsMatch(0f, 1f) + } + + @Test + fun order_sharedBreakpoint_betweenAndAfter_sortedCorrectly() { + val result = + spatialMotionSpec(Mapping.Zero) { + after(2f, FixedValue(2f)) + between(1f, 2f, FixedValue(1f)) + } + + assertThat(result).bothDirections().fixedMappingsMatch(0f, 1f, 2f) + } + + @Test + fun order_sharedBreakpoint_betweenAndBefore_sortedCorrectly() { + val result = + spatialMotionSpec(Mapping.Zero) { + between(1f, 2f, FixedValue(2f)) + before(1f, FixedValue(1f)) + } + + assertThat(result).bothDirections().fixedMappingsMatch(1f, 2f, 0f) + } + + @Test + fun order_sharedBreakpoint_beforeAfter_sortedCorrectly() { + val result = + spatialMotionSpec(Mapping.Zero) { + after(1f, FixedValue(2f)) + before(1f, FixedValue(1f)) + } + + assertThat(result).bothDirections().fixedMappingsMatch(1f, 2f) + } + + private class SimpleEffect(private val createSpec: EffectApplyScope.() -> Unit) : + Effect.PlaceableBetween { + override fun EffectApplyScope.createSpec( + minLimit: Float, + minLimitKey: BreakpointKey, + maxLimit: Float, + maxLimitKey: BreakpointKey, + placement: EffectPlacement, + ) { + createSpec() + } + } + + private class UnidirectionalEffect( + private val initialMapping: Mapping, + private val semantics: List> = emptyList(), + private val init: DirectionalEffectBuilderScope.() -> Unit = {}, + ) : Effect.PlaceableBetween, Effect.PlaceableAfter, Effect.PlaceableBefore { + override fun MotionBuilderContext.intrinsicSize(): Float = Float.POSITIVE_INFINITY + + override fun EffectApplyScope.createSpec( + minLimit: Float, + minLimitKey: BreakpointKey, + maxLimit: Float, + maxLimitKey: BreakpointKey, + placement: EffectPlacement, + ) { + unidirectional(initialMapping, semantics, init) + } + } + + private class FixedValueWithExtent(val value: Float, val extent: Float) : + Effect.PlaceableAfter, Effect.PlaceableBefore { + override fun MotionBuilderContext.intrinsicSize() = extent + + override fun EffectApplyScope.createSpec( + minLimit: Float, + minLimitKey: BreakpointKey, + maxLimit: Float, + maxLimitKey: BreakpointKey, + placement: EffectPlacement, + ) { + return unidirectional(Mapping.Fixed(value)) + } + } + + companion object { + val TestSemantics = SemanticKey("foo") + + fun testSemantics(value: String) = listOf(TestSemantics with value) + } +} diff --git a/mechanics/tests/src/com/android/mechanics/spring/ComposeAndMechanicsSpringCompatibilityTest.kt b/mechanics/tests/src/com/android/mechanics/spring/ComposeAndMechanicsSpringCompatibilityTest.kt new file mode 100644 index 0000000..d06012d --- /dev/null +++ b/mechanics/tests/src/com/android/mechanics/spring/ComposeAndMechanicsSpringCompatibilityTest.kt @@ -0,0 +1,152 @@ +/* + * 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.spring + +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.SpringSpec +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.TestMonotonicFrameClock +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.google.common.truth.Truth.assertThat +import kotlin.math.abs +import kotlin.math.max +import kotlin.math.min +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.withContext +import org.junit.Test +import org.junit.runner.RunWith + +@OptIn(ExperimentalTestApi::class, ExperimentalCoroutinesApi::class) +@RunWith(AndroidJUnit4::class) +class ComposeAndMechanicsSpringCompatibilityTest { + + @Test + fun criticallyDamped_matchesComposeSpring() = runTestWithFrameClock { + assertMechanicsMatchesComposeSpringMovement( + SpringParameters(stiffness = 100f, dampingRatio = 1f) + ) + } + + @Test + fun underDamped_matchesComposeSpring() = runTestWithFrameClock { + assertMechanicsMatchesComposeSpringMovement( + SpringParameters(stiffness = 1000f, dampingRatio = .5f) + ) + } + + @Test + fun overDamped_matchesComposeSpring() = runTestWithFrameClock { + assertMechanicsMatchesComposeSpringMovement( + SpringParameters(stiffness = 2000f, dampingRatio = 1.5f) + ) + } + + @Test + fun withInitialVelocity_matchesComposeSpring() = runTestWithFrameClock { + assertMechanicsMatchesComposeSpringMovement( + SpringParameters(stiffness = 2000f, dampingRatio = .85f), + startDisplacement = 0f, + initialVelocity = 10f, + ) + } + + private suspend fun assertMechanicsMatchesComposeSpringMovement( + parameters: SpringParameters, + startDisplacement: Float = 10f, + initialVelocity: Float = 0f, + ) { + val byCompose = computeComposeSpringValues(startDisplacement, initialVelocity, parameters) + + val byMechanics = + computeMechanicsSpringValues(startDisplacement, initialVelocity, parameters) + + assertSpringValuesMatch(byMechanics, byCompose) + } + + private suspend fun computeComposeSpringValues( + displacement: Float, + initialVelocity: Float, + parameters: SpringParameters, + ) = buildList { + Animatable(displacement, DisplacementThreshold).animateTo( + 0f, + parameters.asSpringSpec(), + initialVelocity, + ) { + add(SpringState(value, velocity)) + } + } + + private fun computeMechanicsSpringValues( + displacement: Float, + initialVelocity: Float, + parameters: SpringParameters, + ) = buildList { + var state = SpringState(displacement, initialVelocity) + while (!state.isStable(parameters, DisplacementThreshold)) { + add(state) + state = state.calculateUpdatedState(FrameDelayNanos, parameters) + } + } + + private fun assertSpringValuesMatch( + byMechanics: List, + byCompose: List, + ) { + // Last element by compose is zero displacement, zero velocity + assertThat(byCompose.last()).isEqualTo(SpringState.AtRest) + + // Mechanics computes when the spring is stable differently. Allow some variance. + assertThat(abs(byMechanics.size - byCompose.size)).isAtMost(2) + + // All frames until either one is considered stable must produce the same displacement + // and velocity + val maxFramesToExactlyCompare = min(byMechanics.size, byCompose.size - 1) + val tolerance = 0.0001f + for (i in 0 until maxFramesToExactlyCompare) { + val mechanics = byMechanics[i] + val compose = byCompose[i] + assertThat(mechanics.displacement).isWithin(tolerance).of(compose.displacement) + assertThat(mechanics.velocity).isWithin(tolerance).of(compose.velocity) + } + + // Afterwards, the displacement must be within displacementThreshold. + for (i in maxFramesToExactlyCompare until max(byMechanics.size, byCompose.size)) { + val mechanics = byMechanics.elementAtOrNull(i) ?: SpringState.AtRest + val compose = byCompose.elementAtOrNull(i) ?: SpringState.AtRest + assertThat(mechanics.displacement) + .isWithin(DisplacementThreshold) + .of(compose.displacement) + } + } + + private fun SpringParameters.asSpringSpec(): SpringSpec { + return SpringSpec(dampingRatio, stiffness) + } + + private fun runTestWithFrameClock(testBody: suspend () -> Unit) = runTest { + val testScope: TestScope = this + withContext(TestMonotonicFrameClock(testScope, FrameDelayNanos)) { testBody() } + } + + companion object { + private val FrameDelayNanos: Long = 16_000_000L + private val DisplacementThreshold: Float = 0.01f + } +} diff --git a/mechanics/tests/src/com/android/mechanics/spring/SpringParameterTest.kt b/mechanics/tests/src/com/android/mechanics/spring/SpringParameterTest.kt new file mode 100644 index 0000000..c8f6c38 --- /dev/null +++ b/mechanics/tests/src/com/android/mechanics/spring/SpringParameterTest.kt @@ -0,0 +1,58 @@ +/* + * 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.spring + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.google.common.truth.Truth.assertThat +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class SpringParameterTest { + + @Test + fun lerp_interpolatesDampingLinearly() { + val start = SpringParameters(stiffness = 100f, dampingRatio = 1.5f) + val stop = SpringParameters(stiffness = 100f, dampingRatio = 0.5f) + + assertThat(lerp(start, stop, 0f).dampingRatio).isEqualTo(1.5f) + assertThat(lerp(start, stop, .25f).dampingRatio).isEqualTo(1.25f) + assertThat(lerp(start, stop, .5f).dampingRatio).isEqualTo(1f) + assertThat(lerp(start, stop, 1f).dampingRatio).isEqualTo(.5f) + } + + @Test + fun lerp_interpolatesStiffnessLogarithmically() { + val start = SpringParameters(stiffness = 100f, dampingRatio = 1f) + val stop = SpringParameters(stiffness = 500_000f, dampingRatio = 1f) + + assertThat(lerp(start, stop, 0f).stiffness).isEqualTo(100f) + assertThat(lerp(start, stop, .25f).stiffness).isWithin(1f).of(840f) + assertThat(lerp(start, stop, .5f).stiffness).isWithin(1f).of(7_071f) + assertThat(lerp(start, stop, .75f).stiffness).isWithin(1f).of(59_460f) + assertThat(lerp(start, stop, 1f).stiffness).isEqualTo(500_000f) + } + + @Test + fun lerp_limitsFraction() { + val start = SpringParameters(stiffness = 100f, dampingRatio = 0.5f) + val stop = SpringParameters(stiffness = 1000f, dampingRatio = 1.5f) + + assertThat(lerp(start, stop, -1f)).isEqualTo(start) + assertThat(lerp(start, stop, +2f)).isEqualTo(stop) + } +} diff --git a/mechanics/tests/src/com/android/mechanics/spring/SpringStateTest.kt b/mechanics/tests/src/com/android/mechanics/spring/SpringStateTest.kt new file mode 100644 index 0000000..3bae23c --- /dev/null +++ b/mechanics/tests/src/com/android/mechanics/spring/SpringStateTest.kt @@ -0,0 +1,144 @@ +/* + * 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.spring + +import android.platform.test.annotations.MotionTest +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.android.mechanics.testing.asDataPoint +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +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 +import platform.test.motion.golden.TimestampFrameId +import platform.test.motion.golden.asDataPoint +import platform.test.motion.testing.createGoldenPathManager + +@RunWith(AndroidJUnit4::class) +@MotionTest +class SpringStateTest { + private val goldenPathManager = + createGoldenPathManager("frameworks/libs/systemui/mechanics/tests/goldens") + + @get:Rule val motion = MotionTestRule(Unit, goldenPathManager) + + @Test + fun criticallyDamped_matchesGolden() { + val parameters = SpringParameters(stiffness = 100f, dampingRatio = 1f) + val initialState = SpringState(displacement = 10f) + + assertSpringMotionMatchesGolden(initialState) { parameters } + } + + @Test + fun overDamped_matchesGolden() { + val parameters = SpringParameters(stiffness = 100f, dampingRatio = 2f) + val initialState = SpringState(displacement = 10f) + + assertSpringMotionMatchesGolden(initialState) { parameters } + } + + @Test + fun underDamped_matchesGolden() { + val parameters = SpringParameters(stiffness = 100f, dampingRatio = .3f) + val initialState = SpringState(displacement = 10f) + + assertSpringMotionMatchesGolden(initialState) { parameters } + } + + @Test + fun zeroDisplacement_initialVelocity_matchesGolden() { + val parameters = SpringParameters(stiffness = 100f, dampingRatio = .3f) + val initialState = SpringState(displacement = 0f, velocity = 10f) + + assertSpringMotionMatchesGolden(initialState) { parameters } + } + + @Test + fun snapSpring_updatesImmediately_matchesGolden() { + val initialState = SpringState(displacement = 10f, velocity = -10f) + + assertSpringMotionMatchesGolden(initialState) { SpringParameters.Snap } + } + + @Test + fun stiffeningSpring_matchesGolden() { + val parameters = SpringParameters(stiffness = 100f, dampingRatio = .3f) + val initialState = SpringState(displacement = 10f, velocity = -10f) + + assertSpringMotionMatchesGolden(initialState) { + lerp(parameters, SpringParameters.Snap, it / 200f) + } + } + + private fun assertSpringMotionMatchesGolden( + initialState: SpringState, + stableThreshold: Float = 0.01f, + sampleFrequencyHz: Float = 100f, + springParameters: (timeMillis: Long) -> SpringParameters, + ) { + val sampleDurationMillis = (1_000f / sampleFrequencyHz).toLong() + + val frameIds = mutableListOf() + + val displacement = mutableListOf>() + val velocity = mutableListOf>() + val isStable = mutableListOf>() + val params = mutableListOf>() + + var iterationTimeMillis = 0L + var keepRecording = 2 + + var springState = initialState + while (keepRecording > 0 && frameIds.size < 1000) { + frameIds.add(TimestampFrameId(iterationTimeMillis)) + + val parameters = springParameters(iterationTimeMillis) + val currentlyStable = springState.isStable(parameters, stableThreshold) + if (currentlyStable) { + keepRecording-- + } + + displacement.add(springState.displacement.asDataPoint()) + velocity.add(springState.velocity.asDataPoint()) + isStable.add(currentlyStable.asDataPoint()) + params.add(parameters.asDataPoint()) + + val elapsedNanos = sampleDurationMillis * 1_000_000 + springState = springState.calculateUpdatedState(elapsedNanos, parameters) + iterationTimeMillis += sampleDurationMillis + } + + val timeSeries = + TimeSeries( + frameIds.toList(), + listOf( + Feature("displacement", displacement), + Feature("velocity", velocity), + Feature("stable", isStable), + Feature("parameters", params), + ), + ) + + val recordedMotion = motion.create(timeSeries, screenshots = null) + motion.assertThat(recordedMotion).timeSeriesMatchesGolden() + } +} diff --git a/mechanics/tests/src/com/android/mechanics/view/ViewGestureContextTest.kt b/mechanics/tests/src/com/android/mechanics/view/ViewGestureContextTest.kt new file mode 100644 index 0000000..dbc6cb0 --- /dev/null +++ b/mechanics/tests/src/com/android/mechanics/view/ViewGestureContextTest.kt @@ -0,0 +1,202 @@ +/* + * Copyright (C) 2025 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.view + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.android.mechanics.spec.InputDirection +import com.google.common.truth.Truth.assertThat +import kotlin.math.nextDown +import kotlin.math.nextUp +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class ViewGestureContextTest { + + @Test + fun update_maxDirection_increasingInput_keepsDirection() { + val underTest = + DistanceGestureContext( + initialDragOffset = 0f, + initialDirection = InputDirection.Max, + directionChangeSlop = 5f, + ) + + for (value in 0..6) { + underTest.dragOffset = value.toFloat() + assertThat(underTest.direction).isEqualTo(InputDirection.Max) + } + } + + @Test + fun update_minDirection_decreasingInput_keepsDirection() { + val underTest = + DistanceGestureContext( + initialDragOffset = 0f, + initialDirection = InputDirection.Min, + directionChangeSlop = 5f, + ) + + for (value in 0 downTo -6) { + underTest.dragOffset = value.toFloat() + assertThat(underTest.direction).isEqualTo(InputDirection.Min) + } + } + + @Test + fun update_maxDirection_decreasingInput_keepsDirection_belowDirectionChangeSlop() { + val underTest = + DistanceGestureContext( + initialDragOffset = 0f, + initialDirection = InputDirection.Max, + directionChangeSlop = 5f, + ) + + underTest.dragOffset = -5f + assertThat(underTest.direction).isEqualTo(InputDirection.Max) + } + + @Test + fun update_maxDirection_decreasingInput_switchesDirection_aboveDirectionChangeSlop() { + val underTest = + DistanceGestureContext( + initialDragOffset = 0f, + initialDirection = InputDirection.Max, + directionChangeSlop = 5f, + ) + + underTest.dragOffset = (-5f).nextDown() + assertThat(underTest.direction).isEqualTo(InputDirection.Min) + } + + @Test + fun update_minDirection_increasingInput_keepsDirection_belowDirectionChangeSlop() { + val underTest = + DistanceGestureContext( + initialDragOffset = 0f, + initialDirection = InputDirection.Min, + directionChangeSlop = 5f, + ) + + underTest.dragOffset = 5f + assertThat(underTest.direction).isEqualTo(InputDirection.Min) + } + + @Test + fun update_minDirection_decreasingInput_switchesDirection_aboveDirectionChangeSlop() { + val underTest = + DistanceGestureContext( + initialDragOffset = 0f, + initialDirection = InputDirection.Min, + directionChangeSlop = 5f, + ) + + underTest.dragOffset = 5f.nextUp() + assertThat(underTest.direction).isEqualTo(InputDirection.Max) + } + + @Test + fun reset_resetsFurthestValue() { + val underTest = + DistanceGestureContext( + initialDragOffset = 10f, + initialDirection = InputDirection.Max, + directionChangeSlop = 1f, + ) + + underTest.reset(5f, direction = InputDirection.Max) + assertThat(underTest.direction).isEqualTo(InputDirection.Max) + assertThat(underTest.dragOffset).isEqualTo(5f) + + underTest.dragOffset -= 1f + assertThat(underTest.direction).isEqualTo(InputDirection.Max) + assertThat(underTest.dragOffset).isEqualTo(4f) + + underTest.dragOffset = underTest.dragOffset.nextDown() + assertThat(underTest.direction).isEqualTo(InputDirection.Min) + assertThat(underTest.dragOffset).isWithin(0.0001f).of(4f) + } + + @Test + fun callback_invokedOnChange() { + val underTest = + DistanceGestureContext( + initialDragOffset = 0f, + initialDirection = InputDirection.Max, + directionChangeSlop = 5f, + ) + + var invocationCount = 0 + underTest.addUpdateCallback { invocationCount++ } + + assertThat(invocationCount).isEqualTo(0) + underTest.dragOffset += 1 + assertThat(invocationCount).isEqualTo(1) + } + + @Test + fun callback_invokedOnReset() { + val underTest = + DistanceGestureContext( + initialDragOffset = 0f, + initialDirection = InputDirection.Max, + directionChangeSlop = 5f, + ) + + var invocationCount = 0 + underTest.addUpdateCallback { invocationCount++ } + + assertThat(invocationCount).isEqualTo(0) + underTest.reset(0f, InputDirection.Max) + assertThat(invocationCount).isEqualTo(1) + } + + @Test + fun callback_ignoredForSameValues() { + val underTest = + DistanceGestureContext( + initialDragOffset = 0f, + initialDirection = InputDirection.Max, + directionChangeSlop = 5f, + ) + + var invocationCount = 0 + underTest.addUpdateCallback { invocationCount++ } + + assertThat(invocationCount).isEqualTo(0) + underTest.dragOffset += 0 + assertThat(invocationCount).isEqualTo(0) + } + + @Test + fun callback_removeUpdateCallback_removesCallback() { + val underTest = + DistanceGestureContext( + initialDragOffset = 0f, + initialDirection = InputDirection.Max, + directionChangeSlop = 5f, + ) + + var invocationCount = 0 + val callback = GestureContextUpdateListener { invocationCount++ } + underTest.addUpdateCallback(callback) + assertThat(invocationCount).isEqualTo(0) + underTest.removeUpdateCallback(callback) + underTest.dragOffset += 1 + assertThat(invocationCount).isEqualTo(0) + } +} diff --git a/mechanics/tests/src/com/android/mechanics/view/ViewMotionBuilderContextTest.kt b/mechanics/tests/src/com/android/mechanics/view/ViewMotionBuilderContextTest.kt new file mode 100644 index 0000000..10dd4f6 --- /dev/null +++ b/mechanics/tests/src/com/android/mechanics/view/ViewMotionBuilderContextTest.kt @@ -0,0 +1,77 @@ +/* + * Copyright (C) 2025 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. + */ + +@file:OptIn(ExperimentalMaterial3ExpressiveApi::class) + +package com.android.mechanics.view + +import android.content.Context +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.MotionScheme +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.android.mechanics.spec.builder.MotionBuilderContext +import com.android.mechanics.spec.builder.rememberMotionBuilderContext +import com.google.common.truth.Truth +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class ViewMotionBuilderContextTest { + + @get:Rule(order = 0) val rule = createComposeRule() + + @Test + fun materialSprings_standardScheme_matchesComposeDefinition() { + lateinit var viewContext: Context + lateinit var composeReference: MotionBuilderContext + + rule.setContent { + viewContext = LocalContext.current + MaterialTheme(motionScheme = MotionScheme.standard()) { + composeReference = rememberMotionBuilderContext() + } + } + + val underTest = standardViewMotionBuilderContext(viewContext) + + Truth.assertThat(underTest.density).isEqualTo(composeReference.density) + Truth.assertThat(underTest.spatial).isEqualTo(composeReference.spatial) + Truth.assertThat(underTest.effects).isEqualTo(composeReference.effects) + } + + @Test + fun materialSprings_expressiveScheme_matchesComposeDefinition() { + lateinit var viewContext: Context + lateinit var composeReference: MotionBuilderContext + + rule.setContent { + viewContext = LocalContext.current + MaterialTheme(motionScheme = MotionScheme.expressive()) { + composeReference = rememberMotionBuilderContext() + } + } + + val underTest = expressiveViewMotionBuilderContext(viewContext) + + Truth.assertThat(underTest.density).isEqualTo(composeReference.density) + Truth.assertThat(underTest.spatial).isEqualTo(composeReference.spatial) + Truth.assertThat(underTest.effects).isEqualTo(composeReference.effects) + } +} diff --git a/mechanics/tests/src/com/android/mechanics/view/ViewMotionValueTest.kt b/mechanics/tests/src/com/android/mechanics/view/ViewMotionValueTest.kt new file mode 100644 index 0000000..7d7fdcd --- /dev/null +++ b/mechanics/tests/src/com/android/mechanics/view/ViewMotionValueTest.kt @@ -0,0 +1,261 @@ +/* + * Copyright (C) 2025 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.view + +import android.platform.test.annotations.MotionTest +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.android.mechanics.MotionValueTest.Companion.B1 +import com.android.mechanics.MotionValueTest.Companion.B2 +import com.android.mechanics.MotionValueTest.Companion.specBuilder +import com.android.mechanics.spec.Breakpoint +import com.android.mechanics.spec.Guarantee.GestureDragDelta +import com.android.mechanics.spec.InputDirection +import com.android.mechanics.spec.Mapping +import com.android.mechanics.spec.MotionSpec +import com.android.mechanics.spec.SegmentKey +import com.android.mechanics.spec.SemanticKey +import com.android.mechanics.spec.with +import com.android.mechanics.testing.VerifyTimeSeriesResult.AssertTimeSeriesMatchesGolden +import com.android.mechanics.testing.ViewMotionValueToolkit +import com.android.mechanics.testing.animateValueTo +import com.android.mechanics.testing.goldenTest +import com.android.mechanics.testing.input +import com.android.mechanics.testing.isStable +import com.android.mechanics.testing.output +import com.google.common.truth.Truth.assertThat +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.test.runTest +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import platform.test.motion.MotionTestRule +import platform.test.motion.testing.createGoldenPathManager +import platform.test.screenshot.PathConfig +import platform.test.screenshot.PathElementNoContext + +/** + * NOTE: This only tests the lifecycle of ViewMotionValue, plus some basic animations. + * + * Most code is shared with MotionValue, and tested there. + */ +@RunWith(AndroidJUnit4::class) +@MotionTest +class ViewMotionValueTest { + private val goldenPathManager = + createGoldenPathManager( + "frameworks/libs/systemui/mechanics/tests/goldens", + // The ViewMotionValue goldens do not currently match MotionValue goldens, because + // the ViewMotionValue computes the output at the beginning of the new frame, while + // MotionValue computes it at when read. Therefore, the output of these goldens is + // delayed by one frame. + PathConfig(PathElementNoContext("base", isDir = true, { "view" })), + ) + + // @get:Rule(order = 1) val activityRule = + // ActivityScenarioRule(EmptyTestActivity::class.java) + @get:Rule(order = 2) val animatorTestRule = android.animation.AnimatorTestRule(this) + + @get:Rule(order = 3) + val motion = MotionTestRule(ViewMotionValueToolkit(animatorTestRule), goldenPathManager) + + @Test + fun emptySpec_outputMatchesInput_withoutAnimation() = + motion.goldenTest( + spec = MotionSpec.Empty, + verifyTimeSeries = { + // Output always matches the input + assertThat(output).containsExactlyElementsIn(input).inOrder() + // There must never be an ongoing animation. + assertThat(isStable).doesNotContain(false) + + AssertTimeSeriesMatchesGolden() + }, + ) { + animateValueTo(100f) + } + + @Test + fun segmentChange_animatedWhenReachingBreakpoint() = + motion.goldenTest( + spec = specBuilder(Mapping.Zero) { fixedValue(breakpoint = 1f, value = 1f) } + ) { + animateValueTo(1f, changePerFrame = 0.5f) + awaitStable() + } + + @Test + fun semantics_returnsValueMatchingSegment() = runTest { + runBlocking(Dispatchers.Main) { + val s1 = SemanticKey("Foo") + val spec = + specBuilder(Mapping.Zero, semantics = listOf(s1 with "zero")) { + fixedValue(1f, 1f, semantics = listOf(s1 with "one")) + fixedValue(2f, 2f, semantics = listOf(s1 with "two")) + } + + val gestureContext = DistanceGestureContext(0f, InputDirection.Max, 5f) + val underTest = ViewMotionValue(0f, gestureContext, spec) + + assertThat(underTest[s1]).isEqualTo("zero") + underTest.input = 2f + animatorTestRule.advanceTimeBy(16L) + assertThat(underTest[s1]).isEqualTo("two") + } + } + + @Test + fun segment_returnsCurrentSegmentKey() = runTest { + runBlocking(Dispatchers.Main) { + val spec = + specBuilder(Mapping.Zero) { + fixedValue(1f, 1f, key = B1) + fixedValue(2f, 2f, key = B2) + } + + val gestureContext = DistanceGestureContext(0f, InputDirection.Max, 5f) + val underTest = ViewMotionValue(1f, gestureContext, spec) + + assertThat(underTest.segmentKey).isEqualTo(SegmentKey(B1, B2, InputDirection.Max)) + underTest.input = 2f + animatorTestRule.advanceTimeBy(16L) + assertThat(underTest.segmentKey) + .isEqualTo(SegmentKey(B2, Breakpoint.maxLimit.key, InputDirection.Max)) + } + } + + @Test + fun gestureContext_listensToGestureContextUpdates() = + motion.goldenTest( + spec = + specBuilder(Mapping.Zero) { + fixedValue(breakpoint = 1f, guarantee = GestureDragDelta(3f), value = 1f) + } + ) { + animateValueTo(1f, changePerFrame = 0.5f) + while (!underTest.isStable) { + gestureContext.dragOffset += 0.5f + awaitFrames() + } + } + + @Test + fun specChange_triggersAnimation() { + fun generateSpec(offset: Float) = + specBuilder(Mapping.Zero) { + targetFromCurrent(breakpoint = offset, key = B1, delta = 1f, to = 2f) + fixedValue(breakpoint = offset + 1f, key = B2, value = 0f) + } + + motion.goldenTest(spec = generateSpec(0f), initialValue = .5f) { + underTest.spec = generateSpec(1f) + awaitFrames() + awaitStable() + } + } + + @Test + fun update_triggersCallback() = runTest { + runBlocking(Dispatchers.Main) { + val gestureContext = DistanceGestureContext(0f, InputDirection.Max, 5f) + val underTest = ViewMotionValue(0f, gestureContext, MotionSpec.Empty) + + var invocationCount = 0 + underTest.addUpdateCallback { invocationCount++ } + underTest.input = 1f + repeat(60) { animatorTestRule.advanceTimeBy(16L) } + + assertThat(invocationCount).isEqualTo(2) + } + } + + @Test + fun update_setSameValue_doesNotTriggerCallback() = runTest { + runBlocking(Dispatchers.Main) { + val gestureContext = DistanceGestureContext(0f, InputDirection.Max, 5f) + val underTest = ViewMotionValue(0f, gestureContext, MotionSpec.Empty) + + var invocationCount = 0 + underTest.addUpdateCallback { invocationCount++ } + underTest.input = 0f + repeat(60) { animatorTestRule.advanceTimeBy(16L) } + + assertThat(invocationCount).isEqualTo(0) + } + } + + @Test + fun update_triggersCallbacksWhileAnimating() = runTest { + runBlocking(Dispatchers.Main) { + val gestureContext = DistanceGestureContext(0f, InputDirection.Max, 5f) + val spec = specBuilder(Mapping.Zero) { fixedValue(breakpoint = 1f, value = 1f) } + val underTest = ViewMotionValue(0f, gestureContext, spec) + + var invocationCount = 0 + underTest.addUpdateCallback { invocationCount++ } + underTest.input = 1f + repeat(60) { animatorTestRule.advanceTimeBy(16L) } + + assertThat(invocationCount).isEqualTo(17) + } + } + + @Test + fun removeCallback_doesNotTriggerAfterRemoving() = runTest { + runBlocking(Dispatchers.Main) { + val gestureContext = DistanceGestureContext(0f, InputDirection.Max, 5f) + val spec = specBuilder(Mapping.Zero) { fixedValue(breakpoint = 1f, value = 1f) } + val underTest = ViewMotionValue(0f, gestureContext, spec) + + var invocationCount = 0 + val callback = ViewMotionValueListener { invocationCount++ } + underTest.addUpdateCallback(callback) + underTest.input = 0.5f + animatorTestRule.advanceTimeBy(16L) + assertThat(invocationCount).isEqualTo(2) + + underTest.removeUpdateCallback(callback) + underTest.input = 1f + repeat(60) { animatorTestRule.advanceTimeBy(16L) } + + assertThat(invocationCount).isEqualTo(2) + } + } + + @Test + fun debugInspector_sameInstance_whileInUse() = runTest { + runBlocking(Dispatchers.Main) { + val gestureContext = DistanceGestureContext(0f, InputDirection.Max, 5f) + val underTest = ViewMotionValue(0f, gestureContext, MotionSpec.Empty) + + val originalInspector = underTest.debugInspector() + assertThat(underTest.debugInspector()).isSameInstanceAs(originalInspector) + } + } + + @Test + fun debugInspector_newInstance_afterUnused() = runTest { + runBlocking(Dispatchers.Main) { + val gestureContext = DistanceGestureContext(0f, InputDirection.Max, 5f) + val underTest = ViewMotionValue(0f, gestureContext, MotionSpec.Empty) + + val originalInspector = underTest.debugInspector() + originalInspector.dispose() + assertThat(underTest.debugInspector()).isNotSameInstanceAs(originalInspector) + } + } +} From 7db7f20f31da12bcd1ec17dcbd5adc3949cc7dfd Mon Sep 17 00:00:00 2001 From: Pun Butrach Date: Tue, 18 Nov 2025 21:12:29 +0700 Subject: [PATCH 13/30] feat: Mechanics --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index 47ceb44..0c13fb8 100644 --- a/README.md +++ b/README.md @@ -7,4 +7,6 @@ A brief explanation of what each library does: * `contextualeducationlib`: Store "education" type * `displaylib`: Handling presumably desktop displays * `iconloaderlib`: Handling all of Launcher3 and Lawnchair icons +* `mechanics`: Complement the `animationlib` * `msdllib`: Multi-Sensory-Design-Language, handling all new vibrations in Launcher3 Android 16 +* `viewcapturelib`: Capture views... yep that's really that it From 2eb34a4332bb139a41fb7bab878b06dd1952ff75 Mon Sep 17 00:00:00 2001 From: Pun Butrach Date: Thu, 20 Nov 2025 21:33:48 +0700 Subject: [PATCH 14/30] Revert "feat: searchuilib Android 16 QPR1" This reverts commit bb5f5584fac4e74c46ac41210bfdeebeec04943d. --- searchuilib/Android.bp | 28 ++++ searchuilib/build.gradle | 10 ++ .../com/android/app/search/LayoutType.java | 155 ++++++++++++++++++ .../com/android/app/search/ResultType.java | 96 +++++++++++ 4 files changed, 289 insertions(+) create mode 100644 searchuilib/Android.bp create mode 100644 searchuilib/build.gradle create mode 100644 searchuilib/src/com/android/app/search/LayoutType.java create mode 100644 searchuilib/src/com/android/app/search/ResultType.java diff --git a/searchuilib/Android.bp b/searchuilib/Android.bp new file mode 100644 index 0000000..2b25616 --- /dev/null +++ b/searchuilib/Android.bp @@ -0,0 +1,28 @@ +// Copyright (C) 2020 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 { + default_applicable_licenses: ["Android-Apache-2.0"], +} + +android_library { + name: "search_ui", + + sdk_version: "current", + min_sdk_version: "26", + + srcs: [ + "src/**/*.java", + ], +} diff --git a/searchuilib/build.gradle b/searchuilib/build.gradle new file mode 100644 index 0000000..76551cf --- /dev/null +++ b/searchuilib/build.gradle @@ -0,0 +1,10 @@ +apply plugin: 'com.android.library' + +android { + namespace "com.android.app.search" + sourceSets { + main { + java.srcDirs = ['src'] + } + } +} diff --git a/searchuilib/src/com/android/app/search/LayoutType.java b/searchuilib/src/com/android/app/search/LayoutType.java new file mode 100644 index 0000000..1adb0ea --- /dev/null +++ b/searchuilib/src/com/android/app/search/LayoutType.java @@ -0,0 +1,155 @@ +/* + * Copyright (C) 2020 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.app.search; + +import androidx.annotation.StringDef; + +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; + +/** + * Constants to be used with {@link SearchTarget}. + */ +public class LayoutType { + + @StringDef(value = { + ICON_SINGLE_VERTICAL_TEXT, + ICON_HORIZONTAL_TEXT, + HORIZONTAL_MEDIUM_TEXT, + EXTRA_TALL_ICON_ROW, + SMALL_ICON_HORIZONTAL_TEXT, + SMALL_ICON_HORIZONTAL_TEXT_THUMBNAIL, + ICON_CONTAINER, + THUMBNAIL_CONTAINER, + BIG_ICON_MEDIUM_HEIGHT_ROW, + THUMBNAIL, + ICON_SLICE, + WIDGET_PREVIEW, + WIDGET_LIVE, + PEOPLE_TILE, + TEXT_HEADER, + DIVIDER, + EMPTY_DIVIDER, + CALCULATOR, + SECTION_HEADER, + TALL_CARD_WITH_IMAGE_NO_ICON, + TEXT_HEADER_ROW, + QS_TILE, + PLACEHOLDER, + RICHANSWER_PLACEHOLDER, + EMPTY_STATE, + SEARCH_SETTINGS, + }) + @Retention(RetentionPolicy.SOURCE) + public @interface SearchLayoutType {} + + // ------ + // | icon | + // ------ + // text + public static final String ICON_SINGLE_VERTICAL_TEXT = "icon"; + + // Below three layouts (to be deprecated) and two layouts render + // {@link SearchTarget}s in following layout. + // ------ ------ ------ + // | | title |(opt)| |(opt)| + // | icon | subtitle (optional) | icon| | icon| + // ------ ------ ------ + @Deprecated + public static final String ICON_SINGLE_HORIZONTAL_TEXT = "icon_text_row"; + @Deprecated + public static final String ICON_DOUBLE_HORIZONTAL_TEXT = "icon_texts_row"; + @Deprecated + public static final String ICON_DOUBLE_HORIZONTAL_TEXT_BUTTON = "icon_texts_button"; + + // will replace ICON_DOUBLE_* ICON_SINGLE_* layouts + public static final String ICON_HORIZONTAL_TEXT = "icon_row"; + public static final String HORIZONTAL_MEDIUM_TEXT = "icon_row_medium"; + public static final String EXTRA_TALL_ICON_ROW = "extra_tall_icon_row"; + public static final String SMALL_ICON_HORIZONTAL_TEXT = "short_icon_row"; + public static final String SMALL_ICON_HORIZONTAL_TEXT_THUMBNAIL = "short_icon_row_thumbnail"; + + // This layout contains a series of icon results (currently up to 4 per row). + // The container does not support stretching for its children, and can only contain + // {@link #ICON_SINGLE_VERTICAL_TEXT} layout types. + public static final String ICON_CONTAINER = "icon_container"; + + // This layout contains a series of thumbnails (currently up to 3 per row). + // The container supports stretching for its children, and can only contain {@link #THUMBNAIL} + // layout types. + public static final String THUMBNAIL_CONTAINER = "thumbnail_container"; + + // This layout creates a container for people grouping + // Only available above version code 2 + public static final String BIG_ICON_MEDIUM_HEIGHT_ROW = "big_icon_medium_row"; + + // This layout creates square thumbnail image (currently 3 column) + public static final String THUMBNAIL = "thumbnail"; + + // This layout contains an icon and slice + public static final String ICON_SLICE = "slice"; + + // Widget bitmap preview + public static final String WIDGET_PREVIEW = "widget_preview"; + + // Live widget search result + public static final String WIDGET_LIVE = "widget_live"; + + // Layout type used to display people tiles using shortcut info + public static final String PEOPLE_TILE = "people_tile"; + + // Deprecated + // text based header to group various layouts in low confidence section of the results. + public static final String TEXT_HEADER = "header"; + + // horizontal bar to be inserted between fallback search results and low confidence section + public static final String EMPTY_DIVIDER = "empty_divider"; + + @Deprecated(since = "Use EMPTY_DIVIDER instead") + public static final String DIVIDER = EMPTY_DIVIDER; + + // layout representing quick calculations + public static final String CALCULATOR = "calculator"; + + // From version code 4, if TEXT_HEADER_ROW is used, no need to insert this on-device + // section header. + public static final String SECTION_HEADER = "section_header"; + + // layout for a tall card with header and image, and no icon. + public static final String TALL_CARD_WITH_IMAGE_NO_ICON = "tall_card_with_image_no_icon"; + + // Layout for a text header + // Available for SearchUiManager proxy service to use above version code 3 + public static final String TEXT_HEADER_ROW = "text_header_row"; + + // Layout for a quick settings tile + public static final String QS_TILE = "qs_tile"; + + // Placeholder for web suggest. + public static final String PLACEHOLDER = "placeholder"; + + // Placeholder for rich answer cards. + // Only available on or above version code 3. + public static final String RICHANSWER_PLACEHOLDER = "richanswer_placeholder"; + + + // layout representing the empty, no query state + public static final String EMPTY_STATE = "empty_state"; + + // layout representing search settings + public static final String SEARCH_SETTINGS = "launcher_settings"; +} diff --git a/searchuilib/src/com/android/app/search/ResultType.java b/searchuilib/src/com/android/app/search/ResultType.java new file mode 100644 index 0000000..5340220 --- /dev/null +++ b/searchuilib/src/com/android/app/search/ResultType.java @@ -0,0 +1,96 @@ +/* + * Copyright (C) 2020 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.app.search; + +/** + * Constants to be used with {@link android.app.search.SearchContext} and + * {@link android.app.search.SearchTarget}. + * + * Note, a result type could be a of two types. + * For example, unpublished settings result type could be in slices: + * resultType = SETTING | SLICE + */ +public class ResultType { + + // published corpus by 3rd party app, supported by SystemService + public static final int APPLICATION = 1 << 0; + public static final int SHORTCUT = 1 << 1; + public static final int SLICE = 1 << 6; + public static final int WIDGETS = 1 << 7; + + // Not extracted from any of the SystemService + public static final int PEOPLE = 1 << 2; + public static final int ACTION = 1 << 3; + public static final int SETTING = 1 << 4; + public static final int IMAGE = 1 << 5; + + @Deprecated(since = "Use IMAGE") + public static final int SCREENSHOT = IMAGE; + + public static final int PLAY = 1 << 8; + public static final int SUGGEST = 1 << 9; + public static final int ASSISTANT = 1 << 10; + public static final int CHROMETAB = 1 << 11; + public static final int NAVVYSITE = 1 << 12; + public static final int TIPS = 1 << 13; + public static final int PEOPLE_TILE = 1 << 14; + public static final int LEGACY_SHORTCUT = 1 << 15; + public static final int MEMORY = 1 << 16; + public static final int WEB_SUGGEST = 1 << 17; + public static final int NO_FULFILLMENT = 1 << 18; + public static final int EDUCARD = 1 << 19; + public static final int SYSTEM_POINTER = 1 << 20; + public static final int VIDEO = 1 << 21; + + public static final int PUBLIC_DATA_TYPE = APPLICATION | SETTING | PLAY | WEB_SUGGEST; + public static final int PRIMITIVE_TYPE = APPLICATION | SLICE | SHORTCUT | WIDGETS | ACTION | + LEGACY_SHORTCUT; + public static final int CORPUS_TYPE = + PEOPLE | SETTING | IMAGE | PLAY | SUGGEST | ASSISTANT | CHROMETAB | NAVVYSITE | TIPS + | PEOPLE_TILE | MEMORY | WEB_SUGGEST | VIDEO; + public static final int RANK_TYPE = SYSTEM_POINTER; + public static final int UI_TYPE = EDUCARD | NO_FULFILLMENT; + + public static boolean isSlice(int resultType) { + return (resultType & SLICE) != 0; + } + + public static boolean isSystemPointer(int resultType) { + return (resultType & SYSTEM_POINTER) != 0; + } + + /** + * Returns result type integer where only {@code #CORPUS_TYPE} bit will turned on. + */ + public static int getCorpusType(int resultType) { + return (resultType & CORPUS_TYPE); + } + + /** + * Returns result type integer where only {@code #PRIMITIVE_TYPE} bit will be turned on. + */ + public static int getPrimitiveType(int resultType) { + return (resultType & PRIMITIVE_TYPE); + } + + /** + * Returns whether the given result type is privacy safe or not. + */ + public static boolean isPrivacySafe(int resultType) { + return (resultType & PUBLIC_DATA_TYPE) != 0; + } +} From f026dadbd4ce187d71276c0403a97f8292a98db4 Mon Sep 17 00:00:00 2001 From: Pun Butrach Date: Thu, 20 Nov 2025 21:38:20 +0700 Subject: [PATCH 15/30] docs: Re-add searchuilib --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 0c13fb8..e44ba63 100644 --- a/README.md +++ b/README.md @@ -9,4 +9,5 @@ A brief explanation of what each library does: * `iconloaderlib`: Handling all of Launcher3 and Lawnchair icons * `mechanics`: Complement the `animationlib` * `msdllib`: Multi-Sensory-Design-Language, handling all new vibrations in Launcher3 Android 16 +* `searchuilib`: Store search-related layout type * `viewcapturelib`: Capture views... yep that's really that it From cba0e996fd22225e6c3facfa47e785cefaf5a99c Mon Sep 17 00:00:00 2001 From: Pun Butrach Date: Thu, 20 Nov 2025 21:44:58 +0700 Subject: [PATCH 16/30] feat: Update searchuilib to Android U --- README.md | 3 +++ .../com/android/app/search/LayoutType.java | 20 +++++++++++-------- .../com/android/app/search/ResultType.java | 11 ++++------ 3 files changed, 19 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index e44ba63..9b96c06 100644 --- a/README.md +++ b/README.md @@ -10,4 +10,7 @@ A brief explanation of what each library does: * `mechanics`: Complement the `animationlib` * `msdllib`: Multi-Sensory-Design-Language, handling all new vibrations in Launcher3 Android 16 * `searchuilib`: Store search-related layout type + * See [AOSP Commit][searchuilib-url] instead because it's gone private after U * `viewcapturelib`: Capture views... yep that's really that it + +[searchuilib-url]: https://cs.android.com/android/_/android/platform/frameworks/libs/systemui/+/main:searchuilib/src/com/android/app/search/;drc=ace90b2ec32d3730141387c56e8abc761c380550;bpv=1;bpt=0 diff --git a/searchuilib/src/com/android/app/search/LayoutType.java b/searchuilib/src/com/android/app/search/LayoutType.java index 1adb0ea..f510c38 100644 --- a/searchuilib/src/com/android/app/search/LayoutType.java +++ b/searchuilib/src/com/android/app/search/LayoutType.java @@ -49,10 +49,11 @@ public class LayoutType { TALL_CARD_WITH_IMAGE_NO_ICON, TEXT_HEADER_ROW, QS_TILE, + QS_TILE_CONTAINER, PLACEHOLDER, RICHANSWER_PLACEHOLDER, - EMPTY_STATE, - SEARCH_SETTINGS, + PLAY_PLACEHOLDER, + EDUCARD, }) @Retention(RetentionPolicy.SOURCE) public @interface SearchLayoutType {} @@ -119,7 +120,7 @@ public class LayoutType { // horizontal bar to be inserted between fallback search results and low confidence section public static final String EMPTY_DIVIDER = "empty_divider"; - @Deprecated(since = "Use EMPTY_DIVIDER instead") + @Deprecated(since = "LC: Use EMPTY_DIVIDER instead") public static final String DIVIDER = EMPTY_DIVIDER; // layout representing quick calculations @@ -139,6 +140,9 @@ public class LayoutType { // Layout for a quick settings tile public static final String QS_TILE = "qs_tile"; + // Layout for a quick settings tile container + public static final String QS_TILE_CONTAINER = "qs_tile_container"; + // Placeholder for web suggest. public static final String PLACEHOLDER = "placeholder"; @@ -146,10 +150,10 @@ public class LayoutType { // Only available on or above version code 3. public static final String RICHANSWER_PLACEHOLDER = "richanswer_placeholder"; + // Play placeholder + public static final String PLAY_PLACEHOLDER = "play_placeholder"; - // layout representing the empty, no query state - public static final String EMPTY_STATE = "empty_state"; - - // layout representing search settings - public static final String SEARCH_SETTINGS = "launcher_settings"; + // Only available on or above version code 8 (UP1A) + // This layout is for educard. + public static final String EDUCARD = "educard"; } diff --git a/searchuilib/src/com/android/app/search/ResultType.java b/searchuilib/src/com/android/app/search/ResultType.java index 5340220..76fc6dd 100644 --- a/searchuilib/src/com/android/app/search/ResultType.java +++ b/searchuilib/src/com/android/app/search/ResultType.java @@ -37,15 +37,11 @@ public class ResultType { public static final int ACTION = 1 << 3; public static final int SETTING = 1 << 4; public static final int IMAGE = 1 << 5; - - @Deprecated(since = "Use IMAGE") - public static final int SCREENSHOT = IMAGE; - public static final int PLAY = 1 << 8; public static final int SUGGEST = 1 << 9; public static final int ASSISTANT = 1 << 10; public static final int CHROMETAB = 1 << 11; - public static final int NAVVYSITE = 1 << 12; + public static final int SESSION_INFO = 1 << 12; public static final int TIPS = 1 << 13; public static final int PEOPLE_TILE = 1 << 14; public static final int LEGACY_SHORTCUT = 1 << 15; @@ -55,13 +51,14 @@ public class ResultType { public static final int EDUCARD = 1 << 19; public static final int SYSTEM_POINTER = 1 << 20; public static final int VIDEO = 1 << 21; + public static final int LOCATION = 1 << 22; public static final int PUBLIC_DATA_TYPE = APPLICATION | SETTING | PLAY | WEB_SUGGEST; public static final int PRIMITIVE_TYPE = APPLICATION | SLICE | SHORTCUT | WIDGETS | ACTION | LEGACY_SHORTCUT; public static final int CORPUS_TYPE = - PEOPLE | SETTING | IMAGE | PLAY | SUGGEST | ASSISTANT | CHROMETAB | NAVVYSITE | TIPS - | PEOPLE_TILE | MEMORY | WEB_SUGGEST | VIDEO; + PEOPLE | SETTING | IMAGE | PLAY | SUGGEST | ASSISTANT | CHROMETAB | TIPS + | PEOPLE_TILE | MEMORY | WEB_SUGGEST | VIDEO | LOCATION; public static final int RANK_TYPE = SYSTEM_POINTER; public static final int UI_TYPE = EDUCARD | NO_FULFILLMENT; From 0910819eb014390585988af523ad1fa4f551039e Mon Sep 17 00:00:00 2001 From: Pun Butrach Date: Thu, 20 Nov 2025 21:59:36 +0700 Subject: [PATCH 17/30] build: Just disable the disabled variant of compose --- searchuilib/src/com/android/app/search/LayoutType.java | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/searchuilib/src/com/android/app/search/LayoutType.java b/searchuilib/src/com/android/app/search/LayoutType.java index f510c38..23ec4e9 100644 --- a/searchuilib/src/com/android/app/search/LayoutType.java +++ b/searchuilib/src/com/android/app/search/LayoutType.java @@ -156,4 +156,10 @@ public class LayoutType { // Only available on or above version code 8 (UP1A) // This layout is for educard. public static final String EDUCARD = "educard"; + + // layout representing the empty, no query state + public static final String EMPTY_STATE = "empty_state"; + + // layout representing search settings + public static final String SEARCH_SETTINGS = "launcher_settings"; } From 3a70b299a9f0e772d58bb4d1f7b6fd39fba4ac51 Mon Sep 17 00:00:00 2001 From: Pun Butrach Date: Thu, 20 Nov 2025 21:59:36 +0700 Subject: [PATCH 18/30] feat: Update Submodule --- searchuilib/src/com/android/app/search/LayoutType.java | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/searchuilib/src/com/android/app/search/LayoutType.java b/searchuilib/src/com/android/app/search/LayoutType.java index f510c38..23ec4e9 100644 --- a/searchuilib/src/com/android/app/search/LayoutType.java +++ b/searchuilib/src/com/android/app/search/LayoutType.java @@ -156,4 +156,10 @@ public class LayoutType { // Only available on or above version code 8 (UP1A) // This layout is for educard. public static final String EDUCARD = "educard"; + + // layout representing the empty, no query state + public static final String EMPTY_STATE = "empty_state"; + + // layout representing search settings + public static final String SEARCH_SETTINGS = "launcher_settings"; } From 7bd36b37dd893c95d46b6a83123a41dcc675f9e2 Mon Sep 17 00:00:00 2001 From: Pun Butrach Date: Sun, 23 Nov 2025 14:40:50 +0700 Subject: [PATCH 19/30] fix: Checkout CompatTier2 --- .../src/com/android/launcher3/icons/GraphicsUtils.java | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/iconloaderlib/src/com/android/launcher3/icons/GraphicsUtils.java b/iconloaderlib/src/com/android/launcher3/icons/GraphicsUtils.java index 1abac90..b17b006 100644 --- a/iconloaderlib/src/com/android/launcher3/icons/GraphicsUtils.java +++ b/iconloaderlib/src/com/android/launcher3/icons/GraphicsUtils.java @@ -95,7 +95,12 @@ public static void noteNewBitmapCreated() { */ public static int getAttrColor(Context context, int attr) { TypedArray ta = context.obtainStyledAttributes(new int[]{attr}); - int colorAccent = ta.getColor(0, 0); + // pE-TODO(CompatTier2): wtf? + int colorAccent = 0; + try { + colorAccent = ta.getColor(0, 0); + } catch (UnsupportedOperationException ignored) { + } ta.recycle(); return colorAccent; } From 6d88bc8954794ebaa426c53ada8a201877b787bf Mon Sep 17 00:00:00 2001 From: Pun Butrach Date: Wed, 3 Dec 2025 22:25:20 +0700 Subject: [PATCH 20/30] feat: displaylib 16r4 --- displaylib/Android.bp | 1 - .../app/displaylib/DisplayRepository.kt | 87 +++++++++++-- .../DisplaysWithDecorationsRepository.kt | 11 +- ...DisplaysWithDecorationsRepositoryCompat.kt | 1 + .../app/displaylib/PerDisplayRepository.kt | 123 ++++++++++++++---- 5 files changed, 191 insertions(+), 32 deletions(-) diff --git a/displaylib/Android.bp b/displaylib/Android.bp index 85eefb8..244e765 100644 --- a/displaylib/Android.bp +++ b/displaylib/Android.bp @@ -18,7 +18,6 @@ package { java_library { name: "displaylib", - manifest: "AndroidManifest.xml", static_libs: [ "kotlinx_coroutines_android", "dagger2", diff --git a/displaylib/src/com/android/app/displaylib/DisplayRepository.kt b/displaylib/src/com/android/app/displaylib/DisplayRepository.kt index 7b43355..b491119 100644 --- a/displaylib/src/com/android/app/displaylib/DisplayRepository.kt +++ b/displaylib/src/com/android/app/displaylib/DisplayRepository.kt @@ -21,9 +21,17 @@ import android.hardware.display.DisplayManager.DisplayListener import android.hardware.display.DisplayManager.EVENT_TYPE_DISPLAY_ADDED import android.hardware.display.DisplayManager.EVENT_TYPE_DISPLAY_CHANGED import android.hardware.display.DisplayManager.EVENT_TYPE_DISPLAY_REMOVED +import android.hardware.display.DisplayManager.EXTERNAL_DISPLAY_CONNECTION_PREFERENCE_ASK +import android.hardware.display.DisplayManager.EXTERNAL_DISPLAY_CONNECTION_PREFERENCE_DESKTOP +import android.hardware.display.DisplayManager.EXTERNAL_DISPLAY_CONNECTION_PREFERENCE_MIRROR import android.os.Handler import android.util.Log import android.view.Display +import com.android.app.displaylib.ExternalDisplayConnectionType.DESKTOP +import com.android.app.displaylib.ExternalDisplayConnectionType.MIRROR +import com.android.app.displaylib.ExternalDisplayConnectionType.NOT_SPECIFIED +import com.android.app.tracing.FlowTracing.traceEach +import com.android.app.tracing.traceSection import javax.inject.Inject import javax.inject.Singleton import kotlinx.coroutines.CoroutineDispatcher @@ -79,7 +87,7 @@ interface DisplayRepository { val pendingDisplay: Flow /** Whether the default display is currently off. */ - val defaultDisplayOff: Flow + val defaultDisplayOff: StateFlow /** * Given a display ID int, return the corresponding Display object, or null if none exist. @@ -111,6 +119,20 @@ interface DisplayRepository { /** Id of the pending display. */ val id: Int + /** + * The saved connection preference for the display, either desktop, mirroring or show the + * dialog. Defaults to [ExternalDisplayConnectionType.NOT_SPECIFIED], if no value saved. + */ + val connectionType: ExternalDisplayConnectionType + + /** + * Updates the saved connection preference for the display, triggered by the connection + * dialog's "remember my choice" checkbox + * + * @see com.android.systemui.display.ui.viewmodel.ConnectingDisplayViewModel + */ + suspend fun updateConnectionPreference(connectionType: ExternalDisplayConnectionType) + /** Enables the display, making it available to the system. */ suspend fun enable() @@ -173,7 +195,8 @@ constructor( // This is necessary because there might be multiple displays, and we could // have missed events for those added before this process or flow started. // Note it causes a binder call from the main thread (it's traced). - private val initialDisplays: Set = displayManager.displays?.toSet() ?: emptySet() + private val initialDisplays: Set = + traceSection("$TAG#initialDisplays") { displayManager.displays?.toSet() ?: emptySet() } private val initialDisplayIds = initialDisplays.map { display -> display.displayId }.toSet() /** Propagate to the listeners only enabled displays */ @@ -243,6 +266,7 @@ constructor( private val ignoredDisplayIds: Flow> = _ignoredDisplayIds.debugLog("ignoredDisplayIds") private fun getInitialConnectedDisplays(): Set = + traceSection("$TAG#getInitialConnectedDisplays") { displayManager .getDisplays(DISPLAY_CATEGORY_ALL_INCLUDING_DISABLED) .map { it.displayId } @@ -252,6 +276,7 @@ constructor( Log.d(TAG, "getInitialConnectedDisplays: $it") } } + } /* keeps connected displays until they are disconnected. */ private val connectedDisplayIds: StateFlow> = @@ -302,16 +327,20 @@ constructor( private val connectedExternalDisplayIds: Flow> = connectedDisplayIds .map { connectedDisplayIds -> + traceSection("$TAG#filteringExternalDisplays") { connectedDisplayIds .filter { id -> getDisplayType(id) == Display.TYPE_EXTERNAL } .toSet() + } } .flowOn(backgroundCoroutineDispatcher) .debugLog("connectedExternalDisplayIds") - private fun getDisplayType(displayId: Int): Int? = displayManager.getDisplay(displayId)?.type + private fun getDisplayType(displayId: Int): Int? = + traceSection("$TAG#getDisplayType") { displayManager.getDisplay(displayId)?.type } - private fun getDisplayFromDisplayManager(displayId: Int): Display? = displayManager.getDisplay(displayId) + private fun getDisplayFromDisplayManager(displayId: Int): Display? = + traceSection("$TAG#getDisplay") { displayManager.getDisplay(displayId) } /** * Pending displays are the ones connected, but not enabled and not ignored. @@ -344,38 +373,68 @@ constructor( pendingDisplayId .map { displayId -> val id = displayId ?: return@map null + val pendingDisplay = getDisplay(id) ?: displayManager.getDisplay(id) + val uniqueId = pendingDisplay?.uniqueId ?: return@map null + val connectionPreference = + displayManager.getExternalDisplayConnectionPreference(uniqueId) + object : DisplayRepository.PendingDisplay { override val id = id + override val connectionType: ExternalDisplayConnectionType = + when (connectionPreference) { + EXTERNAL_DISPLAY_CONNECTION_PREFERENCE_DESKTOP -> DESKTOP + EXTERNAL_DISPLAY_CONNECTION_PREFERENCE_MIRROR -> MIRROR + else -> NOT_SPECIFIED + } + + override suspend fun updateConnectionPreference( + connectionType: ExternalDisplayConnectionType + ) { + displayManager.setExternalDisplayConnectionPreference( + uniqueId, + connectionType.preference, + ) + } override suspend fun enable() { + traceSection("DisplayRepository#enable($id)") { if (DEBUG) { Log.d(TAG, "Enabling display with id=$id") } displayManager.enableConnectedDisplay(id) + } // After the display has been enabled, it is automatically ignored. ignore() } override suspend fun ignore() { + traceSection("DisplayRepository#ignore($id)") { _ignoredDisplayIds.value += id + } } override suspend fun disable() { ignore() + traceSection("DisplayRepository#disable($id)") { if (DEBUG) { Log.d(TAG, "Disabling display with id=$id") } displayManager.disableConnectedDisplay(id) + } } } } .debugLog("pendingDisplay") - override val defaultDisplayOff: Flow = + override val defaultDisplayOff: StateFlow = displayChangeEvent .filter { it == Display.DEFAULT_DISPLAY } .map { defaultDisplay.state == Display.STATE_OFF } - .distinctUntilChanged() + .stateIn( + bgApplicationScope, + SharingStarted.WhileSubscribed(), + defaultDisplay.state == Display.STATE_OFF, + ) override fun getDisplay(displayId: Int): Display? { val cachedDisplay = getCachedDisplay(displayId) @@ -387,7 +446,9 @@ constructor( // In case of option one, let's get it synchronously from display manager to make sure for // this to be consistent. return if (displayIds.value.contains(displayId)) { + traceSection("$TAG#getDisplayFallbackToDisplayManager") { getDisplayFromDisplayManager(displayId) + } } else { null } @@ -395,8 +456,7 @@ constructor( private fun Flow.debugLog(flowName: String): Flow { return if (DEBUG) { - // LC-Ignored - this + traceEach(flowName, logcat = true, traceEmissionCount = true) } else { this } @@ -443,6 +503,17 @@ constructor( } } +/** + * Possible connection types for an external display. + * + * @property preference The integer value that represents the connection type in the system. + */ +enum class ExternalDisplayConnectionType(val preference: Int) { + NOT_SPECIFIED(EXTERNAL_DISPLAY_CONNECTION_PREFERENCE_ASK), + DESKTOP(EXTERNAL_DISPLAY_CONNECTION_PREFERENCE_DESKTOP), + MIRROR(EXTERNAL_DISPLAY_CONNECTION_PREFERENCE_MIRROR), +} + /** Used to provide default implementations for all methods. */ private interface DisplayConnectionListener : DisplayListener { diff --git a/displaylib/src/com/android/app/displaylib/DisplaysWithDecorationsRepository.kt b/displaylib/src/com/android/app/displaylib/DisplaysWithDecorationsRepository.kt index b184bd9..b99030f 100644 --- a/displaylib/src/com/android/app/displaylib/DisplaysWithDecorationsRepository.kt +++ b/displaylib/src/com/android/app/displaylib/DisplaysWithDecorationsRepository.kt @@ -20,6 +20,7 @@ import android.content.res.Configuration import android.graphics.Rect import android.view.IDisplayWindowListener import android.view.IWindowManager +import android.window.DesktopExperienceFlags.ENABLE_DISPLAY_CONTENT_MODE_MANAGEMENT import javax.inject.Inject import javax.inject.Singleton import kotlinx.coroutines.CoroutineScope @@ -53,7 +54,13 @@ constructor( val callback = object : IDisplayWindowListener.Stub() { override fun onDisplayAddSystemDecorations(displayId: Int) { - trySend(Event.Add(displayId)) + if (ENABLE_DISPLAY_CONTENT_MODE_MANAGEMENT.isTrue()) { + trySend(Event.Add(displayId)) + } else { + if (windowManager.shouldShowSystemDecors(displayId)) { + trySend(Event.Add(displayId)) + } + } } override fun onDisplayRemoveSystemDecorations(displayId: Int) { @@ -62,6 +69,8 @@ constructor( override fun onDesktopModeEligibleChanged(displayId: Int) {} + override fun onDisplayAnimationsDisabledChanged(displayId: Int, enabled: Boolean) {} + override fun onDisplayAdded(p0: Int) {} override fun onDisplayConfigurationChanged(p0: Int, p1: Configuration?) {} diff --git a/displaylib/src/com/android/app/displaylib/DisplaysWithDecorationsRepositoryCompat.kt b/displaylib/src/com/android/app/displaylib/DisplaysWithDecorationsRepositoryCompat.kt index 66aa7cc..d4f750b 100644 --- a/displaylib/src/com/android/app/displaylib/DisplaysWithDecorationsRepositoryCompat.kt +++ b/displaylib/src/com/android/app/displaylib/DisplaysWithDecorationsRepositoryCompat.kt @@ -16,6 +16,7 @@ package com.android.app.displaylib +import com.android.app.tracing.TraceUtils.traceAsync import com.android.internal.annotations.GuardedBy import java.util.concurrent.ConcurrentHashMap import javax.inject.Inject diff --git a/displaylib/src/com/android/app/displaylib/PerDisplayRepository.kt b/displaylib/src/com/android/app/displaylib/PerDisplayRepository.kt index 74bc572..7cf6f74 100644 --- a/displaylib/src/com/android/app/displaylib/PerDisplayRepository.kt +++ b/displaylib/src/com/android/app/displaylib/PerDisplayRepository.kt @@ -19,6 +19,9 @@ package com.android.app.displaylib import android.util.Log import android.view.Display import android.view.Display.DEFAULT_DISPLAY +import com.android.app.tracing.coroutines.flow.stateInTraced +import com.android.app.tracing.coroutines.launchTraced as launch +import com.android.app.tracing.traceSection import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject @@ -30,7 +33,6 @@ import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.launch /** * Used to create instances of type `T` for a specific display. @@ -77,6 +79,20 @@ interface PerDisplayInstanceProviderWithTeardown : PerDisplayInstanceProvider fun destroyInstance(instance: T) } +/** + * Extends [PerDisplayInstanceProvider], adding support for setting up an instance after it's + * created. + * + * This is useful to run custom setup after an instance of the repository is created and cached. Why + * not doing it in the [createInstance] itself? if some deps of the setup code tries to get the + * instance again through the repository, it would cause a recursive loop (as it will try to create + * a new instance). Splitting this into another method helps avoiding the recursion. + */ +interface PerDisplayInstanceProviderWithSetup : PerDisplayInstanceProvider { + /** Sets up a previously created instance of `T`. */ + fun setupInstance(instance: T) +} + /** * Provides access to per-display instances of type `T`. * @@ -87,6 +103,25 @@ interface PerDisplayRepository { /** Gets the cached instance or create a new one for a given display. */ operator fun get(displayId: Int): T? + /** + * Gets the cached instance or create a new one for a given display. If the given display + * doesn't exist, returns an instance for the default display. + */ + fun getOrDefault(displayId: Int): T { + val instance = get(displayId) + if (instance == null) { + Log.e( + "PerDisplayRepository", + """<$debugName> getOrDefault: instance for display with id $displayId returned + |null. The display likely doesn't exist anymore. Returning an instance for the + |default display.""" + .trimMargin(), + ) + return get(DEFAULT_DISPLAY)!! + } + return instance + } + /** Debug name for this repository, mainly for tracing and logging. */ val debugName: String @@ -145,32 +180,46 @@ constructor( @DisplayLibBackground bgApplicationScope: CoroutineScope, private val displayRepository: DisplayRepository, private val initCallback: PerDisplayRepository.InitCallback, + @Assisted private val createInstanceEagerly: Boolean = false, ) : PerDisplayRepository { private val perDisplayInstances = ConcurrentHashMap() private val allowedDisplays: StateFlow> = - (if (lifecycleManager == null) { - displayRepository.displayIds - } else { - // If there is a lifecycle manager, we still consider the smallest subset between - // the ones connected and the ones from the lifecycle. This is to safeguard against - // leaks, in case of lifecycle manager misbehaving (as it's provided by clients, and - // we can't guarantee it's correct). - combine(lifecycleManager.displayIds, displayRepository.displayIds) { + if (lifecycleManager == null) { + displayRepository.displayIds + } else { + // If there is a lifecycle manager, we still consider the smallest subset between + // the ones connected and the ones from the lifecycle. This is to safeguard against + // leaks, in case of lifecycle manager misbehaving (as it's provided by clients, and + // we can't guarantee it's correct). + combine(lifecycleManager.displayIds, displayRepository.displayIds) { lifecycleAllowedDisplayIds, connectedDisplays -> - lifecycleAllowedDisplayIds.intersect(connectedDisplays) + lifecycleAllowedDisplayIds.intersect(connectedDisplays) + } } - }) as StateFlow> + .stateInTraced( + "allowed displays for $debugName", + bgApplicationScope, + SharingStarted.WhileSubscribed(), + setOf(Display.DEFAULT_DISPLAY), + ) init { - bgApplicationScope.launch { start() } + bgApplicationScope.launch("$debugName#start") { start() } } private suspend fun start() { initCallback.onInit(debugName, this) allowedDisplays.collectLatest { displayIds -> + if (createInstanceEagerly) { + val toAdd = displayIds - perDisplayInstances.keys + toAdd.forEach { displayId -> + Log.d(TAG, "<$debugName> eagerly creating instance for displayId=$displayId.") + get(displayId) + } + } val toRemove = perDisplayInstances.keys - displayIds toRemove.forEach { displayId -> Log.d(TAG, "<$debugName> destroying instance for displayId=$displayId.") @@ -184,7 +233,10 @@ constructor( } override fun get(displayId: Int): T? { - if (!displayRepository.containsDisplay(displayId)) { + if ( + !displayRepository.containsDisplay(displayId) || + displayRepository.getDisplay(displayId) == null + ) { Log.e(TAG, "<$debugName: Display with id $displayId doesn't exist.") return null } @@ -198,15 +250,41 @@ constructor( return null } - // If it doesn't exist, create it and put it in the map. - return perDisplayInstances.computeIfAbsent(displayId) { key -> - Log.d(TAG, "<$debugName> creating instance for displayId=$key, as it wasn't available.") - val instance = instanceProvider.createInstance(key) - if (instance == null) { - Log.e( - TAG, - "<$debugName> returning null because createInstance($key) returned null.", - ) + // Let's not let this method return the new instance until the possible setup for it was + // executed. + // There is no need to synchronize the other accesses to the map as it's already a + // concurrent one. + return synchronized(this) { + var newlyCreated = false + // If it doesn't exist, create it and put it in the map. + val instance = + perDisplayInstances.computeIfAbsent(displayId) { key -> + Log.d( + TAG, + "<$debugName> creating instance for displayId=$key, as it wasn't available.", + ) + val instance = + traceSection({ "creating instance of $debugName for displayId=$key" }) { + instanceProvider.createInstance(key) + } + if (instance == null) { + Log.e( + TAG, + "<$debugName> returning null because createInstance($key) returned null.", + ) + } + newlyCreated = true + instance + } + + if ( + newlyCreated && + instance != null && + instanceProvider is PerDisplayInstanceProviderWithSetup + ) { + traceSection({ "setting up instance of $debugName for displayId=$displayId" }) { + instanceProvider.setupInstance(instance) + } } instance } @@ -218,6 +296,7 @@ constructor( debugName: String, instanceProvider: PerDisplayInstanceProvider, overrideLifecycleManager: DisplayInstanceLifecycleManager? = null, + createInstanceEagerly: Boolean = false, ): PerDisplayInstanceRepositoryImpl } From 03771261d1bde88f050caa57ca9e53270fd87a72 Mon Sep 17 00:00:00 2001 From: Pun Butrach Date: Wed, 3 Dec 2025 22:27:57 +0700 Subject: [PATCH 21/30] fix: displaylib tracing missing --- .../app/displaylib/PerDisplayRepository.kt | 28 ++++++------------- 1 file changed, 9 insertions(+), 19 deletions(-) diff --git a/displaylib/src/com/android/app/displaylib/PerDisplayRepository.kt b/displaylib/src/com/android/app/displaylib/PerDisplayRepository.kt index 7cf6f74..ee4b7de 100644 --- a/displaylib/src/com/android/app/displaylib/PerDisplayRepository.kt +++ b/displaylib/src/com/android/app/displaylib/PerDisplayRepository.kt @@ -19,9 +19,9 @@ package com.android.app.displaylib import android.util.Log import android.view.Display import android.view.Display.DEFAULT_DISPLAY -import com.android.app.tracing.coroutines.flow.stateInTraced -import com.android.app.tracing.coroutines.launchTraced as launch -import com.android.app.tracing.traceSection +//import com.android.app.tracing.coroutines.flow.stateInTraced +//import com.android.app.tracing.coroutines.launchTraced as launch +//import com.android.app.tracing.traceSection import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject @@ -33,6 +33,7 @@ import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.launch /** * Used to create instances of type `T` for a specific display. @@ -112,8 +113,8 @@ interface PerDisplayRepository { if (instance == null) { Log.e( "PerDisplayRepository", - """<$debugName> getOrDefault: instance for display with id $displayId returned - |null. The display likely doesn't exist anymore. Returning an instance for the + """<$debugName> getOrDefault: instance for display with id $displayId returned + |null. The display likely doesn't exist anymore. Returning an instance for the |default display.""" .trimMargin(), ) @@ -198,16 +199,10 @@ constructor( connectedDisplays -> lifecycleAllowedDisplayIds.intersect(connectedDisplays) } - } - .stateInTraced( - "allowed displays for $debugName", - bgApplicationScope, - SharingStarted.WhileSubscribed(), - setOf(Display.DEFAULT_DISPLAY), - ) + } as StateFlow> init { - bgApplicationScope.launch("$debugName#start") { start() } + bgApplicationScope.launch { start() } } private suspend fun start() { @@ -263,10 +258,7 @@ constructor( TAG, "<$debugName> creating instance for displayId=$key, as it wasn't available.", ) - val instance = - traceSection({ "creating instance of $debugName for displayId=$key" }) { - instanceProvider.createInstance(key) - } + val instance = instanceProvider.createInstance(key) if (instance == null) { Log.e( TAG, @@ -282,9 +274,7 @@ constructor( instance != null && instanceProvider is PerDisplayInstanceProviderWithSetup ) { - traceSection({ "setting up instance of $debugName for displayId=$displayId" }) { instanceProvider.setupInstance(instance) - } } instance } From 9780128933bcb1bfc71d24681918f571a2549e46 Mon Sep 17 00:00:00 2001 From: Pun Butrach Date: Wed, 3 Dec 2025 22:29:09 +0700 Subject: [PATCH 22/30] feat: viewcapturelib 16r4 --- viewcapturelib/build.gradle | 2 -- .../src/com/android/app/viewcapture/PerfettoViewCapture.kt | 2 +- .../app/viewcapture/ViewCaptureAwareWindowManagerTest.kt | 3 --- 3 files changed, 1 insertion(+), 6 deletions(-) diff --git a/viewcapturelib/build.gradle b/viewcapturelib/build.gradle index 87b7efd..47f7fb5 100644 --- a/viewcapturelib/build.gradle +++ b/viewcapturelib/build.gradle @@ -1,7 +1,6 @@ plugins { alias(libs.plugins.android.library) alias(libs.plugins.kotlin.android) - alias(libs.plugins.google.protobuf) } android { @@ -15,7 +14,6 @@ android { main { java.srcDirs = ['src'] manifest.srcFile 'AndroidManifest.xml' - proto.srcDirs = ['src'] } androidTest { java.srcDirs = ["tests"] diff --git a/viewcapturelib/src/com/android/app/viewcapture/PerfettoViewCapture.kt b/viewcapturelib/src/com/android/app/viewcapture/PerfettoViewCapture.kt index 9154e50..cfa6bc7 100644 --- a/viewcapturelib/src/com/android/app/viewcapture/PerfettoViewCapture.kt +++ b/viewcapturelib/src/com/android/app/viewcapture/PerfettoViewCapture.kt @@ -62,7 +62,7 @@ internal constructor(private val context: Context, executor: Executor) : val dataSourceParams = DataSourceParams.Builder() .setBufferExhaustedPolicy( - DataSourceParams.PERFETTO_DS_BUFFER_EXHAUSTED_POLICY_STALL_AND_ABORT + DataSourceParams.PERFETTO_DS_BUFFER_EXHAUSTED_POLICY_STALL_AND_DROP ) .setNoFlush(true) .setWillNotifyOnStop(false) diff --git a/viewcapturelib/tests/com/android/app/viewcapture/ViewCaptureAwareWindowManagerTest.kt b/viewcapturelib/tests/com/android/app/viewcapture/ViewCaptureAwareWindowManagerTest.kt index 378f355..977199c 100644 --- a/viewcapturelib/tests/com/android/app/viewcapture/ViewCaptureAwareWindowManagerTest.kt +++ b/viewcapturelib/tests/com/android/app/viewcapture/ViewCaptureAwareWindowManagerTest.kt @@ -19,7 +19,6 @@ package com.android.app.viewcapture import android.content.Context import android.content.Intent import android.hardware.display.DisplayManager -import android.platform.test.annotations.EnableFlags import android.platform.test.flag.junit.SetFlagsRule import android.testing.AndroidTestingRunner import android.view.Display.DEFAULT_DISPLAY @@ -31,7 +30,6 @@ import android.window.WindowContext import androidx.test.ext.junit.rules.ActivityScenarioRule import androidx.test.filters.SmallTest import androidx.test.platform.app.InstrumentationRegistry -import com.android.window.flags.Flags import com.google.common.truth.Truth.assertWithMessage import java.util.concurrent.CountDownLatch import java.util.concurrent.TimeUnit @@ -74,7 +72,6 @@ class ViewCaptureAwareWindowManagerTest { } } - @EnableFlags(Flags.FLAG_ENABLE_WINDOW_CONTEXT_OVERRIDE_TYPE) @Test fun useWithWindowContext_attachWindow_attachToViewCaptureAwareWm() { val windowContext = From 03892299071aa3657c41a280b8493530e7d3549a Mon Sep 17 00:00:00 2001 From: Pun Butrach Date: Wed, 3 Dec 2025 22:31:01 +0700 Subject: [PATCH 23/30] feat: mechanics 16r4 --- mechanics/Android.bp | 3 +- mechanics/OWNERS | 2 + mechanics/TEST_MAPPING | 24 +- .../benchmark/MechanicsSpringBenchmark.kt | 82 + .../benchmark/MotionValueBenchmark.kt | 8 +- .../MotionValueCollectionBenchmark.kt | 194 +++ mechanics/compose/Android.bp | 4 +- .../compose/modifier/MotionDriver.kt | 191 +++ .../VerticalFadeContentRevealModifier.kt | 399 ++--- .../VerticalTactileSurfaceRevealModifier.kt | 492 +++--- mechanics/compose/tests/Android.bp | 52 + ...actileSurfaceReveal_gesture_dragClose.json | 1288 +++++++++++++++ ...TactileSurfaceReveal_gesture_dragOpen.json | 1390 +++++++++++++++++ ...ctileSurfaceReveal_gesture_flingClose.json | 870 +++++++++++ ...actileSurfaceReveal_gesture_flingOpen.json | 1142 ++++++++++++++ ...erticalTactileSurfaceRevealModifierTest.kt | 308 ++++ .../mechanics/ComposableMotionValue.kt | 113 ++ .../src/com/android/mechanics/MotionValue.kt | 140 +- .../mechanics/MotionValueCollection.kt | 456 ++++++ .../com/android/mechanics/MotionValueState.kt | 63 + .../android/mechanics/debug/DebugInspector.kt | 5 + .../mechanics/debug/DebugVisualization.kt | 55 +- .../mechanics/debug/MotionValueDebugger.kt | 138 +- .../mechanics/effects/CommonSemantics.kt | 23 + .../mechanics/effects/MagneticDetach.kt | 76 +- .../mechanics/effects/RevealOnThreshold.kt | 7 +- .../com/android/mechanics/effects/Toggle.kt | 176 +++ .../android/mechanics/haptics/HapticPlayer.kt | 52 + .../android/mechanics/haptics/HapticTypes.kt | 59 + .../haptics/HapticsExperimentalApi.kt | 21 + .../mechanics/haptics/MetricScaling.kt | 41 + .../haptics/SpringTensionHapticPlayer.kt | 113 ++ .../mechanics/impl/ComputationInput.kt | 3 + .../android/mechanics/impl/Computations.kt | 187 ++- .../com/android/mechanics/spec/Breakpoint.kt | 15 +- .../src/com/android/mechanics/spec/Mapping.kt | 110 ++ .../com/android/mechanics/spec/MotionSpec.kt | 67 +- .../spec/MotionSpecDebugFormatter.kt | 17 + .../src/com/android/mechanics/spec/Segment.kt | 66 +- .../mechanics/spec/SegmentChangeHandler.kt | 32 + .../spec/builder/DirectionalBuilderImpl.kt | 98 +- .../spec/builder/DirectionalBuilderScope.kt | 41 +- .../spec/builder/DirectionalSpecBuilder.kt | 3 + .../spec/builder/EffectApplyScope.kt | 8 +- .../spec/builder/MotionBuilderContext.kt | 20 +- .../spec/builder/MotionSpecBuilder.kt | 17 +- .../spec/builder/MotionSpecBuilderImpl.kt | 49 +- .../android/mechanics/view/ViewMotionValue.kt | 21 +- .../ComposeMotionValueCollectionToolkit.kt | 209 +++ .../testing/ComposeMotionValueToolkit.kt | 17 +- .../mechanics/testing/FeatureCaptures.kt | 16 + .../mechanics/testing/MotionSpecSubject.kt | 40 +- .../mechanics/testing/MotionValueToolkit.kt | 40 +- .../testing/ViewMotionValueToolkit.kt | 14 +- ...rToggle_preventsJumpOnDirectionChange.json | 190 +++ ...n_preventsDirectionChangeBeforeToggle.json | 110 ++ .../maxDirection_togglesAtThreshold.json | 175 +++ ...rToggle_preventsJumpOnDirectionChange.json | 190 +++ ...n_preventsDirectionChangeBeforeToggle.json | 110 ++ .../minDirection_togglesAtThreshold.json | 175 +++ .../Toggle/output_groundedInBaseMapping.json | 180 +++ ...ueIsDisposed_collectionStopsAnimating.json | 111 ++ ...eAnimatingValue_collectionIsAnimating.json | 133 ++ ...ues_oneStops_collectionKeepsAnimating.json | 220 +++ .../collection/wakeUp_onInputChange.json | 119 ++ .../collection/wakeUp_onSpecChange.json | 98 ++ .../goldens/observeWhen_isOutputFixed.json | 444 ++++++ ...c_atTheBeginning_jumpcutsToFirstValue.json | 92 ++ .../goldens/unspecifiedSpec_outputIsNan.json | 102 ++ .../view/specChange_triggersAnimation.json | 18 +- ...c_atTheBeginning_jumpcutsToFirstValue.json | 92 ++ .../view/unspecifiedSpec_outputIsNan.json | 102 ++ .../MotionValueCollectionLifecycleTest.kt | 321 ++++ .../mechanics/MotionValueCollectionTest.kt | 147 ++ .../mechanics/MotionValueLifecycleTest.kt | 6 +- .../com/android/mechanics/MotionValueTest.kt | 138 +- .../debug/MotionValueDebuggerTest.kt | 28 +- .../android/mechanics/effects/ToggleTest.kt | 178 +++ .../spec/DirectionalMotionSpecTest.kt | 58 +- .../spec/MotionSpecDebugFormatterTest.kt | 43 +- .../android/mechanics/spec/MotionSpecTest.kt | 44 +- .../com/android/mechanics/spec/SegmentTest.kt | 94 +- .../builder/DirectionalBuilderImplTest.kt | 40 +- .../spec/builder/MotionSpecBuilderTest.kt | 16 + .../mechanics/view/ViewMotionValueTest.kt | 60 +- 85 files changed, 11995 insertions(+), 916 deletions(-) create mode 100644 mechanics/OWNERS create mode 100644 mechanics/benchmark/tests/src/com/android/mechanics/benchmark/MechanicsSpringBenchmark.kt create mode 100644 mechanics/benchmark/tests/src/com/android/mechanics/benchmark/MotionValueCollectionBenchmark.kt create mode 100644 mechanics/compose/src/com/android/mechanics/compose/modifier/MotionDriver.kt create mode 100644 mechanics/compose/tests/Android.bp create mode 100644 mechanics/compose/tests/goldens/verticalTactileSurfaceReveal_gesture_dragClose.json create mode 100644 mechanics/compose/tests/goldens/verticalTactileSurfaceReveal_gesture_dragOpen.json create mode 100644 mechanics/compose/tests/goldens/verticalTactileSurfaceReveal_gesture_flingClose.json create mode 100644 mechanics/compose/tests/goldens/verticalTactileSurfaceReveal_gesture_flingOpen.json create mode 100644 mechanics/compose/tests/src/com/android/mechanics/compose/modifier/VerticalTactileSurfaceRevealModifierTest.kt create mode 100644 mechanics/src/com/android/mechanics/ComposableMotionValue.kt create mode 100644 mechanics/src/com/android/mechanics/MotionValueCollection.kt create mode 100644 mechanics/src/com/android/mechanics/MotionValueState.kt create mode 100644 mechanics/src/com/android/mechanics/effects/CommonSemantics.kt create mode 100644 mechanics/src/com/android/mechanics/effects/Toggle.kt create mode 100644 mechanics/src/com/android/mechanics/haptics/HapticPlayer.kt create mode 100644 mechanics/src/com/android/mechanics/haptics/HapticTypes.kt create mode 100644 mechanics/src/com/android/mechanics/haptics/HapticsExperimentalApi.kt create mode 100644 mechanics/src/com/android/mechanics/haptics/MetricScaling.kt create mode 100644 mechanics/src/com/android/mechanics/haptics/SpringTensionHapticPlayer.kt create mode 100644 mechanics/src/com/android/mechanics/spec/Mapping.kt create mode 100644 mechanics/testing/src/com/android/mechanics/testing/ComposeMotionValueCollectionToolkit.kt create mode 100644 mechanics/tests/goldens/Toggle/maxDirection_AfterToggle_preventsJumpOnDirectionChange.json create mode 100644 mechanics/tests/goldens/Toggle/maxDirection_preventsDirectionChangeBeforeToggle.json create mode 100644 mechanics/tests/goldens/Toggle/maxDirection_togglesAtThreshold.json create mode 100644 mechanics/tests/goldens/Toggle/minDirection_AfterToggle_preventsJumpOnDirectionChange.json create mode 100644 mechanics/tests/goldens/Toggle/minDirection_preventsDirectionChangeBeforeToggle.json create mode 100644 mechanics/tests/goldens/Toggle/minDirection_togglesAtThreshold.json create mode 100644 mechanics/tests/goldens/Toggle/output_groundedInBaseMapping.json create mode 100644 mechanics/tests/goldens/collection/animatingValueIsDisposed_collectionStopsAnimating.json create mode 100644 mechanics/tests/goldens/collection/oneAnimatingValue_collectionIsAnimating.json create mode 100644 mechanics/tests/goldens/collection/twoAnimatingValues_oneStops_collectionKeepsAnimating.json create mode 100644 mechanics/tests/goldens/collection/wakeUp_onInputChange.json create mode 100644 mechanics/tests/goldens/collection/wakeUp_onSpecChange.json create mode 100644 mechanics/tests/goldens/observeWhen_isOutputFixed.json create mode 100644 mechanics/tests/goldens/unspecifiedSpec_atTheBeginning_jumpcutsToFirstValue.json create mode 100644 mechanics/tests/goldens/unspecifiedSpec_outputIsNan.json create mode 100644 mechanics/tests/goldens/view/unspecifiedSpec_atTheBeginning_jumpcutsToFirstValue.json create mode 100644 mechanics/tests/goldens/view/unspecifiedSpec_outputIsNan.json create mode 100644 mechanics/tests/src/com/android/mechanics/MotionValueCollectionLifecycleTest.kt create mode 100644 mechanics/tests/src/com/android/mechanics/MotionValueCollectionTest.kt create mode 100644 mechanics/tests/src/com/android/mechanics/effects/ToggleTest.kt diff --git a/mechanics/Android.bp b/mechanics/Android.bp index d683892..6df296e 100644 --- a/mechanics/Android.bp +++ b/mechanics/Android.bp @@ -20,7 +20,8 @@ package { android_library { name: "mechanics", manifest: "AndroidManifest.xml", - sdk_version: "system_current", + // sdk_version must be specified, otherwise it compiles against private APIs. + sdk_version: "current", min_sdk_version: "31", static_libs: [ "androidx.compose.runtime_runtime", diff --git a/mechanics/OWNERS b/mechanics/OWNERS new file mode 100644 index 0000000..f895dc9 --- /dev/null +++ b/mechanics/OWNERS @@ -0,0 +1,2 @@ +michschn@google.com +omarmt@google.com diff --git a/mechanics/TEST_MAPPING b/mechanics/TEST_MAPPING index 4dd86b9..7f09a13 100644 --- a/mechanics/TEST_MAPPING +++ b/mechanics/TEST_MAPPING @@ -15,10 +15,28 @@ ] }, { - "name": "PlatformComposeSceneTransitionLayoutTests" + "name": "PlatformComposeSceneTransitionLayoutTests", + "keywords": ["internal"], + "options": [ + { + "exclude-annotation": "org.junit.Ignore" + }, + { + "exclude-annotation": "androidx.test.filters.FlakyTest" + } + ] }, { - "name": "PlatformComposeCoreTests" + "name": "PlatformComposeCoreTests", + "keywords": ["internal"], + "options": [ + { + "exclude-annotation": "org.junit.Ignore" + }, + { + "exclude-annotation": "androidx.test.filters.FlakyTest" + } + ] } ], "presubmit-large": [ @@ -30,7 +48,7 @@ ] } ], - "wm-cf": [ + "wm": [ { "name": "WMShellUnitTests" } diff --git a/mechanics/benchmark/tests/src/com/android/mechanics/benchmark/MechanicsSpringBenchmark.kt b/mechanics/benchmark/tests/src/com/android/mechanics/benchmark/MechanicsSpringBenchmark.kt new file mode 100644 index 0000000..cc6bdfe --- /dev/null +++ b/mechanics/benchmark/tests/src/com/android/mechanics/benchmark/MechanicsSpringBenchmark.kt @@ -0,0 +1,82 @@ +/* + * Copyright (C) 2025 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.benchmark + +import androidx.benchmark.junit4.BenchmarkRule +import androidx.benchmark.junit4.measureRepeated +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.android.mechanics.spring.SpringParameters +import com.android.mechanics.spring.SpringState +import com.android.mechanics.spring.calculateUpdatedState +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class MechanicsSpringBenchmark { + @get:Rule val benchmarkRule = BenchmarkRule() + + @Test + fun calculateUpdatedState_atRest() { + val initialState = SpringState(0f, 0f) + + benchmarkRule.measureRepeated { + initialState.calculateUpdatedState(FrameDuration, CriticallyDamped) + } + } + + @Test + fun calculateUpdatedState_underDamped() { + val initialState = SpringState(10f, -1f) + + benchmarkRule.measureRepeated { + initialState.calculateUpdatedState(FrameDuration, UnderDamped) + } + } + + @Test + fun calculateUpdatedState_criticallyDamped() { + val initialState = SpringState(10f, -1f) + + benchmarkRule.measureRepeated { + initialState.calculateUpdatedState(FrameDuration, CriticallyDamped) + } + } + + @Test + fun calculateUpdatedState_overDamped() { + val initialState = SpringState(10f, -1f) + + benchmarkRule.measureRepeated { + initialState.calculateUpdatedState(FrameDuration, OverDamped) + } + } + + @Test + fun isStable() { + val initialState = SpringState(10f, -1f) + + benchmarkRule.measureRepeated { initialState.isStable(CriticallyDamped, 0.1f) } + } + + companion object { + val FrameDuration = 16_000_000L + val UnderDamped = SpringParameters(stiffness = 100f, dampingRatio = 0.5f) + val CriticallyDamped = SpringParameters(stiffness = 100f, dampingRatio = 1f) + val OverDamped = SpringParameters(stiffness = 100f, dampingRatio = 2f) + } +} diff --git a/mechanics/benchmark/tests/src/com/android/mechanics/benchmark/MotionValueBenchmark.kt b/mechanics/benchmark/tests/src/com/android/mechanics/benchmark/MotionValueBenchmark.kt index f5eab76..b2aab0b 100644 --- a/mechanics/benchmark/tests/src/com/android/mechanics/benchmark/MotionValueBenchmark.kt +++ b/mechanics/benchmark/tests/src/com/android/mechanics/benchmark/MotionValueBenchmark.kt @@ -73,11 +73,11 @@ class MotionValueBenchmark { private fun testData( gestureContext: DistanceGestureContext = DistanceGestureContext(0f, InputDirection.Max, 2f), input: Float = 0f, - spec: MotionSpec = MotionSpec.Empty, + spec: MotionSpec = MotionSpec.Identity, ): TestData { val inputState = mutableFloatStateOf(input) return TestData( - motionValue = MotionValue(inputState::floatValue, gestureContext, spec), + motionValue = MotionValue(inputState::floatValue, gestureContext, { spec }), gestureContext = gestureContext, input = inputState, spec = spec, @@ -91,7 +91,9 @@ class MotionValueBenchmark { val gestureContext = DistanceGestureContext(0f, InputDirection.Max, 2f) val input = { 0f } - benchmarkRule.measureRepeated { MotionValue(input, gestureContext) } + benchmarkRule.measureRepeated { + MotionValue(input, gestureContext, { MotionSpec.Identity }) + } } @Test diff --git a/mechanics/benchmark/tests/src/com/android/mechanics/benchmark/MotionValueCollectionBenchmark.kt b/mechanics/benchmark/tests/src/com/android/mechanics/benchmark/MotionValueCollectionBenchmark.kt new file mode 100644 index 0000000..efbbd02 --- /dev/null +++ b/mechanics/benchmark/tests/src/com/android/mechanics/benchmark/MotionValueCollectionBenchmark.kt @@ -0,0 +1,194 @@ +/* + * Copyright (C) 2025 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.benchmark + +import androidx.benchmark.junit4.BenchmarkRule +import androidx.benchmark.junit4.measureRepeated +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.snapshots.Snapshot +import androidx.compose.ui.util.fastForEach +import com.android.mechanics.DistanceGestureContext +import com.android.mechanics.ManagedMotionValue +import com.android.mechanics.MotionValueCollection +import com.android.mechanics.spec.Guarantee +import com.android.mechanics.spec.InputDirection +import com.android.mechanics.spec.Mapping +import com.android.mechanics.spec.MotionSpec +import com.android.mechanics.spec.builder.MotionBuilderContext +import com.android.mechanics.spec.builder.directionalMotionSpec +import com.android.mechanics.spring.SpringParameters +import kotlinx.coroutines.launch +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.junit.runners.Parameterized +import platform.test.motion.compose.MonotonicClockTestScope + +/** Benchmark, which will execute on an Android device. Previous results: go/mm-microbenchmarks */ +@RunWith(Parameterized::class) +class MotionValueCollectionBenchmark(private val instanceCount: Int) { + + companion object { + @JvmStatic + @Parameterized.Parameters(name = "instanceCount={0}") + fun instanceCount() = listOf(1, 100) + + val DefaultSpring = SpringParameters(stiffness = 300f, dampingRatio = .9f) + } + + @get:Rule val benchmarkRule = BenchmarkRule() + + private val tearDownOperations = mutableListOf<() -> Unit>() + + /** + * Runs a test block within a [MonotonicClockTestScope] provided by the underlying + * [platform.test.motion.compose.runMonotonicClockTest] and ensures automatic cleanup. + * + * This mechanism provides a convenient way to register cleanup actions (e.g., stopping + * coroutines, resetting states) that should reliably run at the end of the test, simplifying + * test setup and teardown. + */ + private fun runMonotonicClockTest(block: suspend MonotonicClockTestScope.() -> Unit) { + return platform.test.motion.compose.runMonotonicClockTest { + try { + block() + } finally { + tearDownOperations.fastForEach { it.invoke() } + } + } + } + + private data class TestFixture( + val collection: MotionValueCollection, + val gestureContext: DistanceGestureContext, + val instances: List, + ) + + private data class MotionValueInstance( + val value: ManagedMotionValue, + val spec: MutableState, + ) + + private fun MonotonicClockTestScope.testFixture( + initialInput: Float = 0f, + init: (Int) -> MotionSpec = { MotionSpec.Identity }, + ): TestFixture { + val gestureContext = DistanceGestureContext(initialInput, InputDirection.Max, 2f) + val collection = + MotionValueCollection( + { gestureContext.dragOffset }, + gestureContext, + stableThreshold = MotionBuilderContext.StableThresholdEffects, + ) + + val instances = + List(instanceCount) { + val spec = mutableStateOf(init(it)) + val value = collection.create(spec::value) + MotionValueInstance(value, spec) + } + + val keepRunningJob = launch { collection.keepRunning() } + tearDownOperations += { keepRunningJob.cancel() } + + return TestFixture( + collection = collection, + gestureContext = gestureContext, + instances = instances, + ) + } + + private fun MonotonicClockTestScope.nextFrame() { + Snapshot.sendApplyNotifications() + testScheduler.advanceTimeBy(16) + } + + private fun MonotonicClockTestScope.measureOscillatingInput( + fixture: TestFixture, + stepSize: Float = 1f, + ) { + var step = stepSize + benchmarkRule.measureRepeated { + val lastInput = fixture.gestureContext.dragOffset + if (lastInput <= .5f) step = stepSize else if (lastInput >= 9.5f) step = -stepSize + fixture.gestureContext.dragOffset = lastInput + step + nextFrame() + } + } + + @Test + fun noChange() = runMonotonicClockTest { + val fixture = testFixture() + + measureOscillatingInput(fixture, stepSize = 0f) + } + + @Test + fun changeInput() = runMonotonicClockTest { + val fixture = testFixture() + + measureOscillatingInput(fixture) + } + + @Test + fun changeInput_sameOutput() = runMonotonicClockTest { + val spec = MotionSpec(directionalMotionSpec(Mapping.Zero)) + + val fixture = testFixture(initialInput = 4f) { spec } + measureOscillatingInput(fixture) + } + + @Test + fun changeSegment_noDiscontinuity() = runMonotonicClockTest { + val spec = + MotionSpec( + directionalMotionSpec(DefaultSpring, Mapping.Zero) { + mapping(breakpoint = 5f, mapping = Mapping.Zero) + } + ) + + val fixture = testFixture(initialInput = 4f) { spec } + measureOscillatingInput(fixture) + } + + @Test + fun animateOutput() = runMonotonicClockTest { + val spec = + MotionSpec( + directionalMotionSpec(DefaultSpring, Mapping.Zero) { + fixedValue(breakpoint = 5f, value = 1f) + } + ) + + val fixture = testFixture(initialInput = 4f) { spec } + measureOscillatingInput(fixture) + } + + @Test + fun animateWithGuarantee() = runMonotonicClockTest { + val spec = + MotionSpec( + directionalMotionSpec(DefaultSpring, Mapping.Zero) { + fixedValue(breakpoint = 5f, value = 1f, guarantee = Guarantee.InputDelta(4f)) + } + ) + + val fixture = testFixture { spec } + measureOscillatingInput(fixture) + } +} diff --git a/mechanics/compose/Android.bp b/mechanics/compose/Android.bp index bc852eb..ddcc569 100644 --- a/mechanics/compose/Android.bp +++ b/mechanics/compose/Android.bp @@ -24,8 +24,10 @@ android_library { "src/**/*.kt", ], static_libs: [ - "PlatformComposeCore", + // Private APIs "PlatformComposeSceneTransitionLayout", + + // Public APIs "//frameworks/libs/systemui/mechanics:mechanics", "androidx.compose.runtime_runtime", ], diff --git a/mechanics/compose/src/com/android/mechanics/compose/modifier/MotionDriver.kt b/mechanics/compose/src/com/android/mechanics/compose/modifier/MotionDriver.kt new file mode 100644 index 0000000..00ed295 --- /dev/null +++ b/mechanics/compose/src/com/android/mechanics/compose/modifier/MotionDriver.kt @@ -0,0 +1,191 @@ +/* + * Copyright (C) 2025 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.compose.modifier + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.layout.LayoutCoordinates +import androidx.compose.ui.layout.Measurable +import androidx.compose.ui.layout.MeasureResult +import androidx.compose.ui.layout.MeasureScope +import androidx.compose.ui.layout.Placeable +import androidx.compose.ui.node.CompositionLocalConsumerModifierNode +import androidx.compose.ui.node.DelegatableNode +import androidx.compose.ui.node.LayoutModifierNode +import androidx.compose.ui.node.ModifierNodeElement +import androidx.compose.ui.node.TraversableNode +import androidx.compose.ui.node.findNearestAncestor +import androidx.compose.ui.platform.InspectorInfo +import androidx.compose.ui.unit.Constraints +import androidx.compose.ui.unit.IntOffset +import com.android.mechanics.GestureContext +import com.android.mechanics.ManagedMotionValue +import com.android.mechanics.MotionValueCollection +import com.android.mechanics.spec.MotionSpec +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch + +private const val TRAVERSAL_NODE_KEY = "MotionDriverNode" + +/** Finds the nearest [MotionDriver] (or null) that was registered via a [motionDriver] modifier. */ +private fun DelegatableNode.findMotionDriverOrNull(): MotionDriver? { + return findNearestAncestor(TRAVERSAL_NODE_KEY) as? MotionDriver +} + +/** Finds the nearest [MotionDriver] that was registered via a [motionDriver] modifier. */ +internal fun DelegatableNode.findMotionDriver(): MotionDriver { + return checkNotNull(findMotionDriverOrNull()) { + "Did you forget to add the `motionDriver()` modifier to a parent Composable?" + } +} + +/** + * A central interface for driving animations based on layout constraints. + * + * A `MotionDriver` is attached to a layout node using the [motionDriver] modifier. Descendant nodes + * can then find this driver to create animations whose target values are derived from the driver's + * layout `Constraints`. This allows for coordinated animations within a component tree that react + * to a parent's size changes, such as expanding or collapsing. + */ +internal interface MotionDriver { + /** The [GestureContext] associated with this motion. */ + val gestureContext: GestureContext + + /** + * The current vertical state of the layout, indicating if it's minimized, maximized, or in + * transition. + */ + val verticalState: State + + enum class State { + MinValue, + Transition, + MaxValue, + } + + /** + * Calculates the positional offset from the `MotionDriver`'s layout to the current layout. + * + * This function should be called from within a `Placeable.PlacementScope` (such as a `layout` + * block) by a descendant of the `motionDriver` modifier. It's useful for determining the + * descendant's position relative to the driver's coordinate system, which can then be used as + * an input for animations or other positional logic. + * + * @return The [Offset] of the current layout within the `MotionDriver`'s coordinate space. + */ + fun Placeable.PlacementScope.driverOffset(): Offset + + /** + * Creates and registers a [ManagedMotionValue] that animates based on layout constraints. + * + * The value will automatically update its output whenever the `MotionDriver`'s `maxHeight` + * constraint changes. + * + * @param spec A factory for the [MotionSpec] that governs the animation. + * @param label A string identifier for debugging purposes. + * @return A [ManagedMotionValue] that provides the animated output. + */ + fun maxHeightDriven(spec: () -> MotionSpec, label: String? = null): ManagedMotionValue +} + +/** + * Creates and registers a [MotionDriver] for this layout. + * + * This allows descendant modifiers or layouts to find this `MotionDriver` (using + * [findMotionDriver]) and observe its state, which is derived from layout changes (e.g., expanding + * or collapsing). + * + * @param gestureContext The [GestureContext] to be made available through this [MotionDriver]. + * @param label An optional label for debugging and inspector tooling. + */ +fun Modifier.motionDriver(gestureContext: GestureContext, label: String? = null): Modifier = + this then MotionDriverElement(gestureContext = gestureContext, label = label) + +private data class MotionDriverElement(val gestureContext: GestureContext, val label: String?) : + ModifierNodeElement() { + override fun create(): MotionDriverNode = + MotionDriverNode(gestureContext = gestureContext, label = label) + + override fun update(node: MotionDriverNode) { + check(node.gestureContext == gestureContext) { "Cannot change the gestureContext" } + } + + override fun InspectorInfo.inspectableProperties() { + name = "motionDriver" + properties["label"] = label + } +} + +private class MotionDriverNode(override val gestureContext: GestureContext, label: String?) : + Modifier.Node(), + TraversableNode, + LayoutModifierNode, + MotionDriver, + CompositionLocalConsumerModifierNode { + override val traverseKey: Any = TRAVERSAL_NODE_KEY + override var verticalState: MotionDriver.State by mutableStateOf(MotionDriver.State.MinValue) + + private var driverCoordinates: LayoutCoordinates? = null + private var lookAheadHeight: Int = 0 + private var input by mutableFloatStateOf(0f) + private val motionValues = MotionValueCollection(::input, gestureContext, label = label) + + override fun onAttach() { + coroutineScope.launch(Dispatchers.Main.immediate) { motionValues.keepRunning() } + } + + override fun maxHeightDriven(spec: () -> MotionSpec, label: String?): ManagedMotionValue { + return motionValues.create(spec, label) + } + + override fun Placeable.PlacementScope.driverOffset(): Offset { + val driverCoordinates = requireNotNull(driverCoordinates) { "No driver coordinates" } + val childCoordinates = requireNotNull(coordinates) { "No child coordinates" } + return driverCoordinates.localPositionOf(childCoordinates) + } + + override fun MeasureScope.measure( + measurable: Measurable, + constraints: Constraints, + ): MeasureResult { + val placeable = measurable.measure(constraints) + + if (isLookingAhead) { + // In the lookahead pass, we capture the target height of the layout. + // This is assumed to be the max value that the layout will animate to. + lookAheadHeight = placeable.height + } else { + verticalState = + when (placeable.height) { + 0 -> MotionDriver.State.MinValue + lookAheadHeight -> MotionDriver.State.MaxValue + else -> MotionDriver.State.Transition + } + + input = constraints.maxHeight.toFloat() + } + + return layout(width = placeable.width, height = placeable.height) { + driverCoordinates = coordinates + placeable.place(IntOffset.Zero) + } + } +} diff --git a/mechanics/compose/src/com/android/mechanics/compose/modifier/VerticalFadeContentRevealModifier.kt b/mechanics/compose/src/com/android/mechanics/compose/modifier/VerticalFadeContentRevealModifier.kt index 6428d9d..0d28358 100644 --- a/mechanics/compose/src/com/android/mechanics/compose/modifier/VerticalFadeContentRevealModifier.kt +++ b/mechanics/compose/src/com/android/mechanics/compose/modifier/VerticalFadeContentRevealModifier.kt @@ -1,229 +1,170 @@ -///* -// * Copyright (C) 2025 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.compose.modifier -// -//import androidx.compose.ui.Modifier -//import androidx.compose.ui.geometry.Rect -//import androidx.compose.ui.graphics.CompositingStrategy -//import androidx.compose.ui.layout.ApproachLayoutModifierNode -//import androidx.compose.ui.layout.ApproachMeasureScope -//import androidx.compose.ui.layout.LayoutCoordinates -//import androidx.compose.ui.layout.Measurable -//import androidx.compose.ui.layout.MeasureResult -//import androidx.compose.ui.layout.MeasureScope -//import androidx.compose.ui.layout.Placeable -//import androidx.compose.ui.layout.boundsInParent -//import androidx.compose.ui.node.ModifierNodeElement -//import androidx.compose.ui.platform.InspectorInfo -//import androidx.compose.ui.unit.Constraints -//import androidx.compose.ui.unit.IntOffset -//import androidx.compose.ui.unit.IntSize -//import androidx.compose.ui.util.fastCoerceAtLeast -//import com.android.compose.animation.scene.ContentScope -//import com.android.compose.animation.scene.ElementKey -//import com.android.compose.animation.scene.mechanics.gestureContextOrDefault -//import com.android.mechanics.MotionValue -//import com.android.mechanics.debug.findMotionValueDebugger -//import com.android.mechanics.effects.FixedValue -//import com.android.mechanics.spec.Mapping -//import com.android.mechanics.spec.builder.MotionBuilderContext -//import com.android.mechanics.spec.builder.effectsMotionSpec -//import kotlinx.coroutines.Job -//import kotlinx.coroutines.launch -// -///** -// * This component remains hidden until it reach its target height. -// * -// * TODO: Once b/413283893 is done, [motionBuilderContext] can be read internally via -// * CompositionLocalConsumerModifierNode, instead of passing it. -// */ -//fun Modifier.verticalFadeContentReveal( -// contentScope: ContentScope, -// motionBuilderContext: MotionBuilderContext, -// container: ElementKey, -// deltaY: Float = 0f, -// label: String? = null, -// debug: Boolean = false, -//): Modifier = -// this then -// FadeContentRevealElement( -// contentScope = contentScope, -// motionBuilderContext = motionBuilderContext, -// container = container, -// deltaY = deltaY, -// label = label, -// debug = debug, -// ) -// -//private data class FadeContentRevealElement( -// val contentScope: ContentScope, -// val motionBuilderContext: MotionBuilderContext, -// val container: ElementKey, -// val deltaY: Float, -// val label: String?, -// val debug: Boolean, -//) : ModifierNodeElement() { -// override fun create(): FadeContentRevealNode = -// FadeContentRevealNode( -// contentScope = contentScope, -// motionBuilderContext = motionBuilderContext, -// container = container, -// deltaY = deltaY, -// label = label, -// debug = debug, -// ) -// -// override fun update(node: FadeContentRevealNode) { -// node.update( -// contentScope = contentScope, -// motionBuilderContext = motionBuilderContext, -// container = container, -// deltaY = deltaY, -// ) -// } -// -// override fun InspectorInfo.inspectableProperties() { -// name = "fadeContentReveal" -// properties["container"] = container -// properties["deltaY"] = deltaY -// properties["label"] = label -// properties["debug"] = debug -// } -//} -// -//internal class FadeContentRevealNode( -// private var contentScope: ContentScope, -// private var motionBuilderContext: MotionBuilderContext, -// private var container: ElementKey, -// private var deltaY: Float, -// label: String?, -// private val debug: Boolean, -//) : Modifier.Node(), ApproachLayoutModifierNode { -// -// private val motionValue = -// MotionValue( -// currentInput = { -// with(contentScope) { -// val containerHeight = -// container.lastSize(contentKey)?.height ?: return@MotionValue 0f -// val containerCoordinates = -// container.targetCoordinates(contentKey) ?: return@MotionValue 0f -// val localCoordinates = lastCoordinates ?: return@MotionValue 0f -// -// val offsetY = containerCoordinates.localPositionOf(localCoordinates).y -// containerHeight - offsetY + deltaY -// } -// }, -// gestureContext = contentScope.gestureContextOrDefault(), -// label = "FadeContentReveal(${label.orEmpty()})", -// ) -// -// fun update( -// contentScope: ContentScope, -// motionBuilderContext: MotionBuilderContext, -// container: ElementKey, -// deltaY: Float, -// ) { -// this.contentScope = contentScope -// this.motionBuilderContext = motionBuilderContext -// this.container = container -// this.deltaY = deltaY -// updateMotionSpec() -// } -// -// private var motionValueJob: Job? = null -// -// override fun onAttach() { -// motionValueJob = -// coroutineScope.launch { -// val disposableHandle = -// if (debug) { -// findMotionValueDebugger()?.register(motionValue) -// } else { -// null -// } -// try { -// motionValue.keepRunning() -// } finally { -// disposableHandle?.dispose() -// } -// } -// } -// -// override fun onDetach() { -// motionValueJob?.cancel() -// } -// -// private fun isAnimating(): Boolean { -// return contentScope.layoutState.currentTransition != null || !motionValue.isStable -// } -// -// override fun isMeasurementApproachInProgress(lookaheadSize: IntSize) = isAnimating() -// -// override fun Placeable.PlacementScope.isPlacementApproachInProgress( -// lookaheadCoordinates: LayoutCoordinates -// ) = isAnimating() -// -// private var targetBounds = Rect.Zero -// -// private var lastCoordinates: LayoutCoordinates? = null -// -// private fun updateMotionSpec() { -// motionValue.spec = -// motionBuilderContext.effectsMotionSpec(Mapping.Zero) { -// after(targetBounds.bottom, FixedValue.One) -// } -// } -// -// override fun MeasureScope.measure( -// measurable: Measurable, -// constraints: Constraints, -// ): MeasureResult { -// val placeable = measurable.measure(constraints) -// return layout(placeable.width, placeable.height) { -// val coordinates = coordinates -// if (isLookingAhead && coordinates != null) { -// lastCoordinates = coordinates -// val bounds = coordinates.boundsInParent() -// if (targetBounds != bounds) { -// targetBounds = bounds -// updateMotionSpec() -// } -// } -// placeable.place(IntOffset.Zero) -// } -// } -// -// override fun ApproachMeasureScope.approachMeasure( -// measurable: Measurable, -// constraints: Constraints, -// ): MeasureResult { -// return measurable.measure(constraints).run { -// layout(width, height) { -// val revealAlpha = motionValue.output -// if (revealAlpha < 1) { -// placeWithLayer(IntOffset.Zero) { -// alpha = revealAlpha.fastCoerceAtLeast(0f) -// compositingStrategy = CompositingStrategy.ModulateAlpha -// } -// } else { -// place(IntOffset.Zero) -// } -// } -// } -// } -//} +/* + * Copyright (C) 2025 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.compose.modifier + +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.CompositingStrategy +import androidx.compose.ui.layout.ApproachLayoutModifierNode +import androidx.compose.ui.layout.ApproachMeasureScope +import androidx.compose.ui.layout.Measurable +import androidx.compose.ui.layout.MeasureResult +import androidx.compose.ui.layout.MeasureScope +import androidx.compose.ui.node.CompositionLocalConsumerModifierNode +import androidx.compose.ui.node.DelegatingNode +import androidx.compose.ui.node.ModifierNodeElement +import androidx.compose.ui.platform.InspectorInfo +import androidx.compose.ui.unit.Constraints +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.util.fastCoerceAtLeast +import com.android.mechanics.ManagedMotionValue +import com.android.mechanics.debug.DebugMotionValueNode +import com.android.mechanics.effects.FixedValue +import com.android.mechanics.spec.Mapping +import com.android.mechanics.spec.MotionSpec +import com.android.mechanics.spec.builder.ComposeMotionBuilderContext +import com.android.mechanics.spec.builder.effectsMotionSpec +import com.android.mechanics.spec.builder.fixedEffectsValueSpec +import com.android.mechanics.spec.builder.motionBuilderContext + +/** This component remains hidden until it reach its target height. */ +fun Modifier.verticalFadeContentReveal(deltaY: Float = 0f, label: String? = null): Modifier = + this then FadeContentRevealElement(deltaY = deltaY, label = label) + +private data class FadeContentRevealElement(val deltaY: Float, val label: String?) : + ModifierNodeElement() { + override fun create(): FadeContentRevealNode = + FadeContentRevealNode(deltaY = deltaY, label = label) + + override fun update(node: FadeContentRevealNode) { + check(node.deltaY == deltaY) { "Cannot update deltaY from ${node.deltaY} to $deltaY" } + } + + override fun InspectorInfo.inspectableProperties() { + name = "fadeContentReveal" + properties["deltaY"] = deltaY + properties["label"] = label + } +} + +private class FadeContentRevealNode(val deltaY: Float, private val label: String?) : + DelegatingNode(), ApproachLayoutModifierNode, CompositionLocalConsumerModifierNode { + // These properties are calculated during the lookahead pass (`lookAheadMeasure`) to + // orchestrate the reveal animation. They are guaranteed to be updated before `approachMeasure` + // is called. + private var lookAheadHeight by mutableFloatStateOf(Float.NaN) + private var layoutOffsetY by mutableFloatStateOf(Float.NaN) + // Created lazily upon first lookahead and disposed in `onDetach`. + private var revealAlpha: ManagedMotionValue? = null + + /** + * The [MotionDriver] that controls the parent's motion, used to determine the reveal + * animation's progress. + * + * It is initialized in `onAttach` and is safe to use in all subsequent measure passes. + */ + private lateinit var motionDriver: MotionDriver + + private lateinit var motionBuilderContext: ComposeMotionBuilderContext + + override fun onAttach() { + motionDriver = findMotionDriver() + motionBuilderContext = motionBuilderContext() + } + + override fun onDetach() { + revealAlpha?.dispose() + } + + private fun spec(): MotionSpec { + return when (motionDriver.verticalState) { + MotionDriver.State.MinValue -> { + motionBuilderContext.fixedEffectsValueSpec(0f) + } + MotionDriver.State.Transition -> { + motionBuilderContext.effectsMotionSpec(Mapping.Zero) { + after(layoutOffsetY + lookAheadHeight, FixedValue.One) + } + } + MotionDriver.State.MaxValue -> { + motionBuilderContext.fixedEffectsValueSpec(1f) + } + } + } + + override fun MeasureScope.measure( + measurable: Measurable, + constraints: Constraints, + ): MeasureResult { + return if (isLookingAhead) { + lookAheadMeasure(measurable, constraints) + } else { + measurable.measure(constraints).run { layout(width, height) { place(IntOffset.Zero) } } + } + } + + private fun MeasureScope.lookAheadMeasure( + measurable: Measurable, + constraints: Constraints, + ): MeasureResult { + val placeable = measurable.measure(constraints) + val targetHeight = placeable.height.toFloat() + lookAheadHeight = targetHeight + return layout(placeable.width, placeable.height) { + layoutOffsetY = with(motionDriver) { driverOffset() }.y + deltaY + + if (revealAlpha == null) { + val maxHeightDriven = + motionDriver.maxHeightDriven( + spec = derivedStateOf(::spec)::value, + label = "FadeContentReveal(${label.orEmpty()})", + ) + revealAlpha = maxHeightDriven + delegate(DebugMotionValueNode(maxHeightDriven)) + } + + placeable.place(IntOffset.Zero) + } + } + + override fun isMeasurementApproachInProgress(lookaheadSize: IntSize): Boolean { + val revealAlpha = revealAlpha + return revealAlpha != null && + (motionDriver.verticalState == MotionDriver.State.Transition || !revealAlpha.isStable) + } + + override fun ApproachMeasureScope.approachMeasure( + measurable: Measurable, + constraints: Constraints, + ): MeasureResult { + return measurable.measure(constraints).run { + layout(width, height) { + placeWithLayer(IntOffset.Zero) { + val revealAlpha = checkNotNull(revealAlpha).output.fastCoerceAtLeast(0f) + if (revealAlpha < 1f) { + alpha = revealAlpha + compositingStrategy = CompositingStrategy.ModulateAlpha + } + } + } + } + } +} diff --git a/mechanics/compose/src/com/android/mechanics/compose/modifier/VerticalTactileSurfaceRevealModifier.kt b/mechanics/compose/src/com/android/mechanics/compose/modifier/VerticalTactileSurfaceRevealModifier.kt index 9bfd3db..2d51a5e 100644 --- a/mechanics/compose/src/com/android/mechanics/compose/modifier/VerticalTactileSurfaceRevealModifier.kt +++ b/mechanics/compose/src/com/android/mechanics/compose/modifier/VerticalTactileSurfaceRevealModifier.kt @@ -1,250 +1,242 @@ -///* -// * Copyright (C) 2025 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.compose.modifier -// -//import androidx.compose.ui.Modifier -//import androidx.compose.ui.geometry.Rect -//import androidx.compose.ui.graphics.CompositingStrategy -//import androidx.compose.ui.layout.ApproachLayoutModifierNode -//import androidx.compose.ui.layout.ApproachMeasureScope -//import androidx.compose.ui.layout.LayoutCoordinates -//import androidx.compose.ui.layout.Measurable -//import androidx.compose.ui.layout.MeasureResult -//import androidx.compose.ui.layout.MeasureScope -//import androidx.compose.ui.layout.Placeable -//import androidx.compose.ui.layout.boundsInParent -//import androidx.compose.ui.node.ModifierNodeElement -//import androidx.compose.ui.platform.InspectorInfo -//import androidx.compose.ui.unit.Constraints -//import androidx.compose.ui.unit.IntOffset -//import androidx.compose.ui.unit.IntSize -//import androidx.compose.ui.util.fastCoerceAtLeast -//import androidx.compose.ui.util.fastCoerceIn -//import com.android.compose.animation.scene.ContentScope -//import com.android.compose.animation.scene.ElementKey -//import com.android.compose.animation.scene.mechanics.gestureContextOrDefault -//import com.android.mechanics.MotionValue -//import com.android.mechanics.debug.findMotionValueDebugger -//import com.android.mechanics.effects.RevealOnThreshold -//import com.android.mechanics.spec.Mapping -//import com.android.mechanics.spec.builder.MotionBuilderContext -//import com.android.mechanics.spec.builder.spatialMotionSpec -//import kotlin.math.roundToInt -//import kotlinx.coroutines.Job -//import kotlinx.coroutines.launch -// -///** -// * This component remains hidden until its target height meets a minimum threshold. At that point, -// * it reveals itself by animating its height from 0 to the current target height. -// * -// * TODO: Once b/413283893 is done, [motionBuilderContext] can be read internally via -// * CompositionLocalConsumerModifierNode, instead of passing it. -// */ -//fun Modifier.verticalTactileSurfaceReveal( -// contentScope: ContentScope, -// motionBuilderContext: MotionBuilderContext, -// container: ElementKey, -// deltaY: Float = 0f, -// revealOnThreshold: RevealOnThreshold = DefaultRevealOnThreshold, -// label: String? = null, -// debug: Boolean = false, -//): Modifier = -// this then -// VerticalTactileSurfaceRevealElement( -// contentScope = contentScope, -// motionBuilderContext = motionBuilderContext, -// container = container, -// deltaY = deltaY, -// revealOnThreshold = revealOnThreshold, -// label = label, -// debug = debug, -// ) -// -//private val DefaultRevealOnThreshold = RevealOnThreshold() -// -//private data class VerticalTactileSurfaceRevealElement( -// val contentScope: ContentScope, -// val motionBuilderContext: MotionBuilderContext, -// val container: ElementKey, -// val deltaY: Float, -// val revealOnThreshold: RevealOnThreshold, -// val label: String?, -// val debug: Boolean, -//) : ModifierNodeElement() { -// override fun create(): VerticalTactileSurfaceRevealNode = -// VerticalTactileSurfaceRevealNode( -// contentScope = contentScope, -// motionBuilderContext = motionBuilderContext, -// container = container, -// deltaY = deltaY, -// revealOnThreshold = revealOnThreshold, -// label = label, -// debug = debug, -// ) -// -// override fun update(node: VerticalTactileSurfaceRevealNode) { -// node.update( -// contentScope = contentScope, -// motionBuilderContext = motionBuilderContext, -// container = container, -// deltaY = deltaY, -// revealOnThreshold = revealOnThreshold, -// ) -// } -// -// override fun InspectorInfo.inspectableProperties() { -// name = "tactileSurfaceReveal" -// properties["container"] = container -// properties["deltaY"] = deltaY -// properties["revealOnThreshold"] = revealOnThreshold -// properties["label"] = label -// properties["debug"] = debug -// } -//} -// -//private class VerticalTactileSurfaceRevealNode( -// private var contentScope: ContentScope, -// private var motionBuilderContext: MotionBuilderContext, -// private var container: ElementKey, -// private var deltaY: Float, -// private var revealOnThreshold: RevealOnThreshold, -// label: String?, -// private val debug: Boolean, -//) : Modifier.Node(), ApproachLayoutModifierNode { -// -// private val motionValue = -// MotionValue( -// currentInput = { -// with(contentScope) { -// val containerHeight = -// container.lastSize(contentKey)?.height ?: return@MotionValue 0f -// val containerCoordinates = -// container.targetCoordinates(contentKey) ?: return@MotionValue 0f -// val localCoordinates = lastCoordinates ?: return@MotionValue 0f -// -// val offsetY = containerCoordinates.localPositionOf(localCoordinates).y -// containerHeight - offsetY + deltaY -// } -// }, -// gestureContext = contentScope.gestureContextOrDefault(), -// label = "TactileSurfaceReveal(${label.orEmpty()})", -// stableThreshold = MotionBuilderContext.StableThresholdSpatial, -// ) -// -// fun update( -// contentScope: ContentScope, -// motionBuilderContext: MotionBuilderContext, -// container: ElementKey, -// deltaY: Float, -// revealOnThreshold: RevealOnThreshold, -// ) { -// this.contentScope = contentScope -// this.motionBuilderContext = motionBuilderContext -// this.container = container -// this.deltaY = deltaY -// this.revealOnThreshold = revealOnThreshold -// updateMotionSpec() -// } -// -// private var motionValueJob: Job? = null -// -// override fun onAttach() { -// motionValueJob = -// coroutineScope.launch { -// val disposableHandle = -// if (debug) { -// findMotionValueDebugger()?.register(motionValue) -// } else { -// null -// } -// try { -// motionValue.keepRunning() -// } finally { -// disposableHandle?.dispose() -// } -// } -// } -// -// override fun onDetach() { -// motionValueJob?.cancel() -// } -// -// private fun isAnimating(): Boolean { -// return contentScope.layoutState.currentTransition != null || !motionValue.isStable -// } -// -// override fun isMeasurementApproachInProgress(lookaheadSize: IntSize) = isAnimating() -// -// override fun Placeable.PlacementScope.isPlacementApproachInProgress( -// lookaheadCoordinates: LayoutCoordinates -// ) = isAnimating() -// -// private var targetBounds = Rect.Zero -// -// private var lastCoordinates: LayoutCoordinates? = null -// -// private fun updateMotionSpec() { -// motionValue.spec = -// motionBuilderContext.spatialMotionSpec(Mapping.Zero) { -// between( -// start = targetBounds.top, -// end = targetBounds.bottom, -// effect = revealOnThreshold, -// ) -// } -// } -// -// override fun MeasureScope.measure( -// measurable: Measurable, -// constraints: Constraints, -// ): MeasureResult { -// val placeable = measurable.measure(constraints) -// return layout(placeable.width, placeable.height) { -// val coordinates = coordinates -// if (isLookingAhead && coordinates != null) { -// lastCoordinates = coordinates -// val bounds = coordinates.boundsInParent() -// if (targetBounds != bounds) { -// targetBounds = bounds -// updateMotionSpec() -// } -// } -// placeable.place(IntOffset.Zero) -// } -// } -// -// override fun ApproachMeasureScope.approachMeasure( -// measurable: Measurable, -// constraints: Constraints, -// ): MeasureResult { -// val height = motionValue.output.roundToInt().fastCoerceAtLeast(0) -// val animatedConstraints = Constraints.fixed(width = constraints.maxWidth, height = height) -// return measurable.measure(animatedConstraints).run { -// layout(width, height) { -// val revealAlpha = (height / revealOnThreshold.minSize.toPx()).fastCoerceIn(0f, 1f) -// if (revealAlpha < 1) { -// placeWithLayer(IntOffset.Zero) { -// alpha = revealAlpha -// compositingStrategy = CompositingStrategy.ModulateAlpha -// } -// } else { -// place(IntOffset.Zero) -// } -// } -// } -// } -//} +/* + * Copyright (C) 2025 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.compose.modifier + +import androidx.compose.foundation.shape.GenericShape +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.CornerRadius +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.geometry.RoundRect +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.CompositingStrategy +import androidx.compose.ui.graphics.GraphicsLayerScope +import androidx.compose.ui.layout.ApproachLayoutModifierNode +import androidx.compose.ui.layout.ApproachMeasureScope +import androidx.compose.ui.layout.Measurable +import androidx.compose.ui.layout.MeasureResult +import androidx.compose.ui.layout.MeasureScope +import androidx.compose.ui.node.CompositionLocalConsumerModifierNode +import androidx.compose.ui.node.DelegatingNode +import androidx.compose.ui.node.ModifierNodeElement +import androidx.compose.ui.platform.InspectorInfo +import androidx.compose.ui.unit.Constraints +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.constrainHeight +import androidx.compose.ui.util.fastCoerceAtLeast +import androidx.compose.ui.util.fastCoerceAtMost +import com.android.mechanics.ManagedMotionValue +import com.android.mechanics.debug.DebugMotionValueNode +import com.android.mechanics.effects.RevealOnThreshold +import com.android.mechanics.spec.Mapping +import com.android.mechanics.spec.MotionSpec +import com.android.mechanics.spec.builder.ComposeMotionBuilderContext +import com.android.mechanics.spec.builder.fixedSpatialValueSpec +import com.android.mechanics.spec.builder.motionBuilderContext +import com.android.mechanics.spec.builder.spatialMotionSpec +import kotlin.math.roundToInt + +/** + * This component remains hidden until its target height meets a minimum threshold. At that point, + * it reveals itself by animating its height from 0 to the current target height. + */ +fun Modifier.verticalTactileSurfaceReveal( + deltaY: Float = 0f, + revealOnThreshold: RevealOnThreshold = DefaultRevealOnThreshold, + label: String? = null, +): Modifier = + this then + VerticalTactileSurfaceRevealElement( + deltaY = deltaY, + revealOnThreshold = revealOnThreshold, + label = label, + ) + +private val DefaultRevealOnThreshold = RevealOnThreshold() + +private data class VerticalTactileSurfaceRevealElement( + val deltaY: Float, + val revealOnThreshold: RevealOnThreshold, + val label: String?, +) : ModifierNodeElement() { + override fun create(): VerticalTactileSurfaceRevealNode = + VerticalTactileSurfaceRevealNode( + deltaY = deltaY, + revealOnThreshold = revealOnThreshold, + label = label, + ) + + override fun update(node: VerticalTactileSurfaceRevealNode) { + check(node.deltaY == deltaY) { "Cannot update deltaY from ${node.deltaY} to $deltaY" } + node.update(revealOnThreshold = revealOnThreshold) + } + + override fun InspectorInfo.inspectableProperties() { + name = "tactileSurfaceReveal" + properties["deltaY"] = deltaY + properties["revealOnThreshold"] = revealOnThreshold + properties["label"] = label + } +} + +private class VerticalTactileSurfaceRevealNode( + val deltaY: Float, + private var revealOnThreshold: RevealOnThreshold, + private val label: String?, +) : DelegatingNode(), ApproachLayoutModifierNode, CompositionLocalConsumerModifierNode { + // These properties are calculated during the lookahead pass (`lookAheadMeasure`) to + // orchestrate the reveal animation. They are guaranteed to be updated before `approachMeasure` + // is called. + private var lookAheadHeight by mutableFloatStateOf(Float.NaN) + private var layoutOffsetY by mutableFloatStateOf(Float.NaN) + // Created lazily upon first lookahead and disposed in `onDetach`. + private var revealHeight: ManagedMotionValue? = null + + /** + * The [MotionDriver] that controls the parent's motion, used to determine the reveal + * animation's progress. + * + * It is initialized in `onAttach` and is safe to use in all subsequent measure passes. + */ + private lateinit var motionDriver: MotionDriver + + private lateinit var motionBuilderContext: ComposeMotionBuilderContext + + override fun onAttach() { + motionDriver = findMotionDriver() + motionBuilderContext = motionBuilderContext() + } + + fun update(revealOnThreshold: RevealOnThreshold) { + this.revealOnThreshold = revealOnThreshold + } + + override fun onDetach() { + revealHeight?.dispose() + } + + private fun spec(): MotionSpec { + return when (motionDriver.verticalState) { + MotionDriver.State.MinValue -> { + motionBuilderContext.fixedSpatialValueSpec(0f) + } + MotionDriver.State.Transition -> { + // Cache the state read to avoid the performance cost of accessing it twice. + val start = layoutOffsetY + motionBuilderContext.spatialMotionSpec(Mapping.Zero) { + between( + start = start, + end = start + lookAheadHeight, + effect = revealOnThreshold, + ) + } + } + MotionDriver.State.MaxValue -> { + motionBuilderContext.fixedSpatialValueSpec(lookAheadHeight) + } + } + } + + override fun MeasureScope.measure( + measurable: Measurable, + constraints: Constraints, + ): MeasureResult { + return if (isLookingAhead) { + lookAheadMeasure(measurable, constraints) + } else { + measurable.measure(constraints).run { layout(width, height) { place(IntOffset.Zero) } } + } + } + + private fun MeasureScope.lookAheadMeasure( + measurable: Measurable, + constraints: Constraints, + ): MeasureResult { + val placeable = measurable.measure(constraints) + val targetHeight = placeable.height.toFloat() + lookAheadHeight = targetHeight + return layout(placeable.width, placeable.height) { + layoutOffsetY = with(motionDriver) { driverOffset() }.y + deltaY + + if (revealHeight == null) { + val maxHeightDriven = + motionDriver.maxHeightDriven( + spec = derivedStateOf(::spec)::value, + label = "TactileSurfaceReveal(${label.orEmpty()})", + ) + revealHeight = maxHeightDriven + delegate(DebugMotionValueNode(maxHeightDriven)) + } + + placeable.place(IntOffset.Zero) + } + } + + override fun isMeasurementApproachInProgress(lookaheadSize: IntSize): Boolean { + val revealHeight = revealHeight + return revealHeight != null && + (motionDriver.verticalState == MotionDriver.State.Transition || !revealHeight.isStable) + } + + override fun ApproachMeasureScope.approachMeasure( + measurable: Measurable, + constraints: Constraints, + ): MeasureResult { + return measurable.measure(constraints).run { + layout(width, height) { + placeWithLayer(IntOffset.Zero) { + val revealHeight = + constraints + .constrainHeight(checkNotNull(revealHeight).output.roundToInt()) + .toFloat() + + if (revealHeight != lookAheadHeight) { + approachGraphicsLayer(revealHeight) + } + } + } + } + } + + private fun GraphicsLayerScope.approachGraphicsLayer(revealHeight: Float) { + translationY = (revealHeight - lookAheadHeight) / 2f + clip = true + shape = GenericShape { placeableSize, _ -> + val rect = Rect(Offset(0f, -translationY), Size(placeableSize.width, revealHeight)) + val cornerMaxSize = revealOnThreshold.cornerMaxSize.toPx() + if (cornerMaxSize != 0f) { + val radius = (revealHeight / 2f).fastCoerceAtMost(cornerMaxSize) + addRoundRect(RoundRect(rect, CornerRadius(radius))) + } else { + addRect(rect) + } + } + val fullyVisibleMinHeight = revealOnThreshold.minSize.toPx() + if (fullyVisibleMinHeight != 0f) { + val revealAlpha = (revealHeight / fullyVisibleMinHeight).fastCoerceAtLeast(0f) + if (revealAlpha < 1f) { + alpha = revealAlpha + compositingStrategy = CompositingStrategy.ModulateAlpha + } + } + } +} diff --git a/mechanics/compose/tests/Android.bp b/mechanics/compose/tests/Android.bp new file mode 100644 index 0000000..e344132 --- /dev/null +++ b/mechanics/compose/tests/Android.bp @@ -0,0 +1,52 @@ +// Copyright (C) 2025 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 { + default_team: "trendy_team_motion", + default_applicable_licenses: ["Android-Apache-2.0"], +} + +android_test { + name: "mechanics-compose_tests", + manifest: "AndroidManifest.xml", + defaults: ["MotionTestDefaults"], + test_suites: ["device-tests"], + + srcs: [ + "src/**/*.kt", + ], + + static_libs: [ + "//frameworks/libs/systemui/mechanics:mechanics", + "//frameworks/libs/systemui/mechanics:mechanics-compose", + "//frameworks/libs/systemui/mechanics:mechanics-testing", + "PlatformComposeSceneTransitionLayoutTestsUtils", + "platform-test-annotations", + "PlatformMotionTestingCompose", + "androidx.compose.runtime_runtime", + "androidx.compose.animation_animation-core", + "androidx.compose.ui_ui-test-junit4", + "androidx.compose.ui_ui-test-manifest", + "androidx.test.runner", + "androidx.test.ext.junit", + "kotlin-test", + "testables", + "truth", + ], + associates: [ + "mechanics-compose", + ], + asset_dirs: ["goldens"], + kotlincflags: ["-Xjvm-default=all"], +} diff --git a/mechanics/compose/tests/goldens/verticalTactileSurfaceReveal_gesture_dragClose.json b/mechanics/compose/tests/goldens/verticalTactileSurfaceReveal_gesture_dragClose.json new file mode 100644 index 0000000..fa777a8 --- /dev/null +++ b/mechanics/compose/tests/goldens/verticalTactileSurfaceReveal_gesture_dragClose.json @@ -0,0 +1,1288 @@ +{ + "frame_ids": [ + "before", + 0, + 16, + 32, + 48, + 64, + 80, + 96, + 112, + 128, + 144, + 160, + 176, + 192, + 208, + 224, + 240, + 256, + 272, + 288, + 304, + 320, + 336, + 352, + 368, + 384, + 400, + 416, + 432, + 448, + 464, + 480, + 496, + 512, + 528, + 544, + 560, + 576, + 592, + 608, + 624, + 640, + 656, + 672, + 688, + 704, + 720, + 736, + 752, + 768, + 784, + 800, + 816, + 832, + 848, + 864, + 880, + 896, + 912, + "after" + ], + "features": [ + { + "name": "isTransitioning", + "type": "int", + "data_points": [ + 0, + 0, + 0, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 0 + ] + }, + { + "name": "ContainerElement_height", + "type": "dp", + "data_points": [ + 300, + 300, + 300, + 300, + 292.8, + 292.8, + 286, + 279.6, + 273.2, + 266.8, + 260.4, + 254, + 247.6, + 241.2, + 241.2, + 234.4, + 228, + 221.6, + 215.2, + 208.8, + 202.4, + 196, + 189.6, + 189.6, + 182.8, + 176.4, + 170, + 163.6, + 157.2, + 150.8, + 144.4, + 138, + 138, + 131.2, + 124.8, + 118.4, + 112, + 112, + 103.2, + 91.2, + 78.8, + 66.4, + 54.8, + 44.8, + 35.6, + 28, + 22, + 16.8, + 12.8, + 9.2, + 6.8, + 4.8, + 3.2, + 2.4, + 1.6, + 0.8, + 0.4, + 0.4, + 0, + { + "type": "not_found" + } + ] + }, + { + "name": "box0_y", + "type": "dp", + "data_points": [ + 4, + 4, + 4, + 4, + 4, + 4, + 4, + 4, + 4, + 4, + 4, + 4, + 4, + 4, + 4, + 4, + 4, + 4, + 4, + 4, + 4, + 4, + 4, + 4, + 4, + 4, + 4, + 4, + 4, + 4, + 4, + 4, + 4, + 4, + 4, + 4, + 4, + 4, + 4, + 4, + 4, + 4, + 4, + 4, + 4, + 4, + 4, + 4, + 4, + 4, + 4, + 4, + 4, + 4, + 4, + 4, + 4, + 4, + 4, + { + "type": "not_found" + } + ] + }, + { + "name": "box0_height", + "type": "dp", + "data_points": [ + 50, + 50, + 50, + 50, + 50, + 50, + 50, + 50, + 50, + 50, + 50, + 50, + 50, + 50, + 50, + 50, + 50, + 50, + 50, + 50, + 50, + 50, + 50, + 50, + 50, + 50, + 50, + 50, + 50, + 50, + 50, + 50, + 50, + 50, + 50, + 50, + 50, + 50, + 50, + 50, + 50, + 50, + 50, + 40.8, + 31.6, + 24, + 18, + 12.8, + 8.8, + 5.2, + 3.2, + 1.6, + 0.8, + 0, + 0, + 0, + 0, + 0, + 0, + { + "type": "not_found" + } + ] + }, + { + "name": "box1_y", + "type": "dp", + "data_points": [ + 62, + 62, + 62, + 62, + 62, + 62, + 62, + 62, + 62, + 62, + 62, + 62, + 62, + 62, + 62, + 62, + 62, + 62, + 62, + 62, + 62, + 62, + 62, + 62, + 62, + 62, + 62, + 62, + 62, + 62, + 62, + 62, + 62, + 62, + 62, + 62, + 62, + 62, + 62, + 62, + 62, + 62, + 62, + 52.8, + 43.6, + 36, + 30, + 24.8, + 20.8, + 17.2, + 15.2, + 13.6, + 12.8, + 12, + 12, + 12, + 12, + 12, + 12, + { + "type": "not_found" + } + ] + }, + { + "name": "box1_height", + "type": "dp", + "data_points": [ + 50, + 50, + 50, + 50, + 50, + 50, + 50, + 50, + 50, + 50, + 50, + 50, + 50, + 50, + 50, + 50, + 50, + 50, + 50, + 50, + 50, + 50, + 50, + 50, + 50, + 50, + 50, + 50, + 50, + 50, + 50, + 50, + 50, + 50, + 50, + 50, + 50, + 50, + 41.2, + 29.2, + 16.8, + 4.8, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + { + "type": "not_found" + } + ] + }, + { + "name": "box2_y", + "type": "dp", + "data_points": [ + 120, + 120, + 120, + 120, + 120, + 120, + 120, + 120, + 120, + 120, + 120, + 120, + 120, + 120, + 120, + 120, + 120, + 120, + 120, + 120, + 120, + 120, + 120, + 120, + 120, + 120, + 120, + 120, + 120, + 120, + 120, + 120, + 120, + 120, + 120, + 120, + 120, + 120, + 111.2, + 99.2, + 86.8, + 74.8, + 70, + 60.8, + 51.6, + 44, + 38, + 32.8, + 28.8, + 25.2, + 23.2, + 21.6, + 20.8, + 20, + 20, + 20, + 20, + 20, + 20, + { + "type": "not_found" + } + ] + }, + { + "name": "box2_height", + "type": "dp", + "data_points": [ + 50, + 50, + 50, + 50, + 50, + 50, + 50, + 50, + 50, + 50, + 50, + 50, + 50, + 50, + 50, + 50, + 50, + 50, + 50, + 50, + 50, + 50, + 50, + 50, + 50, + 50, + 50, + 43.6, + 37.2, + 30.8, + 24.4, + 18, + 18, + 11.2, + 8, + 5.6, + 6.8, + 5.6, + 4.4, + 3.2, + 2.4, + 1.6, + 1.2, + 0.8, + 0.4, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + { + "type": "not_found" + } + ] + }, + { + "name": "box3_y", + "type": "dp", + "data_points": [ + 178, + 178, + 178, + 178, + 178, + 178, + 178, + 178, + 178, + 178, + 178, + 178, + 178, + 178, + 178, + 178, + 178, + 178, + 178, + 178, + 178, + 178, + 178, + 178, + 178, + 178, + 178, + 171.6, + 165.2, + 158.8, + 152.4, + 146, + 146, + 139.2, + 136, + 133.6, + 134.8, + 133.6, + 123.6, + 110.4, + 97.2, + 84.4, + 79.2, + 69.6, + 60, + 52, + 46, + 40.8, + 36.8, + 33.2, + 31.2, + 29.6, + 28.8, + 28, + 28, + 28, + 28, + 28, + 28, + { + "type": "not_found" + } + ] + }, + { + "name": "box3_height", + "type": "dp", + "data_points": [ + 50, + 50, + 50, + 50, + 50, + 50, + 50, + 50, + 50, + 50, + 50, + 50, + 50, + 50, + 50, + 50, + 50, + 43.6, + 37.2, + 30.8, + 24.4, + 18, + 11.6, + 11.6, + 8, + 7.6, + 6.8, + 5.6, + 4.4, + 3.2, + 2.4, + 1.6, + 1.2, + 0.8, + 0.4, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + { + "type": "not_found" + } + ] + }, + { + "name": "box4_y", + "type": "dp", + "data_points": [ + 236, + 236, + 236, + 236, + 236, + 236, + 236, + 236, + 236, + 236, + 236, + 236, + 236, + 236, + 236, + 236, + 236, + 229.6, + 223.2, + 216.8, + 210.4, + 204, + 197.6, + 197.6, + 194, + 193.6, + 192.8, + 185.2, + 177.6, + 170, + 162.8, + 155.6, + 155.2, + 148, + 144.4, + 141.6, + 142.8, + 141.6, + 131.6, + 118.4, + 105.2, + 92.4, + 87.2, + 77.6, + 68, + 60, + 54, + 48.8, + 44.8, + 41.2, + 39.2, + 37.6, + 36.8, + 36, + 36, + 36, + 36, + 36, + 36, + { + "type": "not_found" + } + ] + }, + { + "name": "box4_height", + "type": "dp", + "data_points": [ + 50, + 50, + 50, + 50, + 50, + 50, + 50, + 43.6, + 37.2, + 30.8, + 24.4, + 18, + 11.6, + 6.8, + 5.6, + 1.6, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + { + "type": "not_found" + } + ] + }, + { + "name": "box5_y", + "type": "dp", + "data_points": [ + 294, + 294, + 294, + 294, + 294, + 294, + 294, + 287.6, + 281.2, + 274.8, + 268.4, + 262, + 255.6, + 250.8, + 249.6, + 245.6, + 244, + 237.6, + 231.2, + 224.8, + 218.4, + 212, + 205.6, + 205.6, + 202, + 201.6, + 200.8, + 193.2, + 185.6, + 178, + 170.8, + 163.6, + 163.2, + 156, + 152.4, + 149.6, + 150.8, + 149.6, + 139.6, + 126.4, + 113.2, + 100.4, + 95.2, + 85.6, + 76, + 68, + 62, + 56.8, + 52.8, + 49.2, + 47.2, + 45.6, + 44.8, + 44, + 44, + 44, + 44, + 44, + 44, + { + "type": "not_found" + } + ] + }, + { + "name": "box5_height", + "type": "dp", + "data_points": [ + 50, + 50, + 50, + 46.4, + 23.2, + 16.8, + 11.6, + 8, + 5.2, + 3.2, + 2, + 1.2, + 0.4, + 0.4, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + { + "type": "not_found" + } + ] + }, + { + "name": "box6_y", + "type": "dp", + "data_points": [ + 352, + 352, + 352, + 348.4, + 325.2, + 318.8, + 313.6, + 303.6, + 294.4, + 286, + 278.4, + 271.2, + 264, + 259.2, + 257.6, + 253.6, + 252, + 245.6, + 239.2, + 232.8, + 226.4, + 220, + 213.6, + 213.6, + 210, + 209.6, + 208.8, + 201.2, + 193.6, + 186, + 178.8, + 171.6, + 171.2, + 164, + 160.4, + 157.6, + 158.8, + 157.6, + 147.6, + 134.4, + 121.2, + 108.4, + 103.2, + 93.6, + 84, + 76, + 70, + 64.8, + 60.8, + 57.2, + 55.2, + 53.6, + 52.8, + 52, + 52, + 52, + 52, + 52, + 52, + { + "type": "not_found" + } + ] + }, + { + "name": "box6_height", + "type": "dp", + "data_points": [ + 50, + 50, + 50, + 46.4, + 23.2, + 16.8, + 11.6, + 8, + 5.2, + 3.2, + 2, + 1.2, + 0.4, + 0.4, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + { + "type": "not_found" + } + ] + }, + { + "name": "box7_y", + "type": "dp", + "data_points": [ + 410, + 410, + 410, + 402.8, + 356.4, + 343.6, + 333.2, + 319.6, + 307.6, + 297.2, + 288.4, + 280.4, + 272.4, + 267.6, + 265.6, + 261.6, + 260, + 253.6, + 247.2, + 240.8, + 234.4, + 228, + 221.6, + 221.6, + 218, + 217.6, + 216.8, + 209.2, + 201.6, + 194, + 186.8, + 179.6, + 179.2, + 172, + 168.4, + 165.6, + 166.8, + 165.6, + 155.6, + 142.4, + 129.2, + 116.4, + 111.2, + 101.6, + 92, + 84, + 78, + 72.8, + 68.8, + 65.2, + 63.2, + 61.6, + 60.8, + 60, + 60, + 60, + 60, + 60, + 60, + { + "type": "not_found" + } + ] + }, + { + "name": "box7_height", + "type": "dp", + "data_points": [ + 50, + 50, + 50, + 46.4, + 23.2, + 16.8, + 11.6, + 8, + 5.2, + 3.2, + 2, + 1.2, + 0.4, + 0.4, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + { + "type": "not_found" + } + ] + } + ] +} \ No newline at end of file diff --git a/mechanics/compose/tests/goldens/verticalTactileSurfaceReveal_gesture_dragOpen.json b/mechanics/compose/tests/goldens/verticalTactileSurfaceReveal_gesture_dragOpen.json new file mode 100644 index 0000000..70c8b86 --- /dev/null +++ b/mechanics/compose/tests/goldens/verticalTactileSurfaceReveal_gesture_dragOpen.json @@ -0,0 +1,1390 @@ +{ + "frame_ids": [ + "before", + 0, + 16, + 32, + 48, + 64, + 80, + 96, + 112, + 128, + 144, + 160, + 176, + 192, + 208, + 224, + 240, + 256, + 272, + 288, + 304, + 320, + 336, + 352, + 368, + 384, + 400, + 416, + 432, + 448, + 464, + 480, + 496, + 512, + 528, + 544, + 560, + 576, + 592, + 608, + 624, + 640, + 656, + 672, + 688, + 704, + 720, + 736, + 752, + 768, + 784, + 800, + 816, + 832, + 848, + 864, + 880, + 896, + 912, + "after" + ], + "features": [ + { + "name": "isTransitioning", + "type": "int", + "data_points": [ + 0, + 0, + 0, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 0 + ] + }, + { + "name": "ContainerElement_height", + "type": "dp", + "data_points": [ + { + "type": "not_found" + }, + { + "type": "not_found" + }, + { + "type": "not_found" + }, + { + "type": "not_found" + }, + 7.2, + 7.2, + 14, + 20.4, + 26.8, + 33.2, + 39.6, + 46, + 52.4, + 58.8, + 58.8, + 65.6, + 72, + 78.4, + 84.8, + 91.2, + 97.6, + 104, + 110.4, + 110.4, + 117.2, + 123.6, + 130, + 136.4, + 142.8, + 149.2, + 155.6, + 162, + 162, + 168.8, + 175.2, + 181.6, + 188, + 188, + 196.8, + 208.8, + 221.2, + 233.6, + 245.2, + 255.2, + 264.4, + 272, + 278, + 283.2, + 287.2, + 290.8, + 293.2, + 295.2, + 296.8, + 297.6, + 298.4, + 299.2, + 299.6, + 299.6, + 300, + 300 + ] + }, + { + "name": "box0_y", + "type": "dp", + "data_points": [ + { + "type": "not_found" + }, + { + "type": "not_found" + }, + { + "type": "not_found" + }, + { + "type": "not_found" + }, + 4, + 4, + 4, + 4, + 4, + 4, + 4, + 4, + 4, + 4, + 4, + 4, + 4, + 4, + 4, + 4, + 4, + 4, + 4, + 4, + 4, + 4, + 4, + 4, + 4, + 4, + 4, + 4, + 4, + 4, + 4, + 4, + 4, + 4, + 4, + 4, + 4, + 4, + 4, + 4, + 4, + 4, + 4, + 4, + 4, + 4, + 4, + 4, + 4, + 4, + 4, + 4, + 4, + 4, + 4, + 4 + ] + }, + { + "name": "box0_height", + "type": "dp", + "data_points": [ + { + "type": "not_found" + }, + { + "type": "not_found" + }, + { + "type": "not_found" + }, + { + "type": "not_found" + }, + 0, + 0, + 2, + 8.8, + 15.6, + 23.2, + 31.2, + 38.8, + 46, + 48.4, + 48.8, + 49.2, + 49.6, + 49.6, + 50, + 50, + 50, + 50, + 50, + 50, + 50, + 50, + 50, + 50, + 50, + 50, + 50, + 50, + 50, + 50, + 50, + 50, + 50, + 50, + 50, + 50, + 50, + 50, + 50, + 50, + 50, + 50, + 50, + 50, + 50, + 50, + 50, + 50, + 50, + 50, + 50, + 50, + 50, + 50, + 50, + 50 + ] + }, + { + "name": "box1_y", + "type": "dp", + "data_points": [ + { + "type": "not_found" + }, + { + "type": "not_found" + }, + { + "type": "not_found" + }, + { + "type": "not_found" + }, + 12, + 12, + 14, + 20.8, + 27.6, + 35.2, + 43.2, + 50.8, + 58, + 60.4, + 60.8, + 61.2, + 61.6, + 61.6, + 62, + 62, + 62, + 62, + 62, + 62, + 62, + 62, + 62, + 62, + 62, + 62, + 62, + 62, + 62, + 62, + 62, + 62, + 62, + 62, + 62, + 62, + 62, + 62, + 62, + 62, + 62, + 62, + 62, + 62, + 62, + 62, + 62, + 62, + 62, + 62, + 62, + 62, + 62, + 62, + 62, + 62 + ] + }, + { + "name": "box1_height", + "type": "dp", + "data_points": [ + { + "type": "not_found" + }, + { + "type": "not_found" + }, + { + "type": "not_found" + }, + { + "type": "not_found" + }, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 2, + 9.6, + 15.6, + 23.2, + 31.2, + 38.8, + 46, + 46.8, + 48.8, + 49.2, + 49.6, + 49.6, + 50, + 50, + 50, + 50, + 50, + 50, + 50, + 50, + 50, + 50, + 50, + 50, + 50, + 50, + 50, + 50, + 50, + 50, + 50, + 50, + 50, + 50, + 50, + 50, + 50, + 50, + 50, + 50, + 50, + 50, + 50, + 50 + ] + }, + { + "name": "box2_y", + "type": "dp", + "data_points": [ + { + "type": "not_found" + }, + { + "type": "not_found" + }, + { + "type": "not_found" + }, + { + "type": "not_found" + }, + 20, + 20, + 22, + 28.8, + 35.6, + 43.2, + 51.2, + 58.8, + 66, + 68.4, + 68.8, + 69.2, + 71.6, + 79.2, + 85.6, + 93.2, + 101.2, + 108.8, + 116, + 116.8, + 118.8, + 119.2, + 119.6, + 119.6, + 120, + 120, + 120, + 120, + 120, + 120, + 120, + 120, + 120, + 120, + 120, + 120, + 120, + 120, + 120, + 120, + 120, + 120, + 120, + 120, + 120, + 120, + 120, + 120, + 120, + 120, + 120, + 120, + 120, + 120, + 120, + 120 + ] + }, + { + "name": "box2_height", + "type": "dp", + "data_points": [ + { + "type": "not_found" + }, + { + "type": "not_found" + }, + { + "type": "not_found" + }, + { + "type": "not_found" + }, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 2, + 9.6, + 15.6, + 23.2, + 31.2, + 38.8, + 39.6, + 47.2, + 48.8, + 49.2, + 49.6, + 49.6, + 50, + 50, + 50, + 50, + 50, + 50, + 50, + 50, + 50, + 50, + 50, + 50, + 50, + 50, + 50, + 50, + 50, + 50, + 50, + 50, + 50, + 50 + ] + }, + { + "name": "box3_y", + "type": "dp", + "data_points": [ + { + "type": "not_found" + }, + { + "type": "not_found" + }, + { + "type": "not_found" + }, + { + "type": "not_found" + }, + 28, + 28, + 30, + 36.8, + 43.6, + 51.2, + 59.2, + 66.8, + 74, + 76.4, + 76.8, + 77.2, + 79.6, + 87.2, + 93.6, + 101.2, + 109.2, + 116.8, + 124, + 124.8, + 126.8, + 127.2, + 129.6, + 137.2, + 143.6, + 151.2, + 159.2, + 166.8, + 167.6, + 175.2, + 176.8, + 177.2, + 177.6, + 177.6, + 178, + 178, + 178, + 178, + 178, + 178, + 178, + 178, + 178, + 178, + 178, + 178, + 178, + 178, + 178, + 178, + 178, + 178, + 178, + 178, + 178, + 178 + ] + }, + { + "name": "box3_height", + "type": "dp", + "data_points": [ + { + "type": "not_found" + }, + { + "type": "not_found" + }, + { + "type": "not_found" + }, + { + "type": "not_found" + }, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 2, + 2, + 11.6, + 24.8, + 38.8, + 46.8, + 47.6, + 48.4, + 48.8, + 49.2, + 49.6, + 49.6, + 50, + 50, + 50, + 50, + 50, + 50, + 50, + 50, + 50, + 50, + 50, + 50 + ] + }, + { + "name": "box4_y", + "type": "dp", + "data_points": [ + { + "type": "not_found" + }, + { + "type": "not_found" + }, + { + "type": "not_found" + }, + { + "type": "not_found" + }, + 36, + 36, + 38, + 44.8, + 51.6, + 59.2, + 67.2, + 74.8, + 82, + 84.4, + 84.8, + 85.2, + 87.6, + 95.2, + 101.6, + 109.2, + 117.2, + 124.8, + 132, + 132.8, + 134.8, + 135.2, + 137.6, + 145.2, + 151.6, + 159.2, + 167.2, + 174.8, + 175.6, + 183.2, + 184.8, + 185.2, + 187.6, + 187.6, + 197.6, + 210.8, + 224.8, + 232.8, + 233.6, + 234.4, + 234.8, + 235.2, + 235.6, + 235.6, + 236, + 236, + 236, + 236, + 236, + 236, + 236, + 236, + 236, + 236, + 236, + 236 + ] + }, + { + "name": "box4_height", + "type": "dp", + "data_points": [ + { + "type": "not_found" + }, + { + "type": "not_found" + }, + { + "type": "not_found" + }, + { + "type": "not_found" + }, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 1.2, + 12, + 22.4, + 31.2, + 38.4, + 44.8, + 48.4, + 48.8, + 49.2, + 49.6, + 49.6, + 50, + 50, + 50, + 50, + 50, + 50, + 50 + ] + }, + { + "name": "box5_y", + "type": "dp", + "data_points": [ + { + "type": "not_found" + }, + { + "type": "not_found" + }, + { + "type": "not_found" + }, + { + "type": "not_found" + }, + 44, + 44, + 46, + 52.8, + 59.6, + 67.2, + 75.2, + 82.8, + 90, + 92.4, + 92.8, + 93.2, + 95.6, + 103.2, + 109.6, + 117.2, + 125.2, + 132.8, + 140, + 140.8, + 142.8, + 143.2, + 145.6, + 153.2, + 159.6, + 167.2, + 175.2, + 182.8, + 183.6, + 191.2, + 192.8, + 193.2, + 195.6, + 195.6, + 205.6, + 218.8, + 232.8, + 240.8, + 242.8, + 254.4, + 265.2, + 274.4, + 282, + 288.4, + 292.4, + 292.8, + 293.2, + 293.6, + 293.6, + 294, + 294, + 294, + 294, + 294, + 294, + 294 + ] + }, + { + "name": "box5_height", + "type": "dp", + "data_points": [ + { + "type": "not_found" + }, + { + "type": "not_found" + }, + { + "type": "not_found" + }, + { + "type": "not_found" + }, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 50 + ] + }, + { + "name": "box6_y", + "type": "dp", + "data_points": [ + { + "type": "not_found" + }, + { + "type": "not_found" + }, + { + "type": "not_found" + }, + { + "type": "not_found" + }, + 52, + 52, + 54, + 60.8, + 67.6, + 75.2, + 83.2, + 90.8, + 98, + 100.4, + 100.8, + 101.2, + 103.6, + 111.2, + 117.6, + 125.2, + 133.2, + 140.8, + 148, + 148.8, + 150.8, + 151.2, + 153.6, + 161.2, + 167.6, + 175.2, + 183.2, + 190.8, + 191.6, + 199.2, + 200.8, + 201.2, + 203.6, + 203.6, + 213.6, + 226.8, + 240.8, + 248.8, + 250.8, + 262.4, + 273.2, + 282.4, + 290, + 296.4, + 300.4, + 300.8, + 301.2, + 301.6, + 301.6, + 302, + 302, + 302, + 302, + 302, + 302, + 352 + ] + }, + { + "name": "box6_height", + "type": "dp", + "data_points": [ + { + "type": "not_found" + }, + { + "type": "not_found" + }, + { + "type": "not_found" + }, + { + "type": "not_found" + }, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 50 + ] + }, + { + "name": "box7_y", + "type": "dp", + "data_points": [ + { + "type": "not_found" + }, + { + "type": "not_found" + }, + { + "type": "not_found" + }, + { + "type": "not_found" + }, + 60, + 60, + 62, + 68.8, + 75.6, + 83.2, + 91.2, + 98.8, + 106, + 108.4, + 108.8, + 109.2, + 111.6, + 119.2, + 125.6, + 133.2, + 141.2, + 148.8, + 156, + 156.8, + 158.8, + 159.2, + 161.6, + 169.2, + 175.6, + 183.2, + 191.2, + 198.8, + 199.6, + 207.2, + 208.8, + 209.2, + 211.6, + 211.6, + 221.6, + 234.8, + 248.8, + 256.8, + 258.8, + 270.4, + 281.2, + 290.4, + 298, + 304.4, + 308.4, + 308.8, + 309.2, + 309.6, + 309.6, + 310, + 310, + 310, + 310, + 310, + 310, + 410 + ] + }, + { + "name": "box7_height", + "type": "dp", + "data_points": [ + { + "type": "not_found" + }, + { + "type": "not_found" + }, + { + "type": "not_found" + }, + { + "type": "not_found" + }, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 50 + ] + } + ] +} \ No newline at end of file diff --git a/mechanics/compose/tests/goldens/verticalTactileSurfaceReveal_gesture_flingClose.json b/mechanics/compose/tests/goldens/verticalTactileSurfaceReveal_gesture_flingClose.json new file mode 100644 index 0000000..9273375 --- /dev/null +++ b/mechanics/compose/tests/goldens/verticalTactileSurfaceReveal_gesture_flingClose.json @@ -0,0 +1,870 @@ +{ + "frame_ids": [ + "before", + 0, + 16, + 32, + 48, + 64, + 80, + 96, + 112, + 128, + 144, + 160, + 176, + 192, + 208, + 224, + 240, + 256, + 272, + 288, + 304, + 320, + 336, + 352, + 368, + 384, + 400, + 416, + 432, + 448, + 464, + 480, + 496, + 512, + 528, + 544, + 560, + "after" + ], + "features": [ + { + "name": "isTransitioning", + "type": "int", + "data_points": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 0 + ] + }, + { + "name": "ContainerElement_height", + "type": "dp", + "data_points": [ + 300, + 300, + 300, + 300, + 300, + 300, + 300, + 300, + 300, + 300, + 300, + 289.2, + 278.8, + 266, + 252, + 252, + 232, + 205.6, + 177.6, + 149.6, + 124, + 100.8, + 80.4, + 63.6, + 49.2, + 37.6, + 28.4, + 21.2, + 15.2, + 10.8, + 7.6, + 5.2, + 3.2, + 2, + 1.2, + 0.4, + 0, + { + "type": "not_found" + } + ] + }, + { + "name": "box0_y", + "type": "dp", + "data_points": [ + 4, + 4, + 4, + 4, + 4, + 4, + 4, + 4, + 4, + 4, + 4, + 4, + 4, + 4, + 4, + 4, + 4, + 4, + 4, + 4, + 4, + 4, + 4, + 4, + 4, + 4, + 4, + 4, + 4, + 4, + 4, + 4, + 4, + 4, + 4, + 4, + 4, + { + "type": "not_found" + } + ] + }, + { + "name": "box0_height", + "type": "dp", + "data_points": [ + 50, + 50, + 50, + 50, + 50, + 50, + 50, + 50, + 50, + 50, + 50, + 50, + 50, + 50, + 50, + 50, + 50, + 50, + 50, + 50, + 50, + 50, + 50, + 50, + 45.2, + 33.6, + 24.4, + 17.2, + 11.2, + 6.4, + 2.4, + 0.4, + 0, + 0, + 0, + 0, + 0, + { + "type": "not_found" + } + ] + }, + { + "name": "box1_y", + "type": "dp", + "data_points": [ + 62, + 62, + 62, + 62, + 62, + 62, + 62, + 62, + 62, + 62, + 62, + 62, + 62, + 62, + 62, + 62, + 62, + 62, + 62, + 62, + 62, + 62, + 62, + 62, + 57.2, + 45.6, + 36.4, + 29.2, + 23.2, + 18.4, + 14.4, + 12.4, + 12, + 12, + 12, + 12, + 12, + { + "type": "not_found" + } + ] + }, + { + "name": "box1_height", + "type": "dp", + "data_points": [ + 50, + 50, + 50, + 50, + 50, + 50, + 50, + 50, + 50, + 50, + 50, + 50, + 50, + 50, + 50, + 50, + 50, + 50, + 50, + 50, + 50, + 38.8, + 18.4, + 4.8, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + { + "type": "not_found" + } + ] + }, + { + "name": "box2_y", + "type": "dp", + "data_points": [ + 120, + 120, + 120, + 120, + 120, + 120, + 120, + 120, + 120, + 120, + 120, + 120, + 120, + 120, + 120, + 120, + 120, + 120, + 120, + 120, + 120, + 108.8, + 88.4, + 74.8, + 65.2, + 53.6, + 44.4, + 37.2, + 31.2, + 26.4, + 22.4, + 20.4, + 20, + 20, + 20, + 20, + 20, + { + "type": "not_found" + } + ] + }, + { + "name": "box2_height", + "type": "dp", + "data_points": [ + 50, + 50, + 50, + 50, + 50, + 50, + 50, + 50, + 50, + 50, + 50, + 50, + 50, + 50, + 50, + 50, + 50, + 50, + 50, + 29.6, + 4.8, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + { + "type": "not_found" + } + ] + }, + { + "name": "box3_y", + "type": "dp", + "data_points": [ + 178, + 178, + 178, + 178, + 178, + 178, + 178, + 178, + 178, + 178, + 178, + 178, + 178, + 178, + 178, + 178, + 178, + 178, + 178, + 157.6, + 132.8, + 116.8, + 96.4, + 82.8, + 73.2, + 61.6, + 52.4, + 45.2, + 39.2, + 34.4, + 30.4, + 28.4, + 28, + 28, + 28, + 28, + 28, + { + "type": "not_found" + } + ] + }, + { + "name": "box3_height", + "type": "dp", + "data_points": [ + 50, + 50, + 50, + 50, + 50, + 50, + 50, + 50, + 50, + 50, + 50, + 50, + 50, + 50, + 50, + 50, + 50, + 27.6, + 2, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + { + "type": "not_found" + } + ] + }, + { + "name": "box4_y", + "type": "dp", + "data_points": [ + 236, + 236, + 236, + 236, + 236, + 236, + 236, + 236, + 236, + 236, + 236, + 236, + 236, + 236, + 236, + 236, + 236, + 213.6, + 188, + 165.6, + 140.8, + 124.8, + 104.4, + 90.8, + 81.2, + 69.6, + 60.4, + 53.2, + 47.2, + 42.4, + 38.4, + 36.4, + 36, + 36, + 36, + 36, + 36, + { + "type": "not_found" + } + ] + }, + { + "name": "box4_height", + "type": "dp", + "data_points": [ + 50, + 50, + 50, + 50, + 50, + 50, + 50, + 50, + 50, + 50, + 50, + 50, + 42.8, + 30, + 16, + 16, + 1.2, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + { + "type": "not_found" + } + ] + }, + { + "name": "box5_y", + "type": "dp", + "data_points": [ + 294, + 294, + 294, + 294, + 294, + 294, + 294, + 294, + 294, + 294, + 294, + 294, + 286.8, + 274, + 260, + 260, + 245.2, + 221.6, + 196, + 173.6, + 148.8, + 132.8, + 112.4, + 98.8, + 89.2, + 77.6, + 68.4, + 61.2, + 55.2, + 50.4, + 46.4, + 44.4, + 44, + 44, + 44, + 44, + 44, + { + "type": "not_found" + } + ] + }, + { + "name": "box5_height", + "type": "dp", + "data_points": [ + 50, + 50, + 50, + 50, + 50, + 50, + 50, + 50, + 50, + 50, + 46.4, + 1.2, + 0.4, + 0.4, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + { + "type": "not_found" + } + ] + }, + { + "name": "box6_y", + "type": "dp", + "data_points": [ + 352, + 352, + 352, + 352, + 352, + 352, + 352, + 352, + 352, + 352, + 348.4, + 303.2, + 295.2, + 282.4, + 268, + 268, + 253.2, + 229.6, + 204, + 181.6, + 156.8, + 140.8, + 120.4, + 106.8, + 97.2, + 85.6, + 76.4, + 69.2, + 63.2, + 58.4, + 54.4, + 52.4, + 52, + 52, + 52, + 52, + 52, + { + "type": "not_found" + } + ] + }, + { + "name": "box6_height", + "type": "dp", + "data_points": [ + 50, + 50, + 50, + 50, + 50, + 50, + 50, + 50, + 50, + 50, + 46.4, + 1.2, + 0.4, + 0.4, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + { + "type": "not_found" + } + ] + }, + { + "name": "box7_y", + "type": "dp", + "data_points": [ + 410, + 410, + 410, + 410, + 410, + 410, + 410, + 410, + 410, + 410, + 402.8, + 312.4, + 303.6, + 290.8, + 276, + 276, + 261.2, + 237.6, + 212, + 189.6, + 164.8, + 148.8, + 128.4, + 114.8, + 105.2, + 93.6, + 84.4, + 77.2, + 71.2, + 66.4, + 62.4, + 60.4, + 60, + 60, + 60, + 60, + 60, + { + "type": "not_found" + } + ] + }, + { + "name": "box7_height", + "type": "dp", + "data_points": [ + 50, + 50, + 50, + 50, + 50, + 50, + 50, + 50, + 50, + 50, + 46.4, + 1.2, + 0.4, + 0.4, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + { + "type": "not_found" + } + ] + } + ] +} \ No newline at end of file diff --git a/mechanics/compose/tests/goldens/verticalTactileSurfaceReveal_gesture_flingOpen.json b/mechanics/compose/tests/goldens/verticalTactileSurfaceReveal_gesture_flingOpen.json new file mode 100644 index 0000000..7e2b2c7 --- /dev/null +++ b/mechanics/compose/tests/goldens/verticalTactileSurfaceReveal_gesture_flingOpen.json @@ -0,0 +1,1142 @@ +{ + "frame_ids": [ + "before", + 0, + 16, + 32, + 48, + 64, + 80, + 96, + 112, + 128, + 144, + 160, + 176, + 192, + 208, + 224, + 240, + 256, + 272, + 288, + 304, + 320, + 336, + 352, + 368, + 384, + 400, + 416, + 432, + 448, + 464, + 480, + 496, + 512, + 528, + 544, + 560, + "after" + ], + "features": [ + { + "name": "isTransitioning", + "type": "int", + "data_points": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 0 + ] + }, + { + "name": "ContainerElement_height", + "type": "dp", + "data_points": [ + { + "type": "not_found" + }, + { + "type": "not_found" + }, + { + "type": "not_found" + }, + { + "type": "not_found" + }, + { + "type": "not_found" + }, + { + "type": "not_found" + }, + { + "type": "not_found" + }, + { + "type": "not_found" + }, + { + "type": "not_found" + }, + 9.6, + 18, + 28.8, + 40, + 53.6, + 68, + 68, + 87.6, + 112.4, + 138.4, + 164, + 188, + 209.2, + 227.2, + 242.8, + 255.6, + 266, + 274.4, + 281.2, + 286.4, + 290.4, + 293.2, + 295.6, + 297.2, + 298.4, + 299.2, + 299.6, + 300, + 300 + ] + }, + { + "name": "box0_y", + "type": "dp", + "data_points": [ + { + "type": "not_found" + }, + { + "type": "not_found" + }, + { + "type": "not_found" + }, + { + "type": "not_found" + }, + { + "type": "not_found" + }, + { + "type": "not_found" + }, + { + "type": "not_found" + }, + { + "type": "not_found" + }, + { + "type": "not_found" + }, + 4, + 4, + 4, + 4, + 4, + 4, + 4, + 4, + 4, + 4, + 4, + 4, + 4, + 4, + 4, + 4, + 4, + 4, + 4, + 4, + 4, + 4, + 4, + 4, + 4, + 4, + 4, + 4, + 4 + ] + }, + { + "name": "box0_height", + "type": "dp", + "data_points": [ + { + "type": "not_found" + }, + { + "type": "not_found" + }, + { + "type": "not_found" + }, + { + "type": "not_found" + }, + { + "type": "not_found" + }, + { + "type": "not_found" + }, + { + "type": "not_found" + }, + { + "type": "not_found" + }, + { + "type": "not_found" + }, + 0, + 10.4, + 21.2, + 29.2, + 44.4, + 46, + 47.2, + 48, + 48.4, + 49.2, + 49.6, + 49.6, + 50, + 50, + 50, + 50, + 50, + 50, + 50, + 50, + 50, + 50, + 50, + 50, + 50, + 50, + 50, + 50, + 50 + ] + }, + { + "name": "box1_y", + "type": "dp", + "data_points": [ + { + "type": "not_found" + }, + { + "type": "not_found" + }, + { + "type": "not_found" + }, + { + "type": "not_found" + }, + { + "type": "not_found" + }, + { + "type": "not_found" + }, + { + "type": "not_found" + }, + { + "type": "not_found" + }, + { + "type": "not_found" + }, + 12, + 22.4, + 33.2, + 41.2, + 56.4, + 58, + 59.2, + 60, + 60.4, + 61.2, + 61.6, + 61.6, + 62, + 62, + 62, + 62, + 62, + 62, + 62, + 62, + 62, + 62, + 62, + 62, + 62, + 62, + 62, + 62, + 62 + ] + }, + { + "name": "box1_height", + "type": "dp", + "data_points": [ + { + "type": "not_found" + }, + { + "type": "not_found" + }, + { + "type": "not_found" + }, + { + "type": "not_found" + }, + { + "type": "not_found" + }, + { + "type": "not_found" + }, + { + "type": "not_found" + }, + { + "type": "not_found" + }, + { + "type": "not_found" + }, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 18, + 43.6, + 44.8, + 46, + 47.2, + 48, + 48.8, + 49.2, + 49.6, + 49.6, + 50, + 50, + 50, + 50, + 50, + 50, + 50, + 50, + 50, + 50, + 50, + 50 + ] + }, + { + "name": "box2_y", + "type": "dp", + "data_points": [ + { + "type": "not_found" + }, + { + "type": "not_found" + }, + { + "type": "not_found" + }, + { + "type": "not_found" + }, + { + "type": "not_found" + }, + { + "type": "not_found" + }, + { + "type": "not_found" + }, + { + "type": "not_found" + }, + { + "type": "not_found" + }, + 20, + 30.4, + 41.2, + 49.2, + 64.4, + 66, + 67.2, + 86, + 112, + 114, + 115.6, + 116.8, + 118, + 118.8, + 119.2, + 119.6, + 119.6, + 120, + 120, + 120, + 120, + 120, + 120, + 120, + 120, + 120, + 120, + 120, + 120 + ] + }, + { + "name": "box2_height", + "type": "dp", + "data_points": [ + { + "type": "not_found" + }, + { + "type": "not_found" + }, + { + "type": "not_found" + }, + { + "type": "not_found" + }, + { + "type": "not_found" + }, + { + "type": "not_found" + }, + { + "type": "not_found" + }, + { + "type": "not_found" + }, + { + "type": "not_found" + }, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 10.4, + 36.8, + 44.4, + 45.6, + 46.8, + 47.6, + 48.4, + 48.8, + 49.2, + 49.6, + 49.6, + 50, + 50, + 50, + 50, + 50, + 50, + 50, + 50, + 50 + ] + }, + { + "name": "box3_y", + "type": "dp", + "data_points": [ + { + "type": "not_found" + }, + { + "type": "not_found" + }, + { + "type": "not_found" + }, + { + "type": "not_found" + }, + { + "type": "not_found" + }, + { + "type": "not_found" + }, + { + "type": "not_found" + }, + { + "type": "not_found" + }, + { + "type": "not_found" + }, + 28, + 38.4, + 49.2, + 57.2, + 72.4, + 74, + 75.2, + 94, + 120, + 132.4, + 160.4, + 169.2, + 171.6, + 173.6, + 174.8, + 176, + 176.4, + 177.2, + 177.6, + 177.6, + 178, + 178, + 178, + 178, + 178, + 178, + 178, + 178, + 178 + ] + }, + { + "name": "box3_height", + "type": "dp", + "data_points": [ + { + "type": "not_found" + }, + { + "type": "not_found" + }, + { + "type": "not_found" + }, + { + "type": "not_found" + }, + { + "type": "not_found" + }, + { + "type": "not_found" + }, + { + "type": "not_found" + }, + { + "type": "not_found" + }, + { + "type": "not_found" + }, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 2, + 24, + 43.2, + 45.2, + 46.4, + 47.2, + 48, + 48.8, + 49.2, + 49.6, + 49.6, + 50, + 50, + 50, + 50, + 50, + 50, + 50 + ] + }, + { + "name": "box4_y", + "type": "dp", + "data_points": [ + { + "type": "not_found" + }, + { + "type": "not_found" + }, + { + "type": "not_found" + }, + { + "type": "not_found" + }, + { + "type": "not_found" + }, + { + "type": "not_found" + }, + { + "type": "not_found" + }, + { + "type": "not_found" + }, + { + "type": "not_found" + }, + 36, + 46.4, + 57.2, + 65.2, + 80.4, + 82, + 83.2, + 102, + 128, + 140.4, + 168.4, + 179.2, + 203.6, + 224.8, + 228, + 230.4, + 231.6, + 233.2, + 234.4, + 234.8, + 235.6, + 235.6, + 236, + 236, + 236, + 236, + 236, + 236, + 236 + ] + }, + { + "name": "box4_height", + "type": "dp", + "data_points": [ + { + "type": "not_found" + }, + { + "type": "not_found" + }, + { + "type": "not_found" + }, + { + "type": "not_found" + }, + { + "type": "not_found" + }, + { + "type": "not_found" + }, + { + "type": "not_found" + }, + { + "type": "not_found" + }, + { + "type": "not_found" + }, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 12, + 23.6, + 33.2, + 41.2, + 47.2, + 48, + 48.8, + 49.2, + 49.6, + 49.6, + 50, + 50, + 50, + 50 + ] + }, + { + "name": "box5_y", + "type": "dp", + "data_points": [ + { + "type": "not_found" + }, + { + "type": "not_found" + }, + { + "type": "not_found" + }, + { + "type": "not_found" + }, + { + "type": "not_found" + }, + { + "type": "not_found" + }, + { + "type": "not_found" + }, + { + "type": "not_found" + }, + { + "type": "not_found" + }, + 44, + 54.4, + 65.2, + 73.2, + 88.4, + 90, + 91.2, + 110, + 136, + 148.4, + 176.4, + 187.2, + 211.6, + 232.8, + 236, + 250.4, + 263.2, + 274.4, + 283.6, + 290, + 291.6, + 292.4, + 293.2, + 293.6, + 293.6, + 294, + 294, + 294, + 294 + ] + }, + { + "name": "box5_height", + "type": "dp", + "data_points": [ + { + "type": "not_found" + }, + { + "type": "not_found" + }, + { + "type": "not_found" + }, + { + "type": "not_found" + }, + { + "type": "not_found" + }, + { + "type": "not_found" + }, + { + "type": "not_found" + }, + { + "type": "not_found" + }, + { + "type": "not_found" + }, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 50 + ] + }, + { + "name": "box6_y", + "type": "dp", + "data_points": [ + { + "type": "not_found" + }, + { + "type": "not_found" + }, + { + "type": "not_found" + }, + { + "type": "not_found" + }, + { + "type": "not_found" + }, + { + "type": "not_found" + }, + { + "type": "not_found" + }, + { + "type": "not_found" + }, + { + "type": "not_found" + }, + 52, + 62.4, + 73.2, + 81.2, + 96.4, + 98, + 99.2, + 118, + 144, + 156.4, + 184.4, + 195.2, + 219.6, + 240.8, + 244, + 258.4, + 271.2, + 282.4, + 291.6, + 298, + 299.6, + 300.4, + 301.2, + 301.6, + 301.6, + 302, + 302, + 302, + 352 + ] + }, + { + "name": "box6_height", + "type": "dp", + "data_points": [ + { + "type": "not_found" + }, + { + "type": "not_found" + }, + { + "type": "not_found" + }, + { + "type": "not_found" + }, + { + "type": "not_found" + }, + { + "type": "not_found" + }, + { + "type": "not_found" + }, + { + "type": "not_found" + }, + { + "type": "not_found" + }, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 50 + ] + }, + { + "name": "box7_y", + "type": "dp", + "data_points": [ + { + "type": "not_found" + }, + { + "type": "not_found" + }, + { + "type": "not_found" + }, + { + "type": "not_found" + }, + { + "type": "not_found" + }, + { + "type": "not_found" + }, + { + "type": "not_found" + }, + { + "type": "not_found" + }, + { + "type": "not_found" + }, + 60, + 70.4, + 81.2, + 89.2, + 104.4, + 106, + 107.2, + 126, + 152, + 164.4, + 192.4, + 203.2, + 227.6, + 248.8, + 252, + 266.4, + 279.2, + 290.4, + 299.6, + 306, + 307.6, + 308.4, + 309.2, + 309.6, + 309.6, + 310, + 310, + 310, + 410 + ] + }, + { + "name": "box7_height", + "type": "dp", + "data_points": [ + { + "type": "not_found" + }, + { + "type": "not_found" + }, + { + "type": "not_found" + }, + { + "type": "not_found" + }, + { + "type": "not_found" + }, + { + "type": "not_found" + }, + { + "type": "not_found" + }, + { + "type": "not_found" + }, + { + "type": "not_found" + }, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 50 + ] + } + ] +} \ No newline at end of file diff --git a/mechanics/compose/tests/src/com/android/mechanics/compose/modifier/VerticalTactileSurfaceRevealModifierTest.kt b/mechanics/compose/tests/src/com/android/mechanics/compose/modifier/VerticalTactileSurfaceRevealModifierTest.kt new file mode 100644 index 0000000..769acdd --- /dev/null +++ b/mechanics/compose/tests/src/com/android/mechanics/compose/modifier/VerticalTactileSurfaceRevealModifierTest.kt @@ -0,0 +1,308 @@ +/* + * Copyright (C) 2025 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.compose.modifier + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.test.SemanticsNodeInteractionsProvider +import androidx.compose.ui.test.TouchInjectionScope +import androidx.compose.ui.test.hasTestTag +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.swipeDown +import androidx.compose.ui.test.swipeUp +import androidx.compose.ui.test.swipeWithVelocity +import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.dp +import com.android.compose.animation.scene.ContentScope +import com.android.compose.animation.scene.ElementKey +import com.android.compose.animation.scene.MutableSceneTransitionLayoutState +import com.android.compose.animation.scene.OverlayKey +import com.android.compose.animation.scene.SceneKey +import com.android.compose.animation.scene.SceneTransitionLayout +import com.android.compose.animation.scene.Swipe +import com.android.compose.animation.scene.UserActionResult +import com.android.compose.animation.scene.featureOfElement +import com.android.compose.animation.scene.mechanics.rememberGestureContext +import com.android.compose.animation.scene.rememberMutableSceneTransitionLayoutState +import com.android.compose.animation.scene.transitions +import com.android.mechanics.debug.LocalMotionValueDebugController +import com.android.mechanics.debug.MotionValueDebugController +import com.android.mechanics.spec.builder.MotionBuilderContext +import com.android.mechanics.testing.FakeMotionSpecBuilderContext +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.junit.runners.Parameterized +import platform.test.motion.MotionTestRule +import platform.test.motion.compose.ComposeFeatureCaptures.height +import platform.test.motion.compose.ComposeFeatureCaptures.y +import platform.test.motion.compose.ComposeRecordingSpec +import platform.test.motion.compose.ComposeToolkit +import platform.test.motion.compose.createFixedConfigurationComposeMotionTestRule +import platform.test.motion.compose.on +import platform.test.motion.compose.recordMotion +import platform.test.motion.compose.runTest +import platform.test.motion.golden.FeatureCapture +import platform.test.motion.golden.asDataPoint +import platform.test.motion.testing.createGoldenPathManager + +@RunWith(Parameterized::class) +class VerticalTactileSurfaceRevealModifierTest(val useOverlays: Boolean) : + MotionBuilderContext by FakeMotionSpecBuilderContext.Default { + + @get:Rule + val motionRule: MotionTestRule = + createFixedConfigurationComposeMotionTestRule( + createGoldenPathManager("frameworks/libs/systemui/mechanics/compose/tests/goldens") + ) + + private val debugger = MotionValueDebugController() + + private fun assertVerticalTactileSurfaceRevealMotion( + goldenName: String, + gestureControl: GestureRevealMotion, + ) = + motionRule.runTest { + lateinit var state: MutableSceneTransitionLayoutState + val isTransitioning = + FeatureCapture("") { + (if (state.isTransitioning()) 1 else 0).asDataPoint() + } + + val boxes = 8 + @Composable + fun ContentScope.TestContent(modifier: Modifier = Modifier) { + Box(modifier = modifier.fillMaxSize()) { + Column( + modifier = + Modifier.element(ContainerElement) + .motionDriver(rememberGestureContext()) + .verticalScroll(rememberScrollState()) + .background(Color.LightGray) + .padding(4.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + repeat(boxes) { + Box( + Modifier.testTag("box$it") + .border( + 2.dp, + when (it) { + 0 -> Color.Green + boxes - 1 -> Color.Red + else -> Color.Blue + }, + ) + .verticalTactileSurfaceReveal(label = "box$it") + .size(50.dp) + ) + } + } + } + } + + val motion = + recordMotion( + content = { + CompositionLocalProvider( + LocalMotionValueDebugController provides debugger + ) { + state = + rememberMutableSceneTransitionLayoutState( + initialScene = gestureControl.startScene, + initialOverlays = gestureControl.startOverlays, + transitions = + transitions { + from(CollapsedScene, to = ExpandedOverlay) { + scaleSize(ContainerElement, height = 0f) + } + from(CollapsedScene, to = ExpandedScene) { + scaleSize(ContainerElement, height = 0f) + } + }, + ) + SceneTransitionLayout( + state = state, + modifier = + Modifier.background(Color.Yellow) + .size(ContainerSize) + .testTag(STL_TAG), + implicitTestTags = true, + ) { + scene( + key = CollapsedScene, + userActions = + mapOf( + if (useOverlays) { + Swipe.Down to ExpandedOverlay + } else { + Swipe.Down to ExpandedScene + } + ), + content = { Box(modifier = Modifier.fillMaxSize()) }, + ) + if (useOverlays) { + overlay( + ExpandedOverlay, + userActions = + mapOf( + Swipe.Up to + UserActionResult.HideOverlay(ExpandedOverlay) + ), + content = { + TestContent(Modifier.border(2.dp, Color.Magenta)) + }, + ) + } else { + scene( + key = ExpandedScene, + userActions = mapOf(Swipe.Up to CollapsedScene), + content = { TestContent(Modifier.border(2.dp, Color.Cyan)) }, + ) + } + } + } + }, + ComposeRecordingSpec( + recording = { + performTouchInputAsync( + onNodeWithTag(STL_TAG), + gestureControl.gestureControl, + ) + + awaitCondition { + !state.isTransitioning() && debugger.observed.all { it.isStable } + } + }, + timeSeriesCapture = { + feature(isTransitioning, "isTransitioning") + featureOfElement(ContainerElement, height) + repeat(boxes) { + val testTag = "box$it" + on(hasTestTag(testTag)) { + feature(y, name = "${testTag}_${y.name}") + feature(height, name = "${testTag}_${height.name}") + } + } + }, + ), + ) + + assertThat(motion).timeSeriesMatchesGolden(goldenName) + } + + @Test + fun verticalTactileSurfaceReveal_gesture_dragOpen() { + assertVerticalTactileSurfaceRevealMotion( + // We are using the same golden for scene-to-scene and scene-to-overlay transition. + goldenName = "verticalTactileSurfaceReveal_gesture_dragOpen", + gestureControl = + GestureRevealMotion(startScene = CollapsedScene) { + swipeDown(endY = 200.dp.toPx(), durationMillis = 500) + }, + ) + } + + @Test + fun verticalTactileSurfaceReveal_gesture_flingOpen() { + assertVerticalTactileSurfaceRevealMotion( + // We are using the same golden for scene-to-scene and scene-to-overlay transition. + goldenName = "verticalTactileSurfaceReveal_gesture_flingOpen", + gestureControl = + GestureRevealMotion(startScene = CollapsedScene) { + val end = Offset(centerX, 80.dp.toPx()) + swipeWithVelocity( + start = topCenter, + end = end, + endVelocity = FlingVelocity.toPx(), + ) + }, + ) + } + + private fun startExpanded(gestureControl: TouchInjectionScope.() -> Unit): GestureRevealMotion { + return if (useOverlays) { + GestureRevealMotion( + startScene = CollapsedScene, + startOverlays = setOf(ExpandedOverlay), + gestureControl = gestureControl, + ) + } else { + GestureRevealMotion(startScene = ExpandedScene, gestureControl = gestureControl) + } + } + + @Test + fun verticalTactileSurfaceReveal_gesture_dragClose() { + assertVerticalTactileSurfaceRevealMotion( + // We are using the same golden for scene-to-scene and scene-to-overlay transition. + goldenName = "verticalTactileSurfaceReveal_gesture_dragClose", + gestureControl = + startExpanded { swipeUp(200.dp.toPx(), 0.dp.toPx(), durationMillis = 500) }, + ) + } + + @Test + fun verticalTactileSurfaceReveal_gesture_flingClose() { + assertVerticalTactileSurfaceRevealMotion( + // We are using the same golden for scene-to-scene and scene-to-overlay transition. + goldenName = "verticalTactileSurfaceReveal_gesture_flingClose", + gestureControl = + startExpanded { + val start = Offset(centerX, 260.dp.toPx()) + val end = Offset(centerX, 200.dp.toPx()) + swipeWithVelocity(start, end, FlingVelocity.toPx()) + }, + ) + } + + private class GestureRevealMotion( + val startScene: SceneKey, + val startOverlays: Set = emptySet(), + val gestureControl: TouchInjectionScope.() -> Unit, + ) + + private companion object { + const val STL_TAG = "stl" + + val CollapsedScene = SceneKey("CollapsedScene") + val ExpandedScene = SceneKey("ExpandedScene") + val ExpandedOverlay = OverlayKey("ExpandedOverlay") + val ContainerElement = ElementKey("ContainerElement") + + val ContainerSize = DpSize(150.dp, 300.dp) + val FlingVelocity = 1000.dp // dp/sec + + @Parameterized.Parameters(name = "useOverlays={0}") + @JvmStatic + fun useOverlays() = listOf(false, true) + } +} diff --git a/mechanics/src/com/android/mechanics/ComposableMotionValue.kt b/mechanics/src/com/android/mechanics/ComposableMotionValue.kt new file mode 100644 index 0000000..1df9700 --- /dev/null +++ b/mechanics/src/com/android/mechanics/ComposableMotionValue.kt @@ -0,0 +1,113 @@ +/* + * Copyright (C) 2025 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 + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.State +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import com.android.mechanics.haptics.HapticPlayer +import com.android.mechanics.spec.MotionSpec +import com.android.mechanics.spec.builder.MotionBuilderContext +import com.android.mechanics.spec.builder.rememberMotionBuilderContext + +@Composable +fun rememberMotionValue( + input: () -> Float, + gestureContext: GestureContext, + spec: () -> MotionSpec, + label: String? = null, + stableThreshold: Float = 0.01f, + hapticPlayer: HapticPlayer = HapticPlayer.NoPlayer, +): MotionValue { + val motionValue = + remember(input, hapticPlayer) { + MotionValue( + input = input, + gestureContext = gestureContext, + spec = spec, + label = label, + stableThreshold = stableThreshold, + hapticPlayer = hapticPlayer, + ) + } + + LaunchedEffect(motionValue) { motionValue.keepRunning() } + return motionValue +} + +@Composable +fun rememberMotionValue( + input: () -> Float, + gestureContext: GestureContext, + spec: State, + label: String? = null, + stableThreshold: Float = 0.01f, + hapticPlayer: HapticPlayer = HapticPlayer.NoPlayer, +): MotionValue { + return rememberMotionValue( + input = input, + gestureContext = gestureContext, + spec = spec::value, + label = label, + stableThreshold = stableThreshold, + hapticPlayer = hapticPlayer, + ) +} + +@Composable +fun rememberDerivedMotionValue( + input: MotionValue, + specProvider: () -> MotionSpec, + stableThreshold: Float = 0.01f, + label: String? = null, +): MotionValue { + val motionValue = + remember(input, specProvider) { + MotionValue.createDerived( + source = input, + spec = specProvider, + label = label, + stableThreshold = stableThreshold, + ) + } + + LaunchedEffect(motionValue) { motionValue.keepRunning() } + return motionValue +} + +/** + * Efficiently creates and remembers a [MotionSpec], providing it via a stable lambda. + * + * This function memoizes the [MotionSpec] to avoid expensive recalculations. The spec is + * re-computed only when a state dependency within the `spec` lambda changes, not on every + * recomposition or each time the output is read. + * + * @param calculation A lambda with a [MotionBuilderContext] receiver that defines the [MotionSpec]. + * @return A stable provider `() -> MotionSpec`. Invoking this function is cheap as it returns the + * latest cached value. + */ +@Composable +fun rememberMotionSpecAsState( + calculation: MotionBuilderContext.() -> MotionSpec +): State { + val updatedSpec = rememberUpdatedState(calculation) + val context = rememberMotionBuilderContext() + return remember(context) { derivedStateOf { updatedSpec.value(context) } } +} diff --git a/mechanics/src/com/android/mechanics/MotionValue.kt b/mechanics/src/com/android/mechanics/MotionValue.kt index 9d01c10..95c5790 100644 --- a/mechanics/src/com/android/mechanics/MotionValue.kt +++ b/mechanics/src/com/android/mechanics/MotionValue.kt @@ -17,6 +17,8 @@ package com.android.mechanics import androidx.compose.runtime.FloatState +import androidx.compose.runtime.annotation.FrequentlyChangingValue +import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableLongStateOf @@ -27,6 +29,9 @@ import androidx.compose.runtime.snapshotFlow import androidx.compose.runtime.withFrameNanos import com.android.mechanics.debug.DebugInspector import com.android.mechanics.debug.FrameData +import com.android.mechanics.haptics.BreakpointHaptics +import com.android.mechanics.haptics.HapticPlayer +import com.android.mechanics.haptics.SegmentHaptics import com.android.mechanics.impl.Computations import com.android.mechanics.impl.DiscontinuityAnimation import com.android.mechanics.impl.GuaranteeState @@ -81,9 +86,17 @@ import kotlinx.coroutines.withContext * * ## Updating the MotionSpec * - * The [spec] property can be changed at any time. If the new spec produces a different output for - * the current input, the difference will be animated using the spring parameters defined in - * [MotionSpec.resetSpring]. + * You can provide a new [MotionSpec] at any time. If the new spec produces a different output value + * for the current input, the change will be animated smoothly using the spring parameters defined + * in `[MotionSpec.resetSpring]`. + * + * **Important**: The function that provides the spec may be called frequently (for instance, on + * every frame). To avoid performance issues from re-computing the spec, **you are responsible for + * caching the result**. + * + * For use **in composition**, you can use the [rememberMotionSpecAsState] utility. This composable + * automatically handles caching, ensuring the spec is only re-created when its state dependencies + * change. * * ## Gesture Context * @@ -93,9 +106,9 @@ import kotlinx.coroutines.withContext * * ## Usage * - * The [MotionValue] does animate the [output] implicitly, whenever a change in [currentInput], - * [spec], or [gestureContext] requires it. The animated value is computed whenever the [output] - * property is read, or the latest once the animation frame is complete. + * The [MotionValue] does animate the [output] implicitly, whenever a change in [input], [spec], or + * [gestureContext] requires it. The animated value is computed whenever the [output] property is + * read, or the latest once the animation frame is complete. * 1. Create an instance, providing the input value, gesture context, and an initial spec. * 2. Call [keepRunning] in a coroutine scope, and keep the coroutine running while the * `MotionValue` is in use. @@ -104,27 +117,41 @@ import kotlinx.coroutines.withContext * Internally, the [keepRunning] coroutine is automatically suspended if there is nothing to * animate. * - * @param currentInput Provides the current input value. - * @param gestureContext The [GestureContext] augmenting the [currentInput]. + * @param input Provides the current input value. + * @param gestureContext The [GestureContext] augmenting the current input. + * @param spec Provides the current [MotionSpec]. **Important**: For performance, this should be a + * stable provider. In composition, it's strongly recommended to use an helper like + * [rememberMotionSpecAsState] to create the spec. * @param label An optional label to aid in debugging. * @param stableThreshold A threshold value (in output units) that determines when the * [MotionValue]'s internal spring animation is considered stable. + * @param hapticPlayer When specifying segment and breakpoint haptics, this player will be used to + * deliver haptic feedback. */ class MotionValue( - currentInput: () -> Float, + input: () -> Float, gestureContext: GestureContext, - initialSpec: MotionSpec = MotionSpec.Empty, + spec: () -> MotionSpec, label: String? = null, stableThreshold: Float = StableThresholdEffect, -) : FloatState { + hapticPlayer: HapticPlayer = HapticPlayer.NoPlayer, +) : MotionValueState { private val impl = - ObservableComputations(currentInput, gestureContext, initialSpec, stableThreshold, label) + ObservableComputations( + inputProvider = input, + gestureContext = gestureContext, + specProvider = spec, + stableThreshold = stableThreshold, + label = label, + hapticPlayer = hapticPlayer, + ) /** The [MotionSpec] describing the mapping of this [MotionValue]'s input to the output. */ - var spec: MotionSpec by impl::spec + // TODO(b/441041846): This should not change frequently + @get:FrequentlyChangingValue val spec: MotionSpec by impl::spec /** Animated [output] value. */ - val output: Float by impl::output + @get:FrequentlyChangingValue override val output: Float by impl::computedOutput /** * [output] value, but without animations. @@ -133,25 +160,42 @@ class MotionValue( * * While [isStable], [outputTarget] and [output] are the same value. */ - val outputTarget: Float by impl::outputTarget + // TODO(b/441041846): This should not change frequently + @get:FrequentlyChangingValue override val outputTarget: Float by impl::computedOutputTarget /** The [output] exposed as [FloatState]. */ - override val floatValue: Float by impl::output + @get:FrequentlyChangingValue override val floatValue: Float by impl::computedOutput /** Whether an animation is currently running. */ - val isStable: Boolean by impl::isStable + // TODO(b/441041846): This should not change frequently + @get:FrequentlyChangingValue override val isStable: Boolean by impl::computedIsStable + + /** + * Whether the output can change its value. + * + * This is an optimization hint. It returns `true` if the animation spring is at rest AND the + * current input maps to a fixed value that is the same as the previous one. In this state, the + * output is guaranteed not to change unless the [spec] or the input (enough to change segments) + * changes. This can be used to avoid unnecessary work like recomposition or re-measurement. + */ + // TODO(b/441041846): This should not change frequently + @get:FrequentlyChangingValue val isOutputFixed: Boolean by impl::computedIsOutputFixed /** * The current value for the [SemanticKey]. * * `null` if not defined in the spec. */ - operator fun get(key: SemanticKey): T? { - return impl.semanticState(key) + // TODO(b/441041846): This should not change frequently + @FrequentlyChangingValue + override operator fun get(key: SemanticKey): T? { + return impl.computedSemanticState(key) } /** The current segment used to compute the output. */ - val segmentKey: SegmentKey + // TODO(b/441041846): This should not change frequently + @get:FrequentlyChangingValue + override val segmentKey: SegmentKey get() = impl.currentComputedValues.segment.key /** @@ -186,20 +230,20 @@ class MotionValue( impl.keepRunning { continueRunning.invoke(this@MotionValue) } } - val label: String? by impl::label + override val label: String? by impl::label companion object { /** Creates a [MotionValue] whose [currentInput] is the animated [output] of [source]. */ fun createDerived( source: MotionValue, - initialSpec: MotionSpec = MotionSpec.Empty, + spec: () -> MotionSpec, label: String? = null, stableThreshold: Float = 0.01f, ): MotionValue { return MotionValue( - currentInput = source::output, + input = { source.output }, gestureContext = source.impl.gestureContext, - initialSpec = initialSpec, + spec = derivedStateOf(calculation = spec)::value, label = label, stableThreshold = stableThreshold, ) @@ -224,7 +268,7 @@ class MotionValue( * * The returned [DebugInspector] must be [DebugInspector.dispose]d when no longer needed. */ - fun debugInspector(): DebugInspector { + override fun debugInspector(): DebugInspector { if (debugInspectorRefCount.getAndIncrement() == 0) { impl.debugInspector = DebugInspector( @@ -236,6 +280,7 @@ class MotionValue( impl.lastSpringState, impl.lastSegment, impl.lastAnimation, + impl.computedIsOutputFixed, ), impl.isActive, impl.debugIsAnimating, @@ -248,18 +293,21 @@ class MotionValue( } private class ObservableComputations( - val input: () -> Float, + private val inputProvider: () -> Float, val gestureContext: GestureContext, - initialSpec: MotionSpec = MotionSpec.Empty, + private val specProvider: () -> MotionSpec, override val stableThreshold: Float, override val label: String?, + private val hapticPlayer: HapticPlayer, ) : Computations() { // ---- CurrentFrameInput --------------------------------------------------------------------- - override var spec by mutableStateOf(initialSpec) + override val spec + get() = specProvider.invoke() + override val currentInput: Float - get() = input.invoke() + get() = inputProvider.invoke() override val currentDirection: InputDirection get() = gestureContext.direction @@ -269,11 +317,13 @@ private class ObservableComputations( override var currentAnimationTimeNanos by mutableLongStateOf(-1L) + override var lastHapticsTimeNanos by mutableLongStateOf(-1L) + // ---- LastFrameState --------------------------------------------------------------------- override var lastSegment: SegmentData by mutableStateOf( - spec.segmentAtInput(currentInput, currentDirection), + this.spec.segmentAtInput(currentInput, currentDirection), referentialEqualityPolicy(), ) @@ -366,12 +416,14 @@ private class ObservableComputations( } var scheduleNextFrame = false + var breakpointHaptics: BreakpointHaptics? = null if (!isSameSegmentAndAtRest) { // Read currentComputedValues only once and update it, if necessary val currentValues = currentComputedValues if (capturedSegment != currentValues.segment) { capturedSegment = currentValues.segment + breakpointHaptics = currentValues.breakpointHaptics scheduleNextFrame = true } @@ -406,6 +458,13 @@ private class ObservableComputations( scheduleNextFrame = true } + // Perform haptics + if (breakpointHaptics != null) { + performBreakpointHapticFeedback(breakpointHaptics) + } else { + performSegmentHapticFeedback(capturedSegment.haptics) + } + capturedFrameTimeNanos = currentAnimationTimeNanos debugInspector?.run { @@ -418,6 +477,7 @@ private class ObservableComputations( capturedSpringState, capturedSegment, capturedAnimation, + computedIsOutputFixed, ) } @@ -463,4 +523,24 @@ private class ObservableComputations( } var debugInspector: DebugInspector? = null + + private fun performSegmentHapticFeedback(segmentHaptics: SegmentHaptics) { + val timeDelta = currentAnimationTimeNanos - lastHapticsTimeNanos + if (timeDelta < hapticPlayer.getPlaybackIntervalNanos()) return + + val spatialInputPx = computedOutput + val velocityPxPerSec = directMappedVelocity // we assume this is always in px/sec. + lastHapticsTimeNanos = currentAnimationTimeNanos + hapticPlayer.playSegmentHaptics(segmentHaptics, spatialInputPx, velocityPxPerSec) + } + + private fun performBreakpointHapticFeedback(breakpointHaptics: BreakpointHaptics) { + val timeDelta = currentAnimationTimeNanos - lastHapticsTimeNanos + if (timeDelta < hapticPlayer.getPlaybackIntervalNanos()) return + + val spatialInputPx = computedOutput + val velocityPxPerSec = directMappedVelocity // we assume this is always in px/sec. + lastHapticsTimeNanos = currentAnimationTimeNanos + hapticPlayer.playBreakpointHaptics(breakpointHaptics, spatialInputPx, velocityPxPerSec) + } } diff --git a/mechanics/src/com/android/mechanics/MotionValueCollection.kt b/mechanics/src/com/android/mechanics/MotionValueCollection.kt new file mode 100644 index 0000000..772bca3 --- /dev/null +++ b/mechanics/src/com/android/mechanics/MotionValueCollection.kt @@ -0,0 +1,456 @@ +/* + * Copyright (C) 2025 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 + +import androidx.annotation.VisibleForTesting +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableLongStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.mutableStateSetOf +import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow +import androidx.compose.runtime.withFrameNanos +import androidx.compose.ui.util.trace +import androidx.compose.ui.util.traceValue +import com.android.mechanics.MotionValue.Companion.StableThresholdSpatial +import com.android.mechanics.debug.DebugInspector +import com.android.mechanics.debug.FrameData +import com.android.mechanics.impl.Computations +import com.android.mechanics.impl.DiscontinuityAnimation +import com.android.mechanics.impl.GuaranteeState +import com.android.mechanics.spec.InputDirection +import com.android.mechanics.spec.MotionSpec +import com.android.mechanics.spec.SegmentData +import com.android.mechanics.spec.SegmentKey +import com.android.mechanics.spec.SemanticKey +import com.android.mechanics.spring.SpringState +import java.util.concurrent.atomic.AtomicInteger +import kotlin.time.Duration +import kotlin.time.measureTime +import kotlinx.coroutines.CoroutineName +import kotlinx.coroutines.DisposableHandle +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.withContext + +/** The type of MotionValue created by the [MotionValueCollection]. */ +sealed interface ManagedMotionValue : MotionValueState, DisposableHandle + +/** + * A collection of motion values that all share the same input and gesture context. + * + * All [ManagedMotionValue]s are run from the same [keepRunning], and share the same lifecycle. + * + * Input, gesture context and spec are updated all at once, at the beginning of the, during + * [withFrameNanos]. + */ +class MotionValueCollection( + internal val input: () -> Float, + internal val gestureContext: GestureContext, + internal val stableThreshold: Float = StableThresholdSpatial, + val label: String? = null, +) { + private val managedComputations = mutableStateSetOf() + + /** + * Creates a new [ManagedMotionValue], whose output is controlled by [spec]. + * + * The returned [ManagedMotionValue] must be disposed when not used anymore, while this + * [MotionValueCollection] is kept active. + */ + fun create(spec: () -> MotionSpec, label: String? = null): ManagedMotionValue { + return ManagedMotionComputation(this, spec, label).also { + if (isActive) { + it.onActivate() + } + managedComputations.add(it) + } + } + + /** + * Conditionally wraps the execution of a [block] in a performance trace. + * + * The primary advantage of this helper is lazy evaluation. The trace message from + * [onTraceStart] is not computed and no `try-finally` block is entered unless tracing is + * [enabled]. This helps to avoid performance penalties in production builds where tracing is + * often turned off. + * + * @param enabled A boolean flag to enable or disable tracing. + * @param onTraceStart A lambda that returns the trace section name. Only invoked if [enabled] + * is true. + * @param onTraceEnd A lambda that executes after the block has finished. Only invoked if + * [enabled] is true. + * @param block The code block to be executed and traced. + */ + private inline fun trace( + enabled: Boolean, + onTraceStart: () -> String, + onTraceEnd: (Duration) -> Unit = {}, + block: () -> Unit, + ) { + if (enabled) { + val duration = measureTime { trace(sectionName = onTraceStart(), block = block) } + + onTraceEnd(duration) + } else { + block() + } + } + + /** + * Keeps the all created [ManagedMotionValue]'s animated output running. + * + * Clients must call [keepRunning], and keep the coroutine running while any of the created + * [ManagedMotionValue] is in use. Cancel the coroutine if no values are being used anymore. + * + * Internally, this method does suspend, unless there are animations ongoing. + */ + suspend fun keepRunning(): Nothing { + withContext(CoroutineName("MotionValueCollection($label)")) { + check(!isActive) { "MotionValueCollection($label) is already running" } + isActive = true + + currentInput = input.invoke() + currentGestureDragOffset = gestureContext.dragOffset + currentDirection = gestureContext.direction + + managedComputations.forEach { it.onActivate() } + + try { + isAnimating = true + + // indicates whether withFrameNanos is called continuously (as opposed to being + // suspended for an undetermined amount of time in between withFrameNanos). + // This is essential after `withFrameNanos` returned: if true at this point, + // currentAnimationTimeNanos - lastFrameNanos is the duration of the last frame. + var isAnimatingUninterrupted = false + + while (true) { + var scheduleNextFrame = false + withFrameNanos { frameTimeNanos -> + frameCount++ + + trace( + enabled = isTraceEnabled, + onTraceStart = { + val prefix = "MotionValueCollection($label)" + val unstable = managedComputations.count { !it.isStable } + val all = managedComputations.size + traceValue("$prefix:unstable", unstable.toLong()) + traceValue("$prefix:all", all.toLong()) + + "$prefix withFrameNanos f:$frameCount ($unstable/$all)" + }, + onTraceEnd = { + val prefix = "MotionValueCollection($label)" + traceValue("$prefix:duration", it.inWholeMicroseconds) + }, + ) { + lastFrameTimeNanos = currentAnimationTimeNanos + lastInput = currentInput + lastDirection = currentDirection + lastGestureDragOffset = currentGestureDragOffset + + currentAnimationTimeNanos = frameTimeNanos + currentInput = input.invoke() + currentDirection = gestureContext.direction + currentGestureDragOffset = gestureContext.dragOffset + + if ( + lastInput != currentInput || + lastDirection != currentDirection || + lastGestureDragOffset != currentGestureDragOffset + ) { + scheduleNextFrame = true + } + managedComputations.forEach { + if (it.onFrameStart(isAnimatingUninterrupted)) { + scheduleNextFrame = true + } + } + } + } + + isAnimatingUninterrupted = scheduleNextFrame + if (scheduleNextFrame) { + continue + } + + isAnimating = false + managedComputations.forEach { it.debugInspector?.isAnimating = false } + val activeComputations = managedComputations.toSet() + + snapshotFlow { + val hasComputations = + activeComputations.isNotEmpty() || managedComputations.isNotEmpty() + + val wakeup = + hasComputations && + (activeComputations != managedComputations || + activeComputations.any { it.wantWakeup() } || + input.invoke() != currentInput || + gestureContext.direction != currentDirection || + gestureContext.dragOffset != currentGestureDragOffset) + wakeup + } + .first { it } + isAnimating = true + managedComputations.forEach { it.debugInspector?.isAnimating = true } + } + } finally { + isActive = false + managedComputations.forEach { it.onDeactivate() } + } + } + } + + // ---- Implementation - State shared with all ManagedMotionComputations ---------------------- + // Note that all this state is updated exactly once per frame, during [withFrameNanos]. + internal var currentAnimationTimeNanos = -1L + private set + + @VisibleForTesting + var currentInput: Float = input.invoke() + private set + + @VisibleForTesting + var currentDirection: InputDirection = gestureContext.direction + private set + + @VisibleForTesting + var currentGestureDragOffset: Float = gestureContext.dragOffset + private set + + internal var lastFrameTimeNanos = -1L + internal var lastInput = currentInput + internal var lastGestureDragOffset = currentGestureDragOffset + internal var lastDirection = currentDirection + + // ---- Testing related state ------------------------------------------------------------------ + + @VisibleForTesting + var isActive = false + private set + + @VisibleForTesting + var isAnimating = false + private set + + @VisibleForTesting + var frameCount = 0 + private set + + @VisibleForTesting + // Note - this is public so that its accessible by the mechanics:testing library + val managedMotionValues: Set + get() = managedComputations + + internal fun onDispose(toDispose: ManagedMotionComputation) { + managedComputations.remove(toDispose) + toDispose.onDeactivate() + } + + companion object { + var isTraceEnabled: Boolean = false + } +} + +internal class ManagedMotionComputation( + private val owner: MotionValueCollection, + private val specProvider: () -> MotionSpec, + override val label: String?, +) : Computations(), ManagedMotionValue { + + override val stableThreshold: Float + get() = owner.stableThreshold + + // ---- ManagedMotionValue -------------------------------------------------------------------- + + override var output: Float by mutableFloatStateOf(Float.NaN) + + /** + * [output] value, but without animations. + * + * This value always reports the target value, even before a animation is finished. + * + * While [isStable], [outputTarget] and [output] are the same value. + */ + override var outputTarget: Float by mutableFloatStateOf(Float.NaN) + + /** Whether an animation is currently running. */ + override var isStable: Boolean by mutableStateOf(false) + + override var spec: MotionSpec = specProvider.invoke() + private set + + override fun get(key: SemanticKey): T? { + val segment = capturedComputedValues.segment + return segment.spec.semanticState(key, segment.key) + } + + override val segmentKey: SegmentKey + get() = capturedComputedValues.segment.key + + override val floatValue: Float + get() = output + + override fun dispose() { + owner.onDispose(this) + } + + override fun debugInspector(): DebugInspector { + if (debugInspectorRefCount.getAndIncrement() == 0) { + debugInspector = + DebugInspector( + FrameData( + lastInput, + lastSegment.direction, + lastGestureDragOffset, + lastFrameTimeNanos, + lastSpringState, + lastSegment, + lastAnimation, + computedIsOutputFixed, + ), + owner.isActive, + owner.isAnimating, + ::onDisposeDebugInspector, + ) + } + + return checkNotNull(debugInspector) + } + + private var debugInspectorRefCount = AtomicInteger(0) + + private fun onDisposeDebugInspector() { + if (debugInspectorRefCount.decrementAndGet() == 0) { + debugInspector = null + } + } + + // ---- CurrentFrameInput --------------------------------------------------------------------- + + override val currentInput: Float + get() = owner.currentInput + + override val currentDirection: InputDirection + get() = owner.currentDirection + + override val currentGestureDragOffset: Float + get() = owner.currentGestureDragOffset + + override val currentAnimationTimeNanos + get() = owner.currentAnimationTimeNanos + + private var capturedComputedValues: ComputedValues = currentComputedValues + private var capturedSpringState: SpringState = currentSpringState + + // ---- LastFrameState --------------------------------------------------------------------- + + private var lastComputedValues: ComputedValues = capturedComputedValues + + override val lastSegment: SegmentData + get() = lastComputedValues.segment + + override val lastGuaranteeState: GuaranteeState + get() = lastComputedValues.guarantee + + override val lastAnimation: DiscontinuityAnimation + get() = lastComputedValues.animation + + override var lastSpringState: SpringState = SpringState.AtRest + + override var directMappedVelocity: Float = 0f + + override val lastFrameTimeNanos + get() = owner.lastFrameTimeNanos + + override val lastInput + get() = owner.lastInput + + override val lastGestureDragOffset + get() = owner.lastGestureDragOffset + + override var lastHapticsTimeNanos: Long by mutableLongStateOf(-1L) + + // ---- Computations --------------------------------------------------------------------------- + + var debugInspector: DebugInspector? = null + + fun onActivate() { + capturedComputedValues = currentComputedValues + capturedSpringState = currentSpringState + lastComputedValues = capturedComputedValues + lastSpringState = capturedSpringState + + onFrameStart(isAnimatingUninterrupted = false) + + debugInspector?.isAnimating = true + debugInspector?.isActive = true + } + + fun onDeactivate() { + debugInspector?.isAnimating = false + debugInspector?.isActive = false + } + + fun onFrameStart(isAnimatingUninterrupted: Boolean): Boolean { + spec = specProvider.invoke() + if (isSameSegmentAndAtRest) { + outputTarget = lastSegment.mapping.map(currentInput) + output = outputTarget + isStable = true + } else { + lastComputedValues = capturedComputedValues + lastSpringState = capturedSpringState + + capturedComputedValues = currentComputedValues + capturedSpringState = currentSpringState + + outputTarget = capturedComputedValues.segment.mapping.map(currentInput) + output = outputTarget + capturedSpringState.displacement + isStable = capturedSpringState == SpringState.AtRest + } + + directMappedVelocity = + if (isAnimatingUninterrupted) { + computeDirectMappedVelocity(currentAnimationTimeNanos - lastFrameTimeNanos) + } else 0f + + debugInspector?.run { + frame = + FrameData( + currentInput, + currentDirection, + currentGestureDragOffset, + currentAnimationTimeNanos, + capturedSpringState, + capturedComputedValues.segment, + capturedComputedValues.animation, + computedIsOutputFixed, + ) + } + + return lastSpringState != capturedSpringState || + lastComputedValues != capturedComputedValues + } + + fun wantWakeup(): Boolean { + return specProvider.invoke() != capturedComputedValues.segment.spec + } +} diff --git a/mechanics/src/com/android/mechanics/MotionValueState.kt b/mechanics/src/com/android/mechanics/MotionValueState.kt new file mode 100644 index 0000000..770bd7d --- /dev/null +++ b/mechanics/src/com/android/mechanics/MotionValueState.kt @@ -0,0 +1,63 @@ +/* + * Copyright (C) 2025 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 + +import androidx.compose.runtime.FloatState +import androidx.compose.runtime.Stable +import com.android.mechanics.debug.DebugInspector +import com.android.mechanics.spec.SegmentKey +import com.android.mechanics.spec.SemanticKey + +/** State produces by a motion value. */ +@Stable +sealed interface MotionValueState : FloatState { + + /** + * Animated [output] value. + * + * Same as [floatValue]. + */ + val output: Float + + /** + * [output] value, but without animations. + * + * This value always reports the target value, even before a animation is finished. + * + * While [isStable], [outputTarget] and [output] are the same value. + */ + val outputTarget: Float + + /** Whether an animation is currently running. */ + val isStable: Boolean + + /** + * The current value for the [SemanticKey]. + * + * `null` if not defined in the spec. + */ + operator fun get(key: SemanticKey): T? + + /** The current segment used to compute the output. */ + val segmentKey: SegmentKey + + /** Debug label of the motion value. */ + val label: String? + + /** Provides access to the current state for debugging.. */ + fun debugInspector(): DebugInspector +} diff --git a/mechanics/src/com/android/mechanics/debug/DebugInspector.kt b/mechanics/src/com/android/mechanics/debug/DebugInspector.kt index 088c78b..2247945 100644 --- a/mechanics/src/com/android/mechanics/debug/DebugInspector.kt +++ b/mechanics/src/com/android/mechanics/debug/DebugInspector.kt @@ -22,6 +22,7 @@ import androidx.compose.runtime.setValue import com.android.mechanics.MotionValue import com.android.mechanics.impl.DiscontinuityAnimation import com.android.mechanics.spec.InputDirection +import com.android.mechanics.spec.MotionSpec import com.android.mechanics.spec.SegmentData import com.android.mechanics.spec.SegmentKey import com.android.mechanics.spec.SemanticKey @@ -65,6 +66,7 @@ internal constructor( val springState: SpringState, private val segment: SegmentData, private val animation: DiscontinuityAnimation, + val isOutputFixed: Boolean, ) { val isStable: Boolean get() = springState == SpringState.AtRest @@ -87,4 +89,7 @@ internal constructor( val semantics: List> get() = with(segment) { spec.semantics(key) } + + val spec: MotionSpec + get() = segment.spec } diff --git a/mechanics/src/com/android/mechanics/debug/DebugVisualization.kt b/mechanics/src/com/android/mechanics/debug/DebugVisualization.kt index b89728b..dfd1a5f 100644 --- a/mechanics/src/com/android/mechanics/debug/DebugVisualization.kt +++ b/mechanics/src/com/android/mechanics/debug/DebugVisualization.kt @@ -20,6 +20,7 @@ import androidx.compose.foundation.layout.Spacer import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.mutableStateOf @@ -46,19 +47,26 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.util.fastCoerceAtLeast import androidx.compose.ui.util.fastCoerceAtMost import androidx.compose.ui.util.fastForEachIndexed -import com.android.mechanics.MotionValue +import com.android.mechanics.MotionValueState import com.android.mechanics.spec.DirectionalMotionSpec import com.android.mechanics.spec.Guarantee import com.android.mechanics.spec.InputDirection import com.android.mechanics.spec.Mapping import com.android.mechanics.spec.MotionSpec import com.android.mechanics.spec.SegmentKey +import com.android.mechanics.spec.SemanticKey import kotlin.math.ceil import kotlin.math.max import kotlin.math.min import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch +/** Computes the output range for a debug visualization given a spec and an input range. */ +typealias OutputRangeFn = + (spec: MotionSpec, inputRange: ClosedFloatingPointRange) -> ClosedFloatingPointRange< + Float + > + /** * A debug visualization of the [motionValue]. * @@ -72,16 +80,17 @@ import kotlinx.coroutines.launch */ @Composable fun DebugMotionValueVisualization( - motionValue: MotionValue, + motionValue: MotionValueState, inputRange: ClosedFloatingPointRange, modifier: Modifier = Modifier, + outputRange: OutputRangeFn = DebugMotionValueVisualization.default, maxAgeMillis: Long = 1000L, ) { - val spec = motionValue.spec - val outputRange = remember(spec, inputRange) { spec.computeOutputValueRange(inputRange) } - val inspector = remember(motionValue) { motionValue.debugInspector() } + val spec = remember(motionValue) { derivedStateOf { inspector.frame.spec } }.value + + val computedOutputRange = remember(spec, inputRange) { outputRange(spec, inputRange) } DisposableEffect(inspector) { onDispose { inspector.dispose() } } val colorScheme = MaterialTheme.colorScheme @@ -89,7 +98,7 @@ fun DebugMotionValueVisualization( val specColor = colorScheme.tertiary val valueColor = colorScheme.primary - val primarySpec = motionValue.spec.get(inspector.frame.gestureDirection) + val primarySpec = spec.get(inspector.frame.gestureDirection) val activeSegment = inspector.frame.segmentKey Spacer( @@ -98,7 +107,7 @@ fun DebugMotionValueVisualization( .debugMotionSpecGraph( primarySpec, inputRange, - outputRange, + computedOutputRange, axisColor, specColor, activeSegment, @@ -107,12 +116,36 @@ fun DebugMotionValueVisualization( motionValue, valueColor, inputRange, - outputRange, + computedOutputRange, maxAgeMillis, ) ) } +object DebugMotionValueVisualization { + + /** + * Returns the output range as annotated in the spec using [OutputRangeKey], or + * [minMaxOutputRange] is not specified. + */ + val default: OutputRangeFn = { spec, inputRange -> + spec.semanticState(OutputRangeKey) ?: spec.computeOutputValueRange(inputRange) + } + /** + * Returns an output range containing the min and max output values at each breakpoint within + * the input range + */ + val minMaxOutputRange: OutputRangeFn = { spec, inputRange -> + spec.computeOutputValueRange(inputRange) + } + + /** Returns an output range that is identical to the input range */ + val inputRange: OutputRangeFn = { _, inputRange -> inputRange } + + /** Defines the output range for the visualization. */ + val OutputRangeKey = SemanticKey>("visualizationOutputRange") +} + /** * Draws a full-sized debug visualization of [spec]. * @@ -148,7 +181,7 @@ fun Modifier.debugMotionSpecGraph( */ @Composable fun Modifier.debugMotionValueGraph( - motionValue: MotionValue, + motionValue: MotionValueState, color: Color, inputRange: ClosedFloatingPointRange, outputRange: ClosedFloatingPointRange, @@ -210,7 +243,7 @@ fun DirectionalMotionSpec.computeOutputValueRange( } private data class DebugMotionValueGraphElement( - val motionValue: MotionValue, + val motionValue: MotionValueState, val color: Color, val inputRange: ClosedFloatingPointRange, val outputRange: ClosedFloatingPointRange, @@ -238,7 +271,7 @@ private data class DebugMotionValueGraphElement( } private class DebugMotionValueGraphNode( - motionValue: MotionValue, + motionValue: MotionValueState, var color: Color, var inputRange: ClosedFloatingPointRange, var outputRange: ClosedFloatingPointRange, diff --git a/mechanics/src/com/android/mechanics/debug/MotionValueDebugger.kt b/mechanics/src/com/android/mechanics/debug/MotionValueDebugger.kt index 3c0109d..ac8d634 100644 --- a/mechanics/src/com/android/mechanics/debug/MotionValueDebugger.kt +++ b/mechanics/src/com/android/mechanics/debug/MotionValueDebugger.kt @@ -16,93 +16,87 @@ package com.android.mechanics.debug +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.staticCompositionLocalOf import androidx.compose.ui.Modifier +import androidx.compose.ui.node.CompositionLocalConsumerModifierNode import androidx.compose.ui.node.DelegatableNode import androidx.compose.ui.node.ModifierNodeElement -import androidx.compose.ui.node.TraversableNode -import androidx.compose.ui.node.findNearestAncestor +import androidx.compose.ui.node.ObserverModifierNode +import androidx.compose.ui.node.currentValueOf +import androidx.compose.ui.node.observeReads import androidx.compose.ui.platform.InspectorInfo -import com.android.mechanics.MotionValue -import com.android.mechanics.debug.MotionValueDebuggerNode.Companion.TRAVERSAL_NODE_KEY +import com.android.mechanics.MotionValueState import kotlinx.coroutines.DisposableHandle -/** State for the [MotionValueDebugger]. */ -sealed interface MotionValueDebuggerState { - val observedMotionValues: List -} +/** Keeps track of MotionValues that are registered for debug-inspection. */ +class MotionValueDebugController { + private val observedMotionValues = mutableStateListOf() + + /** + * Registers a [MotionValueState] to be debugged. + * + * Clients must call [DisposableHandle.dispose] when done. + */ + fun register(motionValue: MotionValueState): DisposableHandle { + observedMotionValues.add(motionValue) + return DisposableHandle { observedMotionValues.remove(motionValue) } + } -/** Factory for [MotionValueDebugger]. */ -fun MotionValueDebuggerState(): MotionValueDebuggerState { - return MotionValueDebuggerStateImpl() + /** The currently registered `MotionValues`. */ + val observed: List + get() = observedMotionValues } -/** Collector for [MotionValue]s in the Node subtree that should be observed for debug purposes. */ -fun Modifier.motionValueDebugger(state: MotionValueDebuggerState): Modifier = - this.then(MotionValueDebuggerElement(state as MotionValueDebuggerStateImpl)) +/** Composition-local to provide a [MotionValueDebugController]. */ +val LocalMotionValueDebugController = staticCompositionLocalOf { null } /** - * [motionValueDebugger]'s interface, nodes in the subtree of a [motionValueDebugger] can retrieve - * it using [findMotionValueDebugger]. + * Provides a [MotionValueDebugController], to which [MotionValue]s within [content] can be + * registered to. + * + * With [enableDebugger] set to `false` (or this composable not being in the composition in the + * first place), downstream [debugMotionValue] and [DebugEffect] will be no-ops. */ -sealed interface MotionValueDebugger { - fun register(motionValue: MotionValue): DisposableHandle -} - -/** Finds a [MotionValueDebugger] that was registered via a [motionValueDebugger] modifier. */ -fun DelegatableNode.findMotionValueDebugger(): MotionValueDebugger? { - return findNearestAncestor(TRAVERSAL_NODE_KEY) as? MotionValueDebugger +@Composable +fun MotionValueDebuggerProvider(enableDebugger: Boolean = true, content: @Composable () -> Unit) { + val debugger = + remember(enableDebugger) { if (enableDebugger) MotionValueDebugController() else null } + CompositionLocalProvider(LocalMotionValueDebugController provides debugger) { content() } } -/** Registers the motion value for debugging with the parent [MotionValue]. */ -fun Modifier.debugMotionValue(motionValue: MotionValue): Modifier = +/** Registers the [motionValue] with the [LocalMotionValueDebugController], if available. */ +fun Modifier.debugMotionValue(motionValue: MotionValueState): Modifier = this.then(DebugMotionValueElement(motionValue)) -internal class MotionValueDebuggerNode(internal var state: MotionValueDebuggerStateImpl) : - Modifier.Node(), TraversableNode, MotionValueDebugger { - - override val traverseKey = TRAVERSAL_NODE_KEY - - override fun register(motionValue: MotionValue): DisposableHandle { - val state = state - state.observedMotionValues.add(motionValue) - return DisposableHandle { state.observedMotionValues.remove(motionValue) } - } - - companion object { - const val TRAVERSAL_NODE_KEY = "com.android.mechanics.debug.DEBUG_CONNECTOR_NODE_KEY" - } -} - -private data class MotionValueDebuggerElement(val state: MotionValueDebuggerStateImpl) : - ModifierNodeElement() { - override fun create(): MotionValueDebuggerNode = MotionValueDebuggerNode(state) - - override fun InspectorInfo.inspectableProperties() { - // Intentionally empty - } - - override fun update(node: MotionValueDebuggerNode) { - check(node.state === state) +/** Registers the [motionValue] with the [LocalMotionValueDebugController], if available. */ +@Composable +fun DebugEffect(motionValue: MotionValueState) { + val debugger = LocalMotionValueDebugController.current + if (debugger != null) { + DisposableEffect(debugger, motionValue) { + val handle = debugger.register(motionValue) + onDispose { handle.dispose() } + } } } -internal class DebugMotionValueNode(motionValue: MotionValue) : Modifier.Node() { - - private var debugger: MotionValueDebugger? = null - - internal var motionValue = motionValue - set(value) { - registration?.dispose() - registration = debugger?.register(value) - field = value - } +/** + * [DelegatableNode] to register the [motionValue] with the [LocalMotionValueDebugController], if + * available. + */ +class DebugMotionValueNode(motionValue: MotionValueState) : + Modifier.Node(), DelegatableNode, CompositionLocalConsumerModifierNode, ObserverModifierNode { + private var debugger: MotionValueDebugController? = null internal var registration: DisposableHandle? = null override fun onAttach() { - debugger = findMotionValueDebugger() - registration = debugger?.register(motionValue) + onObservedReadsChanged() } override fun onDetach() { @@ -110,9 +104,21 @@ internal class DebugMotionValueNode(motionValue: MotionValue) : Modifier.Node() registration?.dispose() registration = null } + + override fun onObservedReadsChanged() { + registration?.dispose() + observeReads { debugger = currentValueOf(LocalMotionValueDebugController) } + registration = debugger?.register(motionValue) + } + + var motionValue = motionValue + set(value) { + registration = debugger?.register(value) + field = value + } } -private data class DebugMotionValueElement(val motionValue: MotionValue) : +private data class DebugMotionValueElement(val motionValue: MotionValueState) : ModifierNodeElement() { override fun create(): DebugMotionValueNode = DebugMotionValueNode(motionValue) @@ -124,7 +130,3 @@ private data class DebugMotionValueElement(val motionValue: MotionValue) : node.motionValue = motionValue } } - -internal class MotionValueDebuggerStateImpl : MotionValueDebuggerState { - override val observedMotionValues: MutableList = mutableStateListOf() -} diff --git a/mechanics/src/com/android/mechanics/effects/CommonSemantics.kt b/mechanics/src/com/android/mechanics/effects/CommonSemantics.kt new file mode 100644 index 0000000..3c89e34 --- /dev/null +++ b/mechanics/src/com/android/mechanics/effects/CommonSemantics.kt @@ -0,0 +1,23 @@ +/* + * Copyright (C) 2025 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.effects + +import com.android.mechanics.spec.SemanticKey + +object CommonSemantics { + val RestingValueKey = SemanticKey("") +} diff --git a/mechanics/src/com/android/mechanics/effects/MagneticDetach.kt b/mechanics/src/com/android/mechanics/effects/MagneticDetach.kt index 1e4e38b..3df1a26 100644 --- a/mechanics/src/com/android/mechanics/effects/MagneticDetach.kt +++ b/mechanics/src/com/android/mechanics/effects/MagneticDetach.kt @@ -21,8 +21,11 @@ package com.android.mechanics.effects import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp -import androidx.compose.ui.util.lerp +import com.android.mechanics.haptics.BreakpointHaptics +import com.android.mechanics.haptics.HapticsExperimentalApi +import com.android.mechanics.haptics.SegmentHaptics import com.android.mechanics.spec.BreakpointKey +import com.android.mechanics.spec.ChangeSegmentHandlers.DirectionChangePreservesCurrentValue import com.android.mechanics.spec.ChangeSegmentHandlers.PreventDirectionChangeWithinCurrentSegment import com.android.mechanics.spec.InputDirection import com.android.mechanics.spec.Mapping @@ -56,6 +59,7 @@ class MagneticDetach( private val attachScale: Float = Defaults.AttachDetachScale * (attachPosition / detachPosition), private val detachSpring: SpringParameters = Defaults.Spring, private val attachSpring: SpringParameters = Defaults.Spring, + private val enableHaptics: Boolean = false, ) : Effect.PlaceableAfter, Effect.PlaceableBefore { init { @@ -96,6 +100,7 @@ class MagneticDetach( } /* Effect is attached at minLimit, and detaches at maxLimit. */ + @OptIn(HapticsExperimentalApi::class) private fun EffectApplyScope.createPlacedAfterSpec( minLimit: Float, minLimitKey: BreakpointKey, @@ -115,12 +120,32 @@ class MagneticDetach( val scaledDetachValue = attachedValue + (detachedValue - attachedValue) * detachScale val scaledReattachValue = attachedValue + (reattachValue - attachedValue) * attachScale + // Haptic specs + val tensionHaptics = + if (enableHaptics) { + SegmentHaptics.SpringTension(anchorPointPx = minLimit) + } else { + SegmentHaptics.None + } + val thresholdHaptics = + if (enableHaptics) { + BreakpointHaptics.GenericThreshold + } else { + BreakpointHaptics.None + } + val attachKey = BreakpointKey("attach") + forward( initialMapping = Mapping.Linear(minLimit, attachedValue, maxLimit, scaledDetachValue), + initialSegmentHaptics = tensionHaptics, semantics = attachedSemantics, ) { - after(spring = detachSpring, semantics = detachedSemantics) + after( + spring = detachSpring, + semantics = detachedSemantics, + breakpointHaptics = thresholdHaptics, + ) before(semantics = listOf(semanticAttachedValue with null)) } @@ -135,6 +160,7 @@ class MagneticDetach( spring = attachSpring, semantics = detachedSemantics, mapping = baseMapping, + breakpointHaptics = thresholdHaptics, ) before(semantics = listOf(semanticAttachedValue with null)) after(semantics = listOf(semanticAttachedValue with null)) @@ -144,8 +170,6 @@ class MagneticDetach( beforeDetachSegment = SegmentKey(minLimitKey, maxLimitKey, InputDirection.Max), beforeAttachSegment = SegmentKey(attachKey, maxLimitKey, InputDirection.Min), afterAttachSegment = SegmentKey(minLimitKey, attachKey, InputDirection.Min), - minLimit = minLimit, - maxLimit = maxLimit, ) } @@ -195,8 +219,6 @@ class MagneticDetach( beforeDetachSegment = SegmentKey(minLimitKey, maxLimitKey, InputDirection.Min), beforeAttachSegment = SegmentKey(minLimitKey, attachKey, InputDirection.Max), afterAttachSegment = SegmentKey(attachKey, maxLimitKey, InputDirection.Max), - minLimit = minLimit, - maxLimit = maxLimit, ) } @@ -204,8 +226,6 @@ class MagneticDetach( beforeDetachSegment: SegmentKey, beforeAttachSegment: SegmentKey, afterAttachSegment: SegmentKey, - minLimit: Float, - maxLimit: Float, ) { // Suppress direction change during detach. This prevents snapping to the origin when // changing the direction while detaching. @@ -216,44 +236,6 @@ class MagneticDetach( // When changing direction after re-attaching, the pre-detach ratio is tweaked to // interpolate between the direction change-position and the detach point. - addSegmentHandler(afterAttachSegment) { currentSegment, newInput, newDirection -> - val nextSegment = segmentAtInput(newInput, newDirection) - if (nextSegment.key == beforeDetachSegment) { - nextSegment.copy( - mapping = - switchMappingWithSamePivotValue( - currentSegment.mapping, - nextSegment.mapping, - minLimit, - newInput, - maxLimit, - ) - ) - } else { - nextSegment - } - } - } - - private fun switchMappingWithSamePivotValue( - source: Mapping, - target: Mapping, - minLimit: Float, - pivot: Float, - maxLimit: Float, - ): Mapping { - val minValue = target.map(minLimit) - val pivotValue = source.map(pivot) - val maxValue = target.map(maxLimit) - - return Mapping { input -> - if (input <= pivot) { - val t = (input - minLimit) / (pivot - minLimit) - lerp(minValue, pivotValue, t) - } else { - val t = (input - pivot) / (maxLimit - pivot) - lerp(pivotValue, maxValue, t) - } - } + addSegmentHandler(afterAttachSegment, DirectionChangePreservesCurrentValue) } } diff --git a/mechanics/src/com/android/mechanics/effects/RevealOnThreshold.kt b/mechanics/src/com/android/mechanics/effects/RevealOnThreshold.kt index 124f031..075c9fd 100644 --- a/mechanics/src/com/android/mechanics/effects/RevealOnThreshold.kt +++ b/mechanics/src/com/android/mechanics/effects/RevealOnThreshold.kt @@ -26,9 +26,13 @@ import com.android.mechanics.spec.builder.EffectApplyScope import com.android.mechanics.spec.builder.EffectPlacement /** An effect that reveals a component when the available space reaches a certain threshold. */ -data class RevealOnThreshold(val minSize: Dp = Defaults.MinSize) : Effect.PlaceableBetween { +data class RevealOnThreshold( + val minSize: Dp = Defaults.MinSize, + val cornerMaxSize: Dp = Defaults.CornerMaxSize, +) : Effect.PlaceableBetween { init { require(minSize >= 0.dp) + require(cornerMaxSize >= 0.dp) } override fun EffectApplyScope.createSpec( @@ -52,5 +56,6 @@ data class RevealOnThreshold(val minSize: Dp = Defaults.MinSize) : Effect.Placea object Defaults { val MinSize: Dp = 8.dp + val CornerMaxSize: Dp = 32.dp } } diff --git a/mechanics/src/com/android/mechanics/effects/Toggle.kt b/mechanics/src/com/android/mechanics/effects/Toggle.kt new file mode 100644 index 0000000..f39cbba --- /dev/null +++ b/mechanics/src/com/android/mechanics/effects/Toggle.kt @@ -0,0 +1,176 @@ +/* + * Copyright (C) 2025 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.effects + +import com.android.mechanics.spec.BreakpointKey +import com.android.mechanics.spec.ChangeSegmentHandlers.DirectionChangePreservesCurrentValue +import com.android.mechanics.spec.ChangeSegmentHandlers.PreventDirectionChangeWithinCurrentSegment +import com.android.mechanics.spec.Guarantee +import com.android.mechanics.spec.InputDirection +import com.android.mechanics.spec.Mapping +import com.android.mechanics.spec.SegmentKey +import com.android.mechanics.spec.SemanticKey +import com.android.mechanics.spec.builder.Effect +import com.android.mechanics.spec.builder.EffectApplyScope +import com.android.mechanics.spec.builder.EffectPlacemenType +import com.android.mechanics.spec.builder.EffectPlacement +import com.android.mechanics.spec.with +import com.android.mechanics.spring.SpringParameters + +/** + * A gesture effect that toggles the output value between the placement's `start` and `end` values. + * + * The toggle action is triggered when the input changes by a specified fraction ([toggleFraction]) + * of the total input range, measured from the start of the effect. + * + * The logical state of the toggle is exposed via the SemanticKey [stateKey], and is either + * [minState] or [maxState], based on the input gesture's progress. + * + * @param T The type of the state being toggled. + * @property stateKey A [SemanticKey] used to identify the current state of the toggle (either + * [minState] or [maxState]). + * @property minState The value representing the logical state when toggled to the `min` side. + * @property minState The value representing the logical state when toggled to the `max` side. + * @property restingValueKey A [SemanticKey] used to identify the resting value of the input. + * @property toggleFraction The fraction of the input range (between `minLimit` and `maxLimit` of + * the effect placement) at which the toggle action occurs. For example, a value of 0.7 means the + * toggle happens when the input has covered 70% of the distance from `minLimit` towards + * `maxLimit`. + * @property preToggleScale A scaling factor applied to the output value *before* the toggle point + * is reached. This controls how much the output changes leading up to the toggle. + * @property postToggleScale A scaling factor applied to the output value *after* the toggle point + * is reached. This controls the initial change in output immediately after toggling. + * @property spring The [SpringParameters] used for the animation when the toggle action occurs. + * This defines the physics of the transition between states. + */ +class Toggle( + private val stateKey: SemanticKey, + private val minState: T, + private val maxState: T, + private val restingValueKey: SemanticKey = CommonSemantics.RestingValueKey, + private val toggleFraction: Float = Defaults.ToggleFraction, + private val preToggleScale: Float = Defaults.PreToggleScale, + private val postToggleScale: Float = Defaults.PostToggleScale, + private val spring: SpringParameters = Defaults.Spring, +) : Effect.PlaceableBetween { + + override fun EffectApplyScope.createSpec( + minLimit: Float, + minLimitKey: BreakpointKey, + maxLimit: Float, + maxLimitKey: BreakpointKey, + placement: EffectPlacement, + ) { + check(placement.type == EffectPlacemenType.Between) + val minValue = baseValue(minLimit) + val maxValue = baseValue(maxLimit) + val valueRange = maxValue - minValue + + val distance = maxLimit - minLimit + + val minTargetSemantics = listOf(restingValueKey with minValue, stateKey with minState) + val maxTargetSemantics = listOf(restingValueKey with maxValue, stateKey with maxState) + + val toggleKey = BreakpointKey("toggle") + + val forwardTogglePos = minLimit + distance * toggleFraction + forward( + initialMapping = + Mapping.Linear( + minLimit, + minValue, + forwardTogglePos, + minValue + valueRange * preToggleScale, + ), + semantics = minTargetSemantics, + ) { + target( + forwardTogglePos, + from = maxValue - valueRange * postToggleScale, + to = maxValue, + spring = spring, + semantics = maxTargetSemantics, + key = toggleKey, + guarantee = Guarantee.GestureDragDelta(distance * 2), + ) + } + + val reverseTogglePos = minLimit + distance * (1 - toggleFraction) + backward( + initialMapping = + Mapping.Linear( + minLimit, + minValue, + reverseTogglePos, + minValue + valueRange * postToggleScale, + ), + semantics = minTargetSemantics, + ) { + target( + reverseTogglePos, + from = maxValue - valueRange * preToggleScale, + to = maxValue, + spring = spring, + key = toggleKey, + semantics = maxTargetSemantics, + guarantee = Guarantee.GestureDragDelta(distance * 2), + ) + } + + // Before toggling, suppress direction change + addSegmentHandler( + SegmentKey(minLimitKey, toggleKey, InputDirection.Max), + PreventDirectionChangeWithinCurrentSegment, + ) + addSegmentHandler( + SegmentKey(toggleKey, maxLimitKey, InputDirection.Min), + PreventDirectionChangeWithinCurrentSegment, + ) + + // after toggling, ensure a direction change does + addSegmentHandler( + SegmentKey(toggleKey, maxLimitKey, InputDirection.Max), + DirectionChangePreservesCurrentValue, + ) + + addSegmentHandler( + SegmentKey(minLimitKey, toggleKey, InputDirection.Min), + DirectionChangePreservesCurrentValue, + ) + } + + object Defaults { + val ToggleFraction = 0.7f + val PreToggleScale = 0.2f + val PostToggleScale = 0.01f + val Spring = SpringParameters(stiffness = 800f, dampingRatio = 0.95f) + } +} + +/** + * Convenience implementation of a [Toggle] effect for an expanding / collapsing element. + * + * This object provides a pre-configured [Toggle] specifically designed for elements that can be + * expanded or collapsed. It exposes the logical expansion state via the semantic [IsExpandedKey]. + */ +object ExpansionToggle { + /** Semantic key for a boolean flag indicating whether the element is expanded. */ + val IsExpandedKey: SemanticKey = SemanticKey("IsToggleExpanded") + + /** Toggle effect with default values. */ + val Default = Toggle(IsExpandedKey, minState = false, maxState = true) +} diff --git a/mechanics/src/com/android/mechanics/haptics/HapticPlayer.kt b/mechanics/src/com/android/mechanics/haptics/HapticPlayer.kt new file mode 100644 index 0000000..458d523 --- /dev/null +++ b/mechanics/src/com/android/mechanics/haptics/HapticPlayer.kt @@ -0,0 +1,52 @@ +/* + * Copyright (C) 2025 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.haptics + +interface HapticPlayer { + + fun playSegmentHaptics( + segmentHaptics: SegmentHaptics, + spatialInput: Float, + spatialVelocity: Float, + ) + + fun playBreakpointHaptics( + breakpointHaptics: BreakpointHaptics, + spatialInput: Float, + spatialVelocity: Float, + ) + + /** Get the minimum interval required for haptics to play */ + fun getPlaybackIntervalNanos(): Long = 0L + + companion object { + val NoPlayer = + object : HapticPlayer { + override fun playSegmentHaptics( + segmentHaptics: SegmentHaptics, + spatialInput: Float, + spatialVelocity: Float, + ) {} + + override fun playBreakpointHaptics( + breakpointHaptics: BreakpointHaptics, + spatialInput: Float, + spatialVelocity: Float, + ) {} + } + } +} diff --git a/mechanics/src/com/android/mechanics/haptics/HapticTypes.kt b/mechanics/src/com/android/mechanics/haptics/HapticTypes.kt new file mode 100644 index 0000000..51acb06 --- /dev/null +++ b/mechanics/src/com/android/mechanics/haptics/HapticTypes.kt @@ -0,0 +1,59 @@ +/* + * Copyright (C) 2025 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.haptics + +/** + * Describes haptics triggered when crossing a breakpoint. + * + * Important: This is a complete enumeration of all effects supported. + */ +sealed class BreakpointHaptics { + + /** No Haptics. */ + data object None : BreakpointHaptics() + + /** Haptics force determined by the discontinuity delta and the breakpoint's spring. */ + @HapticsExperimentalApi + data class SpringForce(val stiffness: Float, val dampingRatio: Float) : BreakpointHaptics() + + /** Play a generic threshold effect. */ + @HapticsExperimentalApi data object GenericThreshold : BreakpointHaptics() +} + +/** + * Describes haptics continuously played within a segment. + * + * Important: This is a complete enumeration of all effects supported. + */ +sealed class SegmentHaptics { + + data object None : SegmentHaptics() + + /** + * Haptics effect describing tension texture. + * + * On breakpoints, tension released is played back with an effect similar to + * [BreakpointHaptics.SpringForce] . + */ + @HapticsExperimentalApi + data class SpringTension( + val anchorPointPx: Float, + val attachedMassKg: Float = 1f, // In Kg + val stiffness: Float = 900f, // in Newtons / meter + val dampingRatio: Float = 0.95f, // unitless, + ) : SegmentHaptics() +} diff --git a/mechanics/src/com/android/mechanics/haptics/HapticsExperimentalApi.kt b/mechanics/src/com/android/mechanics/haptics/HapticsExperimentalApi.kt new file mode 100644 index 0000000..345b33e --- /dev/null +++ b/mechanics/src/com/android/mechanics/haptics/HapticsExperimentalApi.kt @@ -0,0 +1,21 @@ +/* + * Copyright (C) 2025 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.haptics + +@RequiresOptIn("This API is experimental and should not be used in general production code.") +@Retention(AnnotationRetention.BINARY) +annotation class HapticsExperimentalApi diff --git a/mechanics/src/com/android/mechanics/haptics/MetricScaling.kt b/mechanics/src/com/android/mechanics/haptics/MetricScaling.kt new file mode 100644 index 0000000..e2062fd --- /dev/null +++ b/mechanics/src/com/android/mechanics/haptics/MetricScaling.kt @@ -0,0 +1,41 @@ +/* + * Copyright (C) 2025 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.haptics + +import androidx.compose.ui.unit.Density +import kotlin.math.abs + +private const val PIXEL_INCH_CONVERSION = 25.4f / (160f * 1000) + +fun Density.pxToMeters(pxValue: Float): Meters = Meters(pxValue * (PIXEL_INCH_CONVERSION / density)) + +fun Density.pxPerSecToMetersPerSec(pxValue: Float): MetersPerSec = + MetersPerSec(pxValue * (PIXEL_INCH_CONVERSION / density)) + +@JvmInline +value class Meters(val value: Float) { + fun absoluteValue(): MetersPerSec = MetersPerSec(abs(value)) + + operator fun minus(other: Meters) = Meters(value - other.value) +} + +@JvmInline +value class MetersPerSec(val value: Float) { + fun absoluteValue(): MetersPerSec = MetersPerSec(abs(value)) + + operator fun div(other: MetersPerSec): MetersPerSec = MetersPerSec(value / other.value) +} diff --git a/mechanics/src/com/android/mechanics/haptics/SpringTensionHapticPlayer.kt b/mechanics/src/com/android/mechanics/haptics/SpringTensionHapticPlayer.kt new file mode 100644 index 0000000..efc8b0d --- /dev/null +++ b/mechanics/src/com/android/mechanics/haptics/SpringTensionHapticPlayer.kt @@ -0,0 +1,113 @@ +/* + * Copyright (C) 2025 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.haptics + +import android.Manifest +import android.os.VibrationEffect +import android.os.VibratorManager +import androidx.annotation.RequiresPermission +import androidx.compose.ui.unit.Density +import java.util.concurrent.Executor +import java.util.concurrent.Executors +import kotlin.math.abs +import kotlin.math.pow +import kotlin.math.sqrt + +@HapticsExperimentalApi +class SpringTensionHapticPlayer(private val density: Density, vibratorManager: VibratorManager) : + HapticPlayer { + + // TODO(b/443090261): We should use the MSDLPlayer to play haptics here + private val vibrator = vibratorManager.defaultVibrator + private val executor: Executor = Executors.newSingleThreadExecutor() + + @RequiresPermission(Manifest.permission.VIBRATE) + override fun playSegmentHaptics( + segmentHaptics: SegmentHaptics, + spatialInput: Float, + spatialVelocity: Float, + ) { + // TODO: Maybe this player can extend to handle other forms of haptics + if (segmentHaptics !is SegmentHaptics.SpringTension) return + + // 1. Convert the inputs in pixels to metric units + val distance = density.pxToMeters(abs(spatialInput - segmentHaptics.anchorPointPx)) + val velocity = + density.pxPerSecToMetersPerSec(spatialVelocity.coerceAtMost(MAX_VELOCITY_PX_PER_SEC)) + + // 2. Derive a force in Newton from the spring tension model and the metric inputs + val damperConstant = + 2f * + segmentHaptics.attachedMassKg * + segmentHaptics.dampingRatio * + sqrt(segmentHaptics.stiffness / segmentHaptics.attachedMassKg) + val force = + segmentHaptics.stiffness * distance.value + + damperConstant * velocity.absoluteValue().value + + // 3. Divide the force by MAX_FORCE to map the values in Newtons to the 0..1 range + // 4. Multiply the proportion by MaX_INPUT_VIBRATION_SCALE to cap the scale + // 5. Apply a power function to compensate for the logarithmic human perception. + val vibrationScale = + (force * MAX_INPUT_VIBRATION_SCALE / MAX_FORCE).pow(VIBRATION_SCALE_EXPONENT) + val compensatedScale = + vibrationScale.pow(VIBRATION_PERCEPTION_EXPONENT).coerceAtMost(maximumValue = 1f) + + // Play the texture. + // TODO(b/443090261): We should play MSDLToken.DRAG_INDICATOR_CONTINUOUS + val composition = VibrationEffect.startComposition() + repeat(5) { + composition.addPrimitive( + VibrationEffect.Composition.PRIMITIVE_LOW_TICK, + compensatedScale, + ) + } + vibrate(composition.compose()) + } + + @RequiresPermission(Manifest.permission.VIBRATE) + override fun playBreakpointHaptics( + breakpointHaptics: BreakpointHaptics, + spatialInput: Float, + spatialVelocity: Float, + ) { + if (breakpointHaptics != BreakpointHaptics.GenericThreshold) return + // TODO: This could be more expressive by using the inputs + + // TODO(b/443090261): We should play MSDLToken.SWIPE_THRESHOLD_INDICATOR + val effect = + VibrationEffect.startComposition() + .addPrimitive(VibrationEffect.Composition.PRIMITIVE_CLICK, 0.7f, 0) + .compose() + vibrate(effect) + } + + // Use 60 ms because, in theory, this is how long the DRAG_INDICATOR_CONTINUOUS token takes + override fun getPlaybackIntervalNanos(): Long = 60_000L + + @RequiresPermission(Manifest.permission.VIBRATE) + private fun vibrate(vibrationEffect: VibrationEffect) = + executor.execute { vibrator.vibrate(vibrationEffect) } + + companion object { + private const val MAX_FORCE = 4f // In Newtons + private const val MAX_INPUT_VIBRATION_SCALE = 0.2f + private const val VIBRATION_SCALE_EXPONENT = 1.5f + private const val VIBRATION_PERCEPTION_EXPONENT = 1 / 0.89f + private const val MAX_VELOCITY_PX_PER_SEC = 2000f + } +} diff --git a/mechanics/src/com/android/mechanics/impl/ComputationInput.kt b/mechanics/src/com/android/mechanics/impl/ComputationInput.kt index 23ac183..3d0175b 100644 --- a/mechanics/src/com/android/mechanics/impl/ComputationInput.kt +++ b/mechanics/src/com/android/mechanics/impl/ComputationInput.kt @@ -98,4 +98,7 @@ internal interface LastFrameState { val lastGestureDragOffset: Float val directMappedVelocity: Float + + /** Last time that haptics played */ + var lastHapticsTimeNanos: Long } diff --git a/mechanics/src/com/android/mechanics/impl/Computations.kt b/mechanics/src/com/android/mechanics/impl/Computations.kt index 2ac9574..2287c67 100644 --- a/mechanics/src/com/android/mechanics/impl/Computations.kt +++ b/mechanics/src/com/android/mechanics/impl/Computations.kt @@ -22,6 +22,7 @@ import androidx.compose.ui.util.fastCoerceIn import androidx.compose.ui.util.fastIsFinite import androidx.compose.ui.util.lerp import com.android.mechanics.MotionValue.Companion.TAG +import com.android.mechanics.haptics.BreakpointHaptics import com.android.mechanics.spec.Guarantee import com.android.mechanics.spec.InputDirection import com.android.mechanics.spec.Mapping @@ -36,20 +37,32 @@ internal abstract class Computations : CurrentFrameInput, LastFrameState, Static val segment: SegmentData, val guarantee: GuaranteeState, val animation: DiscontinuityAnimation, + val breakpointHaptics: BreakpointHaptics?, ) // currentComputedValues input - private var memoizedSpec: MotionSpec? = null + private var memoizedSpec: MotionSpec = MotionSpec.InitiallyUndefined private var memoizedInput: Float = Float.MIN_VALUE private var memoizedAnimationTimeNanos: Long = Long.MIN_VALUE private var memoizedDirection: InputDirection = InputDirection.Min // currentComputedValues output - private lateinit var memoizedComputedValues: ComputedValues + private var memoizedComputedValues: ComputedValues = + ComputedValues( + MotionSpec.InitiallyUndefined.segmentAtInput(memoizedInput, memoizedDirection), + GuaranteeState.Inactive, + DiscontinuityAnimation.None, + BreakpointHaptics.None, + ) internal val currentComputedValues: ComputedValues get() { val currentSpec: MotionSpec = spec + if (currentSpec == MotionSpec.InitiallyUndefined) { + requireNoMotionSpecSet() + return memoizedComputedValues + } + val currentInput: Float = currentInput val currentAnimationTimeNanos: Long = currentAnimationTimeNanos val currentDirection: InputDirection = currentDirection @@ -63,45 +76,58 @@ internal abstract class Computations : CurrentFrameInput, LastFrameState, Static return memoizedComputedValues } + val isInitialComputation = memoizedSpec == MotionSpec.InitiallyUndefined + memoizedSpec = currentSpec memoizedInput = currentInput memoizedAnimationTimeNanos = currentAnimationTimeNanos memoizedDirection = currentDirection - val segment: SegmentData = - computeSegmentData( - spec = currentSpec, - input = currentInput, - direction = currentDirection, - ) - - val segmentChange: SegmentChangeType = - getSegmentChangeType( - segment = segment, - input = currentInput, - direction = currentDirection, - ) - - val guarantee: GuaranteeState = - computeGuaranteeState( - segment = segment, - segmentChange = segmentChange, - input = currentInput, - ) - - val animation: DiscontinuityAnimation = - computeAnimation( - segment = segment, - guarantee = guarantee, - segmentChange = segmentChange, - spec = currentSpec, - input = currentInput, - animationTimeNanos = currentAnimationTimeNanos, - ) - - return ComputedValues(segment, guarantee, animation).also { - memoizedComputedValues = it - } + memoizedComputedValues = + if (isInitialComputation) { + ComputedValues( + currentSpec.segmentAtInput(currentInput, currentDirection), + GuaranteeState.Inactive, + DiscontinuityAnimation.None, + BreakpointHaptics.None, + ) + } else { + val segment: SegmentData = + computeSegmentData( + spec = currentSpec, + input = currentInput, + direction = currentDirection, + ) + + val segmentChange: SegmentChangeType = + getSegmentChangeType( + segment = segment, + input = currentInput, + direction = currentDirection, + ) + + val guarantee: GuaranteeState = + computeGuaranteeState( + segment = segment, + segmentChange = segmentChange, + input = currentInput, + ) + + val animation: DiscontinuityAnimation = + computeAnimation( + segment = segment, + guarantee = guarantee, + segmentChange = segmentChange, + spec = currentSpec, + input = currentInput, + animationTimeNanos = currentAnimationTimeNanos, + ) + + val breakpointHaptics = computeBreakpointHaptics(segment, segmentChange) + + ComputedValues(segment, guarantee, animation, breakpointHaptics) + } + return memoizedComputedValues } // currentSpringState input @@ -129,15 +155,15 @@ internal abstract class Computations : CurrentFrameInput, LastFrameState, Static lastSegment.spec == spec && lastSegment.isValidForInput(currentInput, currentDirection) - val output: Float + val computedOutput: Float get() = if (isSameSegmentAndAtRest) { lastSegment.mapping.map(currentInput) } else { - outputTarget + currentSpringState.displacement + computedOutputTarget + currentSpringState.displacement } - val outputTarget: Float + val computedOutputTarget: Float get() = if (isSameSegmentAndAtRest) { lastSegment.mapping.map(currentInput) @@ -145,7 +171,7 @@ internal abstract class Computations : CurrentFrameInput, LastFrameState, Static currentComputedValues.segment.mapping.map(currentInput) } - val isStable: Boolean + val computedIsStable: Boolean get() = if (isSameSegmentAndAtRest) { true @@ -153,7 +179,47 @@ internal abstract class Computations : CurrentFrameInput, LastFrameState, Static currentSpringState == SpringState.AtRest } - fun semanticState(semanticKey: SemanticKey): T? { + /** + * Determines if the output value is fixed. + * + * The output is considered fixed if the animation has settled and the input falls into a + * segment with a [Mapping.Fixed], and that mapping's value has not changed from the previous + * frame. + */ + val computedIsOutputFixed: Boolean + get() { + if (lastSpringState != SpringState.AtRest) { + // The spring is still settling. + return false + } + + val lastMapping = lastSegment.mapping + if (lastMapping !is Mapping.Fixed) { + // We need to compute a new output value. + return false + } + + val isSameSegment = + lastSegment.spec == spec && + lastSegment.isValidForInput(currentInput, currentDirection) + + return if (isSameSegment) { + // We are in the same fixed-value segment as the last frame. + true + } else { + val currentMapping = currentComputedValues.segment.mapping + if (currentMapping is Mapping.Fixed) { + // Both old and new mappings are fixed. The output is only considered fixed if + // their target values are identical. + lastMapping.value == currentMapping.value + } else { + // The new mapping isn't a fixed value. + false + } + } + } + + fun computedSemanticState(semanticKey: SemanticKey): T? { return with(if (isSameSegmentAndAtRest) lastSegment else currentComputedValues.segment) { spec.semanticState(semanticKey, key) } @@ -573,4 +639,43 @@ internal abstract class Computations : CurrentFrameInput, LastFrameState, Static } } } + + private fun computeBreakpointHaptics( + segment: SegmentData, + segmentChange: SegmentChangeType, + ): BreakpointHaptics? = + when (segmentChange) { + SegmentChangeType.Traverse -> segment.entryBreakpoint.breakpointHaptics + else -> null + } + + /** + * Precondition to ensure that this [Computations] has not yet been initialized with a + * MotionSpec other than [MotionSpec.InitiallyUndefined]. + * + * This precondition is added since the desired behavior of the MotionValue when toggling back + * to a [MotionSpec.InitiallyUndefined] spec is unclear. If there is a compelling usecase, this + * restriction could be lifted. + */ + private fun requireNoMotionSpecSet() { + // A MotionValue's spec can be MotionValue.Undefined initially. However, once a real spec + // has been set, it cannot be changed back to MotionValue.Undefined. + + require(memoizedSpec == MotionSpec.InitiallyUndefined) { + // memoizedSpec is only ever Undefined initially, before a motionSpec was set. + // This is used as a signal to detect if a user switches back to Undefined. + "MotionSpec must not be changed back to undefined!\n" + + " MotionValue: $label\n" + + " last MotionSpec: $memoizedSpec" + } + + // memoizedComputedValues must not have been reassigned either. + require( + with(memoizedComputedValues) { + segment.spec == MotionSpec.InitiallyUndefined && + guarantee == GuaranteeState.Inactive && + animation == DiscontinuityAnimation.None + } + ) + } } diff --git a/mechanics/src/com/android/mechanics/spec/Breakpoint.kt b/mechanics/src/com/android/mechanics/spec/Breakpoint.kt index 5ff18ed..fd92f2b 100644 --- a/mechanics/src/com/android/mechanics/spec/Breakpoint.kt +++ b/mechanics/src/com/android/mechanics/spec/Breakpoint.kt @@ -17,6 +17,7 @@ package com.android.mechanics.spec import androidx.compose.ui.util.fastIsFinite +import com.android.mechanics.haptics.BreakpointHaptics import com.android.mechanics.spring.SpringParameters /** @@ -65,12 +66,14 @@ class BreakpointKey(val debugLabel: String? = null, val identity: Any = Object() * @param spring Parameters of the spring used to animate the breakpoints discontinuity. * @param guarantee Optional constraints to accelerate the completion of the spring motion, based on * `MotionValue`'s input or other non-time signals. + * @param breakpointHaptics A description of haptics when the input crosses this breakpoint. */ data class Breakpoint( val key: BreakpointKey, val position: Float, val spring: SpringParameters, val guarantee: Guarantee, + val breakpointHaptics: BreakpointHaptics = BreakpointHaptics.None, ) : Comparable { init { @@ -89,6 +92,7 @@ data class Breakpoint( Float.NEGATIVE_INFINITY, SpringParameters.Snap, Guarantee.None, + BreakpointHaptics.None, ) /** Last breakpoint of each spec. */ @@ -98,6 +102,7 @@ data class Breakpoint( Float.POSITIVE_INFINITY, SpringParameters.Snap, Guarantee.None, + BreakpointHaptics.None, ) internal fun create( @@ -105,11 +110,19 @@ data class Breakpoint( breakpointPosition: Float, springSpec: SpringParameters, guarantee: Guarantee, + breakpointHaptics: BreakpointHaptics, ): Breakpoint { return when (breakpointKey) { BreakpointKey.MinLimit -> minLimit BreakpointKey.MaxLimit -> maxLimit - else -> Breakpoint(breakpointKey, breakpointPosition, springSpec, guarantee) + else -> + Breakpoint( + breakpointKey, + breakpointPosition, + springSpec, + guarantee, + breakpointHaptics, + ) } } } diff --git a/mechanics/src/com/android/mechanics/spec/Mapping.kt b/mechanics/src/com/android/mechanics/spec/Mapping.kt new file mode 100644 index 0000000..64a4a5d --- /dev/null +++ b/mechanics/src/com/android/mechanics/spec/Mapping.kt @@ -0,0 +1,110 @@ +/* + * Copyright (C) 2025 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.spec + +import androidx.compose.ui.util.lerp + +/** + * Maps the `input` of a [MotionValue] to the desired output value. + * + * The mapping implementation can be arbitrary, but must not produce discontinuities. + */ +fun interface Mapping { + /** Computes the [MotionValue]'s target output, given the input. */ + fun map(input: Float): Float + + /** `f(x) = x` */ + object Identity : Mapping { + override fun map(input: Float): Float { + return input + } + + override fun toString(): String { + return "Identity" + } + } + + /** `f(x) = value` */ + data class Fixed(val value: Float) : Mapping { + init { + require(value.isFinite()) + } + + override fun map(input: Float): Float { + return value + } + } + + /** `f(x) = factor*x + offset` */ + data class Linear(val factor: Float, val offset: Float = 0f) : Mapping { + init { + require(factor.isFinite()) + require(offset.isFinite()) + } + + override fun map(input: Float): Float { + return input * factor + offset + } + } + + companion object { + val Zero = Fixed(0f) + val One = Fixed(1f) + val Two = Fixed(2f) + + /** Create a linear mapping defined as a line between {in0,out0} and {in1,out1}. */ + fun Linear(in0: Float, out0: Float, in1: Float, out1: Float): Linear { + require(in0 != in1) { + "Cannot define a linear function with both inputs being the same ($in0)." + } + + val factor = (out1 - out0) / (in1 - in0) + val offset = out0 - factor * in0 + return Linear(factor, offset) + } + } +} + +/** Convenience helper to create a linear mappings */ +object LinearMappings { + + /** + * Creates a mapping defined as two line segments between {in0,out0} -> {in1,out1}, and + * {in1,out1} -> {in2,out2}. + * + * The inputs must strictly be `in0 < in1 < in2` + */ + fun linearMappingWithPivot( + in0: Float, + out0: Float, + in1: Float, + out1: Float, + in2: Float, + out2: Float, + ): Mapping { + require(in0 < in1 && in1 < in2) + return Mapping { input -> + if (input <= in1) { + val t = (input - in0) / (in1 - in0) + lerp(out0, out1, t) + } else { + val t = (input - in1) / (in2 - in1) + lerp(out1, out2, t) + } + } + } +} diff --git a/mechanics/src/com/android/mechanics/spec/MotionSpec.kt b/mechanics/src/com/android/mechanics/spec/MotionSpec.kt index 4628804..19fd71e 100644 --- a/mechanics/src/com/android/mechanics/spec/MotionSpec.kt +++ b/mechanics/src/com/android/mechanics/spec/MotionSpec.kt @@ -17,6 +17,7 @@ package com.android.mechanics.spec import androidx.compose.ui.util.fastFirstOrNull +import com.android.mechanics.haptics.SegmentHaptics import com.android.mechanics.spring.SpringParameters /** @@ -31,12 +32,14 @@ import com.android.mechanics.spring.SpringParameters * caused by setting this new spec. * @param segmentHandlers allow for custom segment-change logic, when the `MotionValue` runtime * would leave the [SegmentKey]. + * @param semantics semantics applied to the complete [MotionSpec] */ data class MotionSpec( val maxDirection: DirectionalMotionSpec, val minDirection: DirectionalMotionSpec = maxDirection, val resetSpring: SpringParameters = DefaultResetSpring, val segmentHandlers: Map = emptyMap(), + val semantics: List> = emptyList(), ) { /** The [DirectionalMotionSpec] for the specified [direction]. */ @@ -52,6 +55,16 @@ data class MotionSpec( return get(segmentKey.direction).findSegmentIndex(segmentKey) != -1 } + /** + * The semantic state for [key], as defined for the [MotionSpec]. + * + * Returns `null` if no semantic value with [key] is defined. + */ + fun semanticState(key: SemanticKey): T? { + @Suppress("UNCHECKED_CAST") + return semantics.fastFirstOrNull { it.key == key }?.value as T? + } + /** * The semantic state for [key] at segment with [segmentKey]. * @@ -60,7 +73,8 @@ data class MotionSpec( */ fun semanticState(key: SemanticKey, segmentKey: SegmentKey): T? { with(get(segmentKey.direction)) { - val semanticValues = semantics.fastFirstOrNull { it.key == key } ?: return null + val semanticValues = + semantics.fastFirstOrNull { it.key == key } ?: return semanticState(key) val segmentIndex = findSegmentIndex(segmentKey) if (segmentIndex < 0) throw NoSuchElementException() @@ -106,6 +120,7 @@ data class MotionSpec( breakpoints[idx + 1], direction, mappings[idx], + haptics[idx], ) } } @@ -135,8 +150,21 @@ data class MotionSpec( */ private val DefaultResetSpring = SpringParameters(stiffness = 1400f, dampingRatio = 1f) - /* Empty motion spec, the output is the same as the input. */ - val Empty = MotionSpec(DirectionalMotionSpec.Empty) + /* Identity motion spec, the output is the same as the input. */ + val Identity = MotionSpec(DirectionalMotionSpec.Identity) + + /** + * Placeholder to indicate that a [MotionSpec] cannot be supplied yet. + * + * As long as this spec is set, the MotionValue output is NaN. When the MotionValue is first + * supplied with an actual spec, the output value will be set immediately, without an + * animation. + * + * This must only ever be supplied as a spec for new `MotionValue`s, which never were + * supplied any other spec. Supplying this [InitiallyUndefined] spec to a MotionValue that + * has already been supplied a spec will throw an exception. + */ + val InitiallyUndefined = MotionSpec(DirectionalMotionSpec.InitiallyUndefined) } } @@ -154,12 +182,14 @@ data class MotionSpec( * element, and [Breakpoint.maxLimit] as the last element. * @param mappings All mappings in between the breakpoints, thus must always contain * `breakpoints.size - 1` elements. - * @param semantics semantics provided by this spec, must only reference to breakpoint keys included - * in [breakpoints]. + * @param haptics All segment haptics in between the breakpoints, thus must always contain + * `breakpoints.size - 1` elements. + * @param semantics Semantics that apply to the [MotionSpec]. */ data class DirectionalMotionSpec( val breakpoints: List, val mappings: List, + val haptics: List = List(mappings.size) { SegmentHaptics.None }, val semantics: List> = emptyList(), ) { /** Maps all [BreakpointKey]s used in this spec to its index in [breakpoints]. */ @@ -173,6 +203,10 @@ data class DirectionalMotionSpec( "Breakpoints are not sorted ascending ${breakpoints.map { "${it.key}@${it.position}" }}" } require(mappings.size == breakpoints.size - 1) + require(haptics.size == breakpoints.size - 1) { + "${haptics.size} segment haptics were provided but ${breakpoints.size - 1} are " + + "required" + } breakpointIndexByKey = breakpoints.mapIndexed { index, breakpoint -> breakpoint.key to index }.toMap() @@ -229,11 +263,30 @@ data class DirectionalMotionSpec( override fun toString() = toDebugString() companion object { - /* Empty spec, the full input domain is mapped to output using [Mapping.identity]. */ - val Empty = + /* Identity spec, the full input domain is mapped to output using [Mapping.identity]. */ + val Identity = DirectionalMotionSpec( listOf(Breakpoint.minLimit, Breakpoint.maxLimit), listOf(Mapping.Identity), + listOf(SegmentHaptics.None), + ) + + /** Internal marker for [MotionSpec.InitiallyUndefined]. */ + internal val InitiallyUndefined = + DirectionalMotionSpec( + listOf(Breakpoint.minLimit, Breakpoint.maxLimit), + listOf( + object : Mapping { + override fun map(input: Float): Float { + return Float.NaN + } + + override fun toString(): String { + return "InitiallyUndefined" + } + } + ), + listOf(SegmentHaptics.None), ) } } diff --git a/mechanics/src/com/android/mechanics/spec/MotionSpecDebugFormatter.kt b/mechanics/src/com/android/mechanics/spec/MotionSpecDebugFormatter.kt index 9c7f9bd..9430f6f 100644 --- a/mechanics/src/com/android/mechanics/spec/MotionSpecDebugFormatter.kt +++ b/mechanics/src/com/android/mechanics/spec/MotionSpecDebugFormatter.kt @@ -16,6 +16,8 @@ package com.android.mechanics.spec +import com.android.mechanics.haptics.SegmentHaptics + /** Returns a string representation of the [MotionSpec] for debugging by humans. */ fun MotionSpec.toDebugString(): String { return buildString { @@ -47,6 +49,7 @@ fun DirectionalMotionSpec.toDebugString(): String { appendBreakpointLine(breakpoints.first()) for (i in mappings.indices) { appendMappingLine(mappings[i], indent = 2) + appendSegmentHapticsLine(haptics[i], indent = 2) semantics.forEach { appendSemanticsLine(it.key, it.values[i], indent = 4) } appendBreakpointLine(breakpoints[i + 1]) } @@ -79,6 +82,11 @@ private fun StringBuilder.appendBreakpointLine(breakpoint: Breakpoint, indent: I append(breakpoint.spring.dampingRatio) } + append(" [") + append("breakpointHaptics=") + append(breakpoint.breakpointHaptics.toString()) + append("]") + appendLine() } @@ -103,6 +111,15 @@ private fun StringBuilder.appendMappingLine(mapping: Mapping, indent: Int = 0) { appendLine() } +private fun StringBuilder.appendSegmentHapticsLine( + segmentHaptics: SegmentHaptics, + indent: Int = 0, +) { + appendIndent(indent) + append("segment haptics: $segmentHaptics") + appendLine() +} + private fun StringBuilder.appendSemanticsLine( semanticKey: SemanticKey<*>, value: Any?, diff --git a/mechanics/src/com/android/mechanics/spec/Segment.kt b/mechanics/src/com/android/mechanics/spec/Segment.kt index d3bce7b..f212b53 100644 --- a/mechanics/src/com/android/mechanics/spec/Segment.kt +++ b/mechanics/src/com/android/mechanics/spec/Segment.kt @@ -16,6 +16,8 @@ package com.android.mechanics.spec +import com.android.mechanics.haptics.SegmentHaptics + /** * Identifies a segment in a [MotionSpec]. * @@ -49,6 +51,7 @@ data class SegmentData( val maxBreakpoint: Breakpoint, val direction: InputDirection, val mapping: Mapping, + val haptics: SegmentHaptics, ) { val key = SegmentKey(minBreakpoint.key, maxBreakpoint.key, direction) @@ -89,67 +92,6 @@ data class SegmentData( get() = minBreakpoint.position..maxBreakpoint.position override fun toString(): String { - return "SegmentData(key=$key, range=$range, mapping=$mapping)" - } -} - -/** - * Maps the `input` of a [MotionValue] to the desired output value. - * - * The mapping implementation can be arbitrary, but must not produce discontinuities. - */ -fun interface Mapping { - /** Computes the [MotionValue]'s target output, given the input. */ - fun map(input: Float): Float - - /** `f(x) = x` */ - object Identity : Mapping { - override fun map(input: Float): Float { - return input - } - - override fun toString(): String { - return "Identity" - } - } - - /** `f(x) = value` */ - data class Fixed(val value: Float) : Mapping { - init { - require(value.isFinite()) - } - - override fun map(input: Float): Float { - return value - } - } - - /** `f(x) = factor*x + offset` */ - data class Linear(val factor: Float, val offset: Float = 0f) : Mapping { - init { - require(factor.isFinite()) - require(offset.isFinite()) - } - - override fun map(input: Float): Float { - return input * factor + offset - } - } - - companion object { - val Zero = Fixed(0f) - val One = Fixed(1f) - val Two = Fixed(2f) - - /** Create a linear mapping defined as a line between {in0,out0} and {in1,out1}. */ - fun Linear(in0: Float, out0: Float, in1: Float, out1: Float): Linear { - require(in0 != in1) { - "Cannot define a linear function with both inputs being the same ($in0)." - } - - val factor = (out1 - out0) / (in1 - in0) - val offset = out0 - factor * in0 - return Linear(factor, offset) - } + return "SegmentData(key=$key, range=$range, mapping=$mapping, segmentHaptics: $haptics)" } } diff --git a/mechanics/src/com/android/mechanics/spec/SegmentChangeHandler.kt b/mechanics/src/com/android/mechanics/spec/SegmentChangeHandler.kt index b6ce6ab..e1a16d9 100644 --- a/mechanics/src/com/android/mechanics/spec/SegmentChangeHandler.kt +++ b/mechanics/src/com/android/mechanics/spec/SegmentChangeHandler.kt @@ -45,4 +45,36 @@ object ChangeSegmentHandlers { it.isValidForInput(newInput, currentSegment.direction) } } + + /** + * When changing direction, modifies the mapping of the reverse segments so that the output + * values + * + * at the min/max breakpoint are the same, yet the value at the direction change position maps + * the current output value. + */ + val DirectionChangePreservesCurrentValue: OnChangeSegmentHandler = + { currentSegment, newInput, newDirection -> + val nextSegment = segmentAtInput(newInput, newDirection) + val minLimit = nextSegment.minBreakpoint.position + val maxLimit = nextSegment.maxBreakpoint.position + + if ( + currentSegment.direction == newDirection || + minLimit == newInput && newInput == maxLimit + ) { + nextSegment + } else { + val modifiedMapping = + LinearMappings.linearMappingWithPivot( + minLimit, + nextSegment.mapping.map(minLimit), + newInput, + currentSegment.mapping.map(newInput), + maxLimit, + nextSegment.mapping.map(maxLimit), + ) + nextSegment.copy(mapping = modifiedMapping) + } + } } diff --git a/mechanics/src/com/android/mechanics/spec/builder/DirectionalBuilderImpl.kt b/mechanics/src/com/android/mechanics/spec/builder/DirectionalBuilderImpl.kt index 994927f..a5c5e31 100644 --- a/mechanics/src/com/android/mechanics/spec/builder/DirectionalBuilderImpl.kt +++ b/mechanics/src/com/android/mechanics/spec/builder/DirectionalBuilderImpl.kt @@ -16,6 +16,9 @@ package com.android.mechanics.spec.builder +import com.android.mechanics.haptics.BreakpointHaptics +import com.android.mechanics.haptics.HapticsExperimentalApi +import com.android.mechanics.haptics.SegmentHaptics import com.android.mechanics.spec.Breakpoint import com.android.mechanics.spec.BreakpointKey import com.android.mechanics.spec.DirectionalMotionSpec @@ -38,6 +41,8 @@ internal open class DirectionalBuilderImpl( internal val breakpoints = mutableListOf(Breakpoint.minLimit) internal val semantics = mutableListOf>() internal val mappings = mutableListOf() + internal val segmentHaptics = mutableListOf() + private var currentSegmentHaptics: SegmentHaptics = SegmentHaptics.None private var sourceValue: Float = Float.NaN private var targetValue: Float = Float.NaN private var fractionalMapping: Float = Float.NaN @@ -51,11 +56,14 @@ internal open class DirectionalBuilderImpl( /** Prepares the builder for invoking the [DirectionalBuilderFn] on it. */ fun prepareBuilderFn( initialMapping: Mapping = Mapping.Identity, + initialSegmentHaptics: SegmentHaptics = SegmentHaptics.None, initialSemantics: List> = emptyList(), ) { check(mappings.size == breakpoints.size - 1) + check(segmentHaptics.size == breakpoints.size - 1) mappings.add(initialMapping) + segmentHaptics.add(initialSegmentHaptics) val semanticIndex = mappings.size - 1 initialSemantics.forEach { semantic -> getSemantics(semantic.key).apply { set(semanticIndex, semantic.value) } @@ -80,6 +88,7 @@ internal open class DirectionalBuilderImpl( fun finalizeBuilderFn( atPosition: Float, key: BreakpointKey, + breakpointHaptics: BreakpointHaptics, springSpec: SpringParameters, guarantee: Guarantee, semantics: List>, @@ -87,9 +96,13 @@ internal open class DirectionalBuilderImpl( if (!(targetValue.isNaN() && fractionalMapping.isNaN())) { // Finalizing will produce the mapping and breakpoint check(mappings.size == breakpoints.size - 1) + check(segmentHaptics.size == breakpoints.size - 1) } else { // Mapping is already added, this will add the breakpoint check(mappings.size == breakpoints.size) + check(segmentHaptics.size == breakpoints.size) { + "Total segment haptics: ${segmentHaptics.size}. A total of ${breakpoints.size} was expected" + } } if (key == BreakpointKey.MaxLimit) { @@ -103,13 +116,14 @@ internal open class DirectionalBuilderImpl( } toBreakpointImpl(atPosition, key, semantics) - doAddBreakpointImpl(springSpec, guarantee) + doAddBreakpointImpl(springSpec, guarantee, breakpointHaptics) } fun finalizeBuilderFn(breakpoint: Breakpoint) = finalizeBuilderFn( breakpoint.position, breakpoint.key, + breakpoint.breakpointHaptics, breakpoint.spring, breakpoint.guarantee, emptyList(), @@ -118,26 +132,33 @@ internal open class DirectionalBuilderImpl( /* Creates the [DirectionalMotionSpec] from the current builder state. */ fun build(): DirectionalMotionSpec { require(mappings.size == breakpoints.size - 1) + require(segmentHaptics.size == breakpoints.size - 1) check(breakpoints.last() == Breakpoint.maxLimit) val segmentCount = mappings.size val semantics = semantics.map { builder -> with(builder) { build(segmentCount) } } - return DirectionalMotionSpec(breakpoints.toList(), mappings.toList(), semantics) + return DirectionalMotionSpec( + breakpoints.toList(), + mappings.toList(), + segmentHaptics.toList(), + semantics, + ) } override fun target( breakpoint: Float, from: Float, to: Float, + breakpointHaptics: BreakpointHaptics, spring: SpringParameters, guarantee: Guarantee, key: BreakpointKey, semantics: List>, ) { toBreakpointImpl(breakpoint, key, semantics) - jumpToImpl(from, spring, guarantee) + jumpToImpl(from, spring, guarantee, breakpointHaptics) continueWithTargetValueImpl(to) } @@ -145,13 +166,14 @@ internal open class DirectionalBuilderImpl( breakpoint: Float, to: Float, delta: Float, + breakpointHaptics: BreakpointHaptics, spring: SpringParameters, guarantee: Guarantee, key: BreakpointKey, semantics: List>, ) { toBreakpointImpl(breakpoint, key, semantics) - jumpByImpl(delta, spring, guarantee) + jumpByImpl(delta, spring, guarantee, breakpointHaptics) continueWithTargetValueImpl(to) } @@ -159,13 +181,14 @@ internal open class DirectionalBuilderImpl( breakpoint: Float, from: Float, fraction: Float, + breakpointHaptics: BreakpointHaptics, spring: SpringParameters, guarantee: Guarantee, key: BreakpointKey, semantics: List>, ): CanBeLastSegment { toBreakpointImpl(breakpoint, key, semantics) - jumpToImpl(from, spring, guarantee) + jumpToImpl(from, spring, guarantee, breakpointHaptics) continueWithFractionalInputImpl(fraction) return CanBeLastSegmentImpl } @@ -174,13 +197,14 @@ internal open class DirectionalBuilderImpl( breakpoint: Float, fraction: Float, delta: Float, + breakpointHaptics: BreakpointHaptics, spring: SpringParameters, guarantee: Guarantee, key: BreakpointKey, semantics: List>, ): CanBeLastSegment { toBreakpointImpl(breakpoint, key, semantics) - jumpByImpl(delta, spring, guarantee) + jumpByImpl(delta, spring, guarantee, breakpointHaptics) continueWithFractionalInputImpl(fraction) return CanBeLastSegmentImpl } @@ -188,13 +212,14 @@ internal open class DirectionalBuilderImpl( override fun fixedValue( breakpoint: Float, value: Float, + breakpointHaptics: BreakpointHaptics, spring: SpringParameters, guarantee: Guarantee, key: BreakpointKey, semantics: List>, ): CanBeLastSegment { toBreakpointImpl(breakpoint, key, semantics) - jumpToImpl(value, spring, guarantee) + jumpToImpl(value, spring, guarantee, breakpointHaptics) continueWithFixedValueImpl() return CanBeLastSegmentImpl } @@ -202,13 +227,14 @@ internal open class DirectionalBuilderImpl( override fun fixedValueFromCurrent( breakpoint: Float, delta: Float, + breakpointHaptics: BreakpointHaptics, spring: SpringParameters, guarantee: Guarantee, key: BreakpointKey, semantics: List>, ): CanBeLastSegment { toBreakpointImpl(breakpoint, key, semantics) - jumpByImpl(delta, spring, guarantee) + jumpByImpl(delta, spring, guarantee, breakpointHaptics) continueWithFixedValueImpl() return CanBeLastSegmentImpl } @@ -219,10 +245,11 @@ internal open class DirectionalBuilderImpl( guarantee: Guarantee, key: BreakpointKey, semantics: List>, + breakpointHaptics: BreakpointHaptics, mapping: Mapping, ): CanBeLastSegment { toBreakpointImpl(breakpoint, key, semantics) - continueWithImpl(mapping, spring, guarantee) + continueWithImpl(mapping, spring, guarantee, breakpointHaptics) return CanBeLastSegmentImpl } @@ -242,28 +269,45 @@ internal open class DirectionalBuilderImpl( check(sourceValue.isFinite()) mappings.add(Mapping.Fixed(sourceValue)) + segmentHaptics.add(currentSegmentHaptics) sourceValue = Float.NaN } - private fun jumpToImpl(value: Float, spring: SpringParameters, guarantee: Guarantee) { + private fun jumpToImpl( + value: Float, + spring: SpringParameters, + guarantee: Guarantee, + breakpointHaptics: BreakpointHaptics, + ) { check(sourceValue.isNaN()) - doAddBreakpointImpl(spring, guarantee) + doAddBreakpointImpl(spring, guarantee, breakpointHaptics) sourceValue = value } - private fun jumpByImpl(delta: Float, spring: SpringParameters, guarantee: Guarantee) { + private fun jumpByImpl( + delta: Float, + spring: SpringParameters, + guarantee: Guarantee, + breakpointHaptics: BreakpointHaptics, + ) { check(sourceValue.isNaN()) - val breakpoint = doAddBreakpointImpl(spring, guarantee) + val breakpoint = doAddBreakpointImpl(spring, guarantee, breakpointHaptics) sourceValue = mappings.last().map(breakpoint.position) + delta } - private fun continueWithImpl(mapping: Mapping, spring: SpringParameters, guarantee: Guarantee) { + private fun continueWithImpl( + mapping: Mapping, + spring: SpringParameters, + guarantee: Guarantee, + breakpointHaptics: BreakpointHaptics, + ) { check(sourceValue.isNaN()) - doAddBreakpointImpl(spring, guarantee) + doAddBreakpointImpl(spring, guarantee, breakpointHaptics) mappings.add(mapping) + segmentHaptics.add(currentSegmentHaptics) } private fun toBreakpointImpl( @@ -301,6 +345,7 @@ internal open class DirectionalBuilderImpl( } mappings.add(mapping) + segmentHaptics.add(currentSegmentHaptics) targetValue = Float.NaN sourceValue = Float.NaN fractionalMapping = Float.NaN @@ -320,6 +365,7 @@ internal open class DirectionalBuilderImpl( private fun doAddBreakpointImpl( springSpec: SpringParameters, guarantee: Guarantee, + breakpointHaptics: BreakpointHaptics, ): Breakpoint { val breakpoint = Breakpoint.create( @@ -327,6 +373,7 @@ internal open class DirectionalBuilderImpl( breakpointPosition, springSpec, guarantee, + breakpointHaptics, ) breakpoints.add(breakpoint) @@ -335,6 +382,27 @@ internal open class DirectionalBuilderImpl( return breakpoint } + + private fun beginHaptics(segmentHaptics: SegmentHaptics) { + currentSegmentHaptics = segmentHaptics + } + + private fun endHaptics() { + currentSegmentHaptics = SegmentHaptics.None + } + + @HapticsExperimentalApi + override fun haptics( + segmentHaptics: SegmentHaptics, + block: DirectionalBuilderScope.() -> T, + ) { + beginHaptics(segmentHaptics) + try { + block() + } finally { + endHaptics() + } + } } internal class SegmentSemanticValuesBuilder(val key: SemanticKey) { diff --git a/mechanics/src/com/android/mechanics/spec/builder/DirectionalBuilderScope.kt b/mechanics/src/com/android/mechanics/spec/builder/DirectionalBuilderScope.kt index 9eacd8f..8fef629 100644 --- a/mechanics/src/com/android/mechanics/spec/builder/DirectionalBuilderScope.kt +++ b/mechanics/src/com/android/mechanics/spec/builder/DirectionalBuilderScope.kt @@ -16,8 +16,10 @@ package com.android.mechanics.spec.builder +import com.android.mechanics.haptics.BreakpointHaptics +import com.android.mechanics.haptics.HapticsExperimentalApi +import com.android.mechanics.haptics.SegmentHaptics import com.android.mechanics.spec.BreakpointKey -import com.android.mechanics.spec.DirectionalMotionSpec import com.android.mechanics.spec.Guarantee import com.android.mechanics.spec.Mapping import com.android.mechanics.spec.SemanticKey @@ -48,6 +50,7 @@ interface DirectionalBuilderScope { * @param from The output value at the previous breakpoint, explicitly setting the starting * point for the linear mapping. * @param to The desired output value at the new breakpoint. + * @param breakpointHaptics Haptics at the breakpoint that ends the current segment. * @param spring The [SpringParameters] for the transition to this breakpoint. Defaults to * [defaultSpring]. * @param guarantee The animation guarantee for this transition. Defaults to [Guarantee.None]. @@ -59,6 +62,7 @@ interface DirectionalBuilderScope { breakpoint: Float, from: Float, to: Float, + breakpointHaptics: BreakpointHaptics = BreakpointHaptics.None, spring: SpringParameters = defaultSpring, guarantee: Guarantee = Guarantee.None, key: BreakpointKey = BreakpointKey(), @@ -77,6 +81,7 @@ interface DirectionalBuilderScope { * next. * @param to The desired output value at the new breakpoint. * @param delta An optional offset to apply to the calculated starting value. Defaults to 0f. + * @param breakpointHaptics Haptics at the breakpoint that ends the current segment. * @param spring The [SpringParameters] for the transition to this breakpoint. Defaults to * [defaultSpring]. * @param guarantee The animation guarantee for this transition. Defaults to [Guarantee.None]. @@ -88,6 +93,7 @@ interface DirectionalBuilderScope { breakpoint: Float, to: Float, delta: Float = 0f, + breakpointHaptics: BreakpointHaptics = BreakpointHaptics.None, spring: SpringParameters = defaultSpring, guarantee: Guarantee = Guarantee.None, key: BreakpointKey = BreakpointKey(), @@ -107,6 +113,7 @@ interface DirectionalBuilderScope { * point for the linear mapping. * @param fraction The fractional multiplier applied to the input difference between * breakpoints. + * @param breakpointHaptics Haptics at the breakpoint that ends the current segment. * @param spring The [SpringParameters] for the transition to this breakpoint. Defaults to * [defaultSpring]. * @param guarantee The animation guarantee for this transition. Defaults to [Guarantee.None]. @@ -118,6 +125,7 @@ interface DirectionalBuilderScope { breakpoint: Float, from: Float, fraction: Float, + breakpointHaptics: BreakpointHaptics = BreakpointHaptics.None, spring: SpringParameters = defaultSpring, guarantee: Guarantee = Guarantee.None, key: BreakpointKey = BreakpointKey(), @@ -136,6 +144,7 @@ interface DirectionalBuilderScope { * @param fraction The fractional multiplier applied to the input difference between * breakpoints. * @param delta An optional offset to apply to the calculated starting value. Defaults to 0f. + * @param breakpointHaptics Haptics at the breakpoint that ends the current segment. * @param spring The [SpringParameters] for the transition to this breakpoint. Defaults to * [defaultSpring]. * @param guarantee The animation guarantee for this transition. Defaults to [Guarantee.None]. @@ -147,6 +156,7 @@ interface DirectionalBuilderScope { breakpoint: Float, fraction: Float, delta: Float = 0f, + breakpointHaptics: BreakpointHaptics = BreakpointHaptics.None, spring: SpringParameters = defaultSpring, guarantee: Guarantee = Guarantee.None, key: BreakpointKey = BreakpointKey(), @@ -162,6 +172,7 @@ interface DirectionalBuilderScope { * @param breakpoint The breakpoint defining the end of the current segment and the start of the * next. * @param value The constant output value for this segment. + * @param breakpointHaptics Haptics at the breakpoint that ends the current segment. * @param spring The [SpringParameters] for the transition to this breakpoint. Defaults to * [defaultSpring]. * @param guarantee The animation guarantee for this transition. Defaults to [Guarantee.None]. @@ -172,6 +183,7 @@ interface DirectionalBuilderScope { fun fixedValue( breakpoint: Float, value: Float, + breakpointHaptics: BreakpointHaptics = BreakpointHaptics.None, spring: SpringParameters = defaultSpring, guarantee: Guarantee = Guarantee.None, key: BreakpointKey = BreakpointKey(), @@ -189,6 +201,7 @@ interface DirectionalBuilderScope { * next. * @param delta An optional offset to apply to the mapped value to determine the fixed value. * Defaults to 0f. + * @param breakpointHaptics Haptics at the breakpoint that ends the current segment. * @param spring The [SpringParameters] for the transition to this breakpoint. Defaults to * [defaultSpring]. * @param guarantee The animation guarantee for this transition. Defaults to [Guarantee.None]. @@ -199,6 +212,7 @@ interface DirectionalBuilderScope { fun fixedValueFromCurrent( breakpoint: Float, delta: Float = 0f, + breakpointHaptics: BreakpointHaptics = BreakpointHaptics.None, spring: SpringParameters = defaultSpring, guarantee: Guarantee = Guarantee.None, key: BreakpointKey = BreakpointKey(), @@ -219,6 +233,7 @@ interface DirectionalBuilderScope { * @param key A unique [BreakpointKey] for this breakpoint. Defaults to a newly generated key. * @param semantics Updated semantics values to be applied. Must be a subset of the * [SemanticKey]s used when first creating this builder. + * @param breakpointHaptics Haptics at the breakpoint that ends the current segment. * @param mapping The custom [Mapping] to use. */ fun mapping( @@ -227,6 +242,7 @@ interface DirectionalBuilderScope { guarantee: Guarantee = Guarantee.None, key: BreakpointKey = BreakpointKey(), semantics: List> = emptyList(), + breakpointHaptics: BreakpointHaptics = BreakpointHaptics.None, mapping: Mapping, ): CanBeLastSegment @@ -239,6 +255,7 @@ interface DirectionalBuilderScope { * @param breakpoint The breakpoint defining the end of the current segment and the start of the * next. * @param delta An optional offset to apply to the mapped value to determine the fixed value. + * @param breakpointHaptics Haptics at the breakpoint that ends the current segment. * @param spring The [SpringParameters] for the transition to this breakpoint. * @param guarantee The animation guarantee for this transition. * @param key A unique [BreakpointKey] for this breakpoint. @@ -248,17 +265,27 @@ interface DirectionalBuilderScope { fun identity( breakpoint: Float, delta: Float = 0f, + breakpointHaptics: BreakpointHaptics = BreakpointHaptics.None, spring: SpringParameters = defaultSpring, guarantee: Guarantee = Guarantee.None, key: BreakpointKey = BreakpointKey(), semantics: List> = emptyList(), ): CanBeLastSegment { return if (delta == 0f) { - mapping(breakpoint, spring, guarantee, key, semantics, Mapping.Identity) + mapping( + breakpoint, + spring, + guarantee, + key, + semantics, + breakpointHaptics, + Mapping.Identity, + ) } else { fractionalInput( breakpoint, fraction = 1f, + breakpointHaptics = breakpointHaptics, from = breakpoint + delta, spring = spring, guarantee = guarantee, @@ -267,6 +294,16 @@ interface DirectionalBuilderScope { ) } } + + /** + * Builds the [DirectionalMotionSpec] according to the given [block] with the given + * [SegmentHaptics]. + * + * Within the block, one or more segments can be defined and the same type of haptics will be + * delivered during interactions with the segments. + */ + @HapticsExperimentalApi + fun haptics(segmentHaptics: SegmentHaptics, block: DirectionalBuilderScope.() -> T) } /** Marker interface to indicate that a segment can be the last one in a [DirectionalMotionSpec]. */ diff --git a/mechanics/src/com/android/mechanics/spec/builder/DirectionalSpecBuilder.kt b/mechanics/src/com/android/mechanics/spec/builder/DirectionalSpecBuilder.kt index b4483b7..21c1007 100644 --- a/mechanics/src/com/android/mechanics/spec/builder/DirectionalSpecBuilder.kt +++ b/mechanics/src/com/android/mechanics/spec/builder/DirectionalSpecBuilder.kt @@ -16,6 +16,7 @@ package com.android.mechanics.spec.builder +import com.android.mechanics.haptics.SegmentHaptics import com.android.mechanics.spec.Breakpoint import com.android.mechanics.spec.DirectionalMotionSpec import com.android.mechanics.spec.Mapping @@ -115,6 +116,7 @@ fun directionalMotionSpec( */ fun directionalMotionSpec( mapping: Mapping = Mapping.Identity, + segmentHaptics: SegmentHaptics = SegmentHaptics.None, semantics: List> = emptyList(), ): DirectionalMotionSpec { fun toSegmentSemanticValues(semanticValue: SemanticValue) = @@ -123,6 +125,7 @@ fun directionalMotionSpec( return DirectionalMotionSpec( listOf(Breakpoint.minLimit, Breakpoint.maxLimit), listOf(mapping), + listOf(segmentHaptics), semantics.map { toSegmentSemanticValues(it) }, ) } diff --git a/mechanics/src/com/android/mechanics/spec/builder/EffectApplyScope.kt b/mechanics/src/com/android/mechanics/spec/builder/EffectApplyScope.kt index 920b58b..347d429 100644 --- a/mechanics/src/com/android/mechanics/spec/builder/EffectApplyScope.kt +++ b/mechanics/src/com/android/mechanics/spec/builder/EffectApplyScope.kt @@ -16,6 +16,8 @@ package com.android.mechanics.spec.builder +import com.android.mechanics.haptics.BreakpointHaptics +import com.android.mechanics.haptics.SegmentHaptics import com.android.mechanics.spec.Guarantee import com.android.mechanics.spec.Mapping import com.android.mechanics.spec.OnChangeSegmentHandler @@ -26,7 +28,7 @@ import com.android.mechanics.spring.SpringParameters /** * Defines the contract for applying [Effect]s within a [MotionSpecBuilder] * - * Provides methods to define breakpoints and mappings for the motion specification. + * Provides methods to define breakpoints, mappings and haptics for the motion specification. * * Breakpoints for [minLimit] and [maxLimit] will be created, with the specified key and parameters. */ @@ -90,12 +92,14 @@ interface EffectApplyScope : MotionBuilderContext { * spec, unless redefined in another spec. * * @param initialMapping [Mapping] for the first segment after [minLimit]. + * @param initialSegmentHaptics [SegmentHaptics] for the first segment after [minLimit] * @param semantics Initial semantics for the effect. * @param init Configures the effect's spec using [DirectionalBuilderScope]. * @see com.android.mechanics.spec.directionalMotionSpec for in-depth documentation. */ fun forward( initialMapping: Mapping, + initialSegmentHaptics: SegmentHaptics = SegmentHaptics.None, semantics: List> = emptyList(), init: DirectionalEffectBuilderScope.() -> Unit, ) @@ -171,6 +175,7 @@ interface DirectionalEffectBuilderScope : DirectionalBuilderScope { guarantee: Guarantee? = null, semantics: List>? = null, mapping: Mapping? = null, + breakpointHaptics: BreakpointHaptics? = null, ) fun after( @@ -178,5 +183,6 @@ interface DirectionalEffectBuilderScope : DirectionalBuilderScope { guarantee: Guarantee? = null, semantics: List>? = null, mapping: Mapping? = null, + breakpointHaptics: BreakpointHaptics? = null, ) } diff --git a/mechanics/src/com/android/mechanics/spec/builder/MotionBuilderContext.kt b/mechanics/src/com/android/mechanics/spec/builder/MotionBuilderContext.kt index 989d481..de8ab3b 100644 --- a/mechanics/src/com/android/mechanics/spec/builder/MotionBuilderContext.kt +++ b/mechanics/src/com/android/mechanics/spec/builder/MotionBuilderContext.kt @@ -23,6 +23,8 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MotionScheme import androidx.compose.runtime.Composable import androidx.compose.runtime.remember +import androidx.compose.ui.node.CompositionLocalConsumerModifierNode +import androidx.compose.ui.node.currentValueOf import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.unit.Density import com.android.mechanics.spring.SpringParameters @@ -32,7 +34,8 @@ import com.android.mechanics.spring.SpringParameters * * See go/motion-system. * - * @see rememberMotionBuilderContext for Compose + * @see rememberMotionBuilderContext for Compose (in composition) + * @see motionBuilderContext for Compose (in Modifier.Node) * @see standardViewMotionBuilderContext for Views * @see expressiveViewMotionBuilderContext for Views */ @@ -84,7 +87,20 @@ fun rememberMotionBuilderContext(): MotionBuilderContext { return remember(density, motionScheme) { ComposeMotionBuilderContext(motionScheme, density) } } -class ComposeMotionBuilderContext(motionScheme: MotionScheme, density: Density) : +/** + * [MotionBuilderContext] for building motion specs in a [androidx.compose.ui.Modifier.Node]. + * + * This should be read when the node is attached. + */ +fun CompositionLocalConsumerModifierNode.motionBuilderContext(): ComposeMotionBuilderContext { + return ComposeMotionBuilderContext( + motionScheme = currentValueOf(MaterialTheme.LocalMotionScheme), + density = currentValueOf(LocalDensity), + ) +} + +class ComposeMotionBuilderContext +internal constructor(motionScheme: MotionScheme, density: Density) : MotionBuilderContext, Density by density { override val spatial = diff --git a/mechanics/src/com/android/mechanics/spec/builder/MotionSpecBuilder.kt b/mechanics/src/com/android/mechanics/spec/builder/MotionSpecBuilder.kt index de62c44..f6668f7 100644 --- a/mechanics/src/com/android/mechanics/spec/builder/MotionSpecBuilder.kt +++ b/mechanics/src/com/android/mechanics/spec/builder/MotionSpecBuilder.kt @@ -33,9 +33,9 @@ fun MotionBuilderContext.spatialMotionSpec( baseMapping: Mapping = Mapping.Identity, defaultSpring: SpringParameters = this.spatial.default, resetSpring: SpringParameters = defaultSpring, - baseSemantics: List> = emptyList(), + semantics: List> = emptyList(), init: MotionSpecBuilderScope.() -> Unit, -) = motionSpec(baseMapping, defaultSpring, resetSpring, baseSemantics, init) +) = motionSpec(baseMapping, defaultSpring, resetSpring, semantics, init) /** * Creates a [MotionSpec] for an effects value. @@ -49,9 +49,9 @@ fun MotionBuilderContext.effectsMotionSpec( baseMapping: Mapping = Mapping.Zero, defaultSpring: SpringParameters = this.effects.default, resetSpring: SpringParameters = defaultSpring, - baseSemantics: List> = emptyList(), + semantics: List> = emptyList(), init: MotionSpecBuilderScope.() -> Unit, -) = motionSpec(baseMapping, defaultSpring, resetSpring, baseSemantics, init) +) = motionSpec(baseMapping, defaultSpring, resetSpring, semantics, init) /** * Creates a [MotionSpec], based on reusable effects. @@ -61,21 +61,21 @@ fun MotionBuilderContext.effectsMotionSpec( * unless otherwise specified. * @param resetSpring spring parameters to animate a difference in output, if the difference is * caused by setting this new spec. - * @param baseSemantics initial semantics that apply before of effects override them. + * @param semantics initial semantics that apply before of effects override them. * @param init */ fun MotionBuilderContext.motionSpec( baseMapping: Mapping, defaultSpring: SpringParameters, resetSpring: SpringParameters = defaultSpring, - baseSemantics: List> = emptyList(), + semantics: List> = emptyList(), init: MotionSpecBuilderScope.() -> Unit, ): MotionSpec { return MotionSpecBuilderImpl( baseMapping, defaultSpring, resetSpring, - baseSemantics, + semantics, motionBuilderContext = this, ) .apply(init) @@ -121,8 +121,9 @@ fun MotionBuilderContext.fixedValueSpec( semantics: List> = emptyList(), ): MotionSpec { return MotionSpec( - directionalMotionSpec(Mapping.Fixed(value), semantics), + directionalMotionSpec(Mapping.Fixed(value)), resetSpring = resetSpring, + semantics = semantics, ) } diff --git a/mechanics/src/com/android/mechanics/spec/builder/MotionSpecBuilderImpl.kt b/mechanics/src/com/android/mechanics/spec/builder/MotionSpecBuilderImpl.kt index 75b9953..c60aae2 100644 --- a/mechanics/src/com/android/mechanics/spec/builder/MotionSpecBuilderImpl.kt +++ b/mechanics/src/com/android/mechanics/spec/builder/MotionSpecBuilderImpl.kt @@ -23,6 +23,8 @@ import androidx.collection.MutableIntObjectMap import androidx.collection.MutableLongList import androidx.collection.ObjectList import androidx.collection.mutableObjectListOf +import com.android.mechanics.haptics.BreakpointHaptics +import com.android.mechanics.haptics.SegmentHaptics import com.android.mechanics.spec.Breakpoint import com.android.mechanics.spec.BreakpointKey import com.android.mechanics.spec.Guarantee @@ -56,13 +58,17 @@ internal class MotionSpecBuilderImpl( fun build(): MotionSpec { if (placedEffects.isEmpty()) { - return MotionSpec(directionalMotionSpec(baseMapping), resetSpring = resetSpring) + return MotionSpec( + directionalMotionSpec(baseMapping), + resetSpring = resetSpring, + semantics = baseSemantics, + ) } builders = mutableObjectListOf( - DirectionalEffectBuilderScopeImpl(defaultSpring, baseSemantics), - DirectionalEffectBuilderScopeImpl(defaultSpring, baseSemantics), + DirectionalEffectBuilderScopeImpl(defaultSpring), + DirectionalEffectBuilderScopeImpl(defaultSpring), ) segmentHandlers = mutableMapOf() @@ -104,6 +110,7 @@ internal class MotionSpecBuilderImpl( builders[1].build(), resetSpring, segmentHandlers.toMap(), + semantics = baseSemantics, ) } @@ -323,7 +330,7 @@ internal class MotionSpecBuilderImpl( semantics: List>, init: DirectionalEffectBuilderScope.() -> Unit, ) { - forward(initialMapping, semantics, init) + forward(initialMapping, SegmentHaptics.None, semantics, init) backward(initialMapping, semantics, init) } @@ -334,13 +341,14 @@ internal class MotionSpecBuilderImpl( override fun forward( initialMapping: Mapping, + initialSegmentHaptics: SegmentHaptics, semantics: List>, init: DirectionalEffectBuilderScope.() -> Unit, ) { check(!forwardInvoked) { "Cannot define forward spec more than once" } forwardInvoked = true - forwardBuilder.prepareBuilderFn(initialMapping, semantics) + forwardBuilder.prepareBuilderFn(initialMapping, initialSegmentHaptics, semantics) forwardBuilder.init() } @@ -348,7 +356,7 @@ internal class MotionSpecBuilderImpl( check(!forwardInvoked) { "Cannot define forward spec more than once" } forwardInvoked = true - forwardBuilder.prepareBuilderFn(mapping, semantics) + forwardBuilder.prepareBuilderFn(mapping, SegmentHaptics.None, semantics) } override fun backward( @@ -359,7 +367,7 @@ internal class MotionSpecBuilderImpl( check(!backwardInvoked) { "Cannot define backward spec more than once" } backwardInvoked = true - reverseBuilder.prepareBuilderFn(initialMapping, semantics) + reverseBuilder.prepareBuilderFn(initialMapping, SegmentHaptics.None, semantics) reverseBuilder.init() } @@ -367,7 +375,7 @@ internal class MotionSpecBuilderImpl( check(!backwardInvoked) { "Cannot define backward spec more than once" } backwardInvoked = true - reverseBuilder.prepareBuilderFn(mapping, semantics) + reverseBuilder.prepareBuilderFn(mapping, SegmentHaptics.None, semantics) } private var forwardInvoked = false @@ -384,9 +392,16 @@ internal class MotionSpecBuilderImpl( if (effectId == NoEffectPlaceholderId) { val maxBreakpoint = - Breakpoint.create(maxLimitKey, actualPlacement.max, defaultSpring, Guarantee.None) + Breakpoint.create( + maxLimitKey, + actualPlacement.max, + defaultSpring, + Guarantee.None, + BreakpointHaptics.None, + ) builders.forEach { builder -> builder.mappings += builder.afterMapping ?: baseMapping + builder.segmentHaptics += SegmentHaptics.None builder.breakpoints += maxBreakpoint } return @@ -422,6 +437,7 @@ internal class MotionSpecBuilderImpl( builder.finalizeBuilderFn( actualPlacement.max, maxLimitKey, + builder.afterBreakpointHaptics ?: BreakpointHaptics.None, builder.afterSpring ?: defaultSpring, builder.afterGuarantee ?: Guarantee.None, builder.afterSemantics ?: emptyList(), @@ -452,43 +468,48 @@ internal class MotionSpecBuilderImpl( } } -private class DirectionalEffectBuilderScopeImpl( - defaultSpring: SpringParameters, - baseSemantics: List>, -) : DirectionalBuilderImpl(defaultSpring, baseSemantics), DirectionalEffectBuilderScope { +private class DirectionalEffectBuilderScopeImpl(defaultSpring: SpringParameters) : + DirectionalBuilderImpl(defaultSpring, baseSemantics = emptyList()), + DirectionalEffectBuilderScope { var beforeGuarantee: Guarantee? = null var beforeSpring: SpringParameters? = null var beforeSemantics: List>? = null var beforeMapping: Mapping? = null + var beforeBreakpointHaptics: BreakpointHaptics? = null override fun before( spring: SpringParameters?, guarantee: Guarantee?, semantics: List>?, mapping: Mapping?, + breakpointHaptics: BreakpointHaptics?, ) { beforeGuarantee = guarantee beforeSpring = spring beforeSemantics = semantics beforeMapping = mapping + beforeBreakpointHaptics = breakpointHaptics } var afterGuarantee: Guarantee? = null var afterSpring: SpringParameters? = null var afterSemantics: List>? = null var afterMapping: Mapping? = null + var afterBreakpointHaptics: BreakpointHaptics? = null override fun after( spring: SpringParameters?, guarantee: Guarantee?, semantics: List>?, mapping: Mapping?, + breakpointHaptics: BreakpointHaptics?, ) { afterGuarantee = guarantee afterSpring = spring afterSemantics = semantics afterMapping = mapping + afterBreakpointHaptics = breakpointHaptics } fun resetBeforeAfter() { @@ -500,6 +521,8 @@ private class DirectionalEffectBuilderScopeImpl( afterSpring = null afterSemantics = null afterMapping = null + afterBreakpointHaptics = null + beforeBreakpointHaptics = null } } diff --git a/mechanics/src/com/android/mechanics/view/ViewMotionValue.kt b/mechanics/src/com/android/mechanics/view/ViewMotionValue.kt index 617e363..f708cb9 100644 --- a/mechanics/src/com/android/mechanics/view/ViewMotionValue.kt +++ b/mechanics/src/com/android/mechanics/view/ViewMotionValue.kt @@ -49,7 +49,7 @@ class ViewMotionValue constructor( initialInput: Float, gestureContext: ViewGestureContext, - initialSpec: MotionSpec = MotionSpec.Empty, + initialSpec: MotionSpec = MotionSpec.Identity, label: String? = null, stableThreshold: Float = StableThresholdEffect, ) : DisposableHandle { @@ -69,7 +69,7 @@ constructor( var spec: MotionSpec by impl::spec /** Animated [output] value. */ - val output: Float by impl::output + val output: Float by impl::computedOutput /** * [output] value, but without animations. @@ -78,10 +78,10 @@ constructor( * * While [isStable], [outputTarget] and [output] are the same value. */ - val outputTarget: Float by impl::outputTarget + val outputTarget: Float by impl::computedOutputTarget /** Whether an animation is currently running. */ - val isStable: Boolean by impl::isStable + val isStable: Boolean by impl::computedIsStable /** * The current value for the [SemanticKey]. @@ -89,7 +89,7 @@ constructor( * `null` if not defined in the spec. */ operator fun get(key: SemanticKey): T? { - return impl.semanticState(key) + return impl.computedSemanticState(key) } /** The current segment used to compute the output. */ @@ -140,6 +140,7 @@ constructor( impl.lastSpringState, impl.lastSegment, impl.lastAnimation, + impl.computedIsOutputFixed, ), impl.isActive, impl.animationFrameDriver.isRunning, @@ -204,6 +205,7 @@ private class ImperativeComputations( override var lastInput: Float = currentInput override var lastGestureDragOffset: Float = currentGestureDragOffset override var directMappedVelocity: Float = 0f + override var lastHapticsTimeNanos: Long = -1L var lastDirection: InputDirection = currentDirection // ---- Lifecycle ------------------------------------------------------------------------------ @@ -221,9 +223,9 @@ private class ImperativeComputations( repeatMode = ValueAnimator.RESTART repeatCount = ValueAnimator.INFINITE start() - pause() addUpdateListener { val isAnimationFinished = updateOutputValue(currentPlayTime) + debugInspector?.isAnimating = !isAnimationFinished if (isAnimationFinished) { pause() } @@ -233,14 +235,12 @@ private class ImperativeComputations( fun ensureFrameRequested() { if (animationFrameDriver.isPaused) { animationFrameDriver.resume() - debugInspector?.isAnimating = true } } fun pauseFrameRequests() { if (animationFrameDriver.isRunning) { animationFrameDriver.pause() - debugInspector?.isAnimating = false } } @@ -285,9 +285,12 @@ private class ImperativeComputations( currentSpringState, currentValues.segment, currentValues.animation, + computedIsOutputFixed, ) } + if (currentValues.segment.spec == MotionSpec.InitiallyUndefined) return true + listeners.fastForEach { it.onMotionValueUpdated(motionValue) } // Prepare last* state @@ -298,7 +301,7 @@ private class ImperativeComputations( directMappedVelocity = 0f } - var isAnimationFinished = isStable + var isAnimationFinished = computedIsStable if (lastSegment != currentValues.segment) { lastSegment = currentValues.segment isAnimationFinished = false diff --git a/mechanics/testing/src/com/android/mechanics/testing/ComposeMotionValueCollectionToolkit.kt b/mechanics/testing/src/com/android/mechanics/testing/ComposeMotionValueCollectionToolkit.kt new file mode 100644 index 0000000..6f6ff41 --- /dev/null +++ b/mechanics/testing/src/com/android/mechanics/testing/ComposeMotionValueCollectionToolkit.kt @@ -0,0 +1,209 @@ +/* + * Copyright (C) 2025 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. + */ + +@file:OptIn(ExperimentalCoroutinesApi::class) + +package com.android.mechanics.testing + +import android.annotation.SuppressLint +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshots.Snapshot +import com.android.mechanics.DistanceGestureContext +import com.android.mechanics.ManagedMotionValue +import com.android.mechanics.MotionValueCollection +import com.android.mechanics.spec.InputDirection +import com.android.mechanics.spec.MotionSpec +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.drop +import kotlinx.coroutines.flow.take +import kotlinx.coroutines.flow.takeWhile +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import platform.test.motion.MotionTestRule +import platform.test.motion.compose.runMonotonicClockTest +import platform.test.motion.golden.FeatureCapture +import platform.test.motion.golden.FrameId +import platform.test.motion.golden.TimeSeries +import platform.test.motion.golden.TimestampFrameId +import platform.test.motion.golden.asDataPoint + +interface CollectionInputScope : InputScope { + val motionValues: Set + + fun motionValueWithLabel(label: String): ManagedMotionValue? +} + +/** Toolkit to support [MotionValueCollection] motion tests. */ +object ComposeMotionValueCollectionToolkit : + MotionValueToolkit< + CollectionInputScope, + MotionValueCollection, + ManagedMotionValue, + DistanceGestureContext, + >() { + + @SuppressLint("VisibleForTests") + override fun goldenTest( + motionTestRule: MotionTestRule<*>, + spec: MotionSpec, + createDerived: (underTest: MotionValueCollection) -> List, + initialValue: Float, + initialDirection: InputDirection, + directionChangeSlop: Float, + stableThreshold: Float, + verifyTimeSeries: TimeSeries.() -> VerifyTimeSeriesResult, + capture: CaptureTimeSeriesFn, + testInput: suspend CollectionInputScope.() -> Unit, + ) = runMonotonicClockTest { + val frameEmitter = MutableStateFlow(0L) + val testHarness = + ComposeMotionValueCollectionTestHarness( + frameEmitter.asStateFlow(), + spec, + initialValue, + initialDirection, + directionChangeSlop, + stableThreshold, + ) + val underTest = testHarness.underTest + testHarness.createMotionValue("primary", testHarness::spec) + createDerived(underTest) + + val motionValueCaptures = buildList { + testHarness.motionValues.forEach { + add(MotionValueCapture(it.debugInspector(), "${it.label}-")) + } + } + + val collectionCapture = GenericValueCapture(testHarness.underTest) + + val keepRunningJob = launch { underTest.keepRunning() } + + val latch = CompletableDeferred() + + val recordingJob = launch { + latch.await() + testInput.invoke(testHarness) + } + val frameIds = mutableListOf() + + fun recordFrame(frameId: TimestampFrameId) { + + frameIds.add(frameId) + + collectionCapture.captureCurrentFrame { + feature(FeatureCapture("input") { it.currentInput.asDataPoint() }) + feature( + FeatureCapture("gestureDirection") { it.currentDirection.name.asDataPoint() } + ) + } + motionValueCaptures.forEach { it.captureCurrentFrame(capture) } + } + + runBlocking(Dispatchers.Main) { + while (!underTest.isActive) { + testScheduler.runCurrent() + Snapshot.sendApplyNotifications() + testScheduler.advanceTimeBy(FrameDuration) + testScheduler.runCurrent() + } + + latch.complete(Unit) + + val startFrameTime = testScheduler.currentTime + while (!recordingJob.isCompleted) { + recordFrame(TimestampFrameId(testScheduler.currentTime - startFrameTime)) + + frameEmitter.tryEmit(testScheduler.currentTime) + testScheduler.runCurrent() + Snapshot.sendApplyNotifications() + + testScheduler.advanceTimeBy(FrameDuration) + testScheduler.runCurrent() + } + } + + val timeSeries = + createTimeSeries( + frameIds, + buildList { + add(collectionCapture) + addAll(motionValueCaptures) + }, + ) + motionValueCaptures.forEach { it.debugger.dispose() } + keepRunningJob.cancel() + verifyTimeSeries(motionTestRule, timeSeries, verifyTimeSeries) + } +} + +private class ComposeMotionValueCollectionTestHarness( + private val onFrame: StateFlow, + primarySpec: MotionSpec, + initialInput: Float, + initialDirection: InputDirection, + directionChangeSlop: Float, + stableThreshold: Float, +) : CollectionInputScope { + override val motionValues: Set + get() = underTest.managedMotionValues + + override fun motionValueWithLabel(label: String): ManagedMotionValue? { + return motionValues.firstOrNull { it.label == label } + } + + override var input by mutableFloatStateOf(initialInput) + override val gestureContext = + DistanceGestureContext(initialInput, initialDirection, directionChangeSlop) + + override val underTest = MotionValueCollection(::input, gestureContext, stableThreshold) + override var spec: MotionSpec by mutableStateOf(primarySpec) + + fun createMotionValue(label: String, spec: () -> MotionSpec): ManagedMotionValue { + return underTest.create(spec, label) + } + + override fun updateInput(value: Float) { + input = value + gestureContext.dragOffset = value + } + + override suspend fun awaitStable() { + val debugInspectors = buildList { addAll(motionValues.map { it.debugInspector() }) } + try { + onFrame.drop(1).takeWhile { debugInspectors.any { !it.frame.isStable } }.collect {} + } finally { + debugInspectors.forEach { it.dispose() } + } + } + + override suspend fun awaitFrames(frames: Int) { + onFrame.drop(1).take(frames).collect {} + } + + override fun reset(position: Float, direction: InputDirection) { + input = position + gestureContext.reset(position, direction) + } +} diff --git a/mechanics/testing/src/com/android/mechanics/testing/ComposeMotionValueToolkit.kt b/mechanics/testing/src/com/android/mechanics/testing/ComposeMotionValueToolkit.kt index 0144a16..45f7388 100644 --- a/mechanics/testing/src/com/android/mechanics/testing/ComposeMotionValueToolkit.kt +++ b/mechanics/testing/src/com/android/mechanics/testing/ComposeMotionValueToolkit.kt @@ -26,6 +26,7 @@ import com.android.mechanics.DistanceGestureContext import com.android.mechanics.MotionValue import com.android.mechanics.spec.InputDirection import com.android.mechanics.spec.MotionSpec +import com.android.mechanics.testing.MotionValueToolkit.Companion.FrameDuration import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableStateFlow @@ -43,7 +44,13 @@ import platform.test.motion.golden.TimeSeries import platform.test.motion.golden.TimestampFrameId /** Toolkit to support [MotionValue] motion tests. */ -data object ComposeMotionValueToolkit : MotionValueToolkit() { +data object ComposeMotionValueToolkit : + MotionValueToolkit< + InputScope, + MotionValue, + MotionValue, + DistanceGestureContext, + >() { override fun goldenTest( motionTestRule: MotionTestRule<*>, @@ -118,7 +125,7 @@ data object ComposeMotionValueToolkit : MotionValueToolkit, @@ -131,10 +138,10 @@ private class ComposeMotionValueTestHarness( override val underTest = MotionValue( - { input }, - gestureContext, + input = { input }, + gestureContext = gestureContext, + spec = { spec }, stableThreshold = stableThreshold, - initialSpec = spec, ) val derived = createDerived(underTest) diff --git a/mechanics/testing/src/com/android/mechanics/testing/FeatureCaptures.kt b/mechanics/testing/src/com/android/mechanics/testing/FeatureCaptures.kt index d8ef1cf..ece17b6 100644 --- a/mechanics/testing/src/com/android/mechanics/testing/FeatureCaptures.kt +++ b/mechanics/testing/src/com/android/mechanics/testing/FeatureCaptures.kt @@ -20,6 +20,7 @@ import com.android.mechanics.debug.DebugInspector import com.android.mechanics.spec.SemanticKey import com.android.mechanics.spring.SpringParameters import com.android.mechanics.spring.SpringState +import platform.test.motion.golden.DataPoint import platform.test.motion.golden.DataPointType import platform.test.motion.golden.FeatureCapture import platform.test.motion.golden.asDataPoint @@ -60,6 +61,16 @@ object FeatureCaptures { val isStable = FeatureCapture("isStable") { it.frame.isStable.asDataPoint() } + /** Whether the motion value currently is running the animation loop. */ + val isAnimating = + FeatureCapture("isAnimating") { it.isAnimating.asDataPoint() } + + /** Whether the output can change. */ + val isOutputFixed = + FeatureCapture("isOutputFixed") { + it.frame.isOutputFixed.asDataPoint() + } + /** A semantic value to capture in the golden. */ fun semantics( key: SemanticKey, @@ -69,3 +80,8 @@ object FeatureCaptures { return FeatureCapture(name) { dataPointType.makeDataPoint(it.frame.semantic(key)) } } } + +/** Returns notFound if the motion value is not active. */ +fun FeatureCapture.whenActive(): FeatureCapture { + return FeatureCapture(name) { if (it.isActive) capture(it) else DataPoint.notFound() } +} diff --git a/mechanics/testing/src/com/android/mechanics/testing/MotionSpecSubject.kt b/mechanics/testing/src/com/android/mechanics/testing/MotionSpecSubject.kt index 9816d01..0d280c1 100644 --- a/mechanics/testing/src/com/android/mechanics/testing/MotionSpecSubject.kt +++ b/mechanics/testing/src/com/android/mechanics/testing/MotionSpecSubject.kt @@ -16,6 +16,7 @@ package com.android.mechanics.testing +import com.android.mechanics.haptics.SegmentHaptics import com.android.mechanics.spec.Breakpoint import com.android.mechanics.spec.BreakpointKey import com.android.mechanics.spec.DirectionalMotionSpec @@ -29,7 +30,6 @@ import com.google.common.truth.FailureMetadata import com.google.common.truth.FloatSubject import com.google.common.truth.IterableSubject import com.google.common.truth.Subject -import com.google.common.truth.Subject.Factory import com.google.common.truth.Truth /** Subject to verify the definition of a [MotionSpec]. */ @@ -104,6 +104,13 @@ internal constructor(failureMetadata: FailureMetadata, private val actual: Direc return check("mappings").about(MappingsSubject.SubjectFactory).that(actual) } + /** Assert on the segment haptics. */ + fun segmentHaptics(): SegmentHapticsSubject { + isNotNull() + + return check("segmentHaptics").about(SegmentHapticsSubject.SubjectFactory).that(actual) + } + /** Assert that the mappings contain exactly the specified mappings, in order . */ fun mappingsMatch(vararg mappings: Mapping) { isNotNull() @@ -286,6 +293,37 @@ internal constructor(failureMetadata: FailureMetadata, private val actual: Mappi } } +class SegmentHapticsSubject +internal constructor(failureMetadata: FailureMetadata, private val actual: DirectionalMotionSpec?) : + IterableSubject(failureMetadata, actual?.haptics) { + + /** Assert on the mapping at or after the specified position. */ + fun at(position: Float): SegmentHapticSubject { + return check("segment haptics @ $position") + .about(SegmentHapticSubject.SubjectFactory) + .that(actual?.run { haptics[findBreakpointIndex(position)] }) + } + + companion object { + /** Returns a factory to be used with [Truth.assertAbout]. */ + val SubjectFactory = + Factory { failureMetadata, subject -> + SegmentHapticsSubject(failureMetadata, subject) + } + } +} + +class SegmentHapticSubject +internal constructor(failureMetadata: FailureMetadata, private val actual: SegmentHaptics?) : + Subject(failureMetadata, actual) { + companion object { + val SubjectFactory = + Factory { failureMetadata, subject -> + SegmentHapticSubject(failureMetadata, subject) + } + } +} + /** Subject to assert on the list of semantic values of a [DirectionalMotionSpec]. */ class SemanticsSubject( failureMetadata: FailureMetadata, diff --git a/mechanics/testing/src/com/android/mechanics/testing/MotionValueToolkit.kt b/mechanics/testing/src/com/android/mechanics/testing/MotionValueToolkit.kt index a96ca99..c8ac6b4 100644 --- a/mechanics/testing/src/com/android/mechanics/testing/MotionValueToolkit.kt +++ b/mechanics/testing/src/com/android/mechanics/testing/MotionValueToolkit.kt @@ -57,7 +57,9 @@ import platform.test.motion.golden.TimeSeriesCaptureScope * @see ViewMotionValueToolkit */ fun < - T : MotionValueToolkit, + T : MotionValueToolkit, + I : InputScope, + UnderTestType, MotionValueType, GestureContextType, > MotionTestRule.goldenTest( @@ -69,9 +71,9 @@ fun < verifyTimeSeries: VerifyTimeSeriesFn = { VerifyTimeSeriesResult.AssertTimeSeriesMatchesGolden() }, - createDerived: (underTest: MotionValueType) -> List = { emptyList() }, + createDerived: (underTest: UnderTestType) -> List = { emptyList() }, capture: CaptureTimeSeriesFn = defaultFeatureCaptures, - testInput: suspend (InputScope).() -> Unit, + testInput: suspend (I).() -> Unit, ) { toolkit.goldenTest( this, @@ -93,6 +95,8 @@ interface InputScope { val input: Float /** GestureContext created for the `MotionValue` */ val gestureContext: GestureContextType + /** Current spec of the `MotionValue` */ + var spec: MotionSpec /** MotionValue being tested. */ val underTest: MotionValueType @@ -161,27 +165,32 @@ val defaultFeatureCaptures: CaptureTimeSeriesFn = { feature(FeatureCaptures.isStable) } -sealed class MotionValueToolkit { +sealed class MotionValueToolkit< + I : InputScope, + UnderTestType, + MotionValueType, + GestureContextType, +> { internal abstract fun goldenTest( motionTestRule: MotionTestRule<*>, spec: MotionSpec, - createDerived: (underTest: MotionValueType) -> List, + createDerived: (underTest: UnderTestType) -> List, initialValue: Float, initialDirection: InputDirection, directionChangeSlop: Float, stableThreshold: Float, verifyTimeSeries: TimeSeries.() -> VerifyTimeSeriesResult, capture: CaptureTimeSeriesFn, - testInput: suspend (InputScope).() -> Unit, + testInput: suspend (I).() -> Unit, ) internal fun createTimeSeries( frameIds: List, - motionValueCaptures: List, + frameValueCaptures: List, ): TimeSeries { return TimeSeries( frameIds.toList(), - motionValueCaptures.flatMap { motionValueCapture -> + frameValueCaptures.flatMap { motionValueCapture -> motionValueCapture.propertyCollector.entries.map { (name, dataPoints) -> Feature("${motionValueCapture.prefix}$name", dataPoints) } @@ -220,11 +229,24 @@ sealed class MotionValueToolkit { } } -internal class MotionValueCapture(val debugger: DebugInspector, val prefix: String = "") { +internal sealed class FrameValueCapture(val prefix: String) { val propertyCollector = mutableMapOf>>() +} + +internal class MotionValueCapture(val debugger: DebugInspector, prefix: String = "") : + FrameValueCapture(prefix) { val captureScope = TimeSeriesCaptureScope(debugger, propertyCollector) fun captureCurrentFrame(captureFn: CaptureTimeSeriesFn) { captureFn(captureScope) } } + +internal class GenericValueCapture(val scope: T, prefix: String = "") : + FrameValueCapture(prefix) { + val captureScope = TimeSeriesCaptureScope(scope, propertyCollector) + + fun captureCurrentFrame(captureFn: TimeSeriesCaptureScope.() -> Unit) { + captureFn(captureScope) + } +} diff --git a/mechanics/testing/src/com/android/mechanics/testing/ViewMotionValueToolkit.kt b/mechanics/testing/src/com/android/mechanics/testing/ViewMotionValueToolkit.kt index cbe18d5..40b05a3 100644 --- a/mechanics/testing/src/com/android/mechanics/testing/ViewMotionValueToolkit.kt +++ b/mechanics/testing/src/com/android/mechanics/testing/ViewMotionValueToolkit.kt @@ -21,6 +21,7 @@ package com.android.mechanics.testing import android.animation.AnimatorTestRule import com.android.mechanics.spec.InputDirection import com.android.mechanics.spec.MotionSpec +import com.android.mechanics.testing.MotionValueToolkit.Companion.FrameDuration import com.android.mechanics.view.DistanceGestureContext import com.android.mechanics.view.ViewMotionValue import kotlinx.coroutines.Dispatchers @@ -42,7 +43,12 @@ import platform.test.motion.golden.TimestampFrameId /** Toolkit to support [ViewMotionValue] motion tests. */ class ViewMotionValueToolkit(private val animatorTestRule: AnimatorTestRule) : - MotionValueToolkit() { + MotionValueToolkit< + InputScope, + ViewMotionValue, + ViewMotionValue, + DistanceGestureContext, + >() { override fun goldenTest( motionTestRule: MotionTestRule<*>, @@ -138,6 +144,12 @@ private class ViewMotionValueTestHarness( gestureContext.dragOffset = value } + override var spec: MotionSpec + get() = underTest.spec + set(value) { + underTest.spec = value + } + override suspend fun awaitStable() { val debugInspectors = buildList { add(underTest.debugInspector()) } try { diff --git a/mechanics/tests/goldens/Toggle/maxDirection_AfterToggle_preventsJumpOnDirectionChange.json b/mechanics/tests/goldens/Toggle/maxDirection_AfterToggle_preventsJumpOnDirectionChange.json new file mode 100644 index 0000000..272f845 --- /dev/null +++ b/mechanics/tests/goldens/Toggle/maxDirection_AfterToggle_preventsJumpOnDirectionChange.json @@ -0,0 +1,190 @@ +{ + "frame_ids": [ + 0, + 16, + 32, + 48, + 64, + 80, + 96, + 112, + 128, + 144, + 160, + 176, + 192, + 208, + 224, + 240, + 256, + 272, + 288, + 304, + 320, + 336, + 352, + 368, + 384, + 400, + 416, + 432, + 448, + 464, + 480, + 496 + ], + "features": [ + { + "name": "input", + "type": "float", + "data_points": [ + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 17, + 18, + 18, + 18, + 18, + 18, + 18, + 18, + 18, + 18, + 18, + 18, + 18, + 17, + 16, + 15, + 16, + 17, + 18, + 19, + 20, + 21, + 22 + ] + }, + { + "name": "gestureDirection", + "type": "string", + "data_points": [ + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Min", + "Min", + "Min", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max" + ] + }, + { + "name": "output", + "type": "float", + "data_points": [ + 8, + 9, + 10, + 10.285714, + 10.571428, + 10.857143, + 11.142857, + 11.428572, + 11.714286, + 12, + 12.956564, + 14.443979, + 15.901992, + 17.110989, + 18.028572, + 18.686268, + 19.138393, + 19.439032, + 19.633345, + 19.755754, + 19.831007, + 19.933332, + 19.9, + 19.425, + 18.95, + 19.425, + 19.9, + 19.933332, + 19.966667, + 20, + 20.033333, + 20.066666 + ] + }, + { + "name": "outputTarget", + "type": "float", + "data_points": [ + 8, + 9, + 10, + 10.285714, + 10.571428, + 10.857143, + 11.142857, + 11.428572, + 11.714286, + 19.9, + 19.933332, + 19.933332, + 19.933332, + 19.933332, + 19.933332, + 19.933332, + 19.933332, + 19.933332, + 19.933332, + 19.933332, + 19.933332, + 19.933332, + 19.9, + 19.425, + 18.95, + 19.425, + 19.9, + 19.933332, + 19.966667, + 20, + 20.033333, + 20.066666 + ] + } + ] +} \ No newline at end of file diff --git a/mechanics/tests/goldens/Toggle/maxDirection_preventsDirectionChangeBeforeToggle.json b/mechanics/tests/goldens/Toggle/maxDirection_preventsDirectionChangeBeforeToggle.json new file mode 100644 index 0000000..8988d81 --- /dev/null +++ b/mechanics/tests/goldens/Toggle/maxDirection_preventsDirectionChangeBeforeToggle.json @@ -0,0 +1,110 @@ +{ + "frame_ids": [ + 0, + 16, + 32, + 48, + 64, + 80, + 96, + 112, + 128, + 144, + 160, + 176, + 192, + 208, + 224, + 240 + ], + "features": [ + { + "name": "input", + "type": "float", + "data_points": [ + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 15, + 14, + 13, + 12, + 11, + 10, + 9, + 8 + ] + }, + { + "name": "gestureDirection", + "type": "string", + "data_points": [ + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min" + ] + }, + { + "name": "output", + "type": "float", + "data_points": [ + 8, + 9, + 10, + 10.285714, + 10.571428, + 10.857143, + 11.142857, + 11.428572, + 11.428572, + 11.142857, + 10.857143, + 10.571428, + 10.285714, + 10, + 9.714286, + 9.428572 + ] + }, + { + "name": "outputTarget", + "type": "float", + "data_points": [ + 8, + 9, + 10, + 10.285714, + 10.571428, + 10.857143, + 11.142857, + 11.428572, + 11.428572, + 11.142857, + 10.857143, + 10.571428, + 10.285714, + 10, + 9.714286, + 9.428572 + ] + } + ] +} \ No newline at end of file diff --git a/mechanics/tests/goldens/Toggle/maxDirection_togglesAtThreshold.json b/mechanics/tests/goldens/Toggle/maxDirection_togglesAtThreshold.json new file mode 100644 index 0000000..be48faf --- /dev/null +++ b/mechanics/tests/goldens/Toggle/maxDirection_togglesAtThreshold.json @@ -0,0 +1,175 @@ +{ + "frame_ids": [ + 0, + 16, + 32, + 48, + 64, + 80, + 96, + 112, + 128, + 144, + 160, + 176, + 192, + 208, + 224, + 240, + 256, + 272, + 288, + 304, + 320, + 336, + 352, + 368, + 384, + 400, + 416, + 432, + 448 + ], + "features": [ + { + "name": "input", + "type": "float", + "data_points": [ + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 17, + 17, + 17, + 17, + 17, + 17, + 17, + 17, + 17, + 17, + 17, + 17, + 17, + 17, + 17, + 18, + 19, + 20, + 21, + 22 + ] + }, + { + "name": "gestureDirection", + "type": "string", + "data_points": [ + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max" + ] + }, + { + "name": "output", + "type": "float", + "data_points": [ + 8, + 9, + 10, + 10.285714, + 10.571428, + 10.857143, + 11.142857, + 11.428572, + 11.714286, + 12, + 12.794853, + 14.090998, + 15.424225, + 16.593058, + 17.534433, + 18.252384, + 18.778795, + 19.153036, + 19.412325, + 19.587936, + 19.704412, + 19.780127, + 19.828365, + 19.9, + 19.933332, + 19.966665, + 20, + 21, + 22 + ] + }, + { + "name": "outputTarget", + "type": "float", + "data_points": [ + 8, + 9, + 10, + 10.285714, + 10.571428, + 10.857143, + 11.142857, + 11.428572, + 11.714286, + 19.9, + 19.9, + 19.9, + 19.9, + 19.9, + 19.9, + 19.9, + 19.9, + 19.9, + 19.9, + 19.9, + 19.9, + 19.9, + 19.9, + 19.9, + 19.933332, + 19.966665, + 20, + 21, + 22 + ] + } + ] +} \ No newline at end of file diff --git a/mechanics/tests/goldens/Toggle/minDirection_AfterToggle_preventsJumpOnDirectionChange.json b/mechanics/tests/goldens/Toggle/minDirection_AfterToggle_preventsJumpOnDirectionChange.json new file mode 100644 index 0000000..842ef5e --- /dev/null +++ b/mechanics/tests/goldens/Toggle/minDirection_AfterToggle_preventsJumpOnDirectionChange.json @@ -0,0 +1,190 @@ +{ + "frame_ids": [ + 0, + 16, + 32, + 48, + 64, + 80, + 96, + 112, + 128, + 144, + 160, + 176, + 192, + 208, + 224, + 240, + 256, + 272, + 288, + 304, + 320, + 336, + 352, + 368, + 384, + 400, + 416, + 432, + 448, + 464, + 480, + 496 + ], + "features": [ + { + "name": "input", + "type": "float", + "data_points": [ + 22, + 21, + 20, + 19, + 18, + 17, + 16, + 15, + 14, + 13, + 12, + 12, + 12, + 12, + 12, + 12, + 12, + 12, + 12, + 12, + 12, + 12, + 13, + 14, + 15, + 14, + 13, + 12, + 11, + 10, + 9, + 8 + ] + }, + { + "name": "gestureDirection", + "type": "string", + "data_points": [ + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Max", + "Max", + "Max", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min" + ] + }, + { + "name": "output", + "type": "float", + "data_points": [ + 22, + 21, + 20, + 19.714287, + 19.428572, + 19.142857, + 18.857143, + 18.571428, + 18.285713, + 18, + 17.043434, + 15.556019, + 14.098007, + 12.889011, + 11.971426, + 11.313732, + 10.861605, + 10.5609665, + 10.366654, + 10.244246, + 10.168992, + 10.066667, + 10.1, + 10.575001, + 11.05, + 10.575001, + 10.1, + 10.066667, + 10.033333, + 10, + 9.966667, + 9.933334 + ] + }, + { + "name": "outputTarget", + "type": "float", + "data_points": [ + 22, + 21, + 20, + 19.714287, + 19.428572, + 19.142857, + 18.857143, + 18.571428, + 18.285713, + 10.1, + 10.066667, + 10.066667, + 10.066667, + 10.066667, + 10.066667, + 10.066667, + 10.066667, + 10.066667, + 10.066667, + 10.066667, + 10.066667, + 10.066667, + 10.1, + 10.575001, + 11.05, + 10.575001, + 10.1, + 10.066667, + 10.033333, + 10, + 9.966667, + 9.933334 + ] + } + ] +} \ No newline at end of file diff --git a/mechanics/tests/goldens/Toggle/minDirection_preventsDirectionChangeBeforeToggle.json b/mechanics/tests/goldens/Toggle/minDirection_preventsDirectionChangeBeforeToggle.json new file mode 100644 index 0000000..86d4d84 --- /dev/null +++ b/mechanics/tests/goldens/Toggle/minDirection_preventsDirectionChangeBeforeToggle.json @@ -0,0 +1,110 @@ +{ + "frame_ids": [ + 0, + 16, + 32, + 48, + 64, + 80, + 96, + 112, + 128, + 144, + 160, + 176, + 192, + 208, + 224, + 240 + ], + "features": [ + { + "name": "input", + "type": "float", + "data_points": [ + 22, + 21, + 20, + 19, + 18, + 17, + 16, + 15, + 15, + 16, + 17, + 18, + 19, + 20, + 21, + 22 + ] + }, + { + "name": "gestureDirection", + "type": "string", + "data_points": [ + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max" + ] + }, + { + "name": "output", + "type": "float", + "data_points": [ + 22, + 21, + 20, + 19.714287, + 19.428572, + 19.142857, + 18.857143, + 18.571428, + 18.571428, + 18.857143, + 19.142857, + 19.428572, + 19.714287, + 20, + 20.285715, + 20.571428 + ] + }, + { + "name": "outputTarget", + "type": "float", + "data_points": [ + 22, + 21, + 20, + 19.714287, + 19.428572, + 19.142857, + 18.857143, + 18.571428, + 18.571428, + 18.857143, + 19.142857, + 19.428572, + 19.714287, + 20, + 20.285715, + 20.571428 + ] + } + ] +} \ No newline at end of file diff --git a/mechanics/tests/goldens/Toggle/minDirection_togglesAtThreshold.json b/mechanics/tests/goldens/Toggle/minDirection_togglesAtThreshold.json new file mode 100644 index 0000000..0513526 --- /dev/null +++ b/mechanics/tests/goldens/Toggle/minDirection_togglesAtThreshold.json @@ -0,0 +1,175 @@ +{ + "frame_ids": [ + 0, + 16, + 32, + 48, + 64, + 80, + 96, + 112, + 128, + 144, + 160, + 176, + 192, + 208, + 224, + 240, + 256, + 272, + 288, + 304, + 320, + 336, + 352, + 368, + 384, + 400, + 416, + 432, + 448 + ], + "features": [ + { + "name": "input", + "type": "float", + "data_points": [ + 22, + 21, + 20, + 19, + 18, + 17, + 16, + 15, + 14, + 13, + 13, + 13, + 13, + 13, + 13, + 13, + 13, + 13, + 13, + 13, + 13, + 13, + 13, + 13, + 12, + 11, + 10, + 9, + 8 + ] + }, + { + "name": "gestureDirection", + "type": "string", + "data_points": [ + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min" + ] + }, + { + "name": "output", + "type": "float", + "data_points": [ + 22, + 21, + 20, + 19.714287, + 19.428572, + 19.142857, + 18.857143, + 18.571428, + 18.285713, + 18, + 17.205147, + 15.909001, + 14.575774, + 13.40694, + 12.465567, + 11.747616, + 11.221204, + 10.846963, + 10.587676, + 10.412065, + 10.295588, + 10.219872, + 10.171636, + 10.1, + 10.066667, + 10.033333, + 10, + 9, + 8 + ] + }, + { + "name": "outputTarget", + "type": "float", + "data_points": [ + 22, + 21, + 20, + 19.714287, + 19.428572, + 19.142857, + 18.857143, + 18.571428, + 18.285713, + 10.1, + 10.1, + 10.1, + 10.1, + 10.1, + 10.1, + 10.1, + 10.1, + 10.1, + 10.1, + 10.1, + 10.1, + 10.1, + 10.1, + 10.1, + 10.066667, + 10.033333, + 10, + 9, + 8 + ] + } + ] +} \ No newline at end of file diff --git a/mechanics/tests/goldens/Toggle/output_groundedInBaseMapping.json b/mechanics/tests/goldens/Toggle/output_groundedInBaseMapping.json new file mode 100644 index 0000000..9930158 --- /dev/null +++ b/mechanics/tests/goldens/Toggle/output_groundedInBaseMapping.json @@ -0,0 +1,180 @@ +{ + "frame_ids": [ + 0, + 16, + 32, + 48, + 64, + 80, + 96, + 112, + 128, + 144, + 160, + 176, + 192, + 208, + 224, + 240, + 256, + 272, + 288, + 304, + 320, + 336, + 352, + 368, + 384, + 400, + 416, + 432, + 448, + 464 + ], + "features": [ + { + "name": "input", + "type": "float", + "data_points": [ + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 17, + 18, + 19, + 20, + 21, + 22, + 22, + 22, + 22, + 22, + 22, + 22, + 22, + 22, + 22, + 22, + 22, + 22, + 22, + 22, + 22 + ] + }, + { + "name": "gestureDirection", + "type": "string", + "data_points": [ + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max" + ] + }, + { + "name": "output", + "type": "float", + "data_points": [ + -80, + -90, + -100, + -102.85715, + -105.71429, + -108.57143, + -111.42857, + -114.28572, + -117.14286, + -120, + -129.56564, + -145.72162, + -162.55551, + -185.63736, + -204.75362, + -210.89252, + -214.88318, + -217.37805, + -218.86652, + -219.7013, + -220.12784, + -220.31113, + -220.35823, + -220.33595, + -220.28404, + -220.22472, + -220.16925, + -220.12245, + -220.08548, + -220 + ] + }, + { + "name": "outputTarget", + "type": "float", + "data_points": [ + -80, + -90, + -100, + -102.85715, + -105.71429, + -108.57143, + -111.42857, + -114.28572, + -117.14286, + -199, + -199.33333, + -199.66666, + -200, + -210, + -220, + -220, + -220, + -220, + -220, + -220, + -220, + -220, + -220, + -220, + -220, + -220, + -220, + -220, + -220, + -220 + ] + } + ] +} \ No newline at end of file diff --git a/mechanics/tests/goldens/collection/animatingValueIsDisposed_collectionStopsAnimating.json b/mechanics/tests/goldens/collection/animatingValueIsDisposed_collectionStopsAnimating.json new file mode 100644 index 0000000..136a1e4 --- /dev/null +++ b/mechanics/tests/goldens/collection/animatingValueIsDisposed_collectionStopsAnimating.json @@ -0,0 +1,111 @@ +{ + "frame_ids": [ + 0, + 16, + 32, + 48, + 64, + 80, + 96, + 112, + 128 + ], + "features": [ + { + "name": "input", + "type": "float", + "data_points": [ + 0, + 0.3, + 0.6, + 0.90000004, + 1.2, + 1.5, + 1.5, + 1.5, + 1.5 + ] + }, + { + "name": "gestureDirection", + "type": "string", + "data_points": [ + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max" + ] + }, + { + "name": "primary-output", + "type": "float", + "data_points": [ + 0, + 0, + 0, + 0, + 0.033639193, + 0.16369182, + 0.32773256, + 0.48736703, + { + "type": "not_found" + } + ] + }, + { + "name": "primary-outputTarget", + "type": "float", + "data_points": [ + 0, + 0, + 0, + 0, + 1, + 1, + 1, + 1, + { + "type": "not_found" + } + ] + }, + { + "name": "primary-isStable", + "type": "boolean", + "data_points": [ + true, + true, + true, + true, + false, + false, + false, + false, + { + "type": "not_found" + } + ] + }, + { + "name": "primary-isAnimating", + "type": "boolean", + "data_points": [ + false, + true, + true, + true, + true, + true, + true, + true, + false + ] + } + ] +} \ No newline at end of file diff --git a/mechanics/tests/goldens/collection/oneAnimatingValue_collectionIsAnimating.json b/mechanics/tests/goldens/collection/oneAnimatingValue_collectionIsAnimating.json new file mode 100644 index 0000000..9d933ed --- /dev/null +++ b/mechanics/tests/goldens/collection/oneAnimatingValue_collectionIsAnimating.json @@ -0,0 +1,133 @@ +{ + "frame_ids": [ + 0, + 16, + 32, + 48, + 64, + 80, + 96, + 112, + 128, + 144, + 160, + 176, + 192 + ], + "features": [ + { + "name": "input", + "type": "float", + "data_points": [ + 0, + 0.4, + 0.8, + 1.2, + 1.6, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2 + ] + }, + { + "name": "gestureDirection", + "type": "string", + "data_points": [ + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max" + ] + }, + { + "name": "primary-output", + "type": "float", + "data_points": [ + 0, + 0, + 0, + 0.01973492, + 0.1381998, + 0.29998195, + 0.4619913, + 0.6040878, + 0.7193318, + 0.80780226, + 0.8728444, + 0.9189145, + 1 + ] + }, + { + "name": "primary-outputTarget", + "type": "float", + "data_points": [ + 0, + 0, + 0, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1 + ] + }, + { + "name": "primary-isStable", + "type": "boolean", + "data_points": [ + true, + true, + true, + false, + false, + false, + false, + false, + false, + false, + false, + false, + true + ] + }, + { + "name": "primary-isAnimating", + "type": "boolean", + "data_points": [ + false, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true + ] + } + ] +} \ No newline at end of file diff --git a/mechanics/tests/goldens/collection/twoAnimatingValues_oneStops_collectionKeepsAnimating.json b/mechanics/tests/goldens/collection/twoAnimatingValues_oneStops_collectionKeepsAnimating.json new file mode 100644 index 0000000..66657b4 --- /dev/null +++ b/mechanics/tests/goldens/collection/twoAnimatingValues_oneStops_collectionKeepsAnimating.json @@ -0,0 +1,220 @@ +{ + "frame_ids": [ + 0, + 16, + 32, + 48, + 64, + 80, + 96, + 112, + 128, + 144, + 160, + 176, + 192, + 208 + ], + "features": [ + { + "name": "input", + "type": "float", + "data_points": [ + 0, + 0.5, + 1, + 1.5, + 2, + 2.5, + 3, + 3, + 3, + 3, + 3, + 3, + 3, + 3 + ] + }, + { + "name": "gestureDirection", + "type": "string", + "data_points": [ + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max" + ] + }, + { + "name": "primary-output", + "type": "float", + "data_points": [ + 0, + 0, + 0, + 0.0696004, + 0.21705192, + 0.38261998, + 0.536185, + 0.6651724, + 0.7667498, + 0.8429822, + 0.89796525, + 1, + 1, + 1 + ] + }, + { + "name": "primary-outputTarget", + "type": "float", + "data_points": [ + 0, + 0, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1 + ] + }, + { + "name": "primary-isStable", + "type": "boolean", + "data_points": [ + true, + true, + false, + false, + false, + false, + false, + false, + false, + false, + false, + true, + true, + true + ] + }, + { + "name": "primary-isAnimating", + "type": "boolean", + "data_points": [ + false, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true + ] + }, + { + "name": "second-output", + "type": "float", + "data_points": [ + 1, + 1, + 1, + 1, + 1, + 1.0696003, + 1.217052, + 1.38262, + 1.536185, + 1.6651723, + 1.7667497, + 1.8429822, + 1.8979652, + 2 + ] + }, + { + "name": "second-outputTarget", + "type": "float", + "data_points": [ + 1, + 1, + 1, + 1, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2 + ] + }, + { + "name": "second-isStable", + "type": "boolean", + "data_points": [ + true, + true, + true, + true, + false, + false, + false, + false, + false, + false, + false, + false, + false, + true + ] + }, + { + "name": "second-isAnimating", + "type": "boolean", + "data_points": [ + false, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true + ] + } + ] +} \ No newline at end of file diff --git a/mechanics/tests/goldens/collection/wakeUp_onInputChange.json b/mechanics/tests/goldens/collection/wakeUp_onInputChange.json new file mode 100644 index 0000000..7a67648 --- /dev/null +++ b/mechanics/tests/goldens/collection/wakeUp_onInputChange.json @@ -0,0 +1,119 @@ +{ + "frame_ids": [ + 0, + 16, + 32, + 48, + 64, + 80, + 96, + 112, + 128, + 144, + 160 + ], + "features": [ + { + "name": "input", + "type": "float", + "data_points": [ + 0, + 0, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2 + ] + }, + { + "name": "gestureDirection", + "type": "string", + "data_points": [ + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max" + ] + }, + { + "name": "primary-output", + "type": "float", + "data_points": [ + 0, + 0, + 0.0696004, + 0.21705192, + 0.38261998, + 0.536185, + 0.6651724, + 0.7667498, + 0.8429822, + 0.89796525, + 1 + ] + }, + { + "name": "primary-outputTarget", + "type": "float", + "data_points": [ + 0, + 0, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1 + ] + }, + { + "name": "primary-isStable", + "type": "boolean", + "data_points": [ + true, + true, + false, + false, + false, + false, + false, + false, + false, + false, + true + ] + }, + { + "name": "primary-isAnimating", + "type": "boolean", + "data_points": [ + false, + false, + true, + true, + true, + true, + true, + true, + true, + true, + true + ] + } + ] +} \ No newline at end of file diff --git a/mechanics/tests/goldens/collection/wakeUp_onSpecChange.json b/mechanics/tests/goldens/collection/wakeUp_onSpecChange.json new file mode 100644 index 0000000..4d25085 --- /dev/null +++ b/mechanics/tests/goldens/collection/wakeUp_onSpecChange.json @@ -0,0 +1,98 @@ +{ + "frame_ids": [ + 0, + 16, + 32, + 48, + 64, + 80, + 96, + 112 + ], + "features": [ + { + "name": "input", + "type": "float", + "data_points": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ] + }, + { + "name": "gestureDirection", + "type": "string", + "data_points": [ + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max" + ] + }, + { + "name": "primary-output", + "type": "float", + "data_points": [ + 0, + 0, + 0.3545063, + 0.5699669, + 0.73491824, + 0.8475376, + 0.91835225, + 1 + ] + }, + { + "name": "primary-outputTarget", + "type": "float", + "data_points": [ + 0, + 0, + 1, + 1, + 1, + 1, + 1, + 1 + ] + }, + { + "name": "primary-isStable", + "type": "boolean", + "data_points": [ + true, + true, + false, + false, + false, + false, + false, + true + ] + }, + { + "name": "primary-isAnimating", + "type": "boolean", + "data_points": [ + false, + false, + true, + true, + true, + true, + true, + true + ] + } + ] +} \ No newline at end of file diff --git a/mechanics/tests/goldens/observeWhen_isOutputFixed.json b/mechanics/tests/goldens/observeWhen_isOutputFixed.json new file mode 100644 index 0000000..1157f1a --- /dev/null +++ b/mechanics/tests/goldens/observeWhen_isOutputFixed.json @@ -0,0 +1,444 @@ +{ + "frame_ids": [ + 0, + 16, + 32, + 48, + 64, + 80, + 96, + 112, + 128, + 144, + 160, + 176, + 192, + 208, + 224, + 240, + 256, + 272, + 288, + 304, + 320, + 336, + 352, + 368, + 384, + 400, + 416, + 432, + 448, + 464, + 480, + 496, + 512, + 528, + 544, + 560 + ], + "features": [ + { + "name": "input", + "type": "float", + "data_points": [ + 0, + 1.5, + 1.5, + 1.5, + 1.5, + 1.5, + 1.5, + 1.5, + 1.5, + 1.5, + 1.5, + 1.5, + 2.5, + 2.5, + 2.5, + 2.5, + 2.5, + 2.5, + 2.5, + 2.5, + 2.5, + 2.5, + 2.5, + 2.9, + 2.9, + 3.5, + 3.5, + 3.5, + 3.5, + 3.5, + 3.5, + 3.5, + 3.5, + 3.5, + 3.5, + 3.5 + ] + }, + { + "name": "gestureDirection", + "type": "string", + "data_points": [ + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max" + ] + }, + { + "name": "output", + "type": "float", + "data_points": [ + 0, + 0.09148693, + 1.1382809, + 2.7218752, + 4.360338, + 5.8219514, + 7.0204153, + 7.9479666, + 8.634457, + 9.123607, + 10, + 10, + 10.69735, + 11.881998, + 13.49982, + 15.119913, + 16.540878, + 17.693317, + 18.578022, + 19.228443, + 19.689146, + 20.5, + 20.5, + 20.9, + 20.9, + 20.445593, + 18.909435, + 17.091537, + 15.366772, + 13.898596, + 12.731601, + 11.84938, + 11.209088, + 10.760834, + 10, + 10 + ] + }, + { + "name": "outputTarget", + "type": "float", + "data_points": [ + 0, + 10, + 10, + 10, + 10, + 10, + 10, + 10, + 10, + 10, + 10, + 10, + 20.5, + 20.5, + 20.5, + 20.5, + 20.5, + 20.5, + 20.5, + 20.5, + 20.5, + 20.5, + 20.5, + 20.9, + 20.9, + 10, + 10, + 10, + 10, + 10, + 10, + 10, + 10, + 10, + 10, + 10 + ] + }, + { + "name": "outputSpring", + "type": "springParameters", + "data_points": [ + { + "stiffness": 100000, + "dampingRatio": 1 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + } + ] + }, + { + "name": "isStable", + "type": "boolean", + "data_points": [ + true, + false, + false, + false, + false, + false, + false, + false, + false, + false, + true, + true, + false, + false, + false, + false, + false, + false, + false, + false, + false, + true, + true, + true, + true, + false, + false, + false, + false, + false, + false, + false, + false, + false, + true, + true + ] + }, + { + "name": "isOutputFixed", + "type": "boolean", + "data_points": [ + true, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + true, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + true + ] + } + ] +} \ No newline at end of file diff --git a/mechanics/tests/goldens/unspecifiedSpec_atTheBeginning_jumpcutsToFirstValue.json b/mechanics/tests/goldens/unspecifiedSpec_atTheBeginning_jumpcutsToFirstValue.json new file mode 100644 index 0000000..1cec244 --- /dev/null +++ b/mechanics/tests/goldens/unspecifiedSpec_atTheBeginning_jumpcutsToFirstValue.json @@ -0,0 +1,92 @@ +{ + "frame_ids": [ + 0, + 16, + 32, + 48, + 64 + ], + "features": [ + { + "name": "input", + "type": "float", + "data_points": [ + 0, + 5, + 10, + 15, + 20 + ] + }, + { + "name": "gestureDirection", + "type": "string", + "data_points": [ + "Max", + "Max", + "Max", + "Max", + "Max" + ] + }, + { + "name": "output", + "type": "float", + "data_points": [ + "NaN", + "NaN", + "NaN", + 15, + 20 + ] + }, + { + "name": "outputTarget", + "type": "float", + "data_points": [ + "NaN", + "NaN", + "NaN", + 15, + 20 + ] + }, + { + "name": "outputSpring", + "type": "springParameters", + "data_points": [ + { + "stiffness": 100000, + "dampingRatio": 1 + }, + { + "stiffness": 100000, + "dampingRatio": 1 + }, + { + "stiffness": 100000, + "dampingRatio": 1 + }, + { + "stiffness": 100000, + "dampingRatio": 1 + }, + { + "stiffness": 100000, + "dampingRatio": 1 + } + ] + }, + { + "name": "isStable", + "type": "boolean", + "data_points": [ + true, + true, + true, + true, + true + ] + } + ] +} \ No newline at end of file diff --git a/mechanics/tests/goldens/unspecifiedSpec_outputIsNan.json b/mechanics/tests/goldens/unspecifiedSpec_outputIsNan.json new file mode 100644 index 0000000..9fbab70 --- /dev/null +++ b/mechanics/tests/goldens/unspecifiedSpec_outputIsNan.json @@ -0,0 +1,102 @@ +{ + "frame_ids": [ + 0, + 16, + 32, + 48, + 64, + 80 + ], + "features": [ + { + "name": "input", + "type": "float", + "data_points": [ + 0, + 20, + 40, + 60, + 80, + 100 + ] + }, + { + "name": "gestureDirection", + "type": "string", + "data_points": [ + "Max", + "Max", + "Max", + "Max", + "Max", + "Max" + ] + }, + { + "name": "output", + "type": "float", + "data_points": [ + "NaN", + "NaN", + "NaN", + "NaN", + "NaN", + "NaN" + ] + }, + { + "name": "outputTarget", + "type": "float", + "data_points": [ + "NaN", + "NaN", + "NaN", + "NaN", + "NaN", + "NaN" + ] + }, + { + "name": "outputSpring", + "type": "springParameters", + "data_points": [ + { + "stiffness": 100000, + "dampingRatio": 1 + }, + { + "stiffness": 100000, + "dampingRatio": 1 + }, + { + "stiffness": 100000, + "dampingRatio": 1 + }, + { + "stiffness": 100000, + "dampingRatio": 1 + }, + { + "stiffness": 100000, + "dampingRatio": 1 + }, + { + "stiffness": 100000, + "dampingRatio": 1 + } + ] + }, + { + "name": "isStable", + "type": "boolean", + "data_points": [ + true, + true, + true, + true, + true, + true + ] + } + ] +} \ No newline at end of file diff --git a/mechanics/tests/goldens/view/specChange_triggersAnimation.json b/mechanics/tests/goldens/view/specChange_triggersAnimation.json index b237f39..2555903 100644 --- a/mechanics/tests/goldens/view/specChange_triggersAnimation.json +++ b/mechanics/tests/goldens/view/specChange_triggersAnimation.json @@ -52,13 +52,13 @@ "type": "float", "data_points": [ 1.5, - 1.3117526, + 1.5, 0.96824056, 0.6450497, - 0.39762264, - 0.22869362, - 0.122471645, - 0.060223386, + 0.39762267, + 0.22869363, + 0.12247165, + 0.06022339, 0.026204487, 0.009041936, 0 @@ -69,7 +69,7 @@ "type": "float", "data_points": [ 1.5, - 0, + 1.5, 0, 0, 0, @@ -90,8 +90,8 @@ "dampingRatio": 1 }, { - "stiffness": 1400, - "dampingRatio": 0.9 + "stiffness": 100000, + "dampingRatio": 1 }, { "stiffness": 1400, @@ -136,7 +136,7 @@ "type": "boolean", "data_points": [ true, - false, + true, false, false, false, diff --git a/mechanics/tests/goldens/view/unspecifiedSpec_atTheBeginning_jumpcutsToFirstValue.json b/mechanics/tests/goldens/view/unspecifiedSpec_atTheBeginning_jumpcutsToFirstValue.json new file mode 100644 index 0000000..1cec244 --- /dev/null +++ b/mechanics/tests/goldens/view/unspecifiedSpec_atTheBeginning_jumpcutsToFirstValue.json @@ -0,0 +1,92 @@ +{ + "frame_ids": [ + 0, + 16, + 32, + 48, + 64 + ], + "features": [ + { + "name": "input", + "type": "float", + "data_points": [ + 0, + 5, + 10, + 15, + 20 + ] + }, + { + "name": "gestureDirection", + "type": "string", + "data_points": [ + "Max", + "Max", + "Max", + "Max", + "Max" + ] + }, + { + "name": "output", + "type": "float", + "data_points": [ + "NaN", + "NaN", + "NaN", + 15, + 20 + ] + }, + { + "name": "outputTarget", + "type": "float", + "data_points": [ + "NaN", + "NaN", + "NaN", + 15, + 20 + ] + }, + { + "name": "outputSpring", + "type": "springParameters", + "data_points": [ + { + "stiffness": 100000, + "dampingRatio": 1 + }, + { + "stiffness": 100000, + "dampingRatio": 1 + }, + { + "stiffness": 100000, + "dampingRatio": 1 + }, + { + "stiffness": 100000, + "dampingRatio": 1 + }, + { + "stiffness": 100000, + "dampingRatio": 1 + } + ] + }, + { + "name": "isStable", + "type": "boolean", + "data_points": [ + true, + true, + true, + true, + true + ] + } + ] +} \ No newline at end of file diff --git a/mechanics/tests/goldens/view/unspecifiedSpec_outputIsNan.json b/mechanics/tests/goldens/view/unspecifiedSpec_outputIsNan.json new file mode 100644 index 0000000..9fbab70 --- /dev/null +++ b/mechanics/tests/goldens/view/unspecifiedSpec_outputIsNan.json @@ -0,0 +1,102 @@ +{ + "frame_ids": [ + 0, + 16, + 32, + 48, + 64, + 80 + ], + "features": [ + { + "name": "input", + "type": "float", + "data_points": [ + 0, + 20, + 40, + 60, + 80, + 100 + ] + }, + { + "name": "gestureDirection", + "type": "string", + "data_points": [ + "Max", + "Max", + "Max", + "Max", + "Max", + "Max" + ] + }, + { + "name": "output", + "type": "float", + "data_points": [ + "NaN", + "NaN", + "NaN", + "NaN", + "NaN", + "NaN" + ] + }, + { + "name": "outputTarget", + "type": "float", + "data_points": [ + "NaN", + "NaN", + "NaN", + "NaN", + "NaN", + "NaN" + ] + }, + { + "name": "outputSpring", + "type": "springParameters", + "data_points": [ + { + "stiffness": 100000, + "dampingRatio": 1 + }, + { + "stiffness": 100000, + "dampingRatio": 1 + }, + { + "stiffness": 100000, + "dampingRatio": 1 + }, + { + "stiffness": 100000, + "dampingRatio": 1 + }, + { + "stiffness": 100000, + "dampingRatio": 1 + }, + { + "stiffness": 100000, + "dampingRatio": 1 + } + ] + }, + { + "name": "isStable", + "type": "boolean", + "data_points": [ + true, + true, + true, + true, + true, + true + ] + } + ] +} \ No newline at end of file diff --git a/mechanics/tests/src/com/android/mechanics/MotionValueCollectionLifecycleTest.kt b/mechanics/tests/src/com/android/mechanics/MotionValueCollectionLifecycleTest.kt new file mode 100644 index 0000000..7fda275 --- /dev/null +++ b/mechanics/tests/src/com/android/mechanics/MotionValueCollectionLifecycleTest.kt @@ -0,0 +1,321 @@ +/* + * Copyright (C) 2025 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 + +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.android.mechanics.MotionValueTest.Companion.FakeGestureContext +import com.android.mechanics.spec.InputDirection +import com.android.mechanics.spec.Mapping +import com.android.mechanics.spec.MotionSpec +import com.android.mechanics.spec.builder.MotionBuilderContext +import com.android.mechanics.spec.builder.directionalMotionSpec +import com.android.mechanics.spec.builder.fixedSpatialValueSpec +import com.android.mechanics.testing.FakeMotionSpecBuilderContext +import com.google.common.truth.Truth.assertThat +import kotlinx.coroutines.test.runTest +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class MotionValueCollectionLifecycleTest : + MotionBuilderContext by FakeMotionSpecBuilderContext.Default { + + @get:Rule(order = 0) val rule = createComposeRule() + + @Test + fun keepRunning_empty_doesNotWakeup() = runTest { + val input = mutableFloatStateOf(0f) + val underTest = MotionValueCollection(input::value, FakeGestureContext) + rule.setContent { LaunchedEffect(Unit) { underTest.keepRunning() } } + + rule.awaitIdle() + val framesCount = underTest.frameCount + rule.mainClock.autoAdvance = false + + assertThat(underTest.isActive).isTrue() + assertThat(underTest.isAnimating).isFalse() + + // Update the value, but WITHOUT causing an animation + input.floatValue = 0.5f + rule.awaitIdle() + + assertThat(framesCount).isEqualTo(underTest.frameCount) + assertThat(underTest.isAnimating).isFalse() + + rule.mainClock.advanceTimeByFrame() + rule.awaitIdle() + + assertThat(framesCount).isEqualTo(underTest.frameCount) + assertThat(underTest.isAnimating).isFalse() + } + + @Test + fun create_withoutKeepRunning_remainsInactive() = runTest { + val input = mutableFloatStateOf(1f) + val underTest = MotionValueCollection(input::value, FakeGestureContext) + + rule.setContent {} + + assertThat(underTest.isActive).isFalse() + + val motionValue = underTest.create({ MotionSpec.Identity }) + assertThat(motionValue.output).isNaN() + val inspector = motionValue.debugInspector() + assertThat(inspector.isActive).isFalse() + } + + @Test + fun create_whileKeepRunning_isActivatedImmediately() = runTest { + val input = mutableFloatStateOf(1f) + val underTest = MotionValueCollection(input::value, FakeGestureContext) + + rule.setContent { LaunchedEffect(Unit) { underTest.keepRunning() } } + rule.awaitIdle() + + assertThat(underTest.isActive).isTrue() + assertThat(underTest.managedMotionValues.size).isEqualTo(0) + + val motionValue = underTest.create({ MotionSpec.Identity }) + assertThat(motionValue.output).isEqualTo(1f) + val inspector = motionValue.debugInspector() + assertThat(inspector.isActive).isTrue() + } + + @Test + fun keepRunning_activatesAlreadyCreated() = runTest { + val input = mutableFloatStateOf(0f) + val underTest = MotionValueCollection(input::value, FakeGestureContext) + + val motionValue = underTest.create({ MotionSpec.Identity }) + val inspector = motionValue.debugInspector() + + assertThat(underTest.frameCount).isEqualTo(0) + assertThat(underTest.isActive).isFalse() + assertThat(underTest.isAnimating).isFalse() + assertThat(underTest.managedMotionValues.size).isEqualTo(1) + assertThat(inspector.isActive).isFalse() + assertThat(inspector.isAnimating).isFalse() + assertThat(motionValue.output).isNaN() + + rule.setContent { LaunchedEffect(Unit) { underTest.keepRunning() } } + + rule.awaitIdle() + + assertThat(underTest.frameCount).isEqualTo(1) + assertThat(underTest.isActive).isTrue() + assertThat(underTest.isAnimating).isFalse() + assertThat(underTest.managedMotionValues.size).isEqualTo(1) + assertThat(inspector.isActive).isTrue() + assertThat(inspector.isAnimating).isFalse() + assertThat(motionValue.output).isFinite() + } + + @Test + fun keepRunning_deavtivatesOnDispose() = runTest { + val input = mutableFloatStateOf(0f) + val underTest = MotionValueCollection(input::value, FakeGestureContext) + + val motionValue = underTest.create({ MotionSpec.Identity }) + val inspector = motionValue.debugInspector() + + rule.setContent { LaunchedEffect(Unit) { underTest.keepRunning() } } + + rule.awaitIdle() + + assertThat(underTest.frameCount).isEqualTo(1) + assertThat(underTest.isActive).isTrue() + assertThat(underTest.isAnimating).isFalse() + assertThat(underTest.managedMotionValues.size).isEqualTo(1) + assertThat(inspector.isActive).isTrue() + assertThat(inspector.isAnimating).isFalse() + + motionValue.dispose() + rule.awaitIdle() + + assertThat(underTest.frameCount).isEqualTo(2) + assertThat(underTest.isActive).isTrue() + assertThat(underTest.isAnimating).isFalse() + assertThat(underTest.managedMotionValues.size).isEqualTo(0) + assertThat(inspector.isActive).isFalse() + assertThat(inspector.isAnimating).isFalse() + } + + @Test + fun createAndDispose_withoutKeepRunning_isInactive() = runTest { + val input = mutableFloatStateOf(0f) + val underTest = MotionValueCollection(input::value, FakeGestureContext) + + rule.setContent {} + assertThat(underTest.isActive).isFalse() + + val motionValue = underTest.create({ MotionSpec.Identity }) + val inspector = motionValue.debugInspector() + rule.awaitIdle() + + assertThat(underTest.isActive).isFalse() + assertThat(inspector.isActive).isFalse() + assertThat(underTest.managedMotionValues.size).isEqualTo(1) + + motionValue.dispose() + rule.awaitIdle() + + assertThat(underTest.isActive).isFalse() + assertThat(inspector.isActive).isFalse() + assertThat(underTest.managedMotionValues.size).isEqualTo(0) + } + + @Test + fun keepRunning_withMultipleValues() = runTest { + val input = mutableFloatStateOf(0f) + val underTest = MotionValueCollection(input::value, FakeGestureContext) + + val mv1 = underTest.create({ MotionSpec.Identity }) + val inspector1 = mv1.debugInspector() + val mv2 = underTest.create({ MotionSpec.Identity }) + val inspector2 = mv2.debugInspector() + + rule.setContent { LaunchedEffect(Unit) { underTest.keepRunning() } } + rule.awaitIdle() + + assertThat(underTest.isActive).isTrue() + assertThat(underTest.managedMotionValues.size).isEqualTo(2) + assertThat(inspector1.isActive).isTrue() + assertThat(inspector2.isActive).isTrue() + + mv1.dispose() + rule.awaitIdle() + + assertThat(underTest.managedMotionValues.size).isEqualTo(1) + assertThat(inspector1.isActive).isFalse() + assertThat(inspector2.isActive).isTrue() + + mv2.dispose() + rule.awaitIdle() + + assertThat(underTest.managedMotionValues.size).isEqualTo(0) + assertThat(inspector1.isActive).isFalse() + assertThat(inspector2.isActive).isFalse() + } + + @Test + fun keepRunning_cancelled_deactivates() = runTest { + val input = mutableFloatStateOf(0f) + val underTest = MotionValueCollection(input::value, FakeGestureContext) + val inspector = underTest.create({ MotionSpec.Identity }).debugInspector() + val keepRunning = mutableStateOf(true) + + rule.setContent { + if (keepRunning.value) { + LaunchedEffect(Unit) { underTest.keepRunning() } + } + } + rule.awaitIdle() + + assertThat(underTest.isActive).isTrue() + assertThat(inspector.isActive).isTrue() + assertThat(underTest.managedMotionValues.size).isEqualTo(1) + + keepRunning.value = false + rule.awaitIdle() + + assertThat(underTest.isActive).isFalse() + assertThat(inspector.isActive).isFalse() + assertThat(underTest.managedMotionValues.size).isEqualTo(1) + } + + @Test + fun latchesInput_changesAreProcessedOnFrameStartOnly() = runTest { + val input = mutableFloatStateOf(0f) + + val underTest = MotionValueCollection(input::value, FakeGestureContext) + val motionValue = underTest.create({ MotionSpec.Identity }) + + rule.setContent { LaunchedEffect(Unit) { underTest.keepRunning() } } + + rule.awaitIdle() + + rule.mainClock.autoAdvance = false + + assertThat(motionValue.output).isEqualTo(0f) + input.floatValue = 1f + assertThat(motionValue.output).isEqualTo(0f) + + rule.mainClock.advanceTimeByFrame() + rule.awaitIdle() + assertThat(motionValue.output).isEqualTo(1f) + } + + @Test + fun latchesGestureContext_changesAreProcessedOnFrameStartOnly() = runTest { + val gestureContext = ProvidedGestureContext(0f, InputDirection.Max) + val spec = + MotionSpec( + maxDirection = directionalMotionSpec(Mapping.Zero), + minDirection = directionalMotionSpec(Mapping.One), + ) + + val underTest = MotionValueCollection({ 0f }, gestureContext) + val motionValue = underTest.create({ spec }) + + rule.setContent { LaunchedEffect(Unit) { underTest.keepRunning() } } + + rule.awaitIdle() + + rule.mainClock.autoAdvance = false + + assertThat(motionValue.output).isEqualTo(0f) + gestureContext.direction = InputDirection.Min + assertThat(motionValue.output).isEqualTo(0f) + + rule.mainClock.advanceTimeByFrame() + rule.awaitIdle() + + // Note: Animation is not expected here, since the segmentKey is in both directions + // [minLimit,maxLimit]. + assertThat(motionValue.output).isEqualTo(1f) + } + + @Test + fun latchesSpec_changesAreProcessedOnFrameStartOnly() = runTest { + val spec = mutableStateOf(fixedSpatialValueSpec(0f)) + + val underTest = MotionValueCollection({ 0f }, FakeGestureContext) + val motionValue = underTest.create(spec::value) + + rule.setContent { LaunchedEffect(Unit) { underTest.keepRunning() } } + + rule.awaitIdle() + + rule.mainClock.autoAdvance = false + + assertThat(motionValue.output).isEqualTo(0f) + spec.value = fixedSpatialValueSpec(1f) + assertThat(motionValue.output).isEqualTo(0f) + + rule.mainClock.advanceTimeByFrame() + rule.awaitIdle() + + // Note: Animation is not expected here, since the segmentKey is in both directions + // [minLimit,maxLimit]. + assertThat(motionValue.output).isEqualTo(1f) + } +} diff --git a/mechanics/tests/src/com/android/mechanics/MotionValueCollectionTest.kt b/mechanics/tests/src/com/android/mechanics/MotionValueCollectionTest.kt new file mode 100644 index 0000000..c0ceda9 --- /dev/null +++ b/mechanics/tests/src/com/android/mechanics/MotionValueCollectionTest.kt @@ -0,0 +1,147 @@ +/* + * Copyright (C) 2025 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 + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.android.mechanics.MotionValueTest.Companion.specBuilder +import com.android.mechanics.spec.InputDirection +import com.android.mechanics.spec.Mapping +import com.android.mechanics.spec.MotionSpec +import com.android.mechanics.spec.builder.MotionBuilderContext +import com.android.mechanics.testing.CaptureTimeSeriesFn +import com.android.mechanics.testing.CollectionInputScope +import com.android.mechanics.testing.ComposeMotionValueCollectionToolkit +import com.android.mechanics.testing.FakeMotionSpecBuilderContext +import com.android.mechanics.testing.FeatureCaptures +import com.android.mechanics.testing.VerifyTimeSeriesFn +import com.android.mechanics.testing.VerifyTimeSeriesResult +import com.android.mechanics.testing.animateValueTo +import com.android.mechanics.testing.goldenTest +import com.android.mechanics.testing.nullableDataPoints +import com.android.mechanics.testing.whenActive +import com.google.common.truth.Truth.assertThat +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import platform.test.motion.MotionTestRule +import platform.test.motion.testing.createGoldenPathManager +import platform.test.screenshot.PathConfig +import platform.test.screenshot.PathElementNoContext + +@RunWith(AndroidJUnit4::class) +class MotionValueCollectionTest : MotionBuilderContext by FakeMotionSpecBuilderContext.Default { + private val goldenPathManager = + createGoldenPathManager( + "frameworks/libs/systemui/mechanics/tests/goldens", + PathConfig(PathElementNoContext("base", isDir = true, { "collection" })), + ) + + @get:Rule(order = 1) + val motion = MotionTestRule(ComposeMotionValueCollectionToolkit, goldenPathManager) + + @Test + fun oneAnimatingValue_collectionIsAnimating() = + goldenTest(spec = specBuilder(Mapping.Zero) { fixedValue(breakpoint = 1f, value = 1f) }) { + animateValueTo(2f) + awaitStable() + } + + @Test + fun twoAnimatingValues_oneStops_collectionKeepsAnimating() = + goldenTest( + spec = specBuilder(Mapping.Zero) { fixedValue(breakpoint = 1f, value = 1f) }, + createDerived = { + val secondSpec = + specBuilder(Mapping.One) { fixedValue(breakpoint = 2f, value = 2f) } + listOf(it.create({ secondSpec }, "second")) + }, + ) { + animateValueTo(3f, changePerFrame = 0.5f) + awaitStable() + } + + @Test + fun animatingValueIsDisposed_collectionStopsAnimating() = + goldenTest( + spec = specBuilder(Mapping.Zero) { fixedValue(breakpoint = 1f, value = 1f) }, + verifyTimeSeries = { + val output = nullableDataPoints("primary-output") + assertThat(output.last()).isNull() + assertThat(output.dropLast(1)).doesNotContain(null) + + VerifyTimeSeriesResult.AssertTimeSeriesMatchesGolden() + }, + ) { + animateValueTo(1.5f) + awaitFrames(2) + motionValues.first().dispose() + awaitStable() + } + + @Test + fun wakeUp_onInputChange() = + goldenTest(spec = specBuilder(Mapping.Zero) { fixedValue(breakpoint = 1f, value = 1f) }) { + awaitStable() + updateInput(2f) + awaitStable() + } + + @Test + fun wakeUp_onSpecChange() = + goldenTest(spec = specBuilder(Mapping.Zero) { fixedValue(breakpoint = 1f, value = 1f) }) { + awaitStable() + spec = specBuilder(Mapping.Zero) { fixedValue(breakpoint = -1f, value = 1f) } + awaitStable() + } + + private fun goldenTest( + spec: MotionSpec, + initialValue: Float = 0f, + initialDirection: InputDirection = InputDirection.Max, + directionChangeSlop: Float = 5f, + stableThreshold: Float = 0.1f, + verifyTimeSeries: VerifyTimeSeriesFn = { + VerifyTimeSeriesResult.AssertTimeSeriesMatchesGolden() + }, + createDerived: (underTest: MotionValueCollection) -> List = { + emptyList() + }, + capture: CaptureTimeSeriesFn = defaultManagedFeatureCaptures, + testInput: suspend CollectionInputScope.() -> Unit, + ) = + motion.goldenTest( + spec, + initialValue, + initialDirection, + directionChangeSlop, + stableThreshold, + verifyTimeSeries, + createDerived, + capture, + testInput, + ) + + companion object { + /** Default feature captures. */ + val defaultManagedFeatureCaptures: CaptureTimeSeriesFn = { + feature(FeatureCaptures.output.whenActive()) + feature(FeatureCaptures.outputTarget.whenActive()) + feature(FeatureCaptures.isStable.whenActive()) + feature(FeatureCaptures.isAnimating) + } + } +} diff --git a/mechanics/tests/src/com/android/mechanics/MotionValueLifecycleTest.kt b/mechanics/tests/src/com/android/mechanics/MotionValueLifecycleTest.kt index 72ba985..f3dc050 100644 --- a/mechanics/tests/src/com/android/mechanics/MotionValueLifecycleTest.kt +++ b/mechanics/tests/src/com/android/mechanics/MotionValueLifecycleTest.kt @@ -42,7 +42,7 @@ class MotionValueLifecycleTest { fun keepRunning_suspendsWithoutAnAnimation() = runTest { val input = mutableFloatStateOf(0f) val spec = specBuilder(Mapping.Zero) { fixedValue(breakpoint = 1f, value = 1f) } - val underTest = MotionValue(input::value, FakeGestureContext, spec) + val underTest = MotionValue(input::value, FakeGestureContext, { spec }) rule.setContent { LaunchedEffect(Unit) { underTest.keepRunning() } } val inspector = underTest.debugInspector() @@ -91,7 +91,7 @@ class MotionValueLifecycleTest { fun keepRunning_remainsActiveWhileAnimating() = runTest { val input = mutableFloatStateOf(0f) val spec = specBuilder(Mapping.Zero) { fixedValue(breakpoint = 1f, value = 1f) } - val underTest = MotionValue(input::value, FakeGestureContext, spec) + val underTest = MotionValue(input::value, FakeGestureContext, { spec }) rule.setContent { LaunchedEffect(Unit) { underTest.keepRunning() } } val inspector = underTest.debugInspector() @@ -150,7 +150,7 @@ class MotionValueLifecycleTest { fun keepRunningWhile_stopRunningWhileStable_endsImmediately() = runTest { val input = mutableFloatStateOf(0f) val spec = specBuilder(Mapping.Zero) { fixedValue(breakpoint = 1f, value = 1f) } - val underTest = MotionValue(input::value, FakeGestureContext, spec) + val underTest = MotionValue(input::value, FakeGestureContext, { spec }) val continueRunning = mutableStateOf(true) diff --git a/mechanics/tests/src/com/android/mechanics/MotionValueTest.kt b/mechanics/tests/src/com/android/mechanics/MotionValueTest.kt index ffb8e87..740a803 100644 --- a/mechanics/tests/src/com/android/mechanics/MotionValueTest.kt +++ b/mechanics/tests/src/com/android/mechanics/MotionValueTest.kt @@ -50,6 +50,7 @@ import com.android.mechanics.testing.input import com.android.mechanics.testing.isStable import com.android.mechanics.testing.output import com.google.common.truth.Truth.assertThat +import kotlin.test.assertFailsWith import kotlinx.coroutines.launch import org.junit.Rule import org.junit.Test @@ -71,7 +72,7 @@ class MotionValueTest : MotionBuilderContext by FakeMotionSpecBuilderContext.Def @Test fun emptySpec_outputMatchesInput_withoutAnimation() = motion.goldenTest( - spec = MotionSpec.Empty, + spec = MotionSpec.Identity, verifyTimeSeries = { // Output always matches the input assertThat(output).containsExactlyElementsIn(input).inOrder() @@ -84,6 +85,48 @@ class MotionValueTest : MotionBuilderContext by FakeMotionSpecBuilderContext.Def animateValueTo(100f) } + @Test + fun unspecifiedSpec_outputIsNan() = + motion.goldenTest( + spec = MotionSpec.InitiallyUndefined, + verifyTimeSeries = { + // This must only produce NaN values + output.forEach { assertThat(it).isNaN() } + // There must never be an ongoing animation. + assertThat(isStable).doesNotContain(false) + AssertTimeSeriesMatchesGolden() + }, + ) { + animateValueTo(100f) + } + + @Test + fun unspecifiedSpec_atTheBeginning_jumpcutsToFirstValue() = + motion.goldenTest( + spec = MotionSpec.InitiallyUndefined, + verifyTimeSeries = { + // There must never be an ongoing animation. + assertThat(isStable).doesNotContain(false) + + AssertTimeSeriesMatchesGolden() + }, + ) { + animateValueTo(10f, changePerFrame = 5f) + spec = MotionSpec.Identity + animateValueTo(20f, changePerFrame = 5f) + } + + @Test + fun unspecifiedSpec_onAlreadyInitializedValue_throws() { + assertFailsWith { + motion.goldenTest(spec = MotionSpec.Identity) { + animateValueTo(10f, changePerFrame = 5f) + spec = MotionSpec.InitiallyUndefined + animateValueTo(20f, changePerFrame = 5f) + } + } + } + // TODO the tests should describe the expected values not only in terms of goldens, but // also explicitly in verifyTimeSeries @@ -251,6 +294,66 @@ class MotionValueTest : MotionBuilderContext by FakeMotionSpecBuilderContext.Def awaitStable() } + @Test + fun observeWhen_isOutputFixed() { + motion.goldenTest( + spec = + specBuilder(Mapping.Zero) { + fixedValue(breakpoint = 1f, value = 10f) + fractionalInput(breakpoint = 2f, from = 20f, fraction = 1f) + fixedValue(breakpoint = 3f, value = 10f) + }, + stableThreshold = 1f, + capture = { + defaultFeatureCaptures() + feature(FeatureCaptures.isOutputFixed) + }, + ) { + // Segment: Mapping.Zero + + updateInput(0.5f) + assertThat(underTest.isOutputFixed).isTrue() + + // Segment: fixedValue(breakpoint = 1f, value = 10f) + + updateInput(1.5f) + assertThat(underTest.isOutputFixed).isFalse() + awaitStable() + assertThat(underTest.isOutputFixed).isFalse() + awaitFrames(1) + assertThat(underTest.isOutputFixed).isTrue() + + updateInput(1.9f) + assertThat(underTest.isOutputFixed).isTrue() + + // Segment: fractionalInput(breakpoint = 2f, from = 20f, fraction = 1f) + + updateInput(2.5f) + assertThat(underTest.isOutputFixed).isFalse() + awaitStable() + assertThat(underTest.isOutputFixed).isFalse() + awaitFrames(1) + assertThat(underTest.isOutputFixed).isFalse() + + updateInput(2.9f) + awaitStable() + awaitFrames(1) + assertThat(underTest.isOutputFixed).isFalse() + + // Segment: fixedValue(breakpoint = 3f, value = 10f) + + updateInput(3.5f) + assertThat(underTest.isOutputFixed).isFalse() + awaitStable() + assertThat(underTest.isOutputFixed).isFalse() + awaitFrames(1) + assertThat(underTest.isOutputFixed).isTrue() + + updateInput(3.9f) + assertThat(underTest.isOutputFixed).isTrue() + } + } + @Test fun specChange_shiftSegmentBackwards_doesNotAnimateWithinSegment_animatesSegmentChange() { fun generateSpec(offset: Float) = @@ -263,7 +366,7 @@ class MotionValueTest : MotionBuilderContext by FakeMotionSpecBuilderContext.Def var offset = 0f repeat(4) { offset -= .2f - underTest.spec = generateSpec(offset) + spec = generateSpec(offset) awaitFrames() } awaitStable() @@ -282,7 +385,7 @@ class MotionValueTest : MotionBuilderContext by FakeMotionSpecBuilderContext.Def var offset = 0f repeat(4) { offset += .2f - underTest.spec = generateSpec(offset) + spec = generateSpec(offset) awaitFrames() } awaitStable() @@ -426,7 +529,7 @@ class MotionValueTest : MotionBuilderContext by FakeMotionSpecBuilderContext.Def @Test fun semantics_returnsNullForUnknownKey() { - val underTest = MotionValue({ 1f }, FakeGestureContext) + val underTest = MotionValue({ 1f }, FakeGestureContext, { MotionSpec.Identity }) val s1 = SemanticKey("Foo") @@ -443,7 +546,7 @@ class MotionValueTest : MotionBuilderContext by FakeMotionSpecBuilderContext.Def } val input = mutableFloatStateOf(0f) - val underTest = MotionValue(input::value, FakeGestureContext, spec) + val underTest = MotionValue(input::value, FakeGestureContext, { spec }) assertThat(underTest[s1]).isEqualTo("zero") input.floatValue = 2f @@ -459,7 +562,7 @@ class MotionValueTest : MotionBuilderContext by FakeMotionSpecBuilderContext.Def } val input = mutableFloatStateOf(1f) - val underTest = MotionValue(input::value, FakeGestureContext, spec) + val underTest = MotionValue(input::value, FakeGestureContext, { spec }) assertThat(underTest.segmentKey).isEqualTo(SegmentKey(B1, B2, InputDirection.Max)) input.floatValue = 2f @@ -472,7 +575,9 @@ class MotionValueTest : MotionBuilderContext by FakeMotionSpecBuilderContext.Def motion.goldenTest( spec = specBuilder(Mapping.Zero) { fixedValue(breakpoint = 0.5f, value = 1f) }, createDerived = { primary -> - listOf(MotionValue.createDerived(primary, MotionSpec.Empty, label = "derived")) + listOf( + MotionValue.createDerived(primary, { MotionSpec.Identity }, label = "derived") + ) }, verifyTimeSeries = { // the output of the derived value must match the primary value @@ -497,8 +602,10 @@ class MotionValueTest : MotionBuilderContext by FakeMotionSpecBuilderContext.Def createDerived = { primary -> listOf( MotionValue.createDerived( - primary, - specBuilder(Mapping.One) { fixedValue(breakpoint = 0.5f, value = 0f) }, + source = primary, + spec = { + specBuilder(Mapping.One) { fixedValue(breakpoint = 0.5f, value = 0f) } + }, label = "derived", ) ) @@ -529,7 +636,7 @@ class MotionValueTest : MotionBuilderContext by FakeMotionSpecBuilderContext.Def @Test fun nonFiniteNumbers_segmentChange_skipsAnimation() { motion.goldenTest( - spec = MotionSpec.Empty, + spec = MotionSpec.Identity, verifyTimeSeries = { // The mappings produce a non-finite number during a segment change. // The animation thereof is skipped to avoid poisoning the state with non-finite @@ -541,9 +648,7 @@ class MotionValueTest : MotionBuilderContext by FakeMotionSpecBuilderContext.Def }, ) { animatedInputSequence(0f, 1f) - underTest.spec = specBuilder { - mapping(breakpoint = 0f) { if (it >= 1f) Float.NaN else 0f } - } + spec = specBuilder { mapping(breakpoint = 0f) { if (it >= 1f) Float.NaN else 0f } } awaitFrames() @@ -581,7 +686,8 @@ class MotionValueTest : MotionBuilderContext by FakeMotionSpecBuilderContext.Def @Test fun keepRunning_concurrentInvocationThrows() = runMonotonicClockTest { - val underTest = MotionValue({ 1f }, FakeGestureContext, label = "Foo") + val underTest = + MotionValue({ 1f }, FakeGestureContext, { MotionSpec.Identity }, label = "Foo") val realJob = launch { underTest.keepRunning() } testScheduler.runCurrent() @@ -599,7 +705,7 @@ class MotionValueTest : MotionBuilderContext by FakeMotionSpecBuilderContext.Def @Test fun debugInspector_sameInstance_whileInUse() { - val underTest = MotionValue({ 1f }, FakeGestureContext) + val underTest = MotionValue({ 1f }, FakeGestureContext, { MotionSpec.Identity }) val originalInspector = underTest.debugInspector() assertThat(underTest.debugInspector()).isSameInstanceAs(originalInspector) @@ -607,7 +713,7 @@ class MotionValueTest : MotionBuilderContext by FakeMotionSpecBuilderContext.Def @Test fun debugInspector_newInstance_afterUnused() { - val underTest = MotionValue({ 1f }, FakeGestureContext) + val underTest = MotionValue({ 1f }, FakeGestureContext, { MotionSpec.Identity }) val originalInspector = underTest.debugInspector() originalInspector.dispose() diff --git a/mechanics/tests/src/com/android/mechanics/debug/MotionValueDebuggerTest.kt b/mechanics/tests/src/com/android/mechanics/debug/MotionValueDebuggerTest.kt index dfe69b8..a7c3b30 100644 --- a/mechanics/tests/src/com/android/mechanics/debug/MotionValueDebuggerTest.kt +++ b/mechanics/tests/src/com/android/mechanics/debug/MotionValueDebuggerTest.kt @@ -17,6 +17,7 @@ package com.android.mechanics.debug import androidx.compose.foundation.layout.Box +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -27,6 +28,7 @@ import androidx.test.ext.junit.runners.AndroidJUnit4 import com.android.mechanics.MotionValue import com.android.mechanics.ProvidedGestureContext import com.android.mechanics.spec.InputDirection +import com.android.mechanics.spec.MotionSpec import com.google.common.truth.Truth.assertThat import org.junit.Rule import org.junit.Test @@ -43,51 +45,55 @@ class MotionValueDebuggerTest { @Test fun debugMotionValue_registersMotionValue_whenAddingToComposition() { - val debuggerState = MotionValueDebuggerState() + val debuggerState = MotionValueDebugController() var hasValue by mutableStateOf(false) rule.setContent { - Box(modifier = Modifier.motionValueDebugger(debuggerState)) { + CompositionLocalProvider(LocalMotionValueDebugController provides debuggerState) { if (hasValue) { - val toDebug = remember { MotionValue(input, gestureContext) } + val toDebug = remember { + MotionValue(input, gestureContext, { MotionSpec.Identity }) + } Box(modifier = Modifier.debugMotionValue(toDebug)) } } } - assertThat(debuggerState.observedMotionValues).isEmpty() + assertThat(debuggerState.observed).isEmpty() hasValue = true rule.waitForIdle() - assertThat(debuggerState.observedMotionValues).hasSize(1) + assertThat(debuggerState.observed).hasSize(1) } @Test fun debugMotionValue_unregistersMotionValue_whenLeavingComposition() { - val debuggerState = MotionValueDebuggerState() + val debuggerState = MotionValueDebugController() var hasValue by mutableStateOf(true) rule.setContent { - Box(modifier = Modifier.motionValueDebugger(debuggerState)) { + CompositionLocalProvider(LocalMotionValueDebugController provides debuggerState) { if (hasValue) { - val toDebug = remember { MotionValue(input, gestureContext) } + val toDebug = remember { + MotionValue(input, gestureContext, { MotionSpec.Identity }) + } Box(modifier = Modifier.debugMotionValue(toDebug)) } } } - assertThat(debuggerState.observedMotionValues).hasSize(1) + assertThat(debuggerState.observed).hasSize(1) hasValue = false rule.waitForIdle() - assertThat(debuggerState.observedMotionValues).isEmpty() + assertThat(debuggerState.observed).isEmpty() } @Test fun debugMotionValue_noDebugger_isNoOp() { rule.setContent { - val toDebug = remember { MotionValue(input, gestureContext) } + val toDebug = remember { MotionValue(input, gestureContext, { MotionSpec.Identity }) } Box(modifier = Modifier.debugMotionValue(toDebug)) } } diff --git a/mechanics/tests/src/com/android/mechanics/effects/ToggleTest.kt b/mechanics/tests/src/com/android/mechanics/effects/ToggleTest.kt new file mode 100644 index 0000000..17212b8 --- /dev/null +++ b/mechanics/tests/src/com/android/mechanics/effects/ToggleTest.kt @@ -0,0 +1,178 @@ +/* + * Copyright (C) 2025 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.effects + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.android.mechanics.DistanceGestureContext +import com.android.mechanics.MotionValue +import com.android.mechanics.spec.InputDirection +import com.android.mechanics.spec.Mapping +import com.android.mechanics.spec.MotionSpec +import com.android.mechanics.spec.builder.MotionBuilderContext +import com.android.mechanics.spec.builder.spatialMotionSpec +import com.android.mechanics.testing.ComposeMotionValueToolkit +import com.android.mechanics.testing.FakeMotionSpecBuilderContext +import com.android.mechanics.testing.FeatureCaptures +import com.android.mechanics.testing.InputScope +import com.android.mechanics.testing.MotionSpecSubject.Companion.assertThat +import com.android.mechanics.testing.animateValueTo +import com.android.mechanics.testing.goldenTest +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import platform.test.motion.MotionTestRule +import platform.test.motion.testing.createGoldenPathManager +import platform.test.screenshot.PathConfig +import platform.test.screenshot.PathElementNoContext + +@RunWith(AndroidJUnit4::class) +class ToggleSpecTest : MotionBuilderContext by FakeMotionSpecBuilderContext.Default { + + val underTest = ExpansionToggle.Default + + @Test + fun toggle_matchesSpec() { + val spec = spatialMotionSpec { + between( + 0f, + 100f, + Toggle( + ExpansionToggle.IsExpandedKey, + minState = false, + maxState = true, + toggleFraction = .75f, + ), + ) + } + + assertThat(spec).maxDirection().breakpoints().positions().containsExactly(0f, 75f, 100f) + assertThat(spec).minDirection().breakpoints().positions().containsExactly(0f, 25f, 100f) + } + + @Test + fun stateSemantics_isApplied() { + val underTests = spatialMotionSpec { between(10f, 20f, underTest) } + + assertThat(underTests) + .maxDirection() + .semantics() + .withKey(ExpansionToggle.IsExpandedKey) + .containsExactly(false, false, true, true) + assertThat(underTests) + .minDirection() + .semantics() + .withKey(ExpansionToggle.IsExpandedKey) + .containsExactly(false, false, true, true) + } +} + +class ToggleGoldenTest() : MotionBuilderContext by FakeMotionSpecBuilderContext.Default { + + private val goldenPathManager = + createGoldenPathManager( + "frameworks/libs/systemui/mechanics/tests/goldens", + PathConfig(PathElementNoContext("effect", isDir = true) { "Toggle" }), + ) + + @get:Rule val motion = MotionTestRule(ComposeMotionValueToolkit, goldenPathManager) + + val underTest = ExpansionToggle.Default + + @Test + fun maxDirection_togglesAtThreshold() = + goldenTest(spatialMotionSpec { between(10f, 20f, underTest) }, 8f, InputDirection.Max) { + animateValueTo(17f, changePerFrame = 1f) + awaitStable() + animateValueTo(22f, changePerFrame = 1f) + } + + @Test + fun maxDirection_preventsDirectionChangeBeforeToggle() = + goldenTest(spatialMotionSpec { between(10f, 20f, underTest) }, 8f, InputDirection.Max) { + animateValueTo(15f, changePerFrame = 1f) + awaitStable() + animateValueTo(8f, changePerFrame = 1f) + } + + @Test + fun maxDirection_AfterToggle_preventsJumpOnDirectionChange() = + goldenTest(spatialMotionSpec { between(10f, 20f, underTest) }, 8f, InputDirection.Max) { + animateValueTo(18f, changePerFrame = 1f) + awaitStable() + animateValueTo(15f, changePerFrame = 1f) + animateValueTo(22f, changePerFrame = 1f) + } + + @Test + fun minDirection_togglesAtThreshold() = + goldenTest(spatialMotionSpec { between(10f, 20f, underTest) }, 22f, InputDirection.Min) { + animateValueTo(13f, changePerFrame = 1f) + awaitStable() + animateValueTo(8f, changePerFrame = 1f) + } + + @Test + fun minDirection_preventsDirectionChangeBeforeToggle() = + goldenTest(spatialMotionSpec { between(10f, 20f, underTest) }, 22f, InputDirection.Min) { + animateValueTo(15f, changePerFrame = 1f) + awaitStable() + animateValueTo(22f, changePerFrame = 1f) + } + + @Test + fun minDirection_AfterToggle_preventsJumpOnDirectionChange() = + goldenTest(spatialMotionSpec { between(10f, 20f, underTest) }, 22f, InputDirection.Min) { + animateValueTo(12f, changePerFrame = 1f) + awaitStable() + animateValueTo(15f, changePerFrame = 1f) + animateValueTo(8f, changePerFrame = 1f) + } + + @Test + fun output_groundedInBaseMapping() = + goldenTest( + spatialMotionSpec(baseMapping = Mapping.Linear(factor = -10f)) { + between(10f, 20f, underTest) + }, + 8f, + InputDirection.Max, + ) { + animateValueTo(22f, changePerFrame = 1f) + awaitStable() + } + + private fun goldenTest( + spec: MotionSpec, + initialValue: Float, + initialDirection: InputDirection, + testInput: suspend (InputScope).() -> Unit, + ) = + motion.goldenTest( + spec, + initialValue, + initialDirection, + directionChangeSlop = 0.5f, + stableThreshold = 0.1f, + capture = { + feature(FeatureCaptures.input) + feature(FeatureCaptures.gestureDirection) + feature(FeatureCaptures.output) + feature(FeatureCaptures.outputTarget) + }, + testInput = testInput, + ) +} diff --git a/mechanics/tests/src/com/android/mechanics/spec/DirectionalMotionSpecTest.kt b/mechanics/tests/src/com/android/mechanics/spec/DirectionalMotionSpecTest.kt index 30c8513..d2a00cc 100644 --- a/mechanics/tests/src/com/android/mechanics/spec/DirectionalMotionSpecTest.kt +++ b/mechanics/tests/src/com/android/mechanics/spec/DirectionalMotionSpecTest.kt @@ -17,6 +17,8 @@ package com.android.mechanics.spec import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.android.mechanics.haptics.BreakpointHaptics +import com.android.mechanics.haptics.SegmentHaptics import com.android.mechanics.spec.builder.directionalMotionSpec import com.android.mechanics.spring.SpringParameters import com.google.common.truth.Truth.assertThat @@ -32,24 +34,34 @@ class DirectionalMotionSpecTest { @Test fun noBreakpoints_throws() { assertFailsWith { - DirectionalMotionSpec(emptyList(), emptyList()) + DirectionalMotionSpec(emptyList(), emptyList(), emptyList()) } } @Test fun wrongSentinelBreakpoints_throws() { - val breakpoint1 = Breakpoint(B1, position = 10f, Spring, Guarantee.None) - val breakpoint2 = Breakpoint(B2, position = 20f, Spring, Guarantee.None) + val breakpoint1 = + Breakpoint(B1, position = 10f, Spring, Guarantee.None, BreakpointHaptics.None) + val breakpoint2 = + Breakpoint(B2, position = 20f, Spring, Guarantee.None, BreakpointHaptics.None) assertFailsWith { - DirectionalMotionSpec(listOf(breakpoint1, breakpoint2), listOf(Mapping.Identity)) + DirectionalMotionSpec( + listOf(breakpoint1, breakpoint2), + listOf(Mapping.Identity), + listOf(SegmentHaptics.None), + ) } } @Test fun tooFewMappings_throws() { assertFailsWith { - DirectionalMotionSpec(listOf(Breakpoint.minLimit, Breakpoint.maxLimit), emptyList()) + DirectionalMotionSpec( + listOf(Breakpoint.minLimit, Breakpoint.maxLimit), + emptyList(), + listOf(SegmentHaptics.None), + ) } } @@ -59,25 +71,51 @@ class DirectionalMotionSpecTest { DirectionalMotionSpec( listOf(Breakpoint.minLimit, Breakpoint.maxLimit), listOf(Mapping.One, Mapping.Two), + listOf(SegmentHaptics.None), + ) + } + } + + @Test + fun tooFewHaptics_throws() { + assertFailsWith { + DirectionalMotionSpec( + listOf(Breakpoint.minLimit, Breakpoint.maxLimit), + listOf(Mapping.One), + emptyList(), + ) + } + } + + @Test + fun tooManyHaptics_throws() { + assertFailsWith { + DirectionalMotionSpec( + listOf(Breakpoint.minLimit, Breakpoint.maxLimit), + listOf(Mapping.One), + listOf(SegmentHaptics.None, SegmentHaptics.None), ) } } @Test fun breakpointsOutOfOrder_throws() { - val breakpoint1 = Breakpoint(B1, position = 10f, Spring, Guarantee.None) - val breakpoint2 = Breakpoint(B2, position = 20f, Spring, Guarantee.None) + val breakpoint1 = + Breakpoint(B1, position = 10f, Spring, Guarantee.None, BreakpointHaptics.None) + val breakpoint2 = + Breakpoint(B2, position = 20f, Spring, Guarantee.None, BreakpointHaptics.None) assertFailsWith { DirectionalMotionSpec( listOf(Breakpoint.minLimit, breakpoint2, breakpoint1, Breakpoint.maxLimit), listOf(Mapping.Zero, Mapping.One, Mapping.Two), + listOf(SegmentHaptics.None, SegmentHaptics.None, SegmentHaptics.None), ) } } @Test fun findBreakpointIndex_returnsMinForEmptySpec() { - val underTest = DirectionalMotionSpec.Empty + val underTest = DirectionalMotionSpec.Identity assertThat(underTest.findBreakpointIndex(0f)).isEqualTo(0) assertThat(underTest.findBreakpointIndex(Float.MAX_VALUE)).isEqualTo(0) @@ -86,7 +124,7 @@ class DirectionalMotionSpecTest { @Test fun findBreakpointIndex_throwsForNonFiniteInput() { - val underTest = DirectionalMotionSpec.Empty + val underTest = DirectionalMotionSpec.Identity assertFailsWith { underTest.findBreakpointIndex(Float.NaN) } assertFailsWith { @@ -172,6 +210,7 @@ class DirectionalMotionSpecTest { DirectionalMotionSpec( listOf(Breakpoint.minLimit, Breakpoint.maxLimit), listOf(Mapping.Identity), + listOf(SegmentHaptics.None), listOf(SegmentSemanticValues(Semantic1, emptyList())), ) } @@ -183,6 +222,7 @@ class DirectionalMotionSpecTest { DirectionalMotionSpec( listOf(Breakpoint.minLimit, Breakpoint.maxLimit), listOf(Mapping.Identity), + listOf(SegmentHaptics.None), listOf(SegmentSemanticValues(Semantic1, listOf("One", "Two"))), ) } diff --git a/mechanics/tests/src/com/android/mechanics/spec/MotionSpecDebugFormatterTest.kt b/mechanics/tests/src/com/android/mechanics/spec/MotionSpecDebugFormatterTest.kt index 1777a72..86d138a 100644 --- a/mechanics/tests/src/com/android/mechanics/spec/MotionSpecDebugFormatterTest.kt +++ b/mechanics/tests/src/com/android/mechanics/spec/MotionSpecDebugFormatterTest.kt @@ -37,11 +37,13 @@ class MotionSpecDebugFormatterTest : MotionBuilderContext by FakeMotionSpecBuild .isEqualTo( """ unidirectional: - @-Infinity [built-in::min|id:0x1234cdef] + @-Infinity [built-in::min|id:0x1234cdef] [breakpointHaptics=None] Fixed(value=0.0) - @0.0 [id:0x1234cdef] spring=1600.0/1.0 + segment haptics: None + @0.0 [id:0x1234cdef] spring=1600.0/1.0 [breakpointHaptics=None] Fixed(value=1.0) - @Infinity [built-in::max|id:0x1234cdef]""" + segment haptics: None + @Infinity [built-in::max|id:0x1234cdef] [breakpointHaptics=None]""" .trimIndent() ) } @@ -58,17 +60,21 @@ unidirectional: .isEqualTo( """ maxDirection: - @-Infinity [built-in::min|id:0x1234cdef] + @-Infinity [built-in::min|id:0x1234cdef] [breakpointHaptics=None] Fixed(value=0.0) - @0.0 [id:0x1234cdef] spring=700.0/0.9 + segment haptics: None + @0.0 [id:0x1234cdef] spring=700.0/0.9 [breakpointHaptics=None] Fixed(value=1.0) - @Infinity [built-in::max|id:0x1234cdef] + segment haptics: None + @Infinity [built-in::max|id:0x1234cdef] [breakpointHaptics=None] minDirection: - @-Infinity [built-in::min|id:0x1234cdef] + @-Infinity [built-in::min|id:0x1234cdef] [breakpointHaptics=None] Fixed(value=1.0) - @0.0 [id:0x1234cdef] spring=700.0/0.9 + segment haptics: None + @0.0 [id:0x1234cdef] spring=700.0/0.9 [breakpointHaptics=None] Fixed(value=0.0) - @Infinity [built-in::max|id:0x1234cdef]""" + segment haptics: None + @Infinity [built-in::max|id:0x1234cdef] [breakpointHaptics=None]""" .trimIndent() ) } @@ -88,13 +94,15 @@ minDirection: .isEqualTo( """ unidirectional: - @-Infinity [built-in::min|id:0x1234cdef] + @-Infinity [built-in::min|id:0x1234cdef] [breakpointHaptics=None] Fixed(value=0.0) + segment haptics: None foo[id:0x1234cdef]=42.0 - @0.0 [id:0x1234cdef] spring=1600.0/1.0 + @0.0 [id:0x1234cdef] spring=1600.0/1.0 [breakpointHaptics=None] Fixed(value=1.0) + segment haptics: None foo[id:0x1234cdef]=43.0 - @Infinity [built-in::max|id:0x1234cdef]""" + @Infinity [built-in::max|id:0x1234cdef] [breakpointHaptics=None]""" .trimIndent() ) } @@ -122,13 +130,16 @@ unidirectional: .isEqualTo( """ unidirectional: - @-Infinity [built-in::min|id:0x1234cdef] + @-Infinity [built-in::min|id:0x1234cdef] [breakpointHaptics=None] Fixed(value=0.0) - @0.0 [1|id:0x1234cdef] spring=1600.0/1.0 + segment haptics: None + @0.0 [1|id:0x1234cdef] spring=1600.0/1.0 [breakpointHaptics=None] Fixed(value=1.0) - @2.0 [1|id:0x1234cdef] spring=1600.0/1.0 + segment haptics: None + @2.0 [1|id:0x1234cdef] spring=1600.0/1.0 [breakpointHaptics=None] Fixed(value=2.0) - @Infinity [built-in::max|id:0x1234cdef] + segment haptics: None + @Infinity [built-in::max|id:0x1234cdef] [breakpointHaptics=None] segmentHandlers: 1|id:0x1234cdef >> 2|id:0x1234cdef 1|id:0x1234cdef << 2|id:0x1234cdef""" diff --git a/mechanics/tests/src/com/android/mechanics/spec/MotionSpecTest.kt b/mechanics/tests/src/com/android/mechanics/spec/MotionSpecTest.kt index 260a8a7..3f8287e 100644 --- a/mechanics/tests/src/com/android/mechanics/spec/MotionSpecTest.kt +++ b/mechanics/tests/src/com/android/mechanics/spec/MotionSpecTest.kt @@ -30,7 +30,7 @@ class MotionSpecTest { @Test fun containsSegment_unknownSegment_returnsFalse() { - val underTest = MotionSpec.Empty + val underTest = MotionSpec.Identity assertThat(underTest.containsSegment(SegmentKey(B1, B2, InputDirection.Max))).isFalse() } @@ -57,7 +57,7 @@ class MotionSpecTest { fixedValue(breakpoint = 10f, key = B1, value = 1f) identity(breakpoint = 20f, key = B2) }, - minDirection = DirectionalMotionSpec.Empty, + minDirection = DirectionalMotionSpec.Identity, ) assertThat(underTest.containsSegment(SegmentKey(B1, B2, InputDirection.Max))).isTrue() @@ -68,7 +68,7 @@ class MotionSpecTest { fun containsSegment_asymmetricSpec_knownMinDirectionSegment_trueOnlyInMinDirection() { val underTest = MotionSpec( - maxDirection = DirectionalMotionSpec.Empty, + maxDirection = DirectionalMotionSpec.Identity, minDirection = directionalMotionSpec(Spring) { fixedValue(breakpoint = 10f, key = B1, value = 1f) @@ -82,7 +82,7 @@ class MotionSpecTest { @Test fun segmentAtInput_emptySpec_maxDirection_segmentDataIsCorrect() { - val underTest = MotionSpec.Empty + val underTest = MotionSpec.Identity val segmentAtInput = underTest.segmentAtInput(0f, InputDirection.Max) @@ -95,7 +95,7 @@ class MotionSpecTest { @Test fun segmentAtInput_emptySpec_minDirection_segmentDataIsCorrect() { - val underTest = MotionSpec.Empty + val underTest = MotionSpec.Identity val segmentAtInput = underTest.segmentAtInput(0f, InputDirection.Min) @@ -302,11 +302,43 @@ class MotionSpecTest { @Test fun semantics_unknownSegment_throws() { - val underTest = MotionSpec.Empty + val underTest = MotionSpec.Identity val unknownSegment = SegmentKey(BMin, B1, InputDirection.Max) assertFailsWith { underTest.semantics(unknownSegment) } } + @Test + fun semantics_atSpecLevel_canBeAssociatedWithSpec() { + val underTest = + MotionSpec(DirectionalMotionSpec.Identity, semantics = listOf(S1 with "One")) + + assertThat(underTest.semanticState(S1)).isEqualTo("One") + } + + @Test + fun semantics_atSpecLevel_canBeQueriedViaSegment() { + val underTest = + MotionSpec(DirectionalMotionSpec.Identity, semantics = listOf(S1 with "One")) + + val maxDirectionSegment = SegmentKey(BMin, BMax, InputDirection.Max) + assertThat(underTest.semanticState(S1, maxDirectionSegment)).isEqualTo("One") + } + + @Test + fun semantics_atSpecLevel_segmentLevelTakesPrecedence() { + val underTest = + MotionSpec( + maxDirection = directionalMotionSpec(semantics = listOf(S1 with "Two")), + minDirection = DirectionalMotionSpec.Identity, + semantics = listOf(S1 with "One"), + ) + + assertThat(underTest.semanticState(S1, SegmentKey(BMin, BMax, InputDirection.Max))) + .isEqualTo("Two") + assertThat(underTest.semanticState(S1, SegmentKey(BMin, BMax, InputDirection.Min))) + .isEqualTo("One") + } + companion object { val BMin = Breakpoint.minLimit.key val B1 = BreakpointKey("one") diff --git a/mechanics/tests/src/com/android/mechanics/spec/SegmentTest.kt b/mechanics/tests/src/com/android/mechanics/spec/SegmentTest.kt index f66991c..2b4bf5f 100644 --- a/mechanics/tests/src/com/android/mechanics/spec/SegmentTest.kt +++ b/mechanics/tests/src/com/android/mechanics/spec/SegmentTest.kt @@ -17,6 +17,8 @@ package com.android.mechanics.spec import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.android.mechanics.haptics.BreakpointHaptics +import com.android.mechanics.haptics.SegmentHaptics import com.android.mechanics.spring.SpringParameters import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertWithMessage @@ -26,34 +28,61 @@ import org.junit.runner.RunWith @RunWith(AndroidJUnit4::class) class SegmentTest { - private val fakeSpec = MotionSpec.Empty + private val fakeSpec = MotionSpec.Identity @Test fun segmentData_isValidForInput_betweenBreakpointsSameDirection_isTrue() { - val breakpoint1 = Breakpoint(B1, position = 10f, Spring, Guarantee.None) - val breakpoint2 = Breakpoint(B2, position = 20f, Spring, Guarantee.None) + val breakpoint1 = + Breakpoint(B1, position = 10f, Spring, Guarantee.None, BreakpointHaptics.None) + val breakpoint2 = + Breakpoint(B2, position = 20f, Spring, Guarantee.None, BreakpointHaptics.None) val underTest = - SegmentData(fakeSpec, breakpoint1, breakpoint2, InputDirection.Max, Mapping.Identity) + SegmentData( + fakeSpec, + breakpoint1, + breakpoint2, + InputDirection.Max, + Mapping.Identity, + SegmentHaptics.None, + ) assertThat(underTest.isValidForInput(15f, InputDirection.Max)).isTrue() } @Test fun segmentData_isValidForInput_betweenBreakpointsOppositeDirection_isFalse() { - val breakpoint1 = Breakpoint(B1, position = 10f, Spring, Guarantee.None) - val breakpoint2 = Breakpoint(B2, position = 20f, Spring, Guarantee.None) + val breakpoint1 = + Breakpoint(B1, position = 10f, Spring, Guarantee.None, BreakpointHaptics.None) + val breakpoint2 = + Breakpoint(B2, position = 20f, Spring, Guarantee.None, BreakpointHaptics.None) val underTest = - SegmentData(fakeSpec, breakpoint1, breakpoint2, InputDirection.Max, Mapping.Identity) + SegmentData( + fakeSpec, + breakpoint1, + breakpoint2, + InputDirection.Max, + Mapping.Identity, + SegmentHaptics.None, + ) assertThat(underTest.isValidForInput(15f, InputDirection.Min)).isFalse() } @Test fun segmentData_isValidForInput_inMaxDirection_sampledAtVariousPositions_matchesExpectation() { - val breakpoint1 = Breakpoint(B1, position = 10f, Spring, Guarantee.None) - val breakpoint2 = Breakpoint(B2, position = 20f, Spring, Guarantee.None) + val breakpoint1 = + Breakpoint(B1, position = 10f, Spring, Guarantee.None, BreakpointHaptics.None) + val breakpoint2 = + Breakpoint(B2, position = 20f, Spring, Guarantee.None, BreakpointHaptics.None) val underTest = - SegmentData(fakeSpec, breakpoint1, breakpoint2, InputDirection.Max, Mapping.Identity) + SegmentData( + fakeSpec, + breakpoint1, + breakpoint2, + InputDirection.Max, + Mapping.Identity, + SegmentHaptics.None, + ) for ((samplePosition, expectedResult) in listOf(5f to true, 10f to true, 15f to true, 20f to false, 25f to false)) { @@ -65,10 +94,19 @@ class SegmentTest { @Test fun segmentData_isValidForInput_inMinDirection_sampledAtVariousPositions_matchesExpectation() { - val breakpoint1 = Breakpoint(B1, position = 10f, Spring, Guarantee.None) - val breakpoint2 = Breakpoint(B2, position = 20f, Spring, Guarantee.None) + val breakpoint1 = + Breakpoint(B1, position = 10f, Spring, Guarantee.None, BreakpointHaptics.None) + val breakpoint2 = + Breakpoint(B2, position = 20f, Spring, Guarantee.None, BreakpointHaptics.None) val underTest = - SegmentData(fakeSpec, breakpoint1, breakpoint2, InputDirection.Min, Mapping.Identity) + SegmentData( + fakeSpec, + breakpoint1, + breakpoint2, + InputDirection.Min, + Mapping.Identity, + SegmentHaptics.None, + ) for ((samplePosition, expectedResult) in listOf(5f to false, 10f to false, 15f to true, 20f to true, 25f to true)) { @@ -80,20 +118,38 @@ class SegmentTest { @Test fun segmentData_entryBreakpoint_maxDirection_returnsMinBreakpoint() { - val breakpoint1 = Breakpoint(B1, position = 10f, Spring, Guarantee.None) - val breakpoint2 = Breakpoint(B2, position = 20f, Spring, Guarantee.None) + val breakpoint1 = + Breakpoint(B1, position = 10f, Spring, Guarantee.None, BreakpointHaptics.None) + val breakpoint2 = + Breakpoint(B2, position = 20f, Spring, Guarantee.None, BreakpointHaptics.None) val underTest = - SegmentData(fakeSpec, breakpoint1, breakpoint2, InputDirection.Max, Mapping.Identity) + SegmentData( + fakeSpec, + breakpoint1, + breakpoint2, + InputDirection.Max, + Mapping.Identity, + SegmentHaptics.None, + ) assertThat(underTest.entryBreakpoint).isSameInstanceAs(breakpoint1) } @Test fun segmentData_entryBreakpoint_minDirection_returnsMaxBreakpoint() { - val breakpoint1 = Breakpoint(B1, position = 10f, Spring, Guarantee.None) - val breakpoint2 = Breakpoint(B2, position = 20f, Spring, Guarantee.None) + val breakpoint1 = + Breakpoint(B1, position = 10f, Spring, Guarantee.None, BreakpointHaptics.None) + val breakpoint2 = + Breakpoint(B2, position = 20f, Spring, Guarantee.None, BreakpointHaptics.None) val underTest = - SegmentData(fakeSpec, breakpoint1, breakpoint2, InputDirection.Min, Mapping.Identity) + SegmentData( + fakeSpec, + breakpoint1, + breakpoint2, + InputDirection.Min, + Mapping.Identity, + SegmentHaptics.None, + ) assertThat(underTest.entryBreakpoint).isSameInstanceAs(breakpoint2) } diff --git a/mechanics/tests/src/com/android/mechanics/spec/builder/DirectionalBuilderImplTest.kt b/mechanics/tests/src/com/android/mechanics/spec/builder/DirectionalBuilderImplTest.kt index b399731..72fde69 100644 --- a/mechanics/tests/src/com/android/mechanics/spec/builder/DirectionalBuilderImplTest.kt +++ b/mechanics/tests/src/com/android/mechanics/spec/builder/DirectionalBuilderImplTest.kt @@ -17,6 +17,8 @@ package com.android.mechanics.spec.builder import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.android.mechanics.haptics.HapticsExperimentalApi +import com.android.mechanics.haptics.SegmentHaptics import com.android.mechanics.spec.BreakpointKey import com.android.mechanics.spec.Guarantee import com.android.mechanics.spec.Mapping @@ -186,7 +188,12 @@ class DirectionalBuilderImplTest { @Test fun semantics_appliedForSingleSegment() { - val result = directionalMotionSpec(Mapping.Identity, listOf(S1 with "One", S2 with "Two")) + val result = + directionalMotionSpec( + Mapping.Identity, + SegmentHaptics.None, + listOf(S1 with "One", S2 with "Two"), + ) assertThat(result).semantics().containsExactly(S1, S2) assertThat(result).semantics().withKey(S1).containsExactly("One") @@ -285,6 +292,37 @@ class DirectionalBuilderImplTest { assertThat(result).breakpoints().atPosition(0f).spring().isEqualTo(context.effects.default) } + @OptIn(HapticsExperimentalApi::class) + @Test + fun directionalSpec_segmentHapticsBuilder_createsSegmentHapticsForSingleSegment() { + val expectedHaptics = SegmentHaptics.SpringTension(anchorPointPx = 0f) + val result = + directionalMotionSpec(Spring) { + haptics(expectedHaptics) { mapping(breakpoint = 30f, mapping = Mapping.Identity) } + mapping(breakpoint = 40f, mapping = Mapping.Identity) + } + + assertThat(result).segmentHaptics().at(30f).isEqualTo(expectedHaptics) + assertThat(result).segmentHaptics().at(40f).isEqualTo(SegmentHaptics.None) + } + + @OptIn(HapticsExperimentalApi::class) + @Test + fun directionalSpec_segmentHapticsBuilder_createsSegmentHapticsForMultipleSegments() { + val expectedHaptics = SegmentHaptics.SpringTension(anchorPointPx = 0f) + val result = + directionalMotionSpec(Spring) { + haptics(expectedHaptics) { + mapping(breakpoint = 30f, mapping = Mapping.Identity) + mapping(breakpoint = 40f, mapping = Mapping.Identity) + } + mapping(breakpoint = 50f, mapping = Mapping.Identity) + } + + assertThat(result).segmentHaptics().at(30f).isEqualTo(expectedHaptics) + assertThat(result).segmentHaptics().at(40f).isEqualTo(expectedHaptics) + } + companion object { val Spring = SpringParameters(stiffness = 100f, dampingRatio = 1f) val B1 = BreakpointKey("One") diff --git a/mechanics/tests/src/com/android/mechanics/spec/builder/MotionSpecBuilderTest.kt b/mechanics/tests/src/com/android/mechanics/spec/builder/MotionSpecBuilderTest.kt index 2b6760a..148ae86 100644 --- a/mechanics/tests/src/com/android/mechanics/spec/builder/MotionSpecBuilderTest.kt +++ b/mechanics/tests/src/com/android/mechanics/spec/builder/MotionSpecBuilderTest.kt @@ -48,6 +48,22 @@ class MotionSpecBuilderTest : MotionBuilderContext by FakeMotionSpecBuilderConte assertThat(result).bothDirections().breakpoints().isEmpty() } + @Test + fun motionSpec_semantics_appliedToSpec() { + val result = spatialMotionSpec(semantics = listOf(TestSemantics with "One")) {} + + assertThat(result.semanticState(TestSemantics)).isEqualTo("One") + assertThat(result).bothDirections().semantics().withKey(TestSemantics).isNull() + } + + @Test + fun fixedMotionSpec_semantics_appliedToSpec() { + val result = fixedSpatialValueSpec(0f, semantics = listOf(TestSemantics with "One")) + + assertThat(result.semanticState(TestSemantics)).isEqualTo("One") + assertThat(result).bothDirections().semantics().withKey(TestSemantics).isNull() + } + @Test fun placement_absoluteAfter_createsTwoSegments() { val result = diff --git a/mechanics/tests/src/com/android/mechanics/view/ViewMotionValueTest.kt b/mechanics/tests/src/com/android/mechanics/view/ViewMotionValueTest.kt index 7d7fdcd..4027fb4 100644 --- a/mechanics/tests/src/com/android/mechanics/view/ViewMotionValueTest.kt +++ b/mechanics/tests/src/com/android/mechanics/view/ViewMotionValueTest.kt @@ -37,6 +37,7 @@ import com.android.mechanics.testing.input import com.android.mechanics.testing.isStable import com.android.mechanics.testing.output import com.google.common.truth.Truth.assertThat +import kotlin.test.assertFailsWith import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.runBlocking import kotlinx.coroutines.test.runTest @@ -76,7 +77,7 @@ class ViewMotionValueTest { @Test fun emptySpec_outputMatchesInput_withoutAnimation() = motion.goldenTest( - spec = MotionSpec.Empty, + spec = MotionSpec.Identity, verifyTimeSeries = { // Output always matches the input assertThat(output).containsExactlyElementsIn(input).inOrder() @@ -89,6 +90,48 @@ class ViewMotionValueTest { animateValueTo(100f) } + @Test + fun unspecifiedSpec_outputIsNan() = + motion.goldenTest( + spec = MotionSpec.InitiallyUndefined, + verifyTimeSeries = { + // This must only produce NaN values + output.forEach { assertThat(it).isNaN() } + // There must never be an ongoing animation. + assertThat(isStable).doesNotContain(false) + AssertTimeSeriesMatchesGolden() + }, + ) { + animateValueTo(100f) + } + + @Test + fun unspecifiedSpec_atTheBeginning_jumpcutsToFirstValue() = + motion.goldenTest( + spec = MotionSpec.InitiallyUndefined, + verifyTimeSeries = { + // There must never be an ongoing animation. + assertThat(isStable).doesNotContain(false) + + AssertTimeSeriesMatchesGolden() + }, + ) { + animateValueTo(10f, changePerFrame = 5f) + spec = MotionSpec.Identity + animateValueTo(20f, changePerFrame = 5f) + } + + @Test + fun unspecifiedSpec_onAlreadyInitializedValue_throws() { + assertFailsWith { + motion.goldenTest(spec = MotionSpec.Identity) { + animateValueTo(10f, changePerFrame = 5f) + spec = MotionSpec.InitiallyUndefined + animateValueTo(20f, changePerFrame = 5f) + } + } + } + @Test fun segmentChange_animatedWhenReachingBreakpoint() = motion.goldenTest( @@ -162,6 +205,7 @@ class ViewMotionValueTest { } motion.goldenTest(spec = generateSpec(0f), initialValue = .5f) { + awaitFrames() underTest.spec = generateSpec(1f) awaitFrames() awaitStable() @@ -172,7 +216,7 @@ class ViewMotionValueTest { fun update_triggersCallback() = runTest { runBlocking(Dispatchers.Main) { val gestureContext = DistanceGestureContext(0f, InputDirection.Max, 5f) - val underTest = ViewMotionValue(0f, gestureContext, MotionSpec.Empty) + val underTest = ViewMotionValue(0f, gestureContext, MotionSpec.Identity) var invocationCount = 0 underTest.addUpdateCallback { invocationCount++ } @@ -187,7 +231,10 @@ class ViewMotionValueTest { fun update_setSameValue_doesNotTriggerCallback() = runTest { runBlocking(Dispatchers.Main) { val gestureContext = DistanceGestureContext(0f, InputDirection.Max, 5f) - val underTest = ViewMotionValue(0f, gestureContext, MotionSpec.Empty) + val underTest = ViewMotionValue(0f, gestureContext, MotionSpec.Identity) + + // Ensure the initial update has been processed + animatorTestRule.advanceTimeBy(16L) var invocationCount = 0 underTest.addUpdateCallback { invocationCount++ } @@ -205,6 +252,9 @@ class ViewMotionValueTest { val spec = specBuilder(Mapping.Zero) { fixedValue(breakpoint = 1f, value = 1f) } val underTest = ViewMotionValue(0f, gestureContext, spec) + // Ensure the initial update has been processed + animatorTestRule.advanceTimeBy(16L) + var invocationCount = 0 underTest.addUpdateCallback { invocationCount++ } underTest.input = 1f @@ -240,7 +290,7 @@ class ViewMotionValueTest { fun debugInspector_sameInstance_whileInUse() = runTest { runBlocking(Dispatchers.Main) { val gestureContext = DistanceGestureContext(0f, InputDirection.Max, 5f) - val underTest = ViewMotionValue(0f, gestureContext, MotionSpec.Empty) + val underTest = ViewMotionValue(0f, gestureContext, MotionSpec.Identity) val originalInspector = underTest.debugInspector() assertThat(underTest.debugInspector()).isSameInstanceAs(originalInspector) @@ -251,7 +301,7 @@ class ViewMotionValueTest { fun debugInspector_newInstance_afterUnused() = runTest { runBlocking(Dispatchers.Main) { val gestureContext = DistanceGestureContext(0f, InputDirection.Max, 5f) - val underTest = ViewMotionValue(0f, gestureContext, MotionSpec.Empty) + val underTest = ViewMotionValue(0f, gestureContext, MotionSpec.Identity) val originalInspector = underTest.debugInspector() originalInspector.dispose() From e09314ec32f4773d9e8f655a77cbf96772d1b25d Mon Sep 17 00:00:00 2001 From: Pun Butrach Date: Wed, 3 Dec 2025 22:40:35 +0700 Subject: [PATCH 24/30] feat: iconloaderlib 16r4 --- iconloaderlib/Android.bp | 6 + iconloaderlib/build.gradle.kts | 34 ++ iconloaderlib/res/values-night-v31/colors.xml | 1 + iconloaderlib/res/values-v31/colors.xml | 1 + .../launcher3/icons/BaseIconFactory.kt | 512 ++++++++++++++++ .../com/android/launcher3/icons/BitmapInfo.kt | 244 ++++---- .../launcher3/icons/BubbleIconFactory.java | 34 +- .../launcher3/icons/ClockDrawableWrapper.kt | 302 ++++++++++ .../android/launcher3/icons/DotRenderer.java | 188 +++--- .../launcher3/icons/FastBitmapDrawable.kt | 149 +++-- .../icons/FastBitmapDrawableDelegate.kt | 140 +++++ .../android/launcher3/icons/GraphicsUtils.kt | 259 +++++++++ .../launcher3/icons/IconNormalizer.java | 224 +------ .../android/launcher3/icons/IconProvider.java | 77 +-- .../com/android/launcher3/icons/IconShape.kt | 51 ++ .../launcher3/icons/LuminanceComputer.kt | 305 ++++++++++ .../icons/MonochromeIconFactory.java | 68 ++- .../icons/PlaceHolderDrawableDelegate.kt | 94 +++ .../launcher3/icons/RoundRectEstimator.kt | 90 +++ .../launcher3/icons/ShadowGenerator.java | 2 +- .../android/launcher3/icons/ShapeRenderer.kt | 71 +++ .../android/launcher3/icons/ThemedBitmap.kt | 12 +- .../launcher3/icons/UserBadgeDrawable.java | 29 +- .../launcher3/icons/cache/BaseIconCache.kt | 120 ++-- .../cache/LauncherActivityCachingLogic.kt | 18 +- .../icons/mono/MonoIconThemeController.kt | 163 +++--- .../launcher3/icons/mono/MonoThemedBitmap.kt | 114 +++- .../icons/mono/ThemedIconDelegate.kt | 107 ++++ .../launcher3/util/SQLiteCacheHelper.java | 43 +- .../android/launcher3/util/UserIconInfo.kt | 59 ++ iconloaderlib/tests/Android.bp | 65 +++ iconloaderlib/tests/AndroidManifest.xml | 24 + iconloaderlib/tests/TEST_MAPPING | 7 + .../robolectric/config/robolectric.properties | 1 + .../launcher3/icons/BaseIconFactoryTest.kt | 87 +++ .../launcher3/icons/LuminanceComputerTest.kt | 548 ++++++++++++++++++ .../launcher3/icons/RoundRectEstimatorTest.kt | 67 +++ 37 files changed, 3494 insertions(+), 822 deletions(-) create mode 100644 iconloaderlib/build.gradle.kts create mode 100644 iconloaderlib/src/com/android/launcher3/icons/BaseIconFactory.kt create mode 100644 iconloaderlib/src/com/android/launcher3/icons/ClockDrawableWrapper.kt create mode 100644 iconloaderlib/src/com/android/launcher3/icons/FastBitmapDrawableDelegate.kt create mode 100644 iconloaderlib/src/com/android/launcher3/icons/GraphicsUtils.kt create mode 100644 iconloaderlib/src/com/android/launcher3/icons/IconShape.kt create mode 100644 iconloaderlib/src/com/android/launcher3/icons/LuminanceComputer.kt create mode 100644 iconloaderlib/src/com/android/launcher3/icons/PlaceHolderDrawableDelegate.kt create mode 100644 iconloaderlib/src/com/android/launcher3/icons/RoundRectEstimator.kt create mode 100644 iconloaderlib/src/com/android/launcher3/icons/ShapeRenderer.kt create mode 100644 iconloaderlib/src/com/android/launcher3/icons/mono/ThemedIconDelegate.kt create mode 100644 iconloaderlib/src/com/android/launcher3/util/UserIconInfo.kt create mode 100644 iconloaderlib/tests/Android.bp create mode 100644 iconloaderlib/tests/AndroidManifest.xml create mode 100644 iconloaderlib/tests/TEST_MAPPING create mode 100644 iconloaderlib/tests/robolectric/config/robolectric.properties create mode 100644 iconloaderlib/tests/src/com/android/launcher3/icons/BaseIconFactoryTest.kt create mode 100644 iconloaderlib/tests/src/com/android/launcher3/icons/LuminanceComputerTest.kt create mode 100644 iconloaderlib/tests/src/com/android/launcher3/icons/RoundRectEstimatorTest.kt diff --git a/iconloaderlib/Android.bp b/iconloaderlib/Android.bp index e991888..a3ed941 100644 --- a/iconloaderlib/Android.bp +++ b/iconloaderlib/Android.bp @@ -32,6 +32,9 @@ android_library { "src/**/*.java", "src/**/*.kt", ], + kotlincflags: [ + "-Xjvm-default=all", + ], } android_library { @@ -56,4 +59,7 @@ android_library { "//apex_available:platform", "com.android.permission", ], + kotlincflags: [ + "-Xjvm-default=all", + ], } diff --git a/iconloaderlib/build.gradle.kts b/iconloaderlib/build.gradle.kts new file mode 100644 index 0000000..9885cee --- /dev/null +++ b/iconloaderlib/build.gradle.kts @@ -0,0 +1,34 @@ +plugins { + id(libs.plugins.android.library.get().pluginId) + id(libs.plugins.kotlin.android.get().pluginId) +} + +android { + namespace = "com.android.launcher3.icons" + + defaultConfig { + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + testApplicationId = "com.android.launcher3.icons.tests" + } + + sourceSets { + named("main") { + java.setSrcDirs(listOf("src", "src_full_lib")) + manifest.srcFile("AndroidManifest.xml") + res.setSrcDirs(listOf("res")) + } + + named("androidTest") { + java.setSrcDirs(listOf("tests/src")) + } + } +} + +dependencies { + implementation("androidx.core:core") + api(project(":NexusLauncher:Flags")) + api(project(":frameworks:base:packages:SystemUI:SystemUISharedFlags")) + + androidTestImplementation(libs.androidx.test.rules) + androidTestImplementation(libs.androidx.junit) +} diff --git a/iconloaderlib/res/values-night-v31/colors.xml b/iconloaderlib/res/values-night-v31/colors.xml index e5ebda6..6e50d77 100644 --- a/iconloaderlib/res/values-night-v31/colors.xml +++ b/iconloaderlib/res/values-night-v31/colors.xml @@ -19,6 +19,7 @@ @android:color/system_accent1_200 @android:color/system_accent2_800 + @android:color/system_accent1_800 @android:color/system_accent2_800 @android:color/system_accent1_200 diff --git a/iconloaderlib/res/values-v31/colors.xml b/iconloaderlib/res/values-v31/colors.xml index 1405ad0..0bcd4a0 100644 --- a/iconloaderlib/res/values-v31/colors.xml +++ b/iconloaderlib/res/values-v31/colors.xml @@ -19,6 +19,7 @@ @android:color/system_accent1_700 @android:color/system_accent1_100 + @android:color/system_accent1_500 @android:color/system_accent1_700 @android:color/system_accent1_100 diff --git a/iconloaderlib/src/com/android/launcher3/icons/BaseIconFactory.kt b/iconloaderlib/src/com/android/launcher3/icons/BaseIconFactory.kt new file mode 100644 index 0000000..6d0e4e9 --- /dev/null +++ b/iconloaderlib/src/com/android/launcher3/icons/BaseIconFactory.kt @@ -0,0 +1,512 @@ +/* + * Copyright (C) 2017 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.launcher3.icons + +import android.content.Context +import android.content.Intent.ShortcutIconResource +import android.graphics.Bitmap +import android.graphics.Bitmap.Config.ARGB_8888 +import android.graphics.Canvas +import android.graphics.Color +import android.graphics.Paint +import android.graphics.Rect +import android.graphics.drawable.AdaptiveIconDrawable +import android.graphics.drawable.BitmapDrawable +import android.graphics.drawable.ColorDrawable +import android.graphics.drawable.Drawable +import android.graphics.drawable.InsetDrawable +import android.os.UserHandle +import android.util.SparseArray +import androidx.annotation.ColorInt +import androidx.annotation.IntDef +import com.android.launcher3.icons.BitmapInfo.Extender +import com.android.launcher3.icons.ColorExtractor.findDominantColorByHue +import com.android.launcher3.icons.GraphicsUtils.generateIconShape +import com.android.launcher3.icons.GraphicsUtils.transformed +import com.android.launcher3.icons.IconNormalizer.ICON_VISIBLE_AREA_FACTOR +import com.android.launcher3.icons.ShadowGenerator.BLUR_FACTOR +import com.android.launcher3.util.FlagOp +import com.android.launcher3.util.UserIconInfo +import com.android.launcher3.util.UserIconInfo.Companion.TYPE_MAIN +import com.android.launcher3.util.UserIconInfo.Companion.TYPE_WORK +import com.android.systemui.shared.Flags.extendibleThemeManager +import java.lang.ref.WeakReference +import kotlin.annotation.AnnotationRetention.SOURCE +import kotlin.math.ceil +import kotlin.math.max +import kotlin.math.sqrt + +/** + * This class will be moved to androidx library. There shouldn't be any dependency outside this + * package. + */ +open class BaseIconFactory +@JvmOverloads +constructor( + @JvmField val context: Context, + @JvmField val fullResIconDpi: Int, + @JvmField val iconBitmapSize: Int, + private val drawFullBleedIcons: Boolean = false, + val themeController: IconThemeController? = null, +) : AutoCloseable { + + private val cachedUserInfo = SparseArray() + + private val shadowGenerator: ShadowGenerator by lazy { ShadowGenerator(iconBitmapSize) } + + /** Default IconShape for when custom shape is not needed */ + val defaultIconShape: IconShape by + lazy(LazyThreadSafetyMode.NONE) { getDefaultIconShape(iconBitmapSize) } + + @Suppress("deprecation") + fun createIconBitmap(iconRes: ShortcutIconResource): BitmapInfo? { + try { + val resources = context.packageManager.getResourcesForApplication(iconRes.packageName) + if (resources != null) { + val id = resources.getIdentifier(iconRes.resourceName, null, null) + // do not stamp old legacy shortcuts as the app may have already forgotten about it + return createBadgedIconBitmap(resources.getDrawableForDensity(id, fullResIconDpi)!!) + } + } catch (e: Exception) { + // Icon not found. + } + return null + } + + /** + * Create a placeholder icon using the passed in text. + * + * @param placeholder used for foreground element in the icon bitmap + * @param color used for the foreground text color + */ + fun createIconBitmap(placeholder: String, color: Int): BitmapInfo = + createBadgedIconBitmap( + AdaptiveIconDrawable( + ColorDrawable(PLACEHOLDER_BACKGROUND_COLOR), + CenterTextDrawable(placeholder, color), + ), + IconOptions().setExtractedColor(color), + ) + + fun createIconBitmap(icon: Bitmap, isFullBleed: Boolean): BitmapInfo = + if (iconBitmapSize != icon.width || iconBitmapSize != icon.height) + createBadgedIconBitmap( + BitmapDrawable(context.resources, icon), + IconOptions() + .setWrapNonAdaptiveIcon(false) + .setIconScale(1f) + .assumeFullBleedIcon(isFullBleed && isIconFullBleed(icon)) + .setDrawFullBleed(isFullBleed && isIconFullBleed(icon)), + ) + else + BitmapInfo( + icon = icon, + color = findDominantColorByHue(icon), + defaultIconShape = defaultIconShape, + flags = if (isFullBleed && isIconFullBleed(icon)) BitmapInfo.FLAG_FULL_BLEED else 0, + ) + + fun createScaledBitmap(icon: Drawable, @BitmapGenerationMode mode: Int): Bitmap = + createBadgedIconBitmap( + icon, + IconOptions().setBitmapGenerationMode(mode).setDrawFullBleed(false), + ) + .icon + + @JvmOverloads + @Deprecated("Use createBadgedIconBitmap instead") + fun createIconBitmap( + icon: Drawable?, + scale: Float, + @BitmapGenerationMode bitmapGenerationMode: Int = MODE_DEFAULT, + isFullBleed: Boolean = drawFullBleedIcons, + ): Bitmap = + createBadgedIconBitmap( + icon, + IconOptions() + .setBitmapGenerationMode(bitmapGenerationMode) + .setWrapNonAdaptiveIcon(false) + .setDrawFullBleed(isFullBleed) + .setIconScale(scale), + ) + .icon + + /** + * Creates bitmap using the source drawable and various parameters. The bitmap is visually + * normalized with other icons and has enough spacing to add shadow. + * + * @param icon source of the icon + * @return a bitmap suitable for displaying as an icon at various system UIs. + */ + @JvmOverloads + fun createBadgedIconBitmap(icon: Drawable?, options: IconOptions = IconOptions()): BitmapInfo { + if (icon == null) { + return BitmapInfo( + icon = + if (options.useHardware) + BitmapRenderer.createHardwareBitmap(iconBitmapSize, iconBitmapSize) {} + else Bitmap.createBitmap(iconBitmapSize, iconBitmapSize, ARGB_8888), + color = 0, + ) + } + + // Create the bitmap first + val oldBounds = icon.bounds + + var tempIcon: Drawable = icon + if (options.isFullBleed && icon is BitmapDrawable) { + // If the source is a full-bleed icon, create an adaptive icon by insetting this icon to + // the extra padding + var inset = AdaptiveIconDrawable.getExtraInsetFraction() + inset /= (1 + 2 * inset) + tempIcon = + AdaptiveIconDrawable( + ColorDrawable(Color.BLACK), + InsetDrawable(icon, inset, inset, inset, inset), + ) + } + if (options.wrapNonAdaptiveIcon) tempIcon = wrapToAdaptiveIcon(tempIcon, options) + + val drawFullBleed = options.drawFullBleed ?: drawFullBleedIcons + val bitmap = drawableToBitmap(tempIcon, drawFullBleed, options) + icon.bounds = oldBounds + + val color = options.extractedColor ?: findDominantColorByHue(bitmap) + var flagOp = getBitmapFlagOp(options) + if (drawFullBleed) { + flagOp = flagOp.addFlag(BitmapInfo.FLAG_FULL_BLEED) + bitmap.setHasAlpha(false) + } + + var info = + BitmapInfo( + icon = bitmap, + color = color, + defaultIconShape = defaultIconShape, + flags = flagOp.apply(0), + ) + if (icon is Extender) { + info = icon.getUpdatedBitmapInfo(info, this) + } + + if (IconProvider.ATLEAST_T && themeController != null) { + info = + info.copy( + themedBitmap = + if (tempIcon is AdaptiveIconDrawable) + themeController.createThemedBitmap( + tempIcon, + info, + this, + options.sourceHint, + ) + else ThemedBitmap.NOT_SUPPORTED + ) + } else if (extendibleThemeManager()) { + info = info.copy(themedBitmap = ThemedBitmap.NOT_SUPPORTED) + } + + return info + } + + fun getBitmapFlagOp(options: IconOptions?): FlagOp { + if (options == null) return FlagOp.NO_OP + var op = FlagOp.NO_OP + if (options.isInstantApp) op = op.addFlag(BitmapInfo.FLAG_INSTANT) + + val info = options.userIconInfo ?: options.userHandle?.let { getUserInfo(it) } + if (info != null) op = info.applyBitmapInfoFlags(op) + return op + } + + protected open fun getUserInfo(user: UserHandle): UserIconInfo { + val key = user.hashCode() + // We do not have the ability to distinguish between different badged users here. + // As such all badged users will have the work profile badge applied. + return cachedUserInfo[key] + ?: UserIconInfo(user, if (user.isWorkUser()) TYPE_WORK else TYPE_MAIN).also { + cachedUserInfo[key] = it + } + } + + /** Simple check to check if the provided user is work profile or not based on badging */ + private fun UserHandle.isWorkUser() = + NoopDrawable().let { d -> d !== context.packageManager.getUserBadgedIcon(d, this) } + + private fun isIconFullBleed(icon: Bitmap): Boolean { + return icon.height == icon.width && !icon.hasAlpha() + } + + /** + * Wraps this drawable in [InsetDrawable] such that the final drawable has square bounds, while + * preserving the aspect ratio of the source + * + * @param scale additional scale on the source drawable + */ + private fun Drawable.wrapIntoSquareDrawable(scale: Float): Drawable { + val h = intrinsicHeight.toFloat() + val w = intrinsicWidth.toFloat() + var scaleX = scale + var scaleY = scale + if (h > w && w > 0) { + scaleX *= w / h + } else if (w > h && h > 0) { + scaleY *= h / w + } + scaleX = (1 - scaleX) / 2 + scaleY = (1 - scaleY) / 2 + return InsetDrawable(this, scaleX, scaleY, scaleX, scaleY) + } + + /** Wraps the provided icon in an adaptive icon drawable */ + @JvmOverloads + fun wrapToAdaptiveIcon(icon: Drawable, options: IconOptions? = null): AdaptiveIconDrawable = + icon as? AdaptiveIconDrawable + ?: AdaptiveIconDrawable( + ColorDrawable(options?.wrapperBackgroundColor ?: DEFAULT_WRAPPER_BACKGROUND), + icon.wrapIntoSquareDrawable(LEGACY_ICON_SCALE), + ) + .apply { setBounds(0, 0, 1, 1) } + + private fun drawableToBitmap( + icon: Drawable, + drawFullBleed: Boolean, + options: IconOptions, + ): Bitmap { + if (icon is AdaptiveIconDrawable) { + // We are ignoring KEY_SHADOW_DISTANCE because regular icons ignore this at the + // moment b/298203449 + val offset = + if (drawFullBleed) 0 + else + max( + (ceil(BLUR_FACTOR * iconBitmapSize)).toInt(), + Math.round(iconBitmapSize * (1 - options.iconScale) / 2), + ) + // b/211896569: AdaptiveIconDrawable do not work properly for non top-left bounds + val newBounds = iconBitmapSize - offset * 2 + icon.setBounds(0, 0, newBounds, newBounds) + return createBitmap(options) { canvas, _ -> + canvas.transformed { + translate(offset.toFloat(), offset.toFloat()) + if (options.addShadows && !drawFullBleed) + shadowGenerator.addPathShadow(icon.iconMask, canvas) + if (icon is Extender) icon.drawForPersistence() + + if (drawFullBleed) { + drawColor(Color.BLACK) + icon.background?.draw(canvas) + icon.foreground?.draw(canvas) + } else { + icon.draw(canvas) + } + } + } + } else { + if (icon is BitmapDrawable && icon.bitmap?.density == Bitmap.DENSITY_NONE) { + icon.setTargetDensity(context.resources.displayMetrics) + } + val iconToDraw = + if (icon.intrinsicWidth != icon.intrinsicHeight || options.iconScale != 1f) + icon.wrapIntoSquareDrawable(options.iconScale) + else icon + iconToDraw.setBounds(0, 0, iconBitmapSize, iconBitmapSize) + + return createBitmap(options) { canvas, bitmap -> + if (drawFullBleed) canvas.drawColor(Color.BLACK) + iconToDraw.draw(canvas) + + if (options.addShadows && bitmap != null && !drawFullBleed) { + // Shadow extraction only works in software mode + shadowGenerator.drawShadow(bitmap, canvas) + + // Draw the icon again on top + iconToDraw.draw(canvas) + } + } + } + } + + private fun createBitmap(options: IconOptions, block: (Canvas, Bitmap?) -> Unit): Bitmap { + if (options.useHardware) { + return BitmapRenderer.createHardwareBitmap(iconBitmapSize, iconBitmapSize) { + block.invoke(it, null) + } + } + + val result = Bitmap.createBitmap(iconBitmapSize, iconBitmapSize, ARGB_8888) + block.invoke(Canvas(result), result) + return result + } + + override fun close() = clear() + + protected fun clear() {} + + fun makeDefaultIcon(iconProvider: IconProvider): BitmapInfo { + return createBadgedIconBitmap(iconProvider.getFullResDefaultActivityIcon(fullResIconDpi)) + } + + class IconOptions { + internal var isInstantApp: Boolean = false + internal var isFullBleed: Boolean = false + + internal var userHandle: UserHandle? = null + internal var userIconInfo: UserIconInfo? = null + @ColorInt internal var extractedColor: Int? = null + internal var sourceHint: SourceHint? = null + internal var wrapperBackgroundColor = DEFAULT_WRAPPER_BACKGROUND + + internal var useHardware = false + internal var addShadows = true + internal var drawFullBleed: Boolean? = null + internal var iconScale = ICON_VISIBLE_AREA_FACTOR + internal var wrapNonAdaptiveIcon = true + + /** User for this icon, in case of badging */ + fun setUser(user: UserHandle?) = apply { userHandle = user } + + /** User for this icon, in case of badging */ + fun setUser(user: UserIconInfo?) = apply { userIconInfo = user } + + /** If this icon represents an instant app */ + fun setInstantApp(instantApp: Boolean) = apply { isInstantApp = instantApp } + + /** + * If the icon is [BitmapDrawable], assumes that it is a full bleed icon and tries to shape + * it accordingly + */ + fun assumeFullBleedIcon(isFullBleed: Boolean) = apply { this.isFullBleed = isFullBleed } + + /** Disables auto color extraction and overrides the color to the provided value */ + fun setExtractedColor(@ColorInt color: Int) = apply { extractedColor = color } + + /** + * Sets the bitmap generation mode to use for the bitmap info. Note that some generation + * modes do not support color extraction, so consider setting a extracted color manually in + * those cases. + */ + fun setBitmapGenerationMode(@BitmapGenerationMode generationMode: Int) = + setUseHardware((generationMode and MODE_HARDWARE) != 0) + .setAddShadows((generationMode and MODE_WITH_SHADOW) != 0) + + /** User for this icon, in case of badging */ + fun setSourceHint(sourceHint: SourceHint?) = apply { this.sourceHint = sourceHint } + + /** Sets the background color used for wrapped adaptive icon */ + fun setWrapperBackgroundColor(color: Int) = apply { + wrapperBackgroundColor = + if (Color.alpha(color) < 255) DEFAULT_WRAPPER_BACKGROUND else color + } + + /** Sets if hardware bitmap should be generated as the output */ + fun setUseHardware(hardware: Boolean) = apply { useHardware = hardware } + + /** Sets if shadows should be added as part of BitmapInfo generation */ + fun setAddShadows(shadows: Boolean) = apply { addShadows = shadows } + + /** + * Sets if the bitmap info should be drawn full-bleed or not. Defaults to the IconFactory + * constructor parameter. + */ + fun setDrawFullBleed(fullBleed: Boolean) = apply { drawFullBleed = fullBleed } + + /** Sets how much tos cale down the icon when creating the bitmap */ + fun setIconScale(scale: Float) = apply { iconScale = scale } + + /** Sets if a non-adaptive icon should be wrapped into an adaptive icon or not */ + fun setWrapNonAdaptiveIcon(wrap: Boolean) = apply { wrapNonAdaptiveIcon = wrap } + } + + private class NoopDrawable : ColorDrawable() { + override fun getIntrinsicHeight(): Int = 1 + + override fun getIntrinsicWidth(): Int = 1 + } + + private class CenterTextDrawable(private val mText: String, color: Int) : ColorDrawable() { + private val textBounds = Rect() + private val textPaint = + Paint(Paint.ANTI_ALIAS_FLAG or Paint.FILTER_BITMAP_FLAG).also { it.color = color } + + override fun draw(canvas: Canvas) { + val bounds = bounds + textPaint.textSize = bounds.height() / 3f + textPaint.getTextBounds(mText, 0, mText.length, textBounds) + canvas.drawText( + mText, + bounds.exactCenterX() - textBounds.exactCenterX(), + bounds.exactCenterY() - textBounds.exactCenterY(), + textPaint, + ) + } + } + + companion object { + private const val DEFAULT_WRAPPER_BACKGROUND = Color.WHITE + + // Ratio of icon visible area to full icon size for a square shaped icon + private const val MAX_SQUARE_AREA_FACTOR = 375.0 / 576 + + private val LEGACY_ICON_SCALE = + sqrt(MAX_SQUARE_AREA_FACTOR).toFloat() * + .7f * + (1f / (1 + 2 * AdaptiveIconDrawable.getExtraInsetFraction())) + + const val MODE_DEFAULT: Int = 0 + const val MODE_WITH_SHADOW: Int = 1 + const val MODE_HARDWARE: Int = 1 shl 1 + const val MODE_HARDWARE_WITH_SHADOW: Int = MODE_HARDWARE or MODE_WITH_SHADOW + + @Retention(SOURCE) + @IntDef( + value = [MODE_DEFAULT, MODE_WITH_SHADOW, MODE_HARDWARE_WITH_SHADOW, MODE_HARDWARE], + flag = true, + ) + annotation class BitmapGenerationMode + + private const val ICON_BADGE_SCALE = 0.444f + + private val PLACEHOLDER_BACKGROUND_COLOR = Color.rgb(245, 245, 245) + + /** Returns the correct badge size given an icon size */ + @JvmStatic + fun getBadgeSizeForIconSize(iconSize: Int): Int { + return (ICON_BADGE_SCALE * iconSize).toInt() + } + + /** Cache of default icon shape keyed to the path size */ + private val defaultIconShapeCache = SparseArray>() + + private fun getDefaultIconShape(size: Int): IconShape { + synchronized(defaultIconShapeCache) { + val cachedShape = defaultIconShapeCache[size]?.get() + if (cachedShape != null) return cachedShape + + val generatedShape = + generateIconShape( + size, + AdaptiveIconDrawable(ColorDrawable(Color.BLACK), null) + .apply { setBounds(0, 0, size, size) } + .iconMask, + ) + + defaultIconShapeCache[size] = WeakReference(generatedShape) + return generatedShape + } + } + } +} diff --git a/iconloaderlib/src/com/android/launcher3/icons/BitmapInfo.kt b/iconloaderlib/src/com/android/launcher3/icons/BitmapInfo.kt index 1b3f0fa..d6489b8 100644 --- a/iconloaderlib/src/com/android/launcher3/icons/BitmapInfo.kt +++ b/iconloaderlib/src/com/android/launcher3/icons/BitmapInfo.kt @@ -17,82 +17,64 @@ package com.android.launcher3.icons import android.content.Context import android.graphics.Bitmap -import android.graphics.Canvas -import android.graphics.Path import android.graphics.drawable.Drawable import androidx.annotation.ColorRes import androidx.annotation.DrawableRes import androidx.annotation.IntDef +import com.android.launcher3.icons.BitmapInfo.Companion.FLAG_THEMED +import com.android.launcher3.icons.FastBitmapDrawableDelegate.DelegateFactory +import com.android.launcher3.icons.FastBitmapDrawableDelegate.SimpleDelegateFactory +import com.android.launcher3.icons.PlaceHolderDrawableDelegate.PlaceHolderDelegateFactory import com.android.launcher3.icons.cache.CacheLookupFlag import com.android.launcher3.util.FlagOp -open class BitmapInfo( +/** + * Data class that holds all the information needed to create an icon drawable. + * + * @property icon the bitmap of the icon. + * @property color the color of the icon. + * @property flags extra source information associated with this icon + * @property defaultIconShape the fallback shape when no shape is provided during icon creation + * @property themedBitmap theming information if the icon is created using [FLAG_THEMED] + * @property delegateFactory factory used for icon creation + * @property badgeInfo optional badge drawn on the icon + */ +data class BitmapInfo( @JvmField val icon: Bitmap, @JvmField val color: Int, - @BitmapInfoFlags @JvmField var flags: Int = 0, - var themedBitmap: ThemedBitmap? = null, + @BitmapInfoFlags val flags: Int = 0, + val defaultIconShape: IconShape = IconShape.EMPTY, + val themedBitmap: ThemedBitmap? = null, + val badgeInfo: BitmapInfo? = null, + val delegateFactory: DelegateFactory = SimpleDelegateFactory, ) { @IntDef( flag = true, - value = [FLAG_WORK, FLAG_INSTANT, FLAG_CLONE, FLAG_PRIVATE, FLAG_WRAPPED_NON_ADAPTIVE], + value = [FLAG_WORK, FLAG_INSTANT, FLAG_CLONE, FLAG_PRIVATE, FLAG_FULL_BLEED], ) internal annotation class BitmapInfoFlags - @IntDef(flag = true, value = [FLAG_THEMED, FLAG_NO_BADGE, FLAG_SKIP_USER_BADGE]) + @IntDef(flag = true, value = [FLAG_THEMED, FLAG_NO_BADGE, FLAG_SKIP_USER_BADGE, FLAG_CUSTOM_SHAPE]) annotation class DrawableCreationFlags - // b/377618519: These are saved to debug why work badges sometimes don't show up on work apps - @DrawableCreationFlags @JvmField var creationFlags: Int = 0 - - private var badgeInfo: BitmapInfo? = null - - fun withBadgeInfo(badgeInfo: BitmapInfo?) = clone().also { it.badgeInfo = badgeInfo } + fun withBadgeInfo(badgeInfo: BitmapInfo?) = copy(badgeInfo = badgeInfo) /** Returns a bitmapInfo with the flagOP applied */ - fun withFlags(op: FlagOp): BitmapInfo { - if (op === FlagOp.NO_OP) { - return this - } - return clone().also { it.flags = op.apply(it.flags) } - } - - @Override - open fun clone(): BitmapInfo { - return copyInternalsTo(BitmapInfo(icon, color)) - } - - protected fun copyInternalsTo(target: BitmapInfo): BitmapInfo { - target.themedBitmap = themedBitmap - target.flags = flags - target.badgeInfo = badgeInfo - return target - } - - // TODO: rename or remove because icon can no longer be null? - val isNullOrLowRes: Boolean - get() = icon == LOW_RES_ICON + fun withFlags(op: FlagOp): BitmapInfo = + if (op === FlagOp.NO_OP) this else copy(flags = op.apply(this.flags)) val isLowRes: Boolean get() = matchingLookupFlag.useLowRes() - open val matchingLookupFlag: CacheLookupFlag + val matchingLookupFlag: CacheLookupFlag /** Returns the lookup flag to match this current state of this info */ get() = CacheLookupFlag.DEFAULT_LOOKUP_FLAG.withUseLowRes(LOW_RES_ICON == icon) .withThemeIcon(themedBitmap != null) /** BitmapInfo can be stored on disk or other persistent storage */ - open fun canPersist(): Boolean { - return !isNullOrLowRes - } - - /** Creates a drawable for the provided BitmapInfo */ - @JvmOverloads - fun newIcon( - context: Context, - @DrawableCreationFlags creationFlags: Int = 0, - ): FastBitmapDrawable { - return newIcon(context, creationFlags, null) + fun canPersist(): Boolean { + return !isLowRes && delegateFactory == SimpleDelegateFactory } /** @@ -100,173 +82,161 @@ open class BitmapInfo( * * @param context Context * @param creationFlags Flags for creating the FastBitmapDrawable - * @param badgeShape Optional Path for masking icon badges to a shape. Should be 100x100. + * @param iconShape information for custom Icon Shapes, to use with Full-bleed icons. * @return FastBitmapDrawable */ - open fun newIcon( + @JvmOverloads + fun newIcon( context: Context, - @DrawableCreationFlags creationFlags: Int, - badgeShape: Path?, - ): FastBitmapDrawable { - val drawable: FastBitmapDrawable = - if (isLowRes) { - PlaceHolderIconDrawable(this, context) - } else if ( - (creationFlags and FLAG_THEMED) != 0 && - themedBitmap != null && - themedBitmap !== ThemedBitmap.NOT_SUPPORTED - ) { - themedBitmap!!.newDrawable(this, context) + @DrawableCreationFlags creationFlags: Int = 0, + iconShape: IconShape? = null, + ) = + FastBitmapDrawable( + info = this, + iconShape = iconShape ?: defaultIconShape, + delegateFactory = + when { + isLowRes -> PlaceHolderDelegateFactory(context) + creationFlags.hasMask(FLAG_THEMED) && + themedBitmap != null && + themedBitmap !== ThemedBitmap.NOT_SUPPORTED -> + themedBitmap.newDelegateFactory(this, context) + else -> delegateFactory + }, + disabledAlpha = GraphicsUtils.getFloat(context, R.attr.disabledIconAlpha, 1f), + creationFlags = if (iconShape != null) { + creationFlags.or(FLAG_CUSTOM_SHAPE) } else { - FastBitmapDrawable(this) - } - applyFlags(context, drawable, creationFlags, badgeShape) - return drawable - } - - protected fun applyFlags( - context: Context, drawable: FastBitmapDrawable, - @DrawableCreationFlags creationFlags: Int, badgeShape: Path? - ) { - this.creationFlags = creationFlags - drawable.disabledAlpha = GraphicsUtils.getFloat(context, R.attr.disabledIconAlpha, 1f) - drawable.creationFlags = creationFlags - if ((creationFlags and FLAG_NO_BADGE) == 0) { - val badge = getBadgeDrawable( - context, (creationFlags and FLAG_THEMED) != 0, - (creationFlags and FLAG_SKIP_USER_BADGE) != 0, badgeShape - ) - if (badge != null) { - drawable.badge = badge - } - } - } + creationFlags + }, + badge = + if (!creationFlags.hasMask(FLAG_NO_BADGE)) { + getBadgeDrawable( + context, + creationFlags.hasMask(FLAG_THEMED), + creationFlags.hasMask(FLAG_SKIP_USER_BADGE), + ) + } else null, + ) /** * Gets Badge drawable based on current flags * * @param context Context * @param isThemed If Drawable is themed. - * @param badgeShape Optional Path to mask badges to a shape. Should be 100x100. - * @return Drawable for the badge. */ - fun getBadgeDrawable(context: Context, isThemed: Boolean, badgeShape: Path?): Drawable? { - return getBadgeDrawable(context, isThemed, false, badgeShape) + fun getBadgeDrawable(context: Context, isThemed: Boolean): Drawable? { + return getBadgeDrawable(context, isThemed, false) } /** * Creates a Drawable for an icon badge for this BitmapInfo + * * @param context Context * @param isThemed If the drawable is themed. * @param skipUserBadge If should skip User Profile badging. - * @param badgeShape Optional Path to mask badge Drawable to a shape. Should be 100x100. - * @return Drawable for an icon Badge. */ private fun getBadgeDrawable( - context: Context, isThemed: Boolean, skipUserBadge: Boolean, badgeShape: Path? + context: Context, + isThemed: Boolean, + skipUserBadge: Boolean, ): Drawable? { if (badgeInfo != null) { var creationFlag = if (isThemed) FLAG_THEMED else 0 if (skipUserBadge) { creationFlag = creationFlag or FLAG_SKIP_USER_BADGE } - return badgeInfo!!.newIcon(context, creationFlag, badgeShape) + return badgeInfo.newIcon(context, creationFlag, null) } if (skipUserBadge) { return null } else { getBadgeDrawableInfo()?.let { - return UserBadgeDrawable( - context, - it.drawableRes, - it.colorRes, - isThemed, - badgeShape - ) + return UserBadgeDrawable(context, it.drawableRes, it.colorRes, isThemed) } } return null } - /** - * Returns information about the badge to apply based on current flags. - */ + /** Returns information about the badge to apply based on current flags. */ fun getBadgeDrawableInfo(): BadgeDrawableInfo? { return when { - (flags and FLAG_INSTANT) != 0 -> BadgeDrawableInfo( - R.drawable.ic_instant_app_badge, - R.color.badge_tint_instant - ) - (flags and FLAG_WORK) != 0 -> BadgeDrawableInfo( - R.drawable.ic_work_app_badge, - R.color.badge_tint_work - ) - (flags and FLAG_CLONE) != 0 -> BadgeDrawableInfo( - R.drawable.ic_clone_app_badge, - R.color.badge_tint_clone - ) - (flags and FLAG_PRIVATE) != 0 -> BadgeDrawableInfo( - R.drawable.ic_private_profile_app_badge, - R.color.badge_tint_private - ) + flags.hasMask(FLAG_INSTANT) -> + BadgeDrawableInfo(R.drawable.ic_instant_app_badge, R.color.badge_tint_instant) + flags.hasMask(FLAG_WORK) -> + BadgeDrawableInfo(R.drawable.ic_work_app_badge, R.color.badge_tint_work) + flags.hasMask(FLAG_CLONE) -> + BadgeDrawableInfo(R.drawable.ic_clone_app_badge, R.color.badge_tint_clone) + flags.hasMask(FLAG_PRIVATE) -> + BadgeDrawableInfo( + R.drawable.ic_private_profile_app_badge, + R.color.badge_tint_private, + ) else -> null } } + /** + * Checks for FLAG_FULL_BLEED from factory as well as checking bitmap content to verify. + */ + fun isFullBleed(): Boolean { + return flags.hasMask(FLAG_FULL_BLEED) + } - /** Interface to be implemented by drawables to provide a custom BitmapInfo */ + /** Interface to be implemented by drawables to customize a BitmapInfo */ interface Extender { - /** Called for creating a custom BitmapInfo */ - fun getExtendedInfo( - bitmap: Bitmap?, - color: Int, - iconFactory: BaseIconFactory?, - normalizationScale: Float, - ): BitmapInfo? + + /** Returns an update [BitmapInfo] replacing the existing [info] */ + fun getUpdatedBitmapInfo(info: BitmapInfo, factory: BaseIconFactory): BitmapInfo /** Called to draw the UI independent of any runtime configurations like time or theme */ - fun drawForPersistence(canvas: Canvas?) + fun drawForPersistence() } /** * Drawables backing a specific badge shown on app icons. + * * @param drawableRes Drawable resource for the badge. * @param colorRes Color resource to tint the badge. */ @JvmRecord data class BadgeDrawableInfo( @field:DrawableRes @param:DrawableRes val drawableRes: Int, - @field:ColorRes @param:ColorRes val colorRes: Int + @field:ColorRes @param:ColorRes val colorRes: Int, ) companion object { const val TAG: String = "BitmapInfo" - // BitmapInfo flags + // Persisted BitmapInfo flags. + // Reset the cache by changing RELEASE_VERSION whenever making any changes here. + // LINT.IfChange const val FLAG_WORK: Int = 1 shl 0 const val FLAG_INSTANT: Int = 1 shl 1 const val FLAG_CLONE: Int = 1 shl 2 const val FLAG_PRIVATE: Int = 1 shl 3 - const val FLAG_WRAPPED_NON_ADAPTIVE: Int = 1 shl 4 + const val FLAG_FULL_BLEED: Int = 1 shl 4 + // LINT.ThenChange(src/com/android/launcher3/icons/cache/BaseIconCache.kt:cache_release_version) // Drawable creation flags const val FLAG_THEMED: Int = 1 shl 0 const val FLAG_NO_BADGE: Int = 1 shl 1 const val FLAG_SKIP_USER_BADGE: Int = 1 shl 2 + const val FLAG_CUSTOM_SHAPE: Int = 1 shl 3 - @JvmField - val LOW_RES_ICON: Bitmap = Bitmap.createBitmap(1, 1, Bitmap.Config.ALPHA_8) - @JvmField - val LOW_RES_INFO: BitmapInfo = fromBitmap(LOW_RES_ICON) + @JvmField val LOW_RES_ICON: Bitmap = Bitmap.createBitmap(1, 1, Bitmap.Config.ALPHA_8) + @JvmField val LOW_RES_INFO: BitmapInfo = fromBitmap(LOW_RES_ICON) @JvmStatic fun fromBitmap(bitmap: Bitmap): BitmapInfo { - return of(bitmap, 0) + return of(bitmap, 0, IconShape.EMPTY) } @JvmStatic - fun of(bitmap: Bitmap, color: Int): BitmapInfo { - return BitmapInfo(bitmap, color) + fun of(bitmap: Bitmap, color: Int, defaultShape: IconShape = IconShape.EMPTY): BitmapInfo { + return BitmapInfo(icon = bitmap, color = color, defaultIconShape = defaultShape) } + + private inline fun Int.hasMask(mask: Int) = (this and mask) != 0 } } diff --git a/iconloaderlib/src/com/android/launcher3/icons/BubbleIconFactory.java b/iconloaderlib/src/com/android/launcher3/icons/BubbleIconFactory.java index b36dc06..49dcc3c 100644 --- a/iconloaderlib/src/com/android/launcher3/icons/BubbleIconFactory.java +++ b/iconloaderlib/src/com/android/launcher3/icons/BubbleIconFactory.java @@ -6,12 +6,14 @@ import android.content.pm.ShortcutInfo; import android.graphics.Bitmap; import android.graphics.Canvas; +import android.graphics.Color; import android.graphics.Path; import android.graphics.Rect; import android.graphics.drawable.AdaptiveIconDrawable; import android.graphics.drawable.Drawable; import android.graphics.drawable.Icon; import android.os.Build; +import android.os.UserHandle; import androidx.annotation.NonNull; import androidx.annotation.Nullable; @@ -73,30 +75,32 @@ public Drawable getBubbleDrawable(@NonNull final Context context, * Creates the bitmap for the provided drawable and returns the scale used for * drawing the actual drawable. This is used for the larger icon shown for the bubble. */ - public Bitmap getBubbleBitmap(@NonNull Drawable icon, float[] outScale) { - if (outScale == null) { - outScale = new float[1]; - } - icon = normalizeAndWrapToAdaptiveIcon(icon, outScale); - return createIconBitmap(icon, outScale[0], MODE_WITH_SHADOW); + public Bitmap getBubbleBitmap(@NonNull Drawable icon) { + return createBadgedIconBitmap( + icon, new IconOptions() + .setBitmapGenerationMode(MODE_WITH_SHADOW) + // We do not care about extracted color + .setExtractedColor(Color.TRANSPARENT)).icon; } /** * Returns a {@link BitmapInfo} for the app-badge that is shown on top of each bubble. This * will include the workprofile indicator on the badge if appropriate. */ - public BitmapInfo getBadgeBitmap(Drawable userBadgedAppIcon, boolean isImportantConversation) { - if (userBadgedAppIcon instanceof AdaptiveIconDrawable) { - AdaptiveIconDrawable ad = (AdaptiveIconDrawable) userBadgedAppIcon; - userBadgedAppIcon = new CircularAdaptiveIcon(ad.getBackground(), - ad.getForeground()); + public BitmapInfo getBadgeBitmap(Drawable appIcon, UserHandle user, + boolean isImportantConversation) { + if (appIcon instanceof AdaptiveIconDrawable ad) { + appIcon = new CircularAdaptiveIcon(ad.getBackground(), ad.getForeground()); } if (isImportantConversation) { - userBadgedAppIcon = new CircularRingDrawable(userBadgedAppIcon); + appIcon = new CircularRingDrawable(appIcon); } - Bitmap userBadgedBitmap = mBadgeFactory.createIconBitmap( - userBadgedAppIcon, 1, MODE_WITH_SHADOW); - return mBadgeFactory.createIconBitmap(userBadgedBitmap); + return mBadgeFactory.createBadgedIconBitmap( + appIcon, + new IconOptions() + .setBitmapGenerationMode(MODE_WITH_SHADOW) + .setWrapNonAdaptiveIcon(false) + .setUser(user)); } private class CircularRingDrawable extends CircularAdaptiveIcon { diff --git a/iconloaderlib/src/com/android/launcher3/icons/ClockDrawableWrapper.kt b/iconloaderlib/src/com/android/launcher3/icons/ClockDrawableWrapper.kt new file mode 100644 index 0000000..1a73b57 --- /dev/null +++ b/iconloaderlib/src/com/android/launcher3/icons/ClockDrawableWrapper.kt @@ -0,0 +1,302 @@ +/* + * Copyright (C) 2019 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.launcher3.icons + +import android.content.Context +import android.content.pm.PackageManager.GET_META_DATA +import android.content.pm.PackageManager.MATCH_UNINSTALLED_PACKAGES +import android.graphics.BitmapShader +import android.graphics.Canvas +import android.graphics.Color +import android.graphics.ColorFilter +import android.graphics.Paint +import android.graphics.Rect +import android.graphics.Shader +import android.graphics.Shader.TileMode.CLAMP +import android.graphics.drawable.AdaptiveIconDrawable +import android.graphics.drawable.Drawable +import android.graphics.drawable.LayerDrawable +import android.os.SystemClock +import android.util.Log +import com.android.launcher3.icons.BitmapInfo.Extender +import com.android.launcher3.icons.FastBitmapDrawableDelegate.Companion.drawShaderInBounds +import com.android.launcher3.icons.FastBitmapDrawableDelegate.DelegateFactory +import com.android.launcher3.icons.GraphicsUtils.getColorMultipliedFilter +import com.android.launcher3.icons.GraphicsUtils.resizeToContentSize +import java.util.Calendar +import java.util.concurrent.TimeUnit.MINUTES + +/** + * Wrapper over [AdaptiveIconDrawable] to intercept icon flattening logic for dynamic clock icons + */ +class ClockDrawableWrapper +private constructor(base: AdaptiveIconDrawable, private val animationInfo: ClockAnimationInfo) : + AdaptiveIconDrawable(base.background, base.foreground), Extender { + + override fun getMonochrome(): Drawable? { + val monoLayer = + (animationInfo.baseDrawableState.newDrawable().mutate() as? AdaptiveIconDrawable) + ?.monochrome + if (monoLayer is LayerDrawable) animationInfo.applyTime(Calendar.getInstance(), monoLayer) + return monoLayer + } + + override fun getUpdatedBitmapInfo(info: BitmapInfo, factory: BaseIconFactory): BitmapInfo { + val bitmapSize = factory.iconBitmapSize + val flattenBG = + BitmapRenderer.createHardwareBitmap(bitmapSize, bitmapSize) { + val drawable = AdaptiveIconDrawable(background.constantState!!.newDrawable(), null) + drawable.setBounds(0, 0, bitmapSize, bitmapSize) + it.drawColor(Color.BLACK) + drawable.background?.draw(it) + } + return info.copy( + delegateFactory = + animationInfo.copy( + themeFgColor = NO_COLOR, + shader = BitmapShader(flattenBG, CLAMP, CLAMP), + ) + ) + } + + override fun drawForPersistence() { + val foreground = foreground as LayerDrawable + resetLevel(foreground, animationInfo.hourLayerIndex) + resetLevel(foreground, animationInfo.minuteLayerIndex) + resetLevel(foreground, animationInfo.secondLayerIndex) + } + + private fun resetLevel(drawable: LayerDrawable, index: Int) { + if (index != INVALID_VALUE) drawable.getDrawable(index).setLevel(0) + } + + data class ClockAnimationInfo( + val hourLayerIndex: Int, + val minuteLayerIndex: Int, + val secondLayerIndex: Int, + val defaultHour: Int, + val defaultMinute: Int, + val defaultSecond: Int, + val baseDrawableState: ConstantState, + val themeFgColor: Int = NO_COLOR, + val shader: Shader? = null, + ) : DelegateFactory { + + fun applyTime(time: Calendar, foregroundDrawable: LayerDrawable): Boolean { + time.timeInMillis = System.currentTimeMillis() + + // We need to rotate by the difference from the default time if one is specified. + val invalidateHour = + foregroundDrawable.applyLevel(hourLayerIndex) { + val convertedHour = (time[Calendar.HOUR] + (12 - defaultHour)) % 12 + convertedHour * 60 + time[Calendar.MINUTE] + } + val invalidateMinute = + foregroundDrawable.applyLevel(minuteLayerIndex) { + val convertedMinute = (time[Calendar.MINUTE] + (60 - defaultMinute)) % 60 + time[Calendar.HOUR] * 60 + convertedMinute + } + val invalidateSecond = + foregroundDrawable.applyLevel(secondLayerIndex) { + val convertedSecond = (time[Calendar.SECOND] + (60 - defaultSecond)) % 60 + convertedSecond * LEVELS_PER_SECOND + } + return invalidateHour || invalidateMinute || invalidateSecond + } + + override fun newDelegate( + bitmapInfo: BitmapInfo, + iconShape: IconShape, + paint: Paint, + host: FastBitmapDrawable, + ): FastBitmapDrawableDelegate { + return ClockDrawableDelegate(this, host, paint, iconShape) + } + } + + private class ClockDrawableDelegate( + private val animInfo: ClockAnimationInfo, + private val host: FastBitmapDrawable, + private val paint: Paint, + private val iconShape: IconShape, + ) : FastBitmapDrawableDelegate, Runnable { + + private val time = Calendar.getInstance() + private val themedFgColor = animInfo.themeFgColor + + private val foreground = + ((animInfo.baseDrawableState.newDrawable().mutate() as AdaptiveIconDrawable).foreground + as LayerDrawable) + .apply { + val extraMargin = (getExtraInsetFraction() * iconShape.pathSize).toInt() + setBounds( + -extraMargin, + -extraMargin, + iconShape.pathSize + extraMargin, + iconShape.pathSize + extraMargin, + ) + colorFilter = getColorMultipliedFilter(themedFgColor, paint.colorFilter) + } + + override fun setAlpha(alpha: Int) { + foreground.alpha = alpha + } + + override fun drawContent( + info: BitmapInfo, + iconShape: IconShape, + canvas: Canvas, + bounds: Rect, + paint: Paint, + ) { + canvas.drawShaderInBounds(bounds, iconShape, paint, animInfo.shader) + + // prepare and draw the foreground + animInfo.applyTime(time, foreground) + canvas.resizeToContentSize(bounds, iconShape.pathSize.toFloat()) { + clipPath(iconShape.path) + foreground.draw(this) + } + reschedule() + } + + override fun isThemed(): Boolean { + return themedFgColor != NO_COLOR + } + + override fun updateFilter(filter: ColorFilter?) { + foreground.colorFilter = getColorMultipliedFilter(themedFgColor, filter) + } + + override fun getIconColor(info: BitmapInfo): Int { + return if (isThemed()) themedFgColor else super.getIconColor(info) + } + + override fun run() { + if (animInfo.applyTime(time, foreground)) { + host.invalidateSelf() + } else { + reschedule() + } + } + + override fun onVisibilityChanged(isVisible: Boolean) { + if (isVisible) { + reschedule() + } else { + host.unscheduleSelf(this) + } + } + + fun reschedule() { + if (!host.isVisible) { + return + } + host.unscheduleSelf(this) + val upTime = SystemClock.uptimeMillis() + val step = TICK_MS /* tick every 200 ms */ + host.scheduleSelf(this, upTime - ((upTime % step)) + step) + } + } + + companion object { + @JvmField var sRunningInTest: Boolean = false + + private const val TAG = "ClockDrawableWrapper" + + private const val DISABLE_SECONDS = true + private const val NO_COLOR = Color.TRANSPARENT + + // Time after which the clock icon should check for an update. The actual invalidate + // will only happen in case of any change. + val TICK_MS: Long = if (DISABLE_SECONDS) MINUTES.toMillis(1) else 200L + + private const val LAUNCHER_PACKAGE = "com.android.launcher3" + private const val ROUND_ICON_METADATA_KEY = "$LAUNCHER_PACKAGE.LEVEL_PER_TICK_ICON_ROUND" + private const val HOUR_INDEX_METADATA_KEY = "$LAUNCHER_PACKAGE.HOUR_LAYER_INDEX" + private const val MINUTE_INDEX_METADATA_KEY = "$LAUNCHER_PACKAGE.MINUTE_LAYER_INDEX" + private const val SECOND_INDEX_METADATA_KEY = "$LAUNCHER_PACKAGE.SECOND_LAYER_INDEX" + private const val DEFAULT_HOUR_METADATA_KEY = "$LAUNCHER_PACKAGE.DEFAULT_HOUR" + private const val DEFAULT_MINUTE_METADATA_KEY = "$LAUNCHER_PACKAGE.DEFAULT_MINUTE" + private const val DEFAULT_SECOND_METADATA_KEY = "$LAUNCHER_PACKAGE.DEFAULT_SECOND" + + /* Number of levels to jump per second for the second hand */ + private const val LEVELS_PER_SECOND = 10 + + const val INVALID_VALUE: Int = -1 + + /** + * Loads and returns the wrapper from the provided package, or returns null if it is unable + * to load. + */ + @JvmStatic + fun forPackage(context: Context, pkg: String, iconDpi: Int): ClockDrawableWrapper? { + try { + return loadClockDrawableUnsafe(context, pkg, iconDpi) + } catch (e: Exception) { + Log.d(TAG, "Unable to load clock drawable info", e) + } + return null + } + + private inline fun LayerDrawable.applyLevel(index: Int, level: () -> Int) = + (index != INVALID_VALUE && getDrawable(index).setLevel(level.invoke())) + + /** Tries to load clock drawable by reading packageManager information */ + @Throws(Exception::class) + private fun loadClockDrawableUnsafe( + context: Context, + pkg: String, + iconDpi: Int, + ): ClockDrawableWrapper? { + val pm = context.packageManager + val appInfo = + pm.getApplicationInfo(pkg, MATCH_UNINSTALLED_PACKAGES or GET_META_DATA) + ?: return null + val res = pm.getResourcesForApplication(appInfo) + val metadata = appInfo.metaData ?: return null + val drawableId = metadata.getInt(ROUND_ICON_METADATA_KEY, 0) + val drawable = + res.getDrawableForDensity(drawableId, iconDpi)?.mutate() as? AdaptiveIconDrawable + ?: return null + + val foreground = drawable.foreground as? LayerDrawable ?: return null + val layerCount = foreground.numberOfLayers + + fun getLayerIndex(key: String) = + metadata.getInt(key, INVALID_VALUE).let { + if (it < 0 || it >= layerCount) INVALID_VALUE else it + } + var animInfo = + ClockAnimationInfo( + hourLayerIndex = getLayerIndex(HOUR_INDEX_METADATA_KEY), + minuteLayerIndex = getLayerIndex(MINUTE_INDEX_METADATA_KEY), + secondLayerIndex = getLayerIndex(SECOND_INDEX_METADATA_KEY), + defaultHour = metadata.getInt(DEFAULT_HOUR_METADATA_KEY, 0), + defaultMinute = metadata.getInt(DEFAULT_MINUTE_METADATA_KEY, 0), + defaultSecond = metadata.getInt(DEFAULT_SECOND_METADATA_KEY, 0), + baseDrawableState = drawable.constantState!!, + ) + + if (DISABLE_SECONDS && animInfo.secondLayerIndex != INVALID_VALUE) { + foreground.setDrawable(animInfo.secondLayerIndex, null) + animInfo = animInfo.copy(secondLayerIndex = INVALID_VALUE) + } + animInfo.applyTime(Calendar.getInstance(), foreground) + return ClockDrawableWrapper(drawable, animInfo) + } + } +} diff --git a/iconloaderlib/src/com/android/launcher3/icons/DotRenderer.java b/iconloaderlib/src/com/android/launcher3/icons/DotRenderer.java index 4f4693b..7a5f8ad 100644 --- a/iconloaderlib/src/com/android/launcher3/icons/DotRenderer.java +++ b/iconloaderlib/src/com/android/launcher3/icons/DotRenderer.java @@ -16,21 +16,28 @@ package com.android.launcher3.icons; +import static android.graphics.Color.luminance; import static android.graphics.Paint.ANTI_ALIAS_FLAG; import static android.graphics.Paint.FILTER_BITMAP_FLAG; +import static com.android.launcher3.icons.IconNormalizer.ICON_VISIBLE_AREA_FACTOR; +import static com.android.systemui.shared.Flags.notificationDotContrastBorder; + import android.graphics.Bitmap; import android.graphics.Canvas; import android.graphics.Color; import android.graphics.Paint; import android.graphics.Path; import android.graphics.PathMeasure; +import android.graphics.PointF; import android.graphics.Rect; import android.graphics.RectF; import android.graphics.Typeface; import android.util.Log; import android.view.ViewDebug; + import androidx.annotation.ColorInt; +import androidx.annotation.NonNull; import androidx.core.graphics.ColorUtils; import androidx.palette.graphics.Palette; @@ -43,7 +50,9 @@ public class DotRenderer { // The dot size is defined as a percentage of the app icon size. private static final float SIZE_PERCENTAGE = 0.228f; - + // The black border needs a light notification dot color. This is for accessibility. + private static final float LUMINENSCE_LIMIT = .70f; + // Lawnchair private static final float SIZE_PERCENTAGE_WITH_COUNT = 0.348f; private static final int MAX_COUNT = 99; // The max number to draw on dots @@ -54,21 +63,16 @@ public class DotRenderer { // Lawnchair private final Paint mTextPaint = new Paint(ANTI_ALIAS_FLAG | FILTER_BITMAP_FLAG); - private final Bitmap mBackgroundWithShadow; - private final float mBitmapOffset; - - // Stores the center x and y position as a percentage (0 to 1) of the icon size - private final float[] mRightDotPosition; - private final float[] mLeftDotPosition; - - private boolean mDisplayCount; - // Lawnchair @ColorInt private int mColor; @ColorInt private int mCounterColor; private final Rect mTextRect = new Rect(); + private boolean mDisplayCount; + + private final Bitmap mBackgroundWithShadow; + private final float mBitmapOffset; private static final int MIN_DOT_SIZE = 1; @@ -76,10 +80,8 @@ public class DotRenderer { * AOSP's dot renderer with Lawnchair related change to show notification count on a dot. * * @param iconSizePx - * @param iconShapePath - * @param pathSize */ - public DotRenderer(int iconSizePx, Path iconShapePath, int pathSize, Boolean displayCount, Typeface typeface, @ColorInt int color, @ColorInt int counterColor) { + public DotRenderer(int iconSizePx, Boolean displayCount, Typeface typeface, @ColorInt int color, @ColorInt int counterColor) { mDisplayCount = displayCount; mColor = color; mCounterColor = counterColor; @@ -94,10 +96,6 @@ public DotRenderer(int iconSizePx, Path iconShapePath, int pathSize, Boolean dis mBitmapOffset = -mBackgroundWithShadow.getHeight() * 0.5f; // Same as width. - // Find the points on the path that are closest to the top left and right corners. - mLeftDotPosition = getPathPoint(iconShapePath, pathSize, -1); - mRightDotPosition = getPathPoint(iconShapePath, pathSize, 1); - mTextPaint.setTextSize(size * 0.65f); mTextPaint.setTextAlign(Paint.Align.LEFT); mTextPaint.setTypeface(typeface); @@ -107,28 +105,22 @@ public DotRenderer(int iconSizePx, Path iconShapePath, int pathSize, Boolean dis /** * AOSP's dot renderer. To use notification count on the dot see {@link #DotRenderer(int, Path, int, Boolean, Typeface, int, int)} * - * @param iconSizePx - * @param iconShapePath - * @param pathSize + * @param iconSizePx */ - public DotRenderer(int iconSizePx, Path iconShapePath, int pathSize) { + public DotRenderer(int iconSizePx) { int size = Math.round(SIZE_PERCENTAGE * iconSizePx); if (size <= 0) { size = MIN_DOT_SIZE; } ShadowGenerator.Builder builder = new ShadowGenerator.Builder(Color.TRANSPARENT); - builder.ambientShadowAlpha = 88; + builder.ambientShadowAlpha = notificationDotContrastBorder() ? 255 : 88; mBackgroundWithShadow = builder.setupBlurForSize(size).createPill(size, size); mCircleRadius = builder.radius; mBitmapOffset = -mBackgroundWithShadow.getHeight() * 0.5f; // Same as width. - - // Find the points on the path that are closest to the top left and right corners. - mLeftDotPosition = getPathPoint(iconShapePath, pathSize, -1); - mRightDotPosition = getPathPoint(iconShapePath, pathSize, 1); } - private static float[] getPathPoint(Path path, float size, float direction) { + private static PointF getPathPoint(Path path, float size, float direction) { float halfSize = size / 2; // Small delta so that we don't get a zero size triangle float delta = 1; @@ -143,26 +135,15 @@ private static float[] getPathPoint(Path path, float size, float direction) { trianglePath.op(path, Path.Op.INTERSECT); float[] pos = new float[2]; new PathMeasure(trianglePath, false).getPosTan(0, pos, null); - - pos[0] = pos[0] / size; - pos[1] = pos[1] / size; - return pos; - } - - public float[] getLeftDotPosition() { - return mLeftDotPosition; - } - - public float[] getRightDotPosition() { - return mRightDotPosition; + return new PointF(pos[0] / size, pos[1] / size); } /** - * LC: Draw a circle on top of the canvas according to the given params. + * Draw a circle on top of the canvas according to the given params. * - * Include: notification number counter + * This is the original AOSP method without notification count feature. To use it with count see {@link #draw(Canvas, DrawParams, int)} */ - public void draw(Canvas canvas, DrawParams params, int numNotifications) { + public void draw(Canvas canvas, DrawParams params) { if (params == null) { Log.e(TAG, "Invalid null argument(s) passed in call to draw."); return; @@ -170,51 +151,36 @@ public void draw(Canvas canvas, DrawParams params, int numNotifications) { canvas.save(); Rect iconBounds = params.iconBounds; - float[] dotPosition = params.leftAlign ? mLeftDotPosition : mRightDotPosition; - float dotCenterX = iconBounds.left + iconBounds.width() * dotPosition[0]; - float dotCenterY = iconBounds.top + iconBounds.height() * dotPosition[1]; + PointF dotPosition = params.getDotPosition(); + float dotCenterX = iconBounds.left + iconBounds.width() * dotPosition.x; + float dotCenterY = iconBounds.top + iconBounds.height() * dotPosition.y; // Ensure dot fits entirely in canvas clip bounds. Rect canvasBounds = canvas.getClipBounds(); float offsetX = params.leftAlign - ? Math.max(0, canvasBounds.left - (dotCenterX + mBitmapOffset)) - : Math.min(0, canvasBounds.right - (dotCenterX - mBitmapOffset)); + ? Math.max(0, canvasBounds.left - (dotCenterX + mBitmapOffset)) + : Math.min(0, canvasBounds.right - (dotCenterX - mBitmapOffset)); float offsetY = Math.max(0, canvasBounds.top - (dotCenterY + mBitmapOffset)); // We draw the dot relative to its center. canvas.translate(dotCenterX + offsetX, dotCenterY + offsetY); canvas.scale(params.scale, params.scale); + // Draw Background Shadow mCirclePaint.setColor(Color.BLACK); canvas.drawBitmap(mBackgroundWithShadow, mBitmapOffset, mBitmapOffset, mCirclePaint); - mCirclePaint.setColor(params.dotColor); - canvas.drawCircle(0, 0, mCircleRadius, mCirclePaint); - - if (mDisplayCount && numNotifications > 0) { - // Draw the numNotifications text - final int counterColor; - if (mCounterColor != 0) { - counterColor = mCounterColor; - } else { - counterColor = getCounterTextColor(params.dotColor); - } - mTextPaint.setColor(counterColor); - String text = String.valueOf(Math.min(numNotifications, MAX_COUNT)); - mTextPaint.getTextBounds(text, 0, text.length(), mTextRect); - float x = (-mTextRect.width() / 2f - mTextRect.left) * getAdjustment(numNotifications); - float y = mTextRect.height() / 2f - mTextRect.bottom; - canvas.drawText(text, x, y, mTextPaint); - } + mCirclePaint.setColor(params.mDotColor); + canvas.drawCircle(0, 0, mCircleRadius, mCirclePaint); canvas.restore(); } /** - * Draw a circle on top of the canvas according to the given params. - * - * This is the original AOSP method without notification count feature. To use it with count see {@link #draw(Canvas, DrawParams, int)} + * LC: Draw a circle on top of the canvas according to the given params. + * + * Include: notification number counter */ - public void draw(Canvas canvas, DrawParams params) { + public void draw(Canvas canvas, DrawParams params, int numNotifications) { if (params == null) { Log.e(TAG, "Invalid null argument(s) passed in call to draw."); return; @@ -222,15 +188,15 @@ public void draw(Canvas canvas, DrawParams params) { canvas.save(); Rect iconBounds = params.iconBounds; - float[] dotPosition = params.leftAlign ? mLeftDotPosition : mRightDotPosition; - float dotCenterX = iconBounds.left + iconBounds.width() * dotPosition[0]; - float dotCenterY = iconBounds.top + iconBounds.height() * dotPosition[1]; + PointF dotPosition = params.getDotPosition(); + float dotCenterX = iconBounds.left + iconBounds.width() * dotPosition.x; + float dotCenterY = iconBounds.top + iconBounds.height() * dotPosition.y; // Ensure dot fits entirely in canvas clip bounds. Rect canvasBounds = canvas.getClipBounds(); float offsetX = params.leftAlign - ? Math.max(0, canvasBounds.left - (dotCenterX + mBitmapOffset)) - : Math.min(0, canvasBounds.right - (dotCenterX - mBitmapOffset)); + ? Math.max(0, canvasBounds.left - (dotCenterX + mBitmapOffset)) + : Math.min(0, canvasBounds.right - (dotCenterX - mBitmapOffset)); float offsetY = Math.max(0, canvasBounds.top - (dotCenterY + mBitmapOffset)); // We draw the dot relative to its center. @@ -239,8 +205,26 @@ public void draw(Canvas canvas, DrawParams params) { mCirclePaint.setColor(Color.BLACK); canvas.drawBitmap(mBackgroundWithShadow, mBitmapOffset, mBitmapOffset, mCirclePaint); - mCirclePaint.setColor(params.dotColor); + + mCirclePaint.setColor(params.mDotColor); canvas.drawCircle(0, 0, mCircleRadius, mCirclePaint); + + if (mDisplayCount && numNotifications > 0) { + // Draw the numNotifications text + final int counterColor; + if (mCounterColor != 0) { + counterColor = mCounterColor; + } else { + counterColor = getCounterTextColor(params.mDotColor); + } + mTextPaint.setColor(counterColor); + String text = String.valueOf(Math.min(numNotifications, MAX_COUNT)); + mTextPaint.getTextBounds(text, 0, text.length(), mTextRect); + float x = (-mTextRect.width() / 2f - mTextRect.left) * getAdjustment(numNotifications); + float y = mTextRect.height() / 2f - mTextRect.bottom; + canvas.drawText(text, x, y, mTextPaint); + } + canvas.restore(); } @@ -272,7 +256,7 @@ private int getCounterTextColor(int dotBackgroundColor) { public static class DrawParams { /** The color (possibly based on the icon) to use for the dot. */ @ViewDebug.ExportedProperty(category = "notification dot", formatToHexString = true) - public int dotColor; + public int mDotColor; /** The color (possibly based on the icon) to use for a predicted app. */ @ViewDebug.ExportedProperty(category = "notification dot", formatToHexString = true) public int appColor; @@ -285,5 +269,57 @@ public static class DrawParams { /** Whether the dot should align to the top left of the icon rather than the top right. */ @ViewDebug.ExportedProperty(category = "notification dot") public boolean leftAlign; + + @NonNull + public IconShapeInfo shapeInfo = IconShapeInfo.DEFAULT; + + public PointF getDotPosition() { + return leftAlign ? shapeInfo.leftCornerPosition : shapeInfo.rightCornerPosition; + } + + /** The color (possibly based on the icon) to use for the dot. */ + public void setDotColor(int color) { + mDotColor = color; + + if (notificationDotContrastBorder() && luminance(color) < LUMINENSCE_LIMIT) { + double[] lab = new double[3]; + ColorUtils.colorToLAB(color, lab); + mDotColor = ColorUtils.LABToColor(100 * LUMINENSCE_LIMIT, lab[1], lab[2]); + } + } + } + + /** + * Class stores information about the icon icon shape on which the dot is being rendered. + * It stores the center x and y position as a percentage (0 to 1) of the icon size + */ + public record IconShapeInfo(PointF leftCornerPosition, PointF rightCornerPosition) { + + /** Shape when the icon rendered completely fills {@link DrawParams#iconBounds} */ + public static IconShapeInfo DEFAULT = + fromPath(IconShape.EMPTY.path, IconShape.EMPTY.pathSize); + + /** Shape when a normalized icon is rendered within {@link DrawParams#iconBounds} */ + public static IconShapeInfo DEFAULT_NORMALIZED = new IconShapeInfo( + normalizedPosition(DEFAULT.leftCornerPosition), + normalizedPosition(DEFAULT.rightCornerPosition) + ); + + /** + * Creates an IconShapeInfo from the provided path in bounds [0, 0, pathSize, pathSize] + */ + public static IconShapeInfo fromPath(Path path, int pathSize) { + return new IconShapeInfo( + getPathPoint(path, pathSize, -1), + getPathPoint(path, pathSize, 1)); + } + + private static PointF normalizedPosition(PointF pos) { + float center = 0.5f; + return new PointF( + center + ICON_VISIBLE_AREA_FACTOR * (pos.x - center), + center + ICON_VISIBLE_AREA_FACTOR * (pos.y - center) + ); + } } } diff --git a/iconloaderlib/src/com/android/launcher3/icons/FastBitmapDrawable.kt b/iconloaderlib/src/com/android/launcher3/icons/FastBitmapDrawable.kt index 670915a..63cc78c 100644 --- a/iconloaderlib/src/com/android/launcher3/icons/FastBitmapDrawable.kt +++ b/iconloaderlib/src/com/android/launcher3/icons/FastBitmapDrawable.kt @@ -19,7 +19,6 @@ import android.R import android.animation.ObjectAnimator import android.graphics.Bitmap import android.graphics.Canvas -import android.graphics.Color import android.graphics.ColorFilter import android.graphics.ColorMatrix import android.graphics.ColorMatrixColorFilter @@ -36,24 +35,35 @@ import android.view.animation.DecelerateInterpolator import android.view.animation.Interpolator import android.view.animation.PathInterpolator import androidx.annotation.VisibleForTesting -import androidx.core.graphics.ColorUtils +import com.android.launcher3.icons.BitmapInfo.Companion.LOW_RES_INFO import com.android.launcher3.icons.BitmapInfo.DrawableCreationFlags -import kotlin.math.min - -open class FastBitmapDrawable(info: BitmapInfo?) : Drawable(), Callback { +import com.android.launcher3.icons.FastBitmapDrawableDelegate.DelegateFactory +import com.android.launcher3.icons.FastBitmapDrawableDelegate.SimpleDelegateFactory + +class FastBitmapDrawable +@JvmOverloads +constructor( + info: BitmapInfo?, + private val iconShape: IconShape = IconShape.EMPTY, + private val delegateFactory: DelegateFactory = SimpleDelegateFactory, + @JvmField @DrawableCreationFlags val creationFlags: Int = 0, + private val disabledAlpha: Float = 1f, + val badge: Drawable? = null, +) : Drawable(), Callback { @JvmOverloads constructor(b: Bitmap, iconColor: Int = 0) : this(BitmapInfo.of(b, iconColor)) - @JvmField val bitmapInfo: BitmapInfo = info ?: BitmapInfo.LOW_RES_INFO + // b/404578798 - mBitmapInfo isn't expected to be null, but it is in some cases. + @JvmField val bitmapInfo: BitmapInfo = info ?: LOW_RES_INFO var isAnimationEnabled: Boolean = true @JvmField protected val paint: Paint = Paint(FILTER_BITMAP_FLAG or ANTI_ALIAS_FLAG) + val delegate = delegateFactory.newDelegate(bitmapInfo, iconShape, paint, this) + @JvmField @VisibleForTesting var isPressed: Boolean = false @JvmField @VisibleForTesting var isHovered: Boolean = false - @JvmField var disabledAlpha: Float = 1f - var isDisabled: Boolean = false set(value) { if (field != value) { @@ -63,7 +73,6 @@ open class FastBitmapDrawable(info: BitmapInfo?) : Drawable(), Callback { } } - @JvmField @DrawableCreationFlags var creationFlags: Int = 0 @JvmField @VisibleForTesting var scaleAnimation: ObjectAnimator? = null var hoverScaleEnabledForDisplay = true @@ -73,26 +82,16 @@ open class FastBitmapDrawable(info: BitmapInfo?) : Drawable(), Callback { private var paintFilter: ColorFilter? = null init { - isFilterBitmap = true + badge?.callback = this } - var badge: Drawable? = null - set(value) { - field?.callback = null - field = value - field?.let { - it.callback = this - it.setBadgeBounds(bounds) - } - updateFilter() - } - /** Returns true if the drawable points to the same bitmap icon object */ fun isSameInfo(info: BitmapInfo): Boolean = bitmapInfo === info override fun onBoundsChange(bounds: Rect) { super.onBoundsChange(bounds) badge?.setBadgeBounds(bounds) + delegate.onBoundsChange(bounds) } override fun draw(canvas: Canvas) { @@ -101,27 +100,27 @@ open class FastBitmapDrawable(info: BitmapInfo?) : Drawable(), Callback { val bounds = bounds canvas.scale(scale, scale, bounds.exactCenterX(), bounds.exactCenterY()) drawInternal(canvas, bounds) - badge?.draw(canvas) canvas.restoreToCount(count) } else { drawInternal(canvas, bounds) - badge?.draw(canvas) } } - protected open fun drawInternal(canvas: Canvas, bounds: Rect) { - canvas.drawBitmap(bitmapInfo.icon, null, bounds, paint) + private fun drawInternal(canvas: Canvas, bounds: Rect) { + delegate.drawContent(bitmapInfo, iconShape, canvas, bounds, paint) + badge?.draw(canvas) } /** Returns the primary icon color, slightly tinted white */ - open fun getIconColor(): Int = - ColorUtils.compositeColors( - GraphicsUtils.setColorAlphaBound(Color.WHITE, WHITE_SCRIM_ALPHA), - bitmapInfo.color, - ) + fun getIconColor(): Int = delegate.getIconColor(bitmapInfo) /** Returns if this represents a themed icon */ - open fun isThemed(): Boolean = false + fun isThemed(): Boolean = delegate.isThemed() + + override fun setVisible(visible: Boolean, restart: Boolean): Boolean = + super.setVisible(visible, restart).also { delegate.onVisibilityChanged(visible) } + + override fun onLevelChange(level: Int) = delegate.onLevelChange(level) /** * Returns true if the drawable was created with theme, even if it doesn't support theming @@ -145,6 +144,7 @@ open class FastBitmapDrawable(info: BitmapInfo?) : Drawable(), Callback { paint.alpha = alpha invalidateSelf() badge?.alpha = alpha + delegate.setAlpha(alpha) } } @@ -227,23 +227,25 @@ open class FastBitmapDrawable(info: BitmapInfo?) : Drawable(), Callback { } /** Updates the paint to reflect the current brightness and saturation. */ - protected open fun updateFilter() { - paint.setColorFilter(if (isDisabled) getDisabledColorFilter(disabledAlpha) else paintFilter) - badge?.colorFilter = colorFilter + private fun updateFilter() { + val filter = if (isDisabled) getDisabledColorFilter(disabledAlpha) else paintFilter + paint.colorFilter = filter + badge?.colorFilter = filter + delegate.updateFilter(filter) invalidateSelf() } - protected open fun newConstantState(): FastBitmapConstantState { - return FastBitmapConstantState(bitmapInfo) - } - - override fun getConstantState(): ConstantState { - val cs = newConstantState() - cs.mIsDisabled = isDisabled - cs.mBadgeConstantState = badge?.constantState - cs.mCreationFlags = creationFlags - return cs - } + override fun getConstantState() = + FastBitmapConstantState( + bitmapInfo, + isDisabled, + badge?.constantState, + iconShape, + creationFlags, + disabledAlpha, + delegateFactory, + level, + ) // Returns if the FastBitmapDrawable contains a badge. fun hasBadge(): Boolean = (creationFlags and BitmapInfo.FLAG_NO_BADGE) == 0 @@ -264,29 +266,30 @@ open class FastBitmapDrawable(info: BitmapInfo?) : Drawable(), Callback { unscheduleSelf(what) } - open class FastBitmapConstantState(val bitmapInfo: BitmapInfo) : ConstantState() { - // These are initialized later so that subclasses don't need to - // pass everything in constructor - var mIsDisabled: Boolean = false - var mBadgeConstantState: ConstantState? = null - - @DrawableCreationFlags var mCreationFlags: Int = 0 - - constructor(bitmap: Bitmap, color: Int) : this(BitmapInfo.of(bitmap, color)) - - protected open fun createDrawable(): FastBitmapDrawable { - return FastBitmapDrawable(bitmapInfo) - } - - override fun newDrawable(): FastBitmapDrawable { - val drawable = createDrawable() - drawable.isDisabled = mIsDisabled - if (mBadgeConstantState != null) { - drawable.badge = mBadgeConstantState!!.newDrawable() - } - drawable.creationFlags = mCreationFlags - return drawable - } + data class FastBitmapConstantState( + val bitmapInfo: BitmapInfo, + val isDisabled: Boolean, + val badgeConstantState: ConstantState?, + val iconShape: IconShape, + val creationFlags: Int, + val disabledAlpha: Float, + val delegateFactory: DelegateFactory, + val level: Int, + ) : ConstantState() { + + override fun newDrawable() = + FastBitmapDrawable( + info = bitmapInfo, + iconShape = iconShape, + delegateFactory = delegateFactory, + creationFlags = creationFlags, + badge = badgeConstantState?.newDrawable(), + disabledAlpha = disabledAlpha, + ) + .apply { + isDisabled = this@FastBitmapConstantState.isDisabled + level = this@FastBitmapConstantState.level + } override fun getChangingConfigurations(): Int = 0 } @@ -304,7 +307,6 @@ open class FastBitmapDrawable(info: BitmapInfo?) : Drawable(), Callback { private const val DISABLED_DESATURATION = 1f private const val DISABLED_BRIGHTNESS = 0.5f - const val FULLY_OPAQUE: Int = 255 const val CLICK_FEEDBACK_DURATION: Int = 200 const val HOVER_FEEDBACK_DURATION: Int = 300 @@ -345,15 +347,6 @@ open class FastBitmapDrawable(info: BitmapInfo?) : Drawable(), Callback { return ColorMatrixColorFilter(tempFilterMatrix) } - @JvmStatic - fun getDisabledColor(color: Int): Int { - val avgComponent = (Color.red(color) + Color.green(color) + Color.blue(color)) / 3 - val scale = 1 - DISABLED_BRIGHTNESS - val brightnessI = (255 * DISABLED_BRIGHTNESS).toInt() - val component = min(Math.round(scale * avgComponent + brightnessI), FULLY_OPAQUE) - return Color.rgb(component, component, component) - } - /** Sets the bounds for the badge drawable based on the main icon bounds */ @JvmStatic fun Drawable.setBadgeBounds(iconBounds: Rect) { diff --git a/iconloaderlib/src/com/android/launcher3/icons/FastBitmapDrawableDelegate.kt b/iconloaderlib/src/com/android/launcher3/icons/FastBitmapDrawableDelegate.kt new file mode 100644 index 0000000..563d5b9 --- /dev/null +++ b/iconloaderlib/src/com/android/launcher3/icons/FastBitmapDrawableDelegate.kt @@ -0,0 +1,140 @@ +/* + * Copyright (C) 2025 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.launcher3.icons + +import android.graphics.BitmapShader +import android.graphics.Canvas +import android.graphics.Color +import android.graphics.ColorFilter +import android.graphics.Paint +import android.graphics.Rect +import android.graphics.Shader +import android.graphics.Shader.TileMode.CLAMP +import androidx.core.graphics.ColorUtils +import com.android.launcher3.icons.BitmapInfo.Companion.FLAG_FULL_BLEED +import com.android.launcher3.icons.GraphicsUtils.resizeToContentSize + +/** A delegate for changing the rendering of [FastBitmapDrawable], to support multi-inheritance */ +interface FastBitmapDrawableDelegate { + + /** [android.graphics.drawable.Drawable.onBoundsChange] */ + fun onBoundsChange(bounds: Rect) {} + + /** [android.graphics.drawable.Drawable.draw] */ + fun drawContent( + info: BitmapInfo, + iconShape: IconShape, + canvas: Canvas, + bounds: Rect, + paint: Paint, + ) + + /** [FastBitmapDrawable.getIconColor] */ + fun getIconColor(info: BitmapInfo): Int = + ColorUtils.compositeColors( + GraphicsUtils.setColorAlphaBound(Color.WHITE, FastBitmapDrawable.WHITE_SCRIM_ALPHA), + info.color, + ) + + /** [FastBitmapDrawable.isThemed] */ + fun isThemed() = false + + /** [android.graphics.drawable.Drawable.setAlpha] */ + fun setAlpha(alpha: Int) {} + + /** [android.graphics.drawable.Drawable.setColorFilter] */ + fun updateFilter(filter: ColorFilter?) {} + + /** [android.graphics.drawable.Drawable.setVisible] */ + fun onVisibilityChanged(isVisible: Boolean) {} + + /** [android.graphics.drawable.Drawable.onLevelChange] */ + fun onLevelChange(level: Int): Boolean = false + + /** + * Interface for creating new delegates. This should not store any state information and can + * safely be stored in a [android.graphics.drawable.Drawable.ConstantState] + */ + fun interface DelegateFactory { + + fun newDelegate( + bitmapInfo: BitmapInfo, + iconShape: IconShape, + paint: Paint, + host: FastBitmapDrawable, + ): FastBitmapDrawableDelegate + } + + class FullBleedDrawableDelegate(bitmapInfo: BitmapInfo) : FastBitmapDrawableDelegate { + private val shader = BitmapShader(bitmapInfo.icon, CLAMP, CLAMP) + + override fun drawContent( + info: BitmapInfo, + iconShape: IconShape, + canvas: Canvas, + bounds: Rect, + paint: Paint, + ) { + canvas.drawShaderInBounds(bounds, iconShape, paint, shader) + } + } + + object SimpleDrawableDelegate : FastBitmapDrawableDelegate { + + override fun drawContent( + info: BitmapInfo, + iconShape: IconShape, + canvas: Canvas, + bounds: Rect, + paint: Paint, + ) { + canvas.drawBitmap(info.icon, null, bounds, paint) + } + } + + object SimpleDelegateFactory : DelegateFactory { + override fun newDelegate( + bitmapInfo: BitmapInfo, + iconShape: IconShape, + paint: Paint, + host: FastBitmapDrawable, + ) = + if ((bitmapInfo.flags and FLAG_FULL_BLEED) != 0) FullBleedDrawableDelegate(bitmapInfo) + else SimpleDrawableDelegate + } + + companion object { + + /** + * Draws the shader created using [FastBitmapDrawableDelegate.createPaintShader] in the + * provided bounds + */ + fun Canvas.drawShaderInBounds( + bounds: Rect, + iconShape: IconShape, + paint: Paint, + shader: Shader?, + ) { + drawBitmap(iconShape.shadowLayer, null, bounds, paint) + resizeToContentSize(bounds, iconShape.pathSize.toFloat()) { + paint.shader = shader + iconShape.shapeRenderer.render(this, paint) + paint.shader = null + } + } + } +} diff --git a/iconloaderlib/src/com/android/launcher3/icons/GraphicsUtils.kt b/iconloaderlib/src/com/android/launcher3/icons/GraphicsUtils.kt new file mode 100644 index 0000000..56b9a62 --- /dev/null +++ b/iconloaderlib/src/com/android/launcher3/icons/GraphicsUtils.kt @@ -0,0 +1,259 @@ +/* + * Copyright (C) 2018 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.launcher3.icons + +import android.content.Context +import android.graphics.Bitmap +import android.graphics.Bitmap.CompressFormat.PNG +import android.graphics.BitmapFactory +import android.graphics.BitmapFactory.Options +import android.graphics.BlendMode +import android.graphics.BlendModeColorFilter +import android.graphics.Canvas +import android.graphics.Color +import android.graphics.ColorFilter +import android.graphics.ColorMatrix +import android.graphics.ColorMatrixColorFilter +import android.graphics.Matrix +import android.graphics.Paint +import android.graphics.Path +import android.graphics.PorterDuff +import android.graphics.PorterDuffXfermode +import android.graphics.Rect +import android.graphics.RectF +import android.util.Log +import androidx.annotation.ColorInt +import androidx.core.graphics.ColorUtils.compositeColors +import com.android.launcher3.icons.IconNormalizer.ICON_VISIBLE_AREA_FACTOR +import com.android.launcher3.icons.ShadowGenerator.BLUR_FACTOR +import com.android.launcher3.icons.ShapeRenderer.AlphaMaskRenderer +import com.android.launcher3.icons.ShapeRenderer.CircleRenderer +import com.android.launcher3.icons.ShapeRenderer.RoundedRectRenderer +import java.io.ByteArrayOutputStream +import java.io.IOException +import kotlin.math.ceil +import kotlin.math.max + +object GraphicsUtils { + private const val TAG = "GraphicsUtils" + + @JvmField var sOnNewBitmapRunnable: Runnable = Runnable {} + + /** + * Set the alpha component of `color` to be `alpha`. Unlike the support lib version, it bounds + * the alpha in valid range instead of throwing an exception to allow for safer interpolation of + * color animations + */ + @JvmStatic + @ColorInt + fun setColorAlphaBound(color: Int, alpha: Int): Int = + (color and 0x00ffffff) or (alpha.coerceIn(0, 255) shl 24) + + /** Compresses the bitmap to a byte array for serialization. */ + @JvmStatic + fun flattenBitmap(bitmap: Bitmap): ByteArray { + val out = ByteArrayOutputStream(getExpectedBitmapSize(bitmap)) + try { + bitmap.compress(PNG, 100, out) + out.flush() + out.close() + return out.toByteArray() + } catch (e: IOException) { + Log.w(TAG, "Could not write bitmap") + return ByteArray(0) + } + } + + /** Compresses BitmapInfo default shape bitmap to a byte array **/ + @JvmStatic + fun createDefaultFlatBitmap(bitmapInfo: BitmapInfo): ByteArray { + // BitmapInfo uses immutable hardware bitmaps, so we need to make a software copy to apply + // the default shape mask. + val bitmap = bitmapInfo.icon.copy(Bitmap.Config.ARGB_8888, /* isMutable **/ true) + val cropBitmap = Bitmap.createBitmap(bitmap.width, bitmap.height, Bitmap.Config.ARGB_8888) + val canvas = Canvas(cropBitmap) + + var paint = Paint(Paint.ANTI_ALIAS_FLAG) + paint.color = Color.BLACK + paint.style = Paint.Style.FILL + canvas.drawPath(bitmapInfo.defaultIconShape.path, paint) + + paint = Paint(Paint.ANTI_ALIAS_FLAG or Paint.FILTER_BITMAP_FLAG) + paint.setXfermode(PorterDuffXfermode(PorterDuff.Mode.SRC_IN)) + canvas.drawBitmap(bitmap, 0f, 0f, paint) + + val flatBitmap = flattenBitmap(cropBitmap) + cropBitmap.recycle() + bitmap.recycle() + return flatBitmap + } + + /** Tries to decode the [ByteArray] into a [Bitmap] consuming any parsing errors */ + fun ByteArray.parseBitmapSafe(config: Bitmap.Config): Bitmap? = + try { + BitmapFactory.decodeByteArray( + /* data= */ this, + /* offset= */ 0, + /* length= */ size, + Options().apply { inPreferredConfig = config }, + ) + } catch (e: Exception) { + Log.e(TAG, "Error parsing persisted bitmap", e) + null + } + + /** + * Try go guesstimate how much space the icon will take when serialized to avoid unnecessary + * allocations/copies during the write (4 bytes per pixel). + */ + @JvmStatic fun getExpectedBitmapSize(bitmap: Bitmap): Int = bitmap.width * bitmap.height * 4 + + /** Utility method to track new bitmap creation */ + @JvmStatic fun noteNewBitmapCreated() = sOnNewBitmapRunnable.run() + + /** Returns the color associated with the attribute */ + @JvmStatic + fun getAttrColor(context: Context, attr: Int): Int = + context.obtainStyledAttributes(intArrayOf(attr)).use { it.getColor(0, 0) } + + /** Returns the alpha corresponding to the theme attribute {@param attr} */ + @JvmStatic + fun getFloat(context: Context, attr: Int, defValue: Float): Float = + context.obtainStyledAttributes(intArrayOf(attr)).use { it.getFloat(0, defValue) } + + /** + * Canvas extension function which runs the [block] after preserving the canvas transform using + * same/restore pair. + */ + inline fun Canvas.transformed(block: Canvas.() -> Unit) { + val saveCount = save() + block.invoke(this) + restoreToCount(saveCount) + } + + /** Resizes this path from [oldSize] to [newSize] as a new instance of Path. */ + @JvmStatic + fun Path.resize(oldSize: Int, newSize: Int): Path = + Path(this).apply { + transform( + Matrix().apply { + setRectToRect( + RectF(0f, 0f, oldSize.toFloat(), oldSize.toFloat()), + RectF(0f, 0f, newSize.toFloat(), newSize.toFloat()), + Matrix.ScaleToFit.CENTER, + ) + } + ) + } + + /** + * Resizes the canvas to that [bounds] align with [0, 0, [sizeX], [sizeY]] space and executes + * the [block]. It also scales down the drawing by [ICON_VISIBLE_AREA_FACTOR] to account for + * icon normalization. + */ + inline fun Canvas.resizeToContentSize( + bounds: Rect, + sizeX: Float, + sizeY: Float = sizeX, + block: Canvas.() -> Unit, + ) = transformed { + translate(bounds.left.toFloat(), bounds.top.toFloat()) + scale(bounds.width() / sizeX, bounds.height() / sizeY) + scale(ICON_VISIBLE_AREA_FACTOR, ICON_VISIBLE_AREA_FACTOR, sizeX / 2, sizeY / 2) + block.invoke(this) + } + + /** + * Generates a new [IconShape] for the [size] and the [shapePath] (in bounds [0, 0, [size], + * [size]] + */ + @JvmStatic + fun generateIconShape(size: Int, shapePath: Path): IconShape { + // Generate shadow layer: + // Based on adaptive icon drawing in BaseIconFactory + val offset = + max( + ceil((BLUR_FACTOR * size)).toInt(), + Math.round(size * (1 - ICON_VISIBLE_AREA_FACTOR) / 2), + ) + val shadowLayer = + BitmapRenderer.createHardwareBitmap(size, size) { canvas: Canvas -> + canvas.transformed { + canvas.translate(offset.toFloat(), offset.toFloat()) + val drawnPathSize = size - offset * 2 + val drawnPath = shapePath.resize(size, drawnPathSize) + ShadowGenerator(size).addPathShadow(drawnPath, canvas) + } + } + + val roundRectEstimation = RoundRectEstimator.estimateRadius(shapePath, size.toFloat()) + return IconShape( + pathSize = size, + path = shapePath, + shadowLayer = shadowLayer, + shapeRenderer = + when { + roundRectEstimation >= 1f -> CircleRenderer(size.toFloat() / 2) + roundRectEstimation >= 0f -> + RoundedRectRenderer(size.toFloat(), roundRectEstimation * size / 2) + else -> AlphaMaskRenderer(shapePath, size) + }, + ) + } + + /** Returns a color filter which is equivalent to [filter] x BlendModeFilter with [color] */ + fun getColorMultipliedFilter(color: Int, filter: ColorFilter?): ColorFilter? { + if (Color.alpha(color) == 0) return filter + if (filter == null) return BlendModeColorFilter(color, BlendMode.SRC_IN) + + return when { + filter is BlendModeColorFilter && filter.mode == BlendMode.SRC_IN -> + BlendModeColorFilter(compositeColors(filter.color, color), BlendMode.SRC_IN) + filter is ColorMatrixColorFilter -> { + val matrix = ColorMatrix().apply { filter.getColorMatrix(this) }.array + val components = IntArray(4) + for (i in 0..3) { + val s = 5 * i + components[i] = + (Color.red(color) * matrix[s] + + Color.green(color) * matrix[s + 1] + + Color.blue(color) * matrix[s + 2] + + Color.alpha(color) * matrix[s + 3] + + matrix[s + 4]) + .toInt() + .coerceIn(0, 255) + } + BlendModeColorFilter( + Color.argb(components[3], components[0], components[1], components[2]), + BlendMode.SRC_IN, + ) + } + // Don't know what this is, draw and find out + else -> { + val bitmap = + BitmapRenderer.createSoftwareBitmap(1, 1) { c -> + c.drawPaint( + Paint().also { + it.color = color + it.colorFilter = filter + } + ) + } + BlendModeColorFilter(bitmap.getPixel(0, 0), BlendMode.SRC_IN) + } + } + } +} diff --git a/iconloaderlib/src/com/android/launcher3/icons/IconNormalizer.java b/iconloaderlib/src/com/android/launcher3/icons/IconNormalizer.java index dc8d8b2..fc4cdde 100644 --- a/iconloaderlib/src/com/android/launcher3/icons/IconNormalizer.java +++ b/iconloaderlib/src/com/android/launcher3/icons/IconNormalizer.java @@ -16,230 +16,10 @@ package com.android.launcher3.icons; -import android.graphics.Bitmap; -import android.graphics.Canvas; -import android.graphics.Color; -import android.graphics.Rect; -import android.graphics.drawable.AdaptiveIconDrawable; -import android.graphics.drawable.Drawable; - -import androidx.annotation.NonNull; - -import java.nio.ByteBuffer; +import static com.android.launcher3.icons.ShadowGenerator.ICON_SCALE_FOR_SHADOWS; public class IconNormalizer { - // Ratio of icon visible area to full icon size for a square shaped icon - private static final float MAX_SQUARE_AREA_FACTOR = 375.0f / 576; - // Ratio of icon visible area to full icon size for a circular shaped icon - private static final float MAX_CIRCLE_AREA_FACTOR = 380.0f / 576; - - private static final float CIRCLE_AREA_BY_RECT = (float) Math.PI / 4; - - // Slope used to calculate icon visible area to full icon size for any generic shaped icon. - private static final float LINEAR_SCALE_SLOPE = - (MAX_CIRCLE_AREA_FACTOR - MAX_SQUARE_AREA_FACTOR) / (1 - CIRCLE_AREA_BY_RECT); - - private static final int MIN_VISIBLE_ALPHA = 40; - // Ratio of the diameter of an normalized circular icon to the actual icon size. - public static final float ICON_VISIBLE_AREA_FACTOR = 0.92f; - - private final int mMaxSize; - private final Bitmap mBitmap; - private final Canvas mCanvas; - private final byte[] mPixels; - - // for each y, stores the position of the leftmost x and the rightmost x - private final float[] mLeftBorder; - private final float[] mRightBorder; - private final Rect mBounds; - - /** package private **/ - public IconNormalizer(int iconBitmapSize) { - // Use twice the icon size as maximum size to avoid scaling down twice. - mMaxSize = iconBitmapSize * 2; - mBitmap = Bitmap.createBitmap(mMaxSize, mMaxSize, Bitmap.Config.ALPHA_8); - mCanvas = new Canvas(mBitmap); - mPixels = new byte[mMaxSize * mMaxSize]; - mLeftBorder = new float[mMaxSize]; - mRightBorder = new float[mMaxSize]; - mBounds = new Rect(); - } - - private static float getScale(float hullArea, float boundingArea, float fullArea) { - float hullByRect = hullArea / boundingArea; - float scaleRequired; - if (hullByRect < CIRCLE_AREA_BY_RECT) { - scaleRequired = MAX_CIRCLE_AREA_FACTOR; - } else { - scaleRequired = MAX_SQUARE_AREA_FACTOR + LINEAR_SCALE_SLOPE * (1 - hullByRect); - } - - float areaScale = hullArea / fullArea; - // Use sqrt of the final ratio as the images is scaled across both width and height. - return areaScale > scaleRequired ? (float) Math.sqrt(scaleRequired / areaScale) : 1; - } - - /** - * Returns the amount by which the {@param d} should be scaled (in both dimensions) so that it - * matches the design guidelines for a launcher icon. - * - * We first calculate the convex hull of the visible portion of the icon. - * This hull then compared with the bounding rectangle of the hull to find how closely it - * resembles a circle and a square, by comparing the ratio of the areas. Note that this is not an - * ideal solution but it gives satisfactory result without affecting the performance. - * - * This closeness is used to determine the ratio of hull area to the full icon size. - * Refer {@link #MAX_CIRCLE_AREA_FACTOR} and {@link #MAX_SQUARE_AREA_FACTOR} - */ - public synchronized float getScale(@NonNull Drawable d) { - if (d instanceof AdaptiveIconDrawable) { - return ICON_VISIBLE_AREA_FACTOR; - } - int width = d.getIntrinsicWidth(); - int height = d.getIntrinsicHeight(); - if (width <= 0 || height <= 0) { - width = width <= 0 || width > mMaxSize ? mMaxSize : width; - height = height <= 0 || height > mMaxSize ? mMaxSize : height; - } else if (width > mMaxSize || height > mMaxSize) { - int max = Math.max(width, height); - width = mMaxSize * width / max; - height = mMaxSize * height / max; - } - - mBitmap.eraseColor(Color.TRANSPARENT); - d.setBounds(0, 0, width, height); - d.draw(mCanvas); - - ByteBuffer buffer = ByteBuffer.wrap(mPixels); - buffer.rewind(); - mBitmap.copyPixelsToBuffer(buffer); - - // Overall bounds of the visible icon. - int topY = -1; - int bottomY = -1; - int leftX = mMaxSize + 1; - int rightX = -1; - - // Create border by going through all pixels one row at a time and for each row find - // the first and the last non-transparent pixel. Set those values to mLeftBorder and - // mRightBorder and use -1 if there are no visible pixel in the row. - - // buffer position - int index = 0; - // buffer shift after every row, width of buffer = mMaxSize - int rowSizeDiff = mMaxSize - width; - // first and last position for any row. - int firstX, lastX; - - for (int y = 0; y < height; y++) { - firstX = lastX = -1; - for (int x = 0; x < width; x++) { - if ((mPixels[index] & 0xFF) > MIN_VISIBLE_ALPHA) { - if (firstX == -1) { - firstX = x; - } - lastX = x; - } - index++; - } - index += rowSizeDiff; - - mLeftBorder[y] = firstX; - mRightBorder[y] = lastX; - - // If there is at least one visible pixel, update the overall bounds. - if (firstX != -1) { - bottomY = y; - if (topY == -1) { - topY = y; - } - - leftX = Math.min(leftX, firstX); - rightX = Math.max(rightX, lastX); - } - } - - if (topY == -1 || rightX == -1) { - // No valid pixels found. Do not scale. - return 1; - } - - convertToConvexArray(mLeftBorder, 1, topY, bottomY); - convertToConvexArray(mRightBorder, -1, topY, bottomY); - - // Area of the convex hull - float area = 0; - for (int y = 0; y < height; y++) { - if (mLeftBorder[y] <= -1) { - continue; - } - area += mRightBorder[y] - mLeftBorder[y] + 1; - } - - mBounds.left = leftX; - mBounds.right = rightX; - - mBounds.top = topY; - mBounds.bottom = bottomY; - - // Area of the rectangle required to fit the convex hull - float rectArea = (bottomY + 1 - topY) * (rightX + 1 - leftX); - return getScale(area, rectArea, width * height); - } - - /** - * Modifies {@param xCoordinates} to represent a convex border. Fills in all missing values - * (except on either ends) with appropriate values. - * @param xCoordinates map of x coordinate per y. - * @param direction 1 for left border and -1 for right border. - * @param topY the first Y position (inclusive) with a valid value. - * @param bottomY the last Y position (inclusive) with a valid value. - */ - private static void convertToConvexArray( - float[] xCoordinates, int direction, int topY, int bottomY) { - int total = xCoordinates.length; - // The tangent at each pixel. - float[] angles = new float[total - 1]; - - int first = topY; // First valid y coordinate - int last = -1; // Last valid y coordinate which didn't have a missing value - - float lastAngle = Float.MAX_VALUE; - - for (int i = topY + 1; i <= bottomY; i++) { - if (xCoordinates[i] <= -1) { - continue; - } - int start; - - if (lastAngle == Float.MAX_VALUE) { - start = first; - } else { - float currentAngle = (xCoordinates[i] - xCoordinates[last]) / (i - last); - start = last; - // If this position creates a concave angle, keep moving up until we find a - // position which creates a convex angle. - if ((currentAngle - lastAngle) * direction < 0) { - while (start > first) { - start --; - currentAngle = (xCoordinates[i] - xCoordinates[start]) / (i - start); - if ((currentAngle - angles[start]) * direction >= 0) { - break; - } - } - } - } - - // Reset from last check - lastAngle = (xCoordinates[i] - xCoordinates[start]) / (i - start); - // Update all the points from start. - for (int j = start; j < i; j++) { - angles[j] = lastAngle; - xCoordinates[j] = xCoordinates[start] + lastAngle * (j - start); - } - last = i; - } - } + public static final float ICON_VISIBLE_AREA_FACTOR = Math.min(0.92f, ICON_SCALE_FOR_SHADOWS); } diff --git a/iconloaderlib/src/com/android/launcher3/icons/IconProvider.java b/iconloaderlib/src/com/android/launcher3/icons/IconProvider.java index 66057e6..645de6f 100644 --- a/iconloaderlib/src/com/android/launcher3/icons/IconProvider.java +++ b/iconloaderlib/src/com/android/launcher3/icons/IconProvider.java @@ -16,18 +16,12 @@ package com.android.launcher3.icons; -import static android.content.Intent.ACTION_DATE_CHANGED; -import static android.content.Intent.ACTION_TIMEZONE_CHANGED; -import static android.content.Intent.ACTION_TIME_CHANGED; import static android.content.res.Resources.ID_NULL; import static android.graphics.drawable.AdaptiveIconDrawable.getExtraInsetFraction; import android.annotation.TargetApi; -import android.content.BroadcastReceiver; import android.content.ComponentName; import android.content.Context; -import android.content.Intent; -import android.content.IntentFilter; import android.content.pm.ApplicationInfo; import android.content.pm.ComponentInfo; import android.content.pm.PackageItemInfo; @@ -39,13 +33,7 @@ import android.graphics.drawable.Drawable; import android.graphics.drawable.InsetDrawable; import android.os.Build; -import android.os.Build.VERSION; -import android.os.Build.VERSION_CODES; import android.os.Bundle; -import android.os.Handler; -import android.os.Process; -import android.os.UserHandle; -import android.os.UserManager; import android.text.TextUtils; import android.util.Log; @@ -55,7 +43,6 @@ import com.android.launcher3.icons.cache.CachingLogic; import com.android.launcher3.util.ComponentKey; -import com.android.launcher3.util.SafeCloseable; import java.util.Calendar; import java.util.Objects; @@ -304,13 +291,6 @@ private static ComponentName parseComponentOrNull(Context context, int resId) { return TextUtils.isEmpty(cn) ? null : ComponentName.unflattenFromString(cn); } - /** - * Registers a callback to listen for various system dependent icon changes. - */ - public SafeCloseable registerIconChangeListener(IconChangeListener listener, Handler handler) { - return new IconChangeReceiver(listener, handler); - } - /** * Notifies the provider when an icon is loaded from cache */ @@ -327,7 +307,7 @@ public ThemeData(Resources resources, int resID) { mResID = resID; } - public Drawable loadPaddedDrawable() { + Drawable loadPaddedDrawable() { if (!"drawable".equals(mResources.getResourceTypeName(mResID))) { return null; } @@ -338,59 +318,4 @@ public Drawable loadPaddedDrawable() { return fg; } } - - private class IconChangeReceiver extends BroadcastReceiver implements SafeCloseable { - - private final IconChangeListener mCallback; - - IconChangeReceiver(IconChangeListener callback, Handler handler) { - mCallback = callback; - if (mCalendar != null || mClock != null) { - final IntentFilter filter = new IntentFilter(ACTION_TIMEZONE_CHANGED); - if (mCalendar != null) { - filter.addAction(Intent.ACTION_TIME_CHANGED); - filter.addAction(ACTION_DATE_CHANGED); - } - mContext.registerReceiver(this, filter, null, handler); - } - } - - @Override - public void onReceive(Context context, Intent intent) { - switch (intent.getAction()) { - case ACTION_TIMEZONE_CHANGED: - if (mClock != null) { - mCallback.onAppIconChanged(mClock.getPackageName(), Process.myUserHandle()); - } - // follow through - case ACTION_DATE_CHANGED: - case ACTION_TIME_CHANGED: - if (mCalendar != null) { - for (UserHandle user - : context.getSystemService(UserManager.class).getUserProfiles()) { - mCallback.onAppIconChanged(mCalendar.getPackageName(), user); - } - } - break; - } - } - - @Override - public void close() { - try { - mContext.unregisterReceiver(this); - } catch (Exception ignored) { } - } - } - - /** - * Listener for receiving icon changes - */ - public interface IconChangeListener { - - /** - * Called when the icon for a particular app changes - */ - void onAppIconChanged(String packageName, UserHandle user); - } } diff --git a/iconloaderlib/src/com/android/launcher3/icons/IconShape.kt b/iconloaderlib/src/com/android/launcher3/icons/IconShape.kt new file mode 100644 index 0000000..781711e --- /dev/null +++ b/iconloaderlib/src/com/android/launcher3/icons/IconShape.kt @@ -0,0 +1,51 @@ +/* + * Copyright (C) 2025 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.launcher3.icons + +import android.graphics.Bitmap +import android.graphics.Bitmap.createBitmap +import android.graphics.Color +import android.graphics.Path +import android.graphics.drawable.AdaptiveIconDrawable +import android.graphics.drawable.ColorDrawable +import com.android.launcher3.icons.ShapeRenderer.PathRenderer + +data class IconShape( + /** Size that [path] should be scaled to. */ + @JvmField val pathSize: Int, + /** Path for icon shape to be used as mask. Ensure this is scaled to [pathSize] */ + @JvmField val path: Path, + /** Shadow layer to draw behind icon. Should use the same shape and scale as [path] */ + @JvmField val shadowLayer: Bitmap, + /** Renderer for customizing how shapes are drawn to canvas */ + @JvmField val shapeRenderer: ShapeRenderer = PathRenderer(path), +) { + companion object { + private const val DEFAULT_PATH_SIZE = 100 + + // Placeholder that can be used if icon shape is not needed. + @JvmField + val EMPTY = + IconShape( + DEFAULT_PATH_SIZE, + AdaptiveIconDrawable(ColorDrawable(Color.WHITE), null) + .apply { setBounds(0, 0, DEFAULT_PATH_SIZE, DEFAULT_PATH_SIZE) } + .iconMask, + createBitmap(1, 1, Bitmap.Config.ARGB_8888).apply { eraseColor(Color.WHITE) }, + ) + } +} diff --git a/iconloaderlib/src/com/android/launcher3/icons/LuminanceComputer.kt b/iconloaderlib/src/com/android/launcher3/icons/LuminanceComputer.kt new file mode 100644 index 0000000..49131b6 --- /dev/null +++ b/iconloaderlib/src/com/android/launcher3/icons/LuminanceComputer.kt @@ -0,0 +1,305 @@ +/** + * Copyright (C) 2025 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.launcher3.icons + +import android.graphics.Bitmap +import android.util.Log +import androidx.annotation.FloatRange +import androidx.core.graphics.ColorUtils +import kotlin.math.abs + +/** The type of computation to use when computing the luminance of a drawable or a bitmap. */ +enum class ComputationType { + /** Compute the median luminance of a drawable or a bitmap. */ + MEDIAN, + + /** Compute the average luminance of a drawable or a bitmap. */ + AVERAGE, + + /** Compute the difference between the min and max luminance of a drawable or a bitmap. */ + SPREAD, +} + +/** Wrapper for the color space to use when computing the luminance. */ +interface ColorWrapper { + /** The luminance of the color, in the range [0, 1]. */ + var luminance: Double + + /** The color as an integer in the format of the color space. */ + fun toColorInt(): Int +} + +@JvmInline +value class LabColor(val data: DoubleArray) : ColorWrapper { + override var luminance: Double + get() = data[0] / 100 + set(value) { + data[0] = value * 100 + } + + override fun toColorInt(): Int = ColorUtils.LABToColor(data[0], data[1], data[2]) +} + +@JvmInline +value class HslColor(val data: FloatArray) : ColorWrapper { + override var luminance: Double + get() = data[2].toDouble() + set(value) { + data[2] = value.toFloat() + } + + override fun toColorInt(): Int = ColorUtils.HSLToColor(data) +} + +/** The color space to use when computing the luminance of a drawable or a bitmap. */ +enum class LuminanceColorSpace { + /** Use the HSL color space. */ + HSL, + + /** Use the LAB color space. */ + LAB, +} + +/** Class to compute the luminance of a drawable or a bitmap using the chosen color space. */ +class LuminanceComputer( + val colorSpace: LuminanceColorSpace, + val computationType: ComputationType, + private val options: Options = Options(), +) { + + /** + * Options for the luminance computer. + * + * @param ensureMinContrast If true, the resulting luminance ratio will always be the minimum + * contrast ratio passed into [adaptColorLuminance]. + * @param absoluteLuminanceDelta If true, the luminance delta will always be the absolute value + * of the luminance delta passed into [adaptColorLuminance], meaning that the luminance delta + * will always be positive and the foreground color will always be considered to be brighter + * than the background color. + */ + data class Options( + val ensureMinContrast: Boolean = ENABLED_CONTRAST_ADJUSTMENT, + val absoluteLuminanceDelta: Boolean = ENABLED_ABSOLUTE_LUMINANCE_DELTA, + ) + + /** + * Adapt a color to a different luminance level using the selected color space, and optionally + * adjust the contrast and absolute luminance delta. + * + * @param targetColor The color to adapt. + * @param basisColor The color to use as a basis for the luminance. + * @param luminanceDelta The luminance delta to use, which is the difference between the target + * and the basis luminance. + * @param minimumContrast The minimum contrast to use between the target and the basis color. + * @return The adapted color. + */ + fun adaptColorLuminance( + targetColor: Int, + basisColor: Int, + @FloatRange(from = -1.0, to = 1.0, toInclusive = true, fromInclusive = true) + luminanceDelta: Double, + minimumContrast: Double, + useAbsoluteLuminanceDelta: Boolean = options.absoluteLuminanceDelta, + ): Int { + if (luminanceDelta.isNaN()) { + return targetColor + } + + var localLuminanceDelta = + if (useAbsoluteLuminanceDelta) { + // get the absolute value of the luminance delta + abs(luminanceDelta).coerceAtLeast(DEFAULT_ABSOLUTE_LUMINANCE_DELTA) + } else { + luminanceDelta + } + + val mutatedColorWrapper = + mutateColorLuminance(targetColor, basisColor, localLuminanceDelta, minimumContrast) + return mutatedColorWrapper.toColorInt() + } + + private fun mutateColorLuminance( + targetColor: Int, + basisColor: Int, + luminanceDelta: Double, + minimumContrast: Double = 0.0, + ): ColorWrapper { + if (luminanceDelta.isNaN()) { + return colorToColorWrapper(targetColor) + } + + val targetColorWrapper = colorToColorWrapper(targetColor) + val basisColorWrapper = colorToColorWrapper(basisColor) + + val basisLuminance = basisColorWrapper.luminance + + // The target luminance should be between 0 and 1, so we need to clamp + // it to that range + var targetLuminance = (basisLuminance + luminanceDelta).coerceIn(0.0, 1.0) + + targetLuminance = + adjustLuminanceForContrast( + targetLuminance, + basisLuminance, + luminanceDelta, + minimumContrast, + ) + + targetColorWrapper.luminance = targetLuminance + + return targetColorWrapper + } + + /** + * Compute the luminance of a bitmap using the selected color space. + * + * @param bitmap The bitmap to compute the luminance of. + * @param scale if true, the bitmap is resized to [BITMAP_SAMPLE_SIZE] for color calculation + */ + @JvmOverloads + fun computeLuminance(bitmap: Bitmap, scale: Boolean = true): Double { + val bitmapHeight = bitmap.height + val bitmapWidth = bitmap.width + if (bitmapHeight == 0 || bitmapWidth == 0) { + Log.e(TAG, "Bitmap is null") + return Double.NaN + } + + val bitmapToProcess = + if (scale) { + Bitmap.createScaledBitmap(bitmap, BITMAP_SAMPLE_SIZE, BITMAP_SAMPLE_SIZE, true) + } else { + bitmap + } + + val processedWidth = bitmapToProcess.width + val processedHeight = bitmapToProcess.height + + val pixels = IntArray(processedWidth * processedHeight) + bitmapToProcess.getPixels( + /** pixels = */ + pixels, + /** offset = */ + 0, + /** stride = */ + processedWidth, + /** x = */ + 0, + /** y = */ + 0, + /** width = */ + processedWidth, + /** height = */ + processedHeight, + ) + val luminances = pixels.map { colorToColorWrapper(it).luminance } + + when (computationType) { + ComputationType.MEDIAN -> return luminances.sorted().median() + ComputationType.AVERAGE -> return luminances.average() + ComputationType.SPREAD -> return luminances.max() - luminances.min() + } + } + + // The minimum contrast is the ratio minimum ratio that should exist + // between the target and the basis luminance + private fun adjustLuminanceForContrast( + targetLuminance: Double, + basisLuminance: Double, + luminanceDelta: Double, + minimumContrast: Double, + ): Double { + if (!options.ensureMinContrast) return targetLuminance + + val currentContrast = targetLuminance - basisLuminance + if (currentContrast >= minimumContrast) return targetLuminance + + val contrastedTargetLuminance = basisLuminance + (luminanceDelta * minimumContrast) + return contrastedTargetLuminance.coerceIn(0.0, 1.0) + } + + private fun List.median(): Double { + if (isEmpty()) { + return Double.NaN + } + val size = this.size + return if (size % 2 == 0) { + (this[size / 2 - 1] + this[size / 2]) / 2 + } else { + this[size / 2] + } + } + + private fun List.average(): Double { + if (isEmpty()) { + return Double.NaN + } + return sum() / size + } + + // Update to return ColorWrapper + private fun colorToColorWrapper(color: Int): ColorWrapper { + return when (colorSpace) { + LuminanceColorSpace.HSL -> { + val hsl = FloatArray(3) + ColorUtils.colorToHSL(color, hsl) + HslColor(hsl) + } + LuminanceColorSpace.LAB -> { + val lab = DoubleArray(3) + ColorUtils.colorToLAB(color, lab) + LabColor(lab) + } + } + } + + companion object Factory { + const val TAG: String = "LuminanceComputer" + + // If true, the resulting luminance ratio will always be the + // minimum contrast ratio passed into adaptColor + const val ENABLED_CONTRAST_ADJUSTMENT = true + + // If true, the luminance delta will always be the absolute value + // of the luminance delta passed into adaptColor, meaning that + // the luminance delta will always be positive and the foreground + // color will always be considered to be brighter than the background + // color. + const val ENABLED_ABSOLUTE_LUMINANCE_DELTA = true + + // The size of bitmap to derive the luminance from + // eg: 64x64 + const val BITMAP_SAMPLE_SIZE = 64 + + // The default absolute luminance delta to use if the user does not + // specify one. Only valid when ENABLED_ABSOLUTE_LUMINANCE_DELTA is + // true. + const val DEFAULT_ABSOLUTE_LUMINANCE_DELTA = 0.1 + + @JvmStatic + @JvmOverloads + fun createDefaultLuminanceComputer( + computationType: ComputationType = ComputationType.AVERAGE + ): LuminanceComputer { + return LuminanceComputer( + LuminanceColorSpace.LAB, // Keep this as the default color space + computationType, + Options( + ensureMinContrast = ENABLED_CONTRAST_ADJUSTMENT, + absoluteLuminanceDelta = ENABLED_ABSOLUTE_LUMINANCE_DELTA, + ), + ) + } + } +} diff --git a/iconloaderlib/src/com/android/launcher3/icons/MonochromeIconFactory.java b/iconloaderlib/src/com/android/launcher3/icons/MonochromeIconFactory.java index e6ae124..d8eb9d8 100644 --- a/iconloaderlib/src/com/android/launcher3/icons/MonochromeIconFactory.java +++ b/iconloaderlib/src/com/android/launcher3/icons/MonochromeIconFactory.java @@ -17,6 +17,8 @@ import static android.graphics.Paint.FILTER_BITMAP_FLAG; +import static com.android.launcher3.icons.LuminanceComputer.createDefaultLuminanceComputer; + import android.annotation.TargetApi; import android.graphics.Bitmap; import android.graphics.Bitmap.Config; @@ -32,12 +34,11 @@ import android.graphics.Rect; import android.graphics.drawable.AdaptiveIconDrawable; import android.graphics.drawable.Drawable; +import android.graphics.drawable.InsetDrawable; import android.os.Build; import androidx.annotation.WorkerThread; -import com.android.launcher3.icons.mono.MonoIconThemeController.ClippedMonoDrawable; - import java.nio.ByteBuffer; /** @@ -55,17 +56,17 @@ public class MonochromeIconFactory extends Drawable { private final byte[] mPixels; private final int mBitmapSize; - private final int mEdgePixelLength; private final Paint mDrawPaint; private final Rect mSrcRect; + private double mLuminanceDiff = Double.NaN; + public MonochromeIconFactory(int iconBitmapSize) { float extraFactor = AdaptiveIconDrawable.getExtraInsetFraction(); float viewPortScale = 1 / (1 + 2 * extraFactor); mBitmapSize = Math.round(iconBitmapSize * 2 * viewPortScale); mPixels = new byte[mBitmapSize * mBitmapSize]; - mEdgePixelLength = mBitmapSize * (mBitmapSize - iconBitmapSize) / 2; mFlatBitmap = Bitmap.createBitmap(mBitmapSize, mBitmapSize, Config.ARGB_8888); mFlatCanvas = new Canvas(mFlatBitmap); @@ -96,23 +97,56 @@ private void drawDrawable(Drawable drawable) { } } + /** + * Kept to layout lib compilation + * @deprecated use {@link #wrap(AdaptiveIconDrawable)} instead + */ + @Deprecated + public Drawable wrap(AdaptiveIconDrawable icon, Path unused) { + return wrap(icon); + } + /** * Creates a monochrome version of the provided drawable */ @WorkerThread - public Drawable wrap(AdaptiveIconDrawable icon, Path shapePath) { + public Drawable wrap(AdaptiveIconDrawable icon) { mFlatCanvas.drawColor(Color.BLACK); - drawDrawable(icon.getBackground()); - drawDrawable(icon.getForeground()); + Drawable bg = icon.getBackground(); + Drawable fg = icon.getForeground(); + if (bg != null && fg != null) { + LuminanceComputer computer = createDefaultLuminanceComputer(); + // Calculate foreground luminance on black first to account for any transparent pixels + drawDrawable(fg); + double fgLuminance = computer.computeLuminance(mFlatBitmap); + + // Start drawing from scratch and calculate background luminance + mFlatCanvas.drawColor(Color.BLACK); + drawDrawable(bg); + double bgLuminance = computer.computeLuminance(mFlatBitmap); + + drawDrawable(fg); + mLuminanceDiff = fgLuminance - bgLuminance; + } else { + // We do not have separate layer information. + // Try to calculate everything from a single layer + drawDrawable(bg); + drawDrawable(fg); + + LuminanceComputer computer = createDefaultLuminanceComputer(ComputationType.SPREAD); + mLuminanceDiff = computer.computeLuminance(mFlatBitmap, /* scale= */ true); + } generateMono(); - return new ClippedMonoDrawable(this, shapePath); + return new InsetDrawable(this, -AdaptiveIconDrawable.getExtraInsetFraction()); + } + + public double getLuminanceDiff() { + return mLuminanceDiff; } @WorkerThread private void generateMono() { mAlphaCanvas.drawBitmap(mFlatBitmap, 0, 0, mCopyPaint); - - // Scale the end points: ByteBuffer buffer = ByteBuffer.wrap(mPixels); buffer.rewind(); mAlphaBitmap.copyPixelsToBuffer(buffer); @@ -128,22 +162,10 @@ private void generateMono() { // rescale pixels to increase contrast float range = max - min; - // In order to check if the colors should be flipped, we just take the average color - // of top and bottom edge which should correspond to be background color. If the edge - // colors have more opacity, we flip the colors; - int sum = 0; - for (int i = 0; i < mEdgePixelLength; i++) { - sum += (mPixels[i] & 0xFF); - sum += (mPixels[mPixels.length - 1 - i] & 0xFF); - } - float edgeAverage = sum / (mEdgePixelLength * 2f); - float edgeMapped = (edgeAverage - min) / range; - boolean flipColor = edgeMapped > .5f; - for (int i = 0; i < mPixels.length; i++) { int p = mPixels[i] & 0xFF; int p2 = Math.round((p - min) * 0xFF / range); - mPixels[i] = flipColor ? (byte) (255 - p2) : (byte) (p2); + mPixels[i] = (byte) (p2); } // Second phase of processing, aimed on increasing the contrast diff --git a/iconloaderlib/src/com/android/launcher3/icons/PlaceHolderDrawableDelegate.kt b/iconloaderlib/src/com/android/launcher3/icons/PlaceHolderDrawableDelegate.kt new file mode 100644 index 0000000..e7b4f6c --- /dev/null +++ b/iconloaderlib/src/com/android/launcher3/icons/PlaceHolderDrawableDelegate.kt @@ -0,0 +1,94 @@ +/* + * Copyright (C) 2018 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.launcher3.icons + +import android.animation.Animator +import android.animation.AnimatorListenerAdapter +import android.animation.ValueAnimator +import android.content.Context +import android.graphics.Canvas +import android.graphics.Color +import android.graphics.Paint +import android.graphics.PorterDuff.Mode.SRC_ATOP +import android.graphics.PorterDuffColorFilter +import android.graphics.Rect +import android.graphics.drawable.Drawable +import androidx.core.graphics.ColorUtils +import com.android.launcher3.icons.FastBitmapDrawableDelegate.DelegateFactory +import com.android.launcher3.icons.GraphicsUtils.getAttrColor +import com.android.launcher3.icons.GraphicsUtils.resizeToContentSize + +/** Subclass which draws a placeholder icon when the actual icon is not yet loaded */ +class PlaceHolderDrawableDelegate(info: BitmapInfo, paint: Paint, loadingColor: Int) : + FastBitmapDrawableDelegate { + + private val fillColor = ColorUtils.compositeColors(loadingColor, info.color) + + init { + paint.color = fillColor + } + + override fun drawContent( + info: BitmapInfo, + iconShape: IconShape, + canvas: Canvas, + bounds: Rect, + paint: Paint, + ) { + canvas.resizeToContentSize(bounds, iconShape.pathSize.toFloat()) { + iconShape.shapeRenderer.render(this, paint) + } + } + + /** Updates this placeholder to `newIcon` with animation. */ + fun animateIconUpdate(newIcon: Drawable) { + val placeholderColor = fillColor + val originalAlpha = Color.alpha(placeholderColor) + + ValueAnimator.ofInt(originalAlpha, 0) + .apply { + duration = 375L + addUpdateListener { + newIcon.colorFilter = + PorterDuffColorFilter( + ColorUtils.setAlphaComponent(placeholderColor, it.animatedValue as Int), + SRC_ATOP, + ) + } + addListener( + object : AnimatorListenerAdapter() { + override fun onAnimationEnd(animation: Animator) { + newIcon.colorFilter = null + } + } + ) + } + .start() + } + + class PlaceHolderDelegateFactory(context: Context) : DelegateFactory { + private val loadingColor = getAttrColor(context, R.attr.loadingIconColor) + + override fun newDelegate( + bitmapInfo: BitmapInfo, + iconShape: IconShape, + paint: Paint, + host: FastBitmapDrawable, + ): FastBitmapDrawableDelegate { + return PlaceHolderDrawableDelegate(bitmapInfo, paint, loadingColor) + } + } +} diff --git a/iconloaderlib/src/com/android/launcher3/icons/RoundRectEstimator.kt b/iconloaderlib/src/com/android/launcher3/icons/RoundRectEstimator.kt new file mode 100644 index 0000000..c682c62 --- /dev/null +++ b/iconloaderlib/src/com/android/launcher3/icons/RoundRectEstimator.kt @@ -0,0 +1,90 @@ +/* + * Copyright (C) 2025 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.launcher3.icons + +import android.graphics.Matrix +import android.graphics.Path +import android.graphics.Rect +import android.graphics.Region +import android.graphics.RegionIterator + +/** Utility class to estimate round rect parameters from a [Path] */ +object RoundRectEstimator { + + internal const val AREA_CALC_SIZE = 1000 + // .1% error margin + internal const val AREA_DIFF_THRESHOLD = AREA_CALC_SIZE * AREA_CALC_SIZE / 1000 + + internal const val ITERATION_COUNT = 20 + + fun getArea(r: Region): Int { + val itr = RegionIterator(r) + var area = 0 + val tempRect = Rect() + while (itr.next(tempRect)) { + area += tempRect.width() * tempRect.height() + } + return area + } + + /** + * For the provided [path] in bounds [0, 0, [size], [size]], tries to estimate the radius of the + * rounded rectangle which closely resembles this path. Returns the radius as a factor of + * half-[size] or -1 if the provided path can't be estimated as a rounded rectangle. + */ + fun estimateRadius(path: Path, size: Float): Float { + val fullRegion = Region(0, 0, AREA_CALC_SIZE, AREA_CALC_SIZE) + + val tmpPath = Path() + path.transform( + Matrix().apply { setScale(AREA_CALC_SIZE / size, AREA_CALC_SIZE / size) }, + tmpPath, + ) + val iconRegion = Region().apply { setPath(tmpPath, fullRegion) } + + val shapePath = Path() + val shapeRegion = Region() + + var minAreaDiff = Int.MAX_VALUE + var radiusFactor = -1f + // iterate over radius factor + for (f in 0..ITERATION_COUNT) { + shapePath.reset() + val currentRadiusFactor = f.toFloat() / ITERATION_COUNT + val radius = currentRadiusFactor * AREA_CALC_SIZE / 2 + shapePath.addRoundRect( + 0f, + 0f, + AREA_CALC_SIZE.toFloat(), + AREA_CALC_SIZE.toFloat(), + radius, + radius, + Path.Direction.CW, + ) + shapeRegion.setPath(shapePath, fullRegion) + shapeRegion.op(iconRegion, Region.Op.XOR) + + val rectArea = getArea(shapeRegion) + if (rectArea < minAreaDiff) { + minAreaDiff = rectArea + radiusFactor = currentRadiusFactor + } + } + + return if (minAreaDiff < AREA_DIFF_THRESHOLD) radiusFactor else -1f + } +} diff --git a/iconloaderlib/src/com/android/launcher3/icons/ShadowGenerator.java b/iconloaderlib/src/com/android/launcher3/icons/ShadowGenerator.java index 5cd05c5..4d22aec 100644 --- a/iconloaderlib/src/com/android/launcher3/icons/ShadowGenerator.java +++ b/iconloaderlib/src/com/android/launcher3/icons/ShadowGenerator.java @@ -81,7 +81,7 @@ public synchronized void drawShadow(Bitmap icon, Canvas out) { } /** package private **/ - void addPathShadow(Path path, Canvas out) { + public void addPathShadow(Path path, Canvas out) { if (ENABLE_SHADOWS) { mDrawPaint.setMaskFilter(mDefaultBlurMaskFilter); diff --git a/iconloaderlib/src/com/android/launcher3/icons/ShapeRenderer.kt b/iconloaderlib/src/com/android/launcher3/icons/ShapeRenderer.kt new file mode 100644 index 0000000..d368ec8 --- /dev/null +++ b/iconloaderlib/src/com/android/launcher3/icons/ShapeRenderer.kt @@ -0,0 +1,71 @@ +/* + * Copyright (C) 2025 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.launcher3.icons + +import android.graphics.Canvas +import android.graphics.Paint +import android.graphics.Paint.ANTI_ALIAS_FLAG +import android.graphics.Path +import com.android.launcher3.icons.BitmapRenderer.createSoftwareBitmap + +sealed interface ShapeRenderer { + /** + * Draws shape to the canvas using the provided parameters. This is used in draw methods, so + * operations should be fast, with no new objects initialized. + * + * @param canvas Canvas to draw shape on. + * @param paint Paint to draw on the Canvas with. + */ + fun render(canvas: Canvas, paint: Paint) + + /** A renderer which draws a circle of radius [r] */ + class CircleRenderer(private val r: Float) : ShapeRenderer { + + override fun render(canvas: Canvas, paint: Paint) { + canvas.drawCircle(r, r, r, paint) + } + } + + /** A renderer which draws a rounded rect in [0, 0, [size], [size]] of corner radius [r] */ + class RoundedRectRenderer(private val size: Float, private val r: Float) : ShapeRenderer { + override fun render(canvas: Canvas, paint: Paint) { + canvas.drawRoundRect(0f, 0f, size, size, r, r, paint) + } + } + + /** A renderer which draws the [path] */ + class PathRenderer(private val path: Path) : ShapeRenderer { + override fun render(canvas: Canvas, paint: Paint) { + canvas.drawPath(path, paint) + } + } + + /** + * A renderer which draws the a alpha bitmap mask. This is preferred over [PathRenderer] if the + * max rendering size is known + */ + class AlphaMaskRenderer(path: Path, size: Int) : ShapeRenderer { + + private val mask = + createSoftwareBitmap(size, size) { it.drawPath(path, Paint(ANTI_ALIAS_FLAG)) } + .extractAlpha() + + override fun render(canvas: Canvas, paint: Paint) { + canvas.drawBitmap(mask, 0f, 0f, paint) + } + } +} diff --git a/iconloaderlib/src/com/android/launcher3/icons/ThemedBitmap.kt b/iconloaderlib/src/com/android/launcher3/icons/ThemedBitmap.kt index 77b34ac..cee9aad 100644 --- a/iconloaderlib/src/com/android/launcher3/icons/ThemedBitmap.kt +++ b/iconloaderlib/src/com/android/launcher3/icons/ThemedBitmap.kt @@ -18,14 +18,15 @@ package com.android.launcher3.icons import android.content.Context import android.graphics.drawable.AdaptiveIconDrawable +import com.android.launcher3.icons.FastBitmapDrawableDelegate.DelegateFactory import com.android.launcher3.icons.cache.CachingLogic import com.android.launcher3.util.ComponentKey /** Represents a themed version of a BitmapInfo */ interface ThemedBitmap { - /** Creates a new Drawable */ - fun newDrawable(info: BitmapInfo, context: Context): FastBitmapDrawable + /** Creates a new [DelegateFactory] based on the [context] */ + fun newDelegateFactory(info: BitmapInfo, context: Context): DelegateFactory fun serialize(): ByteArray @@ -35,7 +36,8 @@ interface ThemedBitmap { /** ThemedBitmap to be used when theming is not supported for a particular bitmap */ val NOT_SUPPORTED = object : ThemedBitmap { - override fun newDrawable(info: BitmapInfo, context: Context) = info.newIcon(context) + override fun newDelegateFactory(info: BitmapInfo, context: Context) = + info.delegateFactory override fun serialize() = ByteArray(0) } @@ -51,10 +53,10 @@ interface IconThemeController { info: BitmapInfo, factory: BaseIconFactory, sourceHint: SourceHint? = null, - ): ThemedBitmap? + ): ThemedBitmap fun decode( - data: ByteArray, + bytes: ByteArray, info: BitmapInfo, factory: BaseIconFactory, sourceHint: SourceHint, diff --git a/iconloaderlib/src/com/android/launcher3/icons/UserBadgeDrawable.java b/iconloaderlib/src/com/android/launcher3/icons/UserBadgeDrawable.java index 07e12ef..ae9da70 100644 --- a/iconloaderlib/src/com/android/launcher3/icons/UserBadgeDrawable.java +++ b/iconloaderlib/src/com/android/launcher3/icons/UserBadgeDrawable.java @@ -25,11 +25,8 @@ import android.graphics.ColorFilter; import android.graphics.ColorMatrix; import android.graphics.ColorMatrixColorFilter; -import android.graphics.Matrix; import android.graphics.Paint; -import android.graphics.Path; import android.graphics.Rect; -import android.graphics.RectF; import android.graphics.drawable.Drawable; import android.graphics.drawable.DrawableWrapper; @@ -60,24 +57,12 @@ public class UserBadgeDrawable extends DrawableWrapper { private final int mBaseColor; private final int mBgColor; private boolean mShouldDrawBackground = true; - @Nullable private Path mShape; - - private Matrix mShapeMatrix = new Matrix(); @VisibleForTesting public final boolean mIsThemed; - public UserBadgeDrawable(Context context, int badgeRes, int colorRes, boolean isThemed, - @Nullable Path shape) { + public UserBadgeDrawable(Context context, int badgeRes, int colorRes, boolean isThemed) { super(context.getDrawable(badgeRes)); - mShape = shape; - mShapeMatrix = new Matrix(); - if (mShape != null) { - mShapeMatrix.setRectToRect(new RectF(0f, 0f, 100f, 100f), - new RectF(0f, 0f, CENTER * 2, CENTER * 2), - Matrix.ScaleToFit.CENTER); - mShape.transform(mShapeMatrix); - } mIsThemed = isThemed; if (isThemed) { mutate(); @@ -108,17 +93,9 @@ public void draw(@NonNull Canvas canvas) { canvas.scale(b.width() / VIEWPORT_SIZE, b.height() / VIEWPORT_SIZE); mPaint.setColor(blendDrawableAlpha(SHADOW_COLOR)); - if (mShape != null) { - canvas.drawPath(mShape, mPaint); - } else { - canvas.drawCircle(CENTER, CENTER + SHADOW_OFFSET_Y, SHADOW_RADIUS, mPaint); - } + canvas.drawCircle(CENTER, CENTER + SHADOW_OFFSET_Y, SHADOW_RADIUS, mPaint); mPaint.setColor(blendDrawableAlpha(mBgColor)); - if (mShape != null) { - canvas.drawPath(mShape, mPaint); - } else { - canvas.drawCircle(CENTER, CENTER, BG_RADIUS, mPaint); - } + canvas.drawCircle(CENTER, CENTER, BG_RADIUS, mPaint); canvas.restoreToCount(saveCount); } super.draw(canvas); diff --git a/iconloaderlib/src/com/android/launcher3/icons/cache/BaseIconCache.kt b/iconloaderlib/src/com/android/launcher3/icons/cache/BaseIconCache.kt index 0e4544e..5157698 100644 --- a/iconloaderlib/src/com/android/launcher3/icons/cache/BaseIconCache.kt +++ b/iconloaderlib/src/com/android/launcher3/icons/cache/BaseIconCache.kt @@ -24,13 +24,12 @@ import android.content.pm.LauncherApps import android.content.pm.PackageManager import android.content.pm.PackageManager.NameNotFoundException import android.database.Cursor -import android.database.sqlite.SQLiteDatabase -import android.database.sqlite.SQLiteException import android.database.sqlite.SQLiteReadOnlyDatabaseException import android.graphics.Bitmap import android.graphics.Bitmap.Config.HARDWARE import android.graphics.BitmapFactory import android.graphics.BitmapFactory.Options +import android.graphics.drawable.BitmapDrawable import android.graphics.drawable.Drawable import android.os.Handler import android.os.Looper @@ -42,7 +41,6 @@ import android.util.SparseArray import androidx.annotation.VisibleForTesting import androidx.annotation.WorkerThread import com.android.launcher3.Flags -import com.android.systemui.shared.Flags.extendibleThemeManager import com.android.launcher3.icons.BaseIconFactory import com.android.launcher3.icons.BaseIconFactory.IconOptions import com.android.launcher3.icons.BitmapInfo @@ -55,6 +53,7 @@ import com.android.launcher3.icons.cache.CacheLookupFlag.Companion.DEFAULT_LOOKU import com.android.launcher3.util.ComponentKey import com.android.launcher3.util.FlagOp import com.android.launcher3.util.SQLiteCacheHelper +import com.android.systemui.shared.Flags.extendibleThemeManager import java.util.function.Supplier import kotlin.collections.MutableMap.MutableEntry @@ -95,7 +94,7 @@ constructor( @JvmField val workerHandler = Handler(bgLooper) - @JvmField protected var iconDb = IconDB(context, dbFileName, iconPixelSize) + @JvmField protected var iconDb = createIconDb(iconPixelSize) private var defaultIcon: BitmapInfo? = null private val userFlagOpMap = SparseArray() @@ -135,7 +134,7 @@ constructor( userFlagOpMap.clear() iconDb.clear() iconDb.close() - iconDb = IconDB(context, dbFileName, iconPixelSize) + iconDb = createIconDb(iconPixelSize) cache.clear() } catch (e: SQLiteReadOnlyDatabaseException) { // This is known to happen during repeated backup and restores, if the Launcher is in @@ -192,8 +191,15 @@ constructor( val index = userFormatString.indexOfKey(key) var format: String? if (index < 0) { - format = packageManager.getUserBadgedLabel(IDENTITY_FORMAT_STRING, user).toString() - if (TextUtils.equals(IDENTITY_FORMAT_STRING, format)) { + try { + format = packageManager.getUserBadgedLabel(IDENTITY_FORMAT_STRING, user).toString() + if (TextUtils.equals(IDENTITY_FORMAT_STRING, format)) { + format = null + } + } catch (e: Exception) { + // Its possible that the caller may have an outdated cached user specific-entry. + // For eg, if a user was removed but that event has not propagated to the client yet + Log.e(TAG, "failed to access private profile data", e) format = null } userFormatString.put(key, format) @@ -217,7 +223,7 @@ constructor( // Icon can't be loaded from cachingLogic, which implies alternative icon was loaded // (e.g. fallback icon, default icon). So we drop here since there's no point in caching // an empty entry. - if (bitmapInfo.isNullOrLowRes || isDefaultIcon(bitmapInfo, user)) { + if (bitmapInfo.isLowRes || isDefaultIcon(bitmapInfo, user)) { return } val entryTitle = @@ -385,8 +391,8 @@ constructor( iconFactory.use { li -> entry.bitmap = li.createBadgedIconBitmap( - li.createShapedAdaptiveIcon(icon), - IconOptions().setUser(user), + BitmapDrawable(icon), + IconOptions().setUser(user).assumeFullBleedIcon(true), ) } } @@ -488,28 +494,22 @@ constructor( lookupFlags: CacheLookupFlag, cachingLogic: CachingLogic<*>, ): Boolean { - var c: Cursor? = null Trace.beginSection("loadIconIndividually") try { - c = - iconDb.query( - lookupFlags.toLookupColumns(), - "$COLUMN_COMPONENT = ? AND $COLUMN_USER = ?", - arrayOf( - cacheKey.componentName.flattenToString(), - getSerialNumberForUser(cacheKey.user).toString(), - ), - ) - if (c.moveToNext()) { - return updateTitleAndIconLocked(cacheKey, entry, c, lookupFlags, cachingLogic) + return iconDb.querySingleEntry( + lookupFlags.toLookupColumns(), + "$COLUMN_COMPONENT = ? AND $COLUMN_USER = ?", + arrayOf( + cacheKey.componentName.flattenToString(), + getSerialNumberForUser(cacheKey.user).toString(), + ), + false, + ) { + updateTitleAndIconLocked(cacheKey, entry, it, lookupFlags, cachingLogic) } - } catch (e: SQLiteException) { - Log.d(TAG, "Error reading icon cache", e) } finally { - c?.close() Trace.endSection() } - return false } private fun updateTitleAndIconLocked( @@ -547,6 +547,7 @@ constructor( Options().apply { inPreferredConfig = HARDWARE }, )!!, entry.bitmap.color, + iconFactory.use { it.defaultIconShape }, ) } catch (e: Exception) { return false @@ -554,26 +555,35 @@ constructor( if (!extendibleThemeManager() || lookupFlags.hasThemeIcon()) { // Always set a non-null theme bitmap if theming was requested - entry.bitmap.themedBitmap = ThemedBitmap.NOT_SUPPORTED + entry.bitmap = entry.bitmap.copy(themedBitmap = ThemedBitmap.NOT_SUPPORTED) iconFactory.use { factory -> val themeController = factory.themeController val monoIconData = c.getBlob(INDEX_MONO_ICON) if (themeController != null && monoIconData != null) { - entry.bitmap.themedBitmap = - themeController.decode( - data = monoIconData, - info = entry.bitmap, - factory = factory, - sourceHint = - SourceHint(cacheKey, logic, c.getString(INDEX_FRESHNESS_ID)), + entry.bitmap = + entry.bitmap.copy( + themedBitmap = + themeController.decode( + bytes = monoIconData, + info = entry.bitmap, + factory = factory, + sourceHint = + SourceHint( + cacheKey, + logic, + c.getString(INDEX_FRESHNESS_ID), + ), + ) ) } } } } - entry.bitmap.flags = c.getInt(INDEX_FLAGS) - entry.bitmap = entry.bitmap.withFlags(getUserFlagOpLocked(cacheKey.user)) + entry.bitmap = + entry.bitmap.copy( + flags = getUserFlagOpLocked(cacheKey.user).apply(c.getInt(INDEX_FLAGS)) + ) iconProvider.notifyIconLoaded(entry.bitmap, cacheKey, logic) return true } @@ -615,31 +625,26 @@ constructor( Log.d(TAG, message, e) } - /** Cache class to store the actual entries on disk */ - class IconDB(context: Context, dbFileName: String?, iconPixelSize: Int) : + /** Creates a cache class to store the actual entries on disk */ + private fun createIconDb(iconPixelSize: Int) = SQLiteCacheHelper( context, dbFileName, (RELEASE_VERSION shl 16) + iconPixelSize, TABLE_NAME, ) { - - override fun onCreateTable(db: SQLiteDatabase) { - db.execSQL( - ("CREATE TABLE IF NOT EXISTS $TABLE_NAME (" + - "$COLUMN_COMPONENT TEXT NOT NULL, " + - "$COLUMN_USER INTEGER NOT NULL, " + - "$COLUMN_FRESHNESS_ID TEXT, " + - "$COLUMN_ICON BLOB, " + - "$COLUMN_MONO_ICON BLOB, " + - "$COLUMN_ICON_COLOR INTEGER NOT NULL DEFAULT 0, " + - "$COLUMN_FLAGS INTEGER NOT NULL DEFAULT 0, " + - "$COLUMN_LABEL TEXT, " + - "PRIMARY KEY ($COLUMN_COMPONENT, $COLUMN_USER) " + - ");") - ) + "CREATE TABLE IF NOT EXISTS $TABLE_NAME (" + + "$COLUMN_COMPONENT TEXT NOT NULL, " + + "$COLUMN_USER INTEGER NOT NULL, " + + "$COLUMN_FRESHNESS_ID TEXT, " + + "$COLUMN_ICON BLOB, " + + "$COLUMN_MONO_ICON BLOB, " + + "$COLUMN_ICON_COLOR INTEGER NOT NULL DEFAULT 0, " + + "$COLUMN_FLAGS INTEGER NOT NULL DEFAULT 0, " + + "$COLUMN_LABEL TEXT, " + + "PRIMARY KEY ($COLUMN_COMPONENT, $COLUMN_USER) " + + ");" } - } companion object { protected const val TAG = "BaseIconCache" @@ -657,7 +662,9 @@ constructor( ComponentKey(ComponentName(packageName, packageName + EMPTY_CLASS_NAME), user) // Ensures themed bitmaps in the icon cache are invalidated - @JvmField val RELEASE_VERSION = if (Flags.enableLauncherIconShapes()) 11 else 10 + // LINT.IfChange(cache_release_version) + @JvmField val RELEASE_VERSION = if (Flags.enableLauncherIconShapes()) 14 else 12 + // LINT.ThenChange() @JvmField val TABLE_NAME = "icons" @JvmField val COLUMN_ROWID = "rowid" @@ -707,8 +714,7 @@ constructor( when { !extendibleThemeManager() -> this flag.useLowRes() -> BitmapInfo.of(LOW_RES_ICON, color) - !flag.hasThemeIcon() && themedBitmap != null -> - clone().apply { themedBitmap = null } + !flag.hasThemeIcon() && themedBitmap != null -> copy(themedBitmap = null) else -> this } } diff --git a/iconloaderlib/src/com/android/launcher3/icons/cache/LauncherActivityCachingLogic.kt b/iconloaderlib/src/com/android/launcher3/icons/cache/LauncherActivityCachingLogic.kt index c7dd470..ed7f66b 100644 --- a/iconloaderlib/src/com/android/launcher3/icons/cache/LauncherActivityCachingLogic.kt +++ b/iconloaderlib/src/com/android/launcher3/icons/cache/LauncherActivityCachingLogic.kt @@ -46,14 +46,16 @@ object LauncherActivityCachingLogic : CachingLogic { info: LauncherActivityInfo, ): BitmapInfo { cache.iconFactory.use { li -> - val iconOptions: IconOptions = IconOptions().setUser(info.user) - iconOptions - .setIsArchived( - useNewIconForArchivedApps() && - VERSION.SDK_INT >= 35 && - info.activityInfo.isArchived - ) - .setSourceHint(getSourceHint(info, cache)) + val iconOptions: IconOptions = + IconOptions() + .setUser(info.user) + .assumeFullBleedIcon( + // b/358123888: Pre-archived apps can have BitmapDrawables without insets + useNewIconForArchivedApps() && + VERSION.SDK_INT >= 35 && + info.activityInfo.isArchived + ) + .setSourceHint(getSourceHint(info, cache)) val iconDrawable = cache.iconProvider.getIcon(info.activityInfo, li.fullResIconDpi) if (context.packageManager.isDefaultApplicationIcon(iconDrawable)) { Log.w( diff --git a/iconloaderlib/src/com/android/launcher3/icons/mono/MonoIconThemeController.kt b/iconloaderlib/src/com/android/launcher3/icons/mono/MonoIconThemeController.kt index 8af8fd9..e2ed92d 100644 --- a/iconloaderlib/src/com/android/launcher3/icons/mono/MonoIconThemeController.kt +++ b/iconloaderlib/src/com/android/launcher3/icons/mono/MonoIconThemeController.kt @@ -24,20 +24,18 @@ import android.graphics.Bitmap.Config.HARDWARE import android.graphics.BlendMode.SRC_IN import android.graphics.BlendModeColorFilter import android.graphics.Canvas -import android.graphics.Color -import android.graphics.Path -import android.graphics.Rect import android.graphics.drawable.AdaptiveIconDrawable +import android.graphics.drawable.AdaptiveIconDrawable.getExtraInsetFraction import android.graphics.drawable.BitmapDrawable import android.graphics.drawable.ColorDrawable import android.graphics.drawable.Drawable import android.graphics.drawable.InsetDrawable +import android.graphics.drawable.LayerDrawable import android.os.Build import com.android.launcher3.Flags import com.android.launcher3.icons.BaseIconFactory -import com.android.launcher3.icons.BaseIconFactory.MODE_ALPHA import com.android.launcher3.icons.BitmapInfo -import com.android.launcher3.icons.IconNormalizer.ICON_VISIBLE_AREA_FACTOR +import com.android.launcher3.icons.ClockDrawableWrapper.ClockAnimationInfo import com.android.launcher3.icons.IconThemeController import com.android.launcher3.icons.MonochromeIconFactory import com.android.launcher3.icons.SourceHint @@ -47,7 +45,7 @@ import java.nio.ByteBuffer @TargetApi(Build.VERSION_CODES.TIRAMISU) class MonoIconThemeController( private val shouldForceThemeIcon: Boolean = false, - private val colorProvider: (Context) -> IntArray = ThemedIconDrawable.Companion::getColors, + private val colorProvider: (Context) -> IntArray = ThemedIconDelegate.Companion::getColors, ) : IconThemeController { override val themeID = "with-theme" @@ -58,65 +56,84 @@ class MonoIconThemeController( info: BitmapInfo, factory: BaseIconFactory, sourceHint: SourceHint?, - ): ThemedBitmap? { - val mono = - getMonochromeDrawable( - icon, - info, - factory.getShapePath(icon, Rect(0, 0, info.icon.width, info.icon.height)), - sourceHint?.isFileDrawable ?: false, - shouldForceThemeIcon, - ) + ): ThemedBitmap { + val currentDelegateFactory = info.delegateFactory + if (currentDelegateFactory is ClockAnimationInfo) { + val fullDrawable = currentDelegateFactory.baseDrawableState.newDrawable() + val monoDrawable = (fullDrawable as? AdaptiveIconDrawable)?.monochrome?.mutate() + + if (monoDrawable is LayerDrawable) { + return ClockThemedBitmap( + currentDelegateFactory.copy( + baseDrawableState = AdaptiveIconDrawable(null, monoDrawable).constantState!! + ), + colorProvider, + ) + } else { + return ThemedBitmap.NOT_SUPPORTED + } + } + + val mono = icon.monochrome if (mono != null) { return MonoThemedBitmap( - factory.createIconBitmap(mono, ICON_VISIBLE_AREA_FACTOR, MODE_ALPHA), - factory.whiteShadowLayer, + InsetDrawable(mono, -getExtraInsetFraction()).toAlphaBitmap(factory.iconBitmapSize), colorProvider, ) } - return null - } - /** - * Returns a monochromatic version of the given drawable or null, if it is not supported - * - * @param base the original icon - */ - private fun getMonochromeDrawable( - base: AdaptiveIconDrawable, - info: BitmapInfo, - shapePath: Path, - isFileDrawable: Boolean, - shouldForceThemeIcon: Boolean, - ): Drawable? { - val mono = base.monochrome - if (mono != null) { - return ClippedMonoDrawable(mono, shapePath) - } - if (Flags.forceMonochromeAppIcons() && shouldForceThemeIcon && !isFileDrawable) { - return MonochromeIconFactory(info.icon.width).wrap(base, shapePath) + if (Flags.forceMonochromeAppIcons() && shouldForceThemeIcon) { + val monoFactory = MonochromeIconFactory(info.icon.width) + val wrappedIcon = monoFactory.wrap(icon) + return MonoThemedBitmap( + wrappedIcon.toAlphaBitmap(factory.iconBitmapSize), + colorProvider, + monoFactory.luminanceDiff, + ) } - return null + + return ThemedBitmap.NOT_SUPPORTED + } + + private fun Drawable.toAlphaBitmap(size: Int): Bitmap { + val result = Bitmap.createBitmap(size, size, ALPHA_8) + setBounds(0, 0, size, size) + draw(Canvas(result)) + return result } override fun decode( - data: ByteArray, + bytes: ByteArray, info: BitmapInfo, factory: BaseIconFactory, sourceHint: SourceHint, ): ThemedBitmap { val icon = info.icon - if (data.size != icon.height * icon.width) return ThemedBitmap.NOT_SUPPORTED + val expectedSize = icon.height * icon.width + + return when (bytes.size) { + expectedSize -> { + MonoThemedBitmap( + ByteBuffer.wrap(bytes).readMonoBitmap(icon.width, icon.height), + colorProvider, + ) + } + (expectedSize + MonoThemedBitmap.DOUBLE_BYTE_SIZE) -> { + val buffer = ByteBuffer.wrap(bytes) + val monoBitmap = buffer.readMonoBitmap(icon.width, icon.height) + val luminanceDelta = buffer.asDoubleBuffer().get() + MonoThemedBitmap(monoBitmap, colorProvider, luminanceDelta) + } + else -> ThemedBitmap.NOT_SUPPORTED + } + } - var monoBitmap = Bitmap.createBitmap(icon.width, icon.height, ALPHA_8) - monoBitmap.copyPixelsFromBuffer(ByteBuffer.wrap(data)) + private fun ByteBuffer.readMonoBitmap(width: Int, height: Int): Bitmap { + val monoBitmap = Bitmap.createBitmap(width, height, ALPHA_8) + monoBitmap.copyPixelsFromBuffer(this) val hwMonoBitmap = monoBitmap.copy(HARDWARE, false /*isMutable*/) - if (hwMonoBitmap != null) { - monoBitmap.recycle() - monoBitmap = hwMonoBitmap - } - return MonoThemedBitmap(monoBitmap, factory.whiteShadowLayer, colorProvider) + return hwMonoBitmap?.also { monoBitmap.recycle() } ?: monoBitmap } override fun createThemedAdaptiveIcon( @@ -124,47 +141,25 @@ class MonoIconThemeController( originalIcon: AdaptiveIconDrawable, info: BitmapInfo?, ): AdaptiveIconDrawable { - val colors = colorProvider(context) + originalIcon.mutate() - var monoDrawable = originalIcon.monochrome?.apply { setTint(colors[1]) } - - if (monoDrawable == null) { - info?.themedBitmap?.let { themedBitmap -> - if (themedBitmap is MonoThemedBitmap) { - // Inject a previously generated monochrome icon - // Use BitmapDrawable instead of FastBitmapDrawable so that the colorState is - // preserved in constantState - // Inset the drawable according to the AdaptiveIconDrawable layers - monoDrawable = - InsetDrawable( - BitmapDrawable(themedBitmap.mono).apply { - colorFilter = BlendModeColorFilter(colors[1], SRC_IN) - }, - AdaptiveIconDrawable.getExtraInsetFraction() / 2, - ) - } - } + originalIcon.monochrome?.let { + val colors = colorProvider(context) + it.setTint(colors[1]) + return@createThemedAdaptiveIcon AdaptiveIconDrawable(ColorDrawable(colors[0]), it) } - return monoDrawable?.let { AdaptiveIconDrawable(ColorDrawable(colors[0]), it) } - ?: originalIcon - } + val themedBitmap = info?.themedBitmap as? MonoThemedBitmap ?: return originalIcon + val colors = themedBitmap.getUpdatedColors(context) - class ClippedMonoDrawable(base: Drawable?, private val shapePath: Path) : - InsetDrawable(base, -AdaptiveIconDrawable.getExtraInsetFraction()) { - // TODO(b/399666950): remove this after launcher icon shapes is fully enabled - private val mCrop = AdaptiveIconDrawable(ColorDrawable(Color.BLACK), null) - - override fun draw(canvas: Canvas) { - mCrop.bounds = bounds - val saveCount = canvas.save() - if (Flags.enableLauncherIconShapes()) { - canvas.clipPath(shapePath) - } else { - canvas.clipPath(mCrop.iconMask) + // Inject a previously generated monochrome icon + // Use BitmapDrawable instead of FastBitmapDrawable so that the colorState is + // preserved in constantState + // Inset the drawable according to the AdaptiveIconDrawable layers + val monoDrawable = + BitmapDrawable(themedBitmap.mono).apply { + colorFilter = BlendModeColorFilter(colors[1], SRC_IN) } - super.draw(canvas) - canvas.restoreToCount(saveCount) - } + return AdaptiveIconDrawable(ColorDrawable(colors[0]), monoDrawable) } } diff --git a/iconloaderlib/src/com/android/launcher3/icons/mono/MonoThemedBitmap.kt b/iconloaderlib/src/com/android/launcher3/icons/mono/MonoThemedBitmap.kt index 2edd0b7..159ae54 100644 --- a/iconloaderlib/src/com/android/launcher3/icons/mono/MonoThemedBitmap.kt +++ b/iconloaderlib/src/com/android/launcher3/icons/mono/MonoThemedBitmap.kt @@ -18,23 +18,119 @@ package com.android.launcher3.icons.mono import android.content.Context import android.graphics.Bitmap +import android.graphics.LinearGradient +import android.graphics.Shader.TileMode.CLAMP +import android.util.Log +import androidx.annotation.VisibleForTesting +import com.android.launcher3.Flags import com.android.launcher3.icons.BitmapInfo -import com.android.launcher3.icons.FastBitmapDrawable +import com.android.launcher3.icons.ClockDrawableWrapper.ClockAnimationInfo +import com.android.launcher3.icons.FastBitmapDrawableDelegate.DelegateFactory +import com.android.launcher3.icons.LuminanceComputer import com.android.launcher3.icons.ThemedBitmap -import com.android.launcher3.icons.mono.ThemedIconDrawable.ThemedConstantState import java.nio.ByteBuffer class MonoThemedBitmap( val mono: Bitmap, - private val whiteShadowLayer: Bitmap, - private val colorProvider: (Context) -> IntArray = ThemedIconDrawable.Companion::getColors, + private val colorProvider: (Context) -> IntArray = ThemedIconDelegate.Companion::getColors, + @get:VisibleForTesting val luminanceDelta: Double? = null, ) : ThemedBitmap { - override fun newDrawable(info: BitmapInfo, context: Context): FastBitmapDrawable { - val colors = colorProvider(context) - return ThemedConstantState(info, mono, whiteShadowLayer, colors[0], colors[1]).newDrawable() + override fun newDelegateFactory(info: BitmapInfo, context: Context): DelegateFactory = + getUpdatedColors(context).let { ThemedIconInfo(mono, it[0], it[1]) } + + override fun serialize(): ByteArray { + val expectedSize = mono.width * mono.height + return if (luminanceDelta == null) + ByteArray(expectedSize).apply { mono.copyPixelsToBuffer(ByteBuffer.wrap(this)) } + else + ByteArray(expectedSize + DOUBLE_BYTE_SIZE).apply { + val buffer = ByteBuffer.wrap(this) + mono.copyPixelsToBuffer(buffer) + buffer.asDoubleBuffer().put(luminanceDelta) + } + } + + fun getUpdatedColors(ctx: Context): IntArray = + if (luminanceDelta != null) + ColorAdapter(luminanceDelta).adaptedColorProvider(colorProvider)(ctx) + else colorProvider(ctx) + + companion object { + const val DOUBLE_BYTE_SIZE = 8 + } +} + +class ClockThemedBitmap( + private val animInfo: ClockAnimationInfo, + private val colorProvider: (Context) -> IntArray = ThemedIconDelegate.Companion::getColors, +) : ThemedBitmap { + + override fun newDelegateFactory(info: BitmapInfo, context: Context): DelegateFactory = + colorProvider(context).let { colors -> + animInfo.copy( + themeFgColor = colors[1], + shader = LinearGradient(0f, 0f, 1f, 1f, colors[0], colors[0], CLAMP), + ) + } + + override fun serialize() = byteArrayOf() +} + +class ColorAdapter(private val luminanceDelta: Double) { + + private val luminanceComputer = LuminanceComputer.createDefaultLuminanceComputer() + + fun adaptedColorProvider(colorProvider: (Context) -> IntArray): (Context) -> IntArray { + // if the feature flag is off, then we don't need to adapt the colors at all. + if (!Flags.forceMonochromeAppIconsAdaptColors()) { + return colorProvider + } + + // we need to adapt the color provider here, by adapting the foregrund color at + // index 0, and the background color at index 1. + + // order is important here, we want to adapt the background color first, then the foreground + // color. + return { context -> + val colors = colorProvider(context) + intArrayOf( + adaptBackgroundColor(colors[0], colors[2]), + adaptForegroundColor(colors[1], colors[0]), + colors[2], + ) + } } - override fun serialize() = - ByteArray(mono.width * mono.height).apply { mono.copyPixelsToBuffer(ByteBuffer.wrap(this)) } + private fun adaptForegroundColor(localFgColor: Int, localBgColor: Int): Int { + if (luminanceDelta.isNaN()) { + return localFgColor + } + + try { + val adaptedColor = + luminanceComputer.adaptColorLuminance( + localFgColor, + localBgColor, + luminanceDelta, + MINIMUM_CONTRAST_RATIO, + ) + return adaptedColor + } catch (e: Exception) { + Log.e(TAG, "Failed to adjust luminance color", e) + } + return localFgColor + } + + private fun adaptBackgroundColor(colorBg: Int, colorBgNonMonochrome: Int): Int { + if (luminanceDelta.isNaN()) { + return colorBg + } + return colorBgNonMonochrome + } + + private companion object { + const val TAG = "ColorAdapter" + const val MINIMUM_CONTRAST_RATIO = 8.0 + } } diff --git a/iconloaderlib/src/com/android/launcher3/icons/mono/ThemedIconDelegate.kt b/iconloaderlib/src/com/android/launcher3/icons/mono/ThemedIconDelegate.kt new file mode 100644 index 0000000..e056d71 --- /dev/null +++ b/iconloaderlib/src/com/android/launcher3/icons/mono/ThemedIconDelegate.kt @@ -0,0 +1,107 @@ +/* + * Copyright (C) 2021 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.launcher3.icons.mono + +import android.content.Context +import android.graphics.Bitmap +import android.graphics.Canvas +import android.graphics.ColorFilter +import android.graphics.Paint +import android.graphics.Rect +import com.android.launcher3.icons.BitmapInfo +import com.android.launcher3.icons.FastBitmapDrawable +import com.android.launcher3.icons.FastBitmapDrawableDelegate +import com.android.launcher3.icons.FastBitmapDrawableDelegate.DelegateFactory +import com.android.launcher3.icons.GraphicsUtils.getColorMultipliedFilter +import com.android.launcher3.icons.GraphicsUtils.resizeToContentSize +import com.android.launcher3.icons.IconShape +import com.android.launcher3.icons.R + +/** Drawing delegate handle monochrome themed app icons */ +class ThemedIconDelegate( + constantState: ThemedIconInfo, + val bitmapInfo: BitmapInfo, + val paint: Paint, +) : FastBitmapDrawableDelegate { + + private val colorFg = constantState.colorFg + + // The foreground/monochrome icon for the app + private val monoIcon = constantState.mono + private val monoPaint = + Paint(Paint.ANTI_ALIAS_FLAG or Paint.FILTER_BITMAP_FLAG).apply { + colorFilter = getColorMultipliedFilter(colorFg, paint.colorFilter) + } + + private val shapeBounds = Rect(0, 0, bitmapInfo.icon.width, bitmapInfo.icon.height) + + init { + paint.color = constantState.colorBg + } + + override fun drawContent( + info: BitmapInfo, + iconShape: IconShape, + canvas: Canvas, + bounds: Rect, + paint: Paint, + ) { + canvas.drawBitmap(iconShape.shadowLayer, null, bounds, paint) + + canvas.resizeToContentSize(bounds, iconShape.pathSize.toFloat()) { + clipPath(iconShape.path) + drawPaint(paint) + drawBitmap(monoIcon, null, shapeBounds, monoPaint) + } + } + + override fun setAlpha(alpha: Int) { + monoPaint.alpha = alpha + } + + override fun updateFilter(filter: ColorFilter?) { + monoPaint.colorFilter = getColorMultipliedFilter(colorFg, filter) + } + + override fun isThemed() = true + + override fun getIconColor(info: BitmapInfo) = colorFg + + companion object { + const val TAG: String = "ThemedIconDrawable" + + /** Get an int array representing background and foreground colors for themed icons */ + @JvmStatic + fun getColors(context: Context): IntArray { + val res = context.resources + return intArrayOf( + res.getColor(R.color.themed_icon_background_color), + res.getColor(R.color.themed_icon_color), + res.getColor(R.color.themed_icon_adaptive_background_color), + ) + } + } +} + +class ThemedIconInfo(val mono: Bitmap, val colorBg: Int, val colorFg: Int) : DelegateFactory { + + override fun newDelegate( + bitmapInfo: BitmapInfo, + iconShape: IconShape, + paint: Paint, + host: FastBitmapDrawable, + ) = ThemedIconDelegate(this, bitmapInfo, paint) +} diff --git a/iconloaderlib/src/com/android/launcher3/util/SQLiteCacheHelper.java b/iconloaderlib/src/com/android/launcher3/util/SQLiteCacheHelper.java index 49de4bd..45158e5 100644 --- a/iconloaderlib/src/com/android/launcher3/util/SQLiteCacheHelper.java +++ b/iconloaderlib/src/com/android/launcher3/util/SQLiteCacheHelper.java @@ -1,33 +1,42 @@ package com.android.launcher3.util; +import static android.database.sqlite.SQLiteDatabase.NO_LOCALIZED_COLLATORS; + import android.content.ContentValues; import android.content.Context; import android.database.Cursor; import android.database.sqlite.SQLiteDatabase; +import android.database.sqlite.SQLiteDatabase.OpenParams; import android.database.sqlite.SQLiteException; import android.database.sqlite.SQLiteFullException; import android.database.sqlite.SQLiteOpenHelper; import android.util.Log; +import java.util.function.Function; +import java.util.function.Supplier; + /** * An extension of {@link SQLiteOpenHelper} with utility methods for a single table cache DB. * Any exception during write operations are ignored, and any version change causes a DB reset. */ -public abstract class SQLiteCacheHelper { +public class SQLiteCacheHelper { private static final String TAG = "SQLiteCacheHelper"; private static final boolean IN_MEMORY_CACHE = false; private final String mTableName; private final MySQLiteOpenHelper mOpenHelper; + private final Supplier mCreationCommand; private boolean mIgnoreWrites; - public SQLiteCacheHelper(Context context, String name, int version, String tableName) { + public SQLiteCacheHelper(Context context, String name, int version, + String tableName, Supplier creationCommand) { if (IN_MEMORY_CACHE) { name = null; } mTableName = tableName; + mCreationCommand = creationCommand; mOpenHelper = new MySQLiteOpenHelper(context, name, version); mIgnoreWrites = false; @@ -79,6 +88,20 @@ public Cursor query(String[] columns, String selection, String[] selectionArgs) mTableName, columns, selection, selectionArgs, null, null, null); } + /** Helper method to read a single entry from cache */ + public T querySingleEntry(String[] columns, String selection, String[] selectionArgs, + T defaultValue, Function callback) { + + try (Cursor c = query(columns, selection, selectionArgs)) { + if (c.moveToNext()) { + return callback.apply(c); + } + } catch (SQLiteException e) { + Log.d(TAG, "Error reading cache", e); + } + return defaultValue; + } + public void clear() { mOpenHelper.clearDB(mOpenHelper.getWritableDatabase()); } @@ -87,15 +110,17 @@ public void close() { mOpenHelper.close(); } - protected abstract void onCreateTable(SQLiteDatabase db); + protected void onCreateTable(SQLiteDatabase db) { + db.execSQL(mCreationCommand.get()); + } /** * A private inner class to prevent direct DB access. */ - private class MySQLiteOpenHelper extends NoLocaleSQLiteHelper { + private class MySQLiteOpenHelper extends SQLiteOpenHelper { public MySQLiteOpenHelper(Context context, String name, int version) { - super(context, name, version); + super(context, name, version, createNoLocaleParams()); } @Override @@ -122,4 +147,12 @@ private void clearDB(SQLiteDatabase db) { onCreate(db); } } + + /** + * Returns {@link OpenParams} which can be used to create databases without support for + * localized collators. + */ + public static OpenParams createNoLocaleParams() { + return new OpenParams.Builder().addOpenFlags(NO_LOCALIZED_COLLATORS).build(); + } } diff --git a/iconloaderlib/src/com/android/launcher3/util/UserIconInfo.kt b/iconloaderlib/src/com/android/launcher3/util/UserIconInfo.kt new file mode 100644 index 0000000..e3341df --- /dev/null +++ b/iconloaderlib/src/com/android/launcher3/util/UserIconInfo.kt @@ -0,0 +1,59 @@ +/* + * Copyright (C) 2013 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.launcher3.util + +import android.os.UserHandle +import androidx.annotation.IntDef +import com.android.launcher3.icons.BitmapInfo + +/** + * Data class which stores various properties of a [android.os.UserHandle] which affects rendering + */ +data class UserIconInfo +@JvmOverloads +constructor( + @JvmField val user: UserHandle, + @JvmField @UserType val type: Int, + @JvmField val userSerial: Long = user.hashCode().toLong(), +) { + @Target(AnnotationTarget.PROPERTY, AnnotationTarget.VALUE_PARAMETER, AnnotationTarget.TYPE) + @IntDef(TYPE_MAIN, TYPE_WORK, TYPE_CLONED, TYPE_PRIVATE) + annotation class UserType + + val isMain: Boolean + get() = type == TYPE_MAIN + + val isWork: Boolean + get() = type == TYPE_WORK + + val isCloned: Boolean + get() = type == TYPE_CLONED + + val isPrivate: Boolean + get() = type == TYPE_PRIVATE + + fun applyBitmapInfoFlags(op: FlagOp): FlagOp = + op.setFlag(BitmapInfo.FLAG_WORK, isWork) + .setFlag(BitmapInfo.FLAG_CLONE, isCloned) + .setFlag(BitmapInfo.FLAG_PRIVATE, isPrivate) + + companion object { + const val TYPE_MAIN: Int = 0 + const val TYPE_WORK: Int = 1 + const val TYPE_CLONED: Int = 2 + const val TYPE_PRIVATE: Int = 3 + } +} diff --git a/iconloaderlib/tests/Android.bp b/iconloaderlib/tests/Android.bp new file mode 100644 index 0000000..4c8ef8c --- /dev/null +++ b/iconloaderlib/tests/Android.bp @@ -0,0 +1,65 @@ +// Copyright (C) 2025 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 { + default_applicable_licenses: ["Android-Apache-2.0"], +} + +android_library { + name: "iconloader-tests-base", + libs: [ + "android.test.base.stubs.system", + "androidx.test.core", + ], + static_libs: [ + "iconloader", + "androidx.test.ext.junit", + "androidx.test.rules", + ], +} + +android_app { + name: "TestIconLoaderLibApp", + platform_apis: true, + static_libs: [ + "iconloader-tests-base", + ], +} + +android_robolectric_test { + enabled: true, + name: "iconloader_robo_tests", + srcs: [ + "src/**/*.kt", + "robolectric/src/**/*.kt", + ], + java_resource_dirs: ["robolectric/config"], + instrumentation_for: "TestIconLoaderLibApp", + strict_mode: false, +} + +android_test { + name: "iconloader_tests", + manifest: "AndroidManifest.xml", + + static_libs: [ + "iconloader-tests-base", + ], + srcs: [ + "src/**/*.java", + "src/**/*.kt", + ], + kotlincflags: ["-Xjvm-default=all"], + test_suites: ["general-tests"], +} diff --git a/iconloaderlib/tests/AndroidManifest.xml b/iconloaderlib/tests/AndroidManifest.xml new file mode 100644 index 0000000..ae13e77 --- /dev/null +++ b/iconloaderlib/tests/AndroidManifest.xml @@ -0,0 +1,24 @@ + + + + + + + \ No newline at end of file diff --git a/iconloaderlib/tests/TEST_MAPPING b/iconloaderlib/tests/TEST_MAPPING new file mode 100644 index 0000000..eb9aa17 --- /dev/null +++ b/iconloaderlib/tests/TEST_MAPPING @@ -0,0 +1,7 @@ +{ + "presubmit": [ + { + "name": "iconloader_tests" + } + ] +} \ No newline at end of file diff --git a/iconloaderlib/tests/robolectric/config/robolectric.properties b/iconloaderlib/tests/robolectric/config/robolectric.properties new file mode 100644 index 0000000..850557a --- /dev/null +++ b/iconloaderlib/tests/robolectric/config/robolectric.properties @@ -0,0 +1 @@ +sdk=NEWEST_SDK \ No newline at end of file diff --git a/iconloaderlib/tests/src/com/android/launcher3/icons/BaseIconFactoryTest.kt b/iconloaderlib/tests/src/com/android/launcher3/icons/BaseIconFactoryTest.kt new file mode 100644 index 0000000..3af215c --- /dev/null +++ b/iconloaderlib/tests/src/com/android/launcher3/icons/BaseIconFactoryTest.kt @@ -0,0 +1,87 @@ +/* + * Copyright (C) 2025 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.launcher3.icons + +import android.content.Context +import android.graphics.Color +import android.graphics.drawable.AdaptiveIconDrawable +import android.graphics.drawable.ColorDrawable +import androidx.test.core.app.ApplicationProvider +import com.android.launcher3.icons.BaseIconFactory.IconOptions +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class BaseIconFactoryTest { + + private val context: Context = ApplicationProvider.getApplicationContext() + + @Test + fun fullBleed_has_no_alpha() { + val info = + factory(drawFullBleedIcons = true) + .createBadgedIconBitmap(AdaptiveIconDrawable(ColorDrawable(Color.RED), null)) + + assertFalse(info.icon.hasAlpha()) + assertEquals(BitmapInfo.FLAG_FULL_BLEED, info.flags and BitmapInfo.FLAG_FULL_BLEED) + } + + @Test + fun non_fullBleed_has_alpha() { + val info = + factory(drawFullBleedIcons = false) + .createBadgedIconBitmap(AdaptiveIconDrawable(ColorDrawable(Color.RED), null)) + assertTrue(info.icon.hasAlpha()) + assertEquals(0, info.flags and BitmapInfo.FLAG_FULL_BLEED) + } + + @Test + fun icon_options_overrides_fullBleed() { + val info = + factory(drawFullBleedIcons = false) + .createBadgedIconBitmap( + AdaptiveIconDrawable(ColorDrawable(Color.RED), null), + IconOptions().setDrawFullBleed(true), + ) + assertFalse(info.icon.hasAlpha()) + assertEquals(BitmapInfo.FLAG_FULL_BLEED, info.flags and BitmapInfo.FLAG_FULL_BLEED) + + val info2 = + factory(drawFullBleedIcons = true) + .createBadgedIconBitmap( + AdaptiveIconDrawable(ColorDrawable(Color.RED), null), + IconOptions().setDrawFullBleed(false), + ) + assertTrue(info2.icon.hasAlpha()) + assertEquals(0, info2.flags and BitmapInfo.FLAG_FULL_BLEED) + } + + private fun factory( + fullResIconDpi: Int = context.resources.displayMetrics.densityDpi, + iconBitmapSize: Int = 64, + drawFullBleedIcons: Boolean = false, + themeController: IconThemeController? = null, + ) = + BaseIconFactory( + context = context, + fullResIconDpi = fullResIconDpi, + iconBitmapSize = iconBitmapSize, + drawFullBleedIcons = drawFullBleedIcons, + themeController = themeController, + ) +} diff --git a/iconloaderlib/tests/src/com/android/launcher3/icons/LuminanceComputerTest.kt b/iconloaderlib/tests/src/com/android/launcher3/icons/LuminanceComputerTest.kt new file mode 100644 index 0000000..4019a26 --- /dev/null +++ b/iconloaderlib/tests/src/com/android/launcher3/icons/LuminanceComputerTest.kt @@ -0,0 +1,548 @@ +/** + * Copyright (C) 2025 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.launcher3.icons + +import android.graphics.Bitmap +import android.graphics.Color +import androidx.core.graphics.ColorUtils +import androidx.test.ext.junit.runners.AndroidJUnit4 +import org.junit.Assert.assertEquals +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class LuminanceComputerTest { + + @Test + fun computeLuminance_solidColor_average_hsl() { + val color = Color.RED // R=255, G=0, B=0 + val width = 2 + val height = 2 + + val computer = + LuminanceComputer( + computationType = ComputationType.AVERAGE, // + colorSpace = LuminanceColorSpace.HSL, + ) + + val bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888) + for (x in 0 until width) { + for (y in 0 until height) { + bitmap.setPixel(x, y, color) + } + } + + // Calculate expected HSL luminance (L component) for red + val hsl = FloatArray(3) + ColorUtils.colorToHSL(color, hsl) + val expectedLuminance = hsl[2].toDouble() + + val actualLuminance = computer.computeLuminance(bitmap, scale = false) + + assertEquals(expectedLuminance, actualLuminance, TOLERANCE) + } + + @Test + fun computeLuminance_solidColor_median_hsl() { + val color = Color.GREEN // R=0, G=255, B=0 + val width = 3 + val height = 3 + + val computer = + LuminanceComputer( + computationType = ComputationType.MEDIAN, + colorSpace = LuminanceColorSpace.HSL, + ) + + val bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888) + for (x in 0 until width) { + for (y in 0 until height) { + bitmap.setPixel(x, y, color) + } + } + + // Calculate expected HSL luminance (L component) for green + val hsl = FloatArray(3) + ColorUtils.colorToHSL(color, hsl) + val expectedLuminance = hsl[2].toDouble() + + val actualLuminance = computer.computeLuminance(bitmap, scale = false) + + assertEquals(expectedLuminance, actualLuminance, TOLERANCE) + } + + @Test + fun computeLuminance_solidColor_average_hsl_with_scale() { + val color = Color.RED // R=255, G=0, B=0 + val width = 2 + val height = 2 + + val computer = + LuminanceComputer( + computationType = ComputationType.AVERAGE, + colorSpace = LuminanceColorSpace.HSL, + ) + + // Create a real solid color bitmap + val bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888) + for (x in 0 until width) { + for (y in 0 until height) { + bitmap.setPixel(x, y, color) + } + } + + // Calculate expected HSL luminance (L component) for red + val hsl = FloatArray(3) + ColorUtils.colorToHSL(color, hsl) + val expectedLuminance = hsl[2].toDouble() + + // Call computeLuminance with scale = true + val actualLuminance = computer.computeLuminance(bitmap, scale = true) + + assertEquals(expectedLuminance, actualLuminance, TOLERANCE) + } + + @Test + fun computeLuminance_solidColor_median_hsl_with_scale() { + val color = Color.GREEN // R=0, G=255, B=0 + val width = 3 + val height = 3 + + val computer = + LuminanceComputer( + computationType = ComputationType.MEDIAN, + colorSpace = LuminanceColorSpace.HSL, + ) + + // Create a real solid color bitmap + val bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888) + for (x in 0 until width) { + for (y in 0 until height) { + bitmap.setPixel(x, y, color) + } + } + + // Calculate expected HSL luminance (L component) for green + val hsl = FloatArray(3) + ColorUtils.colorToHSL(color, hsl) + val expectedLuminance = hsl[2].toDouble() + + // Call computeLuminance with scale = true + val actualLuminance = computer.computeLuminance(bitmap, scale = true) + assertEquals(expectedLuminance, actualLuminance, TOLERANCE) + } + + @Test + fun computeLuminance_solidColor_average_lab() { + val color = Color.BLUE // R=0, G=0, B=255 + val width = 4 + val height = 4 + + val computer = + LuminanceComputer( + computationType = ComputationType.AVERAGE, + colorSpace = LuminanceColorSpace.LAB, + ) + + // Create a real solid color bitmap + val bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888) + for (x in 0 until width) { + for (y in 0 until height) { + bitmap.setPixel(x, y, color) + } + } + + // Calculate expected LAB luminance (L component) for blue + val lab = DoubleArray(3) + ColorUtils.colorToLAB(color, lab) + val expectedLuminance = lab[0].toDouble() / 100.0 // LAB L is 0-100, convert to 0-1 + + // Call computeLuminance with scale = true + val actualLuminance = computer.computeLuminance(bitmap, scale = false) + + assertEquals(expectedLuminance, actualLuminance, TOLERANCE) + } + + @Test + fun computeLuminance_solidColor_median_lab() { + val color = Color.YELLOW // R=255, G=255, B=0 + val width = 5 + val height = 5 + + val computer = + LuminanceComputer( + computationType = ComputationType.MEDIAN, + colorSpace = LuminanceColorSpace.LAB, + ) + + // Create a real solid color bitmap + val bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888) + for (x in 0 until width) { + for (y in 0 until height) { + bitmap.setPixel(x, y, color) + } + } + + // Calculate expected LAB luminance (L component) for yellow + val lab = DoubleArray(3) + ColorUtils.colorToLAB(color, lab) + val expectedLuminance = lab[0].toDouble() / 100.0 + + // Call computeLuminance with scale = true + val actualLuminance = computer.computeLuminance(bitmap, scale = false) + + assertEquals(expectedLuminance, actualLuminance, TOLERANCE) + } + + @Test + fun computeLuminance_mixedColors_average_hsl() { + val width = 2 // Use a small 2x2 real bitmap + val height = 2 + + val computer = + LuminanceComputer( + computationType = ComputationType.AVERAGE, + colorSpace = LuminanceColorSpace.HSL, + ) + + val color1 = Color.RED + val color2 = Color.GREEN + val color3 = Color.BLUE + val color4 = Color.YELLOW + + // Create a real 2x2 bitmap with mixed colors + val bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888) + bitmap.setPixel(0, 0, color1) + bitmap.setPixel(1, 0, color2) + bitmap.setPixel(0, 1, color3) + bitmap.setPixel(1, 1, color4) + + val hsl1 = FloatArray(3).also { ColorUtils.colorToHSL(color1, it) } + val hsl2 = FloatArray(3).also { ColorUtils.colorToHSL(color2, it) } + val hsl3 = FloatArray(3).also { ColorUtils.colorToHSL(color3, it) } + val hsl4 = FloatArray(3).also { ColorUtils.colorToHSL(color4, it) } + val expectedLuminance = + (hsl1[2] + hsl2[2] + hsl3[2] + hsl4[2]).toDouble() / (width * height) + // Call computeLuminance with scale = true + val actualLuminance = computer.computeLuminance(bitmap, scale = true) + assertEquals(expectedLuminance, actualLuminance, TOLERANCE) + } + + @Test + fun computeLuminance_mixedColors_median_hsl() { + val width = 2 // Use a small 2x2 real bitmap + val height = 2 + + val computer = + LuminanceComputer( + computationType = ComputationType.MEDIAN, + colorSpace = LuminanceColorSpace.HSL, + options = LuminanceComputer.Options(), + ) + + val color1 = Color.RED + val color2 = Color.GREEN + val color3 = Color.BLUE + val color4 = Color.YELLOW + + // Create a real 2x2 bitmap with mixed colors + val bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888) + bitmap.setPixel(0, 0, color1) + bitmap.setPixel(1, 0, color2) + bitmap.setPixel(0, 1, color3) + bitmap.setPixel(1, 1, color4) + + val hsl1 = FloatArray(3).also { ColorUtils.colorToHSL(color1, it) } + val hsl2 = FloatArray(3).also { ColorUtils.colorToHSL(color2, it) } + val hsl3 = FloatArray(3).also { ColorUtils.colorToHSL(color3, it) } + val hsl4 = FloatArray(3).also { ColorUtils.colorToHSL(color4, it) } + + // Calculate expected median HSL luminance + val luminances = + listOf(hsl1[2].toDouble(), hsl2[2].toDouble(), hsl3[2].toDouble(), hsl4[2].toDouble()) + .sorted() + + val expectedLuminance = (luminances[1] + luminances[2]) / 2.0 // Median for 4 values + + // Call computeLuminance with scale = true + val actualLuminance = computer.computeLuminance(bitmap, scale = true) + + assertEquals(expectedLuminance, actualLuminance, TOLERANCE) + } + + @Test + fun computeLuminance_solidColor_spread_hsl() { + val color = Color.BLUE // R=0, G=0, B=255 + val width = 4 + val height = 4 + + val computer = + LuminanceComputer( + computationType = ComputationType.SPREAD, + colorSpace = LuminanceColorSpace.HSL, + ) + + val bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888) + for (x in 0 until width) { + for (y in 0 until height) { + bitmap.setPixel(x, y, color) + } + } + + // For a solid color, the spread should be 0 + val expectedLuminance = 0.0 + + val actualLuminance = computer.computeLuminance(bitmap, scale = false) + + assertEquals(expectedLuminance, actualLuminance, TOLERANCE) + } + + @Test + fun computeLuminance_solidColor_spread_lab() { + val color = Color.YELLOW // R=255, G=255, B=0 + val width = 5 + val height = 5 + + val computer = + LuminanceComputer( + computationType = ComputationType.SPREAD, + colorSpace = LuminanceColorSpace.LAB, + ) + + val bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888) + for (x in 0 until width) { + for (y in 0 until height) { + bitmap.setPixel(x, y, color) + } + } + + // For a solid color, the spread should be 0 + val expectedLuminance = 0.0 + + val actualLuminance = computer.computeLuminance(bitmap, scale = false) + + assertEquals(expectedLuminance, actualLuminance, TOLERANCE) + } + + @Test + fun computeLuminance_mixedColors_spread_hsl() { + val width = 2 // Use a small 2x2 real bitmap + val height = 2 + + val computer = + LuminanceComputer( + computationType = ComputationType.SPREAD, + colorSpace = LuminanceColorSpace.HSL, + ) + + val color1 = Color.RED + val color2 = Color.GREEN + val color3 = Color.BLUE + val color4 = Color.YELLOW + + // Create a real 2x2 bitmap with mixed colors + val bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888) + bitmap.setPixel(0, 0, color1) + bitmap.setPixel(1, 0, color2) + bitmap.setPixel(0, 1, color3) + bitmap.setPixel(1, 1, color4) + + // Calculate expected spread HSL luminance by processing the bitmap like the computeLuminance method + val bitmapToProcess = + Bitmap.createScaledBitmap(bitmap, LuminanceComputer.BITMAP_SAMPLE_SIZE, LuminanceComputer.BITMAP_SAMPLE_SIZE, true) + + val processedWidth = bitmapToProcess.width + val processedHeight = bitmapToProcess.height + val pixels = IntArray(processedWidth * processedHeight) + bitmapToProcess.getPixels(pixels, 0, processedWidth, 0, 0, processedWidth, processedHeight) + + val luminances = pixels.map { + val hsl = FloatArray(3) + ColorUtils.colorToHSL(it, hsl) + hsl[2].toDouble() + } + + val expectedLuminance = luminances.max() - luminances.min() + + val actualLuminance = computer.computeLuminance(bitmap, scale = true) + assertEquals(expectedLuminance, actualLuminance, TOLERANCE) + } + + @Test + fun computeLuminance_mixedColors_spread_lab() { + val width = 2 // Use a small 2x2 real bitmap + val height = 2 + + val computer = + LuminanceComputer( + computationType = ComputationType.SPREAD, + colorSpace = LuminanceColorSpace.LAB, + ) + + val color1 = Color.RED + val color2 = Color.GREEN + val color3 = Color.BLUE + val color4 = Color.YELLOW + + // Create a real 2x2 bitmap with mixed colors + val bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888) + bitmap.setPixel(0, 0, color1) + bitmap.setPixel(1, 0, color2) + bitmap.setPixel(0, 1, color3) + bitmap.setPixel(1, 1, color4) + + // Calculate expected spread LAB luminance (L component, scaled to 0-1) by processing the bitmap + val bitmapToProcess = + Bitmap.createScaledBitmap(bitmap, LuminanceComputer.BITMAP_SAMPLE_SIZE, LuminanceComputer.BITMAP_SAMPLE_SIZE, true) + + val processedWidth = bitmapToProcess.width + val processedHeight = bitmapToProcess.height + val pixels = IntArray(processedWidth * processedHeight) + bitmapToProcess.getPixels(pixels, 0, processedWidth, 0, 0, processedWidth, processedHeight) + + val luminances = pixels.map { + val lab = DoubleArray(3) + ColorUtils.colorToLAB(it, lab) + lab[0].toDouble() / 100.0 // LAB L is 0-100, convert to 0-1 + } + + val expectedLuminance = luminances.max() - luminances.min() + + val actualLuminance = computer.computeLuminance(bitmap, scale = true) + assertEquals(expectedLuminance, actualLuminance, TOLERANCE) + } + + @Test + fun adaptColorLuminance_basic() { + val computer = + LuminanceComputer( + computationType = ComputationType.AVERAGE, + colorSpace = LuminanceColorSpace.HSL, + ) + val targetColor = Color.GRAY // HSL L ~ 0.5 + val basisColor = Color.BLACK // HSL L = 0 + val luminanceDelta = 0.3 + val minimumContrast = 0.0 + + val adaptedColor = + computer.adaptColorLuminance(targetColor, basisColor, luminanceDelta, minimumContrast) + + val adaptedHsl = FloatArray(3) + ColorUtils.colorToHSL(adaptedColor, adaptedHsl) + + // Expected luminance should be basisLuminance + luminanceDelta = 0 + 0.3 = 0.3 + assertEquals(0.3, adaptedHsl[2].toDouble(), TOLERANCE) + } + + @Test + fun adaptColorLuminance_withContrastAdjustment_meetsMinimumContrast() { + val options = + LuminanceComputer.Options(ensureMinContrast = true, absoluteLuminanceDelta = false) + val computer = + LuminanceComputer( + computationType = ComputationType.AVERAGE, + colorSpace = LuminanceColorSpace.HSL, + options = options, + ) + val targetColor = Color.GRAY // HSL L ~ 0.5 + val basisColor = Color.BLACK // HSL L = 0 + val luminanceDelta = 0.1 // Small delta + val minimumContrast = 2.0 // High minimum contrast + + val adaptedColor = + computer.adaptColorLuminance(targetColor, basisColor, luminanceDelta, minimumContrast) + + val adaptedHsl = FloatArray(3) + ColorUtils.colorToHSL(adaptedColor, adaptedHsl) + val adaptedLuminance = adaptedHsl[2].toDouble() + + // Expected luminance should be basisLuminance + (luminanceDelta * minimumContrast) + // 0 + (0.1 * 2.0) = 0.2 + assertEquals(0.2, adaptedLuminance, TOLERANCE) + } + + @Test + fun adaptColorLuminance_withContrastAdjustment_alreadyMeetsMinimumContrast() { + val options = + LuminanceComputer.Options(ensureMinContrast = true, absoluteLuminanceDelta = false) + + val computer = + LuminanceComputer( + computationType = ComputationType.AVERAGE, + colorSpace = LuminanceColorSpace.HSL, + options = options, + ) + val targetColor = Color.WHITE // HSL L = 1.0 + val basisColor = Color.BLACK // HSL L = 0.0 + val luminanceDelta = 0.5 + val minimumContrast = 0.1 // Low minimum contrast + + val adaptedColor = + computer.adaptColorLuminance(targetColor, basisColor, luminanceDelta, minimumContrast) + + val adaptedHsl = FloatArray(3) + ColorUtils.colorToHSL(adaptedColor, adaptedHsl) + val adaptedLuminance = adaptedHsl[2].toDouble() + + // Expected luminance should be basisLuminance + luminanceDelta = 0 + 0.5 = 0.5 + // Since the original contrast (infinite) is already higher than minimumContrast, + // the contrast adjustment should not change the luminance calculated from delta. + assertEquals(0.5, adaptedLuminance, TOLERANCE) + } + + @Test + fun adaptColorLuminance_withAbsoluteLuminanceDelta() { + val options = + LuminanceComputer.Options(ensureMinContrast = false, absoluteLuminanceDelta = true) + val computer = + LuminanceComputer( + computationType = ComputationType.AVERAGE, + colorSpace = LuminanceColorSpace.HSL, + options = options, + ) + val targetColor = Color.GRAY // HSL L ~ 0.5 + val basisColor = Color.WHITE // HSL L = 1.0 + val luminanceDelta = -0.3 // Negative delta + + val adaptedColor = + computer.adaptColorLuminance(targetColor, basisColor, luminanceDelta, 0.0) + + val adaptedHsl = FloatArray(3) + ColorUtils.colorToHSL(adaptedColor, adaptedHsl) + val adaptedLuminance = adaptedHsl[2].toDouble() + + // Expected luminance should be basisLuminance + abs(luminanceDelta) = 1.0 + abs(-0.3) = 1.3 + // But it should be clamped to 1.0 + assertEquals(1.0, adaptedLuminance, TOLERANCE) + } + + @Test + fun adaptColorLuminance_nanLuminanceDelta() { + val computer = LuminanceComputer(LuminanceColorSpace.HSL, ComputationType.AVERAGE) + val targetColor = Color.RED + val basisColor = Color.BLUE + val luminanceDelta = Double.NaN + val minimumContrast = 0.0 + + val adaptedColor = + computer.adaptColorLuminance(targetColor, basisColor, luminanceDelta, minimumContrast) + + assertEquals(targetColor, adaptedColor) + } + + private companion object { + // Tolerance for floating point comparisons + const val TOLERANCE = 0.08 + } +} diff --git a/iconloaderlib/tests/src/com/android/launcher3/icons/RoundRectEstimatorTest.kt b/iconloaderlib/tests/src/com/android/launcher3/icons/RoundRectEstimatorTest.kt new file mode 100644 index 0000000..a3a3526 --- /dev/null +++ b/iconloaderlib/tests/src/com/android/launcher3/icons/RoundRectEstimatorTest.kt @@ -0,0 +1,67 @@ +/* + * Copyright (C) 2025 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.launcher3.icons + +import android.graphics.Path +import android.graphics.Path.Direction +import androidx.test.ext.junit.runners.AndroidJUnit4 +import org.junit.Assert.assertEquals +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class RoundRectEstimatorTest { + + @Test + fun `estimateRadius circle`() { + val r = 160f + val path = Path().apply { addCircle(r, r, r, Direction.CW) } + assertEquals(1f, RoundRectEstimator.estimateRadius(path, r * 2)) + } + + @Test + fun `estimateRadius picks rounded rect 0_5`() { + val factor = 0.5f + val path = roundedRectPath(factor, 140f) + assertEquals(0.5f, RoundRectEstimator.estimateRadius(path, 140f)) + } + + @Test + fun `estimateRadius picks rounded rect 0_2`() { + val factor = 0.2f + val path = roundedRectPath(factor, 190f) + assertEquals(0.2f, RoundRectEstimator.estimateRadius(path, 190f)) + } + + @Test + fun `estimateRadius fails on generic shape`() { + val path = + Path().apply { + moveTo(0f, 0f) + lineTo(50f, 50f) + lineTo(0f, 50f) + close() + } + assertEquals(-1f, RoundRectEstimator.estimateRadius(path, 50f)) + } + + private fun roundedRectPath(factor: Float, size: Float) = + Path().apply { + val r = factor * size / 2 + addRoundRect(0f, 0f, size, size, r, r, Direction.CW) + } +} From 582b2287ea372d1633ddab0f82a0ed43a23d6d5e Mon Sep 17 00:00:00 2001 From: Pun Butrach Date: Wed, 3 Dec 2025 22:55:35 +0700 Subject: [PATCH 25/30] refactor: Remove test from mechanics entirely It failed building mechanics, and we have no interest in making test work --- mechanics/compose/tests/Android.bp | 52 - mechanics/compose/tests/AndroidManifest.xml | 28 - ...actileSurfaceReveal_gesture_dragClose.json | 1288 --------------- ...TactileSurfaceReveal_gesture_dragOpen.json | 1390 ----------------- ...ctileSurfaceReveal_gesture_flingClose.json | 870 ----------- ...actileSurfaceReveal_gesture_flingOpen.json | 1142 -------------- ...erticalTactileSurfaceRevealModifierTest.kt | 308 ---- 7 files changed, 5078 deletions(-) delete mode 100644 mechanics/compose/tests/Android.bp delete mode 100644 mechanics/compose/tests/AndroidManifest.xml delete mode 100644 mechanics/compose/tests/goldens/verticalTactileSurfaceReveal_gesture_dragClose.json delete mode 100644 mechanics/compose/tests/goldens/verticalTactileSurfaceReveal_gesture_dragOpen.json delete mode 100644 mechanics/compose/tests/goldens/verticalTactileSurfaceReveal_gesture_flingClose.json delete mode 100644 mechanics/compose/tests/goldens/verticalTactileSurfaceReveal_gesture_flingOpen.json delete mode 100644 mechanics/compose/tests/src/com/android/mechanics/compose/modifier/VerticalTactileSurfaceRevealModifierTest.kt diff --git a/mechanics/compose/tests/Android.bp b/mechanics/compose/tests/Android.bp deleted file mode 100644 index e344132..0000000 --- a/mechanics/compose/tests/Android.bp +++ /dev/null @@ -1,52 +0,0 @@ -// Copyright (C) 2025 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 { - default_team: "trendy_team_motion", - default_applicable_licenses: ["Android-Apache-2.0"], -} - -android_test { - name: "mechanics-compose_tests", - manifest: "AndroidManifest.xml", - defaults: ["MotionTestDefaults"], - test_suites: ["device-tests"], - - srcs: [ - "src/**/*.kt", - ], - - static_libs: [ - "//frameworks/libs/systemui/mechanics:mechanics", - "//frameworks/libs/systemui/mechanics:mechanics-compose", - "//frameworks/libs/systemui/mechanics:mechanics-testing", - "PlatformComposeSceneTransitionLayoutTestsUtils", - "platform-test-annotations", - "PlatformMotionTestingCompose", - "androidx.compose.runtime_runtime", - "androidx.compose.animation_animation-core", - "androidx.compose.ui_ui-test-junit4", - "androidx.compose.ui_ui-test-manifest", - "androidx.test.runner", - "androidx.test.ext.junit", - "kotlin-test", - "testables", - "truth", - ], - associates: [ - "mechanics-compose", - ], - asset_dirs: ["goldens"], - kotlincflags: ["-Xjvm-default=all"], -} diff --git a/mechanics/compose/tests/AndroidManifest.xml b/mechanics/compose/tests/AndroidManifest.xml deleted file mode 100644 index 182f244..0000000 --- a/mechanics/compose/tests/AndroidManifest.xml +++ /dev/null @@ -1,28 +0,0 @@ - - - - - - - - - - diff --git a/mechanics/compose/tests/goldens/verticalTactileSurfaceReveal_gesture_dragClose.json b/mechanics/compose/tests/goldens/verticalTactileSurfaceReveal_gesture_dragClose.json deleted file mode 100644 index fa777a8..0000000 --- a/mechanics/compose/tests/goldens/verticalTactileSurfaceReveal_gesture_dragClose.json +++ /dev/null @@ -1,1288 +0,0 @@ -{ - "frame_ids": [ - "before", - 0, - 16, - 32, - 48, - 64, - 80, - 96, - 112, - 128, - 144, - 160, - 176, - 192, - 208, - 224, - 240, - 256, - 272, - 288, - 304, - 320, - 336, - 352, - 368, - 384, - 400, - 416, - 432, - 448, - 464, - 480, - 496, - 512, - 528, - 544, - 560, - 576, - 592, - 608, - 624, - 640, - 656, - 672, - 688, - 704, - 720, - 736, - 752, - 768, - 784, - 800, - 816, - 832, - 848, - 864, - 880, - 896, - 912, - "after" - ], - "features": [ - { - "name": "isTransitioning", - "type": "int", - "data_points": [ - 0, - 0, - 0, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 0 - ] - }, - { - "name": "ContainerElement_height", - "type": "dp", - "data_points": [ - 300, - 300, - 300, - 300, - 292.8, - 292.8, - 286, - 279.6, - 273.2, - 266.8, - 260.4, - 254, - 247.6, - 241.2, - 241.2, - 234.4, - 228, - 221.6, - 215.2, - 208.8, - 202.4, - 196, - 189.6, - 189.6, - 182.8, - 176.4, - 170, - 163.6, - 157.2, - 150.8, - 144.4, - 138, - 138, - 131.2, - 124.8, - 118.4, - 112, - 112, - 103.2, - 91.2, - 78.8, - 66.4, - 54.8, - 44.8, - 35.6, - 28, - 22, - 16.8, - 12.8, - 9.2, - 6.8, - 4.8, - 3.2, - 2.4, - 1.6, - 0.8, - 0.4, - 0.4, - 0, - { - "type": "not_found" - } - ] - }, - { - "name": "box0_y", - "type": "dp", - "data_points": [ - 4, - 4, - 4, - 4, - 4, - 4, - 4, - 4, - 4, - 4, - 4, - 4, - 4, - 4, - 4, - 4, - 4, - 4, - 4, - 4, - 4, - 4, - 4, - 4, - 4, - 4, - 4, - 4, - 4, - 4, - 4, - 4, - 4, - 4, - 4, - 4, - 4, - 4, - 4, - 4, - 4, - 4, - 4, - 4, - 4, - 4, - 4, - 4, - 4, - 4, - 4, - 4, - 4, - 4, - 4, - 4, - 4, - 4, - 4, - { - "type": "not_found" - } - ] - }, - { - "name": "box0_height", - "type": "dp", - "data_points": [ - 50, - 50, - 50, - 50, - 50, - 50, - 50, - 50, - 50, - 50, - 50, - 50, - 50, - 50, - 50, - 50, - 50, - 50, - 50, - 50, - 50, - 50, - 50, - 50, - 50, - 50, - 50, - 50, - 50, - 50, - 50, - 50, - 50, - 50, - 50, - 50, - 50, - 50, - 50, - 50, - 50, - 50, - 50, - 40.8, - 31.6, - 24, - 18, - 12.8, - 8.8, - 5.2, - 3.2, - 1.6, - 0.8, - 0, - 0, - 0, - 0, - 0, - 0, - { - "type": "not_found" - } - ] - }, - { - "name": "box1_y", - "type": "dp", - "data_points": [ - 62, - 62, - 62, - 62, - 62, - 62, - 62, - 62, - 62, - 62, - 62, - 62, - 62, - 62, - 62, - 62, - 62, - 62, - 62, - 62, - 62, - 62, - 62, - 62, - 62, - 62, - 62, - 62, - 62, - 62, - 62, - 62, - 62, - 62, - 62, - 62, - 62, - 62, - 62, - 62, - 62, - 62, - 62, - 52.8, - 43.6, - 36, - 30, - 24.8, - 20.8, - 17.2, - 15.2, - 13.6, - 12.8, - 12, - 12, - 12, - 12, - 12, - 12, - { - "type": "not_found" - } - ] - }, - { - "name": "box1_height", - "type": "dp", - "data_points": [ - 50, - 50, - 50, - 50, - 50, - 50, - 50, - 50, - 50, - 50, - 50, - 50, - 50, - 50, - 50, - 50, - 50, - 50, - 50, - 50, - 50, - 50, - 50, - 50, - 50, - 50, - 50, - 50, - 50, - 50, - 50, - 50, - 50, - 50, - 50, - 50, - 50, - 50, - 41.2, - 29.2, - 16.8, - 4.8, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - { - "type": "not_found" - } - ] - }, - { - "name": "box2_y", - "type": "dp", - "data_points": [ - 120, - 120, - 120, - 120, - 120, - 120, - 120, - 120, - 120, - 120, - 120, - 120, - 120, - 120, - 120, - 120, - 120, - 120, - 120, - 120, - 120, - 120, - 120, - 120, - 120, - 120, - 120, - 120, - 120, - 120, - 120, - 120, - 120, - 120, - 120, - 120, - 120, - 120, - 111.2, - 99.2, - 86.8, - 74.8, - 70, - 60.8, - 51.6, - 44, - 38, - 32.8, - 28.8, - 25.2, - 23.2, - 21.6, - 20.8, - 20, - 20, - 20, - 20, - 20, - 20, - { - "type": "not_found" - } - ] - }, - { - "name": "box2_height", - "type": "dp", - "data_points": [ - 50, - 50, - 50, - 50, - 50, - 50, - 50, - 50, - 50, - 50, - 50, - 50, - 50, - 50, - 50, - 50, - 50, - 50, - 50, - 50, - 50, - 50, - 50, - 50, - 50, - 50, - 50, - 43.6, - 37.2, - 30.8, - 24.4, - 18, - 18, - 11.2, - 8, - 5.6, - 6.8, - 5.6, - 4.4, - 3.2, - 2.4, - 1.6, - 1.2, - 0.8, - 0.4, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - { - "type": "not_found" - } - ] - }, - { - "name": "box3_y", - "type": "dp", - "data_points": [ - 178, - 178, - 178, - 178, - 178, - 178, - 178, - 178, - 178, - 178, - 178, - 178, - 178, - 178, - 178, - 178, - 178, - 178, - 178, - 178, - 178, - 178, - 178, - 178, - 178, - 178, - 178, - 171.6, - 165.2, - 158.8, - 152.4, - 146, - 146, - 139.2, - 136, - 133.6, - 134.8, - 133.6, - 123.6, - 110.4, - 97.2, - 84.4, - 79.2, - 69.6, - 60, - 52, - 46, - 40.8, - 36.8, - 33.2, - 31.2, - 29.6, - 28.8, - 28, - 28, - 28, - 28, - 28, - 28, - { - "type": "not_found" - } - ] - }, - { - "name": "box3_height", - "type": "dp", - "data_points": [ - 50, - 50, - 50, - 50, - 50, - 50, - 50, - 50, - 50, - 50, - 50, - 50, - 50, - 50, - 50, - 50, - 50, - 43.6, - 37.2, - 30.8, - 24.4, - 18, - 11.6, - 11.6, - 8, - 7.6, - 6.8, - 5.6, - 4.4, - 3.2, - 2.4, - 1.6, - 1.2, - 0.8, - 0.4, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - { - "type": "not_found" - } - ] - }, - { - "name": "box4_y", - "type": "dp", - "data_points": [ - 236, - 236, - 236, - 236, - 236, - 236, - 236, - 236, - 236, - 236, - 236, - 236, - 236, - 236, - 236, - 236, - 236, - 229.6, - 223.2, - 216.8, - 210.4, - 204, - 197.6, - 197.6, - 194, - 193.6, - 192.8, - 185.2, - 177.6, - 170, - 162.8, - 155.6, - 155.2, - 148, - 144.4, - 141.6, - 142.8, - 141.6, - 131.6, - 118.4, - 105.2, - 92.4, - 87.2, - 77.6, - 68, - 60, - 54, - 48.8, - 44.8, - 41.2, - 39.2, - 37.6, - 36.8, - 36, - 36, - 36, - 36, - 36, - 36, - { - "type": "not_found" - } - ] - }, - { - "name": "box4_height", - "type": "dp", - "data_points": [ - 50, - 50, - 50, - 50, - 50, - 50, - 50, - 43.6, - 37.2, - 30.8, - 24.4, - 18, - 11.6, - 6.8, - 5.6, - 1.6, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - { - "type": "not_found" - } - ] - }, - { - "name": "box5_y", - "type": "dp", - "data_points": [ - 294, - 294, - 294, - 294, - 294, - 294, - 294, - 287.6, - 281.2, - 274.8, - 268.4, - 262, - 255.6, - 250.8, - 249.6, - 245.6, - 244, - 237.6, - 231.2, - 224.8, - 218.4, - 212, - 205.6, - 205.6, - 202, - 201.6, - 200.8, - 193.2, - 185.6, - 178, - 170.8, - 163.6, - 163.2, - 156, - 152.4, - 149.6, - 150.8, - 149.6, - 139.6, - 126.4, - 113.2, - 100.4, - 95.2, - 85.6, - 76, - 68, - 62, - 56.8, - 52.8, - 49.2, - 47.2, - 45.6, - 44.8, - 44, - 44, - 44, - 44, - 44, - 44, - { - "type": "not_found" - } - ] - }, - { - "name": "box5_height", - "type": "dp", - "data_points": [ - 50, - 50, - 50, - 46.4, - 23.2, - 16.8, - 11.6, - 8, - 5.2, - 3.2, - 2, - 1.2, - 0.4, - 0.4, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - { - "type": "not_found" - } - ] - }, - { - "name": "box6_y", - "type": "dp", - "data_points": [ - 352, - 352, - 352, - 348.4, - 325.2, - 318.8, - 313.6, - 303.6, - 294.4, - 286, - 278.4, - 271.2, - 264, - 259.2, - 257.6, - 253.6, - 252, - 245.6, - 239.2, - 232.8, - 226.4, - 220, - 213.6, - 213.6, - 210, - 209.6, - 208.8, - 201.2, - 193.6, - 186, - 178.8, - 171.6, - 171.2, - 164, - 160.4, - 157.6, - 158.8, - 157.6, - 147.6, - 134.4, - 121.2, - 108.4, - 103.2, - 93.6, - 84, - 76, - 70, - 64.8, - 60.8, - 57.2, - 55.2, - 53.6, - 52.8, - 52, - 52, - 52, - 52, - 52, - 52, - { - "type": "not_found" - } - ] - }, - { - "name": "box6_height", - "type": "dp", - "data_points": [ - 50, - 50, - 50, - 46.4, - 23.2, - 16.8, - 11.6, - 8, - 5.2, - 3.2, - 2, - 1.2, - 0.4, - 0.4, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - { - "type": "not_found" - } - ] - }, - { - "name": "box7_y", - "type": "dp", - "data_points": [ - 410, - 410, - 410, - 402.8, - 356.4, - 343.6, - 333.2, - 319.6, - 307.6, - 297.2, - 288.4, - 280.4, - 272.4, - 267.6, - 265.6, - 261.6, - 260, - 253.6, - 247.2, - 240.8, - 234.4, - 228, - 221.6, - 221.6, - 218, - 217.6, - 216.8, - 209.2, - 201.6, - 194, - 186.8, - 179.6, - 179.2, - 172, - 168.4, - 165.6, - 166.8, - 165.6, - 155.6, - 142.4, - 129.2, - 116.4, - 111.2, - 101.6, - 92, - 84, - 78, - 72.8, - 68.8, - 65.2, - 63.2, - 61.6, - 60.8, - 60, - 60, - 60, - 60, - 60, - 60, - { - "type": "not_found" - } - ] - }, - { - "name": "box7_height", - "type": "dp", - "data_points": [ - 50, - 50, - 50, - 46.4, - 23.2, - 16.8, - 11.6, - 8, - 5.2, - 3.2, - 2, - 1.2, - 0.4, - 0.4, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - { - "type": "not_found" - } - ] - } - ] -} \ No newline at end of file diff --git a/mechanics/compose/tests/goldens/verticalTactileSurfaceReveal_gesture_dragOpen.json b/mechanics/compose/tests/goldens/verticalTactileSurfaceReveal_gesture_dragOpen.json deleted file mode 100644 index 70c8b86..0000000 --- a/mechanics/compose/tests/goldens/verticalTactileSurfaceReveal_gesture_dragOpen.json +++ /dev/null @@ -1,1390 +0,0 @@ -{ - "frame_ids": [ - "before", - 0, - 16, - 32, - 48, - 64, - 80, - 96, - 112, - 128, - 144, - 160, - 176, - 192, - 208, - 224, - 240, - 256, - 272, - 288, - 304, - 320, - 336, - 352, - 368, - 384, - 400, - 416, - 432, - 448, - 464, - 480, - 496, - 512, - 528, - 544, - 560, - 576, - 592, - 608, - 624, - 640, - 656, - 672, - 688, - 704, - 720, - 736, - 752, - 768, - 784, - 800, - 816, - 832, - 848, - 864, - 880, - 896, - 912, - "after" - ], - "features": [ - { - "name": "isTransitioning", - "type": "int", - "data_points": [ - 0, - 0, - 0, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 0 - ] - }, - { - "name": "ContainerElement_height", - "type": "dp", - "data_points": [ - { - "type": "not_found" - }, - { - "type": "not_found" - }, - { - "type": "not_found" - }, - { - "type": "not_found" - }, - 7.2, - 7.2, - 14, - 20.4, - 26.8, - 33.2, - 39.6, - 46, - 52.4, - 58.8, - 58.8, - 65.6, - 72, - 78.4, - 84.8, - 91.2, - 97.6, - 104, - 110.4, - 110.4, - 117.2, - 123.6, - 130, - 136.4, - 142.8, - 149.2, - 155.6, - 162, - 162, - 168.8, - 175.2, - 181.6, - 188, - 188, - 196.8, - 208.8, - 221.2, - 233.6, - 245.2, - 255.2, - 264.4, - 272, - 278, - 283.2, - 287.2, - 290.8, - 293.2, - 295.2, - 296.8, - 297.6, - 298.4, - 299.2, - 299.6, - 299.6, - 300, - 300 - ] - }, - { - "name": "box0_y", - "type": "dp", - "data_points": [ - { - "type": "not_found" - }, - { - "type": "not_found" - }, - { - "type": "not_found" - }, - { - "type": "not_found" - }, - 4, - 4, - 4, - 4, - 4, - 4, - 4, - 4, - 4, - 4, - 4, - 4, - 4, - 4, - 4, - 4, - 4, - 4, - 4, - 4, - 4, - 4, - 4, - 4, - 4, - 4, - 4, - 4, - 4, - 4, - 4, - 4, - 4, - 4, - 4, - 4, - 4, - 4, - 4, - 4, - 4, - 4, - 4, - 4, - 4, - 4, - 4, - 4, - 4, - 4, - 4, - 4, - 4, - 4, - 4, - 4 - ] - }, - { - "name": "box0_height", - "type": "dp", - "data_points": [ - { - "type": "not_found" - }, - { - "type": "not_found" - }, - { - "type": "not_found" - }, - { - "type": "not_found" - }, - 0, - 0, - 2, - 8.8, - 15.6, - 23.2, - 31.2, - 38.8, - 46, - 48.4, - 48.8, - 49.2, - 49.6, - 49.6, - 50, - 50, - 50, - 50, - 50, - 50, - 50, - 50, - 50, - 50, - 50, - 50, - 50, - 50, - 50, - 50, - 50, - 50, - 50, - 50, - 50, - 50, - 50, - 50, - 50, - 50, - 50, - 50, - 50, - 50, - 50, - 50, - 50, - 50, - 50, - 50, - 50, - 50, - 50, - 50, - 50, - 50 - ] - }, - { - "name": "box1_y", - "type": "dp", - "data_points": [ - { - "type": "not_found" - }, - { - "type": "not_found" - }, - { - "type": "not_found" - }, - { - "type": "not_found" - }, - 12, - 12, - 14, - 20.8, - 27.6, - 35.2, - 43.2, - 50.8, - 58, - 60.4, - 60.8, - 61.2, - 61.6, - 61.6, - 62, - 62, - 62, - 62, - 62, - 62, - 62, - 62, - 62, - 62, - 62, - 62, - 62, - 62, - 62, - 62, - 62, - 62, - 62, - 62, - 62, - 62, - 62, - 62, - 62, - 62, - 62, - 62, - 62, - 62, - 62, - 62, - 62, - 62, - 62, - 62, - 62, - 62, - 62, - 62, - 62, - 62 - ] - }, - { - "name": "box1_height", - "type": "dp", - "data_points": [ - { - "type": "not_found" - }, - { - "type": "not_found" - }, - { - "type": "not_found" - }, - { - "type": "not_found" - }, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 2, - 9.6, - 15.6, - 23.2, - 31.2, - 38.8, - 46, - 46.8, - 48.8, - 49.2, - 49.6, - 49.6, - 50, - 50, - 50, - 50, - 50, - 50, - 50, - 50, - 50, - 50, - 50, - 50, - 50, - 50, - 50, - 50, - 50, - 50, - 50, - 50, - 50, - 50, - 50, - 50, - 50, - 50, - 50, - 50, - 50, - 50, - 50, - 50 - ] - }, - { - "name": "box2_y", - "type": "dp", - "data_points": [ - { - "type": "not_found" - }, - { - "type": "not_found" - }, - { - "type": "not_found" - }, - { - "type": "not_found" - }, - 20, - 20, - 22, - 28.8, - 35.6, - 43.2, - 51.2, - 58.8, - 66, - 68.4, - 68.8, - 69.2, - 71.6, - 79.2, - 85.6, - 93.2, - 101.2, - 108.8, - 116, - 116.8, - 118.8, - 119.2, - 119.6, - 119.6, - 120, - 120, - 120, - 120, - 120, - 120, - 120, - 120, - 120, - 120, - 120, - 120, - 120, - 120, - 120, - 120, - 120, - 120, - 120, - 120, - 120, - 120, - 120, - 120, - 120, - 120, - 120, - 120, - 120, - 120, - 120, - 120 - ] - }, - { - "name": "box2_height", - "type": "dp", - "data_points": [ - { - "type": "not_found" - }, - { - "type": "not_found" - }, - { - "type": "not_found" - }, - { - "type": "not_found" - }, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 2, - 9.6, - 15.6, - 23.2, - 31.2, - 38.8, - 39.6, - 47.2, - 48.8, - 49.2, - 49.6, - 49.6, - 50, - 50, - 50, - 50, - 50, - 50, - 50, - 50, - 50, - 50, - 50, - 50, - 50, - 50, - 50, - 50, - 50, - 50, - 50, - 50, - 50, - 50 - ] - }, - { - "name": "box3_y", - "type": "dp", - "data_points": [ - { - "type": "not_found" - }, - { - "type": "not_found" - }, - { - "type": "not_found" - }, - { - "type": "not_found" - }, - 28, - 28, - 30, - 36.8, - 43.6, - 51.2, - 59.2, - 66.8, - 74, - 76.4, - 76.8, - 77.2, - 79.6, - 87.2, - 93.6, - 101.2, - 109.2, - 116.8, - 124, - 124.8, - 126.8, - 127.2, - 129.6, - 137.2, - 143.6, - 151.2, - 159.2, - 166.8, - 167.6, - 175.2, - 176.8, - 177.2, - 177.6, - 177.6, - 178, - 178, - 178, - 178, - 178, - 178, - 178, - 178, - 178, - 178, - 178, - 178, - 178, - 178, - 178, - 178, - 178, - 178, - 178, - 178, - 178, - 178 - ] - }, - { - "name": "box3_height", - "type": "dp", - "data_points": [ - { - "type": "not_found" - }, - { - "type": "not_found" - }, - { - "type": "not_found" - }, - { - "type": "not_found" - }, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 2, - 2, - 11.6, - 24.8, - 38.8, - 46.8, - 47.6, - 48.4, - 48.8, - 49.2, - 49.6, - 49.6, - 50, - 50, - 50, - 50, - 50, - 50, - 50, - 50, - 50, - 50, - 50, - 50 - ] - }, - { - "name": "box4_y", - "type": "dp", - "data_points": [ - { - "type": "not_found" - }, - { - "type": "not_found" - }, - { - "type": "not_found" - }, - { - "type": "not_found" - }, - 36, - 36, - 38, - 44.8, - 51.6, - 59.2, - 67.2, - 74.8, - 82, - 84.4, - 84.8, - 85.2, - 87.6, - 95.2, - 101.6, - 109.2, - 117.2, - 124.8, - 132, - 132.8, - 134.8, - 135.2, - 137.6, - 145.2, - 151.6, - 159.2, - 167.2, - 174.8, - 175.6, - 183.2, - 184.8, - 185.2, - 187.6, - 187.6, - 197.6, - 210.8, - 224.8, - 232.8, - 233.6, - 234.4, - 234.8, - 235.2, - 235.6, - 235.6, - 236, - 236, - 236, - 236, - 236, - 236, - 236, - 236, - 236, - 236, - 236, - 236 - ] - }, - { - "name": "box4_height", - "type": "dp", - "data_points": [ - { - "type": "not_found" - }, - { - "type": "not_found" - }, - { - "type": "not_found" - }, - { - "type": "not_found" - }, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 1.2, - 12, - 22.4, - 31.2, - 38.4, - 44.8, - 48.4, - 48.8, - 49.2, - 49.6, - 49.6, - 50, - 50, - 50, - 50, - 50, - 50, - 50 - ] - }, - { - "name": "box5_y", - "type": "dp", - "data_points": [ - { - "type": "not_found" - }, - { - "type": "not_found" - }, - { - "type": "not_found" - }, - { - "type": "not_found" - }, - 44, - 44, - 46, - 52.8, - 59.6, - 67.2, - 75.2, - 82.8, - 90, - 92.4, - 92.8, - 93.2, - 95.6, - 103.2, - 109.6, - 117.2, - 125.2, - 132.8, - 140, - 140.8, - 142.8, - 143.2, - 145.6, - 153.2, - 159.6, - 167.2, - 175.2, - 182.8, - 183.6, - 191.2, - 192.8, - 193.2, - 195.6, - 195.6, - 205.6, - 218.8, - 232.8, - 240.8, - 242.8, - 254.4, - 265.2, - 274.4, - 282, - 288.4, - 292.4, - 292.8, - 293.2, - 293.6, - 293.6, - 294, - 294, - 294, - 294, - 294, - 294, - 294 - ] - }, - { - "name": "box5_height", - "type": "dp", - "data_points": [ - { - "type": "not_found" - }, - { - "type": "not_found" - }, - { - "type": "not_found" - }, - { - "type": "not_found" - }, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 50 - ] - }, - { - "name": "box6_y", - "type": "dp", - "data_points": [ - { - "type": "not_found" - }, - { - "type": "not_found" - }, - { - "type": "not_found" - }, - { - "type": "not_found" - }, - 52, - 52, - 54, - 60.8, - 67.6, - 75.2, - 83.2, - 90.8, - 98, - 100.4, - 100.8, - 101.2, - 103.6, - 111.2, - 117.6, - 125.2, - 133.2, - 140.8, - 148, - 148.8, - 150.8, - 151.2, - 153.6, - 161.2, - 167.6, - 175.2, - 183.2, - 190.8, - 191.6, - 199.2, - 200.8, - 201.2, - 203.6, - 203.6, - 213.6, - 226.8, - 240.8, - 248.8, - 250.8, - 262.4, - 273.2, - 282.4, - 290, - 296.4, - 300.4, - 300.8, - 301.2, - 301.6, - 301.6, - 302, - 302, - 302, - 302, - 302, - 302, - 352 - ] - }, - { - "name": "box6_height", - "type": "dp", - "data_points": [ - { - "type": "not_found" - }, - { - "type": "not_found" - }, - { - "type": "not_found" - }, - { - "type": "not_found" - }, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 50 - ] - }, - { - "name": "box7_y", - "type": "dp", - "data_points": [ - { - "type": "not_found" - }, - { - "type": "not_found" - }, - { - "type": "not_found" - }, - { - "type": "not_found" - }, - 60, - 60, - 62, - 68.8, - 75.6, - 83.2, - 91.2, - 98.8, - 106, - 108.4, - 108.8, - 109.2, - 111.6, - 119.2, - 125.6, - 133.2, - 141.2, - 148.8, - 156, - 156.8, - 158.8, - 159.2, - 161.6, - 169.2, - 175.6, - 183.2, - 191.2, - 198.8, - 199.6, - 207.2, - 208.8, - 209.2, - 211.6, - 211.6, - 221.6, - 234.8, - 248.8, - 256.8, - 258.8, - 270.4, - 281.2, - 290.4, - 298, - 304.4, - 308.4, - 308.8, - 309.2, - 309.6, - 309.6, - 310, - 310, - 310, - 310, - 310, - 310, - 410 - ] - }, - { - "name": "box7_height", - "type": "dp", - "data_points": [ - { - "type": "not_found" - }, - { - "type": "not_found" - }, - { - "type": "not_found" - }, - { - "type": "not_found" - }, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 50 - ] - } - ] -} \ No newline at end of file diff --git a/mechanics/compose/tests/goldens/verticalTactileSurfaceReveal_gesture_flingClose.json b/mechanics/compose/tests/goldens/verticalTactileSurfaceReveal_gesture_flingClose.json deleted file mode 100644 index 9273375..0000000 --- a/mechanics/compose/tests/goldens/verticalTactileSurfaceReveal_gesture_flingClose.json +++ /dev/null @@ -1,870 +0,0 @@ -{ - "frame_ids": [ - "before", - 0, - 16, - 32, - 48, - 64, - 80, - 96, - 112, - 128, - 144, - 160, - 176, - 192, - 208, - 224, - 240, - 256, - 272, - 288, - 304, - 320, - 336, - 352, - 368, - 384, - 400, - 416, - 432, - 448, - 464, - 480, - 496, - 512, - 528, - 544, - 560, - "after" - ], - "features": [ - { - "name": "isTransitioning", - "type": "int", - "data_points": [ - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 0 - ] - }, - { - "name": "ContainerElement_height", - "type": "dp", - "data_points": [ - 300, - 300, - 300, - 300, - 300, - 300, - 300, - 300, - 300, - 300, - 300, - 289.2, - 278.8, - 266, - 252, - 252, - 232, - 205.6, - 177.6, - 149.6, - 124, - 100.8, - 80.4, - 63.6, - 49.2, - 37.6, - 28.4, - 21.2, - 15.2, - 10.8, - 7.6, - 5.2, - 3.2, - 2, - 1.2, - 0.4, - 0, - { - "type": "not_found" - } - ] - }, - { - "name": "box0_y", - "type": "dp", - "data_points": [ - 4, - 4, - 4, - 4, - 4, - 4, - 4, - 4, - 4, - 4, - 4, - 4, - 4, - 4, - 4, - 4, - 4, - 4, - 4, - 4, - 4, - 4, - 4, - 4, - 4, - 4, - 4, - 4, - 4, - 4, - 4, - 4, - 4, - 4, - 4, - 4, - 4, - { - "type": "not_found" - } - ] - }, - { - "name": "box0_height", - "type": "dp", - "data_points": [ - 50, - 50, - 50, - 50, - 50, - 50, - 50, - 50, - 50, - 50, - 50, - 50, - 50, - 50, - 50, - 50, - 50, - 50, - 50, - 50, - 50, - 50, - 50, - 50, - 45.2, - 33.6, - 24.4, - 17.2, - 11.2, - 6.4, - 2.4, - 0.4, - 0, - 0, - 0, - 0, - 0, - { - "type": "not_found" - } - ] - }, - { - "name": "box1_y", - "type": "dp", - "data_points": [ - 62, - 62, - 62, - 62, - 62, - 62, - 62, - 62, - 62, - 62, - 62, - 62, - 62, - 62, - 62, - 62, - 62, - 62, - 62, - 62, - 62, - 62, - 62, - 62, - 57.2, - 45.6, - 36.4, - 29.2, - 23.2, - 18.4, - 14.4, - 12.4, - 12, - 12, - 12, - 12, - 12, - { - "type": "not_found" - } - ] - }, - { - "name": "box1_height", - "type": "dp", - "data_points": [ - 50, - 50, - 50, - 50, - 50, - 50, - 50, - 50, - 50, - 50, - 50, - 50, - 50, - 50, - 50, - 50, - 50, - 50, - 50, - 50, - 50, - 38.8, - 18.4, - 4.8, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - { - "type": "not_found" - } - ] - }, - { - "name": "box2_y", - "type": "dp", - "data_points": [ - 120, - 120, - 120, - 120, - 120, - 120, - 120, - 120, - 120, - 120, - 120, - 120, - 120, - 120, - 120, - 120, - 120, - 120, - 120, - 120, - 120, - 108.8, - 88.4, - 74.8, - 65.2, - 53.6, - 44.4, - 37.2, - 31.2, - 26.4, - 22.4, - 20.4, - 20, - 20, - 20, - 20, - 20, - { - "type": "not_found" - } - ] - }, - { - "name": "box2_height", - "type": "dp", - "data_points": [ - 50, - 50, - 50, - 50, - 50, - 50, - 50, - 50, - 50, - 50, - 50, - 50, - 50, - 50, - 50, - 50, - 50, - 50, - 50, - 29.6, - 4.8, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - { - "type": "not_found" - } - ] - }, - { - "name": "box3_y", - "type": "dp", - "data_points": [ - 178, - 178, - 178, - 178, - 178, - 178, - 178, - 178, - 178, - 178, - 178, - 178, - 178, - 178, - 178, - 178, - 178, - 178, - 178, - 157.6, - 132.8, - 116.8, - 96.4, - 82.8, - 73.2, - 61.6, - 52.4, - 45.2, - 39.2, - 34.4, - 30.4, - 28.4, - 28, - 28, - 28, - 28, - 28, - { - "type": "not_found" - } - ] - }, - { - "name": "box3_height", - "type": "dp", - "data_points": [ - 50, - 50, - 50, - 50, - 50, - 50, - 50, - 50, - 50, - 50, - 50, - 50, - 50, - 50, - 50, - 50, - 50, - 27.6, - 2, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - { - "type": "not_found" - } - ] - }, - { - "name": "box4_y", - "type": "dp", - "data_points": [ - 236, - 236, - 236, - 236, - 236, - 236, - 236, - 236, - 236, - 236, - 236, - 236, - 236, - 236, - 236, - 236, - 236, - 213.6, - 188, - 165.6, - 140.8, - 124.8, - 104.4, - 90.8, - 81.2, - 69.6, - 60.4, - 53.2, - 47.2, - 42.4, - 38.4, - 36.4, - 36, - 36, - 36, - 36, - 36, - { - "type": "not_found" - } - ] - }, - { - "name": "box4_height", - "type": "dp", - "data_points": [ - 50, - 50, - 50, - 50, - 50, - 50, - 50, - 50, - 50, - 50, - 50, - 50, - 42.8, - 30, - 16, - 16, - 1.2, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - { - "type": "not_found" - } - ] - }, - { - "name": "box5_y", - "type": "dp", - "data_points": [ - 294, - 294, - 294, - 294, - 294, - 294, - 294, - 294, - 294, - 294, - 294, - 294, - 286.8, - 274, - 260, - 260, - 245.2, - 221.6, - 196, - 173.6, - 148.8, - 132.8, - 112.4, - 98.8, - 89.2, - 77.6, - 68.4, - 61.2, - 55.2, - 50.4, - 46.4, - 44.4, - 44, - 44, - 44, - 44, - 44, - { - "type": "not_found" - } - ] - }, - { - "name": "box5_height", - "type": "dp", - "data_points": [ - 50, - 50, - 50, - 50, - 50, - 50, - 50, - 50, - 50, - 50, - 46.4, - 1.2, - 0.4, - 0.4, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - { - "type": "not_found" - } - ] - }, - { - "name": "box6_y", - "type": "dp", - "data_points": [ - 352, - 352, - 352, - 352, - 352, - 352, - 352, - 352, - 352, - 352, - 348.4, - 303.2, - 295.2, - 282.4, - 268, - 268, - 253.2, - 229.6, - 204, - 181.6, - 156.8, - 140.8, - 120.4, - 106.8, - 97.2, - 85.6, - 76.4, - 69.2, - 63.2, - 58.4, - 54.4, - 52.4, - 52, - 52, - 52, - 52, - 52, - { - "type": "not_found" - } - ] - }, - { - "name": "box6_height", - "type": "dp", - "data_points": [ - 50, - 50, - 50, - 50, - 50, - 50, - 50, - 50, - 50, - 50, - 46.4, - 1.2, - 0.4, - 0.4, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - { - "type": "not_found" - } - ] - }, - { - "name": "box7_y", - "type": "dp", - "data_points": [ - 410, - 410, - 410, - 410, - 410, - 410, - 410, - 410, - 410, - 410, - 402.8, - 312.4, - 303.6, - 290.8, - 276, - 276, - 261.2, - 237.6, - 212, - 189.6, - 164.8, - 148.8, - 128.4, - 114.8, - 105.2, - 93.6, - 84.4, - 77.2, - 71.2, - 66.4, - 62.4, - 60.4, - 60, - 60, - 60, - 60, - 60, - { - "type": "not_found" - } - ] - }, - { - "name": "box7_height", - "type": "dp", - "data_points": [ - 50, - 50, - 50, - 50, - 50, - 50, - 50, - 50, - 50, - 50, - 46.4, - 1.2, - 0.4, - 0.4, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - { - "type": "not_found" - } - ] - } - ] -} \ No newline at end of file diff --git a/mechanics/compose/tests/goldens/verticalTactileSurfaceReveal_gesture_flingOpen.json b/mechanics/compose/tests/goldens/verticalTactileSurfaceReveal_gesture_flingOpen.json deleted file mode 100644 index 7e2b2c7..0000000 --- a/mechanics/compose/tests/goldens/verticalTactileSurfaceReveal_gesture_flingOpen.json +++ /dev/null @@ -1,1142 +0,0 @@ -{ - "frame_ids": [ - "before", - 0, - 16, - 32, - 48, - 64, - 80, - 96, - 112, - 128, - 144, - 160, - 176, - 192, - 208, - 224, - 240, - 256, - 272, - 288, - 304, - 320, - 336, - 352, - 368, - 384, - 400, - 416, - 432, - 448, - 464, - 480, - 496, - 512, - 528, - 544, - 560, - "after" - ], - "features": [ - { - "name": "isTransitioning", - "type": "int", - "data_points": [ - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 0 - ] - }, - { - "name": "ContainerElement_height", - "type": "dp", - "data_points": [ - { - "type": "not_found" - }, - { - "type": "not_found" - }, - { - "type": "not_found" - }, - { - "type": "not_found" - }, - { - "type": "not_found" - }, - { - "type": "not_found" - }, - { - "type": "not_found" - }, - { - "type": "not_found" - }, - { - "type": "not_found" - }, - 9.6, - 18, - 28.8, - 40, - 53.6, - 68, - 68, - 87.6, - 112.4, - 138.4, - 164, - 188, - 209.2, - 227.2, - 242.8, - 255.6, - 266, - 274.4, - 281.2, - 286.4, - 290.4, - 293.2, - 295.6, - 297.2, - 298.4, - 299.2, - 299.6, - 300, - 300 - ] - }, - { - "name": "box0_y", - "type": "dp", - "data_points": [ - { - "type": "not_found" - }, - { - "type": "not_found" - }, - { - "type": "not_found" - }, - { - "type": "not_found" - }, - { - "type": "not_found" - }, - { - "type": "not_found" - }, - { - "type": "not_found" - }, - { - "type": "not_found" - }, - { - "type": "not_found" - }, - 4, - 4, - 4, - 4, - 4, - 4, - 4, - 4, - 4, - 4, - 4, - 4, - 4, - 4, - 4, - 4, - 4, - 4, - 4, - 4, - 4, - 4, - 4, - 4, - 4, - 4, - 4, - 4, - 4 - ] - }, - { - "name": "box0_height", - "type": "dp", - "data_points": [ - { - "type": "not_found" - }, - { - "type": "not_found" - }, - { - "type": "not_found" - }, - { - "type": "not_found" - }, - { - "type": "not_found" - }, - { - "type": "not_found" - }, - { - "type": "not_found" - }, - { - "type": "not_found" - }, - { - "type": "not_found" - }, - 0, - 10.4, - 21.2, - 29.2, - 44.4, - 46, - 47.2, - 48, - 48.4, - 49.2, - 49.6, - 49.6, - 50, - 50, - 50, - 50, - 50, - 50, - 50, - 50, - 50, - 50, - 50, - 50, - 50, - 50, - 50, - 50, - 50 - ] - }, - { - "name": "box1_y", - "type": "dp", - "data_points": [ - { - "type": "not_found" - }, - { - "type": "not_found" - }, - { - "type": "not_found" - }, - { - "type": "not_found" - }, - { - "type": "not_found" - }, - { - "type": "not_found" - }, - { - "type": "not_found" - }, - { - "type": "not_found" - }, - { - "type": "not_found" - }, - 12, - 22.4, - 33.2, - 41.2, - 56.4, - 58, - 59.2, - 60, - 60.4, - 61.2, - 61.6, - 61.6, - 62, - 62, - 62, - 62, - 62, - 62, - 62, - 62, - 62, - 62, - 62, - 62, - 62, - 62, - 62, - 62, - 62 - ] - }, - { - "name": "box1_height", - "type": "dp", - "data_points": [ - { - "type": "not_found" - }, - { - "type": "not_found" - }, - { - "type": "not_found" - }, - { - "type": "not_found" - }, - { - "type": "not_found" - }, - { - "type": "not_found" - }, - { - "type": "not_found" - }, - { - "type": "not_found" - }, - { - "type": "not_found" - }, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 18, - 43.6, - 44.8, - 46, - 47.2, - 48, - 48.8, - 49.2, - 49.6, - 49.6, - 50, - 50, - 50, - 50, - 50, - 50, - 50, - 50, - 50, - 50, - 50, - 50 - ] - }, - { - "name": "box2_y", - "type": "dp", - "data_points": [ - { - "type": "not_found" - }, - { - "type": "not_found" - }, - { - "type": "not_found" - }, - { - "type": "not_found" - }, - { - "type": "not_found" - }, - { - "type": "not_found" - }, - { - "type": "not_found" - }, - { - "type": "not_found" - }, - { - "type": "not_found" - }, - 20, - 30.4, - 41.2, - 49.2, - 64.4, - 66, - 67.2, - 86, - 112, - 114, - 115.6, - 116.8, - 118, - 118.8, - 119.2, - 119.6, - 119.6, - 120, - 120, - 120, - 120, - 120, - 120, - 120, - 120, - 120, - 120, - 120, - 120 - ] - }, - { - "name": "box2_height", - "type": "dp", - "data_points": [ - { - "type": "not_found" - }, - { - "type": "not_found" - }, - { - "type": "not_found" - }, - { - "type": "not_found" - }, - { - "type": "not_found" - }, - { - "type": "not_found" - }, - { - "type": "not_found" - }, - { - "type": "not_found" - }, - { - "type": "not_found" - }, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 10.4, - 36.8, - 44.4, - 45.6, - 46.8, - 47.6, - 48.4, - 48.8, - 49.2, - 49.6, - 49.6, - 50, - 50, - 50, - 50, - 50, - 50, - 50, - 50, - 50 - ] - }, - { - "name": "box3_y", - "type": "dp", - "data_points": [ - { - "type": "not_found" - }, - { - "type": "not_found" - }, - { - "type": "not_found" - }, - { - "type": "not_found" - }, - { - "type": "not_found" - }, - { - "type": "not_found" - }, - { - "type": "not_found" - }, - { - "type": "not_found" - }, - { - "type": "not_found" - }, - 28, - 38.4, - 49.2, - 57.2, - 72.4, - 74, - 75.2, - 94, - 120, - 132.4, - 160.4, - 169.2, - 171.6, - 173.6, - 174.8, - 176, - 176.4, - 177.2, - 177.6, - 177.6, - 178, - 178, - 178, - 178, - 178, - 178, - 178, - 178, - 178 - ] - }, - { - "name": "box3_height", - "type": "dp", - "data_points": [ - { - "type": "not_found" - }, - { - "type": "not_found" - }, - { - "type": "not_found" - }, - { - "type": "not_found" - }, - { - "type": "not_found" - }, - { - "type": "not_found" - }, - { - "type": "not_found" - }, - { - "type": "not_found" - }, - { - "type": "not_found" - }, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 2, - 24, - 43.2, - 45.2, - 46.4, - 47.2, - 48, - 48.8, - 49.2, - 49.6, - 49.6, - 50, - 50, - 50, - 50, - 50, - 50, - 50 - ] - }, - { - "name": "box4_y", - "type": "dp", - "data_points": [ - { - "type": "not_found" - }, - { - "type": "not_found" - }, - { - "type": "not_found" - }, - { - "type": "not_found" - }, - { - "type": "not_found" - }, - { - "type": "not_found" - }, - { - "type": "not_found" - }, - { - "type": "not_found" - }, - { - "type": "not_found" - }, - 36, - 46.4, - 57.2, - 65.2, - 80.4, - 82, - 83.2, - 102, - 128, - 140.4, - 168.4, - 179.2, - 203.6, - 224.8, - 228, - 230.4, - 231.6, - 233.2, - 234.4, - 234.8, - 235.6, - 235.6, - 236, - 236, - 236, - 236, - 236, - 236, - 236 - ] - }, - { - "name": "box4_height", - "type": "dp", - "data_points": [ - { - "type": "not_found" - }, - { - "type": "not_found" - }, - { - "type": "not_found" - }, - { - "type": "not_found" - }, - { - "type": "not_found" - }, - { - "type": "not_found" - }, - { - "type": "not_found" - }, - { - "type": "not_found" - }, - { - "type": "not_found" - }, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 12, - 23.6, - 33.2, - 41.2, - 47.2, - 48, - 48.8, - 49.2, - 49.6, - 49.6, - 50, - 50, - 50, - 50 - ] - }, - { - "name": "box5_y", - "type": "dp", - "data_points": [ - { - "type": "not_found" - }, - { - "type": "not_found" - }, - { - "type": "not_found" - }, - { - "type": "not_found" - }, - { - "type": "not_found" - }, - { - "type": "not_found" - }, - { - "type": "not_found" - }, - { - "type": "not_found" - }, - { - "type": "not_found" - }, - 44, - 54.4, - 65.2, - 73.2, - 88.4, - 90, - 91.2, - 110, - 136, - 148.4, - 176.4, - 187.2, - 211.6, - 232.8, - 236, - 250.4, - 263.2, - 274.4, - 283.6, - 290, - 291.6, - 292.4, - 293.2, - 293.6, - 293.6, - 294, - 294, - 294, - 294 - ] - }, - { - "name": "box5_height", - "type": "dp", - "data_points": [ - { - "type": "not_found" - }, - { - "type": "not_found" - }, - { - "type": "not_found" - }, - { - "type": "not_found" - }, - { - "type": "not_found" - }, - { - "type": "not_found" - }, - { - "type": "not_found" - }, - { - "type": "not_found" - }, - { - "type": "not_found" - }, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 50 - ] - }, - { - "name": "box6_y", - "type": "dp", - "data_points": [ - { - "type": "not_found" - }, - { - "type": "not_found" - }, - { - "type": "not_found" - }, - { - "type": "not_found" - }, - { - "type": "not_found" - }, - { - "type": "not_found" - }, - { - "type": "not_found" - }, - { - "type": "not_found" - }, - { - "type": "not_found" - }, - 52, - 62.4, - 73.2, - 81.2, - 96.4, - 98, - 99.2, - 118, - 144, - 156.4, - 184.4, - 195.2, - 219.6, - 240.8, - 244, - 258.4, - 271.2, - 282.4, - 291.6, - 298, - 299.6, - 300.4, - 301.2, - 301.6, - 301.6, - 302, - 302, - 302, - 352 - ] - }, - { - "name": "box6_height", - "type": "dp", - "data_points": [ - { - "type": "not_found" - }, - { - "type": "not_found" - }, - { - "type": "not_found" - }, - { - "type": "not_found" - }, - { - "type": "not_found" - }, - { - "type": "not_found" - }, - { - "type": "not_found" - }, - { - "type": "not_found" - }, - { - "type": "not_found" - }, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 50 - ] - }, - { - "name": "box7_y", - "type": "dp", - "data_points": [ - { - "type": "not_found" - }, - { - "type": "not_found" - }, - { - "type": "not_found" - }, - { - "type": "not_found" - }, - { - "type": "not_found" - }, - { - "type": "not_found" - }, - { - "type": "not_found" - }, - { - "type": "not_found" - }, - { - "type": "not_found" - }, - 60, - 70.4, - 81.2, - 89.2, - 104.4, - 106, - 107.2, - 126, - 152, - 164.4, - 192.4, - 203.2, - 227.6, - 248.8, - 252, - 266.4, - 279.2, - 290.4, - 299.6, - 306, - 307.6, - 308.4, - 309.2, - 309.6, - 309.6, - 310, - 310, - 310, - 410 - ] - }, - { - "name": "box7_height", - "type": "dp", - "data_points": [ - { - "type": "not_found" - }, - { - "type": "not_found" - }, - { - "type": "not_found" - }, - { - "type": "not_found" - }, - { - "type": "not_found" - }, - { - "type": "not_found" - }, - { - "type": "not_found" - }, - { - "type": "not_found" - }, - { - "type": "not_found" - }, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 50 - ] - } - ] -} \ No newline at end of file diff --git a/mechanics/compose/tests/src/com/android/mechanics/compose/modifier/VerticalTactileSurfaceRevealModifierTest.kt b/mechanics/compose/tests/src/com/android/mechanics/compose/modifier/VerticalTactileSurfaceRevealModifierTest.kt deleted file mode 100644 index 769acdd..0000000 --- a/mechanics/compose/tests/src/com/android/mechanics/compose/modifier/VerticalTactileSurfaceRevealModifierTest.kt +++ /dev/null @@ -1,308 +0,0 @@ -/* - * Copyright (C) 2025 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.compose.modifier - -import androidx.compose.foundation.background -import androidx.compose.foundation.border -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.verticalScroll -import androidx.compose.runtime.Composable -import androidx.compose.runtime.CompositionLocalProvider -import androidx.compose.ui.Modifier -import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.platform.testTag -import androidx.compose.ui.test.SemanticsNodeInteractionsProvider -import androidx.compose.ui.test.TouchInjectionScope -import androidx.compose.ui.test.hasTestTag -import androidx.compose.ui.test.onNodeWithTag -import androidx.compose.ui.test.swipeDown -import androidx.compose.ui.test.swipeUp -import androidx.compose.ui.test.swipeWithVelocity -import androidx.compose.ui.unit.DpSize -import androidx.compose.ui.unit.dp -import com.android.compose.animation.scene.ContentScope -import com.android.compose.animation.scene.ElementKey -import com.android.compose.animation.scene.MutableSceneTransitionLayoutState -import com.android.compose.animation.scene.OverlayKey -import com.android.compose.animation.scene.SceneKey -import com.android.compose.animation.scene.SceneTransitionLayout -import com.android.compose.animation.scene.Swipe -import com.android.compose.animation.scene.UserActionResult -import com.android.compose.animation.scene.featureOfElement -import com.android.compose.animation.scene.mechanics.rememberGestureContext -import com.android.compose.animation.scene.rememberMutableSceneTransitionLayoutState -import com.android.compose.animation.scene.transitions -import com.android.mechanics.debug.LocalMotionValueDebugController -import com.android.mechanics.debug.MotionValueDebugController -import com.android.mechanics.spec.builder.MotionBuilderContext -import com.android.mechanics.testing.FakeMotionSpecBuilderContext -import org.junit.Rule -import org.junit.Test -import org.junit.runner.RunWith -import org.junit.runners.Parameterized -import platform.test.motion.MotionTestRule -import platform.test.motion.compose.ComposeFeatureCaptures.height -import platform.test.motion.compose.ComposeFeatureCaptures.y -import platform.test.motion.compose.ComposeRecordingSpec -import platform.test.motion.compose.ComposeToolkit -import platform.test.motion.compose.createFixedConfigurationComposeMotionTestRule -import platform.test.motion.compose.on -import platform.test.motion.compose.recordMotion -import platform.test.motion.compose.runTest -import platform.test.motion.golden.FeatureCapture -import platform.test.motion.golden.asDataPoint -import platform.test.motion.testing.createGoldenPathManager - -@RunWith(Parameterized::class) -class VerticalTactileSurfaceRevealModifierTest(val useOverlays: Boolean) : - MotionBuilderContext by FakeMotionSpecBuilderContext.Default { - - @get:Rule - val motionRule: MotionTestRule = - createFixedConfigurationComposeMotionTestRule( - createGoldenPathManager("frameworks/libs/systemui/mechanics/compose/tests/goldens") - ) - - private val debugger = MotionValueDebugController() - - private fun assertVerticalTactileSurfaceRevealMotion( - goldenName: String, - gestureControl: GestureRevealMotion, - ) = - motionRule.runTest { - lateinit var state: MutableSceneTransitionLayoutState - val isTransitioning = - FeatureCapture("") { - (if (state.isTransitioning()) 1 else 0).asDataPoint() - } - - val boxes = 8 - @Composable - fun ContentScope.TestContent(modifier: Modifier = Modifier) { - Box(modifier = modifier.fillMaxSize()) { - Column( - modifier = - Modifier.element(ContainerElement) - .motionDriver(rememberGestureContext()) - .verticalScroll(rememberScrollState()) - .background(Color.LightGray) - .padding(4.dp), - verticalArrangement = Arrangement.spacedBy(8.dp), - ) { - repeat(boxes) { - Box( - Modifier.testTag("box$it") - .border( - 2.dp, - when (it) { - 0 -> Color.Green - boxes - 1 -> Color.Red - else -> Color.Blue - }, - ) - .verticalTactileSurfaceReveal(label = "box$it") - .size(50.dp) - ) - } - } - } - } - - val motion = - recordMotion( - content = { - CompositionLocalProvider( - LocalMotionValueDebugController provides debugger - ) { - state = - rememberMutableSceneTransitionLayoutState( - initialScene = gestureControl.startScene, - initialOverlays = gestureControl.startOverlays, - transitions = - transitions { - from(CollapsedScene, to = ExpandedOverlay) { - scaleSize(ContainerElement, height = 0f) - } - from(CollapsedScene, to = ExpandedScene) { - scaleSize(ContainerElement, height = 0f) - } - }, - ) - SceneTransitionLayout( - state = state, - modifier = - Modifier.background(Color.Yellow) - .size(ContainerSize) - .testTag(STL_TAG), - implicitTestTags = true, - ) { - scene( - key = CollapsedScene, - userActions = - mapOf( - if (useOverlays) { - Swipe.Down to ExpandedOverlay - } else { - Swipe.Down to ExpandedScene - } - ), - content = { Box(modifier = Modifier.fillMaxSize()) }, - ) - if (useOverlays) { - overlay( - ExpandedOverlay, - userActions = - mapOf( - Swipe.Up to - UserActionResult.HideOverlay(ExpandedOverlay) - ), - content = { - TestContent(Modifier.border(2.dp, Color.Magenta)) - }, - ) - } else { - scene( - key = ExpandedScene, - userActions = mapOf(Swipe.Up to CollapsedScene), - content = { TestContent(Modifier.border(2.dp, Color.Cyan)) }, - ) - } - } - } - }, - ComposeRecordingSpec( - recording = { - performTouchInputAsync( - onNodeWithTag(STL_TAG), - gestureControl.gestureControl, - ) - - awaitCondition { - !state.isTransitioning() && debugger.observed.all { it.isStable } - } - }, - timeSeriesCapture = { - feature(isTransitioning, "isTransitioning") - featureOfElement(ContainerElement, height) - repeat(boxes) { - val testTag = "box$it" - on(hasTestTag(testTag)) { - feature(y, name = "${testTag}_${y.name}") - feature(height, name = "${testTag}_${height.name}") - } - } - }, - ), - ) - - assertThat(motion).timeSeriesMatchesGolden(goldenName) - } - - @Test - fun verticalTactileSurfaceReveal_gesture_dragOpen() { - assertVerticalTactileSurfaceRevealMotion( - // We are using the same golden for scene-to-scene and scene-to-overlay transition. - goldenName = "verticalTactileSurfaceReveal_gesture_dragOpen", - gestureControl = - GestureRevealMotion(startScene = CollapsedScene) { - swipeDown(endY = 200.dp.toPx(), durationMillis = 500) - }, - ) - } - - @Test - fun verticalTactileSurfaceReveal_gesture_flingOpen() { - assertVerticalTactileSurfaceRevealMotion( - // We are using the same golden for scene-to-scene and scene-to-overlay transition. - goldenName = "verticalTactileSurfaceReveal_gesture_flingOpen", - gestureControl = - GestureRevealMotion(startScene = CollapsedScene) { - val end = Offset(centerX, 80.dp.toPx()) - swipeWithVelocity( - start = topCenter, - end = end, - endVelocity = FlingVelocity.toPx(), - ) - }, - ) - } - - private fun startExpanded(gestureControl: TouchInjectionScope.() -> Unit): GestureRevealMotion { - return if (useOverlays) { - GestureRevealMotion( - startScene = CollapsedScene, - startOverlays = setOf(ExpandedOverlay), - gestureControl = gestureControl, - ) - } else { - GestureRevealMotion(startScene = ExpandedScene, gestureControl = gestureControl) - } - } - - @Test - fun verticalTactileSurfaceReveal_gesture_dragClose() { - assertVerticalTactileSurfaceRevealMotion( - // We are using the same golden for scene-to-scene and scene-to-overlay transition. - goldenName = "verticalTactileSurfaceReveal_gesture_dragClose", - gestureControl = - startExpanded { swipeUp(200.dp.toPx(), 0.dp.toPx(), durationMillis = 500) }, - ) - } - - @Test - fun verticalTactileSurfaceReveal_gesture_flingClose() { - assertVerticalTactileSurfaceRevealMotion( - // We are using the same golden for scene-to-scene and scene-to-overlay transition. - goldenName = "verticalTactileSurfaceReveal_gesture_flingClose", - gestureControl = - startExpanded { - val start = Offset(centerX, 260.dp.toPx()) - val end = Offset(centerX, 200.dp.toPx()) - swipeWithVelocity(start, end, FlingVelocity.toPx()) - }, - ) - } - - private class GestureRevealMotion( - val startScene: SceneKey, - val startOverlays: Set = emptySet(), - val gestureControl: TouchInjectionScope.() -> Unit, - ) - - private companion object { - const val STL_TAG = "stl" - - val CollapsedScene = SceneKey("CollapsedScene") - val ExpandedScene = SceneKey("ExpandedScene") - val ExpandedOverlay = OverlayKey("ExpandedOverlay") - val ContainerElement = ElementKey("ContainerElement") - - val ContainerSize = DpSize(150.dp, 300.dp) - val FlingVelocity = 1000.dp // dp/sec - - @Parameterized.Parameters(name = "useOverlays={0}") - @JvmStatic - fun useOverlays() = listOf(false, true) - } -} From 5b5456e753df12461a17fc402d69ca3d41401d63 Mon Sep 17 00:00:00 2001 From: Pun Butrach Date: Wed, 3 Dec 2025 22:58:17 +0700 Subject: [PATCH 26/30] refactor: Remove tracing in displaylib We have no interest in making tracing work anyway --- .../app/displaylib/DisplayRepository.kt | 28 +++++++++---------- ...DisplaysWithDecorationsRepositoryCompat.kt | 2 +- 2 files changed, 15 insertions(+), 15 deletions(-) diff --git a/displaylib/src/com/android/app/displaylib/DisplayRepository.kt b/displaylib/src/com/android/app/displaylib/DisplayRepository.kt index b491119..15f23c9 100644 --- a/displaylib/src/com/android/app/displaylib/DisplayRepository.kt +++ b/displaylib/src/com/android/app/displaylib/DisplayRepository.kt @@ -30,8 +30,8 @@ import android.view.Display import com.android.app.displaylib.ExternalDisplayConnectionType.DESKTOP import com.android.app.displaylib.ExternalDisplayConnectionType.MIRROR import com.android.app.displaylib.ExternalDisplayConnectionType.NOT_SPECIFIED -import com.android.app.tracing.FlowTracing.traceEach -import com.android.app.tracing.traceSection + + import javax.inject.Inject import javax.inject.Singleton import kotlinx.coroutines.CoroutineDispatcher @@ -196,7 +196,7 @@ constructor( // have missed events for those added before this process or flow started. // Note it causes a binder call from the main thread (it's traced). private val initialDisplays: Set = - traceSection("$TAG#initialDisplays") { displayManager.displays?.toSet() ?: emptySet() } + { displayManager.displays?.toSet() ?: emptySet() } private val initialDisplayIds = initialDisplays.map { display -> display.displayId }.toSet() /** Propagate to the listeners only enabled displays */ @@ -266,7 +266,7 @@ constructor( private val ignoredDisplayIds: Flow> = _ignoredDisplayIds.debugLog("ignoredDisplayIds") private fun getInitialConnectedDisplays(): Set = - traceSection("$TAG#getInitialConnectedDisplays") { + displayManager .getDisplays(DISPLAY_CATEGORY_ALL_INCLUDING_DISABLED) .map { it.displayId } @@ -276,7 +276,7 @@ constructor( Log.d(TAG, "getInitialConnectedDisplays: $it") } } - } + /* keeps connected displays until they are disconnected. */ private val connectedDisplayIds: StateFlow> = @@ -337,10 +337,10 @@ constructor( .debugLog("connectedExternalDisplayIds") private fun getDisplayType(displayId: Int): Int? = - traceSection("$TAG#getDisplayType") { displayManager.getDisplay(displayId)?.type } + { displayManager.getDisplay(displayId)?.type } private fun getDisplayFromDisplayManager(displayId: Int): Display? = - traceSection("$TAG#getDisplay") { displayManager.getDisplay(displayId) } + { displayManager.getDisplay(displayId) } /** * Pending displays are the ones connected, but not enabled and not ignored. @@ -397,30 +397,30 @@ constructor( } override suspend fun enable() { - traceSection("DisplayRepository#enable($id)") { + if (DEBUG) { Log.d(TAG, "Enabling display with id=$id") } displayManager.enableConnectedDisplay(id) - } + // After the display has been enabled, it is automatically ignored. ignore() } override suspend fun ignore() { - traceSection("DisplayRepository#ignore($id)") { + _ignoredDisplayIds.value += id - } + } override suspend fun disable() { ignore() - traceSection("DisplayRepository#disable($id)") { + if (DEBUG) { Log.d(TAG, "Disabling display with id=$id") } displayManager.disableConnectedDisplay(id) - } + } } } @@ -456,7 +456,7 @@ constructor( private fun Flow.debugLog(flowName: String): Flow { return if (DEBUG) { - traceEach(flowName, logcat = true, traceEmissionCount = true) + } else { this } diff --git a/displaylib/src/com/android/app/displaylib/DisplaysWithDecorationsRepositoryCompat.kt b/displaylib/src/com/android/app/displaylib/DisplaysWithDecorationsRepositoryCompat.kt index d4f750b..d670884 100644 --- a/displaylib/src/com/android/app/displaylib/DisplaysWithDecorationsRepositoryCompat.kt +++ b/displaylib/src/com/android/app/displaylib/DisplaysWithDecorationsRepositoryCompat.kt @@ -16,7 +16,7 @@ package com.android.app.displaylib -import com.android.app.tracing.TraceUtils.traceAsync + import com.android.internal.annotations.GuardedBy import java.util.concurrent.ConcurrentHashMap import javax.inject.Inject From 2105c74b3f515e3bdf6b4f700ab0b62ca5d406c8 Mon Sep 17 00:00:00 2001 From: Pun Butrach Date: Wed, 3 Dec 2025 23:02:14 +0700 Subject: [PATCH 27/30] fix: Remove more tracing and correct usage in displaylib --- .../app/displaylib/DisplayRepository.kt | 23 +++++++------------ 1 file changed, 8 insertions(+), 15 deletions(-) diff --git a/displaylib/src/com/android/app/displaylib/DisplayRepository.kt b/displaylib/src/com/android/app/displaylib/DisplayRepository.kt index 15f23c9..d4d2d09 100644 --- a/displaylib/src/com/android/app/displaylib/DisplayRepository.kt +++ b/displaylib/src/com/android/app/displaylib/DisplayRepository.kt @@ -195,8 +195,7 @@ constructor( // This is necessary because there might be multiple displays, and we could // have missed events for those added before this process or flow started. // Note it causes a binder call from the main thread (it's traced). - private val initialDisplays: Set = - { displayManager.displays?.toSet() ?: emptySet() } + private val initialDisplays: Set = displayManager.displays?.toSet() ?: emptySet() private val initialDisplayIds = initialDisplays.map { display -> display.displayId }.toSet() /** Propagate to the listeners only enabled displays */ @@ -327,20 +326,18 @@ constructor( private val connectedExternalDisplayIds: Flow> = connectedDisplayIds .map { connectedDisplayIds -> - traceSection("$TAG#filteringExternalDisplays") { + connectedDisplayIds .filter { id -> getDisplayType(id) == Display.TYPE_EXTERNAL } .toSet() - } + } .flowOn(backgroundCoroutineDispatcher) .debugLog("connectedExternalDisplayIds") - private fun getDisplayType(displayId: Int): Int? = - { displayManager.getDisplay(displayId)?.type } + private fun getDisplayType(displayId: Int): Int? = displayManager.getDisplay(displayId)?.type - private fun getDisplayFromDisplayManager(displayId: Int): Display? = - { displayManager.getDisplay(displayId) } + private fun getDisplayFromDisplayManager(displayId: Int): Display? = displayManager.getDisplay(displayId) /** * Pending displays are the ones connected, but not enabled and not ignored. @@ -446,20 +443,16 @@ constructor( // In case of option one, let's get it synchronously from display manager to make sure for // this to be consistent. return if (displayIds.value.contains(displayId)) { - traceSection("$TAG#getDisplayFallbackToDisplayManager") { + getDisplayFromDisplayManager(displayId) - } + } else { null } } private fun Flow.debugLog(flowName: String): Flow { - return if (DEBUG) { - - } else { - this - } + return this } /** From 2475262ff4cb31c0e7e8b5487989287ae478077f Mon Sep 17 00:00:00 2001 From: Pun Butrach Date: Wed, 3 Dec 2025 23:03:59 +0700 Subject: [PATCH 28/30] fix: Bring back protobuf in viewcapturelib --- viewcapturelib/build.gradle | 2 ++ 1 file changed, 2 insertions(+) diff --git a/viewcapturelib/build.gradle b/viewcapturelib/build.gradle index 47f7fb5..87b7efd 100644 --- a/viewcapturelib/build.gradle +++ b/viewcapturelib/build.gradle @@ -1,6 +1,7 @@ plugins { alias(libs.plugins.android.library) alias(libs.plugins.kotlin.android) + alias(libs.plugins.google.protobuf) } android { @@ -14,6 +15,7 @@ android { main { java.srcDirs = ['src'] manifest.srcFile 'AndroidManifest.xml' + proto.srcDirs = ['src'] } androidTest { java.srcDirs = ["tests"] From 4a903cc6aa976443ea1ad14e453713f04ce7332b Mon Sep 17 00:00:00 2001 From: Pun Butrach Date: Wed, 3 Dec 2025 23:12:43 +0700 Subject: [PATCH 29/30] fix: Duplicated file in Iconloaderlib (TODO) --- .../lawnchair/icons/FixedScaleDrawable.java | 53 -- .../app/lawnchair/icons/IconPreferences.kt | 2 +- .../launcher3/icons/BaseIconFactory.java | 738 ------------------ .../launcher3/icons/BaseIconFactory.kt | 4 +- .../launcher3/icons/ClockDrawableWrapper.java | 518 ------------ .../launcher3/icons/ClockDrawableWrapper.kt | 5 +- .../launcher3/icons/GraphicsUtils.java | 117 --- .../icons/PlaceHolderIconDrawable.java | 91 --- .../icons/mono/ThemedIconDrawable.kt | 148 ---- .../android/launcher3/util/UserIconInfo.java | 81 -- 10 files changed, 7 insertions(+), 1750 deletions(-) delete mode 100644 iconloaderlib/src/app/lawnchair/icons/FixedScaleDrawable.java delete mode 100644 iconloaderlib/src/com/android/launcher3/icons/BaseIconFactory.java delete mode 100644 iconloaderlib/src/com/android/launcher3/icons/ClockDrawableWrapper.java delete mode 100644 iconloaderlib/src/com/android/launcher3/icons/GraphicsUtils.java delete mode 100644 iconloaderlib/src/com/android/launcher3/icons/PlaceHolderIconDrawable.java delete mode 100644 iconloaderlib/src/com/android/launcher3/icons/mono/ThemedIconDrawable.kt delete mode 100644 iconloaderlib/src/com/android/launcher3/util/UserIconInfo.java diff --git a/iconloaderlib/src/app/lawnchair/icons/FixedScaleDrawable.java b/iconloaderlib/src/app/lawnchair/icons/FixedScaleDrawable.java deleted file mode 100644 index 9013697..0000000 --- a/iconloaderlib/src/app/lawnchair/icons/FixedScaleDrawable.java +++ /dev/null @@ -1,53 +0,0 @@ -package app.lawnchair.icons; - -import static com.android.launcher3.icons.BaseIconFactory.LEGACY_ICON_SCALE; - -import android.content.res.Resources; -import android.content.res.Resources.Theme; -import android.graphics.Canvas; -import android.graphics.drawable.ColorDrawable; -import android.graphics.drawable.DrawableWrapper; -import android.util.AttributeSet; - -import org.xmlpull.v1.XmlPullParser; - -/** - * Extension of {@link DrawableWrapper} which scales the child drawables by a fixed amount. - */ -public class FixedScaleDrawable extends DrawableWrapper { - - private float mScaleX, mScaleY; - - public FixedScaleDrawable() { - super(new ColorDrawable()); - mScaleX = LEGACY_ICON_SCALE; - mScaleY = LEGACY_ICON_SCALE; - } - - @Override - public void draw(Canvas canvas) { - int saveCount = canvas.save(); - canvas.scale(mScaleX, mScaleY, - getBounds().exactCenterX(), getBounds().exactCenterY()); - super.draw(canvas); - canvas.restoreToCount(saveCount); - } - - @Override - public void inflate(Resources r, XmlPullParser parser, AttributeSet attrs) { } - - @Override - public void inflate(Resources r, XmlPullParser parser, AttributeSet attrs, Theme theme) { } - - public void setScale(float scale) { - float h = getIntrinsicHeight(); - float w = getIntrinsicWidth(); - mScaleX = scale * LEGACY_ICON_SCALE; - mScaleY = scale * LEGACY_ICON_SCALE; - if (h > w && w > 0) { - mScaleX *= w / h; - } else if (w > h && h > 0) { - mScaleY *= h / w; - } - } -} diff --git a/iconloaderlib/src/app/lawnchair/icons/IconPreferences.kt b/iconloaderlib/src/app/lawnchair/icons/IconPreferences.kt index 30f0ece..f81da6f 100644 --- a/iconloaderlib/src/app/lawnchair/icons/IconPreferences.kt +++ b/iconloaderlib/src/app/lawnchair/icons/IconPreferences.kt @@ -10,7 +10,7 @@ import android.graphics.drawable.BitmapDrawable import android.graphics.drawable.Drawable import androidx.core.graphics.ColorUtils import androidx.palette.graphics.Palette -import com.android.launcher3.icons.BaseIconFactory.DEFAULT_WRAPPER_BACKGROUND +import com.android.launcher3.icons.BaseIconFactory.Companion.DEFAULT_WRAPPER_BACKGROUND import com.android.launcher3.util.ComponentKey import org.json.JSONObject diff --git a/iconloaderlib/src/com/android/launcher3/icons/BaseIconFactory.java b/iconloaderlib/src/com/android/launcher3/icons/BaseIconFactory.java deleted file mode 100644 index 5523ecb..0000000 --- a/iconloaderlib/src/com/android/launcher3/icons/BaseIconFactory.java +++ /dev/null @@ -1,738 +0,0 @@ -package com.android.launcher3.icons; - -import static android.graphics.Color.BLACK; -import static android.graphics.Paint.ANTI_ALIAS_FLAG; -import static android.graphics.Paint.DITHER_FLAG; -import static android.graphics.Paint.FILTER_BITMAP_FLAG; -import static android.graphics.drawable.AdaptiveIconDrawable.getExtraInsetFraction; - -import static com.android.launcher3.icons.BitmapInfo.FLAG_INSTANT; -import static com.android.launcher3.icons.IconNormalizer.ICON_VISIBLE_AREA_FACTOR; -import static com.android.launcher3.icons.ShadowGenerator.BLUR_FACTOR; -import static com.android.launcher3.icons.ShadowGenerator.ICON_SCALE_FOR_SHADOWS; - -import static java.lang.annotation.RetentionPolicy.SOURCE; - -import android.annotation.TargetApi; -import android.content.Context; -import android.content.Intent; -import android.content.pm.PackageManager; -import android.content.res.Resources; -import android.graphics.Bitmap; -import android.graphics.Bitmap.Config; -import android.graphics.BitmapShader; -import android.graphics.Canvas; -import android.graphics.Color; -import android.graphics.Paint; -import android.graphics.PaintFlagsDrawFilter; -import android.graphics.Path; -import android.graphics.Rect; -import android.graphics.Shader.TileMode; -import android.graphics.drawable.AdaptiveIconDrawable; -import android.graphics.drawable.BitmapDrawable; -import android.graphics.drawable.ColorDrawable; -import android.graphics.drawable.Drawable; -import android.graphics.drawable.InsetDrawable; -import android.os.Build; -import android.os.UserHandle; -import android.util.SparseArray; - -import androidx.annotation.ColorInt; -import androidx.annotation.IntDef; -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; - -import com.android.launcher3.Flags; -import com.android.launcher3.icons.BitmapInfo.Extender; -import com.android.launcher3.util.FlagOp; -import com.android.launcher3.util.UserIconInfo; - -import java.lang.annotation.Retention; - -import app.lawnchair.icons.CustomAdaptiveIconDrawable; -import app.lawnchair.icons.ExtendedBitmapDrawable; -import app.lawnchair.icons.FixedScaleDrawable; -import app.lawnchair.icons.IconPreferencesKt; - -/** - * This class will be moved to androidx library. There shouldn't be any dependency outside - * this package. - */ -public class BaseIconFactory implements AutoCloseable { - - public static final int DEFAULT_WRAPPER_BACKGROUND = Color.WHITE; - public static final float LEGACY_ICON_SCALE = .7f * (1f / (1 + 2 * getExtraInsetFraction())); - - public static final int MODE_DEFAULT = 0; - public static final int MODE_ALPHA = 1; - public static final int MODE_WITH_SHADOW = 2; - public static final int MODE_HARDWARE = 3; - public static final int MODE_HARDWARE_WITH_SHADOW = 4; - - @Retention(SOURCE) - @IntDef({MODE_DEFAULT, MODE_ALPHA, MODE_WITH_SHADOW, MODE_HARDWARE_WITH_SHADOW, MODE_HARDWARE}) - @interface BitmapGenerationMode { - } - - private static final float ICON_BADGE_SCALE = 0.444f; - - @NonNull - private final Rect mOldBounds = new Rect(); - - @NonNull - private final SparseArray mCachedUserInfo = new SparseArray<>(); - - @NonNull - protected final Context mContext; - - @NonNull - private final Canvas mCanvas; - - @NonNull - private final PackageManager mPm; - - protected final int mFullResIconDpi; - protected final int mIconBitmapSize; - - protected IconThemeController mThemeController; - - @Nullable - private ShadowGenerator mShadowGenerator; - - /** Shadow bitmap used as background for theme icons */ - private Bitmap mWhiteShadowLayer; - /** Bitmap used for {@link BitmapShader} to mask Adaptive Icons when drawing */ - private Bitmap mShaderBitmap; - - private int mWrapperBackgroundColor = DEFAULT_WRAPPER_BACKGROUND; - - private static int PLACEHOLDER_BACKGROUND_COLOR = Color.rgb(245, 245, 245); - - protected BaseIconFactory(Context context, int fullResIconDpi, int iconBitmapSize, - boolean unused) { - this(context, fullResIconDpi, iconBitmapSize); - } - - public BaseIconFactory(Context context, int fullResIconDpi, int iconBitmapSize) { - mContext = context.getApplicationContext(); - mFullResIconDpi = fullResIconDpi; - mIconBitmapSize = iconBitmapSize; - - mPm = mContext.getPackageManager(); - - mCanvas = new Canvas(); - mCanvas.setDrawFilter(new PaintFlagsDrawFilter(DITHER_FLAG, FILTER_BITMAP_FLAG)); - clear(); - } - - protected void clear() { - mWrapperBackgroundColor = DEFAULT_WRAPPER_BACKGROUND; - } - - @NonNull - public ShadowGenerator getShadowGenerator() { - if (mShadowGenerator == null) { - mShadowGenerator = new ShadowGenerator(mIconBitmapSize); - } - return mShadowGenerator; - } - - @Nullable - public IconThemeController getThemeController() { - return mThemeController; - } - - public int getFullResIconDpi() { - return mFullResIconDpi; - } - - public int getIconBitmapSize() { - return mIconBitmapSize; - } - - @SuppressWarnings("deprecation") - public BitmapInfo createIconBitmap(Intent.ShortcutIconResource iconRes) { - try { - Resources resources = mPm.getResourcesForApplication(iconRes.packageName); - if (resources != null) { - final int id = resources.getIdentifier(iconRes.resourceName, null, null); - // do not stamp old legacy shortcuts as the app may have already forgotten about it - return createBadgedIconBitmap(resources.getDrawableForDensity(id, mFullResIconDpi)); - } - } catch (Exception e) { - // Icon not found. - } - return null; - } - - /** - * Create a placeholder icon using the passed in text. - * - * @param placeholder used for foreground element in the icon bitmap - * @param color used for the foreground text color - */ - public BitmapInfo createIconBitmap(String placeholder, int color) { - AdaptiveIconDrawable drawable = new AdaptiveIconDrawable( - new ColorDrawable(PLACEHOLDER_BACKGROUND_COLOR), - new CenterTextDrawable(placeholder, color)); - Bitmap icon = createIconBitmap(drawable, ICON_VISIBLE_AREA_FACTOR); - return BitmapInfo.of(icon, color); - } - - public BitmapInfo createIconBitmap(Bitmap icon) { - if (mIconBitmapSize != icon.getWidth() || mIconBitmapSize != icon.getHeight()) { - icon = createIconBitmap(new BitmapDrawable(mContext.getResources(), icon), 1f); - } - - return BitmapInfo.of(icon, ColorExtractor.findDominantColorByHue(icon)); - } - - /** - * Creates an icon from the bitmap cropped to the current device icon shape - */ - @NonNull - public AdaptiveIconDrawable createShapedAdaptiveIcon(Bitmap iconBitmap) { - Drawable drawable = new FixedSizeBitmapDrawable(iconBitmap); - float inset = getExtraInsetFraction(); - inset = inset / (1 + 2 * inset); - return new AdaptiveIconDrawable(new ColorDrawable(BLACK), - new InsetDrawable(drawable, inset, inset, inset, inset) - ); - } - - @NonNull - public BitmapInfo createBadgedIconBitmap(@NonNull Drawable icon) { - return createBadgedIconBitmap(icon, null); - } - - /** - * Creates bitmap using the source drawable and various parameters. - * The bitmap is visually normalized with other icons and has enough spacing to add shadow. - * - * @param icon source of the icon - * @return a bitmap suitable for displaying as an icon at various system UIs. - */ - @TargetApi(Build.VERSION_CODES.TIRAMISU) - @NonNull - public BitmapInfo createBadgedIconBitmap(@NonNull Drawable icon, - @Nullable IconOptions options) { - float[] scale = new float[1]; - Drawable tempIcon = icon; - if (options != null - && options.mIsArchived - && icon instanceof BitmapDrawable bitmapDrawable) { - // b/358123888 - // Pre-archived apps can have BitmapDrawables without insets. - // Need to convert to Adaptive Icon with insets to avoid cropping. - tempIcon = createShapedAdaptiveIcon(bitmapDrawable.getBitmap()); - } - AdaptiveIconDrawable adaptiveIcon = normalizeAndWrapToAdaptiveIcon(tempIcon, scale); - Bitmap bitmap = createIconBitmap(adaptiveIcon, scale[0], - options == null ? MODE_WITH_SHADOW : options.mGenerationMode); - int color = (options != null && options.mExtractedColor != null) - ? options.mExtractedColor : ColorExtractor.findDominantColorByHue(bitmap); - BitmapInfo info = BitmapInfo.of(bitmap, color); - - if (adaptiveIcon instanceof Extender extender) { - info = extender.getExtendedInfo(bitmap, color, this, scale[0]); - } else if (IconProvider.ATLEAST_T && mThemeController != null && adaptiveIcon != null) { - info.setThemedBitmap( - mThemeController.createThemedBitmap( - adaptiveIcon, - info, - this, - options == null ? null : options.mSourceHint - ) - ); - } - FlagOp flagOp = getBitmapFlagOp(options); - if (adaptiveIcon instanceof WrappedAdaptiveIcon) { - flagOp = flagOp.addFlag(BitmapInfo.FLAG_WRAPPED_NON_ADAPTIVE); - } - info = info.withFlags(flagOp); - return info; - } - - @NonNull - public FlagOp getBitmapFlagOp(@Nullable IconOptions options) { - FlagOp op = FlagOp.NO_OP; - if (options != null) { - if (options.mIsInstantApp) { - op = op.addFlag(FLAG_INSTANT); - } - - UserIconInfo info = options.mUserIconInfo; - if (info == null && options.mUserHandle != null) { - info = getUserInfo(options.mUserHandle); - } - if (info != null) { - op = info.applyBitmapInfoFlags(op); - } - } - return op; - } - - @NonNull - protected UserIconInfo getUserInfo(@NonNull UserHandle user) { - int key = user.hashCode(); - UserIconInfo info = mCachedUserInfo.get(key); - /* - * We do not have the ability to distinguish between different badged users here. - * As such all badged users will have the work profile badge applied. - */ - if (info == null) { - // Simple check to check if the provided user is work profile or not based on badging - NoopDrawable d = new NoopDrawable(); - boolean isWork = (d != mPm.getUserBadgedIcon(d, user)); - info = new UserIconInfo(user, isWork ? UserIconInfo.TYPE_WORK : UserIconInfo.TYPE_MAIN); - mCachedUserInfo.put(key, info); - } - return info; - } - - @NonNull - public Path getShapePath(AdaptiveIconDrawable drawable, Rect iconBounds) { - return drawable.getIconMask(); - } - - @NonNull - public Bitmap getWhiteShadowLayer() { - if (mWhiteShadowLayer == null) { - mWhiteShadowLayer = createScaledBitmap( - new AdaptiveIconDrawable(new ColorDrawable(Color.WHITE), null), - MODE_HARDWARE_WITH_SHADOW); - } - return mWhiteShadowLayer; - } - - /** - * Takes an {@link AdaptiveIconDrawable} and uses it to create a new Shader Bitmap. - * {@link mShaderBitmap} will be used to create a {@link BitmapShader} for masking, - * such as for icon shapes. Will reuse underlying Bitmap where possible. - * - * @param adaptiveIcon AdaptiveIconDrawable to draw with shader - */ - @NonNull - private Bitmap getAdaptiveShaderBitmap(AdaptiveIconDrawable adaptiveIcon) { - Rect bounds = adaptiveIcon.getBounds(); - int iconWidth = bounds.width(); - int iconHeight = bounds.width(); - - BitmapRenderer shaderRenderer = new BitmapRenderer() { - @Override - public void draw(Canvas canvas) { - canvas.translate(-bounds.left, -bounds.top); - canvas.drawColor(BLACK); - if (adaptiveIcon.getBackground() != null) { - adaptiveIcon.getBackground().draw(canvas); - } - if (adaptiveIcon.getForeground() != null) { - adaptiveIcon.getForeground().draw(canvas); - } - } - }; - if (mShaderBitmap == null || iconWidth != mShaderBitmap.getWidth() - || iconHeight != mShaderBitmap.getHeight()) { - mShaderBitmap = BitmapRenderer.createSoftwareBitmap(iconWidth, iconHeight, - shaderRenderer); - } else { - shaderRenderer.draw(new Canvas(mShaderBitmap)); - } - return mShaderBitmap; - } - - @NonNull - public Bitmap createScaledBitmap(@NonNull Drawable icon, @BitmapGenerationMode int mode) { - float[] scale = new float[1]; - icon = normalizeAndWrapToAdaptiveIcon(icon, scale); - return createIconBitmap(icon, Math.min(scale[0], ICON_SCALE_FOR_SHADOWS), mode); - } - - /** - * Sets the background color used for wrapped adaptive icon - */ - public void setWrapperBackgroundColor(final int color) { - mWrapperBackgroundColor = (Color.alpha(color) < 255) ? DEFAULT_WRAPPER_BACKGROUND : color; - } - - @Nullable - protected AdaptiveIconDrawable normalizeAndWrapToAdaptiveIcon( - @Nullable Drawable icon, @NonNull final float[] outScale) { - if (icon == null) { - return null; - } - boolean isFromIconPack = ExtendedBitmapDrawable.isFromIconPack(icon); - boolean shrinkNonAdaptiveIcons = !isFromIconPack && IconPreferencesKt.shouldWrapAdaptive(mContext); - float scale; - - if (shrinkNonAdaptiveIcons && !(icon instanceof AdaptiveIconDrawable)) { - scale = new IconNormalizer(mIconBitmapSize).getScale(icon); - - int wrapperBackgroundColor = IconPreferencesKt.getWrapperBackgroundColor(mContext, icon); - - FixedScaleDrawable foreground = new FixedScaleDrawable(); - foreground.setDrawable(icon); - foreground.setScale(scale); - - CustomAdaptiveIconDrawable wrapper = new CustomAdaptiveIconDrawable( - new ColorDrawable(wrapperBackgroundColor), - foreground - ); - - scale = new IconNormalizer(mIconBitmapSize).getScale(wrapper); - outScale[0] = scale; - - // pE-TODO: If this is wrapper, shouldn't we be using DEFAULT_WRAPPER_BACKGROUND for background? To be fair the background doesn't seem to be rendering - return wrapper; - } else { - scale = new IconNormalizer(mIconBitmapSize).getScale(icon); - outScale[0] = scale; - - // Icon is either legacy or isn't an proper icon, and/or doesn't support monochrome - return wrapToAdaptiveIcon(icon); - } - } - - /** - * Returns a drawable which draws the original drawable at a fixed scale - */ - private Drawable createScaledDrawable(@NonNull Drawable main, float scale) { - float h = main.getIntrinsicHeight(); - float w = main.getIntrinsicWidth(); - float scaleX = scale; - float scaleY = scale; - if (h > w && w > 0) { - scaleX *= w / h; - } else if (w > h && h > 0) { - scaleY *= h / w; - } - scaleX = (1 - scaleX) / 2; - scaleY = (1 - scaleY) / 2; - return new InsetDrawable(main, scaleX, scaleY, scaleX, scaleY); - } - - /** - * Wraps the provided icon in an adaptive icon drawable - */ - public AdaptiveIconDrawable wrapToAdaptiveIcon(@NonNull Drawable icon) { - if (icon instanceof AdaptiveIconDrawable aid) { - return aid; - } else { - int wrapperBackgroundColor = IconPreferencesKt.getWrapperBackgroundColor(mContext, icon); - - FixedScaleDrawable foreground = new FixedScaleDrawable(); - // pE-TODO(QPR1): Investigate - // foreground = createScaledDrawable(icon, scale * LEGACY_ICON_SCALE) - CustomAdaptiveIconDrawable dr = new CustomAdaptiveIconDrawable( - new ColorDrawable(wrapperBackgroundColor), foreground); - dr.setBounds(0, 0, 1, 1); - float scale = new IconNormalizer(mIconBitmapSize).getScale(icon); - foreground.setDrawable(icon); - foreground.setScale(scale); - - return dr; - } - } - - @NonNull - public Bitmap createIconBitmap(@Nullable final Drawable icon, final float scale) { - return createIconBitmap(icon, scale, MODE_DEFAULT); - } - - @NonNull - public Bitmap createIconBitmap(@Nullable final Drawable icon, final float scale, - @BitmapGenerationMode int bitmapGenerationMode) { - final int size = mIconBitmapSize; - final Bitmap bitmap; - switch (bitmapGenerationMode) { - case MODE_ALPHA: - bitmap = Bitmap.createBitmap(size, size, Config.ALPHA_8); - break; - case MODE_HARDWARE: - case MODE_HARDWARE_WITH_SHADOW: { - return BitmapRenderer.createHardwareBitmap(size, size, canvas -> - drawIconBitmap(canvas, icon, scale, bitmapGenerationMode, null)); - } - case MODE_WITH_SHADOW: - default: - bitmap = Bitmap.createBitmap(size, size, Config.ARGB_8888); - break; - } - if (icon == null) { - return bitmap; - } - mCanvas.setBitmap(bitmap); - drawIconBitmap(mCanvas, icon, scale, bitmapGenerationMode, bitmap); - mCanvas.setBitmap(null); - return bitmap; - } - - private void drawIconBitmap(@NonNull Canvas canvas, @Nullable Drawable icon, - final float scale, @BitmapGenerationMode int bitmapGenerationMode, - @Nullable Bitmap targetBitmap) { - final int size = mIconBitmapSize; - mOldBounds.set(icon.getBounds()); - if (icon instanceof AdaptiveIconDrawable aid) { - // We are ignoring KEY_SHADOW_DISTANCE because regular icons ignore this at the - // moment b/298203449 - int offset = Math.max((int) Math.ceil(BLUR_FACTOR * size), - Math.round(size * (1 - scale) / 2)); - // b/211896569: AdaptiveIconDrawable do not work properly for non top-left bounds - int newBounds = size - offset * 2; - icon.setBounds(0, 0, newBounds, newBounds); - Path shapePath = getShapePath(aid, icon.getBounds()); - int count = canvas.save(); - canvas.translate(offset, offset); - if (bitmapGenerationMode == MODE_WITH_SHADOW - || bitmapGenerationMode == MODE_HARDWARE_WITH_SHADOW) { - getShadowGenerator().addPathShadow(shapePath, canvas); - } - - if (icon instanceof Extender) { - ((Extender) icon).drawForPersistence(canvas); - } else { - drawAdaptiveIcon(canvas, aid, shapePath); - } - - canvas.restoreToCount(count); - } else { - if (icon instanceof BitmapDrawable) { - BitmapDrawable bitmapDrawable = (BitmapDrawable) icon; - Bitmap b = bitmapDrawable.getBitmap(); - if (b != null && b.getDensity() == Bitmap.DENSITY_NONE) { - bitmapDrawable.setTargetDensity(mContext.getResources().getDisplayMetrics()); - } - } - int width = size; - int height = size; - - int intrinsicWidth = icon.getIntrinsicWidth(); - int intrinsicHeight = icon.getIntrinsicHeight(); - if (intrinsicWidth > 0 && intrinsicHeight > 0) { - // Scale the icon proportionally to the icon dimensions - final float ratio = (float) intrinsicWidth / intrinsicHeight; - if (intrinsicWidth > intrinsicHeight) { - height = (int) (width / ratio); - } else if (intrinsicHeight > intrinsicWidth) { - width = (int) (height * ratio); - } - } - final int left = (size - width) / 2; - final int top = (size - height) / 2; - icon.setBounds(left, top, left + width, top + height); - - canvas.save(); - canvas.scale(scale, scale, size / 2, size / 2); - icon.draw(canvas); - canvas.restore(); - - if (bitmapGenerationMode == MODE_WITH_SHADOW && targetBitmap != null) { - // Shadow extraction only works in software mode - getShadowGenerator().drawShadow(targetBitmap, canvas); - - // Draw the icon again on top: - canvas.save(); - canvas.scale(scale, scale, size / 2, size / 2); - icon.draw(canvas); - canvas.restore(); - } - } - icon.setBounds(mOldBounds); - } - - /** - * Draws AdaptiveIconDrawable onto canvas using provided Path - * and {@link mShaderBitmap} as a shader. - * - * @param canvas canvas to draw on - * @param drawable AdaptiveIconDrawable to draw - * @param shapePath path to clip icon with for shapes - */ - protected void drawAdaptiveIcon( - @NonNull Canvas canvas, - @NonNull AdaptiveIconDrawable drawable, - @NonNull Path shapePath - ) { - Drawable background = drawable.getBackground(); - Drawable foreground = drawable.getForeground(); - if (!Flags.enableLauncherIconShapes() || (background == null && foreground == null)) { - drawable.draw(canvas); - return; - } - Bitmap shaderBitmap = getAdaptiveShaderBitmap(drawable); - Paint paint = new Paint(); - paint.setShader(new BitmapShader(shaderBitmap, TileMode.CLAMP, TileMode.CLAMP)); - canvas.drawPath(shapePath, paint); - } - - @Override - public void close() { - clear(); - } - - @NonNull - public BitmapInfo makeDefaultIcon(IconProvider iconProvider) { - return createBadgedIconBitmap(iconProvider.getFullResDefaultActivityIcon(mFullResIconDpi)); - } - - /** - * Returns the correct badge size given an icon size - */ - public static int getBadgeSizeForIconSize(final int iconSize) { - return (int) (ICON_BADGE_SCALE * iconSize); - } - - public static class IconOptions { - - boolean mIsInstantApp; - - boolean mIsArchived; - - @BitmapGenerationMode - int mGenerationMode = MODE_WITH_SHADOW; - - @Nullable - UserHandle mUserHandle; - @Nullable - UserIconInfo mUserIconInfo; - - @ColorInt - @Nullable - Integer mExtractedColor; - - @Nullable - SourceHint mSourceHint; - - /** - * User for this icon, in case of badging - */ - @NonNull - public IconOptions setUser(@Nullable final UserHandle user) { - mUserHandle = user; - return this; - } - - /** - * User for this icon, in case of badging - */ - @NonNull - public IconOptions setUser(@Nullable final UserIconInfo user) { - mUserIconInfo = user; - return this; - } - - /** - * If this icon represents an instant app - */ - @NonNull - public IconOptions setInstantApp(final boolean instantApp) { - mIsInstantApp = instantApp; - return this; - } - - /** - * If the icon represents an archived app - */ - public IconOptions setIsArchived(boolean isArchived) { - mIsArchived = isArchived; - return this; - } - - /** - * Disables auto color extraction and overrides the color to the provided value - */ - @NonNull - public IconOptions setExtractedColor(@ColorInt int color) { - mExtractedColor = color; - return this; - } - - /** - * Sets the bitmap generation mode to use for the bitmap info. Note that some generation - * modes do not support color extraction, so consider setting a extracted color manually - * in those cases. - */ - public IconOptions setBitmapGenerationMode(@BitmapGenerationMode int generationMode) { - mGenerationMode = generationMode; - return this; - } - - /** - * User for this icon, in case of badging - */ - @NonNull - public IconOptions setSourceHint(@Nullable SourceHint sourceHint) { - mSourceHint = sourceHint; - return this; - } - } - - /** - * An extension of {@link BitmapDrawable} which returns the bitmap pixel size as intrinsic size. - * This allows the badging to be done based on the action bitmap size rather than - * the scaled bitmap size. - */ - private static class FixedSizeBitmapDrawable extends BitmapDrawable { - - public FixedSizeBitmapDrawable(@Nullable final Bitmap bitmap) { - super(null, bitmap); - } - - @Override - public int getIntrinsicHeight() { - return getBitmap().getWidth(); - } - - @Override - public int getIntrinsicWidth() { - return getBitmap().getWidth(); - } - } - - private static class NoopDrawable extends ColorDrawable { - @Override - public int getIntrinsicHeight() { - return 1; - } - - @Override - public int getIntrinsicWidth() { - return 1; - } - } - - private static class CenterTextDrawable extends ColorDrawable { - - @NonNull - private final Rect mTextBounds = new Rect(); - - @NonNull - private final Paint mTextPaint = new Paint(ANTI_ALIAS_FLAG | FILTER_BITMAP_FLAG); - - @NonNull - private final String mText; - - CenterTextDrawable(@NonNull final String text, final int color) { - mText = text; - mTextPaint.setColor(color); - } - - @Override - public void draw(Canvas canvas) { - Rect bounds = getBounds(); - mTextPaint.setTextSize(bounds.height() / 3f); - mTextPaint.getTextBounds(mText, 0, mText.length(), mTextBounds); - canvas.drawText(mText, - bounds.exactCenterX() - mTextBounds.exactCenterX(), - bounds.exactCenterY() - mTextBounds.exactCenterY(), - mTextPaint); - } - } - - private static class WrappedAdaptiveIcon extends AdaptiveIconDrawable { - - WrappedAdaptiveIcon(Drawable backgroundDrawable, Drawable foregroundDrawable) { - super(backgroundDrawable, foregroundDrawable); - } - } -} diff --git a/iconloaderlib/src/com/android/launcher3/icons/BaseIconFactory.kt b/iconloaderlib/src/com/android/launcher3/icons/BaseIconFactory.kt index 6d0e4e9..c9be156 100644 --- a/iconloaderlib/src/com/android/launcher3/icons/BaseIconFactory.kt +++ b/iconloaderlib/src/com/android/launcher3/icons/BaseIconFactory.kt @@ -456,12 +456,12 @@ constructor( } companion object { - private const val DEFAULT_WRAPPER_BACKGROUND = Color.WHITE + const val DEFAULT_WRAPPER_BACKGROUND = Color.WHITE // Ratio of icon visible area to full icon size for a square shaped icon private const val MAX_SQUARE_AREA_FACTOR = 375.0 / 576 - private val LEGACY_ICON_SCALE = + val LEGACY_ICON_SCALE = sqrt(MAX_SQUARE_AREA_FACTOR).toFloat() * .7f * (1f / (1 + 2 * AdaptiveIconDrawable.getExtraInsetFraction())) diff --git a/iconloaderlib/src/com/android/launcher3/icons/ClockDrawableWrapper.java b/iconloaderlib/src/com/android/launcher3/icons/ClockDrawableWrapper.java deleted file mode 100644 index 33fc4ee..0000000 --- a/iconloaderlib/src/com/android/launcher3/icons/ClockDrawableWrapper.java +++ /dev/null @@ -1,518 +0,0 @@ -/* - * Copyright (C) 2019 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.launcher3.icons; - -import static com.android.launcher3.icons.IconProvider.ATLEAST_T; -import static com.android.launcher3.icons.cache.CacheLookupFlag.DEFAULT_LOOKUP_FLAG; - -import android.annotation.TargetApi; -import android.content.Context; -import android.content.pm.ApplicationInfo; -import android.content.pm.PackageManager; -import android.content.res.Resources; -import android.graphics.Bitmap; -import android.graphics.BlendMode; -import android.graphics.BlendModeColorFilter; -import android.graphics.Canvas; -import android.graphics.Color; -import android.graphics.ColorFilter; -import android.graphics.Paint; -import android.graphics.Path; -import android.graphics.Rect; -import android.graphics.drawable.AdaptiveIconDrawable; -import android.graphics.drawable.ColorDrawable; -import android.graphics.drawable.Drawable; -import android.graphics.drawable.LayerDrawable; -import android.os.Build; -import android.os.Bundle; -import android.os.SystemClock; -import android.util.Log; - -import androidx.annotation.NonNull; -import androidx.core.util.Supplier; -import com.android.launcher3.icons.cache.CacheLookupFlag; -import com.android.launcher3.icons.mono.ThemedIconDrawable; - -import java.util.Calendar; -import java.util.Objects; -import java.util.concurrent.TimeUnit; -import java.util.function.IntFunction; - -import app.lawnchair.icons.ClockMetadata; -import app.lawnchair.icons.CustomAdaptiveIconDrawable; - -/** - * Wrapper over {@link AdaptiveIconDrawable} to intercept icon flattening logic for dynamic - * clock icons - */ -public class ClockDrawableWrapper extends CustomAdaptiveIconDrawable implements BitmapInfo.Extender { - - public static boolean sRunningInTest = false; - - private static final String TAG = "ClockDrawableWrapper"; - - private static final boolean DISABLE_SECONDS = false; // pE-TODO: Enable/Disable second hand of clock drawable via prefs - private static final int NO_COLOR = -1; - - // Time after which the clock icon should check for an update. The actual invalidate - // will only happen in case of any change. - public static final long TICK_MS = DISABLE_SECONDS ? TimeUnit.MINUTES.toMillis(1) : 200L; - - private static final String LAUNCHER_PACKAGE = "com.android.launcher3"; - private static final String ROUND_ICON_METADATA_KEY = LAUNCHER_PACKAGE - + ".LEVEL_PER_TICK_ICON_ROUND"; - private static final String HOUR_INDEX_METADATA_KEY = LAUNCHER_PACKAGE + ".HOUR_LAYER_INDEX"; - private static final String MINUTE_INDEX_METADATA_KEY = LAUNCHER_PACKAGE - + ".MINUTE_LAYER_INDEX"; - private static final String SECOND_INDEX_METADATA_KEY = LAUNCHER_PACKAGE - + ".SECOND_LAYER_INDEX"; - private static final String DEFAULT_HOUR_METADATA_KEY = LAUNCHER_PACKAGE - + ".DEFAULT_HOUR"; - private static final String DEFAULT_MINUTE_METADATA_KEY = LAUNCHER_PACKAGE - + ".DEFAULT_MINUTE"; - private static final String DEFAULT_SECOND_METADATA_KEY = LAUNCHER_PACKAGE - + ".DEFAULT_SECOND"; - - /* Number of levels to jump per second for the second hand */ - private static final int LEVELS_PER_SECOND = 10; - - public static final int INVALID_VALUE = -1; - - private final AnimationInfo mAnimationInfo = new AnimationInfo(); - private AnimationInfo mThemeInfo = null; - - private ClockDrawableWrapper(AdaptiveIconDrawable base) { - super(base.getBackground(), base.getForeground()); - } - - @Override - public Drawable getMonochrome() { - if (mThemeInfo == null) { - return null; - } - Drawable d = mThemeInfo.baseDrawableState.newDrawable().mutate(); - if (d instanceof AdaptiveIconDrawable) { - Drawable mono = ((AdaptiveIconDrawable) d).getForeground(); - mThemeInfo.applyTime(Calendar.getInstance(), (LayerDrawable) mono); - return mono; - } - return null; - } - - /** - * Loads and returns the wrapper from the provided package, or returns null - * if it is unable to load. - */ - public static ClockDrawableWrapper forPackage(Context context, String pkg, int iconDpi) { - try { - PackageManager pm = context.getPackageManager(); - ApplicationInfo appInfo = pm.getApplicationInfo(pkg, - PackageManager.MATCH_UNINSTALLED_PACKAGES | PackageManager.GET_META_DATA); - Resources res = pm.getResourcesForApplication(appInfo); - return forExtras(appInfo.metaData, resId -> CustomAdaptiveIconDrawable.wrapNonNull( - Objects.requireNonNull(res.getDrawableForDensity(resId, iconDpi)))); - } catch (Exception e) { - Log.d(TAG, "Unable to load clock drawable info", e); - } - return null; - } - - public static ClockDrawableWrapper forExtras( - Bundle metadata, IntFunction drawableProvider) { - if (metadata == null) { - return null; - } - int drawableId = metadata.getInt(ROUND_ICON_METADATA_KEY, 0); - if (drawableId == 0) { - return null; - } - - int hourLayerIndex = metadata.getInt(HOUR_INDEX_METADATA_KEY, INVALID_VALUE); - int minuteLayerIndex = metadata.getInt(MINUTE_INDEX_METADATA_KEY, INVALID_VALUE); - int secondLayerIndex = metadata.getInt(SECOND_INDEX_METADATA_KEY, INVALID_VALUE); - - int defaultHour = metadata.getInt(DEFAULT_HOUR_METADATA_KEY, 0); - int defaultMinute = metadata.getInt(DEFAULT_MINUTE_METADATA_KEY, 0); - int defaultSecond = metadata.getInt(DEFAULT_SECOND_METADATA_KEY, 0); - - ClockMetadata clockMetadata = new ClockMetadata( - hourLayerIndex, - minuteLayerIndex, - secondLayerIndex, - defaultHour, - defaultMinute, - defaultSecond - ); - - return forMeta(0, clockMetadata, () -> drawableProvider.apply(drawableId)); - } - - public static ClockDrawableWrapper forMeta( - @Deprecated(since = "Not used, kept for compatibility reason.") int targetSdkVersion, - @NonNull ClockMetadata metadata, Supplier drawableProvider) { - Drawable drawable = drawableProvider.get().mutate(); - if (!(drawable instanceof AdaptiveIconDrawable)) { - return null; - } - AdaptiveIconDrawable aid = (AdaptiveIconDrawable) drawable; - - ClockDrawableWrapper wrapper = new ClockDrawableWrapper(aid); - AnimationInfo info = wrapper.mAnimationInfo; - - info.baseDrawableState = drawable.getConstantState(); - info.hourLayerIndex = metadata.getHourLayerIndex(); - info.minuteLayerIndex = metadata.getMinuteLayerIndex(); - info.secondLayerIndex = metadata.getSecondLayerIndex(); - - info.defaultHour = metadata.getDefaultHour(); - info.defaultMinute = metadata.getDefaultMinute(); - info.defaultSecond = metadata.getDefaultSecond(); - - LayerDrawable foreground = (LayerDrawable) wrapper.getForeground(); - int layerCount = foreground.getNumberOfLayers(); - if (info.hourLayerIndex < 0 || info.hourLayerIndex >= layerCount) { - info.hourLayerIndex = INVALID_VALUE; - } - if (info.minuteLayerIndex < 0 || info.minuteLayerIndex >= layerCount) { - info.minuteLayerIndex = INVALID_VALUE; - } - if (info.secondLayerIndex < 0 || info.secondLayerIndex >= layerCount) { - info.secondLayerIndex = INVALID_VALUE; - } else if (DISABLE_SECONDS) { - foreground.setDrawable(info.secondLayerIndex, null); - info.secondLayerIndex = INVALID_VALUE; - } - - if (ATLEAST_T && aid.getMonochrome() instanceof LayerDrawable) { - wrapper.mThemeInfo = info.copyForIcon(new AdaptiveIconDrawable( - new ColorDrawable(Color.WHITE), aid.getMonochrome().mutate())); - } - info.applyTime(Calendar.getInstance(), foreground); - return wrapper; - } - - @Override - public ClockBitmapInfo getExtendedInfo(Bitmap bitmap, int color, - BaseIconFactory iconFactory, float normalizationScale) { - AdaptiveIconDrawable background = new AdaptiveIconDrawable( - getBackground().getConstantState().newDrawable(), null); - Bitmap flattenBG = iconFactory.createScaledBitmap(background, - BaseIconFactory.MODE_HARDWARE_WITH_SHADOW); - - // Only pass theme info if mono-icon is enabled - AnimationInfo themeInfo = iconFactory.getThemeController() != null ? mThemeInfo : null; - Bitmap themeBG = themeInfo == null ? null : iconFactory.getWhiteShadowLayer(); - return new ClockBitmapInfo(bitmap, color, normalizationScale, - mAnimationInfo, flattenBG, themeInfo, themeBG); - } - - @Override - public void drawForPersistence(Canvas canvas) { - LayerDrawable foreground = (LayerDrawable) getForeground(); - resetLevel(foreground, mAnimationInfo.hourLayerIndex); - resetLevel(foreground, mAnimationInfo.minuteLayerIndex); - resetLevel(foreground, mAnimationInfo.secondLayerIndex); - draw(canvas); - mAnimationInfo.applyTime(Calendar.getInstance(), (LayerDrawable) getForeground()); - } - - private void resetLevel(LayerDrawable drawable, int index) { - if (index != INVALID_VALUE) { - drawable.getDrawable(index).setLevel(0); - } - } - - private static class AnimationInfo { - - public ConstantState baseDrawableState; - - public int hourLayerIndex; - public int minuteLayerIndex; - public int secondLayerIndex; - public int defaultHour; - public int defaultMinute; - public int defaultSecond; - - public AnimationInfo copyForIcon(Drawable icon) { - AnimationInfo result = new AnimationInfo(); - result.baseDrawableState = icon.getConstantState(); - result.defaultHour = defaultHour; - result.defaultMinute = defaultMinute; - result.defaultSecond = defaultSecond; - result.hourLayerIndex = hourLayerIndex; - result.minuteLayerIndex = minuteLayerIndex; - result.secondLayerIndex = secondLayerIndex; - return result; - } - - boolean applyTime(Calendar time, LayerDrawable foregroundDrawable) { - time.setTimeInMillis(System.currentTimeMillis()); - - // We need to rotate by the difference from the default time if one is specified. - int convertedHour = (time.get(Calendar.HOUR) + (12 - defaultHour)) % 12; - int convertedMinute = (time.get(Calendar.MINUTE) + (60 - defaultMinute)) % 60; - int convertedSecond = (time.get(Calendar.SECOND) + (60 - defaultSecond)) % 60; - - boolean invalidate = false; - if (hourLayerIndex != INVALID_VALUE) { - final Drawable hour = foregroundDrawable.getDrawable(hourLayerIndex); - if (hour.setLevel(convertedHour * 60 + time.get(Calendar.MINUTE))) { - invalidate = true; - } - } - - if (minuteLayerIndex != INVALID_VALUE) { - final Drawable minute = foregroundDrawable.getDrawable(minuteLayerIndex); - if (minute.setLevel(time.get(Calendar.HOUR) * 60 + convertedMinute)) { - invalidate = true; - } - } - - if (secondLayerIndex != INVALID_VALUE) { - final Drawable second = foregroundDrawable.getDrawable(secondLayerIndex); - if (second.setLevel(convertedSecond * LEVELS_PER_SECOND)) { - invalidate = true; - } - } - - return invalidate; - } - } - - static class ClockBitmapInfo extends BitmapInfo { - - public final float boundsOffset; - - public final AnimationInfo animInfo; - public final Bitmap mFlattenedBackground; - - public final AnimationInfo themeData; - public final Bitmap themeBackground; - - ClockBitmapInfo(Bitmap icon, int color, float scale, - AnimationInfo animInfo, Bitmap background, - AnimationInfo themeInfo, Bitmap themeBackground) { - super(icon, color, /* flags */ 0, /* themedBitmap */ null); - this.boundsOffset = Math.max(ShadowGenerator.BLUR_FACTOR, (1 - scale) / 2); - this.animInfo = animInfo; - this.mFlattenedBackground = background; - this.themeData = themeInfo; - this.themeBackground = themeBackground; - } - - @Override - @TargetApi(Build.VERSION_CODES.TIRAMISU) - public FastBitmapDrawable newIcon(Context context, - @DrawableCreationFlags int creationFlags, Path badgeShape) { - AnimationInfo info; - Bitmap bg; - int themedFgColor; - ColorFilter bgFilter; - if ((creationFlags & FLAG_THEMED) != 0 && themeData != null) { - int[] colors = ThemedIconDrawable.getColors(context); - Drawable tintedDrawable = themeData.baseDrawableState.newDrawable().mutate(); - themedFgColor = colors[1]; - tintedDrawable.setTint(colors[1]); - info = themeData.copyForIcon(tintedDrawable); - bg = themeBackground; - bgFilter = new BlendModeColorFilter(colors[0], BlendMode.SRC_IN); - } else { - info = animInfo; - themedFgColor = NO_COLOR; - bg = mFlattenedBackground; - bgFilter = null; - } - if (info == null) { - return super.newIcon(context, creationFlags); - } - ClockIconDrawable.ClockConstantState cs = new ClockIconDrawable.ClockConstantState( - this, themedFgColor, boundsOffset, info, bg, bgFilter); - FastBitmapDrawable d = cs.newDrawable(); - applyFlags(context, d, creationFlags, null); - return d; - } - - @Override - public boolean canPersist() { - return false; - } - - @Override - public BitmapInfo clone() { - return copyInternalsTo(new ClockBitmapInfo(icon, color, - 1 - 2 * boundsOffset, animInfo, mFlattenedBackground, - themeData, themeBackground)); - } - - @Override - public CacheLookupFlag getMatchingLookupFlag() { - return DEFAULT_LOOKUP_FLAG.withThemeIcon(themeData != null); - } - } - - private static class ClockIconDrawable extends FastBitmapDrawable implements Runnable { - - private final Calendar mTime = Calendar.getInstance(); - - private final float mBoundsOffset; - private final AnimationInfo mAnimInfo; - - private final Bitmap mBG; - private final Paint mBgPaint = new Paint(Paint.FILTER_BITMAP_FLAG | Paint.ANTI_ALIAS_FLAG); - private final ColorFilter mBgFilter; - private final int mThemedFgColor; - - private final AdaptiveIconDrawable mFullDrawable; - private final LayerDrawable mFG; - private final float mCanvasScale; - - ClockIconDrawable(ClockConstantState cs) { - super(cs.getBitmapInfo()); - mBoundsOffset = cs.mBoundsOffset; - mAnimInfo = cs.mAnimInfo; - - mBG = cs.mBG; - mBgFilter = cs.mBgFilter; - mBgPaint.setColorFilter(cs.mBgFilter); - mThemedFgColor = cs.mThemedFgColor; - - mFullDrawable = - (AdaptiveIconDrawable) mAnimInfo.baseDrawableState.newDrawable().mutate(); - mFG = (LayerDrawable) mFullDrawable.getForeground(); - - // Time needs to be applied here since drawInternal is NOT guaranteed to be called - // before this foreground drawable is shown on the screen. - mAnimInfo.applyTime(mTime, mFG); - mCanvasScale = 1 - 2 * mBoundsOffset; - } - - @Override - public void setAlpha(int alpha) { - super.setAlpha(alpha); - mBgPaint.setAlpha(alpha); - mFG.setAlpha(alpha); - } - - @Override - protected void onBoundsChange(Rect bounds) { - super.onBoundsChange(bounds); - - // b/211896569 AdaptiveIcon does not work properly when bounds - // are not aligned to top/left corner - mFullDrawable.setBounds(0, 0, bounds.width(), bounds.height()); - } - - @Override - public void drawInternal(Canvas canvas, Rect bounds) { - if (mAnimInfo == null) { - super.drawInternal(canvas, bounds); - return; - } - canvas.drawBitmap(mBG, null, bounds, mBgPaint); - - // prepare and draw the foreground - mAnimInfo.applyTime(mTime, mFG); - int saveCount = canvas.save(); - canvas.translate(bounds.left, bounds.top); - canvas.scale(mCanvasScale, mCanvasScale, bounds.width() / 2, bounds.height() / 2); - canvas.clipPath(mFullDrawable.getIconMask()); - mFG.draw(canvas); - canvas.restoreToCount(saveCount); - - reschedule(); - } - - @Override - public boolean isThemed() { - return mBgPaint.getColorFilter() != null; - } - - @Override - protected void updateFilter() { - super.updateFilter(); - boolean isDisabled = isDisabled(); - int alpha = isDisabled ? (int) (disabledAlpha * FULLY_OPAQUE) : FULLY_OPAQUE; - setAlpha(alpha); - mBgPaint.setColorFilter(isDisabled ? getDisabledColorFilter() : mBgFilter); - mFG.setColorFilter(isDisabled ? getDisabledColorFilter() : null); - } - - @Override - public int getIconColor() { - return isThemed() ? mThemedFgColor : super.getIconColor(); - } - - @Override - public void run() { - if (mAnimInfo.applyTime(mTime, mFG)) { - invalidateSelf(); - } else { - reschedule(); - } - } - - @Override - public boolean setVisible(boolean visible, boolean restart) { - boolean result = super.setVisible(visible, restart); - if (visible) { - reschedule(); - } else { - unscheduleSelf(this); - } - return result; - } - - private void reschedule() { - if (!isVisible()) { - return; - } - unscheduleSelf(this); - final long upTime = SystemClock.uptimeMillis(); - final long step = TICK_MS; /* tick every 200 ms */ - scheduleSelf(this, upTime - ((upTime % step)) + step); - } - - @Override - public FastBitmapConstantState newConstantState() { - return new ClockConstantState(bitmapInfo, mThemedFgColor, mBoundsOffset, - mAnimInfo, mBG, mBgPaint.getColorFilter()); - } - - private static class ClockConstantState extends FastBitmapConstantState { - - private final float mBoundsOffset; - private final AnimationInfo mAnimInfo; - private final Bitmap mBG; - private final ColorFilter mBgFilter; - private final int mThemedFgColor; - - ClockConstantState(BitmapInfo info, int themedFgColor, - float boundsOffset, AnimationInfo animInfo, Bitmap bg, ColorFilter bgFilter) { - super(info); - mBoundsOffset = boundsOffset; - mAnimInfo = animInfo; - mBG = bg; - mBgFilter = bgFilter; - mThemedFgColor = themedFgColor; - } - - @Override - public FastBitmapDrawable createDrawable() { - return new ClockIconDrawable(this); - } - } - } -} diff --git a/iconloaderlib/src/com/android/launcher3/icons/ClockDrawableWrapper.kt b/iconloaderlib/src/com/android/launcher3/icons/ClockDrawableWrapper.kt index 1a73b57..a315c13 100644 --- a/iconloaderlib/src/com/android/launcher3/icons/ClockDrawableWrapper.kt +++ b/iconloaderlib/src/com/android/launcher3/icons/ClockDrawableWrapper.kt @@ -29,8 +29,10 @@ import android.graphics.Shader.TileMode.CLAMP import android.graphics.drawable.AdaptiveIconDrawable import android.graphics.drawable.Drawable import android.graphics.drawable.LayerDrawable +import android.os.Build import android.os.SystemClock import android.util.Log +import androidx.annotation.RequiresApi import com.android.launcher3.icons.BitmapInfo.Extender import com.android.launcher3.icons.FastBitmapDrawableDelegate.Companion.drawShaderInBounds import com.android.launcher3.icons.FastBitmapDrawableDelegate.DelegateFactory @@ -46,6 +48,7 @@ class ClockDrawableWrapper private constructor(base: AdaptiveIconDrawable, private val animationInfo: ClockAnimationInfo) : AdaptiveIconDrawable(base.background, base.foreground), Extender { + @RequiresApi(Build.VERSION_CODES.TIRAMISU) override fun getMonochrome(): Drawable? { val monoLayer = (animationInfo.baseDrawableState.newDrawable().mutate() as? AdaptiveIconDrawable) @@ -217,7 +220,7 @@ private constructor(base: AdaptiveIconDrawable, private val animationInfo: Clock private const val TAG = "ClockDrawableWrapper" - private const val DISABLE_SECONDS = true + private const val DISABLE_SECONDS = false // Lawnchair-TODO: Make it a toggle for seconds hand private const val NO_COLOR = Color.TRANSPARENT // Time after which the clock icon should check for an update. The actual invalidate diff --git a/iconloaderlib/src/com/android/launcher3/icons/GraphicsUtils.java b/iconloaderlib/src/com/android/launcher3/icons/GraphicsUtils.java deleted file mode 100644 index b17b006..0000000 --- a/iconloaderlib/src/com/android/launcher3/icons/GraphicsUtils.java +++ /dev/null @@ -1,117 +0,0 @@ -/* - * Copyright (C) 2018 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.launcher3.icons; - -import android.content.Context; -import android.content.res.TypedArray; -import android.graphics.Bitmap; -import android.graphics.Rect; -import android.graphics.Region; -import android.graphics.RegionIterator; -import android.util.Log; - -import androidx.annotation.ColorInt; - -import java.io.ByteArrayOutputStream; -import java.io.IOException; - -public class GraphicsUtils { - - private static final String TAG = "GraphicsUtils"; - - public static Runnable sOnNewBitmapRunnable = () -> { }; - - /** - * Set the alpha component of {@code color} to be {@code alpha}. Unlike the support lib version, - * it bounds the alpha in valid range instead of throwing an exception to allow for safer - * interpolation of color animations - */ - @ColorInt - public static int setColorAlphaBound(int color, int alpha) { - if (alpha < 0) { - alpha = 0; - } else if (alpha > 255) { - alpha = 255; - } - return (color & 0x00ffffff) | (alpha << 24); - } - - /** - * Compresses the bitmap to a byte array for serialization. - */ - public static byte[] flattenBitmap(Bitmap bitmap) { - ByteArrayOutputStream out = new ByteArrayOutputStream(getExpectedBitmapSize(bitmap)); - try { - bitmap.compress(Bitmap.CompressFormat.PNG, 100, out); - out.flush(); - out.close(); - return out.toByteArray(); - } catch (IOException e) { - Log.w(TAG, "Could not write bitmap"); - return null; - } - } - - /** - * Try go guesstimate how much space the icon will take when serialized to avoid unnecessary - * allocations/copies during the write (4 bytes per pixel). - */ - static int getExpectedBitmapSize(Bitmap bitmap) { - return bitmap.getWidth() * bitmap.getHeight() * 4; - } - - public static int getArea(Region r) { - RegionIterator itr = new RegionIterator(r); - int area = 0; - Rect tempRect = new Rect(); - while (itr.next(tempRect)) { - area += tempRect.width() * tempRect.height(); - } - return area; - } - - /** - * Utility method to track new bitmap creation - */ - public static void noteNewBitmapCreated() { - sOnNewBitmapRunnable.run(); - } - - /** - * Returns the color associated with the attribute - */ - public static int getAttrColor(Context context, int attr) { - TypedArray ta = context.obtainStyledAttributes(new int[]{attr}); - // pE-TODO(CompatTier2): wtf? - int colorAccent = 0; - try { - colorAccent = ta.getColor(0, 0); - } catch (UnsupportedOperationException ignored) { - } - ta.recycle(); - return colorAccent; - } - - /** - * Returns the alpha corresponding to the theme attribute {@param attr} - */ - public static float getFloat(Context context, int attr, float defValue) { - TypedArray ta = context.obtainStyledAttributes(new int[]{attr}); - float value = ta.getFloat(0, defValue); - ta.recycle(); - return value; - } -} diff --git a/iconloaderlib/src/com/android/launcher3/icons/PlaceHolderIconDrawable.java b/iconloaderlib/src/com/android/launcher3/icons/PlaceHolderIconDrawable.java deleted file mode 100644 index 531c35a..0000000 --- a/iconloaderlib/src/com/android/launcher3/icons/PlaceHolderIconDrawable.java +++ /dev/null @@ -1,91 +0,0 @@ -/* - * Copyright (C) 2018 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.launcher3.icons; - -import android.animation.Animator; -import android.animation.AnimatorListenerAdapter; -import android.animation.ValueAnimator; -import android.content.Context; -import android.graphics.Canvas; -import android.graphics.Color; -import android.graphics.Path; -import android.graphics.PorterDuff; -import android.graphics.PorterDuffColorFilter; -import android.graphics.Rect; -import android.graphics.drawable.AdaptiveIconDrawable; -import android.graphics.drawable.ColorDrawable; -import android.graphics.drawable.Drawable; - -import androidx.core.graphics.ColorUtils; - -/** - * Subclass which draws a placeholder icon when the actual icon is not yet loaded - */ -public class PlaceHolderIconDrawable extends FastBitmapDrawable { - - // Path in [0, 100] bounds. - private final Path mProgressPath; - - public PlaceHolderIconDrawable(BitmapInfo info, Context context) { - super(info); - mProgressPath = getDefaultPath(); - paint.setColor(ColorUtils.compositeColors( - GraphicsUtils.getAttrColor(context, R.attr.loadingIconColor), info.color)); - } - - /** - * Gets the current default icon mask {@link Path}. - * @return Shaped {@link Path} scaled to [0, 0, 100, 100] bounds - */ - private Path getDefaultPath() { - AdaptiveIconDrawable drawable = new AdaptiveIconDrawable( - new ColorDrawable(Color.BLACK), new ColorDrawable(Color.BLACK)); - drawable.setBounds(0, 0, 100, 100); - return new Path(drawable.getIconMask()); - } - - @Override - protected void drawInternal(Canvas canvas, Rect bounds) { - int saveCount = canvas.save(); - canvas.translate(bounds.left, bounds.top); - canvas.scale(bounds.width() / 100f, bounds.height() / 100f); - canvas.drawPath(mProgressPath, paint); - canvas.restoreToCount(saveCount); - } - - /** Updates this placeholder to {@code newIcon} with animation. */ - public void animateIconUpdate(Drawable newIcon) { - int placeholderColor = paint.getColor(); - int originalAlpha = Color.alpha(placeholderColor); - - ValueAnimator iconUpdateAnimation = ValueAnimator.ofInt(originalAlpha, 0); - iconUpdateAnimation.setDuration(375); - iconUpdateAnimation.addUpdateListener(valueAnimator -> { - int newAlpha = (int) valueAnimator.getAnimatedValue(); - int newColor = ColorUtils.setAlphaComponent(placeholderColor, newAlpha); - - newIcon.setColorFilter(new PorterDuffColorFilter(newColor, PorterDuff.Mode.SRC_ATOP)); - }); - iconUpdateAnimation.addListener(new AnimatorListenerAdapter() { - @Override - public void onAnimationEnd(Animator animation) { - newIcon.setColorFilter(null); - } - }); - iconUpdateAnimation.start(); - } - -} diff --git a/iconloaderlib/src/com/android/launcher3/icons/mono/ThemedIconDrawable.kt b/iconloaderlib/src/com/android/launcher3/icons/mono/ThemedIconDrawable.kt deleted file mode 100644 index 4ed5017..0000000 --- a/iconloaderlib/src/com/android/launcher3/icons/mono/ThemedIconDrawable.kt +++ /dev/null @@ -1,148 +0,0 @@ -/* - * Copyright (C) 2021 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.launcher3.icons.mono - -import android.annotation.ColorInt -import android.content.Context -import android.content.res.Configuration.UI_MODE_NIGHT_MASK -import android.content.res.Configuration.UI_MODE_NIGHT_YES -import android.graphics.Bitmap -import android.graphics.BlendMode.SRC_IN -import android.graphics.BlendModeColorFilter -import android.graphics.Canvas -import android.graphics.Paint -import android.graphics.PorterDuff -import android.graphics.PorterDuffColorFilter -import android.graphics.Rect -import android.os.Build -import androidx.core.graphics.ColorUtils -import com.android.launcher3.icons.BitmapInfo -import com.android.launcher3.icons.FastBitmapDrawable -import com.android.launcher3.icons.R - -import app.lawnchair.icons.shouldTransparentBGIcons - -/** Class to handle monochrome themed app icons */ -class ThemedIconDrawable(constantState: ThemedConstantState) : - FastBitmapDrawable(constantState.bitmapInfo) { - private val colorFg = constantState.colorFg - private val colorBg = constantState.colorBg - - // The foreground/monochrome icon for the app - private val monoIcon = constantState.mono - private val monoFilter = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - BlendModeColorFilter(colorFg, SRC_IN) - } else { - PorterDuffColorFilter(colorFg, PorterDuff.Mode.SRC_IN) - } - private val monoPaint = - Paint(Paint.ANTI_ALIAS_FLAG or Paint.FILTER_BITMAP_FLAG).apply { colorFilter = monoFilter } - - private val bgBitmap = constantState.whiteShadowLayer - private val bgFilter = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - BlendModeColorFilter(colorBg, SRC_IN) - } else { - PorterDuffColorFilter(colorBg, PorterDuff.Mode.SRC_IN) - } - private val mBgPaint = - Paint(Paint.ANTI_ALIAS_FLAG or Paint.FILTER_BITMAP_FLAG).apply { colorFilter = bgFilter } - - override fun drawInternal(canvas: Canvas, bounds: Rect) { - canvas.drawBitmap(bgBitmap, null, bounds, mBgPaint) - canvas.drawBitmap(monoIcon, null, bounds, monoPaint) - } - - override fun updateFilter() { - super.updateFilter() - val alpha = if (isDisabled) (disabledAlpha * FULLY_OPAQUE).toInt() else FULLY_OPAQUE - mBgPaint.alpha = alpha - mBgPaint.setColorFilter( - if (isDisabled) if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - BlendModeColorFilter(getDisabledColor(colorBg), SRC_IN) - } else { - PorterDuffColorFilter(getDisabledColor(colorBg), PorterDuff.Mode.SRC_IN) - } else bgFilter, - ) - - monoPaint.alpha = alpha - monoPaint.setColorFilter( - if (isDisabled) if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - BlendModeColorFilter( - getDisabledColor(colorFg), - SRC_IN, - ) - } else { - PorterDuffColorFilter(getDisabledColor(colorFg), PorterDuff.Mode.SRC_IN) - } else monoFilter, - ) - } - - override fun isThemed() = true - - override fun newConstantState() = - ThemedConstantState(bitmapInfo, monoIcon, bgBitmap, colorBg, colorFg) - - override fun getIconColor() = colorFg - - class ThemedConstantState( - bitmapInfo: BitmapInfo, - val mono: Bitmap, - val whiteShadowLayer: Bitmap, - val colorBg: Int, - val colorFg: Int, - ) : FastBitmapConstantState(bitmapInfo) { - - public override fun createDrawable() = ThemedIconDrawable(this) - } - - companion object { - const val TAG: String = "ThemedIconDrawable" - - @ColorInt - fun getThemedColors(context: Context): IntArray { - val result = getColors(context) - if (!context.shouldTransparentBGIcons()) { - return result - } - if ((context.getResources() - .getConfiguration().uiMode and UI_MODE_NIGHT_MASK) !== UI_MODE_NIGHT_YES - ) { - //Get Composite color for light mode or non dark mode - result[1] = ColorUtils.compositeColors( - context.getResources().getColor(android.R.color.black), result[1], - ) - } - result[0] = 0 - return result - } - - /** Get an int array representing background and foreground colors for themed icons */ - @JvmStatic - fun getColors(context: Context): IntArray { - if (COLORS_LOADER != null) { - return COLORS_LOADER(context); - } - val res = context.resources - return intArrayOf( - res.getColor(R.color.themed_icon_background_color), - res.getColor(R.color.themed_icon_color), - ) - } - - @JvmStatic - var COLORS_LOADER: (Context) -> IntArray = { context -> getColors(context) } - } -} diff --git a/iconloaderlib/src/com/android/launcher3/util/UserIconInfo.java b/iconloaderlib/src/com/android/launcher3/util/UserIconInfo.java deleted file mode 100644 index c06f6d9..0000000 --- a/iconloaderlib/src/com/android/launcher3/util/UserIconInfo.java +++ /dev/null @@ -1,81 +0,0 @@ -/* - * Copyright (C) 2013 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.launcher3.util; - -import static com.android.launcher3.icons.BitmapInfo.FLAG_CLONE; -import static com.android.launcher3.icons.BitmapInfo.FLAG_PRIVATE; -import static com.android.launcher3.icons.BitmapInfo.FLAG_WORK; - -import android.os.UserHandle; - -import androidx.annotation.IntDef; -import androidx.annotation.NonNull; - -/** - * Data class which stores various properties of a {@link android.os.UserHandle} - * which affects rendering - */ -public class UserIconInfo { - - public static final int TYPE_MAIN = 0; - public static final int TYPE_WORK = 1; - public static final int TYPE_CLONED = 2; - - public static final int TYPE_PRIVATE = 3; - - @IntDef({TYPE_MAIN, TYPE_WORK, TYPE_CLONED, TYPE_PRIVATE}) - public @interface UserType { } - - public final UserHandle user; - @UserType - public final int type; - - public final long userSerial; - - public UserIconInfo(UserHandle user, @UserType int type) { - this(user, type, user != null ? user.hashCode() : 0); - } - - public UserIconInfo(UserHandle user, @UserType int type, long userSerial) { - this.user = user; - this.type = type; - this.userSerial = userSerial; - } - - public boolean isMain() { - return type == TYPE_MAIN; - } - - public boolean isWork() { - return type == TYPE_WORK; - } - - public boolean isCloned() { - return type == TYPE_CLONED; - } - - public boolean isPrivate() { - return type == TYPE_PRIVATE; - } - - @NonNull - public FlagOp applyBitmapInfoFlags(@NonNull FlagOp op) { - return op.setFlag(FLAG_WORK, isWork()) - .setFlag(FLAG_CLONE, isCloned()) - .setFlag(FLAG_PRIVATE, isPrivate()); - } -} From b97fefd51878bb498964aa430b78e092f7443211 Mon Sep 17 00:00:00 2001 From: Pun Butrach Date: Fri, 5 Dec 2025 21:44:23 +0700 Subject: [PATCH 30/30] fix: A lot of stuff --- .../launcher3/icons/ClockDrawableWrapper.kt | 67 ++++++++++++++++++- .../android/launcher3/icons/IconProvider.java | 2 +- .../icons/mono/ThemedIconDelegate.kt | 3 + 3 files changed, 69 insertions(+), 3 deletions(-) diff --git a/iconloaderlib/src/com/android/launcher3/icons/ClockDrawableWrapper.kt b/iconloaderlib/src/com/android/launcher3/icons/ClockDrawableWrapper.kt index a315c13..4ef1adb 100644 --- a/iconloaderlib/src/com/android/launcher3/icons/ClockDrawableWrapper.kt +++ b/iconloaderlib/src/com/android/launcher3/icons/ClockDrawableWrapper.kt @@ -30,9 +30,12 @@ import android.graphics.drawable.AdaptiveIconDrawable import android.graphics.drawable.Drawable import android.graphics.drawable.LayerDrawable import android.os.Build +import android.os.Bundle import android.os.SystemClock import android.util.Log import androidx.annotation.RequiresApi +import app.lawnchair.icons.ClockMetadata +import app.lawnchair.icons.CustomAdaptiveIconDrawable import com.android.launcher3.icons.BitmapInfo.Extender import com.android.launcher3.icons.FastBitmapDrawableDelegate.Companion.drawShaderInBounds import com.android.launcher3.icons.FastBitmapDrawableDelegate.DelegateFactory @@ -40,13 +43,14 @@ import com.android.launcher3.icons.GraphicsUtils.getColorMultipliedFilter import com.android.launcher3.icons.GraphicsUtils.resizeToContentSize import java.util.Calendar import java.util.concurrent.TimeUnit.MINUTES +import java.util.function.IntFunction /** * Wrapper over [AdaptiveIconDrawable] to intercept icon flattening logic for dynamic clock icons */ class ClockDrawableWrapper private constructor(base: AdaptiveIconDrawable, private val animationInfo: ClockAnimationInfo) : - AdaptiveIconDrawable(base.background, base.foreground), Extender { + CustomAdaptiveIconDrawable(base.background, base.foreground), Extender { @RequiresApi(Build.VERSION_CODES.TIRAMISU) override fun getMonochrome(): Drawable? { @@ -71,7 +75,7 @@ private constructor(base: AdaptiveIconDrawable, private val animationInfo: Clock animationInfo.copy( themeFgColor = NO_COLOR, shader = BitmapShader(flattenBG, CLAMP, CLAMP), - ) + ), ) } @@ -255,6 +259,65 @@ private constructor(base: AdaptiveIconDrawable, private val animationInfo: Clock return null } + /** + * Loads and returns the wrapper from the provided Bundle metadata. + */ + @JvmStatic + fun forExtras( + metadata: Bundle?, + drawableProvider: IntFunction, + ): ClockDrawableWrapper? { + if (metadata == null) return null + val drawableId = metadata.getInt(ROUND_ICON_METADATA_KEY, 0) + if (drawableId == 0) return null + + val clockMetadata = ClockMetadata( + hourLayerIndex = metadata.getInt(HOUR_INDEX_METADATA_KEY, INVALID_VALUE), + minuteLayerIndex = metadata.getInt(MINUTE_INDEX_METADATA_KEY, INVALID_VALUE), + secondLayerIndex = metadata.getInt(SECOND_INDEX_METADATA_KEY, INVALID_VALUE), + defaultHour = metadata.getInt(DEFAULT_HOUR_METADATA_KEY, 0), + defaultMinute = metadata.getInt(DEFAULT_MINUTE_METADATA_KEY, 0), + defaultSecond = metadata.getInt(DEFAULT_SECOND_METADATA_KEY, 0), + ) + return forMeta(0, clockMetadata) { drawableProvider.apply(drawableId) } + } + + /** + * Loads and returns the wrapper from the provided ClockMetadata. + */ + @JvmStatic + fun forMeta( + @Suppress("UNUSED_PARAMETER") targetSdkVersion: Int, + metadata: ClockMetadata, + drawableProvider: () -> Drawable, + ): ClockDrawableWrapper? { + val drawable = drawableProvider().mutate() + if (drawable !is AdaptiveIconDrawable) return null + + val foreground = drawable.foreground as LayerDrawable + val layerCount = foreground.numberOfLayers + + fun validateIndex(index: Int) = if (index < 0 || index >= layerCount) INVALID_VALUE else index + + var animInfo = ClockAnimationInfo( + hourLayerIndex = validateIndex(metadata.hourLayerIndex), + minuteLayerIndex = validateIndex(metadata.minuteLayerIndex), + secondLayerIndex = validateIndex(metadata.secondLayerIndex), + defaultHour = metadata.defaultHour, + defaultMinute = metadata.defaultMinute, + defaultSecond = metadata.defaultSecond, + baseDrawableState = drawable.constantState!!, + ) + + if (DISABLE_SECONDS && animInfo.secondLayerIndex != INVALID_VALUE) { + foreground.setDrawable(animInfo.secondLayerIndex, null) + animInfo = animInfo.copy(secondLayerIndex = INVALID_VALUE) + } + + animInfo.applyTime(Calendar.getInstance(), foreground) + return ClockDrawableWrapper(drawable, animInfo) + } + private inline fun LayerDrawable.applyLevel(index: Int, level: () -> Int) = (index != INVALID_VALUE && getDrawable(index).setLevel(level.invoke())) diff --git a/iconloaderlib/src/com/android/launcher3/icons/IconProvider.java b/iconloaderlib/src/com/android/launcher3/icons/IconProvider.java index 645de6f..a0342ef 100644 --- a/iconloaderlib/src/com/android/launcher3/icons/IconProvider.java +++ b/iconloaderlib/src/com/android/launcher3/icons/IconProvider.java @@ -307,7 +307,7 @@ public ThemeData(Resources resources, int resID) { mResID = resID; } - Drawable loadPaddedDrawable() { + public Drawable loadPaddedDrawable() { if (!"drawable".equals(mResources.getResourceTypeName(mResID))) { return null; } diff --git a/iconloaderlib/src/com/android/launcher3/icons/mono/ThemedIconDelegate.kt b/iconloaderlib/src/com/android/launcher3/icons/mono/ThemedIconDelegate.kt index e056d71..39012a9 100644 --- a/iconloaderlib/src/com/android/launcher3/icons/mono/ThemedIconDelegate.kt +++ b/iconloaderlib/src/com/android/launcher3/icons/mono/ThemedIconDelegate.kt @@ -93,6 +93,9 @@ class ThemedIconDelegate( res.getColor(R.color.themed_icon_adaptive_background_color), ) } + + @JvmStatic + var COLORS_LOADER: (Context) -> IntArray = { context -> getColors(context) } } }