feat: new icon patcher (#71)
This commit is contained in:
parent
4b820e594e
commit
ce7f726ee8
14 changed files with 543 additions and 26 deletions
|
|
@ -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") })
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
Binary file not shown.
|
Before Width: | Height: | Size: 6.1 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 4.5 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 4.5 KiB |
|
|
@ -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<CopyDependenciesStep>().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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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 <adaptive-icon> 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 <background> with.
|
||||
*
|
||||
* @param foregroundIcon A drawable resource id to replace <foreground> with.
|
||||
* @param monochromeIcon A drawable resource id to add or replace <monochrome> 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) {
|
||||
// <monochrome> 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
|
||||
// `<monochrome android:drawable="@drawable/xyz"></monochrome>
|
||||
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 <application> 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,
|
||||
)
|
||||
}
|
||||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 <E> ObjectList<E>.find(block: (E) -> Boolean): E? {
|
||||
forEach { value ->
|
||||
if (block(value))
|
||||
return value
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
|
|
|||
12
app/src/main/res/drawable/ic_discord_monochrome.xml
Normal file
12
app/src/main/res/drawable/ic_discord_monochrome.xml
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportWidth="108"
|
||||
android:viewportHeight="108">
|
||||
<group>
|
||||
<clip-path android:pathData="M31.56,36.63l44.88,0l0,34.77l-44.88,0z" />
|
||||
<path
|
||||
android:fillColor="#5865f2"
|
||||
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>
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue