diff --git a/displaylib/Android.bp b/displaylib/Android.bp index 85eefb8..244e765 100644 --- a/displaylib/Android.bp +++ b/displaylib/Android.bp @@ -18,7 +18,6 @@ package { java_library { name: "displaylib", - manifest: "AndroidManifest.xml", static_libs: [ "kotlinx_coroutines_android", "dagger2", diff --git a/displaylib/src/com/android/app/displaylib/DisplayRepository.kt b/displaylib/src/com/android/app/displaylib/DisplayRepository.kt index 7b43355..d4d2d09 100644 --- a/displaylib/src/com/android/app/displaylib/DisplayRepository.kt +++ b/displaylib/src/com/android/app/displaylib/DisplayRepository.kt @@ -21,9 +21,17 @@ import android.hardware.display.DisplayManager.DisplayListener import android.hardware.display.DisplayManager.EVENT_TYPE_DISPLAY_ADDED import android.hardware.display.DisplayManager.EVENT_TYPE_DISPLAY_CHANGED import android.hardware.display.DisplayManager.EVENT_TYPE_DISPLAY_REMOVED +import android.hardware.display.DisplayManager.EXTERNAL_DISPLAY_CONNECTION_PREFERENCE_ASK +import android.hardware.display.DisplayManager.EXTERNAL_DISPLAY_CONNECTION_PREFERENCE_DESKTOP +import android.hardware.display.DisplayManager.EXTERNAL_DISPLAY_CONNECTION_PREFERENCE_MIRROR import android.os.Handler import android.util.Log import android.view.Display +import com.android.app.displaylib.ExternalDisplayConnectionType.DESKTOP +import com.android.app.displaylib.ExternalDisplayConnectionType.MIRROR +import com.android.app.displaylib.ExternalDisplayConnectionType.NOT_SPECIFIED + + import javax.inject.Inject import javax.inject.Singleton import kotlinx.coroutines.CoroutineDispatcher @@ -79,7 +87,7 @@ interface DisplayRepository { val pendingDisplay: Flow /** Whether the default display is currently off. */ - val defaultDisplayOff: Flow + val defaultDisplayOff: StateFlow /** * Given a display ID int, return the corresponding Display object, or null if none exist. @@ -111,6 +119,20 @@ interface DisplayRepository { /** Id of the pending display. */ val id: Int + /** + * The saved connection preference for the display, either desktop, mirroring or show the + * dialog. Defaults to [ExternalDisplayConnectionType.NOT_SPECIFIED], if no value saved. + */ + val connectionType: ExternalDisplayConnectionType + + /** + * Updates the saved connection preference for the display, triggered by the connection + * dialog's "remember my choice" checkbox + * + * @see com.android.systemui.display.ui.viewmodel.ConnectingDisplayViewModel + */ + suspend fun updateConnectionPreference(connectionType: ExternalDisplayConnectionType) + /** Enables the display, making it available to the system. */ suspend fun enable() @@ -243,6 +265,7 @@ constructor( private val ignoredDisplayIds: Flow> = _ignoredDisplayIds.debugLog("ignoredDisplayIds") private fun getInitialConnectedDisplays(): Set = + displayManager .getDisplays(DISPLAY_CATEGORY_ALL_INCLUDING_DISABLED) .map { it.displayId } @@ -253,6 +276,7 @@ constructor( } } + /* keeps connected displays until they are disconnected. */ private val connectedDisplayIds: StateFlow> = callbackFlow { @@ -302,9 +326,11 @@ constructor( private val connectedExternalDisplayIds: Flow> = connectedDisplayIds .map { connectedDisplayIds -> + connectedDisplayIds .filter { id -> getDisplayType(id) == Display.TYPE_EXTERNAL } .toSet() + } .flowOn(backgroundCoroutineDispatcher) .debugLog("connectedExternalDisplayIds") @@ -344,38 +370,68 @@ constructor( pendingDisplayId .map { displayId -> val id = displayId ?: return@map null + val pendingDisplay = getDisplay(id) ?: displayManager.getDisplay(id) + val uniqueId = pendingDisplay?.uniqueId ?: return@map null + val connectionPreference = + displayManager.getExternalDisplayConnectionPreference(uniqueId) + object : DisplayRepository.PendingDisplay { override val id = id + override val connectionType: ExternalDisplayConnectionType = + when (connectionPreference) { + EXTERNAL_DISPLAY_CONNECTION_PREFERENCE_DESKTOP -> DESKTOP + EXTERNAL_DISPLAY_CONNECTION_PREFERENCE_MIRROR -> MIRROR + else -> NOT_SPECIFIED + } + + override suspend fun updateConnectionPreference( + connectionType: ExternalDisplayConnectionType + ) { + displayManager.setExternalDisplayConnectionPreference( + uniqueId, + connectionType.preference, + ) + } override suspend fun enable() { + if (DEBUG) { Log.d(TAG, "Enabling display with id=$id") } displayManager.enableConnectedDisplay(id) + // After the display has been enabled, it is automatically ignored. ignore() } override suspend fun ignore() { + _ignoredDisplayIds.value += id + } override suspend fun disable() { ignore() + if (DEBUG) { Log.d(TAG, "Disabling display with id=$id") } displayManager.disableConnectedDisplay(id) + } } } .debugLog("pendingDisplay") - override val defaultDisplayOff: Flow = + override val defaultDisplayOff: StateFlow = displayChangeEvent .filter { it == Display.DEFAULT_DISPLAY } .map { defaultDisplay.state == Display.STATE_OFF } - .distinctUntilChanged() + .stateIn( + bgApplicationScope, + SharingStarted.WhileSubscribed(), + defaultDisplay.state == Display.STATE_OFF, + ) override fun getDisplay(displayId: Int): Display? { val cachedDisplay = getCachedDisplay(displayId) @@ -387,19 +443,16 @@ constructor( // In case of option one, let's get it synchronously from display manager to make sure for // this to be consistent. return if (displayIds.value.contains(displayId)) { + getDisplayFromDisplayManager(displayId) + } else { null } } private fun Flow.debugLog(flowName: String): Flow { - return if (DEBUG) { - // LC-Ignored - this - } else { - this - } + return this } /** @@ -443,6 +496,17 @@ constructor( } } +/** + * Possible connection types for an external display. + * + * @property preference The integer value that represents the connection type in the system. + */ +enum class ExternalDisplayConnectionType(val preference: Int) { + NOT_SPECIFIED(EXTERNAL_DISPLAY_CONNECTION_PREFERENCE_ASK), + DESKTOP(EXTERNAL_DISPLAY_CONNECTION_PREFERENCE_DESKTOP), + MIRROR(EXTERNAL_DISPLAY_CONNECTION_PREFERENCE_MIRROR), +} + /** Used to provide default implementations for all methods. */ private interface DisplayConnectionListener : DisplayListener { diff --git a/displaylib/src/com/android/app/displaylib/DisplaysWithDecorationsRepository.kt b/displaylib/src/com/android/app/displaylib/DisplaysWithDecorationsRepository.kt index b184bd9..b99030f 100644 --- a/displaylib/src/com/android/app/displaylib/DisplaysWithDecorationsRepository.kt +++ b/displaylib/src/com/android/app/displaylib/DisplaysWithDecorationsRepository.kt @@ -20,6 +20,7 @@ import android.content.res.Configuration import android.graphics.Rect import android.view.IDisplayWindowListener import android.view.IWindowManager +import android.window.DesktopExperienceFlags.ENABLE_DISPLAY_CONTENT_MODE_MANAGEMENT import javax.inject.Inject import javax.inject.Singleton import kotlinx.coroutines.CoroutineScope @@ -53,7 +54,13 @@ constructor( val callback = object : IDisplayWindowListener.Stub() { override fun onDisplayAddSystemDecorations(displayId: Int) { - trySend(Event.Add(displayId)) + if (ENABLE_DISPLAY_CONTENT_MODE_MANAGEMENT.isTrue()) { + trySend(Event.Add(displayId)) + } else { + if (windowManager.shouldShowSystemDecors(displayId)) { + trySend(Event.Add(displayId)) + } + } } override fun onDisplayRemoveSystemDecorations(displayId: Int) { @@ -62,6 +69,8 @@ constructor( override fun onDesktopModeEligibleChanged(displayId: Int) {} + override fun onDisplayAnimationsDisabledChanged(displayId: Int, enabled: Boolean) {} + override fun onDisplayAdded(p0: Int) {} override fun onDisplayConfigurationChanged(p0: Int, p1: Configuration?) {} diff --git a/displaylib/src/com/android/app/displaylib/DisplaysWithDecorationsRepositoryCompat.kt b/displaylib/src/com/android/app/displaylib/DisplaysWithDecorationsRepositoryCompat.kt index 66aa7cc..d670884 100644 --- a/displaylib/src/com/android/app/displaylib/DisplaysWithDecorationsRepositoryCompat.kt +++ b/displaylib/src/com/android/app/displaylib/DisplaysWithDecorationsRepositoryCompat.kt @@ -16,6 +16,7 @@ package com.android.app.displaylib + import com.android.internal.annotations.GuardedBy import java.util.concurrent.ConcurrentHashMap import javax.inject.Inject diff --git a/displaylib/src/com/android/app/displaylib/PerDisplayRepository.kt b/displaylib/src/com/android/app/displaylib/PerDisplayRepository.kt index 74bc572..ee4b7de 100644 --- a/displaylib/src/com/android/app/displaylib/PerDisplayRepository.kt +++ b/displaylib/src/com/android/app/displaylib/PerDisplayRepository.kt @@ -19,6 +19,9 @@ package com.android.app.displaylib import android.util.Log import android.view.Display import android.view.Display.DEFAULT_DISPLAY +//import com.android.app.tracing.coroutines.flow.stateInTraced +//import com.android.app.tracing.coroutines.launchTraced as launch +//import com.android.app.tracing.traceSection import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject @@ -77,6 +80,20 @@ interface PerDisplayInstanceProviderWithTeardown : PerDisplayInstanceProvider fun destroyInstance(instance: T) } +/** + * Extends [PerDisplayInstanceProvider], adding support for setting up an instance after it's + * created. + * + * This is useful to run custom setup after an instance of the repository is created and cached. Why + * not doing it in the [createInstance] itself? if some deps of the setup code tries to get the + * instance again through the repository, it would cause a recursive loop (as it will try to create + * a new instance). Splitting this into another method helps avoiding the recursion. + */ +interface PerDisplayInstanceProviderWithSetup : PerDisplayInstanceProvider { + /** Sets up a previously created instance of `T`. */ + fun setupInstance(instance: T) +} + /** * Provides access to per-display instances of type `T`. * @@ -87,6 +104,25 @@ interface PerDisplayRepository { /** Gets the cached instance or create a new one for a given display. */ operator fun get(displayId: Int): T? + /** + * Gets the cached instance or create a new one for a given display. If the given display + * doesn't exist, returns an instance for the default display. + */ + fun getOrDefault(displayId: Int): T { + val instance = get(displayId) + if (instance == null) { + Log.e( + "PerDisplayRepository", + """<$debugName> getOrDefault: instance for display with id $displayId returned + |null. The display likely doesn't exist anymore. Returning an instance for the + |default display.""" + .trimMargin(), + ) + return get(DEFAULT_DISPLAY)!! + } + return instance + } + /** Debug name for this repository, mainly for tracing and logging. */ val debugName: String @@ -145,24 +181,25 @@ constructor( @DisplayLibBackground bgApplicationScope: CoroutineScope, private val displayRepository: DisplayRepository, private val initCallback: PerDisplayRepository.InitCallback, + @Assisted private val createInstanceEagerly: Boolean = false, ) : PerDisplayRepository { private val perDisplayInstances = ConcurrentHashMap() private val allowedDisplays: StateFlow> = - (if (lifecycleManager == null) { - displayRepository.displayIds - } else { - // If there is a lifecycle manager, we still consider the smallest subset between - // the ones connected and the ones from the lifecycle. This is to safeguard against - // leaks, in case of lifecycle manager misbehaving (as it's provided by clients, and - // we can't guarantee it's correct). - combine(lifecycleManager.displayIds, displayRepository.displayIds) { + if (lifecycleManager == null) { + displayRepository.displayIds + } else { + // If there is a lifecycle manager, we still consider the smallest subset between + // the ones connected and the ones from the lifecycle. This is to safeguard against + // leaks, in case of lifecycle manager misbehaving (as it's provided by clients, and + // we can't guarantee it's correct). + combine(lifecycleManager.displayIds, displayRepository.displayIds) { lifecycleAllowedDisplayIds, connectedDisplays -> - lifecycleAllowedDisplayIds.intersect(connectedDisplays) - } - }) as StateFlow> + lifecycleAllowedDisplayIds.intersect(connectedDisplays) + } + } as StateFlow> init { bgApplicationScope.launch { start() } @@ -171,6 +208,13 @@ constructor( private suspend fun start() { initCallback.onInit(debugName, this) allowedDisplays.collectLatest { displayIds -> + if (createInstanceEagerly) { + val toAdd = displayIds - perDisplayInstances.keys + toAdd.forEach { displayId -> + Log.d(TAG, "<$debugName> eagerly creating instance for displayId=$displayId.") + get(displayId) + } + } val toRemove = perDisplayInstances.keys - displayIds toRemove.forEach { displayId -> Log.d(TAG, "<$debugName> destroying instance for displayId=$displayId.") @@ -184,7 +228,10 @@ constructor( } override fun get(displayId: Int): T? { - if (!displayRepository.containsDisplay(displayId)) { + if ( + !displayRepository.containsDisplay(displayId) || + displayRepository.getDisplay(displayId) == null + ) { Log.e(TAG, "<$debugName: Display with id $displayId doesn't exist.") return null } @@ -198,15 +245,36 @@ constructor( return null } - // If it doesn't exist, create it and put it in the map. - return perDisplayInstances.computeIfAbsent(displayId) { key -> - Log.d(TAG, "<$debugName> creating instance for displayId=$key, as it wasn't available.") - val instance = instanceProvider.createInstance(key) - if (instance == null) { - Log.e( - TAG, - "<$debugName> returning null because createInstance($key) returned null.", - ) + // Let's not let this method return the new instance until the possible setup for it was + // executed. + // There is no need to synchronize the other accesses to the map as it's already a + // concurrent one. + return synchronized(this) { + var newlyCreated = false + // If it doesn't exist, create it and put it in the map. + val instance = + perDisplayInstances.computeIfAbsent(displayId) { key -> + Log.d( + TAG, + "<$debugName> creating instance for displayId=$key, as it wasn't available.", + ) + val instance = 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 } @@ -218,6 +286,7 @@ constructor( debugName: String, instanceProvider: PerDisplayInstanceProvider, overrideLifecycleManager: DisplayInstanceLifecycleManager? = null, + createInstanceEagerly: Boolean = false, ): PerDisplayInstanceRepositoryImpl } diff --git a/iconloaderlib/Android.bp b/iconloaderlib/Android.bp index e991888..a3ed941 100644 --- a/iconloaderlib/Android.bp +++ b/iconloaderlib/Android.bp @@ -32,6 +32,9 @@ android_library { "src/**/*.java", "src/**/*.kt", ], + kotlincflags: [ + "-Xjvm-default=all", + ], } android_library { @@ -56,4 +59,7 @@ android_library { "//apex_available:platform", "com.android.permission", ], + kotlincflags: [ + "-Xjvm-default=all", + ], } diff --git a/iconloaderlib/build.gradle.kts b/iconloaderlib/build.gradle.kts new file mode 100644 index 0000000..9885cee --- /dev/null +++ b/iconloaderlib/build.gradle.kts @@ -0,0 +1,34 @@ +plugins { + id(libs.plugins.android.library.get().pluginId) + id(libs.plugins.kotlin.android.get().pluginId) +} + +android { + namespace = "com.android.launcher3.icons" + + defaultConfig { + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + testApplicationId = "com.android.launcher3.icons.tests" + } + + sourceSets { + named("main") { + java.setSrcDirs(listOf("src", "src_full_lib")) + manifest.srcFile("AndroidManifest.xml") + res.setSrcDirs(listOf("res")) + } + + named("androidTest") { + java.setSrcDirs(listOf("tests/src")) + } + } +} + +dependencies { + implementation("androidx.core:core") + api(project(":NexusLauncher:Flags")) + api(project(":frameworks:base:packages:SystemUI:SystemUISharedFlags")) + + androidTestImplementation(libs.androidx.test.rules) + androidTestImplementation(libs.androidx.junit) +} diff --git a/iconloaderlib/res/values-night-v31/colors.xml b/iconloaderlib/res/values-night-v31/colors.xml index e5ebda6..6e50d77 100644 --- a/iconloaderlib/res/values-night-v31/colors.xml +++ b/iconloaderlib/res/values-night-v31/colors.xml @@ -19,6 +19,7 @@ @android:color/system_accent1_200 @android:color/system_accent2_800 + @android:color/system_accent1_800 @android:color/system_accent2_800 @android:color/system_accent1_200 diff --git a/iconloaderlib/res/values-v31/colors.xml b/iconloaderlib/res/values-v31/colors.xml index 1405ad0..0bcd4a0 100644 --- a/iconloaderlib/res/values-v31/colors.xml +++ b/iconloaderlib/res/values-v31/colors.xml @@ -19,6 +19,7 @@ @android:color/system_accent1_700 @android:color/system_accent1_100 + @android:color/system_accent1_500 @android:color/system_accent1_700 @android:color/system_accent1_100 diff --git a/iconloaderlib/src/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 411e18d..4292988 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 2d246c1..0000000 --- a/iconloaderlib/src/com/android/launcher3/icons/BaseIconFactory.java +++ /dev/null @@ -1,744 +0,0 @@ -package com.android.launcher3.icons; - -import static android.graphics.Color.BLACK; -import static android.graphics.Paint.ANTI_ALIAS_FLAG; -import static android.graphics.Paint.DITHER_FLAG; -import static android.graphics.Paint.FILTER_BITMAP_FLAG; -import static android.graphics.drawable.AdaptiveIconDrawable.getExtraInsetFraction; - -import static com.android.launcher3.icons.BitmapInfo.FLAG_INSTANT; -import static com.android.launcher3.icons.IconNormalizer.ICON_VISIBLE_AREA_FACTOR; -import static com.android.launcher3.icons.ShadowGenerator.BLUR_FACTOR; -import static com.android.launcher3.icons.ShadowGenerator.ICON_SCALE_FOR_SHADOWS; - -import static java.lang.annotation.RetentionPolicy.SOURCE; - -import android.annotation.TargetApi; -import android.content.Context; -import android.content.Intent; -import android.content.pm.PackageManager; -import android.content.res.Resources; -import android.graphics.Bitmap; -import android.graphics.Bitmap.Config; -import android.graphics.BitmapShader; -import android.graphics.Canvas; -import android.graphics.Color; -import android.graphics.Paint; -import android.graphics.PaintFlagsDrawFilter; -import android.graphics.Path; -import android.graphics.Rect; -import android.graphics.Shader.TileMode; -import android.graphics.drawable.AdaptiveIconDrawable; -import android.graphics.drawable.BitmapDrawable; -import android.graphics.drawable.ColorDrawable; -import android.graphics.drawable.Drawable; -import android.graphics.drawable.InsetDrawable; -import android.os.Build; -import android.os.UserHandle; -import android.util.SparseArray; - -import androidx.annotation.ColorInt; -import androidx.annotation.IntDef; -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; - -import com.android.launcher3.Flags; -import com.android.launcher3.icons.BitmapInfo.Extender; -import com.android.launcher3.util.FlagOp; -import com.android.launcher3.util.UserIconInfo; - -import java.lang.annotation.Retention; - -import app.lawnchair.icons.CustomAdaptiveIconDrawable; -import app.lawnchair.icons.ExtendedBitmapDrawable; -import app.lawnchair.icons.FixedScaleDrawable; -import app.lawnchair.icons.IconPreferencesKt; - -/** - * This class will be moved to androidx library. There shouldn't be any dependency outside - * this package. - */ -public class BaseIconFactory implements AutoCloseable { - - public static final int DEFAULT_WRAPPER_BACKGROUND = Color.WHITE; - public static final float LEGACY_ICON_SCALE = .7f * (1f / (1 + 2 * getExtraInsetFraction())); - - public static final int MODE_DEFAULT = 0; - public static final int MODE_ALPHA = 1; - public static final int MODE_WITH_SHADOW = 2; - public static final int MODE_HARDWARE = 3; - public static final int MODE_HARDWARE_WITH_SHADOW = 4; - - @Retention(SOURCE) - @IntDef({MODE_DEFAULT, MODE_ALPHA, MODE_WITH_SHADOW, MODE_HARDWARE_WITH_SHADOW, MODE_HARDWARE}) - @interface BitmapGenerationMode { - } - - private static final float ICON_BADGE_SCALE = 0.444f; - - @NonNull - private final Rect mOldBounds = new Rect(); - - @NonNull - private final SparseArray mCachedUserInfo = new SparseArray<>(); - - @NonNull - protected final Context mContext; - - @NonNull - private final Canvas mCanvas; - - @NonNull - private final PackageManager mPm; - - protected final int mFullResIconDpi; - protected final int mIconBitmapSize; - - protected IconThemeController mThemeController; - - @Nullable - private ShadowGenerator mShadowGenerator; - - /** Shadow bitmap used as background for theme icons */ - private Bitmap mWhiteShadowLayer; - /** Bitmap used for {@link BitmapShader} to mask Adaptive Icons when drawing */ - private Bitmap mShaderBitmap; - - private int mWrapperBackgroundColor = DEFAULT_WRAPPER_BACKGROUND; - - private static int PLACEHOLDER_BACKGROUND_COLOR = Color.rgb(245, 245, 245); - - protected BaseIconFactory(Context context, int fullResIconDpi, int iconBitmapSize, - boolean unused) { - this(context, fullResIconDpi, iconBitmapSize); - } - - public BaseIconFactory(Context context, int fullResIconDpi, int iconBitmapSize) { - mContext = context.getApplicationContext(); - mFullResIconDpi = fullResIconDpi; - mIconBitmapSize = iconBitmapSize; - - mPm = mContext.getPackageManager(); - - mCanvas = new Canvas(); - mCanvas.setDrawFilter(new PaintFlagsDrawFilter(DITHER_FLAG, FILTER_BITMAP_FLAG)); - clear(); - } - - protected void clear() { - mWrapperBackgroundColor = DEFAULT_WRAPPER_BACKGROUND; - } - - @NonNull - public ShadowGenerator getShadowGenerator() { - if (mShadowGenerator == null) { - mShadowGenerator = new ShadowGenerator(mIconBitmapSize); - } - return mShadowGenerator; - } - - @Nullable - public IconThemeController getThemeController() { - return mThemeController; - } - - public int getFullResIconDpi() { - return mFullResIconDpi; - } - - public int getIconBitmapSize() { - return mIconBitmapSize; - } - - @SuppressWarnings("deprecation") - public BitmapInfo createIconBitmap(Intent.ShortcutIconResource iconRes) { - try { - Resources resources = mPm.getResourcesForApplication(iconRes.packageName); - if (resources != null) { - final int id = resources.getIdentifier(iconRes.resourceName, null, null); - // do not stamp old legacy shortcuts as the app may have already forgotten about it - return createBadgedIconBitmap(resources.getDrawableForDensity(id, mFullResIconDpi)); - } - } catch (Exception e) { - // Icon not found. - } - return null; - } - - /** - * Create a placeholder icon using the passed in text. - * - * @param placeholder used for foreground element in the icon bitmap - * @param color used for the foreground text color - */ - public BitmapInfo createIconBitmap(String placeholder, int color) { - AdaptiveIconDrawable drawable = new AdaptiveIconDrawable( - new ColorDrawable(PLACEHOLDER_BACKGROUND_COLOR), - new CenterTextDrawable(placeholder, color)); - Bitmap icon = createIconBitmap(drawable, ICON_VISIBLE_AREA_FACTOR); - return BitmapInfo.of(icon, color); - } - - public BitmapInfo createIconBitmap(Bitmap icon) { - if (mIconBitmapSize != icon.getWidth() || mIconBitmapSize != icon.getHeight()) { - icon = createIconBitmap(new BitmapDrawable(mContext.getResources(), icon), 1f); - } - - return BitmapInfo.of(icon, ColorExtractor.findDominantColorByHue(icon)); - } - - /** - * Creates an icon from the bitmap cropped to the current device icon shape - */ - @NonNull - public AdaptiveIconDrawable createShapedAdaptiveIcon(Bitmap iconBitmap) { - Drawable drawable = new FixedSizeBitmapDrawable(iconBitmap); - float inset = getExtraInsetFraction(); - inset = inset / (1 + 2 * inset); - return new AdaptiveIconDrawable(new ColorDrawable(BLACK), - new InsetDrawable(drawable, inset, inset, inset, inset) - ); - } - - @NonNull - public BitmapInfo createBadgedIconBitmap(@NonNull Drawable icon) { - return createBadgedIconBitmap(icon, null); - } - - /** - * Creates bitmap using the source drawable and various parameters. - * The bitmap is visually normalized with other icons and has enough spacing to add shadow. - * - * @param icon source of the icon - * @return a bitmap suitable for displaying as an icon at various system UIs. - */ - @TargetApi(Build.VERSION_CODES.TIRAMISU) - @NonNull - public BitmapInfo createBadgedIconBitmap(@NonNull Drawable icon, - @Nullable IconOptions options) { - float[] scale = new float[1]; - Drawable tempIcon = icon; - if (options != null - && options.mIsArchived - && icon instanceof BitmapDrawable bitmapDrawable) { - // b/358123888 - // Pre-archived apps can have BitmapDrawables without insets. - // Need to convert to Adaptive Icon with insets to avoid cropping. - tempIcon = createShapedAdaptiveIcon(bitmapDrawable.getBitmap()); - } - Drawable 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 instanceof AdaptiveIconDrawable aid) { - info.setThemedBitmap( - mThemeController.createThemedBitmap( - aid, - info, - this, - options == null ? null : options.mSourceHint - ) - ); - } - FlagOp flagOp = getBitmapFlagOp(options); - if (adaptiveIcon instanceof WrappedAdaptiveIcon) { - flagOp = flagOp.addFlag(BitmapInfo.FLAG_WRAPPED_NON_ADAPTIVE); - } - info = info.withFlags(flagOp); - return info; - } - - @NonNull - public FlagOp getBitmapFlagOp(@Nullable IconOptions options) { - FlagOp op = FlagOp.NO_OP; - if (options != null) { - if (options.mIsInstantApp) { - op = op.addFlag(FLAG_INSTANT); - } - - UserIconInfo info = options.mUserIconInfo; - if (info == null && options.mUserHandle != null) { - info = getUserInfo(options.mUserHandle); - } - if (info != null) { - op = info.applyBitmapInfoFlags(op); - } - } - return op; - } - - @NonNull - protected UserIconInfo getUserInfo(@NonNull UserHandle user) { - int key = user.hashCode(); - UserIconInfo info = mCachedUserInfo.get(key); - /* - * We do not have the ability to distinguish between different badged users here. - * As such all badged users will have the work profile badge applied. - */ - if (info == null) { - // Simple check to check if the provided user is work profile or not based on badging - NoopDrawable d = new NoopDrawable(); - boolean isWork = (d != mPm.getUserBadgedIcon(d, user)); - info = new UserIconInfo(user, isWork ? UserIconInfo.TYPE_WORK : UserIconInfo.TYPE_MAIN); - mCachedUserInfo.put(key, info); - } - return info; - } - - @NonNull - public Path getShapePath(AdaptiveIconDrawable drawable, Rect iconBounds) { - return drawable.getIconMask(); - } - - @NonNull - public Bitmap getWhiteShadowLayer() { - if (mWhiteShadowLayer == null) { - mWhiteShadowLayer = createScaledBitmap( - new AdaptiveIconDrawable(new ColorDrawable(Color.WHITE), null), - MODE_HARDWARE_WITH_SHADOW); - } - return mWhiteShadowLayer; - } - - /** - * Takes an {@link AdaptiveIconDrawable} and uses it to create a new Shader Bitmap. - * {@link mShaderBitmap} will be used to create a {@link BitmapShader} for masking, - * such as for icon shapes. Will reuse underlying Bitmap where possible. - * - * @param adaptiveIcon AdaptiveIconDrawable to draw with shader - */ - @NonNull - private Bitmap getAdaptiveShaderBitmap(AdaptiveIconDrawable adaptiveIcon) { - Rect bounds = adaptiveIcon.getBounds(); - int iconWidth = bounds.width(); - int iconHeight = bounds.width(); - - BitmapRenderer shaderRenderer = new BitmapRenderer() { - @Override - public void draw(Canvas canvas) { - canvas.translate(-bounds.left, -bounds.top); - canvas.drawColor(BLACK); - if (adaptiveIcon.getBackground() != null) { - adaptiveIcon.getBackground().draw(canvas); - } - if (adaptiveIcon.getForeground() != null) { - adaptiveIcon.getForeground().draw(canvas); - } - } - }; - if (mShaderBitmap == null || iconWidth != mShaderBitmap.getWidth() - || iconHeight != mShaderBitmap.getHeight()) { - mShaderBitmap = BitmapRenderer.createSoftwareBitmap(iconWidth, iconHeight, - shaderRenderer); - } else { - shaderRenderer.draw(new Canvas(mShaderBitmap)); - } - return mShaderBitmap; - } - - @NonNull - public Bitmap createScaledBitmap(@NonNull Drawable icon, @BitmapGenerationMode int mode) { - float[] scale = new float[1]; - icon = normalizeAndWrapToAdaptiveIcon(icon, scale); - return createIconBitmap(icon, Math.min(scale[0], ICON_SCALE_FOR_SHADOWS), mode); - } - - /** - * Sets the background color used for wrapped adaptive icon - */ - public void setWrapperBackgroundColor(final int color) { - mWrapperBackgroundColor = (Color.alpha(color) < 255) ? DEFAULT_WRAPPER_BACKGROUND : color; - } - - @Nullable - protected Drawable normalizeAndWrapToAdaptiveIcon( - @Nullable Drawable icon, @NonNull final float[] outScale) { - if (icon == null) { - return null; - } - - boolean isFromIconPack = ExtendedBitmapDrawable.isFromIconPack(icon); - boolean shouldWrapAdaptive = !isFromIconPack && IconPreferencesKt.shouldWrapAdaptive(mContext); - boolean shrinkNonAdaptiveIcons = IconProvider.ATLEAST_OREO && shouldWrapAdaptive; - - 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; - - return wrapper; - } else { - if (icon instanceof AdaptiveIconDrawable) { - outScale[0] = ICON_VISIBLE_AREA_FACTOR; - return icon; - } - - if (shouldWrapAdaptive) { - outScale[0] = ICON_VISIBLE_AREA_FACTOR; - return wrapToAdaptiveIcon(icon); - } else { - scale = new IconNormalizer(mIconBitmapSize).getScale(icon); - outScale[0] = scale; - return 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); - - float scale = new IconNormalizer(mIconBitmapSize).getScale(icon); - CustomAdaptiveIconDrawable dr = new CustomAdaptiveIconDrawable( - new ColorDrawable(wrapperBackgroundColor), createScaledDrawable(icon, scale * LEGACY_ICON_SCALE)); - dr.setBounds(0, 0, 1, 1); - - return dr; - } - } - - @NonNull - public Bitmap createIconBitmap(@Nullable final Drawable icon, final float scale) { - return createIconBitmap(icon, scale, MODE_DEFAULT); - } - - @NonNull - public Bitmap createIconBitmap(@Nullable final Drawable icon, final float scale, - @BitmapGenerationMode int bitmapGenerationMode) { - final int size = mIconBitmapSize; - final Bitmap bitmap; - switch (bitmapGenerationMode) { - case MODE_ALPHA: - bitmap = Bitmap.createBitmap(size, size, Config.ALPHA_8); - break; - case MODE_HARDWARE: - case MODE_HARDWARE_WITH_SHADOW: { - return BitmapRenderer.createHardwareBitmap(size, size, canvas -> - drawIconBitmap(canvas, icon, scale, bitmapGenerationMode, null)); - } - case MODE_WITH_SHADOW: - default: - bitmap = Bitmap.createBitmap(size, size, Config.ARGB_8888); - break; - } - if (icon == null) { - return bitmap; - } - mCanvas.setBitmap(bitmap); - drawIconBitmap(mCanvas, icon, scale, bitmapGenerationMode, bitmap); - mCanvas.setBitmap(null); - return bitmap; - } - - private void drawIconBitmap(@NonNull Canvas canvas, @Nullable Drawable icon, - final float scale, @BitmapGenerationMode int bitmapGenerationMode, - @Nullable Bitmap targetBitmap) { - final int size = mIconBitmapSize; - mOldBounds.set(icon.getBounds()); - if (icon instanceof AdaptiveIconDrawable aid) { - // We are ignoring KEY_SHADOW_DISTANCE because regular icons ignore this at the - // moment b/298203449 - int offset = Math.max((int) Math.ceil(BLUR_FACTOR * size), - Math.round(size * (1 - scale) / 2)); - // b/211896569: AdaptiveIconDrawable do not work properly for non top-left bounds - int newBounds = size - offset * 2; - icon.setBounds(0, 0, newBounds, newBounds); - Path shapePath = getShapePath(aid, icon.getBounds()); - int count = canvas.save(); - canvas.translate(offset, offset); - if (bitmapGenerationMode == MODE_WITH_SHADOW - || bitmapGenerationMode == MODE_HARDWARE_WITH_SHADOW) { - getShadowGenerator().addPathShadow(shapePath, canvas); - } - - if (icon instanceof Extender) { - ((Extender) icon).drawForPersistence(canvas); - } else { - drawAdaptiveIcon(canvas, aid, shapePath); - } - - canvas.restoreToCount(count); - } else { - if (icon instanceof BitmapDrawable) { - BitmapDrawable bitmapDrawable = (BitmapDrawable) icon; - Bitmap b = bitmapDrawable.getBitmap(); - if (b != null && b.getDensity() == Bitmap.DENSITY_NONE) { - bitmapDrawable.setTargetDensity(mContext.getResources().getDisplayMetrics()); - } - } - int width = size; - int height = size; - - int intrinsicWidth = icon.getIntrinsicWidth(); - int intrinsicHeight = icon.getIntrinsicHeight(); - if (intrinsicWidth > 0 && intrinsicHeight > 0) { - // Scale the icon proportionally to the icon dimensions - final float ratio = (float) intrinsicWidth / intrinsicHeight; - if (intrinsicWidth > intrinsicHeight) { - height = (int) (width / ratio); - } else if (intrinsicHeight > intrinsicWidth) { - width = (int) (height * ratio); - } - } - final int left = (size - width) / 2; - final int top = (size - height) / 2; - icon.setBounds(left, top, left + width, top + height); - - canvas.save(); - canvas.scale(scale, scale, size / 2, size / 2); - icon.draw(canvas); - canvas.restore(); - - if (bitmapGenerationMode == MODE_WITH_SHADOW && targetBitmap != null) { - // Shadow extraction only works in software mode - getShadowGenerator().drawShadow(targetBitmap, canvas); - - // Draw the icon again on top: - canvas.save(); - canvas.scale(scale, scale, size / 2, size / 2); - icon.draw(canvas); - canvas.restore(); - } - } - icon.setBounds(mOldBounds); - } - - /** - * Draws AdaptiveIconDrawable onto canvas using provided Path - * and {@link mShaderBitmap} as a shader. - * - * @param canvas canvas to draw on - * @param drawable AdaptiveIconDrawable to draw - * @param shapePath path to clip icon with for shapes - */ - protected void drawAdaptiveIcon( - @NonNull Canvas canvas, - @NonNull AdaptiveIconDrawable drawable, - @NonNull Path shapePath - ) { - Drawable background = drawable.getBackground(); - Drawable foreground = drawable.getForeground(); - if (!Flags.enableLauncherIconShapes() || (background == null && foreground == null)) { - drawable.draw(canvas); - return; - } - Bitmap shaderBitmap = getAdaptiveShaderBitmap(drawable); - Paint paint = new Paint(); - paint.setShader(new BitmapShader(shaderBitmap, TileMode.CLAMP, TileMode.CLAMP)); - canvas.drawPath(shapePath, paint); - } - - @Override - public void close() { - clear(); - } - - @NonNull - public BitmapInfo makeDefaultIcon(IconProvider iconProvider) { - return createBadgedIconBitmap(iconProvider.getFullResDefaultActivityIcon(mFullResIconDpi)); - } - - /** - * Returns the correct badge size given an icon size - */ - public static int getBadgeSizeForIconSize(final int iconSize) { - return (int) (ICON_BADGE_SCALE * iconSize); - } - - public static class IconOptions { - - boolean mIsInstantApp; - - boolean mIsArchived; - - @BitmapGenerationMode - int mGenerationMode = MODE_WITH_SHADOW; - - @Nullable - UserHandle mUserHandle; - @Nullable - UserIconInfo mUserIconInfo; - - @ColorInt - @Nullable - Integer mExtractedColor; - - @Nullable - SourceHint mSourceHint; - - /** - * User for this icon, in case of badging - */ - @NonNull - public IconOptions setUser(@Nullable final UserHandle user) { - mUserHandle = user; - return this; - } - - /** - * User for this icon, in case of badging - */ - @NonNull - public IconOptions setUser(@Nullable final UserIconInfo user) { - mUserIconInfo = user; - return this; - } - - /** - * If this icon represents an instant app - */ - @NonNull - public IconOptions setInstantApp(final boolean instantApp) { - mIsInstantApp = instantApp; - return this; - } - - /** - * If the icon represents an archived app - */ - public IconOptions setIsArchived(boolean isArchived) { - mIsArchived = isArchived; - return this; - } - - /** - * Disables auto color extraction and overrides the color to the provided value - */ - @NonNull - public IconOptions setExtractedColor(@ColorInt int color) { - mExtractedColor = color; - return this; - } - - /** - * Sets the bitmap generation mode to use for the bitmap info. Note that some generation - * modes do not support color extraction, so consider setting a extracted color manually - * in those cases. - */ - public IconOptions setBitmapGenerationMode(@BitmapGenerationMode int generationMode) { - mGenerationMode = generationMode; - return this; - } - - /** - * User for this icon, in case of badging - */ - @NonNull - public IconOptions setSourceHint(@Nullable SourceHint sourceHint) { - mSourceHint = sourceHint; - return this; - } - } - - /** - * An extension of {@link BitmapDrawable} which returns the bitmap pixel size as intrinsic size. - * This allows the badging to be done based on the action bitmap size rather than - * the scaled bitmap size. - */ - private static class FixedSizeBitmapDrawable extends BitmapDrawable { - - public FixedSizeBitmapDrawable(@Nullable final Bitmap bitmap) { - super(null, bitmap); - } - - @Override - public int getIntrinsicHeight() { - return getBitmap().getWidth(); - } - - @Override - public int getIntrinsicWidth() { - return getBitmap().getWidth(); - } - } - - private static class NoopDrawable extends ColorDrawable { - @Override - public int getIntrinsicHeight() { - return 1; - } - - @Override - public int getIntrinsicWidth() { - return 1; - } - } - - private static class CenterTextDrawable extends ColorDrawable { - - @NonNull - private final Rect mTextBounds = new Rect(); - - @NonNull - private final Paint mTextPaint = new Paint(ANTI_ALIAS_FLAG | FILTER_BITMAP_FLAG); - - @NonNull - private final String mText; - - CenterTextDrawable(@NonNull final String text, final int color) { - mText = text; - mTextPaint.setColor(color); - } - - @Override - public void draw(Canvas canvas) { - Rect bounds = getBounds(); - mTextPaint.setTextSize(bounds.height() / 3f); - mTextPaint.getTextBounds(mText, 0, mText.length(), mTextBounds); - canvas.drawText(mText, - bounds.exactCenterX() - mTextBounds.exactCenterX(), - bounds.exactCenterY() - mTextBounds.exactCenterY(), - mTextPaint); - } - } - - private static class WrappedAdaptiveIcon extends AdaptiveIconDrawable { - - WrappedAdaptiveIcon(Drawable backgroundDrawable, Drawable foregroundDrawable) { - super(backgroundDrawable, foregroundDrawable); - } - } -} diff --git a/iconloaderlib/src/com/android/launcher3/icons/BaseIconFactory.kt b/iconloaderlib/src/com/android/launcher3/icons/BaseIconFactory.kt 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.kt b/iconloaderlib/src/com/android/launcher3/icons/BitmapInfo.kt index 1b3f0fa..d6489b8 100644 --- a/iconloaderlib/src/com/android/launcher3/icons/BitmapInfo.kt +++ b/iconloaderlib/src/com/android/launcher3/icons/BitmapInfo.kt @@ -17,82 +17,64 @@ package com.android.launcher3.icons import android.content.Context import android.graphics.Bitmap -import android.graphics.Canvas -import android.graphics.Path import android.graphics.drawable.Drawable import androidx.annotation.ColorRes import androidx.annotation.DrawableRes import androidx.annotation.IntDef +import com.android.launcher3.icons.BitmapInfo.Companion.FLAG_THEMED +import com.android.launcher3.icons.FastBitmapDrawableDelegate.DelegateFactory +import com.android.launcher3.icons.FastBitmapDrawableDelegate.SimpleDelegateFactory +import com.android.launcher3.icons.PlaceHolderDrawableDelegate.PlaceHolderDelegateFactory import com.android.launcher3.icons.cache.CacheLookupFlag import com.android.launcher3.util.FlagOp -open class BitmapInfo( +/** + * Data class that holds all the information needed to create an icon drawable. + * + * @property icon the bitmap of the icon. + * @property color the color of the icon. + * @property flags extra source information associated with this icon + * @property defaultIconShape the fallback shape when no shape is provided during icon creation + * @property themedBitmap theming information if the icon is created using [FLAG_THEMED] + * @property delegateFactory factory used for icon creation + * @property badgeInfo optional badge drawn on the icon + */ +data class BitmapInfo( @JvmField val icon: Bitmap, @JvmField val color: Int, - @BitmapInfoFlags @JvmField var flags: Int = 0, - var themedBitmap: ThemedBitmap? = null, + @BitmapInfoFlags val flags: Int = 0, + val defaultIconShape: IconShape = IconShape.EMPTY, + val themedBitmap: ThemedBitmap? = null, + val badgeInfo: BitmapInfo? = null, + val delegateFactory: DelegateFactory = SimpleDelegateFactory, ) { @IntDef( flag = true, - value = [FLAG_WORK, FLAG_INSTANT, FLAG_CLONE, FLAG_PRIVATE, FLAG_WRAPPED_NON_ADAPTIVE], + value = [FLAG_WORK, FLAG_INSTANT, FLAG_CLONE, FLAG_PRIVATE, FLAG_FULL_BLEED], ) internal annotation class BitmapInfoFlags - @IntDef(flag = true, value = [FLAG_THEMED, FLAG_NO_BADGE, FLAG_SKIP_USER_BADGE]) + @IntDef(flag = true, value = [FLAG_THEMED, FLAG_NO_BADGE, FLAG_SKIP_USER_BADGE, FLAG_CUSTOM_SHAPE]) annotation class DrawableCreationFlags - // b/377618519: These are saved to debug why work badges sometimes don't show up on work apps - @DrawableCreationFlags @JvmField var creationFlags: Int = 0 - - private var badgeInfo: BitmapInfo? = null - - fun withBadgeInfo(badgeInfo: BitmapInfo?) = clone().also { it.badgeInfo = badgeInfo } + fun withBadgeInfo(badgeInfo: BitmapInfo?) = copy(badgeInfo = badgeInfo) /** Returns a bitmapInfo with the flagOP applied */ - fun withFlags(op: FlagOp): BitmapInfo { - if (op === FlagOp.NO_OP) { - return this - } - return clone().also { it.flags = op.apply(it.flags) } - } - - @Override - open fun clone(): BitmapInfo { - return copyInternalsTo(BitmapInfo(icon, color)) - } - - protected fun copyInternalsTo(target: BitmapInfo): BitmapInfo { - target.themedBitmap = themedBitmap - target.flags = flags - target.badgeInfo = badgeInfo - return target - } - - // TODO: rename or remove because icon can no longer be null? - val isNullOrLowRes: Boolean - get() = icon == LOW_RES_ICON + fun withFlags(op: FlagOp): BitmapInfo = + if (op === FlagOp.NO_OP) this else copy(flags = op.apply(this.flags)) val isLowRes: Boolean get() = matchingLookupFlag.useLowRes() - open val matchingLookupFlag: CacheLookupFlag + val matchingLookupFlag: CacheLookupFlag /** Returns the lookup flag to match this current state of this info */ get() = CacheLookupFlag.DEFAULT_LOOKUP_FLAG.withUseLowRes(LOW_RES_ICON == icon) .withThemeIcon(themedBitmap != null) /** BitmapInfo can be stored on disk or other persistent storage */ - open fun canPersist(): Boolean { - return !isNullOrLowRes - } - - /** Creates a drawable for the provided BitmapInfo */ - @JvmOverloads - fun newIcon( - context: Context, - @DrawableCreationFlags creationFlags: Int = 0, - ): FastBitmapDrawable { - return newIcon(context, creationFlags, null) + fun canPersist(): Boolean { + return !isLowRes && delegateFactory == SimpleDelegateFactory } /** @@ -100,173 +82,161 @@ open class BitmapInfo( * * @param context Context * @param creationFlags Flags for creating the FastBitmapDrawable - * @param badgeShape Optional Path for masking icon badges to a shape. Should be 100x100. + * @param iconShape information for custom Icon Shapes, to use with Full-bleed icons. * @return FastBitmapDrawable */ - open fun newIcon( + @JvmOverloads + fun newIcon( context: Context, - @DrawableCreationFlags creationFlags: Int, - badgeShape: Path?, - ): FastBitmapDrawable { - val drawable: FastBitmapDrawable = - if (isLowRes) { - PlaceHolderIconDrawable(this, context) - } else if ( - (creationFlags and FLAG_THEMED) != 0 && - themedBitmap != null && - themedBitmap !== ThemedBitmap.NOT_SUPPORTED - ) { - themedBitmap!!.newDrawable(this, context) + @DrawableCreationFlags creationFlags: Int = 0, + iconShape: IconShape? = null, + ) = + FastBitmapDrawable( + info = this, + iconShape = iconShape ?: defaultIconShape, + delegateFactory = + when { + isLowRes -> PlaceHolderDelegateFactory(context) + creationFlags.hasMask(FLAG_THEMED) && + themedBitmap != null && + themedBitmap !== ThemedBitmap.NOT_SUPPORTED -> + themedBitmap.newDelegateFactory(this, context) + else -> delegateFactory + }, + disabledAlpha = GraphicsUtils.getFloat(context, R.attr.disabledIconAlpha, 1f), + creationFlags = if (iconShape != null) { + creationFlags.or(FLAG_CUSTOM_SHAPE) } else { - FastBitmapDrawable(this) - } - applyFlags(context, drawable, creationFlags, badgeShape) - return drawable - } - - protected fun applyFlags( - context: Context, drawable: FastBitmapDrawable, - @DrawableCreationFlags creationFlags: Int, badgeShape: Path? - ) { - this.creationFlags = creationFlags - drawable.disabledAlpha = GraphicsUtils.getFloat(context, R.attr.disabledIconAlpha, 1f) - drawable.creationFlags = creationFlags - if ((creationFlags and FLAG_NO_BADGE) == 0) { - val badge = getBadgeDrawable( - context, (creationFlags and FLAG_THEMED) != 0, - (creationFlags and FLAG_SKIP_USER_BADGE) != 0, badgeShape - ) - if (badge != null) { - drawable.badge = badge - } - } - } + creationFlags + }, + badge = + if (!creationFlags.hasMask(FLAG_NO_BADGE)) { + getBadgeDrawable( + context, + creationFlags.hasMask(FLAG_THEMED), + creationFlags.hasMask(FLAG_SKIP_USER_BADGE), + ) + } else null, + ) /** * Gets Badge drawable based on current flags * * @param context Context * @param isThemed If Drawable is themed. - * @param badgeShape Optional Path to mask badges to a shape. Should be 100x100. - * @return Drawable for the badge. */ - fun getBadgeDrawable(context: Context, isThemed: Boolean, badgeShape: Path?): Drawable? { - return getBadgeDrawable(context, isThemed, false, badgeShape) + fun getBadgeDrawable(context: Context, isThemed: Boolean): Drawable? { + return getBadgeDrawable(context, isThemed, false) } /** * Creates a Drawable for an icon badge for this BitmapInfo + * * @param context Context * @param isThemed If the drawable is themed. * @param skipUserBadge If should skip User Profile badging. - * @param badgeShape Optional Path to mask badge Drawable to a shape. Should be 100x100. - * @return Drawable for an icon Badge. */ private fun getBadgeDrawable( - context: Context, isThemed: Boolean, skipUserBadge: Boolean, badgeShape: Path? + context: Context, + isThemed: Boolean, + skipUserBadge: Boolean, ): Drawable? { if (badgeInfo != null) { var creationFlag = if (isThemed) FLAG_THEMED else 0 if (skipUserBadge) { creationFlag = creationFlag or FLAG_SKIP_USER_BADGE } - return badgeInfo!!.newIcon(context, creationFlag, badgeShape) + return badgeInfo.newIcon(context, creationFlag, null) } if (skipUserBadge) { return null } else { getBadgeDrawableInfo()?.let { - return UserBadgeDrawable( - context, - it.drawableRes, - it.colorRes, - isThemed, - badgeShape - ) + return UserBadgeDrawable(context, it.drawableRes, it.colorRes, isThemed) } } return null } - /** - * Returns information about the badge to apply based on current flags. - */ + /** Returns information about the badge to apply based on current flags. */ fun getBadgeDrawableInfo(): BadgeDrawableInfo? { return when { - (flags and FLAG_INSTANT) != 0 -> BadgeDrawableInfo( - R.drawable.ic_instant_app_badge, - R.color.badge_tint_instant - ) - (flags and FLAG_WORK) != 0 -> BadgeDrawableInfo( - R.drawable.ic_work_app_badge, - R.color.badge_tint_work - ) - (flags and FLAG_CLONE) != 0 -> BadgeDrawableInfo( - R.drawable.ic_clone_app_badge, - R.color.badge_tint_clone - ) - (flags and FLAG_PRIVATE) != 0 -> BadgeDrawableInfo( - R.drawable.ic_private_profile_app_badge, - R.color.badge_tint_private - ) + flags.hasMask(FLAG_INSTANT) -> + BadgeDrawableInfo(R.drawable.ic_instant_app_badge, R.color.badge_tint_instant) + flags.hasMask(FLAG_WORK) -> + BadgeDrawableInfo(R.drawable.ic_work_app_badge, R.color.badge_tint_work) + flags.hasMask(FLAG_CLONE) -> + BadgeDrawableInfo(R.drawable.ic_clone_app_badge, R.color.badge_tint_clone) + flags.hasMask(FLAG_PRIVATE) -> + BadgeDrawableInfo( + R.drawable.ic_private_profile_app_badge, + R.color.badge_tint_private, + ) else -> null } } + /** + * Checks for FLAG_FULL_BLEED from factory as well as checking bitmap content to verify. + */ + fun isFullBleed(): Boolean { + return flags.hasMask(FLAG_FULL_BLEED) + } - /** Interface to be implemented by drawables to provide a custom BitmapInfo */ + /** Interface to be implemented by drawables to customize a BitmapInfo */ interface Extender { - /** Called for creating a custom BitmapInfo */ - fun getExtendedInfo( - bitmap: Bitmap?, - color: Int, - iconFactory: BaseIconFactory?, - normalizationScale: Float, - ): BitmapInfo? + + /** Returns an update [BitmapInfo] replacing the existing [info] */ + fun getUpdatedBitmapInfo(info: BitmapInfo, factory: BaseIconFactory): BitmapInfo /** Called to draw the UI independent of any runtime configurations like time or theme */ - fun drawForPersistence(canvas: Canvas?) + fun drawForPersistence() } /** * Drawables backing a specific badge shown on app icons. + * * @param drawableRes Drawable resource for the badge. * @param colorRes Color resource to tint the badge. */ @JvmRecord data class BadgeDrawableInfo( @field:DrawableRes @param:DrawableRes val drawableRes: Int, - @field:ColorRes @param:ColorRes val colorRes: Int + @field:ColorRes @param:ColorRes val colorRes: Int, ) companion object { const val TAG: String = "BitmapInfo" - // BitmapInfo flags + // Persisted BitmapInfo flags. + // Reset the cache by changing RELEASE_VERSION whenever making any changes here. + // LINT.IfChange const val FLAG_WORK: Int = 1 shl 0 const val FLAG_INSTANT: Int = 1 shl 1 const val FLAG_CLONE: Int = 1 shl 2 const val FLAG_PRIVATE: Int = 1 shl 3 - const val FLAG_WRAPPED_NON_ADAPTIVE: Int = 1 shl 4 + const val FLAG_FULL_BLEED: Int = 1 shl 4 + // LINT.ThenChange(src/com/android/launcher3/icons/cache/BaseIconCache.kt:cache_release_version) // Drawable creation flags const val FLAG_THEMED: Int = 1 shl 0 const val FLAG_NO_BADGE: Int = 1 shl 1 const val FLAG_SKIP_USER_BADGE: Int = 1 shl 2 + const val FLAG_CUSTOM_SHAPE: Int = 1 shl 3 - @JvmField - val LOW_RES_ICON: Bitmap = Bitmap.createBitmap(1, 1, Bitmap.Config.ALPHA_8) - @JvmField - val LOW_RES_INFO: BitmapInfo = fromBitmap(LOW_RES_ICON) + @JvmField val LOW_RES_ICON: Bitmap = Bitmap.createBitmap(1, 1, Bitmap.Config.ALPHA_8) + @JvmField val LOW_RES_INFO: BitmapInfo = fromBitmap(LOW_RES_ICON) @JvmStatic fun fromBitmap(bitmap: Bitmap): BitmapInfo { - return of(bitmap, 0) + return of(bitmap, 0, IconShape.EMPTY) } @JvmStatic - fun of(bitmap: Bitmap, color: Int): BitmapInfo { - return BitmapInfo(bitmap, color) + fun of(bitmap: Bitmap, color: Int, defaultShape: IconShape = IconShape.EMPTY): BitmapInfo { + return BitmapInfo(icon = bitmap, color = color, defaultIconShape = defaultShape) } + + private inline fun Int.hasMask(mask: Int) = (this and mask) != 0 } } diff --git a/iconloaderlib/src/com/android/launcher3/icons/BubbleIconFactory.java b/iconloaderlib/src/com/android/launcher3/icons/BubbleIconFactory.java index b36dc06..49dcc3c 100644 --- a/iconloaderlib/src/com/android/launcher3/icons/BubbleIconFactory.java +++ b/iconloaderlib/src/com/android/launcher3/icons/BubbleIconFactory.java @@ -6,12 +6,14 @@ import android.content.pm.ShortcutInfo; import android.graphics.Bitmap; import android.graphics.Canvas; +import android.graphics.Color; import android.graphics.Path; import android.graphics.Rect; import android.graphics.drawable.AdaptiveIconDrawable; import android.graphics.drawable.Drawable; import android.graphics.drawable.Icon; import android.os.Build; +import android.os.UserHandle; import androidx.annotation.NonNull; import androidx.annotation.Nullable; @@ -73,30 +75,32 @@ public Drawable getBubbleDrawable(@NonNull final Context context, * Creates the bitmap for the provided drawable and returns the scale used for * drawing the actual drawable. This is used for the larger icon shown for the bubble. */ - public Bitmap getBubbleBitmap(@NonNull Drawable icon, float[] outScale) { - if (outScale == null) { - outScale = new float[1]; - } - icon = normalizeAndWrapToAdaptiveIcon(icon, outScale); - return createIconBitmap(icon, outScale[0], MODE_WITH_SHADOW); + public Bitmap getBubbleBitmap(@NonNull Drawable icon) { + return createBadgedIconBitmap( + icon, new IconOptions() + .setBitmapGenerationMode(MODE_WITH_SHADOW) + // We do not care about extracted color + .setExtractedColor(Color.TRANSPARENT)).icon; } /** * Returns a {@link BitmapInfo} for the app-badge that is shown on top of each bubble. This * will include the workprofile indicator on the badge if appropriate. */ - public BitmapInfo getBadgeBitmap(Drawable userBadgedAppIcon, boolean isImportantConversation) { - if (userBadgedAppIcon instanceof AdaptiveIconDrawable) { - AdaptiveIconDrawable ad = (AdaptiveIconDrawable) userBadgedAppIcon; - userBadgedAppIcon = new CircularAdaptiveIcon(ad.getBackground(), - ad.getForeground()); + public BitmapInfo getBadgeBitmap(Drawable appIcon, UserHandle user, + boolean isImportantConversation) { + if (appIcon instanceof AdaptiveIconDrawable ad) { + appIcon = new CircularAdaptiveIcon(ad.getBackground(), ad.getForeground()); } if (isImportantConversation) { - userBadgedAppIcon = new CircularRingDrawable(userBadgedAppIcon); + appIcon = new CircularRingDrawable(appIcon); } - Bitmap userBadgedBitmap = mBadgeFactory.createIconBitmap( - userBadgedAppIcon, 1, MODE_WITH_SHADOW); - return mBadgeFactory.createIconBitmap(userBadgedBitmap); + return mBadgeFactory.createBadgedIconBitmap( + appIcon, + new IconOptions() + .setBitmapGenerationMode(MODE_WITH_SHADOW) + .setWrapNonAdaptiveIcon(false) + .setUser(user)); } private class CircularRingDrawable extends CircularAdaptiveIcon { diff --git a/iconloaderlib/src/com/android/launcher3/icons/ClockDrawableWrapper.java b/iconloaderlib/src/com/android/launcher3/icons/ClockDrawableWrapper.java deleted file mode 100644 index 33fc4ee..0000000 --- a/iconloaderlib/src/com/android/launcher3/icons/ClockDrawableWrapper.java +++ /dev/null @@ -1,518 +0,0 @@ -/* - * Copyright (C) 2019 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package com.android.launcher3.icons; - -import static com.android.launcher3.icons.IconProvider.ATLEAST_T; -import static com.android.launcher3.icons.cache.CacheLookupFlag.DEFAULT_LOOKUP_FLAG; - -import android.annotation.TargetApi; -import android.content.Context; -import android.content.pm.ApplicationInfo; -import android.content.pm.PackageManager; -import android.content.res.Resources; -import android.graphics.Bitmap; -import android.graphics.BlendMode; -import android.graphics.BlendModeColorFilter; -import android.graphics.Canvas; -import android.graphics.Color; -import android.graphics.ColorFilter; -import android.graphics.Paint; -import android.graphics.Path; -import android.graphics.Rect; -import android.graphics.drawable.AdaptiveIconDrawable; -import android.graphics.drawable.ColorDrawable; -import android.graphics.drawable.Drawable; -import android.graphics.drawable.LayerDrawable; -import android.os.Build; -import android.os.Bundle; -import android.os.SystemClock; -import android.util.Log; - -import androidx.annotation.NonNull; -import androidx.core.util.Supplier; -import com.android.launcher3.icons.cache.CacheLookupFlag; -import com.android.launcher3.icons.mono.ThemedIconDrawable; - -import java.util.Calendar; -import java.util.Objects; -import java.util.concurrent.TimeUnit; -import java.util.function.IntFunction; - -import app.lawnchair.icons.ClockMetadata; -import app.lawnchair.icons.CustomAdaptiveIconDrawable; - -/** - * Wrapper over {@link AdaptiveIconDrawable} to intercept icon flattening logic for dynamic - * clock icons - */ -public class ClockDrawableWrapper extends CustomAdaptiveIconDrawable implements BitmapInfo.Extender { - - public static boolean sRunningInTest = false; - - private static final String TAG = "ClockDrawableWrapper"; - - private static final boolean DISABLE_SECONDS = false; // pE-TODO: Enable/Disable second hand of clock drawable via prefs - private static final int NO_COLOR = -1; - - // Time after which the clock icon should check for an update. The actual invalidate - // will only happen in case of any change. - public static final long TICK_MS = DISABLE_SECONDS ? TimeUnit.MINUTES.toMillis(1) : 200L; - - private static final String LAUNCHER_PACKAGE = "com.android.launcher3"; - private static final String ROUND_ICON_METADATA_KEY = LAUNCHER_PACKAGE - + ".LEVEL_PER_TICK_ICON_ROUND"; - private static final String HOUR_INDEX_METADATA_KEY = LAUNCHER_PACKAGE + ".HOUR_LAYER_INDEX"; - private static final String MINUTE_INDEX_METADATA_KEY = LAUNCHER_PACKAGE - + ".MINUTE_LAYER_INDEX"; - private static final String SECOND_INDEX_METADATA_KEY = LAUNCHER_PACKAGE - + ".SECOND_LAYER_INDEX"; - private static final String DEFAULT_HOUR_METADATA_KEY = LAUNCHER_PACKAGE - + ".DEFAULT_HOUR"; - private static final String DEFAULT_MINUTE_METADATA_KEY = LAUNCHER_PACKAGE - + ".DEFAULT_MINUTE"; - private static final String DEFAULT_SECOND_METADATA_KEY = LAUNCHER_PACKAGE - + ".DEFAULT_SECOND"; - - /* Number of levels to jump per second for the second hand */ - private static final int LEVELS_PER_SECOND = 10; - - public static final int INVALID_VALUE = -1; - - private final AnimationInfo mAnimationInfo = new AnimationInfo(); - private AnimationInfo mThemeInfo = null; - - private ClockDrawableWrapper(AdaptiveIconDrawable base) { - super(base.getBackground(), base.getForeground()); - } - - @Override - public Drawable getMonochrome() { - if (mThemeInfo == null) { - return null; - } - Drawable d = mThemeInfo.baseDrawableState.newDrawable().mutate(); - if (d instanceof AdaptiveIconDrawable) { - Drawable mono = ((AdaptiveIconDrawable) d).getForeground(); - mThemeInfo.applyTime(Calendar.getInstance(), (LayerDrawable) mono); - return mono; - } - return null; - } - - /** - * Loads and returns the wrapper from the provided package, or returns null - * if it is unable to load. - */ - public static ClockDrawableWrapper forPackage(Context context, String pkg, int iconDpi) { - try { - PackageManager pm = context.getPackageManager(); - ApplicationInfo appInfo = pm.getApplicationInfo(pkg, - PackageManager.MATCH_UNINSTALLED_PACKAGES | PackageManager.GET_META_DATA); - Resources res = pm.getResourcesForApplication(appInfo); - return forExtras(appInfo.metaData, resId -> CustomAdaptiveIconDrawable.wrapNonNull( - Objects.requireNonNull(res.getDrawableForDensity(resId, iconDpi)))); - } catch (Exception e) { - Log.d(TAG, "Unable to load clock drawable info", e); - } - return null; - } - - public static ClockDrawableWrapper forExtras( - Bundle metadata, IntFunction drawableProvider) { - if (metadata == null) { - return null; - } - int drawableId = metadata.getInt(ROUND_ICON_METADATA_KEY, 0); - if (drawableId == 0) { - return null; - } - - int hourLayerIndex = metadata.getInt(HOUR_INDEX_METADATA_KEY, INVALID_VALUE); - int minuteLayerIndex = metadata.getInt(MINUTE_INDEX_METADATA_KEY, INVALID_VALUE); - int secondLayerIndex = metadata.getInt(SECOND_INDEX_METADATA_KEY, INVALID_VALUE); - - int defaultHour = metadata.getInt(DEFAULT_HOUR_METADATA_KEY, 0); - int defaultMinute = metadata.getInt(DEFAULT_MINUTE_METADATA_KEY, 0); - int defaultSecond = metadata.getInt(DEFAULT_SECOND_METADATA_KEY, 0); - - ClockMetadata clockMetadata = new ClockMetadata( - hourLayerIndex, - minuteLayerIndex, - secondLayerIndex, - defaultHour, - defaultMinute, - defaultSecond - ); - - return forMeta(0, clockMetadata, () -> drawableProvider.apply(drawableId)); - } - - public static ClockDrawableWrapper forMeta( - @Deprecated(since = "Not used, kept for compatibility reason.") int targetSdkVersion, - @NonNull ClockMetadata metadata, Supplier drawableProvider) { - Drawable drawable = drawableProvider.get().mutate(); - if (!(drawable instanceof AdaptiveIconDrawable)) { - return null; - } - AdaptiveIconDrawable aid = (AdaptiveIconDrawable) drawable; - - ClockDrawableWrapper wrapper = new ClockDrawableWrapper(aid); - AnimationInfo info = wrapper.mAnimationInfo; - - info.baseDrawableState = drawable.getConstantState(); - info.hourLayerIndex = metadata.getHourLayerIndex(); - info.minuteLayerIndex = metadata.getMinuteLayerIndex(); - info.secondLayerIndex = metadata.getSecondLayerIndex(); - - info.defaultHour = metadata.getDefaultHour(); - info.defaultMinute = metadata.getDefaultMinute(); - info.defaultSecond = metadata.getDefaultSecond(); - - LayerDrawable foreground = (LayerDrawable) wrapper.getForeground(); - int layerCount = foreground.getNumberOfLayers(); - if (info.hourLayerIndex < 0 || info.hourLayerIndex >= layerCount) { - info.hourLayerIndex = INVALID_VALUE; - } - if (info.minuteLayerIndex < 0 || info.minuteLayerIndex >= layerCount) { - info.minuteLayerIndex = INVALID_VALUE; - } - if (info.secondLayerIndex < 0 || info.secondLayerIndex >= layerCount) { - info.secondLayerIndex = INVALID_VALUE; - } else if (DISABLE_SECONDS) { - foreground.setDrawable(info.secondLayerIndex, null); - info.secondLayerIndex = INVALID_VALUE; - } - - if (ATLEAST_T && aid.getMonochrome() instanceof LayerDrawable) { - wrapper.mThemeInfo = info.copyForIcon(new AdaptiveIconDrawable( - new ColorDrawable(Color.WHITE), aid.getMonochrome().mutate())); - } - info.applyTime(Calendar.getInstance(), foreground); - return wrapper; - } - - @Override - public ClockBitmapInfo getExtendedInfo(Bitmap bitmap, int color, - BaseIconFactory iconFactory, float normalizationScale) { - AdaptiveIconDrawable background = new AdaptiveIconDrawable( - getBackground().getConstantState().newDrawable(), null); - Bitmap flattenBG = iconFactory.createScaledBitmap(background, - BaseIconFactory.MODE_HARDWARE_WITH_SHADOW); - - // Only pass theme info if mono-icon is enabled - AnimationInfo themeInfo = iconFactory.getThemeController() != null ? mThemeInfo : null; - Bitmap themeBG = themeInfo == null ? null : iconFactory.getWhiteShadowLayer(); - return new ClockBitmapInfo(bitmap, color, normalizationScale, - mAnimationInfo, flattenBG, themeInfo, themeBG); - } - - @Override - public void drawForPersistence(Canvas canvas) { - LayerDrawable foreground = (LayerDrawable) getForeground(); - resetLevel(foreground, mAnimationInfo.hourLayerIndex); - resetLevel(foreground, mAnimationInfo.minuteLayerIndex); - resetLevel(foreground, mAnimationInfo.secondLayerIndex); - draw(canvas); - mAnimationInfo.applyTime(Calendar.getInstance(), (LayerDrawable) getForeground()); - } - - private void resetLevel(LayerDrawable drawable, int index) { - if (index != INVALID_VALUE) { - drawable.getDrawable(index).setLevel(0); - } - } - - private static class AnimationInfo { - - public ConstantState baseDrawableState; - - public int hourLayerIndex; - public int minuteLayerIndex; - public int secondLayerIndex; - public int defaultHour; - public int defaultMinute; - public int defaultSecond; - - public AnimationInfo copyForIcon(Drawable icon) { - AnimationInfo result = new AnimationInfo(); - result.baseDrawableState = icon.getConstantState(); - result.defaultHour = defaultHour; - result.defaultMinute = defaultMinute; - result.defaultSecond = defaultSecond; - result.hourLayerIndex = hourLayerIndex; - result.minuteLayerIndex = minuteLayerIndex; - result.secondLayerIndex = secondLayerIndex; - return result; - } - - boolean applyTime(Calendar time, LayerDrawable foregroundDrawable) { - time.setTimeInMillis(System.currentTimeMillis()); - - // We need to rotate by the difference from the default time if one is specified. - int convertedHour = (time.get(Calendar.HOUR) + (12 - defaultHour)) % 12; - int convertedMinute = (time.get(Calendar.MINUTE) + (60 - defaultMinute)) % 60; - int convertedSecond = (time.get(Calendar.SECOND) + (60 - defaultSecond)) % 60; - - boolean invalidate = false; - if (hourLayerIndex != INVALID_VALUE) { - final Drawable hour = foregroundDrawable.getDrawable(hourLayerIndex); - if (hour.setLevel(convertedHour * 60 + time.get(Calendar.MINUTE))) { - invalidate = true; - } - } - - if (minuteLayerIndex != INVALID_VALUE) { - final Drawable minute = foregroundDrawable.getDrawable(minuteLayerIndex); - if (minute.setLevel(time.get(Calendar.HOUR) * 60 + convertedMinute)) { - invalidate = true; - } - } - - if (secondLayerIndex != INVALID_VALUE) { - final Drawable second = foregroundDrawable.getDrawable(secondLayerIndex); - if (second.setLevel(convertedSecond * LEVELS_PER_SECOND)) { - invalidate = true; - } - } - - return invalidate; - } - } - - static class ClockBitmapInfo extends BitmapInfo { - - public final float boundsOffset; - - public final AnimationInfo animInfo; - public final Bitmap mFlattenedBackground; - - public final AnimationInfo themeData; - public final Bitmap themeBackground; - - ClockBitmapInfo(Bitmap icon, int color, float scale, - AnimationInfo animInfo, Bitmap background, - AnimationInfo themeInfo, Bitmap themeBackground) { - super(icon, color, /* flags */ 0, /* themedBitmap */ null); - this.boundsOffset = Math.max(ShadowGenerator.BLUR_FACTOR, (1 - scale) / 2); - this.animInfo = animInfo; - this.mFlattenedBackground = background; - this.themeData = themeInfo; - this.themeBackground = themeBackground; - } - - @Override - @TargetApi(Build.VERSION_CODES.TIRAMISU) - public FastBitmapDrawable newIcon(Context context, - @DrawableCreationFlags int creationFlags, Path badgeShape) { - AnimationInfo info; - Bitmap bg; - int themedFgColor; - ColorFilter bgFilter; - if ((creationFlags & FLAG_THEMED) != 0 && themeData != null) { - int[] colors = ThemedIconDrawable.getColors(context); - Drawable tintedDrawable = themeData.baseDrawableState.newDrawable().mutate(); - themedFgColor = colors[1]; - tintedDrawable.setTint(colors[1]); - info = themeData.copyForIcon(tintedDrawable); - bg = themeBackground; - bgFilter = new BlendModeColorFilter(colors[0], BlendMode.SRC_IN); - } else { - info = animInfo; - themedFgColor = NO_COLOR; - bg = mFlattenedBackground; - bgFilter = null; - } - if (info == null) { - return super.newIcon(context, creationFlags); - } - ClockIconDrawable.ClockConstantState cs = new ClockIconDrawable.ClockConstantState( - this, themedFgColor, boundsOffset, info, bg, bgFilter); - FastBitmapDrawable d = cs.newDrawable(); - applyFlags(context, d, creationFlags, null); - return d; - } - - @Override - public boolean canPersist() { - return false; - } - - @Override - public BitmapInfo clone() { - return copyInternalsTo(new ClockBitmapInfo(icon, color, - 1 - 2 * boundsOffset, animInfo, mFlattenedBackground, - themeData, themeBackground)); - } - - @Override - public CacheLookupFlag getMatchingLookupFlag() { - return DEFAULT_LOOKUP_FLAG.withThemeIcon(themeData != null); - } - } - - private static class ClockIconDrawable extends FastBitmapDrawable implements Runnable { - - private final Calendar mTime = Calendar.getInstance(); - - private final float mBoundsOffset; - private final AnimationInfo mAnimInfo; - - private final Bitmap mBG; - private final Paint mBgPaint = new Paint(Paint.FILTER_BITMAP_FLAG | Paint.ANTI_ALIAS_FLAG); - private final ColorFilter mBgFilter; - private final int mThemedFgColor; - - private final AdaptiveIconDrawable mFullDrawable; - private final LayerDrawable mFG; - private final float mCanvasScale; - - ClockIconDrawable(ClockConstantState cs) { - super(cs.getBitmapInfo()); - mBoundsOffset = cs.mBoundsOffset; - mAnimInfo = cs.mAnimInfo; - - mBG = cs.mBG; - mBgFilter = cs.mBgFilter; - mBgPaint.setColorFilter(cs.mBgFilter); - mThemedFgColor = cs.mThemedFgColor; - - mFullDrawable = - (AdaptiveIconDrawable) mAnimInfo.baseDrawableState.newDrawable().mutate(); - mFG = (LayerDrawable) mFullDrawable.getForeground(); - - // Time needs to be applied here since drawInternal is NOT guaranteed to be called - // before this foreground drawable is shown on the screen. - mAnimInfo.applyTime(mTime, mFG); - mCanvasScale = 1 - 2 * mBoundsOffset; - } - - @Override - public void setAlpha(int alpha) { - super.setAlpha(alpha); - mBgPaint.setAlpha(alpha); - mFG.setAlpha(alpha); - } - - @Override - protected void onBoundsChange(Rect bounds) { - super.onBoundsChange(bounds); - - // b/211896569 AdaptiveIcon does not work properly when bounds - // are not aligned to top/left corner - mFullDrawable.setBounds(0, 0, bounds.width(), bounds.height()); - } - - @Override - public void drawInternal(Canvas canvas, Rect bounds) { - if (mAnimInfo == null) { - super.drawInternal(canvas, bounds); - return; - } - canvas.drawBitmap(mBG, null, bounds, mBgPaint); - - // prepare and draw the foreground - mAnimInfo.applyTime(mTime, mFG); - int saveCount = canvas.save(); - canvas.translate(bounds.left, bounds.top); - canvas.scale(mCanvasScale, mCanvasScale, bounds.width() / 2, bounds.height() / 2); - canvas.clipPath(mFullDrawable.getIconMask()); - mFG.draw(canvas); - canvas.restoreToCount(saveCount); - - reschedule(); - } - - @Override - public boolean isThemed() { - return mBgPaint.getColorFilter() != null; - } - - @Override - protected void updateFilter() { - super.updateFilter(); - boolean isDisabled = isDisabled(); - int alpha = isDisabled ? (int) (disabledAlpha * FULLY_OPAQUE) : FULLY_OPAQUE; - setAlpha(alpha); - mBgPaint.setColorFilter(isDisabled ? getDisabledColorFilter() : mBgFilter); - mFG.setColorFilter(isDisabled ? getDisabledColorFilter() : null); - } - - @Override - public int getIconColor() { - return isThemed() ? mThemedFgColor : super.getIconColor(); - } - - @Override - public void run() { - if (mAnimInfo.applyTime(mTime, mFG)) { - invalidateSelf(); - } else { - reschedule(); - } - } - - @Override - public boolean setVisible(boolean visible, boolean restart) { - boolean result = super.setVisible(visible, restart); - if (visible) { - reschedule(); - } else { - unscheduleSelf(this); - } - return result; - } - - private void reschedule() { - if (!isVisible()) { - return; - } - unscheduleSelf(this); - final long upTime = SystemClock.uptimeMillis(); - final long step = TICK_MS; /* tick every 200 ms */ - scheduleSelf(this, upTime - ((upTime % step)) + step); - } - - @Override - public FastBitmapConstantState newConstantState() { - return new ClockConstantState(bitmapInfo, mThemedFgColor, mBoundsOffset, - mAnimInfo, mBG, mBgPaint.getColorFilter()); - } - - private static class ClockConstantState extends FastBitmapConstantState { - - private final float mBoundsOffset; - private final AnimationInfo mAnimInfo; - private final Bitmap mBG; - private final ColorFilter mBgFilter; - private final int mThemedFgColor; - - ClockConstantState(BitmapInfo info, int themedFgColor, - float boundsOffset, AnimationInfo animInfo, Bitmap bg, ColorFilter bgFilter) { - super(info); - mBoundsOffset = boundsOffset; - mAnimInfo = animInfo; - mBG = bg; - mBgFilter = bgFilter; - mThemedFgColor = themedFgColor; - } - - @Override - public FastBitmapDrawable createDrawable() { - return new ClockIconDrawable(this); - } - } - } -} diff --git a/iconloaderlib/src/com/android/launcher3/icons/ClockDrawableWrapper.kt b/iconloaderlib/src/com/android/launcher3/icons/ClockDrawableWrapper.kt 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.kt b/iconloaderlib/src/com/android/launcher3/icons/FastBitmapDrawable.kt index 670915a..63cc78c 100644 --- a/iconloaderlib/src/com/android/launcher3/icons/FastBitmapDrawable.kt +++ b/iconloaderlib/src/com/android/launcher3/icons/FastBitmapDrawable.kt @@ -19,7 +19,6 @@ import android.R import android.animation.ObjectAnimator import android.graphics.Bitmap import android.graphics.Canvas -import android.graphics.Color import android.graphics.ColorFilter import android.graphics.ColorMatrix import android.graphics.ColorMatrixColorFilter @@ -36,24 +35,35 @@ import android.view.animation.DecelerateInterpolator import android.view.animation.Interpolator import android.view.animation.PathInterpolator import androidx.annotation.VisibleForTesting -import androidx.core.graphics.ColorUtils +import com.android.launcher3.icons.BitmapInfo.Companion.LOW_RES_INFO import com.android.launcher3.icons.BitmapInfo.DrawableCreationFlags -import kotlin.math.min - -open class FastBitmapDrawable(info: BitmapInfo?) : Drawable(), Callback { +import com.android.launcher3.icons.FastBitmapDrawableDelegate.DelegateFactory +import com.android.launcher3.icons.FastBitmapDrawableDelegate.SimpleDelegateFactory + +class FastBitmapDrawable +@JvmOverloads +constructor( + info: BitmapInfo?, + private val iconShape: IconShape = IconShape.EMPTY, + private val delegateFactory: DelegateFactory = SimpleDelegateFactory, + @JvmField @DrawableCreationFlags val creationFlags: Int = 0, + private val disabledAlpha: Float = 1f, + val badge: Drawable? = null, +) : Drawable(), Callback { @JvmOverloads constructor(b: Bitmap, iconColor: Int = 0) : this(BitmapInfo.of(b, iconColor)) - @JvmField val bitmapInfo: BitmapInfo = info ?: BitmapInfo.LOW_RES_INFO + // b/404578798 - mBitmapInfo isn't expected to be null, but it is in some cases. + @JvmField val bitmapInfo: BitmapInfo = info ?: LOW_RES_INFO var isAnimationEnabled: Boolean = true @JvmField protected val paint: Paint = Paint(FILTER_BITMAP_FLAG or ANTI_ALIAS_FLAG) + val delegate = delegateFactory.newDelegate(bitmapInfo, iconShape, paint, this) + @JvmField @VisibleForTesting var isPressed: Boolean = false @JvmField @VisibleForTesting var isHovered: Boolean = false - @JvmField var disabledAlpha: Float = 1f - var isDisabled: Boolean = false set(value) { if (field != value) { @@ -63,7 +73,6 @@ open class FastBitmapDrawable(info: BitmapInfo?) : Drawable(), Callback { } } - @JvmField @DrawableCreationFlags var creationFlags: Int = 0 @JvmField @VisibleForTesting var scaleAnimation: ObjectAnimator? = null var hoverScaleEnabledForDisplay = true @@ -73,26 +82,16 @@ open class FastBitmapDrawable(info: BitmapInfo?) : Drawable(), Callback { private var paintFilter: ColorFilter? = null init { - isFilterBitmap = true + badge?.callback = this } - var badge: Drawable? = null - set(value) { - field?.callback = null - field = value - field?.let { - it.callback = this - it.setBadgeBounds(bounds) - } - updateFilter() - } - /** Returns true if the drawable points to the same bitmap icon object */ fun isSameInfo(info: BitmapInfo): Boolean = bitmapInfo === info override fun onBoundsChange(bounds: Rect) { super.onBoundsChange(bounds) badge?.setBadgeBounds(bounds) + delegate.onBoundsChange(bounds) } override fun draw(canvas: Canvas) { @@ -101,27 +100,27 @@ open class FastBitmapDrawable(info: BitmapInfo?) : Drawable(), Callback { val bounds = bounds canvas.scale(scale, scale, bounds.exactCenterX(), bounds.exactCenterY()) drawInternal(canvas, bounds) - badge?.draw(canvas) canvas.restoreToCount(count) } else { drawInternal(canvas, bounds) - badge?.draw(canvas) } } - protected open fun drawInternal(canvas: Canvas, bounds: Rect) { - canvas.drawBitmap(bitmapInfo.icon, null, bounds, paint) + private fun drawInternal(canvas: Canvas, bounds: Rect) { + delegate.drawContent(bitmapInfo, iconShape, canvas, bounds, paint) + badge?.draw(canvas) } /** Returns the primary icon color, slightly tinted white */ - open fun getIconColor(): Int = - ColorUtils.compositeColors( - GraphicsUtils.setColorAlphaBound(Color.WHITE, WHITE_SCRIM_ALPHA), - bitmapInfo.color, - ) + fun getIconColor(): Int = delegate.getIconColor(bitmapInfo) /** Returns if this represents a themed icon */ - open fun isThemed(): Boolean = false + fun isThemed(): Boolean = delegate.isThemed() + + override fun setVisible(visible: Boolean, restart: Boolean): Boolean = + super.setVisible(visible, restart).also { delegate.onVisibilityChanged(visible) } + + override fun onLevelChange(level: Int) = delegate.onLevelChange(level) /** * Returns true if the drawable was created with theme, even if it doesn't support theming @@ -145,6 +144,7 @@ open class FastBitmapDrawable(info: BitmapInfo?) : Drawable(), Callback { paint.alpha = alpha invalidateSelf() badge?.alpha = alpha + delegate.setAlpha(alpha) } } @@ -227,23 +227,25 @@ open class FastBitmapDrawable(info: BitmapInfo?) : Drawable(), Callback { } /** Updates the paint to reflect the current brightness and saturation. */ - protected open fun updateFilter() { - paint.setColorFilter(if (isDisabled) getDisabledColorFilter(disabledAlpha) else paintFilter) - badge?.colorFilter = colorFilter + private fun updateFilter() { + val filter = if (isDisabled) getDisabledColorFilter(disabledAlpha) else paintFilter + paint.colorFilter = filter + badge?.colorFilter = filter + delegate.updateFilter(filter) invalidateSelf() } - protected open fun newConstantState(): FastBitmapConstantState { - return FastBitmapConstantState(bitmapInfo) - } - - override fun getConstantState(): ConstantState { - val cs = newConstantState() - cs.mIsDisabled = isDisabled - cs.mBadgeConstantState = badge?.constantState - cs.mCreationFlags = creationFlags - return cs - } + override fun getConstantState() = + FastBitmapConstantState( + bitmapInfo, + isDisabled, + badge?.constantState, + iconShape, + creationFlags, + disabledAlpha, + delegateFactory, + level, + ) // Returns if the FastBitmapDrawable contains a badge. fun hasBadge(): Boolean = (creationFlags and BitmapInfo.FLAG_NO_BADGE) == 0 @@ -264,29 +266,30 @@ open class FastBitmapDrawable(info: BitmapInfo?) : Drawable(), Callback { unscheduleSelf(what) } - open class FastBitmapConstantState(val bitmapInfo: BitmapInfo) : ConstantState() { - // These are initialized later so that subclasses don't need to - // pass everything in constructor - var mIsDisabled: Boolean = false - var mBadgeConstantState: ConstantState? = null - - @DrawableCreationFlags var mCreationFlags: Int = 0 - - constructor(bitmap: Bitmap, color: Int) : this(BitmapInfo.of(bitmap, color)) - - protected open fun createDrawable(): FastBitmapDrawable { - return FastBitmapDrawable(bitmapInfo) - } - - override fun newDrawable(): FastBitmapDrawable { - val drawable = createDrawable() - drawable.isDisabled = mIsDisabled - if (mBadgeConstantState != null) { - drawable.badge = mBadgeConstantState!!.newDrawable() - } - drawable.creationFlags = mCreationFlags - return drawable - } + data class FastBitmapConstantState( + val bitmapInfo: BitmapInfo, + val isDisabled: Boolean, + val badgeConstantState: ConstantState?, + val iconShape: IconShape, + val creationFlags: Int, + val disabledAlpha: Float, + val delegateFactory: DelegateFactory, + val level: Int, + ) : ConstantState() { + + override fun newDrawable() = + FastBitmapDrawable( + info = bitmapInfo, + iconShape = iconShape, + delegateFactory = delegateFactory, + creationFlags = creationFlags, + badge = badgeConstantState?.newDrawable(), + disabledAlpha = disabledAlpha, + ) + .apply { + isDisabled = this@FastBitmapConstantState.isDisabled + level = this@FastBitmapConstantState.level + } override fun getChangingConfigurations(): Int = 0 } @@ -304,7 +307,6 @@ open class FastBitmapDrawable(info: BitmapInfo?) : Drawable(), Callback { private const val DISABLED_DESATURATION = 1f private const val DISABLED_BRIGHTNESS = 0.5f - const val FULLY_OPAQUE: Int = 255 const val CLICK_FEEDBACK_DURATION: Int = 200 const val HOVER_FEEDBACK_DURATION: Int = 300 @@ -345,15 +347,6 @@ open class FastBitmapDrawable(info: BitmapInfo?) : Drawable(), Callback { return ColorMatrixColorFilter(tempFilterMatrix) } - @JvmStatic - fun getDisabledColor(color: Int): Int { - val avgComponent = (Color.red(color) + Color.green(color) + Color.blue(color)) / 3 - val scale = 1 - DISABLED_BRIGHTNESS - val brightnessI = (255 * DISABLED_BRIGHTNESS).toInt() - val component = min(Math.round(scale * avgComponent + brightnessI), FULLY_OPAQUE) - return Color.rgb(component, component, component) - } - /** Sets the bounds for the badge drawable based on the main icon bounds */ @JvmStatic fun Drawable.setBadgeBounds(iconBounds: Rect) { diff --git a/iconloaderlib/src/com/android/launcher3/icons/FastBitmapDrawableDelegate.kt b/iconloaderlib/src/com/android/launcher3/icons/FastBitmapDrawableDelegate.kt new file mode 100644 index 0000000..563d5b9 --- /dev/null +++ b/iconloaderlib/src/com/android/launcher3/icons/FastBitmapDrawableDelegate.kt @@ -0,0 +1,140 @@ +/* + * Copyright (C) 2025 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.launcher3.icons + +import android.graphics.BitmapShader +import android.graphics.Canvas +import android.graphics.Color +import android.graphics.ColorFilter +import android.graphics.Paint +import android.graphics.Rect +import android.graphics.Shader +import android.graphics.Shader.TileMode.CLAMP +import androidx.core.graphics.ColorUtils +import com.android.launcher3.icons.BitmapInfo.Companion.FLAG_FULL_BLEED +import com.android.launcher3.icons.GraphicsUtils.resizeToContentSize + +/** A delegate for changing the rendering of [FastBitmapDrawable], to support multi-inheritance */ +interface FastBitmapDrawableDelegate { + + /** [android.graphics.drawable.Drawable.onBoundsChange] */ + fun onBoundsChange(bounds: Rect) {} + + /** [android.graphics.drawable.Drawable.draw] */ + fun drawContent( + info: BitmapInfo, + iconShape: IconShape, + canvas: Canvas, + bounds: Rect, + paint: Paint, + ) + + /** [FastBitmapDrawable.getIconColor] */ + fun getIconColor(info: BitmapInfo): Int = + ColorUtils.compositeColors( + GraphicsUtils.setColorAlphaBound(Color.WHITE, FastBitmapDrawable.WHITE_SCRIM_ALPHA), + info.color, + ) + + /** [FastBitmapDrawable.isThemed] */ + fun isThemed() = false + + /** [android.graphics.drawable.Drawable.setAlpha] */ + fun setAlpha(alpha: Int) {} + + /** [android.graphics.drawable.Drawable.setColorFilter] */ + fun updateFilter(filter: ColorFilter?) {} + + /** [android.graphics.drawable.Drawable.setVisible] */ + fun onVisibilityChanged(isVisible: Boolean) {} + + /** [android.graphics.drawable.Drawable.onLevelChange] */ + fun onLevelChange(level: Int): Boolean = false + + /** + * Interface for creating new delegates. This should not store any state information and can + * safely be stored in a [android.graphics.drawable.Drawable.ConstantState] + */ + fun interface DelegateFactory { + + fun newDelegate( + bitmapInfo: BitmapInfo, + iconShape: IconShape, + paint: Paint, + host: FastBitmapDrawable, + ): FastBitmapDrawableDelegate + } + + class FullBleedDrawableDelegate(bitmapInfo: BitmapInfo) : FastBitmapDrawableDelegate { + private val shader = BitmapShader(bitmapInfo.icon, CLAMP, CLAMP) + + override fun drawContent( + info: BitmapInfo, + iconShape: IconShape, + canvas: Canvas, + bounds: Rect, + paint: Paint, + ) { + canvas.drawShaderInBounds(bounds, iconShape, paint, shader) + } + } + + object SimpleDrawableDelegate : FastBitmapDrawableDelegate { + + override fun drawContent( + info: BitmapInfo, + iconShape: IconShape, + canvas: Canvas, + bounds: Rect, + paint: Paint, + ) { + canvas.drawBitmap(info.icon, null, bounds, paint) + } + } + + object SimpleDelegateFactory : DelegateFactory { + override fun newDelegate( + bitmapInfo: BitmapInfo, + iconShape: IconShape, + paint: Paint, + host: FastBitmapDrawable, + ) = + if ((bitmapInfo.flags and FLAG_FULL_BLEED) != 0) FullBleedDrawableDelegate(bitmapInfo) + else SimpleDrawableDelegate + } + + companion object { + + /** + * Draws the shader created using [FastBitmapDrawableDelegate.createPaintShader] in the + * provided bounds + */ + fun Canvas.drawShaderInBounds( + bounds: Rect, + iconShape: IconShape, + paint: Paint, + shader: Shader?, + ) { + drawBitmap(iconShape.shadowLayer, null, bounds, paint) + resizeToContentSize(bounds, iconShape.pathSize.toFloat()) { + paint.shader = shader + iconShape.shapeRenderer.render(this, paint) + paint.shader = null + } + } + } +} diff --git a/iconloaderlib/src/com/android/launcher3/icons/GraphicsUtils.java b/iconloaderlib/src/com/android/launcher3/icons/GraphicsUtils.java deleted file mode 100644 index b17b006..0000000 --- a/iconloaderlib/src/com/android/launcher3/icons/GraphicsUtils.java +++ /dev/null @@ -1,117 +0,0 @@ -/* - * Copyright (C) 2018 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package com.android.launcher3.icons; - -import android.content.Context; -import android.content.res.TypedArray; -import android.graphics.Bitmap; -import android.graphics.Rect; -import android.graphics.Region; -import android.graphics.RegionIterator; -import android.util.Log; - -import androidx.annotation.ColorInt; - -import java.io.ByteArrayOutputStream; -import java.io.IOException; - -public class GraphicsUtils { - - private static final String TAG = "GraphicsUtils"; - - public static Runnable sOnNewBitmapRunnable = () -> { }; - - /** - * Set the alpha component of {@code color} to be {@code alpha}. Unlike the support lib version, - * it bounds the alpha in valid range instead of throwing an exception to allow for safer - * interpolation of color animations - */ - @ColorInt - public static int setColorAlphaBound(int color, int alpha) { - if (alpha < 0) { - alpha = 0; - } else if (alpha > 255) { - alpha = 255; - } - return (color & 0x00ffffff) | (alpha << 24); - } - - /** - * Compresses the bitmap to a byte array for serialization. - */ - public static byte[] flattenBitmap(Bitmap bitmap) { - ByteArrayOutputStream out = new ByteArrayOutputStream(getExpectedBitmapSize(bitmap)); - try { - bitmap.compress(Bitmap.CompressFormat.PNG, 100, out); - out.flush(); - out.close(); - return out.toByteArray(); - } catch (IOException e) { - Log.w(TAG, "Could not write bitmap"); - return null; - } - } - - /** - * Try go guesstimate how much space the icon will take when serialized to avoid unnecessary - * allocations/copies during the write (4 bytes per pixel). - */ - static int getExpectedBitmapSize(Bitmap bitmap) { - return bitmap.getWidth() * bitmap.getHeight() * 4; - } - - public static int getArea(Region r) { - RegionIterator itr = new RegionIterator(r); - int area = 0; - Rect tempRect = new Rect(); - while (itr.next(tempRect)) { - area += tempRect.width() * tempRect.height(); - } - return area; - } - - /** - * Utility method to track new bitmap creation - */ - public static void noteNewBitmapCreated() { - sOnNewBitmapRunnable.run(); - } - - /** - * Returns the color associated with the attribute - */ - public static int getAttrColor(Context context, int attr) { - TypedArray ta = context.obtainStyledAttributes(new int[]{attr}); - // pE-TODO(CompatTier2): wtf? - int colorAccent = 0; - try { - colorAccent = ta.getColor(0, 0); - } catch (UnsupportedOperationException ignored) { - } - ta.recycle(); - return colorAccent; - } - - /** - * Returns the alpha corresponding to the theme attribute {@param attr} - */ - public static float getFloat(Context context, int attr, float defValue) { - TypedArray ta = context.obtainStyledAttributes(new int[]{attr}); - float value = ta.getFloat(0, defValue); - ta.recycle(); - return value; - } -} diff --git a/iconloaderlib/src/com/android/launcher3/icons/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 5ff3018..e6740cb 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; @@ -40,10 +34,6 @@ import android.graphics.drawable.InsetDrawable; import android.os.Build; 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 +43,6 @@ import com.android.launcher3.icons.cache.CachingLogic; import com.android.launcher3.util.ComponentKey; -import com.android.launcher3.util.SafeCloseable; import java.util.Calendar; import java.util.Objects; @@ -303,13 +292,6 @@ private static ComponentName parseComponentOrNull(Context context, int resId) { return TextUtils.isEmpty(cn) ? null : ComponentName.unflattenFromString(cn); } - /** - * Registers a callback to listen for various system dependent icon changes. - */ - public SafeCloseable registerIconChangeListener(IconChangeListener listener, Handler handler) { - return new IconChangeReceiver(listener, handler); - } - /** * Notifies the provider when an icon is loaded from cache */ @@ -337,59 +319,4 @@ public Drawable loadPaddedDrawable() { return fg; } } - - private class IconChangeReceiver extends BroadcastReceiver implements SafeCloseable { - - private final IconChangeListener mCallback; - - IconChangeReceiver(IconChangeListener callback, Handler handler) { - mCallback = callback; - if (mCalendar != null || mClock != null) { - final IntentFilter filter = new IntentFilter(ACTION_TIMEZONE_CHANGED); - if (mCalendar != null) { - filter.addAction(Intent.ACTION_TIME_CHANGED); - filter.addAction(ACTION_DATE_CHANGED); - } - mContext.registerReceiver(this, filter, null, handler); - } - } - - @Override - public void onReceive(Context context, Intent intent) { - switch (intent.getAction()) { - case ACTION_TIMEZONE_CHANGED: - if (mClock != null) { - mCallback.onAppIconChanged(mClock.getPackageName(), Process.myUserHandle()); - } - // follow through - case ACTION_DATE_CHANGED: - case ACTION_TIME_CHANGED: - if (mCalendar != null) { - for (UserHandle user - : context.getSystemService(UserManager.class).getUserProfiles()) { - mCallback.onAppIconChanged(mCalendar.getPackageName(), user); - } - } - break; - } - } - - @Override - public void close() { - try { - mContext.unregisterReceiver(this); - } catch (Exception ignored) { } - } - } - - /** - * Listener for receiving icon changes - */ - public interface IconChangeListener { - - /** - * Called when the icon for a particular app changes - */ - void onAppIconChanged(String packageName, UserHandle user); - } } diff --git a/iconloaderlib/src/com/android/launcher3/icons/IconShape.kt b/iconloaderlib/src/com/android/launcher3/icons/IconShape.kt new file mode 100644 index 0000000..781711e --- /dev/null +++ b/iconloaderlib/src/com/android/launcher3/icons/IconShape.kt @@ -0,0 +1,51 @@ +/* + * Copyright (C) 2025 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.launcher3.icons + +import android.graphics.Bitmap +import android.graphics.Bitmap.createBitmap +import android.graphics.Color +import android.graphics.Path +import android.graphics.drawable.AdaptiveIconDrawable +import android.graphics.drawable.ColorDrawable +import com.android.launcher3.icons.ShapeRenderer.PathRenderer + +data class IconShape( + /** Size that [path] should be scaled to. */ + @JvmField val pathSize: Int, + /** Path for icon shape to be used as mask. Ensure this is scaled to [pathSize] */ + @JvmField val path: Path, + /** Shadow layer to draw behind icon. Should use the same shape and scale as [path] */ + @JvmField val shadowLayer: Bitmap, + /** Renderer for customizing how shapes are drawn to canvas */ + @JvmField val shapeRenderer: ShapeRenderer = PathRenderer(path), +) { + companion object { + private const val DEFAULT_PATH_SIZE = 100 + + // Placeholder that can be used if icon shape is not needed. + @JvmField + val EMPTY = + IconShape( + DEFAULT_PATH_SIZE, + AdaptiveIconDrawable(ColorDrawable(Color.WHITE), null) + .apply { setBounds(0, 0, DEFAULT_PATH_SIZE, DEFAULT_PATH_SIZE) } + .iconMask, + createBitmap(1, 1, Bitmap.Config.ARGB_8888).apply { eraseColor(Color.WHITE) }, + ) + } +} diff --git a/iconloaderlib/src/com/android/launcher3/icons/LuminanceComputer.kt b/iconloaderlib/src/com/android/launcher3/icons/LuminanceComputer.kt new file mode 100644 index 0000000..49131b6 --- /dev/null +++ b/iconloaderlib/src/com/android/launcher3/icons/LuminanceComputer.kt @@ -0,0 +1,305 @@ +/** + * Copyright (C) 2025 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express + * or implied. See the License for the specific language governing permissions and limitations under + * the License. + */ +package com.android.launcher3.icons + +import android.graphics.Bitmap +import android.util.Log +import androidx.annotation.FloatRange +import androidx.core.graphics.ColorUtils +import kotlin.math.abs + +/** The type of computation to use when computing the luminance of a drawable or a bitmap. */ +enum class ComputationType { + /** Compute the median luminance of a drawable or a bitmap. */ + MEDIAN, + + /** Compute the average luminance of a drawable or a bitmap. */ + AVERAGE, + + /** Compute the difference between the min and max luminance of a drawable or a bitmap. */ + SPREAD, +} + +/** Wrapper for the color space to use when computing the luminance. */ +interface ColorWrapper { + /** The luminance of the color, in the range [0, 1]. */ + var luminance: Double + + /** The color as an integer in the format of the color space. */ + fun toColorInt(): Int +} + +@JvmInline +value class LabColor(val data: DoubleArray) : ColorWrapper { + override var luminance: Double + get() = data[0] / 100 + set(value) { + data[0] = value * 100 + } + + override fun toColorInt(): Int = ColorUtils.LABToColor(data[0], data[1], data[2]) +} + +@JvmInline +value class HslColor(val data: FloatArray) : ColorWrapper { + override var luminance: Double + get() = data[2].toDouble() + set(value) { + data[2] = value.toFloat() + } + + override fun toColorInt(): Int = ColorUtils.HSLToColor(data) +} + +/** The color space to use when computing the luminance of a drawable or a bitmap. */ +enum class LuminanceColorSpace { + /** Use the HSL color space. */ + HSL, + + /** Use the LAB color space. */ + LAB, +} + +/** Class to compute the luminance of a drawable or a bitmap using the chosen color space. */ +class LuminanceComputer( + val colorSpace: LuminanceColorSpace, + val computationType: ComputationType, + private val options: Options = Options(), +) { + + /** + * Options for the luminance computer. + * + * @param ensureMinContrast If true, the resulting luminance ratio will always be the minimum + * contrast ratio passed into [adaptColorLuminance]. + * @param absoluteLuminanceDelta If true, the luminance delta will always be the absolute value + * of the luminance delta passed into [adaptColorLuminance], meaning that the luminance delta + * will always be positive and the foreground color will always be considered to be brighter + * than the background color. + */ + data class Options( + val ensureMinContrast: Boolean = ENABLED_CONTRAST_ADJUSTMENT, + val absoluteLuminanceDelta: Boolean = ENABLED_ABSOLUTE_LUMINANCE_DELTA, + ) + + /** + * Adapt a color to a different luminance level using the selected color space, and optionally + * adjust the contrast and absolute luminance delta. + * + * @param targetColor The color to adapt. + * @param basisColor The color to use as a basis for the luminance. + * @param luminanceDelta The luminance delta to use, which is the difference between the target + * and the basis luminance. + * @param minimumContrast The minimum contrast to use between the target and the basis color. + * @return The adapted color. + */ + fun adaptColorLuminance( + targetColor: Int, + basisColor: Int, + @FloatRange(from = -1.0, to = 1.0, toInclusive = true, fromInclusive = true) + luminanceDelta: Double, + minimumContrast: Double, + useAbsoluteLuminanceDelta: Boolean = options.absoluteLuminanceDelta, + ): Int { + if (luminanceDelta.isNaN()) { + return targetColor + } + + var localLuminanceDelta = + if (useAbsoluteLuminanceDelta) { + // get the absolute value of the luminance delta + abs(luminanceDelta).coerceAtLeast(DEFAULT_ABSOLUTE_LUMINANCE_DELTA) + } else { + luminanceDelta + } + + val mutatedColorWrapper = + mutateColorLuminance(targetColor, basisColor, localLuminanceDelta, minimumContrast) + return mutatedColorWrapper.toColorInt() + } + + private fun mutateColorLuminance( + targetColor: Int, + basisColor: Int, + luminanceDelta: Double, + minimumContrast: Double = 0.0, + ): ColorWrapper { + if (luminanceDelta.isNaN()) { + return colorToColorWrapper(targetColor) + } + + val targetColorWrapper = colorToColorWrapper(targetColor) + val basisColorWrapper = colorToColorWrapper(basisColor) + + val basisLuminance = basisColorWrapper.luminance + + // The target luminance should be between 0 and 1, so we need to clamp + // it to that range + var targetLuminance = (basisLuminance + luminanceDelta).coerceIn(0.0, 1.0) + + targetLuminance = + adjustLuminanceForContrast( + targetLuminance, + basisLuminance, + luminanceDelta, + minimumContrast, + ) + + targetColorWrapper.luminance = targetLuminance + + return targetColorWrapper + } + + /** + * Compute the luminance of a bitmap using the selected color space. + * + * @param bitmap The bitmap to compute the luminance of. + * @param scale if true, the bitmap is resized to [BITMAP_SAMPLE_SIZE] for color calculation + */ + @JvmOverloads + fun computeLuminance(bitmap: Bitmap, scale: Boolean = true): Double { + val bitmapHeight = bitmap.height + val bitmapWidth = bitmap.width + if (bitmapHeight == 0 || bitmapWidth == 0) { + Log.e(TAG, "Bitmap is null") + return Double.NaN + } + + val bitmapToProcess = + if (scale) { + Bitmap.createScaledBitmap(bitmap, BITMAP_SAMPLE_SIZE, BITMAP_SAMPLE_SIZE, true) + } else { + bitmap + } + + val processedWidth = bitmapToProcess.width + val processedHeight = bitmapToProcess.height + + val pixels = IntArray(processedWidth * processedHeight) + bitmapToProcess.getPixels( + /** pixels = */ + pixels, + /** offset = */ + 0, + /** stride = */ + processedWidth, + /** x = */ + 0, + /** y = */ + 0, + /** width = */ + processedWidth, + /** height = */ + processedHeight, + ) + val luminances = pixels.map { colorToColorWrapper(it).luminance } + + when (computationType) { + ComputationType.MEDIAN -> return luminances.sorted().median() + ComputationType.AVERAGE -> return luminances.average() + ComputationType.SPREAD -> return luminances.max() - luminances.min() + } + } + + // The minimum contrast is the ratio minimum ratio that should exist + // between the target and the basis luminance + private fun adjustLuminanceForContrast( + targetLuminance: Double, + basisLuminance: Double, + luminanceDelta: Double, + minimumContrast: Double, + ): Double { + if (!options.ensureMinContrast) return targetLuminance + + val currentContrast = targetLuminance - basisLuminance + if (currentContrast >= minimumContrast) return targetLuminance + + val contrastedTargetLuminance = basisLuminance + (luminanceDelta * minimumContrast) + return contrastedTargetLuminance.coerceIn(0.0, 1.0) + } + + private fun List.median(): Double { + if (isEmpty()) { + return Double.NaN + } + val size = this.size + return if (size % 2 == 0) { + (this[size / 2 - 1] + this[size / 2]) / 2 + } else { + this[size / 2] + } + } + + private fun List.average(): Double { + if (isEmpty()) { + return Double.NaN + } + return sum() / size + } + + // Update to return ColorWrapper + private fun colorToColorWrapper(color: Int): ColorWrapper { + return when (colorSpace) { + LuminanceColorSpace.HSL -> { + val hsl = FloatArray(3) + ColorUtils.colorToHSL(color, hsl) + HslColor(hsl) + } + LuminanceColorSpace.LAB -> { + val lab = DoubleArray(3) + ColorUtils.colorToLAB(color, lab) + LabColor(lab) + } + } + } + + companion object Factory { + const val TAG: String = "LuminanceComputer" + + // If true, the resulting luminance ratio will always be the + // minimum contrast ratio passed into adaptColor + const val ENABLED_CONTRAST_ADJUSTMENT = true + + // If true, the luminance delta will always be the absolute value + // of the luminance delta passed into adaptColor, meaning that + // the luminance delta will always be positive and the foreground + // color will always be considered to be brighter than the background + // color. + const val ENABLED_ABSOLUTE_LUMINANCE_DELTA = true + + // The size of bitmap to derive the luminance from + // eg: 64x64 + const val BITMAP_SAMPLE_SIZE = 64 + + // The default absolute luminance delta to use if the user does not + // specify one. Only valid when ENABLED_ABSOLUTE_LUMINANCE_DELTA is + // true. + const val DEFAULT_ABSOLUTE_LUMINANCE_DELTA = 0.1 + + @JvmStatic + @JvmOverloads + fun createDefaultLuminanceComputer( + computationType: ComputationType = ComputationType.AVERAGE + ): LuminanceComputer { + return LuminanceComputer( + LuminanceColorSpace.LAB, // Keep this as the default color space + computationType, + Options( + ensureMinContrast = ENABLED_CONTRAST_ADJUSTMENT, + absoluteLuminanceDelta = ENABLED_ABSOLUTE_LUMINANCE_DELTA, + ), + ) + } + } +} diff --git a/iconloaderlib/src/com/android/launcher3/icons/MonochromeIconFactory.java b/iconloaderlib/src/com/android/launcher3/icons/MonochromeIconFactory.java index e6ae124..d8eb9d8 100644 --- a/iconloaderlib/src/com/android/launcher3/icons/MonochromeIconFactory.java +++ b/iconloaderlib/src/com/android/launcher3/icons/MonochromeIconFactory.java @@ -17,6 +17,8 @@ import static android.graphics.Paint.FILTER_BITMAP_FLAG; +import static com.android.launcher3.icons.LuminanceComputer.createDefaultLuminanceComputer; + import android.annotation.TargetApi; import android.graphics.Bitmap; import android.graphics.Bitmap.Config; @@ -32,12 +34,11 @@ import android.graphics.Rect; import android.graphics.drawable.AdaptiveIconDrawable; import android.graphics.drawable.Drawable; +import android.graphics.drawable.InsetDrawable; import android.os.Build; import androidx.annotation.WorkerThread; -import com.android.launcher3.icons.mono.MonoIconThemeController.ClippedMonoDrawable; - import java.nio.ByteBuffer; /** @@ -55,17 +56,17 @@ public class MonochromeIconFactory extends Drawable { private final byte[] mPixels; private final int mBitmapSize; - private final int mEdgePixelLength; private final Paint mDrawPaint; private final Rect mSrcRect; + private double mLuminanceDiff = Double.NaN; + public MonochromeIconFactory(int iconBitmapSize) { float extraFactor = AdaptiveIconDrawable.getExtraInsetFraction(); float viewPortScale = 1 / (1 + 2 * extraFactor); mBitmapSize = Math.round(iconBitmapSize * 2 * viewPortScale); mPixels = new byte[mBitmapSize * mBitmapSize]; - mEdgePixelLength = mBitmapSize * (mBitmapSize - iconBitmapSize) / 2; mFlatBitmap = Bitmap.createBitmap(mBitmapSize, mBitmapSize, Config.ARGB_8888); mFlatCanvas = new Canvas(mFlatBitmap); @@ -96,23 +97,56 @@ private void drawDrawable(Drawable drawable) { } } + /** + * Kept to layout lib compilation + * @deprecated use {@link #wrap(AdaptiveIconDrawable)} instead + */ + @Deprecated + public Drawable wrap(AdaptiveIconDrawable icon, Path unused) { + return wrap(icon); + } + /** * Creates a monochrome version of the provided drawable */ @WorkerThread - public Drawable wrap(AdaptiveIconDrawable icon, Path shapePath) { + public Drawable wrap(AdaptiveIconDrawable icon) { mFlatCanvas.drawColor(Color.BLACK); - drawDrawable(icon.getBackground()); - drawDrawable(icon.getForeground()); + Drawable bg = icon.getBackground(); + Drawable fg = icon.getForeground(); + if (bg != null && fg != null) { + LuminanceComputer computer = createDefaultLuminanceComputer(); + // Calculate foreground luminance on black first to account for any transparent pixels + drawDrawable(fg); + double fgLuminance = computer.computeLuminance(mFlatBitmap); + + // Start drawing from scratch and calculate background luminance + mFlatCanvas.drawColor(Color.BLACK); + drawDrawable(bg); + double bgLuminance = computer.computeLuminance(mFlatBitmap); + + drawDrawable(fg); + mLuminanceDiff = fgLuminance - bgLuminance; + } else { + // We do not have separate layer information. + // Try to calculate everything from a single layer + drawDrawable(bg); + drawDrawable(fg); + + LuminanceComputer computer = createDefaultLuminanceComputer(ComputationType.SPREAD); + mLuminanceDiff = computer.computeLuminance(mFlatBitmap, /* scale= */ true); + } generateMono(); - return new ClippedMonoDrawable(this, shapePath); + return new InsetDrawable(this, -AdaptiveIconDrawable.getExtraInsetFraction()); + } + + public double getLuminanceDiff() { + return mLuminanceDiff; } @WorkerThread private void generateMono() { mAlphaCanvas.drawBitmap(mFlatBitmap, 0, 0, mCopyPaint); - - // Scale the end points: ByteBuffer buffer = ByteBuffer.wrap(mPixels); buffer.rewind(); mAlphaBitmap.copyPixelsToBuffer(buffer); @@ -128,22 +162,10 @@ private void generateMono() { // rescale pixels to increase contrast float range = max - min; - // In order to check if the colors should be flipped, we just take the average color - // of top and bottom edge which should correspond to be background color. If the edge - // colors have more opacity, we flip the colors; - int sum = 0; - for (int i = 0; i < mEdgePixelLength; i++) { - sum += (mPixels[i] & 0xFF); - sum += (mPixels[mPixels.length - 1 - i] & 0xFF); - } - float edgeAverage = sum / (mEdgePixelLength * 2f); - float edgeMapped = (edgeAverage - min) / range; - boolean flipColor = edgeMapped > .5f; - for (int i = 0; i < mPixels.length; i++) { int p = mPixels[i] & 0xFF; int p2 = Math.round((p - min) * 0xFF / range); - mPixels[i] = flipColor ? (byte) (255 - p2) : (byte) (p2); + mPixels[i] = (byte) (p2); } // Second phase of processing, aimed on increasing the contrast diff --git a/iconloaderlib/src/com/android/launcher3/icons/PlaceHolderDrawableDelegate.kt b/iconloaderlib/src/com/android/launcher3/icons/PlaceHolderDrawableDelegate.kt new file mode 100644 index 0000000..e7b4f6c --- /dev/null +++ b/iconloaderlib/src/com/android/launcher3/icons/PlaceHolderDrawableDelegate.kt @@ -0,0 +1,94 @@ +/* + * Copyright (C) 2018 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.launcher3.icons + +import android.animation.Animator +import android.animation.AnimatorListenerAdapter +import android.animation.ValueAnimator +import android.content.Context +import android.graphics.Canvas +import android.graphics.Color +import android.graphics.Paint +import android.graphics.PorterDuff.Mode.SRC_ATOP +import android.graphics.PorterDuffColorFilter +import android.graphics.Rect +import android.graphics.drawable.Drawable +import androidx.core.graphics.ColorUtils +import com.android.launcher3.icons.FastBitmapDrawableDelegate.DelegateFactory +import com.android.launcher3.icons.GraphicsUtils.getAttrColor +import com.android.launcher3.icons.GraphicsUtils.resizeToContentSize + +/** Subclass which draws a placeholder icon when the actual icon is not yet loaded */ +class PlaceHolderDrawableDelegate(info: BitmapInfo, paint: Paint, loadingColor: Int) : + FastBitmapDrawableDelegate { + + private val fillColor = ColorUtils.compositeColors(loadingColor, info.color) + + init { + paint.color = fillColor + } + + override fun drawContent( + info: BitmapInfo, + iconShape: IconShape, + canvas: Canvas, + bounds: Rect, + paint: Paint, + ) { + canvas.resizeToContentSize(bounds, iconShape.pathSize.toFloat()) { + iconShape.shapeRenderer.render(this, paint) + } + } + + /** Updates this placeholder to `newIcon` with animation. */ + fun animateIconUpdate(newIcon: Drawable) { + val placeholderColor = fillColor + val originalAlpha = Color.alpha(placeholderColor) + + ValueAnimator.ofInt(originalAlpha, 0) + .apply { + duration = 375L + addUpdateListener { + newIcon.colorFilter = + PorterDuffColorFilter( + ColorUtils.setAlphaComponent(placeholderColor, it.animatedValue as Int), + SRC_ATOP, + ) + } + addListener( + object : AnimatorListenerAdapter() { + override fun onAnimationEnd(animation: Animator) { + newIcon.colorFilter = null + } + } + ) + } + .start() + } + + class PlaceHolderDelegateFactory(context: Context) : DelegateFactory { + private val loadingColor = getAttrColor(context, R.attr.loadingIconColor) + + override fun newDelegate( + bitmapInfo: BitmapInfo, + iconShape: IconShape, + paint: Paint, + host: FastBitmapDrawable, + ): FastBitmapDrawableDelegate { + return PlaceHolderDrawableDelegate(bitmapInfo, paint, loadingColor) + } + } +} diff --git a/iconloaderlib/src/com/android/launcher3/icons/PlaceHolderIconDrawable.java b/iconloaderlib/src/com/android/launcher3/icons/PlaceHolderIconDrawable.java deleted file mode 100644 index 531c35a..0000000 --- a/iconloaderlib/src/com/android/launcher3/icons/PlaceHolderIconDrawable.java +++ /dev/null @@ -1,91 +0,0 @@ -/* - * Copyright (C) 2018 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package com.android.launcher3.icons; - -import android.animation.Animator; -import android.animation.AnimatorListenerAdapter; -import android.animation.ValueAnimator; -import android.content.Context; -import android.graphics.Canvas; -import android.graphics.Color; -import android.graphics.Path; -import android.graphics.PorterDuff; -import android.graphics.PorterDuffColorFilter; -import android.graphics.Rect; -import android.graphics.drawable.AdaptiveIconDrawable; -import android.graphics.drawable.ColorDrawable; -import android.graphics.drawable.Drawable; - -import androidx.core.graphics.ColorUtils; - -/** - * Subclass which draws a placeholder icon when the actual icon is not yet loaded - */ -public class PlaceHolderIconDrawable extends FastBitmapDrawable { - - // Path in [0, 100] bounds. - private final Path mProgressPath; - - public PlaceHolderIconDrawable(BitmapInfo info, Context context) { - super(info); - mProgressPath = getDefaultPath(); - paint.setColor(ColorUtils.compositeColors( - GraphicsUtils.getAttrColor(context, R.attr.loadingIconColor), info.color)); - } - - /** - * Gets the current default icon mask {@link Path}. - * @return Shaped {@link Path} scaled to [0, 0, 100, 100] bounds - */ - private Path getDefaultPath() { - AdaptiveIconDrawable drawable = new AdaptiveIconDrawable( - new ColorDrawable(Color.BLACK), new ColorDrawable(Color.BLACK)); - drawable.setBounds(0, 0, 100, 100); - return new Path(drawable.getIconMask()); - } - - @Override - protected void drawInternal(Canvas canvas, Rect bounds) { - int saveCount = canvas.save(); - canvas.translate(bounds.left, bounds.top); - canvas.scale(bounds.width() / 100f, bounds.height() / 100f); - canvas.drawPath(mProgressPath, paint); - canvas.restoreToCount(saveCount); - } - - /** Updates this placeholder to {@code newIcon} with animation. */ - public void animateIconUpdate(Drawable newIcon) { - int placeholderColor = paint.getColor(); - int originalAlpha = Color.alpha(placeholderColor); - - ValueAnimator iconUpdateAnimation = ValueAnimator.ofInt(originalAlpha, 0); - iconUpdateAnimation.setDuration(375); - iconUpdateAnimation.addUpdateListener(valueAnimator -> { - int newAlpha = (int) valueAnimator.getAnimatedValue(); - int newColor = ColorUtils.setAlphaComponent(placeholderColor, newAlpha); - - newIcon.setColorFilter(new PorterDuffColorFilter(newColor, PorterDuff.Mode.SRC_ATOP)); - }); - iconUpdateAnimation.addListener(new AnimatorListenerAdapter() { - @Override - public void onAnimationEnd(Animator animation) { - newIcon.setColorFilter(null); - } - }); - iconUpdateAnimation.start(); - } - -} diff --git a/iconloaderlib/src/com/android/launcher3/icons/RoundRectEstimator.kt b/iconloaderlib/src/com/android/launcher3/icons/RoundRectEstimator.kt new file mode 100644 index 0000000..c682c62 --- /dev/null +++ b/iconloaderlib/src/com/android/launcher3/icons/RoundRectEstimator.kt @@ -0,0 +1,90 @@ +/* + * Copyright (C) 2025 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.launcher3.icons + +import android.graphics.Matrix +import android.graphics.Path +import android.graphics.Rect +import android.graphics.Region +import android.graphics.RegionIterator + +/** Utility class to estimate round rect parameters from a [Path] */ +object RoundRectEstimator { + + internal const val AREA_CALC_SIZE = 1000 + // .1% error margin + internal const val AREA_DIFF_THRESHOLD = AREA_CALC_SIZE * AREA_CALC_SIZE / 1000 + + internal const val ITERATION_COUNT = 20 + + fun getArea(r: Region): Int { + val itr = RegionIterator(r) + var area = 0 + val tempRect = Rect() + while (itr.next(tempRect)) { + area += tempRect.width() * tempRect.height() + } + return area + } + + /** + * For the provided [path] in bounds [0, 0, [size], [size]], tries to estimate the radius of the + * rounded rectangle which closely resembles this path. Returns the radius as a factor of + * half-[size] or -1 if the provided path can't be estimated as a rounded rectangle. + */ + fun estimateRadius(path: Path, size: Float): Float { + val fullRegion = Region(0, 0, AREA_CALC_SIZE, AREA_CALC_SIZE) + + val tmpPath = Path() + path.transform( + Matrix().apply { setScale(AREA_CALC_SIZE / size, AREA_CALC_SIZE / size) }, + tmpPath, + ) + val iconRegion = Region().apply { setPath(tmpPath, fullRegion) } + + val shapePath = Path() + val shapeRegion = Region() + + var minAreaDiff = Int.MAX_VALUE + var radiusFactor = -1f + // iterate over radius factor + for (f in 0..ITERATION_COUNT) { + shapePath.reset() + val currentRadiusFactor = f.toFloat() / ITERATION_COUNT + val radius = currentRadiusFactor * AREA_CALC_SIZE / 2 + shapePath.addRoundRect( + 0f, + 0f, + AREA_CALC_SIZE.toFloat(), + AREA_CALC_SIZE.toFloat(), + radius, + radius, + Path.Direction.CW, + ) + shapeRegion.setPath(shapePath, fullRegion) + shapeRegion.op(iconRegion, Region.Op.XOR) + + val rectArea = getArea(shapeRegion) + if (rectArea < minAreaDiff) { + minAreaDiff = rectArea + radiusFactor = currentRadiusFactor + } + } + + return if (minAreaDiff < AREA_DIFF_THRESHOLD) radiusFactor else -1f + } +} diff --git a/iconloaderlib/src/com/android/launcher3/icons/ShadowGenerator.java b/iconloaderlib/src/com/android/launcher3/icons/ShadowGenerator.java index 5cd05c5..4d22aec 100644 --- a/iconloaderlib/src/com/android/launcher3/icons/ShadowGenerator.java +++ b/iconloaderlib/src/com/android/launcher3/icons/ShadowGenerator.java @@ -81,7 +81,7 @@ public synchronized void drawShadow(Bitmap icon, Canvas out) { } /** package private **/ - void addPathShadow(Path path, Canvas out) { + public void addPathShadow(Path path, Canvas out) { if (ENABLE_SHADOWS) { mDrawPaint.setMaskFilter(mDefaultBlurMaskFilter); diff --git a/iconloaderlib/src/com/android/launcher3/icons/ShapeRenderer.kt b/iconloaderlib/src/com/android/launcher3/icons/ShapeRenderer.kt new file mode 100644 index 0000000..d368ec8 --- /dev/null +++ b/iconloaderlib/src/com/android/launcher3/icons/ShapeRenderer.kt @@ -0,0 +1,71 @@ +/* + * Copyright (C) 2025 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.launcher3.icons + +import android.graphics.Canvas +import android.graphics.Paint +import android.graphics.Paint.ANTI_ALIAS_FLAG +import android.graphics.Path +import com.android.launcher3.icons.BitmapRenderer.createSoftwareBitmap + +sealed interface ShapeRenderer { + /** + * Draws shape to the canvas using the provided parameters. This is used in draw methods, so + * operations should be fast, with no new objects initialized. + * + * @param canvas Canvas to draw shape on. + * @param paint Paint to draw on the Canvas with. + */ + fun render(canvas: Canvas, paint: Paint) + + /** A renderer which draws a circle of radius [r] */ + class CircleRenderer(private val r: Float) : ShapeRenderer { + + override fun render(canvas: Canvas, paint: Paint) { + canvas.drawCircle(r, r, r, paint) + } + } + + /** A renderer which draws a rounded rect in [0, 0, [size], [size]] of corner radius [r] */ + class RoundedRectRenderer(private val size: Float, private val r: Float) : ShapeRenderer { + override fun render(canvas: Canvas, paint: Paint) { + canvas.drawRoundRect(0f, 0f, size, size, r, r, paint) + } + } + + /** A renderer which draws the [path] */ + class PathRenderer(private val path: Path) : ShapeRenderer { + override fun render(canvas: Canvas, paint: Paint) { + canvas.drawPath(path, paint) + } + } + + /** + * A renderer which draws the a alpha bitmap mask. This is preferred over [PathRenderer] if the + * max rendering size is known + */ + class AlphaMaskRenderer(path: Path, size: Int) : ShapeRenderer { + + private val mask = + createSoftwareBitmap(size, size) { it.drawPath(path, Paint(ANTI_ALIAS_FLAG)) } + .extractAlpha() + + override fun render(canvas: Canvas, paint: Paint) { + canvas.drawBitmap(mask, 0f, 0f, paint) + } + } +} diff --git a/iconloaderlib/src/com/android/launcher3/icons/ThemedBitmap.kt b/iconloaderlib/src/com/android/launcher3/icons/ThemedBitmap.kt index 77b34ac..cee9aad 100644 --- a/iconloaderlib/src/com/android/launcher3/icons/ThemedBitmap.kt +++ b/iconloaderlib/src/com/android/launcher3/icons/ThemedBitmap.kt @@ -18,14 +18,15 @@ package com.android.launcher3.icons import android.content.Context import android.graphics.drawable.AdaptiveIconDrawable +import com.android.launcher3.icons.FastBitmapDrawableDelegate.DelegateFactory import com.android.launcher3.icons.cache.CachingLogic import com.android.launcher3.util.ComponentKey /** Represents a themed version of a BitmapInfo */ interface ThemedBitmap { - /** Creates a new Drawable */ - fun newDrawable(info: BitmapInfo, context: Context): FastBitmapDrawable + /** Creates a new [DelegateFactory] based on the [context] */ + fun newDelegateFactory(info: BitmapInfo, context: Context): DelegateFactory fun serialize(): ByteArray @@ -35,7 +36,8 @@ interface ThemedBitmap { /** ThemedBitmap to be used when theming is not supported for a particular bitmap */ val NOT_SUPPORTED = object : ThemedBitmap { - override fun newDrawable(info: BitmapInfo, context: Context) = info.newIcon(context) + override fun newDelegateFactory(info: BitmapInfo, context: Context) = + info.delegateFactory override fun serialize() = ByteArray(0) } @@ -51,10 +53,10 @@ interface IconThemeController { info: BitmapInfo, factory: BaseIconFactory, sourceHint: SourceHint? = null, - ): ThemedBitmap? + ): ThemedBitmap fun decode( - data: ByteArray, + bytes: ByteArray, info: BitmapInfo, factory: BaseIconFactory, sourceHint: SourceHint, diff --git a/iconloaderlib/src/com/android/launcher3/icons/UserBadgeDrawable.java b/iconloaderlib/src/com/android/launcher3/icons/UserBadgeDrawable.java index 07e12ef..ae9da70 100644 --- a/iconloaderlib/src/com/android/launcher3/icons/UserBadgeDrawable.java +++ b/iconloaderlib/src/com/android/launcher3/icons/UserBadgeDrawable.java @@ -25,11 +25,8 @@ import android.graphics.ColorFilter; import android.graphics.ColorMatrix; import android.graphics.ColorMatrixColorFilter; -import android.graphics.Matrix; import android.graphics.Paint; -import android.graphics.Path; import android.graphics.Rect; -import android.graphics.RectF; import android.graphics.drawable.Drawable; import android.graphics.drawable.DrawableWrapper; @@ -60,24 +57,12 @@ public class UserBadgeDrawable extends DrawableWrapper { private final int mBaseColor; private final int mBgColor; private boolean mShouldDrawBackground = true; - @Nullable private Path mShape; - - private Matrix mShapeMatrix = new Matrix(); @VisibleForTesting public final boolean mIsThemed; - public UserBadgeDrawable(Context context, int badgeRes, int colorRes, boolean isThemed, - @Nullable Path shape) { + public UserBadgeDrawable(Context context, int badgeRes, int colorRes, boolean isThemed) { super(context.getDrawable(badgeRes)); - mShape = shape; - mShapeMatrix = new Matrix(); - if (mShape != null) { - mShapeMatrix.setRectToRect(new RectF(0f, 0f, 100f, 100f), - new RectF(0f, 0f, CENTER * 2, CENTER * 2), - Matrix.ScaleToFit.CENTER); - mShape.transform(mShapeMatrix); - } mIsThemed = isThemed; if (isThemed) { mutate(); @@ -108,17 +93,9 @@ public void draw(@NonNull Canvas canvas) { canvas.scale(b.width() / VIEWPORT_SIZE, b.height() / VIEWPORT_SIZE); mPaint.setColor(blendDrawableAlpha(SHADOW_COLOR)); - if (mShape != null) { - canvas.drawPath(mShape, mPaint); - } else { - canvas.drawCircle(CENTER, CENTER + SHADOW_OFFSET_Y, SHADOW_RADIUS, mPaint); - } + canvas.drawCircle(CENTER, CENTER + SHADOW_OFFSET_Y, SHADOW_RADIUS, mPaint); mPaint.setColor(blendDrawableAlpha(mBgColor)); - if (mShape != null) { - canvas.drawPath(mShape, mPaint); - } else { - canvas.drawCircle(CENTER, CENTER, BG_RADIUS, mPaint); - } + canvas.drawCircle(CENTER, CENTER, BG_RADIUS, mPaint); canvas.restoreToCount(saveCount); } super.draw(canvas); diff --git a/iconloaderlib/src/com/android/launcher3/icons/cache/BaseIconCache.kt b/iconloaderlib/src/com/android/launcher3/icons/cache/BaseIconCache.kt index c4049df..0f33941 100644 --- a/iconloaderlib/src/com/android/launcher3/icons/cache/BaseIconCache.kt +++ b/iconloaderlib/src/com/android/launcher3/icons/cache/BaseIconCache.kt @@ -24,13 +24,12 @@ import android.content.pm.LauncherApps import android.content.pm.PackageManager import android.content.pm.PackageManager.NameNotFoundException import android.database.Cursor -import android.database.sqlite.SQLiteDatabase -import android.database.sqlite.SQLiteException import android.database.sqlite.SQLiteReadOnlyDatabaseException import android.graphics.Bitmap import android.graphics.Bitmap.Config.HARDWARE import android.graphics.BitmapFactory import android.graphics.BitmapFactory.Options +import android.graphics.drawable.BitmapDrawable import android.graphics.drawable.Drawable import android.os.Handler import android.os.Looper @@ -42,7 +41,6 @@ import android.util.SparseArray import androidx.annotation.VisibleForTesting import androidx.annotation.WorkerThread import com.android.launcher3.Flags -import com.android.systemui.shared.Flags.extendibleThemeManager import com.android.launcher3.icons.BaseIconFactory import com.android.launcher3.icons.BaseIconFactory.IconOptions import com.android.launcher3.icons.BitmapInfo @@ -55,6 +53,7 @@ import com.android.launcher3.icons.cache.CacheLookupFlag.Companion.DEFAULT_LOOKU import com.android.launcher3.util.ComponentKey import com.android.launcher3.util.FlagOp import com.android.launcher3.util.SQLiteCacheHelper +import com.android.systemui.shared.Flags.extendibleThemeManager import java.util.function.Supplier import kotlin.collections.MutableMap.MutableEntry @@ -95,7 +94,7 @@ constructor( @JvmField val workerHandler = Handler(bgLooper) - @JvmField protected var iconDb = IconDB(context, dbFileName, iconPixelSize) + @JvmField protected var iconDb = createIconDb(iconPixelSize) private var defaultIcon: BitmapInfo? = null private val userFlagOpMap = SparseArray() @@ -135,7 +134,7 @@ constructor( userFlagOpMap.clear() iconDb.clear() iconDb.close() - iconDb = IconDB(context, dbFileName, iconPixelSize) + iconDb = createIconDb(iconPixelSize) cache.clear() } catch (e: SQLiteReadOnlyDatabaseException) { // This is known to happen during repeated backup and restores, if the Launcher is in @@ -202,8 +201,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) @@ -227,7 +233,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 = @@ -395,8 +401,8 @@ constructor( iconFactory.use { li -> entry.bitmap = li.createBadgedIconBitmap( - li.createShapedAdaptiveIcon(icon), - IconOptions().setUser(user), + BitmapDrawable(icon), + IconOptions().setUser(user).assumeFullBleedIcon(true), ) } } @@ -498,28 +504,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( @@ -557,6 +557,7 @@ constructor( Options().apply { inPreferredConfig = HARDWARE }, )!!, entry.bitmap.color, + iconFactory.use { it.defaultIconShape }, ) } catch (e: Exception) { return false @@ -564,26 +565,35 @@ constructor( if (!extendibleThemeManager() || lookupFlags.hasThemeIcon()) { // Always set a non-null theme bitmap if theming was requested - entry.bitmap.themedBitmap = ThemedBitmap.NOT_SUPPORTED + entry.bitmap = entry.bitmap.copy(themedBitmap = ThemedBitmap.NOT_SUPPORTED) iconFactory.use { factory -> val themeController = factory.themeController val monoIconData = c.getBlob(INDEX_MONO_ICON) if (themeController != null && monoIconData != null) { - entry.bitmap.themedBitmap = - themeController.decode( - data = monoIconData, - info = entry.bitmap, - factory = factory, - sourceHint = - SourceHint(cacheKey, logic, c.getString(INDEX_FRESHNESS_ID)), + entry.bitmap = + entry.bitmap.copy( + themedBitmap = + themeController.decode( + bytes = monoIconData, + info = entry.bitmap, + factory = factory, + sourceHint = + SourceHint( + cacheKey, + logic, + c.getString(INDEX_FRESHNESS_ID), + ), + ) ) } } } } - entry.bitmap.flags = c.getInt(INDEX_FLAGS) - entry.bitmap = entry.bitmap.withFlags(getUserFlagOpLocked(cacheKey.user)) + entry.bitmap = + entry.bitmap.copy( + flags = getUserFlagOpLocked(cacheKey.user).apply(c.getInt(INDEX_FLAGS)) + ) iconProvider.notifyIconLoaded(entry.bitmap, cacheKey, logic) return true } @@ -625,31 +635,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" @@ -667,7 +672,9 @@ constructor( ComponentKey(ComponentName(packageName, packageName + EMPTY_CLASS_NAME), user) // Ensures themed bitmaps in the icon cache are invalidated - @JvmField val RELEASE_VERSION = if (Flags.enableLauncherIconShapes()) 11 else 10 + // LINT.IfChange(cache_release_version) + @JvmField val RELEASE_VERSION = if (Flags.enableLauncherIconShapes()) 14 else 12 + // LINT.ThenChange() @JvmField val TABLE_NAME = "icons" @JvmField val COLUMN_ROWID = "rowid" @@ -717,8 +724,7 @@ constructor( when { !extendibleThemeManager() -> this flag.useLowRes() -> BitmapInfo.of(LOW_RES_ICON, color) - !flag.hasThemeIcon() && themedBitmap != null -> - clone().apply { themedBitmap = null } + !flag.hasThemeIcon() && themedBitmap != null -> copy(themedBitmap = null) else -> this } } diff --git a/iconloaderlib/src/com/android/launcher3/icons/cache/LauncherActivityCachingLogic.kt b/iconloaderlib/src/com/android/launcher3/icons/cache/LauncherActivityCachingLogic.kt index c16b8db..4be4c77 100644 --- a/iconloaderlib/src/com/android/launcher3/icons/cache/LauncherActivityCachingLogic.kt +++ b/iconloaderlib/src/com/android/launcher3/icons/cache/LauncherActivityCachingLogic.kt @@ -54,14 +54,16 @@ object LauncherActivityCachingLogic : CachingLogic { context.packageManager.getActivityInfo(info.componentName, 0) } cache.iconFactory.use { li -> - val iconOptions: IconOptions = IconOptions().setUser(info.user) - iconOptions - .setIsArchived( - useNewIconForArchivedApps() && - VERSION.SDK_INT >= 35 && - 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 && + activityInfo.isArchived + ) + .setSourceHint(getSourceHint(info, cache)) val iconDrawable = cache.iconProvider.getIcon(activityInfo, li.fullResIconDpi) if (VERSION.SDK_INT >= 30 && 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 e57e659..b95f8bf 100644 --- a/iconloaderlib/src/com/android/launcher3/icons/mono/MonoIconThemeController.kt +++ b/iconloaderlib/src/com/android/launcher3/icons/mono/MonoIconThemeController.kt @@ -24,21 +24,19 @@ 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 app.lawnchair.icons.shouldForceMonochrome 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 @@ -48,75 +46,95 @@ import java.nio.ByteBuffer @TargetApi(Build.VERSION_CODES.TIRAMISU) class MonoIconThemeController( private val shouldForceThemeIcon: Boolean = false, - private val colorProvider: (Context) -> IntArray = ThemedIconDrawable.Companion::getColors, + private val colorProvider: (Context) -> IntArray = ThemedIconDelegate.Companion::getColors, ) : IconThemeController { override val themeID = "with-theme" + // Lawnchair-TODO: CustomAdaptiveIconDrawable override fun createThemedBitmap( icon: AdaptiveIconDrawable, info: BitmapInfo, factory: BaseIconFactory, sourceHint: SourceHint?, - ): ThemedBitmap? { - val mono = - getMonochromeDrawable( - icon, - info, - factory.getShapePath(icon, Rect(0, 0, info.icon.width, info.icon.height)), - sourceHint?.isFileDrawable ?: false, - shouldForceThemeIcon, - ) + ): ThemedBitmap { + val currentDelegateFactory = info.delegateFactory + if (currentDelegateFactory is ClockAnimationInfo) { + val fullDrawable = currentDelegateFactory.baseDrawableState.newDrawable() + val monoDrawable = (fullDrawable as? AdaptiveIconDrawable)?.monochrome?.mutate() + + if (monoDrawable is LayerDrawable) { + return ClockThemedBitmap( + currentDelegateFactory.copy( + baseDrawableState = AdaptiveIconDrawable(null, monoDrawable).constantState!! + ), + colorProvider, + ) + } else { + return ThemedBitmap.NOT_SUPPORTED + } + } + + val mono = icon.monochrome if (mono != null) { return MonoThemedBitmap( - factory.createIconBitmap(mono, ICON_VISIBLE_AREA_FACTOR, MODE_ALPHA), - factory.whiteShadowLayer, + InsetDrawable(mono, -getExtraInsetFraction()).toAlphaBitmap(factory.iconBitmapSize), colorProvider, ) } - return null - } - /** - * Returns a monochromatic version of the given drawable or null, if it is not supported - * - * @param base the original icon - */ - private fun getMonochromeDrawable( - base: AdaptiveIconDrawable, - info: BitmapInfo, - shapePath: Path, - isFileDrawable: Boolean, - shouldForceThemeIcon: Boolean, - ): Drawable? { - val mono = base.monochrome - if (mono != null) { - return ClippedMonoDrawable(mono, shapePath) - } - if (shouldForceMonochrome() && shouldForceThemeIcon && !isFileDrawable) { - return MonochromeIconFactory(info.icon.width).wrap(base, shapePath) + if (shouldForceMonochrome() && shouldForceThemeIcon) { + val monoFactory = MonochromeIconFactory(info.icon.width) + val wrappedIcon = monoFactory.wrap(icon) + return MonoThemedBitmap( + wrappedIcon.toAlphaBitmap(factory.iconBitmapSize), + colorProvider, + monoFactory.luminanceDiff, + ) } - return null + + return ThemedBitmap.NOT_SUPPORTED + } + + private fun Drawable.toAlphaBitmap(size: Int): Bitmap { + val result = Bitmap.createBitmap(size, size, ALPHA_8) + setBounds(0, 0, size, size) + draw(Canvas(result)) + return result } override fun decode( - data: ByteArray, + bytes: ByteArray, info: BitmapInfo, factory: BaseIconFactory, sourceHint: SourceHint, ): ThemedBitmap { val icon = info.icon - if (data.size != icon.height * icon.width) return ThemedBitmap.NOT_SUPPORTED + val expectedSize = icon.height * icon.width + + return when (bytes.size) { + expectedSize -> { + MonoThemedBitmap( + ByteBuffer.wrap(bytes).readMonoBitmap(icon.width, icon.height), + colorProvider, + ) + } + (expectedSize + MonoThemedBitmap.DOUBLE_BYTE_SIZE) -> { + val buffer = ByteBuffer.wrap(bytes) + val monoBitmap = buffer.readMonoBitmap(icon.width, icon.height) + val luminanceDelta = buffer.asDoubleBuffer().get() + MonoThemedBitmap(monoBitmap, colorProvider, luminanceDelta) + } + else -> ThemedBitmap.NOT_SUPPORTED + } + } - var monoBitmap = Bitmap.createBitmap(icon.width, icon.height, ALPHA_8) - monoBitmap.copyPixelsFromBuffer(ByteBuffer.wrap(data)) + private fun ByteBuffer.readMonoBitmap(width: Int, height: Int): Bitmap { + val monoBitmap = Bitmap.createBitmap(width, height, ALPHA_8) + monoBitmap.copyPixelsFromBuffer(this) val hwMonoBitmap = monoBitmap.copy(HARDWARE, false /*isMutable*/) - if (hwMonoBitmap != null) { - monoBitmap.recycle() - monoBitmap = hwMonoBitmap - } - return MonoThemedBitmap(monoBitmap, factory.whiteShadowLayer, colorProvider) + return hwMonoBitmap?.also { monoBitmap.recycle() } ?: monoBitmap } override fun createThemedAdaptiveIcon( @@ -124,47 +142,25 @@ class MonoIconThemeController( originalIcon: AdaptiveIconDrawable, info: BitmapInfo?, ): AdaptiveIconDrawable { - val colors = colorProvider(context) + originalIcon.mutate() - var monoDrawable = originalIcon.monochrome?.apply { setTint(colors[1]) } - - if (monoDrawable == null) { - info?.themedBitmap?.let { themedBitmap -> - if (themedBitmap is MonoThemedBitmap) { - // Inject a previously generated monochrome icon - // Use BitmapDrawable instead of FastBitmapDrawable so that the colorState is - // preserved in constantState - // Inset the drawable according to the AdaptiveIconDrawable layers - monoDrawable = - InsetDrawable( - BitmapDrawable(themedBitmap.mono).apply { - colorFilter = BlendModeColorFilter(colors[1], SRC_IN) - }, - AdaptiveIconDrawable.getExtraInsetFraction() / 2, - ) - } - } + originalIcon.monochrome?.let { + val colors = colorProvider(context) + it.setTint(colors[1]) + return@createThemedAdaptiveIcon AdaptiveIconDrawable(ColorDrawable(colors[0]), it) } - return monoDrawable?.let { AdaptiveIconDrawable(ColorDrawable(colors[0]), it) } - ?: originalIcon - } + val themedBitmap = info?.themedBitmap as? MonoThemedBitmap ?: return originalIcon + val colors = themedBitmap.getUpdatedColors(context) - class ClippedMonoDrawable(base: Drawable?, private val shapePath: Path) : - InsetDrawable(base, -AdaptiveIconDrawable.getExtraInsetFraction()) { - // TODO(b/399666950): remove this after launcher icon shapes is fully enabled - private val mCrop = AdaptiveIconDrawable(ColorDrawable(Color.BLACK), null) - - override fun draw(canvas: Canvas) { - mCrop.bounds = bounds - val saveCount = canvas.save() - if (Flags.enableLauncherIconShapes()) { - canvas.clipPath(shapePath) - } else { - canvas.clipPath(mCrop.iconMask) + // Inject a previously generated monochrome icon + // Use BitmapDrawable instead of FastBitmapDrawable so that the colorState is + // preserved in constantState + // Inset the drawable according to the AdaptiveIconDrawable layers + val monoDrawable = + BitmapDrawable(themedBitmap.mono).apply { + colorFilter = BlendModeColorFilter(colors[1], SRC_IN) } - super.draw(canvas) - canvas.restoreToCount(saveCount) - } + return AdaptiveIconDrawable(ColorDrawable(colors[0]), monoDrawable) } } diff --git a/iconloaderlib/src/com/android/launcher3/icons/mono/MonoThemedBitmap.kt b/iconloaderlib/src/com/android/launcher3/icons/mono/MonoThemedBitmap.kt index 2edd0b7..159ae54 100644 --- a/iconloaderlib/src/com/android/launcher3/icons/mono/MonoThemedBitmap.kt +++ b/iconloaderlib/src/com/android/launcher3/icons/mono/MonoThemedBitmap.kt @@ -18,23 +18,119 @@ package com.android.launcher3.icons.mono import android.content.Context import android.graphics.Bitmap +import android.graphics.LinearGradient +import android.graphics.Shader.TileMode.CLAMP +import android.util.Log +import androidx.annotation.VisibleForTesting +import com.android.launcher3.Flags import com.android.launcher3.icons.BitmapInfo -import com.android.launcher3.icons.FastBitmapDrawable +import com.android.launcher3.icons.ClockDrawableWrapper.ClockAnimationInfo +import com.android.launcher3.icons.FastBitmapDrawableDelegate.DelegateFactory +import com.android.launcher3.icons.LuminanceComputer import com.android.launcher3.icons.ThemedBitmap -import com.android.launcher3.icons.mono.ThemedIconDrawable.ThemedConstantState import java.nio.ByteBuffer class MonoThemedBitmap( val mono: Bitmap, - private val whiteShadowLayer: Bitmap, - private val colorProvider: (Context) -> IntArray = ThemedIconDrawable.Companion::getColors, + private val colorProvider: (Context) -> IntArray = ThemedIconDelegate.Companion::getColors, + @get:VisibleForTesting val luminanceDelta: Double? = null, ) : ThemedBitmap { - override fun newDrawable(info: BitmapInfo, context: Context): FastBitmapDrawable { - val colors = colorProvider(context) - return ThemedConstantState(info, mono, whiteShadowLayer, colors[0], colors[1]).newDrawable() + override fun newDelegateFactory(info: BitmapInfo, context: Context): DelegateFactory = + getUpdatedColors(context).let { ThemedIconInfo(mono, it[0], it[1]) } + + override fun serialize(): ByteArray { + val expectedSize = mono.width * mono.height + return if (luminanceDelta == null) + ByteArray(expectedSize).apply { mono.copyPixelsToBuffer(ByteBuffer.wrap(this)) } + else + ByteArray(expectedSize + DOUBLE_BYTE_SIZE).apply { + val buffer = ByteBuffer.wrap(this) + mono.copyPixelsToBuffer(buffer) + buffer.asDoubleBuffer().put(luminanceDelta) + } + } + + fun getUpdatedColors(ctx: Context): IntArray = + if (luminanceDelta != null) + ColorAdapter(luminanceDelta).adaptedColorProvider(colorProvider)(ctx) + else colorProvider(ctx) + + companion object { + const val DOUBLE_BYTE_SIZE = 8 + } +} + +class ClockThemedBitmap( + private val animInfo: ClockAnimationInfo, + private val colorProvider: (Context) -> IntArray = ThemedIconDelegate.Companion::getColors, +) : ThemedBitmap { + + override fun newDelegateFactory(info: BitmapInfo, context: Context): DelegateFactory = + colorProvider(context).let { colors -> + animInfo.copy( + themeFgColor = colors[1], + shader = LinearGradient(0f, 0f, 1f, 1f, colors[0], colors[0], CLAMP), + ) + } + + override fun serialize() = byteArrayOf() +} + +class ColorAdapter(private val luminanceDelta: Double) { + + private val luminanceComputer = LuminanceComputer.createDefaultLuminanceComputer() + + fun adaptedColorProvider(colorProvider: (Context) -> IntArray): (Context) -> IntArray { + // if the feature flag is off, then we don't need to adapt the colors at all. + if (!Flags.forceMonochromeAppIconsAdaptColors()) { + return colorProvider + } + + // we need to adapt the color provider here, by adapting the foregrund color at + // index 0, and the background color at index 1. + + // order is important here, we want to adapt the background color first, then the foreground + // color. + return { context -> + val colors = colorProvider(context) + intArrayOf( + adaptBackgroundColor(colors[0], colors[2]), + adaptForegroundColor(colors[1], colors[0]), + colors[2], + ) + } } - override fun serialize() = - ByteArray(mono.width * mono.height).apply { mono.copyPixelsToBuffer(ByteBuffer.wrap(this)) } + private fun adaptForegroundColor(localFgColor: Int, localBgColor: Int): Int { + if (luminanceDelta.isNaN()) { + return localFgColor + } + + try { + val adaptedColor = + luminanceComputer.adaptColorLuminance( + localFgColor, + localBgColor, + luminanceDelta, + MINIMUM_CONTRAST_RATIO, + ) + return adaptedColor + } catch (e: Exception) { + Log.e(TAG, "Failed to adjust luminance color", e) + } + return localFgColor + } + + private fun adaptBackgroundColor(colorBg: Int, colorBgNonMonochrome: Int): Int { + if (luminanceDelta.isNaN()) { + return colorBg + } + return colorBgNonMonochrome + } + + private companion object { + const val TAG = "ColorAdapter" + const val MINIMUM_CONTRAST_RATIO = 8.0 + } } diff --git a/iconloaderlib/src/com/android/launcher3/icons/mono/ThemedIconDelegate.kt b/iconloaderlib/src/com/android/launcher3/icons/mono/ThemedIconDelegate.kt new file mode 100644 index 0000000..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 4ed5017..0000000 --- a/iconloaderlib/src/com/android/launcher3/icons/mono/ThemedIconDrawable.kt +++ /dev/null @@ -1,148 +0,0 @@ -/* - * Copyright (C) 2021 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package com.android.launcher3.icons.mono - -import android.annotation.ColorInt -import android.content.Context -import android.content.res.Configuration.UI_MODE_NIGHT_MASK -import android.content.res.Configuration.UI_MODE_NIGHT_YES -import android.graphics.Bitmap -import android.graphics.BlendMode.SRC_IN -import android.graphics.BlendModeColorFilter -import android.graphics.Canvas -import android.graphics.Paint -import android.graphics.PorterDuff -import android.graphics.PorterDuffColorFilter -import android.graphics.Rect -import android.os.Build -import androidx.core.graphics.ColorUtils -import com.android.launcher3.icons.BitmapInfo -import com.android.launcher3.icons.FastBitmapDrawable -import com.android.launcher3.icons.R - -import app.lawnchair.icons.shouldTransparentBGIcons - -/** Class to handle monochrome themed app icons */ -class ThemedIconDrawable(constantState: ThemedConstantState) : - FastBitmapDrawable(constantState.bitmapInfo) { - private val colorFg = constantState.colorFg - private val colorBg = constantState.colorBg - - // The foreground/monochrome icon for the app - private val monoIcon = constantState.mono - private val monoFilter = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - BlendModeColorFilter(colorFg, SRC_IN) - } else { - PorterDuffColorFilter(colorFg, PorterDuff.Mode.SRC_IN) - } - private val monoPaint = - Paint(Paint.ANTI_ALIAS_FLAG or Paint.FILTER_BITMAP_FLAG).apply { colorFilter = monoFilter } - - private val bgBitmap = constantState.whiteShadowLayer - private val bgFilter = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - BlendModeColorFilter(colorBg, SRC_IN) - } else { - PorterDuffColorFilter(colorBg, PorterDuff.Mode.SRC_IN) - } - private val mBgPaint = - Paint(Paint.ANTI_ALIAS_FLAG or Paint.FILTER_BITMAP_FLAG).apply { colorFilter = bgFilter } - - override fun drawInternal(canvas: Canvas, bounds: Rect) { - canvas.drawBitmap(bgBitmap, null, bounds, mBgPaint) - canvas.drawBitmap(monoIcon, null, bounds, monoPaint) - } - - override fun updateFilter() { - super.updateFilter() - val alpha = if (isDisabled) (disabledAlpha * FULLY_OPAQUE).toInt() else FULLY_OPAQUE - mBgPaint.alpha = alpha - mBgPaint.setColorFilter( - if (isDisabled) if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - BlendModeColorFilter(getDisabledColor(colorBg), SRC_IN) - } else { - PorterDuffColorFilter(getDisabledColor(colorBg), PorterDuff.Mode.SRC_IN) - } else bgFilter, - ) - - monoPaint.alpha = alpha - monoPaint.setColorFilter( - if (isDisabled) if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - BlendModeColorFilter( - getDisabledColor(colorFg), - SRC_IN, - ) - } else { - PorterDuffColorFilter(getDisabledColor(colorFg), PorterDuff.Mode.SRC_IN) - } else monoFilter, - ) - } - - override fun isThemed() = true - - override fun newConstantState() = - ThemedConstantState(bitmapInfo, monoIcon, bgBitmap, colorBg, colorFg) - - override fun getIconColor() = colorFg - - class ThemedConstantState( - bitmapInfo: BitmapInfo, - val mono: Bitmap, - val whiteShadowLayer: Bitmap, - val colorBg: Int, - val colorFg: Int, - ) : FastBitmapConstantState(bitmapInfo) { - - public override fun createDrawable() = ThemedIconDrawable(this) - } - - companion object { - const val TAG: String = "ThemedIconDrawable" - - @ColorInt - fun getThemedColors(context: Context): IntArray { - val result = getColors(context) - if (!context.shouldTransparentBGIcons()) { - return result - } - if ((context.getResources() - .getConfiguration().uiMode and UI_MODE_NIGHT_MASK) !== UI_MODE_NIGHT_YES - ) { - //Get Composite color for light mode or non dark mode - result[1] = ColorUtils.compositeColors( - context.getResources().getColor(android.R.color.black), result[1], - ) - } - result[0] = 0 - return result - } - - /** Get an int array representing background and foreground colors for themed icons */ - @JvmStatic - fun getColors(context: Context): IntArray { - if (COLORS_LOADER != null) { - return COLORS_LOADER(context); - } - val res = context.resources - return intArrayOf( - res.getColor(R.color.themed_icon_background_color), - res.getColor(R.color.themed_icon_color), - ) - } - - @JvmStatic - var COLORS_LOADER: (Context) -> IntArray = { context -> getColors(context) } - } -} diff --git a/iconloaderlib/src/com/android/launcher3/util/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 index d683892..6df296e 100644 --- a/mechanics/Android.bp +++ b/mechanics/Android.bp @@ -20,7 +20,8 @@ package { android_library { name: "mechanics", manifest: "AndroidManifest.xml", - sdk_version: "system_current", + // sdk_version must be specified, otherwise it compiles against private APIs. + sdk_version: "current", min_sdk_version: "31", static_libs: [ "androidx.compose.runtime_runtime", diff --git a/mechanics/OWNERS b/mechanics/OWNERS new file mode 100644 index 0000000..f895dc9 --- /dev/null +++ b/mechanics/OWNERS @@ -0,0 +1,2 @@ +michschn@google.com +omarmt@google.com diff --git a/mechanics/TEST_MAPPING b/mechanics/TEST_MAPPING index 4dd86b9..7f09a13 100644 --- a/mechanics/TEST_MAPPING +++ b/mechanics/TEST_MAPPING @@ -15,10 +15,28 @@ ] }, { - "name": "PlatformComposeSceneTransitionLayoutTests" + "name": "PlatformComposeSceneTransitionLayoutTests", + "keywords": ["internal"], + "options": [ + { + "exclude-annotation": "org.junit.Ignore" + }, + { + "exclude-annotation": "androidx.test.filters.FlakyTest" + } + ] }, { - "name": "PlatformComposeCoreTests" + "name": "PlatformComposeCoreTests", + "keywords": ["internal"], + "options": [ + { + "exclude-annotation": "org.junit.Ignore" + }, + { + "exclude-annotation": "androidx.test.filters.FlakyTest" + } + ] } ], "presubmit-large": [ @@ -30,7 +48,7 @@ ] } ], - "wm-cf": [ + "wm": [ { "name": "WMShellUnitTests" } diff --git a/mechanics/benchmark/tests/src/com/android/mechanics/benchmark/MechanicsSpringBenchmark.kt b/mechanics/benchmark/tests/src/com/android/mechanics/benchmark/MechanicsSpringBenchmark.kt new file mode 100644 index 0000000..cc6bdfe --- /dev/null +++ b/mechanics/benchmark/tests/src/com/android/mechanics/benchmark/MechanicsSpringBenchmark.kt @@ -0,0 +1,82 @@ +/* + * Copyright (C) 2025 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.mechanics.benchmark + +import androidx.benchmark.junit4.BenchmarkRule +import androidx.benchmark.junit4.measureRepeated +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.android.mechanics.spring.SpringParameters +import com.android.mechanics.spring.SpringState +import com.android.mechanics.spring.calculateUpdatedState +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class MechanicsSpringBenchmark { + @get:Rule val benchmarkRule = BenchmarkRule() + + @Test + fun calculateUpdatedState_atRest() { + val initialState = SpringState(0f, 0f) + + benchmarkRule.measureRepeated { + initialState.calculateUpdatedState(FrameDuration, CriticallyDamped) + } + } + + @Test + fun calculateUpdatedState_underDamped() { + val initialState = SpringState(10f, -1f) + + benchmarkRule.measureRepeated { + initialState.calculateUpdatedState(FrameDuration, UnderDamped) + } + } + + @Test + fun calculateUpdatedState_criticallyDamped() { + val initialState = SpringState(10f, -1f) + + benchmarkRule.measureRepeated { + initialState.calculateUpdatedState(FrameDuration, CriticallyDamped) + } + } + + @Test + fun calculateUpdatedState_overDamped() { + val initialState = SpringState(10f, -1f) + + benchmarkRule.measureRepeated { + initialState.calculateUpdatedState(FrameDuration, OverDamped) + } + } + + @Test + fun isStable() { + val initialState = SpringState(10f, -1f) + + benchmarkRule.measureRepeated { initialState.isStable(CriticallyDamped, 0.1f) } + } + + companion object { + val FrameDuration = 16_000_000L + val UnderDamped = SpringParameters(stiffness = 100f, dampingRatio = 0.5f) + val CriticallyDamped = SpringParameters(stiffness = 100f, dampingRatio = 1f) + val OverDamped = SpringParameters(stiffness = 100f, dampingRatio = 2f) + } +} diff --git a/mechanics/benchmark/tests/src/com/android/mechanics/benchmark/MotionValueBenchmark.kt b/mechanics/benchmark/tests/src/com/android/mechanics/benchmark/MotionValueBenchmark.kt index f5eab76..b2aab0b 100644 --- a/mechanics/benchmark/tests/src/com/android/mechanics/benchmark/MotionValueBenchmark.kt +++ b/mechanics/benchmark/tests/src/com/android/mechanics/benchmark/MotionValueBenchmark.kt @@ -73,11 +73,11 @@ class MotionValueBenchmark { private fun testData( gestureContext: DistanceGestureContext = DistanceGestureContext(0f, InputDirection.Max, 2f), input: Float = 0f, - spec: MotionSpec = MotionSpec.Empty, + spec: MotionSpec = MotionSpec.Identity, ): TestData { val inputState = mutableFloatStateOf(input) return TestData( - motionValue = MotionValue(inputState::floatValue, gestureContext, spec), + motionValue = MotionValue(inputState::floatValue, gestureContext, { spec }), gestureContext = gestureContext, input = inputState, spec = spec, @@ -91,7 +91,9 @@ class MotionValueBenchmark { val gestureContext = DistanceGestureContext(0f, InputDirection.Max, 2f) val input = { 0f } - benchmarkRule.measureRepeated { MotionValue(input, gestureContext) } + benchmarkRule.measureRepeated { + MotionValue(input, gestureContext, { MotionSpec.Identity }) + } } @Test diff --git a/mechanics/benchmark/tests/src/com/android/mechanics/benchmark/MotionValueCollectionBenchmark.kt b/mechanics/benchmark/tests/src/com/android/mechanics/benchmark/MotionValueCollectionBenchmark.kt new file mode 100644 index 0000000..efbbd02 --- /dev/null +++ b/mechanics/benchmark/tests/src/com/android/mechanics/benchmark/MotionValueCollectionBenchmark.kt @@ -0,0 +1,194 @@ +/* + * Copyright (C) 2025 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.mechanics.benchmark + +import androidx.benchmark.junit4.BenchmarkRule +import androidx.benchmark.junit4.measureRepeated +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.snapshots.Snapshot +import androidx.compose.ui.util.fastForEach +import com.android.mechanics.DistanceGestureContext +import com.android.mechanics.ManagedMotionValue +import com.android.mechanics.MotionValueCollection +import com.android.mechanics.spec.Guarantee +import com.android.mechanics.spec.InputDirection +import com.android.mechanics.spec.Mapping +import com.android.mechanics.spec.MotionSpec +import com.android.mechanics.spec.builder.MotionBuilderContext +import com.android.mechanics.spec.builder.directionalMotionSpec +import com.android.mechanics.spring.SpringParameters +import kotlinx.coroutines.launch +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.junit.runners.Parameterized +import platform.test.motion.compose.MonotonicClockTestScope + +/** Benchmark, which will execute on an Android device. Previous results: go/mm-microbenchmarks */ +@RunWith(Parameterized::class) +class MotionValueCollectionBenchmark(private val instanceCount: Int) { + + companion object { + @JvmStatic + @Parameterized.Parameters(name = "instanceCount={0}") + fun instanceCount() = listOf(1, 100) + + val DefaultSpring = SpringParameters(stiffness = 300f, dampingRatio = .9f) + } + + @get:Rule val benchmarkRule = BenchmarkRule() + + private val tearDownOperations = mutableListOf<() -> Unit>() + + /** + * Runs a test block within a [MonotonicClockTestScope] provided by the underlying + * [platform.test.motion.compose.runMonotonicClockTest] and ensures automatic cleanup. + * + * This mechanism provides a convenient way to register cleanup actions (e.g., stopping + * coroutines, resetting states) that should reliably run at the end of the test, simplifying + * test setup and teardown. + */ + private fun runMonotonicClockTest(block: suspend MonotonicClockTestScope.() -> Unit) { + return platform.test.motion.compose.runMonotonicClockTest { + try { + block() + } finally { + tearDownOperations.fastForEach { it.invoke() } + } + } + } + + private data class TestFixture( + val collection: MotionValueCollection, + val gestureContext: DistanceGestureContext, + val instances: List, + ) + + private data class MotionValueInstance( + val value: ManagedMotionValue, + val spec: MutableState, + ) + + private fun MonotonicClockTestScope.testFixture( + initialInput: Float = 0f, + init: (Int) -> MotionSpec = { MotionSpec.Identity }, + ): TestFixture { + val gestureContext = DistanceGestureContext(initialInput, InputDirection.Max, 2f) + val collection = + MotionValueCollection( + { gestureContext.dragOffset }, + gestureContext, + stableThreshold = MotionBuilderContext.StableThresholdEffects, + ) + + val instances = + List(instanceCount) { + val spec = mutableStateOf(init(it)) + val value = collection.create(spec::value) + MotionValueInstance(value, spec) + } + + val keepRunningJob = launch { collection.keepRunning() } + tearDownOperations += { keepRunningJob.cancel() } + + return TestFixture( + collection = collection, + gestureContext = gestureContext, + instances = instances, + ) + } + + private fun MonotonicClockTestScope.nextFrame() { + Snapshot.sendApplyNotifications() + testScheduler.advanceTimeBy(16) + } + + private fun MonotonicClockTestScope.measureOscillatingInput( + fixture: TestFixture, + stepSize: Float = 1f, + ) { + var step = stepSize + benchmarkRule.measureRepeated { + val lastInput = fixture.gestureContext.dragOffset + if (lastInput <= .5f) step = stepSize else if (lastInput >= 9.5f) step = -stepSize + fixture.gestureContext.dragOffset = lastInput + step + nextFrame() + } + } + + @Test + fun noChange() = runMonotonicClockTest { + val fixture = testFixture() + + measureOscillatingInput(fixture, stepSize = 0f) + } + + @Test + fun changeInput() = runMonotonicClockTest { + val fixture = testFixture() + + measureOscillatingInput(fixture) + } + + @Test + fun changeInput_sameOutput() = runMonotonicClockTest { + val spec = MotionSpec(directionalMotionSpec(Mapping.Zero)) + + val fixture = testFixture(initialInput = 4f) { spec } + measureOscillatingInput(fixture) + } + + @Test + fun changeSegment_noDiscontinuity() = runMonotonicClockTest { + val spec = + MotionSpec( + directionalMotionSpec(DefaultSpring, Mapping.Zero) { + mapping(breakpoint = 5f, mapping = Mapping.Zero) + } + ) + + val fixture = testFixture(initialInput = 4f) { spec } + measureOscillatingInput(fixture) + } + + @Test + fun animateOutput() = runMonotonicClockTest { + val spec = + MotionSpec( + directionalMotionSpec(DefaultSpring, Mapping.Zero) { + fixedValue(breakpoint = 5f, value = 1f) + } + ) + + val fixture = testFixture(initialInput = 4f) { spec } + measureOscillatingInput(fixture) + } + + @Test + fun animateWithGuarantee() = runMonotonicClockTest { + val spec = + MotionSpec( + directionalMotionSpec(DefaultSpring, Mapping.Zero) { + fixedValue(breakpoint = 5f, value = 1f, guarantee = Guarantee.InputDelta(4f)) + } + ) + + val fixture = testFixture { spec } + measureOscillatingInput(fixture) + } +} diff --git a/mechanics/compose/Android.bp b/mechanics/compose/Android.bp index bc852eb..ddcc569 100644 --- a/mechanics/compose/Android.bp +++ b/mechanics/compose/Android.bp @@ -24,8 +24,10 @@ android_library { "src/**/*.kt", ], static_libs: [ - "PlatformComposeCore", + // Private APIs "PlatformComposeSceneTransitionLayout", + + // Public APIs "//frameworks/libs/systemui/mechanics:mechanics", "androidx.compose.runtime_runtime", ], diff --git a/mechanics/compose/src/com/android/mechanics/compose/modifier/MotionDriver.kt b/mechanics/compose/src/com/android/mechanics/compose/modifier/MotionDriver.kt new file mode 100644 index 0000000..00ed295 --- /dev/null +++ b/mechanics/compose/src/com/android/mechanics/compose/modifier/MotionDriver.kt @@ -0,0 +1,191 @@ +/* + * Copyright (C) 2025 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.mechanics.compose.modifier + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.layout.LayoutCoordinates +import androidx.compose.ui.layout.Measurable +import androidx.compose.ui.layout.MeasureResult +import androidx.compose.ui.layout.MeasureScope +import androidx.compose.ui.layout.Placeable +import androidx.compose.ui.node.CompositionLocalConsumerModifierNode +import androidx.compose.ui.node.DelegatableNode +import androidx.compose.ui.node.LayoutModifierNode +import androidx.compose.ui.node.ModifierNodeElement +import androidx.compose.ui.node.TraversableNode +import androidx.compose.ui.node.findNearestAncestor +import androidx.compose.ui.platform.InspectorInfo +import androidx.compose.ui.unit.Constraints +import androidx.compose.ui.unit.IntOffset +import com.android.mechanics.GestureContext +import com.android.mechanics.ManagedMotionValue +import com.android.mechanics.MotionValueCollection +import com.android.mechanics.spec.MotionSpec +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch + +private const val TRAVERSAL_NODE_KEY = "MotionDriverNode" + +/** Finds the nearest [MotionDriver] (or null) that was registered via a [motionDriver] modifier. */ +private fun DelegatableNode.findMotionDriverOrNull(): MotionDriver? { + return findNearestAncestor(TRAVERSAL_NODE_KEY) as? MotionDriver +} + +/** Finds the nearest [MotionDriver] that was registered via a [motionDriver] modifier. */ +internal fun DelegatableNode.findMotionDriver(): MotionDriver { + return checkNotNull(findMotionDriverOrNull()) { + "Did you forget to add the `motionDriver()` modifier to a parent Composable?" + } +} + +/** + * A central interface for driving animations based on layout constraints. + * + * A `MotionDriver` is attached to a layout node using the [motionDriver] modifier. Descendant nodes + * can then find this driver to create animations whose target values are derived from the driver's + * layout `Constraints`. This allows for coordinated animations within a component tree that react + * to a parent's size changes, such as expanding or collapsing. + */ +internal interface MotionDriver { + /** The [GestureContext] associated with this motion. */ + val gestureContext: GestureContext + + /** + * The current vertical state of the layout, indicating if it's minimized, maximized, or in + * transition. + */ + val verticalState: State + + enum class State { + MinValue, + Transition, + MaxValue, + } + + /** + * Calculates the positional offset from the `MotionDriver`'s layout to the current layout. + * + * This function should be called from within a `Placeable.PlacementScope` (such as a `layout` + * block) by a descendant of the `motionDriver` modifier. It's useful for determining the + * descendant's position relative to the driver's coordinate system, which can then be used as + * an input for animations or other positional logic. + * + * @return The [Offset] of the current layout within the `MotionDriver`'s coordinate space. + */ + fun Placeable.PlacementScope.driverOffset(): Offset + + /** + * Creates and registers a [ManagedMotionValue] that animates based on layout constraints. + * + * The value will automatically update its output whenever the `MotionDriver`'s `maxHeight` + * constraint changes. + * + * @param spec A factory for the [MotionSpec] that governs the animation. + * @param label A string identifier for debugging purposes. + * @return A [ManagedMotionValue] that provides the animated output. + */ + fun maxHeightDriven(spec: () -> MotionSpec, label: String? = null): ManagedMotionValue +} + +/** + * Creates and registers a [MotionDriver] for this layout. + * + * This allows descendant modifiers or layouts to find this `MotionDriver` (using + * [findMotionDriver]) and observe its state, which is derived from layout changes (e.g., expanding + * or collapsing). + * + * @param gestureContext The [GestureContext] to be made available through this [MotionDriver]. + * @param label An optional label for debugging and inspector tooling. + */ +fun Modifier.motionDriver(gestureContext: GestureContext, label: String? = null): Modifier = + this then MotionDriverElement(gestureContext = gestureContext, label = label) + +private data class MotionDriverElement(val gestureContext: GestureContext, val label: String?) : + ModifierNodeElement() { + override fun create(): MotionDriverNode = + MotionDriverNode(gestureContext = gestureContext, label = label) + + override fun update(node: MotionDriverNode) { + check(node.gestureContext == gestureContext) { "Cannot change the gestureContext" } + } + + override fun InspectorInfo.inspectableProperties() { + name = "motionDriver" + properties["label"] = label + } +} + +private class MotionDriverNode(override val gestureContext: GestureContext, label: String?) : + Modifier.Node(), + TraversableNode, + LayoutModifierNode, + MotionDriver, + CompositionLocalConsumerModifierNode { + override val traverseKey: Any = TRAVERSAL_NODE_KEY + override var verticalState: MotionDriver.State by mutableStateOf(MotionDriver.State.MinValue) + + private var driverCoordinates: LayoutCoordinates? = null + private var lookAheadHeight: Int = 0 + private var input by mutableFloatStateOf(0f) + private val motionValues = MotionValueCollection(::input, gestureContext, label = label) + + override fun onAttach() { + coroutineScope.launch(Dispatchers.Main.immediate) { motionValues.keepRunning() } + } + + override fun maxHeightDriven(spec: () -> MotionSpec, label: String?): ManagedMotionValue { + return motionValues.create(spec, label) + } + + override fun Placeable.PlacementScope.driverOffset(): Offset { + val driverCoordinates = requireNotNull(driverCoordinates) { "No driver coordinates" } + val childCoordinates = requireNotNull(coordinates) { "No child coordinates" } + return driverCoordinates.localPositionOf(childCoordinates) + } + + override fun MeasureScope.measure( + measurable: Measurable, + constraints: Constraints, + ): MeasureResult { + val placeable = measurable.measure(constraints) + + if (isLookingAhead) { + // In the lookahead pass, we capture the target height of the layout. + // This is assumed to be the max value that the layout will animate to. + lookAheadHeight = placeable.height + } else { + verticalState = + when (placeable.height) { + 0 -> MotionDriver.State.MinValue + lookAheadHeight -> MotionDriver.State.MaxValue + else -> MotionDriver.State.Transition + } + + input = constraints.maxHeight.toFloat() + } + + return layout(width = placeable.width, height = placeable.height) { + driverCoordinates = coordinates + placeable.place(IntOffset.Zero) + } + } +} diff --git a/mechanics/compose/src/com/android/mechanics/compose/modifier/VerticalFadeContentRevealModifier.kt b/mechanics/compose/src/com/android/mechanics/compose/modifier/VerticalFadeContentRevealModifier.kt index 6428d9d..0d28358 100644 --- a/mechanics/compose/src/com/android/mechanics/compose/modifier/VerticalFadeContentRevealModifier.kt +++ b/mechanics/compose/src/com/android/mechanics/compose/modifier/VerticalFadeContentRevealModifier.kt @@ -1,229 +1,170 @@ -///* -// * Copyright (C) 2025 The Android Open Source Project -// * -// * Licensed under the Apache License, Version 2.0 (the "License"); -// * you may not use this file except in compliance with the License. -// * You may obtain a copy of the License at -// * -// * http://www.apache.org/licenses/LICENSE-2.0 -// * -// * Unless required by applicable law or agreed to in writing, software -// * distributed under the License is distributed on an "AS IS" BASIS, -// * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// * See the License for the specific language governing permissions and -// * limitations under the License. -// */ -// -//package com.android.mechanics.compose.modifier -// -//import androidx.compose.ui.Modifier -//import androidx.compose.ui.geometry.Rect -//import androidx.compose.ui.graphics.CompositingStrategy -//import androidx.compose.ui.layout.ApproachLayoutModifierNode -//import androidx.compose.ui.layout.ApproachMeasureScope -//import androidx.compose.ui.layout.LayoutCoordinates -//import androidx.compose.ui.layout.Measurable -//import androidx.compose.ui.layout.MeasureResult -//import androidx.compose.ui.layout.MeasureScope -//import androidx.compose.ui.layout.Placeable -//import androidx.compose.ui.layout.boundsInParent -//import androidx.compose.ui.node.ModifierNodeElement -//import androidx.compose.ui.platform.InspectorInfo -//import androidx.compose.ui.unit.Constraints -//import androidx.compose.ui.unit.IntOffset -//import androidx.compose.ui.unit.IntSize -//import androidx.compose.ui.util.fastCoerceAtLeast -//import com.android.compose.animation.scene.ContentScope -//import com.android.compose.animation.scene.ElementKey -//import com.android.compose.animation.scene.mechanics.gestureContextOrDefault -//import com.android.mechanics.MotionValue -//import com.android.mechanics.debug.findMotionValueDebugger -//import com.android.mechanics.effects.FixedValue -//import com.android.mechanics.spec.Mapping -//import com.android.mechanics.spec.builder.MotionBuilderContext -//import com.android.mechanics.spec.builder.effectsMotionSpec -//import kotlinx.coroutines.Job -//import kotlinx.coroutines.launch -// -///** -// * This component remains hidden until it reach its target height. -// * -// * TODO: Once b/413283893 is done, [motionBuilderContext] can be read internally via -// * CompositionLocalConsumerModifierNode, instead of passing it. -// */ -//fun Modifier.verticalFadeContentReveal( -// contentScope: ContentScope, -// motionBuilderContext: MotionBuilderContext, -// container: ElementKey, -// deltaY: Float = 0f, -// label: String? = null, -// debug: Boolean = false, -//): Modifier = -// this then -// FadeContentRevealElement( -// contentScope = contentScope, -// motionBuilderContext = motionBuilderContext, -// container = container, -// deltaY = deltaY, -// label = label, -// debug = debug, -// ) -// -//private data class FadeContentRevealElement( -// val contentScope: ContentScope, -// val motionBuilderContext: MotionBuilderContext, -// val container: ElementKey, -// val deltaY: Float, -// val label: String?, -// val debug: Boolean, -//) : ModifierNodeElement() { -// override fun create(): FadeContentRevealNode = -// FadeContentRevealNode( -// contentScope = contentScope, -// motionBuilderContext = motionBuilderContext, -// container = container, -// deltaY = deltaY, -// label = label, -// debug = debug, -// ) -// -// override fun update(node: FadeContentRevealNode) { -// node.update( -// contentScope = contentScope, -// motionBuilderContext = motionBuilderContext, -// container = container, -// deltaY = deltaY, -// ) -// } -// -// override fun InspectorInfo.inspectableProperties() { -// name = "fadeContentReveal" -// properties["container"] = container -// properties["deltaY"] = deltaY -// properties["label"] = label -// properties["debug"] = debug -// } -//} -// -//internal class FadeContentRevealNode( -// private var contentScope: ContentScope, -// private var motionBuilderContext: MotionBuilderContext, -// private var container: ElementKey, -// private var deltaY: Float, -// label: String?, -// private val debug: Boolean, -//) : Modifier.Node(), ApproachLayoutModifierNode { -// -// private val motionValue = -// MotionValue( -// currentInput = { -// with(contentScope) { -// val containerHeight = -// container.lastSize(contentKey)?.height ?: return@MotionValue 0f -// val containerCoordinates = -// container.targetCoordinates(contentKey) ?: return@MotionValue 0f -// val localCoordinates = lastCoordinates ?: return@MotionValue 0f -// -// val offsetY = containerCoordinates.localPositionOf(localCoordinates).y -// containerHeight - offsetY + deltaY -// } -// }, -// gestureContext = contentScope.gestureContextOrDefault(), -// label = "FadeContentReveal(${label.orEmpty()})", -// ) -// -// fun update( -// contentScope: ContentScope, -// motionBuilderContext: MotionBuilderContext, -// container: ElementKey, -// deltaY: Float, -// ) { -// this.contentScope = contentScope -// this.motionBuilderContext = motionBuilderContext -// this.container = container -// this.deltaY = deltaY -// updateMotionSpec() -// } -// -// private var motionValueJob: Job? = null -// -// override fun onAttach() { -// motionValueJob = -// coroutineScope.launch { -// val disposableHandle = -// if (debug) { -// findMotionValueDebugger()?.register(motionValue) -// } else { -// null -// } -// try { -// motionValue.keepRunning() -// } finally { -// disposableHandle?.dispose() -// } -// } -// } -// -// override fun onDetach() { -// motionValueJob?.cancel() -// } -// -// private fun isAnimating(): Boolean { -// return contentScope.layoutState.currentTransition != null || !motionValue.isStable -// } -// -// override fun isMeasurementApproachInProgress(lookaheadSize: IntSize) = isAnimating() -// -// override fun Placeable.PlacementScope.isPlacementApproachInProgress( -// lookaheadCoordinates: LayoutCoordinates -// ) = isAnimating() -// -// private var targetBounds = Rect.Zero -// -// private var lastCoordinates: LayoutCoordinates? = null -// -// private fun updateMotionSpec() { -// motionValue.spec = -// motionBuilderContext.effectsMotionSpec(Mapping.Zero) { -// after(targetBounds.bottom, FixedValue.One) -// } -// } -// -// override fun MeasureScope.measure( -// measurable: Measurable, -// constraints: Constraints, -// ): MeasureResult { -// val placeable = measurable.measure(constraints) -// return layout(placeable.width, placeable.height) { -// val coordinates = coordinates -// if (isLookingAhead && coordinates != null) { -// lastCoordinates = coordinates -// val bounds = coordinates.boundsInParent() -// if (targetBounds != bounds) { -// targetBounds = bounds -// updateMotionSpec() -// } -// } -// placeable.place(IntOffset.Zero) -// } -// } -// -// override fun ApproachMeasureScope.approachMeasure( -// measurable: Measurable, -// constraints: Constraints, -// ): MeasureResult { -// return measurable.measure(constraints).run { -// layout(width, height) { -// val revealAlpha = motionValue.output -// if (revealAlpha < 1) { -// placeWithLayer(IntOffset.Zero) { -// alpha = revealAlpha.fastCoerceAtLeast(0f) -// compositingStrategy = CompositingStrategy.ModulateAlpha -// } -// } else { -// place(IntOffset.Zero) -// } -// } -// } -// } -//} +/* + * Copyright (C) 2025 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.mechanics.compose.modifier + +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.CompositingStrategy +import androidx.compose.ui.layout.ApproachLayoutModifierNode +import androidx.compose.ui.layout.ApproachMeasureScope +import androidx.compose.ui.layout.Measurable +import androidx.compose.ui.layout.MeasureResult +import androidx.compose.ui.layout.MeasureScope +import androidx.compose.ui.node.CompositionLocalConsumerModifierNode +import androidx.compose.ui.node.DelegatingNode +import androidx.compose.ui.node.ModifierNodeElement +import androidx.compose.ui.platform.InspectorInfo +import androidx.compose.ui.unit.Constraints +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.util.fastCoerceAtLeast +import com.android.mechanics.ManagedMotionValue +import com.android.mechanics.debug.DebugMotionValueNode +import com.android.mechanics.effects.FixedValue +import com.android.mechanics.spec.Mapping +import com.android.mechanics.spec.MotionSpec +import com.android.mechanics.spec.builder.ComposeMotionBuilderContext +import com.android.mechanics.spec.builder.effectsMotionSpec +import com.android.mechanics.spec.builder.fixedEffectsValueSpec +import com.android.mechanics.spec.builder.motionBuilderContext + +/** This component remains hidden until it reach its target height. */ +fun Modifier.verticalFadeContentReveal(deltaY: Float = 0f, label: String? = null): Modifier = + this then FadeContentRevealElement(deltaY = deltaY, label = label) + +private data class FadeContentRevealElement(val deltaY: Float, val label: String?) : + ModifierNodeElement() { + override fun create(): FadeContentRevealNode = + FadeContentRevealNode(deltaY = deltaY, label = label) + + override fun update(node: FadeContentRevealNode) { + check(node.deltaY == deltaY) { "Cannot update deltaY from ${node.deltaY} to $deltaY" } + } + + override fun InspectorInfo.inspectableProperties() { + name = "fadeContentReveal" + properties["deltaY"] = deltaY + properties["label"] = label + } +} + +private class FadeContentRevealNode(val deltaY: Float, private val label: String?) : + DelegatingNode(), ApproachLayoutModifierNode, CompositionLocalConsumerModifierNode { + // These properties are calculated during the lookahead pass (`lookAheadMeasure`) to + // orchestrate the reveal animation. They are guaranteed to be updated before `approachMeasure` + // is called. + private var lookAheadHeight by mutableFloatStateOf(Float.NaN) + private var layoutOffsetY by mutableFloatStateOf(Float.NaN) + // Created lazily upon first lookahead and disposed in `onDetach`. + private var revealAlpha: ManagedMotionValue? = null + + /** + * The [MotionDriver] that controls the parent's motion, used to determine the reveal + * animation's progress. + * + * It is initialized in `onAttach` and is safe to use in all subsequent measure passes. + */ + private lateinit var motionDriver: MotionDriver + + private lateinit var motionBuilderContext: ComposeMotionBuilderContext + + override fun onAttach() { + motionDriver = findMotionDriver() + motionBuilderContext = motionBuilderContext() + } + + override fun onDetach() { + revealAlpha?.dispose() + } + + private fun spec(): MotionSpec { + return when (motionDriver.verticalState) { + MotionDriver.State.MinValue -> { + motionBuilderContext.fixedEffectsValueSpec(0f) + } + MotionDriver.State.Transition -> { + motionBuilderContext.effectsMotionSpec(Mapping.Zero) { + after(layoutOffsetY + lookAheadHeight, FixedValue.One) + } + } + MotionDriver.State.MaxValue -> { + motionBuilderContext.fixedEffectsValueSpec(1f) + } + } + } + + override fun MeasureScope.measure( + measurable: Measurable, + constraints: Constraints, + ): MeasureResult { + return if (isLookingAhead) { + lookAheadMeasure(measurable, constraints) + } else { + measurable.measure(constraints).run { layout(width, height) { place(IntOffset.Zero) } } + } + } + + private fun MeasureScope.lookAheadMeasure( + measurable: Measurable, + constraints: Constraints, + ): MeasureResult { + val placeable = measurable.measure(constraints) + val targetHeight = placeable.height.toFloat() + lookAheadHeight = targetHeight + return layout(placeable.width, placeable.height) { + layoutOffsetY = with(motionDriver) { driverOffset() }.y + deltaY + + if (revealAlpha == null) { + val maxHeightDriven = + motionDriver.maxHeightDriven( + spec = derivedStateOf(::spec)::value, + label = "FadeContentReveal(${label.orEmpty()})", + ) + revealAlpha = maxHeightDriven + delegate(DebugMotionValueNode(maxHeightDriven)) + } + + placeable.place(IntOffset.Zero) + } + } + + override fun isMeasurementApproachInProgress(lookaheadSize: IntSize): Boolean { + val revealAlpha = revealAlpha + return revealAlpha != null && + (motionDriver.verticalState == MotionDriver.State.Transition || !revealAlpha.isStable) + } + + override fun ApproachMeasureScope.approachMeasure( + measurable: Measurable, + constraints: Constraints, + ): MeasureResult { + return measurable.measure(constraints).run { + layout(width, height) { + placeWithLayer(IntOffset.Zero) { + val revealAlpha = checkNotNull(revealAlpha).output.fastCoerceAtLeast(0f) + if (revealAlpha < 1f) { + alpha = revealAlpha + compositingStrategy = CompositingStrategy.ModulateAlpha + } + } + } + } + } +} diff --git a/mechanics/compose/src/com/android/mechanics/compose/modifier/VerticalTactileSurfaceRevealModifier.kt b/mechanics/compose/src/com/android/mechanics/compose/modifier/VerticalTactileSurfaceRevealModifier.kt index 9bfd3db..2d51a5e 100644 --- a/mechanics/compose/src/com/android/mechanics/compose/modifier/VerticalTactileSurfaceRevealModifier.kt +++ b/mechanics/compose/src/com/android/mechanics/compose/modifier/VerticalTactileSurfaceRevealModifier.kt @@ -1,250 +1,242 @@ -///* -// * Copyright (C) 2025 The Android Open Source Project -// * -// * Licensed under the Apache License, Version 2.0 (the "License"); -// * you may not use this file except in compliance with the License. -// * You may obtain a copy of the License at -// * -// * http://www.apache.org/licenses/LICENSE-2.0 -// * -// * Unless required by applicable law or agreed to in writing, software -// * distributed under the License is distributed on an "AS IS" BASIS, -// * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// * See the License for the specific language governing permissions and -// * limitations under the License. -// */ -// -//package com.android.mechanics.compose.modifier -// -//import androidx.compose.ui.Modifier -//import androidx.compose.ui.geometry.Rect -//import androidx.compose.ui.graphics.CompositingStrategy -//import androidx.compose.ui.layout.ApproachLayoutModifierNode -//import androidx.compose.ui.layout.ApproachMeasureScope -//import androidx.compose.ui.layout.LayoutCoordinates -//import androidx.compose.ui.layout.Measurable -//import androidx.compose.ui.layout.MeasureResult -//import androidx.compose.ui.layout.MeasureScope -//import androidx.compose.ui.layout.Placeable -//import androidx.compose.ui.layout.boundsInParent -//import androidx.compose.ui.node.ModifierNodeElement -//import androidx.compose.ui.platform.InspectorInfo -//import androidx.compose.ui.unit.Constraints -//import androidx.compose.ui.unit.IntOffset -//import androidx.compose.ui.unit.IntSize -//import androidx.compose.ui.util.fastCoerceAtLeast -//import androidx.compose.ui.util.fastCoerceIn -//import com.android.compose.animation.scene.ContentScope -//import com.android.compose.animation.scene.ElementKey -//import com.android.compose.animation.scene.mechanics.gestureContextOrDefault -//import com.android.mechanics.MotionValue -//import com.android.mechanics.debug.findMotionValueDebugger -//import com.android.mechanics.effects.RevealOnThreshold -//import com.android.mechanics.spec.Mapping -//import com.android.mechanics.spec.builder.MotionBuilderContext -//import com.android.mechanics.spec.builder.spatialMotionSpec -//import kotlin.math.roundToInt -//import kotlinx.coroutines.Job -//import kotlinx.coroutines.launch -// -///** -// * This component remains hidden until its target height meets a minimum threshold. At that point, -// * it reveals itself by animating its height from 0 to the current target height. -// * -// * TODO: Once b/413283893 is done, [motionBuilderContext] can be read internally via -// * CompositionLocalConsumerModifierNode, instead of passing it. -// */ -//fun Modifier.verticalTactileSurfaceReveal( -// contentScope: ContentScope, -// motionBuilderContext: MotionBuilderContext, -// container: ElementKey, -// deltaY: Float = 0f, -// revealOnThreshold: RevealOnThreshold = DefaultRevealOnThreshold, -// label: String? = null, -// debug: Boolean = false, -//): Modifier = -// this then -// VerticalTactileSurfaceRevealElement( -// contentScope = contentScope, -// motionBuilderContext = motionBuilderContext, -// container = container, -// deltaY = deltaY, -// revealOnThreshold = revealOnThreshold, -// label = label, -// debug = debug, -// ) -// -//private val DefaultRevealOnThreshold = RevealOnThreshold() -// -//private data class VerticalTactileSurfaceRevealElement( -// val contentScope: ContentScope, -// val motionBuilderContext: MotionBuilderContext, -// val container: ElementKey, -// val deltaY: Float, -// val revealOnThreshold: RevealOnThreshold, -// val label: String?, -// val debug: Boolean, -//) : ModifierNodeElement() { -// override fun create(): VerticalTactileSurfaceRevealNode = -// VerticalTactileSurfaceRevealNode( -// contentScope = contentScope, -// motionBuilderContext = motionBuilderContext, -// container = container, -// deltaY = deltaY, -// revealOnThreshold = revealOnThreshold, -// label = label, -// debug = debug, -// ) -// -// override fun update(node: VerticalTactileSurfaceRevealNode) { -// node.update( -// contentScope = contentScope, -// motionBuilderContext = motionBuilderContext, -// container = container, -// deltaY = deltaY, -// revealOnThreshold = revealOnThreshold, -// ) -// } -// -// override fun InspectorInfo.inspectableProperties() { -// name = "tactileSurfaceReveal" -// properties["container"] = container -// properties["deltaY"] = deltaY -// properties["revealOnThreshold"] = revealOnThreshold -// properties["label"] = label -// properties["debug"] = debug -// } -//} -// -//private class VerticalTactileSurfaceRevealNode( -// private var contentScope: ContentScope, -// private var motionBuilderContext: MotionBuilderContext, -// private var container: ElementKey, -// private var deltaY: Float, -// private var revealOnThreshold: RevealOnThreshold, -// label: String?, -// private val debug: Boolean, -//) : Modifier.Node(), ApproachLayoutModifierNode { -// -// private val motionValue = -// MotionValue( -// currentInput = { -// with(contentScope) { -// val containerHeight = -// container.lastSize(contentKey)?.height ?: return@MotionValue 0f -// val containerCoordinates = -// container.targetCoordinates(contentKey) ?: return@MotionValue 0f -// val localCoordinates = lastCoordinates ?: return@MotionValue 0f -// -// val offsetY = containerCoordinates.localPositionOf(localCoordinates).y -// containerHeight - offsetY + deltaY -// } -// }, -// gestureContext = contentScope.gestureContextOrDefault(), -// label = "TactileSurfaceReveal(${label.orEmpty()})", -// stableThreshold = MotionBuilderContext.StableThresholdSpatial, -// ) -// -// fun update( -// contentScope: ContentScope, -// motionBuilderContext: MotionBuilderContext, -// container: ElementKey, -// deltaY: Float, -// revealOnThreshold: RevealOnThreshold, -// ) { -// this.contentScope = contentScope -// this.motionBuilderContext = motionBuilderContext -// this.container = container -// this.deltaY = deltaY -// this.revealOnThreshold = revealOnThreshold -// updateMotionSpec() -// } -// -// private var motionValueJob: Job? = null -// -// override fun onAttach() { -// motionValueJob = -// coroutineScope.launch { -// val disposableHandle = -// if (debug) { -// findMotionValueDebugger()?.register(motionValue) -// } else { -// null -// } -// try { -// motionValue.keepRunning() -// } finally { -// disposableHandle?.dispose() -// } -// } -// } -// -// override fun onDetach() { -// motionValueJob?.cancel() -// } -// -// private fun isAnimating(): Boolean { -// return contentScope.layoutState.currentTransition != null || !motionValue.isStable -// } -// -// override fun isMeasurementApproachInProgress(lookaheadSize: IntSize) = isAnimating() -// -// override fun Placeable.PlacementScope.isPlacementApproachInProgress( -// lookaheadCoordinates: LayoutCoordinates -// ) = isAnimating() -// -// private var targetBounds = Rect.Zero -// -// private var lastCoordinates: LayoutCoordinates? = null -// -// private fun updateMotionSpec() { -// motionValue.spec = -// motionBuilderContext.spatialMotionSpec(Mapping.Zero) { -// between( -// start = targetBounds.top, -// end = targetBounds.bottom, -// effect = revealOnThreshold, -// ) -// } -// } -// -// override fun MeasureScope.measure( -// measurable: Measurable, -// constraints: Constraints, -// ): MeasureResult { -// val placeable = measurable.measure(constraints) -// return layout(placeable.width, placeable.height) { -// val coordinates = coordinates -// if (isLookingAhead && coordinates != null) { -// lastCoordinates = coordinates -// val bounds = coordinates.boundsInParent() -// if (targetBounds != bounds) { -// targetBounds = bounds -// updateMotionSpec() -// } -// } -// placeable.place(IntOffset.Zero) -// } -// } -// -// override fun ApproachMeasureScope.approachMeasure( -// measurable: Measurable, -// constraints: Constraints, -// ): MeasureResult { -// val height = motionValue.output.roundToInt().fastCoerceAtLeast(0) -// val animatedConstraints = Constraints.fixed(width = constraints.maxWidth, height = height) -// return measurable.measure(animatedConstraints).run { -// layout(width, height) { -// val revealAlpha = (height / revealOnThreshold.minSize.toPx()).fastCoerceIn(0f, 1f) -// if (revealAlpha < 1) { -// placeWithLayer(IntOffset.Zero) { -// alpha = revealAlpha -// compositingStrategy = CompositingStrategy.ModulateAlpha -// } -// } else { -// place(IntOffset.Zero) -// } -// } -// } -// } -//} +/* + * Copyright (C) 2025 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.mechanics.compose.modifier + +import androidx.compose.foundation.shape.GenericShape +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.CornerRadius +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.geometry.RoundRect +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.CompositingStrategy +import androidx.compose.ui.graphics.GraphicsLayerScope +import androidx.compose.ui.layout.ApproachLayoutModifierNode +import androidx.compose.ui.layout.ApproachMeasureScope +import androidx.compose.ui.layout.Measurable +import androidx.compose.ui.layout.MeasureResult +import androidx.compose.ui.layout.MeasureScope +import androidx.compose.ui.node.CompositionLocalConsumerModifierNode +import androidx.compose.ui.node.DelegatingNode +import androidx.compose.ui.node.ModifierNodeElement +import androidx.compose.ui.platform.InspectorInfo +import androidx.compose.ui.unit.Constraints +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.constrainHeight +import androidx.compose.ui.util.fastCoerceAtLeast +import androidx.compose.ui.util.fastCoerceAtMost +import com.android.mechanics.ManagedMotionValue +import com.android.mechanics.debug.DebugMotionValueNode +import com.android.mechanics.effects.RevealOnThreshold +import com.android.mechanics.spec.Mapping +import com.android.mechanics.spec.MotionSpec +import com.android.mechanics.spec.builder.ComposeMotionBuilderContext +import com.android.mechanics.spec.builder.fixedSpatialValueSpec +import com.android.mechanics.spec.builder.motionBuilderContext +import com.android.mechanics.spec.builder.spatialMotionSpec +import kotlin.math.roundToInt + +/** + * This component remains hidden until its target height meets a minimum threshold. At that point, + * it reveals itself by animating its height from 0 to the current target height. + */ +fun Modifier.verticalTactileSurfaceReveal( + deltaY: Float = 0f, + revealOnThreshold: RevealOnThreshold = DefaultRevealOnThreshold, + label: String? = null, +): Modifier = + this then + VerticalTactileSurfaceRevealElement( + deltaY = deltaY, + revealOnThreshold = revealOnThreshold, + label = label, + ) + +private val DefaultRevealOnThreshold = RevealOnThreshold() + +private data class VerticalTactileSurfaceRevealElement( + val deltaY: Float, + val revealOnThreshold: RevealOnThreshold, + val label: String?, +) : ModifierNodeElement() { + override fun create(): VerticalTactileSurfaceRevealNode = + VerticalTactileSurfaceRevealNode( + deltaY = deltaY, + revealOnThreshold = revealOnThreshold, + label = label, + ) + + override fun update(node: VerticalTactileSurfaceRevealNode) { + check(node.deltaY == deltaY) { "Cannot update deltaY from ${node.deltaY} to $deltaY" } + node.update(revealOnThreshold = revealOnThreshold) + } + + override fun InspectorInfo.inspectableProperties() { + name = "tactileSurfaceReveal" + properties["deltaY"] = deltaY + properties["revealOnThreshold"] = revealOnThreshold + properties["label"] = label + } +} + +private class VerticalTactileSurfaceRevealNode( + val deltaY: Float, + private var revealOnThreshold: RevealOnThreshold, + private val label: String?, +) : DelegatingNode(), ApproachLayoutModifierNode, CompositionLocalConsumerModifierNode { + // These properties are calculated during the lookahead pass (`lookAheadMeasure`) to + // orchestrate the reveal animation. They are guaranteed to be updated before `approachMeasure` + // is called. + private var lookAheadHeight by mutableFloatStateOf(Float.NaN) + private var layoutOffsetY by mutableFloatStateOf(Float.NaN) + // Created lazily upon first lookahead and disposed in `onDetach`. + private var revealHeight: ManagedMotionValue? = null + + /** + * The [MotionDriver] that controls the parent's motion, used to determine the reveal + * animation's progress. + * + * It is initialized in `onAttach` and is safe to use in all subsequent measure passes. + */ + private lateinit var motionDriver: MotionDriver + + private lateinit var motionBuilderContext: ComposeMotionBuilderContext + + override fun onAttach() { + motionDriver = findMotionDriver() + motionBuilderContext = motionBuilderContext() + } + + fun update(revealOnThreshold: RevealOnThreshold) { + this.revealOnThreshold = revealOnThreshold + } + + override fun onDetach() { + revealHeight?.dispose() + } + + private fun spec(): MotionSpec { + return when (motionDriver.verticalState) { + MotionDriver.State.MinValue -> { + motionBuilderContext.fixedSpatialValueSpec(0f) + } + MotionDriver.State.Transition -> { + // Cache the state read to avoid the performance cost of accessing it twice. + val start = layoutOffsetY + motionBuilderContext.spatialMotionSpec(Mapping.Zero) { + between( + start = start, + end = start + lookAheadHeight, + effect = revealOnThreshold, + ) + } + } + MotionDriver.State.MaxValue -> { + motionBuilderContext.fixedSpatialValueSpec(lookAheadHeight) + } + } + } + + override fun MeasureScope.measure( + measurable: Measurable, + constraints: Constraints, + ): MeasureResult { + return if (isLookingAhead) { + lookAheadMeasure(measurable, constraints) + } else { + measurable.measure(constraints).run { layout(width, height) { place(IntOffset.Zero) } } + } + } + + private fun MeasureScope.lookAheadMeasure( + measurable: Measurable, + constraints: Constraints, + ): MeasureResult { + val placeable = measurable.measure(constraints) + val targetHeight = placeable.height.toFloat() + lookAheadHeight = targetHeight + return layout(placeable.width, placeable.height) { + layoutOffsetY = with(motionDriver) { driverOffset() }.y + deltaY + + if (revealHeight == null) { + val maxHeightDriven = + motionDriver.maxHeightDriven( + spec = derivedStateOf(::spec)::value, + label = "TactileSurfaceReveal(${label.orEmpty()})", + ) + revealHeight = maxHeightDriven + delegate(DebugMotionValueNode(maxHeightDriven)) + } + + placeable.place(IntOffset.Zero) + } + } + + override fun isMeasurementApproachInProgress(lookaheadSize: IntSize): Boolean { + val revealHeight = revealHeight + return revealHeight != null && + (motionDriver.verticalState == MotionDriver.State.Transition || !revealHeight.isStable) + } + + override fun ApproachMeasureScope.approachMeasure( + measurable: Measurable, + constraints: Constraints, + ): MeasureResult { + return measurable.measure(constraints).run { + layout(width, height) { + placeWithLayer(IntOffset.Zero) { + val revealHeight = + constraints + .constrainHeight(checkNotNull(revealHeight).output.roundToInt()) + .toFloat() + + if (revealHeight != lookAheadHeight) { + approachGraphicsLayer(revealHeight) + } + } + } + } + } + + private fun GraphicsLayerScope.approachGraphicsLayer(revealHeight: Float) { + translationY = (revealHeight - lookAheadHeight) / 2f + clip = true + shape = GenericShape { placeableSize, _ -> + val rect = Rect(Offset(0f, -translationY), Size(placeableSize.width, revealHeight)) + val cornerMaxSize = revealOnThreshold.cornerMaxSize.toPx() + if (cornerMaxSize != 0f) { + val radius = (revealHeight / 2f).fastCoerceAtMost(cornerMaxSize) + addRoundRect(RoundRect(rect, CornerRadius(radius))) + } else { + addRect(rect) + } + } + val fullyVisibleMinHeight = revealOnThreshold.minSize.toPx() + if (fullyVisibleMinHeight != 0f) { + val revealAlpha = (revealHeight / fullyVisibleMinHeight).fastCoerceAtLeast(0f) + if (revealAlpha < 1f) { + alpha = revealAlpha + compositingStrategy = CompositingStrategy.ModulateAlpha + } + } + } +} diff --git a/mechanics/compose/tests/AndroidManifest.xml b/mechanics/compose/tests/AndroidManifest.xml deleted file mode 100644 index 182f244..0000000 --- a/mechanics/compose/tests/AndroidManifest.xml +++ /dev/null @@ -1,28 +0,0 @@ - - - - - - - - - - diff --git a/mechanics/src/com/android/mechanics/ComposableMotionValue.kt b/mechanics/src/com/android/mechanics/ComposableMotionValue.kt new file mode 100644 index 0000000..1df9700 --- /dev/null +++ b/mechanics/src/com/android/mechanics/ComposableMotionValue.kt @@ -0,0 +1,113 @@ +/* + * Copyright (C) 2025 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.mechanics + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.State +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import com.android.mechanics.haptics.HapticPlayer +import com.android.mechanics.spec.MotionSpec +import com.android.mechanics.spec.builder.MotionBuilderContext +import com.android.mechanics.spec.builder.rememberMotionBuilderContext + +@Composable +fun rememberMotionValue( + input: () -> Float, + gestureContext: GestureContext, + spec: () -> MotionSpec, + label: String? = null, + stableThreshold: Float = 0.01f, + hapticPlayer: HapticPlayer = HapticPlayer.NoPlayer, +): MotionValue { + val motionValue = + remember(input, hapticPlayer) { + MotionValue( + input = input, + gestureContext = gestureContext, + spec = spec, + label = label, + stableThreshold = stableThreshold, + hapticPlayer = hapticPlayer, + ) + } + + LaunchedEffect(motionValue) { motionValue.keepRunning() } + return motionValue +} + +@Composable +fun rememberMotionValue( + input: () -> Float, + gestureContext: GestureContext, + spec: State, + label: String? = null, + stableThreshold: Float = 0.01f, + hapticPlayer: HapticPlayer = HapticPlayer.NoPlayer, +): MotionValue { + return rememberMotionValue( + input = input, + gestureContext = gestureContext, + spec = spec::value, + label = label, + stableThreshold = stableThreshold, + hapticPlayer = hapticPlayer, + ) +} + +@Composable +fun rememberDerivedMotionValue( + input: MotionValue, + specProvider: () -> MotionSpec, + stableThreshold: Float = 0.01f, + label: String? = null, +): MotionValue { + val motionValue = + remember(input, specProvider) { + MotionValue.createDerived( + source = input, + spec = specProvider, + label = label, + stableThreshold = stableThreshold, + ) + } + + LaunchedEffect(motionValue) { motionValue.keepRunning() } + return motionValue +} + +/** + * Efficiently creates and remembers a [MotionSpec], providing it via a stable lambda. + * + * This function memoizes the [MotionSpec] to avoid expensive recalculations. The spec is + * re-computed only when a state dependency within the `spec` lambda changes, not on every + * recomposition or each time the output is read. + * + * @param calculation A lambda with a [MotionBuilderContext] receiver that defines the [MotionSpec]. + * @return A stable provider `() -> MotionSpec`. Invoking this function is cheap as it returns the + * latest cached value. + */ +@Composable +fun rememberMotionSpecAsState( + calculation: MotionBuilderContext.() -> MotionSpec +): State { + val updatedSpec = rememberUpdatedState(calculation) + val context = rememberMotionBuilderContext() + return remember(context) { derivedStateOf { updatedSpec.value(context) } } +} diff --git a/mechanics/src/com/android/mechanics/MotionValue.kt b/mechanics/src/com/android/mechanics/MotionValue.kt index 9d01c10..95c5790 100644 --- a/mechanics/src/com/android/mechanics/MotionValue.kt +++ b/mechanics/src/com/android/mechanics/MotionValue.kt @@ -17,6 +17,8 @@ package com.android.mechanics import androidx.compose.runtime.FloatState +import androidx.compose.runtime.annotation.FrequentlyChangingValue +import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableLongStateOf @@ -27,6 +29,9 @@ import androidx.compose.runtime.snapshotFlow import androidx.compose.runtime.withFrameNanos import com.android.mechanics.debug.DebugInspector import com.android.mechanics.debug.FrameData +import com.android.mechanics.haptics.BreakpointHaptics +import com.android.mechanics.haptics.HapticPlayer +import com.android.mechanics.haptics.SegmentHaptics import com.android.mechanics.impl.Computations import com.android.mechanics.impl.DiscontinuityAnimation import com.android.mechanics.impl.GuaranteeState @@ -81,9 +86,17 @@ import kotlinx.coroutines.withContext * * ## Updating the MotionSpec * - * The [spec] property can be changed at any time. If the new spec produces a different output for - * the current input, the difference will be animated using the spring parameters defined in - * [MotionSpec.resetSpring]. + * You can provide a new [MotionSpec] at any time. If the new spec produces a different output value + * for the current input, the change will be animated smoothly using the spring parameters defined + * in `[MotionSpec.resetSpring]`. + * + * **Important**: The function that provides the spec may be called frequently (for instance, on + * every frame). To avoid performance issues from re-computing the spec, **you are responsible for + * caching the result**. + * + * For use **in composition**, you can use the [rememberMotionSpecAsState] utility. This composable + * automatically handles caching, ensuring the spec is only re-created when its state dependencies + * change. * * ## Gesture Context * @@ -93,9 +106,9 @@ import kotlinx.coroutines.withContext * * ## Usage * - * The [MotionValue] does animate the [output] implicitly, whenever a change in [currentInput], - * [spec], or [gestureContext] requires it. The animated value is computed whenever the [output] - * property is read, or the latest once the animation frame is complete. + * The [MotionValue] does animate the [output] implicitly, whenever a change in [input], [spec], or + * [gestureContext] requires it. The animated value is computed whenever the [output] property is + * read, or the latest once the animation frame is complete. * 1. Create an instance, providing the input value, gesture context, and an initial spec. * 2. Call [keepRunning] in a coroutine scope, and keep the coroutine running while the * `MotionValue` is in use. @@ -104,27 +117,41 @@ import kotlinx.coroutines.withContext * Internally, the [keepRunning] coroutine is automatically suspended if there is nothing to * animate. * - * @param currentInput Provides the current input value. - * @param gestureContext The [GestureContext] augmenting the [currentInput]. + * @param input Provides the current input value. + * @param gestureContext The [GestureContext] augmenting the current input. + * @param spec Provides the current [MotionSpec]. **Important**: For performance, this should be a + * stable provider. In composition, it's strongly recommended to use an helper like + * [rememberMotionSpecAsState] to create the spec. * @param label An optional label to aid in debugging. * @param stableThreshold A threshold value (in output units) that determines when the * [MotionValue]'s internal spring animation is considered stable. + * @param hapticPlayer When specifying segment and breakpoint haptics, this player will be used to + * deliver haptic feedback. */ class MotionValue( - currentInput: () -> Float, + input: () -> Float, gestureContext: GestureContext, - initialSpec: MotionSpec = MotionSpec.Empty, + spec: () -> MotionSpec, label: String? = null, stableThreshold: Float = StableThresholdEffect, -) : FloatState { + hapticPlayer: HapticPlayer = HapticPlayer.NoPlayer, +) : MotionValueState { private val impl = - ObservableComputations(currentInput, gestureContext, initialSpec, stableThreshold, label) + ObservableComputations( + inputProvider = input, + gestureContext = gestureContext, + specProvider = spec, + stableThreshold = stableThreshold, + label = label, + hapticPlayer = hapticPlayer, + ) /** The [MotionSpec] describing the mapping of this [MotionValue]'s input to the output. */ - var spec: MotionSpec by impl::spec + // TODO(b/441041846): This should not change frequently + @get:FrequentlyChangingValue val spec: MotionSpec by impl::spec /** Animated [output] value. */ - val output: Float by impl::output + @get:FrequentlyChangingValue override val output: Float by impl::computedOutput /** * [output] value, but without animations. @@ -133,25 +160,42 @@ class MotionValue( * * While [isStable], [outputTarget] and [output] are the same value. */ - val outputTarget: Float by impl::outputTarget + // TODO(b/441041846): This should not change frequently + @get:FrequentlyChangingValue override val outputTarget: Float by impl::computedOutputTarget /** The [output] exposed as [FloatState]. */ - override val floatValue: Float by impl::output + @get:FrequentlyChangingValue override val floatValue: Float by impl::computedOutput /** Whether an animation is currently running. */ - val isStable: Boolean by impl::isStable + // TODO(b/441041846): This should not change frequently + @get:FrequentlyChangingValue override val isStable: Boolean by impl::computedIsStable + + /** + * Whether the output can change its value. + * + * This is an optimization hint. It returns `true` if the animation spring is at rest AND the + * current input maps to a fixed value that is the same as the previous one. In this state, the + * output is guaranteed not to change unless the [spec] or the input (enough to change segments) + * changes. This can be used to avoid unnecessary work like recomposition or re-measurement. + */ + // TODO(b/441041846): This should not change frequently + @get:FrequentlyChangingValue val isOutputFixed: Boolean by impl::computedIsOutputFixed /** * The current value for the [SemanticKey]. * * `null` if not defined in the spec. */ - operator fun get(key: SemanticKey): T? { - return impl.semanticState(key) + // TODO(b/441041846): This should not change frequently + @FrequentlyChangingValue + override operator fun get(key: SemanticKey): T? { + return impl.computedSemanticState(key) } /** The current segment used to compute the output. */ - val segmentKey: SegmentKey + // TODO(b/441041846): This should not change frequently + @get:FrequentlyChangingValue + override val segmentKey: SegmentKey get() = impl.currentComputedValues.segment.key /** @@ -186,20 +230,20 @@ class MotionValue( impl.keepRunning { continueRunning.invoke(this@MotionValue) } } - val label: String? by impl::label + override val label: String? by impl::label companion object { /** Creates a [MotionValue] whose [currentInput] is the animated [output] of [source]. */ fun createDerived( source: MotionValue, - initialSpec: MotionSpec = MotionSpec.Empty, + spec: () -> MotionSpec, label: String? = null, stableThreshold: Float = 0.01f, ): MotionValue { return MotionValue( - currentInput = source::output, + input = { source.output }, gestureContext = source.impl.gestureContext, - initialSpec = initialSpec, + spec = derivedStateOf(calculation = spec)::value, label = label, stableThreshold = stableThreshold, ) @@ -224,7 +268,7 @@ class MotionValue( * * The returned [DebugInspector] must be [DebugInspector.dispose]d when no longer needed. */ - fun debugInspector(): DebugInspector { + override fun debugInspector(): DebugInspector { if (debugInspectorRefCount.getAndIncrement() == 0) { impl.debugInspector = DebugInspector( @@ -236,6 +280,7 @@ class MotionValue( impl.lastSpringState, impl.lastSegment, impl.lastAnimation, + impl.computedIsOutputFixed, ), impl.isActive, impl.debugIsAnimating, @@ -248,18 +293,21 @@ class MotionValue( } private class ObservableComputations( - val input: () -> Float, + private val inputProvider: () -> Float, val gestureContext: GestureContext, - initialSpec: MotionSpec = MotionSpec.Empty, + private val specProvider: () -> MotionSpec, override val stableThreshold: Float, override val label: String?, + private val hapticPlayer: HapticPlayer, ) : Computations() { // ---- CurrentFrameInput --------------------------------------------------------------------- - override var spec by mutableStateOf(initialSpec) + override val spec + get() = specProvider.invoke() + override val currentInput: Float - get() = input.invoke() + get() = inputProvider.invoke() override val currentDirection: InputDirection get() = gestureContext.direction @@ -269,11 +317,13 @@ private class ObservableComputations( override var currentAnimationTimeNanos by mutableLongStateOf(-1L) + override var lastHapticsTimeNanos by mutableLongStateOf(-1L) + // ---- LastFrameState --------------------------------------------------------------------- override var lastSegment: SegmentData by mutableStateOf( - spec.segmentAtInput(currentInput, currentDirection), + this.spec.segmentAtInput(currentInput, currentDirection), referentialEqualityPolicy(), ) @@ -366,12 +416,14 @@ private class ObservableComputations( } var scheduleNextFrame = false + var breakpointHaptics: BreakpointHaptics? = null if (!isSameSegmentAndAtRest) { // Read currentComputedValues only once and update it, if necessary val currentValues = currentComputedValues if (capturedSegment != currentValues.segment) { capturedSegment = currentValues.segment + breakpointHaptics = currentValues.breakpointHaptics scheduleNextFrame = true } @@ -406,6 +458,13 @@ private class ObservableComputations( scheduleNextFrame = true } + // Perform haptics + if (breakpointHaptics != null) { + performBreakpointHapticFeedback(breakpointHaptics) + } else { + performSegmentHapticFeedback(capturedSegment.haptics) + } + capturedFrameTimeNanos = currentAnimationTimeNanos debugInspector?.run { @@ -418,6 +477,7 @@ private class ObservableComputations( capturedSpringState, capturedSegment, capturedAnimation, + computedIsOutputFixed, ) } @@ -463,4 +523,24 @@ private class ObservableComputations( } var debugInspector: DebugInspector? = null + + private fun performSegmentHapticFeedback(segmentHaptics: SegmentHaptics) { + val timeDelta = currentAnimationTimeNanos - lastHapticsTimeNanos + if (timeDelta < hapticPlayer.getPlaybackIntervalNanos()) return + + val spatialInputPx = computedOutput + val velocityPxPerSec = directMappedVelocity // we assume this is always in px/sec. + lastHapticsTimeNanos = currentAnimationTimeNanos + hapticPlayer.playSegmentHaptics(segmentHaptics, spatialInputPx, velocityPxPerSec) + } + + private fun performBreakpointHapticFeedback(breakpointHaptics: BreakpointHaptics) { + val timeDelta = currentAnimationTimeNanos - lastHapticsTimeNanos + if (timeDelta < hapticPlayer.getPlaybackIntervalNanos()) return + + val spatialInputPx = computedOutput + val velocityPxPerSec = directMappedVelocity // we assume this is always in px/sec. + lastHapticsTimeNanos = currentAnimationTimeNanos + hapticPlayer.playBreakpointHaptics(breakpointHaptics, spatialInputPx, velocityPxPerSec) + } } diff --git a/mechanics/src/com/android/mechanics/MotionValueCollection.kt b/mechanics/src/com/android/mechanics/MotionValueCollection.kt new file mode 100644 index 0000000..772bca3 --- /dev/null +++ b/mechanics/src/com/android/mechanics/MotionValueCollection.kt @@ -0,0 +1,456 @@ +/* + * Copyright (C) 2025 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.mechanics + +import androidx.annotation.VisibleForTesting +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableLongStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.mutableStateSetOf +import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow +import androidx.compose.runtime.withFrameNanos +import androidx.compose.ui.util.trace +import androidx.compose.ui.util.traceValue +import com.android.mechanics.MotionValue.Companion.StableThresholdSpatial +import com.android.mechanics.debug.DebugInspector +import com.android.mechanics.debug.FrameData +import com.android.mechanics.impl.Computations +import com.android.mechanics.impl.DiscontinuityAnimation +import com.android.mechanics.impl.GuaranteeState +import com.android.mechanics.spec.InputDirection +import com.android.mechanics.spec.MotionSpec +import com.android.mechanics.spec.SegmentData +import com.android.mechanics.spec.SegmentKey +import com.android.mechanics.spec.SemanticKey +import com.android.mechanics.spring.SpringState +import java.util.concurrent.atomic.AtomicInteger +import kotlin.time.Duration +import kotlin.time.measureTime +import kotlinx.coroutines.CoroutineName +import kotlinx.coroutines.DisposableHandle +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.withContext + +/** The type of MotionValue created by the [MotionValueCollection]. */ +sealed interface ManagedMotionValue : MotionValueState, DisposableHandle + +/** + * A collection of motion values that all share the same input and gesture context. + * + * All [ManagedMotionValue]s are run from the same [keepRunning], and share the same lifecycle. + * + * Input, gesture context and spec are updated all at once, at the beginning of the, during + * [withFrameNanos]. + */ +class MotionValueCollection( + internal val input: () -> Float, + internal val gestureContext: GestureContext, + internal val stableThreshold: Float = StableThresholdSpatial, + val label: String? = null, +) { + private val managedComputations = mutableStateSetOf() + + /** + * Creates a new [ManagedMotionValue], whose output is controlled by [spec]. + * + * The returned [ManagedMotionValue] must be disposed when not used anymore, while this + * [MotionValueCollection] is kept active. + */ + fun create(spec: () -> MotionSpec, label: String? = null): ManagedMotionValue { + return ManagedMotionComputation(this, spec, label).also { + if (isActive) { + it.onActivate() + } + managedComputations.add(it) + } + } + + /** + * Conditionally wraps the execution of a [block] in a performance trace. + * + * The primary advantage of this helper is lazy evaluation. The trace message from + * [onTraceStart] is not computed and no `try-finally` block is entered unless tracing is + * [enabled]. This helps to avoid performance penalties in production builds where tracing is + * often turned off. + * + * @param enabled A boolean flag to enable or disable tracing. + * @param onTraceStart A lambda that returns the trace section name. Only invoked if [enabled] + * is true. + * @param onTraceEnd A lambda that executes after the block has finished. Only invoked if + * [enabled] is true. + * @param block The code block to be executed and traced. + */ + private inline fun trace( + enabled: Boolean, + onTraceStart: () -> String, + onTraceEnd: (Duration) -> Unit = {}, + block: () -> Unit, + ) { + if (enabled) { + val duration = measureTime { trace(sectionName = onTraceStart(), block = block) } + + onTraceEnd(duration) + } else { + block() + } + } + + /** + * Keeps the all created [ManagedMotionValue]'s animated output running. + * + * Clients must call [keepRunning], and keep the coroutine running while any of the created + * [ManagedMotionValue] is in use. Cancel the coroutine if no values are being used anymore. + * + * Internally, this method does suspend, unless there are animations ongoing. + */ + suspend fun keepRunning(): Nothing { + withContext(CoroutineName("MotionValueCollection($label)")) { + check(!isActive) { "MotionValueCollection($label) is already running" } + isActive = true + + currentInput = input.invoke() + currentGestureDragOffset = gestureContext.dragOffset + currentDirection = gestureContext.direction + + managedComputations.forEach { it.onActivate() } + + try { + isAnimating = true + + // indicates whether withFrameNanos is called continuously (as opposed to being + // suspended for an undetermined amount of time in between withFrameNanos). + // This is essential after `withFrameNanos` returned: if true at this point, + // currentAnimationTimeNanos - lastFrameNanos is the duration of the last frame. + var isAnimatingUninterrupted = false + + while (true) { + var scheduleNextFrame = false + withFrameNanos { frameTimeNanos -> + frameCount++ + + trace( + enabled = isTraceEnabled, + onTraceStart = { + val prefix = "MotionValueCollection($label)" + val unstable = managedComputations.count { !it.isStable } + val all = managedComputations.size + traceValue("$prefix:unstable", unstable.toLong()) + traceValue("$prefix:all", all.toLong()) + + "$prefix withFrameNanos f:$frameCount ($unstable/$all)" + }, + onTraceEnd = { + val prefix = "MotionValueCollection($label)" + traceValue("$prefix:duration", it.inWholeMicroseconds) + }, + ) { + lastFrameTimeNanos = currentAnimationTimeNanos + lastInput = currentInput + lastDirection = currentDirection + lastGestureDragOffset = currentGestureDragOffset + + currentAnimationTimeNanos = frameTimeNanos + currentInput = input.invoke() + currentDirection = gestureContext.direction + currentGestureDragOffset = gestureContext.dragOffset + + if ( + lastInput != currentInput || + lastDirection != currentDirection || + lastGestureDragOffset != currentGestureDragOffset + ) { + scheduleNextFrame = true + } + managedComputations.forEach { + if (it.onFrameStart(isAnimatingUninterrupted)) { + scheduleNextFrame = true + } + } + } + } + + isAnimatingUninterrupted = scheduleNextFrame + if (scheduleNextFrame) { + continue + } + + isAnimating = false + managedComputations.forEach { it.debugInspector?.isAnimating = false } + val activeComputations = managedComputations.toSet() + + snapshotFlow { + val hasComputations = + activeComputations.isNotEmpty() || managedComputations.isNotEmpty() + + val wakeup = + hasComputations && + (activeComputations != managedComputations || + activeComputations.any { it.wantWakeup() } || + input.invoke() != currentInput || + gestureContext.direction != currentDirection || + gestureContext.dragOffset != currentGestureDragOffset) + wakeup + } + .first { it } + isAnimating = true + managedComputations.forEach { it.debugInspector?.isAnimating = true } + } + } finally { + isActive = false + managedComputations.forEach { it.onDeactivate() } + } + } + } + + // ---- Implementation - State shared with all ManagedMotionComputations ---------------------- + // Note that all this state is updated exactly once per frame, during [withFrameNanos]. + internal var currentAnimationTimeNanos = -1L + private set + + @VisibleForTesting + var currentInput: Float = input.invoke() + private set + + @VisibleForTesting + var currentDirection: InputDirection = gestureContext.direction + private set + + @VisibleForTesting + var currentGestureDragOffset: Float = gestureContext.dragOffset + private set + + internal var lastFrameTimeNanos = -1L + internal var lastInput = currentInput + internal var lastGestureDragOffset = currentGestureDragOffset + internal var lastDirection = currentDirection + + // ---- Testing related state ------------------------------------------------------------------ + + @VisibleForTesting + var isActive = false + private set + + @VisibleForTesting + var isAnimating = false + private set + + @VisibleForTesting + var frameCount = 0 + private set + + @VisibleForTesting + // Note - this is public so that its accessible by the mechanics:testing library + val managedMotionValues: Set + get() = managedComputations + + internal fun onDispose(toDispose: ManagedMotionComputation) { + managedComputations.remove(toDispose) + toDispose.onDeactivate() + } + + companion object { + var isTraceEnabled: Boolean = false + } +} + +internal class ManagedMotionComputation( + private val owner: MotionValueCollection, + private val specProvider: () -> MotionSpec, + override val label: String?, +) : Computations(), ManagedMotionValue { + + override val stableThreshold: Float + get() = owner.stableThreshold + + // ---- ManagedMotionValue -------------------------------------------------------------------- + + override var output: Float by mutableFloatStateOf(Float.NaN) + + /** + * [output] value, but without animations. + * + * This value always reports the target value, even before a animation is finished. + * + * While [isStable], [outputTarget] and [output] are the same value. + */ + override var outputTarget: Float by mutableFloatStateOf(Float.NaN) + + /** Whether an animation is currently running. */ + override var isStable: Boolean by mutableStateOf(false) + + override var spec: MotionSpec = specProvider.invoke() + private set + + override fun get(key: SemanticKey): T? { + val segment = capturedComputedValues.segment + return segment.spec.semanticState(key, segment.key) + } + + override val segmentKey: SegmentKey + get() = capturedComputedValues.segment.key + + override val floatValue: Float + get() = output + + override fun dispose() { + owner.onDispose(this) + } + + override fun debugInspector(): DebugInspector { + if (debugInspectorRefCount.getAndIncrement() == 0) { + debugInspector = + DebugInspector( + FrameData( + lastInput, + lastSegment.direction, + lastGestureDragOffset, + lastFrameTimeNanos, + lastSpringState, + lastSegment, + lastAnimation, + computedIsOutputFixed, + ), + owner.isActive, + owner.isAnimating, + ::onDisposeDebugInspector, + ) + } + + return checkNotNull(debugInspector) + } + + private var debugInspectorRefCount = AtomicInteger(0) + + private fun onDisposeDebugInspector() { + if (debugInspectorRefCount.decrementAndGet() == 0) { + debugInspector = null + } + } + + // ---- CurrentFrameInput --------------------------------------------------------------------- + + override val currentInput: Float + get() = owner.currentInput + + override val currentDirection: InputDirection + get() = owner.currentDirection + + override val currentGestureDragOffset: Float + get() = owner.currentGestureDragOffset + + override val currentAnimationTimeNanos + get() = owner.currentAnimationTimeNanos + + private var capturedComputedValues: ComputedValues = currentComputedValues + private var capturedSpringState: SpringState = currentSpringState + + // ---- LastFrameState --------------------------------------------------------------------- + + private var lastComputedValues: ComputedValues = capturedComputedValues + + override val lastSegment: SegmentData + get() = lastComputedValues.segment + + override val lastGuaranteeState: GuaranteeState + get() = lastComputedValues.guarantee + + override val lastAnimation: DiscontinuityAnimation + get() = lastComputedValues.animation + + override var lastSpringState: SpringState = SpringState.AtRest + + override var directMappedVelocity: Float = 0f + + override val lastFrameTimeNanos + get() = owner.lastFrameTimeNanos + + override val lastInput + get() = owner.lastInput + + override val lastGestureDragOffset + get() = owner.lastGestureDragOffset + + override var lastHapticsTimeNanos: Long by mutableLongStateOf(-1L) + + // ---- Computations --------------------------------------------------------------------------- + + var debugInspector: DebugInspector? = null + + fun onActivate() { + capturedComputedValues = currentComputedValues + capturedSpringState = currentSpringState + lastComputedValues = capturedComputedValues + lastSpringState = capturedSpringState + + onFrameStart(isAnimatingUninterrupted = false) + + debugInspector?.isAnimating = true + debugInspector?.isActive = true + } + + fun onDeactivate() { + debugInspector?.isAnimating = false + debugInspector?.isActive = false + } + + fun onFrameStart(isAnimatingUninterrupted: Boolean): Boolean { + spec = specProvider.invoke() + if (isSameSegmentAndAtRest) { + outputTarget = lastSegment.mapping.map(currentInput) + output = outputTarget + isStable = true + } else { + lastComputedValues = capturedComputedValues + lastSpringState = capturedSpringState + + capturedComputedValues = currentComputedValues + capturedSpringState = currentSpringState + + outputTarget = capturedComputedValues.segment.mapping.map(currentInput) + output = outputTarget + capturedSpringState.displacement + isStable = capturedSpringState == SpringState.AtRest + } + + directMappedVelocity = + if (isAnimatingUninterrupted) { + computeDirectMappedVelocity(currentAnimationTimeNanos - lastFrameTimeNanos) + } else 0f + + debugInspector?.run { + frame = + FrameData( + currentInput, + currentDirection, + currentGestureDragOffset, + currentAnimationTimeNanos, + capturedSpringState, + capturedComputedValues.segment, + capturedComputedValues.animation, + computedIsOutputFixed, + ) + } + + return lastSpringState != capturedSpringState || + lastComputedValues != capturedComputedValues + } + + fun wantWakeup(): Boolean { + return specProvider.invoke() != capturedComputedValues.segment.spec + } +} diff --git a/mechanics/src/com/android/mechanics/MotionValueState.kt b/mechanics/src/com/android/mechanics/MotionValueState.kt new file mode 100644 index 0000000..770bd7d --- /dev/null +++ b/mechanics/src/com/android/mechanics/MotionValueState.kt @@ -0,0 +1,63 @@ +/* + * Copyright (C) 2025 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.mechanics + +import androidx.compose.runtime.FloatState +import androidx.compose.runtime.Stable +import com.android.mechanics.debug.DebugInspector +import com.android.mechanics.spec.SegmentKey +import com.android.mechanics.spec.SemanticKey + +/** State produces by a motion value. */ +@Stable +sealed interface MotionValueState : FloatState { + + /** + * Animated [output] value. + * + * Same as [floatValue]. + */ + val output: Float + + /** + * [output] value, but without animations. + * + * This value always reports the target value, even before a animation is finished. + * + * While [isStable], [outputTarget] and [output] are the same value. + */ + val outputTarget: Float + + /** Whether an animation is currently running. */ + val isStable: Boolean + + /** + * The current value for the [SemanticKey]. + * + * `null` if not defined in the spec. + */ + operator fun get(key: SemanticKey): T? + + /** The current segment used to compute the output. */ + val segmentKey: SegmentKey + + /** Debug label of the motion value. */ + val label: String? + + /** Provides access to the current state for debugging.. */ + fun debugInspector(): DebugInspector +} diff --git a/mechanics/src/com/android/mechanics/debug/DebugInspector.kt b/mechanics/src/com/android/mechanics/debug/DebugInspector.kt index 088c78b..2247945 100644 --- a/mechanics/src/com/android/mechanics/debug/DebugInspector.kt +++ b/mechanics/src/com/android/mechanics/debug/DebugInspector.kt @@ -22,6 +22,7 @@ import androidx.compose.runtime.setValue import com.android.mechanics.MotionValue import com.android.mechanics.impl.DiscontinuityAnimation import com.android.mechanics.spec.InputDirection +import com.android.mechanics.spec.MotionSpec import com.android.mechanics.spec.SegmentData import com.android.mechanics.spec.SegmentKey import com.android.mechanics.spec.SemanticKey @@ -65,6 +66,7 @@ internal constructor( val springState: SpringState, private val segment: SegmentData, private val animation: DiscontinuityAnimation, + val isOutputFixed: Boolean, ) { val isStable: Boolean get() = springState == SpringState.AtRest @@ -87,4 +89,7 @@ internal constructor( val semantics: List> get() = with(segment) { spec.semantics(key) } + + val spec: MotionSpec + get() = segment.spec } diff --git a/mechanics/src/com/android/mechanics/debug/DebugVisualization.kt b/mechanics/src/com/android/mechanics/debug/DebugVisualization.kt index b89728b..dfd1a5f 100644 --- a/mechanics/src/com/android/mechanics/debug/DebugVisualization.kt +++ b/mechanics/src/com/android/mechanics/debug/DebugVisualization.kt @@ -20,6 +20,7 @@ import androidx.compose.foundation.layout.Spacer import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.mutableStateOf @@ -46,19 +47,26 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.util.fastCoerceAtLeast import androidx.compose.ui.util.fastCoerceAtMost import androidx.compose.ui.util.fastForEachIndexed -import com.android.mechanics.MotionValue +import com.android.mechanics.MotionValueState import com.android.mechanics.spec.DirectionalMotionSpec import com.android.mechanics.spec.Guarantee import com.android.mechanics.spec.InputDirection import com.android.mechanics.spec.Mapping import com.android.mechanics.spec.MotionSpec import com.android.mechanics.spec.SegmentKey +import com.android.mechanics.spec.SemanticKey import kotlin.math.ceil import kotlin.math.max import kotlin.math.min import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch +/** Computes the output range for a debug visualization given a spec and an input range. */ +typealias OutputRangeFn = + (spec: MotionSpec, inputRange: ClosedFloatingPointRange) -> ClosedFloatingPointRange< + Float + > + /** * A debug visualization of the [motionValue]. * @@ -72,16 +80,17 @@ import kotlinx.coroutines.launch */ @Composable fun DebugMotionValueVisualization( - motionValue: MotionValue, + motionValue: MotionValueState, inputRange: ClosedFloatingPointRange, modifier: Modifier = Modifier, + outputRange: OutputRangeFn = DebugMotionValueVisualization.default, maxAgeMillis: Long = 1000L, ) { - val spec = motionValue.spec - val outputRange = remember(spec, inputRange) { spec.computeOutputValueRange(inputRange) } - val inspector = remember(motionValue) { motionValue.debugInspector() } + val spec = remember(motionValue) { derivedStateOf { inspector.frame.spec } }.value + + val computedOutputRange = remember(spec, inputRange) { outputRange(spec, inputRange) } DisposableEffect(inspector) { onDispose { inspector.dispose() } } val colorScheme = MaterialTheme.colorScheme @@ -89,7 +98,7 @@ fun DebugMotionValueVisualization( val specColor = colorScheme.tertiary val valueColor = colorScheme.primary - val primarySpec = motionValue.spec.get(inspector.frame.gestureDirection) + val primarySpec = spec.get(inspector.frame.gestureDirection) val activeSegment = inspector.frame.segmentKey Spacer( @@ -98,7 +107,7 @@ fun DebugMotionValueVisualization( .debugMotionSpecGraph( primarySpec, inputRange, - outputRange, + computedOutputRange, axisColor, specColor, activeSegment, @@ -107,12 +116,36 @@ fun DebugMotionValueVisualization( motionValue, valueColor, inputRange, - outputRange, + computedOutputRange, maxAgeMillis, ) ) } +object DebugMotionValueVisualization { + + /** + * Returns the output range as annotated in the spec using [OutputRangeKey], or + * [minMaxOutputRange] is not specified. + */ + val default: OutputRangeFn = { spec, inputRange -> + spec.semanticState(OutputRangeKey) ?: spec.computeOutputValueRange(inputRange) + } + /** + * Returns an output range containing the min and max output values at each breakpoint within + * the input range + */ + val minMaxOutputRange: OutputRangeFn = { spec, inputRange -> + spec.computeOutputValueRange(inputRange) + } + + /** Returns an output range that is identical to the input range */ + val inputRange: OutputRangeFn = { _, inputRange -> inputRange } + + /** Defines the output range for the visualization. */ + val OutputRangeKey = SemanticKey>("visualizationOutputRange") +} + /** * Draws a full-sized debug visualization of [spec]. * @@ -148,7 +181,7 @@ fun Modifier.debugMotionSpecGraph( */ @Composable fun Modifier.debugMotionValueGraph( - motionValue: MotionValue, + motionValue: MotionValueState, color: Color, inputRange: ClosedFloatingPointRange, outputRange: ClosedFloatingPointRange, @@ -210,7 +243,7 @@ fun DirectionalMotionSpec.computeOutputValueRange( } private data class DebugMotionValueGraphElement( - val motionValue: MotionValue, + val motionValue: MotionValueState, val color: Color, val inputRange: ClosedFloatingPointRange, val outputRange: ClosedFloatingPointRange, @@ -238,7 +271,7 @@ private data class DebugMotionValueGraphElement( } private class DebugMotionValueGraphNode( - motionValue: MotionValue, + motionValue: MotionValueState, var color: Color, var inputRange: ClosedFloatingPointRange, var outputRange: ClosedFloatingPointRange, diff --git a/mechanics/src/com/android/mechanics/debug/MotionValueDebugger.kt b/mechanics/src/com/android/mechanics/debug/MotionValueDebugger.kt index 3c0109d..ac8d634 100644 --- a/mechanics/src/com/android/mechanics/debug/MotionValueDebugger.kt +++ b/mechanics/src/com/android/mechanics/debug/MotionValueDebugger.kt @@ -16,93 +16,87 @@ package com.android.mechanics.debug +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.staticCompositionLocalOf import androidx.compose.ui.Modifier +import androidx.compose.ui.node.CompositionLocalConsumerModifierNode import androidx.compose.ui.node.DelegatableNode import androidx.compose.ui.node.ModifierNodeElement -import androidx.compose.ui.node.TraversableNode -import androidx.compose.ui.node.findNearestAncestor +import androidx.compose.ui.node.ObserverModifierNode +import androidx.compose.ui.node.currentValueOf +import androidx.compose.ui.node.observeReads import androidx.compose.ui.platform.InspectorInfo -import com.android.mechanics.MotionValue -import com.android.mechanics.debug.MotionValueDebuggerNode.Companion.TRAVERSAL_NODE_KEY +import com.android.mechanics.MotionValueState import kotlinx.coroutines.DisposableHandle -/** State for the [MotionValueDebugger]. */ -sealed interface MotionValueDebuggerState { - val observedMotionValues: List -} +/** Keeps track of MotionValues that are registered for debug-inspection. */ +class MotionValueDebugController { + private val observedMotionValues = mutableStateListOf() + + /** + * Registers a [MotionValueState] to be debugged. + * + * Clients must call [DisposableHandle.dispose] when done. + */ + fun register(motionValue: MotionValueState): DisposableHandle { + observedMotionValues.add(motionValue) + return DisposableHandle { observedMotionValues.remove(motionValue) } + } -/** Factory for [MotionValueDebugger]. */ -fun MotionValueDebuggerState(): MotionValueDebuggerState { - return MotionValueDebuggerStateImpl() + /** The currently registered `MotionValues`. */ + val observed: List + get() = observedMotionValues } -/** Collector for [MotionValue]s in the Node subtree that should be observed for debug purposes. */ -fun Modifier.motionValueDebugger(state: MotionValueDebuggerState): Modifier = - this.then(MotionValueDebuggerElement(state as MotionValueDebuggerStateImpl)) +/** Composition-local to provide a [MotionValueDebugController]. */ +val LocalMotionValueDebugController = staticCompositionLocalOf { null } /** - * [motionValueDebugger]'s interface, nodes in the subtree of a [motionValueDebugger] can retrieve - * it using [findMotionValueDebugger]. + * Provides a [MotionValueDebugController], to which [MotionValue]s within [content] can be + * registered to. + * + * With [enableDebugger] set to `false` (or this composable not being in the composition in the + * first place), downstream [debugMotionValue] and [DebugEffect] will be no-ops. */ -sealed interface MotionValueDebugger { - fun register(motionValue: MotionValue): DisposableHandle -} - -/** Finds a [MotionValueDebugger] that was registered via a [motionValueDebugger] modifier. */ -fun DelegatableNode.findMotionValueDebugger(): MotionValueDebugger? { - return findNearestAncestor(TRAVERSAL_NODE_KEY) as? MotionValueDebugger +@Composable +fun MotionValueDebuggerProvider(enableDebugger: Boolean = true, content: @Composable () -> Unit) { + val debugger = + remember(enableDebugger) { if (enableDebugger) MotionValueDebugController() else null } + CompositionLocalProvider(LocalMotionValueDebugController provides debugger) { content() } } -/** Registers the motion value for debugging with the parent [MotionValue]. */ -fun Modifier.debugMotionValue(motionValue: MotionValue): Modifier = +/** Registers the [motionValue] with the [LocalMotionValueDebugController], if available. */ +fun Modifier.debugMotionValue(motionValue: MotionValueState): Modifier = this.then(DebugMotionValueElement(motionValue)) -internal class MotionValueDebuggerNode(internal var state: MotionValueDebuggerStateImpl) : - Modifier.Node(), TraversableNode, MotionValueDebugger { - - override val traverseKey = TRAVERSAL_NODE_KEY - - override fun register(motionValue: MotionValue): DisposableHandle { - val state = state - state.observedMotionValues.add(motionValue) - return DisposableHandle { state.observedMotionValues.remove(motionValue) } - } - - companion object { - const val TRAVERSAL_NODE_KEY = "com.android.mechanics.debug.DEBUG_CONNECTOR_NODE_KEY" - } -} - -private data class MotionValueDebuggerElement(val state: MotionValueDebuggerStateImpl) : - ModifierNodeElement() { - override fun create(): MotionValueDebuggerNode = MotionValueDebuggerNode(state) - - override fun InspectorInfo.inspectableProperties() { - // Intentionally empty - } - - override fun update(node: MotionValueDebuggerNode) { - check(node.state === state) +/** Registers the [motionValue] with the [LocalMotionValueDebugController], if available. */ +@Composable +fun DebugEffect(motionValue: MotionValueState) { + val debugger = LocalMotionValueDebugController.current + if (debugger != null) { + DisposableEffect(debugger, motionValue) { + val handle = debugger.register(motionValue) + onDispose { handle.dispose() } + } } } -internal class DebugMotionValueNode(motionValue: MotionValue) : Modifier.Node() { - - private var debugger: MotionValueDebugger? = null - - internal var motionValue = motionValue - set(value) { - registration?.dispose() - registration = debugger?.register(value) - field = value - } +/** + * [DelegatableNode] to register the [motionValue] with the [LocalMotionValueDebugController], if + * available. + */ +class DebugMotionValueNode(motionValue: MotionValueState) : + Modifier.Node(), DelegatableNode, CompositionLocalConsumerModifierNode, ObserverModifierNode { + private var debugger: MotionValueDebugController? = null internal var registration: DisposableHandle? = null override fun onAttach() { - debugger = findMotionValueDebugger() - registration = debugger?.register(motionValue) + onObservedReadsChanged() } override fun onDetach() { @@ -110,9 +104,21 @@ internal class DebugMotionValueNode(motionValue: MotionValue) : Modifier.Node() registration?.dispose() registration = null } + + override fun onObservedReadsChanged() { + registration?.dispose() + observeReads { debugger = currentValueOf(LocalMotionValueDebugController) } + registration = debugger?.register(motionValue) + } + + var motionValue = motionValue + set(value) { + registration = debugger?.register(value) + field = value + } } -private data class DebugMotionValueElement(val motionValue: MotionValue) : +private data class DebugMotionValueElement(val motionValue: MotionValueState) : ModifierNodeElement() { override fun create(): DebugMotionValueNode = DebugMotionValueNode(motionValue) @@ -124,7 +130,3 @@ private data class DebugMotionValueElement(val motionValue: MotionValue) : node.motionValue = motionValue } } - -internal class MotionValueDebuggerStateImpl : MotionValueDebuggerState { - override val observedMotionValues: MutableList = mutableStateListOf() -} diff --git a/mechanics/src/com/android/mechanics/effects/CommonSemantics.kt b/mechanics/src/com/android/mechanics/effects/CommonSemantics.kt new file mode 100644 index 0000000..3c89e34 --- /dev/null +++ b/mechanics/src/com/android/mechanics/effects/CommonSemantics.kt @@ -0,0 +1,23 @@ +/* + * Copyright (C) 2025 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.mechanics.effects + +import com.android.mechanics.spec.SemanticKey + +object CommonSemantics { + val RestingValueKey = SemanticKey("") +} diff --git a/mechanics/src/com/android/mechanics/effects/MagneticDetach.kt b/mechanics/src/com/android/mechanics/effects/MagneticDetach.kt index 1e4e38b..3df1a26 100644 --- a/mechanics/src/com/android/mechanics/effects/MagneticDetach.kt +++ b/mechanics/src/com/android/mechanics/effects/MagneticDetach.kt @@ -21,8 +21,11 @@ package com.android.mechanics.effects import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp -import androidx.compose.ui.util.lerp +import com.android.mechanics.haptics.BreakpointHaptics +import com.android.mechanics.haptics.HapticsExperimentalApi +import com.android.mechanics.haptics.SegmentHaptics import com.android.mechanics.spec.BreakpointKey +import com.android.mechanics.spec.ChangeSegmentHandlers.DirectionChangePreservesCurrentValue import com.android.mechanics.spec.ChangeSegmentHandlers.PreventDirectionChangeWithinCurrentSegment import com.android.mechanics.spec.InputDirection import com.android.mechanics.spec.Mapping @@ -56,6 +59,7 @@ class MagneticDetach( private val attachScale: Float = Defaults.AttachDetachScale * (attachPosition / detachPosition), private val detachSpring: SpringParameters = Defaults.Spring, private val attachSpring: SpringParameters = Defaults.Spring, + private val enableHaptics: Boolean = false, ) : Effect.PlaceableAfter, Effect.PlaceableBefore { init { @@ -96,6 +100,7 @@ class MagneticDetach( } /* Effect is attached at minLimit, and detaches at maxLimit. */ + @OptIn(HapticsExperimentalApi::class) private fun EffectApplyScope.createPlacedAfterSpec( minLimit: Float, minLimitKey: BreakpointKey, @@ -115,12 +120,32 @@ class MagneticDetach( val scaledDetachValue = attachedValue + (detachedValue - attachedValue) * detachScale val scaledReattachValue = attachedValue + (reattachValue - attachedValue) * attachScale + // Haptic specs + val tensionHaptics = + if (enableHaptics) { + SegmentHaptics.SpringTension(anchorPointPx = minLimit) + } else { + SegmentHaptics.None + } + val thresholdHaptics = + if (enableHaptics) { + BreakpointHaptics.GenericThreshold + } else { + BreakpointHaptics.None + } + val attachKey = BreakpointKey("attach") + forward( initialMapping = Mapping.Linear(minLimit, attachedValue, maxLimit, scaledDetachValue), + initialSegmentHaptics = tensionHaptics, semantics = attachedSemantics, ) { - after(spring = detachSpring, semantics = detachedSemantics) + after( + spring = detachSpring, + semantics = detachedSemantics, + breakpointHaptics = thresholdHaptics, + ) before(semantics = listOf(semanticAttachedValue with null)) } @@ -135,6 +160,7 @@ class MagneticDetach( spring = attachSpring, semantics = detachedSemantics, mapping = baseMapping, + breakpointHaptics = thresholdHaptics, ) before(semantics = listOf(semanticAttachedValue with null)) after(semantics = listOf(semanticAttachedValue with null)) @@ -144,8 +170,6 @@ class MagneticDetach( beforeDetachSegment = SegmentKey(minLimitKey, maxLimitKey, InputDirection.Max), beforeAttachSegment = SegmentKey(attachKey, maxLimitKey, InputDirection.Min), afterAttachSegment = SegmentKey(minLimitKey, attachKey, InputDirection.Min), - minLimit = minLimit, - maxLimit = maxLimit, ) } @@ -195,8 +219,6 @@ class MagneticDetach( beforeDetachSegment = SegmentKey(minLimitKey, maxLimitKey, InputDirection.Min), beforeAttachSegment = SegmentKey(minLimitKey, attachKey, InputDirection.Max), afterAttachSegment = SegmentKey(attachKey, maxLimitKey, InputDirection.Max), - minLimit = minLimit, - maxLimit = maxLimit, ) } @@ -204,8 +226,6 @@ class MagneticDetach( beforeDetachSegment: SegmentKey, beforeAttachSegment: SegmentKey, afterAttachSegment: SegmentKey, - minLimit: Float, - maxLimit: Float, ) { // Suppress direction change during detach. This prevents snapping to the origin when // changing the direction while detaching. @@ -216,44 +236,6 @@ class MagneticDetach( // When changing direction after re-attaching, the pre-detach ratio is tweaked to // interpolate between the direction change-position and the detach point. - addSegmentHandler(afterAttachSegment) { currentSegment, newInput, newDirection -> - val nextSegment = segmentAtInput(newInput, newDirection) - if (nextSegment.key == beforeDetachSegment) { - nextSegment.copy( - mapping = - switchMappingWithSamePivotValue( - currentSegment.mapping, - nextSegment.mapping, - minLimit, - newInput, - maxLimit, - ) - ) - } else { - nextSegment - } - } - } - - private fun switchMappingWithSamePivotValue( - source: Mapping, - target: Mapping, - minLimit: Float, - pivot: Float, - maxLimit: Float, - ): Mapping { - val minValue = target.map(minLimit) - val pivotValue = source.map(pivot) - val maxValue = target.map(maxLimit) - - return Mapping { input -> - if (input <= pivot) { - val t = (input - minLimit) / (pivot - minLimit) - lerp(minValue, pivotValue, t) - } else { - val t = (input - pivot) / (maxLimit - pivot) - lerp(pivotValue, maxValue, t) - } - } + addSegmentHandler(afterAttachSegment, DirectionChangePreservesCurrentValue) } } diff --git a/mechanics/src/com/android/mechanics/effects/RevealOnThreshold.kt b/mechanics/src/com/android/mechanics/effects/RevealOnThreshold.kt index 124f031..075c9fd 100644 --- a/mechanics/src/com/android/mechanics/effects/RevealOnThreshold.kt +++ b/mechanics/src/com/android/mechanics/effects/RevealOnThreshold.kt @@ -26,9 +26,13 @@ import com.android.mechanics.spec.builder.EffectApplyScope import com.android.mechanics.spec.builder.EffectPlacement /** An effect that reveals a component when the available space reaches a certain threshold. */ -data class RevealOnThreshold(val minSize: Dp = Defaults.MinSize) : Effect.PlaceableBetween { +data class RevealOnThreshold( + val minSize: Dp = Defaults.MinSize, + val cornerMaxSize: Dp = Defaults.CornerMaxSize, +) : Effect.PlaceableBetween { init { require(minSize >= 0.dp) + require(cornerMaxSize >= 0.dp) } override fun EffectApplyScope.createSpec( @@ -52,5 +56,6 @@ data class RevealOnThreshold(val minSize: Dp = Defaults.MinSize) : Effect.Placea object Defaults { val MinSize: Dp = 8.dp + val CornerMaxSize: Dp = 32.dp } } diff --git a/mechanics/src/com/android/mechanics/effects/Toggle.kt b/mechanics/src/com/android/mechanics/effects/Toggle.kt new file mode 100644 index 0000000..f39cbba --- /dev/null +++ b/mechanics/src/com/android/mechanics/effects/Toggle.kt @@ -0,0 +1,176 @@ +/* + * Copyright (C) 2025 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.mechanics.effects + +import com.android.mechanics.spec.BreakpointKey +import com.android.mechanics.spec.ChangeSegmentHandlers.DirectionChangePreservesCurrentValue +import com.android.mechanics.spec.ChangeSegmentHandlers.PreventDirectionChangeWithinCurrentSegment +import com.android.mechanics.spec.Guarantee +import com.android.mechanics.spec.InputDirection +import com.android.mechanics.spec.Mapping +import com.android.mechanics.spec.SegmentKey +import com.android.mechanics.spec.SemanticKey +import com.android.mechanics.spec.builder.Effect +import com.android.mechanics.spec.builder.EffectApplyScope +import com.android.mechanics.spec.builder.EffectPlacemenType +import com.android.mechanics.spec.builder.EffectPlacement +import com.android.mechanics.spec.with +import com.android.mechanics.spring.SpringParameters + +/** + * A gesture effect that toggles the output value between the placement's `start` and `end` values. + * + * The toggle action is triggered when the input changes by a specified fraction ([toggleFraction]) + * of the total input range, measured from the start of the effect. + * + * The logical state of the toggle is exposed via the SemanticKey [stateKey], and is either + * [minState] or [maxState], based on the input gesture's progress. + * + * @param T The type of the state being toggled. + * @property stateKey A [SemanticKey] used to identify the current state of the toggle (either + * [minState] or [maxState]). + * @property minState The value representing the logical state when toggled to the `min` side. + * @property minState The value representing the logical state when toggled to the `max` side. + * @property restingValueKey A [SemanticKey] used to identify the resting value of the input. + * @property toggleFraction The fraction of the input range (between `minLimit` and `maxLimit` of + * the effect placement) at which the toggle action occurs. For example, a value of 0.7 means the + * toggle happens when the input has covered 70% of the distance from `minLimit` towards + * `maxLimit`. + * @property preToggleScale A scaling factor applied to the output value *before* the toggle point + * is reached. This controls how much the output changes leading up to the toggle. + * @property postToggleScale A scaling factor applied to the output value *after* the toggle point + * is reached. This controls the initial change in output immediately after toggling. + * @property spring The [SpringParameters] used for the animation when the toggle action occurs. + * This defines the physics of the transition between states. + */ +class Toggle( + private val stateKey: SemanticKey, + private val minState: T, + private val maxState: T, + private val restingValueKey: SemanticKey = CommonSemantics.RestingValueKey, + private val toggleFraction: Float = Defaults.ToggleFraction, + private val preToggleScale: Float = Defaults.PreToggleScale, + private val postToggleScale: Float = Defaults.PostToggleScale, + private val spring: SpringParameters = Defaults.Spring, +) : Effect.PlaceableBetween { + + override fun EffectApplyScope.createSpec( + minLimit: Float, + minLimitKey: BreakpointKey, + maxLimit: Float, + maxLimitKey: BreakpointKey, + placement: EffectPlacement, + ) { + check(placement.type == EffectPlacemenType.Between) + val minValue = baseValue(minLimit) + val maxValue = baseValue(maxLimit) + val valueRange = maxValue - minValue + + val distance = maxLimit - minLimit + + val minTargetSemantics = listOf(restingValueKey with minValue, stateKey with minState) + val maxTargetSemantics = listOf(restingValueKey with maxValue, stateKey with maxState) + + val toggleKey = BreakpointKey("toggle") + + val forwardTogglePos = minLimit + distance * toggleFraction + forward( + initialMapping = + Mapping.Linear( + minLimit, + minValue, + forwardTogglePos, + minValue + valueRange * preToggleScale, + ), + semantics = minTargetSemantics, + ) { + target( + forwardTogglePos, + from = maxValue - valueRange * postToggleScale, + to = maxValue, + spring = spring, + semantics = maxTargetSemantics, + key = toggleKey, + guarantee = Guarantee.GestureDragDelta(distance * 2), + ) + } + + val reverseTogglePos = minLimit + distance * (1 - toggleFraction) + backward( + initialMapping = + Mapping.Linear( + minLimit, + minValue, + reverseTogglePos, + minValue + valueRange * postToggleScale, + ), + semantics = minTargetSemantics, + ) { + target( + reverseTogglePos, + from = maxValue - valueRange * preToggleScale, + to = maxValue, + spring = spring, + key = toggleKey, + semantics = maxTargetSemantics, + guarantee = Guarantee.GestureDragDelta(distance * 2), + ) + } + + // Before toggling, suppress direction change + addSegmentHandler( + SegmentKey(minLimitKey, toggleKey, InputDirection.Max), + PreventDirectionChangeWithinCurrentSegment, + ) + addSegmentHandler( + SegmentKey(toggleKey, maxLimitKey, InputDirection.Min), + PreventDirectionChangeWithinCurrentSegment, + ) + + // after toggling, ensure a direction change does + addSegmentHandler( + SegmentKey(toggleKey, maxLimitKey, InputDirection.Max), + DirectionChangePreservesCurrentValue, + ) + + addSegmentHandler( + SegmentKey(minLimitKey, toggleKey, InputDirection.Min), + DirectionChangePreservesCurrentValue, + ) + } + + object Defaults { + val ToggleFraction = 0.7f + val PreToggleScale = 0.2f + val PostToggleScale = 0.01f + val Spring = SpringParameters(stiffness = 800f, dampingRatio = 0.95f) + } +} + +/** + * Convenience implementation of a [Toggle] effect for an expanding / collapsing element. + * + * This object provides a pre-configured [Toggle] specifically designed for elements that can be + * expanded or collapsed. It exposes the logical expansion state via the semantic [IsExpandedKey]. + */ +object ExpansionToggle { + /** Semantic key for a boolean flag indicating whether the element is expanded. */ + val IsExpandedKey: SemanticKey = SemanticKey("IsToggleExpanded") + + /** Toggle effect with default values. */ + val Default = Toggle(IsExpandedKey, minState = false, maxState = true) +} diff --git a/mechanics/src/com/android/mechanics/haptics/HapticPlayer.kt b/mechanics/src/com/android/mechanics/haptics/HapticPlayer.kt new file mode 100644 index 0000000..458d523 --- /dev/null +++ b/mechanics/src/com/android/mechanics/haptics/HapticPlayer.kt @@ -0,0 +1,52 @@ +/* + * Copyright (C) 2025 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.mechanics.haptics + +interface HapticPlayer { + + fun playSegmentHaptics( + segmentHaptics: SegmentHaptics, + spatialInput: Float, + spatialVelocity: Float, + ) + + fun playBreakpointHaptics( + breakpointHaptics: BreakpointHaptics, + spatialInput: Float, + spatialVelocity: Float, + ) + + /** Get the minimum interval required for haptics to play */ + fun getPlaybackIntervalNanos(): Long = 0L + + companion object { + val NoPlayer = + object : HapticPlayer { + override fun playSegmentHaptics( + segmentHaptics: SegmentHaptics, + spatialInput: Float, + spatialVelocity: Float, + ) {} + + override fun playBreakpointHaptics( + breakpointHaptics: BreakpointHaptics, + spatialInput: Float, + spatialVelocity: Float, + ) {} + } + } +} diff --git a/mechanics/src/com/android/mechanics/haptics/HapticTypes.kt b/mechanics/src/com/android/mechanics/haptics/HapticTypes.kt new file mode 100644 index 0000000..51acb06 --- /dev/null +++ b/mechanics/src/com/android/mechanics/haptics/HapticTypes.kt @@ -0,0 +1,59 @@ +/* + * Copyright (C) 2025 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.mechanics.haptics + +/** + * Describes haptics triggered when crossing a breakpoint. + * + * Important: This is a complete enumeration of all effects supported. + */ +sealed class BreakpointHaptics { + + /** No Haptics. */ + data object None : BreakpointHaptics() + + /** Haptics force determined by the discontinuity delta and the breakpoint's spring. */ + @HapticsExperimentalApi + data class SpringForce(val stiffness: Float, val dampingRatio: Float) : BreakpointHaptics() + + /** Play a generic threshold effect. */ + @HapticsExperimentalApi data object GenericThreshold : BreakpointHaptics() +} + +/** + * Describes haptics continuously played within a segment. + * + * Important: This is a complete enumeration of all effects supported. + */ +sealed class SegmentHaptics { + + data object None : SegmentHaptics() + + /** + * Haptics effect describing tension texture. + * + * On breakpoints, tension released is played back with an effect similar to + * [BreakpointHaptics.SpringForce] . + */ + @HapticsExperimentalApi + data class SpringTension( + val anchorPointPx: Float, + val attachedMassKg: Float = 1f, // In Kg + val stiffness: Float = 900f, // in Newtons / meter + val dampingRatio: Float = 0.95f, // unitless, + ) : SegmentHaptics() +} diff --git a/mechanics/src/com/android/mechanics/haptics/HapticsExperimentalApi.kt b/mechanics/src/com/android/mechanics/haptics/HapticsExperimentalApi.kt new file mode 100644 index 0000000..345b33e --- /dev/null +++ b/mechanics/src/com/android/mechanics/haptics/HapticsExperimentalApi.kt @@ -0,0 +1,21 @@ +/* + * Copyright (C) 2025 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.mechanics.haptics + +@RequiresOptIn("This API is experimental and should not be used in general production code.") +@Retention(AnnotationRetention.BINARY) +annotation class HapticsExperimentalApi diff --git a/mechanics/src/com/android/mechanics/haptics/MetricScaling.kt b/mechanics/src/com/android/mechanics/haptics/MetricScaling.kt new file mode 100644 index 0000000..e2062fd --- /dev/null +++ b/mechanics/src/com/android/mechanics/haptics/MetricScaling.kt @@ -0,0 +1,41 @@ +/* + * Copyright (C) 2025 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.mechanics.haptics + +import androidx.compose.ui.unit.Density +import kotlin.math.abs + +private const val PIXEL_INCH_CONVERSION = 25.4f / (160f * 1000) + +fun Density.pxToMeters(pxValue: Float): Meters = Meters(pxValue * (PIXEL_INCH_CONVERSION / density)) + +fun Density.pxPerSecToMetersPerSec(pxValue: Float): MetersPerSec = + MetersPerSec(pxValue * (PIXEL_INCH_CONVERSION / density)) + +@JvmInline +value class Meters(val value: Float) { + fun absoluteValue(): MetersPerSec = MetersPerSec(abs(value)) + + operator fun minus(other: Meters) = Meters(value - other.value) +} + +@JvmInline +value class MetersPerSec(val value: Float) { + fun absoluteValue(): MetersPerSec = MetersPerSec(abs(value)) + + operator fun div(other: MetersPerSec): MetersPerSec = MetersPerSec(value / other.value) +} diff --git a/mechanics/src/com/android/mechanics/haptics/SpringTensionHapticPlayer.kt b/mechanics/src/com/android/mechanics/haptics/SpringTensionHapticPlayer.kt new file mode 100644 index 0000000..efc8b0d --- /dev/null +++ b/mechanics/src/com/android/mechanics/haptics/SpringTensionHapticPlayer.kt @@ -0,0 +1,113 @@ +/* + * Copyright (C) 2025 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.mechanics.haptics + +import android.Manifest +import android.os.VibrationEffect +import android.os.VibratorManager +import androidx.annotation.RequiresPermission +import androidx.compose.ui.unit.Density +import java.util.concurrent.Executor +import java.util.concurrent.Executors +import kotlin.math.abs +import kotlin.math.pow +import kotlin.math.sqrt + +@HapticsExperimentalApi +class SpringTensionHapticPlayer(private val density: Density, vibratorManager: VibratorManager) : + HapticPlayer { + + // TODO(b/443090261): We should use the MSDLPlayer to play haptics here + private val vibrator = vibratorManager.defaultVibrator + private val executor: Executor = Executors.newSingleThreadExecutor() + + @RequiresPermission(Manifest.permission.VIBRATE) + override fun playSegmentHaptics( + segmentHaptics: SegmentHaptics, + spatialInput: Float, + spatialVelocity: Float, + ) { + // TODO: Maybe this player can extend to handle other forms of haptics + if (segmentHaptics !is SegmentHaptics.SpringTension) return + + // 1. Convert the inputs in pixels to metric units + val distance = density.pxToMeters(abs(spatialInput - segmentHaptics.anchorPointPx)) + val velocity = + density.pxPerSecToMetersPerSec(spatialVelocity.coerceAtMost(MAX_VELOCITY_PX_PER_SEC)) + + // 2. Derive a force in Newton from the spring tension model and the metric inputs + val damperConstant = + 2f * + segmentHaptics.attachedMassKg * + segmentHaptics.dampingRatio * + sqrt(segmentHaptics.stiffness / segmentHaptics.attachedMassKg) + val force = + segmentHaptics.stiffness * distance.value + + damperConstant * velocity.absoluteValue().value + + // 3. Divide the force by MAX_FORCE to map the values in Newtons to the 0..1 range + // 4. Multiply the proportion by MaX_INPUT_VIBRATION_SCALE to cap the scale + // 5. Apply a power function to compensate for the logarithmic human perception. + val vibrationScale = + (force * MAX_INPUT_VIBRATION_SCALE / MAX_FORCE).pow(VIBRATION_SCALE_EXPONENT) + val compensatedScale = + vibrationScale.pow(VIBRATION_PERCEPTION_EXPONENT).coerceAtMost(maximumValue = 1f) + + // Play the texture. + // TODO(b/443090261): We should play MSDLToken.DRAG_INDICATOR_CONTINUOUS + val composition = VibrationEffect.startComposition() + repeat(5) { + composition.addPrimitive( + VibrationEffect.Composition.PRIMITIVE_LOW_TICK, + compensatedScale, + ) + } + vibrate(composition.compose()) + } + + @RequiresPermission(Manifest.permission.VIBRATE) + override fun playBreakpointHaptics( + breakpointHaptics: BreakpointHaptics, + spatialInput: Float, + spatialVelocity: Float, + ) { + if (breakpointHaptics != BreakpointHaptics.GenericThreshold) return + // TODO: This could be more expressive by using the inputs + + // TODO(b/443090261): We should play MSDLToken.SWIPE_THRESHOLD_INDICATOR + val effect = + VibrationEffect.startComposition() + .addPrimitive(VibrationEffect.Composition.PRIMITIVE_CLICK, 0.7f, 0) + .compose() + vibrate(effect) + } + + // Use 60 ms because, in theory, this is how long the DRAG_INDICATOR_CONTINUOUS token takes + override fun getPlaybackIntervalNanos(): Long = 60_000L + + @RequiresPermission(Manifest.permission.VIBRATE) + private fun vibrate(vibrationEffect: VibrationEffect) = + executor.execute { vibrator.vibrate(vibrationEffect) } + + companion object { + private const val MAX_FORCE = 4f // In Newtons + private const val MAX_INPUT_VIBRATION_SCALE = 0.2f + private const val VIBRATION_SCALE_EXPONENT = 1.5f + private const val VIBRATION_PERCEPTION_EXPONENT = 1 / 0.89f + private const val MAX_VELOCITY_PX_PER_SEC = 2000f + } +} diff --git a/mechanics/src/com/android/mechanics/impl/ComputationInput.kt b/mechanics/src/com/android/mechanics/impl/ComputationInput.kt index 23ac183..3d0175b 100644 --- a/mechanics/src/com/android/mechanics/impl/ComputationInput.kt +++ b/mechanics/src/com/android/mechanics/impl/ComputationInput.kt @@ -98,4 +98,7 @@ internal interface LastFrameState { val lastGestureDragOffset: Float val directMappedVelocity: Float + + /** Last time that haptics played */ + var lastHapticsTimeNanos: Long } diff --git a/mechanics/src/com/android/mechanics/impl/Computations.kt b/mechanics/src/com/android/mechanics/impl/Computations.kt index 2ac9574..2287c67 100644 --- a/mechanics/src/com/android/mechanics/impl/Computations.kt +++ b/mechanics/src/com/android/mechanics/impl/Computations.kt @@ -22,6 +22,7 @@ import androidx.compose.ui.util.fastCoerceIn import androidx.compose.ui.util.fastIsFinite import androidx.compose.ui.util.lerp import com.android.mechanics.MotionValue.Companion.TAG +import com.android.mechanics.haptics.BreakpointHaptics import com.android.mechanics.spec.Guarantee import com.android.mechanics.spec.InputDirection import com.android.mechanics.spec.Mapping @@ -36,20 +37,32 @@ internal abstract class Computations : CurrentFrameInput, LastFrameState, Static val segment: SegmentData, val guarantee: GuaranteeState, val animation: DiscontinuityAnimation, + val breakpointHaptics: BreakpointHaptics?, ) // currentComputedValues input - private var memoizedSpec: MotionSpec? = null + private var memoizedSpec: MotionSpec = MotionSpec.InitiallyUndefined private var memoizedInput: Float = Float.MIN_VALUE private var memoizedAnimationTimeNanos: Long = Long.MIN_VALUE private var memoizedDirection: InputDirection = InputDirection.Min // currentComputedValues output - private lateinit var memoizedComputedValues: ComputedValues + private var memoizedComputedValues: ComputedValues = + ComputedValues( + MotionSpec.InitiallyUndefined.segmentAtInput(memoizedInput, memoizedDirection), + GuaranteeState.Inactive, + DiscontinuityAnimation.None, + BreakpointHaptics.None, + ) internal val currentComputedValues: ComputedValues get() { val currentSpec: MotionSpec = spec + if (currentSpec == MotionSpec.InitiallyUndefined) { + requireNoMotionSpecSet() + return memoizedComputedValues + } + val currentInput: Float = currentInput val currentAnimationTimeNanos: Long = currentAnimationTimeNanos val currentDirection: InputDirection = currentDirection @@ -63,45 +76,58 @@ internal abstract class Computations : CurrentFrameInput, LastFrameState, Static return memoizedComputedValues } + val isInitialComputation = memoizedSpec == MotionSpec.InitiallyUndefined + memoizedSpec = currentSpec memoizedInput = currentInput memoizedAnimationTimeNanos = currentAnimationTimeNanos memoizedDirection = currentDirection - val segment: SegmentData = - computeSegmentData( - spec = currentSpec, - input = currentInput, - direction = currentDirection, - ) - - val segmentChange: SegmentChangeType = - getSegmentChangeType( - segment = segment, - input = currentInput, - direction = currentDirection, - ) - - val guarantee: GuaranteeState = - computeGuaranteeState( - segment = segment, - segmentChange = segmentChange, - input = currentInput, - ) - - val animation: DiscontinuityAnimation = - computeAnimation( - segment = segment, - guarantee = guarantee, - segmentChange = segmentChange, - spec = currentSpec, - input = currentInput, - animationTimeNanos = currentAnimationTimeNanos, - ) - - return ComputedValues(segment, guarantee, animation).also { - memoizedComputedValues = it - } + memoizedComputedValues = + if (isInitialComputation) { + ComputedValues( + currentSpec.segmentAtInput(currentInput, currentDirection), + GuaranteeState.Inactive, + DiscontinuityAnimation.None, + BreakpointHaptics.None, + ) + } else { + val segment: SegmentData = + computeSegmentData( + spec = currentSpec, + input = currentInput, + direction = currentDirection, + ) + + val segmentChange: SegmentChangeType = + getSegmentChangeType( + segment = segment, + input = currentInput, + direction = currentDirection, + ) + + val guarantee: GuaranteeState = + computeGuaranteeState( + segment = segment, + segmentChange = segmentChange, + input = currentInput, + ) + + val animation: DiscontinuityAnimation = + computeAnimation( + segment = segment, + guarantee = guarantee, + segmentChange = segmentChange, + spec = currentSpec, + input = currentInput, + animationTimeNanos = currentAnimationTimeNanos, + ) + + val breakpointHaptics = computeBreakpointHaptics(segment, segmentChange) + + ComputedValues(segment, guarantee, animation, breakpointHaptics) + } + return memoizedComputedValues } // currentSpringState input @@ -129,15 +155,15 @@ internal abstract class Computations : CurrentFrameInput, LastFrameState, Static lastSegment.spec == spec && lastSegment.isValidForInput(currentInput, currentDirection) - val output: Float + val computedOutput: Float get() = if (isSameSegmentAndAtRest) { lastSegment.mapping.map(currentInput) } else { - outputTarget + currentSpringState.displacement + computedOutputTarget + currentSpringState.displacement } - val outputTarget: Float + val computedOutputTarget: Float get() = if (isSameSegmentAndAtRest) { lastSegment.mapping.map(currentInput) @@ -145,7 +171,7 @@ internal abstract class Computations : CurrentFrameInput, LastFrameState, Static currentComputedValues.segment.mapping.map(currentInput) } - val isStable: Boolean + val computedIsStable: Boolean get() = if (isSameSegmentAndAtRest) { true @@ -153,7 +179,47 @@ internal abstract class Computations : CurrentFrameInput, LastFrameState, Static currentSpringState == SpringState.AtRest } - fun semanticState(semanticKey: SemanticKey): T? { + /** + * Determines if the output value is fixed. + * + * The output is considered fixed if the animation has settled and the input falls into a + * segment with a [Mapping.Fixed], and that mapping's value has not changed from the previous + * frame. + */ + val computedIsOutputFixed: Boolean + get() { + if (lastSpringState != SpringState.AtRest) { + // The spring is still settling. + return false + } + + val lastMapping = lastSegment.mapping + if (lastMapping !is Mapping.Fixed) { + // We need to compute a new output value. + return false + } + + val isSameSegment = + lastSegment.spec == spec && + lastSegment.isValidForInput(currentInput, currentDirection) + + return if (isSameSegment) { + // We are in the same fixed-value segment as the last frame. + true + } else { + val currentMapping = currentComputedValues.segment.mapping + if (currentMapping is Mapping.Fixed) { + // Both old and new mappings are fixed. The output is only considered fixed if + // their target values are identical. + lastMapping.value == currentMapping.value + } else { + // The new mapping isn't a fixed value. + false + } + } + } + + fun computedSemanticState(semanticKey: SemanticKey): T? { return with(if (isSameSegmentAndAtRest) lastSegment else currentComputedValues.segment) { spec.semanticState(semanticKey, key) } @@ -573,4 +639,43 @@ internal abstract class Computations : CurrentFrameInput, LastFrameState, Static } } } + + private fun computeBreakpointHaptics( + segment: SegmentData, + segmentChange: SegmentChangeType, + ): BreakpointHaptics? = + when (segmentChange) { + SegmentChangeType.Traverse -> segment.entryBreakpoint.breakpointHaptics + else -> null + } + + /** + * Precondition to ensure that this [Computations] has not yet been initialized with a + * MotionSpec other than [MotionSpec.InitiallyUndefined]. + * + * This precondition is added since the desired behavior of the MotionValue when toggling back + * to a [MotionSpec.InitiallyUndefined] spec is unclear. If there is a compelling usecase, this + * restriction could be lifted. + */ + private fun requireNoMotionSpecSet() { + // A MotionValue's spec can be MotionValue.Undefined initially. However, once a real spec + // has been set, it cannot be changed back to MotionValue.Undefined. + + require(memoizedSpec == MotionSpec.InitiallyUndefined) { + // memoizedSpec is only ever Undefined initially, before a motionSpec was set. + // This is used as a signal to detect if a user switches back to Undefined. + "MotionSpec must not be changed back to undefined!\n" + + " MotionValue: $label\n" + + " last MotionSpec: $memoizedSpec" + } + + // memoizedComputedValues must not have been reassigned either. + require( + with(memoizedComputedValues) { + segment.spec == MotionSpec.InitiallyUndefined && + guarantee == GuaranteeState.Inactive && + animation == DiscontinuityAnimation.None + } + ) + } } diff --git a/mechanics/src/com/android/mechanics/spec/Breakpoint.kt b/mechanics/src/com/android/mechanics/spec/Breakpoint.kt index 5ff18ed..fd92f2b 100644 --- a/mechanics/src/com/android/mechanics/spec/Breakpoint.kt +++ b/mechanics/src/com/android/mechanics/spec/Breakpoint.kt @@ -17,6 +17,7 @@ package com.android.mechanics.spec import androidx.compose.ui.util.fastIsFinite +import com.android.mechanics.haptics.BreakpointHaptics import com.android.mechanics.spring.SpringParameters /** @@ -65,12 +66,14 @@ class BreakpointKey(val debugLabel: String? = null, val identity: Any = Object() * @param spring Parameters of the spring used to animate the breakpoints discontinuity. * @param guarantee Optional constraints to accelerate the completion of the spring motion, based on * `MotionValue`'s input or other non-time signals. + * @param breakpointHaptics A description of haptics when the input crosses this breakpoint. */ data class Breakpoint( val key: BreakpointKey, val position: Float, val spring: SpringParameters, val guarantee: Guarantee, + val breakpointHaptics: BreakpointHaptics = BreakpointHaptics.None, ) : Comparable { init { @@ -89,6 +92,7 @@ data class Breakpoint( Float.NEGATIVE_INFINITY, SpringParameters.Snap, Guarantee.None, + BreakpointHaptics.None, ) /** Last breakpoint of each spec. */ @@ -98,6 +102,7 @@ data class Breakpoint( Float.POSITIVE_INFINITY, SpringParameters.Snap, Guarantee.None, + BreakpointHaptics.None, ) internal fun create( @@ -105,11 +110,19 @@ data class Breakpoint( breakpointPosition: Float, springSpec: SpringParameters, guarantee: Guarantee, + breakpointHaptics: BreakpointHaptics, ): Breakpoint { return when (breakpointKey) { BreakpointKey.MinLimit -> minLimit BreakpointKey.MaxLimit -> maxLimit - else -> Breakpoint(breakpointKey, breakpointPosition, springSpec, guarantee) + else -> + Breakpoint( + breakpointKey, + breakpointPosition, + springSpec, + guarantee, + breakpointHaptics, + ) } } } diff --git a/mechanics/src/com/android/mechanics/spec/Mapping.kt b/mechanics/src/com/android/mechanics/spec/Mapping.kt new file mode 100644 index 0000000..64a4a5d --- /dev/null +++ b/mechanics/src/com/android/mechanics/spec/Mapping.kt @@ -0,0 +1,110 @@ +/* + * Copyright (C) 2025 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.mechanics.spec + +import androidx.compose.ui.util.lerp + +/** + * Maps the `input` of a [MotionValue] to the desired output value. + * + * The mapping implementation can be arbitrary, but must not produce discontinuities. + */ +fun interface Mapping { + /** Computes the [MotionValue]'s target output, given the input. */ + fun map(input: Float): Float + + /** `f(x) = x` */ + object Identity : Mapping { + override fun map(input: Float): Float { + return input + } + + override fun toString(): String { + return "Identity" + } + } + + /** `f(x) = value` */ + data class Fixed(val value: Float) : Mapping { + init { + require(value.isFinite()) + } + + override fun map(input: Float): Float { + return value + } + } + + /** `f(x) = factor*x + offset` */ + data class Linear(val factor: Float, val offset: Float = 0f) : Mapping { + init { + require(factor.isFinite()) + require(offset.isFinite()) + } + + override fun map(input: Float): Float { + return input * factor + offset + } + } + + companion object { + val Zero = Fixed(0f) + val One = Fixed(1f) + val Two = Fixed(2f) + + /** Create a linear mapping defined as a line between {in0,out0} and {in1,out1}. */ + fun Linear(in0: Float, out0: Float, in1: Float, out1: Float): Linear { + require(in0 != in1) { + "Cannot define a linear function with both inputs being the same ($in0)." + } + + val factor = (out1 - out0) / (in1 - in0) + val offset = out0 - factor * in0 + return Linear(factor, offset) + } + } +} + +/** Convenience helper to create a linear mappings */ +object LinearMappings { + + /** + * Creates a mapping defined as two line segments between {in0,out0} -> {in1,out1}, and + * {in1,out1} -> {in2,out2}. + * + * The inputs must strictly be `in0 < in1 < in2` + */ + fun linearMappingWithPivot( + in0: Float, + out0: Float, + in1: Float, + out1: Float, + in2: Float, + out2: Float, + ): Mapping { + require(in0 < in1 && in1 < in2) + return Mapping { input -> + if (input <= in1) { + val t = (input - in0) / (in1 - in0) + lerp(out0, out1, t) + } else { + val t = (input - in1) / (in2 - in1) + lerp(out1, out2, t) + } + } + } +} diff --git a/mechanics/src/com/android/mechanics/spec/MotionSpec.kt b/mechanics/src/com/android/mechanics/spec/MotionSpec.kt index 4628804..19fd71e 100644 --- a/mechanics/src/com/android/mechanics/spec/MotionSpec.kt +++ b/mechanics/src/com/android/mechanics/spec/MotionSpec.kt @@ -17,6 +17,7 @@ package com.android.mechanics.spec import androidx.compose.ui.util.fastFirstOrNull +import com.android.mechanics.haptics.SegmentHaptics import com.android.mechanics.spring.SpringParameters /** @@ -31,12 +32,14 @@ import com.android.mechanics.spring.SpringParameters * caused by setting this new spec. * @param segmentHandlers allow for custom segment-change logic, when the `MotionValue` runtime * would leave the [SegmentKey]. + * @param semantics semantics applied to the complete [MotionSpec] */ data class MotionSpec( val maxDirection: DirectionalMotionSpec, val minDirection: DirectionalMotionSpec = maxDirection, val resetSpring: SpringParameters = DefaultResetSpring, val segmentHandlers: Map = emptyMap(), + val semantics: List> = emptyList(), ) { /** The [DirectionalMotionSpec] for the specified [direction]. */ @@ -52,6 +55,16 @@ data class MotionSpec( return get(segmentKey.direction).findSegmentIndex(segmentKey) != -1 } + /** + * The semantic state for [key], as defined for the [MotionSpec]. + * + * Returns `null` if no semantic value with [key] is defined. + */ + fun semanticState(key: SemanticKey): T? { + @Suppress("UNCHECKED_CAST") + return semantics.fastFirstOrNull { it.key == key }?.value as T? + } + /** * The semantic state for [key] at segment with [segmentKey]. * @@ -60,7 +73,8 @@ data class MotionSpec( */ fun semanticState(key: SemanticKey, segmentKey: SegmentKey): T? { with(get(segmentKey.direction)) { - val semanticValues = semantics.fastFirstOrNull { it.key == key } ?: return null + val semanticValues = + semantics.fastFirstOrNull { it.key == key } ?: return semanticState(key) val segmentIndex = findSegmentIndex(segmentKey) if (segmentIndex < 0) throw NoSuchElementException() @@ -106,6 +120,7 @@ data class MotionSpec( breakpoints[idx + 1], direction, mappings[idx], + haptics[idx], ) } } @@ -135,8 +150,21 @@ data class MotionSpec( */ private val DefaultResetSpring = SpringParameters(stiffness = 1400f, dampingRatio = 1f) - /* Empty motion spec, the output is the same as the input. */ - val Empty = MotionSpec(DirectionalMotionSpec.Empty) + /* Identity motion spec, the output is the same as the input. */ + val Identity = MotionSpec(DirectionalMotionSpec.Identity) + + /** + * Placeholder to indicate that a [MotionSpec] cannot be supplied yet. + * + * As long as this spec is set, the MotionValue output is NaN. When the MotionValue is first + * supplied with an actual spec, the output value will be set immediately, without an + * animation. + * + * This must only ever be supplied as a spec for new `MotionValue`s, which never were + * supplied any other spec. Supplying this [InitiallyUndefined] spec to a MotionValue that + * has already been supplied a spec will throw an exception. + */ + val InitiallyUndefined = MotionSpec(DirectionalMotionSpec.InitiallyUndefined) } } @@ -154,12 +182,14 @@ data class MotionSpec( * element, and [Breakpoint.maxLimit] as the last element. * @param mappings All mappings in between the breakpoints, thus must always contain * `breakpoints.size - 1` elements. - * @param semantics semantics provided by this spec, must only reference to breakpoint keys included - * in [breakpoints]. + * @param haptics All segment haptics in between the breakpoints, thus must always contain + * `breakpoints.size - 1` elements. + * @param semantics Semantics that apply to the [MotionSpec]. */ data class DirectionalMotionSpec( val breakpoints: List, val mappings: List, + val haptics: List = List(mappings.size) { SegmentHaptics.None }, val semantics: List> = emptyList(), ) { /** Maps all [BreakpointKey]s used in this spec to its index in [breakpoints]. */ @@ -173,6 +203,10 @@ data class DirectionalMotionSpec( "Breakpoints are not sorted ascending ${breakpoints.map { "${it.key}@${it.position}" }}" } require(mappings.size == breakpoints.size - 1) + require(haptics.size == breakpoints.size - 1) { + "${haptics.size} segment haptics were provided but ${breakpoints.size - 1} are " + + "required" + } breakpointIndexByKey = breakpoints.mapIndexed { index, breakpoint -> breakpoint.key to index }.toMap() @@ -229,11 +263,30 @@ data class DirectionalMotionSpec( override fun toString() = toDebugString() companion object { - /* Empty spec, the full input domain is mapped to output using [Mapping.identity]. */ - val Empty = + /* Identity spec, the full input domain is mapped to output using [Mapping.identity]. */ + val Identity = DirectionalMotionSpec( listOf(Breakpoint.minLimit, Breakpoint.maxLimit), listOf(Mapping.Identity), + listOf(SegmentHaptics.None), + ) + + /** Internal marker for [MotionSpec.InitiallyUndefined]. */ + internal val InitiallyUndefined = + DirectionalMotionSpec( + listOf(Breakpoint.minLimit, Breakpoint.maxLimit), + listOf( + object : Mapping { + override fun map(input: Float): Float { + return Float.NaN + } + + override fun toString(): String { + return "InitiallyUndefined" + } + } + ), + listOf(SegmentHaptics.None), ) } } diff --git a/mechanics/src/com/android/mechanics/spec/MotionSpecDebugFormatter.kt b/mechanics/src/com/android/mechanics/spec/MotionSpecDebugFormatter.kt index 9c7f9bd..9430f6f 100644 --- a/mechanics/src/com/android/mechanics/spec/MotionSpecDebugFormatter.kt +++ b/mechanics/src/com/android/mechanics/spec/MotionSpecDebugFormatter.kt @@ -16,6 +16,8 @@ package com.android.mechanics.spec +import com.android.mechanics.haptics.SegmentHaptics + /** Returns a string representation of the [MotionSpec] for debugging by humans. */ fun MotionSpec.toDebugString(): String { return buildString { @@ -47,6 +49,7 @@ fun DirectionalMotionSpec.toDebugString(): String { appendBreakpointLine(breakpoints.first()) for (i in mappings.indices) { appendMappingLine(mappings[i], indent = 2) + appendSegmentHapticsLine(haptics[i], indent = 2) semantics.forEach { appendSemanticsLine(it.key, it.values[i], indent = 4) } appendBreakpointLine(breakpoints[i + 1]) } @@ -79,6 +82,11 @@ private fun StringBuilder.appendBreakpointLine(breakpoint: Breakpoint, indent: I append(breakpoint.spring.dampingRatio) } + append(" [") + append("breakpointHaptics=") + append(breakpoint.breakpointHaptics.toString()) + append("]") + appendLine() } @@ -103,6 +111,15 @@ private fun StringBuilder.appendMappingLine(mapping: Mapping, indent: Int = 0) { appendLine() } +private fun StringBuilder.appendSegmentHapticsLine( + segmentHaptics: SegmentHaptics, + indent: Int = 0, +) { + appendIndent(indent) + append("segment haptics: $segmentHaptics") + appendLine() +} + private fun StringBuilder.appendSemanticsLine( semanticKey: SemanticKey<*>, value: Any?, diff --git a/mechanics/src/com/android/mechanics/spec/Segment.kt b/mechanics/src/com/android/mechanics/spec/Segment.kt index d3bce7b..f212b53 100644 --- a/mechanics/src/com/android/mechanics/spec/Segment.kt +++ b/mechanics/src/com/android/mechanics/spec/Segment.kt @@ -16,6 +16,8 @@ package com.android.mechanics.spec +import com.android.mechanics.haptics.SegmentHaptics + /** * Identifies a segment in a [MotionSpec]. * @@ -49,6 +51,7 @@ data class SegmentData( val maxBreakpoint: Breakpoint, val direction: InputDirection, val mapping: Mapping, + val haptics: SegmentHaptics, ) { val key = SegmentKey(minBreakpoint.key, maxBreakpoint.key, direction) @@ -89,67 +92,6 @@ data class SegmentData( get() = minBreakpoint.position..maxBreakpoint.position override fun toString(): String { - return "SegmentData(key=$key, range=$range, mapping=$mapping)" - } -} - -/** - * Maps the `input` of a [MotionValue] to the desired output value. - * - * The mapping implementation can be arbitrary, but must not produce discontinuities. - */ -fun interface Mapping { - /** Computes the [MotionValue]'s target output, given the input. */ - fun map(input: Float): Float - - /** `f(x) = x` */ - object Identity : Mapping { - override fun map(input: Float): Float { - return input - } - - override fun toString(): String { - return "Identity" - } - } - - /** `f(x) = value` */ - data class Fixed(val value: Float) : Mapping { - init { - require(value.isFinite()) - } - - override fun map(input: Float): Float { - return value - } - } - - /** `f(x) = factor*x + offset` */ - data class Linear(val factor: Float, val offset: Float = 0f) : Mapping { - init { - require(factor.isFinite()) - require(offset.isFinite()) - } - - override fun map(input: Float): Float { - return input * factor + offset - } - } - - companion object { - val Zero = Fixed(0f) - val One = Fixed(1f) - val Two = Fixed(2f) - - /** Create a linear mapping defined as a line between {in0,out0} and {in1,out1}. */ - fun Linear(in0: Float, out0: Float, in1: Float, out1: Float): Linear { - require(in0 != in1) { - "Cannot define a linear function with both inputs being the same ($in0)." - } - - val factor = (out1 - out0) / (in1 - in0) - val offset = out0 - factor * in0 - return Linear(factor, offset) - } + return "SegmentData(key=$key, range=$range, mapping=$mapping, segmentHaptics: $haptics)" } } diff --git a/mechanics/src/com/android/mechanics/spec/SegmentChangeHandler.kt b/mechanics/src/com/android/mechanics/spec/SegmentChangeHandler.kt index b6ce6ab..e1a16d9 100644 --- a/mechanics/src/com/android/mechanics/spec/SegmentChangeHandler.kt +++ b/mechanics/src/com/android/mechanics/spec/SegmentChangeHandler.kt @@ -45,4 +45,36 @@ object ChangeSegmentHandlers { it.isValidForInput(newInput, currentSegment.direction) } } + + /** + * When changing direction, modifies the mapping of the reverse segments so that the output + * values + * + * at the min/max breakpoint are the same, yet the value at the direction change position maps + * the current output value. + */ + val DirectionChangePreservesCurrentValue: OnChangeSegmentHandler = + { currentSegment, newInput, newDirection -> + val nextSegment = segmentAtInput(newInput, newDirection) + val minLimit = nextSegment.minBreakpoint.position + val maxLimit = nextSegment.maxBreakpoint.position + + if ( + currentSegment.direction == newDirection || + minLimit == newInput && newInput == maxLimit + ) { + nextSegment + } else { + val modifiedMapping = + LinearMappings.linearMappingWithPivot( + minLimit, + nextSegment.mapping.map(minLimit), + newInput, + currentSegment.mapping.map(newInput), + maxLimit, + nextSegment.mapping.map(maxLimit), + ) + nextSegment.copy(mapping = modifiedMapping) + } + } } diff --git a/mechanics/src/com/android/mechanics/spec/builder/DirectionalBuilderImpl.kt b/mechanics/src/com/android/mechanics/spec/builder/DirectionalBuilderImpl.kt index 994927f..a5c5e31 100644 --- a/mechanics/src/com/android/mechanics/spec/builder/DirectionalBuilderImpl.kt +++ b/mechanics/src/com/android/mechanics/spec/builder/DirectionalBuilderImpl.kt @@ -16,6 +16,9 @@ package com.android.mechanics.spec.builder +import com.android.mechanics.haptics.BreakpointHaptics +import com.android.mechanics.haptics.HapticsExperimentalApi +import com.android.mechanics.haptics.SegmentHaptics import com.android.mechanics.spec.Breakpoint import com.android.mechanics.spec.BreakpointKey import com.android.mechanics.spec.DirectionalMotionSpec @@ -38,6 +41,8 @@ internal open class DirectionalBuilderImpl( internal val breakpoints = mutableListOf(Breakpoint.minLimit) internal val semantics = mutableListOf>() internal val mappings = mutableListOf() + internal val segmentHaptics = mutableListOf() + private var currentSegmentHaptics: SegmentHaptics = SegmentHaptics.None private var sourceValue: Float = Float.NaN private var targetValue: Float = Float.NaN private var fractionalMapping: Float = Float.NaN @@ -51,11 +56,14 @@ internal open class DirectionalBuilderImpl( /** Prepares the builder for invoking the [DirectionalBuilderFn] on it. */ fun prepareBuilderFn( initialMapping: Mapping = Mapping.Identity, + initialSegmentHaptics: SegmentHaptics = SegmentHaptics.None, initialSemantics: List> = emptyList(), ) { check(mappings.size == breakpoints.size - 1) + check(segmentHaptics.size == breakpoints.size - 1) mappings.add(initialMapping) + segmentHaptics.add(initialSegmentHaptics) val semanticIndex = mappings.size - 1 initialSemantics.forEach { semantic -> getSemantics(semantic.key).apply { set(semanticIndex, semantic.value) } @@ -80,6 +88,7 @@ internal open class DirectionalBuilderImpl( fun finalizeBuilderFn( atPosition: Float, key: BreakpointKey, + breakpointHaptics: BreakpointHaptics, springSpec: SpringParameters, guarantee: Guarantee, semantics: List>, @@ -87,9 +96,13 @@ internal open class DirectionalBuilderImpl( if (!(targetValue.isNaN() && fractionalMapping.isNaN())) { // Finalizing will produce the mapping and breakpoint check(mappings.size == breakpoints.size - 1) + check(segmentHaptics.size == breakpoints.size - 1) } else { // Mapping is already added, this will add the breakpoint check(mappings.size == breakpoints.size) + check(segmentHaptics.size == breakpoints.size) { + "Total segment haptics: ${segmentHaptics.size}. A total of ${breakpoints.size} was expected" + } } if (key == BreakpointKey.MaxLimit) { @@ -103,13 +116,14 @@ internal open class DirectionalBuilderImpl( } toBreakpointImpl(atPosition, key, semantics) - doAddBreakpointImpl(springSpec, guarantee) + doAddBreakpointImpl(springSpec, guarantee, breakpointHaptics) } fun finalizeBuilderFn(breakpoint: Breakpoint) = finalizeBuilderFn( breakpoint.position, breakpoint.key, + breakpoint.breakpointHaptics, breakpoint.spring, breakpoint.guarantee, emptyList(), @@ -118,26 +132,33 @@ internal open class DirectionalBuilderImpl( /* Creates the [DirectionalMotionSpec] from the current builder state. */ fun build(): DirectionalMotionSpec { require(mappings.size == breakpoints.size - 1) + require(segmentHaptics.size == breakpoints.size - 1) check(breakpoints.last() == Breakpoint.maxLimit) val segmentCount = mappings.size val semantics = semantics.map { builder -> with(builder) { build(segmentCount) } } - return DirectionalMotionSpec(breakpoints.toList(), mappings.toList(), semantics) + return DirectionalMotionSpec( + breakpoints.toList(), + mappings.toList(), + segmentHaptics.toList(), + semantics, + ) } override fun target( breakpoint: Float, from: Float, to: Float, + breakpointHaptics: BreakpointHaptics, spring: SpringParameters, guarantee: Guarantee, key: BreakpointKey, semantics: List>, ) { toBreakpointImpl(breakpoint, key, semantics) - jumpToImpl(from, spring, guarantee) + jumpToImpl(from, spring, guarantee, breakpointHaptics) continueWithTargetValueImpl(to) } @@ -145,13 +166,14 @@ internal open class DirectionalBuilderImpl( breakpoint: Float, to: Float, delta: Float, + breakpointHaptics: BreakpointHaptics, spring: SpringParameters, guarantee: Guarantee, key: BreakpointKey, semantics: List>, ) { toBreakpointImpl(breakpoint, key, semantics) - jumpByImpl(delta, spring, guarantee) + jumpByImpl(delta, spring, guarantee, breakpointHaptics) continueWithTargetValueImpl(to) } @@ -159,13 +181,14 @@ internal open class DirectionalBuilderImpl( breakpoint: Float, from: Float, fraction: Float, + breakpointHaptics: BreakpointHaptics, spring: SpringParameters, guarantee: Guarantee, key: BreakpointKey, semantics: List>, ): CanBeLastSegment { toBreakpointImpl(breakpoint, key, semantics) - jumpToImpl(from, spring, guarantee) + jumpToImpl(from, spring, guarantee, breakpointHaptics) continueWithFractionalInputImpl(fraction) return CanBeLastSegmentImpl } @@ -174,13 +197,14 @@ internal open class DirectionalBuilderImpl( breakpoint: Float, fraction: Float, delta: Float, + breakpointHaptics: BreakpointHaptics, spring: SpringParameters, guarantee: Guarantee, key: BreakpointKey, semantics: List>, ): CanBeLastSegment { toBreakpointImpl(breakpoint, key, semantics) - jumpByImpl(delta, spring, guarantee) + jumpByImpl(delta, spring, guarantee, breakpointHaptics) continueWithFractionalInputImpl(fraction) return CanBeLastSegmentImpl } @@ -188,13 +212,14 @@ internal open class DirectionalBuilderImpl( override fun fixedValue( breakpoint: Float, value: Float, + breakpointHaptics: BreakpointHaptics, spring: SpringParameters, guarantee: Guarantee, key: BreakpointKey, semantics: List>, ): CanBeLastSegment { toBreakpointImpl(breakpoint, key, semantics) - jumpToImpl(value, spring, guarantee) + jumpToImpl(value, spring, guarantee, breakpointHaptics) continueWithFixedValueImpl() return CanBeLastSegmentImpl } @@ -202,13 +227,14 @@ internal open class DirectionalBuilderImpl( override fun fixedValueFromCurrent( breakpoint: Float, delta: Float, + breakpointHaptics: BreakpointHaptics, spring: SpringParameters, guarantee: Guarantee, key: BreakpointKey, semantics: List>, ): CanBeLastSegment { toBreakpointImpl(breakpoint, key, semantics) - jumpByImpl(delta, spring, guarantee) + jumpByImpl(delta, spring, guarantee, breakpointHaptics) continueWithFixedValueImpl() return CanBeLastSegmentImpl } @@ -219,10 +245,11 @@ internal open class DirectionalBuilderImpl( guarantee: Guarantee, key: BreakpointKey, semantics: List>, + breakpointHaptics: BreakpointHaptics, mapping: Mapping, ): CanBeLastSegment { toBreakpointImpl(breakpoint, key, semantics) - continueWithImpl(mapping, spring, guarantee) + continueWithImpl(mapping, spring, guarantee, breakpointHaptics) return CanBeLastSegmentImpl } @@ -242,28 +269,45 @@ internal open class DirectionalBuilderImpl( check(sourceValue.isFinite()) mappings.add(Mapping.Fixed(sourceValue)) + segmentHaptics.add(currentSegmentHaptics) sourceValue = Float.NaN } - private fun jumpToImpl(value: Float, spring: SpringParameters, guarantee: Guarantee) { + private fun jumpToImpl( + value: Float, + spring: SpringParameters, + guarantee: Guarantee, + breakpointHaptics: BreakpointHaptics, + ) { check(sourceValue.isNaN()) - doAddBreakpointImpl(spring, guarantee) + doAddBreakpointImpl(spring, guarantee, breakpointHaptics) sourceValue = value } - private fun jumpByImpl(delta: Float, spring: SpringParameters, guarantee: Guarantee) { + private fun jumpByImpl( + delta: Float, + spring: SpringParameters, + guarantee: Guarantee, + breakpointHaptics: BreakpointHaptics, + ) { check(sourceValue.isNaN()) - val breakpoint = doAddBreakpointImpl(spring, guarantee) + val breakpoint = doAddBreakpointImpl(spring, guarantee, breakpointHaptics) sourceValue = mappings.last().map(breakpoint.position) + delta } - private fun continueWithImpl(mapping: Mapping, spring: SpringParameters, guarantee: Guarantee) { + private fun continueWithImpl( + mapping: Mapping, + spring: SpringParameters, + guarantee: Guarantee, + breakpointHaptics: BreakpointHaptics, + ) { check(sourceValue.isNaN()) - doAddBreakpointImpl(spring, guarantee) + doAddBreakpointImpl(spring, guarantee, breakpointHaptics) mappings.add(mapping) + segmentHaptics.add(currentSegmentHaptics) } private fun toBreakpointImpl( @@ -301,6 +345,7 @@ internal open class DirectionalBuilderImpl( } mappings.add(mapping) + segmentHaptics.add(currentSegmentHaptics) targetValue = Float.NaN sourceValue = Float.NaN fractionalMapping = Float.NaN @@ -320,6 +365,7 @@ internal open class DirectionalBuilderImpl( private fun doAddBreakpointImpl( springSpec: SpringParameters, guarantee: Guarantee, + breakpointHaptics: BreakpointHaptics, ): Breakpoint { val breakpoint = Breakpoint.create( @@ -327,6 +373,7 @@ internal open class DirectionalBuilderImpl( breakpointPosition, springSpec, guarantee, + breakpointHaptics, ) breakpoints.add(breakpoint) @@ -335,6 +382,27 @@ internal open class DirectionalBuilderImpl( return breakpoint } + + private fun beginHaptics(segmentHaptics: SegmentHaptics) { + currentSegmentHaptics = segmentHaptics + } + + private fun endHaptics() { + currentSegmentHaptics = SegmentHaptics.None + } + + @HapticsExperimentalApi + override fun haptics( + segmentHaptics: SegmentHaptics, + block: DirectionalBuilderScope.() -> T, + ) { + beginHaptics(segmentHaptics) + try { + block() + } finally { + endHaptics() + } + } } internal class SegmentSemanticValuesBuilder(val key: SemanticKey) { diff --git a/mechanics/src/com/android/mechanics/spec/builder/DirectionalBuilderScope.kt b/mechanics/src/com/android/mechanics/spec/builder/DirectionalBuilderScope.kt index 9eacd8f..8fef629 100644 --- a/mechanics/src/com/android/mechanics/spec/builder/DirectionalBuilderScope.kt +++ b/mechanics/src/com/android/mechanics/spec/builder/DirectionalBuilderScope.kt @@ -16,8 +16,10 @@ package com.android.mechanics.spec.builder +import com.android.mechanics.haptics.BreakpointHaptics +import com.android.mechanics.haptics.HapticsExperimentalApi +import com.android.mechanics.haptics.SegmentHaptics import com.android.mechanics.spec.BreakpointKey -import com.android.mechanics.spec.DirectionalMotionSpec import com.android.mechanics.spec.Guarantee import com.android.mechanics.spec.Mapping import com.android.mechanics.spec.SemanticKey @@ -48,6 +50,7 @@ interface DirectionalBuilderScope { * @param from The output value at the previous breakpoint, explicitly setting the starting * point for the linear mapping. * @param to The desired output value at the new breakpoint. + * @param breakpointHaptics Haptics at the breakpoint that ends the current segment. * @param spring The [SpringParameters] for the transition to this breakpoint. Defaults to * [defaultSpring]. * @param guarantee The animation guarantee for this transition. Defaults to [Guarantee.None]. @@ -59,6 +62,7 @@ interface DirectionalBuilderScope { breakpoint: Float, from: Float, to: Float, + breakpointHaptics: BreakpointHaptics = BreakpointHaptics.None, spring: SpringParameters = defaultSpring, guarantee: Guarantee = Guarantee.None, key: BreakpointKey = BreakpointKey(), @@ -77,6 +81,7 @@ interface DirectionalBuilderScope { * next. * @param to The desired output value at the new breakpoint. * @param delta An optional offset to apply to the calculated starting value. Defaults to 0f. + * @param breakpointHaptics Haptics at the breakpoint that ends the current segment. * @param spring The [SpringParameters] for the transition to this breakpoint. Defaults to * [defaultSpring]. * @param guarantee The animation guarantee for this transition. Defaults to [Guarantee.None]. @@ -88,6 +93,7 @@ interface DirectionalBuilderScope { breakpoint: Float, to: Float, delta: Float = 0f, + breakpointHaptics: BreakpointHaptics = BreakpointHaptics.None, spring: SpringParameters = defaultSpring, guarantee: Guarantee = Guarantee.None, key: BreakpointKey = BreakpointKey(), @@ -107,6 +113,7 @@ interface DirectionalBuilderScope { * point for the linear mapping. * @param fraction The fractional multiplier applied to the input difference between * breakpoints. + * @param breakpointHaptics Haptics at the breakpoint that ends the current segment. * @param spring The [SpringParameters] for the transition to this breakpoint. Defaults to * [defaultSpring]. * @param guarantee The animation guarantee for this transition. Defaults to [Guarantee.None]. @@ -118,6 +125,7 @@ interface DirectionalBuilderScope { breakpoint: Float, from: Float, fraction: Float, + breakpointHaptics: BreakpointHaptics = BreakpointHaptics.None, spring: SpringParameters = defaultSpring, guarantee: Guarantee = Guarantee.None, key: BreakpointKey = BreakpointKey(), @@ -136,6 +144,7 @@ interface DirectionalBuilderScope { * @param fraction The fractional multiplier applied to the input difference between * breakpoints. * @param delta An optional offset to apply to the calculated starting value. Defaults to 0f. + * @param breakpointHaptics Haptics at the breakpoint that ends the current segment. * @param spring The [SpringParameters] for the transition to this breakpoint. Defaults to * [defaultSpring]. * @param guarantee The animation guarantee for this transition. Defaults to [Guarantee.None]. @@ -147,6 +156,7 @@ interface DirectionalBuilderScope { breakpoint: Float, fraction: Float, delta: Float = 0f, + breakpointHaptics: BreakpointHaptics = BreakpointHaptics.None, spring: SpringParameters = defaultSpring, guarantee: Guarantee = Guarantee.None, key: BreakpointKey = BreakpointKey(), @@ -162,6 +172,7 @@ interface DirectionalBuilderScope { * @param breakpoint The breakpoint defining the end of the current segment and the start of the * next. * @param value The constant output value for this segment. + * @param breakpointHaptics Haptics at the breakpoint that ends the current segment. * @param spring The [SpringParameters] for the transition to this breakpoint. Defaults to * [defaultSpring]. * @param guarantee The animation guarantee for this transition. Defaults to [Guarantee.None]. @@ -172,6 +183,7 @@ interface DirectionalBuilderScope { fun fixedValue( breakpoint: Float, value: Float, + breakpointHaptics: BreakpointHaptics = BreakpointHaptics.None, spring: SpringParameters = defaultSpring, guarantee: Guarantee = Guarantee.None, key: BreakpointKey = BreakpointKey(), @@ -189,6 +201,7 @@ interface DirectionalBuilderScope { * next. * @param delta An optional offset to apply to the mapped value to determine the fixed value. * Defaults to 0f. + * @param breakpointHaptics Haptics at the breakpoint that ends the current segment. * @param spring The [SpringParameters] for the transition to this breakpoint. Defaults to * [defaultSpring]. * @param guarantee The animation guarantee for this transition. Defaults to [Guarantee.None]. @@ -199,6 +212,7 @@ interface DirectionalBuilderScope { fun fixedValueFromCurrent( breakpoint: Float, delta: Float = 0f, + breakpointHaptics: BreakpointHaptics = BreakpointHaptics.None, spring: SpringParameters = defaultSpring, guarantee: Guarantee = Guarantee.None, key: BreakpointKey = BreakpointKey(), @@ -219,6 +233,7 @@ interface DirectionalBuilderScope { * @param key A unique [BreakpointKey] for this breakpoint. Defaults to a newly generated key. * @param semantics Updated semantics values to be applied. Must be a subset of the * [SemanticKey]s used when first creating this builder. + * @param breakpointHaptics Haptics at the breakpoint that ends the current segment. * @param mapping The custom [Mapping] to use. */ fun mapping( @@ -227,6 +242,7 @@ interface DirectionalBuilderScope { guarantee: Guarantee = Guarantee.None, key: BreakpointKey = BreakpointKey(), semantics: List> = emptyList(), + breakpointHaptics: BreakpointHaptics = BreakpointHaptics.None, mapping: Mapping, ): CanBeLastSegment @@ -239,6 +255,7 @@ interface DirectionalBuilderScope { * @param breakpoint The breakpoint defining the end of the current segment and the start of the * next. * @param delta An optional offset to apply to the mapped value to determine the fixed value. + * @param breakpointHaptics Haptics at the breakpoint that ends the current segment. * @param spring The [SpringParameters] for the transition to this breakpoint. * @param guarantee The animation guarantee for this transition. * @param key A unique [BreakpointKey] for this breakpoint. @@ -248,17 +265,27 @@ interface DirectionalBuilderScope { fun identity( breakpoint: Float, delta: Float = 0f, + breakpointHaptics: BreakpointHaptics = BreakpointHaptics.None, spring: SpringParameters = defaultSpring, guarantee: Guarantee = Guarantee.None, key: BreakpointKey = BreakpointKey(), semantics: List> = emptyList(), ): CanBeLastSegment { return if (delta == 0f) { - mapping(breakpoint, spring, guarantee, key, semantics, Mapping.Identity) + mapping( + breakpoint, + spring, + guarantee, + key, + semantics, + breakpointHaptics, + Mapping.Identity, + ) } else { fractionalInput( breakpoint, fraction = 1f, + breakpointHaptics = breakpointHaptics, from = breakpoint + delta, spring = spring, guarantee = guarantee, @@ -267,6 +294,16 @@ interface DirectionalBuilderScope { ) } } + + /** + * Builds the [DirectionalMotionSpec] according to the given [block] with the given + * [SegmentHaptics]. + * + * Within the block, one or more segments can be defined and the same type of haptics will be + * delivered during interactions with the segments. + */ + @HapticsExperimentalApi + fun haptics(segmentHaptics: SegmentHaptics, block: DirectionalBuilderScope.() -> T) } /** Marker interface to indicate that a segment can be the last one in a [DirectionalMotionSpec]. */ diff --git a/mechanics/src/com/android/mechanics/spec/builder/DirectionalSpecBuilder.kt b/mechanics/src/com/android/mechanics/spec/builder/DirectionalSpecBuilder.kt index b4483b7..21c1007 100644 --- a/mechanics/src/com/android/mechanics/spec/builder/DirectionalSpecBuilder.kt +++ b/mechanics/src/com/android/mechanics/spec/builder/DirectionalSpecBuilder.kt @@ -16,6 +16,7 @@ package com.android.mechanics.spec.builder +import com.android.mechanics.haptics.SegmentHaptics import com.android.mechanics.spec.Breakpoint import com.android.mechanics.spec.DirectionalMotionSpec import com.android.mechanics.spec.Mapping @@ -115,6 +116,7 @@ fun directionalMotionSpec( */ fun directionalMotionSpec( mapping: Mapping = Mapping.Identity, + segmentHaptics: SegmentHaptics = SegmentHaptics.None, semantics: List> = emptyList(), ): DirectionalMotionSpec { fun toSegmentSemanticValues(semanticValue: SemanticValue) = @@ -123,6 +125,7 @@ fun directionalMotionSpec( return DirectionalMotionSpec( listOf(Breakpoint.minLimit, Breakpoint.maxLimit), listOf(mapping), + listOf(segmentHaptics), semantics.map { toSegmentSemanticValues(it) }, ) } diff --git a/mechanics/src/com/android/mechanics/spec/builder/EffectApplyScope.kt b/mechanics/src/com/android/mechanics/spec/builder/EffectApplyScope.kt index 920b58b..347d429 100644 --- a/mechanics/src/com/android/mechanics/spec/builder/EffectApplyScope.kt +++ b/mechanics/src/com/android/mechanics/spec/builder/EffectApplyScope.kt @@ -16,6 +16,8 @@ package com.android.mechanics.spec.builder +import com.android.mechanics.haptics.BreakpointHaptics +import com.android.mechanics.haptics.SegmentHaptics import com.android.mechanics.spec.Guarantee import com.android.mechanics.spec.Mapping import com.android.mechanics.spec.OnChangeSegmentHandler @@ -26,7 +28,7 @@ import com.android.mechanics.spring.SpringParameters /** * Defines the contract for applying [Effect]s within a [MotionSpecBuilder] * - * Provides methods to define breakpoints and mappings for the motion specification. + * Provides methods to define breakpoints, mappings and haptics for the motion specification. * * Breakpoints for [minLimit] and [maxLimit] will be created, with the specified key and parameters. */ @@ -90,12 +92,14 @@ interface EffectApplyScope : MotionBuilderContext { * spec, unless redefined in another spec. * * @param initialMapping [Mapping] for the first segment after [minLimit]. + * @param initialSegmentHaptics [SegmentHaptics] for the first segment after [minLimit] * @param semantics Initial semantics for the effect. * @param init Configures the effect's spec using [DirectionalBuilderScope]. * @see com.android.mechanics.spec.directionalMotionSpec for in-depth documentation. */ fun forward( initialMapping: Mapping, + initialSegmentHaptics: SegmentHaptics = SegmentHaptics.None, semantics: List> = emptyList(), init: DirectionalEffectBuilderScope.() -> Unit, ) @@ -171,6 +175,7 @@ interface DirectionalEffectBuilderScope : DirectionalBuilderScope { guarantee: Guarantee? = null, semantics: List>? = null, mapping: Mapping? = null, + breakpointHaptics: BreakpointHaptics? = null, ) fun after( @@ -178,5 +183,6 @@ interface DirectionalEffectBuilderScope : DirectionalBuilderScope { guarantee: Guarantee? = null, semantics: List>? = null, mapping: Mapping? = null, + breakpointHaptics: BreakpointHaptics? = null, ) } diff --git a/mechanics/src/com/android/mechanics/spec/builder/MotionBuilderContext.kt b/mechanics/src/com/android/mechanics/spec/builder/MotionBuilderContext.kt index 989d481..de8ab3b 100644 --- a/mechanics/src/com/android/mechanics/spec/builder/MotionBuilderContext.kt +++ b/mechanics/src/com/android/mechanics/spec/builder/MotionBuilderContext.kt @@ -23,6 +23,8 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MotionScheme import androidx.compose.runtime.Composable import androidx.compose.runtime.remember +import androidx.compose.ui.node.CompositionLocalConsumerModifierNode +import androidx.compose.ui.node.currentValueOf import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.unit.Density import com.android.mechanics.spring.SpringParameters @@ -32,7 +34,8 @@ import com.android.mechanics.spring.SpringParameters * * See go/motion-system. * - * @see rememberMotionBuilderContext for Compose + * @see rememberMotionBuilderContext for Compose (in composition) + * @see motionBuilderContext for Compose (in Modifier.Node) * @see standardViewMotionBuilderContext for Views * @see expressiveViewMotionBuilderContext for Views */ @@ -84,7 +87,20 @@ fun rememberMotionBuilderContext(): MotionBuilderContext { return remember(density, motionScheme) { ComposeMotionBuilderContext(motionScheme, density) } } -class ComposeMotionBuilderContext(motionScheme: MotionScheme, density: Density) : +/** + * [MotionBuilderContext] for building motion specs in a [androidx.compose.ui.Modifier.Node]. + * + * This should be read when the node is attached. + */ +fun CompositionLocalConsumerModifierNode.motionBuilderContext(): ComposeMotionBuilderContext { + return ComposeMotionBuilderContext( + motionScheme = currentValueOf(MaterialTheme.LocalMotionScheme), + density = currentValueOf(LocalDensity), + ) +} + +class ComposeMotionBuilderContext +internal constructor(motionScheme: MotionScheme, density: Density) : MotionBuilderContext, Density by density { override val spatial = diff --git a/mechanics/src/com/android/mechanics/spec/builder/MotionSpecBuilder.kt b/mechanics/src/com/android/mechanics/spec/builder/MotionSpecBuilder.kt index de62c44..f6668f7 100644 --- a/mechanics/src/com/android/mechanics/spec/builder/MotionSpecBuilder.kt +++ b/mechanics/src/com/android/mechanics/spec/builder/MotionSpecBuilder.kt @@ -33,9 +33,9 @@ fun MotionBuilderContext.spatialMotionSpec( baseMapping: Mapping = Mapping.Identity, defaultSpring: SpringParameters = this.spatial.default, resetSpring: SpringParameters = defaultSpring, - baseSemantics: List> = emptyList(), + semantics: List> = emptyList(), init: MotionSpecBuilderScope.() -> Unit, -) = motionSpec(baseMapping, defaultSpring, resetSpring, baseSemantics, init) +) = motionSpec(baseMapping, defaultSpring, resetSpring, semantics, init) /** * Creates a [MotionSpec] for an effects value. @@ -49,9 +49,9 @@ fun MotionBuilderContext.effectsMotionSpec( baseMapping: Mapping = Mapping.Zero, defaultSpring: SpringParameters = this.effects.default, resetSpring: SpringParameters = defaultSpring, - baseSemantics: List> = emptyList(), + semantics: List> = emptyList(), init: MotionSpecBuilderScope.() -> Unit, -) = motionSpec(baseMapping, defaultSpring, resetSpring, baseSemantics, init) +) = motionSpec(baseMapping, defaultSpring, resetSpring, semantics, init) /** * Creates a [MotionSpec], based on reusable effects. @@ -61,21 +61,21 @@ fun MotionBuilderContext.effectsMotionSpec( * unless otherwise specified. * @param resetSpring spring parameters to animate a difference in output, if the difference is * caused by setting this new spec. - * @param baseSemantics initial semantics that apply before of effects override them. + * @param semantics initial semantics that apply before of effects override them. * @param init */ fun MotionBuilderContext.motionSpec( baseMapping: Mapping, defaultSpring: SpringParameters, resetSpring: SpringParameters = defaultSpring, - baseSemantics: List> = emptyList(), + semantics: List> = emptyList(), init: MotionSpecBuilderScope.() -> Unit, ): MotionSpec { return MotionSpecBuilderImpl( baseMapping, defaultSpring, resetSpring, - baseSemantics, + semantics, motionBuilderContext = this, ) .apply(init) @@ -121,8 +121,9 @@ fun MotionBuilderContext.fixedValueSpec( semantics: List> = emptyList(), ): MotionSpec { return MotionSpec( - directionalMotionSpec(Mapping.Fixed(value), semantics), + directionalMotionSpec(Mapping.Fixed(value)), resetSpring = resetSpring, + semantics = semantics, ) } diff --git a/mechanics/src/com/android/mechanics/spec/builder/MotionSpecBuilderImpl.kt b/mechanics/src/com/android/mechanics/spec/builder/MotionSpecBuilderImpl.kt index 75b9953..c60aae2 100644 --- a/mechanics/src/com/android/mechanics/spec/builder/MotionSpecBuilderImpl.kt +++ b/mechanics/src/com/android/mechanics/spec/builder/MotionSpecBuilderImpl.kt @@ -23,6 +23,8 @@ import androidx.collection.MutableIntObjectMap import androidx.collection.MutableLongList import androidx.collection.ObjectList import androidx.collection.mutableObjectListOf +import com.android.mechanics.haptics.BreakpointHaptics +import com.android.mechanics.haptics.SegmentHaptics import com.android.mechanics.spec.Breakpoint import com.android.mechanics.spec.BreakpointKey import com.android.mechanics.spec.Guarantee @@ -56,13 +58,17 @@ internal class MotionSpecBuilderImpl( fun build(): MotionSpec { if (placedEffects.isEmpty()) { - return MotionSpec(directionalMotionSpec(baseMapping), resetSpring = resetSpring) + return MotionSpec( + directionalMotionSpec(baseMapping), + resetSpring = resetSpring, + semantics = baseSemantics, + ) } builders = mutableObjectListOf( - DirectionalEffectBuilderScopeImpl(defaultSpring, baseSemantics), - DirectionalEffectBuilderScopeImpl(defaultSpring, baseSemantics), + DirectionalEffectBuilderScopeImpl(defaultSpring), + DirectionalEffectBuilderScopeImpl(defaultSpring), ) segmentHandlers = mutableMapOf() @@ -104,6 +110,7 @@ internal class MotionSpecBuilderImpl( builders[1].build(), resetSpring, segmentHandlers.toMap(), + semantics = baseSemantics, ) } @@ -323,7 +330,7 @@ internal class MotionSpecBuilderImpl( semantics: List>, init: DirectionalEffectBuilderScope.() -> Unit, ) { - forward(initialMapping, semantics, init) + forward(initialMapping, SegmentHaptics.None, semantics, init) backward(initialMapping, semantics, init) } @@ -334,13 +341,14 @@ internal class MotionSpecBuilderImpl( override fun forward( initialMapping: Mapping, + initialSegmentHaptics: SegmentHaptics, semantics: List>, init: DirectionalEffectBuilderScope.() -> Unit, ) { check(!forwardInvoked) { "Cannot define forward spec more than once" } forwardInvoked = true - forwardBuilder.prepareBuilderFn(initialMapping, semantics) + forwardBuilder.prepareBuilderFn(initialMapping, initialSegmentHaptics, semantics) forwardBuilder.init() } @@ -348,7 +356,7 @@ internal class MotionSpecBuilderImpl( check(!forwardInvoked) { "Cannot define forward spec more than once" } forwardInvoked = true - forwardBuilder.prepareBuilderFn(mapping, semantics) + forwardBuilder.prepareBuilderFn(mapping, SegmentHaptics.None, semantics) } override fun backward( @@ -359,7 +367,7 @@ internal class MotionSpecBuilderImpl( check(!backwardInvoked) { "Cannot define backward spec more than once" } backwardInvoked = true - reverseBuilder.prepareBuilderFn(initialMapping, semantics) + reverseBuilder.prepareBuilderFn(initialMapping, SegmentHaptics.None, semantics) reverseBuilder.init() } @@ -367,7 +375,7 @@ internal class MotionSpecBuilderImpl( check(!backwardInvoked) { "Cannot define backward spec more than once" } backwardInvoked = true - reverseBuilder.prepareBuilderFn(mapping, semantics) + reverseBuilder.prepareBuilderFn(mapping, SegmentHaptics.None, semantics) } private var forwardInvoked = false @@ -384,9 +392,16 @@ internal class MotionSpecBuilderImpl( if (effectId == NoEffectPlaceholderId) { val maxBreakpoint = - Breakpoint.create(maxLimitKey, actualPlacement.max, defaultSpring, Guarantee.None) + Breakpoint.create( + maxLimitKey, + actualPlacement.max, + defaultSpring, + Guarantee.None, + BreakpointHaptics.None, + ) builders.forEach { builder -> builder.mappings += builder.afterMapping ?: baseMapping + builder.segmentHaptics += SegmentHaptics.None builder.breakpoints += maxBreakpoint } return @@ -422,6 +437,7 @@ internal class MotionSpecBuilderImpl( builder.finalizeBuilderFn( actualPlacement.max, maxLimitKey, + builder.afterBreakpointHaptics ?: BreakpointHaptics.None, builder.afterSpring ?: defaultSpring, builder.afterGuarantee ?: Guarantee.None, builder.afterSemantics ?: emptyList(), @@ -452,43 +468,48 @@ internal class MotionSpecBuilderImpl( } } -private class DirectionalEffectBuilderScopeImpl( - defaultSpring: SpringParameters, - baseSemantics: List>, -) : DirectionalBuilderImpl(defaultSpring, baseSemantics), DirectionalEffectBuilderScope { +private class DirectionalEffectBuilderScopeImpl(defaultSpring: SpringParameters) : + DirectionalBuilderImpl(defaultSpring, baseSemantics = emptyList()), + DirectionalEffectBuilderScope { var beforeGuarantee: Guarantee? = null var beforeSpring: SpringParameters? = null var beforeSemantics: List>? = null var beforeMapping: Mapping? = null + var beforeBreakpointHaptics: BreakpointHaptics? = null override fun before( spring: SpringParameters?, guarantee: Guarantee?, semantics: List>?, mapping: Mapping?, + breakpointHaptics: BreakpointHaptics?, ) { beforeGuarantee = guarantee beforeSpring = spring beforeSemantics = semantics beforeMapping = mapping + beforeBreakpointHaptics = breakpointHaptics } var afterGuarantee: Guarantee? = null var afterSpring: SpringParameters? = null var afterSemantics: List>? = null var afterMapping: Mapping? = null + var afterBreakpointHaptics: BreakpointHaptics? = null override fun after( spring: SpringParameters?, guarantee: Guarantee?, semantics: List>?, mapping: Mapping?, + breakpointHaptics: BreakpointHaptics?, ) { afterGuarantee = guarantee afterSpring = spring afterSemantics = semantics afterMapping = mapping + afterBreakpointHaptics = breakpointHaptics } fun resetBeforeAfter() { @@ -500,6 +521,8 @@ private class DirectionalEffectBuilderScopeImpl( afterSpring = null afterSemantics = null afterMapping = null + afterBreakpointHaptics = null + beforeBreakpointHaptics = null } } diff --git a/mechanics/src/com/android/mechanics/view/ViewMotionValue.kt b/mechanics/src/com/android/mechanics/view/ViewMotionValue.kt index 617e363..f708cb9 100644 --- a/mechanics/src/com/android/mechanics/view/ViewMotionValue.kt +++ b/mechanics/src/com/android/mechanics/view/ViewMotionValue.kt @@ -49,7 +49,7 @@ class ViewMotionValue constructor( initialInput: Float, gestureContext: ViewGestureContext, - initialSpec: MotionSpec = MotionSpec.Empty, + initialSpec: MotionSpec = MotionSpec.Identity, label: String? = null, stableThreshold: Float = StableThresholdEffect, ) : DisposableHandle { @@ -69,7 +69,7 @@ constructor( var spec: MotionSpec by impl::spec /** Animated [output] value. */ - val output: Float by impl::output + val output: Float by impl::computedOutput /** * [output] value, but without animations. @@ -78,10 +78,10 @@ constructor( * * While [isStable], [outputTarget] and [output] are the same value. */ - val outputTarget: Float by impl::outputTarget + val outputTarget: Float by impl::computedOutputTarget /** Whether an animation is currently running. */ - val isStable: Boolean by impl::isStable + val isStable: Boolean by impl::computedIsStable /** * The current value for the [SemanticKey]. @@ -89,7 +89,7 @@ constructor( * `null` if not defined in the spec. */ operator fun get(key: SemanticKey): T? { - return impl.semanticState(key) + return impl.computedSemanticState(key) } /** The current segment used to compute the output. */ @@ -140,6 +140,7 @@ constructor( impl.lastSpringState, impl.lastSegment, impl.lastAnimation, + impl.computedIsOutputFixed, ), impl.isActive, impl.animationFrameDriver.isRunning, @@ -204,6 +205,7 @@ private class ImperativeComputations( override var lastInput: Float = currentInput override var lastGestureDragOffset: Float = currentGestureDragOffset override var directMappedVelocity: Float = 0f + override var lastHapticsTimeNanos: Long = -1L var lastDirection: InputDirection = currentDirection // ---- Lifecycle ------------------------------------------------------------------------------ @@ -221,9 +223,9 @@ private class ImperativeComputations( repeatMode = ValueAnimator.RESTART repeatCount = ValueAnimator.INFINITE start() - pause() addUpdateListener { val isAnimationFinished = updateOutputValue(currentPlayTime) + debugInspector?.isAnimating = !isAnimationFinished if (isAnimationFinished) { pause() } @@ -233,14 +235,12 @@ private class ImperativeComputations( fun ensureFrameRequested() { if (animationFrameDriver.isPaused) { animationFrameDriver.resume() - debugInspector?.isAnimating = true } } fun pauseFrameRequests() { if (animationFrameDriver.isRunning) { animationFrameDriver.pause() - debugInspector?.isAnimating = false } } @@ -285,9 +285,12 @@ private class ImperativeComputations( currentSpringState, currentValues.segment, currentValues.animation, + computedIsOutputFixed, ) } + if (currentValues.segment.spec == MotionSpec.InitiallyUndefined) return true + listeners.fastForEach { it.onMotionValueUpdated(motionValue) } // Prepare last* state @@ -298,7 +301,7 @@ private class ImperativeComputations( directMappedVelocity = 0f } - var isAnimationFinished = isStable + var isAnimationFinished = computedIsStable if (lastSegment != currentValues.segment) { lastSegment = currentValues.segment isAnimationFinished = false diff --git a/mechanics/testing/src/com/android/mechanics/testing/ComposeMotionValueCollectionToolkit.kt b/mechanics/testing/src/com/android/mechanics/testing/ComposeMotionValueCollectionToolkit.kt new file mode 100644 index 0000000..6f6ff41 --- /dev/null +++ b/mechanics/testing/src/com/android/mechanics/testing/ComposeMotionValueCollectionToolkit.kt @@ -0,0 +1,209 @@ +/* + * Copyright (C) 2025 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +@file:OptIn(ExperimentalCoroutinesApi::class) + +package com.android.mechanics.testing + +import android.annotation.SuppressLint +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshots.Snapshot +import com.android.mechanics.DistanceGestureContext +import com.android.mechanics.ManagedMotionValue +import com.android.mechanics.MotionValueCollection +import com.android.mechanics.spec.InputDirection +import com.android.mechanics.spec.MotionSpec +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.drop +import kotlinx.coroutines.flow.take +import kotlinx.coroutines.flow.takeWhile +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import platform.test.motion.MotionTestRule +import platform.test.motion.compose.runMonotonicClockTest +import platform.test.motion.golden.FeatureCapture +import platform.test.motion.golden.FrameId +import platform.test.motion.golden.TimeSeries +import platform.test.motion.golden.TimestampFrameId +import platform.test.motion.golden.asDataPoint + +interface CollectionInputScope : InputScope { + val motionValues: Set + + fun motionValueWithLabel(label: String): ManagedMotionValue? +} + +/** Toolkit to support [MotionValueCollection] motion tests. */ +object ComposeMotionValueCollectionToolkit : + MotionValueToolkit< + CollectionInputScope, + MotionValueCollection, + ManagedMotionValue, + DistanceGestureContext, + >() { + + @SuppressLint("VisibleForTests") + override fun goldenTest( + motionTestRule: MotionTestRule<*>, + spec: MotionSpec, + createDerived: (underTest: MotionValueCollection) -> List, + initialValue: Float, + initialDirection: InputDirection, + directionChangeSlop: Float, + stableThreshold: Float, + verifyTimeSeries: TimeSeries.() -> VerifyTimeSeriesResult, + capture: CaptureTimeSeriesFn, + testInput: suspend CollectionInputScope.() -> Unit, + ) = runMonotonicClockTest { + val frameEmitter = MutableStateFlow(0L) + val testHarness = + ComposeMotionValueCollectionTestHarness( + frameEmitter.asStateFlow(), + spec, + initialValue, + initialDirection, + directionChangeSlop, + stableThreshold, + ) + val underTest = testHarness.underTest + testHarness.createMotionValue("primary", testHarness::spec) + createDerived(underTest) + + val motionValueCaptures = buildList { + testHarness.motionValues.forEach { + add(MotionValueCapture(it.debugInspector(), "${it.label}-")) + } + } + + val collectionCapture = GenericValueCapture(testHarness.underTest) + + val keepRunningJob = launch { underTest.keepRunning() } + + val latch = CompletableDeferred() + + val recordingJob = launch { + latch.await() + testInput.invoke(testHarness) + } + val frameIds = mutableListOf() + + fun recordFrame(frameId: TimestampFrameId) { + + frameIds.add(frameId) + + collectionCapture.captureCurrentFrame { + feature(FeatureCapture("input") { it.currentInput.asDataPoint() }) + feature( + FeatureCapture("gestureDirection") { it.currentDirection.name.asDataPoint() } + ) + } + motionValueCaptures.forEach { it.captureCurrentFrame(capture) } + } + + runBlocking(Dispatchers.Main) { + while (!underTest.isActive) { + testScheduler.runCurrent() + Snapshot.sendApplyNotifications() + testScheduler.advanceTimeBy(FrameDuration) + testScheduler.runCurrent() + } + + latch.complete(Unit) + + val startFrameTime = testScheduler.currentTime + while (!recordingJob.isCompleted) { + recordFrame(TimestampFrameId(testScheduler.currentTime - startFrameTime)) + + frameEmitter.tryEmit(testScheduler.currentTime) + testScheduler.runCurrent() + Snapshot.sendApplyNotifications() + + testScheduler.advanceTimeBy(FrameDuration) + testScheduler.runCurrent() + } + } + + val timeSeries = + createTimeSeries( + frameIds, + buildList { + add(collectionCapture) + addAll(motionValueCaptures) + }, + ) + motionValueCaptures.forEach { it.debugger.dispose() } + keepRunningJob.cancel() + verifyTimeSeries(motionTestRule, timeSeries, verifyTimeSeries) + } +} + +private class ComposeMotionValueCollectionTestHarness( + private val onFrame: StateFlow, + primarySpec: MotionSpec, + initialInput: Float, + initialDirection: InputDirection, + directionChangeSlop: Float, + stableThreshold: Float, +) : CollectionInputScope { + override val motionValues: Set + get() = underTest.managedMotionValues + + override fun motionValueWithLabel(label: String): ManagedMotionValue? { + return motionValues.firstOrNull { it.label == label } + } + + override var input by mutableFloatStateOf(initialInput) + override val gestureContext = + DistanceGestureContext(initialInput, initialDirection, directionChangeSlop) + + override val underTest = MotionValueCollection(::input, gestureContext, stableThreshold) + override var spec: MotionSpec by mutableStateOf(primarySpec) + + fun createMotionValue(label: String, spec: () -> MotionSpec): ManagedMotionValue { + return underTest.create(spec, label) + } + + override fun updateInput(value: Float) { + input = value + gestureContext.dragOffset = value + } + + override suspend fun awaitStable() { + val debugInspectors = buildList { addAll(motionValues.map { it.debugInspector() }) } + try { + onFrame.drop(1).takeWhile { debugInspectors.any { !it.frame.isStable } }.collect {} + } finally { + debugInspectors.forEach { it.dispose() } + } + } + + override suspend fun awaitFrames(frames: Int) { + onFrame.drop(1).take(frames).collect {} + } + + override fun reset(position: Float, direction: InputDirection) { + input = position + gestureContext.reset(position, direction) + } +} diff --git a/mechanics/testing/src/com/android/mechanics/testing/ComposeMotionValueToolkit.kt b/mechanics/testing/src/com/android/mechanics/testing/ComposeMotionValueToolkit.kt index 0144a16..45f7388 100644 --- a/mechanics/testing/src/com/android/mechanics/testing/ComposeMotionValueToolkit.kt +++ b/mechanics/testing/src/com/android/mechanics/testing/ComposeMotionValueToolkit.kt @@ -26,6 +26,7 @@ import com.android.mechanics.DistanceGestureContext import com.android.mechanics.MotionValue import com.android.mechanics.spec.InputDirection import com.android.mechanics.spec.MotionSpec +import com.android.mechanics.testing.MotionValueToolkit.Companion.FrameDuration import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableStateFlow @@ -43,7 +44,13 @@ import platform.test.motion.golden.TimeSeries import platform.test.motion.golden.TimestampFrameId /** Toolkit to support [MotionValue] motion tests. */ -data object ComposeMotionValueToolkit : MotionValueToolkit() { +data object ComposeMotionValueToolkit : + MotionValueToolkit< + InputScope, + MotionValue, + MotionValue, + DistanceGestureContext, + >() { override fun goldenTest( motionTestRule: MotionTestRule<*>, @@ -118,7 +125,7 @@ data object ComposeMotionValueToolkit : MotionValueToolkit, @@ -131,10 +138,10 @@ private class ComposeMotionValueTestHarness( override val underTest = MotionValue( - { input }, - gestureContext, + input = { input }, + gestureContext = gestureContext, + spec = { spec }, stableThreshold = stableThreshold, - initialSpec = spec, ) val derived = createDerived(underTest) diff --git a/mechanics/testing/src/com/android/mechanics/testing/FeatureCaptures.kt b/mechanics/testing/src/com/android/mechanics/testing/FeatureCaptures.kt index d8ef1cf..ece17b6 100644 --- a/mechanics/testing/src/com/android/mechanics/testing/FeatureCaptures.kt +++ b/mechanics/testing/src/com/android/mechanics/testing/FeatureCaptures.kt @@ -20,6 +20,7 @@ import com.android.mechanics.debug.DebugInspector import com.android.mechanics.spec.SemanticKey import com.android.mechanics.spring.SpringParameters import com.android.mechanics.spring.SpringState +import platform.test.motion.golden.DataPoint import platform.test.motion.golden.DataPointType import platform.test.motion.golden.FeatureCapture import platform.test.motion.golden.asDataPoint @@ -60,6 +61,16 @@ object FeatureCaptures { val isStable = FeatureCapture("isStable") { it.frame.isStable.asDataPoint() } + /** Whether the motion value currently is running the animation loop. */ + val isAnimating = + FeatureCapture("isAnimating") { it.isAnimating.asDataPoint() } + + /** Whether the output can change. */ + val isOutputFixed = + FeatureCapture("isOutputFixed") { + it.frame.isOutputFixed.asDataPoint() + } + /** A semantic value to capture in the golden. */ fun semantics( key: SemanticKey, @@ -69,3 +80,8 @@ object FeatureCaptures { return FeatureCapture(name) { dataPointType.makeDataPoint(it.frame.semantic(key)) } } } + +/** Returns notFound if the motion value is not active. */ +fun FeatureCapture.whenActive(): FeatureCapture { + return FeatureCapture(name) { if (it.isActive) capture(it) else DataPoint.notFound() } +} diff --git a/mechanics/testing/src/com/android/mechanics/testing/MotionSpecSubject.kt b/mechanics/testing/src/com/android/mechanics/testing/MotionSpecSubject.kt index 9816d01..0d280c1 100644 --- a/mechanics/testing/src/com/android/mechanics/testing/MotionSpecSubject.kt +++ b/mechanics/testing/src/com/android/mechanics/testing/MotionSpecSubject.kt @@ -16,6 +16,7 @@ package com.android.mechanics.testing +import com.android.mechanics.haptics.SegmentHaptics import com.android.mechanics.spec.Breakpoint import com.android.mechanics.spec.BreakpointKey import com.android.mechanics.spec.DirectionalMotionSpec @@ -29,7 +30,6 @@ import com.google.common.truth.FailureMetadata import com.google.common.truth.FloatSubject import com.google.common.truth.IterableSubject import com.google.common.truth.Subject -import com.google.common.truth.Subject.Factory import com.google.common.truth.Truth /** Subject to verify the definition of a [MotionSpec]. */ @@ -104,6 +104,13 @@ internal constructor(failureMetadata: FailureMetadata, private val actual: Direc return check("mappings").about(MappingsSubject.SubjectFactory).that(actual) } + /** Assert on the segment haptics. */ + fun segmentHaptics(): SegmentHapticsSubject { + isNotNull() + + return check("segmentHaptics").about(SegmentHapticsSubject.SubjectFactory).that(actual) + } + /** Assert that the mappings contain exactly the specified mappings, in order . */ fun mappingsMatch(vararg mappings: Mapping) { isNotNull() @@ -286,6 +293,37 @@ internal constructor(failureMetadata: FailureMetadata, private val actual: Mappi } } +class SegmentHapticsSubject +internal constructor(failureMetadata: FailureMetadata, private val actual: DirectionalMotionSpec?) : + IterableSubject(failureMetadata, actual?.haptics) { + + /** Assert on the mapping at or after the specified position. */ + fun at(position: Float): SegmentHapticSubject { + return check("segment haptics @ $position") + .about(SegmentHapticSubject.SubjectFactory) + .that(actual?.run { haptics[findBreakpointIndex(position)] }) + } + + companion object { + /** Returns a factory to be used with [Truth.assertAbout]. */ + val SubjectFactory = + Factory { failureMetadata, subject -> + SegmentHapticsSubject(failureMetadata, subject) + } + } +} + +class SegmentHapticSubject +internal constructor(failureMetadata: FailureMetadata, private val actual: SegmentHaptics?) : + Subject(failureMetadata, actual) { + companion object { + val SubjectFactory = + Factory { failureMetadata, subject -> + SegmentHapticSubject(failureMetadata, subject) + } + } +} + /** Subject to assert on the list of semantic values of a [DirectionalMotionSpec]. */ class SemanticsSubject( failureMetadata: FailureMetadata, diff --git a/mechanics/testing/src/com/android/mechanics/testing/MotionValueToolkit.kt b/mechanics/testing/src/com/android/mechanics/testing/MotionValueToolkit.kt index a96ca99..c8ac6b4 100644 --- a/mechanics/testing/src/com/android/mechanics/testing/MotionValueToolkit.kt +++ b/mechanics/testing/src/com/android/mechanics/testing/MotionValueToolkit.kt @@ -57,7 +57,9 @@ import platform.test.motion.golden.TimeSeriesCaptureScope * @see ViewMotionValueToolkit */ fun < - T : MotionValueToolkit, + T : MotionValueToolkit, + I : InputScope, + UnderTestType, MotionValueType, GestureContextType, > MotionTestRule.goldenTest( @@ -69,9 +71,9 @@ fun < verifyTimeSeries: VerifyTimeSeriesFn = { VerifyTimeSeriesResult.AssertTimeSeriesMatchesGolden() }, - createDerived: (underTest: MotionValueType) -> List = { emptyList() }, + createDerived: (underTest: UnderTestType) -> List = { emptyList() }, capture: CaptureTimeSeriesFn = defaultFeatureCaptures, - testInput: suspend (InputScope).() -> Unit, + testInput: suspend (I).() -> Unit, ) { toolkit.goldenTest( this, @@ -93,6 +95,8 @@ interface InputScope { val input: Float /** GestureContext created for the `MotionValue` */ val gestureContext: GestureContextType + /** Current spec of the `MotionValue` */ + var spec: MotionSpec /** MotionValue being tested. */ val underTest: MotionValueType @@ -161,27 +165,32 @@ val defaultFeatureCaptures: CaptureTimeSeriesFn = { feature(FeatureCaptures.isStable) } -sealed class MotionValueToolkit { +sealed class MotionValueToolkit< + I : InputScope, + UnderTestType, + MotionValueType, + GestureContextType, +> { internal abstract fun goldenTest( motionTestRule: MotionTestRule<*>, spec: MotionSpec, - createDerived: (underTest: MotionValueType) -> List, + createDerived: (underTest: UnderTestType) -> List, initialValue: Float, initialDirection: InputDirection, directionChangeSlop: Float, stableThreshold: Float, verifyTimeSeries: TimeSeries.() -> VerifyTimeSeriesResult, capture: CaptureTimeSeriesFn, - testInput: suspend (InputScope).() -> Unit, + testInput: suspend (I).() -> Unit, ) internal fun createTimeSeries( frameIds: List, - motionValueCaptures: List, + frameValueCaptures: List, ): TimeSeries { return TimeSeries( frameIds.toList(), - motionValueCaptures.flatMap { motionValueCapture -> + frameValueCaptures.flatMap { motionValueCapture -> motionValueCapture.propertyCollector.entries.map { (name, dataPoints) -> Feature("${motionValueCapture.prefix}$name", dataPoints) } @@ -220,11 +229,24 @@ sealed class MotionValueToolkit { } } -internal class MotionValueCapture(val debugger: DebugInspector, val prefix: String = "") { +internal sealed class FrameValueCapture(val prefix: String) { val propertyCollector = mutableMapOf>>() +} + +internal class MotionValueCapture(val debugger: DebugInspector, prefix: String = "") : + FrameValueCapture(prefix) { val captureScope = TimeSeriesCaptureScope(debugger, propertyCollector) fun captureCurrentFrame(captureFn: CaptureTimeSeriesFn) { captureFn(captureScope) } } + +internal class GenericValueCapture(val scope: T, prefix: String = "") : + FrameValueCapture(prefix) { + val captureScope = TimeSeriesCaptureScope(scope, propertyCollector) + + fun captureCurrentFrame(captureFn: TimeSeriesCaptureScope.() -> Unit) { + captureFn(captureScope) + } +} diff --git a/mechanics/testing/src/com/android/mechanics/testing/ViewMotionValueToolkit.kt b/mechanics/testing/src/com/android/mechanics/testing/ViewMotionValueToolkit.kt index cbe18d5..40b05a3 100644 --- a/mechanics/testing/src/com/android/mechanics/testing/ViewMotionValueToolkit.kt +++ b/mechanics/testing/src/com/android/mechanics/testing/ViewMotionValueToolkit.kt @@ -21,6 +21,7 @@ package com.android.mechanics.testing import android.animation.AnimatorTestRule import com.android.mechanics.spec.InputDirection import com.android.mechanics.spec.MotionSpec +import com.android.mechanics.testing.MotionValueToolkit.Companion.FrameDuration import com.android.mechanics.view.DistanceGestureContext import com.android.mechanics.view.ViewMotionValue import kotlinx.coroutines.Dispatchers @@ -42,7 +43,12 @@ import platform.test.motion.golden.TimestampFrameId /** Toolkit to support [ViewMotionValue] motion tests. */ class ViewMotionValueToolkit(private val animatorTestRule: AnimatorTestRule) : - MotionValueToolkit() { + MotionValueToolkit< + InputScope, + ViewMotionValue, + ViewMotionValue, + DistanceGestureContext, + >() { override fun goldenTest( motionTestRule: MotionTestRule<*>, @@ -138,6 +144,12 @@ private class ViewMotionValueTestHarness( gestureContext.dragOffset = value } + override var spec: MotionSpec + get() = underTest.spec + set(value) { + underTest.spec = value + } + override suspend fun awaitStable() { val debugInspectors = buildList { add(underTest.debugInspector()) } try { diff --git a/mechanics/tests/goldens/Toggle/maxDirection_AfterToggle_preventsJumpOnDirectionChange.json b/mechanics/tests/goldens/Toggle/maxDirection_AfterToggle_preventsJumpOnDirectionChange.json new file mode 100644 index 0000000..272f845 --- /dev/null +++ b/mechanics/tests/goldens/Toggle/maxDirection_AfterToggle_preventsJumpOnDirectionChange.json @@ -0,0 +1,190 @@ +{ + "frame_ids": [ + 0, + 16, + 32, + 48, + 64, + 80, + 96, + 112, + 128, + 144, + 160, + 176, + 192, + 208, + 224, + 240, + 256, + 272, + 288, + 304, + 320, + 336, + 352, + 368, + 384, + 400, + 416, + 432, + 448, + 464, + 480, + 496 + ], + "features": [ + { + "name": "input", + "type": "float", + "data_points": [ + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 17, + 18, + 18, + 18, + 18, + 18, + 18, + 18, + 18, + 18, + 18, + 18, + 18, + 17, + 16, + 15, + 16, + 17, + 18, + 19, + 20, + 21, + 22 + ] + }, + { + "name": "gestureDirection", + "type": "string", + "data_points": [ + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Min", + "Min", + "Min", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max" + ] + }, + { + "name": "output", + "type": "float", + "data_points": [ + 8, + 9, + 10, + 10.285714, + 10.571428, + 10.857143, + 11.142857, + 11.428572, + 11.714286, + 12, + 12.956564, + 14.443979, + 15.901992, + 17.110989, + 18.028572, + 18.686268, + 19.138393, + 19.439032, + 19.633345, + 19.755754, + 19.831007, + 19.933332, + 19.9, + 19.425, + 18.95, + 19.425, + 19.9, + 19.933332, + 19.966667, + 20, + 20.033333, + 20.066666 + ] + }, + { + "name": "outputTarget", + "type": "float", + "data_points": [ + 8, + 9, + 10, + 10.285714, + 10.571428, + 10.857143, + 11.142857, + 11.428572, + 11.714286, + 19.9, + 19.933332, + 19.933332, + 19.933332, + 19.933332, + 19.933332, + 19.933332, + 19.933332, + 19.933332, + 19.933332, + 19.933332, + 19.933332, + 19.933332, + 19.9, + 19.425, + 18.95, + 19.425, + 19.9, + 19.933332, + 19.966667, + 20, + 20.033333, + 20.066666 + ] + } + ] +} \ No newline at end of file diff --git a/mechanics/tests/goldens/Toggle/maxDirection_preventsDirectionChangeBeforeToggle.json b/mechanics/tests/goldens/Toggle/maxDirection_preventsDirectionChangeBeforeToggle.json new file mode 100644 index 0000000..8988d81 --- /dev/null +++ b/mechanics/tests/goldens/Toggle/maxDirection_preventsDirectionChangeBeforeToggle.json @@ -0,0 +1,110 @@ +{ + "frame_ids": [ + 0, + 16, + 32, + 48, + 64, + 80, + 96, + 112, + 128, + 144, + 160, + 176, + 192, + 208, + 224, + 240 + ], + "features": [ + { + "name": "input", + "type": "float", + "data_points": [ + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 15, + 14, + 13, + 12, + 11, + 10, + 9, + 8 + ] + }, + { + "name": "gestureDirection", + "type": "string", + "data_points": [ + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min" + ] + }, + { + "name": "output", + "type": "float", + "data_points": [ + 8, + 9, + 10, + 10.285714, + 10.571428, + 10.857143, + 11.142857, + 11.428572, + 11.428572, + 11.142857, + 10.857143, + 10.571428, + 10.285714, + 10, + 9.714286, + 9.428572 + ] + }, + { + "name": "outputTarget", + "type": "float", + "data_points": [ + 8, + 9, + 10, + 10.285714, + 10.571428, + 10.857143, + 11.142857, + 11.428572, + 11.428572, + 11.142857, + 10.857143, + 10.571428, + 10.285714, + 10, + 9.714286, + 9.428572 + ] + } + ] +} \ No newline at end of file diff --git a/mechanics/tests/goldens/Toggle/maxDirection_togglesAtThreshold.json b/mechanics/tests/goldens/Toggle/maxDirection_togglesAtThreshold.json new file mode 100644 index 0000000..be48faf --- /dev/null +++ b/mechanics/tests/goldens/Toggle/maxDirection_togglesAtThreshold.json @@ -0,0 +1,175 @@ +{ + "frame_ids": [ + 0, + 16, + 32, + 48, + 64, + 80, + 96, + 112, + 128, + 144, + 160, + 176, + 192, + 208, + 224, + 240, + 256, + 272, + 288, + 304, + 320, + 336, + 352, + 368, + 384, + 400, + 416, + 432, + 448 + ], + "features": [ + { + "name": "input", + "type": "float", + "data_points": [ + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 17, + 17, + 17, + 17, + 17, + 17, + 17, + 17, + 17, + 17, + 17, + 17, + 17, + 17, + 17, + 18, + 19, + 20, + 21, + 22 + ] + }, + { + "name": "gestureDirection", + "type": "string", + "data_points": [ + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max" + ] + }, + { + "name": "output", + "type": "float", + "data_points": [ + 8, + 9, + 10, + 10.285714, + 10.571428, + 10.857143, + 11.142857, + 11.428572, + 11.714286, + 12, + 12.794853, + 14.090998, + 15.424225, + 16.593058, + 17.534433, + 18.252384, + 18.778795, + 19.153036, + 19.412325, + 19.587936, + 19.704412, + 19.780127, + 19.828365, + 19.9, + 19.933332, + 19.966665, + 20, + 21, + 22 + ] + }, + { + "name": "outputTarget", + "type": "float", + "data_points": [ + 8, + 9, + 10, + 10.285714, + 10.571428, + 10.857143, + 11.142857, + 11.428572, + 11.714286, + 19.9, + 19.9, + 19.9, + 19.9, + 19.9, + 19.9, + 19.9, + 19.9, + 19.9, + 19.9, + 19.9, + 19.9, + 19.9, + 19.9, + 19.9, + 19.933332, + 19.966665, + 20, + 21, + 22 + ] + } + ] +} \ No newline at end of file diff --git a/mechanics/tests/goldens/Toggle/minDirection_AfterToggle_preventsJumpOnDirectionChange.json b/mechanics/tests/goldens/Toggle/minDirection_AfterToggle_preventsJumpOnDirectionChange.json new file mode 100644 index 0000000..842ef5e --- /dev/null +++ b/mechanics/tests/goldens/Toggle/minDirection_AfterToggle_preventsJumpOnDirectionChange.json @@ -0,0 +1,190 @@ +{ + "frame_ids": [ + 0, + 16, + 32, + 48, + 64, + 80, + 96, + 112, + 128, + 144, + 160, + 176, + 192, + 208, + 224, + 240, + 256, + 272, + 288, + 304, + 320, + 336, + 352, + 368, + 384, + 400, + 416, + 432, + 448, + 464, + 480, + 496 + ], + "features": [ + { + "name": "input", + "type": "float", + "data_points": [ + 22, + 21, + 20, + 19, + 18, + 17, + 16, + 15, + 14, + 13, + 12, + 12, + 12, + 12, + 12, + 12, + 12, + 12, + 12, + 12, + 12, + 12, + 13, + 14, + 15, + 14, + 13, + 12, + 11, + 10, + 9, + 8 + ] + }, + { + "name": "gestureDirection", + "type": "string", + "data_points": [ + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Max", + "Max", + "Max", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min" + ] + }, + { + "name": "output", + "type": "float", + "data_points": [ + 22, + 21, + 20, + 19.714287, + 19.428572, + 19.142857, + 18.857143, + 18.571428, + 18.285713, + 18, + 17.043434, + 15.556019, + 14.098007, + 12.889011, + 11.971426, + 11.313732, + 10.861605, + 10.5609665, + 10.366654, + 10.244246, + 10.168992, + 10.066667, + 10.1, + 10.575001, + 11.05, + 10.575001, + 10.1, + 10.066667, + 10.033333, + 10, + 9.966667, + 9.933334 + ] + }, + { + "name": "outputTarget", + "type": "float", + "data_points": [ + 22, + 21, + 20, + 19.714287, + 19.428572, + 19.142857, + 18.857143, + 18.571428, + 18.285713, + 10.1, + 10.066667, + 10.066667, + 10.066667, + 10.066667, + 10.066667, + 10.066667, + 10.066667, + 10.066667, + 10.066667, + 10.066667, + 10.066667, + 10.066667, + 10.1, + 10.575001, + 11.05, + 10.575001, + 10.1, + 10.066667, + 10.033333, + 10, + 9.966667, + 9.933334 + ] + } + ] +} \ No newline at end of file diff --git a/mechanics/tests/goldens/Toggle/minDirection_preventsDirectionChangeBeforeToggle.json b/mechanics/tests/goldens/Toggle/minDirection_preventsDirectionChangeBeforeToggle.json new file mode 100644 index 0000000..86d4d84 --- /dev/null +++ b/mechanics/tests/goldens/Toggle/minDirection_preventsDirectionChangeBeforeToggle.json @@ -0,0 +1,110 @@ +{ + "frame_ids": [ + 0, + 16, + 32, + 48, + 64, + 80, + 96, + 112, + 128, + 144, + 160, + 176, + 192, + 208, + 224, + 240 + ], + "features": [ + { + "name": "input", + "type": "float", + "data_points": [ + 22, + 21, + 20, + 19, + 18, + 17, + 16, + 15, + 15, + 16, + 17, + 18, + 19, + 20, + 21, + 22 + ] + }, + { + "name": "gestureDirection", + "type": "string", + "data_points": [ + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max" + ] + }, + { + "name": "output", + "type": "float", + "data_points": [ + 22, + 21, + 20, + 19.714287, + 19.428572, + 19.142857, + 18.857143, + 18.571428, + 18.571428, + 18.857143, + 19.142857, + 19.428572, + 19.714287, + 20, + 20.285715, + 20.571428 + ] + }, + { + "name": "outputTarget", + "type": "float", + "data_points": [ + 22, + 21, + 20, + 19.714287, + 19.428572, + 19.142857, + 18.857143, + 18.571428, + 18.571428, + 18.857143, + 19.142857, + 19.428572, + 19.714287, + 20, + 20.285715, + 20.571428 + ] + } + ] +} \ No newline at end of file diff --git a/mechanics/tests/goldens/Toggle/minDirection_togglesAtThreshold.json b/mechanics/tests/goldens/Toggle/minDirection_togglesAtThreshold.json new file mode 100644 index 0000000..0513526 --- /dev/null +++ b/mechanics/tests/goldens/Toggle/minDirection_togglesAtThreshold.json @@ -0,0 +1,175 @@ +{ + "frame_ids": [ + 0, + 16, + 32, + 48, + 64, + 80, + 96, + 112, + 128, + 144, + 160, + 176, + 192, + 208, + 224, + 240, + 256, + 272, + 288, + 304, + 320, + 336, + 352, + 368, + 384, + 400, + 416, + 432, + 448 + ], + "features": [ + { + "name": "input", + "type": "float", + "data_points": [ + 22, + 21, + 20, + 19, + 18, + 17, + 16, + 15, + 14, + 13, + 13, + 13, + 13, + 13, + 13, + 13, + 13, + 13, + 13, + 13, + 13, + 13, + 13, + 13, + 12, + 11, + 10, + 9, + 8 + ] + }, + { + "name": "gestureDirection", + "type": "string", + "data_points": [ + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min" + ] + }, + { + "name": "output", + "type": "float", + "data_points": [ + 22, + 21, + 20, + 19.714287, + 19.428572, + 19.142857, + 18.857143, + 18.571428, + 18.285713, + 18, + 17.205147, + 15.909001, + 14.575774, + 13.40694, + 12.465567, + 11.747616, + 11.221204, + 10.846963, + 10.587676, + 10.412065, + 10.295588, + 10.219872, + 10.171636, + 10.1, + 10.066667, + 10.033333, + 10, + 9, + 8 + ] + }, + { + "name": "outputTarget", + "type": "float", + "data_points": [ + 22, + 21, + 20, + 19.714287, + 19.428572, + 19.142857, + 18.857143, + 18.571428, + 18.285713, + 10.1, + 10.1, + 10.1, + 10.1, + 10.1, + 10.1, + 10.1, + 10.1, + 10.1, + 10.1, + 10.1, + 10.1, + 10.1, + 10.1, + 10.1, + 10.066667, + 10.033333, + 10, + 9, + 8 + ] + } + ] +} \ No newline at end of file diff --git a/mechanics/tests/goldens/Toggle/output_groundedInBaseMapping.json b/mechanics/tests/goldens/Toggle/output_groundedInBaseMapping.json new file mode 100644 index 0000000..9930158 --- /dev/null +++ b/mechanics/tests/goldens/Toggle/output_groundedInBaseMapping.json @@ -0,0 +1,180 @@ +{ + "frame_ids": [ + 0, + 16, + 32, + 48, + 64, + 80, + 96, + 112, + 128, + 144, + 160, + 176, + 192, + 208, + 224, + 240, + 256, + 272, + 288, + 304, + 320, + 336, + 352, + 368, + 384, + 400, + 416, + 432, + 448, + 464 + ], + "features": [ + { + "name": "input", + "type": "float", + "data_points": [ + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 17, + 18, + 19, + 20, + 21, + 22, + 22, + 22, + 22, + 22, + 22, + 22, + 22, + 22, + 22, + 22, + 22, + 22, + 22, + 22, + 22 + ] + }, + { + "name": "gestureDirection", + "type": "string", + "data_points": [ + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max" + ] + }, + { + "name": "output", + "type": "float", + "data_points": [ + -80, + -90, + -100, + -102.85715, + -105.71429, + -108.57143, + -111.42857, + -114.28572, + -117.14286, + -120, + -129.56564, + -145.72162, + -162.55551, + -185.63736, + -204.75362, + -210.89252, + -214.88318, + -217.37805, + -218.86652, + -219.7013, + -220.12784, + -220.31113, + -220.35823, + -220.33595, + -220.28404, + -220.22472, + -220.16925, + -220.12245, + -220.08548, + -220 + ] + }, + { + "name": "outputTarget", + "type": "float", + "data_points": [ + -80, + -90, + -100, + -102.85715, + -105.71429, + -108.57143, + -111.42857, + -114.28572, + -117.14286, + -199, + -199.33333, + -199.66666, + -200, + -210, + -220, + -220, + -220, + -220, + -220, + -220, + -220, + -220, + -220, + -220, + -220, + -220, + -220, + -220, + -220, + -220 + ] + } + ] +} \ No newline at end of file diff --git a/mechanics/tests/goldens/collection/animatingValueIsDisposed_collectionStopsAnimating.json b/mechanics/tests/goldens/collection/animatingValueIsDisposed_collectionStopsAnimating.json new file mode 100644 index 0000000..136a1e4 --- /dev/null +++ b/mechanics/tests/goldens/collection/animatingValueIsDisposed_collectionStopsAnimating.json @@ -0,0 +1,111 @@ +{ + "frame_ids": [ + 0, + 16, + 32, + 48, + 64, + 80, + 96, + 112, + 128 + ], + "features": [ + { + "name": "input", + "type": "float", + "data_points": [ + 0, + 0.3, + 0.6, + 0.90000004, + 1.2, + 1.5, + 1.5, + 1.5, + 1.5 + ] + }, + { + "name": "gestureDirection", + "type": "string", + "data_points": [ + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max" + ] + }, + { + "name": "primary-output", + "type": "float", + "data_points": [ + 0, + 0, + 0, + 0, + 0.033639193, + 0.16369182, + 0.32773256, + 0.48736703, + { + "type": "not_found" + } + ] + }, + { + "name": "primary-outputTarget", + "type": "float", + "data_points": [ + 0, + 0, + 0, + 0, + 1, + 1, + 1, + 1, + { + "type": "not_found" + } + ] + }, + { + "name": "primary-isStable", + "type": "boolean", + "data_points": [ + true, + true, + true, + true, + false, + false, + false, + false, + { + "type": "not_found" + } + ] + }, + { + "name": "primary-isAnimating", + "type": "boolean", + "data_points": [ + false, + true, + true, + true, + true, + true, + true, + true, + false + ] + } + ] +} \ No newline at end of file diff --git a/mechanics/tests/goldens/collection/oneAnimatingValue_collectionIsAnimating.json b/mechanics/tests/goldens/collection/oneAnimatingValue_collectionIsAnimating.json new file mode 100644 index 0000000..9d933ed --- /dev/null +++ b/mechanics/tests/goldens/collection/oneAnimatingValue_collectionIsAnimating.json @@ -0,0 +1,133 @@ +{ + "frame_ids": [ + 0, + 16, + 32, + 48, + 64, + 80, + 96, + 112, + 128, + 144, + 160, + 176, + 192 + ], + "features": [ + { + "name": "input", + "type": "float", + "data_points": [ + 0, + 0.4, + 0.8, + 1.2, + 1.6, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2 + ] + }, + { + "name": "gestureDirection", + "type": "string", + "data_points": [ + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max" + ] + }, + { + "name": "primary-output", + "type": "float", + "data_points": [ + 0, + 0, + 0, + 0.01973492, + 0.1381998, + 0.29998195, + 0.4619913, + 0.6040878, + 0.7193318, + 0.80780226, + 0.8728444, + 0.9189145, + 1 + ] + }, + { + "name": "primary-outputTarget", + "type": "float", + "data_points": [ + 0, + 0, + 0, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1 + ] + }, + { + "name": "primary-isStable", + "type": "boolean", + "data_points": [ + true, + true, + true, + false, + false, + false, + false, + false, + false, + false, + false, + false, + true + ] + }, + { + "name": "primary-isAnimating", + "type": "boolean", + "data_points": [ + false, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true + ] + } + ] +} \ No newline at end of file diff --git a/mechanics/tests/goldens/collection/twoAnimatingValues_oneStops_collectionKeepsAnimating.json b/mechanics/tests/goldens/collection/twoAnimatingValues_oneStops_collectionKeepsAnimating.json new file mode 100644 index 0000000..66657b4 --- /dev/null +++ b/mechanics/tests/goldens/collection/twoAnimatingValues_oneStops_collectionKeepsAnimating.json @@ -0,0 +1,220 @@ +{ + "frame_ids": [ + 0, + 16, + 32, + 48, + 64, + 80, + 96, + 112, + 128, + 144, + 160, + 176, + 192, + 208 + ], + "features": [ + { + "name": "input", + "type": "float", + "data_points": [ + 0, + 0.5, + 1, + 1.5, + 2, + 2.5, + 3, + 3, + 3, + 3, + 3, + 3, + 3, + 3 + ] + }, + { + "name": "gestureDirection", + "type": "string", + "data_points": [ + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max" + ] + }, + { + "name": "primary-output", + "type": "float", + "data_points": [ + 0, + 0, + 0, + 0.0696004, + 0.21705192, + 0.38261998, + 0.536185, + 0.6651724, + 0.7667498, + 0.8429822, + 0.89796525, + 1, + 1, + 1 + ] + }, + { + "name": "primary-outputTarget", + "type": "float", + "data_points": [ + 0, + 0, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1 + ] + }, + { + "name": "primary-isStable", + "type": "boolean", + "data_points": [ + true, + true, + false, + false, + false, + false, + false, + false, + false, + false, + false, + true, + true, + true + ] + }, + { + "name": "primary-isAnimating", + "type": "boolean", + "data_points": [ + false, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true + ] + }, + { + "name": "second-output", + "type": "float", + "data_points": [ + 1, + 1, + 1, + 1, + 1, + 1.0696003, + 1.217052, + 1.38262, + 1.536185, + 1.6651723, + 1.7667497, + 1.8429822, + 1.8979652, + 2 + ] + }, + { + "name": "second-outputTarget", + "type": "float", + "data_points": [ + 1, + 1, + 1, + 1, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2 + ] + }, + { + "name": "second-isStable", + "type": "boolean", + "data_points": [ + true, + true, + true, + true, + false, + false, + false, + false, + false, + false, + false, + false, + false, + true + ] + }, + { + "name": "second-isAnimating", + "type": "boolean", + "data_points": [ + false, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true + ] + } + ] +} \ No newline at end of file diff --git a/mechanics/tests/goldens/collection/wakeUp_onInputChange.json b/mechanics/tests/goldens/collection/wakeUp_onInputChange.json new file mode 100644 index 0000000..7a67648 --- /dev/null +++ b/mechanics/tests/goldens/collection/wakeUp_onInputChange.json @@ -0,0 +1,119 @@ +{ + "frame_ids": [ + 0, + 16, + 32, + 48, + 64, + 80, + 96, + 112, + 128, + 144, + 160 + ], + "features": [ + { + "name": "input", + "type": "float", + "data_points": [ + 0, + 0, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2 + ] + }, + { + "name": "gestureDirection", + "type": "string", + "data_points": [ + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max" + ] + }, + { + "name": "primary-output", + "type": "float", + "data_points": [ + 0, + 0, + 0.0696004, + 0.21705192, + 0.38261998, + 0.536185, + 0.6651724, + 0.7667498, + 0.8429822, + 0.89796525, + 1 + ] + }, + { + "name": "primary-outputTarget", + "type": "float", + "data_points": [ + 0, + 0, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1 + ] + }, + { + "name": "primary-isStable", + "type": "boolean", + "data_points": [ + true, + true, + false, + false, + false, + false, + false, + false, + false, + false, + true + ] + }, + { + "name": "primary-isAnimating", + "type": "boolean", + "data_points": [ + false, + false, + true, + true, + true, + true, + true, + true, + true, + true, + true + ] + } + ] +} \ No newline at end of file diff --git a/mechanics/tests/goldens/collection/wakeUp_onSpecChange.json b/mechanics/tests/goldens/collection/wakeUp_onSpecChange.json new file mode 100644 index 0000000..4d25085 --- /dev/null +++ b/mechanics/tests/goldens/collection/wakeUp_onSpecChange.json @@ -0,0 +1,98 @@ +{ + "frame_ids": [ + 0, + 16, + 32, + 48, + 64, + 80, + 96, + 112 + ], + "features": [ + { + "name": "input", + "type": "float", + "data_points": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ] + }, + { + "name": "gestureDirection", + "type": "string", + "data_points": [ + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max" + ] + }, + { + "name": "primary-output", + "type": "float", + "data_points": [ + 0, + 0, + 0.3545063, + 0.5699669, + 0.73491824, + 0.8475376, + 0.91835225, + 1 + ] + }, + { + "name": "primary-outputTarget", + "type": "float", + "data_points": [ + 0, + 0, + 1, + 1, + 1, + 1, + 1, + 1 + ] + }, + { + "name": "primary-isStable", + "type": "boolean", + "data_points": [ + true, + true, + false, + false, + false, + false, + false, + true + ] + }, + { + "name": "primary-isAnimating", + "type": "boolean", + "data_points": [ + false, + false, + true, + true, + true, + true, + true, + true + ] + } + ] +} \ No newline at end of file diff --git a/mechanics/tests/goldens/observeWhen_isOutputFixed.json b/mechanics/tests/goldens/observeWhen_isOutputFixed.json new file mode 100644 index 0000000..1157f1a --- /dev/null +++ b/mechanics/tests/goldens/observeWhen_isOutputFixed.json @@ -0,0 +1,444 @@ +{ + "frame_ids": [ + 0, + 16, + 32, + 48, + 64, + 80, + 96, + 112, + 128, + 144, + 160, + 176, + 192, + 208, + 224, + 240, + 256, + 272, + 288, + 304, + 320, + 336, + 352, + 368, + 384, + 400, + 416, + 432, + 448, + 464, + 480, + 496, + 512, + 528, + 544, + 560 + ], + "features": [ + { + "name": "input", + "type": "float", + "data_points": [ + 0, + 1.5, + 1.5, + 1.5, + 1.5, + 1.5, + 1.5, + 1.5, + 1.5, + 1.5, + 1.5, + 1.5, + 2.5, + 2.5, + 2.5, + 2.5, + 2.5, + 2.5, + 2.5, + 2.5, + 2.5, + 2.5, + 2.5, + 2.9, + 2.9, + 3.5, + 3.5, + 3.5, + 3.5, + 3.5, + 3.5, + 3.5, + 3.5, + 3.5, + 3.5, + 3.5 + ] + }, + { + "name": "gestureDirection", + "type": "string", + "data_points": [ + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max" + ] + }, + { + "name": "output", + "type": "float", + "data_points": [ + 0, + 0.09148693, + 1.1382809, + 2.7218752, + 4.360338, + 5.8219514, + 7.0204153, + 7.9479666, + 8.634457, + 9.123607, + 10, + 10, + 10.69735, + 11.881998, + 13.49982, + 15.119913, + 16.540878, + 17.693317, + 18.578022, + 19.228443, + 19.689146, + 20.5, + 20.5, + 20.9, + 20.9, + 20.445593, + 18.909435, + 17.091537, + 15.366772, + 13.898596, + 12.731601, + 11.84938, + 11.209088, + 10.760834, + 10, + 10 + ] + }, + { + "name": "outputTarget", + "type": "float", + "data_points": [ + 0, + 10, + 10, + 10, + 10, + 10, + 10, + 10, + 10, + 10, + 10, + 10, + 20.5, + 20.5, + 20.5, + 20.5, + 20.5, + 20.5, + 20.5, + 20.5, + 20.5, + 20.5, + 20.5, + 20.9, + 20.9, + 10, + 10, + 10, + 10, + 10, + 10, + 10, + 10, + 10, + 10, + 10 + ] + }, + { + "name": "outputSpring", + "type": "springParameters", + "data_points": [ + { + "stiffness": 100000, + "dampingRatio": 1 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + } + ] + }, + { + "name": "isStable", + "type": "boolean", + "data_points": [ + true, + false, + false, + false, + false, + false, + false, + false, + false, + false, + true, + true, + false, + false, + false, + false, + false, + false, + false, + false, + false, + true, + true, + true, + true, + false, + false, + false, + false, + false, + false, + false, + false, + false, + true, + true + ] + }, + { + "name": "isOutputFixed", + "type": "boolean", + "data_points": [ + true, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + true, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + true + ] + } + ] +} \ No newline at end of file diff --git a/mechanics/tests/goldens/unspecifiedSpec_atTheBeginning_jumpcutsToFirstValue.json b/mechanics/tests/goldens/unspecifiedSpec_atTheBeginning_jumpcutsToFirstValue.json new file mode 100644 index 0000000..1cec244 --- /dev/null +++ b/mechanics/tests/goldens/unspecifiedSpec_atTheBeginning_jumpcutsToFirstValue.json @@ -0,0 +1,92 @@ +{ + "frame_ids": [ + 0, + 16, + 32, + 48, + 64 + ], + "features": [ + { + "name": "input", + "type": "float", + "data_points": [ + 0, + 5, + 10, + 15, + 20 + ] + }, + { + "name": "gestureDirection", + "type": "string", + "data_points": [ + "Max", + "Max", + "Max", + "Max", + "Max" + ] + }, + { + "name": "output", + "type": "float", + "data_points": [ + "NaN", + "NaN", + "NaN", + 15, + 20 + ] + }, + { + "name": "outputTarget", + "type": "float", + "data_points": [ + "NaN", + "NaN", + "NaN", + 15, + 20 + ] + }, + { + "name": "outputSpring", + "type": "springParameters", + "data_points": [ + { + "stiffness": 100000, + "dampingRatio": 1 + }, + { + "stiffness": 100000, + "dampingRatio": 1 + }, + { + "stiffness": 100000, + "dampingRatio": 1 + }, + { + "stiffness": 100000, + "dampingRatio": 1 + }, + { + "stiffness": 100000, + "dampingRatio": 1 + } + ] + }, + { + "name": "isStable", + "type": "boolean", + "data_points": [ + true, + true, + true, + true, + true + ] + } + ] +} \ No newline at end of file diff --git a/mechanics/tests/goldens/unspecifiedSpec_outputIsNan.json b/mechanics/tests/goldens/unspecifiedSpec_outputIsNan.json new file mode 100644 index 0000000..9fbab70 --- /dev/null +++ b/mechanics/tests/goldens/unspecifiedSpec_outputIsNan.json @@ -0,0 +1,102 @@ +{ + "frame_ids": [ + 0, + 16, + 32, + 48, + 64, + 80 + ], + "features": [ + { + "name": "input", + "type": "float", + "data_points": [ + 0, + 20, + 40, + 60, + 80, + 100 + ] + }, + { + "name": "gestureDirection", + "type": "string", + "data_points": [ + "Max", + "Max", + "Max", + "Max", + "Max", + "Max" + ] + }, + { + "name": "output", + "type": "float", + "data_points": [ + "NaN", + "NaN", + "NaN", + "NaN", + "NaN", + "NaN" + ] + }, + { + "name": "outputTarget", + "type": "float", + "data_points": [ + "NaN", + "NaN", + "NaN", + "NaN", + "NaN", + "NaN" + ] + }, + { + "name": "outputSpring", + "type": "springParameters", + "data_points": [ + { + "stiffness": 100000, + "dampingRatio": 1 + }, + { + "stiffness": 100000, + "dampingRatio": 1 + }, + { + "stiffness": 100000, + "dampingRatio": 1 + }, + { + "stiffness": 100000, + "dampingRatio": 1 + }, + { + "stiffness": 100000, + "dampingRatio": 1 + }, + { + "stiffness": 100000, + "dampingRatio": 1 + } + ] + }, + { + "name": "isStable", + "type": "boolean", + "data_points": [ + true, + true, + true, + true, + true, + true + ] + } + ] +} \ No newline at end of file diff --git a/mechanics/tests/goldens/view/specChange_triggersAnimation.json b/mechanics/tests/goldens/view/specChange_triggersAnimation.json index b237f39..2555903 100644 --- a/mechanics/tests/goldens/view/specChange_triggersAnimation.json +++ b/mechanics/tests/goldens/view/specChange_triggersAnimation.json @@ -52,13 +52,13 @@ "type": "float", "data_points": [ 1.5, - 1.3117526, + 1.5, 0.96824056, 0.6450497, - 0.39762264, - 0.22869362, - 0.122471645, - 0.060223386, + 0.39762267, + 0.22869363, + 0.12247165, + 0.06022339, 0.026204487, 0.009041936, 0 @@ -69,7 +69,7 @@ "type": "float", "data_points": [ 1.5, - 0, + 1.5, 0, 0, 0, @@ -90,8 +90,8 @@ "dampingRatio": 1 }, { - "stiffness": 1400, - "dampingRatio": 0.9 + "stiffness": 100000, + "dampingRatio": 1 }, { "stiffness": 1400, @@ -136,7 +136,7 @@ "type": "boolean", "data_points": [ true, - false, + true, false, false, false, diff --git a/mechanics/tests/goldens/view/unspecifiedSpec_atTheBeginning_jumpcutsToFirstValue.json b/mechanics/tests/goldens/view/unspecifiedSpec_atTheBeginning_jumpcutsToFirstValue.json new file mode 100644 index 0000000..1cec244 --- /dev/null +++ b/mechanics/tests/goldens/view/unspecifiedSpec_atTheBeginning_jumpcutsToFirstValue.json @@ -0,0 +1,92 @@ +{ + "frame_ids": [ + 0, + 16, + 32, + 48, + 64 + ], + "features": [ + { + "name": "input", + "type": "float", + "data_points": [ + 0, + 5, + 10, + 15, + 20 + ] + }, + { + "name": "gestureDirection", + "type": "string", + "data_points": [ + "Max", + "Max", + "Max", + "Max", + "Max" + ] + }, + { + "name": "output", + "type": "float", + "data_points": [ + "NaN", + "NaN", + "NaN", + 15, + 20 + ] + }, + { + "name": "outputTarget", + "type": "float", + "data_points": [ + "NaN", + "NaN", + "NaN", + 15, + 20 + ] + }, + { + "name": "outputSpring", + "type": "springParameters", + "data_points": [ + { + "stiffness": 100000, + "dampingRatio": 1 + }, + { + "stiffness": 100000, + "dampingRatio": 1 + }, + { + "stiffness": 100000, + "dampingRatio": 1 + }, + { + "stiffness": 100000, + "dampingRatio": 1 + }, + { + "stiffness": 100000, + "dampingRatio": 1 + } + ] + }, + { + "name": "isStable", + "type": "boolean", + "data_points": [ + true, + true, + true, + true, + true + ] + } + ] +} \ No newline at end of file diff --git a/mechanics/tests/goldens/view/unspecifiedSpec_outputIsNan.json b/mechanics/tests/goldens/view/unspecifiedSpec_outputIsNan.json new file mode 100644 index 0000000..9fbab70 --- /dev/null +++ b/mechanics/tests/goldens/view/unspecifiedSpec_outputIsNan.json @@ -0,0 +1,102 @@ +{ + "frame_ids": [ + 0, + 16, + 32, + 48, + 64, + 80 + ], + "features": [ + { + "name": "input", + "type": "float", + "data_points": [ + 0, + 20, + 40, + 60, + 80, + 100 + ] + }, + { + "name": "gestureDirection", + "type": "string", + "data_points": [ + "Max", + "Max", + "Max", + "Max", + "Max", + "Max" + ] + }, + { + "name": "output", + "type": "float", + "data_points": [ + "NaN", + "NaN", + "NaN", + "NaN", + "NaN", + "NaN" + ] + }, + { + "name": "outputTarget", + "type": "float", + "data_points": [ + "NaN", + "NaN", + "NaN", + "NaN", + "NaN", + "NaN" + ] + }, + { + "name": "outputSpring", + "type": "springParameters", + "data_points": [ + { + "stiffness": 100000, + "dampingRatio": 1 + }, + { + "stiffness": 100000, + "dampingRatio": 1 + }, + { + "stiffness": 100000, + "dampingRatio": 1 + }, + { + "stiffness": 100000, + "dampingRatio": 1 + }, + { + "stiffness": 100000, + "dampingRatio": 1 + }, + { + "stiffness": 100000, + "dampingRatio": 1 + } + ] + }, + { + "name": "isStable", + "type": "boolean", + "data_points": [ + true, + true, + true, + true, + true, + true + ] + } + ] +} \ No newline at end of file diff --git a/mechanics/tests/src/com/android/mechanics/MotionValueCollectionLifecycleTest.kt b/mechanics/tests/src/com/android/mechanics/MotionValueCollectionLifecycleTest.kt new file mode 100644 index 0000000..7fda275 --- /dev/null +++ b/mechanics/tests/src/com/android/mechanics/MotionValueCollectionLifecycleTest.kt @@ -0,0 +1,321 @@ +/* + * Copyright (C) 2025 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.mechanics + +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.android.mechanics.MotionValueTest.Companion.FakeGestureContext +import com.android.mechanics.spec.InputDirection +import com.android.mechanics.spec.Mapping +import com.android.mechanics.spec.MotionSpec +import com.android.mechanics.spec.builder.MotionBuilderContext +import com.android.mechanics.spec.builder.directionalMotionSpec +import com.android.mechanics.spec.builder.fixedSpatialValueSpec +import com.android.mechanics.testing.FakeMotionSpecBuilderContext +import com.google.common.truth.Truth.assertThat +import kotlinx.coroutines.test.runTest +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class MotionValueCollectionLifecycleTest : + MotionBuilderContext by FakeMotionSpecBuilderContext.Default { + + @get:Rule(order = 0) val rule = createComposeRule() + + @Test + fun keepRunning_empty_doesNotWakeup() = runTest { + val input = mutableFloatStateOf(0f) + val underTest = MotionValueCollection(input::value, FakeGestureContext) + rule.setContent { LaunchedEffect(Unit) { underTest.keepRunning() } } + + rule.awaitIdle() + val framesCount = underTest.frameCount + rule.mainClock.autoAdvance = false + + assertThat(underTest.isActive).isTrue() + assertThat(underTest.isAnimating).isFalse() + + // Update the value, but WITHOUT causing an animation + input.floatValue = 0.5f + rule.awaitIdle() + + assertThat(framesCount).isEqualTo(underTest.frameCount) + assertThat(underTest.isAnimating).isFalse() + + rule.mainClock.advanceTimeByFrame() + rule.awaitIdle() + + assertThat(framesCount).isEqualTo(underTest.frameCount) + assertThat(underTest.isAnimating).isFalse() + } + + @Test + fun create_withoutKeepRunning_remainsInactive() = runTest { + val input = mutableFloatStateOf(1f) + val underTest = MotionValueCollection(input::value, FakeGestureContext) + + rule.setContent {} + + assertThat(underTest.isActive).isFalse() + + val motionValue = underTest.create({ MotionSpec.Identity }) + assertThat(motionValue.output).isNaN() + val inspector = motionValue.debugInspector() + assertThat(inspector.isActive).isFalse() + } + + @Test + fun create_whileKeepRunning_isActivatedImmediately() = runTest { + val input = mutableFloatStateOf(1f) + val underTest = MotionValueCollection(input::value, FakeGestureContext) + + rule.setContent { LaunchedEffect(Unit) { underTest.keepRunning() } } + rule.awaitIdle() + + assertThat(underTest.isActive).isTrue() + assertThat(underTest.managedMotionValues.size).isEqualTo(0) + + val motionValue = underTest.create({ MotionSpec.Identity }) + assertThat(motionValue.output).isEqualTo(1f) + val inspector = motionValue.debugInspector() + assertThat(inspector.isActive).isTrue() + } + + @Test + fun keepRunning_activatesAlreadyCreated() = runTest { + val input = mutableFloatStateOf(0f) + val underTest = MotionValueCollection(input::value, FakeGestureContext) + + val motionValue = underTest.create({ MotionSpec.Identity }) + val inspector = motionValue.debugInspector() + + assertThat(underTest.frameCount).isEqualTo(0) + assertThat(underTest.isActive).isFalse() + assertThat(underTest.isAnimating).isFalse() + assertThat(underTest.managedMotionValues.size).isEqualTo(1) + assertThat(inspector.isActive).isFalse() + assertThat(inspector.isAnimating).isFalse() + assertThat(motionValue.output).isNaN() + + rule.setContent { LaunchedEffect(Unit) { underTest.keepRunning() } } + + rule.awaitIdle() + + assertThat(underTest.frameCount).isEqualTo(1) + assertThat(underTest.isActive).isTrue() + assertThat(underTest.isAnimating).isFalse() + assertThat(underTest.managedMotionValues.size).isEqualTo(1) + assertThat(inspector.isActive).isTrue() + assertThat(inspector.isAnimating).isFalse() + assertThat(motionValue.output).isFinite() + } + + @Test + fun keepRunning_deavtivatesOnDispose() = runTest { + val input = mutableFloatStateOf(0f) + val underTest = MotionValueCollection(input::value, FakeGestureContext) + + val motionValue = underTest.create({ MotionSpec.Identity }) + val inspector = motionValue.debugInspector() + + rule.setContent { LaunchedEffect(Unit) { underTest.keepRunning() } } + + rule.awaitIdle() + + assertThat(underTest.frameCount).isEqualTo(1) + assertThat(underTest.isActive).isTrue() + assertThat(underTest.isAnimating).isFalse() + assertThat(underTest.managedMotionValues.size).isEqualTo(1) + assertThat(inspector.isActive).isTrue() + assertThat(inspector.isAnimating).isFalse() + + motionValue.dispose() + rule.awaitIdle() + + assertThat(underTest.frameCount).isEqualTo(2) + assertThat(underTest.isActive).isTrue() + assertThat(underTest.isAnimating).isFalse() + assertThat(underTest.managedMotionValues.size).isEqualTo(0) + assertThat(inspector.isActive).isFalse() + assertThat(inspector.isAnimating).isFalse() + } + + @Test + fun createAndDispose_withoutKeepRunning_isInactive() = runTest { + val input = mutableFloatStateOf(0f) + val underTest = MotionValueCollection(input::value, FakeGestureContext) + + rule.setContent {} + assertThat(underTest.isActive).isFalse() + + val motionValue = underTest.create({ MotionSpec.Identity }) + val inspector = motionValue.debugInspector() + rule.awaitIdle() + + assertThat(underTest.isActive).isFalse() + assertThat(inspector.isActive).isFalse() + assertThat(underTest.managedMotionValues.size).isEqualTo(1) + + motionValue.dispose() + rule.awaitIdle() + + assertThat(underTest.isActive).isFalse() + assertThat(inspector.isActive).isFalse() + assertThat(underTest.managedMotionValues.size).isEqualTo(0) + } + + @Test + fun keepRunning_withMultipleValues() = runTest { + val input = mutableFloatStateOf(0f) + val underTest = MotionValueCollection(input::value, FakeGestureContext) + + val mv1 = underTest.create({ MotionSpec.Identity }) + val inspector1 = mv1.debugInspector() + val mv2 = underTest.create({ MotionSpec.Identity }) + val inspector2 = mv2.debugInspector() + + rule.setContent { LaunchedEffect(Unit) { underTest.keepRunning() } } + rule.awaitIdle() + + assertThat(underTest.isActive).isTrue() + assertThat(underTest.managedMotionValues.size).isEqualTo(2) + assertThat(inspector1.isActive).isTrue() + assertThat(inspector2.isActive).isTrue() + + mv1.dispose() + rule.awaitIdle() + + assertThat(underTest.managedMotionValues.size).isEqualTo(1) + assertThat(inspector1.isActive).isFalse() + assertThat(inspector2.isActive).isTrue() + + mv2.dispose() + rule.awaitIdle() + + assertThat(underTest.managedMotionValues.size).isEqualTo(0) + assertThat(inspector1.isActive).isFalse() + assertThat(inspector2.isActive).isFalse() + } + + @Test + fun keepRunning_cancelled_deactivates() = runTest { + val input = mutableFloatStateOf(0f) + val underTest = MotionValueCollection(input::value, FakeGestureContext) + val inspector = underTest.create({ MotionSpec.Identity }).debugInspector() + val keepRunning = mutableStateOf(true) + + rule.setContent { + if (keepRunning.value) { + LaunchedEffect(Unit) { underTest.keepRunning() } + } + } + rule.awaitIdle() + + assertThat(underTest.isActive).isTrue() + assertThat(inspector.isActive).isTrue() + assertThat(underTest.managedMotionValues.size).isEqualTo(1) + + keepRunning.value = false + rule.awaitIdle() + + assertThat(underTest.isActive).isFalse() + assertThat(inspector.isActive).isFalse() + assertThat(underTest.managedMotionValues.size).isEqualTo(1) + } + + @Test + fun latchesInput_changesAreProcessedOnFrameStartOnly() = runTest { + val input = mutableFloatStateOf(0f) + + val underTest = MotionValueCollection(input::value, FakeGestureContext) + val motionValue = underTest.create({ MotionSpec.Identity }) + + rule.setContent { LaunchedEffect(Unit) { underTest.keepRunning() } } + + rule.awaitIdle() + + rule.mainClock.autoAdvance = false + + assertThat(motionValue.output).isEqualTo(0f) + input.floatValue = 1f + assertThat(motionValue.output).isEqualTo(0f) + + rule.mainClock.advanceTimeByFrame() + rule.awaitIdle() + assertThat(motionValue.output).isEqualTo(1f) + } + + @Test + fun latchesGestureContext_changesAreProcessedOnFrameStartOnly() = runTest { + val gestureContext = ProvidedGestureContext(0f, InputDirection.Max) + val spec = + MotionSpec( + maxDirection = directionalMotionSpec(Mapping.Zero), + minDirection = directionalMotionSpec(Mapping.One), + ) + + val underTest = MotionValueCollection({ 0f }, gestureContext) + val motionValue = underTest.create({ spec }) + + rule.setContent { LaunchedEffect(Unit) { underTest.keepRunning() } } + + rule.awaitIdle() + + rule.mainClock.autoAdvance = false + + assertThat(motionValue.output).isEqualTo(0f) + gestureContext.direction = InputDirection.Min + assertThat(motionValue.output).isEqualTo(0f) + + rule.mainClock.advanceTimeByFrame() + rule.awaitIdle() + + // Note: Animation is not expected here, since the segmentKey is in both directions + // [minLimit,maxLimit]. + assertThat(motionValue.output).isEqualTo(1f) + } + + @Test + fun latchesSpec_changesAreProcessedOnFrameStartOnly() = runTest { + val spec = mutableStateOf(fixedSpatialValueSpec(0f)) + + val underTest = MotionValueCollection({ 0f }, FakeGestureContext) + val motionValue = underTest.create(spec::value) + + rule.setContent { LaunchedEffect(Unit) { underTest.keepRunning() } } + + rule.awaitIdle() + + rule.mainClock.autoAdvance = false + + assertThat(motionValue.output).isEqualTo(0f) + spec.value = fixedSpatialValueSpec(1f) + assertThat(motionValue.output).isEqualTo(0f) + + rule.mainClock.advanceTimeByFrame() + rule.awaitIdle() + + // Note: Animation is not expected here, since the segmentKey is in both directions + // [minLimit,maxLimit]. + assertThat(motionValue.output).isEqualTo(1f) + } +} diff --git a/mechanics/tests/src/com/android/mechanics/MotionValueCollectionTest.kt b/mechanics/tests/src/com/android/mechanics/MotionValueCollectionTest.kt new file mode 100644 index 0000000..c0ceda9 --- /dev/null +++ b/mechanics/tests/src/com/android/mechanics/MotionValueCollectionTest.kt @@ -0,0 +1,147 @@ +/* + * Copyright (C) 2025 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.mechanics + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.android.mechanics.MotionValueTest.Companion.specBuilder +import com.android.mechanics.spec.InputDirection +import com.android.mechanics.spec.Mapping +import com.android.mechanics.spec.MotionSpec +import com.android.mechanics.spec.builder.MotionBuilderContext +import com.android.mechanics.testing.CaptureTimeSeriesFn +import com.android.mechanics.testing.CollectionInputScope +import com.android.mechanics.testing.ComposeMotionValueCollectionToolkit +import com.android.mechanics.testing.FakeMotionSpecBuilderContext +import com.android.mechanics.testing.FeatureCaptures +import com.android.mechanics.testing.VerifyTimeSeriesFn +import com.android.mechanics.testing.VerifyTimeSeriesResult +import com.android.mechanics.testing.animateValueTo +import com.android.mechanics.testing.goldenTest +import com.android.mechanics.testing.nullableDataPoints +import com.android.mechanics.testing.whenActive +import com.google.common.truth.Truth.assertThat +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import platform.test.motion.MotionTestRule +import platform.test.motion.testing.createGoldenPathManager +import platform.test.screenshot.PathConfig +import platform.test.screenshot.PathElementNoContext + +@RunWith(AndroidJUnit4::class) +class MotionValueCollectionTest : MotionBuilderContext by FakeMotionSpecBuilderContext.Default { + private val goldenPathManager = + createGoldenPathManager( + "frameworks/libs/systemui/mechanics/tests/goldens", + PathConfig(PathElementNoContext("base", isDir = true, { "collection" })), + ) + + @get:Rule(order = 1) + val motion = MotionTestRule(ComposeMotionValueCollectionToolkit, goldenPathManager) + + @Test + fun oneAnimatingValue_collectionIsAnimating() = + goldenTest(spec = specBuilder(Mapping.Zero) { fixedValue(breakpoint = 1f, value = 1f) }) { + animateValueTo(2f) + awaitStable() + } + + @Test + fun twoAnimatingValues_oneStops_collectionKeepsAnimating() = + goldenTest( + spec = specBuilder(Mapping.Zero) { fixedValue(breakpoint = 1f, value = 1f) }, + createDerived = { + val secondSpec = + specBuilder(Mapping.One) { fixedValue(breakpoint = 2f, value = 2f) } + listOf(it.create({ secondSpec }, "second")) + }, + ) { + animateValueTo(3f, changePerFrame = 0.5f) + awaitStable() + } + + @Test + fun animatingValueIsDisposed_collectionStopsAnimating() = + goldenTest( + spec = specBuilder(Mapping.Zero) { fixedValue(breakpoint = 1f, value = 1f) }, + verifyTimeSeries = { + val output = nullableDataPoints("primary-output") + assertThat(output.last()).isNull() + assertThat(output.dropLast(1)).doesNotContain(null) + + VerifyTimeSeriesResult.AssertTimeSeriesMatchesGolden() + }, + ) { + animateValueTo(1.5f) + awaitFrames(2) + motionValues.first().dispose() + awaitStable() + } + + @Test + fun wakeUp_onInputChange() = + goldenTest(spec = specBuilder(Mapping.Zero) { fixedValue(breakpoint = 1f, value = 1f) }) { + awaitStable() + updateInput(2f) + awaitStable() + } + + @Test + fun wakeUp_onSpecChange() = + goldenTest(spec = specBuilder(Mapping.Zero) { fixedValue(breakpoint = 1f, value = 1f) }) { + awaitStable() + spec = specBuilder(Mapping.Zero) { fixedValue(breakpoint = -1f, value = 1f) } + awaitStable() + } + + private fun goldenTest( + spec: MotionSpec, + initialValue: Float = 0f, + initialDirection: InputDirection = InputDirection.Max, + directionChangeSlop: Float = 5f, + stableThreshold: Float = 0.1f, + verifyTimeSeries: VerifyTimeSeriesFn = { + VerifyTimeSeriesResult.AssertTimeSeriesMatchesGolden() + }, + createDerived: (underTest: MotionValueCollection) -> List = { + emptyList() + }, + capture: CaptureTimeSeriesFn = defaultManagedFeatureCaptures, + testInput: suspend CollectionInputScope.() -> Unit, + ) = + motion.goldenTest( + spec, + initialValue, + initialDirection, + directionChangeSlop, + stableThreshold, + verifyTimeSeries, + createDerived, + capture, + testInput, + ) + + companion object { + /** Default feature captures. */ + val defaultManagedFeatureCaptures: CaptureTimeSeriesFn = { + feature(FeatureCaptures.output.whenActive()) + feature(FeatureCaptures.outputTarget.whenActive()) + feature(FeatureCaptures.isStable.whenActive()) + feature(FeatureCaptures.isAnimating) + } + } +} diff --git a/mechanics/tests/src/com/android/mechanics/MotionValueLifecycleTest.kt b/mechanics/tests/src/com/android/mechanics/MotionValueLifecycleTest.kt index 72ba985..f3dc050 100644 --- a/mechanics/tests/src/com/android/mechanics/MotionValueLifecycleTest.kt +++ b/mechanics/tests/src/com/android/mechanics/MotionValueLifecycleTest.kt @@ -42,7 +42,7 @@ class MotionValueLifecycleTest { fun keepRunning_suspendsWithoutAnAnimation() = runTest { val input = mutableFloatStateOf(0f) val spec = specBuilder(Mapping.Zero) { fixedValue(breakpoint = 1f, value = 1f) } - val underTest = MotionValue(input::value, FakeGestureContext, spec) + val underTest = MotionValue(input::value, FakeGestureContext, { spec }) rule.setContent { LaunchedEffect(Unit) { underTest.keepRunning() } } val inspector = underTest.debugInspector() @@ -91,7 +91,7 @@ class MotionValueLifecycleTest { fun keepRunning_remainsActiveWhileAnimating() = runTest { val input = mutableFloatStateOf(0f) val spec = specBuilder(Mapping.Zero) { fixedValue(breakpoint = 1f, value = 1f) } - val underTest = MotionValue(input::value, FakeGestureContext, spec) + val underTest = MotionValue(input::value, FakeGestureContext, { spec }) rule.setContent { LaunchedEffect(Unit) { underTest.keepRunning() } } val inspector = underTest.debugInspector() @@ -150,7 +150,7 @@ class MotionValueLifecycleTest { fun keepRunningWhile_stopRunningWhileStable_endsImmediately() = runTest { val input = mutableFloatStateOf(0f) val spec = specBuilder(Mapping.Zero) { fixedValue(breakpoint = 1f, value = 1f) } - val underTest = MotionValue(input::value, FakeGestureContext, spec) + val underTest = MotionValue(input::value, FakeGestureContext, { spec }) val continueRunning = mutableStateOf(true) diff --git a/mechanics/tests/src/com/android/mechanics/MotionValueTest.kt b/mechanics/tests/src/com/android/mechanics/MotionValueTest.kt index ffb8e87..740a803 100644 --- a/mechanics/tests/src/com/android/mechanics/MotionValueTest.kt +++ b/mechanics/tests/src/com/android/mechanics/MotionValueTest.kt @@ -50,6 +50,7 @@ import com.android.mechanics.testing.input import com.android.mechanics.testing.isStable import com.android.mechanics.testing.output import com.google.common.truth.Truth.assertThat +import kotlin.test.assertFailsWith import kotlinx.coroutines.launch import org.junit.Rule import org.junit.Test @@ -71,7 +72,7 @@ class MotionValueTest : MotionBuilderContext by FakeMotionSpecBuilderContext.Def @Test fun emptySpec_outputMatchesInput_withoutAnimation() = motion.goldenTest( - spec = MotionSpec.Empty, + spec = MotionSpec.Identity, verifyTimeSeries = { // Output always matches the input assertThat(output).containsExactlyElementsIn(input).inOrder() @@ -84,6 +85,48 @@ class MotionValueTest : MotionBuilderContext by FakeMotionSpecBuilderContext.Def animateValueTo(100f) } + @Test + fun unspecifiedSpec_outputIsNan() = + motion.goldenTest( + spec = MotionSpec.InitiallyUndefined, + verifyTimeSeries = { + // This must only produce NaN values + output.forEach { assertThat(it).isNaN() } + // There must never be an ongoing animation. + assertThat(isStable).doesNotContain(false) + AssertTimeSeriesMatchesGolden() + }, + ) { + animateValueTo(100f) + } + + @Test + fun unspecifiedSpec_atTheBeginning_jumpcutsToFirstValue() = + motion.goldenTest( + spec = MotionSpec.InitiallyUndefined, + verifyTimeSeries = { + // There must never be an ongoing animation. + assertThat(isStable).doesNotContain(false) + + AssertTimeSeriesMatchesGolden() + }, + ) { + animateValueTo(10f, changePerFrame = 5f) + spec = MotionSpec.Identity + animateValueTo(20f, changePerFrame = 5f) + } + + @Test + fun unspecifiedSpec_onAlreadyInitializedValue_throws() { + assertFailsWith { + motion.goldenTest(spec = MotionSpec.Identity) { + animateValueTo(10f, changePerFrame = 5f) + spec = MotionSpec.InitiallyUndefined + animateValueTo(20f, changePerFrame = 5f) + } + } + } + // TODO the tests should describe the expected values not only in terms of goldens, but // also explicitly in verifyTimeSeries @@ -251,6 +294,66 @@ class MotionValueTest : MotionBuilderContext by FakeMotionSpecBuilderContext.Def awaitStable() } + @Test + fun observeWhen_isOutputFixed() { + motion.goldenTest( + spec = + specBuilder(Mapping.Zero) { + fixedValue(breakpoint = 1f, value = 10f) + fractionalInput(breakpoint = 2f, from = 20f, fraction = 1f) + fixedValue(breakpoint = 3f, value = 10f) + }, + stableThreshold = 1f, + capture = { + defaultFeatureCaptures() + feature(FeatureCaptures.isOutputFixed) + }, + ) { + // Segment: Mapping.Zero + + updateInput(0.5f) + assertThat(underTest.isOutputFixed).isTrue() + + // Segment: fixedValue(breakpoint = 1f, value = 10f) + + updateInput(1.5f) + assertThat(underTest.isOutputFixed).isFalse() + awaitStable() + assertThat(underTest.isOutputFixed).isFalse() + awaitFrames(1) + assertThat(underTest.isOutputFixed).isTrue() + + updateInput(1.9f) + assertThat(underTest.isOutputFixed).isTrue() + + // Segment: fractionalInput(breakpoint = 2f, from = 20f, fraction = 1f) + + updateInput(2.5f) + assertThat(underTest.isOutputFixed).isFalse() + awaitStable() + assertThat(underTest.isOutputFixed).isFalse() + awaitFrames(1) + assertThat(underTest.isOutputFixed).isFalse() + + updateInput(2.9f) + awaitStable() + awaitFrames(1) + assertThat(underTest.isOutputFixed).isFalse() + + // Segment: fixedValue(breakpoint = 3f, value = 10f) + + updateInput(3.5f) + assertThat(underTest.isOutputFixed).isFalse() + awaitStable() + assertThat(underTest.isOutputFixed).isFalse() + awaitFrames(1) + assertThat(underTest.isOutputFixed).isTrue() + + updateInput(3.9f) + assertThat(underTest.isOutputFixed).isTrue() + } + } + @Test fun specChange_shiftSegmentBackwards_doesNotAnimateWithinSegment_animatesSegmentChange() { fun generateSpec(offset: Float) = @@ -263,7 +366,7 @@ class MotionValueTest : MotionBuilderContext by FakeMotionSpecBuilderContext.Def var offset = 0f repeat(4) { offset -= .2f - underTest.spec = generateSpec(offset) + spec = generateSpec(offset) awaitFrames() } awaitStable() @@ -282,7 +385,7 @@ class MotionValueTest : MotionBuilderContext by FakeMotionSpecBuilderContext.Def var offset = 0f repeat(4) { offset += .2f - underTest.spec = generateSpec(offset) + spec = generateSpec(offset) awaitFrames() } awaitStable() @@ -426,7 +529,7 @@ class MotionValueTest : MotionBuilderContext by FakeMotionSpecBuilderContext.Def @Test fun semantics_returnsNullForUnknownKey() { - val underTest = MotionValue({ 1f }, FakeGestureContext) + val underTest = MotionValue({ 1f }, FakeGestureContext, { MotionSpec.Identity }) val s1 = SemanticKey("Foo") @@ -443,7 +546,7 @@ class MotionValueTest : MotionBuilderContext by FakeMotionSpecBuilderContext.Def } val input = mutableFloatStateOf(0f) - val underTest = MotionValue(input::value, FakeGestureContext, spec) + val underTest = MotionValue(input::value, FakeGestureContext, { spec }) assertThat(underTest[s1]).isEqualTo("zero") input.floatValue = 2f @@ -459,7 +562,7 @@ class MotionValueTest : MotionBuilderContext by FakeMotionSpecBuilderContext.Def } val input = mutableFloatStateOf(1f) - val underTest = MotionValue(input::value, FakeGestureContext, spec) + val underTest = MotionValue(input::value, FakeGestureContext, { spec }) assertThat(underTest.segmentKey).isEqualTo(SegmentKey(B1, B2, InputDirection.Max)) input.floatValue = 2f @@ -472,7 +575,9 @@ class MotionValueTest : MotionBuilderContext by FakeMotionSpecBuilderContext.Def motion.goldenTest( spec = specBuilder(Mapping.Zero) { fixedValue(breakpoint = 0.5f, value = 1f) }, createDerived = { primary -> - listOf(MotionValue.createDerived(primary, MotionSpec.Empty, label = "derived")) + listOf( + MotionValue.createDerived(primary, { MotionSpec.Identity }, label = "derived") + ) }, verifyTimeSeries = { // the output of the derived value must match the primary value @@ -497,8 +602,10 @@ class MotionValueTest : MotionBuilderContext by FakeMotionSpecBuilderContext.Def createDerived = { primary -> listOf( MotionValue.createDerived( - primary, - specBuilder(Mapping.One) { fixedValue(breakpoint = 0.5f, value = 0f) }, + source = primary, + spec = { + specBuilder(Mapping.One) { fixedValue(breakpoint = 0.5f, value = 0f) } + }, label = "derived", ) ) @@ -529,7 +636,7 @@ class MotionValueTest : MotionBuilderContext by FakeMotionSpecBuilderContext.Def @Test fun nonFiniteNumbers_segmentChange_skipsAnimation() { motion.goldenTest( - spec = MotionSpec.Empty, + spec = MotionSpec.Identity, verifyTimeSeries = { // The mappings produce a non-finite number during a segment change. // The animation thereof is skipped to avoid poisoning the state with non-finite @@ -541,9 +648,7 @@ class MotionValueTest : MotionBuilderContext by FakeMotionSpecBuilderContext.Def }, ) { animatedInputSequence(0f, 1f) - underTest.spec = specBuilder { - mapping(breakpoint = 0f) { if (it >= 1f) Float.NaN else 0f } - } + spec = specBuilder { mapping(breakpoint = 0f) { if (it >= 1f) Float.NaN else 0f } } awaitFrames() @@ -581,7 +686,8 @@ class MotionValueTest : MotionBuilderContext by FakeMotionSpecBuilderContext.Def @Test fun keepRunning_concurrentInvocationThrows() = runMonotonicClockTest { - val underTest = MotionValue({ 1f }, FakeGestureContext, label = "Foo") + val underTest = + MotionValue({ 1f }, FakeGestureContext, { MotionSpec.Identity }, label = "Foo") val realJob = launch { underTest.keepRunning() } testScheduler.runCurrent() @@ -599,7 +705,7 @@ class MotionValueTest : MotionBuilderContext by FakeMotionSpecBuilderContext.Def @Test fun debugInspector_sameInstance_whileInUse() { - val underTest = MotionValue({ 1f }, FakeGestureContext) + val underTest = MotionValue({ 1f }, FakeGestureContext, { MotionSpec.Identity }) val originalInspector = underTest.debugInspector() assertThat(underTest.debugInspector()).isSameInstanceAs(originalInspector) @@ -607,7 +713,7 @@ class MotionValueTest : MotionBuilderContext by FakeMotionSpecBuilderContext.Def @Test fun debugInspector_newInstance_afterUnused() { - val underTest = MotionValue({ 1f }, FakeGestureContext) + val underTest = MotionValue({ 1f }, FakeGestureContext, { MotionSpec.Identity }) val originalInspector = underTest.debugInspector() originalInspector.dispose() diff --git a/mechanics/tests/src/com/android/mechanics/debug/MotionValueDebuggerTest.kt b/mechanics/tests/src/com/android/mechanics/debug/MotionValueDebuggerTest.kt index dfe69b8..a7c3b30 100644 --- a/mechanics/tests/src/com/android/mechanics/debug/MotionValueDebuggerTest.kt +++ b/mechanics/tests/src/com/android/mechanics/debug/MotionValueDebuggerTest.kt @@ -17,6 +17,7 @@ package com.android.mechanics.debug import androidx.compose.foundation.layout.Box +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -27,6 +28,7 @@ import androidx.test.ext.junit.runners.AndroidJUnit4 import com.android.mechanics.MotionValue import com.android.mechanics.ProvidedGestureContext import com.android.mechanics.spec.InputDirection +import com.android.mechanics.spec.MotionSpec import com.google.common.truth.Truth.assertThat import org.junit.Rule import org.junit.Test @@ -43,51 +45,55 @@ class MotionValueDebuggerTest { @Test fun debugMotionValue_registersMotionValue_whenAddingToComposition() { - val debuggerState = MotionValueDebuggerState() + val debuggerState = MotionValueDebugController() var hasValue by mutableStateOf(false) rule.setContent { - Box(modifier = Modifier.motionValueDebugger(debuggerState)) { + CompositionLocalProvider(LocalMotionValueDebugController provides debuggerState) { if (hasValue) { - val toDebug = remember { MotionValue(input, gestureContext) } + val toDebug = remember { + MotionValue(input, gestureContext, { MotionSpec.Identity }) + } Box(modifier = Modifier.debugMotionValue(toDebug)) } } } - assertThat(debuggerState.observedMotionValues).isEmpty() + assertThat(debuggerState.observed).isEmpty() hasValue = true rule.waitForIdle() - assertThat(debuggerState.observedMotionValues).hasSize(1) + assertThat(debuggerState.observed).hasSize(1) } @Test fun debugMotionValue_unregistersMotionValue_whenLeavingComposition() { - val debuggerState = MotionValueDebuggerState() + val debuggerState = MotionValueDebugController() var hasValue by mutableStateOf(true) rule.setContent { - Box(modifier = Modifier.motionValueDebugger(debuggerState)) { + CompositionLocalProvider(LocalMotionValueDebugController provides debuggerState) { if (hasValue) { - val toDebug = remember { MotionValue(input, gestureContext) } + val toDebug = remember { + MotionValue(input, gestureContext, { MotionSpec.Identity }) + } Box(modifier = Modifier.debugMotionValue(toDebug)) } } } - assertThat(debuggerState.observedMotionValues).hasSize(1) + assertThat(debuggerState.observed).hasSize(1) hasValue = false rule.waitForIdle() - assertThat(debuggerState.observedMotionValues).isEmpty() + assertThat(debuggerState.observed).isEmpty() } @Test fun debugMotionValue_noDebugger_isNoOp() { rule.setContent { - val toDebug = remember { MotionValue(input, gestureContext) } + val toDebug = remember { MotionValue(input, gestureContext, { MotionSpec.Identity }) } Box(modifier = Modifier.debugMotionValue(toDebug)) } } diff --git a/mechanics/tests/src/com/android/mechanics/effects/ToggleTest.kt b/mechanics/tests/src/com/android/mechanics/effects/ToggleTest.kt new file mode 100644 index 0000000..17212b8 --- /dev/null +++ b/mechanics/tests/src/com/android/mechanics/effects/ToggleTest.kt @@ -0,0 +1,178 @@ +/* + * Copyright (C) 2025 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.mechanics.effects + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.android.mechanics.DistanceGestureContext +import com.android.mechanics.MotionValue +import com.android.mechanics.spec.InputDirection +import com.android.mechanics.spec.Mapping +import com.android.mechanics.spec.MotionSpec +import com.android.mechanics.spec.builder.MotionBuilderContext +import com.android.mechanics.spec.builder.spatialMotionSpec +import com.android.mechanics.testing.ComposeMotionValueToolkit +import com.android.mechanics.testing.FakeMotionSpecBuilderContext +import com.android.mechanics.testing.FeatureCaptures +import com.android.mechanics.testing.InputScope +import com.android.mechanics.testing.MotionSpecSubject.Companion.assertThat +import com.android.mechanics.testing.animateValueTo +import com.android.mechanics.testing.goldenTest +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import platform.test.motion.MotionTestRule +import platform.test.motion.testing.createGoldenPathManager +import platform.test.screenshot.PathConfig +import platform.test.screenshot.PathElementNoContext + +@RunWith(AndroidJUnit4::class) +class ToggleSpecTest : MotionBuilderContext by FakeMotionSpecBuilderContext.Default { + + val underTest = ExpansionToggle.Default + + @Test + fun toggle_matchesSpec() { + val spec = spatialMotionSpec { + between( + 0f, + 100f, + Toggle( + ExpansionToggle.IsExpandedKey, + minState = false, + maxState = true, + toggleFraction = .75f, + ), + ) + } + + assertThat(spec).maxDirection().breakpoints().positions().containsExactly(0f, 75f, 100f) + assertThat(spec).minDirection().breakpoints().positions().containsExactly(0f, 25f, 100f) + } + + @Test + fun stateSemantics_isApplied() { + val underTests = spatialMotionSpec { between(10f, 20f, underTest) } + + assertThat(underTests) + .maxDirection() + .semantics() + .withKey(ExpansionToggle.IsExpandedKey) + .containsExactly(false, false, true, true) + assertThat(underTests) + .minDirection() + .semantics() + .withKey(ExpansionToggle.IsExpandedKey) + .containsExactly(false, false, true, true) + } +} + +class ToggleGoldenTest() : MotionBuilderContext by FakeMotionSpecBuilderContext.Default { + + private val goldenPathManager = + createGoldenPathManager( + "frameworks/libs/systemui/mechanics/tests/goldens", + PathConfig(PathElementNoContext("effect", isDir = true) { "Toggle" }), + ) + + @get:Rule val motion = MotionTestRule(ComposeMotionValueToolkit, goldenPathManager) + + val underTest = ExpansionToggle.Default + + @Test + fun maxDirection_togglesAtThreshold() = + goldenTest(spatialMotionSpec { between(10f, 20f, underTest) }, 8f, InputDirection.Max) { + animateValueTo(17f, changePerFrame = 1f) + awaitStable() + animateValueTo(22f, changePerFrame = 1f) + } + + @Test + fun maxDirection_preventsDirectionChangeBeforeToggle() = + goldenTest(spatialMotionSpec { between(10f, 20f, underTest) }, 8f, InputDirection.Max) { + animateValueTo(15f, changePerFrame = 1f) + awaitStable() + animateValueTo(8f, changePerFrame = 1f) + } + + @Test + fun maxDirection_AfterToggle_preventsJumpOnDirectionChange() = + goldenTest(spatialMotionSpec { between(10f, 20f, underTest) }, 8f, InputDirection.Max) { + animateValueTo(18f, changePerFrame = 1f) + awaitStable() + animateValueTo(15f, changePerFrame = 1f) + animateValueTo(22f, changePerFrame = 1f) + } + + @Test + fun minDirection_togglesAtThreshold() = + goldenTest(spatialMotionSpec { between(10f, 20f, underTest) }, 22f, InputDirection.Min) { + animateValueTo(13f, changePerFrame = 1f) + awaitStable() + animateValueTo(8f, changePerFrame = 1f) + } + + @Test + fun minDirection_preventsDirectionChangeBeforeToggle() = + goldenTest(spatialMotionSpec { between(10f, 20f, underTest) }, 22f, InputDirection.Min) { + animateValueTo(15f, changePerFrame = 1f) + awaitStable() + animateValueTo(22f, changePerFrame = 1f) + } + + @Test + fun minDirection_AfterToggle_preventsJumpOnDirectionChange() = + goldenTest(spatialMotionSpec { between(10f, 20f, underTest) }, 22f, InputDirection.Min) { + animateValueTo(12f, changePerFrame = 1f) + awaitStable() + animateValueTo(15f, changePerFrame = 1f) + animateValueTo(8f, changePerFrame = 1f) + } + + @Test + fun output_groundedInBaseMapping() = + goldenTest( + spatialMotionSpec(baseMapping = Mapping.Linear(factor = -10f)) { + between(10f, 20f, underTest) + }, + 8f, + InputDirection.Max, + ) { + animateValueTo(22f, changePerFrame = 1f) + awaitStable() + } + + private fun goldenTest( + spec: MotionSpec, + initialValue: Float, + initialDirection: InputDirection, + testInput: suspend (InputScope).() -> Unit, + ) = + motion.goldenTest( + spec, + initialValue, + initialDirection, + directionChangeSlop = 0.5f, + stableThreshold = 0.1f, + capture = { + feature(FeatureCaptures.input) + feature(FeatureCaptures.gestureDirection) + feature(FeatureCaptures.output) + feature(FeatureCaptures.outputTarget) + }, + testInput = testInput, + ) +} diff --git a/mechanics/tests/src/com/android/mechanics/spec/DirectionalMotionSpecTest.kt b/mechanics/tests/src/com/android/mechanics/spec/DirectionalMotionSpecTest.kt index 30c8513..d2a00cc 100644 --- a/mechanics/tests/src/com/android/mechanics/spec/DirectionalMotionSpecTest.kt +++ b/mechanics/tests/src/com/android/mechanics/spec/DirectionalMotionSpecTest.kt @@ -17,6 +17,8 @@ package com.android.mechanics.spec import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.android.mechanics.haptics.BreakpointHaptics +import com.android.mechanics.haptics.SegmentHaptics import com.android.mechanics.spec.builder.directionalMotionSpec import com.android.mechanics.spring.SpringParameters import com.google.common.truth.Truth.assertThat @@ -32,24 +34,34 @@ class DirectionalMotionSpecTest { @Test fun noBreakpoints_throws() { assertFailsWith { - DirectionalMotionSpec(emptyList(), emptyList()) + DirectionalMotionSpec(emptyList(), emptyList(), emptyList()) } } @Test fun wrongSentinelBreakpoints_throws() { - val breakpoint1 = Breakpoint(B1, position = 10f, Spring, Guarantee.None) - val breakpoint2 = Breakpoint(B2, position = 20f, Spring, Guarantee.None) + val breakpoint1 = + Breakpoint(B1, position = 10f, Spring, Guarantee.None, BreakpointHaptics.None) + val breakpoint2 = + Breakpoint(B2, position = 20f, Spring, Guarantee.None, BreakpointHaptics.None) assertFailsWith { - DirectionalMotionSpec(listOf(breakpoint1, breakpoint2), listOf(Mapping.Identity)) + DirectionalMotionSpec( + listOf(breakpoint1, breakpoint2), + listOf(Mapping.Identity), + listOf(SegmentHaptics.None), + ) } } @Test fun tooFewMappings_throws() { assertFailsWith { - DirectionalMotionSpec(listOf(Breakpoint.minLimit, Breakpoint.maxLimit), emptyList()) + DirectionalMotionSpec( + listOf(Breakpoint.minLimit, Breakpoint.maxLimit), + emptyList(), + listOf(SegmentHaptics.None), + ) } } @@ -59,25 +71,51 @@ class DirectionalMotionSpecTest { DirectionalMotionSpec( listOf(Breakpoint.minLimit, Breakpoint.maxLimit), listOf(Mapping.One, Mapping.Two), + listOf(SegmentHaptics.None), + ) + } + } + + @Test + fun tooFewHaptics_throws() { + assertFailsWith { + DirectionalMotionSpec( + listOf(Breakpoint.minLimit, Breakpoint.maxLimit), + listOf(Mapping.One), + emptyList(), + ) + } + } + + @Test + fun tooManyHaptics_throws() { + assertFailsWith { + DirectionalMotionSpec( + listOf(Breakpoint.minLimit, Breakpoint.maxLimit), + listOf(Mapping.One), + listOf(SegmentHaptics.None, SegmentHaptics.None), ) } } @Test fun breakpointsOutOfOrder_throws() { - val breakpoint1 = Breakpoint(B1, position = 10f, Spring, Guarantee.None) - val breakpoint2 = Breakpoint(B2, position = 20f, Spring, Guarantee.None) + val breakpoint1 = + Breakpoint(B1, position = 10f, Spring, Guarantee.None, BreakpointHaptics.None) + val breakpoint2 = + Breakpoint(B2, position = 20f, Spring, Guarantee.None, BreakpointHaptics.None) assertFailsWith { DirectionalMotionSpec( listOf(Breakpoint.minLimit, breakpoint2, breakpoint1, Breakpoint.maxLimit), listOf(Mapping.Zero, Mapping.One, Mapping.Two), + listOf(SegmentHaptics.None, SegmentHaptics.None, SegmentHaptics.None), ) } } @Test fun findBreakpointIndex_returnsMinForEmptySpec() { - val underTest = DirectionalMotionSpec.Empty + val underTest = DirectionalMotionSpec.Identity assertThat(underTest.findBreakpointIndex(0f)).isEqualTo(0) assertThat(underTest.findBreakpointIndex(Float.MAX_VALUE)).isEqualTo(0) @@ -86,7 +124,7 @@ class DirectionalMotionSpecTest { @Test fun findBreakpointIndex_throwsForNonFiniteInput() { - val underTest = DirectionalMotionSpec.Empty + val underTest = DirectionalMotionSpec.Identity assertFailsWith { underTest.findBreakpointIndex(Float.NaN) } assertFailsWith { @@ -172,6 +210,7 @@ class DirectionalMotionSpecTest { DirectionalMotionSpec( listOf(Breakpoint.minLimit, Breakpoint.maxLimit), listOf(Mapping.Identity), + listOf(SegmentHaptics.None), listOf(SegmentSemanticValues(Semantic1, emptyList())), ) } @@ -183,6 +222,7 @@ class DirectionalMotionSpecTest { DirectionalMotionSpec( listOf(Breakpoint.minLimit, Breakpoint.maxLimit), listOf(Mapping.Identity), + listOf(SegmentHaptics.None), listOf(SegmentSemanticValues(Semantic1, listOf("One", "Two"))), ) } diff --git a/mechanics/tests/src/com/android/mechanics/spec/MotionSpecDebugFormatterTest.kt b/mechanics/tests/src/com/android/mechanics/spec/MotionSpecDebugFormatterTest.kt index 1777a72..86d138a 100644 --- a/mechanics/tests/src/com/android/mechanics/spec/MotionSpecDebugFormatterTest.kt +++ b/mechanics/tests/src/com/android/mechanics/spec/MotionSpecDebugFormatterTest.kt @@ -37,11 +37,13 @@ class MotionSpecDebugFormatterTest : MotionBuilderContext by FakeMotionSpecBuild .isEqualTo( """ unidirectional: - @-Infinity [built-in::min|id:0x1234cdef] + @-Infinity [built-in::min|id:0x1234cdef] [breakpointHaptics=None] Fixed(value=0.0) - @0.0 [id:0x1234cdef] spring=1600.0/1.0 + segment haptics: None + @0.0 [id:0x1234cdef] spring=1600.0/1.0 [breakpointHaptics=None] Fixed(value=1.0) - @Infinity [built-in::max|id:0x1234cdef]""" + segment haptics: None + @Infinity [built-in::max|id:0x1234cdef] [breakpointHaptics=None]""" .trimIndent() ) } @@ -58,17 +60,21 @@ unidirectional: .isEqualTo( """ maxDirection: - @-Infinity [built-in::min|id:0x1234cdef] + @-Infinity [built-in::min|id:0x1234cdef] [breakpointHaptics=None] Fixed(value=0.0) - @0.0 [id:0x1234cdef] spring=700.0/0.9 + segment haptics: None + @0.0 [id:0x1234cdef] spring=700.0/0.9 [breakpointHaptics=None] Fixed(value=1.0) - @Infinity [built-in::max|id:0x1234cdef] + segment haptics: None + @Infinity [built-in::max|id:0x1234cdef] [breakpointHaptics=None] minDirection: - @-Infinity [built-in::min|id:0x1234cdef] + @-Infinity [built-in::min|id:0x1234cdef] [breakpointHaptics=None] Fixed(value=1.0) - @0.0 [id:0x1234cdef] spring=700.0/0.9 + segment haptics: None + @0.0 [id:0x1234cdef] spring=700.0/0.9 [breakpointHaptics=None] Fixed(value=0.0) - @Infinity [built-in::max|id:0x1234cdef]""" + segment haptics: None + @Infinity [built-in::max|id:0x1234cdef] [breakpointHaptics=None]""" .trimIndent() ) } @@ -88,13 +94,15 @@ minDirection: .isEqualTo( """ unidirectional: - @-Infinity [built-in::min|id:0x1234cdef] + @-Infinity [built-in::min|id:0x1234cdef] [breakpointHaptics=None] Fixed(value=0.0) + segment haptics: None foo[id:0x1234cdef]=42.0 - @0.0 [id:0x1234cdef] spring=1600.0/1.0 + @0.0 [id:0x1234cdef] spring=1600.0/1.0 [breakpointHaptics=None] Fixed(value=1.0) + segment haptics: None foo[id:0x1234cdef]=43.0 - @Infinity [built-in::max|id:0x1234cdef]""" + @Infinity [built-in::max|id:0x1234cdef] [breakpointHaptics=None]""" .trimIndent() ) } @@ -122,13 +130,16 @@ unidirectional: .isEqualTo( """ unidirectional: - @-Infinity [built-in::min|id:0x1234cdef] + @-Infinity [built-in::min|id:0x1234cdef] [breakpointHaptics=None] Fixed(value=0.0) - @0.0 [1|id:0x1234cdef] spring=1600.0/1.0 + segment haptics: None + @0.0 [1|id:0x1234cdef] spring=1600.0/1.0 [breakpointHaptics=None] Fixed(value=1.0) - @2.0 [1|id:0x1234cdef] spring=1600.0/1.0 + segment haptics: None + @2.0 [1|id:0x1234cdef] spring=1600.0/1.0 [breakpointHaptics=None] Fixed(value=2.0) - @Infinity [built-in::max|id:0x1234cdef] + segment haptics: None + @Infinity [built-in::max|id:0x1234cdef] [breakpointHaptics=None] segmentHandlers: 1|id:0x1234cdef >> 2|id:0x1234cdef 1|id:0x1234cdef << 2|id:0x1234cdef""" diff --git a/mechanics/tests/src/com/android/mechanics/spec/MotionSpecTest.kt b/mechanics/tests/src/com/android/mechanics/spec/MotionSpecTest.kt index 260a8a7..3f8287e 100644 --- a/mechanics/tests/src/com/android/mechanics/spec/MotionSpecTest.kt +++ b/mechanics/tests/src/com/android/mechanics/spec/MotionSpecTest.kt @@ -30,7 +30,7 @@ class MotionSpecTest { @Test fun containsSegment_unknownSegment_returnsFalse() { - val underTest = MotionSpec.Empty + val underTest = MotionSpec.Identity assertThat(underTest.containsSegment(SegmentKey(B1, B2, InputDirection.Max))).isFalse() } @@ -57,7 +57,7 @@ class MotionSpecTest { fixedValue(breakpoint = 10f, key = B1, value = 1f) identity(breakpoint = 20f, key = B2) }, - minDirection = DirectionalMotionSpec.Empty, + minDirection = DirectionalMotionSpec.Identity, ) assertThat(underTest.containsSegment(SegmentKey(B1, B2, InputDirection.Max))).isTrue() @@ -68,7 +68,7 @@ class MotionSpecTest { fun containsSegment_asymmetricSpec_knownMinDirectionSegment_trueOnlyInMinDirection() { val underTest = MotionSpec( - maxDirection = DirectionalMotionSpec.Empty, + maxDirection = DirectionalMotionSpec.Identity, minDirection = directionalMotionSpec(Spring) { fixedValue(breakpoint = 10f, key = B1, value = 1f) @@ -82,7 +82,7 @@ class MotionSpecTest { @Test fun segmentAtInput_emptySpec_maxDirection_segmentDataIsCorrect() { - val underTest = MotionSpec.Empty + val underTest = MotionSpec.Identity val segmentAtInput = underTest.segmentAtInput(0f, InputDirection.Max) @@ -95,7 +95,7 @@ class MotionSpecTest { @Test fun segmentAtInput_emptySpec_minDirection_segmentDataIsCorrect() { - val underTest = MotionSpec.Empty + val underTest = MotionSpec.Identity val segmentAtInput = underTest.segmentAtInput(0f, InputDirection.Min) @@ -302,11 +302,43 @@ class MotionSpecTest { @Test fun semantics_unknownSegment_throws() { - val underTest = MotionSpec.Empty + val underTest = MotionSpec.Identity val unknownSegment = SegmentKey(BMin, B1, InputDirection.Max) assertFailsWith { underTest.semantics(unknownSegment) } } + @Test + fun semantics_atSpecLevel_canBeAssociatedWithSpec() { + val underTest = + MotionSpec(DirectionalMotionSpec.Identity, semantics = listOf(S1 with "One")) + + assertThat(underTest.semanticState(S1)).isEqualTo("One") + } + + @Test + fun semantics_atSpecLevel_canBeQueriedViaSegment() { + val underTest = + MotionSpec(DirectionalMotionSpec.Identity, semantics = listOf(S1 with "One")) + + val maxDirectionSegment = SegmentKey(BMin, BMax, InputDirection.Max) + assertThat(underTest.semanticState(S1, maxDirectionSegment)).isEqualTo("One") + } + + @Test + fun semantics_atSpecLevel_segmentLevelTakesPrecedence() { + val underTest = + MotionSpec( + maxDirection = directionalMotionSpec(semantics = listOf(S1 with "Two")), + minDirection = DirectionalMotionSpec.Identity, + semantics = listOf(S1 with "One"), + ) + + assertThat(underTest.semanticState(S1, SegmentKey(BMin, BMax, InputDirection.Max))) + .isEqualTo("Two") + assertThat(underTest.semanticState(S1, SegmentKey(BMin, BMax, InputDirection.Min))) + .isEqualTo("One") + } + companion object { val BMin = Breakpoint.minLimit.key val B1 = BreakpointKey("one") diff --git a/mechanics/tests/src/com/android/mechanics/spec/SegmentTest.kt b/mechanics/tests/src/com/android/mechanics/spec/SegmentTest.kt index f66991c..2b4bf5f 100644 --- a/mechanics/tests/src/com/android/mechanics/spec/SegmentTest.kt +++ b/mechanics/tests/src/com/android/mechanics/spec/SegmentTest.kt @@ -17,6 +17,8 @@ package com.android.mechanics.spec import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.android.mechanics.haptics.BreakpointHaptics +import com.android.mechanics.haptics.SegmentHaptics import com.android.mechanics.spring.SpringParameters import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertWithMessage @@ -26,34 +28,61 @@ import org.junit.runner.RunWith @RunWith(AndroidJUnit4::class) class SegmentTest { - private val fakeSpec = MotionSpec.Empty + private val fakeSpec = MotionSpec.Identity @Test fun segmentData_isValidForInput_betweenBreakpointsSameDirection_isTrue() { - val breakpoint1 = Breakpoint(B1, position = 10f, Spring, Guarantee.None) - val breakpoint2 = Breakpoint(B2, position = 20f, Spring, Guarantee.None) + val breakpoint1 = + Breakpoint(B1, position = 10f, Spring, Guarantee.None, BreakpointHaptics.None) + val breakpoint2 = + Breakpoint(B2, position = 20f, Spring, Guarantee.None, BreakpointHaptics.None) val underTest = - SegmentData(fakeSpec, breakpoint1, breakpoint2, InputDirection.Max, Mapping.Identity) + SegmentData( + fakeSpec, + breakpoint1, + breakpoint2, + InputDirection.Max, + Mapping.Identity, + SegmentHaptics.None, + ) assertThat(underTest.isValidForInput(15f, InputDirection.Max)).isTrue() } @Test fun segmentData_isValidForInput_betweenBreakpointsOppositeDirection_isFalse() { - val breakpoint1 = Breakpoint(B1, position = 10f, Spring, Guarantee.None) - val breakpoint2 = Breakpoint(B2, position = 20f, Spring, Guarantee.None) + val breakpoint1 = + Breakpoint(B1, position = 10f, Spring, Guarantee.None, BreakpointHaptics.None) + val breakpoint2 = + Breakpoint(B2, position = 20f, Spring, Guarantee.None, BreakpointHaptics.None) val underTest = - SegmentData(fakeSpec, breakpoint1, breakpoint2, InputDirection.Max, Mapping.Identity) + SegmentData( + fakeSpec, + breakpoint1, + breakpoint2, + InputDirection.Max, + Mapping.Identity, + SegmentHaptics.None, + ) assertThat(underTest.isValidForInput(15f, InputDirection.Min)).isFalse() } @Test fun segmentData_isValidForInput_inMaxDirection_sampledAtVariousPositions_matchesExpectation() { - val breakpoint1 = Breakpoint(B1, position = 10f, Spring, Guarantee.None) - val breakpoint2 = Breakpoint(B2, position = 20f, Spring, Guarantee.None) + val breakpoint1 = + Breakpoint(B1, position = 10f, Spring, Guarantee.None, BreakpointHaptics.None) + val breakpoint2 = + Breakpoint(B2, position = 20f, Spring, Guarantee.None, BreakpointHaptics.None) val underTest = - SegmentData(fakeSpec, breakpoint1, breakpoint2, InputDirection.Max, Mapping.Identity) + SegmentData( + fakeSpec, + breakpoint1, + breakpoint2, + InputDirection.Max, + Mapping.Identity, + SegmentHaptics.None, + ) for ((samplePosition, expectedResult) in listOf(5f to true, 10f to true, 15f to true, 20f to false, 25f to false)) { @@ -65,10 +94,19 @@ class SegmentTest { @Test fun segmentData_isValidForInput_inMinDirection_sampledAtVariousPositions_matchesExpectation() { - val breakpoint1 = Breakpoint(B1, position = 10f, Spring, Guarantee.None) - val breakpoint2 = Breakpoint(B2, position = 20f, Spring, Guarantee.None) + val breakpoint1 = + Breakpoint(B1, position = 10f, Spring, Guarantee.None, BreakpointHaptics.None) + val breakpoint2 = + Breakpoint(B2, position = 20f, Spring, Guarantee.None, BreakpointHaptics.None) val underTest = - SegmentData(fakeSpec, breakpoint1, breakpoint2, InputDirection.Min, Mapping.Identity) + SegmentData( + fakeSpec, + breakpoint1, + breakpoint2, + InputDirection.Min, + Mapping.Identity, + SegmentHaptics.None, + ) for ((samplePosition, expectedResult) in listOf(5f to false, 10f to false, 15f to true, 20f to true, 25f to true)) { @@ -80,20 +118,38 @@ class SegmentTest { @Test fun segmentData_entryBreakpoint_maxDirection_returnsMinBreakpoint() { - val breakpoint1 = Breakpoint(B1, position = 10f, Spring, Guarantee.None) - val breakpoint2 = Breakpoint(B2, position = 20f, Spring, Guarantee.None) + val breakpoint1 = + Breakpoint(B1, position = 10f, Spring, Guarantee.None, BreakpointHaptics.None) + val breakpoint2 = + Breakpoint(B2, position = 20f, Spring, Guarantee.None, BreakpointHaptics.None) val underTest = - SegmentData(fakeSpec, breakpoint1, breakpoint2, InputDirection.Max, Mapping.Identity) + SegmentData( + fakeSpec, + breakpoint1, + breakpoint2, + InputDirection.Max, + Mapping.Identity, + SegmentHaptics.None, + ) assertThat(underTest.entryBreakpoint).isSameInstanceAs(breakpoint1) } @Test fun segmentData_entryBreakpoint_minDirection_returnsMaxBreakpoint() { - val breakpoint1 = Breakpoint(B1, position = 10f, Spring, Guarantee.None) - val breakpoint2 = Breakpoint(B2, position = 20f, Spring, Guarantee.None) + val breakpoint1 = + Breakpoint(B1, position = 10f, Spring, Guarantee.None, BreakpointHaptics.None) + val breakpoint2 = + Breakpoint(B2, position = 20f, Spring, Guarantee.None, BreakpointHaptics.None) val underTest = - SegmentData(fakeSpec, breakpoint1, breakpoint2, InputDirection.Min, Mapping.Identity) + SegmentData( + fakeSpec, + breakpoint1, + breakpoint2, + InputDirection.Min, + Mapping.Identity, + SegmentHaptics.None, + ) assertThat(underTest.entryBreakpoint).isSameInstanceAs(breakpoint2) } diff --git a/mechanics/tests/src/com/android/mechanics/spec/builder/DirectionalBuilderImplTest.kt b/mechanics/tests/src/com/android/mechanics/spec/builder/DirectionalBuilderImplTest.kt index b399731..72fde69 100644 --- a/mechanics/tests/src/com/android/mechanics/spec/builder/DirectionalBuilderImplTest.kt +++ b/mechanics/tests/src/com/android/mechanics/spec/builder/DirectionalBuilderImplTest.kt @@ -17,6 +17,8 @@ package com.android.mechanics.spec.builder import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.android.mechanics.haptics.HapticsExperimentalApi +import com.android.mechanics.haptics.SegmentHaptics import com.android.mechanics.spec.BreakpointKey import com.android.mechanics.spec.Guarantee import com.android.mechanics.spec.Mapping @@ -186,7 +188,12 @@ class DirectionalBuilderImplTest { @Test fun semantics_appliedForSingleSegment() { - val result = directionalMotionSpec(Mapping.Identity, listOf(S1 with "One", S2 with "Two")) + val result = + directionalMotionSpec( + Mapping.Identity, + SegmentHaptics.None, + listOf(S1 with "One", S2 with "Two"), + ) assertThat(result).semantics().containsExactly(S1, S2) assertThat(result).semantics().withKey(S1).containsExactly("One") @@ -285,6 +292,37 @@ class DirectionalBuilderImplTest { assertThat(result).breakpoints().atPosition(0f).spring().isEqualTo(context.effects.default) } + @OptIn(HapticsExperimentalApi::class) + @Test + fun directionalSpec_segmentHapticsBuilder_createsSegmentHapticsForSingleSegment() { + val expectedHaptics = SegmentHaptics.SpringTension(anchorPointPx = 0f) + val result = + directionalMotionSpec(Spring) { + haptics(expectedHaptics) { mapping(breakpoint = 30f, mapping = Mapping.Identity) } + mapping(breakpoint = 40f, mapping = Mapping.Identity) + } + + assertThat(result).segmentHaptics().at(30f).isEqualTo(expectedHaptics) + assertThat(result).segmentHaptics().at(40f).isEqualTo(SegmentHaptics.None) + } + + @OptIn(HapticsExperimentalApi::class) + @Test + fun directionalSpec_segmentHapticsBuilder_createsSegmentHapticsForMultipleSegments() { + val expectedHaptics = SegmentHaptics.SpringTension(anchorPointPx = 0f) + val result = + directionalMotionSpec(Spring) { + haptics(expectedHaptics) { + mapping(breakpoint = 30f, mapping = Mapping.Identity) + mapping(breakpoint = 40f, mapping = Mapping.Identity) + } + mapping(breakpoint = 50f, mapping = Mapping.Identity) + } + + assertThat(result).segmentHaptics().at(30f).isEqualTo(expectedHaptics) + assertThat(result).segmentHaptics().at(40f).isEqualTo(expectedHaptics) + } + companion object { val Spring = SpringParameters(stiffness = 100f, dampingRatio = 1f) val B1 = BreakpointKey("One") diff --git a/mechanics/tests/src/com/android/mechanics/spec/builder/MotionSpecBuilderTest.kt b/mechanics/tests/src/com/android/mechanics/spec/builder/MotionSpecBuilderTest.kt index 2b6760a..148ae86 100644 --- a/mechanics/tests/src/com/android/mechanics/spec/builder/MotionSpecBuilderTest.kt +++ b/mechanics/tests/src/com/android/mechanics/spec/builder/MotionSpecBuilderTest.kt @@ -48,6 +48,22 @@ class MotionSpecBuilderTest : MotionBuilderContext by FakeMotionSpecBuilderConte assertThat(result).bothDirections().breakpoints().isEmpty() } + @Test + fun motionSpec_semantics_appliedToSpec() { + val result = spatialMotionSpec(semantics = listOf(TestSemantics with "One")) {} + + assertThat(result.semanticState(TestSemantics)).isEqualTo("One") + assertThat(result).bothDirections().semantics().withKey(TestSemantics).isNull() + } + + @Test + fun fixedMotionSpec_semantics_appliedToSpec() { + val result = fixedSpatialValueSpec(0f, semantics = listOf(TestSemantics with "One")) + + assertThat(result.semanticState(TestSemantics)).isEqualTo("One") + assertThat(result).bothDirections().semantics().withKey(TestSemantics).isNull() + } + @Test fun placement_absoluteAfter_createsTwoSegments() { val result = diff --git a/mechanics/tests/src/com/android/mechanics/view/ViewMotionValueTest.kt b/mechanics/tests/src/com/android/mechanics/view/ViewMotionValueTest.kt index 7d7fdcd..4027fb4 100644 --- a/mechanics/tests/src/com/android/mechanics/view/ViewMotionValueTest.kt +++ b/mechanics/tests/src/com/android/mechanics/view/ViewMotionValueTest.kt @@ -37,6 +37,7 @@ import com.android.mechanics.testing.input import com.android.mechanics.testing.isStable import com.android.mechanics.testing.output import com.google.common.truth.Truth.assertThat +import kotlin.test.assertFailsWith import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.runBlocking import kotlinx.coroutines.test.runTest @@ -76,7 +77,7 @@ class ViewMotionValueTest { @Test fun emptySpec_outputMatchesInput_withoutAnimation() = motion.goldenTest( - spec = MotionSpec.Empty, + spec = MotionSpec.Identity, verifyTimeSeries = { // Output always matches the input assertThat(output).containsExactlyElementsIn(input).inOrder() @@ -89,6 +90,48 @@ class ViewMotionValueTest { animateValueTo(100f) } + @Test + fun unspecifiedSpec_outputIsNan() = + motion.goldenTest( + spec = MotionSpec.InitiallyUndefined, + verifyTimeSeries = { + // This must only produce NaN values + output.forEach { assertThat(it).isNaN() } + // There must never be an ongoing animation. + assertThat(isStable).doesNotContain(false) + AssertTimeSeriesMatchesGolden() + }, + ) { + animateValueTo(100f) + } + + @Test + fun unspecifiedSpec_atTheBeginning_jumpcutsToFirstValue() = + motion.goldenTest( + spec = MotionSpec.InitiallyUndefined, + verifyTimeSeries = { + // There must never be an ongoing animation. + assertThat(isStable).doesNotContain(false) + + AssertTimeSeriesMatchesGolden() + }, + ) { + animateValueTo(10f, changePerFrame = 5f) + spec = MotionSpec.Identity + animateValueTo(20f, changePerFrame = 5f) + } + + @Test + fun unspecifiedSpec_onAlreadyInitializedValue_throws() { + assertFailsWith { + motion.goldenTest(spec = MotionSpec.Identity) { + animateValueTo(10f, changePerFrame = 5f) + spec = MotionSpec.InitiallyUndefined + animateValueTo(20f, changePerFrame = 5f) + } + } + } + @Test fun segmentChange_animatedWhenReachingBreakpoint() = motion.goldenTest( @@ -162,6 +205,7 @@ class ViewMotionValueTest { } motion.goldenTest(spec = generateSpec(0f), initialValue = .5f) { + awaitFrames() underTest.spec = generateSpec(1f) awaitFrames() awaitStable() @@ -172,7 +216,7 @@ class ViewMotionValueTest { fun update_triggersCallback() = runTest { runBlocking(Dispatchers.Main) { val gestureContext = DistanceGestureContext(0f, InputDirection.Max, 5f) - val underTest = ViewMotionValue(0f, gestureContext, MotionSpec.Empty) + val underTest = ViewMotionValue(0f, gestureContext, MotionSpec.Identity) var invocationCount = 0 underTest.addUpdateCallback { invocationCount++ } @@ -187,7 +231,10 @@ class ViewMotionValueTest { fun update_setSameValue_doesNotTriggerCallback() = runTest { runBlocking(Dispatchers.Main) { val gestureContext = DistanceGestureContext(0f, InputDirection.Max, 5f) - val underTest = ViewMotionValue(0f, gestureContext, MotionSpec.Empty) + val underTest = ViewMotionValue(0f, gestureContext, MotionSpec.Identity) + + // Ensure the initial update has been processed + animatorTestRule.advanceTimeBy(16L) var invocationCount = 0 underTest.addUpdateCallback { invocationCount++ } @@ -205,6 +252,9 @@ class ViewMotionValueTest { val spec = specBuilder(Mapping.Zero) { fixedValue(breakpoint = 1f, value = 1f) } val underTest = ViewMotionValue(0f, gestureContext, spec) + // Ensure the initial update has been processed + animatorTestRule.advanceTimeBy(16L) + var invocationCount = 0 underTest.addUpdateCallback { invocationCount++ } underTest.input = 1f @@ -240,7 +290,7 @@ class ViewMotionValueTest { fun debugInspector_sameInstance_whileInUse() = runTest { runBlocking(Dispatchers.Main) { val gestureContext = DistanceGestureContext(0f, InputDirection.Max, 5f) - val underTest = ViewMotionValue(0f, gestureContext, MotionSpec.Empty) + val underTest = ViewMotionValue(0f, gestureContext, MotionSpec.Identity) val originalInspector = underTest.debugInspector() assertThat(underTest.debugInspector()).isSameInstanceAs(originalInspector) @@ -251,7 +301,7 @@ class ViewMotionValueTest { fun debugInspector_newInstance_afterUnused() = runTest { runBlocking(Dispatchers.Main) { val gestureContext = DistanceGestureContext(0f, InputDirection.Max, 5f) - val underTest = ViewMotionValue(0f, gestureContext, MotionSpec.Empty) + val underTest = ViewMotionValue(0f, gestureContext, MotionSpec.Identity) val originalInspector = underTest.debugInspector() originalInspector.dispose() diff --git a/viewcapturelib/build.gradle b/viewcapturelib/build.gradle index 0eb79da..de0471e 100644 --- a/viewcapturelib/build.gradle +++ b/viewcapturelib/build.gradle @@ -1,5 +1,6 @@ plugins { alias(libs.plugins.android.library) + alias(libs.plugins.kotlin.android) alias(libs.plugins.google.protobuf) } diff --git a/viewcapturelib/src/com/android/app/viewcapture/PerfettoViewCapture.kt b/viewcapturelib/src/com/android/app/viewcapture/PerfettoViewCapture.kt index 9154e50..cfa6bc7 100644 --- a/viewcapturelib/src/com/android/app/viewcapture/PerfettoViewCapture.kt +++ b/viewcapturelib/src/com/android/app/viewcapture/PerfettoViewCapture.kt @@ -62,7 +62,7 @@ internal constructor(private val context: Context, executor: Executor) : val dataSourceParams = DataSourceParams.Builder() .setBufferExhaustedPolicy( - DataSourceParams.PERFETTO_DS_BUFFER_EXHAUSTED_POLICY_STALL_AND_ABORT + DataSourceParams.PERFETTO_DS_BUFFER_EXHAUSTED_POLICY_STALL_AND_DROP ) .setNoFlush(true) .setWillNotifyOnStop(false) diff --git a/viewcapturelib/tests/com/android/app/viewcapture/ViewCaptureAwareWindowManagerTest.kt b/viewcapturelib/tests/com/android/app/viewcapture/ViewCaptureAwareWindowManagerTest.kt index 378f355..977199c 100644 --- a/viewcapturelib/tests/com/android/app/viewcapture/ViewCaptureAwareWindowManagerTest.kt +++ b/viewcapturelib/tests/com/android/app/viewcapture/ViewCaptureAwareWindowManagerTest.kt @@ -19,7 +19,6 @@ package com.android.app.viewcapture import android.content.Context import android.content.Intent import android.hardware.display.DisplayManager -import android.platform.test.annotations.EnableFlags import android.platform.test.flag.junit.SetFlagsRule import android.testing.AndroidTestingRunner import android.view.Display.DEFAULT_DISPLAY @@ -31,7 +30,6 @@ import android.window.WindowContext import androidx.test.ext.junit.rules.ActivityScenarioRule import androidx.test.filters.SmallTest import androidx.test.platform.app.InstrumentationRegistry -import com.android.window.flags.Flags import com.google.common.truth.Truth.assertWithMessage import java.util.concurrent.CountDownLatch import java.util.concurrent.TimeUnit @@ -74,7 +72,6 @@ class ViewCaptureAwareWindowManagerTest { } } - @EnableFlags(Flags.FLAG_ENABLE_WINDOW_CONTEXT_OVERRIDE_TYPE) @Test fun useWithWindowContext_attachWindow_attachToViewCaptureAwareWm() { val windowContext =