summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
author Marcelo Arteiro <arteiro@google.com> 2025-03-03 15:53:44 +0000
committer Marcelo Arteiro <arteiro@google.com> 2025-03-20 12:58:20 +0000
commitf16c7d31c48408e03646457121a65c8bd0cbbbbb (patch)
tree0308733222499525b2edbada33e450946963549a
parent97faf81623fd3329eb19fac685a8703b8db07719 (diff)
Add Settings management for upcoming ThemeService
This change introduces the new ThemeSettings framework, enabling management and persistence of user theme preferences. Key components include ThemeSettings (data model), ThemeSettingsManager (persistence and loading), ThemeSettingsField (individual property handling), and ThemeStyle (theme type management). The framework serializes theme data to JSON in Settings.Secure, parses JSON, and provides an API for accessing and updating theme preferences. Comprehensive tests have been added to validate core functionality, and the api is under a feature flag for future enabling. Bug: 333694176 Test: atest FrameworksServicesTests_theme Flag: android.server.enable_theme_service Change-Id: I18268db0cb4ee4c0fbd753f11850eeb7230a50ac
-rw-r--r--core/java/android/content/theming/FieldColor.java80
-rw-r--r--core/java/android/content/theming/FieldColorBoth.java70
-rw-r--r--core/java/android/content/theming/FieldColorIndex.java64
-rw-r--r--core/java/android/content/theming/FieldColorSource.java76
-rw-r--r--core/java/android/content/theming/FieldThemeStyle.java76
-rw-r--r--core/java/android/content/theming/ThemeSettings.java200
-rw-r--r--core/java/android/content/theming/ThemeSettingsField.java287
-rw-r--r--core/java/android/content/theming/ThemeSettingsUpdater.java244
-rw-r--r--core/java/android/content/theming/ThemeStyle.java180
-rw-r--r--services/core/java/com/android/server/theming/ThemeSettingsManager.java166
-rw-r--r--services/java/com/android/server/flags.aconfig7
-rw-r--r--services/tests/servicestests/Android.bp11
-rw-r--r--services/tests/servicestests/src/com/android/server/theming/FieldColorBothTests.java99
-rw-r--r--services/tests/servicestests/src/com/android/server/theming/FieldColorIndexTests.java103
-rw-r--r--services/tests/servicestests/src/com/android/server/theming/FieldColorSourceTests.java94
-rw-r--r--services/tests/servicestests/src/com/android/server/theming/FieldColorTests.java107
-rw-r--r--services/tests/servicestests/src/com/android/server/theming/FieldThemeStyleTests.java86
-rw-r--r--services/tests/servicestests/src/com/android/server/theming/TEST_MAPPING7
-rw-r--r--services/tests/servicestests/src/com/android/server/theming/ThemeSettingsFieldTests.java169
-rw-r--r--services/tests/servicestests/src/com/android/server/theming/ThemeSettingsManagerTests.java114
-rw-r--r--services/tests/servicestests/src/com/android/server/theming/ThemeSettingsTests.java108
-rw-r--r--services/tests/servicestests/src/com/android/server/theming/ThemeSettingsUpdaterTests.java154
22 files changed, 2502 insertions, 0 deletions
diff --git a/core/java/android/content/theming/FieldColor.java b/core/java/android/content/theming/FieldColor.java
new file mode 100644
index 000000000000..a06a54f362b5
--- /dev/null
+++ b/core/java/android/content/theming/FieldColor.java
@@ -0,0 +1,80 @@
+/*
+ * Copyright (C) 2025 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package android.content.theming;
+
+import android.annotation.ColorInt;
+import android.annotation.FlaggedApi;
+import android.graphics.Color;
+
+import androidx.annotation.Nullable;
+
+import java.util.function.BiConsumer;
+import java.util.function.Function;
+import java.util.regex.Pattern;
+
+/** @hide */
+@FlaggedApi(android.server.Flags.FLAG_ENABLE_THEME_SERVICE)
+public class FieldColor extends ThemeSettingsField<Integer, String> {
+ private static final Pattern COLOR_PATTERN = Pattern.compile("[0-9a-fA-F]{6,8}");
+
+ public FieldColor(
+ String key,
+ BiConsumer<ThemeSettingsUpdater, Integer> setter,
+ Function<ThemeSettings, Integer> getter,
+ ThemeSettings defaults
+ ) {
+ super(key, setter, getter, defaults);
+ }
+
+ @Override
+ @ColorInt
+ @Nullable
+ public Integer parse(String primitive) {
+ if (primitive == null) {
+ return null;
+ }
+ if (!COLOR_PATTERN.matcher(primitive).matches()) {
+ return null;
+ }
+
+ try {
+ return Color.valueOf(Color.parseColor("#" + primitive)).toArgb();
+ } catch (IllegalArgumentException e) {
+ return null;
+ }
+ }
+
+ @Override
+ public String serialize(@ColorInt Integer value) {
+ return Integer.toHexString(value);
+ }
+
+ @Override
+ public boolean validate(Integer value) {
+ return !value.equals(Color.TRANSPARENT);
+ }
+
+ @Override
+ public Class<Integer> getFieldType() {
+ return Integer.class;
+ }
+
+ @Override
+ public Class<String> getJsonType() {
+ return String.class;
+ }
+}
diff --git a/core/java/android/content/theming/FieldColorBoth.java b/core/java/android/content/theming/FieldColorBoth.java
new file mode 100644
index 000000000000..e4a9f7f716d8
--- /dev/null
+++ b/core/java/android/content/theming/FieldColorBoth.java
@@ -0,0 +1,70 @@
+/*
+ * 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.FlaggedApi;
+
+import androidx.annotation.Nullable;
+
+import java.util.Objects;
+import java.util.function.BiConsumer;
+import java.util.function.Function;
+
+/** @hide */
+@FlaggedApi(android.server.Flags.FLAG_ENABLE_THEME_SERVICE)
+public class FieldColorBoth extends ThemeSettingsField<Boolean, String> {
+ public FieldColorBoth(
+ String key,
+ BiConsumer<ThemeSettingsUpdater, Boolean> setter,
+ Function<ThemeSettings, Boolean> getter,
+ ThemeSettings defaults
+ ) {
+ super(key, setter, getter, defaults);
+ }
+
+ @Override
+ @Nullable
+ public Boolean parse(String primitive) {
+ return switch (primitive) {
+ case "1" -> true;
+ case "0" -> false;
+ default -> null;
+ };
+ }
+
+ @Override
+ public String serialize(Boolean typedValue) {
+ if (typedValue) return "1";
+ return "0";
+ }
+
+ @Override
+ public boolean validate(Boolean value) {
+ Objects.requireNonNull(value);
+ return true;
+ }
+
+ @Override
+ public Class<Boolean> getFieldType() {
+ return Boolean.class;
+ }
+
+ @Override
+ public Class<String> getJsonType() {
+ return String.class;
+ }
+}
diff --git a/core/java/android/content/theming/FieldColorIndex.java b/core/java/android/content/theming/FieldColorIndex.java
new file mode 100644
index 000000000000..683568a42318
--- /dev/null
+++ b/core/java/android/content/theming/FieldColorIndex.java
@@ -0,0 +1,64 @@
+/*
+ * 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.FlaggedApi;
+
+import java.util.function.BiConsumer;
+import java.util.function.Function;
+
+/** @hide */
+@FlaggedApi(android.server.Flags.FLAG_ENABLE_THEME_SERVICE)
+public class FieldColorIndex extends ThemeSettingsField<Integer, String> {
+ public FieldColorIndex(
+ String key,
+ BiConsumer<ThemeSettingsUpdater, Integer> setter,
+ Function<ThemeSettings, Integer> getter,
+ ThemeSettings defaults
+ ) {
+ super(key, setter, getter, defaults);
+ }
+
+ @Override
+ public Integer parse(String primitive) {
+ try {
+ return Integer.parseInt(primitive);
+ } catch (NumberFormatException e) {
+ return null;
+ }
+ }
+
+ @Override
+ public String serialize(Integer typedValue) {
+ return typedValue.toString();
+ }
+
+ @Override
+ public boolean validate(Integer value) {
+ return value >= -1;
+ }
+
+ @Override
+ public Class<Integer> getFieldType() {
+ return Integer.class;
+ }
+
+ @Override
+ public Class<String> getJsonType() {
+ return String.class;
+ }
+}
diff --git a/core/java/android/content/theming/FieldColorSource.java b/core/java/android/content/theming/FieldColorSource.java
new file mode 100644
index 000000000000..1ff3aa64fda5
--- /dev/null
+++ b/core/java/android/content/theming/FieldColorSource.java
@@ -0,0 +1,76 @@
+/*
+ * 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.FlaggedApi;
+import android.annotation.StringDef;
+
+import androidx.annotation.Nullable;
+
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.util.function.BiConsumer;
+import java.util.function.Function;
+
+/** @hide */
+@FlaggedApi(android.server.Flags.FLAG_ENABLE_THEME_SERVICE)
+public class FieldColorSource extends ThemeSettingsField<String, String> {
+ public FieldColorSource(
+ String key,
+ BiConsumer<ThemeSettingsUpdater, String> setter,
+ Function<ThemeSettings, String> getter,
+ ThemeSettings defaults
+ ) {
+ super(key, setter, getter, defaults);
+ }
+
+ @Override
+ @Nullable
+ @Type
+ public String parse(String primitive) {
+ return primitive;
+ }
+
+ @Override
+ public String serialize(@Type String typedValue) {
+ return typedValue;
+ }
+
+ @Override
+ public boolean validate(String value) {
+ return switch (value) {
+ case "preset", "home_wallpaper", "lock_wallpaper" -> true;
+ default -> false;
+ };
+ }
+
+ @Override
+ public Class<String> getFieldType() {
+ return String.class;
+ }
+
+ @Override
+ public Class<String> getJsonType() {
+ return String.class;
+ }
+
+
+ @StringDef({"preset", "home_wallpaper", "lock_wallpaper"})
+ @Retention(RetentionPolicy.SOURCE)
+ @interface Type {
+ }
+}
diff --git a/core/java/android/content/theming/FieldThemeStyle.java b/core/java/android/content/theming/FieldThemeStyle.java
new file mode 100644
index 000000000000..b433e5b96ec3
--- /dev/null
+++ b/core/java/android/content/theming/FieldThemeStyle.java
@@ -0,0 +1,76 @@
+/*
+ * 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.FlaggedApi;
+import android.annotation.Nullable;
+
+import java.util.Arrays;
+import java.util.List;
+import java.util.function.BiConsumer;
+import java.util.function.Function;
+
+/** @hide */
+@FlaggedApi(android.server.Flags.FLAG_ENABLE_THEME_SERVICE)
+public class FieldThemeStyle extends ThemeSettingsField<Integer, String> {
+ public FieldThemeStyle(
+ String key,
+ BiConsumer<ThemeSettingsUpdater, Integer> setter,
+ Function<ThemeSettings, Integer> getter,
+ ThemeSettings defaults
+ ) {
+ super(key, setter, getter, defaults);
+ }
+
+ private static final @ThemeStyle.Type List<Integer> sValidStyles = Arrays.asList(
+ ThemeStyle.EXPRESSIVE,
+ ThemeStyle.SPRITZ,
+ ThemeStyle.TONAL_SPOT, ThemeStyle.FRUIT_SALAD, ThemeStyle.RAINBOW,
+ ThemeStyle.VIBRANT,
+ ThemeStyle.MONOCHROMATIC);
+
+ @Override
+ public String serialize(@ThemeStyle.Type Integer typedValue) {
+ return ThemeStyle.toString(typedValue);
+ }
+
+ @Override
+ public boolean validate(Integer value) {
+ return sValidStyles.contains(value);
+ }
+
+ @Override
+ @Nullable
+ @ThemeStyle.Type
+ public Integer parse(String primitive) {
+ try {
+ return ThemeStyle.valueOf(primitive);
+ } catch (Exception e) {
+ return null;
+ }
+ }
+
+ @Override
+ public Class<Integer> getFieldType() {
+ return Integer.class;
+ }
+
+ @Override
+ public Class<String> getJsonType() {
+ return String.class;
+ }
+}
diff --git a/core/java/android/content/theming/ThemeSettings.java b/core/java/android/content/theming/ThemeSettings.java
new file mode 100644
index 000000000000..e94c1fef5382
--- /dev/null
+++ b/core/java/android/content/theming/ThemeSettings.java
@@ -0,0 +1,200 @@
+/*
+ * 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.os.Parcel;
+import android.os.Parcelable;
+
+import androidx.annotation.NonNull;
+
+import java.util.Objects;
+
+/**
+ * Represents the theme settings for the system.
+ * This class holds various properties related to theming, such as color indices, palettes,
+ * accent colors, color sources, theme styles, and color combinations.
+ *
+ * @hide
+ */
+@FlaggedApi(android.server.Flags.FLAG_ENABLE_THEME_SERVICE)
+public final class ThemeSettings implements Parcelable {
+ private final int mColorIndex;
+ private final int mSystemPalette;
+ private final int mAccentColor;
+ @NonNull
+ private final String mColorSource;
+ private final int mThemeStyle;
+ private final boolean mColorBoth;
+
+ /**
+ * Constructs a new ThemeSettings object.
+ *
+ * @param colorIndex The color index.
+ * @param systemPalette The system palette color.
+ * @param accentColor The accent color.
+ * @param colorSource The color source.
+ * @param themeStyle The theme style.
+ * @param colorBoth The color combination.
+ */
+
+ public ThemeSettings(int colorIndex, @ColorInt int systemPalette,
+ @ColorInt int accentColor, @NonNull String colorSource, int themeStyle,
+ boolean colorBoth) {
+
+ this.mAccentColor = accentColor;
+ this.mColorBoth = colorBoth;
+ this.mColorIndex = colorIndex;
+ this.mColorSource = colorSource;
+ this.mSystemPalette = systemPalette;
+ this.mThemeStyle = themeStyle;
+ }
+
+ /**
+ * Constructs a ThemeSettings object from a Parcel.
+ *
+ * @param in The Parcel to read from.
+ */
+ ThemeSettings(Parcel in) {
+ this.mAccentColor = in.readInt();
+ this.mColorBoth = in.readBoolean();
+ this.mColorIndex = in.readInt();
+ this.mColorSource = Objects.requireNonNullElse(in.readString8(), "s");
+ this.mSystemPalette = in.readInt();
+ this.mThemeStyle = in.readInt();
+ }
+
+ @Override
+ public void writeToParcel(@NonNull Parcel dest, int flags) {
+ dest.writeInt(mAccentColor);
+ dest.writeBoolean(mColorBoth);
+ dest.writeInt(mColorIndex);
+ dest.writeString8(mColorSource);
+ dest.writeInt(mSystemPalette);
+ dest.writeInt(mThemeStyle);
+ }
+
+ /**
+ * Gets the color index.
+ *
+ * @return The color index.
+ */
+ public Integer colorIndex() {
+ return mColorIndex;
+ }
+
+ /**
+ * Gets the system palette color.
+ *
+ * @return The system palette color.
+ */
+ @ColorInt
+ public Integer systemPalette() {
+ return mSystemPalette;
+ }
+
+ /**
+ * Gets the accent color.
+ *
+ * @return The accent color.
+ */
+ @ColorInt
+ public Integer accentColor() {
+ return mAccentColor;
+ }
+
+ /**
+ * Gets the color source.
+ *
+ * @return The color source.
+ */
+ @FieldColorSource.Type
+ public String colorSource() {
+ return mColorSource;
+ }
+
+ /**
+ * Gets the theme style.
+ *
+ * @return The theme style.
+ */
+ @ThemeStyle.Type
+ public Integer themeStyle() {
+ return mThemeStyle;
+ }
+
+ /**
+ * Gets the color combination.
+ *
+ * @return The color combination.
+ */
+ public Boolean colorBoth() {
+ return mColorBoth;
+ }
+
+ @Override
+ public boolean equals(Object obj) {
+ if (this == obj) {
+ return true;
+ }
+
+ return obj instanceof ThemeSettings other
+ && mColorIndex == other.mColorIndex
+ && mSystemPalette == other.mSystemPalette
+ && mAccentColor == other.mAccentColor
+ && mColorSource.equals(other.mColorSource)
+ && mThemeStyle == other.mThemeStyle
+ && mColorBoth == other.mColorBoth;
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(mColorIndex, mSystemPalette, mAccentColor, mColorSource, mThemeStyle,
+ mColorBoth);
+ }
+
+ @Override
+ public int describeContents() {
+ return 0;
+ }
+
+ /**
+ * Creator for Parcelable interface.
+ */
+ public static final Creator<ThemeSettings> CREATOR = new Creator<>() {
+ @Override
+ public ThemeSettings createFromParcel(Parcel in) {
+ return new ThemeSettings(in);
+ }
+
+ @Override
+ public ThemeSettings[] newArray(int size) {
+ return new ThemeSettings[size];
+ }
+ };
+
+ /**
+ * Creates a new {@link ThemeSettingsUpdater} instance for updating the {@link ThemeSettings}
+ * through the API.
+ *
+ * @return A new {@link ThemeSettingsUpdater} instance.
+ */
+ public static ThemeSettingsUpdater updater() {
+ return new ThemeSettingsUpdater();
+ }
+}
diff --git a/core/java/android/content/theming/ThemeSettingsField.java b/core/java/android/content/theming/ThemeSettingsField.java
new file mode 100644
index 000000000000..1696df4ad0f6
--- /dev/null
+++ b/core/java/android/content/theming/ThemeSettingsField.java
@@ -0,0 +1,287 @@
+/*
+ * 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.FlaggedApi;
+import android.util.Log;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.annotation.VisibleForTesting;
+
+import com.android.internal.util.Preconditions;
+
+import org.json.JSONException;
+import org.json.JSONObject;
+
+import java.util.function.BiConsumer;
+import java.util.function.Function;
+
+/**
+ * Represents a field within {@link ThemeSettings}, providing methods for parsing, serializing,
+ * managing default values, and validating the field's value.
+ * <p>
+ * This class is designed to be extended by concrete classes that represent specific fields within
+ * {@link ThemeSettings}. Each subclass should define the following methods, where T is the type of
+ * the field's value and J is the type of the field's value stored in JSON:
+ * <ul>
+ * <li>{@link #parse(Object)} to parse a JSON representation into the field's value type.</li>
+ * <li>{@link #serialize(Object)} to serialize the field's value into a JSON representation.</li>
+ * <li>{@link #validate(Object)} to validate the field's value.</li>
+ * <li>{@link #getFieldType()} to return the type of the field's value.</li>
+ * <li>{@link #getJsonType()} to return the type of the field's value stored in JSON.</li>
+ * </ul>
+ * <p>
+ * The {@link #fromJSON(JSONObject, ThemeSettingsUpdater)} and
+ * {@link #toJSON(ThemeSettings, JSONObject)}
+ * methods handle the extraction and serialization of the field's value to and from JSON objects
+ * respectively. The {@link #fallbackParse(Object, Object)} method is used to parse a string
+ * representation of the field's value, falling back to a default value if parsing fails.
+ *
+ * @param <T> The type of the field's value.
+ * @param <J> The type of the JSON property.
+ * @hide
+ */
+@FlaggedApi(android.server.Flags.FLAG_ENABLE_THEME_SERVICE)
+public abstract class ThemeSettingsField<T, J> {
+ private static final String TAG = ThemeSettingsField.class.getSimpleName();
+
+ private static final String KEY_PREFIX = "android.theme.customization.";
+ public static final String OVERLAY_CATEGORY_ACCENT_COLOR = KEY_PREFIX + "accent_color";
+ public static final String OVERLAY_CATEGORY_SYSTEM_PALETTE = KEY_PREFIX + "system_palette";
+ public static final String OVERLAY_CATEGORY_THEME_STYLE = KEY_PREFIX + "theme_style";
+ public static final String OVERLAY_COLOR_SOURCE = KEY_PREFIX + "color_source";
+ public static final String OVERLAY_COLOR_INDEX = KEY_PREFIX + "color_index";
+ public static final String OVERLAY_COLOR_BOTH = KEY_PREFIX + "color_both";
+
+
+ /**
+ * Returns an array of all available {@link ThemeSettingsField} instances.
+ *
+ * @param defaults The default {@link ThemeSettings} object to use for default values.
+ * @return An array of {@link ThemeSettingsField} instances.
+ */
+ public static ThemeSettingsField<?, ?>[] getFields(ThemeSettings defaults) {
+ return new ThemeSettingsField[]{
+ new FieldColorIndex(
+ OVERLAY_COLOR_INDEX,
+ ThemeSettingsUpdater::colorIndex,
+ ThemeSettings::colorIndex,
+ defaults),
+ new FieldColor(
+ OVERLAY_CATEGORY_SYSTEM_PALETTE,
+ ThemeSettingsUpdater::systemPalette,
+ ThemeSettings::systemPalette,
+ defaults),
+ new FieldColor(
+ OVERLAY_CATEGORY_ACCENT_COLOR,
+ ThemeSettingsUpdater::accentColor,
+ ThemeSettings::accentColor,
+ defaults),
+ new FieldColorSource(
+ OVERLAY_COLOR_SOURCE,
+ ThemeSettingsUpdater::colorSource,
+ ThemeSettings::colorSource,
+ defaults),
+ new FieldThemeStyle(
+ OVERLAY_CATEGORY_THEME_STYLE,
+ ThemeSettingsUpdater::themeStyle,
+ ThemeSettings::themeStyle,
+ defaults),
+ new FieldColorBoth(
+ OVERLAY_COLOR_BOTH,
+ ThemeSettingsUpdater::colorBoth,
+ ThemeSettings::colorBoth,
+ defaults)
+ };
+ }
+
+ public final String key;
+ private final BiConsumer<ThemeSettingsUpdater, T> mSetter;
+ private final Function<ThemeSettings, T> mGetter;
+ private final ThemeSettings mDefaults;
+
+ /**
+ * Creates a new {@link ThemeSettingsField}.
+ *
+ * @param key The key to identify the field in JSON objects.
+ * @param setter The setter to update the field's value in a {@link ThemeSettingsUpdater}.
+ * @param getter The getter to retrieve the field's value from a {@link ThemeSettings}
+ * object.
+ * @param defaults The default {@link ThemeSettings} object to provide default values.
+ */
+
+ public ThemeSettingsField(
+ String key,
+ BiConsumer<ThemeSettingsUpdater, T> setter,
+ Function<ThemeSettings, T> getter,
+ ThemeSettings defaults
+ ) {
+ this.key = key;
+ mSetter = setter;
+ mGetter = getter;
+ mDefaults = defaults;
+ }
+
+ /**
+ * Attempts to parse a JSON primitive representation of the field's value. If parsing fails, it
+ * defaults to the field's default value.
+ *
+ * @param primitive The string representation to parse.
+ */
+ private T fallbackParse(Object primitive, T fallbackValue) {
+ if (primitive == null) {
+ Log.w(TAG, "Error, field `" + key + "` was not found, defaulting to " + fallbackValue);
+ return fallbackValue;
+ }
+
+ if (!getJsonType().isInstance(primitive)) {
+ Log.w(TAG, "Error, field `" + key + "` expected to be of type `"
+ + getJsonType().getSimpleName()
+ + "`, got `" + primitive.getClass().getSimpleName() + "`, defaulting to "
+ + fallbackValue);
+ return fallbackValue;
+ }
+
+ // skips parsing if destination json type is already the same as field type
+ T parsedValue = getFieldType() == getJsonType() ? (T) primitive : parse((J) primitive);
+
+ if (parsedValue == null) {
+ Log.w(TAG, "Error parsing JSON field `" + key + "` , defaulting to " + fallbackValue);
+ return fallbackValue;
+ }
+
+ if (!validate(parsedValue)) {
+ Log.w(TAG,
+ "Error validating JSON field `" + key + "` , defaulting to " + fallbackValue);
+ return fallbackValue;
+ }
+
+ if (parsedValue.getClass() != getFieldType()) {
+ Log.w(TAG, "Error: JSON field `" + key + "` expected to be of type `"
+ + getFieldType().getSimpleName()
+ + "`, defaulting to " + fallbackValue);
+ return fallbackValue;
+ }
+
+ return parsedValue;
+ }
+
+
+ /**
+ * Extracts the field's value from a JSON object and sets it in a
+ * {@link ThemeSettingsUpdater}.
+ *
+ * @param source The JSON object containing the field's value.
+ */
+ public void fromJSON(JSONObject source, ThemeSettingsUpdater updater) {
+ Object primitiveStr = source.opt(key);
+ T typedValue = fallbackParse(primitiveStr, getDefaultValue());
+ mSetter.accept(updater, typedValue);
+ }
+
+ /**
+ * Serializes the field's value from a {@link ThemeSettings} object into a JSON object.
+ *
+ * @param source The {@link ThemeSettings} object from which to retrieve the field's
+ * value.
+ * @param destination The JSON object to which the field's value will be added.
+ */
+ public void toJSON(ThemeSettings source, JSONObject destination) {
+ T value = mGetter.apply(source);
+ Preconditions.checkState(value.getClass() == getFieldType());
+
+ J serialized;
+ if (validate(value)) {
+ serialized = serialize(value);
+ } else {
+ T fallbackValue = getDefaultValue();
+ serialized = serialize(fallbackValue);
+ Log.w(TAG, "Invalid value `" + value + "` for key `" + key + "`, defaulting to '"
+ + fallbackValue);
+ }
+
+ try {
+ destination.put(key, serialized);
+ } catch (JSONException e) {
+ Log.d(TAG,
+ "Error writing JSON primitive, skipping field " + key + ", " + e.getMessage());
+ }
+ }
+
+
+ /**
+ * Returns the default value of the field.
+ *
+ * @return The default value.
+ */
+ @VisibleForTesting
+ @NonNull
+ public T getDefaultValue() {
+ return mGetter.apply(mDefaults);
+ }
+
+ /**
+ * Parses a string representation into the field's value type.
+ *
+ * @param primitive The string representation to parse.
+ * @return The parsed value, or null if parsing fails.
+ */
+ @VisibleForTesting
+ @Nullable
+ public abstract T parse(J primitive);
+
+ /**
+ * Serializes the field's value into a primitive type suitable for JSON.
+ *
+ * @param value The value to serialize.
+ * @return The serialized value.
+ */
+ @VisibleForTesting
+ public abstract J serialize(T value);
+
+ /**
+ * Validates the field's value.
+ * This method can be overridden to perform custom validation logic and MUST NOT validate for
+ * nullity.
+ *
+ * @param value The value to validate.
+ * @return {@code true} if the value is valid, {@code false} otherwise.
+ */
+ @VisibleForTesting
+ public abstract boolean validate(T value);
+
+ /**
+ * Returns the type of the field's value.
+ *
+ * @return The type of the field's value.
+ */
+ @VisibleForTesting
+ public abstract Class<T> getFieldType();
+
+ /**
+ * Returns the type of the field's value stored in JSON.
+ *
+ * <p>This method is used to determine the expected type of the field's value when it is
+ * stored in a JSON object.
+ *
+ * @return The type of the field's value stored in JSON.
+ */
+ @VisibleForTesting
+ public abstract Class<J> getJsonType();
+}
diff --git a/core/java/android/content/theming/ThemeSettingsUpdater.java b/core/java/android/content/theming/ThemeSettingsUpdater.java
new file mode 100644
index 000000000000..acd7d356db69
--- /dev/null
+++ b/core/java/android/content/theming/ThemeSettingsUpdater.java
@@ -0,0 +1,244 @@
+/*
+ * 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.NonNull;
+import android.annotation.SuppressLint;
+import android.os.Parcel;
+import android.os.Parcelable;
+
+import androidx.annotation.VisibleForTesting;
+
+import java.util.Objects;
+
+/**
+ * Updater class for constructing {@link ThemeSettings} objects.
+ * This class provides a fluent interface for setting the various properties of the theme
+ * settings.
+ *
+ * @hide
+ */
+@FlaggedApi(android.server.Flags.FLAG_ENABLE_THEME_SERVICE)
+public class ThemeSettingsUpdater implements Parcelable {
+ @ColorInt
+ private Integer mAccentColor;
+ private Boolean mColorBoth;
+ private Integer mColorIndex;
+ private String mColorSource;
+ @ColorInt
+ private Integer mSystemPalette;
+ private Integer mThemeStyle;
+
+ ThemeSettingsUpdater(Integer colorIndex, @ColorInt Integer systemPalette,
+ @ColorInt Integer accentColor, @FieldColorSource.Type String colorSource,
+ @ThemeStyle.Type Integer themeStyle, Boolean colorBoth) {
+ this.mAccentColor = accentColor;
+ this.mColorBoth = colorBoth;
+ this.mColorIndex = colorIndex;
+ this.mColorSource = colorSource;
+ this.mSystemPalette = systemPalette;
+ this.mThemeStyle = themeStyle;
+ }
+
+ ThemeSettingsUpdater() {
+ }
+
+ // only reading basic JVM types for nullability
+ @SuppressLint("ParcelClassLoader")
+ protected ThemeSettingsUpdater(Parcel in) {
+ mAccentColor = (Integer) in.readValue(null);
+ mColorBoth = (Boolean) in.readValue(null);
+ mColorIndex = (Integer) in.readValue(null);
+ mColorSource = (String) in.readValue(null);
+ mSystemPalette = (Integer) in.readValue(null);
+ mThemeStyle = (Integer) in.readValue(null);
+ }
+
+ // using read/writeValue for nullability support
+ @SuppressWarnings("AndroidFrameworkEfficientParcelable")
+ @Override
+ public void writeToParcel(Parcel dest, int flags) {
+ dest.writeValue(mAccentColor);
+ dest.writeValue(mColorBoth);
+ dest.writeValue(mColorIndex);
+ dest.writeValue(mColorSource);
+ dest.writeValue(mSystemPalette);
+ dest.writeValue(mThemeStyle);
+ }
+
+ /**
+ * Sets the color index.
+ *
+ * @param colorIndex The color index to set.
+ * @return This {@link ThemeSettingsUpdater} instance.
+ */
+ public ThemeSettingsUpdater colorIndex(int colorIndex) {
+ this.mColorIndex = colorIndex;
+ return this;
+ }
+
+ /**
+ * Returns the color index.
+ *
+ * @return The color index.
+ */
+ @VisibleForTesting
+ public Integer getColorIndex() {
+ return mColorIndex;
+ }
+
+ /**
+ * Sets the system palette color.
+ *
+ * @param systemPalette The system palette color to set.
+ * @return This {@link ThemeSettingsUpdater} instance.
+ */
+ public ThemeSettingsUpdater systemPalette(@ColorInt int systemPalette) {
+ this.mSystemPalette = systemPalette;
+ return this;
+ }
+
+ /**
+ * Returns the system palette color.
+ *
+ * @return The system palette color.
+ */
+ @VisibleForTesting
+ public Integer getSystemPalette() {
+ return mSystemPalette;
+ }
+
+ /**
+ * Sets the accent color.
+ *
+ * @param accentColor The accent color to set.
+ * @return This {@link ThemeSettingsUpdater} instance.
+ */
+ public ThemeSettingsUpdater accentColor(@ColorInt int accentColor) {
+ this.mAccentColor = accentColor;
+ return this;
+ }
+
+ /**
+ * Returns the accent color.
+ *
+ * @return The accent color.
+ */
+ @VisibleForTesting
+ public Integer getAccentColor() {
+ return mAccentColor;
+ }
+
+ /**
+ * Sets the color source.
+ *
+ * @param colorSource The color source to set.
+ * @return This {@link ThemeSettingsUpdater} instance.
+ */
+ public ThemeSettingsUpdater colorSource(@NonNull @FieldColorSource.Type String colorSource) {
+ this.mColorSource = colorSource;
+ return this;
+ }
+
+ /**
+ * Returns the theme style.
+ *
+ * @return The theme style.
+ */
+ @VisibleForTesting
+ public Integer getThemeStyle() {
+ return mThemeStyle;
+ }
+
+ /**
+ * Sets the theme style.
+ *
+ * @param themeStyle The theme style to set.
+ * @return This {@link ThemeSettingsUpdater} instance.
+ */
+ public ThemeSettingsUpdater themeStyle(@ThemeStyle.Type int themeStyle) {
+ this.mThemeStyle = themeStyle;
+ return this;
+ }
+
+ /**
+ * Returns the color source.
+ *
+ * @return The color source.
+ */
+ @VisibleForTesting
+ public String getColorSource() {
+ return mColorSource;
+ }
+
+ /**
+ * Sets the color combination.
+ *
+ * @param colorBoth The color combination to set.
+ * @return This {@link ThemeSettingsUpdater} instance.
+ */
+ public ThemeSettingsUpdater colorBoth(boolean colorBoth) {
+ this.mColorBoth = colorBoth;
+ return this;
+ }
+
+ /**
+ * Returns the color combination.
+ *
+ * @return The color combination.
+ */
+ @VisibleForTesting
+ public Boolean getColorBoth() {
+ return mColorBoth;
+ }
+
+ /**
+ * Constructs a new {@link ThemeSettings} object with the current builder settings.
+ *
+ * @return A new {@link ThemeSettings} object.
+ */
+ public ThemeSettings toThemeSettings(@NonNull ThemeSettings defaults) {
+ return new ThemeSettings(
+ Objects.requireNonNullElse(mColorIndex, defaults.colorIndex()),
+ Objects.requireNonNullElse(mSystemPalette, defaults.systemPalette()),
+ Objects.requireNonNullElse(mAccentColor, defaults.accentColor()),
+ Objects.requireNonNullElse(mColorSource, defaults.colorSource()),
+ Objects.requireNonNullElse(mThemeStyle, defaults.themeStyle()),
+ Objects.requireNonNullElse(mColorBoth, defaults.colorBoth()));
+ }
+
+ @Override
+ public int describeContents() {
+ return 0;
+ }
+
+ public static final Creator<ThemeSettingsUpdater> CREATOR =
+ new Creator<>() {
+ @Override
+ public ThemeSettingsUpdater createFromParcel(Parcel in) {
+ return new ThemeSettingsUpdater(in);
+ }
+
+ @Override
+ public ThemeSettingsUpdater[] newArray(int size) {
+ return new ThemeSettingsUpdater[size];
+ }
+ };
+}
diff --git a/core/java/android/content/theming/ThemeStyle.java b/core/java/android/content/theming/ThemeStyle.java
new file mode 100644
index 000000000000..607896405020
--- /dev/null
+++ b/core/java/android/content/theming/ThemeStyle.java
@@ -0,0 +1,180 @@
+/*
+ * 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.IntDef;
+import android.annotation.NonNull;
+import android.annotation.Nullable;
+
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+
+/**
+ * A class defining the different styles available for theming.
+ * This class replaces the previous enum implementation for improved performance and compatibility.
+ *
+ * @hide
+ */
+public final class ThemeStyle {
+
+ private ThemeStyle() {
+ }
+
+ /**
+ * @hide
+ */
+ @IntDef({
+ SPRITZ,
+ TONAL_SPOT,
+ VIBRANT,
+ EXPRESSIVE,
+ RAINBOW,
+ FRUIT_SALAD,
+ CONTENT,
+ MONOCHROMATIC,
+ CLOCK,
+ CLOCK_VIBRANT
+ })
+ @Retention(RetentionPolicy.SOURCE)
+ public @interface Type {
+ }
+
+ /**
+ * Represents the SPRITZ style.
+ */
+ public static final int SPRITZ = 0;
+ /**
+ * Represents the TONAL_SPOT style.
+ */
+ public static final int TONAL_SPOT = 1;
+ /**
+ * Represents the VIBRANT style.
+ */
+ public static final int VIBRANT = 2;
+ /**
+ * Represents the EXPRESSIVE style.
+ */
+ public static final int EXPRESSIVE = 3;
+ /**
+ * Represents the RAINBOW style.
+ */
+ public static final int RAINBOW = 4;
+ /**
+ * Represents the FRUIT_SALAD style.
+ */
+ public static final int FRUIT_SALAD = 5;
+ /**
+ * Represents the CONTENT style.
+ */
+ public static final int CONTENT = 6;
+ /**
+ * Represents the MONOCHROMATIC style.
+ */
+ public static final int MONOCHROMATIC = 7;
+ /**
+ * Represents the CLOCK style.
+ */
+ public static final int CLOCK = 8;
+ /**
+ * Represents the CLOCK_VIBRANT style.
+ */
+ public static final int CLOCK_VIBRANT = 9;
+
+
+ /**
+ * Returns the string representation of the given style.
+ *
+ * @param style The style value.
+ * @return The string representation of the style.
+ * @throws IllegalArgumentException if the style value is invalid.
+ */
+ @NonNull
+ public static String toString(@Nullable @Type Integer style) {
+ // Throw an exception if style is null
+ if (style == null) {
+ throw new IllegalArgumentException("Invalid style value: null");
+ }
+
+ return switch (style) {
+ case SPRITZ -> "SPRITZ";
+ case TONAL_SPOT -> "TONAL_SPOT";
+ case VIBRANT -> "VIBRANT";
+ case EXPRESSIVE -> "EXPRESSIVE";
+ case RAINBOW -> "RAINBOW";
+ case FRUIT_SALAD -> "FRUIT_SALAD";
+ case CONTENT -> "CONTENT";
+ case MONOCHROMATIC -> "MONOCHROMATIC";
+ case CLOCK -> "CLOCK";
+ case CLOCK_VIBRANT -> "CLOCK_VIBRANT";
+ default -> throw new IllegalArgumentException("Invalid style value: " + style);
+ };
+ }
+
+ /**
+ * Returns the style value corresponding to the given style name.
+ *
+ * @param styleName The name of the style.
+ * @return The style value.
+ * @throws IllegalArgumentException if the style name is invalid.
+ */
+ public static @Type int valueOf(@Nullable String styleName) {
+ return switch (styleName) {
+ case "SPRITZ" -> SPRITZ;
+ case "TONAL_SPOT" -> TONAL_SPOT;
+ case "VIBRANT" -> VIBRANT;
+ case "EXPRESSIVE" -> EXPRESSIVE;
+ case "RAINBOW" -> RAINBOW;
+ case "FRUIT_SALAD" -> FRUIT_SALAD;
+ case "CONTENT" -> CONTENT;
+ case "MONOCHROMATIC" -> MONOCHROMATIC;
+ case "CLOCK" -> CLOCK;
+ case "CLOCK_VIBRANT" -> CLOCK_VIBRANT;
+ default -> throw new IllegalArgumentException("Invalid style name: " + styleName);
+ };
+ }
+
+ /**
+ * Returns the name of the given style. This method is equivalent to {@link #toString(int)}.
+ *
+ * @param style The style value.
+ * @return The name of the style.
+ */
+ @NonNull
+ public static String name(@Type int style) {
+ return toString(style);
+ }
+
+ /**
+ * Returns an array containing all the style values.
+ *
+ * @return An array of all style values.
+ */
+ public static int[] values() {
+ return new int[]{
+ SPRITZ,
+ TONAL_SPOT,
+ VIBRANT,
+ EXPRESSIVE,
+ RAINBOW,
+ FRUIT_SALAD,
+ CONTENT,
+ MONOCHROMATIC,
+ CLOCK,
+ CLOCK_VIBRANT
+ };
+ }
+}
diff --git a/services/core/java/com/android/server/theming/ThemeSettingsManager.java b/services/core/java/com/android/server/theming/ThemeSettingsManager.java
new file mode 100644
index 000000000000..94094a6f9603
--- /dev/null
+++ b/services/core/java/com/android/server/theming/ThemeSettingsManager.java
@@ -0,0 +1,166 @@
+/*
+ * 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.FlaggedApi;
+import android.annotation.NonNull;
+import android.annotation.UserIdInt;
+import android.content.ContentResolver;
+import android.content.theming.ThemeSettings;
+import android.content.theming.ThemeSettingsField;
+import android.content.theming.ThemeSettingsUpdater;
+import android.provider.Settings;
+import android.telecom.Log;
+
+import com.android.internal.util.Preconditions;
+
+import org.json.JSONException;
+import org.json.JSONObject;
+
+import java.util.Iterator;
+
+/**
+ * Manages the loading and saving of theme settings. This class handles the persistence of theme
+ * settings to and from the system settings. It utilizes a collection of {@link ThemeSettingsField}
+ * objects to represent individual theme setting fields.
+ *
+ * @hide
+ */
+@FlaggedApi(android.server.Flags.FLAG_ENABLE_THEME_SERVICE)
+class ThemeSettingsManager {
+ private static final String TAG = ThemeSettingsManager.class.getSimpleName();
+ static final String TIMESTAMP_FIELD = "_applied_timestamp";
+ private final ThemeSettingsField<?, ?>[] mFields;
+ private final ThemeSettings mDefaults;
+
+ /**
+ * Constructs a new {@code ThemeSettingsManager} with the specified default settings.
+ *
+ * @param defaults The default theme settings to use.
+ */
+ ThemeSettingsManager(ThemeSettings defaults) {
+ mDefaults = defaults;
+ mFields = ThemeSettingsField.getFields(defaults);
+ }
+
+ /**
+ * Loads the theme settings for the specified user.
+ *
+ * @param userId The ID of the user.
+ * @param contentResolver The content resolver to use.
+ * @return The loaded {@link ThemeSettings}.
+ */
+ @NonNull
+ ThemeSettings loadSettings(@UserIdInt int userId, ContentResolver contentResolver) {
+ String jsonString = Settings.Secure.getStringForUser(contentResolver,
+ Settings.Secure.THEME_CUSTOMIZATION_OVERLAY_PACKAGES, userId);
+
+ JSONObject userSettings;
+
+ try {
+ userSettings = new JSONObject(jsonString == null ? "" : jsonString);
+ } catch (JSONException e) {
+ userSettings = new JSONObject();
+ }
+
+ ThemeSettingsUpdater updater = ThemeSettings.updater();
+
+ for (ThemeSettingsField<?, ?> field : mFields) {
+ field.fromJSON(userSettings, updater);
+ }
+
+ return updater.toThemeSettings(mDefaults);
+ }
+
+ /**
+ * Saves the specified theme settings for the given user.
+ *
+ * @param userId The ID of the user.
+ * @param contentResolver The content resolver to use.
+ * @param newSettings The {@link ThemeSettings} to save.
+ */
+ void replaceSettings(@UserIdInt int userId, ContentResolver contentResolver,
+ ThemeSettings newSettings) throws RuntimeException {
+ Preconditions.checkArgument(newSettings != null, "Impossible to write empty settings");
+
+ JSONObject jsonSettings = new JSONObject();
+
+
+ for (ThemeSettingsField<?, ?> field : mFields) {
+ field.toJSON(newSettings, jsonSettings);
+ }
+
+ // user defined timestamp should be ignored. Storing new timestamp.
+ try {
+ jsonSettings.put(TIMESTAMP_FIELD, System.currentTimeMillis());
+ } catch (JSONException e) {
+ Log.w(TAG, "Error saving timestamp: " + e.getMessage());
+ }
+
+ String jsonString = jsonSettings.toString();
+
+ Settings.Secure.putStringForUser(contentResolver,
+ Settings.Secure.THEME_CUSTOMIZATION_OVERLAY_PACKAGES, jsonString, userId);
+ }
+
+ /**
+ * Saves the specified theme settings for the given user, while preserving unrelated existing
+ * properties.
+ *
+ * @param userId The ID of the user.
+ * @param contentResolver The content resolver to use.
+ * @param newSettings The {@link ThemeSettings} to save.
+ */
+ void updateSettings(@UserIdInt int userId, ContentResolver contentResolver,
+ ThemeSettings newSettings) throws JSONException, RuntimeException {
+ Preconditions.checkArgument(newSettings != null, "Impossible to write empty settings");
+
+ String existingJsonString = Settings.Secure.getStringForUser(contentResolver,
+ Settings.Secure.THEME_CUSTOMIZATION_OVERLAY_PACKAGES, userId);
+
+ JSONObject existingJson;
+ try {
+ existingJson = new JSONObject(existingJsonString == null ? "{}" : existingJsonString);
+ } catch (JSONException e) {
+ existingJson = new JSONObject();
+ }
+
+ JSONObject newJson = new JSONObject();
+ for (ThemeSettingsField<?, ?> field : mFields) {
+ field.toJSON(newSettings, newJson);
+ }
+
+ // user defined timestamp should be ignored. Storing new timestamp.
+ try {
+ newJson.put(TIMESTAMP_FIELD, System.currentTimeMillis());
+ } catch (JSONException e) {
+ Log.w(TAG, "Error saving timestamp: " + e.getMessage());
+ }
+
+ // Merge the new settings with the existing settings
+ Iterator<String> keys = newJson.keys();
+ while (keys.hasNext()) {
+ String key = keys.next();
+ existingJson.put(key, newJson.get(key));
+ }
+
+ String mergedJsonString = existingJson.toString();
+
+ Settings.Secure.putStringForUser(contentResolver,
+ Settings.Secure.THEME_CUSTOMIZATION_OVERLAY_PACKAGES, mergedJsonString, userId);
+ }
+}
diff --git a/services/java/com/android/server/flags.aconfig b/services/java/com/android/server/flags.aconfig
index 7a6bd75e5893..f864b6b8c768 100644
--- a/services/java/com/android/server/flags.aconfig
+++ b/services/java/com/android/server/flags.aconfig
@@ -31,6 +31,13 @@ flag {
}
flag {
+ namespace: "system_performance"
+ name: "enable_theme_service"
+ description: "Switches from SystemUi's ThemeOverlayController to Server's ThemeService."
+ bug: "333694176"
+}
+
+flag {
name: "allow_removing_vpn_service"
namespace: "wear_frameworks"
description: "Allow removing VpnManagerService"
diff --git a/services/tests/servicestests/Android.bp b/services/tests/servicestests/Android.bp
index 64e6d323bdfd..d3c3178f3513 100644
--- a/services/tests/servicestests/Android.bp
+++ b/services/tests/servicestests/Android.bp
@@ -347,6 +347,17 @@ test_module_config {
include_filters: ["com.android.server.om."],
}
+test_module_config {
+ name: "FrameworksServicesTests_theme",
+ base: "FrameworksServicesTests",
+ test_suites: [
+ "device-tests",
+ "automotive-tests",
+ ],
+
+ include_filters: ["com.android.server.theming."],
+}
+
// Used by contexthub TEST_MAPPING
test_module_config {
name: "FrameworksServicesTests_contexthub_presubmit",
diff --git a/services/tests/servicestests/src/com/android/server/theming/FieldColorBothTests.java b/services/tests/servicestests/src/com/android/server/theming/FieldColorBothTests.java
new file mode 100644
index 000000000000..38cbcf37f88c
--- /dev/null
+++ b/services/tests/servicestests/src/com/android/server/theming/FieldColorBothTests.java
@@ -0,0 +1,99 @@
+/*
+ * 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 static com.google.common.truth.Truth.assertThat;
+
+import android.content.theming.FieldColorBoth;
+import android.content.theming.ThemeSettings;
+import android.content.theming.ThemeSettingsUpdater;
+import android.content.theming.ThemeStyle;
+
+import com.google.common.truth.Truth;
+
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+@RunWith(JUnit4.class)
+public class FieldColorBothTests {
+ static final ThemeSettings DEFAULTS = new ThemeSettings(1, 0xFF123456, 0xFF654321,
+ "home_wallpaper", ThemeStyle.VIBRANT, true);
+ private FieldColorBoth mFieldColorBoth;
+
+ @Before
+ public void setup() {
+ mFieldColorBoth = new FieldColorBoth("colorBoth", ThemeSettingsUpdater::colorBoth,
+ ThemeSettings::colorBoth, DEFAULTS);
+ }
+
+ @Test
+ public void parse_validColorBoth_returnsTrue() {
+ Boolean parsedValue = mFieldColorBoth.parse("1");
+ assertThat(parsedValue).isTrue();
+ }
+
+ @Test
+ public void parse_validColorBoth_returnsFalse() {
+ Boolean parsedValue = mFieldColorBoth.parse("0");
+ assertThat(parsedValue).isFalse();
+ }
+
+ @Test
+ public void parse_invalidColorBoth_returnsNull() {
+ Boolean parsedValue = mFieldColorBoth.parse("invalid");
+ assertThat(parsedValue).isNull();
+ }
+
+ @Test
+ public void serialize_true_returnsTrueString() {
+ String serializedValue = mFieldColorBoth.serialize(true);
+ assertThat(serializedValue).isEqualTo("1");
+ }
+
+ @Test
+ public void serialize_false_returnsFalseString() {
+ String serializedValue = mFieldColorBoth.serialize(false);
+ assertThat(serializedValue).isEqualTo("0");
+ }
+
+ @Test
+ public void validate_true_returnsTrue() {
+ assertThat(mFieldColorBoth.validate(true)).isTrue();
+ }
+
+ @Test
+ public void validate_false_returnsTrue() {
+ assertThat(mFieldColorBoth.validate(false)).isTrue();
+ }
+
+ @Test
+ public void getFieldType_returnsBooleanClass() {
+ Truth.assertThat(mFieldColorBoth.getFieldType()).isEqualTo(Boolean.class);
+ }
+
+ @Test
+ public void getJsonType_returnsStringClass() {
+ Truth.assertThat(mFieldColorBoth.getJsonType()).isEqualTo(String.class);
+ }
+
+ @Test
+ public void get_returnsDefaultValue() {
+ Truth.assertThat(mFieldColorBoth.getDefaultValue()).isEqualTo(DEFAULTS.colorBoth());
+ }
+}
diff --git a/services/tests/servicestests/src/com/android/server/theming/FieldColorIndexTests.java b/services/tests/servicestests/src/com/android/server/theming/FieldColorIndexTests.java
new file mode 100644
index 000000000000..32df3684a81d
--- /dev/null
+++ b/services/tests/servicestests/src/com/android/server/theming/FieldColorIndexTests.java
@@ -0,0 +1,103 @@
+/*
+ * 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 static com.google.common.truth.Truth.assertThat;
+
+import android.content.theming.FieldColorIndex;
+import android.content.theming.ThemeSettings;
+import android.content.theming.ThemeSettingsUpdater;
+import android.content.theming.ThemeStyle;
+
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+@RunWith(JUnit4.class)
+public class FieldColorIndexTests {
+ static final ThemeSettings DEFAULTS = new ThemeSettings(1, 0xFF123456, 0xFF654321,
+ "home_wallpaper", ThemeStyle.VIBRANT, true);
+
+ private FieldColorIndex mFieldColorIndex;
+
+ @Before
+ public void setup() {
+ mFieldColorIndex = new FieldColorIndex("colorIndex", ThemeSettingsUpdater::colorIndex,
+ ThemeSettings::colorIndex, DEFAULTS);
+ }
+
+ @Test
+ public void parse_validColorIndex_returnsCorrectInteger() {
+ Integer parsedValue = mFieldColorIndex.parse("10");
+ assertThat(parsedValue).isEqualTo(10);
+ }
+
+ @Test
+ public void parse_negativeColorIndex_returnsCorrectInteger() {
+ Integer parsedValue = mFieldColorIndex.parse("-1");
+ assertThat(parsedValue).isEqualTo(-1);
+ }
+
+ @Test
+ public void parse_invalidColorIndex_returnsNull() {
+ Integer parsedValue = mFieldColorIndex.parse("invalid");
+ assertThat(parsedValue).isNull();
+ }
+
+ @Test
+ public void serialize_validColorIndex_returnsCorrectString() {
+ String serializedValue = mFieldColorIndex.serialize(15);
+ assertThat(serializedValue).isEqualTo("15");
+ }
+
+ @Test
+ public void serialize_negativeColorIndex_returnsCorrectString() {
+ String serializedValue = mFieldColorIndex.serialize(-1);
+ assertThat(serializedValue).isEqualTo("-1");
+ }
+
+ @Test
+ public void validate_validColorIndex_returnsTrue() {
+ assertThat(mFieldColorIndex.validate(5)).isTrue();
+ }
+
+ @Test
+ public void validate_negativeColorIndex_returnsTrue() {
+ assertThat(mFieldColorIndex.validate(-1)).isTrue();
+ }
+
+ @Test
+ public void validate_invalidColorIndex_returnsFalse() {
+ assertThat(mFieldColorIndex.validate(-2)).isFalse();
+ }
+
+ @Test
+ public void getFieldType_returnsIntegerClass() {
+ assertThat(mFieldColorIndex.getFieldType()).isEqualTo(Integer.class);
+ }
+
+ @Test
+ public void getJsonType_returnsStringClass() {
+ assertThat(mFieldColorIndex.getJsonType()).isEqualTo(String.class);
+ }
+
+ @Test
+ public void get_returnsDefaultValue() {
+ assertThat(mFieldColorIndex.getDefaultValue()).isEqualTo(DEFAULTS.colorIndex());
+ }
+}
diff --git a/services/tests/servicestests/src/com/android/server/theming/FieldColorSourceTests.java b/services/tests/servicestests/src/com/android/server/theming/FieldColorSourceTests.java
new file mode 100644
index 000000000000..06edfa862d9c
--- /dev/null
+++ b/services/tests/servicestests/src/com/android/server/theming/FieldColorSourceTests.java
@@ -0,0 +1,94 @@
+/*
+ * 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 static com.google.common.truth.Truth.assertThat;
+
+import android.content.theming.FieldColorSource;
+import android.content.theming.ThemeSettings;
+import android.content.theming.ThemeSettingsUpdater;
+import android.content.theming.ThemeStyle;
+
+import com.google.common.truth.Truth;
+
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+
+@RunWith(JUnit4.class)
+public class FieldColorSourceTests {
+ static final ThemeSettings DEFAULTS = new ThemeSettings(1, 0xFF123456, 0xFF654321,
+ "home_wallpaper", ThemeStyle.VIBRANT, true);
+ private FieldColorSource mFieldColorSource;
+
+ @Before
+ public void setup() {
+ mFieldColorSource = new FieldColorSource("colorSource", ThemeSettingsUpdater::colorSource,
+ ThemeSettings::colorSource, DEFAULTS);
+ }
+
+ @Test
+ public void parse_validColorSource_returnsSameString() {
+ String validColorSource = "home_wallpaper";
+ String parsedValue = mFieldColorSource.parse(validColorSource);
+ assertThat(parsedValue).isEqualTo(validColorSource);
+ }
+
+ @Test
+ public void serialize_validColorSource_returnsSameString() {
+ String validColorSource = "lock_wallpaper";
+ String serializedValue = mFieldColorSource.serialize(validColorSource);
+ assertThat(serializedValue).isEqualTo(validColorSource);
+ }
+
+ @Test
+ public void validate_preset_returnsTrue() {
+ assertThat(mFieldColorSource.validate("preset")).isTrue();
+ }
+
+ @Test
+ public void validate_homeWallpaper_returnsTrue() {
+ assertThat(mFieldColorSource.validate("home_wallpaper")).isTrue();
+ }
+
+ @Test
+ public void validate_lockWallpaper_returnsTrue() {
+ assertThat(mFieldColorSource.validate("lock_wallpaper")).isTrue();
+ }
+
+ @Test
+ public void validate_invalidColorSource_returnsFalse() {
+ assertThat(mFieldColorSource.validate("invalid")).isFalse();
+ }
+
+ @Test
+ public void getFieldType_returnsStringClass() {
+ Truth.assertThat(mFieldColorSource.getFieldType()).isEqualTo(String.class);
+ }
+
+ @Test
+ public void getJsonType_returnsStringClass() {
+ Truth.assertThat(mFieldColorSource.getJsonType()).isEqualTo(String.class);
+ }
+
+ @Test
+ public void get_returnsDefaultValue() {
+ Truth.assertThat(mFieldColorSource.getDefaultValue()).isEqualTo(DEFAULTS.colorSource());
+ }
+}
diff --git a/services/tests/servicestests/src/com/android/server/theming/FieldColorTests.java b/services/tests/servicestests/src/com/android/server/theming/FieldColorTests.java
new file mode 100644
index 000000000000..54c4b29a5063
--- /dev/null
+++ b/services/tests/servicestests/src/com/android/server/theming/FieldColorTests.java
@@ -0,0 +1,107 @@
+/*
+ * 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 static com.google.common.truth.Truth.assertThat;
+
+import android.content.theming.FieldColor;
+import android.content.theming.ThemeSettings;
+import android.content.theming.ThemeSettingsUpdater;
+import android.content.theming.ThemeStyle;
+
+import com.google.common.truth.Truth;
+
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+@RunWith(JUnit4.class)
+public class FieldColorTests {
+ static final ThemeSettings DEFAULTS = new ThemeSettings(1, 0xFF123456, 0xFF654321,
+ "home_wallpaper", ThemeStyle.VIBRANT, true);
+
+ private FieldColor mFieldColor;
+
+ @Before
+ public void setup() {
+ // Default to blue
+ mFieldColor = new FieldColor("accentColor", ThemeSettingsUpdater::accentColor,
+ ThemeSettings::accentColor, DEFAULTS);
+ }
+
+ @Test
+ public void parse_validColor_returnsCorrectColor() {
+ Integer parsedValue = mFieldColor.parse("FF0000FF");
+ assertThat(parsedValue).isEqualTo(0xFF0000FF);
+ } @Test
+ public void parse_validColorLowercase_returnsCorrectColor() {
+ Integer parsedValue = mFieldColor.parse("ff0000ff");
+ assertThat(parsedValue).isEqualTo(0xFF0000FF);
+ }
+
+ @Test
+ public void parse_validColorNoAlpha_returnsCorrectColor() {
+ Integer parsedValue = mFieldColor.parse("0000ff");
+ assertThat(parsedValue).isEqualTo(0xFF0000FF);
+ }
+
+
+ @Test
+ public void parse_invalidColor_returnsNull() {
+ Integer parsedValue = mFieldColor.parse("invalid");
+ assertThat(parsedValue).isNull();
+ }
+
+ @Test
+ public void parse_nullColor_returnsNull() {
+ Integer parsedValue = mFieldColor.parse(null);
+ assertThat(parsedValue).isNull();
+ }
+
+ @Test
+ public void serialize_validColor_returnsCorrectString() {
+ String serializedValue = mFieldColor.serialize(0xFFFF0000); // Red
+ assertThat(serializedValue).isEqualTo("ffff0000");
+ }
+
+ @Test
+ public void serialize_zeroColor_returnsZeroString() {
+ String serializedValue = mFieldColor.serialize(0);
+ assertThat(serializedValue).isEqualTo("0");
+ }
+
+ @Test
+ public void validate_validColor_returnsTrue() {
+ assertThat(mFieldColor.validate(0xFF00FF00)).isTrue(); // Green
+ }
+
+ @Test
+ public void getFieldType_returnsIntegerClass() {
+ Truth.assertThat(mFieldColor.getFieldType()).isEqualTo(Integer.class);
+ }
+
+ @Test
+ public void getJsonType_returnsStringClass() {
+ Truth.assertThat(mFieldColor.getJsonType()).isEqualTo(String.class);
+ }
+
+ @Test
+ public void get_returnsDefaultValue() {
+ Truth.assertThat(mFieldColor.getDefaultValue()).isEqualTo(DEFAULTS.accentColor());
+ }
+}
diff --git a/services/tests/servicestests/src/com/android/server/theming/FieldThemeStyleTests.java b/services/tests/servicestests/src/com/android/server/theming/FieldThemeStyleTests.java
new file mode 100644
index 000000000000..09d71292fcf6
--- /dev/null
+++ b/services/tests/servicestests/src/com/android/server/theming/FieldThemeStyleTests.java
@@ -0,0 +1,86 @@
+/*
+ * 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 static com.google.common.truth.Truth.assertThat;
+
+import android.content.theming.FieldThemeStyle;
+import android.content.theming.ThemeSettings;
+import android.content.theming.ThemeSettingsUpdater;
+import android.content.theming.ThemeStyle;
+
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+@RunWith(JUnit4.class)
+public class FieldThemeStyleTests {
+ static final ThemeSettings DEFAULTS = new ThemeSettings(1, 0xFF123456, 0xFF654321,
+ "home_wallpaper", ThemeStyle.VIBRANT, true);
+
+ private FieldThemeStyle mFieldThemeStyle;
+
+ @Before
+ public void setup() {
+ mFieldThemeStyle = new FieldThemeStyle("themeStyle", ThemeSettingsUpdater::themeStyle,
+ ThemeSettings::themeStyle, DEFAULTS);
+ }
+
+ @Test
+ public void parse_validThemeStyle_returnsCorrectStyle() {
+ Integer parsedValue = mFieldThemeStyle.parse("EXPRESSIVE");
+ assertThat(parsedValue).isEqualTo(ThemeStyle.EXPRESSIVE);
+ }
+
+ @Test
+ public void parse_invalidThemeStyle_returnsNull() {
+ Integer parsedValue = mFieldThemeStyle.parse("INVALID");
+ assertThat(parsedValue).isNull();
+ }
+
+ @Test
+ public void serialize_validThemeStyle_returnsCorrectString() {
+ String serializedValue = mFieldThemeStyle.serialize(ThemeStyle.SPRITZ);
+ assertThat(serializedValue).isEqualTo("SPRITZ");
+ }
+
+ @Test
+ public void validate_validThemeStyle_returnsTrue() {
+ assertThat(mFieldThemeStyle.validate(ThemeStyle.TONAL_SPOT)).isTrue();
+ }
+
+ @Test
+ public void validate_invalidThemeStyle_returnsFalse() {
+ assertThat(mFieldThemeStyle.validate(-1)).isFalse();
+ }
+
+ @Test
+ public void getFieldType_returnsIntegerClass() {
+ assertThat(mFieldThemeStyle.getFieldType()).isEqualTo(Integer.class);
+ }
+
+ @Test
+ public void getJsonType_returnsStringClass() {
+ assertThat(mFieldThemeStyle.getJsonType()).isEqualTo(String.class);
+ }
+
+ @Test
+ public void get_returnsDefaultValue() {
+ assertThat(mFieldThemeStyle.getDefaultValue()).isEqualTo(DEFAULTS.themeStyle());
+ }
+}
diff --git a/services/tests/servicestests/src/com/android/server/theming/TEST_MAPPING b/services/tests/servicestests/src/com/android/server/theming/TEST_MAPPING
new file mode 100644
index 000000000000..d8d73444f6ce
--- /dev/null
+++ b/services/tests/servicestests/src/com/android/server/theming/TEST_MAPPING
@@ -0,0 +1,7 @@
+{
+ "presubmit": [
+ {
+ "name": "FrameworksServicesTests_theme"
+ }
+ ]
+} \ No newline at end of file
diff --git a/services/tests/servicestests/src/com/android/server/theming/ThemeSettingsFieldTests.java b/services/tests/servicestests/src/com/android/server/theming/ThemeSettingsFieldTests.java
new file mode 100644
index 000000000000..0dc267a8059f
--- /dev/null
+++ b/services/tests/servicestests/src/com/android/server/theming/ThemeSettingsFieldTests.java
@@ -0,0 +1,169 @@
+/*
+ * 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 static com.google.common.truth.Truth.assertThat;
+
+import android.content.theming.ThemeSettings;
+import android.content.theming.ThemeSettingsField;
+import android.content.theming.ThemeSettingsUpdater;
+import android.content.theming.ThemeStyle;
+
+import org.json.JSONException;
+import org.json.JSONObject;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+import java.util.function.BiConsumer;
+import java.util.function.Function;
+
+@RunWith(JUnit4.class)
+public class ThemeSettingsFieldTests {
+ static final ThemeSettings DEFAULTS = new ThemeSettings(1, 0xFF123456, 0xFF654321,
+ "home_wallpaper", ThemeStyle.VIBRANT, true);
+ private ThemeSettingsUpdater mUpdater;
+
+ @Before
+ public void setup() {
+ mUpdater = ThemeSettings.updater();
+ }
+
+ @Test
+ public void testFromJSON_validValue_setsValue() throws Exception {
+ TestThemeSettingsFieldInteger field = getSampleField();
+
+ JSONObject json = new JSONObject();
+ json.put("testKey", "5");
+
+ field.fromJSON(json, mUpdater);
+
+ assertThat(mUpdater.getColorIndex()).isEqualTo(5);
+ }
+
+ @Test
+ public void testFromJSON_nullValue_setsDefault() throws Exception {
+ TestThemeSettingsFieldInteger field = getSampleField();
+
+ JSONObject json = new JSONObject();
+ json.put("testKey",
+ JSONObject.NULL); // Using JSONObject.NULL is how you should indicate null in JSON
+
+ field.fromJSON(json, mUpdater);
+
+ assertThat(mUpdater.getColorIndex()).isEqualTo(DEFAULTS.colorIndex());
+ }
+
+ @Test
+ public void testFromJSON_invalidValue_setsDefault() throws Exception {
+ TestThemeSettingsFieldInteger field = getSampleField();
+
+ JSONObject json = new JSONObject();
+ json.put("testKey", "abc"); // Invalid value
+
+ field.fromJSON(json, mUpdater);
+
+ assertThat(mUpdater.getColorIndex()).isEqualTo(DEFAULTS.colorIndex());
+ }
+
+ @Test
+ public void testToJSON_validValue_writesValue() throws JSONException {
+ TestThemeSettingsFieldInteger field = getSampleField();
+ ThemeSettings settings = new ThemeSettings(10, 0xFF123456, 0xFF654321, "home_wallpaper",
+ 0, true);
+ JSONObject json = new JSONObject();
+
+ field.toJSON(settings, json);
+
+ assertThat(json.getString("testKey")).isEqualTo("10");
+ }
+
+ @Test
+ public void testDefaultValue_returnsGetDefault() {
+ TestThemeSettingsFieldInteger field = getSampleField();
+
+ assertThat(field.getDefaultValue()).isEqualTo(DEFAULTS.colorIndex());
+ }
+
+ @Test
+ public void test_String_validValue_returnsParsedValue() throws JSONException {
+ TestThemeSettingsFieldInteger field = getSampleField();
+
+ JSONObject json = new JSONObject();
+ json.put("testKey", "123");
+
+ field.fromJSON(json, mUpdater);
+
+ assertThat(mUpdater.getColorIndex()).isEqualTo(123);
+ }
+
+ @Test
+ public void test_String_invalidValue_returnsDefaultValue() throws JSONException {
+ TestThemeSettingsFieldInteger field = getSampleField();
+
+ JSONObject json = new JSONObject();
+ // values < 0 are invalid
+ json.put("testKey", "-123");
+ field.fromJSON(json, mUpdater);
+
+ assertThat(mUpdater.getColorIndex()).isEqualTo(DEFAULTS.colorIndex());
+ }
+
+ private TestThemeSettingsFieldInteger getSampleField() {
+ return new TestThemeSettingsFieldInteger("testKey", ThemeSettingsUpdater::colorIndex,
+ ThemeSettings::colorIndex, DEFAULTS);
+ }
+
+
+ // Helper class for testing
+ private static class TestThemeSettingsFieldInteger extends ThemeSettingsField<Integer, String> {
+ TestThemeSettingsFieldInteger(String key, BiConsumer<ThemeSettingsUpdater, Integer> setter,
+ Function<ThemeSettings, Integer> getter, ThemeSettings defaults) {
+ super(key, setter, getter, defaults);
+ }
+
+ @Override
+ public Integer parse(String primitive) {
+ try {
+ return Integer.parseInt(primitive);
+ } catch (NumberFormatException e) {
+ return null;
+ }
+ }
+
+ @Override
+ public String serialize(Integer value) throws RuntimeException {
+ return value.toString();
+ }
+
+ @Override
+ public boolean validate(Integer value) {
+ return value > 0;
+ }
+
+ @Override
+ public Class<Integer> getFieldType() {
+ return Integer.class;
+ }
+
+ @Override
+ public Class<String> getJsonType() {
+ return String.class;
+ }
+ }
+}
diff --git a/services/tests/servicestests/src/com/android/server/theming/ThemeSettingsManagerTests.java b/services/tests/servicestests/src/com/android/server/theming/ThemeSettingsManagerTests.java
new file mode 100644
index 000000000000..44f8c73dec84
--- /dev/null
+++ b/services/tests/servicestests/src/com/android/server/theming/ThemeSettingsManagerTests.java
@@ -0,0 +1,114 @@
+/*
+ * 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 static androidx.test.platform.app.InstrumentationRegistry.getInstrumentation;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import android.content.ContentResolver;
+import android.content.theming.ThemeSettings;
+import android.content.theming.ThemeStyle;
+import android.provider.Settings;
+import android.testing.TestableContext;
+
+import org.json.JSONObject;
+import org.junit.Before;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.rules.TemporaryFolder;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+@RunWith(JUnit4.class)
+public class ThemeSettingsManagerTests {
+ private final int mUserId = 0;
+ public static final ThemeSettings DEFAULTS = new ThemeSettings(
+ /* colorIndex= */ 1,
+ /* systemPalette= */ 0xFF123456,
+ /* accentColor= */ 0xFF654321,
+ /* colorSource= */ "home_wallpaper",
+ /* themeStyle= */ ThemeStyle.VIBRANT,
+ /* colorBoth= */ true);
+
+ @Rule
+ public final TestableContext mContext = new TestableContext(
+ getInstrumentation().getTargetContext(), null);
+
+ @Rule
+ public TemporaryFolder temporaryFolder = new TemporaryFolder();
+ private ContentResolver mContentResolver;
+
+
+ @Before
+ public void setup() {
+ mContentResolver = mContext.getContentResolver();
+ }
+
+ @Test
+ public void loadSettings_emptyJSON_returnsDefault() {
+ Settings.Secure.putStringForUser(mContentResolver,
+ Settings.Secure.THEME_CUSTOMIZATION_OVERLAY_PACKAGES, "{}", mUserId);
+
+ ThemeSettingsManager manager = new ThemeSettingsManager(DEFAULTS);
+ ThemeSettings settings = manager.loadSettings(mUserId, mContentResolver);
+
+ assertThat(settings.colorIndex()).isEqualTo(DEFAULTS.colorIndex());
+ assertThat(settings.systemPalette()).isEqualTo(DEFAULTS.systemPalette());
+ assertThat(settings.accentColor()).isEqualTo(DEFAULTS.accentColor());
+ assertThat(settings.colorSource()).isEqualTo(DEFAULTS.colorSource());
+ assertThat(settings.themeStyle()).isEqualTo(DEFAULTS.themeStyle());
+ assertThat(settings.colorBoth()).isEqualTo(DEFAULTS.colorBoth());
+ }
+
+ @Test
+ public void replaceSettings_writesSettingsToProvider() throws Exception {
+
+ ThemeSettingsManager manager = new ThemeSettingsManager(DEFAULTS);
+
+ ThemeSettings newSettings = new ThemeSettings(3, 0xFF112233, 0xFF332211, "preset",
+ ThemeStyle.MONOCHROMATIC, false);
+ manager.replaceSettings(mUserId, mContentResolver, newSettings);
+
+ String settingsString = Settings.Secure.getStringForUser(mContentResolver,
+ Settings.Secure.THEME_CUSTOMIZATION_OVERLAY_PACKAGES, mUserId);
+ JSONObject settingsJson = new JSONObject(settingsString);
+ assertThat(settingsJson.getString("android.theme.customization.color_index")).isEqualTo(
+ "3");
+ assertThat(settingsJson.getString("android.theme.customization.system_palette"))
+ .isEqualTo("ff112233");
+ assertThat(settingsJson.getString("android.theme.customization.accent_color"))
+ .isEqualTo("ff332211");
+ assertThat(settingsJson.getString("android.theme.customization.color_source"))
+ .isEqualTo("preset");
+ assertThat(settingsJson.getString("android.theme.customization.theme_style"))
+ .isEqualTo("MONOCHROMATIC");
+ assertThat(settingsJson.getString("android.theme.customization.color_both")).isEqualTo("0");
+ }
+
+ @Test
+ public void updatesSettings_writesSettingsToProvider() throws Exception {
+ ThemeSettingsManager manager = new ThemeSettingsManager(DEFAULTS);
+
+ ThemeSettings newSettings = new ThemeSettings(3, 0xFF112233, 0xFF332211, "preset",
+ ThemeStyle.MONOCHROMATIC, false);
+ manager.updateSettings(mUserId, mContentResolver, newSettings);
+
+ ThemeSettings loadedSettings = manager.loadSettings(mUserId, mContentResolver);
+ assertThat(loadedSettings.equals(newSettings)).isTrue();
+ }
+}
diff --git a/services/tests/servicestests/src/com/android/server/theming/ThemeSettingsTests.java b/services/tests/servicestests/src/com/android/server/theming/ThemeSettingsTests.java
new file mode 100644
index 000000000000..c417a4b571cb
--- /dev/null
+++ b/services/tests/servicestests/src/com/android/server/theming/ThemeSettingsTests.java
@@ -0,0 +1,108 @@
+/*
+ * 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 static com.google.common.truth.Truth.assertThat;
+
+import static org.junit.Assert.assertNull;
+
+import android.content.theming.ThemeSettings;
+import android.content.theming.ThemeSettingsUpdater;
+import android.content.theming.ThemeStyle;
+import android.os.Parcel;
+
+import androidx.test.runner.AndroidJUnit4;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+@RunWith(AndroidJUnit4.class)
+public class ThemeSettingsTests {
+ public static final ThemeSettings DEFAULTS = new ThemeSettings(
+ /* colorIndex= */ 1,
+ /* systemPalette= */ 0xFF123456,
+ /* accentColor= */ 0xFF654321,
+ /* colorSource= */ "home_wallpaper",
+ /* themeStyle= */ ThemeStyle.VIBRANT,
+ /* colorBoth= */ true);
+
+ /**
+ * Test that the updater correctly sets all fields when they are provided.
+ */
+ @Test
+ public void testUpdater_allFieldsSet() {
+ ThemeSettingsUpdater updater = ThemeSettings.updater()
+ .colorIndex(2)
+ .systemPalette(0xFFFF0000)
+ .accentColor(0xFF00FF00)
+ .colorSource("preset")
+ .themeStyle(ThemeStyle.MONOCHROMATIC)
+ .colorBoth(false);
+
+ ThemeSettings settings = updater.toThemeSettings(DEFAULTS);
+
+ assertThat(settings.colorIndex()).isEqualTo(2);
+ assertThat(settings.systemPalette()).isEqualTo(0xFFFF0000);
+ assertThat(settings.accentColor()).isEqualTo(0xFF00FF00);
+ assertThat(settings.colorSource()).isEqualTo("preset");
+ assertThat(settings.themeStyle()).isEqualTo(ThemeStyle.MONOCHROMATIC);
+ assertThat(settings.colorBoth()).isEqualTo(false);
+ }
+
+ /**
+ * Test that the updater uses null values when no fields are explicitly set.
+ */
+ @Test
+ public void testUpdater_noFieldsSet() {
+ ThemeSettingsUpdater updater = ThemeSettings.updater();
+
+ assertNull(updater.getColorIndex());
+ assertNull(updater.getSystemPalette());
+ assertNull(updater.getAccentColor());
+ assertNull(updater.getColorSource());
+ assertNull(updater.getThemeStyle());
+ assertNull(updater.getColorBoth());
+ }
+
+ /**
+ * Test that the ThemeSettings object can be correctly parceled and restored.
+ */
+ @Test
+ public void testParcel_roundTrip() {
+ ThemeSettingsUpdater updater = ThemeSettings.updater()
+ .colorIndex(2)
+ .systemPalette(0xFFFF0000)
+ .accentColor(0xFF00FF00)
+ .colorSource("preset")
+ .themeStyle(ThemeStyle.MONOCHROMATIC)
+ .colorBoth(false);
+
+ ThemeSettings settings = updater.toThemeSettings(DEFAULTS);
+
+ Parcel parcel = Parcel.obtain();
+ settings.writeToParcel(parcel, 0);
+ parcel.setDataPosition(0);
+ ThemeSettings fromParcel = ThemeSettings.CREATOR.createFromParcel(parcel);
+
+ assertThat(settings.colorIndex()).isEqualTo(fromParcel.colorIndex());
+ assertThat(settings.systemPalette()).isEqualTo(fromParcel.systemPalette());
+ assertThat(settings.accentColor()).isEqualTo(fromParcel.accentColor());
+ assertThat(settings.colorSource()).isEqualTo(fromParcel.colorSource());
+ assertThat(settings.themeStyle()).isEqualTo(fromParcel.themeStyle());
+ assertThat(settings.colorBoth()).isEqualTo(fromParcel.colorBoth());
+ }
+}
diff --git a/services/tests/servicestests/src/com/android/server/theming/ThemeSettingsUpdaterTests.java b/services/tests/servicestests/src/com/android/server/theming/ThemeSettingsUpdaterTests.java
new file mode 100644
index 000000000000..7ce32da7b713
--- /dev/null
+++ b/services/tests/servicestests/src/com/android/server/theming/ThemeSettingsUpdaterTests.java
@@ -0,0 +1,154 @@
+/*
+ * 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 static com.google.common.truth.Truth.assertThat;
+
+import android.content.theming.ThemeSettings;
+import android.content.theming.ThemeSettingsUpdater;
+import android.content.theming.ThemeStyle;
+import android.os.Parcel;
+
+import androidx.test.runner.AndroidJUnit4;
+
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+@RunWith(AndroidJUnit4.class)
+public class ThemeSettingsUpdaterTests {
+ public static final ThemeSettings DEFAULTS = new ThemeSettings(
+ /* colorIndex= */ 1,
+ /* systemPalette= */ 0xFF123456,
+ /* accentColor= */ 0xFF654321,
+ /* colorSource= */ "home_wallpaper",
+ /* themeStyle= */ ThemeStyle.VIBRANT,
+ /* colorBoth= */ true);
+ private ThemeSettingsUpdater mUpdater;
+
+ @Before
+ public void setUp() {
+ mUpdater = ThemeSettings.updater();
+ }
+
+ @Test
+ public void testSetAndGetColorIndex() {
+ mUpdater.colorIndex(5);
+ assertThat(mUpdater.getColorIndex()).isEqualTo(5);
+ }
+
+ @Test
+ public void testSetAndGetSystemPalette() {
+ mUpdater.systemPalette(0xFFABCDEF);
+ assertThat(mUpdater.getSystemPalette()).isEqualTo(0xFFABCDEF);
+ }
+
+ @Test
+ public void testSetAndGetAccentColor() {
+ mUpdater.accentColor(0xFFFEDCBA);
+ assertThat(mUpdater.getAccentColor()).isEqualTo(0xFFFEDCBA);
+ }
+
+ @Test
+ public void testSetAndGetColorSource() {
+ mUpdater.colorSource("lock_wallpaper");
+ assertThat(mUpdater.getColorSource()).isEqualTo("lock_wallpaper");
+ }
+
+ @Test
+ public void testSetAndGetThemeStyle() {
+ mUpdater.themeStyle(ThemeStyle.EXPRESSIVE);
+ assertThat(mUpdater.getThemeStyle()).isEqualTo(ThemeStyle.EXPRESSIVE);
+ }
+
+ @Test
+ public void testSetAndGetColorBoth() {
+ mUpdater.colorBoth(false);
+ assertThat(mUpdater.getColorBoth()).isFalse();
+ }
+
+
+ @Test
+ public void testToThemeSettings_allFieldsSet() {
+ mUpdater.colorIndex(5)
+ .systemPalette(0xFFABCDEF)
+ .accentColor(0xFFFEDCBA)
+ .colorSource("lock_wallpaper")
+ .themeStyle(ThemeStyle.EXPRESSIVE)
+ .colorBoth(false);
+ ThemeSettings settings = mUpdater.toThemeSettings(DEFAULTS);
+
+ assertThat(settings.colorIndex()).isEqualTo(5);
+ assertThat(settings.systemPalette()).isEqualTo(0xFFABCDEF);
+ assertThat(settings.accentColor()).isEqualTo(0xFFFEDCBA);
+ assertThat(settings.colorSource()).isEqualTo("lock_wallpaper");
+ assertThat(settings.themeStyle()).isEqualTo(ThemeStyle.EXPRESSIVE);
+ assertThat(settings.colorBoth()).isFalse();
+ }
+
+ @Test
+ public void testToThemeSettings_someFieldsNotSet_usesDefaults() {
+ mUpdater.colorIndex(5)
+ .systemPalette(0xFFABCDEF);
+
+ ThemeSettings settings = mUpdater.toThemeSettings(DEFAULTS);
+
+ assertThat(settings.colorIndex()).isEqualTo(5);
+ assertThat(settings.systemPalette()).isEqualTo(0xFFABCDEF);
+ assertThat(settings.accentColor()).isEqualTo(DEFAULTS.accentColor());
+ assertThat(settings.colorSource()).isEqualTo(DEFAULTS.colorSource());
+ assertThat(settings.themeStyle()).isEqualTo(DEFAULTS.themeStyle());
+ assertThat(settings.colorBoth()).isEqualTo(DEFAULTS.colorBoth());
+ }
+
+ @Test
+ public void testParcel_roundTrip_allFieldsSet() {
+ mUpdater.colorIndex(5)
+ .systemPalette(0xFFABCDEF)
+ .accentColor(0xFFFEDCBA)
+ .colorSource("lock_wallpaper")
+ .themeStyle(ThemeStyle.EXPRESSIVE)
+ .colorBoth(false);
+
+ Parcel parcel = Parcel.obtain();
+ mUpdater.writeToParcel(parcel, 0);
+ parcel.setDataPosition(0);
+ ThemeSettingsUpdater fromParcel = ThemeSettingsUpdater.CREATOR.createFromParcel(parcel);
+
+ assertThat(fromParcel.getColorIndex()).isEqualTo(5);
+ assertThat(fromParcel.getSystemPalette()).isEqualTo(0xFFABCDEF);
+ assertThat(fromParcel.getAccentColor()).isEqualTo(0xFFFEDCBA);
+ assertThat(fromParcel.getColorSource()).isEqualTo("lock_wallpaper");
+ assertThat(fromParcel.getThemeStyle()).isEqualTo(ThemeStyle.EXPRESSIVE);
+ assertThat(fromParcel.getColorBoth()).isFalse();
+ }
+
+ @Test
+ public void testParcel_roundTrip_noFieldsSet() {
+ Parcel parcel = Parcel.obtain();
+ mUpdater.writeToParcel(parcel, 0);
+ parcel.setDataPosition(0);
+ ThemeSettingsUpdater fromParcel = ThemeSettingsUpdater.CREATOR.createFromParcel(parcel);
+
+ assertThat(fromParcel.getColorIndex()).isNull();
+ assertThat(fromParcel.getSystemPalette()).isNull();
+ assertThat(fromParcel.getAccentColor()).isNull();
+ assertThat(fromParcel.getColorSource()).isNull();
+ assertThat(fromParcel.getThemeStyle()).isNull();
+ assertThat(fromParcel.getColorBoth()).isNull();
+ }
+}