Skip to content

Commit

Permalink
feat: new icon patcher (#71)
Browse files Browse the repository at this point in the history
  • Loading branch information
rushiiMachine authored Mar 15, 2024
1 parent 4b820e5 commit ce7f726
Show file tree
Hide file tree
Showing 14 changed files with 541 additions and 24 deletions.
1 change: 1 addition & 0 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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") })
}
Expand Down
1 change: 1 addition & 0 deletions app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -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">
Expand Down
Binary file removed app/src/main/assets/icons/ic_logo_foreground.png
Binary file not shown.
Binary file removed app/src/main/assets/icons/ic_logo_round.png
Binary file not shown.
Binary file removed app/src/main/assets/icons/ic_logo_square.png
Binary file not shown.
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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<CopyDependenciesStep>().patchedApk
val arsc = ArscUtil.readArsc(apk)

ZipWriter(apk, /* append = */ true).use {
val foregroundIcon = readAsset("icons/ic_logo_foreground.png")
val squareIcon = readAsset("icons/ic_logo_square.png")
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)
}

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
// 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 ((files, replacement) in replacements) {
for (file in files) {
val path = "res/$file"
it.deleteEntry(path)
it.writeEntry(path, replacement)
}
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 {
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)
}
155 changes: 155 additions & 0 deletions app/src/main/kotlin/com/aliucord/manager/installer/util/ArscUtil.kt
Original file line number Diff line number Diff line change
@@ -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
}
}
Loading

0 comments on commit ce7f726

Please sign in to comment.