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

Commit 25c0edc2 authored by Marcelo Arteiro's avatar Marcelo Arteiro
Browse files

Introduce state management dependencies for ThemeService

Adds the core data structures and logic for managing user-specific theme states.

- `ThemeState`: An immutable record to hold a user's theme settings.
- `ThemeStatePair`: Manages current and pending `ThemeState`, containing the logic to determine when to update overlays and handle deferrals.
- `ThemeInfo`: A parcelable used for IPC to request theme changes.

This change also includes comprehensive unit tests for the new components and a `FakeScheduledExecutorService` for testing.

Bug: 333694176
Test: atest FrameworksServicesTests_theme
Flag: android.server.enable_theme_service
EXPECTED_PROCESS_CRASH=system_server

Change-Id: I692a408a6e5479853182e2dc3f307518306d0f7b
parent 1059d0e8
Loading
Loading
Loading
Loading
+20 −0
Original line number Diff line number Diff line
/*
 * 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 android.content.theming;

/** {@hide} */
parcelable ThemeInfo;
 No newline at end of file
+97 −0
Original line number Diff line number Diff line
/*
 * 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 android.content.theming;

import android.annotation.ColorInt;
import android.annotation.FlaggedApi;
import android.annotation.Nullable;
import android.graphics.Color;
import android.os.Parcel;
import android.os.Parcelable;

import androidx.annotation.NonNull;

/**
 * Represents the core information of a user's theme, including the seed color,
 * style, and contrast level.
 *
 * @hide
 */
@FlaggedApi(android.server.Flags.FLAG_ENABLE_THEME_SERVICE)
public final class ThemeInfo implements Parcelable {
    @Nullable
    @ColorInt
    public final Integer seedColor;
    @Nullable
    @ThemeStyle.Type
    public final Integer style;
    @Nullable
    public final Float contrast;

    private ThemeInfo(@Nullable @ColorInt Integer seedColor,
            @Nullable @ThemeStyle.Type Integer style,
            @Nullable Float contrast) {
        this.seedColor = seedColor;
        this.style = style;
        this.contrast = contrast;
    }

    private ThemeInfo(Parcel in) {
        seedColor = (Integer) in.readValue(Integer.class.getClassLoader());
        style = (Integer) in.readValue(Integer.class.getClassLoader());
        contrast = (Float) in.readValue(Float.class.getClassLoader());
    }

    /**
     * A builder for creating {@link ThemeInfo} instances. Any parameter can be {@code null}
     * to indicate that the current system value for that attribute should be used.
     *
     * @param seedColor The primary color to generate the theme's color palette, or {@code null}.
     * @param style     The theme style (e.g., tonal, vibrant), or {@code null}.
     * @param contrast  The contrast level of the theme, or {@code null}.
     * @return A new {@link ThemeInfo} instance.
     */
    public static ThemeInfo build(@Nullable Color seedColor,
            @Nullable @ThemeStyle.Type Integer style, @Nullable Float contrast) {
        return new ThemeInfo(seedColor == null ? null : seedColor.toArgb(), style, contrast);
    }

    @Override
    @SuppressWarnings("AndroidFrameworkEfficientParcelable")
    public void writeToParcel(@NonNull Parcel dest, int flags) {
        dest.writeValue(seedColor);
        dest.writeValue(style);
        dest.writeValue(contrast);
    }

    @Override
    public int describeContents() {
        return 0;
    }

    public static final Creator<ThemeInfo> CREATOR = new Creator<ThemeInfo>() {
        @Override
        public ThemeInfo createFromParcel(Parcel in) {
            return new ThemeInfo(in);
        }

        @Override
        public ThemeInfo[] newArray(int size) {
            return new ThemeInfo[size];
        }
    };
}
+5 −1
Original line number Diff line number Diff line
@@ -161,6 +161,7 @@ java_library_static {
        "error_prone_annotations",
        "framework-tethering.stubs.module_lib",
        "keepanno-annotations",
        "monet",
        "service-art.stubs.system_server",
        "service-permission.stubs.system_server",
        "service-rkp.stubs.system_server",
@@ -294,7 +295,10 @@ java_genrule_combiner {

java_library {
    name: "services.core",
    static_libs: ["services.core.combined"],
    static_libs: [
        "services.core.combined",
        "monet",
    ],
}

java_library_host {
+175 −0
Original line number Diff line number Diff line
/*
 * 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.server.theming;

import android.annotation.Nullable;
import android.content.theming.ThemeStyle;

import java.io.PrintWriter;
import java.util.Collections;
import java.util.HashSet;
import java.util.Locale;
import java.util.Set;

/**
 * Represents the immutable theme state for a specific user.
 * <p>
 * This class encapsulates attributes like seed color, style, contrast, and associated user
 * profiles. This record provides a convenient way to store and update the theme-related
 * settings for a user, ensuring immutability and ease of comparison between different states.
 * <p>
 * The 'apply' methods (e.g., {@code applySeedColor}, {@code applyStyle}) are designed to
 * return the same {@code ThemeState} instance if no changes are made. This facilitates
 * efficient comparison between pending and current states, allowing for optimized theme updates.
 *
 * @param userId        The ID of the user.
 * @param isSetup       {@code true} if the user has completed the setup wizard.
 * @param seedColor     The seed color used for theme generation.
 * @param contrast      The user-selected contrast level.
 * @param style         The theme style, e.g., TONAL_SPOT, VIBRANT.
 * @param childProfiles A set of user IDs for associated profiles.
 * @param timeStamp     A timestamp also used to force updates.
 */
record ThemeState(
        int userId,
        boolean isSetup,
        int seedColor,
        float contrast,
        int style,
        Set<Integer> childProfiles,
        long timeStamp
) {
    ThemeState(int userId, boolean isSetup, int seedColor, float contrast,
            @ThemeStyle.Type Integer style, Set<Integer> childProfiles, long timeStamp) {
        this(userId, isSetup, seedColor, contrast, style == null ? 0 : style, childProfiles,
                timeStamp); // Delegates to canonical
    }

    ThemeState withSeedColor(int newSeedColor) {
        if (seedColor == newSeedColor) {
            return this;
        }

        return new ThemeState(
                userId,
                isSetup,
                newSeedColor,
                contrast,
                style,
                childProfiles,
                timeStamp
        );
    }

    ThemeState withStyle(@Nullable @ThemeStyle.Type Integer newStyle) {
        if (newStyle == null || newStyle.equals(style)) {
            return this;
        }

        return new ThemeState(
                userId,
                isSetup,
                seedColor,
                contrast,
                newStyle,
                childProfiles,
                timeStamp
        );
    }

    ThemeState withContrast(float newContrast) {
        if (contrast == newContrast) {
            return this;
        }

        return new ThemeState(
                userId,
                isSetup,
                seedColor,
                newContrast,
                style,
                childProfiles,
                timeStamp
        );
    }

    ThemeState withSetupComplete() {
        if (isSetup) {
            return this;
        }

        return new ThemeState(
                userId,
                true,
                seedColor,
                contrast,
                style,
                childProfiles,
                timeStamp
        );
    }

    ThemeState addProfile(int profileId) {
        if (childProfiles.contains(profileId)) {
            return this;
        }

        HashSet<Integer> newChildProfiles = new HashSet<>(childProfiles);
        newChildProfiles.add(profileId);

        return new ThemeState(
                userId,
                isSetup,
                seedColor,
                contrast,
                style,
                Collections.unmodifiableSet(newChildProfiles),
                timeStamp
        );
    }

    // Use this to cause a difference between states, forcing an update.
    ThemeState withTimeStamp() {
        return new ThemeState(
                userId,
                true,
                seedColor,
                contrast,
                style,
                childProfiles,
                System.currentTimeMillis()
        );
    }

    /**
     * Dumps the current state of the ThemeState to the provided PrintWriter.
     *
     * @param pw     The PrintWriter to dump the state to.
     * @param prefix A prefix to prepend to each line for indentation.
     */
    public void dump(PrintWriter pw, String prefix) {
        pw.println(prefix + "isSetup: " + isSetup);
        pw.println(
                prefix + "seedColor: #" + Integer.toHexString(seedColor).toUpperCase(Locale.ROOT));
        pw.println(prefix + "contrast: " + contrast);
        pw.println(prefix + "style: " + ThemeStyle.toString(style));
        pw.println(prefix + "childProfiles: " + childProfiles);
        pw.println(prefix + "timeStamp: " + timeStamp);
    }
}

+403 −0
Original line number Diff line number Diff line
/*
 * 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.server.theming;

import android.annotation.Nullable;
import android.annotation.SuppressLint;
import android.content.Context;
import android.content.res.Resources;
import android.content.theming.ThemeStyle;
import android.os.UserHandle;
import android.util.Slog;

import com.android.internal.R;
import com.android.systemui.monet.ColorScheme;

import java.io.PrintWriter;
import java.util.Collections;
import java.util.HashSet;
import java.util.Set;
import java.util.concurrent.ScheduledFuture;

/**
 * Holds the current and pending {@link ThemeState} for a user, managing updates
 * and handling potential deferments due to wallpaper changes from background apps.
 * <p>
 * This class facilitates theme updates by tracking both the current and pending theme states,
 * preventing unnecessary overlay updates. It also handles deferments when wallpaper changes
 * originate from background apps, ensuring that theme updates are applied at the appropriate time.
 */
class ThemeStatePair {
    private static final String TAG = ThemeStatePair.class.getSimpleName();

    public final int userId;
    private ThemeState mCurrent;
    private ThemeState mPending;
    private boolean mThemeUpdatesDeferredOnLock = false;
    private ScheduledFuture<?> mFuture;

    // We are storing only the currently applied Schemes and Overlays
    private ColorScheme mDarkScheme;
    private ColorScheme mLightScheme;

    /**
     * Constructs a new ThemeStatePair object.
     *
     * @param userId    The ID of the user associated with this state pair.
     * @param isSetup   Indicates whether the user has completed setup.
     * @param seedColor The initial seed color for the user's theme.
     * @param contrast  The initial contrast value for the user's theme.
     * @param style     The initial style for the user's theme.
     */
    @SuppressLint("WrongConstant")
    protected ThemeStatePair(
            int userId,
            boolean isSetup,
            int seedColor,
            float contrast,
            @ThemeStyle.Type Integer style) {

        this.userId = userId;

        ThemeState initialState = new ThemeState(userId, isSetup, seedColor, contrast, style,
                Collections.unmodifiableSet(new HashSet<>()), 0);

        mDarkScheme = new ColorScheme(seedColor, true, style, contrast);
        mLightScheme = new ColorScheme(seedColor, false, style, contrast);

        mPending = initialState;
        mCurrent = initialState;
    }

    /**
     * Applies a new seed color to the pending theme state.
     *
     * @param newSeedColor The new seed color to apply.
     */
    protected void applySeedColor(int newSeedColor) {
        mPending = mPending.withSeedColor(newSeedColor);
    }

    /**
     * Applies a new style to the pending theme state.
     *
     * @param newStyle The new style to apply.
     */
    protected void applyStyle(@ThemeStyle.Type Integer newStyle) {
        mPending = mPending.withStyle(newStyle);
    }

    /**
     * Applies a new contrast value to the pending theme state.
     *
     * @param newContrast The new contrast value to apply.
     */
    protected void applyContrast(float newContrast) {
        mPending = mPending.withContrast(newContrast);
    }

    /**
     * Marks the pending theme state as setup complete.
     */
    protected void applySetupComplete() {
        mPending = mPending.withSetupComplete();
    }

    /**
     * Adds a new profile ID to the pending theme state.
     *
     * @param profileId The ID of the new profile.
     */
    protected void addProfile(int profileId) {
        mPending = mPending.addProfile(profileId);
    }

    /**
     * Forces an update to the theme by applying a new timestamp to the pending state.
     * This ensures that the theme will be reevaluated and overlays will be updated.
     */
    protected void forceUpdate() {
        mPending = mPending.withTimeStamp();
    }


    // setters and getters

    /**
     * Returns the current {@link ThemeState}. The returned object is immutable.
     *
     * @return The current state.
     */
    protected ThemeState getCurrentState() {
        return mCurrent;
    }

    /**
     * Returns the pending {@link ThemeState}. The returned object is immutable.
     *
     * @return The pending state, or {@code null} if there are no scheduled updates.
     */
    @Nullable
    protected ThemeState getPendingState() {
        return mPending.equals(mCurrent) ? null : mPending;
    }

    /**
     * Returns the {@link ScheduledFuture} associated with the current theme update task,
     * or {@code null} if there is no task scheduled.
     */
    @Nullable
    protected ScheduledFuture<?> getFuture() {
        return mFuture;
    }

    /**
     * Sets the {@link ScheduledFuture} associated with the current theme update task.
     *
     * @param newTask The new task to set.
     */
    protected void setFuture(ScheduledFuture<?> newTask) {
        mFuture = newTask;
    }

    /**
     * Clears the current theme update task, effectively cancelling any pending updates.
     */
    protected void clearTimer() {
        mFuture = null;
    }

    /**
     * Checks if theme updates are currently deferred until the device is locked.
     *
     * @return {@code true} if updates are deferred, {@code false} otherwise.
     * @see #setDeferUpdatesOnLock(boolean)
     */
    protected boolean areUpdatesDeferredOnLock() {
        return mThemeUpdatesDeferredOnLock;
    }

    /**
     * Sets whether to defer theme updates until the device is locked.
     *
     * <p>This is used to prevent jarring theme changes when a background application
     * (e.g., a live wallpaper) changes the color scheme while the user is actively
     * using the device. When deferred, the pending theme update will be applied the
     * next time the device enters the locked state.
     *
     * @param defer {@code true} to defer updates until the next lock, {@code false} to allow
     *              immediate updates.
     */
    protected void setDeferUpdatesOnLock(boolean defer) {
        mThemeUpdatesDeferredOnLock = defer;
    }

    /**
     * Returns the set of child profile IDs associated with the pending theme state.
     */
    protected Set<Integer> getPendingChildProfiles() {
        return mPending.childProfiles();
    }

    protected ColorScheme getDarkScheme() {
        return mDarkScheme;
    }

    protected ColorScheme getLightScheme() {
        return mLightScheme;
    }

    /**
     * Updates the current theme state with the provided ColorSchemes and sets the current
     * state to the pending state, finalizing the theme update.
     *
     * @param newDarkScheme  The new dark {@link ColorScheme}.
     * @param newLightScheme The new light {@link ColorScheme}.
     */
    protected void update(ColorScheme newDarkScheme, ColorScheme newLightScheme) {
        mDarkScheme = newDarkScheme;
        mLightScheme = newLightScheme;
        mCurrent = mPending;
    }

    /**
     * Generates a new ColorScheme based on the pending theme state and the provided
     * darkness flag.
     *
     * @param isDark {@code true} to generate a dark scheme, {@code false} for light.
     * @return The newly generated ColorScheme.
     */
    protected ColorScheme generatePendingScheme(boolean isDark) {
        return new ColorScheme(mPending.seedColor(), isDark, mPending.style(), mPending.contrast());
    }

    // Useful checks before updating state

    /**
     * Checks if the current state warrants an update to the applied overlays.
     * <p>
     * This method considers various factors, including:
     * - Whether the theme state has changed.
     * - Whether the ColorScheme requires a new overlay.
     *
     * @return {@code true} if an update is necessary, {@code false} otherwise.
     */
    protected boolean shouldUpdateOverlays() {
        if (mPending.equals(mCurrent)) {
            Slog.d(TAG, "No change in State for user " + userId + ". Skipping. ");
            return false;
        }

        // Checks if ColorScheme related state attributes (contrast, seedColor and Style) are
        // different. Only in this case we must regenerate a new Overlay
        if (mCurrent.seedColor() == mPending.seedColor()
                && mCurrent.contrast() == mPending.contrast()
                && mCurrent.style() == mPending.style()) {
            Slog.d(TAG, "User " + userId + " state updated, but new overlay was not necessary");
            return false;
        }

        return true;
    }

    /**
     * Checks if the current state warrants an update to the applied overlays.
     * <p>
     * This method considers various factors, including:
     * - Whether the user has completed setup.
     * - Whether changes are deferred due to a wallpaper change from a background app.
     * - Whether the theme state has changed.
     * - Whether the ColorScheme requires a new overlay.
     *
     * @return {@code true} if an update is necessary, {@code false} otherwise.
     */
    protected boolean shouldUpdate() {
        // force update in case of different timeStamp
        if (mCurrent.timeStamp() != mPending.timeStamp()) {
            Slog.d(TAG, "User " + userId + " requested forced update");
            return true;
        }

        if (mPending.equals(mCurrent)) {
            Slog.d(TAG, "No change in State for user " + userId + ". Skipping. ");
            return false;
        }

        // never update if user is not setup, even if forced
        if (!mPending.isSetup()) {
            Slog.d(TAG, "Deferring theme evaluation for user " + userId + " during setup");
            return false;
        }

        if (mThemeUpdatesDeferredOnLock) {
            Slog.d(TAG, "Deferring theme evaluation of user " + userId
                    + " due to wallpaper change from background app");
            return false;
        }

        if (shouldUpdateOverlays()) {
            return true;
        }


        Slog.d(TAG, "User " + userId + " will update.");
        return true;
    }


    /**
     * Checks if the current ColorScheme is correctly applied across all user profiles.
     * <p>
     * This method verifies that the colors extracted from the ColorScheme match the
     * actual colors applied in the system resources for each profile associated with
     * the current state.
     * <p>
     * Note: This is a heuristic check and does not verify every single color. It checks a
     * representative subset of colors to determine if the ColorScheme is generally applied.
     *
     * @param mainContext The main application context.
     * @return {@code true} if the ColorScheme is correctly applied, {@code false} otherwise.
     */
    protected boolean isColorSchemeApplied(Context mainContext) {
        final Set<Integer> allProfiles = new HashSet<>(mCurrent.childProfiles());
        allProfiles.add(userId);

        for (Integer userId : allProfiles) {
            Resources res = mainContext.createContextAsUser(UserHandle.of(userId),
                    0).getResources();

            if (!(res.getColor(R.color.system_accent1_500_dark)
                    == mDarkScheme.getAccent1().getS500()
                    && res.getColor(R.color.system_accent1_500_light)
                    == mLightScheme.getAccent1().getS500()

                    && res.getColor(com.android.internal.R.color.system_accent2_500_dark)
                    == mDarkScheme.getAccent2().getS500()
                    && res.getColor(R.color.system_accent2_500_light)
                    == mLightScheme.getAccent2().getS500()

                    && res.getColor(com.android.internal.R.color.system_accent3_500_dark)
                    == mDarkScheme.getAccent3().getS500()
                    && res.getColor(R.color.system_accent3_500_light)
                    == mLightScheme.getAccent3().getS500()

                    && res.getColor(com.android.internal.R.color.system_neutral1_500_dark)
                    == mDarkScheme.getNeutral1().getS500()
                    && res.getColor(R.color.system_neutral1_500_light)
                    == mLightScheme.getNeutral1().getS500()

                    && res.getColor(com.android.internal.R.color.system_neutral2_500_dark)
                    == mDarkScheme.getNeutral2().getS500()
                    && res.getColor(R.color.system_neutral2_500_light)
                    == mLightScheme.getNeutral2().getS500()

                    && res.getColor(android.R.color.system_outline_variant_dark)
                    == mDarkScheme.getMaterialScheme().getOutlineVariant()
                    && res.getColor(android.R.color.system_outline_variant_light)
                    == mLightScheme.getMaterialScheme().getOutlineVariant()

                    && res.getColor(android.R.color.system_primary_container_dark)
                    == mDarkScheme.getMaterialScheme().getPrimaryContainer()
                    && res.getColor(android.R.color.system_primary_container_light)
                    == mLightScheme.getMaterialScheme().getPrimaryContainer())
            ) {
                return false;
            }
        }

        return true;
    }

    /**
     * Dumps the current state of the ThemeStatePair to the provided PrintWriter.
     *
     * @param pw The PrintWriter to dump the state to.
     */
    public void dump(PrintWriter pw) {
        pw.println("    userId: " + userId);
        pw.println("    isDeferred: " + mThemeUpdatesDeferredOnLock);
        pw.println("    Current State:");
        mCurrent.dump(pw, "      ");
        ThemeState pending = getPendingState();
        if (pending != null) {
            pw.println("    Pending State:");
            pending.dump(pw, "      ");
        } else {
            pw.println("    Pending State: (same as current)");
        }
    }
}
Loading