From 7c2a422094c47f71b7062431bc9ab4fc04bfd03e Mon Sep 17 00:00:00 2001 From: rushiiMachine <33725716+rushiiMachine@users.noreply.github.com> Date: Sun, 25 May 2025 13:40:12 -0700 Subject: [PATCH] feat: patch icons on Android <8 (no adaptive icons) --- .../manager/patcher/KotlinPatchRunner.kt | 2 +- .../{ReplaceIconStep.kt => PatchIconsStep.kt} | 115 ++++++++++++++++-- .../aliucord/manager/patcher/util/ArscUtil.kt | 29 ++++- .../ui/screens/iconopts/IconOptionsScreen.kt | 17 ++- .../screens/patchopts/PatchOptionsScreen.kt | 1 - app/src/main/res/drawable/ic_discord.xml | 11 +- .../res/drawable/ic_discord_monochrome.xml | 2 +- 7 files changed, 150 insertions(+), 27 deletions(-) rename app/src/main/kotlin/com/aliucord/manager/patcher/steps/patch/{ReplaceIconStep.kt => PatchIconsStep.kt} (58%) diff --git a/app/src/main/kotlin/com/aliucord/manager/patcher/KotlinPatchRunner.kt b/app/src/main/kotlin/com/aliucord/manager/patcher/KotlinPatchRunner.kt index fe19524..e035706 100644 --- a/app/src/main/kotlin/com/aliucord/manager/patcher/KotlinPatchRunner.kt +++ b/app/src/main/kotlin/com/aliucord/manager/patcher/KotlinPatchRunner.kt @@ -27,7 +27,7 @@ class KotlinPatchRunner(options: PatchOptions) : StepRunner() { // Patch SmaliPatchStep(), - ReplaceIconStep(options), + PatchIconsStep(options), PatchManifestStep(options), ReorganizeDexStep(), AddAliuhookLibsStep(), diff --git a/app/src/main/kotlin/com/aliucord/manager/patcher/steps/patch/ReplaceIconStep.kt b/app/src/main/kotlin/com/aliucord/manager/patcher/steps/patch/PatchIconsStep.kt similarity index 58% rename from app/src/main/kotlin/com/aliucord/manager/patcher/steps/patch/ReplaceIconStep.kt rename to app/src/main/kotlin/com/aliucord/manager/patcher/steps/patch/PatchIconsStep.kt index 028fcde..bc2635f 100644 --- a/app/src/main/kotlin/com/aliucord/manager/patcher/steps/patch/ReplaceIconStep.kt +++ b/app/src/main/kotlin/com/aliucord/manager/patcher/steps/patch/PatchIconsStep.kt @@ -1,8 +1,17 @@ package com.aliucord.manager.patcher.steps.patch import android.content.Context +import android.graphics.* +import android.graphics.drawable.InsetDrawable import android.os.Build import androidx.compose.runtime.Stable +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.toArgb +import androidx.core.content.ContextCompat +import androidx.core.graphics.applyCanvas +import androidx.core.graphics.createBitmap +import androidx.core.graphics.drawable.RoundedBitmapDrawableFactory +import androidx.core.graphics.drawable.toBitmap import com.aliucord.manager.R import com.aliucord.manager.patcher.StepRunner import com.aliucord.manager.patcher.steps.StepGroup @@ -15,15 +24,17 @@ import com.aliucord.manager.patcher.util.ArscUtil.addResource import com.aliucord.manager.patcher.util.ArscUtil.getMainArscChunk import com.aliucord.manager.patcher.util.ArscUtil.getPackageChunk import com.aliucord.manager.patcher.util.ArscUtil.getResourceFileName +import com.aliucord.manager.patcher.util.ArscUtil.getResourceFileNames import com.aliucord.manager.patcher.util.AxmlUtil import com.aliucord.manager.ui.screens.patchopts.PatchOptions import com.aliucord.manager.ui.screens.patchopts.PatchOptions.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 com.google.devrel.gmscore.tools.apk.arsc.* import org.koin.core.component.KoinComponent import org.koin.core.component.inject +import java.io.ByteArrayOutputStream +import java.io.File /** * Patch the mipmap-v26 launcher icons' background, foreground and monochrome attributes, @@ -32,21 +43,23 @@ import org.koin.core.component.inject * so that this works on pretty much almost any APK. */ @Stable -class ReplaceIconStep(private val options: PatchOptions) : Step(), KoinComponent { +class PatchIconsStep(private val options: PatchOptions) : Step(), KoinComponent { private val context: Context by inject() override val group = StepGroup.Patch override val localizedName = R.string.patch_step_patch_icon + // Adaptive icons are available starting with Android 8 + private val isAdaptiveIconsAvailable = Build.VERSION.SDK_INT >= 28 + + // Monochrome icons are always patched starting with Android 12 + private val isMonochromeIconsAvailable = Build.VERSION.SDK_INT >= 31 + override suspend fun execute(container: StepRunner) { - val isAdaptiveIconsAvailable = Build.VERSION.SDK_INT >= 28 - val isMonochromeIconsAvailable = Build.VERSION.SDK_INT >= 31 container.log("isAdaptiveIconsAvailable: $isAdaptiveIconsAvailable, isMonochromeIconsAvailable: $isMonochromeIconsAvailable") - // Adaptive icons are available starting with Android 8 - // Monochrome icons are always patched starting with Android 12 - // Skip if adaptive icons are unavailable or { not patching main icon AND monochrome icons are unavailable } - if (!isAdaptiveIconsAvailable || (!isMonochromeIconsAvailable && options.iconReplacement is IconReplacement.Original)) { + // Skip if not patching main icon AND monochrome icons are unavailable + if (!isMonochromeIconsAvailable && options.iconReplacement is IconReplacement.Original) { container.log("No patching necessary, skipping step") state = StepState.Skipped return @@ -56,6 +69,26 @@ class ReplaceIconStep(private val options: PatchOptions) : Step(), KoinComponent val apk = container.getStep().patchedApk val arsc = ArscUtil.readArsc(apk) + if (isAdaptiveIconsAvailable) { + patchAdaptiveIcons( + container = container, + apk = apk, + arsc = arsc, + ) + } else { + patchRawIcons( + container = container, + apk = apk, + arsc = arsc, + ) + } + } + + private fun patchAdaptiveIcons( + container: StepRunner, + apk: File, + arsc: BinaryResourceFile, + ) { container.log("Parsing AndroidManifest.xml and obtaining adaptive square/round icon file paths") val iconRscIds = AxmlUtil.readManifestIconInfo(apk) val squareIconFile = arsc.getMainArscChunk().getResourceFileName(iconRscIds.squareIcon, "anydpi-v26") @@ -130,8 +163,70 @@ class ReplaceIconStep(private val options: PatchOptions) : Step(), KoinComponent container.log("Writing resources unaligned compressed") it.deleteEntry("resources.arsc") - // This doesn't need to be aligned and uncompressed here, since it that is done during AlignmentSte + // This doesn't need to be aligned and uncompressed here, since it that is done during AlignmentStep it.writeEntry("resources.arsc", arsc.toByteArray()) } } + + private fun patchRawIcons( + container: StepRunner, + apk: File, + arsc: BinaryResourceFile, + ) { + container.log("Parsing AndroidManifest.xml and obtaining all square/round launcher icon file paths") + val iconRscIds = AxmlUtil.readManifestIconInfo(apk) + val squareIconFiles = arsc.getMainArscChunk().getResourceFileNames(iconRscIds.squareIcon) + val roundIconFiles = arsc.getMainArscChunk().getResourceFileNames(iconRscIds.roundIcon) + val allIconFiles = squareIconFiles + roundIconFiles + + val backgroundColor = when (options.iconReplacement) { + IconReplacement.Original -> IconReplacement.Aliucord.color + is IconReplacement.CustomColor -> options.iconReplacement.color + is IconReplacement.CustomImage -> error("Cannot patch custom images below Android 8 (Adaptive Icons are required)") + } + + container.log("Generating static launcher icon") + val icon = createDiscordLauncherIcon(backgroundColor) + val iconBytes = ByteArrayOutputStream().use { + icon.compress(Bitmap.CompressFormat.PNG, 0, it) + icon.recycle() + it.toByteArray() + } + + container.log("Writing patched icons back to apk") + ZipWriter(apk, /* append = */ true).use { + it.deleteEntries(allIconFiles) + + for (path in allIconFiles) + it.writeEntry(path, iconBytes) + } + } + + /** + * Draws the Discord icon over a custom colored background and rounds it + * to create a launcher icon like Discord's. This is used for devices that + * do not support adaptive icons. + */ + private fun createDiscordLauncherIcon(backgroundColor: Color, size: Int = 192): Bitmap { + val paint = Paint().apply { + style = Paint.Style.FILL + setColor(backgroundColor.toArgb()) + } + + val vectorDrawable = ContextCompat.getDrawable(context, R.drawable.ic_discord)!! + val drawable = InsetDrawable(vectorDrawable, (size * .17f).toInt()) + + val icon = createBitmap(size, size).applyCanvas { + drawRect(Rect(0, 0, width, height), paint) + drawable.setBounds(0, 0, width, height) + drawable.draw(this) + } + val iconRound = RoundedBitmapDrawableFactory.create(context.resources, icon).apply { + isCircular = true + setAntiAlias(true) + }.toBitmap() + + icon.recycle() + return iconRound + } } diff --git a/app/src/main/kotlin/com/aliucord/manager/patcher/util/ArscUtil.kt b/app/src/main/kotlin/com/aliucord/manager/patcher/util/ArscUtil.kt index 241e57a..2d9fb56 100644 --- a/app/src/main/kotlin/com/aliucord/manager/patcher/util/ArscUtil.kt +++ b/app/src/main/kotlin/com/aliucord/manager/patcher/util/ArscUtil.kt @@ -125,7 +125,7 @@ object ArscUtil { * * @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. + * @return The string value of the resource, which should be a file path into the apk. */ fun ResourceTableChunk.getResourceFileName( resourceId: BinaryResourceIdentifier, @@ -152,4 +152,31 @@ object ArscUtil { return value } + + /** + * In an arsc file, for a specific resource in all configurations, get all values. + * + * @param resourceId The target resource id. + * @return The string value of all configuration values of the resource, which should be file paths into the apk. + */ + fun ResourceTableChunk.getResourceFileNames(resourceId: BinaryResourceIdentifier): List { + val packageChunk = this.packages.find { it.id == resourceId.packageId() } + ?: error("Unable to find target resource") + + val typeChunks = packageChunk.getTypeChunks(resourceId.typeId()) + val entries = typeChunks.map { typeChunk -> + val entry = typeChunk.getEntry(resourceId.entryId()) + ?: error("Unable to find target resource in type chunk " + typeChunk.configuration) + + if (entry.isComplex || entry.value().type() != BinaryResourceValue.Type.STRING) + error("Target resource value type in type chunk ${typeChunk.configuration} is not STRING") + + entry + } + + return entries.map { entry -> + val valueIdx = entry.value().data() + this.stringPool.getString(valueIdx) + } + } } diff --git a/app/src/main/kotlin/com/aliucord/manager/ui/screens/iconopts/IconOptionsScreen.kt b/app/src/main/kotlin/com/aliucord/manager/ui/screens/iconopts/IconOptionsScreen.kt index f691308..8439b24 100644 --- a/app/src/main/kotlin/com/aliucord/manager/ui/screens/iconopts/IconOptionsScreen.kt +++ b/app/src/main/kotlin/com/aliucord/manager/ui/screens/iconopts/IconOptionsScreen.kt @@ -1,6 +1,7 @@ package com.aliucord.manager.ui.screens.iconopts import android.net.Uri +import android.os.Build import android.os.Parcelable import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.PickVisualMediaRequest @@ -74,6 +75,8 @@ fun IconOptionsScreenContent( selectedImage: () -> ByteArray?, setSelectedImage: (Uri) -> Unit, ) { + val isAdaptiveIconsAvailable = Build.VERSION.SDK_INT >= 28 + Scaffold( topBar = { IconOptionsAppBar() }, ) { paddingValues -> @@ -107,12 +110,14 @@ fun IconOptionsScreenContent( selected = mode == IconOptionsMode.CustomColor, onClick = remember { { setMode(IconOptionsMode.CustomColor) } }, ) - RadioSelectorItem( - name = stringResource(R.string.iconopts_variant_title_image), - description = stringResource(R.string.iconopts_variant_desc_image), - selected = mode == IconOptionsMode.CustomImage, - onClick = remember { { setMode(IconOptionsMode.CustomImage) } }, - ) + if (isAdaptiveIconsAvailable) { + RadioSelectorItem( + name = stringResource(R.string.iconopts_variant_title_image), + description = stringResource(R.string.iconopts_variant_desc_image), + selected = mode == IconOptionsMode.CustomImage, + onClick = remember { { setMode(IconOptionsMode.CustomImage) } }, + ) + } HorizontalDivider(Modifier.padding(vertical = 16.dp)) diff --git a/app/src/main/kotlin/com/aliucord/manager/ui/screens/patchopts/PatchOptionsScreen.kt b/app/src/main/kotlin/com/aliucord/manager/ui/screens/patchopts/PatchOptionsScreen.kt index 53205ef..435ad97 100644 --- a/app/src/main/kotlin/com/aliucord/manager/ui/screens/patchopts/PatchOptionsScreen.kt +++ b/app/src/main/kotlin/com/aliucord/manager/ui/screens/patchopts/PatchOptionsScreen.kt @@ -142,7 +142,6 @@ fun PatchOptionsScreenContent( TextDivider(text = stringResource(R.string.patchopts_divider_basic)) - // TODO: hide when below to Android 8 IconPatchOption( icon = painterResource(R.drawable.ic_app_shortcut), name = stringResource(R.string.patchopts_icon_title), diff --git a/app/src/main/res/drawable/ic_discord.xml b/app/src/main/res/drawable/ic_discord.xml index ae265d4..a753f01 100644 --- a/app/src/main/res/drawable/ic_discord.xml +++ b/app/src/main/res/drawable/ic_discord.xml @@ -1,12 +1,9 @@ - - + android:height="24dp" + android:viewportWidth="256" + android:viewportHeight="256"> - + android:pathData="M216.86,45.1C200.29,37.34 182.57,31.71 164.04,28.5C161.77,32.61 159.11,38.15 157.28,42.55C137.58,39.58 118.07,39.58 98.74,42.55C96.91,38.15 94.19,32.61 91.9,28.5C73.35,31.71 55.61,37.36 39.04,45.14C5.62,95.65 -3.44,144.9 1.09,193.46C23.26,210.01 44.74,220.07 65.86,226.65C71.08,219.47 75.73,211.84 79.74,203.8C72.1,200.9 64.79,197.32 57.89,193.17C59.72,191.81 61.51,190.39 63.24,188.93C105.37,208.63 151.13,208.63 192.75,188.93C194.51,190.39 196.3,191.81 198.11,193.17C191.18,197.34 183.85,200.92 176.22,203.82C180.23,211.84 184.86,219.49 190.1,226.67C211.24,220.09 232.74,210.03 254.91,193.46C260.23,137.17 245.83,88.37 216.86,45.1ZM85.47,163.59C72.83,163.59 62.46,151.79 62.46,137.41C62.46,123.04 72.61,111.21 85.47,111.21C98.34,111.21 108.71,123.02 108.49,137.41C108.51,151.79 98.34,163.59 85.47,163.59ZM170.53,163.59C157.88,163.59 147.51,151.79 147.51,137.41C147.51,123.04 157.66,111.21 170.53,111.21C183.39,111.21 193.76,123.02 193.54,137.41C193.54,151.79 183.39,163.59 170.53,163.59Z" /> diff --git a/app/src/main/res/drawable/ic_discord_monochrome.xml b/app/src/main/res/drawable/ic_discord_monochrome.xml index fa30732..917e8c8 100644 --- a/app/src/main/res/drawable/ic_discord_monochrome.xml +++ b/app/src/main/res/drawable/ic_discord_monochrome.xml @@ -6,7 +6,7 @@