feat: patch icons on Android <8 (no adaptive icons)

This commit is contained in:
rushiiMachine 2025-05-25 13:40:12 -07:00
parent 5edc39f289
commit 7c2a422094
No known key found for this signature in database
GPG key ID: DCBE5952BB3B6420
7 changed files with 150 additions and 27 deletions

View file

@ -27,7 +27,7 @@ class KotlinPatchRunner(options: PatchOptions) : StepRunner() {
// Patch
SmaliPatchStep(),
ReplaceIconStep(options),
PatchIconsStep(options),
PatchManifestStep(options),
ReorganizeDexStep(),
AddAliuhookLibsStep(),

View file

@ -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<CopyDependenciesStep>().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
}
}

View file

@ -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<String> {
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)
}
}
}

View file

@ -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))

View file

@ -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),

View file

@ -1,12 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="19dp"
android:viewportWidth="71"
android:viewportHeight="55">
<group>
<clip-path android:pathData="M0,0h71v55h-71z" />
android:height="24dp"
android:viewportWidth="256"
android:viewportHeight="256">
<path
android:fillColor="#FFFFFFFF"
android:pathData="M60.1045,4.8978C55.5792,2.8214 50.7265,1.2916 45.6527,0.4154C45.5603,0.3985 45.468,0.4408 45.4204,0.5253C44.7963,1.6353 44.105,3.0834 43.6209,4.2216C38.1637,3.4046 32.7345,3.4046 27.3892,4.2216C26.905,3.0581 26.1886,1.6353 25.5617,0.5253C25.5141,0.4436 25.4218,0.4013 25.3294,0.4154C20.2584,1.2888 15.4057,2.8186 10.8776,4.8978C10.8384,4.9147 10.8048,4.9429 10.7825,4.9795C1.578,18.7309 -0.9436,32.1443 0.2934,45.3914C0.299,45.4562 0.3354,45.5182 0.3858,45.5576C6.4587,50.0174 12.3413,52.7249 18.1147,54.5195C18.2071,54.5477 18.305,54.5139 18.3638,54.4378C19.7295,52.5728 20.9469,50.6063 21.9907,48.5383C22.0523,48.4172 21.9935,48.2735 21.8676,48.2256C19.9366,47.4931 18.0979,46.6 16.3292,45.5858C16.1893,45.5041 16.1781,45.304 16.3068,45.2082C16.679,44.9293 17.0513,44.6391 17.4067,44.3461C17.471,44.2926 17.5606,44.2813 17.6362,44.3151C29.2558,49.6202 41.8354,49.6202 53.3179,44.3151C53.3935,44.2785 53.4831,44.2898 53.5502,44.3433C53.9057,44.6363 54.2779,44.9293 54.6529,45.2082C54.7816,45.304 54.7732,45.5041 54.6333,45.5858C52.8646,46.6197 51.0259,47.4931 49.0921,48.2228C48.9662,48.2707 48.9102,48.4172 48.9718,48.5383C50.038,50.6034 51.2554,52.5699 52.5959,54.435C52.6519,54.5139 52.7526,54.5477 52.845,54.5195C58.6464,52.7249 64.529,50.0174 70.6019,45.5576C70.6551,45.5182 70.6887,45.459 70.6943,45.3942C72.1747,30.0791 68.2147,16.7757 60.1968,4.9823C60.1772,4.9429 60.1437,4.9147 60.1045,4.8978ZM23.7259,37.3253C20.2276,37.3253 17.3451,34.1136 17.3451,30.1693C17.3451,26.225 20.1717,23.0133 23.7259,23.0133C27.308,23.0133 30.1626,26.2532 30.1066,30.1693C30.1066,34.1136 27.28,37.3253 23.7259,37.3253ZM47.3178,37.3253C43.8196,37.3253 40.9371,34.1136 40.9371,30.1693C40.9371,26.225 43.7636,23.0133 47.3178,23.0133C50.9,23.0133 53.7545,26.2532 53.6986,30.1693C53.6986,34.1136 50.9,37.3253 47.3178,37.3253Z" />
</group>
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" />
</vector>

View file

@ -6,7 +6,7 @@
<group>
<clip-path android:pathData="M31.56,36.63l44.88,0l0,34.77l-44.88,0z" />
<path
android:fillColor="#5865f2"
android:fillColor="#FFFFFFFF"
android:pathData="M69.55,39.73C66.69,38.42 63.63,37.45 60.42,36.9 60.36,36.89 60.3,36.91 60.27,36.97 59.88,37.67 59.44,38.58 59.13,39.3 55.68,38.79 52.25,38.79 48.87,39.3 48.57,38.57 48.11,37.67 47.72,36.97 47.69,36.91 47.63,36.89 47.57,36.9 44.36,37.45 41.3,38.42 38.43,39.73 38.41,39.74 38.39,39.76 38.37,39.78 32.56,48.48 30.96,56.95 31.74,65.33c0,0.04 0.03,0.08 0.06,0.11 3.84,2.82 7.56,4.53 11.21,5.67 0.06,0.02 0.12,-0 0.16,-0.05 0.86,-1.18 1.63,-2.42 2.29,-3.73 0.04,-0.08 0,-0.17 -0.08,-0.2 -1.22,-0.46 -2.38,-1.03 -3.5,-1.67 -0.09,-0.05 -0.1,-0.18 -0.01,-0.24 0.24,-0.18 0.47,-0.36 0.7,-0.54 0.04,-0.03 0.1,-0.04 0.15,-0.02 7.35,3.35 15.3,3.35 22.56,0 0.05,-0.02 0.1,-0.02 0.15,0.02 0.22,0.19 0.46,0.37 0.7,0.55 0.08,0.06 0.08,0.19 -0.01,0.24 -1.12,0.65 -2.28,1.21 -3.5,1.67 -0.08,0.03 -0.11,0.12 -0.08,0.2 0.67,1.31 1.44,2.55 2.29,3.73 0.04,0.05 0.1,0.07 0.16,0.05 3.67,-1.13 7.39,-2.85 11.23,-5.67 0.03,-0.02 0.05,-0.06 0.06,-0.1C77.18,55.65 74.68,47.24 69.61,39.78 69.6,39.76 69.58,39.74 69.55,39.73ZM46.56,60.23c-2.21,0 -4.03,-2.03 -4.03,-4.52 0,-2.49 1.79,-4.52 4.03,-4.52 2.26,0 4.07,2.05 4.03,4.52 0,2.49 -1.79,4.52 -4.03,4.52zM61.47,60.23c-2.21,0 -4.03,-2.03 -4.03,-4.52 0,-2.49 1.79,-4.52 4.03,-4.52 2.26,0 4.07,2.05 4.03,4.52 0,2.49 -1.77,4.52 -4.03,4.52z" />
</group>
</vector>