diff --git a/build.gradle b/build.gradle index 21db6d088..bb63976e9 100644 --- a/build.gradle +++ b/build.gradle @@ -82,6 +82,7 @@ dependencies { implementation 'com.googlecode.ez-vcard:ez-vcard:0.11.3' implementation 'org.jxmpp:jxmpp-jid:1.0.3' implementation 'org.hsluv:hsluv:0.2' + implementation 'com.github.martin-stone:hsv-alpha-color-picker-android:2.4.2' implementation 'org.conscrypt:conscrypt-android:2.5.2' implementation 'pl.droidsonroids.gif:android-gif-drawable:1.2.25' implementation 'me.drakeet.support:toastcompat:1.1.0' diff --git a/src/git/java/de/monocles/chat/ui/PermissionsActivity.java b/src/git/java/de/monocles/chat/ui/PermissionsActivity.java index a0a6bfe7a..69027fff4 100644 --- a/src/git/java/de/monocles/chat/ui/PermissionsActivity.java +++ b/src/git/java/de/monocles/chat/ui/PermissionsActivity.java @@ -94,3 +94,4 @@ public class PermissionsActivity extends AppCompatActivity void onPermissionGranted(); } } + diff --git a/src/git/java/de/monocles/chat/ui/StartUI.java b/src/git/java/de/monocles/chat/ui/StartUI.java index 669bd9c6a..b7686b1d0 100644 --- a/src/git/java/de/monocles/chat/ui/StartUI.java +++ b/src/git/java/de/monocles/chat/ui/StartUI.java @@ -39,4 +39,4 @@ public class StartUI extends AppCompatActivity { protected void onDestroy() { super.onDestroy(); } -} \ No newline at end of file +} diff --git a/src/main/java/de/monocles/chat/ColorResourcesLoaderCreator.java b/src/main/java/de/monocles/chat/ColorResourcesLoaderCreator.java new file mode 100644 index 000000000..b57804ebd --- /dev/null +++ b/src/main/java/de/monocles/chat/ColorResourcesLoaderCreator.java @@ -0,0 +1,75 @@ +/* + * 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 de.monocles.chat; + +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 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; + } +} + diff --git a/src/main/java/de/monocles/chat/ColorResourcesTableCreator.java b/src/main/java/de/monocles/chat/ColorResourcesTableCreator.java new file mode 100644 index 000000000..053d388e8 --- /dev/null +++ b/src/main/java/de/monocles/chat/ColorResourcesTableCreator.java @@ -0,0 +1,622 @@ +/* + * 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 de.monocles.chat; + +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 COLOR_RESOURCE_COMPARATOR = + new Comparator() { + @Override + public int compare(ColorResource res1, ColorResource res2) { + return res1.entryId - res2.entryId; + } + }; + + static byte[] create(Context context, Map 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> colorResourceMap = new HashMap<>(); + ColorResource colorResource = null; + for (Map.Entry 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()); + } + 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. + * + *

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 packageChunks = new ArrayList<>(); + + ResTable(Map> colorResourceMap) { + packageCount = colorResourceMap.size(); + stringPool = new StringPoolChunk(); + for (Entry> entry : colorResourceMap.entrySet()) { + List 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 stringIndex = new ArrayList<>(); + private final List styledSpanIndex = new ArrayList<>(); + private final List strings = new ArrayList<>(); + private final List> 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> processedString = processString(string); + stringIndex.add(stringOffset); + stringOffset += processedString.first.length; + strings.add(processedString.first); + styledSpans.add(processedString.second); + } + int styledSpanOffset = 0; + for (List 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 styledSpanList : styledSpans) { + for (StringStyledSpan styledSpan : styledSpanList) { + styledSpan.writeTo(outputStream); + } + outputStream.write(intToByteArray(STYLED_SPAN_LIST_END)); + } + } + + int getChunkSize() { + return chunkSize; + } + + private Pair> processString(String rawString) { + // Ignore styled spans, won't be used in our scenario. + return new Pair<>( + utf8Encode ? stringToByteArrayUtf8(rawString) : stringToByteArray(rawString), + Collections.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. + * + *

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 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. + * + *

There should be one of these chunks for each resource type. + * + *

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 colorResources) { + entryCount = colorResources.get(colorResources.size() - 1).entryId + 1; + Set 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. + * + *

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 colorResources, Set 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; + } +} + diff --git a/src/main/java/eu/siacs/conversations/services/XmppConnectionService.java b/src/main/java/eu/siacs/conversations/services/XmppConnectionService.java index 9f20928d5..75f849ff0 100644 --- a/src/main/java/eu/siacs/conversations/services/XmppConnectionService.java +++ b/src/main/java/eu/siacs/conversations/services/XmppConnectionService.java @@ -70,6 +70,8 @@ import net.java.otr4j.session.Session; import net.java.otr4j.session.SessionID; import net.java.otr4j.session.SessionImpl; import net.java.otr4j.session.SessionStatus; + +import eu.siacs.conversations.utils.ThemeHelper; import eu.siacs.conversations.xmpp.jid.OtrJidHelper; import eu.siacs.conversations.xmpp.Jid; @@ -1421,6 +1423,8 @@ public class XmppConnectionService extends Service { @Override public void onCreate() { updateNotificationChannels(); + setTheme(ThemeHelper.find(this)); + ThemeHelper.applyCustomColors(this); if (Compatibility.runsTwentySix()) { cleanOldNotificationChannels(); } diff --git a/src/main/java/eu/siacs/conversations/ui/ConversationsActivity.java b/src/main/java/eu/siacs/conversations/ui/ConversationsActivity.java index b8c785949..1146e15fb 100644 --- a/src/main/java/eu/siacs/conversations/ui/ConversationsActivity.java +++ b/src/main/java/eu/siacs/conversations/ui/ConversationsActivity.java @@ -111,6 +111,8 @@ import eu.siacs.conversations.xmpp.Jid; import eu.siacs.conversations.xmpp.OnUpdateBlocklist; import eu.siacs.conversations.xmpp.chatstate.ChatState; import me.drakeet.support.toast.ToastCompat; +import eu.siacs.conversations.utils.ThemeHelper; + public class ConversationsActivity extends XmppActivity implements OnConversationSelected, OnConversationArchived, OnConversationsListItemUpdated, OnConversationRead, XmppConnectionService.OnAccountUpdate, XmppConnectionService.OnConversationUpdate, XmppConnectionService.OnRosterUpdate, OnUpdateBlocklist, XmppConnectionService.OnShowErrorToast, XmppConnectionService.OnAffiliationChanged, XmppConnectionService.OnRoomDestroy { @@ -756,7 +758,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 { diff --git a/src/main/java/eu/siacs/conversations/ui/SettingsActivity.java b/src/main/java/eu/siacs/conversations/ui/SettingsActivity.java index 1c13c081c..f8095c02f 100644 --- a/src/main/java/eu/siacs/conversations/ui/SettingsActivity.java +++ b/src/main/java/eu/siacs/conversations/ui/SettingsActivity.java @@ -53,6 +53,8 @@ import eu.siacs.conversations.utils.TimeFrameUtils; import eu.siacs.conversations.xmpp.Jid; import me.drakeet.support.toast.ToastCompat; import eu.siacs.conversations.services.UnifiedPushDistributor; +import eu.siacs.conversations.utils.ThemeHelper; + public class SettingsActivity extends XmppActivity implements OnSharedPreferenceChangeListener { @@ -119,6 +121,7 @@ public class SettingsActivity extends XmppActivity implements OnSharedPreference protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setTheme(ThemeHelper.find(this)); + ThemeHelper.applyCustomColors(this); setContentView(R.layout.activity_settings); FragmentManager fm = getFragmentManager(); mSettingsFragment = (SettingsFragment) fm.findFragmentById(R.id.settings_content); @@ -508,6 +511,13 @@ public class SettingsActivity extends XmppActivity implements OnSharedPreference }); updateTheme(); } + // TODO: handle skd<30 + // 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 updateTheme() { @@ -679,7 +689,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) || name.equals(THEME_COLOR)) { + } else if (name.equals(THEME) || name.equals(THEME_COLOR) || 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(); + xmppConnectionService.setTheme(theme); + ThemeHelper.applyCustomColors(xmppConnectionService); + recreate(); updateTheme(); } else if (name.equals(USE_UNICOLORED_CHATBG)) { xmppConnectionService.updateConversationUi(); diff --git a/src/main/java/eu/siacs/conversations/ui/XmppActivity.java b/src/main/java/eu/siacs/conversations/ui/XmppActivity.java index f69f7cc7f..d65e7f6da 100644 --- a/src/main/java/eu/siacs/conversations/ui/XmppActivity.java +++ b/src/main/java/eu/siacs/conversations/ui/XmppActivity.java @@ -65,6 +65,7 @@ import androidx.databinding.DataBindingUtil; import com.google.common.base.Strings; import com.google.common.collect.Collections2; +import java.util.HashMap; import java.io.File; import java.io.IOException; import java.lang.ref.WeakReference; @@ -140,6 +141,7 @@ public abstract class XmppActivity extends ActionBarActivity { private boolean isCameraFeatureAvailable = false; protected int mTheme; + protected HashMap mCustomColors; protected boolean mUsingEnterKey = false; public boolean mUseTor = false; public boolean mUseI2P = false; @@ -479,6 +481,7 @@ public abstract class XmppActivity extends ActionBarActivity { super.onCreate(savedInstanceState); this.mTheme = findTheme(); setTheme(this.mTheme); + this.mCustomColors = ThemeHelper.applyCustomColors(this); metrics = getResources().getDisplayMetrics(); ExceptionHelper.init(getApplicationContext()); EmojiInitializationService.execute(this); diff --git a/src/main/java/eu/siacs/conversations/utils/ThemeHelper.java b/src/main/java/eu/siacs/conversations/utils/ThemeHelper.java index 12c4fa86c..0650e8e86 100644 --- a/src/main/java/eu/siacs/conversations/utils/ThemeHelper.java +++ b/src/main/java/eu/siacs/conversations/utils/ThemeHelper.java @@ -35,9 +35,13 @@ import android.content.res.ColorStateList; import android.content.res.Configuration; import android.content.res.Resources; import android.content.res.TypedArray; +import android.os.Build; import android.preference.PreferenceManager; import android.util.TypedValue; import android.widget.TextView; +import android.content.res.loader.ResourcesLoader; +import de.monocles.chat.ColorResourcesLoaderCreator; +import java.util.HashMap; import androidx.annotation.StyleRes; import androidx.core.content.ContextCompat; @@ -49,7 +53,20 @@ import eu.siacs.conversations.ui.SettingsActivity; import eu.siacs.conversations.ui.util.StyledAttributes; public class ThemeHelper { + public static HashMap applyCustomColors(final Context context) { + HashMap 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 = de.monocles.chat.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(); @@ -227,6 +244,33 @@ public class ThemeHelper { return R.style.ConversationsTheme_Pink; } } + case "custom": + switch (fontSize) { + case "medium": + if (black) { + return R.style.ConversationsTheme_CustomDark_Medium; + } else if (dark) { + return R.style.ConversationsTheme_CustomDark_Medium; + } else { + return R.style.ConversationsTheme_Custom_Medium; + } + case "large": + if (black) { + return R.style.ConversationsTheme_CustomDark_Large; + } else if (dark) { + return R.style.ConversationsTheme_CustomDark_Large; + } else { + return R.style.ConversationsTheme_Custom_Large; + } + default: + if (black) { + return R.style.ConversationsTheme_CustomDark; + } else if (dark) { + return R.style.ConversationsTheme_CustomDark; + } else { + return R.style.ConversationsTheme_Custom; + } + } default: if (black) { return R.style.ConversationsTheme_Monocles_Black; @@ -439,7 +483,15 @@ public class ThemeHelper { } } } - + private static boolean isDark(final SharedPreferences sharedPreferences, final Resources resources) { + final String setting = sharedPreferences.getString(SettingsActivity.THEME, resources.getString(R.string.theme)); + 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); + } + } public static boolean isDark(@StyleRes int id) { switch (id) { //blue @@ -484,6 +536,10 @@ public class ThemeHelper { case R.style.ConversationsTheme_Pink_Black: case R.style.ConversationsTheme_Pink_Black_Large: case R.style.ConversationsTheme_Pink_Black_Medium: + //custom + case R.style.ConversationsTheme_CustomDark: + case R.style.ConversationsTheme_CustomDark_Large: + case R.style.ConversationsTheme_CustomDark_Medium: return true; default: return false; @@ -514,6 +570,8 @@ public class ThemeHelper { return dark ? ContextCompat.getColorStateList(context, R.color.white70) : ContextCompat.getColorStateList(context, R.color.darkWhite); case "pink": return dark ? ContextCompat.getColorStateList(context, R.color.white70) : ContextCompat.getColorStateList(context, R.color.darkpink); + case "custom": + return dark ? ContextCompat.getColorStateList(context, R.color.white70) : ContextCompat.getColorStateList(context, R.color.darkWhite); default: return dark ? ContextCompat.getColorStateList(context, R.color.white70) : ContextCompat.getColorStateList(context, R.color.darkmonocles); } diff --git a/src/main/res/values/arrays.xml b/src/main/res/values/arrays.xml index df75ad9c5..79e350900 100644 --- a/src/main/res/values/arrays.xml +++ b/src/main/res/values/arrays.xml @@ -18,7 +18,8 @@ @string/pref_theme_orange @string/pref_theme_grey @string/pref_theme_pink - + @string/pref_theme_custom + monocles @@ -26,7 +27,8 @@ orange grey pink - + custom + diff --git a/src/main/res/values/colors.xml b/src/main/res/values/colors.xml index 1fefb3f2b..8049606b7 100644 --- a/src/main/res/values/colors.xml +++ b/src/main/res/values/colors.xml @@ -146,4 +146,13 @@ #ff4caf50 + + + #7401CF + #1E0036 + #FFC700 + + @color/perpy + @color/black_perpy + @color/black_perpy diff --git a/src/main/res/values/strings.xml b/src/main/res/values/strings.xml index 70bff3001..630a5dde5 100644 --- a/src/main/res/values/strings.xml +++ b/src/main/res/values/strings.xml @@ -1251,4 +1251,5 @@ Push Server A user-chosen push server to relay push messages via XMPP to your device. None (deactivated) + Custom diff --git a/src/main/res/values/themes.xml b/src/main/res/values/themes.xml index c3c0b0541..4cd4400ac 100644 --- a/src/main/res/values/themes.xml +++ b/src/main/res/values/themes.xml @@ -1538,5 +1538,64 @@ 22sp + + + + + + + + + + + \ No newline at end of file diff --git a/src/main/res/xml/preferences.xml b/src/main/res/xml/preferences.xml index 11d3f06d5..b44d61720 100644 --- a/src/main/res/xml/preferences.xml +++ b/src/main/res/xml/preferences.xml @@ -27,6 +27,46 @@ android:key="theme_color" android:summary="@string/pref_theme_color_options_summary" android:title="@string/pref_theme_color_options" /> + + + + + + + + + + + + + + +