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

Commit ef62191e authored by Adnan Begovic's avatar Adnan Begovic
Browse files

iconloaderlib: Adjust luminance delta to maintain contrast when applying monochrome icons.

This CL introduces a new column to the icon database that encodes the luminance delta of the original adaptive icon.

This is done by comparing the luminance of the foreground and the background, and storing this information in the database.

Then, when an icon is themed, this information is used to adapt the foreground and background colors to maintain contrast ratio between the two colors.

This approach allows to preserve the look and feel of adaptive icons, while still allowing for theming.

This change also fixes a bug where monochrome icons would have their color reversed when it was not appropriate. With this change we do not flip monochrome anymore.

Bug: 418850749
Flag: com.android.launcher3.force_monochrome_app_icons
Test: Added tests
Change-Id: I52a8bda6301193f7741f82ac116a91e90525e98d
parent a9babccb
Loading
Loading
Loading
Loading
+1 −0
Original line number Diff line number Diff line
@@ -19,6 +19,7 @@
<resources>
    <color name="themed_icon_color">@android:color/system_accent1_200</color>
    <color name="themed_icon_background_color">@android:color/system_accent2_800</color>
    <color name="themed_icon_adaptive_background_color">@android:color/system_accent1_800</color>
    <color name="themed_badge_icon_color">@android:color/system_accent2_800</color>
    <color name="themed_badge_icon_background_color">@android:color/system_accent1_200</color>
</resources>
+1 −0
Original line number Diff line number Diff line
@@ -19,6 +19,7 @@
<resources>
    <color name="themed_icon_color">@android:color/system_accent1_700</color>
    <color name="themed_icon_background_color">@android:color/system_accent1_100</color>
    <color name="themed_icon_adaptive_background_color">@android:color/system_accent1_500</color>
    <color name="themed_badge_icon_color">@android:color/system_accent1_700</color>
    <color name="themed_badge_icon_background_color">@android:color/system_accent1_100</color>
</resources>
+14 −55
Original line number Diff line number Diff line
@@ -14,11 +14,8 @@
package com.android.launcher3.icons

import android.graphics.Bitmap
import android.graphics.Canvas
import android.graphics.drawable.Drawable
import android.util.Log
import androidx.annotation.FloatRange
import androidx.annotation.VisibleForTesting
import androidx.core.graphics.ColorUtils
import kotlin.math.abs

@@ -29,6 +26,9 @@ enum class ComputationType {

    /** 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. */
@@ -142,7 +142,6 @@ class LuminanceComputer(
        val targetColorWrapper = colorToColorWrapper(targetColor)
        val basisColorWrapper = colorToColorWrapper(basisColor)

        val originalTargetLuminance = targetColorWrapper.luminance
        val basisLuminance = basisColorWrapper.luminance

        // The target luminance should be between 0 and 1, so we need to clamp
@@ -162,22 +161,14 @@ class LuminanceComputer(
        return targetColorWrapper
    }

    /**
     * Compute the luminance of a drawable using the selected color space.
     *
     * @param drawable The drawable to compute the luminance of.
     */
    fun computeLuminance(drawable: Drawable): Double {
        val bitmap = createBitmapFromDrawable(drawable)
        return computeLuminance(bitmap)
    }

    /**
     * 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
     */
    fun computeLuminance(bitmap: Bitmap, scale: Boolean = false): Double {
    @JvmOverloads
    fun computeLuminance(bitmap: Bitmap, scale: Boolean = true): Double {
        val bitmapHeight = bitmap.height
        val bitmapWidth = bitmap.width
        if (bitmapHeight == 0 || bitmapWidth == 0) {
@@ -217,42 +208,7 @@ class LuminanceComputer(
        when (computationType) {
            ComputationType.MEDIAN -> return luminances.sorted().median()
            ComputationType.AVERAGE -> return luminances.average()
        }
    }

    private fun scaleBitmap(
        bitmap: Bitmap,
        targetWidth: Int,
        targetHeight: Int,
        filter: Boolean,
    ): Bitmap {
        if (targetWidth <= 0 || targetHeight <= 0) {
            Log.w(TAG, "Invalid dimensions for scaling: $targetWidth x $targetHeight")
            return bitmap
        }
        return Bitmap.createScaledBitmap(bitmap, targetWidth, targetHeight, filter)
    }

    private fun createBitmapFromDrawable(drawable: Drawable): Bitmap {
        val b = Bitmap.createBitmap(BITMAP_SAMPLE_SIZE, BITMAP_SAMPLE_SIZE, Bitmap.Config.ARGB_8888)
        drawable.setBounds(0, 0, BITMAP_SAMPLE_SIZE, BITMAP_SAMPLE_SIZE)
        drawable.draw(Canvas(b))
        return b
    }

    /**
     * Scale the height and width of a bitmap to a maximum size.
     *
     * @param height The height of the bitmap.
     * @param width The width of the bitmap.
     * @return A pair of the scaled height and width.
     */
    @VisibleForTesting
    fun scaleHeightAndWidth(height: Int, width: Int): Pair<Int, Int> {
        if (height > width) {
            return Pair(BITMAP_SAMPLE_SIZE, (width * BITMAP_SAMPLE_SIZE) / height)
        } else {
            return Pair((height * BITMAP_SAMPLE_SIZE) / width, BITMAP_SAMPLE_SIZE)
            ComputationType.SPREAD -> return luminances.max() - luminances.min()
        }
    }

@@ -298,12 +254,12 @@ class LuminanceComputer(
            LuminanceColorSpace.HSL -> {
                val hsl = FloatArray(3)
                ColorUtils.colorToHSL(color, hsl)
                return HslColor(hsl)
                HslColor(hsl)
            }
            LuminanceColorSpace.LAB -> {
                val lab = DoubleArray(3)
                ColorUtils.colorToLAB(color, lab)
                return LabColor(lab)
                LabColor(lab)
            }
        }
    }
@@ -332,10 +288,13 @@ class LuminanceComputer(
        const val DEFAULT_ABSOLUTE_LUMINANCE_DELTA = 0.1

        @JvmStatic
        fun createDefaultLuminanceComputer(): LuminanceComputer {
        @JvmOverloads
        fun createDefaultLuminanceComputer(
            computationType: ComputationType = ComputationType.AVERAGE
        ): LuminanceComputer {
            return LuminanceComputer(
                LuminanceColorSpace.LAB, // Keep this as the default color space
                ComputationType.AVERAGE,
                computationType,
                Options(
                    ensureMinContrast = ENABLED_CONTRAST_ADJUSTMENT,
                    absoluteLuminanceDelta = ENABLED_ABSOLUTE_LUMINANCE_DELTA,
+33 −19
Original line number Diff line number Diff line
@@ -17,6 +17,8 @@ package com.android.launcher3.icons;

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;
@@ -54,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);
@@ -110,17 +112,41 @@ public class MonochromeIconFactory extends Drawable {
    @WorkerThread
    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 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);
@@ -136,22 +162,10 @@ public class MonochromeIconFactory extends Drawable {
            // 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
+65 −59
Original line number Diff line number Diff line
@@ -24,9 +24,9 @@ import android.graphics.Bitmap.Config.HARDWARE
import android.graphics.BlendMode.SRC_IN
import android.graphics.BlendModeColorFilter
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
@@ -55,7 +55,6 @@ class MonoIconThemeController(
        factory: BaseIconFactory,
        sourceHint: SourceHint?,
    ): ThemedBitmap {

        val currentDelegateFactory = info.delegateFactory
        if (currentDelegateFactory is ClockAnimationInfo) {
            val fullDrawable = currentDelegateFactory.baseDrawableState.newDrawable()
@@ -73,41 +72,35 @@ class MonoIconThemeController(
            }
        }

        val mono =
            getMonochromeDrawable(
                icon,
                info,
                sourceHint?.isFileDrawable ?: false,
                shouldForceThemeIcon,
            )
        val mono = icon.monochrome
        if (mono != null) {
            return MonoThemedBitmap(
                factory.createIconBitmap(mono, 1f /* scale */, MODE_ALPHA, true /* isFullBleed */),
                factory.createIconBitmap(
                    InsetDrawable(mono, -getExtraInsetFraction()),
                    1f /* scale */,
                    MODE_ALPHA,
                    true, /* isFullBleed */
                ),
                colorProvider,
            )
        }
        return ThemedBitmap.NOT_SUPPORTED
    }

    /**
     * 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,
        isFileDrawable: Boolean,
        shouldForceThemeIcon: Boolean,
    ): Drawable? {
        val mono = base.monochrome
        if (mono != null) {
            return InsetDrawable(mono, -AdaptiveIconDrawable.getExtraInsetFraction())
        }
        if (Flags.forceMonochromeAppIcons() && shouldForceThemeIcon && !isFileDrawable) {
            return MonochromeIconFactory(info.icon.width).wrap(base)
        if (Flags.forceMonochromeAppIcons() && shouldForceThemeIcon) {
            val monoFactory = MonochromeIconFactory(info.icon.width)
            val wrappedIcon = monoFactory.wrap(icon)
            return MonoThemedBitmap(
                factory.createIconBitmap(
                    wrappedIcon,
                    1f /* scale */,
                    MODE_ALPHA,
                    true, /* isFullBleed */
                ),
                colorProvider,
                monoFactory.luminanceDiff,
            )
        }
        return null

        return ThemedBitmap.NOT_SUPPORTED
    }

    override fun decode(
@@ -117,17 +110,31 @@ class MonoIconThemeController(
        sourceHint: SourceHint,
    ): ThemedBitmap {
        val icon = info.icon
        if (bytes.size != icon.height * icon.width) return ThemedBitmap.NOT_SUPPORTED
        val expectedSize = icon.height * icon.width

        var monoBitmap = Bitmap.createBitmap(icon.width, icon.height, ALPHA_8)
        monoBitmap.copyPixelsFromBuffer(ByteBuffer.wrap(bytes))
        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
        }
    }

    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, colorProvider)
        return hwMonoBitmap?.also { monoBitmap.recycle() } ?: monoBitmap
    }

    override fun createThemedAdaptiveIcon(
@@ -135,29 +142,28 @@ class MonoIconThemeController(
        originalIcon: AdaptiveIconDrawable,
        info: BitmapInfo?,
    ): AdaptiveIconDrawable {
        val colors = colorProvider(context)

        originalIcon.mutate()
        var monoDrawable = originalIcon.monochrome?.apply { setTint(colors[1]) }
        originalIcon.monochrome?.let {
            val colors = colorProvider(context)
            it.setTint(colors[1])
            return@createThemedAdaptiveIcon AdaptiveIconDrawable(ColorDrawable(colors[0]), it)
        }

        val themedBitmap = info?.themedBitmap as? MonoThemedBitmap ?: return originalIcon
        val colors = themedBitmap.getUpdatedColors(context)

        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 =
        val monoDrawable =
            InsetDrawable(
                BitmapDrawable(themedBitmap.mono).apply {
                    colorFilter = BlendModeColorFilter(colors[1], SRC_IN)
                },
                            AdaptiveIconDrawable.getExtraInsetFraction() / 2,
                getExtraInsetFraction() / 2,
            )
                }
            }
        }

        return monoDrawable?.let { AdaptiveIconDrawable(ColorDrawable(colors[0]), it) }
            ?: originalIcon
        return AdaptiveIconDrawable(ColorDrawable(colors[0]), monoDrawable)
    }
}
Loading