Merge branch 'any-colour-theme'

Stephen Paul Weber created

* any-colour-theme:
  Any colour theme

Change summary

build.gradle                                                             |   1 
src/cheogram/java/com/cheogram/android/ColorResourcesLoaderCreator.java  |  74 
src/cheogram/java/com/cheogram/android/ColorResourcesTableCreator.java   | 621 
src/cheogram/res/values/colors.xml                                       |   7 
src/cheogram/res/values/strings.xml                                      |   1 
src/cheogram/res/values/themes.xml                                       |  76 
src/main/java/eu/siacs/conversations/services/XmppConnectionService.java |   1 
src/main/java/eu/siacs/conversations/ui/ConversationsActivity.java       |   3 
src/main/java/eu/siacs/conversations/ui/SettingsActivity.java            |  18 
src/main/java/eu/siacs/conversations/ui/XmppActivity.java                |   3 
src/main/java/eu/siacs/conversations/utils/ThemeHelper.java              |  27 
src/main/res/values-v30/theme-settings.xml                               |  22 
src/main/res/values/colors.xml                                           |   2 
src/main/res/xml/preferences.xml                                         |  42 
14 files changed, 881 insertions(+), 17 deletions(-)

Detailed changes

build.gradle 🔗

@@ -95,6 +95,7 @@ dependencies {
     implementation 'io.github.nishkarsh:android-permissions:2.1.6'
     implementation 'androidx.recyclerview:recyclerview:1.1.0'
     implementation 'androidx.documentfile:documentfile:1.0.1'
+    implementation 'com.github.martin-stone:hsv-alpha-color-picker-android:2.4.2'
     implementation 'com.github.ipld:java-cid:v1.3.1'
     implementation 'com.splitwise:tokenautocomplete:3.0.2'
     implementation 'me.saket:better-link-movement-method:2.2.0'

src/cheogram/java/com/cheogram/android/ColorResourcesLoaderCreator.java 🔗

@@ -0,0 +1,74 @@
+/*
+ * Copyright (C) 2022 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.cheogram.android;
+
+import android.content.Context;
+import android.content.res.loader.ResourcesLoader;
+import android.content.res.loader.ResourcesProvider;
+import android.os.Build.VERSION_CODES;
+import android.os.ParcelFileDescriptor;
+import android.system.Os;
+import android.util.Log;
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.annotation.RequiresApi;
+import java.io.FileDescriptor;
+import java.io.FileOutputStream;
+import java.io.OutputStream;
+import java.util.Map;
+
+/** This class creates a Resources Table at runtime and helps replace color Resources on the fly. */
+public final class ColorResourcesLoaderCreator {
+
+  private ColorResourcesLoaderCreator() {}
+
+  private static final String TAG = ColorResourcesLoaderCreator.class.getSimpleName();
+
+  @Nullable
+  public static ResourcesLoader create(
+      @NonNull Context context, @NonNull Map<Integer, Integer> colorMapping) {
+    try {
+      byte[] contentBytes = ColorResourcesTableCreator.create(context, colorMapping);
+      Log.i(TAG, "Table created, length: " + contentBytes.length);
+      if (contentBytes.length == 0) {
+        return null;
+      }
+      FileDescriptor arscFile = null;
+      try {
+        arscFile = Os.memfd_create("temp.arsc", /* flags= */ 0);
+        // Note: This must not be closed through the OutputStream.
+        try (OutputStream pipeWriter = new FileOutputStream(arscFile)) {
+          pipeWriter.write(contentBytes);
+
+          try (ParcelFileDescriptor pfd = ParcelFileDescriptor.dup(arscFile)) {
+            ResourcesLoader colorsLoader = new ResourcesLoader();
+            colorsLoader.addProvider(
+                ResourcesProvider.loadFromTable(pfd, /* assetsProvider= */ null));
+            return colorsLoader;
+          }
+        }
+      } finally {
+        if (arscFile != null) {
+          Os.close(arscFile);
+        }
+      }
+    } catch (Exception e) {
+      Log.e(TAG, "Failed to create the ColorResourcesTableCreator.", e);
+    }
+    return null;
+  }
+}

src/cheogram/java/com/cheogram/android/ColorResourcesTableCreator.java 🔗

@@ -0,0 +1,621 @@
+/*
+ * Copyright (C) 2021 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.cheogram.android;
+
+import android.content.Context;
+import android.util.Pair;
+import androidx.annotation.ColorInt;
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.nio.charset.Charset;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.Comparator;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Map.Entry;
+import java.util.Set;
+
+/**
+ * This class consists of definitions of resource data structures and helps creates a Color
+ * Resources Table on the fly. It is a Java replicate of the framework's code, see
+ * frameworks/base/include/ResourceTypes.h.
+ */
+final class ColorResourcesTableCreator {
+  private ColorResourcesTableCreator() {}
+
+  private static final short HEADER_TYPE_RES_TABLE = 0x0002;
+  private static final short HEADER_TYPE_STRING_POOL = 0x0001;
+  private static final short HEADER_TYPE_PACKAGE = 0x0200;
+  private static final short HEADER_TYPE_TYPE = 0x0201;
+  private static final short HEADER_TYPE_TYPE_SPEC = 0x0202;
+
+  private static final byte ANDROID_PACKAGE_ID = 0x01;
+  private static final byte APPLICATION_PACKAGE_ID = 0x7F;
+
+  private static final String RESOURCE_TYPE_NAME_COLOR = "color";
+
+  private static byte typeIdColor;
+
+  private static final PackageInfo ANDROID_PACKAGE_INFO =
+      new PackageInfo(ANDROID_PACKAGE_ID, "android");
+
+  private static final Comparator<ColorResource> COLOR_RESOURCE_COMPARATOR =
+      new Comparator<ColorResource>() {
+        @Override
+        public int compare(ColorResource res1, ColorResource res2) {
+          return res1.entryId - res2.entryId;
+        }
+      };
+
+  static byte[] create(Context context, Map<Integer, Integer> colorMapping) throws IOException {
+    if (colorMapping.entrySet().isEmpty()) {
+      throw new IllegalArgumentException("No color resources provided for harmonization.");
+    }
+    PackageInfo applicationPackageInfo =
+        new PackageInfo(APPLICATION_PACKAGE_ID, context.getPackageName());
+
+    Map<PackageInfo, List<ColorResource>> colorResourceMap = new HashMap<>();
+    ColorResource colorResource = null;
+    for (Map.Entry<Integer, Integer> entry : colorMapping.entrySet()) {
+      colorResource =
+          new ColorResource(
+              entry.getKey(),
+              context.getResources().getResourceName(entry.getKey()),
+              entry.getValue());
+      if (!context
+          .getResources()
+          .getResourceTypeName(entry.getKey())
+          .equals(RESOURCE_TYPE_NAME_COLOR)) {
+        throw new IllegalArgumentException(
+            "Non color resource found: name="
+                + colorResource.name
+                + ", typeId="
+                + Integer.toHexString(colorResource.typeId & 0xFF));
+      }
+      PackageInfo packageInfo;
+      if (colorResource.packageId == ANDROID_PACKAGE_ID) {
+        packageInfo = ANDROID_PACKAGE_INFO;
+      } else if (colorResource.packageId == APPLICATION_PACKAGE_ID) {
+        packageInfo = applicationPackageInfo;
+      } else {
+        throw new IllegalArgumentException(
+            "Not supported with unknown package id: " + colorResource.packageId);
+      }
+      if (!colorResourceMap.containsKey(packageInfo)) {
+        colorResourceMap.put(packageInfo, new ArrayList<ColorResource>());
+      }
+      colorResourceMap.get(packageInfo).add(colorResource);
+    }
+    // Resource Type Ids are assigned by aapt arbitrarily, for each new type the next available
+    // number is assigned and used. The type id will be the same for resources that are the same
+    // type.
+    typeIdColor = colorResource.typeId;
+    if (typeIdColor == 0) {
+      throw new IllegalArgumentException("No color resources found for harmonization.");
+    }
+    ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
+    new ResTable(colorResourceMap).writeTo(outputStream);
+    return outputStream.toByteArray();
+  }
+
+  /**
+   * A Table chunk contains: a set of Packages, where a Package is a collection of Resources and a
+   * set of strings used by the Resources contained in those Packages.
+   *
+   * <p>The set of strings are contained in a StringPool chunk. Each Package is contained in a
+   * corresponding Package chunk. The StringPool chunk immediately follows the Table chunk header.
+   * The Package chunks follow the StringPool chunk.
+   */
+  private static class ResTable {
+    private static final short HEADER_SIZE = 0x000C;
+
+    private final ResChunkHeader header;
+    private final int packageCount;
+    private final StringPoolChunk stringPool;
+    private final List<PackageChunk> packageChunks = new ArrayList<>();
+
+    ResTable(Map<PackageInfo, List<ColorResource>> colorResourceMap) {
+      packageCount = colorResourceMap.size();
+      stringPool = new StringPoolChunk();
+      for (Entry<PackageInfo, List<ColorResource>> entry : colorResourceMap.entrySet()) {
+        List<ColorResource> colorResources = entry.getValue();
+        Collections.sort(colorResources, COLOR_RESOURCE_COMPARATOR);
+        packageChunks.add(new PackageChunk(entry.getKey(), colorResources));
+      }
+      header = new ResChunkHeader(HEADER_TYPE_RES_TABLE, HEADER_SIZE, getOverallSize());
+    }
+
+    void writeTo(ByteArrayOutputStream outputStream) throws IOException {
+      header.writeTo(outputStream);
+      outputStream.write(intToByteArray(packageCount));
+      stringPool.writeTo(outputStream);
+      for (PackageChunk packageChunk : packageChunks) {
+        packageChunk.writeTo(outputStream);
+      }
+    }
+
+    private int getOverallSize() {
+      int packageChunkSize = 0;
+      for (PackageChunk packageChunk : packageChunks) {
+        packageChunkSize += packageChunk.getChunkSize();
+      }
+      return HEADER_SIZE + stringPool.getChunkSize() + packageChunkSize;
+    }
+  }
+
+  /** Header that appears at the front of every data chunk in a resource. */
+  private static class ResChunkHeader {
+    // Type identifier for this chunk.  The meaning of this value depends
+    // on the containing chunk.
+    private final short type;
+    // Size of the chunk header (in bytes).  Adding this value to
+    // the address of the chunk allows you to find its associated data
+    // (if any).
+    private final short headerSize;
+    // Total size of this chunk (in bytes).  This is the chunkSize plus
+    // the size of any data associated with the chunk.  Adding this value
+    // to the chunk allows you to completely skip its contents (including
+    // any child chunks).  If this value is the same as chunkSize, there is
+    // no data associated with the chunk.
+    private final int chunkSize;
+
+    ResChunkHeader(short type, short headerSize, int chunkSize) {
+      this.type = type;
+      this.headerSize = headerSize;
+      this.chunkSize = chunkSize;
+    }
+
+    void writeTo(ByteArrayOutputStream outputStream) throws IOException {
+      outputStream.write(shortToByteArray(type));
+      outputStream.write(shortToByteArray(headerSize));
+      outputStream.write(intToByteArray(chunkSize));
+    }
+  }
+
+  /**
+   * Immediately following the Table header is a StringPool chunk. It consists of StringPool chunk
+   * header and StringPool chunk body.
+   */
+  private static class StringPoolChunk {
+    private static final short HEADER_SIZE = 0x001C;
+    private static final int FLAG_UTF8 = 0x00000100;
+    private static final int STYLED_SPAN_LIST_END = 0xFFFFFFFF;
+
+    private final ResChunkHeader header;
+    private final int stringCount;
+    private final int styledSpanCount;
+    private final int stringsStart;
+    private final int styledSpansStart;
+    private final List<Integer> stringIndex = new ArrayList<>();
+    private final List<Integer> styledSpanIndex = new ArrayList<>();
+    private final List<byte[]> strings = new ArrayList<>();
+    private final List<List<StringStyledSpan>> styledSpans = new ArrayList<>();
+
+    private final boolean utf8Encode;
+    private final int stringsPaddingSize;
+    private final int chunkSize;
+
+    StringPoolChunk(String... rawStrings) {
+      this(false, rawStrings);
+    }
+
+    StringPoolChunk(boolean utf8, String... rawStrings) {
+      utf8Encode = utf8;
+      int stringOffset = 0;
+      for (String string : rawStrings) {
+        Pair<byte[], List<StringStyledSpan>> processedString = processString(string);
+        stringIndex.add(stringOffset);
+        stringOffset += processedString.first.length;
+        strings.add(processedString.first);
+        styledSpans.add(processedString.second);
+      }
+      int styledSpanOffset = 0;
+      for (List<StringStyledSpan> styledSpanList : styledSpans) {
+        for (StringStyledSpan styledSpan : styledSpanList) {
+          stringIndex.add(stringOffset);
+          stringOffset += styledSpan.styleString.length;
+          strings.add(styledSpan.styleString);
+        }
+        styledSpanIndex.add(styledSpanOffset);
+        // Each span occupies 3 int32, plus one end mark per chunk
+        styledSpanOffset += styledSpanList.size() * 12 + 4;
+      }
+
+      // All chunk size needs to be a multiple of 4
+      int stringOffsetResidue = stringOffset % 4;
+      stringsPaddingSize = stringOffsetResidue == 0 ? 0 : 4 - stringOffsetResidue;
+      stringCount = strings.size();
+      styledSpanCount = strings.size() - rawStrings.length;
+
+      boolean hasStyledSpans = strings.size() - rawStrings.length > 0;
+      if (!hasStyledSpans) {
+        // No styled spans, clear relevant data
+        styledSpanIndex.clear();
+        styledSpans.clear();
+      }
+
+      // Int32 per index
+      stringsStart =
+          HEADER_SIZE
+              + stringCount * 4 // String index
+              + styledSpanIndex.size() * 4; // Styled span index
+      int stringsSize = stringOffset + stringsPaddingSize;
+      styledSpansStart = hasStyledSpans ? stringsStart + stringsSize : 0;
+      chunkSize = stringsStart + stringsSize + (hasStyledSpans ? styledSpanOffset : 0);
+      header = new ResChunkHeader(HEADER_TYPE_STRING_POOL, HEADER_SIZE, chunkSize);
+    }
+
+    void writeTo(ByteArrayOutputStream outputStream) throws IOException {
+      header.writeTo(outputStream);
+      outputStream.write(intToByteArray(stringCount));
+      outputStream.write(intToByteArray(styledSpanCount));
+      outputStream.write(intToByteArray(utf8Encode ? FLAG_UTF8 : 0));
+      outputStream.write(intToByteArray(stringsStart));
+      outputStream.write(intToByteArray(styledSpansStart));
+      for (Integer index : stringIndex) {
+        outputStream.write(intToByteArray(index));
+      }
+      for (Integer index : styledSpanIndex) {
+        outputStream.write(intToByteArray(index));
+      }
+      for (byte[] string : strings) {
+        outputStream.write(string);
+      }
+      if (stringsPaddingSize > 0) {
+        outputStream.write(new byte[stringsPaddingSize]);
+      }
+      for (List<StringStyledSpan> styledSpanList : styledSpans) {
+        for (StringStyledSpan styledSpan : styledSpanList) {
+          styledSpan.writeTo(outputStream);
+        }
+        outputStream.write(intToByteArray(STYLED_SPAN_LIST_END));
+      }
+    }
+
+    int getChunkSize() {
+      return chunkSize;
+    }
+
+    private Pair<byte[], List<StringStyledSpan>> processString(String rawString) {
+      // Ignore styled spans, won't be used in our scenario.
+      return new Pair<>(
+          utf8Encode ? stringToByteArrayUtf8(rawString) : stringToByteArray(rawString),
+          Collections.<StringStyledSpan>emptyList());
+    }
+  }
+
+  /** This structure defines a span of style information associated with a string in the pool. */
+  private static class StringStyledSpan {
+
+    private byte[] styleString;
+    private int nameReference;
+    private int firstCharacterIndex;
+    private int lastCharacterIndex;
+
+    void writeTo(ByteArrayOutputStream outputStream) throws IOException {
+      outputStream.write(intToByteArray(nameReference));
+      outputStream.write(intToByteArray(firstCharacterIndex));
+      outputStream.write(intToByteArray(lastCharacterIndex));
+    }
+  }
+
+  /**
+   * A Package chunk contains a set of Resources and a set of strings associated with those
+   * Resources. The Resources are grouped by type. For each of set of Resources of a given type that
+   * the Package chunk contains there is a TypeSpec chunk and one or more Type chunks.
+   *
+   * <p>The strings are stored in two StringPool chunks: the typeStrings StringPool chunk which
+   * contains the names of the types of the Resources defined in the Package; the keyStrings
+   * StringPool chunk which contains the names (keys) of the Resources defined in the Package.
+   */
+  private static class PackageChunk {
+    private static final short HEADER_SIZE = 0x0120;
+    private static final int PACKAGE_NAME_MAX_LENGTH = 128;
+
+    private final ResChunkHeader header;
+    private final PackageInfo packageInfo;
+    private final StringPoolChunk typeStrings;
+    private final StringPoolChunk keyStrings;
+    private final TypeSpecChunk typeSpecChunk;
+
+    PackageChunk(PackageInfo packageInfo, List<ColorResource> colorResources) {
+      this.packageInfo = packageInfo;
+
+      // Placeholder String type, since only XML color resources will be replaced at runtime.
+      typeStrings = new StringPoolChunk(false, "?1", "?2", "?3", "?4", "?5", "color");
+      String[] keys = new String[colorResources.size()];
+      for (int i = 0; i < colorResources.size(); i++) {
+        keys[i] = colorResources.get(i).name;
+      }
+      keyStrings = new StringPoolChunk(true, keys);
+      typeSpecChunk = new TypeSpecChunk(colorResources);
+
+      header = new ResChunkHeader(HEADER_TYPE_PACKAGE, HEADER_SIZE, getChunkSize());
+    }
+
+    void writeTo(ByteArrayOutputStream outputStream) throws IOException {
+      header.writeTo(outputStream);
+      outputStream.write(intToByteArray(packageInfo.id));
+      char[] packageName = packageInfo.name.toCharArray();
+      for (int i = 0; i < PACKAGE_NAME_MAX_LENGTH; i++) {
+        if (i < packageName.length) {
+          outputStream.write(charToByteArray(packageName[i]));
+        } else {
+          outputStream.write(charToByteArray((char) 0));
+        }
+      }
+      outputStream.write(intToByteArray(HEADER_SIZE)); // Type strings offset
+      outputStream.write(intToByteArray(0)); // Last public type
+      outputStream.write(
+          intToByteArray(HEADER_SIZE + typeStrings.getChunkSize())); // Key strings offset
+      outputStream.write(intToByteArray(0)); // Last public key
+      outputStream.write(intToByteArray(0)); // Note
+      typeStrings.writeTo(outputStream);
+      keyStrings.writeTo(outputStream);
+      typeSpecChunk.writeTo(outputStream);
+    }
+
+    int getChunkSize() {
+      return HEADER_SIZE
+          + typeStrings.getChunkSize()
+          + keyStrings.getChunkSize()
+          + typeSpecChunk.getChunkSizeWithTypeChunk();
+    }
+  }
+
+  /**
+   * A specification of the resources defined by a particular type.
+   *
+   * <p>There should be one of these chunks for each resource type.
+   *
+   * <p>This structure is followed by an array of integers providing the set of configuration change
+   * flags (ResTable_config::CONFIG_*) that have multiple resources for that configuration. In
+   * addition, the high bit is set if that resource has been made public.
+   */
+  private static class TypeSpecChunk {
+    private static final short HEADER_SIZE = 0x0010;
+    private static final int SPEC_PUBLIC = 0x40000000;
+
+    private final ResChunkHeader header;
+    private final int entryCount;
+    private final int[] entryFlags;
+    private final TypeChunk typeChunk;
+
+    TypeSpecChunk(List<ColorResource> colorResources) {
+      entryCount = colorResources.get(colorResources.size() - 1).entryId + 1;
+      Set<Short> validEntryIds = new HashSet<>();
+      for (ColorResource colorResource : colorResources) {
+        validEntryIds.add(colorResource.entryId);
+      }
+      entryFlags = new int[entryCount];
+      // All color resources in the table are marked as PUBLIC.
+      for (short entryId = 0; entryId < entryCount; entryId++) {
+        if (validEntryIds.contains(entryId)) {
+          entryFlags[entryId] = SPEC_PUBLIC;
+        }
+      }
+
+      header = new ResChunkHeader(HEADER_TYPE_TYPE_SPEC, HEADER_SIZE, getChunkSize());
+
+      typeChunk = new TypeChunk(colorResources, validEntryIds, entryCount);
+    }
+
+    void writeTo(ByteArrayOutputStream outputStream) throws IOException {
+      header.writeTo(outputStream);
+      outputStream.write(new byte[] {typeIdColor, 0x00, 0x00, 0x00});
+      outputStream.write(intToByteArray(entryCount));
+      for (int entryFlag : entryFlags) {
+        outputStream.write(intToByteArray(entryFlag));
+      }
+      typeChunk.writeTo(outputStream);
+    }
+
+    int getChunkSizeWithTypeChunk() {
+      return getChunkSize() + typeChunk.getChunkSize();
+    }
+
+    private int getChunkSize() {
+      return HEADER_SIZE + entryCount * 4; // Int32 per entry flag
+    }
+  }
+
+  /**
+   * A collection of resource entries for a particular resource data type.
+   *
+   * <p>There may be multiple of these chunks for a particular resource type, supply different
+   * configuration variations for the resource values of that type.
+   */
+  private static class TypeChunk {
+    private static final int OFFSET_NO_ENTRY = 0xFFFFFFFF;
+
+    private static final short HEADER_SIZE = 0x0054;
+    private static final byte CONFIG_SIZE = 0x40;
+
+    private final ResChunkHeader header;
+    private final int entryCount;
+    private final byte[] config = new byte[CONFIG_SIZE];
+    private final int[] offsetTable;
+    private final ResEntry[] resEntries;
+
+    TypeChunk(List<ColorResource> colorResources, Set<Short> entryIds, int entryCount) {
+      this.entryCount = entryCount;
+      this.config[0] = CONFIG_SIZE;
+
+      this.resEntries = new ResEntry[colorResources.size()];
+
+      for (int index = 0; index < colorResources.size(); index++) {
+        ColorResource colorResource = colorResources.get(index);
+        this.resEntries[index] = new ResEntry(index, colorResource.value);
+      }
+
+      this.offsetTable = new int[entryCount];
+      int currentOffset = 0;
+      for (short entryId = 0; entryId < entryCount; entryId++) {
+        if (entryIds.contains(entryId)) {
+          this.offsetTable[entryId] = currentOffset;
+          currentOffset += ResEntry.SIZE;
+        } else {
+          this.offsetTable[entryId] = OFFSET_NO_ENTRY;
+        }
+      }
+
+      this.header = new ResChunkHeader(HEADER_TYPE_TYPE, HEADER_SIZE, getChunkSize());
+    }
+
+    void writeTo(ByteArrayOutputStream outputStream) throws IOException {
+      header.writeTo(outputStream);
+      outputStream.write(new byte[] {typeIdColor, 0x00, 0x00, 0x00});
+      outputStream.write(intToByteArray(entryCount));
+      outputStream.write(intToByteArray(getEntryStart()));
+      outputStream.write(config);
+      for (int offset : offsetTable) {
+        outputStream.write(intToByteArray(offset));
+      }
+      for (ResEntry entry : resEntries) {
+        entry.writeTo(outputStream);
+      }
+    }
+
+    int getChunkSize() {
+      return getEntryStart() + resEntries.length * ResEntry.SIZE;
+    }
+
+    private int getEntryStart() {
+      return HEADER_SIZE + getOffsetTableSize();
+    }
+
+    private int getOffsetTableSize() {
+      return offsetTable.length * 4; // One int32 per entry
+    }
+  }
+
+  /**
+   * This is the beginning of information about an entry in the resource table. It holds the
+   * reference to the name of this entry, and is immediately followed by one of: A Res_value
+   * structure, if FLAG_COMPLEX is -not- set. An array of ResTable_map structures, if FLAG_COMPLEX
+   * is set. These supply a set of name/value mappings of data.
+   */
+  private static class ResEntry {
+    private static final short ENTRY_SIZE = 8;
+    private static final short FLAG_PUBLIC = 0x0002; // Always set to "Public"
+    private static final short VALUE_SIZE = 8;
+    private static final byte DATA_TYPE_AARRGGBB = 0x1C; // Type #aarrggbb
+
+    private static final int SIZE = ENTRY_SIZE + VALUE_SIZE;
+
+    private final int keyStringIndex;
+    private final int data;
+
+    ResEntry(int keyStringIndex, @ColorInt int data) {
+      this.keyStringIndex = keyStringIndex;
+      this.data = data;
+    }
+
+    void writeTo(ByteArrayOutputStream outputStream) throws IOException {
+      outputStream.write(shortToByteArray(ENTRY_SIZE));
+      outputStream.write(shortToByteArray(FLAG_PUBLIC));
+      outputStream.write(intToByteArray(keyStringIndex));
+      outputStream.write(shortToByteArray(VALUE_SIZE));
+      outputStream.write(new byte[] {0x00, DATA_TYPE_AARRGGBB});
+      outputStream.write(intToByteArray(data));
+    }
+  }
+
+  /** The basic info of a package, which consists of the id and the name of the package. */
+  static class PackageInfo {
+    private final int id;
+    private final String name;
+
+    PackageInfo(int id, String name) {
+      this.id = id;
+      this.name = name;
+    }
+  }
+
+  /**
+   * A Color Resource object, which consists of the id of the package that the resource belongs to;
+   * the name and value of the color resource.
+   */
+  static class ColorResource {
+    private final byte packageId;
+    private final byte typeId;
+    private final short entryId;
+
+    private final String name;
+
+    @ColorInt private final int value;
+
+    ColorResource(int id, String name, int value) {
+      this.name = name;
+      this.value = value;
+
+      this.entryId = (short) (id & 0xFFFF);
+      this.typeId = (byte) ((id >> 16) & 0xFF);
+      this.packageId = (byte) ((id >> 24) & 0xFF);
+    }
+  }
+
+  private static byte[] shortToByteArray(short value) {
+    return new byte[] {
+      (byte) (value & 0xFF), (byte) ((value >> 8) & 0xFF),
+    };
+  }
+
+  private static byte[] charToByteArray(char value) {
+    return new byte[] {
+      (byte) (value & 0xFF), (byte) ((value >> 8) & 0xFF),
+    };
+  }
+
+  private static byte[] intToByteArray(int value) {
+    return new byte[] {
+      (byte) (value & 0xFF),
+      (byte) ((value >> 8) & 0xFF),
+      (byte) ((value >> 16) & 0xFF),
+      (byte) ((value >> 24) & 0xFF),
+    };
+  }
+
+  private static byte[] stringToByteArray(String value) {
+    char[] chars = value.toCharArray();
+    byte[] bytes = new byte[chars.length * 2 + 4];
+    byte[] lengthBytes = shortToByteArray((short) chars.length);
+    bytes[0] = lengthBytes[0];
+    bytes[1] = lengthBytes[1];
+    for (int i = 0; i < chars.length; i++) {
+      byte[] charBytes = charToByteArray(chars[i]);
+      bytes[i * 2 + 2] = charBytes[0];
+      bytes[i * 2 + 3] = charBytes[1];
+    }
+    bytes[bytes.length - 2] = 0;
+    bytes[bytes.length - 1] = 0; // EOS
+    return bytes;
+  }
+
+  private static byte[] stringToByteArrayUtf8(String value) {
+    byte[] rawBytes = value.getBytes(Charset.forName("UTF-8"));
+    byte stringLength = (byte) rawBytes.length;
+    byte[] bytes = new byte[rawBytes.length + 3];
+    System.arraycopy(rawBytes, 0, bytes, 2, stringLength);
+    bytes[0] = bytes[1] = stringLength;
+    bytes[bytes.length - 1] = 0; // EOS
+    return bytes;
+  }
+}

src/cheogram/res/values/colors.xml 🔗

@@ -2,4 +2,11 @@
 <resources>
 	<color name="splash_screen_background">#7401cf</color>
 	<color name="splash_screen_status_bar">#7401cf</color>
+	<color name="perpy">#7401CF</color>
+	<color name="black_perpy">#1E0036</color>
+	<color name="yeller">#FFC700</color>
+
+	<color name="custom_theme_primary">@color/perpy</color>
+	<color name="custom_theme_primary_dark">@color/black_perpy</color>
+	<color name="custom_theme_accent">@color/black_perpy</color>
 </resources>

src/cheogram/res/values/strings.xml 🔗

@@ -24,6 +24,7 @@
     <string name="action_close">Close</string>
     <string name="action_execute">Go</string>
     <string name="pref_theme_oledblack">OLED Black</string>
+    <string name="pref_theme_custom">Custom</string>
     <string name="invite_to_app">Invite to Chat</string>
     <string name="only_this_thread">Show only this thread</string>
     <string name="pref_dialler_integration_incoming">Use Phone Accounts for Incoming Calls</string>

src/cheogram/res/values/themes.xml 🔗

@@ -3,8 +3,8 @@
 
     <style name="ConversationsTheme" parent="Theme.AppCompat.Light.NoActionBar">
         <item name="colorPrimary">@color/perpy</item>
-        <item name="colorPrimaryDark">#1E0036</item>
-        <item name="colorAccent">#1E0036</item>
+        <item name="colorPrimaryDark">@color/black_perpy</item>
+        <item name="colorAccent">@color/black_perpy</item>
         <item name="popupOverlayStyle">@style/ThemeOverlay.AppCompat.Light</item>
 
         <item name="message_bubble_received_bg">?colorPrimary</item>
@@ -154,7 +154,7 @@
 
     <style name="ConversationsTheme.Dark" parent="Theme.AppCompat.NoActionBar">
         <item name="colorPrimary">@color/perpy</item>
-        <item name="colorPrimaryDark">#1E0036</item>
+        <item name="colorPrimaryDark">@color/black_perpy</item>
         <item name="colorAccent">@color/yeller</item>
         <item name="popupOverlayStyle">@style/ThemeOverlay.AppCompat.Dark</item>
         <item name="android:navigationBarColor" tools:targetApi="21">@color/black</item>
@@ -308,8 +308,8 @@
     </style>
 
     <style name="ConversationsTheme.Obsidian" parent="ConversationsTheme.Dark">
-        <item name="colorPrimary">#1E0036</item>
-        <item name="colorPrimaryDark">#1E0036</item>
+        <item name="colorPrimary">@color/black_perpy</item>
+        <item name="colorPrimaryDark">@color/black_perpy</item>
         <item name="colorAccent">@color/yeller</item>
 
         <item name="message_bubble_received_bg">?colorPrimary</item>
@@ -320,10 +320,10 @@
 
         <item name="color_background_primary">#0E0020</item>
         <item name="color_background_secondary">@color/black</item>
-        <item name="color_background_tertiary">#1E0036</item>
+        <item name="color_background_tertiary">@color/black_perpy</item>
         <item name="color_background_overlay">@color/black26</item>
 
-        <item name="unread_count">#1E0036</item>
+        <item name="unread_count">@color/black_perpy</item>
     </style>
 
     <style name="ConversationsTheme.OLEDBlack" parent="ConversationsTheme.Dark">
@@ -342,7 +342,19 @@
         <item name="color_background_tertiary">@color/black</item>
         <item name="color_background_overlay">@color/black26</item>
 
-        <item name="unread_count">#1E0036</item>
+        <item name="unread_count">@color/black_perpy</item>
+    </style>
+
+    <style name="ConversationsTheme.Custom" parent="ConversationsTheme">
+        <item name="colorPrimary">@color/custom_theme_primary</item>
+        <item name="colorPrimaryDark">@color/custom_theme_primary_dark</item>
+        <item name="colorAccent">@color/custom_theme_accent</item>
+    </style>
+
+    <style name="ConversationsTheme.CustomDark" parent="ConversationsTheme.Dark">
+        <item name="colorPrimary">@color/custom_theme_primary</item>
+        <item name="colorPrimaryDark">@color/custom_theme_primary_dark</item>
+        <item name="colorAccent">@color/custom_theme_accent</item>
     </style>
 
     <style name="ConversationsTheme.Medium" parent="ConversationsTheme">
@@ -381,6 +393,30 @@
         <item name="IconSize">20sp</item>
     </style>
 
+    <style name="ConversationsTheme.CustomDark.Medium" parent="ConversationsTheme.CustomDark">
+        <item name="TextSizeCaption">14sp</item>
+        <item name="TextSizeBody1">16sp</item>
+        <item name="TextSizeBody2">16sp</item>
+        <item name="TextSizeSubhead">18sp</item>
+        <item name="TextSizeTitle">22sp</item>
+        <item name="TextSizeDisplay2">47sp</item>
+        <item name="TextSizeInput">18sp</item>
+        <item name="TextSeparation">6sp</item>
+        <item name="IconSize">20sp</item>
+    </style>
+
+    <style name="ConversationsTheme.Custom.Medium" parent="ConversationsTheme.Custom">
+        <item name="TextSizeCaption">14sp</item>
+        <item name="TextSizeBody1">16sp</item>
+        <item name="TextSizeBody2">16sp</item>
+        <item name="TextSizeSubhead">18sp</item>
+        <item name="TextSizeTitle">22sp</item>
+        <item name="TextSizeDisplay2">47sp</item>
+        <item name="TextSizeInput">18sp</item>
+        <item name="TextSeparation">6sp</item>
+        <item name="IconSize">20sp</item>
+    </style>
+
     <style name="ConversationsTheme.OLEDBlack.Medium" parent="ConversationsTheme.OLEDBlack">
         <item name="TextSizeCaption">14sp</item>
         <item name="TextSizeBody1">16sp</item>
@@ -429,6 +465,30 @@
         <item name="IconSize">22sp</item>
     </style>
 
+    <style name="ConversationsTheme.Custom.Large" parent="ConversationsTheme.Custom">
+        <item name="TextSizeCaption">16sp</item>
+        <item name="TextSizeBody1">18sp</item>
+        <item name="TextSizeBody2">18sp</item>
+        <item name="TextSizeSubhead">20sp</item>
+        <item name="TextSizeTitle">24sp</item>
+        <item name="TextSizeDisplay2">48sp</item>
+        <item name="TextSizeInput">20sp</item>
+        <item name="TextSeparation">7sp</item>
+        <item name="IconSize">22sp</item>
+    </style>
+
+    <style name="ConversationsTheme.CustomDark.Large" parent="ConversationsTheme.CustomDark">
+        <item name="TextSizeCaption">16sp</item>
+        <item name="TextSizeBody1">18sp</item>
+        <item name="TextSizeBody2">18sp</item>
+        <item name="TextSizeSubhead">20sp</item>
+        <item name="TextSizeTitle">24sp</item>
+        <item name="TextSizeDisplay2">48sp</item>
+        <item name="TextSizeInput">20sp</item>
+        <item name="TextSeparation">7sp</item>
+        <item name="IconSize">22sp</item>
+    </style>
+
     <style name="ConversationsTheme.Large" parent="ConversationsTheme">
         <item name="TextSizeCaption">16sp</item>
         <item name="TextSizeBody1">18sp</item>

src/main/java/eu/siacs/conversations/ui/ConversationsActivity.java 🔗

@@ -88,6 +88,7 @@ import eu.siacs.conversations.ui.util.MenuDoubleTabUtil;
 import eu.siacs.conversations.ui.util.PendingItem;
 import eu.siacs.conversations.utils.ExceptionHelper;
 import eu.siacs.conversations.utils.SignupUtils;
+import eu.siacs.conversations.utils.ThemeHelper;
 import eu.siacs.conversations.utils.XmppUri;
 import eu.siacs.conversations.xmpp.Jid;
 import eu.siacs.conversations.xmpp.OnUpdateBlocklist;
@@ -619,7 +620,7 @@ public class ConversationsActivity extends XmppActivity implements OnConversatio
     protected void onStart() {
         super.onStart();
         final int theme = findTheme();
-        if (this.mTheme != theme) {
+        if (this.mTheme != theme || !this.mCustomColors.equals(ThemeHelper.applyCustomColors(this))) {
             this.mSkipBackgroundBinding = true;
             recreate();
         } else {

src/main/java/eu/siacs/conversations/ui/SettingsActivity.java 🔗

@@ -49,6 +49,7 @@ import eu.siacs.conversations.services.UnifiedPushDistributor;
 import eu.siacs.conversations.ui.util.SettingsUtils;
 import eu.siacs.conversations.ui.util.StyledAttributes;
 import eu.siacs.conversations.utils.GeoHelper;
+import eu.siacs.conversations.utils.ThemeHelper;
 import eu.siacs.conversations.utils.TimeFrameUtils;
 import eu.siacs.conversations.xmpp.Jid;
 
@@ -85,6 +86,7 @@ public class SettingsActivity extends XmppActivity implements OnSharedPreference
         mSettingsFragment.setActivityIntent(getIntent());
         this.mTheme = findTheme();
         setTheme(this.mTheme);
+        ThemeHelper.applyCustomColors(this);
         getWindow()
                 .getDecorView()
                 .setBackgroundColor(
@@ -382,6 +384,13 @@ public class SettingsActivity extends XmppActivity implements OnSharedPreference
                 return true;
             });
         }
+
+        final String theTheme = PreferenceManager.getDefaultSharedPreferences(this).getString(THEME, "");
+        if (Build.VERSION.SDK_INT < 30 || !theTheme.equals("custom")) {
+            final PreferenceCategory uiCategory = (PreferenceCategory) mSettingsFragment.findPreference("ui");
+            final Preference customTheme = mSettingsFragment.findPreference("custom_theme");
+            if (customTheme != null) uiCategory.removePreference(customTheme);
+        }
     }
 
     private void changeOmemoSettingSummary() {
@@ -535,12 +544,11 @@ public class SettingsActivity extends XmppActivity implements OnSharedPreference
             xmppConnectionService.reinitializeMuclumbusService();
         } else if (name.equals(AUTOMATIC_MESSAGE_DELETION)) {
             xmppConnectionService.expireOldMessages(true);
-        } else if (name.equals(THEME)) {
+        } else if (name.equals(THEME) || name.equals("custom_theme_primary") || name.equals("custom_theme_primary_dark") || name.equals("custom_theme_accent") || name.equals("custom_theme_dark")) {
             final int theme = findTheme();
-            if (this.mTheme != theme) {
-                xmppConnectionService.setTheme(theme);
-                recreate();
-            }
+            xmppConnectionService.setTheme(theme);
+            ThemeHelper.applyCustomColors(xmppConnectionService);
+            recreate();
         } else if (name.equals(PREVENT_SCREENSHOTS)) {
             SettingsUtils.applyScreenshotPreventionSetting(this);
         } else if (UnifiedPushDistributor.PREFERENCES.contains(name)) {

src/main/java/eu/siacs/conversations/ui/XmppActivity.java 🔗

@@ -61,6 +61,7 @@ import com.google.common.base.Strings;
 import java.io.IOException;
 import java.lang.ref.WeakReference;
 import java.util.ArrayList;
+import java.util.HashMap;
 import java.util.List;
 import java.util.concurrent.RejectedExecutionException;
 
@@ -106,6 +107,7 @@ public abstract class XmppActivity extends ActionBarActivity {
     private boolean isCameraFeatureAvailable = false;
 
     protected int mTheme;
+    protected HashMap<Integer,Integer> mCustomColors;
     protected boolean mUsingEnterKey = false;
     protected boolean mUseTor = false;
     protected Toast mToast;
@@ -417,6 +419,7 @@ public abstract class XmppActivity extends ActionBarActivity {
         this.isCameraFeatureAvailable = getPackageManager().hasSystemFeature(PackageManager.FEATURE_CAMERA_ANY);
         this.mTheme = findTheme();
         setTheme(this.mTheme);
+        this.mCustomColors = ThemeHelper.applyCustomColors(this);
     }
 
     protected boolean isCameraFeatureAvailable() {

src/main/java/eu/siacs/conversations/utils/ThemeHelper.java 🔗

@@ -34,6 +34,7 @@ import android.content.SharedPreferences;
 import android.content.res.Configuration;
 import android.content.res.Resources;
 import android.content.res.TypedArray;
+import android.content.res.loader.ResourcesLoader;
 import android.os.Build;
 import android.preference.PreferenceManager;
 import android.util.TypedValue;
@@ -42,13 +43,32 @@ import android.widget.TextView;
 import androidx.annotation.StyleRes;
 import androidx.core.content.ContextCompat;
 
+import com.cheogram.android.ColorResourcesLoaderCreator;
+
 import com.google.android.material.snackbar.Snackbar;
 
+import java.util.HashMap;
+
 import eu.siacs.conversations.R;
 import eu.siacs.conversations.ui.SettingsActivity;
 
 public class ThemeHelper {
 
+	public static HashMap<Integer, Integer> applyCustomColors(final Context context) {
+		HashMap<Integer, Integer> colors = new HashMap<>();
+		if (Build.VERSION.SDK_INT < 30) return colors;
+
+		final SharedPreferences sharedPreferences = PreferenceManager.getDefaultSharedPreferences(context);
+		if (sharedPreferences.contains("custom_theme_primary")) colors.put(R.color.custom_theme_primary, sharedPreferences.getInt("custom_theme_primary", 0));
+		if (sharedPreferences.contains("custom_theme_primary_dark")) colors.put(R.color.custom_theme_primary_dark, sharedPreferences.getInt("custom_theme_primary_dark", 0));
+		if (sharedPreferences.contains("custom_theme_accent")) colors.put(R.color.custom_theme_accent, sharedPreferences.getInt("custom_theme_accent", 0));
+		if (colors.isEmpty()) return colors;
+
+		ResourcesLoader loader = ColorResourcesLoaderCreator.create(context, colors);
+		if (loader != null) context.getResources().addLoaders(loader);
+		return colors;
+	}
+
 	public static int find(final Context context) {
 		final SharedPreferences sharedPreferences = PreferenceManager.getDefaultSharedPreferences(context);
 		final Resources resources = context.getResources();
@@ -59,14 +79,17 @@ public class ThemeHelper {
 			case "medium":
 				if ("obsidian".equals(setting)) return R.style.ConversationsTheme_Obsidian_Medium;
 				else if ("oledblack".equals(setting)) return R.style.ConversationsTheme_OLEDBlack_Medium;
+				else if ("custom".equals(setting)) return dark ? R.style.ConversationsTheme_CustomDark_Medium : R.style.ConversationsTheme_Custom_Medium;
 				return dark ? R.style.ConversationsTheme_Dark_Medium : R.style.ConversationsTheme_Medium;
 			case "large":
 				if ("obsidian".equals(setting)) return R.style.ConversationsTheme_Obsidian_Large;
 				else if ("oledblack".equals(setting)) return R.style.ConversationsTheme_OLEDBlack_Large;
+				else if ("custom".equals(setting)) return dark ? R.style.ConversationsTheme_CustomDark_Large : R.style.ConversationsTheme_Custom_Large;
 				return dark ? R.style.ConversationsTheme_Dark_Large : R.style.ConversationsTheme_Large;
 			default:
 				if ("obsidian".equals(setting)) return R.style.ConversationsTheme_Obsidian;
 				else if ("oledblack".equals(setting)) return R.style.ConversationsTheme_OLEDBlack;
+				else if ("custom".equals(setting)) return dark ? R.style.ConversationsTheme_CustomDark : R.style.ConversationsTheme_Custom;
 				return dark ? R.style.ConversationsTheme_Dark : R.style.ConversationsTheme;
 		}
 	}
@@ -91,6 +114,7 @@ public class ThemeHelper {
 		if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q && "automatic".equals(setting)) {
 			return (resources.getConfiguration().uiMode & Configuration.UI_MODE_NIGHT_MASK) == Configuration.UI_MODE_NIGHT_YES;
 		} else {
+			if ("custom".equals(setting)) return sharedPreferences.getBoolean("custom_theme_dark", false);
 			return "dark".equals(setting) || "obsidian".equals(setting) || "oledblack".equals(setting);
 		}
 	}
@@ -100,6 +124,9 @@ public class ThemeHelper {
 			case R.style.ConversationsTheme_Dark:
 			case R.style.ConversationsTheme_Dark_Large:
 			case R.style.ConversationsTheme_Dark_Medium:
+			case R.style.ConversationsTheme_CustomDark:
+			case R.style.ConversationsTheme_CustomDark_Large:
+			case R.style.ConversationsTheme_CustomDark_Medium:
 			case R.style.ConversationsTheme_Obsidian:
 			case R.style.ConversationsTheme_Obsidian_Large:
 			case R.style.ConversationsTheme_Obsidian_Medium:

src/main/res/values-v30/theme-settings.xml 🔗

@@ -0,0 +1,22 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources xmlns:tools="http://schemas.android.com/tools">
+
+    <string name="theme">automatic</string>
+    <string-array name="themes" tools:ignore="InconsistentArrays">
+        <item>@string/pref_theme_automatic</item>
+        <item>@string/pref_theme_light</item>
+        <item>@string/pref_theme_dark</item>
+        <item>@string/pref_theme_obsidian</item>
+        <item>@string/pref_theme_oledblack</item>
+        <item>@string/pref_theme_custom</item>
+    </string-array>
+    <string-array name="themes_values" tools:ignore="InconsistentArrays">
+		<item>automatic</item>
+		<item>light</item>
+		<item>dark</item>
+		<item>obsidian</item>
+		<item>oledblack</item>
+		<item>custom</item>
+	</string-array>
+
+</resources>

src/main/res/values/colors.xml 🔗

@@ -1,7 +1,5 @@
 <?xml version="1.0" encoding="utf-8"?>
 <resources>
-	<color name="perpy">#7401CF</color>
-	<color name="yeller">#FFC700</color>
 	<color name="black">#ff000000</color>
 	<color name="black87">#de000000</color>
 	<color name="black54">#8a000000</color>

src/main/res/xml/preferences.xml 🔗

@@ -169,7 +169,7 @@
             android:summary="@string/pref_accept_files_summary"
             android:title="@string/pref_accept_files" />
     </PreferenceCategory>
-    <PreferenceCategory android:title="@string/pref_ui_options">
+    <PreferenceCategory android:key="ui" android:title="@string/pref_ui_options">
         <CheckBoxPreference
             android:defaultValue="@bool/use_green_background"
             android:key="use_green_background"
@@ -187,6 +187,46 @@
             android:key="theme"
             android:summary="@string/pref_theme_options_summary"
             android:title="@string/pref_theme_options" />
+        <PreferenceScreen
+            android:key="custom_theme"
+            android:title="Custom Theme Options">
+            <Preference
+                android:key="pref_static_field_key"
+                android:selectable="false"
+                android:persistent="false"
+                android:summary="You may sometimes have to force quit the app to get changes applied."/>
+
+            <PreferenceCategory
+                android:key="custom_theme_colors"
+                android:title="Colors">
+
+                <CheckBoxPreference
+                    android:defaultValue="false"
+                    android:key="custom_theme_dark"
+                    android:title="Custom Theme is Dark?" />
+                <com.rarepebble.colorpicker.ColorPreference
+                    android:key="custom_theme_primary"
+                    android:title="Custom Primary Color"
+                    android:defaultValue="@color/perpy" />
+                <com.rarepebble.colorpicker.ColorPreference
+                    android:key="custom_theme_primary_dark"
+                    android:title="Custom Primary Dark Color"
+                    android:defaultValue="@color/black_perpy" />
+                <com.rarepebble.colorpicker.ColorPreference
+                    android:key="custom_theme_accent"
+                    android:title="Custom Accent Color"
+                    android:defaultValue="@color/black_perpy" />
+            </PreferenceCategory>
+
+            <intent
+                android:action="android.intent.action.VIEW"
+                android:targetClass="eu.siacs.conversations.ui.SettingsActivity"
+                android:targetPackage="@string/applicationId">
+                <extra
+                    android:name="page"
+                    android:value="custom_theme" />
+            </intent>
+        </PreferenceScreen>
         <ListPreference
             android:defaultValue="@string/quick_action"
             android:dialogTitle="@string/choose_quick_action"