diff --git a/app/build.gradle.kts b/app/build.gradle.kts index b8940fb..c2a4d46 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -135,6 +135,7 @@ dependencies { implementation(libs.apksig) implementation(libs.axml) implementation(libs.bouncycastle) + implementation(libs.binaryResources) implementation(libs.coil) implementation(variantOf(libs.zip) { artifactType("aar") }) } diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 28aec09..9618ec9 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -24,6 +24,7 @@ android:name=".ManagerApplication" android:icon="@mipmap/ic_launcher" android:label="@string/app_name" + android:largeHeap="true" android:requestLegacyExternalStorage="true" android:roundIcon="@mipmap/ic_launcher_round" android:supportsRtl="true"> diff --git a/app/src/main/assets/icons/ic_logo_foreground.png b/app/src/main/assets/icons/ic_logo_foreground.png deleted file mode 100644 index 592fc38..0000000 Binary files a/app/src/main/assets/icons/ic_logo_foreground.png and /dev/null differ diff --git a/app/src/main/assets/icons/ic_logo_round.png b/app/src/main/assets/icons/ic_logo_round.png deleted file mode 100644 index 25241e8..0000000 Binary files a/app/src/main/assets/icons/ic_logo_round.png and /dev/null differ diff --git a/app/src/main/assets/icons/ic_logo_square.png b/app/src/main/assets/icons/ic_logo_square.png deleted file mode 100644 index fc0090f..0000000 Binary files a/app/src/main/assets/icons/ic_logo_square.png and /dev/null differ diff --git a/app/src/main/kotlin/com/aliucord/manager/installer/steps/patch/ReplaceIconStep.kt b/app/src/main/kotlin/com/aliucord/manager/installer/steps/patch/ReplaceIconStep.kt index 1afe19e..e631205 100644 --- a/app/src/main/kotlin/com/aliucord/manager/installer/steps/patch/ReplaceIconStep.kt +++ b/app/src/main/kotlin/com/aliucord/manager/installer/steps/patch/ReplaceIconStep.kt @@ -1,20 +1,34 @@ package com.aliucord.manager.installer.steps.patch import android.content.Context +import android.os.Build import androidx.compose.runtime.Stable import com.aliucord.manager.R import com.aliucord.manager.installer.steps.StepGroup import com.aliucord.manager.installer.steps.StepRunner import com.aliucord.manager.installer.steps.base.Step import com.aliucord.manager.installer.steps.base.StepState +import com.aliucord.manager.installer.util.ArscUtil +import com.aliucord.manager.installer.util.ArscUtil.addColorResource +import com.aliucord.manager.installer.util.ArscUtil.addResource +import com.aliucord.manager.installer.util.ArscUtil.getMainArscChunk +import com.aliucord.manager.installer.util.ArscUtil.getPackageChunk +import com.aliucord.manager.installer.util.ArscUtil.getResourceFileName +import com.aliucord.manager.installer.util.AxmlUtil import com.aliucord.manager.ui.screens.installopts.InstallOptions +import com.aliucord.manager.ui.screens.installopts.InstallOptions.IconReplacement +import com.aliucord.manager.util.getResBytes import com.github.diamondminer88.zip.ZipWriter +import com.google.devrel.gmscore.tools.apk.arsc.BinaryResourceIdentifier +import com.google.devrel.gmscore.tools.apk.arsc.BinaryResourceValue import org.koin.core.component.KoinComponent import org.koin.core.component.inject -import java.io.InputStream /** - * Replace icons + * Patch the mipmap-v26 launcher icons' background, foreground and monochrome attributes, + * and if below API 26, replace the traditional mipmap icons instead. + * All filenames are retrieved based on the resource definitions in `resources.arsc`, + * so that this works on pretty much almost any APK. */ @Stable class ReplaceIconStep(private val options: InstallOptions) : Step(), KoinComponent { @@ -24,32 +38,79 @@ class ReplaceIconStep(private val options: InstallOptions) : Step(), KoinCompone override val localizedName = R.string.install_step_patch_icon override suspend fun execute(container: StepRunner) { - if (!options.replaceIcon) { + if (Build.VERSION.SDK_INT < 26 || (!options.monochromeIcon && options.iconReplacement is IconReplacement.Original)) { state = StepState.Skipped return } val apk = container.getStep().patchedApk + val arsc = ArscUtil.readArsc(apk) + + val iconRscIds = AxmlUtil.readManifestIconInfo(apk) + val squareIconFile = arsc.getMainArscChunk().getResourceFileName(iconRscIds.squareIcon, "anydpi-v26") + val roundIconFile = arsc.getMainArscChunk().getResourceFileName(iconRscIds.roundIcon, "anydpi-v26") + + var foregroundIcon: BinaryResourceIdentifier? = null + var backgroundIcon: BinaryResourceIdentifier? = null + var monochromeIcon: BinaryResourceIdentifier? = null + + // Add the monochrome resource and add the resource file later + if (options.monochromeIcon) { + val filePathIdx = arsc.getMainArscChunk().stringPool + .addString("res/ic_aliucord_monochrome.xml") + + monochromeIcon = arsc.getPackageChunk().addResource( + typeName = "drawable", + resourceName = "ic_aliucord_monochrome", + configurations = { it.isDefault }, + valueType = BinaryResourceValue.Type.STRING, + valueData = filePathIdx, + ) + } + + // Add a new color resource to use + if (options.iconReplacement is IconReplacement.CustomColor) { + backgroundIcon = arsc.getPackageChunk() + .addColorResource("aliucord", options.iconReplacement.color) + } + + // Add a new mipmap resource and the file to be added later + if (options.iconReplacement is IconReplacement.CustomImage) { + val iconPathIdx = arsc.getMainArscChunk().stringPool + .addString("res/ic_foreground_replacement.png") + + foregroundIcon = arsc.getPackageChunk().addResource( + typeName = "mipmap", + resourceName = "ic_foreground_replacement", + configurations = { it.toString().endsWith("dpi") }, // Any mipmap config except anydpi-v26 + valueType = BinaryResourceValue.Type.STRING, + valueData = iconPathIdx, + ) + } + + for (rscFile in setOf(squareIconFile, roundIconFile)) { // setOf to not possibly patch same file twice + AxmlUtil.patchAdaptiveIcon( + apk = apk, + resourcePath = rscFile, + backgroundColor = backgroundIcon, + foregroundIcon = foregroundIcon, + monochromeIcon = monochromeIcon, + ) + } + + val newArscBytes = arsc.toByteArray(/* shrink = */ true) ZipWriter(apk, /* append = */ true).use { - val foregroundIcon = readAsset("icons/ic_logo_foreground.png") - val squareIcon = readAsset("icons/ic_logo_square.png") - - val replacements = mapOf( - arrayOf("MbV.png", "kbF.png", "_eu.png", "EtS.png") to foregroundIcon, - arrayOf("_h_.png", "9MB.png", "Dy7.png", "kC0.png", "oEH.png", "RG0.png", "ud_.png", "W_3.png") to squareIcon - ) - - for ((files, replacement) in replacements) { - for (file in files) { - val path = "res/$file" - it.deleteEntry(path) - it.writeEntry(path, replacement) - } + if (options.monochromeIcon) { + it.writeEntry("res/ic_aliucord_monochrome.xml", context.getResBytes(R.drawable.ic_discord_monochrome)) } + + if (options.iconReplacement is IconReplacement.CustomImage) { + it.writeEntry("res/ic_foreground_replacement.png", options.iconReplacement.imageBytes) + } + + it.deleteEntry("resources.arsc") + it.writeEntry("resources.arsc", newArscBytes) } } - - private fun readAsset(fileName: String): ByteArray = - context.assets.open(fileName).use(InputStream::readBytes) } diff --git a/app/src/main/kotlin/com/aliucord/manager/installer/util/ArscUtil.kt b/app/src/main/kotlin/com/aliucord/manager/installer/util/ArscUtil.kt new file mode 100644 index 0000000..f5509df --- /dev/null +++ b/app/src/main/kotlin/com/aliucord/manager/installer/util/ArscUtil.kt @@ -0,0 +1,155 @@ +package com.aliucord.manager.installer.util + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.toArgb +import com.github.diamondminer88.zip.ZipReader +import com.google.devrel.gmscore.tools.apk.arsc.* +import java.io.File + +object ArscUtil { + /** + * Read and parse `resources.arsc` from an APK. + */ + fun readArsc(apk: File): BinaryResourceFile { + val bytes = ZipReader(apk).use { it.openEntry("resources.arsc")?.read() } + ?: error("APK missing resources.arsc") + + return try { + BinaryResourceFile(bytes) + } catch (t: Throwable) { + throw Error("Failed to parse resources.arsc", t) + } + } + + /** + * Get the only top-level chunk in an arsc file. + */ + fun BinaryResourceFile.getMainArscChunk(): ResourceTableChunk { + if (this.chunks.size > 1) + error("More than 1 top level chunk in resources.arsc") + + return this.chunks.first() as? ResourceTableChunk + ?: error("Invalid top-level resources.arsc chunk") + } + + /** + * Get a singular package chunk in an arsc file. + */ + fun BinaryResourceFile.getPackageChunk(): PackageChunk { + return this.getMainArscChunk().packages.singleOrNull() + ?: error("resources.arsc must contain exactly 1 package chunk") + } + + /** + * Adds a new color resource to all configuration variants in an arsc package. + * + * @param name The new resource name. + * @param color The value of the new color resource. + * @return The resource ID of the newly added resource. + */ + fun PackageChunk.addColorResource( + name: String, + color: Color, + ): BinaryResourceIdentifier { + return this.addResource( + typeName = "color", + resourceName = name, + configurations = { true }, + valueType = BinaryResourceValue.Type.INT_COLOR_ARGB8, + valueData = color.toArgb(), + ) + } + + /** + * Adds a new color resource to the matching configuration variants in an arsc package. + * + * @param typeName The type of the resource (ex: `mipmap`, `drawable`, etc.) + * @param resourceName The new resource name. + * @param configurations A predicate whether to add the value into a matching type chunk. + * @param valueType The type of the resource value. + * @param valueData The raw data of the resource value. + * @return The resource ID of the newly added resource. + */ + fun PackageChunk.addResource( + typeName: String, + resourceName: String, + configurations: (BinaryResourceConfiguration) -> Boolean, + valueType: BinaryResourceValue.Type, + valueData: Int, + ): BinaryResourceIdentifier { + // Add a new resource entry to the "type spec chunk" and, + // a new resource entry to all matching "type chunks" + + val specChunk = this.getTypeSpecChunk(typeName) + val typeChunks = this.getTypeChunks(typeName) + + // Add a new string to the pool to be used as a key + val resourceNameIdx = this.keyStringPool.addString(resourceName, /* deduplicate = */ true) + + // Add a new resource entry to the type spec chunk + val resourceIdx = specChunk.addResource(/* flags = */ 0) + + for (typeChunk in typeChunks) { + // If no matching config, add a null entry and try next chunk + if (!configurations(typeChunk.configuration)) { + typeChunk.addEntry(null) + continue + } + + val entry = TypeChunk.Entry( + /* headerSize = */ 8, + /* flags = */ 0, + /* keyIndex = */ resourceNameIdx, + /* value = */ + BinaryResourceValue( + /* type = */ valueType, + /* data = */ valueData, + ), + /* values = */ null, // not a complex resource + /* parentEntry = */ 0, // not a complex resource + /* parent = */ typeChunk, + ) + + typeChunk.addEntry(entry) + } + + return BinaryResourceIdentifier.create( + /* packageId = */ this.id, + /* typeId = */ specChunk.id, + /* entryId = */ resourceIdx, + ) + } + + /** + * In an arsc file, for a specific resource in a configuration, get it's value. + * + * @param resourceId The target resource id. + * @param configurationName The target configuration variant of the resource. (ex: `anydpi-v26`, `xxhdpi`, `ldtrl-mpi`, etc.) + * @return The string value of the resource, which should be a file path inside the apk. + */ + fun ResourceTableChunk.getResourceFileName( + resourceId: BinaryResourceIdentifier, + configurationName: String, + ): String { + val packageChunk = this.packages.find { it.id == resourceId.packageId() } + ?: error("Unable to find target resource") + + val typeChunk = packageChunk.getTypeChunks(resourceId.typeId()) + .find { it.configuration.toString() == configurationName } + ?: error("Unable to find target resource") + + val entry = try { + typeChunk.getEntry(resourceId.entryId())!! + } catch (_: Throwable) { + error("Unable to find target resource") + } + + if (entry.isComplex || entry.value().type() != BinaryResourceValue.Type.STRING) + error("Target resource value type is not STRING") + + val valueIdx = entry.value().data() + val value = this.stringPool.getString(valueIdx) + + return value + } +} diff --git a/app/src/main/kotlin/com/aliucord/manager/installer/util/AxmlUtil.kt b/app/src/main/kotlin/com/aliucord/manager/installer/util/AxmlUtil.kt new file mode 100644 index 0000000..204a27c --- /dev/null +++ b/app/src/main/kotlin/com/aliucord/manager/installer/util/AxmlUtil.kt @@ -0,0 +1,201 @@ +package com.aliucord.manager.installer.util + +import com.aliucord.manager.util.find +import com.github.diamondminer88.zip.ZipReader +import com.github.diamondminer88.zip.ZipWriter +import com.google.devrel.gmscore.tools.apk.arsc.* +import java.io.File + +object AxmlUtil { + /** + * Read and parse a specific axml resource inside an APK + * @param apk The source apk + * @param resourcePath The full path to the axml file inside the apk, which may be flattened. + */ + private fun readAxml(apk: File, resourcePath: String): BinaryResourceFile { + val bytes = ZipReader(apk).use { it.openEntry(resourcePath)?.read() } + ?: error("APK missing resource file at $resourcePath") + + return try { + BinaryResourceFile(bytes) + } catch (t: Throwable) { + throw Error("Failed to parse axml at $resourcePath", t) + } + } + + /** + * Get the only top-level chunk in an axml file. + */ + private fun BinaryResourceFile.getMainAxmlChunk(): XmlChunk { + if (this.chunks.size > 1) + error("More than 1 top level chunk in axml") + + return this.chunks.first() as? XmlChunk + ?: error("Invalid top-level axml chunk") + } + + /** + * Finds the first chunk with a matching [name] in a flattened chunk list. + * @receiver The top level XmlChunk ([getMainAxmlChunk]) + */ + private fun XmlChunk.getStartElementChunk(name: String): XmlStartElementChunk? { + val nameIdx = this.stringPool.indexOf(name) + + return this.chunks + .find { it is XmlStartElementChunk && it.nameIndex == nameIdx } + as? XmlStartElementChunk + } + + /** + * Finds the first attribute with a matching name (ignoring namespace) + * in a starting element chunk. + */ + private fun XmlStartElementChunk.getAttribute(name: String): XmlAttribute { + val nameIdx = (this.parent as XmlChunk).stringPool.indexOf(name) + + return this.attributes + .find { it.nameIndex() == nameIdx } + ?: error("Failed to find $name attribute in an axml chunk") + } + + /** + * Patches an axml file to change the `background`, `foreground`, and `monochrome` resource references. + * If any of the following are not null, then they will be patched. + * @param backgroundColor A color resource id to replace with. + * + * @param foregroundIcon A drawable resource id to replace with. + * @param monochromeIcon A drawable resource id to add or replace with. + */ + fun patchAdaptiveIcon( + apk: File, + resourcePath: String, + backgroundColor: BinaryResourceIdentifier? = null, + foregroundIcon: BinaryResourceIdentifier? = null, + monochromeIcon: BinaryResourceIdentifier? = null, + ) { + val xml = readAxml(apk, resourcePath) + val xmlChunk = xml.getMainAxmlChunk() + + // Patch the background color resource reference + if (backgroundColor != null) { + val chunk = xmlChunk.getStartElementChunk("background")!! + val attribute = chunk.getAttribute("drawable") + attribute.typedValue().setValue( + /* type = */ BinaryResourceValue.Type.REFERENCE, + /* data = */ backgroundColor.resourceId(), + ) + } + + // Patch the foreground drawable reference + if (foregroundIcon != null) { + val chunk = xmlChunk.getStartElementChunk("foreground")!! + val attribute = chunk.getAttribute("drawable") + attribute.typedValue().setValue( + /* type = */ BinaryResourceValue.Type.REFERENCE, + /* data = */ foregroundIcon.resourceId(), + ) + } + + // Add or replace the monochrome drawable reference + if (monochromeIcon != null) { + // already exists, patch existing chunk + val existingChunk = xmlChunk.getStartElementChunk("monochrome") + if (existingChunk != null) { + val attribute = existingChunk.getAttribute("drawable") + attribute.typedValue().setValue( + /* type = */ BinaryResourceValue.Type.REFERENCE, + /* data = */ monochromeIcon.resourceId(), + ) + } + // Add a new start & end chunk since they don't exist + // ` + else { + val iconEndChunkIdx = xmlChunk.chunks + .indexOfLast { it is XmlEndElementChunk && it.name == "adaptive-icon" } + + val namespaceIdx = xmlChunk.stringPool.indexOf("http://schemas.android.com/apk/res/android") + val drawableIdx = xmlChunk.stringPool.indexOf("drawable") + val monochromeIdx = xmlChunk.stringPool.addString("monochrome") + + val startChunk = XmlStartElementChunk( + /* namespaceIndex = */ -1, + /* nameIndex = */ monochromeIdx, + /* idIndex = */ -1, + /* classIndex = */ -1, + /* styleIndex = */ -1, + /* attributes = */ + listOf( + XmlAttribute( + /* namespaceIndex = */ namespaceIdx, + /* nameIndex = */ drawableIdx, + /* rawValueIndex = */ -1, + /* typedValue = */ + BinaryResourceValue( + /* type = */ BinaryResourceValue.Type.REFERENCE, + /* data = */ monochromeIcon.resourceId(), + ), + // This is wrong but it doesn't matter here as long as this attribute isn't stringified + /* parent = */ null, + ) + ), + /* parent = */ xmlChunk, + ) + val endChunk = XmlEndElementChunk( + /* namespaceIndex = */ namespaceIdx, + /* nameIndex = */ monochromeIdx, + /* parent = */ xmlChunk, + ) + + xmlChunk.addChunk(iconEndChunkIdx, startChunk) + xmlChunk.addChunk(iconEndChunkIdx + 1, endChunk) + } + } + + ZipWriter(apk, /* append = */ true).use { zip -> + zip.deleteEntry(resourcePath) + zip.writeEntry(resourcePath, xml.toByteArray(/* shrink = */ true)) + } + } + + /** + * From an APK, read the manifest's `icon` and `roundIcon` references to a resource. + * This is then used to get the filename of the resource from `resources.arsc`. + */ + fun readManifestIconInfo(apk: File): ManifestIconInfo { + val manifestBytes = ZipReader(apk).use { it.openEntry("AndroidManifest.xml")?.read() } + ?: error("APK missing manifest") + val manifest = BinaryResourceFile(manifestBytes) + val mainChunk = manifest.getMainAxmlChunk() + + // Prefetch string indexes to avoid parsing the entire string pool + val iconStringIdx = mainChunk.stringPool.indexOf("icon") + val roundIconStringIdx = mainChunk.stringPool.indexOf("roundIcon") + val applicationStringIdx = mainChunk.stringPool.indexOf("application") + + val applicationChunk = mainChunk.chunks + .find { it is XmlStartElementChunk && it.nameIndex == applicationStringIdx } as? XmlStartElementChunk + ?: error("Unable to find in manifest") + + val squareIcon = applicationChunk.attributes + .find { it.nameIndex() == iconStringIdx } + ?: error("Unable to find android:icon in manifest") + + val roundIcon = applicationChunk.attributes + .find { it.nameIndex() == roundIconStringIdx } + ?: error("Unable to find android:roundIcon in manifest") + + assert(squareIcon.typedValue().type() == BinaryResourceValue.Type.REFERENCE) + assert(roundIcon.typedValue().type() == BinaryResourceValue.Type.REFERENCE) + + return ManifestIconInfo( + // Resource IDs into resources.arsc + squareIcon = BinaryResourceIdentifier.create(squareIcon.typedValue().data()), + roundIcon = BinaryResourceIdentifier.create(roundIcon.typedValue().data()), + ) + } + + data class ManifestIconInfo( + val squareIcon: BinaryResourceIdentifier, + val roundIcon: BinaryResourceIdentifier, + ) +} diff --git a/app/src/main/kotlin/com/aliucord/manager/ui/screens/installopts/InstallOptions.kt b/app/src/main/kotlin/com/aliucord/manager/ui/screens/installopts/InstallOptions.kt index 16809fa..10ea91b 100644 --- a/app/src/main/kotlin/com/aliucord/manager/ui/screens/installopts/InstallOptions.kt +++ b/app/src/main/kotlin/com/aliucord/manager/ui/screens/installopts/InstallOptions.kt @@ -1,12 +1,60 @@ package com.aliucord.manager.ui.screens.installopts import androidx.compose.runtime.Immutable -import java.io.Serializable +import androidx.compose.ui.graphics.Color @Immutable data class InstallOptions( + /** + * The app name that's user-facing in launchers. + */ val appName: String, + + /** + * Changes the installation package name. + */ val packageName: String, + + /** + * Adding the debuggable APK flag. + */ val debuggable: Boolean, - val replaceIcon: Boolean, -) : Serializable + + /** + * Replacement of the user-facing launcher icon. + */ + val iconReplacement: IconReplacement, + + /** + * Adding a monochrome variant to the launcher icon. + * This is independent of [InstallOptions.iconReplacement] + */ + val monochromeIcon: Boolean, +) { + @Immutable + sealed interface IconReplacement { + /** + * Keeps the original icons that are present in the APK. + */ + data object Original : IconReplacement + + /** + * Replaces the foreground image of the icon entirely and sets the background to transparent. + * This does not affect the monochrome icon. + */ + data class CustomImage(val imageBytes: ByteArray) : IconReplacement + + /** + * Changes the background of the icon to a specific color without + * altering the foreground or monochrome variants. + */ + data class CustomColor(val color: Color) : IconReplacement + + companion object { + /** + * The default icon replacement option. + */ + val Aliucord = CustomColor(Color(0xFF00C853)) + } + } +} diff --git a/app/src/main/kotlin/com/aliucord/manager/ui/screens/installopts/InstallOptionsModel.kt b/app/src/main/kotlin/com/aliucord/manager/ui/screens/installopts/InstallOptionsModel.kt index 31136bd..40fe4d5 100644 --- a/app/src/main/kotlin/com/aliucord/manager/ui/screens/installopts/InstallOptionsModel.kt +++ b/app/src/main/kotlin/com/aliucord/manager/ui/screens/installopts/InstallOptionsModel.kt @@ -86,7 +86,9 @@ class InstallOptionsModel( appName = appName, packageName = packageName, debuggable = debuggable, - replaceIcon = replaceIcon, // TODO: advanced icon patchers + // TODO: advanced icon options + iconReplacement = InstallOptions.IconReplacement.Aliucord, + monochromeIcon = true, ) } diff --git a/app/src/main/kotlin/com/aliucord/manager/util/Context.kt b/app/src/main/kotlin/com/aliucord/manager/util/Context.kt index 7e7f893..5af069e 100644 --- a/app/src/main/kotlin/com/aliucord/manager/util/Context.kt +++ b/app/src/main/kotlin/com/aliucord/manager/util/Context.kt @@ -7,11 +7,14 @@ import android.net.Uri import android.os.* import android.provider.Settings import android.util.Log +import android.util.TypedValue import android.widget.Toast +import androidx.annotation.AnyRes import androidx.annotation.StringRes import com.aliucord.manager.BuildConfig import com.aliucord.manager.R import java.io.File +import java.io.InputStream fun Context.copyToClipboard(text: String) { val clipboard = getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager @@ -86,3 +89,24 @@ fun Context.requestNoBatteryOptimizations() { startActivity(intent) } + +/** + * Get the raw bytes for a resource. + * @param id The resource identifier + * @return The resource's raw bytes as stored inside the APK + */ +fun Context.getResBytes(@AnyRes id: Int): ByteArray { + val tValue = TypedValue() + this.resources.getValue( + /* id = */ id, + /* outValue = */ tValue, + /* resolveRefs = */ true, + ) + + val resPath = tValue.string.toString() + + return this.javaClass.classLoader + ?.getResourceAsStream(resPath) + ?.use(InputStream::readBytes) + ?: error("Failed to get resource file $resPath from APK") +} diff --git a/app/src/main/kotlin/com/aliucord/manager/util/Utils.kt b/app/src/main/kotlin/com/aliucord/manager/util/Utils.kt index ba716ee..9acd3e0 100644 --- a/app/src/main/kotlin/com/aliucord/manager/util/Utils.kt +++ b/app/src/main/kotlin/com/aliucord/manager/util/Utils.kt @@ -1,5 +1,6 @@ package com.aliucord.manager.util +import androidx.collection.ObjectList import kotlin.math.pow import kotlin.math.truncate @@ -10,3 +11,12 @@ fun Double.toPrecision(decimals: Int): Double { val multiplier = 10.0.pow(decimals) return truncate(this * multiplier) / multiplier } + +inline fun ObjectList.find(block: (E) -> Boolean): E? { + forEach { value -> + if (block(value)) + return value + } + + return null +} diff --git a/app/src/main/res/drawable/ic_discord_monochrome.xml b/app/src/main/res/drawable/ic_discord_monochrome.xml new file mode 100644 index 0000000..fa30732 --- /dev/null +++ b/app/src/main/res/drawable/ic_discord_monochrome.xml @@ -0,0 +1,12 @@ + + + + + + diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 077bfef..7cda3d7 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,12 +1,13 @@ [versions] accompanist = "0.32.0" -agp = "8.2.1" +agp = "8.2.2" androidx-activity = "1.8.2" androidx-core = "1.12.0" androidx-splashscreen = "1.0.1" androidx-lifecycle = "2.7.0" apksig = "7.4.2" axml = "1.0.1" +binary-resources = "1.0.0" bouncycastle = "1.70" coil = "2.5.0" compose = "1.5.4" @@ -19,7 +20,7 @@ kotlinx-immutable = "0.3.7" kotlinx-serialization = "1.5.0" ktor = "2.3.7" voyager = "1.0.0" -zip = "2.1.0" +zip = "2.1.1" [libraries] # Accompanist @@ -68,6 +69,7 @@ voyager-koin = { module = "cafe.adriel.voyager:voyager-koin", version.ref = "voy # Other apksig = { module = "com.android.tools.build:apksig", version.ref = "apksig" } axml = { module = "com.aliucord:axml", version.ref = "axml" } +binaryResources = { module = "com.aliucord:binary-resources", version.ref = "binary-resources" } bouncycastle = { module = "org.bouncycastle:bcpkix-jdk15on", version.ref = "bouncycastle" } coil = { module = "io.coil-kt:coil-compose", version.ref = "coil" } zip = { module = "io.github.diamondminer88:zip-android", version.ref = "zip" } # Only use the "aar" artifact