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