diff --git a/README.md b/README.md index 5797480..9b96c06 100644 --- a/README.md +++ b/README.md @@ -5,6 +5,12 @@ 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 +* `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/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 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 diff --git a/displaylib/Android.bp b/displaylib/Android.bp new file mode 100644 index 0000000..244e765 --- /dev/null +++ b/displaylib/Android.bp @@ -0,0 +1,29 @@ +// 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", + 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/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") +} 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..d4d2d09 --- /dev/null +++ b/displaylib/src/com/android/app/displaylib/DisplayRepository.kt @@ -0,0 +1,553 @@ +/* + * 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.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 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: StateFlow + + /** + * 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 + + /** + * 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() + + /** + * 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 + 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() { + + 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: StateFlow = + displayChangeEvent + .filter { it == Display.DEFAULT_DISPLAY } + .map { defaultDisplay.state == Display.STATE_OFF } + .stateIn( + bgApplicationScope, + SharingStarted.WhileSubscribed(), + defaultDisplay.state == Display.STATE_OFF, + ) + + 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 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) + } +} + +/** + * 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 { + + 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..b99030f --- /dev/null +++ b/displaylib/src/com/android/app/displaylib/DisplaysWithDecorationsRepository.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.app.displaylib + +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 +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) { + 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) { + trySend(Event.Remove(displayId)) + } + + override fun onDesktopModeEligibleChanged(displayId: Int) {} + + override fun onDisplayAnimationsDisabledChanged(displayId: Int, enabled: Boolean) {} + + 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..d670884 --- /dev/null +++ b/displaylib/src/com/android/app/displaylib/DisplaysWithDecorationsRepositoryCompat.kt @@ -0,0 +1,132 @@ +/* + * 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..ee4b7de --- /dev/null +++ b/displaylib/src/com/android/app/displaylib/PerDisplayRepository.kt @@ -0,0 +1,360 @@ +/* + * 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 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 +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) +} + +/** + * 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`. + * + * 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? + + /** + * 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 + + /** + * 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, + @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) { + lifecycleAllowedDisplayIds, + connectedDisplays -> + lifecycleAllowedDisplayIds.intersect(connectedDisplays) + } + } as StateFlow> + + init { + bgApplicationScope.launch { 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.") + perDisplayInstances.remove(displayId)?.let { instance -> + (instanceProvider as? PerDisplayInstanceProviderWithTeardown)?.destroyInstance( + instance + ) + } + } + } + } + + override fun get(displayId: Int): T? { + if ( + !displayRepository.containsDisplay(displayId) || + displayRepository.getDisplay(displayId) == null + ) { + 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 + } + + // 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 = 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 + ) { + instanceProvider.setupInstance(instance) + } + instance + } + } + + @AssistedFactory + interface Factory { + fun create( + debugName: String, + instanceProvider: PerDisplayInstanceProvider, + overrideLifecycleManager: DisplayInstanceLifecycleManager? = null, + createInstanceEagerly: Boolean = false, + ): 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 diff --git a/iconloaderlib/Android.bp b/iconloaderlib/Android.bp index 6867e6b..a3ed941 100644 --- a/iconloaderlib/Android.bp +++ b/iconloaderlib/Android.bp @@ -19,30 +19,47 @@ 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", + ], + kotlincflags: [ + "-Xjvm-default=all", ], } 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", + ], + 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/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/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 f5a16dc..0000000 --- a/iconloaderlib/src/com/android/launcher3/icons/BaseIconFactory.java +++ /dev/null @@ -1,715 +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.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.Canvas; -import android.graphics.Color; -import android.graphics.Paint; -import android.graphics.PaintFlagsDrawFilter; -import android.graphics.Path; -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.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; -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.icons.mono.ThemedIconDrawable; -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; - - 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); - } - - 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(); - - mShouldForceThemeIcon = mContext.getResources().getBoolean( - R.bool.enable_forced_themed_icon); - } - - 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, IconNormalizer.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(Color.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 - ) - ); - } - info = info.withFlags(getBitmapFlagOp(options)); - 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; - } - - /** - * @return True if forced theme icon is enabled - */ - public boolean shouldForceThemeIcon() { - return mShouldForceThemeIcon; - } - - @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(); - } - - public float getIconScale() { - return 1f; - } - - @NonNull - public Bitmap getWhiteShadowLayer() { - if (mWhiteShadowLayer == null) { - mWhiteShadowLayer = createScaledBitmap( - new AdaptiveIconDrawable(new ColorDrawable(Color.WHITE), null), - MODE_HARDWARE_WITH_SHADOW); - } - return mWhiteShadowLayer; - } - - @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(); - 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. - * @param canvas canvas to draw on - * @param drawable AdaptiveIconDrawable to draw - * @param overridePath path to clip icon with for shapes - */ - protected void drawAdaptiveIcon( - @NonNull Canvas canvas, - @NonNull AdaptiveIconDrawable drawable, - @NonNull Path overridePath - ) { - if (!Flags.enableLauncherIconShapes()) { - 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); - } - } - - @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 EmptyWrapper extends DrawableWrapper { - - EmptyWrapper() { - super(new ColorDrawable()); - } - - @Override - public ConstantState getConstantState() { - Drawable d = getDrawable(); - return d == null ? null : d.getConstantState(); - } - } -} 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..c9be156 --- /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 { + 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 + + 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.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/BitmapInfo.kt b/iconloaderlib/src/com/android/launcher3/icons/BitmapInfo.kt new file mode 100644 index 0000000..d6489b8 --- /dev/null +++ b/iconloaderlib/src/com/android/launcher3/icons/BitmapInfo.kt @@ -0,0 +1,242 @@ +/* + * 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.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 + +/** + * 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 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_FULL_BLEED], + ) + internal annotation class BitmapInfoFlags + + @IntDef(flag = true, value = [FLAG_THEMED, FLAG_NO_BADGE, FLAG_SKIP_USER_BADGE, FLAG_CUSTOM_SHAPE]) + annotation class DrawableCreationFlags + + fun withBadgeInfo(badgeInfo: BitmapInfo?) = copy(badgeInfo = badgeInfo) + + /** Returns a bitmapInfo with the flagOP applied */ + fun withFlags(op: FlagOp): BitmapInfo = + if (op === FlagOp.NO_OP) this else copy(flags = op.apply(this.flags)) + + val isLowRes: Boolean + get() = matchingLookupFlag.useLowRes() + + 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 */ + fun canPersist(): Boolean { + return !isLowRes && delegateFactory == SimpleDelegateFactory + } + + /** + * Creates a drawable for the provided BitmapInfo + * + * @param context Context + * @param creationFlags Flags for creating the FastBitmapDrawable + * @param iconShape information for custom Icon Shapes, to use with Full-bleed icons. + * @return FastBitmapDrawable + */ + @JvmOverloads + fun newIcon( + context: 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 { + 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. + */ + 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. + */ + private fun getBadgeDrawable( + 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, null) + } + if (skipUserBadge) { + return null + } else { + getBadgeDrawableInfo()?.let { + return UserBadgeDrawable(context, it.drawableRes, it.colorRes, isThemed) + } + } + return null + } + + /** Returns information about the badge to apply based on current flags. */ + fun getBadgeDrawableInfo(): BadgeDrawableInfo? { + return when { + 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 customize a BitmapInfo */ + interface Extender { + + /** 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() + } + + /** + * 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" + + // 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_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) + + @JvmStatic + fun fromBitmap(bitmap: Bitmap): BitmapInfo { + return of(bitmap, 0, IconShape.EMPTY) + } + + @JvmStatic + 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.java b/iconloaderlib/src/com/android/launcher3/icons/ClockDrawableWrapper.java deleted file mode 100644 index 3e8874a..0000000 --- a/iconloaderlib/src/com/android/launcher3/icons/ClockDrawableWrapper.java +++ /dev/null @@ -1,508 +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 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 app.lawnchair.icons.ClockMetadata; -import app.lawnchair.icons.CustomAdaptiveIconDrawable; -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; - -/** - * 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); - 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)); - } - } - - 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.mBitmapInfo); - 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(); - int alpha = mIsDisabled ? (int) (mDisabledAlpha * FULLY_OPAQUE) : FULLY_OPAQUE; - setAlpha(alpha); - mBgPaint.setColorFilter(mIsDisabled ? getDisabledColorFilter() : mBgFilter); - mFG.setColorFilter(mIsDisabled ? 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(mBitmapInfo, 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 new file mode 100644 index 0000000..4ef1adb --- /dev/null +++ b/iconloaderlib/src/com/android/launcher3/icons/ClockDrawableWrapper.kt @@ -0,0 +1,368 @@ +/* + * 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.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 +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) : + CustomAdaptiveIconDrawable(base.background, base.foreground), Extender { + + @RequiresApi(Build.VERSION_CODES.TIRAMISU) + 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 = 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 + // 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 + } + + /** + * 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())) + + /** 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.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; - } - } -} 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..63cc78c --- /dev/null +++ b/iconloaderlib/src/com/android/launcher3/icons/FastBitmapDrawable.kt @@ -0,0 +1,362 @@ +/* + * 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.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 com.android.launcher3.icons.BitmapInfo.Companion.LOW_RES_INFO +import com.android.launcher3.icons.BitmapInfo.DrawableCreationFlags +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)) + + // 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 + + var isDisabled: Boolean = false + set(value) { + if (field != value) { + field = value + badge.let { if (it is FastBitmapDrawable) it.isDisabled = value } + updateFilter() + } + } + + @JvmField @VisibleForTesting var scaleAnimation: ObjectAnimator? = null + var hoverScaleEnabledForDisplay = true + + private var scale = 1f + + private var paintAlpha = 255 + private var paintFilter: ColorFilter? = null + + init { + badge?.callback = this + } + + /** 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) { + if (scale != 1f) { + val count = canvas.save() + val bounds = bounds + canvas.scale(scale, scale, bounds.exactCenterX(), bounds.exactCenterY()) + drawInternal(canvas, bounds) + canvas.restoreToCount(count) + } else { + drawInternal(canvas, bounds) + } + } + + 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 */ + fun getIconColor(): Int = delegate.getIconColor(bitmapInfo) + + /** Returns if this represents a themed icon */ + 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 + * 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 + delegate.setAlpha(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. */ + private fun updateFilter() { + val filter = if (isDisabled) getDisabledColorFilter(disabledAlpha) else paintFilter + paint.colorFilter = filter + badge?.colorFilter = filter + delegate.updateFilter(filter) + invalidateSelf() + } + + 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 + + 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) + } + + 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 + } + + 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 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) + } + + /** 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/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.java b/iconloaderlib/src/com/android/launcher3/icons/GraphicsUtils.java deleted file mode 100644 index 1abac90..0000000 --- a/iconloaderlib/src/com/android/launcher3/icons/GraphicsUtils.java +++ /dev/null @@ -1,112 +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}); - int colorAccent = ta.getColor(0, 0); - 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/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 23eed3b..a0342ef 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; @@ -53,7 +41,8 @@ import androidx.annotation.Nullable; import androidx.core.os.BuildCompat; -import com.android.launcher3.util.SafeCloseable; +import com.android.launcher3.icons.cache.CachingLogic; +import com.android.launcher3.util.ComponentKey; import java.util.Calendar; import java.util.Objects; @@ -155,7 +144,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 +160,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; } @@ -300,11 +292,10 @@ private static ComponentName parseComponentOrNull(Context context, int resId) { } /** - * Registers a callback to listen for various system dependent icon changes. + * Notifies the provider when an icon is loaded from cache */ - public SafeCloseable registerIconChangeListener(IconChangeListener listener, Handler handler) { - return new IconChangeReceiver(listener, handler); - } + public void notifyIconLoaded( + @NonNull BitmapInfo icon, @NonNull ComponentKey key, @NonNull CachingLogic logic) { } public static class ThemeData { @@ -327,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 ae71236..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, Float iconScale) { + 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, iconScale); + 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/PlaceHolderIconDrawable.java b/iconloaderlib/src/com/android/launcher3/icons/PlaceHolderIconDrawable.java deleted file mode 100644 index 00f1942..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(); - mPaint.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, mPaint); - canvas.restoreToCount(saveCount); - } - - /** Updates this placeholder to {@code newIcon} with animation. */ - public void animateIconUpdate(Drawable newIcon) { - int placeholderColor = mPaint.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/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 6c937db..cee9aad 100644 --- a/iconloaderlib/src/com/android/launcher3/icons/ThemedBitmap.kt +++ b/iconloaderlib/src/com/android/launcher3/icons/ThemedBitmap.kt @@ -18,16 +18,30 @@ 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 + + companion object { + + @JvmField + /** ThemedBitmap to be used when theming is not supported for a particular bitmap */ + val NOT_SUPPORTED = + object : ThemedBitmap { + override fun newDelegateFactory(info: BitmapInfo, context: Context) = + info.delegateFactory + + override fun serialize() = ByteArray(0) + } + } } interface IconThemeController { @@ -39,15 +53,21 @@ interface IconThemeController { info: BitmapInfo, factory: BaseIconFactory, sourceHint: SourceHint? = null, - ): ThemedBitmap? + ): ThemedBitmap fun decode( - data: ByteArray, + bytes: ByteArray, 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/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 c305607..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 @@ -45,13 +44,16 @@ import com.android.launcher3.Flags 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 import com.android.launcher3.util.SQLiteCacheHelper +import com.android.systemui.shared.Flags.extendibleThemeManager import java.util.function.Supplier import kotlin.collections.MutableMap.MutableEntry @@ -92,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() @@ -132,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 @@ -189,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) @@ -214,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 = @@ -223,9 +232,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 +303,7 @@ constructor( obj, entry, cachingLogic, - lookupFlags.usePackageIcon(), + lookupFlags, /* usePackageTitle= */ true, componentName, user, @@ -311,7 +322,7 @@ constructor( obj: T?, entry: CacheEntry, cachingLogic: CachingLogic, - usePackageIcon: Boolean, + lookupFlag: CacheLookupFlag, usePackageTitle: Boolean, componentName: ComponentName, user: UserHandle, @@ -319,8 +330,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 +343,7 @@ constructor( entry.title = packageEntry.title } } + entry.bitmap = entry.bitmap.downSampleToLookupFlag(lookupFlag) } } @@ -378,8 +391,8 @@ constructor( iconFactory.use { li -> entry.bitmap = li.createBadgedIconBitmap( - li.createShapedAdaptiveIcon(icon), - IconOptions().setUser(user), + BitmapDrawable(icon), + IconOptions().setUser(user).assumeFullBleedIcon(true), ) } } @@ -442,8 +455,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) @@ -482,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( @@ -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 { @@ -541,28 +547,44 @@ constructor( Options().apply { inPreferredConfig = HARDWARE }, )!!, entry.bitmap.color, + iconFactory.use { it.defaultIconShape }, ) } catch (e: Exception) { 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 = 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 = + 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 } @@ -603,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" @@ -645,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.forceMonochromeAppIcons()) 10 else 9 + // 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" @@ -662,12 +681,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 +703,19 @@ 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 -> copy(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..ed7f66b 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" @@ -45,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 411d714..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 @@ -46,7 +44,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 = ThemedIconDelegate.Companion::getColors, ) : IconThemeController { override val themeID = "with-theme" @@ -57,118 +56,110 @@ 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)), - factory.iconScale, - sourceHint?.isFileDrawable ?: false, - factory.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, - iconScale: Float, - isFileDrawable: Boolean, - shouldForceThemeIcon: Boolean, - ): Drawable? { - val mono = base.monochrome - if (mono != null) { - return ClippedMonoDrawable(mono, shapePath, iconScale) - } - if (Flags.forceMonochromeAppIcons() && shouldForceThemeIcon && !isFileDrawable) { - return MonochromeIconFactory(info.icon.width).wrap(base, shapePath, iconScale) + 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? { + ): ThemedBitmap { val icon = info.icon - if (data.size != icon.height * icon.width) return null + 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( context: Context, originalIcon: AdaptiveIconDrawable, info: BitmapInfo?, - ): AdaptiveIconDrawable? { - val colors = colorProvider(context) + ): AdaptiveIconDrawable { + 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) } - } + val themedBitmap = info?.themedBitmap as? MonoThemedBitmap ?: return originalIcon + val colors = themedBitmap.getUpdatedColors(context) - class ClippedMonoDrawable( - base: Drawable?, - private val shapePath: Path, - private val iconScale: Float, - ) : 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) - canvas.scale(iconScale, iconScale, bounds.width() / 2f, bounds.height() / 2f) - } 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..39012a9 --- /dev/null +++ b/iconloaderlib/src/com/android/launcher3/icons/mono/ThemedIconDelegate.kt @@ -0,0 +1,110 @@ +/* + * 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), + ) + } + + @JvmStatic + var COLORS_LOADER: (Context) -> IntArray = { context -> getColors(context) } + } +} + +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/icons/mono/ThemedIconDrawable.kt b/iconloaderlib/src/com/android/launcher3/icons/mono/ThemedIconDrawable.kt deleted file mode 100644 index a0cabf1..0000000 --- a/iconloaderlib/src/com/android/launcher3/icons/mono/ThemedIconDrawable.kt +++ /dev/null @@ -1,149 +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 app.lawnchair.icons.shouldTransparentBGIcons -import com.android.launcher3.icons.BitmapInfo -import com.android.launcher3.icons.FastBitmapDrawable -import com.android.launcher3.icons.R - -/** Class to handle monochrome themed app icons */ -class ThemedIconDrawable(constantState: ThemedConstantState) : - FastBitmapDrawable(constantState.getBitmapInfo()) { - 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 (mIsDisabled) (mDisabledAlpha * FULLY_OPAQUE).toInt() else FULLY_OPAQUE - mBgPaint.alpha = alpha - mBgPaint.setColorFilter( - if (mIsDisabled) 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 (mIsDisabled) 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(mBitmapInfo, 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) - - fun getBitmapInfo(): BitmapInfo = mBitmapInfo - } - - 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/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.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()); - } -} 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) + } +} diff --git a/mechanics/Android.bp b/mechanics/Android.bp new file mode 100644 index 0000000..6df296e --- /dev/null +++ b/mechanics/Android.bp @@ -0,0 +1,36 @@ +// 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 must be specified, otherwise it compiles against private APIs. + sdk_version: "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/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 new file mode 100644 index 0000000..7f09a13 --- /dev/null +++ b/mechanics/TEST_MAPPING @@ -0,0 +1,56 @@ +{ + "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", + "keywords": ["internal"], + "options": [ + { + "exclude-annotation": "org.junit.Ignore" + }, + { + "exclude-annotation": "androidx.test.filters.FlakyTest" + } + ] + }, + { + "name": "PlatformComposeCoreTests", + "keywords": ["internal"], + "options": [ + { + "exclude-annotation": "org.junit.Ignore" + }, + { + "exclude-annotation": "androidx.test.filters.FlakyTest" + } + ] + } + ], + "presubmit-large": [ + { + "name": "SystemUITests", + "options": [ + {"exclude-annotation": "org.junit.Ignore"}, + {"exclude-annotation": "androidx.test.filters.FlakyTest"} + ] + } + ], + "wm": [ + { + "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/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 new file mode 100644 index 0000000..b2aab0b --- /dev/null +++ b/mechanics/benchmark/tests/src/com/android/mechanics/benchmark/MotionValueBenchmark.kt @@ -0,0 +1,262 @@ +/* + * 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.Identity, + ): 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, { MotionSpec.Identity }) + } + } + + @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/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/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..ddcc569 --- /dev/null +++ b/mechanics/compose/Android.bp @@ -0,0 +1,35 @@ +// 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: [ + // Private APIs + "PlatformComposeSceneTransitionLayout", + + // Public APIs + "//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/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 new file mode 100644 index 0000000..0d28358 --- /dev/null +++ b/mechanics/compose/src/com/android/mechanics/compose/modifier/VerticalFadeContentRevealModifier.kt @@ -0,0 +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.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 new file mode 100644 index 0000000..2d51a5e --- /dev/null +++ b/mechanics/compose/src/com/android/mechanics/compose/modifier/VerticalTactileSurfaceRevealModifier.kt @@ -0,0 +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.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/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/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..95c5790 --- /dev/null +++ b/mechanics/src/com/android/mechanics/MotionValue.kt @@ -0,0 +1,546 @@ +/* + * 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.annotation.FrequentlyChangingValue +import androidx.compose.runtime.derivedStateOf +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.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 +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 + * + * 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 + * + * 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 [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. + * 3. Access the animated output value through the [output] property. + * + * Internally, the [keepRunning] coroutine is automatically suspended if there is nothing to + * animate. + * + * @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( + input: () -> Float, + gestureContext: GestureContext, + spec: () -> MotionSpec, + label: String? = null, + stableThreshold: Float = StableThresholdEffect, + hapticPlayer: HapticPlayer = HapticPlayer.NoPlayer, +) : MotionValueState { + private val impl = + 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. */ + // TODO(b/441041846): This should not change frequently + @get:FrequentlyChangingValue val spec: MotionSpec by impl::spec + + /** Animated [output] value. */ + @get:FrequentlyChangingValue override val output: Float by impl::computedOutput + + /** + * [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. + */ + // TODO(b/441041846): This should not change frequently + @get:FrequentlyChangingValue override val outputTarget: Float by impl::computedOutputTarget + + /** The [output] exposed as [FloatState]. */ + @get:FrequentlyChangingValue override val floatValue: Float by impl::computedOutput + + /** Whether an animation is currently running. */ + // 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. + */ + // 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. */ + // TODO(b/441041846): This should not change frequently + @get:FrequentlyChangingValue + override 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) } + } + + override val label: String? by impl::label + + companion object { + /** Creates a [MotionValue] whose [currentInput] is the animated [output] of [source]. */ + fun createDerived( + source: MotionValue, + spec: () -> MotionSpec, + label: String? = null, + stableThreshold: Float = 0.01f, + ): MotionValue { + return MotionValue( + input = { source.output }, + gestureContext = source.impl.gestureContext, + spec = derivedStateOf(calculation = spec)::value, + 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. + */ + override 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.computedIsOutputFixed, + ), + impl.isActive, + impl.debugIsAnimating, + ::onDisposeDebugInspector, + ) + } + + return checkNotNull(impl.debugInspector) + } +} + +private class ObservableComputations( + private val inputProvider: () -> Float, + val gestureContext: GestureContext, + private val specProvider: () -> MotionSpec, + override val stableThreshold: Float, + override val label: String?, + private val hapticPlayer: HapticPlayer, +) : Computations() { + + // ---- CurrentFrameInput --------------------------------------------------------------------- + + override val spec + get() = specProvider.invoke() + + override val currentInput: Float + get() = inputProvider.invoke() + + override val currentDirection: InputDirection + get() = gestureContext.direction + + override val currentGestureDragOffset: Float + get() = gestureContext.dragOffset + + override var currentAnimationTimeNanos by mutableLongStateOf(-1L) + + override var lastHapticsTimeNanos by mutableLongStateOf(-1L) + + // ---- LastFrameState --------------------------------------------------------------------- + + override var lastSegment: SegmentData by + mutableStateOf( + this.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 + 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 + } + + 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 + } + + // Perform haptics + if (breakpointHaptics != null) { + performBreakpointHapticFeedback(breakpointHaptics) + } else { + performSegmentHapticFeedback(capturedSegment.haptics) + } + + capturedFrameTimeNanos = currentAnimationTimeNanos + + debugInspector?.run { + frame = + FrameData( + capturedInput, + capturedDirection, + capturedGestureDragOffset, + capturedFrameTimeNanos, + capturedSpringState, + capturedSegment, + capturedAnimation, + computedIsOutputFixed, + ) + } + + 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 + + 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/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..2247945 --- /dev/null +++ b/mechanics/src/com/android/mechanics/debug/DebugInspector.kt @@ -0,0 +1,95 @@ +/* + * 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.MotionSpec +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 isOutputFixed: Boolean, +) { + 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) } + + 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 new file mode 100644 index 0000000..dfd1a5f --- /dev/null +++ b/mechanics/src/com/android/mechanics/debug/DebugVisualization.kt @@ -0,0 +1,543 @@ +/* + * 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.derivedStateOf +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.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]. + * + * 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: MotionValueState, + inputRange: ClosedFloatingPointRange, + modifier: Modifier = Modifier, + outputRange: OutputRangeFn = DebugMotionValueVisualization.default, + maxAgeMillis: Long = 1000L, +) { + 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 + val axisColor = colorScheme.outline + val specColor = colorScheme.tertiary + val valueColor = colorScheme.primary + + val primarySpec = spec.get(inspector.frame.gestureDirection) + val activeSegment = inspector.frame.segmentKey + + Spacer( + modifier = + modifier + .debugMotionSpecGraph( + primarySpec, + inputRange, + computedOutputRange, + axisColor, + specColor, + activeSegment, + ) + .debugMotionValueGraph( + motionValue, + valueColor, + inputRange, + 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]. + * + * 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: MotionValueState, + 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: MotionValueState, + 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: MotionValueState, + 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..ac8d634 --- /dev/null +++ b/mechanics/src/com/android/mechanics/debug/MotionValueDebugger.kt @@ -0,0 +1,132 @@ +/* + * 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.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.ObserverModifierNode +import androidx.compose.ui.node.currentValueOf +import androidx.compose.ui.node.observeReads +import androidx.compose.ui.platform.InspectorInfo +import com.android.mechanics.MotionValueState +import kotlinx.coroutines.DisposableHandle + +/** 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) } + } + + /** The currently registered `MotionValues`. */ + val observed: List + get() = observedMotionValues +} + +/** Composition-local to provide a [MotionValueDebugController]. */ +val LocalMotionValueDebugController = staticCompositionLocalOf { null } + +/** + * 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. + */ +@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 [motionValue] with the [LocalMotionValueDebugController], if available. */ +fun Modifier.debugMotionValue(motionValue: MotionValueState): Modifier = + this.then(DebugMotionValueElement(motionValue)) + +/** 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() } + } + } +} + +/** + * [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() { + onObservedReadsChanged() + } + + override fun onDetach() { + debugger = null + 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: MotionValueState) : + ModifierNodeElement() { + override fun create(): DebugMotionValueNode = DebugMotionValueNode(motionValue) + + override fun InspectorInfo.inspectableProperties() { + // Intentionally empty + } + + override fun update(node: DebugMotionValueNode) { + node.motionValue = motionValue + } +} 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/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..3df1a26 --- /dev/null +++ b/mechanics/src/com/android/mechanics/effects/MagneticDetach.kt @@ -0,0 +1,241 @@ +/* + * 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 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 +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, + private val enableHaptics: Boolean = false, +) : 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. */ + @OptIn(HapticsExperimentalApi::class) + 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 + + // 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, + breakpointHaptics = thresholdHaptics, + ) + 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, + breakpointHaptics = thresholdHaptics, + ) + 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), + ) + } + + /* 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), + ) + } + + private fun EffectApplyScope.addSegmentHandlers( + beforeDetachSegment: SegmentKey, + beforeAttachSegment: SegmentKey, + afterAttachSegment: SegmentKey, + ) { + // 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, DirectionChangePreservesCurrentValue) + } +} 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..075c9fd --- /dev/null +++ b/mechanics/src/com/android/mechanics/effects/RevealOnThreshold.kt @@ -0,0 +1,61 @@ +/* + * 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, + val cornerMaxSize: Dp = Defaults.CornerMaxSize, +) : Effect.PlaceableBetween { + init { + require(minSize >= 0.dp) + require(cornerMaxSize >= 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 + 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 new file mode 100644 index 0000000..3d0175b --- /dev/null +++ b/mechanics/src/com/android/mechanics/impl/ComputationInput.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. + */ + +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 + + /** 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 new file mode 100644 index 0000000..2287c67 --- /dev/null +++ b/mechanics/src/com/android/mechanics/impl/Computations.kt @@ -0,0 +1,681 @@ +/* + * 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.haptics.BreakpointHaptics +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, + val breakpointHaptics: BreakpointHaptics?, + ) + + // currentComputedValues input + 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 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 + + if ( + memoizedSpec == currentSpec && + memoizedInput == currentInput && + memoizedAnimationTimeNanos == currentAnimationTimeNanos && + memoizedDirection == currentDirection + ) { + return memoizedComputedValues + } + + val isInitialComputation = memoizedSpec == MotionSpec.InitiallyUndefined + + memoizedSpec = currentSpec + memoizedInput = currentInput + memoizedAnimationTimeNanos = currentAnimationTimeNanos + memoizedDirection = currentDirection + + 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 + 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 computedOutput: Float + get() = + if (isSameSegmentAndAtRest) { + lastSegment.mapping.map(currentInput) + } else { + computedOutputTarget + currentSpringState.displacement + } + + val computedOutputTarget: Float + get() = + if (isSameSegmentAndAtRest) { + lastSegment.mapping.map(currentInput) + } else { + currentComputedValues.segment.mapping.map(currentInput) + } + + val computedIsStable: Boolean + get() = + if (isSameSegmentAndAtRest) { + true + } else { + currentSpringState == SpringState.AtRest + } + + /** + * 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) + } + } + + 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 + } + } + } + + 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/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..fd92f2b --- /dev/null +++ b/mechanics/src/com/android/mechanics/spec/Breakpoint.kt @@ -0,0 +1,133 @@ +/* + * 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.haptics.BreakpointHaptics +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. + * @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 { + 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, + BreakpointHaptics.None, + ) + + /** Last breakpoint of each spec. */ + val maxLimit = + Breakpoint( + BreakpointKey.MaxLimit, + Float.POSITIVE_INFINITY, + SpringParameters.Snap, + Guarantee.None, + BreakpointHaptics.None, + ) + + internal fun create( + breakpointKey: BreakpointKey, + breakpointPosition: Float, + springSpec: SpringParameters, + guarantee: Guarantee, + breakpointHaptics: BreakpointHaptics, + ): Breakpoint { + return when (breakpointKey) { + BreakpointKey.MinLimit -> minLimit + BreakpointKey.MaxLimit -> maxLimit + else -> + Breakpoint( + breakpointKey, + breakpointPosition, + springSpec, + guarantee, + breakpointHaptics, + ) + } + } + } + + 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/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 new file mode 100644 index 0000000..19fd71e --- /dev/null +++ b/mechanics/src/com/android/mechanics/spec/MotionSpec.kt @@ -0,0 +1,292 @@ +/* + * 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.haptics.SegmentHaptics +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]. + * @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]. */ + 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], 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]. + * + * 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 semanticState(key) + 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], + haptics[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) + + /* 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) + } +} + +/** + * 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 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]. */ + 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) + 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() + + 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 { + /* 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 new file mode 100644 index 0000000..9430f6f --- /dev/null +++ b/mechanics/src/com/android/mechanics/spec/MotionSpecDebugFormatter.kt @@ -0,0 +1,138 @@ +/* + * 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 com.android.mechanics.haptics.SegmentHaptics + +/** 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) + appendSegmentHapticsLine(haptics[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) + } + + append(" [") + append("breakpointHaptics=") + append(breakpoint.breakpointHaptics.toString()) + append("]") + + 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.appendSegmentHapticsLine( + segmentHaptics: SegmentHaptics, + indent: Int = 0, +) { + appendIndent(indent) + append("segment haptics: $segmentHaptics") + 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..f212b53 --- /dev/null +++ b/mechanics/src/com/android/mechanics/spec/Segment.kt @@ -0,0 +1,97 @@ +/* + * 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 com.android.mechanics.haptics.SegmentHaptics + +/** + * 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 haptics: SegmentHaptics, +) { + 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, segmentHaptics: $haptics)" + } +} 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..e1a16d9 --- /dev/null +++ b/mechanics/src/com/android/mechanics/spec/SegmentChangeHandler.kt @@ -0,0 +1,80 @@ +/* + * 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) + } + } + + /** + * 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/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..a5c5e31 --- /dev/null +++ b/mechanics/src/com/android/mechanics/spec/builder/DirectionalBuilderImpl.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.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 +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() + 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 + 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, + 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) } + } + } + + 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, + breakpointHaptics: BreakpointHaptics, + springSpec: SpringParameters, + guarantee: Guarantee, + semantics: List>, + ) { + 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) { + 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, breakpointHaptics) + } + + fun finalizeBuilderFn(breakpoint: Breakpoint) = + finalizeBuilderFn( + breakpoint.position, + breakpoint.key, + breakpoint.breakpointHaptics, + breakpoint.spring, + breakpoint.guarantee, + emptyList(), + ) + + /* 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(), + 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, breakpointHaptics) + continueWithTargetValueImpl(to) + } + + override fun targetFromCurrent( + breakpoint: Float, + to: Float, + delta: Float, + breakpointHaptics: BreakpointHaptics, + spring: SpringParameters, + guarantee: Guarantee, + key: BreakpointKey, + semantics: List>, + ) { + toBreakpointImpl(breakpoint, key, semantics) + jumpByImpl(delta, spring, guarantee, breakpointHaptics) + continueWithTargetValueImpl(to) + } + + override fun fractionalInput( + 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, breakpointHaptics) + continueWithFractionalInputImpl(fraction) + return CanBeLastSegmentImpl + } + + override fun fractionalInputFromCurrent( + 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, breakpointHaptics) + continueWithFractionalInputImpl(fraction) + return CanBeLastSegmentImpl + } + + 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, breakpointHaptics) + continueWithFixedValueImpl() + return CanBeLastSegmentImpl + } + + 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, breakpointHaptics) + continueWithFixedValueImpl() + return CanBeLastSegmentImpl + } + + override fun mapping( + breakpoint: Float, + spring: SpringParameters, + guarantee: Guarantee, + key: BreakpointKey, + semantics: List>, + breakpointHaptics: BreakpointHaptics, + mapping: Mapping, + ): CanBeLastSegment { + toBreakpointImpl(breakpoint, key, semantics) + continueWithImpl(mapping, spring, guarantee, breakpointHaptics) + 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)) + segmentHaptics.add(currentSegmentHaptics) + sourceValue = Float.NaN + } + + private fun jumpToImpl( + value: Float, + spring: SpringParameters, + guarantee: Guarantee, + breakpointHaptics: BreakpointHaptics, + ) { + check(sourceValue.isNaN()) + + doAddBreakpointImpl(spring, guarantee, breakpointHaptics) + sourceValue = value + } + + private fun jumpByImpl( + delta: Float, + spring: SpringParameters, + guarantee: Guarantee, + breakpointHaptics: BreakpointHaptics, + ) { + check(sourceValue.isNaN()) + + val breakpoint = doAddBreakpointImpl(spring, guarantee, breakpointHaptics) + sourceValue = mappings.last().map(breakpoint.position) + delta + } + + private fun continueWithImpl( + mapping: Mapping, + spring: SpringParameters, + guarantee: Guarantee, + breakpointHaptics: BreakpointHaptics, + ) { + check(sourceValue.isNaN()) + + doAddBreakpointImpl(spring, guarantee, breakpointHaptics) + mappings.add(mapping) + segmentHaptics.add(currentSegmentHaptics) + } + + 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) + segmentHaptics.add(currentSegmentHaptics) + 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, + breakpointHaptics: BreakpointHaptics, + ): Breakpoint { + val breakpoint = + Breakpoint.create( + checkNotNull(breakpointKey), + breakpointPosition, + springSpec, + guarantee, + breakpointHaptics, + ) + + breakpoints.add(breakpoint) + breakpointPosition = Float.NaN + breakpointKey = null + + 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) { + 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..8fef629 --- /dev/null +++ b/mechanics/src/com/android/mechanics/spec/builder/DirectionalBuilderScope.kt @@ -0,0 +1,310 @@ +/* + * 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.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.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 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]. + * @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, + breakpointHaptics: BreakpointHaptics = BreakpointHaptics.None, + 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 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]. + * @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, + breakpointHaptics: BreakpointHaptics = BreakpointHaptics.None, + 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 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]. + * @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, + breakpointHaptics: BreakpointHaptics = BreakpointHaptics.None, + 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 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]. + * @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, + breakpointHaptics: BreakpointHaptics = BreakpointHaptics.None, + 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 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]. + * @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, + breakpointHaptics: BreakpointHaptics = BreakpointHaptics.None, + 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 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]. + * @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, + breakpointHaptics: BreakpointHaptics = BreakpointHaptics.None, + 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 breakpointHaptics Haptics at the breakpoint that ends the current segment. + * @param mapping The custom [Mapping] to use. + */ + fun mapping( + breakpoint: Float, + spring: SpringParameters = defaultSpring, + guarantee: Guarantee = Guarantee.None, + key: BreakpointKey = BreakpointKey(), + semantics: List> = emptyList(), + breakpointHaptics: BreakpointHaptics = BreakpointHaptics.None, + 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 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. + * @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, + 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, + breakpointHaptics, + Mapping.Identity, + ) + } else { + fractionalInput( + breakpoint, + fraction = 1f, + breakpointHaptics = breakpointHaptics, + from = breakpoint + delta, + spring = spring, + guarantee = guarantee, + key = key, + semantics = semantics, + ) + } + } + + /** + * 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]. */ +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..21c1007 --- /dev/null +++ b/mechanics/src/com/android/mechanics/spec/builder/DirectionalSpecBuilder.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.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 +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, + segmentHaptics: SegmentHaptics = SegmentHaptics.None, + semantics: List> = emptyList(), +): DirectionalMotionSpec { + fun toSegmentSemanticValues(semanticValue: SemanticValue) = + SegmentSemanticValues(semanticValue.key, listOf(semanticValue.value)) + + 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/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..347d429 --- /dev/null +++ b/mechanics/src/com/android/mechanics/spec/builder/EffectApplyScope.kt @@ -0,0 +1,188 @@ +/* + * 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.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 +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, mappings and haptics 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 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, + ) + + /** + * 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, + breakpointHaptics: BreakpointHaptics? = null, + ) + + fun after( + spring: SpringParameters? = null, + guarantee: Guarantee? = null, + semantics: List>? = null, + mapping: Mapping? = null, + breakpointHaptics: BreakpointHaptics? = 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..de8ab3b --- /dev/null +++ b/mechanics/src/com/android/mechanics/spec/builder/MotionBuilderContext.kt @@ -0,0 +1,120 @@ +/* + * 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.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 + +/** + * Device / scheme specific context for building motion specs. + * + * See go/motion-system. + * + * @see rememberMotionBuilderContext for Compose (in composition) + * @see motionBuilderContext for Compose (in Modifier.Node) + * @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) } +} + +/** + * [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 = + 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..f6668f7 --- /dev/null +++ b/mechanics/src/com/android/mechanics/spec/builder/MotionSpecBuilder.kt @@ -0,0 +1,163 @@ +/* + * 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, + semantics: List> = emptyList(), + init: MotionSpecBuilderScope.() -> Unit, +) = motionSpec(baseMapping, defaultSpring, resetSpring, semantics, 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, + semantics: List> = emptyList(), + init: MotionSpecBuilderScope.() -> Unit, +) = motionSpec(baseMapping, defaultSpring, resetSpring, semantics, 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 semantics initial semantics that apply before of effects override them. + * @param init + */ +fun MotionBuilderContext.motionSpec( + baseMapping: Mapping, + defaultSpring: SpringParameters, + resetSpring: SpringParameters = defaultSpring, + semantics: List> = emptyList(), + init: MotionSpecBuilderScope.() -> Unit, +): MotionSpec { + return MotionSpecBuilderImpl( + baseMapping, + defaultSpring, + resetSpring, + semantics, + 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)), + resetSpring = resetSpring, + semantics = semantics, + ) +} + +/** 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..c60aae2 --- /dev/null +++ b/mechanics/src/com/android/mechanics/spec/builder/MotionSpecBuilderImpl.kt @@ -0,0 +1,612 @@ +/* + * 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.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 +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, + semantics = baseSemantics, + ) + } + + builders = + mutableObjectListOf( + DirectionalEffectBuilderScopeImpl(defaultSpring), + DirectionalEffectBuilderScopeImpl(defaultSpring), + ) + 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(), + semantics = baseSemantics, + ) + } + + 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, SegmentHaptics.None, semantics, init) + backward(initialMapping, semantics, init) + } + + override fun unidirectional(mapping: Mapping, semantics: List>) { + forward(mapping, semantics) + backward(mapping, semantics) + } + + 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, initialSegmentHaptics, semantics) + forwardBuilder.init() + } + + override fun forward(mapping: Mapping, semantics: List>) { + check(!forwardInvoked) { "Cannot define forward spec more than once" } + forwardInvoked = true + + forwardBuilder.prepareBuilderFn(mapping, SegmentHaptics.None, 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, SegmentHaptics.None, semantics) + reverseBuilder.init() + } + + override fun backward(mapping: Mapping, semantics: List>) { + check(!backwardInvoked) { "Cannot define backward spec more than once" } + backwardInvoked = true + + reverseBuilder.prepareBuilderFn(mapping, SegmentHaptics.None, 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, + BreakpointHaptics.None, + ) + builders.forEach { builder -> + builder.mappings += builder.afterMapping ?: baseMapping + builder.segmentHaptics += SegmentHaptics.None + 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.afterBreakpointHaptics ?: BreakpointHaptics.None, + 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) : + 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() { + beforeGuarantee = null + beforeSpring = null + beforeSemantics = null + beforeMapping = null + afterGuarantee = null + afterSpring = null + afterSemantics = null + afterMapping = null + afterBreakpointHaptics = null + beforeBreakpointHaptics = 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..f708cb9 --- /dev/null +++ b/mechanics/src/com/android/mechanics/view/ViewMotionValue.kt @@ -0,0 +1,345 @@ +/* + * 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.Identity, + 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::computedOutput + + /** + * [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::computedOutputTarget + + /** Whether an animation is currently running. */ + val isStable: Boolean by impl::computedIsStable + + /** + * The current value for the [SemanticKey]. + * + * `null` if not defined in the spec. + */ + operator fun get(key: SemanticKey): T? { + return impl.computedSemanticState(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.computedIsOutputFixed, + ), + 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 + override var lastHapticsTimeNanos: Long = -1L + 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() + addUpdateListener { + val isAnimationFinished = updateOutputValue(currentPlayTime) + debugInspector?.isAnimating = !isAnimationFinished + if (isAnimationFinished) { + pause() + } + } + } + + fun ensureFrameRequested() { + if (animationFrameDriver.isPaused) { + animationFrameDriver.resume() + } + } + + fun pauseFrameRequests() { + if (animationFrameDriver.isRunning) { + animationFrameDriver.pause() + } + } + + /** `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, + computedIsOutputFixed, + ) + } + + if (currentValues.segment.spec == MotionSpec.InitiallyUndefined) return true + + listeners.fastForEach { it.onMotionValueUpdated(motionValue) } + + // Prepare last* state + if (isAnimatingUninterrupted) { + directMappedVelocity = + computeDirectMappedVelocity(currentAnimationTimeNanos - lastFrameTimeNanos) + } else { + directMappedVelocity = 0f + } + + var isAnimationFinished = computedIsStable + 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/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 new file mode 100644 index 0000000..45f7388 --- /dev/null +++ b/mechanics/testing/src/com/android/mechanics/testing/ComposeMotionValueToolkit.kt @@ -0,0 +1,183 @@ +/* + * 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 com.android.mechanics.testing.MotionValueToolkit.Companion.FrameDuration +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< + InputScope, + MotionValue, + MotionValue, + DistanceGestureContext, + >() { + + 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, + override var 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 = { input }, + gestureContext = gestureContext, + spec = { spec }, + stableThreshold = stableThreshold, + ) + + 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..ece17b6 --- /dev/null +++ b/mechanics/testing/src/com/android/mechanics/testing/FeatureCaptures.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.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.DataPoint +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() } + + /** 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, + dataPointType: DataPointType, + name: String = key.debugLabel, + ): FeatureCapture { + 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 new file mode 100644 index 0000000..0d280c1 --- /dev/null +++ b/mechanics/testing/src/com/android/mechanics/testing/MotionSpecSubject.kt @@ -0,0 +1,346 @@ +/* + * 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.haptics.SegmentHaptics +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.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 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() + + 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) + } +} + +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, + 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..c8ac6b4 --- /dev/null +++ b/mechanics/testing/src/com/android/mechanics/testing/MotionValueToolkit.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.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, + I : InputScope, + UnderTestType, + 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: UnderTestType) -> List = { emptyList() }, + capture: CaptureTimeSeriesFn = defaultFeatureCaptures, + testInput: suspend (I).() -> 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 + /** Current spec of the `MotionValue` */ + var spec: MotionSpec + /** 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< + I : InputScope, + UnderTestType, + MotionValueType, + GestureContextType, +> { + internal abstract fun goldenTest( + motionTestRule: MotionTestRule<*>, + spec: MotionSpec, + createDerived: (underTest: UnderTestType) -> List, + initialValue: Float, + initialDirection: InputDirection, + directionChangeSlop: Float, + stableThreshold: Float, + verifyTimeSeries: TimeSeries.() -> VerifyTimeSeriesResult, + capture: CaptureTimeSeriesFn, + testInput: suspend (I).() -> Unit, + ) + + internal fun createTimeSeries( + frameIds: List, + frameValueCaptures: List, + ): TimeSeries { + return TimeSeries( + frameIds.toList(), + frameValueCaptures.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 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/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..40b05a3 --- /dev/null +++ b/mechanics/testing/src/com/android/mechanics/testing/ViewMotionValueToolkit.kt @@ -0,0 +1,179 @@ +/* + * 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.testing.MotionValueToolkit.Companion.FrameDuration +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< + InputScope, + ViewMotionValue, + ViewMotionValue, + DistanceGestureContext, + >() { + + 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 var spec: MotionSpec + get() = underTest.spec + set(value) { + underTest.spec = 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/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/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/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/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/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/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/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/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..2555903 --- /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.5, + 0.96824056, + 0.6450497, + 0.39762267, + 0.22869363, + 0.12247165, + 0.06022339, + 0.026204487, + 0.009041936, + 0 + ] + }, + { + "name": "outputTarget", + "type": "float", + "data_points": [ + 1.5, + 1.5, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ] + }, + { + "name": "outputSpring", + "type": "springParameters", + "data_points": [ + { + "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, + false, + false, + false, + false, + false, + false, + false, + false, + true + ] + } + ] +} \ No newline at end of file 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/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/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 new file mode 100644 index 0000000..f3dc050 --- /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..740a803 --- /dev/null +++ b/mechanics/tests/src/com/android/mechanics/MotionValueTest.kt @@ -0,0 +1,781 @@ +/* + * 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 kotlin.test.assertFailsWith +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.Identity, + 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 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 + + @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 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) = + 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 + 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 + 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, { MotionSpec.Identity }) + + 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.Identity }, 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( + source = primary, + spec = { + 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.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 + // numbers + assertThat(output.drop(1).take(5)) + .containsExactlyElementsIn(listOf(0f, 1f, Float.NaN, 0f, 0f)) + .inOrder() + SkipGoldenVerification + }, + ) { + animatedInputSequence(0f, 1f) + 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, { MotionSpec.Identity }, 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, { MotionSpec.Identity }) + + val originalInspector = underTest.debugInspector() + assertThat(underTest.debugInspector()).isSameInstanceAs(originalInspector) + } + + @Test + fun debugInspector_newInstance_afterUnused() { + val underTest = MotionValue({ 1f }, FakeGestureContext, { MotionSpec.Identity }) + + 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..a7c3b30 --- /dev/null +++ b/mechanics/tests/src/com/android/mechanics/debug/MotionValueDebuggerTest.kt @@ -0,0 +1,100 @@ +/* + * 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.CompositionLocalProvider +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.android.mechanics.spec.MotionSpec +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 = MotionValueDebugController() + var hasValue by mutableStateOf(false) + + rule.setContent { + CompositionLocalProvider(LocalMotionValueDebugController provides debuggerState) { + if (hasValue) { + val toDebug = remember { + MotionValue(input, gestureContext, { MotionSpec.Identity }) + } + Box(modifier = Modifier.debugMotionValue(toDebug)) + } + } + } + + assertThat(debuggerState.observed).isEmpty() + + hasValue = true + rule.waitForIdle() + + assertThat(debuggerState.observed).hasSize(1) + } + + @Test + fun debugMotionValue_unregistersMotionValue_whenLeavingComposition() { + val debuggerState = MotionValueDebugController() + var hasValue by mutableStateOf(true) + + rule.setContent { + CompositionLocalProvider(LocalMotionValueDebugController provides debuggerState) { + if (hasValue) { + val toDebug = remember { + MotionValue(input, gestureContext, { MotionSpec.Identity }) + } + Box(modifier = Modifier.debugMotionValue(toDebug)) + } + } + } + + assertThat(debuggerState.observed).hasSize(1) + + hasValue = false + rule.waitForIdle() + assertThat(debuggerState.observed).isEmpty() + } + + @Test + fun debugMotionValue_noDebugger_isNoOp() { + rule.setContent { + val toDebug = remember { MotionValue(input, gestureContext, { MotionSpec.Identity }) } + 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/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 new file mode 100644 index 0000000..d2a00cc --- /dev/null +++ b/mechanics/tests/src/com/android/mechanics/spec/DirectionalMotionSpecTest.kt @@ -0,0 +1,240 @@ +/* + * 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.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 +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(), emptyList()) + } + } + + @Test + fun wrongSentinelBreakpoints_throws() { + 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), + listOf(SegmentHaptics.None), + ) + } + } + + @Test + fun tooFewMappings_throws() { + assertFailsWith { + DirectionalMotionSpec( + listOf(Breakpoint.minLimit, Breakpoint.maxLimit), + emptyList(), + listOf(SegmentHaptics.None), + ) + } + } + + @Test + fun tooManyMappings_throws() { + assertFailsWith { + 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, 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.Identity + + 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.Identity + + 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(SegmentHaptics.None), + listOf(SegmentSemanticValues(Semantic1, emptyList())), + ) + } + } + + @Test + fun semantics_tooManyValues_throws() { + assertFailsWith { + DirectionalMotionSpec( + listOf(Breakpoint.minLimit, Breakpoint.maxLimit), + listOf(Mapping.Identity), + listOf(SegmentHaptics.None), + 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..86d138a --- /dev/null +++ b/mechanics/tests/src/com/android/mechanics/spec/MotionSpecDebugFormatterTest.kt @@ -0,0 +1,156 @@ +/* + * 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] [breakpointHaptics=None] + Fixed(value=0.0) + segment haptics: None + @0.0 [id:0x1234cdef] spring=1600.0/1.0 [breakpointHaptics=None] + Fixed(value=1.0) + segment haptics: None + @Infinity [built-in::max|id:0x1234cdef] [breakpointHaptics=None]""" + .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] [breakpointHaptics=None] + Fixed(value=0.0) + segment haptics: None + @0.0 [id:0x1234cdef] spring=700.0/0.9 [breakpointHaptics=None] + Fixed(value=1.0) + segment haptics: None + @Infinity [built-in::max|id:0x1234cdef] [breakpointHaptics=None] +minDirection: + @-Infinity [built-in::min|id:0x1234cdef] [breakpointHaptics=None] + Fixed(value=1.0) + segment haptics: None + @0.0 [id:0x1234cdef] spring=700.0/0.9 [breakpointHaptics=None] + Fixed(value=0.0) + segment haptics: None + @Infinity [built-in::max|id:0x1234cdef] [breakpointHaptics=None]""" + .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] [breakpointHaptics=None] + Fixed(value=0.0) + segment haptics: None + foo[id:0x1234cdef]=42.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] [breakpointHaptics=None]""" + .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] [breakpointHaptics=None] + Fixed(value=0.0) + segment haptics: None + @0.0 [1|id:0x1234cdef] spring=1600.0/1.0 [breakpointHaptics=None] + Fixed(value=1.0) + segment haptics: None + @2.0 [1|id:0x1234cdef] spring=1600.0/1.0 [breakpointHaptics=None] + Fixed(value=2.0) + segment haptics: None + @Infinity [built-in::max|id:0x1234cdef] [breakpointHaptics=None] +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..3f8287e --- /dev/null +++ b/mechanics/tests/src/com/android/mechanics/spec/MotionSpecTest.kt @@ -0,0 +1,352 @@ +/* + * 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.Identity + 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.Identity, + ) + + 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.Identity, + 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.Identity + + 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.Identity + + 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.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") + 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..2b4bf5f --- /dev/null +++ b/mechanics/tests/src/com/android/mechanics/spec/SegmentTest.kt @@ -0,0 +1,162 @@ +/* + * 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.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 +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class SegmentTest { + + private val fakeSpec = MotionSpec.Identity + + @Test + fun segmentData_isValidForInput_betweenBreakpointsSameDirection_isTrue() { + 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, + SegmentHaptics.None, + ) + + assertThat(underTest.isValidForInput(15f, InputDirection.Max)).isTrue() + } + + @Test + fun segmentData_isValidForInput_betweenBreakpointsOppositeDirection_isFalse() { + 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, + 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, BreakpointHaptics.None) + val breakpoint2 = + Breakpoint(B2, position = 20f, Spring, Guarantee.None, BreakpointHaptics.None) + val underTest = + 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)) { + 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, BreakpointHaptics.None) + val breakpoint2 = + Breakpoint(B2, position = 20f, Spring, Guarantee.None, BreakpointHaptics.None) + val underTest = + 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)) { + 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, BreakpointHaptics.None) + val breakpoint2 = + Breakpoint(B2, position = 20f, Spring, Guarantee.None, BreakpointHaptics.None) + val underTest = + 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, BreakpointHaptics.None) + val breakpoint2 = + Breakpoint(B2, position = 20f, Spring, Guarantee.None, BreakpointHaptics.None) + val underTest = + SegmentData( + fakeSpec, + breakpoint1, + breakpoint2, + InputDirection.Min, + Mapping.Identity, + SegmentHaptics.None, + ) + + 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..72fde69 --- /dev/null +++ b/mechanics/tests/src/com/android/mechanics/spec/builder/DirectionalBuilderImplTest.kt @@ -0,0 +1,333 @@ +/* + * 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.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 +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, + SegmentHaptics.None, + 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) + } + + @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") + 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..148ae86 --- /dev/null +++ b/mechanics/tests/src/com/android/mechanics/spec/builder/MotionSpecBuilderTest.kt @@ -0,0 +1,756 @@ +/* + * 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 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 = + 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..4027fb4 --- /dev/null +++ b/mechanics/tests/src/com/android/mechanics/view/ViewMotionValueTest.kt @@ -0,0 +1,311 @@ +/* + * 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 kotlin.test.assertFailsWith +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.Identity, + 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 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( + 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) { + awaitFrames() + 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.Identity) + + 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.Identity) + + // Ensure the initial update has been processed + animatorTestRule.advanceTimeBy(16L) + + 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) + + // Ensure the initial update has been processed + animatorTestRule.advanceTimeBy(16L) + + 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.Identity) + + 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.Identity) + + val originalInspector = underTest.debugInspector() + originalInspector.dispose() + assertThat(underTest.debugInspector()).isNotSameInstanceAs(originalInspector) + } + } +} 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 + } } diff --git a/searchuilib/src/com/android/app/search/LayoutType.java b/searchuilib/src/com/android/app/search/LayoutType.java index 1adb0ea..23ec4e9 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,6 +150,12 @@ 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"; + + // 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"; 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; 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..cfa6bc7 --- /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_DROP + ) + .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..977199c --- /dev/null +++ b/viewcapturelib/tests/com/android/app/viewcapture/ViewCaptureAwareWindowManagerTest.kt @@ -0,0 +1,140 @@ +/* + * 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.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.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) + } + } + + @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) + ) + } + } +}