feat: rewrite install steps & steps UI (#63)

This commit is contained in:
rushii 2024-02-06 23:09:40 -08:00 • committed by GitHub
parent a53e21f001
commit 09e1ca200e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
40 changed files with 1424 additions and 688 deletions

View file

@ -59,6 +59,7 @@ class ManagerApplication : Application() {
modules(module {
single { providePreferences() }
single { provideDownloadManager() }
single { providePathManager() }
})
}
}

View file

@ -2,8 +2,7 @@ package com.aliucord.manager.di
import android.app.Application
import android.content.Context
import com.aliucord.manager.manager.DownloadManager
import com.aliucord.manager.manager.PreferencesManager
import com.aliucord.manager.manager.*
import org.koin.core.scope.Scope
fun Scope.providePreferences(): PreferencesManager {
@ -15,3 +14,8 @@ fun Scope.provideDownloadManager(): DownloadManager {
val application: Application = get()
return DownloadManager(application)
}
fun Scope.providePathManager(): PathManager {
val ctx: Context = get()
return PathManager(ctx)
}

View file

@ -0,0 +1,39 @@
package com.aliucord.manager.installer.steps
import com.aliucord.manager.installer.steps.base.Step
import com.aliucord.manager.installer.steps.download.*
import com.aliucord.manager.installer.steps.install.*
import com.aliucord.manager.installer.steps.patch.*
import com.aliucord.manager.installer.steps.prepare.DowngradeCheckStep
import com.aliucord.manager.installer.steps.prepare.FetchInfoStep
import kotlinx.collections.immutable.persistentListOf
/**
* Used for installing the old Kotlin Discord app.
*/
class KotlinInstallRunner : StepRunner() {
override val steps = persistentListOf<Step>(
// Prepare
FetchInfoStep(),
DowngradeCheckStep(),
// Download
DownloadDiscordStep(),
DownloadInjectorStep(),
DownloadAliuhookStep(),
DownloadKotlinStep(),
// Patch
CopyDependenciesStep(),
ReplaceIconStep(),
PatchManifestStep(),
AddInjectorStep(),
AddAliuhookStep(),
// Install
AlignmentStep(),
SigningStep(),
InstallStep(),
CleanupStep(),
)
}

View file

@ -0,0 +1,23 @@
package com.aliucord.manager.installer.steps
import androidx.annotation.StringRes
import androidx.compose.runtime.Immutable
import com.aliucord.manager.R
/**
* A group of steps that is shown under one section in the install UI.
* This has no functional impact.
*/
@Immutable
enum class StepGroup(
/**
* The UI name to display this group as
*/
@get:StringRes
val localizedName: Int,
) {
Prepare(R.string.install_group_prepare),
Download(R.string.install_group_download),
Patch(R.string.install_group_patch),
Install(R.string.install_group_install)
}

View file

@ -0,0 +1,52 @@
package com.aliucord.manager.installer.steps
import com.aliucord.manager.installer.steps.base.Step
import com.aliucord.manager.manager.PreferencesManager
import kotlinx.collections.immutable.ImmutableList
import kotlinx.coroutines.delay
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
/**
* The minimum time that is required to occur between step switches, to avoid
* quickly switching the step groups in the UI. (very disorienting)
* Larger delay leads to a perception that it's doing more work than it actually is.
*/
const val MINIMUM_STEP_DELAY: Long = 600L
abstract class StepRunner : KoinComponent {
private val preferences: PreferencesManager by inject()
abstract val steps: ImmutableList<Step>
/**
* Get a step that has already been successfully executed.
* This is used to retrieve previously executed dependency steps from a later step.
*/
inline fun <reified T : Step> getStep(): T {
val step = steps.asSequence()
.filterIsInstance<T>()
.filter { it.state.isFinished }
.firstOrNull()
if (step == null) {
throw IllegalArgumentException("No completed step ${T::class.simpleName} exists in container")
}
return step
}
suspend fun executeAll(): Throwable? {
for (step in steps) {
val error = step.executeCatching(this@StepRunner)
if (error != null) return error
// Skip minimum run time when in dev mode
if (!preferences.devMode && step.durationMs < MINIMUM_STEP_DELAY) {
delay(MINIMUM_STEP_DELAY - step.durationMs)
}
}
return null
}
}

View file

@ -0,0 +1,85 @@
package com.aliucord.manager.installer.steps.base
import android.content.Context
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.manager.DownloadManager
import com.aliucord.manager.util.showToast
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
import java.io.File
@Stable
abstract class DownloadStep : Step(), KoinComponent {
private val context: Context by inject()
private val downloads: DownloadManager by inject()
/**
* The remote url to download
*/
abstract val targetUrl: String
/**
* Target path to store the download in. If this file already exists,
* then the cached version is used and the step is marked as cancelled/skipped.
*/
abstract val targetFile: File
/**
* Verify that the download completely successfully without errors.
* @throws Throwable If verification fails.
*/
open suspend fun verify() {
if (!targetFile.exists())
throw Error("Downloaded file is missing!")
if (targetFile.length() <= 0)
throw Error("Downloaded file is empty!")
}
override val group = StepGroup.Download
override suspend fun execute(container: StepRunner) {
if (targetFile.exists()) {
if (targetFile.length() > 0) {
state = StepState.Skipped
return
}
targetFile.delete()
}
val result = downloads.download(targetUrl, targetFile) { newProgress ->
progress = newProgress ?: -1f
}
when (result) {
is DownloadManager.Result.Success -> {
try {
verify()
} catch (t: Throwable) {
withContext(Dispatchers.Main) {
context.showToast(R.string.installer_dl_verify_fail)
}
throw t
}
}
is DownloadManager.Result.Error -> {
withContext(Dispatchers.Main) {
context.showToast(result.localizedReason)
}
throw Error("Failed to download: ${result.debugReason}")
}
is DownloadManager.Result.Cancelled ->
state = StepState.Error
}
}
}

View file

@ -0,0 +1,84 @@
package com.aliucord.manager.installer.steps.base
import androidx.annotation.StringRes
import androidx.compose.runtime.*
import com.aliucord.manager.installer.steps.StepGroup
import com.aliucord.manager.installer.steps.StepRunner
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.koin.core.time.measureTimedValue
import kotlin.math.roundToInt
/**
* A base install process step. Steps are single-use
*/
@Stable
abstract class Step {
/**
* The group this step belongs to.
*/
abstract val group: StepGroup
/**
* The UI name to display this step as
*/
@get:StringRes
abstract val localizedName: Int
/**
* Run the step's logic.
* It can be assumed that this is executed in the correct order after other steps.
*/
protected abstract suspend fun execute(container: StepRunner)
/**
* The current state of this step in the installation process.
*/
var state by mutableStateOf(StepState.Pending)
protected set
/**
* If the current state is [StepState.Running], then the progress of this step.
* If the progress isn't currently measurable, then this should be set to `-1`.
*/
var progress by mutableFloatStateOf(-1f)
protected set
/**
* The total execution time once this step has finished execution.
*/
// TODO: make this a live value
var durationMs by mutableIntStateOf(0)
private set
/**
* Thin wrapper over [execute] but handling errors.
* @return An exception if the step failed to execute.
*/
suspend fun executeCatching(container: StepRunner): Throwable? {
if (state != StepState.Pending)
throw IllegalStateException("Cannot execute a step that has already started")
state = StepState.Running
// Execute this steps logic while timing it
val (error, executionTimeMs) = measureTimedValue {
try {
withContext(Dispatchers.Default) {
execute(container)
}
if (state != StepState.Skipped)
state = StepState.Success
null
} catch (t: Throwable) {
state = StepState.Error
t
}
}
durationMs = executionTimeMs.roundToInt()
return error
}
}

View file

@ -0,0 +1,12 @@
package com.aliucord.manager.installer.steps.base
enum class StepState {
Pending,
Running,
Success,
Error,
Skipped;
val isFinished: Boolean
get() = this == Success || this == Error || this == Skipped
}

View file

@ -0,0 +1,36 @@
package com.aliucord.manager.installer.steps.download
import androidx.compose.runtime.Stable
import com.aliucord.manager.R
import com.aliucord.manager.domain.repository.AliucordMavenRepository
import com.aliucord.manager.installer.steps.StepRunner
import com.aliucord.manager.installer.steps.base.DownloadStep
import com.aliucord.manager.manager.PathManager
import com.aliucord.manager.network.utils.getOrThrow
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
/**
* Download a packaged AAR of the latest Aliuhook build from the Aliucord maven.
*/
@Stable
class DownloadAliuhookStep : DownloadStep(), KoinComponent {
private val paths: PathManager by inject()
private val maven: AliucordMavenRepository by inject()
/**
* This is populated right before the download starts (ref: [execute])
*/
private lateinit var targetVersion: String
override val localizedName = R.string.install_step_dl_aliuhook
override val targetUrl get() = AliucordMavenRepository.getAliuhookUrl(targetVersion)
override val targetFile get() = paths.cachedAliuhookAAR(targetVersion)
override suspend fun execute(container: StepRunner) {
targetVersion = maven.getAliuhookVersion().getOrThrow()
super.execute(container)
}
}

View file

@ -0,0 +1,39 @@
package com.aliucord.manager.installer.steps.download
import androidx.compose.runtime.Stable
import com.aliucord.manager.BuildConfig
import com.aliucord.manager.R
import com.aliucord.manager.installer.steps.base.DownloadStep
import com.aliucord.manager.manager.PathManager
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
/**
* If not already cached, then download the raw unmodified v126.21 (Kotlin) Discord APK
* from a redirect to an APK mirror site provided by the Aliucord backend.
*/
@Stable
class DownloadDiscordStep : DownloadStep(), KoinComponent {
private val paths: PathManager by inject()
override val localizedName = R.string.install_step_dl_kt_apk
override val targetUrl = getDiscordApkUrl(DISCORD_KT_VERSION)
override val targetFile = paths.discordApkVersionCache(DISCORD_KT_VERSION)
.resolve("discord.apk")
override suspend fun verify() {
super.verify()
// TODO: verify signature
}
private companion object {
/**
* Last version of Discord before the RN transition.
*/
const val DISCORD_KT_VERSION = 126021
fun getDiscordApkUrl(version: Int) =
"${BuildConfig.BACKEND_URL}/download/discord?v=$version"
}
}

View file

@ -0,0 +1,45 @@
package com.aliucord.manager.installer.steps.download
import androidx.compose.runtime.Stable
import com.aliucord.manager.R
import com.aliucord.manager.installer.steps.StepRunner
import com.aliucord.manager.installer.steps.base.DownloadStep
import com.aliucord.manager.installer.steps.prepare.FetchInfoStep
import com.aliucord.manager.manager.PathManager
import com.aliucord.manager.network.dto.Version
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
/**
* Download a compiled dex file to be injected into the APK as the first `classes.dex` to override an entry point class.
*/
@Stable
class DownloadInjectorStep : DownloadStep(), KoinComponent {
private val paths: PathManager by inject()
/**
* Populated from a dependency step ([FetchInfoStep]).
* This is used as cache invalidation (ref: [Version.aliucordHash])
*/
private lateinit var aliucordHash: String
override val localizedName = R.string.install_step_dl_injector
override val targetUrl = URL
override val targetFile
get() = paths.cachedInjectorDex(aliucordHash).resolve("discord.apk")
override suspend fun execute(container: StepRunner) {
aliucordHash = container
.getStep<FetchInfoStep>()
.data.aliucordHash
super.execute(container)
}
private companion object {
const val ORG = "Aliucord"
const val MAIN_REPO = "Aliucord"
const val URL = "https://raw.githubusercontent.com/$ORG/$MAIN_REPO/builds/Injector.dex"
}
}

View file

@ -0,0 +1,27 @@
package com.aliucord.manager.installer.steps.download
import androidx.compose.runtime.Stable
import com.aliucord.manager.R
import com.aliucord.manager.installer.steps.base.DownloadStep
import com.aliucord.manager.manager.PathManager
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
/**
* Download the most recent available Kotlin stdlib build that is supported.
*/
@Stable
class DownloadKotlinStep : DownloadStep(), KoinComponent {
private val paths: PathManager by inject()
override val localizedName = R.string.install_step_dl_kotlin
override val targetUrl = URL
override val targetFile = paths.cachedKotlinDex()
private companion object {
const val ORG = "Aliucord"
const val MAIN_REPO = "Aliucord"
const val URL = "https://raw.githubusercontent.com/$ORG/$MAIN_REPO/main/installer/android/app/src/main/assets/kotlin/classes.dex"
}
}

View file

@ -0,0 +1,39 @@
package com.aliucord.manager.installer.steps.install
import android.os.Build
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.steps.patch.CopyDependenciesStep
import com.github.diamondminer88.zip.*
import org.koin.core.component.KoinComponent
/**
* Align certain files in the APK to a 4KiB boundary.
*/
class AlignmentStep : Step(), KoinComponent {
private val currentDeviceArch = Build.SUPPORTED_ABIS.first()
override val group = StepGroup.Install
override val localizedName = R.string.install_step_alignment
override suspend fun execute(container: StepRunner) {
val apk = container.getStep<CopyDependenciesStep>().patchedApk
// Align resources.arsc due to targeting API 30 for silent install
if (Build.VERSION.SDK_INT >= 30) {
val bytes = ZipReader(apk)
.use { it.openEntry("resources.arsc")?.read() }
?: throw IllegalArgumentException("APK is missing resources.arsc")
ZipWriter(apk, /* append = */ true).use {
it.deleteEntry("resources.arsc")
it.writeEntry("resources.arsc", bytes, ZipCompression.NONE, 4096)
}
} else {
state = StepState.Skipped
}
}
}

View file

@ -0,0 +1,31 @@
package com.aliucord.manager.installer.steps.install
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.manager.PathManager
import com.aliucord.manager.manager.PreferencesManager
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
/**
* Cleanup patching working directory once the installation has completed.
*/
class CleanupStep : Step(), KoinComponent {
private val paths: PathManager by inject()
private val prefs: PreferencesManager by inject()
override val group = StepGroup.Install
override val localizedName = R.string.install_step_cleanup
override suspend fun execute(container: StepRunner) {
if (prefs.keepPatchedApks) {
state = StepState.Skipped
} else {
if (!paths.patchingWorkingDir().deleteRecursively())
throw IllegalStateException("Failed to delete patching working dir")
}
}
}

View file

@ -0,0 +1,32 @@
package com.aliucord.manager.installer.steps.install
import android.app.Application
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.patch.CopyDependenciesStep
import com.aliucord.manager.installer.util.installApks
import com.aliucord.manager.manager.PreferencesManager
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
/**
* Install the final APK with the system's PackageManager.
*/
class InstallStep : Step(), KoinComponent {
private val application: Application by inject()
private val prefs: PreferencesManager by inject()
override val group = StepGroup.Install
override val localizedName = R.string.install_step_installing
override suspend fun execute(container: StepRunner) {
val apk = container.getStep<CopyDependenciesStep>().patchedApk
application.installApks(
silent = !prefs.devMode,
apks = arrayOf(apk),
)
}
}

View file

@ -0,0 +1,23 @@
package com.aliucord.manager.installer.steps.install
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.patch.CopyDependenciesStep
import com.aliucord.manager.installer.util.Signer
import org.koin.core.component.KoinComponent
/**
* Sign the APK with a keystore generated on-device.
*/
class SigningStep : Step(), KoinComponent {
override val group = StepGroup.Install
override val localizedName = R.string.install_step_signing
override suspend fun execute(container: StepRunner) {
val apk = container.getStep<CopyDependenciesStep>().patchedApk
Signer.signApk(apk)
}
}

View file

@ -0,0 +1,47 @@
package com.aliucord.manager.installer.steps.patch
import android.os.Build
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.download.DownloadAliuhookStep
import com.github.diamondminer88.zip.ZipReader
import com.github.diamondminer88.zip.ZipWriter
import org.koin.core.component.KoinComponent
/**
* Add the Aliuhook library's native libs along with dex
*/
class AddAliuhookStep : Step(), KoinComponent {
private val currentDeviceArch = Build.SUPPORTED_ABIS.first()
override val group = StepGroup.Patch
override val localizedName = R.string.install_step_add_aliuhook
override suspend fun execute(container: StepRunner) {
val apk = container.getStep<CopyDependenciesStep>().patchedApk
val aliuhook = container.getStep<DownloadAliuhookStep>().targetFile
// Find the amount of .dex files in the apk
val dexCount = ZipReader(apk).use {
it.entryNames.count { name -> name.endsWith(".dex") }
}
ZipWriter(apk, /* append = */ true).use { patchedApk ->
ZipReader(aliuhook).use { aliuhook ->
for (libFile in arrayOf("libaliuhook.so", "libc++_shared.so", "liblsplant.so")) {
val bytes = aliuhook.openEntry("jni/$currentDeviceArch/$libFile")?.read()
?: throw IllegalStateException("Failed to read $libFile from aliuhook aar")
patchedApk.writeEntry("lib/$currentDeviceArch/$libFile", bytes)
}
val aliuhookDex = aliuhook.openEntry("classes.dex")?.read()
?: throw IllegalStateException("No classes.dex in aliuhook aar")
patchedApk.writeEntry("classes${dexCount + 1}.dex", aliuhookDex)
}
}
}
}

View file

@ -0,0 +1,46 @@
package com.aliucord.manager.installer.steps.patch
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.download.DownloadInjectorStep
import com.aliucord.manager.installer.steps.download.DownloadKotlinStep
import com.github.diamondminer88.zip.ZipReader
import com.github.diamondminer88.zip.ZipWriter
import org.koin.core.component.KoinComponent
/**
* Reorder the existing dex files to add the Aliucord injector as the first `classes.dex` file.
*/
class AddInjectorStep : Step(), KoinComponent {
override val group = StepGroup.Patch
override val localizedName = R.string.install_step_add_injector
override suspend fun execute(container: StepRunner) {
val apk = container.getStep<CopyDependenciesStep>().patchedApk
val injector = container.getStep<DownloadInjectorStep>().targetFile
val kotlinStdlib = container.getStep<DownloadKotlinStep>().targetFile
val (dexCount, firstDexBytes) = ZipReader(apk).use {
Pair(
// Find the amount of .dex files in apk
it.entryNames.count { name -> name.endsWith(".dex") },
// Get the first dex
it.openEntry("classes.dex")?.read()
?: throw IllegalStateException("No classes.dex in base apk")
)
}
ZipWriter(apk, /* append = */ true).use {
// Move copied dex to end of dex list
it.deleteEntry("classes.dex")
it.writeEntry("classes${dexCount + 1}.dex", firstDexBytes)
// Add Kotlin & Aliucord's dex
it.writeEntry("classes.dex", injector.readBytes())
it.writeEntry("classes${dexCount + 2}.dex", kotlinStdlib.readBytes())
}
}
}

View file

@ -0,0 +1,39 @@
package com.aliucord.manager.installer.steps.patch
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.download.DownloadDiscordStep
import com.aliucord.manager.manager.PathManager
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
import java.io.File
/**
* Step to duplicate the Discord APK to be worked on.
*/
class CopyDependenciesStep : Step(), KoinComponent {
private val paths: PathManager by inject()
/**
* The target APK file which can be modified during patching
*/
val patchedApk: File = paths.patchingWorkingDir()
.resolve("patched.apk")
override val group = StepGroup.Patch
override val localizedName = R.string.install_step_copy
override suspend fun execute(container: StepRunner) {
val dir = paths.patchingWorkingDir()
// TODO: move this to a prepare step
if (!dir.deleteRecursively())
throw Error("Failed to clear existing patched dir")
val srcApk = container.getStep<DownloadDiscordStep>().targetFile
srcApk.copyTo(patchedApk)
}
}

View file

@ -0,0 +1,42 @@
package com.aliucord.manager.installer.steps.patch
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.util.ManifestPatcher
import com.aliucord.manager.manager.PreferencesManager
import com.github.diamondminer88.zip.ZipReader
import com.github.diamondminer88.zip.ZipWriter
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
/**
* Patch the APK's AndroidManifest.xml
*/
class PatchManifestStep : Step(), KoinComponent {
private val prefs: PreferencesManager by inject()
override val group = StepGroup.Patch
override val localizedName = R.string.install_step_patch_manifests
override suspend fun execute(container: StepRunner) {
val apk = container.getStep<CopyDependenciesStep>().patchedApk
val manifest = ZipReader(apk)
.use { zip -> zip.openEntry("AndroidManifest.xml")?.read() }
?: throw IllegalArgumentException("No manifest found in APK")
val patchedManifest = ManifestPatcher.patchManifest(
manifestBytes = manifest,
packageName = prefs.packageName,
appName = prefs.appName,
debuggable = prefs.debuggable,
)
ZipWriter(apk, /* append = */ true).use {
it.deleteEntry("AndroidManifest.xml")
it.writeEntry("AndroidManifest.xml", patchedManifest)
}
}
}

View file

@ -0,0 +1,56 @@
package com.aliucord.manager.installer.steps.patch
import android.content.Context
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.manager.PreferencesManager
import com.github.diamondminer88.zip.ZipWriter
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
import java.io.InputStream
/**
* Replace icons
*/
@Stable
class ReplaceIconStep : Step(), KoinComponent {
private val context: Context by inject()
private val prefs: PreferencesManager by inject()
override val group = StepGroup.Patch
override val localizedName = R.string.setting_replace_icon
override suspend fun execute(container: StepRunner) {
if (!prefs.replaceIcon) {
state = StepState.Skipped
return
}
val apk = container.getStep<CopyDependenciesStep>().patchedApk
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)
}
}
}
}
private fun readAsset(fileName: String): ByteArray =
context.assets.open(fileName).use(InputStream::readBytes)
}

View file

@ -0,0 +1,54 @@
package com.aliucord.manager.installer.steps.prepare
import android.content.Context
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.uninstallApk
import com.aliucord.manager.manager.PreferencesManager
import com.aliucord.manager.util.getPackageVersion
import com.aliucord.manager.util.showToast
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
/**
* Prompt the user to uninstall a previous version of Aliucord if it has a larger version code.
* (Prevent conflicts from downgrading)
*/
class DowngradeCheckStep : Step(), KoinComponent {
private val context: Context by inject()
private val prefs: PreferencesManager by inject()
override val group = StepGroup.Prepare
override val localizedName = R.string.install_step_downgrade_check
override suspend fun execute(container: StepRunner) {
val (_, currentVersion) = try {
context.getPackageVersion(prefs.packageName)
}
// Package is not installed
catch (_: Throwable) {
state = StepState.Skipped
return
}
val targetVersion = container
.getStep<FetchInfoStep>()
.data.versionCode.toIntOrNull()
?: throw IllegalArgumentException("Invalid fetched Aliucord target Discord version")
if (currentVersion > targetVersion) {
context.uninstallApk(prefs.packageName)
withContext(Dispatchers.Main) {
context.showToast(R.string.installer_uninstall_new)
}
throw Error("Newer version of Aliucord must be uninstalled prior to installing an older version")
}
}
}

View file

@ -0,0 +1,29 @@
package com.aliucord.manager.installer.steps.prepare
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.network.dto.Version
import com.aliucord.manager.network.service.AliucordGithubService
import com.aliucord.manager.network.utils.getOrThrow
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
@Stable
class FetchInfoStep : Step(), KoinComponent {
private val github: AliucordGithubService by inject()
override val group = StepGroup.Prepare
override val localizedName = R.string.install_step_fetch_kt_version
/**
* Fetched data about the latest Aliucord commit and supported Discord version.
*/
lateinit var data: Version
override suspend fun execute(container: StepRunner) {
data = github.getDataJson().getOrThrow()
}
}

View file

@ -4,51 +4,37 @@ import android.app.Application
import android.app.DownloadManager
import android.database.Cursor
import android.net.Uri
import android.os.Build
import android.util.Log
import androidx.annotation.StringRes
import androidx.core.content.getSystemService
import com.aliucord.manager.BuildConfig
import com.aliucord.manager.R
import com.aliucord.manager.domain.repository.AliucordMavenRepository
import com.aliucord.manager.network.service.AliucordGithubService
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.delay
import java.io.File
import kotlin.coroutines.cancellation.CancellationException
/**
* Handle downloading remote urls to a file through the system [DownloadManager].
* Handle downloading remote urls to a path through the system [DownloadManager].
*/
class DownloadManager(application: Application) {
private val downloadManager = application.getSystemService<DownloadManager>()
?: throw IllegalStateException("DownloadManager service is not available")
// Discord APK downloading
suspend fun downloadDiscordApk(version: String, out: File): Result =
download("${BuildConfig.BACKEND_URL}/download/discord?v=$version", out)
// Aliucord Kotlin downloads
suspend fun downloadKtInjector(out: File): Result =
download(AliucordGithubService.KT_INJECTOR_URL, out)
suspend fun downloadAliuhook(version: String, out: File): Result =
download(AliucordMavenRepository.getAliuhookUrl(version), out)
suspend fun downloadKotlinDex(out: File): Result =
download(AliucordGithubService.KOTLIN_DEX_URL, out)
/**
* Start a cancellable download with the system [DownloadManager].
* If the current [CoroutineScope] is cancelled, then the system download will be cancelled within 100ms.
* @param url Remote src url
* @param out Target path to download to
* @param onProgressUpdate Download progress update in a `[0,1]` range, and if null then the download is currently in a pending state.
* This is called every 100ms, and should not perform long-running tasks.
* @param out Target path to download to. It is assumed that the application has write permissions to this path.
* @param onProgressUpdate An optional [ProgressListener]
*/
suspend fun download(
url: String,
out: File,
onProgressUpdate: ((Float?) -> Unit)? = null,
onProgressUpdate: ProgressListener? = null,
): Result {
onProgressUpdate?.onUpdate(null)
out.parentFile?.mkdirs()
// Create and start a download in the system DownloadManager
@ -56,10 +42,15 @@ class DownloadManager(application: Application) {
.setTitle("Aliucord Manager")
.setDescription("Downloading ${out.name}...")
.setDestinationUri(Uri.fromFile(out))
.setAllowedOverMetered(true)
.setAllowedOverRoaming(true)
.setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE)
.addRequestHeader("User-Agent", "Aliucord Manager/${BuildConfig.VERSION_NAME}")
.apply {
// Disable gzip on emulator due to https compression bug
if (Build.FINGERPRINT.contains("emulator")) {
Log.d(BuildConfig.TAG, "Disabling DownloadManager compression")
addRequestHeader("Accept-Encoding", null)
}
}
.let(downloadManager::enqueue)
// Repeatedly request download state until it is finished
@ -90,13 +81,13 @@ class DownloadManager(application: Application) {
when (status) {
DownloadManager.STATUS_PENDING, DownloadManager.STATUS_PAUSED ->
onProgressUpdate?.invoke(null)
onProgressUpdate?.onUpdate(null)
DownloadManager.STATUS_RUNNING ->
onProgressUpdate?.invoke(getDownloadProgress(cursor))
onProgressUpdate?.onUpdate(getDownloadProgress(cursor))
DownloadManager.STATUS_SUCCESSFUL ->
return Result.Success
return Result.Success(out)
DownloadManager.STATUS_FAILED -> {
val reasonColumn = cursor.getColumnIndex(DownloadManager.COLUMN_REASON)
@ -126,11 +117,27 @@ class DownloadManager(application: Application) {
return bytes.toFloat() / totalBytes
}
/**
* A callback executed from a coroutine called every 100ms in order to provide
* info about the current download. This should not perform long-running tasks as the delay will be offset.
*/
fun interface ProgressListener {
/**
* @param progress The current download progress in a `[0,1]` range. If null, then the download is either
* paused, pending, or waiting to retry.
*/
fun onUpdate(progress: Float?)
}
/**
* The state of a download after execution has been completed and the system-level [DownloadManager] has been cleaned up.
*/
sealed interface Result {
data object Success : Result
/**
* The download succeeded successfully.
* @param file The path that the download was downloaded to.
*/
data class Success(val file: File) : Result
/**
* This download was interrupted and the in-progress file has been deleted.

View file

@ -0,0 +1,62 @@
package com.aliucord.manager.manager
import android.content.Context
import java.io.File
/**
* A central place to provide all system paths that are used.
*/
class PathManager(context: Context) {
private val externalCacheDir = context.externalCacheDir
?: throw Error("External cache directory isn't supported")
/**
* Standard path: `~/Android/data/com.aliucord.manager/cache`
*/
private val discordApkCache = externalCacheDir
.resolve("discord")
/**
* Delete the entire cache dir and recreate it.
*/
fun clearCache() {
if (!externalCacheDir.deleteRecursively())
throw IllegalStateException("Failed to delete cache")
externalCacheDir.mkdirs()
}
/**
* Create a new subfolder in the Discord APK cache for a specific version.
*/
fun discordApkVersionCache(version: Int): File = discordApkCache
.resolve(version.toString())
.apply { mkdirs() }
/**
* Resolve a specific path for a cached injector.
*/
fun cachedInjectorDex(aliucordHash: String) = externalCacheDir
.resolve("injector").apply { mkdirs() }
.resolve("$aliucordHash.dex")
/**
* Resolve a specific path for a versioned cached Aliuhook build
*/
fun cachedAliuhookAAR(version: String) = externalCacheDir
.resolve("aliuhook").apply { mkdirs() }
.resolve("$version.aar")
/**
* Singular Kotlin file of the most up-to-date version
* since the stdlib is backwards compatible.
*/
fun cachedKotlinDex() = externalCacheDir
.resolve("kotlin.dex")
/**
* The temporary working directory of a currently executing patching process.
*/
fun patchingWorkingDir() = externalCacheDir
.resolve("patched")
}

View file

@ -21,12 +21,9 @@ class AliucordGithubService(
suspend fun getManagerReleases() = github.getReleases(ORG, MANAGER_REPO)
suspend fun getContributors() = github.getContributors(ORG, MAIN_REPO)
companion object {
private const val ORG = "Aliucord"
private const val MAIN_REPO = "Aliucord"
private const val MANAGER_REPO = "Manager"
const val KT_INJECTOR_URL = "https://raw.githubusercontent.com/$ORG/$MAIN_REPO/builds/Injector.dex"
const val KOTLIN_DEX_URL = "https://raw.githubusercontent.com/$ORG/$MAIN_REPO/main/installer/android/app/src/main/assets/kotlin/classes.dex"
private companion object {
const val ORG = "Aliucord"
const val MAIN_REPO = "Aliucord"
const val MANAGER_REPO = "Manager"
}
}

View file

@ -5,29 +5,18 @@
package com.aliucord.manager.ui.components.dialogs
import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.rememberSaveable
import com.aliucord.manager.ui.screens.install.InstallData
enum class DownloadMethod { DOWNLOAD }
import androidx.compose.runtime.Composable
import androidx.compose.runtime.SideEffect
@Composable
fun InstallerDialog(
onDismiss: () -> Unit,
onConfirm: (InstallData) -> Unit,
onConfirm: () -> Unit,
) {
val downloadMethod by rememberSaveable { mutableStateOf(DownloadMethod.DOWNLOAD) }
fun triggerConfirm() {
onDismiss()
onConfirm(
InstallData(downloadMethod)
)
}
SideEffect(::triggerConfirm)
SideEffect(onConfirm)
// TODO: local install option
// TODO: mobile data warning
// Dialog(
// onDismissRequest = onDismiss,

View file

@ -1,194 +0,0 @@
package com.aliucord.manager.ui.components.installer
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.*
import com.aliucord.manager.R
import kotlinx.collections.immutable.ImmutableList
import kotlin.math.floor
enum class InstallStatus {
ONGOING,
SUCCESSFUL,
UNSUCCESSFUL,
QUEUED
}
@Stable
class InstallStepData(
val nameResId: Int,
status: InstallStatus,
duration: Float = 0f,
cached: Boolean = false,
) {
var status by mutableStateOf(status)
var duration by mutableStateOf(duration)
var cached by mutableStateOf(cached)
}
@Composable
fun InstallGroup(
name: String,
isCurrent: Boolean,
subSteps: ImmutableList<InstallStepData>,
onClick: () -> Unit,
) {
val status = when {
subSteps.all { it.status == InstallStatus.QUEUED } ->
InstallStatus.QUEUED
subSteps.all { it.status == InstallStatus.SUCCESSFUL } ->
InstallStatus.SUCCESSFUL
subSteps.any { it.status == InstallStatus.ONGOING } ->
InstallStatus.ONGOING
else -> InstallStatus.UNSUCCESSFUL
}
Column(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(16.dp))
.run {
if (isCurrent) {
background(MaterialTheme.colorScheme.surfaceColorAtElevation(1.dp))
} else this
}
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
modifier = Modifier
.clickable(true, onClick = onClick)
.fillMaxWidth()
.padding(16.dp)
) {
StepIcon(status, 24.dp)
Text(text = name)
Spacer(modifier = Modifier.weight(1f))
if (status != InstallStatus.ONGOING && status != InstallStatus.QUEUED) Text(
"%.2fs".format(subSteps.map { it.duration }.sum()),
style = MaterialTheme.typography.labelMedium
)
if (isCurrent) {
Icon(
painter = painterResource(R.drawable.ic_arrow_up_small),
contentDescription = stringResource(R.string.action_collapse)
)
} else {
Icon(
painter = painterResource(R.drawable.ic_arrow_down_small),
contentDescription = stringResource(R.string.action_expand)
)
}
}
AnimatedVisibility(visible = isCurrent) {
Column(
verticalArrangement = Arrangement.spacedBy(16.dp),
modifier = Modifier
.background(MaterialTheme.colorScheme.background.copy(0.6f))
.fillMaxWidth()
.padding(16.dp)
.padding(start = 4.dp)
) {
subSteps.forEach {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(16.dp),
) {
StepIcon(it.status, size = 18.dp)
Text(
text = stringResource(it.nameResId),
style = MaterialTheme.typography.labelLarge,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f, true),
)
if (it.status != InstallStatus.ONGOING && it.status != InstallStatus.QUEUED) {
if (it.cached) {
val style = MaterialTheme.typography.labelSmall.copy(
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f),
fontStyle = FontStyle.Italic,
fontSize = 11.sp
)
Text(
text = stringResource(R.string.installer_cached),
style = style,
maxLines = 1,
)
}
Text(
text = "%.2fs".format(it.duration),
style = MaterialTheme.typography.labelSmall,
maxLines = 1,
)
}
}
}
}
}
}
}
@Composable
private fun StepIcon(status: InstallStatus, size: Dp) {
val strokeWidth = Dp(floor(size.value / 10) + 1)
val context = LocalContext.current
when (status) {
InstallStatus.ONGOING -> CircularProgressIndicator(
strokeWidth = strokeWidth,
modifier = Modifier
.size(size)
.semantics {
contentDescription = context.getString(R.string.status_ongoing)
}
)
InstallStatus.SUCCESSFUL -> Icon(
painter = painterResource(R.drawable.ic_check_circle),
contentDescription = stringResource(R.string.status_success),
tint = Color(0xFF59B463),
modifier = Modifier.size(size)
)
InstallStatus.UNSUCCESSFUL -> Icon(
painter = painterResource(R.drawable.ic_canceled),
contentDescription = stringResource(R.string.status_failed),
tint = MaterialTheme.colorScheme.error,
modifier = Modifier.size(size)
)
InstallStatus.QUEUED -> Icon(
painter = painterResource(R.drawable.ic_circle),
contentDescription = stringResource(R.string.status_queued),
tint = MaterialTheme.colorScheme.onSurface.copy(0.4f),
modifier = Modifier.size(size)
)
}
}

View file

@ -8,9 +8,9 @@ import cafe.adriel.voyager.core.model.ScreenModel
import cafe.adriel.voyager.core.model.screenModelScope
import com.aliucord.manager.BuildConfig
import com.aliucord.manager.R
import com.aliucord.manager.manager.PreferencesManager
import com.aliucord.manager.domain.repository.GithubRepository
import com.aliucord.manager.installer.util.uninstallApk
import com.aliucord.manager.manager.PreferencesManager
import com.aliucord.manager.network.utils.fold
import com.aliucord.manager.ui.util.DiscordVersion
import com.aliucord.manager.util.getPackageVersion

View file

@ -46,9 +46,9 @@ class HomeScreen : Screen {
if (showInstallerDialog) {
InstallerDialog(
onDismiss = { showInstallerDialog = false },
onConfirm = { data ->
onConfirm = {
showInstallerDialog = false
navigator.push(InstallScreen(data))
navigator.push(InstallScreen())
}
)
}

View file

@ -4,416 +4,131 @@ import android.annotation.SuppressLint
import android.app.Application
import android.os.Build
import android.util.Log
import androidx.annotation.StringRes
import androidx.compose.runtime.*
import cafe.adriel.voyager.core.model.ScreenModel
import cafe.adriel.voyager.core.model.StateScreenModel
import cafe.adriel.voyager.core.model.screenModelScope
import com.aliucord.manager.BuildConfig
import com.aliucord.manager.R
import com.aliucord.manager.manager.DownloadManager
import com.aliucord.manager.manager.PreferencesManager
import com.aliucord.manager.domain.repository.AliucordMavenRepository
import com.aliucord.manager.domain.repository.GithubRepository
import com.aliucord.manager.installer.util.*
import com.aliucord.manager.network.utils.getOrThrow
import com.aliucord.manager.ui.components.installer.InstallStatus
import com.aliucord.manager.ui.components.installer.InstallStepData
import com.aliucord.manager.installer.steps.KotlinInstallRunner
import com.aliucord.manager.installer.steps.StepGroup
import com.aliucord.manager.installer.steps.base.Step
import com.aliucord.manager.manager.PathManager
import com.aliucord.manager.ui.util.toUnsafeImmutable
import com.aliucord.manager.util.*
import com.github.diamondminer88.zip.*
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.ImmutableMap
import kotlinx.coroutines.*
import java.text.SimpleDateFormat
import java.util.Date
import java.util.concurrent.atomic.AtomicBoolean
import kotlin.time.measureTimedValue
class InstallModel(
private val application: Application,
private val downloadManager: DownloadManager,
private val preferences: PreferencesManager,
private val githubRepository: GithubRepository,
private val aliucordMaven: AliucordMavenRepository,
private val installData: InstallData,
) : ScreenModel {
private val externalCacheDir = application.externalCacheDir!!
private val installationRunning = AtomicBoolean(false)
private val paths: PathManager,
) : StateScreenModel<InstallScreenState>(InstallScreenState.Pending) {
private lateinit var startTime: Date
private var installJob: Job? = null
var returnToHome by mutableStateOf(false)
var isFinished by mutableStateOf(false)
var installSteps by mutableStateOf<ImmutableMap<StepGroup, ImmutableList<Step>>?>(null)
private set
var stacktrace by mutableStateOf("")
private set
private val debugInfo: String
get() = """
Aliucord Manager ${BuildConfig.VERSION_NAME}
Built from commit ${BuildConfig.GIT_COMMIT} on ${BuildConfig.GIT_BRANCH} ${if (BuildConfig.GIT_LOCAL_CHANGES || BuildConfig.GIT_LOCAL_COMMITS) "(Changes present)" else ""}
Running Android ${Build.VERSION.RELEASE}, API level ${Build.VERSION.SDK_INT}
Supported ABIs: ${Build.SUPPORTED_ABIS.joinToString()}
Installing Aliucord kt with the ${installData.downloadMethod} apk method
Failed on: ${currentStep?.name}
""".trimIndent()
init {
restart()
}
fun copyDebugToClipboard() {
val text = "$debugInfo\n\n$stacktrace"
// TODO: remove this useless replace
.replace("(\\\\*~_)".toRegex(), "\\$1")
val content = (state.value as? InstallScreenState.Failed)?.failureLog
?: return
application.copyToClipboard(text)
application.copyToClipboard(content)
application.showToast(R.string.action_copied)
}
private var debugLogPath by mutableStateOf<String?>(null)
fun saveFailureLog() {
val failureLog = (state.value as? InstallScreenState.Failed)?.failureLog
?: return
@SuppressLint("SimpleDateFormat")
fun saveDebugToFile() {
val name = if (debugLogPath != null) {
debugLogPath!!
} else {
"Aliucord Manager ${SimpleDateFormat("yyyy-MM-dd hh-mm-s a").format(Date())}.log"
.also { debugLogPath = it }
}
@SuppressLint("SimpleDateFormat")
val formattedDate = SimpleDateFormat("yyyy-MM-dd hh-mm-s a").format(startTime)
val fileName = "Aliucord Manager $formattedDate.log"
application.saveFile(name, "$debugInfo\n\n$stacktrace")
application.saveFile(fileName, failureLog)
}
fun clearCache() {
externalCacheDir.deleteRecursively()
paths.clearCache()
application.showToast(R.string.action_cleared_cache)
}
private val installJob = screenModelScope.launch(Dispatchers.Main) {
if (installationRunning.getAndSet(true)) {
return@launch
}
fun restart() {
installJob?.cancel("Manual cancellation")
installSteps = null
withContext(Dispatchers.IO) {
try {
installKotlin()
startTime = Date()
mutableState.value = InstallScreenState.Working
isFinished = true
delay(20000)
returnToHome = true
} catch (t: Throwable) {
stacktrace = Log.getStackTraceString(t)
val newInstallJob = screenModelScope.launch {
val runner = KotlinInstallRunner()
Log.e(
BuildConfig.TAG,
"$debugInfo\n\n${Log.getStackTraceString(t)}"
)
}
installSteps = runner.steps.groupBy { it.group }
.mapValues { it.value.toUnsafeImmutable() }
.toUnsafeImmutable()
installationRunning.set(false)
}
}
// Intentionally delay to show the state change of the first step in UI when it runs
// without it, on a fast internet it just immediately shows as "Success"
delay(600)
private fun clearOldCache(targetVersion: Int) {
externalCacheDir.listFiles { f -> f.isDirectory }
?.map { it.name.toIntOrNull() to it }
?.filter { it.first != null }
?.filter { it.first!! in (126021 + 1) until targetVersion }
?.forEach { it.second.deleteRecursively() }
}
// Execute all the steps and catch any errors
when (val error = runner.executeAll()) {
// Successfully installed
null -> {
mutableState.value = InstallScreenState.Success
private suspend fun uninstallNewAliucord(targetVersion: Int) {
val (_, versionCode) = try {
application.getPackageVersion(preferences.packageName)
} catch (t: Throwable) {
return
}
if (targetVersion < versionCode) {
application.uninstallApk(preferences.packageName)
withContext(Dispatchers.Main) {
application.showToast(R.string.installer_uninstall_new)
}
throw Error("Pleaser uninstall newer Aliucord prior to installing")
}
}
override fun onDispose() {
if (installationRunning.getAndSet(false)) {
installJob.cancel("ViewModel cleared")
}
}
private suspend fun installKotlin() {
steps += listOfNotNull(
InstallStep.FETCH_KT_VERSION,
InstallStep.DL_KT_APK,
InstallStep.DL_KOTLIN,
InstallStep.DL_INJECTOR,
InstallStep.DL_ALIUHOOK,
if (preferences.replaceIcon) InstallStep.PATCH_APP_ICON else null,
InstallStep.PATCH_MANIFEST,
InstallStep.PATCH_DEX,
InstallStep.PATCH_LIBS,
InstallStep.SIGN_APK,
InstallStep.INSTALL_APK,
).map {
it to InstallStepData(it.nameResId, InstallStatus.QUEUED)
}
val dataJson = step(InstallStep.FETCH_KT_VERSION) {
githubRepository.getDataJson().getOrThrow()
}
val arch = Build.SUPPORTED_ABIS.first()
val cacheDir = externalCacheDir
val discordCacheDir = externalCacheDir.resolve(dataJson.versionCode)
val patchedDir = discordCacheDir.resolve("patched").also { it.deleteRecursively() }
dataJson.versionCode.toInt().also {
clearOldCache(it)
uninstallNewAliucord(it)
}
// Download base.apk
val baseApkFile = step(InstallStep.DL_KT_APK) {
discordCacheDir.resolve("base.apk").let { file ->
if (file.exists()) {
cached = true
} else {
downloadManager.downloadDiscordApk(dataJson.versionCode, file)
// Wait 20s before returning to Home
delay(20_000)
mutableState.value = InstallScreenState.CloseScreen
}
file.copyTo(
patchedDir.resolve(file.name),
true
)
}
}
else -> {
Log.e(BuildConfig.TAG, "Failed to perform installation process", error)
val kotlinFile = step(InstallStep.DL_KOTLIN) {
cacheDir.resolve("kotlin.dex").also { file ->
if (file.exists()) {
cached = true
} else {
downloadManager.downloadKotlinDex(file)
mutableState.value = InstallScreenState.Failed(failureLog = getFailureInfo(error))
}
}
}
// Download the injector dex
val injectorFile = step(InstallStep.DL_INJECTOR) {
cacheDir.resolve("injector-${dataJson.aliucordHash}.dex").also { file ->
if (file.exists()) {
cached = true
} else {
downloadManager.downloadKtInjector(file)
}
}
}
newInstallJob.invokeOnCompletion { error ->
when (error) {
// Successfully executed, already handled above
null -> {}
// Download Aliuhook aar
val aliuhookAarFile = step(InstallStep.DL_ALIUHOOK) {
// Fetch aliuhook version
val aliuhookVersion = aliucordMaven.getAliuhookVersion().getOrThrow()
// Download aliuhook aar
cacheDir.resolve("aliuhook-${aliuhookVersion}.aar").also { file ->
if (file.exists()) {
cached = true
} else {
downloadManager.downloadAliuhook(aliuhookVersion, file)
}
}
}
// Replace app icons
if (preferences.replaceIcon) {
step(InstallStep.PATCH_APP_ICON) {
ZipWriter(baseApkFile, true).use { baseApk ->
val foregroundIcon = application.assets.open("icons/ic_logo_foreground.png")
.use { it.readBytes() }
val squareIcon = application.assets.open("icons/ic_logo_square.png")
.use { it.readBytes() }
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"
baseApk.deleteEntry(path)
baseApk.writeEntry(path, replacement)
}
}
}
}
}
// Patch manifests
step(InstallStep.PATCH_MANIFEST) {
val manifest = ZipReader(baseApkFile)
.use { zip -> zip.openEntry("AndroidManifest.xml")?.read() }
?: throw IllegalStateException("No manifest in base apk")
ZipWriter(baseApkFile, true).use { zip ->
val patchedManifestBytes = ManifestPatcher.patchManifest(
manifestBytes = manifest,
packageName = preferences.packageName,
appName = preferences.appName,
debuggable = preferences.debuggable,
)
zip.deleteEntry("AndroidManifest.xml")
zip.writeEntry("AndroidManifest.xml", patchedManifestBytes)
}
}
// Re-order dex files
val dexCount = step(InstallStep.PATCH_DEX) {
val (dexCount, firstDexBytes) = ZipReader(baseApkFile).use { zip ->
Pair(
// Find the amount of .dex files in apk
zip.entryNames.count { it.endsWith(".dex") },
// Get the first dex
zip.openEntry("classes.dex")?.read()
?: throw IllegalStateException("No classes.dex in base apk")
)
}
ZipWriter(baseApkFile, true).use { zip ->
// Move copied dex to end of dex list
zip.deleteEntry("classes.dex")
zip.writeEntry("classes${dexCount + 1}.dex", firstDexBytes)
// Add Kotlin & Aliucord's dex
zip.writeEntry("classes.dex", injectorFile.readBytes())
zip.writeEntry("classes${dexCount + 2}.dex", kotlinFile.readBytes())
}
dexCount
}
// Replace libs
step(InstallStep.PATCH_LIBS) {
ZipWriter(baseApkFile, true).use { baseApk ->
ZipReader(aliuhookAarFile).use { aliuhookAar ->
for (libFile in arrayOf("libaliuhook.so", "libc++_shared.so", "liblsplant.so")) {
val bytes = aliuhookAar.openEntry("jni/$arch/$libFile")?.read()
?: throw IllegalStateException("Failed to read $libFile from aliuhook aar")
baseApk.writeEntry("lib/$arch/$libFile", bytes)
}
// Add Aliuhook's dex file
val aliuhookDex = aliuhookAar.openEntry("classes.dex")?.read()
?: throw IllegalStateException("No classes.dex in aliuhook aar")
baseApk.writeEntry("classes${dexCount + 3}.dex", aliuhookDex)
}
}
}
step(InstallStep.SIGN_APK) {
// Align resources.arsc due to targeting api 30 for silent install
if (Build.VERSION.SDK_INT >= 31) {
val bytes = ZipReader(baseApkFile).use {
if (it.entryNames.contains("resources.arsc")) {
it.openEntry("resources.arsc")?.read()
} else {
null
}
// Job was cancelled before being able to finish setting state
is CancellationException -> {
Log.w(BuildConfig.TAG, "Installation was cancelled before completing", error)
mutableState.value = InstallScreenState.CloseScreen
}
ZipWriter(baseApkFile, true).use {
it.deleteEntry("resources.arsc", true)
it.writeEntry("resources.arsc", bytes, ZipCompression.NONE, 4096)
}
}
Signer.signApk(baseApkFile)
}
step(InstallStep.INSTALL_APK) {
application.installApks(silent = !preferences.devMode, baseApkFile)
if (!preferences.keepPatchedApks) {
patchedDir.deleteRecursively()
// This should never happen, all install errors are caught
else -> throw error
}
}
installJob = newInstallJob
}
private inline fun <T> step(step: InstallStep, block: InstallStepData.() -> T): T {
steps[step]!!.status = InstallStatus.ONGOING
currentStep = step
private fun getFailureInfo(stacktrace: Throwable): String {
val gitChanges = if (BuildConfig.GIT_LOCAL_CHANGES || BuildConfig.GIT_LOCAL_COMMITS) "(Changes present)" else ""
val soc = if (Build.VERSION.SDK_INT >= 31) (Build.SOC_MANUFACTURER + ' ' + Build.SOC_MODEL) else "Unknown"
try {
val value = measureTimedValue { block.invoke(steps[step]!!) }
val millis = value.duration.inWholeMilliseconds
val header = """
Aliucord Manager v${BuildConfig.VERSION_NAME}
Built from commit ${BuildConfig.GIT_COMMIT} on ${BuildConfig.GIT_BRANCH} $gitChanges
// Add delay for human psychology + groups are switched too fast
if (!preferences.devMode && millis < 1000) {
Thread.sleep(1000 - millis)
}
Running Android ${Build.VERSION.RELEASE}, API level ${Build.VERSION.SDK_INT}
Supported ABIs: ${Build.SUPPORTED_ABIS.joinToString()}
Device: ${Build.MANUFACTURER} - ${Build.MODEL} (${Build.DEVICE})
SOC: $soc
""".trimIndent()
steps[step]!!.apply {
duration = millis.div(1000f)
status = InstallStatus.SUCCESSFUL
}
currentStep = step
return value.value
} catch (t: Throwable) {
steps[step]!!.status = InstallStatus.UNSUCCESSFUL
currentStep = step
throw t
}
}
enum class InstallStepGroup(
@StringRes
val nameResId: Int,
) {
APK_DL(R.string.install_group_apk_dl),
LIB_DL(R.string.install_group_lib_dl),
PATCHING(R.string.install_group_patch),
INSTALLING(R.string.install_group_install)
}
// Order matters, define it in the same order as it is patched
enum class InstallStep(
val group: InstallStepGroup,
@StringRes
val nameResId: Int,
) {
// Kotlin
FETCH_KT_VERSION(InstallStepGroup.APK_DL, R.string.install_step_fetch_kt_version),
DL_KT_APK(InstallStepGroup.APK_DL, R.string.install_step_dl_kt_apk),
DL_KOTLIN(InstallStepGroup.LIB_DL, R.string.install_step_dl_kotlin),
DL_INJECTOR(InstallStepGroup.LIB_DL, R.string.install_step_dl_injector),
DL_ALIUHOOK(InstallStepGroup.LIB_DL, R.string.install_step_dl_aliuhook),
// Common
PATCH_APP_ICON(InstallStepGroup.PATCHING, R.string.install_step_patch_icons),
PATCH_MANIFEST(InstallStepGroup.PATCHING, R.string.install_step_patch_manifests),
PATCH_DEX(InstallStepGroup.PATCHING, R.string.install_step_patch_dex),
PATCH_LIBS(InstallStepGroup.PATCHING, R.string.install_step_patch_libs),
SIGN_APK(InstallStepGroup.INSTALLING, R.string.install_step_signing),
INSTALL_APK(InstallStepGroup.INSTALLING, R.string.install_step_installing);
}
var currentStep: InstallStep? by mutableStateOf(null)
val steps = mutableStateMapOf<InstallStep, InstallStepData>()
// TODO: cache this instead
fun getSteps(group: InstallStepGroup): List<InstallStepData> {
return steps
.filterKeys { it.group == group }.entries
.sortedBy { it.key.ordinal }
.map { it.value }
return header + "\n\n" + Log.getStackTraceString(stacktrace)
}
}

View file

@ -5,7 +5,6 @@
package com.aliucord.manager.ui.screens.install
import android.os.Parcelable
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.*
import androidx.compose.foundation.layout.*
@ -25,44 +24,39 @@ import cafe.adriel.voyager.koin.getScreenModel
import cafe.adriel.voyager.navigator.LocalNavigator
import cafe.adriel.voyager.navigator.currentOrThrow
import com.aliucord.manager.R
import com.aliucord.manager.ui.components.BackButton
import com.aliucord.manager.installer.steps.StepGroup
import com.aliucord.manager.ui.components.back
import com.aliucord.manager.ui.components.dialogs.DownloadMethod
import com.aliucord.manager.ui.components.dialogs.InstallerAbortDialog
import com.aliucord.manager.ui.components.installer.InstallGroup
import com.aliucord.manager.ui.components.installer.InstallStatus
import com.aliucord.manager.ui.screens.install.InstallModel.InstallStepGroup
import kotlinx.collections.immutable.toImmutableList
import kotlinx.parcelize.Parcelize
import org.koin.core.parameter.parametersOf
import com.aliucord.manager.ui.screens.install.components.StepGroupCard
@Immutable // this isn't *really* stable, but this never gets modified after being passed to a composable, so...
@Parcelize
data class InstallData(
val downloadMethod: DownloadMethod,
var baseApk: String? = null,
var splits: List<String>? = null,
) : Parcelable
class InstallScreen(val data: InstallData) : Screen {
class InstallScreen : Screen {
override val key = "Install"
@Composable
override fun Content() {
val navigator = LocalNavigator.currentOrThrow
val model = getScreenModel<InstallModel>(parameters = { parametersOf(data) })
val model = getScreenModel<InstallModel>()
val state = model.state.collectAsState()
var expandedGroup by remember { mutableStateOf<InstallStepGroup?>(null) }
if (model.returnToHome)
navigator.back(null)
LaunchedEffect(model.currentStep) {
expandedGroup = model.currentStep?.group
LaunchedEffect(model.state) {
if (model.state.value is InstallScreenState.CloseScreen)
navigator.back(currentActivity = null)
}
// Exit warning dialog (dismiss itself if install process state changes, esp. for Success)
var showAbortWarning by remember(model.state.collectAsState()) { mutableStateOf(false) }
// Only show exit warning if currently working
val onTryExit: () -> Unit = remember {
{
if (state.value == InstallScreenState.Working) {
showAbortWarning = true
} else {
navigator.back(currentActivity = null)
}
}
}
// Exit warning dialog
var showAbortWarning by remember { mutableStateOf(false) }
if (showAbortWarning) {
InstallerAbortDialog(
onDismiss = { showAbortWarning = false },
@ -72,9 +66,7 @@ class InstallScreen(val data: InstallData) : Screen {
},
)
} else {
BackHandler {
showAbortWarning = true
}
BackHandler(onBack = onTryExit)
}
Scaffold(
@ -82,9 +74,7 @@ class InstallScreen(val data: InstallData) : Screen {
TopAppBar(
title = { Text(stringResource(R.string.installer)) },
navigationIcon = {
IconButton(
onClick = { showAbortWarning = true },
) {
IconButton(onClick = onTryExit) {
Icon(
painter = painterResource(R.drawable.ic_back),
contentDescription = stringResource(R.string.navigation_back),
@ -95,13 +85,7 @@ class InstallScreen(val data: InstallData) : Screen {
}
) { paddingValues ->
Column(Modifier.padding(paddingValues)) {
val isCurrentlyProcessing by remember {
derivedStateOf {
model.steps[model.currentStep]?.status == InstallStatus.ONGOING
}
}
if (isCurrentlyProcessing) {
if (state.value is InstallScreenState.Working) {
LinearProgressIndicator(
modifier = Modifier
.fillMaxWidth()
@ -117,16 +101,26 @@ class InstallScreen(val data: InstallData) : Screen {
.fillMaxWidth()
.padding(16.dp)
) {
for (group in InstallStepGroup.entries) key(group) {
InstallGroup(
name = stringResource(group.nameResId),
isCurrent = expandedGroup == group,
onClick = { expandedGroup = group },
subSteps = model.getSteps(group).toImmutableList(),
)
var expandedGroup by remember { mutableStateOf<StepGroup?>(StepGroup.Prepare) }
// Close all groups when successfully finished everything
LaunchedEffect(state.value) {
if (state.value == InstallScreenState.Success)
expandedGroup = null
}
if (model.isFinished && model.stacktrace.isEmpty()) {
model.installSteps?.let { groupedSteps ->
for ((group, steps) in groupedSteps.entries) key(group) {
StepGroupCard(
name = stringResource(group.localizedName),
subSteps = steps,
isExpanded = expandedGroup == group,
onExpand = { expandedGroup = group },
)
}
}
if (state.value.isFinished) {
Row(
horizontalArrangement = Arrangement.End,
modifier = Modifier.fillMaxWidth()
@ -137,20 +131,8 @@ class InstallScreen(val data: InstallData) : Screen {
}
}
if (model.stacktrace.isNotEmpty()) {
SelectionContainer {
Text(
text = model.stacktrace,
style = MaterialTheme.typography.labelSmall,
fontFamily = FontFamily.Monospace,
softWrap = false,
modifier = Modifier
.clip(RoundedCornerShape(10.dp))
.background(MaterialTheme.colorScheme.surfaceColorAtElevation(10.dp))
.padding(10.dp)
.horizontalScroll(rememberScrollState())
)
}
if (state.value is InstallScreenState.Failed) {
val failureLog = (state.value as InstallScreenState.Failed).failureLog
Row(
horizontalArrangement = Arrangement.spacedBy(10.dp, Alignment.End),
@ -162,7 +144,7 @@ class InstallScreen(val data: InstallData) : Screen {
Spacer(Modifier.weight(1f, true))
OutlinedButton(onClick = model::saveDebugToFile) {
OutlinedButton(onClick = model::saveFailureLog) {
Text(stringResource(R.string.installer_save_file))
}
@ -170,6 +152,20 @@ class InstallScreen(val data: InstallData) : Screen {
Text(stringResource(R.string.action_copy))
}
}
SelectionContainer {
Text(
text = failureLog,
style = MaterialTheme.typography.labelSmall,
fontFamily = FontFamily.Monospace,
softWrap = false,
modifier = Modifier
.clip(RoundedCornerShape(10.dp))
.background(MaterialTheme.colorScheme.surfaceColorAtElevation(10.dp))
.padding(10.dp)
.horizontalScroll(rememberScrollState())
)
}
}
}
}

View file

@ -0,0 +1,16 @@
package com.aliucord.manager.ui.screens.install
sealed interface InstallScreenState {
data object Pending : InstallScreenState
data object Working : InstallScreenState
data object Success : InstallScreenState
data class Failed(
val failureLog: String,
) : InstallScreenState
data object CloseScreen : InstallScreenState
val isFinished: Boolean
get() = this !is Pending && this !is Working
}

View file

@ -0,0 +1,104 @@
package com.aliucord.manager.ui.screens.install.components
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.aliucord.manager.R
import com.aliucord.manager.installer.steps.base.Step
import com.aliucord.manager.installer.steps.base.StepState
import com.aliucord.manager.ui.util.thenIf
import kotlinx.collections.immutable.ImmutableList
@Composable
fun StepGroupCard(
name: String,
subSteps: ImmutableList<Step>,
isExpanded: Boolean,
onExpand: () -> Unit,
) {
val groupState by remember {
derivedStateOf {
when {
// If all steps are pending then show pending
subSteps.all { it.state == StepState.Pending } -> StepState.Pending
// If any step has finished with an error then default to error
subSteps.any { it.state == StepState.Error } -> StepState.Error
// If all steps have finished as Skipped/Success then show success
subSteps.all { it.state.isFinished } -> StepState.Success
else -> StepState.Running
}
}
}
LaunchedEffect(groupState) {
if (groupState != StepState.Pending)
onExpand()
}
Column(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(16.dp))
.thenIf(isExpanded) {
background(MaterialTheme.colorScheme.surfaceColorAtElevation(1.dp))
}
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
modifier = Modifier
.clickable(true, onClick = onExpand)
.fillMaxWidth()
.padding(16.dp)
) {
StepStatusIcon(groupState, 24.dp)
Text(text = name)
Spacer(modifier = Modifier.weight(1f))
if (groupState.isFinished) Text(
"%.2fs".format(subSteps.sumOf { it.durationMs } / 1000f),
style = MaterialTheme.typography.labelMedium
)
if (isExpanded) {
Icon(
painter = painterResource(R.drawable.ic_arrow_up_small),
contentDescription = stringResource(R.string.action_collapse)
)
} else {
Icon(
painter = painterResource(R.drawable.ic_arrow_down_small),
contentDescription = stringResource(R.string.action_expand)
)
}
}
AnimatedVisibility(visible = isExpanded) {
Column(
verticalArrangement = Arrangement.spacedBy(16.dp),
modifier = Modifier
.background(MaterialTheme.colorScheme.background.copy(0.6f))
.fillMaxWidth()
.padding(16.dp)
.padding(start = 4.dp)
) {
for (step in subSteps) key(step) {
StepItem(step)
}
}
}
}
}

View file

@ -0,0 +1,44 @@
package com.aliucord.manager.ui.screens.install.components
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.aliucord.manager.installer.steps.base.Step
@Composable
fun StepItem(
step: Step,
modifier: Modifier = Modifier,
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(16.dp),
modifier = modifier,
) {
StepStatusIcon(step.state, size = 18.dp)
Text(
text = stringResource(step.localizedName),
style = MaterialTheme.typography.labelLarge,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f, true),
)
// TODO: live step duration counter
if (step.state.isFinished) {
Text(
text = "%.2fs".format(step.durationMs / 1000f),
style = MaterialTheme.typography.labelSmall,
maxLines = 1,
)
}
}
}

View file

@ -0,0 +1,59 @@
package com.aliucord.manager.ui.screens.install.components
import androidx.compose.foundation.layout.size
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.Dp
import com.aliucord.manager.R
import com.aliucord.manager.installer.steps.base.StepState
import kotlin.math.floor
@Composable
fun StepStatusIcon(status: StepState, size: Dp) {
val strokeWidth = Dp(floor(size.value / 10) + 1)
val context = LocalContext.current
when (status) {
StepState.Pending -> Icon(
painter = painterResource(R.drawable.ic_circle),
contentDescription = stringResource(R.string.status_queued),
tint = MaterialTheme.colorScheme.onSurface.copy(0.4f),
modifier = Modifier.size(size)
)
StepState.Running -> CircularProgressIndicator(
strokeWidth = strokeWidth,
modifier = Modifier
.size(size)
.semantics { contentDescription = context.getString(R.string.status_ongoing) }
)
StepState.Success -> Icon(
painter = painterResource(R.drawable.ic_check_circle),
contentDescription = stringResource(R.string.status_success),
tint = Color(0xFF59B463),
modifier = Modifier.size(size)
)
StepState.Error -> Icon(
painter = painterResource(R.drawable.ic_canceled),
contentDescription = stringResource(R.string.status_failed),
tint = MaterialTheme.colorScheme.error,
modifier = Modifier.size(size)
)
StepState.Skipped -> Icon(
painter = painterResource(R.drawable.ic_check_circle),
contentDescription = stringResource(R.string.status_skipped),
tint = Color(0xFFAEAEAE),
modifier = Modifier.size(size)
)
}
}

View file

@ -0,0 +1,22 @@
@file:Suppress("unused")
package com.aliucord.manager.ui.util
import androidx.compose.ui.Modifier
/**
* Apply additional modifiers if [value] is not null.
*/
inline fun <T> Modifier.thenIf(value: T?, block: Modifier.(T) -> Modifier): Modifier =
value?.let { block(it) } ?: this
/**
* Apply additional modifiers if [predicate] is true.
*/
inline fun Modifier.thenIf(predicate: Boolean, block: Modifier.() -> Modifier): Modifier {
return if (predicate) {
block()
} else {
this
}
}

View file

@ -0,0 +1,29 @@
@file:Suppress("unused")
package com.aliucord.manager.ui.util
import kotlinx.collections.immutable.*
import kotlinx.collections.immutable.adapters.*
/*
* Compose-stable wrappers over a list for performance.
*
* This does NOT guarantee stability. It is merely a stable wrapper over another collection,
* and assumes the user knows that it shouldn't change through crucial parts of rendering.
*/
fun <T> Collection<T>.toUnsafeImmutable(): ImmutableCollection<T> =
ImmutableCollectionAdapter(this)
fun <T> List<T>.toUnsafeImmutable(): ImmutableList<T> =
ImmutableListAdapter(this)
fun <T> Set<T>.toUnsafeImmutable(): ImmutableSet<T> =
ImmutableSetAdapter(this)
fun <K, V> Map<K, V>.toUnsafeImmutable(): ImmutableMap<K, V> =
ImmutableMapAdapter(this)
fun <T> emptyImmutableList(): ImmutableList<T> = persistentListOf()
fun <T> emptyImmutableSet(): ImmutableSet<T> = persistentSetOf()
fun <K, V> emptyImmutableMap(): ImmutableMap<K, V> = persistentMapOf()

View file

@ -5,9 +5,9 @@ import androidx.compose.runtime.*
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.aliucord.manager.BuildConfig
import com.aliucord.manager.manager.DownloadManager
import com.aliucord.manager.domain.repository.GithubRepository
import com.aliucord.manager.installer.util.installApks
import com.aliucord.manager.manager.DownloadManager
import com.aliucord.manager.network.utils.SemVer
import com.aliucord.manager.network.utils.getOrNull
import kotlinx.coroutines.Dispatchers

View file

@ -93,6 +93,7 @@
<string name="installer_cached">(Cached)</string>
<string name="installer_success">Successfully installed Aliucord</string>
<string name="installer_aborted">Aborted Aliucord installation</string>
<string name="installer_dl_verify_fail">Failed to verify download</string>
<string name="installer_uninstall_new">Please uninstall your current version of Aliucord in order to continue!</string>
<string name="install_error_unknown">Failed to install (Unknown reason)</string>
@ -104,28 +105,32 @@
<string name="install_error_incompatible">Application is incompatible with this device</string>
<string name="install_error_timeout">Installation timed out</string>
<string name="install_group_apk_dl">Download APKs</string>
<string name="install_group_lib_dl">Download Libraries</string>
<string name="install_group_patch">Patch APKs</string>
<string name="install_group_prepare">Prepare</string>
<string name="install_group_download">Download dependencies</string>
<string name="install_group_patch">Patch APK</string>
<string name="install_group_install">Install APK</string>
<string name="install_step_fetch_kt_version">Fetching target Discord version</string>
<string name="install_step_downgrade_check">Checking for older installations</string>
<string name="install_step_dl_kt_apk">Downloading Discord APK</string>
<string name="install_step_dl_kotlin">Downloading Kotlin Stdlib</string>
<string name="install_step_dl_injector">Downloading Aliucord Injector</string>
<string name="install_step_dl_aliuhook">Downloading Aliuhook library</string>
<string name="install_step_copy">Copying dependencies</string>
<string name="install_step_patch_icons">Patching app icons</string>
<string name="install_step_patch_manifests">Patching apk manifests</string>
<string name="install_step_patch_dex">Adding aliucord dex into apk</string>
<string name="install_step_patch_libs">Replacing libraries</string>
<string name="install_step_add_injector">Adding Aliucord injector</string>
<string name="install_step_add_aliuhook">Adding Aliuhook library</string>
<string name="install_step_alignment">Aligning APKs</string>
<string name="install_step_signing">Signing APKs</string>
<string name="install_step_installing">Installing APKs</string>
<string name="install_step_cleanup">Cleaning up</string>
<string name="install_step_fetch_kt_version">Fetching target Discord version</string>
<string name="install_step_dl_kt_apk">Downloading Discord APK</string>
<string name="install_step_dl_kotlin">Downloading Kotlin library</string>
<string name="install_step_dl_injector">Downloading injector</string>
<string name="install_step_dl_aliuhook">Downloading Aliuhook library</string>
<string name="status_success">Success</string>
<string name="status_ongoing">Ongoing</string>
<string name="status_failed">Failed</string>
<string name="status_queued">Queued</string>
<string name="status_ongoing">Ongoing</string>
<string name="status_skipped">Skipped</string>
<string name="status_success">Success</string>
<string name="status_failed">Failed</string>
<string name="installer_abort_title">Really exit?</string>
<string name="installer_abort_body">Are you sure you really want to abort an in-progress installation? Cached files will be cleared to avoid corruption.</string>