feat(wintry): Implement Wintry patcher

This commit introduces a new patcher for WintryXposed. `minSdk` is bumped to 28 to match LSPosed deps
This commit is contained in:
pylixonly 2025-07-13 23:02:58 +08:00
parent 457347167d
commit 5ef7946a9c
11 changed files with 309 additions and 192 deletions

View file

@ -22,7 +22,7 @@ android {
compileSdk = 36
defaultConfig {
minSdk = 24
minSdk = 28
targetSdk = 36
versionCode = 10_00_02
versionName = "1.0.2"
@ -169,6 +169,8 @@ dependencies {
implementation(libs.bundles.ktor)
implementation(libs.bundles.voyager)
implementation(files("libs/lspatch.aar"))
implementation(libs.compose.ui.tooling.preview)
debugImplementation(libs.compose.ui.tooling)
debugImplementation(libs.compose.runtime.tracing)
@ -191,6 +193,28 @@ dependencies {
coreLibraryDesugaring(libs.desugaring)
}
configurations.all {
// ---- Previous Exclusions ----
exclude(group = "org.bouncycastle")
exclude(group = "org.checkerframework", module = "checker-qual")
exclude(group = "com.aliucord", module = "axml")
// ---- New Exclusions ----
// Exclude apksig, as it's bundled in LSPatch
exclude(group = "com.android.tools.build", module = "apksig")
// Exclude jcommander
exclude(group = "com.beust", module = "jcommander")
// Exclude Google Guava and its related modules
exclude(group = "com.google.guava")
// Exclude other common utility/annotation libraries
exclude(group = "com.google.errorprone", module = "error_prone_annotations")
exclude(group = "com.google.j2objc", module = "j2objc-annotations")
exclude(group = "com.google.code.findbugs", module = "jsr305")
}
fun ProviderFactory.execIgnoreCode(vararg command: String): String = run {
val result = exec {
commandLine = command.toList()

View file

@ -125,7 +125,7 @@ class PathManager(
/**
* The APK that is worked on during the patching process.
*/
fun patchedApk(part: String = "") = patchingWorkingDir().resolve("patched-${part}.apk")
fun patchedApk(part: String = "base") = patchingWorkingDir().resolve("patched-$part.apk")
private companion object {
/**

View file

@ -1,6 +1,5 @@
package com.aliucord.manager.patcher
import com.aliucord.manager.patcher.steps.base.Step
import com.aliucord.manager.patcher.steps.download.*
import com.aliucord.manager.patcher.steps.install.*
import com.aliucord.manager.patcher.steps.patch.*
@ -8,21 +7,23 @@ import com.aliucord.manager.ui.screens.patchopts.PatchOptions
import kotlinx.collections.immutable.persistentListOf
class WintryPatchRunner(options: PatchOptions) : StepRunner() {
override val steps = persistentListOf<Step>(
override val steps = persistentListOf(
DownloadDiscordRNABaseStep(),
DownloadDiscordRNALangStep(),
DownloadDiscordRNAResourcesStep(),
DownloadDiscordRNALibStep(),
DownloadWintryXposed(),
CopyDependenciesStep(),
PatchIconsStep(options),
PatchManifestStep(options),
// ReorganizeDexStep(),
PatchWintryManifestStep(options),
SaveMetadataStep(options),
AlignmentStep(),
SigningStep(),
InjectWintryXposedStep(),
InstallStep(options),
// CleanupStep(),
CleanupStep(),
)
}

View file

@ -72,9 +72,10 @@ class CopyDependenciesStep : Step(), KoinComponent {
}
}
fun copyApkSafely(src: File?, destFile: File, onCopied: (File) -> Unit) {
fun copyApkSafely(src: File?, part: String, onCopied: (File) -> Unit) {
src?.let {
container.log("Copying patched apk from ${it.absolutePath} to ${destFile.absolutePath}")
container.log("Copying patched apk from ${it.absolutePath} to $part")
val destFile = File(dir, paths.patchedApk(part).name)
it.copyTo(destFile)
onCopied(destFile)
}
@ -85,8 +86,8 @@ class CopyDependenciesStep : Step(), KoinComponent {
srcApk.copyTo(patchedApk)
// Optional APKs
copyApkSafely(langApk, File(dir, "lang")) { patchedLangApk = it }
copyApkSafely(libApk, File(dir, "lib")) { patchedLibApk = it }
copyApkSafely(resApk, File(dir, "res")) { patchedResApk = it }
copyApkSafely(langApk, "lang") { patchedLangApk = it }
copyApkSafely(libApk, "lib") { patchedLibApk = it }
copyApkSafely(resApk, "res") { patchedResApk = it }
}
}

View file

@ -0,0 +1,14 @@
package com.aliucord.manager.patcher.steps.download
import com.aliucord.manager.R
import com.aliucord.manager.manager.PathManager
import com.aliucord.manager.patcher.steps.base.DownloadStep
import org.koin.core.component.inject
class DownloadWintryXposed : DownloadStep() {
private val paths: PathManager by inject()
override val localizedName = R.string.downloading_wintryxposed
override val targetFile = paths.patchingDownloadDir.resolve("xposed.apk")
override val targetUrl = "https://github.com/wtcord/wt-xposed/releases/download/1002/app-release.apk"
}

View file

@ -0,0 +1,99 @@
package com.aliucord.manager.patcher.steps.install
import com.aliucord.manager.R
import com.aliucord.manager.patcher.StepRunner
import com.aliucord.manager.patcher.steps.StepGroup
import com.aliucord.manager.patcher.steps.base.Step
import com.aliucord.manager.patcher.steps.download.CopyDependenciesStep
import com.aliucord.manager.patcher.steps.download.DownloadWintryXposed
import com.aliucord.manager.patcher.util.Signer
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.lsposed.lspatch.share.LSPConfig
import org.lsposed.patch.LSPatch
import org.lsposed.patch.util.Logger
import java.io.File
class InjectWintryXposedStep : Step() {
override val group: StepGroup = StepGroup.Install
override val localizedName: Int = R.string.injecting_wintryxposed_with_lspatch
suspend fun patch(
container: StepRunner,
outputDir: File,
apkPaths: List<String>,
embeddedModules: List<String>,
) {
withContext(Dispatchers.IO) {
LSPatch(
object : Logger() {
override fun d(p0: String?) {
container.log("[LSPatch:D] $p0")
}
override fun e(p0: String?) {
container.log("[LSPatch:E] $p0")
}
override fun i(p0: String?) {
container.log("[LSPatch] $p0")
}
},
*apkPaths.toTypedArray(),
"-o",
outputDir.absolutePath,
"-l",
"0",
"-v",
"-m",
*embeddedModules.toTypedArray(),
"-k",
Signer.getKeystoreFile().absolutePath,
"password",
"alias",
"password"
).doCommandLine()
}
}
override suspend fun execute(container: StepRunner) {
val apks = container.getStep<CopyDependenciesStep>().patchedApks
val xposed = container.getStep<DownloadWintryXposed>().targetFile
container.log("Adding WintryXposed module with LSPatch")
container.log("WintryXposed path = ${xposed.absolutePath}")
// Create temporary folder in working directory
val tempDir = apks.first().parentFile!!.resolve("lspatched")
patch(
container,
outputDir = tempDir,
apkPaths = apks.map { it.absolutePath },
embeddedModules = listOf(xposed.absolutePath)
)
// Process each APK and replace with patched version
apks.forEach { originalApk ->
val baseName = originalApk.nameWithoutExtension
// https://github.com/JingMatrix/LSPatch/blob/b98eaf805018c4cc258ded12efe89212a4855e6a/patch/src/main/java/org/lsposed/patch/LSPatch.java#L159-L163
// String.format(
// Locale.getDefault(), "%s-%d-lspatched.apk",
// FilenameUtils.getBaseName(apkFileName),
// LSPConfig.instance.VERSION_CODE)
// )
val patchedApkName = "${baseName}-${LSPConfig.instance.VERSION_CODE}-lspatched.apk"
val patchedApk = File(tempDir, patchedApkName)
if (patchedApk.exists()) {
patchedApk.copyTo(originalApk, overwrite = true)
container.log("Replaced ${originalApk.name} with ${patchedApk.name}")
} else {
container.log("Warning: Could not find patched APK for ${originalApk.name}")
container.log("Expected patched APK at: ${patchedApk.absolutePath}")
}
}
tempDir.deleteRecursively()
}
}

View file

@ -13,36 +13,32 @@ import com.github.diamondminer88.zip.ZipWriter
/**
* Patch the APK's AndroidManifest.xml
*/
class PatchManifestStep(private val options: PatchOptions) : Step() {
open class PatchManifestStep(private val options: PatchOptions) : Step() {
override val group = StepGroup.Patch
override val localizedName = R.string.patch_step_patch_manifests
protected open fun patchManifest(manifestBytes: ByteArray, isBase: Boolean) = ManifestPatcher.patchManifest(
manifestBytes = manifestBytes,
packageName = options.packageName,
appName = options.appName,
debuggable = options.debuggable,
)
override suspend fun execute(container: StepRunner) {
val deps = container.getStep<CopyDependenciesStep>()
for (apk in deps.patchedApks) {
val dependenciesStep = container.getStep<CopyDependenciesStep>()
for (apk in dependenciesStep.patchedApks) {
container.log("Reading manifest from apk")
val manifest = ZipReader(apk)
val manifestBytes = ZipReader(apk)
.use { zip -> zip.openEntry("AndroidManifest.xml")?.read() }
?: throw IllegalArgumentException("No manifest found in APK")
container.log("Patching manifest")
val patchedManifest = if (apk == deps.patchedApk)
ManifestPatcher.patchManifest(
manifestBytes = manifest,
packageName = options.packageName,
appName = options.appName,
debuggable = options.debuggable,
)
else {
ManifestPatcher.patchManifestPackageName(
manifestBytes = manifest,
packageName = options.packageName,
)
}
val isBase = apk == dependenciesStep.patchedApk
val patchedManifest = patchManifest(manifestBytes, isBase)
container.log("Writing patched manifest to apk unaligned compressed")
ZipWriter(apk, /* append = */ true).use {
it.deleteEntry("AndroidManifest.xml", apk == deps.patchedLibApk || apk == deps.patchedApk)
it.deleteEntry("AndroidManifest.xml", apk == dependenciesStep.patchedLibApk || apk == dependenciesStep.patchedApk)
it.writeEntry("AndroidManifest.xml", patchedManifest)
}
}

View file

@ -0,0 +1,18 @@
package com.aliucord.manager.patcher.steps.patch
import com.aliucord.manager.patcher.util.ManifestPatcher
import com.aliucord.manager.ui.screens.patchopts.PatchOptions
class PatchWintryManifestStep(val options: PatchOptions) : PatchManifestStep(options) {
override fun patchManifest(manifestBytes: ByteArray, isBase: Boolean) = ManifestPatcher.patchManifest(
manifestBytes = manifestBytes,
packageName = options.packageName,
compileSdkVersion = null,
compileSdkVersionCodename = null,
targetSdkVersion = null,
useEmbeddedDex = false,
requestLegacyExternalStorage = false,
appName = if (isBase) options.appName else null,
debuggable = options.debuggable,
)
}

View file

@ -24,16 +24,26 @@ object ManifestPatcher {
fun patchManifest(
manifestBytes: ByteArray,
packageName: String,
appName: String,
debuggable: Boolean,
packageName: String? = null,
appName: String? = null,
debuggable: Boolean? = null,
compileSdkVersion: Int? = 23,
compileSdkVersionCodename: String? = "6.0-2438415",
addManageExternalStorage: Boolean? = true,
targetSdkVersion: Int? = if (Build.VERSION.SDK_INT >= 31) 30 else 28,
requestLegacyExternalStorage: Boolean? = true,
vmSafeMode: Boolean? = true,
useEmbeddedDex: Boolean? = true,
extractNativeLibs: Boolean? = false,
addAliucordMetadata: Boolean? = true,
modifyPermissions: Boolean? = true,
modifyProviders: Boolean? = true,
modifyActivities: Boolean? = true,
): ByteArray {
val reader = AxmlReader(manifestBytes)
val writer = AxmlWriter()
reader.accept(object : AxmlVisitor(writer) {
// Without this, decompiling the finished manifest has the android namespace
// under an autogenerated name like axml_00 or something.
override fun ns(prefix: String?, uri: String?, ln: Int) {
val realUri = uri ?: ANDROID_NAMESPACE
super.ns(prefix, realUri, ln)
@ -42,94 +52,98 @@ object ManifestPatcher {
override fun child(ns: String?, name: String?) =
object : ReplaceAttrsVisitor(
super.child(ns, name),
mapOf(
PACKAGE to packageName,
COMPILE_SDK_VERSION to 23,
COMPILE_SDK_VERSION_CODENAME to "6.0-2438415"
)
buildMap {
packageName?.let { put(PACKAGE, it) }
compileSdkVersion?.let { put(COMPILE_SDK_VERSION, it) }
compileSdkVersionCodename?.let { put(COMPILE_SDK_VERSION_CODENAME, it) }
}
) {
private var addExternalStoragePerm = false
private var shouldAddExternalStoragePerm = false
override fun child(ns: String?, name: String): NodeVisitor {
val nv = super.child(ns, name)
// Add MANAGE_EXTERNAL_STORAGE when necessary
if (addExternalStoragePerm) {
if (shouldAddExternalStoragePerm && addManageExternalStorage == true) {
super
.child(null, "uses-permission")
.attr(ANDROID_NAMESPACE, "name", android.R.attr.name, TYPE_STRING, Manifest.permission.MANAGE_EXTERNAL_STORAGE)
addExternalStoragePerm = false
shouldAddExternalStoragePerm = false
}
return when (name) {
"uses-permission" -> object : NodeVisitor(nv) {
override fun attr(ns: String?, name: String?, resourceId: Int, type: Int, value: Any?) {
if (name != "maxSdkVersion") {
super.attr(ns, name, resourceId, type, value)
}
// Set the add external storage permission to be added after WRITE_EXTERNAL_STORAGE (which is after read)
if (name == "name" && value == Manifest.permission.READ_EXTERNAL_STORAGE) {
addExternalStoragePerm = true
}
}
}
"uses-sdk" -> object : NodeVisitor(nv) {
override fun attr(ns: String?, name: String?, resourceId: Int, type: Int, value: Any?) {
if (name == "targetSdkVersion") {
val version = if (Build.VERSION.SDK_INT >= 31) 30 else 28
super.attr(ns, name, resourceId, type, version)
} else {
super.attr(ns, name, resourceId, type, value)
}
}
}
"permission" -> object : NodeVisitor(nv) {
override fun attr(ns: String?, name: String, resourceId: Int, type: Int, value: Any?) {
super.attr(
ns, name, resourceId, type,
when (name) {
"name" -> (value as String).replace("com.discord", packageName)
else -> value
"uses-permission" -> if (modifyPermissions != false) {
object : NodeVisitor(nv) {
override fun attr(ns: String?, name: String?, resourceId: Int, type: Int, value: Any?) {
if (name != "maxSdkVersion") {
super.attr(ns, name, resourceId, type, value)
}
)
// Set the add external storage permission to be added after WRITE_EXTERNAL_STORAGE (which is after read)
if (name == "name" && value == Manifest.permission.READ_EXTERNAL_STORAGE) {
shouldAddExternalStoragePerm = true
}
}
}
}
} else nv
"uses-sdk" -> if (targetSdkVersion != null) {
object : NodeVisitor(nv) {
override fun attr(ns: String?, name: String?, resourceId: Int, type: Int, value: Any?) {
if (name == "targetSdkVersion") {
super.attr(ns, name, resourceId, type, targetSdkVersion)
} else {
super.attr(ns, name, resourceId, type, value)
}
}
}
} else nv
"permission" -> if (modifyPermissions != false && packageName != null) {
object : NodeVisitor(nv) {
override fun attr(ns: String?, name: String, resourceId: Int, type: Int, value: Any?) {
super.attr(
ns, name, resourceId, type,
when (name) {
"name" -> (value as String).replace("com.discord", packageName)
else -> value
}
)
}
}
} else nv
"application" -> object : ReplaceAttrsVisitor(
nv,
mapOf(
LABEL to appName,
DEBUGGABLE to debuggable,
REQUEST_LEGACY_EXTERNAL_STORAGE to true,
VM_SAFE_MODE to true,
USE_EMBEDDED_DEX to true,
EXTRACT_NATIVE_LIBS to false,
)
buildMap {
appName?.let { put(LABEL, it) }
debuggable?.let { put(DEBUGGABLE, it) }
requestLegacyExternalStorage?.let { put(REQUEST_LEGACY_EXTERNAL_STORAGE, it) }
vmSafeMode?.let { put(VM_SAFE_MODE, it) }
useEmbeddedDex?.let { put(USE_EMBEDDED_DEX, it) }
extractNativeLibs?.let { put(EXTRACT_NATIVE_LIBS, !it) } // Inverted since we set to false originally
}
) {
private var addDebuggable = debuggable
private var addLegacyStorage = true
private var addUseEmbeddedDex = true
private var addExtractNativeLibs = true
private var addMetadata = true
private var shouldAddDebuggable = debuggable ?: false
private var shouldAddLegacyStorage = requestLegacyExternalStorage ?: false
private var shouldAddUseEmbeddedDex = useEmbeddedDex ?: false
private var shouldAddExtractNativeLibs = extractNativeLibs ?: false
private var shouldAddMetadata = addAliucordMetadata ?: false
override fun attr(ns: String?, name: String, resourceId: Int, type: Int, value: Any?) {
if (name == NETWORK_SECURITY_CONFIG) return
if (name == REQUEST_LEGACY_EXTERNAL_STORAGE) addLegacyStorage = false
if (name == USE_EMBEDDED_DEX) addUseEmbeddedDex = false
if (name == EXTRACT_NATIVE_LIBS) addExtractNativeLibs = false
if (name == DEBUGGABLE) addDebuggable = false
if (name == REQUEST_LEGACY_EXTERNAL_STORAGE) shouldAddLegacyStorage = false
if (name == USE_EMBEDDED_DEX) shouldAddUseEmbeddedDex = false
if (name == EXTRACT_NATIVE_LIBS) shouldAddExtractNativeLibs = false
if (name == DEBUGGABLE) shouldAddDebuggable = false
super.attr(ns, name, resourceId, type, value)
}
override fun child(ns: String?, name: String): NodeVisitor {
val visitor = super.child(ns, name)
// Adds a <meta-data> tag to make multi-install detection by HomeScreen work
if (addMetadata) {
addMetadata = false
if (shouldAddMetadata && addAliucordMetadata == true) {
shouldAddMetadata = false
super.child(ANDROID_NAMESPACE, "meta-data").apply {
attr(ANDROID_NAMESPACE, "name", android.R.attr.name, TYPE_STRING, "isAliucord")
attr(ANDROID_NAMESPACE, "value", android.R.attr.value, TYPE_INT_BOOLEAN, 1)
@ -137,46 +151,57 @@ object ManifestPatcher {
}
return when (name) {
"activity" -> ReplaceAttrsVisitor(visitor, mapOf("label" to appName))
"provider" -> object : NodeVisitor(visitor) {
override fun attr(ns: String?, name: String, resourceId: Int, type: Int, value: Any?) {
super.attr(
ns, name, resourceId, type,
if (name == "authorities") {
(value as String).replace("com.discord", packageName)
} else {
value
}
)
"activity" -> if (modifyActivities != false && appName != null) {
ReplaceAttrsVisitor(visitor, mapOf("label" to appName))
} else visitor
"provider" -> if (modifyProviders != false && packageName != null) {
object : NodeVisitor(visitor) {
override fun attr(ns: String?, name: String, resourceId: Int, type: Int, value: Any?) {
super.attr(
ns, name, resourceId, type,
if (name == "authorities") {
(value as String).replace("com.discord", packageName)
} else {
value
}
)
}
}
}
} else visitor
else -> visitor
}
}
override fun end() {
if (addLegacyStorage && Build.VERSION.SDK_INT >= 29) super.attr(
ANDROID_NAMESPACE,
REQUEST_LEGACY_EXTERNAL_STORAGE,
android.R.attr.requestLegacyExternalStorage,
TYPE_INT_BOOLEAN,
1
)
if (addDebuggable) super.attr(ANDROID_NAMESPACE, DEBUGGABLE, android.R.attr.debuggable, TYPE_INT_BOOLEAN, 1)
if (shouldAddLegacyStorage && Build.VERSION.SDK_INT >= 29 && requestLegacyExternalStorage == true) {
super.attr(
ANDROID_NAMESPACE,
REQUEST_LEGACY_EXTERNAL_STORAGE,
android.R.attr.requestLegacyExternalStorage,
TYPE_INT_BOOLEAN,
1
)
}
if (shouldAddDebuggable && debuggable == true) {
super.attr(ANDROID_NAMESPACE, DEBUGGABLE, android.R.attr.debuggable, TYPE_INT_BOOLEAN, 1)
}
// Disable AOT (Necessary for AOSP Android 15)
if (Build.VERSION.SDK_INT >= 29 && addUseEmbeddedDex) {
if (Build.VERSION.SDK_INT >= 29 && shouldAddUseEmbeddedDex && useEmbeddedDex == true) {
super.attr(ANDROID_NAMESPACE, USE_EMBEDDED_DEX, android.R.attr.useEmbeddedDex, TYPE_INT_BOOLEAN, 1)
}
if (addExtractNativeLibs) super.attr(
ANDROID_NAMESPACE,
EXTRACT_NATIVE_LIBS,
android.R.attr.extractNativeLibs,
TYPE_INT_BOOLEAN,
0
)
if (shouldAddExtractNativeLibs && extractNativeLibs == false) {
super.attr(
ANDROID_NAMESPACE,
EXTRACT_NATIVE_LIBS,
android.R.attr.extractNativeLibs,
TYPE_INT_BOOLEAN,
0
)
}
super.end()
}
@ -191,72 +216,6 @@ object ManifestPatcher {
return writer.toByteArray()
}
fun patchManifestPackageName(
manifestBytes: ByteArray,
packageName: String,
): ByteArray {
val reader = AxmlReader(manifestBytes)
val writer = AxmlWriter()
reader.accept(object : AxmlVisitor(writer) {
override fun ns(prefix: String?, uri: String?, ln: Int) {
val realUri = uri ?: ANDROID_NAMESPACE
super.ns(prefix, realUri, ln)
}
override fun child(ns: String?, name: String?) =
object : ReplaceAttrsVisitor(
super.child(ns, name),
mapOf(PACKAGE to packageName)
) {
override fun child(ns: String?, name: String): NodeVisitor {
val nv = super.child(ns, name)
return when (name) {
"permission" -> object : NodeVisitor(nv) {
override fun attr(ns: String?, name: String, resourceId: Int, type: Int, value: Any?) {
super.attr(
ns, name, resourceId, type,
if (name == "name") {
(value as String).replace("com.discord", packageName)
} else {
value
}
)
}
}
"application" -> object : NodeVisitor(nv) {
override fun child(ns: String?, name: String): NodeVisitor {
val visitor = super.child(ns, name)
return when (name) {
"provider" -> object : NodeVisitor(visitor) {
override fun attr(ns: String?, name: String, resourceId: Int, type: Int, value: Any?) {
super.attr(
ns, name, resourceId, type,
if (name == "authorities") {
(value as String).replace("com.discord", packageName)
} else {
value
}
)
}
}
else -> visitor
}
}
}
else -> nv
}
}
}
})
return writer.toByteArray()
}
private open class ReplaceAttrsVisitor(
nv: NodeVisitor,
private val attrs: Map<String, Any>,

View file

@ -7,7 +7,6 @@ package com.aliucord.manager.patcher.util
import com.aliucord.manager.manager.PathManager
import com.android.apksig.ApkSigner
import com.android.apksig.KeyConfig
import org.bouncycastle.asn1.x500.X500Name
import org.bouncycastle.asn1.x509.SubjectPublicKeyInfo
import org.bouncycastle.cert.X509v3CertificateBuilder
@ -44,7 +43,7 @@ object Signer : KoinComponent {
ApkSigner.SignerConfig.Builder(
"Aliucord Manager signer",
KeyConfig.Jca(keyStore.getKey(alias, password) as PrivateKey),
keyStore.getKey(alias, password) as PrivateKey,
listOf(certificate)
).build()
}
@ -99,5 +98,9 @@ object Signer : KoinComponent {
tmpApk.renameTo(apkFile)
}
fun getKeystoreFile(): File {
return paths.keystoreFile
}
private class KeySet(val publicKey: X509Certificate, val privateKey: PrivateKey)
}

View file

@ -250,4 +250,6 @@
<string name="downloading_base_apk">Downloading base APK</string>
<string name="downloading_libs_apk">Downloading libs apk</string>
<string name="downloading_resources_apk">Downloading resources apk</string>
<string name="injecting_wintryxposed_with_lspatch">Injecting WintryXposed with LSPatch</string>
<string name="downloading_wintryxposed">Downloading WintryXposed</string>
</resources>