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

Commit 4ad7a592 authored by Tyler Freeman's avatar Tyler Freeman
Browse files

feat(force invert): detect the polarity of an app to decide if we should force invert it

Added the ColorArea class to track the app's overall polarity (i.e. dark
or light theme) by counting the areas of backgrounds and their colors.
This is used to determine if we should force invert the app, for
instance if the user prefers dark theme but this app is mainly light.

The idea is that we count the fill colors of any background-type draw
calls: drawRect(), drawColor(), etc. If the area of light fills drawn to
the screen is greater than the area of dark fills drawn to the screen,
we can reasonably guess that the app is light theme, and vice-versa.

Bug: 372558459
Flag: android.view.accessibility.force_invert_color
Test: atest hwui_unit_tests

Change-Id: I524c3f5cde0a65cad4087aeefdec8eb0477a7971
parent e7ba61fd
Loading
Loading
Loading
Loading
+5 −16
Original line number Diff line number Diff line
@@ -135,10 +135,10 @@ import static com.android.internal.annotations.VisibleForTesting.Visibility.PACK
import static com.android.text.flags.Flags.disableHandwritingInitiatorForIme;
import static com.android.window.flags.Flags.enableBufferTransformHintFromDisplay;
import static com.android.window.flags.Flags.enableWindowContextResourcesUpdateOnConfigChange;
import static com.android.window.flags.Flags.fixViewRootCallTrace;
import static com.android.window.flags.Flags.predictiveBackSwipeEdgeNoneApi;
import static com.android.window.flags.Flags.reduceChangedExclusionRectsMsgs;
import static com.android.window.flags.Flags.setScPropertiesInClient;
import static com.android.window.flags.Flags.fixViewRootCallTrace;
import android.Manifest;
import android.accessibilityservice.AccessibilityService;
@@ -190,7 +190,6 @@ import android.graphics.Rect;
import android.graphics.RectF;
import android.graphics.Region;
import android.graphics.RenderNode;
import android.graphics.drawable.ColorDrawable;
import android.graphics.drawable.Drawable;
import android.graphics.drawable.GradientDrawable;
import android.hardware.SyncFence;
@@ -294,7 +293,6 @@ import com.android.internal.os.SomeArgs;
import com.android.internal.policy.DecorView;
import com.android.internal.policy.PhoneFallbackEventHandler;
import com.android.internal.protolog.ProtoLog;
import com.android.internal.util.ContrastColorUtil;
import com.android.internal.util.FastPrintWriter;
import com.android.internal.view.BaseSurfaceHolder;
import com.android.internal.view.RootViewSurfaceTaker;
@@ -2088,21 +2086,12 @@ public final class ViewRootImpl implements ViewParent,
                // preference for dark mode in configuration.uiMode. Instead, we assume that both
                // force invert and the system's dark theme are enabled.
                if (shouldApplyForceInvertDark()) {
                    // TODO: b/368725782 - Use hwui color area detection instead of / in
                    //  addition to these heuristics.
                    // We will use HWUI color area detection to determine if it should actually be
                    // inverted. Checking light theme simply gives the developer a way to "opt-out"
                    // of force invert.
                    final boolean isLightTheme =
                            a.getBoolean(R.styleable.Theme_isLightTheme, false);
                    final boolean isBackgroundColorLight;
                    if (mView != null && mView.getBackground()
                            instanceof ColorDrawable colorDrawable) {
                        isBackgroundColorLight =
                                !ContrastColorUtil.isColorDarkLab(colorDrawable.getColor());
                    } else {
                        // Treat unknown as light, so that only isLightTheme is used to determine
                        // force dark treatment.
                        isBackgroundColorLight = true;
                    }
                    if (isLightTheme && isBackgroundColorLight) {
                    if (isLightTheme) {
                        return ForceDarkType.FORCE_INVERT_COLOR_DARK;
                    } else {
                        return ForceDarkType.NONE;
+3 −2
Original line number Diff line number Diff line
@@ -1548,7 +1548,7 @@ public class ViewRootImplTest {

    @Test
    @EnableFlags(FLAG_FORCE_INVERT_COLOR)
    public void determineForceDarkType_isLightThemeAndNotLightBackground_returnsNone()
    public void determineForceDarkType_isLightThemeAndNotLightBackground_returnsForceInvertColorDark()
            throws Exception {
        // Set up configurations for force invert color
        waitForSystemNightModeActivated(true);
@@ -1557,7 +1557,8 @@ public class ViewRootImplTest {
        setUpViewAttributes(/* isLightTheme= */ true, /* isLightBackground = */ false);

        TestUtils.waitUntil("Waiting for ForceDarkType to be ready",
                () -> (mViewRootImpl.determineForceDarkType() == ForceDarkType.NONE));
                () -> (mViewRootImpl.determineForceDarkType()
                        == ForceDarkType.FORCE_INVERT_COLOR_DARK));
    }

    @Test
+1 −0
Original line number Diff line number Diff line
@@ -616,6 +616,7 @@ cc_defaults {
        "Animator.cpp",
        "AnimatorManager.cpp",
        "CanvasTransform.cpp",
        "ColorArea.cpp",
        "DamageAccumulator.cpp",
        "DeviceInfo.cpp",
        "FrameInfo.cpp",
+111 −0
Original line number Diff line number Diff line
/*
 * Copyright (C) 2024 The Android Open Source Project
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *      http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

#include "ColorArea.h"

#include "utils/MathUtils.h"

namespace android::uirenderer {

constexpr static int kMinimumAlphaToConsiderArea = 200;

void ColorArea::addArea(const SkRect& rect, const SkPaint* paint) {
    addArea(rect.width(), rect.height(), paint);
}

void ColorArea::addArea(int32_t width, int32_t height, const SkPaint* paint) {
    if (!paint) return;
    // HWUI doesn't draw anything with negative width or height
    if (width <= 0 || height <= 0) return;

    uint64_t area = width * height;
    addArea(area, *paint);
}

void ColorArea::addArea(uint64_t area, const SkPaint& paint) {
    if (paint.getStyle() == SkPaint::Style::kStroke_Style) return;
    if (CC_UNLIKELY(paint.nothingToDraw())) return;

    if (paint.getShader()) {
        // TODO(b/409395389): check if the shader is a gradient, and then slice up area into
        //  sections, determining polarity for each color stop of the gradient.
        return;
    }

    addArea(area, paint.getColor());
}

void ColorArea::addArea(uint64_t area, SkColor color) {
    if (CC_UNLIKELY(SkColorGetA(color) < kMinimumAlphaToConsiderArea)) return;

    // TODO(b/381930266): optimize by detecting common black/white/grey colors and avoid converting
    //  also maybe cache colors or something?
    Lab lab = sRGBToLab(color);
    // TODO(b/372558459): add a case for a middle L that is grey, and don't count it?
    addArea(area, lab.L > 50 ? Light : Dark);
}

void ColorArea::addArea(uint64_t area, Polarity polarity) {
    // HWUI doesn't draw anything with negative width or height
    if (area <= 0) return;

    if (polarity == Light) {
        mLight += area;
    } else if (polarity == Dark) {
        mDark += area;
    }
}

Polarity ColorArea::getPolarity() const {
    if (mLight == mDark) {  // also covers the case if it was just reset()
        return Polarity::Unknown;
    }
    if (mLight > mDark) {
        return Polarity::Light;
    } else {
        return Polarity::Dark;
    }
}

void ColorArea::reset() {
    mParentHeight = -1;
    mParentWidth = -1;
    mLight = 0;
    mDark = 0;
}

void ColorArea::merge(const ColorArea& source) {
    mLight += source.mLight;
    mDark += source.mDark;
}

int ColorArea::getParentWidth() const {
    return mParentWidth;
}

void ColorArea::setParentWidth(int width) {
    mParentWidth = width;
}

int ColorArea::getParentHeight() const {
    return mParentHeight;
}

void ColorArea::setParentHeight(int height) {
    mParentHeight = height;
}

}  // namespace android::uirenderer

libs/hwui/ColorArea.h

0 → 100644
+112 −0
Original line number Diff line number Diff line
/*
 * Copyright (C) 2024 The Android Open Source Project
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *      http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

#pragma once

#include <SkCanvas.h>
#include <SkPaintFilterCanvas.h>

#include "utils/Color.h"
#include "utils/Macros.h"

namespace android::uirenderer {

/**
 * The result of counting the color area.
 */
enum Polarity {
    /** The result is too close to make a definite determination */
    Unknown = 0,
    /** Majority light fills */
    Light,
    /** Majority dark fills */
    Dark
};

/**
 * Tracks the app's overall polarity (i.e. dark or light theme) by counting the areas of backgrounds
 * and their colors. This is used to determine if we should force invert the app, for instance if
 * the user prefers dark theme but this app is mainly light.
 *
 * The idea is that we count the fill colors of any background-type draw calls: drawRect(),
 * drawColor(), etc. If the area of light fills drawn to the screen is greater than the area of dark
 * fills drawn to the screen, we can reasonably guess that the app is light theme, and vice-versa.
 */
class ColorArea {
public:
    ColorArea() {}
    ~ColorArea() {}

    /**
     * Counts the given area of a draw call that is reasonably expected to draw a background:
     * drawRect, drawColor, etc.
     *
     * @param area the total area of the draw call's fill (approximate)
     * @param paint the paint used to fill the area. If the paint is not a fill, the area will not
     *              be added.
     */
    void addArea(uint64_t area, const SkPaint& paint);

    /**
     * See [addArea(uint64_t, SkPaint&)]
     */
    void addArea(const SkRect& rect, const SkPaint* paint);

    /**
     * See [addArea(uint64_t, SkPaint&)]
     */
    void addArea(int32_t width, int32_t height, const SkPaint* paint);

    /**
     * See [addArea(uint64_t, SkPaint&)]
     */
    void addArea(uint64_t area, SkColor color);

    /**
     * Prefer [addArea(uint64_t, SkPaint&)], unless the area you're measuring doesn't have a paint
     * with measurable colors.
     *
     * @param area the total area of the draw call's fill (approximate)
     * @param polarity whether the color of the given area is light or dark
     */
    void addArea(uint64_t area, Polarity polarity);

    /**
     * Adds the source's area to this area. This is so you can sum up the areas of a bunch of child
     * nodes.
     */
    void merge(const ColorArea& source);

    /** Resets the object back to the initial state */
    void reset();

    int getParentWidth() const;
    void setParentWidth(int width);
    int getParentHeight() const;
    void setParentHeight(int height);

    /** Returns the best guess of the polarity of this area */
    Polarity getPolarity() const;

private:
    int mParentWidth = -1;
    int mParentHeight = -1;

    uint64_t mLight = 0;
    uint64_t mDark = 0;
};

}  // namespace android::uirenderer
Loading