feat: Support split APKs for RNA

This commit is contained in:
pylixonly 2025-07-13 19:17:30 +08:00
parent 69153dbe2d
commit 457347167d
21 changed files with 331 additions and 129 deletions

View file

@ -12,8 +12,7 @@ import com.aliucord.manager.BuildConfig
import com.aliucord.manager.ui.screens.patchopts.PatchOptions
import com.aliucord.manager.util.IS_PROBABLY_EMULATOR
import com.aliucord.manager.util.isPlayProtectEnabled
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.Serializable
import kotlinx.serialization.*
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.decodeFromStream
import java.io.IOException
@ -149,7 +148,7 @@ class InstallLogManager(
@Serializable
data class InstallLogData(
val id: String,
val installDate: Instant,
@Contextual val installDate: Instant,
val installDuration: Duration,
val installOptions: PatchOptions,
val environmentInfo: String,

View file

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

View file

@ -27,15 +27,15 @@ data class InstallMetadata(
/**
* Version (commit hash) of the Aliuhook build that was injected into the APK.
*/
val aliuhookVersion: SemVer,
val aliuhookVersion: SemVer?,
/**
* Version of the injector build that was injected into the APK.
*/
val injectorVersion: SemVer,
val injectorVersion: SemVer?,
/**
* Version of the smali patches that were applied onto the APK.
*/
val patchesVersion: SemVer,
val patchesVersion: SemVer?,
)

View file

@ -42,10 +42,7 @@ abstract class StepRunner : KoinComponent {
* @param completed Only match steps that have finished executing.
*/
inline fun <reified T : Step> getStep(completed: Boolean = true): T {
val step = steps.asSequence()
.filterIsInstance<T>()
.filter { !completed || it.state.isFinished }
.firstOrNull()
val step = getStepOrNull<T>(completed)
if (step == null) {
throw IllegalArgumentException("No completed step ${T::class.simpleName} exists in container")
@ -54,6 +51,13 @@ abstract class StepRunner : KoinComponent {
return step
}
inline fun <reified T : Step> getStepOrNull(completed: Boolean = true): T? {
return steps.asSequence()
.filterIsInstance<T>()
.filter { !completed || it.state.isFinished }
.firstOrNull()
}
/**
* Adds a log entry without any associated log level.
*/

View file

@ -0,0 +1,28 @@
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.*
import com.aliucord.manager.ui.screens.patchopts.PatchOptions
import kotlinx.collections.immutable.persistentListOf
class WintryPatchRunner(options: PatchOptions) : StepRunner() {
override val steps = persistentListOf<Step>(
DownloadDiscordRNABaseStep(),
DownloadDiscordRNALangStep(),
DownloadDiscordRNAResourcesStep(),
DownloadDiscordRNALibStep(),
CopyDependenciesStep(),
PatchIconsStep(options),
PatchManifestStep(options),
// ReorganizeDexStep(),
SaveMetadataStep(options),
AlignmentStep(),
SigningStep(),
InstallStep(options),
// CleanupStep(),
)
}

View file

@ -22,6 +22,9 @@ abstract class DownloadStep : Step(), KoinComponent {
private val context: Context by inject()
private val downloader: KtorDownloadManager by inject()
// Temporary
protected val version: Int = 289210
/**
* The remote url to download
*/

View file

@ -23,15 +23,30 @@ class CopyDependenciesStep : Step(), KoinComponent {
private val application: Application by inject()
/**
* The target APK file which can be modified during patching
* The target APK files which can be modified during patching
*/
val patchedApk: File = paths.patchedApk()
var patchedLangApk: File? = null
var patchedLibApk: File? = null
var patchedResApk: File? = null
val patchedApks
get() = listOfNotNull(
patchedApk,
patchedLangApk.takeIf { it?.exists() == true },
patchedLibApk.takeIf { it?.exists() == true },
patchedResApk.takeIf { it?.exists() == true }
)
override val group = StepGroup.Download
override val localizedName = R.string.patch_step_copy_deps
override suspend fun execute(container: StepRunner) {
val srcApk = container.getStep<DownloadDiscordStep>().targetFile
val langApk = container.getStepOrNull<DownloadDiscordRNALangStep>()?.targetFile
val libApk = container.getStepOrNull<DownloadDiscordRNALibStep>()?.targetFile
val resApk = container.getStepOrNull<DownloadDiscordRNAResourcesStep>()?.targetFile
val dir = paths.patchingWorkingDir()
container.log("Clearing patched directory")
@ -42,7 +57,7 @@ class CopyDependenciesStep : Step(), KoinComponent {
if (Build.VERSION.SDK_INT >= 26) {
val storageManager = application.getSystemService<StorageManager>()!!
val targetFileStorageId = storageManager.getUuidForPath(patchedApk)
val fileSize = srcApk.length()
val fileSize = srcApk.length() + (langApk?.length() ?: 0) + (libApk?.length() ?: 0) + (resApk?.length() ?: 0)
// We request 3.5x the size of the APK, to give space for the following:
// 1) A copy of the APK
@ -57,7 +72,21 @@ class CopyDependenciesStep : Step(), KoinComponent {
}
}
container.log("Copying patched apk from ${srcApk.absolutePath} to ${patchedApk.absolutePath}")
fun copyApkSafely(src: File?, destFile: File, onCopied: (File) -> Unit) {
src?.let {
container.log("Copying patched apk from ${it.absolutePath} to ${destFile.absolutePath}")
it.copyTo(destFile)
onCopied(destFile)
}
}
// Base APK
container.log("Copying patched base apk from ${srcApk.absolutePath} to ${patchedApk.absolutePath}")
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 }
}
}

View file

@ -0,0 +1,9 @@
package com.aliucord.manager.patcher.steps.download
import com.aliucord.manager.R
class DownloadDiscordRNABaseStep : DownloadDiscordStep() {
override val targetUrl = "https://tracker.vendetta.rocks/tracker/download/$version/base"
override val targetFile = paths.discordApkVersionCache(version).resolve("base.apk")
override val localizedName = R.string.downloading_base_apk
}

View file

@ -0,0 +1,15 @@
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.KoinComponent
import org.koin.core.component.inject
class DownloadDiscordRNALangStep : DownloadStep(), KoinComponent {
private val paths: PathManager by inject()
override val targetUrl = "https://tracker.vendetta.rocks/tracker/download/$version/config.en"
override val targetFile = paths.discordApkVersionCache(version).resolve("config.en.apk")
override val localizedName = R.string.download_libs_apk
}

View file

@ -0,0 +1,17 @@
package com.aliucord.manager.patcher.steps.download
import android.os.Build
import com.aliucord.manager.R
import com.aliucord.manager.manager.PathManager
import com.aliucord.manager.patcher.steps.base.DownloadStep
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
class DownloadDiscordRNALibStep : DownloadStep(), KoinComponent {
private val paths: PathManager by inject()
private val arch = Build.SUPPORTED_ABIS.first().replace("-v", "_v")
override val targetUrl = "https://tracker.vendetta.rocks/tracker/download/$version/config.$arch"
override val targetFile = paths.discordApkVersionCache(version).resolve("config.$arch.apk")
override val localizedName = R.string.downloading_libs_apk
}

View file

@ -0,0 +1,15 @@
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.KoinComponent
import org.koin.core.component.inject
class DownloadDiscordRNAResourcesStep : DownloadStep(), KoinComponent {
private val paths: PathManager by inject()
override val targetUrl = "https://tracker.vendetta.rocks/tracker/download/$version/config.xxhdpi"
override val targetFile = paths.discordApkVersionCache(version).resolve("config.xxhdpi.apk")
override val localizedName = R.string.downloading_resources_apk
}

View file

@ -13,8 +13,8 @@ import org.koin.core.component.inject
* from a redirect to an APK mirror site provided by the Aliucord backend.
*/
@Stable
class DownloadDiscordStep : DownloadStep(), KoinComponent {
private val paths: PathManager by inject()
open class DownloadDiscordStep : DownloadStep(), KoinComponent {
protected val paths: PathManager by inject()
override val localizedName = R.string.patch_step_dl_kt_apk
override val targetUrl = getDiscordApkUrl(DISCORD_KT_VERSION)

View file

@ -22,98 +22,100 @@ class AlignmentStep : Step(), KoinComponent {
override suspend fun execute(container: StepRunner) {
val currentDeviceArch = Build.SUPPORTED_ABIS.first()
val apk = container.getStep<CopyDependenciesStep>().patchedApk
val apks = container.getStep<CopyDependenciesStep>().patchedApks
var resourcesArscBytes: ByteArray? = null
var dexCount: Int = -1
for (apk in apks) {
var resourcesArscBytes: ByteArray? = null
var dexCount: Int = -1
// Align resources.arsc due to targeting API 30 for silent install
if (Build.VERSION.SDK_INT >= 30) {
container.log("Extracting resources.arsc to be aligned later")
resourcesArscBytes = ZipReader(apk)
.use { it.openEntry("resources.arsc")?.read() }
?: throw IllegalArgumentException("APK is missing resources.arsc")
}
// Align resources.arsc due to targeting API 30 for silent install
if (Build.VERSION.SDK_INT >= 30) {
container.log("Extracting resources.arsc to be aligned later")
resourcesArscBytes = ZipReader(apk)
.use { it.openEntry("resources.arsc")?.read() }
?: continue // Alignment is not needed
}
// Align dex files due to using useEmbeddedDex (ref. ManifestPatcher)
if (Build.VERSION.SDK_INT >= 29) {
container.log("Extracting all dex files to be aligned later")
ZipReader(apk).use { zip ->
// Count the amount of dex files currently in the apk
dexCount = zip.entryNames.count { it.endsWith(".dex") }
// Align dex files due to using useEmbeddedDex (ref. ManifestPatcher)
if (Build.VERSION.SDK_INT >= 29) {
container.log("Extracting all dex files to be aligned later")
ZipReader(apk).use { zip ->
// Count the amount of dex files currently in the apk
dexCount = zip.entryNames.count { it.endsWith(".dex") }
// Copy all the dex files that need to be moved out of the apk
for (idx in 0..<dexCount) {
val bytes = zip.openEntry(getDexName(idx))!!.read()
val file = paths.patchingWorkingDir().resolve(getDexName(idx))
// Copy all the dex files that need to be moved out of the apk
for (idx in 0..<dexCount) {
val bytes = zip.openEntry(getDexName(idx))!!.read()
val file = paths.patchingWorkingDir().resolve(getDexName(idx))
file.writeBytes(bytes)
}
}
container.log("Extracted $dexCount dex files")
}
// Align native libs due to using extractNativeLibs
container.log("Extracting native libraries to be aligned later")
val nativeLibPaths = ZipReader(apk).use { zip ->
val libPaths = zip.entryNames.filter { it.endsWith(".so") }
// Extract to disk temporarily
for ((idx, path) in libPaths.withIndex()) {
// Ignore lib architectures that don't match this device
if (!path.startsWith("lib/$currentDeviceArch")) {
container.log("Skipping native lib $path due to incompatible architecture")
continue
}
// Index is just used as a placeholder id to cache on disk
val bytes = zip.openEntry(path)!!.read()
val file = paths.patchingWorkingDir().resolve("$idx.so")
file.writeBytes(bytes)
}
}
container.log("Extracted $dexCount dex files")
}
// Align native libs due to using extractNativeLibs
container.log("Extracting native libraries to be aligned later")
val nativeLibPaths = ZipReader(apk).use { zip ->
val libPaths = zip.entryNames.filter { it.endsWith(".so") }
// Extract to disk temporarily
for ((idx, path) in libPaths.withIndex()) {
// Ignore lib architectures that don't match this device
if (!path.startsWith("lib/$currentDeviceArch")) {
container.log("Skipping native lib $path due to incompatible architecture")
continue
container.log("Extracted native lib $file")
}
// Index is just used as a placeholder id to cache on disk
val bytes = zip.openEntry(path)!!.read()
val file = paths.patchingWorkingDir().resolve("$idx.so")
file.writeBytes(bytes)
container.log("Extracted native lib $file")
libPaths
}
libPaths
}
container.log("Writing entries back aligned")
ZipWriter(apk, /* append = */ true).use { zip ->
// Delete all the unaligned files from APK
container.log("Deleting resources.arsc")
if (resourcesArscBytes != null)
zip.deleteEntry("resources.arsc")
container.log("Writing entries back aligned")
ZipWriter(apk, /* append = */ true).use { zip ->
// Delete all the unaligned files from APK
container.log("Deleting resources.arsc")
if (resourcesArscBytes != null)
zip.deleteEntry("resources.arsc")
container.log("Deleting $dexCount dex files")
for (i in 0..<dexCount)
zip.deleteEntry(getDexName(i))
container.log("Deleting $dexCount dex files")
for (i in 0..<dexCount)
zip.deleteEntry(getDexName(i))
container.log("Deleting native libraries: $nativeLibPaths")
for (path in nativeLibPaths)
zip.deleteEntry(path)
container.log("Deleting native libraries: $nativeLibPaths")
for (path in nativeLibPaths)
zip.deleteEntry(path)
// Write all the files back aligned this time
if (resourcesArscBytes != null) {
container.log("Writing resources.arsc uncompressed aligned to 4 bytes")
zip.writeEntry("resources.arsc", resourcesArscBytes, ZipCompression.NONE, 4)
}
// Write all the files back aligned this time
if (resourcesArscBytes != null) {
container.log("Writing resources.arsc uncompressed aligned to 4 bytes")
zip.writeEntry("resources.arsc", resourcesArscBytes, ZipCompression.NONE, 4)
}
container.log("Writing dex files uncompressed aligned to 4 bytes")
for (idx in 0..<dexCount) {
val file = paths.patchingWorkingDir().resolve(getDexName(idx))
val bytes = file.readBytes()
zip.writeEntry(getDexName(idx), bytes, ZipCompression.NONE, 4)
}
container.log("Writing dex files uncompressed aligned to 4 bytes")
for (idx in 0..<dexCount) {
val file = paths.patchingWorkingDir().resolve(getDexName(idx))
val bytes = file.readBytes()
zip.writeEntry(getDexName(idx), bytes, ZipCompression.NONE, 4)
}
// Write back native libraries aligned to 16KiB page boundary
for ((idx, path) in nativeLibPaths.withIndex()) {
// Ignore lib architectures that don't match this device
if (!path.startsWith("lib/$currentDeviceArch"))
continue
// Write back native libraries aligned to 16KiB page boundary
for ((idx, path) in nativeLibPaths.withIndex()) {
// Ignore lib architectures that don't match this device
if (!path.startsWith("lib/$currentDeviceArch"))
continue
val file = paths.patchingWorkingDir().resolve("$idx.so")
val bytes = file.readBytes()
val file = paths.patchingWorkingDir().resolve("$idx.so")
val bytes = file.readBytes()
container.log("Writing $path uncompressed aligned to 16KiB")
zip.writeEntry(path, bytes, ZipCompression.NONE, 16384)
container.log("Writing $path uncompressed aligned to 16KiB")
zip.writeEntry(path, bytes, ZipCompression.NONE, 16384)
}
}
}
}

View file

@ -26,7 +26,7 @@ private const val READY_NOTIF_ID = 200001
/**
* Install the final APK with the system's PackageManager.
*/
class InstallStep(private val options: PatchOptions) : Step(), KoinComponent {
open class InstallStep(private val options: PatchOptions) : Step(), KoinComponent {
private val context: Context by inject()
private val installers: InstallerManager by inject()
private val prefs: PreferencesManager by inject()
@ -36,7 +36,7 @@ class InstallStep(private val options: PatchOptions) : Step(), KoinComponent {
override val localizedName = R.string.patch_step_install
override suspend fun execute(container: StepRunner) {
val apk = container.getStep<CopyDependenciesStep>().patchedApk
val apks = container.getStep<CopyDependenciesStep>().patchedApks
// If app backgrounded, show notification
if (ProcessLifecycleOwner.get().lifecycle.currentState == Lifecycle.State.CREATED) {
@ -66,9 +66,9 @@ class InstallStep(private val options: PatchOptions) : Step(), KoinComponent {
prefs.showPlayProtectWarning = !neverShowAgain
}
container.log("Installing ${apk.absolutePath}, silent: ${!prefs.devMode}")
container.log("Installing ${apks.joinToString(", ") { it.name }}, silent: ${!prefs.devMode}")
val result = installers.getActiveInstaller().waitInstall(
apks = listOf(apk),
apks = apks,
silent = !prefs.devMode,
)

View file

@ -16,9 +16,9 @@ class SigningStep : Step(), KoinComponent {
override val localizedName = R.string.patch_step_signing
override suspend fun execute(container: StepRunner) {
val apk = container.getStep<CopyDependenciesStep>().patchedApk
container.log("Signing apk at ${apk.absolutePath}")
Signer.signApk(apk)
for (apk in container.getStep<CopyDependenciesStep>().patchedApks) {
container.log("Signing apk at ${apk.absolutePath}")
Signer.signApk(apk)
}
}
}

View file

@ -18,25 +18,33 @@ class PatchManifestStep(private val options: PatchOptions) : Step() {
override val localizedName = R.string.patch_step_patch_manifests
override suspend fun execute(container: StepRunner) {
val apk = container.getStep<CopyDependenciesStep>().patchedApk
val deps = container.getStep<CopyDependenciesStep>()
for (apk in deps.patchedApks) {
container.log("Reading manifest from apk")
val manifest = ZipReader(apk)
.use { zip -> zip.openEntry("AndroidManifest.xml")?.read() }
?: throw IllegalArgumentException("No manifest found in APK")
container.log("Reading manifest from apk")
val manifest = 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,
)
}
container.log("Patching manifest")
val patchedManifest = ManifestPatcher.patchManifest(
manifestBytes = manifest,
packageName = options.packageName,
appName = options.appName,
debuggable = options.debuggable,
)
container.log("Writing patched manifest to apk unaligned compressed")
ZipWriter(apk, /* append = */ true).use {
it.deleteEntry("AndroidManifest.xml")
it.writeEntry("AndroidManifest.xml", patchedManifest)
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.writeEntry("AndroidManifest.xml", patchedManifest)
}
}
}
}

View file

@ -27,17 +27,17 @@ class SaveMetadataStep(private val options: PatchOptions) : Step(), KoinComponen
override suspend fun execute(container: StepRunner) {
val apk = container.getStep<CopyDependenciesStep>().patchedApk
val aliuhook = container.getStep<DownloadAliuhookStep>()
val injector = container.getStep<DownloadInjectorStep>()
val patches = container.getStep<DownloadPatchesStep>()
val aliuhook = container.getStepOrNull<DownloadAliuhookStep>()
val injector = container.getStepOrNull<DownloadInjectorStep>()
val patches = container.getStepOrNull<DownloadPatchesStep>()
val metadata = InstallMetadata(
options = options,
customManager = !BuildConfig.RELEASE,
managerVersion = SemVer.parse(BuildConfig.VERSION_NAME),
aliuhookVersion = aliuhook.targetVersion,
injectorVersion = injector.targetVersion,
patchesVersion = patches.targetVersion,
aliuhookVersion = aliuhook?.targetVersion,
injectorVersion = injector?.targetVersion,
patchesVersion = patches?.targetVersion,
)
container.log("Writing serialized install metadata to APK")

View file

@ -191,6 +191,72 @@ 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

@ -250,9 +250,11 @@ class HomeModel(
return false
}
// Check that all the installation components are up-to-date
return remoteBuildData.injectorVersion == installMetadata.injectorVersion
&& remoteBuildData.patchesVersion == installMetadata.patchesVersion
&& latestAliuhookVersion == installMetadata.aliuhookVersion
// Check that all the installation components are up-to-date (skip not installed)
val injectorUpToDate = installMetadata.injectorVersion == null || remoteBuildData.injectorVersion == installMetadata.injectorVersion
val patchesUpToDate = installMetadata.patchesVersion == null || remoteBuildData.patchesVersion == installMetadata.patchesVersion
val aliuhookUpToDate = installMetadata.aliuhookVersion == null || latestAliuhookVersion == installMetadata.aliuhookVersion
return injectorUpToDate && patchesUpToDate && aliuhookUpToDate
}
}

View file

@ -10,8 +10,8 @@ import cafe.adriel.voyager.core.model.screenModelScope
import com.aliucord.manager.BuildConfig
import com.aliucord.manager.R
import com.aliucord.manager.manager.*
import com.aliucord.manager.patcher.KotlinPatchRunner
import com.aliucord.manager.patcher.StepRunner
import com.aliucord.manager.patcher.WintryPatchRunner
import com.aliucord.manager.patcher.steps.StepGroup
import com.aliucord.manager.patcher.steps.base.*
import com.aliucord.manager.patcher.steps.install.InstallStep
@ -125,7 +125,7 @@ class PatchingScreenModel(
}
private suspend fun startPatchRunner() {
val runner = KotlinPatchRunner(options)
val runner = WintryPatchRunner(options)
.also { stepRunner = it }
val newSteps = runner.steps.groupBy { it.group }

View file

@ -245,4 +245,9 @@
<string name="fun_fact_10">Aliucord uses the *real* old UI, from mid-2022!</string>
<string name="fun_fact_11">There was an attempt to make Aliucord run on iOS…</string>
<string name="fun_fact_12" translatable="false">veeeee 😭</string>
<string name="download_libs_apk">Download libs apk</string>
<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>
</resources>