diff --git a/app/src/main/kotlin/com/aliucord/manager/ManagerApplication.kt b/app/src/main/kotlin/com/aliucord/manager/ManagerApplication.kt index db70858b..17f47532 100644 --- a/app/src/main/kotlin/com/aliucord/manager/ManagerApplication.kt +++ b/app/src/main/kotlin/com/aliucord/manager/ManagerApplication.kt @@ -59,6 +59,7 @@ class ManagerApplication : Application() { modules(module { single { providePreferences() } single { provideDownloadManager() } + single { providePathManager() } }) } } diff --git a/app/src/main/kotlin/com/aliucord/manager/di/Managers.kt b/app/src/main/kotlin/com/aliucord/manager/di/Managers.kt index 534b1a4b..52b2e6a9 100644 --- a/app/src/main/kotlin/com/aliucord/manager/di/Managers.kt +++ b/app/src/main/kotlin/com/aliucord/manager/di/Managers.kt @@ -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) +} diff --git a/app/src/main/kotlin/com/aliucord/manager/installer/steps/KotlinInstallRunner.kt b/app/src/main/kotlin/com/aliucord/manager/installer/steps/KotlinInstallRunner.kt new file mode 100644 index 00000000..6a6430fd --- /dev/null +++ b/app/src/main/kotlin/com/aliucord/manager/installer/steps/KotlinInstallRunner.kt @@ -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( + // Prepare + FetchInfoStep(), + DowngradeCheckStep(), + + // Download + DownloadDiscordStep(), + DownloadInjectorStep(), + DownloadAliuhookStep(), + DownloadKotlinStep(), + + // Patch + CopyDependenciesStep(), + ReplaceIconStep(), + PatchManifestStep(), + AddInjectorStep(), + AddAliuhookStep(), + + // Install + AlignmentStep(), + SigningStep(), + InstallStep(), + CleanupStep(), + ) +} diff --git a/app/src/main/kotlin/com/aliucord/manager/installer/steps/StepGroup.kt b/app/src/main/kotlin/com/aliucord/manager/installer/steps/StepGroup.kt new file mode 100644 index 00000000..46fc6d3c --- /dev/null +++ b/app/src/main/kotlin/com/aliucord/manager/installer/steps/StepGroup.kt @@ -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) +} diff --git a/app/src/main/kotlin/com/aliucord/manager/installer/steps/StepRunner.kt b/app/src/main/kotlin/com/aliucord/manager/installer/steps/StepRunner.kt new file mode 100644 index 00000000..bac13c98 --- /dev/null +++ b/app/src/main/kotlin/com/aliucord/manager/installer/steps/StepRunner.kt @@ -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 + + /** + * Get a step that has already been successfully executed. + * This is used to retrieve previously executed dependency steps from a later step. + */ + inline fun getStep(): T { + val step = steps.asSequence() + .filterIsInstance() + .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 + } +} diff --git a/app/src/main/kotlin/com/aliucord/manager/installer/steps/base/DownloadStep.kt b/app/src/main/kotlin/com/aliucord/manager/installer/steps/base/DownloadStep.kt new file mode 100644 index 00000000..c35e6253 --- /dev/null +++ b/app/src/main/kotlin/com/aliucord/manager/installer/steps/base/DownloadStep.kt @@ -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 + } + } +} diff --git a/app/src/main/kotlin/com/aliucord/manager/installer/steps/base/Step.kt b/app/src/main/kotlin/com/aliucord/manager/installer/steps/base/Step.kt new file mode 100644 index 00000000..1b870d1c --- /dev/null +++ b/app/src/main/kotlin/com/aliucord/manager/installer/steps/base/Step.kt @@ -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 + } +} diff --git a/app/src/main/kotlin/com/aliucord/manager/installer/steps/base/StepState.kt b/app/src/main/kotlin/com/aliucord/manager/installer/steps/base/StepState.kt new file mode 100644 index 00000000..eac39e79 --- /dev/null +++ b/app/src/main/kotlin/com/aliucord/manager/installer/steps/base/StepState.kt @@ -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 +} diff --git a/app/src/main/kotlin/com/aliucord/manager/installer/steps/download/DownloadAliuhookStep.kt b/app/src/main/kotlin/com/aliucord/manager/installer/steps/download/DownloadAliuhookStep.kt new file mode 100644 index 00000000..87e5fb01 --- /dev/null +++ b/app/src/main/kotlin/com/aliucord/manager/installer/steps/download/DownloadAliuhookStep.kt @@ -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) + } +} + diff --git a/app/src/main/kotlin/com/aliucord/manager/installer/steps/download/DownloadDiscordStep.kt b/app/src/main/kotlin/com/aliucord/manager/installer/steps/download/DownloadDiscordStep.kt new file mode 100644 index 00000000..2fced638 --- /dev/null +++ b/app/src/main/kotlin/com/aliucord/manager/installer/steps/download/DownloadDiscordStep.kt @@ -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" + } +} diff --git a/app/src/main/kotlin/com/aliucord/manager/installer/steps/download/DownloadInjectorStep.kt b/app/src/main/kotlin/com/aliucord/manager/installer/steps/download/DownloadInjectorStep.kt new file mode 100644 index 00000000..831b54df --- /dev/null +++ b/app/src/main/kotlin/com/aliucord/manager/installer/steps/download/DownloadInjectorStep.kt @@ -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() + .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" + } +} diff --git a/app/src/main/kotlin/com/aliucord/manager/installer/steps/download/DownloadKotlinStep.kt b/app/src/main/kotlin/com/aliucord/manager/installer/steps/download/DownloadKotlinStep.kt new file mode 100644 index 00000000..cf5990e4 --- /dev/null +++ b/app/src/main/kotlin/com/aliucord/manager/installer/steps/download/DownloadKotlinStep.kt @@ -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" + } +} diff --git a/app/src/main/kotlin/com/aliucord/manager/installer/steps/install/AlignmentStep.kt b/app/src/main/kotlin/com/aliucord/manager/installer/steps/install/AlignmentStep.kt new file mode 100644 index 00000000..b4e9d758 --- /dev/null +++ b/app/src/main/kotlin/com/aliucord/manager/installer/steps/install/AlignmentStep.kt @@ -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().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 + } + } +} diff --git a/app/src/main/kotlin/com/aliucord/manager/installer/steps/install/CleanupStep.kt b/app/src/main/kotlin/com/aliucord/manager/installer/steps/install/CleanupStep.kt new file mode 100644 index 00000000..d57635f2 --- /dev/null +++ b/app/src/main/kotlin/com/aliucord/manager/installer/steps/install/CleanupStep.kt @@ -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") + } + } +} diff --git a/app/src/main/kotlin/com/aliucord/manager/installer/steps/install/InstallStep.kt b/app/src/main/kotlin/com/aliucord/manager/installer/steps/install/InstallStep.kt new file mode 100644 index 00000000..026b4c9c --- /dev/null +++ b/app/src/main/kotlin/com/aliucord/manager/installer/steps/install/InstallStep.kt @@ -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().patchedApk + + application.installApks( + silent = !prefs.devMode, + apks = arrayOf(apk), + ) + } +} diff --git a/app/src/main/kotlin/com/aliucord/manager/installer/steps/install/SigningStep.kt b/app/src/main/kotlin/com/aliucord/manager/installer/steps/install/SigningStep.kt new file mode 100644 index 00000000..bf84aced --- /dev/null +++ b/app/src/main/kotlin/com/aliucord/manager/installer/steps/install/SigningStep.kt @@ -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().patchedApk + + Signer.signApk(apk) + } +} diff --git a/app/src/main/kotlin/com/aliucord/manager/installer/steps/patch/AddAliuhookStep.kt b/app/src/main/kotlin/com/aliucord/manager/installer/steps/patch/AddAliuhookStep.kt new file mode 100644 index 00000000..b10f997f --- /dev/null +++ b/app/src/main/kotlin/com/aliucord/manager/installer/steps/patch/AddAliuhookStep.kt @@ -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().patchedApk + val aliuhook = container.getStep().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) + } + } + } +} diff --git a/app/src/main/kotlin/com/aliucord/manager/installer/steps/patch/AddInjectorStep.kt b/app/src/main/kotlin/com/aliucord/manager/installer/steps/patch/AddInjectorStep.kt new file mode 100644 index 00000000..e60ce32d --- /dev/null +++ b/app/src/main/kotlin/com/aliucord/manager/installer/steps/patch/AddInjectorStep.kt @@ -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().patchedApk + val injector = container.getStep().targetFile + val kotlinStdlib = container.getStep().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()) + } + } +} diff --git a/app/src/main/kotlin/com/aliucord/manager/installer/steps/patch/CopyDependenciesStep.kt b/app/src/main/kotlin/com/aliucord/manager/installer/steps/patch/CopyDependenciesStep.kt new file mode 100644 index 00000000..309f402d --- /dev/null +++ b/app/src/main/kotlin/com/aliucord/manager/installer/steps/patch/CopyDependenciesStep.kt @@ -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().targetFile + + srcApk.copyTo(patchedApk) + } +} diff --git a/app/src/main/kotlin/com/aliucord/manager/installer/steps/patch/PatchManifestStep.kt b/app/src/main/kotlin/com/aliucord/manager/installer/steps/patch/PatchManifestStep.kt new file mode 100644 index 00000000..4f50ada3 --- /dev/null +++ b/app/src/main/kotlin/com/aliucord/manager/installer/steps/patch/PatchManifestStep.kt @@ -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().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) + } + } +} diff --git a/app/src/main/kotlin/com/aliucord/manager/installer/steps/patch/ReplaceIconStep.kt b/app/src/main/kotlin/com/aliucord/manager/installer/steps/patch/ReplaceIconStep.kt new file mode 100644 index 00000000..90597035 --- /dev/null +++ b/app/src/main/kotlin/com/aliucord/manager/installer/steps/patch/ReplaceIconStep.kt @@ -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().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) +} diff --git a/app/src/main/kotlin/com/aliucord/manager/installer/steps/prepare/DowngradeCheckStep.kt b/app/src/main/kotlin/com/aliucord/manager/installer/steps/prepare/DowngradeCheckStep.kt new file mode 100644 index 00000000..223af6f6 --- /dev/null +++ b/app/src/main/kotlin/com/aliucord/manager/installer/steps/prepare/DowngradeCheckStep.kt @@ -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() + .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") + } + } +} diff --git a/app/src/main/kotlin/com/aliucord/manager/installer/steps/prepare/FetchInfoStep.kt b/app/src/main/kotlin/com/aliucord/manager/installer/steps/prepare/FetchInfoStep.kt new file mode 100644 index 00000000..19dff13d --- /dev/null +++ b/app/src/main/kotlin/com/aliucord/manager/installer/steps/prepare/FetchInfoStep.kt @@ -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() + } +} diff --git a/app/src/main/kotlin/com/aliucord/manager/manager/DownloadManager.kt b/app/src/main/kotlin/com/aliucord/manager/manager/DownloadManager.kt index 990d4e02..55b7993d 100644 --- a/app/src/main/kotlin/com/aliucord/manager/manager/DownloadManager.kt +++ b/app/src/main/kotlin/com/aliucord/manager/manager/DownloadManager.kt @@ -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() ?: 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. diff --git a/app/src/main/kotlin/com/aliucord/manager/manager/PathManager.kt b/app/src/main/kotlin/com/aliucord/manager/manager/PathManager.kt new file mode 100644 index 00000000..6070aa8d --- /dev/null +++ b/app/src/main/kotlin/com/aliucord/manager/manager/PathManager.kt @@ -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") +} diff --git a/app/src/main/kotlin/com/aliucord/manager/network/service/AliucordGithubService.kt b/app/src/main/kotlin/com/aliucord/manager/network/service/AliucordGithubService.kt index eafcdd7c..bb14129e 100644 --- a/app/src/main/kotlin/com/aliucord/manager/network/service/AliucordGithubService.kt +++ b/app/src/main/kotlin/com/aliucord/manager/network/service/AliucordGithubService.kt @@ -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" } } diff --git a/app/src/main/kotlin/com/aliucord/manager/ui/components/dialogs/InstallerDialog.kt b/app/src/main/kotlin/com/aliucord/manager/ui/components/dialogs/InstallerDialog.kt index a4fa0254..08534d19 100644 --- a/app/src/main/kotlin/com/aliucord/manager/ui/components/dialogs/InstallerDialog.kt +++ b/app/src/main/kotlin/com/aliucord/manager/ui/components/dialogs/InstallerDialog.kt @@ -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, diff --git a/app/src/main/kotlin/com/aliucord/manager/ui/components/installer/InstallGroup.kt b/app/src/main/kotlin/com/aliucord/manager/ui/components/installer/InstallGroup.kt deleted file mode 100644 index 461a7f14..00000000 --- a/app/src/main/kotlin/com/aliucord/manager/ui/components/installer/InstallGroup.kt +++ /dev/null @@ -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, - 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) - ) - } -} diff --git a/app/src/main/kotlin/com/aliucord/manager/ui/screens/home/HomeModel.kt b/app/src/main/kotlin/com/aliucord/manager/ui/screens/home/HomeModel.kt index 9586fe06..003a4283 100644 --- a/app/src/main/kotlin/com/aliucord/manager/ui/screens/home/HomeModel.kt +++ b/app/src/main/kotlin/com/aliucord/manager/ui/screens/home/HomeModel.kt @@ -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 diff --git a/app/src/main/kotlin/com/aliucord/manager/ui/screens/home/HomeScreen.kt b/app/src/main/kotlin/com/aliucord/manager/ui/screens/home/HomeScreen.kt index 5e9d3380..eca80bc9 100644 --- a/app/src/main/kotlin/com/aliucord/manager/ui/screens/home/HomeScreen.kt +++ b/app/src/main/kotlin/com/aliucord/manager/ui/screens/home/HomeScreen.kt @@ -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()) } ) } diff --git a/app/src/main/kotlin/com/aliucord/manager/ui/screens/install/InstallModel.kt b/app/src/main/kotlin/com/aliucord/manager/ui/screens/install/InstallModel.kt index 19c0998f..951cff09 100644 --- a/app/src/main/kotlin/com/aliucord/manager/ui/screens/install/InstallModel.kt +++ b/app/src/main/kotlin/com/aliucord/manager/ui/screens/install/InstallModel.kt @@ -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.Pending) { + private lateinit var startTime: Date + private var installJob: Job? = null - var returnToHome by mutableStateOf(false) - - var isFinished by mutableStateOf(false) - private set - - var stacktrace by mutableStateOf("") + var installSteps by mutableStateOf>?>(null) 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(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 - } - - withContext(Dispatchers.IO) { - try { - installKotlin() - - isFinished = true - delay(20000) - returnToHome = true - } catch (t: Throwable) { - stacktrace = Log.getStackTraceString(t) - - Log.e( - BuildConfig.TAG, - "$debugInfo\n\n${Log.getStackTraceString(t)}" - ) - } - - installationRunning.set(false) - } - } - - 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() } - } - - 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) - } - - file.copyTo( - patchedDir.resolve(file.name), - true - ) - } - } - - val kotlinFile = step(InstallStep.DL_KOTLIN) { - cacheDir.resolve("kotlin.dex").also { file -> - if (file.exists()) { - cached = true - } else { - downloadManager.downloadKotlinDex(file) - } - } - } + fun restart() { + installJob?.cancel("Manual cancellation") + installSteps = null - // 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) - } - } - } + startTime = Date() + mutableState.value = InstallScreenState.Working - // Download Aliuhook aar - val aliuhookAarFile = step(InstallStep.DL_ALIUHOOK) { - // Fetch aliuhook version - val aliuhookVersion = aliucordMaven.getAliuhookVersion().getOrThrow() + val newInstallJob = screenModelScope.launch { + val runner = KotlinInstallRunner() - // Download aliuhook aar - cacheDir.resolve("aliuhook-${aliuhookVersion}.aar").also { file -> - if (file.exists()) { - cached = true - } else { - downloadManager.downloadAliuhook(aliuhookVersion, file) - } - } - } + installSteps = runner.steps.groupBy { it.group } + .mapValues { it.value.toUnsafeImmutable() } + .toUnsafeImmutable() - // 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() } + // 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) - 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 - ) + // Execute all the steps and catch any errors + when (val error = runner.executeAll()) { + // Successfully installed + null -> { + mutableState.value = InstallScreenState.Success - for ((files, replacement) in replacements) { - for (file in files) { - val path = "res/$file" - baseApk.deleteEntry(path) - baseApk.writeEntry(path, replacement) - } - } + // Wait 20s before returning to Home + delay(20_000) + mutableState.value = InstallScreenState.CloseScreen } - } - } - - // 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") + else -> { + Log.e(BuildConfig.TAG, "Failed to perform installation process", error) - 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) + mutableState.value = InstallScreenState.Failed(failureLog = getFailureInfo(error)) } } } - 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 - } - } + newInstallJob.invokeOnCompletion { error -> + when (error) { + // Successfully executed, already handled above + null -> {} - ZipWriter(baseApkFile, true).use { - it.deleteEntry("resources.arsc", true) - it.writeEntry("resources.arsc", bytes, ZipCompression.NONE, 4096) + // 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 } - } - - 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 } } - } - - private inline fun step(step: InstallStep, block: InstallStepData.() -> T): T { - steps[step]!!.status = InstallStatus.ONGOING - currentStep = step - - try { - val value = measureTimedValue { block.invoke(steps[step]!!) } - val millis = value.duration.inWholeMilliseconds - - // Add delay for human psychology + groups are switched too fast - if (!preferences.devMode && millis < 1000) { - Thread.sleep(1000 - millis) - } - 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) + installJob = newInstallJob } - // Order matters, define it in the same order as it is patched - enum class InstallStep( - val group: InstallStepGroup, + 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" - @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), + val header = """ + Aliucord Manager v${BuildConfig.VERSION_NAME} + Built from commit ${BuildConfig.GIT_COMMIT} on ${BuildConfig.GIT_BRANCH} $gitChanges - // 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() + 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() - // TODO: cache this instead - fun getSteps(group: InstallStepGroup): List { - return steps - .filterKeys { it.group == group }.entries - .sortedBy { it.key.ordinal } - .map { it.value } + return header + "\n\n" + Log.getStackTraceString(stacktrace) } } diff --git a/app/src/main/kotlin/com/aliucord/manager/ui/screens/install/InstallScreen.kt b/app/src/main/kotlin/com/aliucord/manager/ui/screens/install/InstallScreen.kt index 4e45d5c9..9cfcc3bc 100644 --- a/app/src/main/kotlin/com/aliucord/manager/ui/screens/install/InstallScreen.kt +++ b/app/src/main/kotlin/com/aliucord/manager/ui/screens/install/InstallScreen.kt @@ -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 - -@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? = null, -) : Parcelable - -class InstallScreen(val data: InstallData) : Screen { +import com.aliucord.manager.ui.screens.install.components.StepGroupCard + +class InstallScreen : Screen { override val key = "Install" @Composable override fun Content() { val navigator = LocalNavigator.currentOrThrow - val model = getScreenModel(parameters = { parametersOf(data) }) + val model = getScreenModel() + val state = model.state.collectAsState() - var expandedGroup by remember { mutableStateOf(null) } + LaunchedEffect(model.state) { + if (model.state.value is InstallScreenState.CloseScreen) + navigator.back(currentActivity = null) + } - if (model.returnToHome) - navigator.back(null) + // Exit warning dialog (dismiss itself if install process state changes, esp. for Success) + var showAbortWarning by remember(model.state.collectAsState()) { mutableStateOf(false) } - LaunchedEffect(model.currentStep) { - expandedGroup = model.currentStep?.group + // 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.Prepare) } + + // Close all groups when successfully finished everything + LaunchedEffect(state.value) { + if (state.value == InstallScreenState.Success) + expandedGroup = null + } + + 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 (model.isFinished && model.stacktrace.isEmpty()) { + 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()) + ) + } } } } diff --git a/app/src/main/kotlin/com/aliucord/manager/ui/screens/install/InstallScreenState.kt b/app/src/main/kotlin/com/aliucord/manager/ui/screens/install/InstallScreenState.kt new file mode 100644 index 00000000..f9fab47a --- /dev/null +++ b/app/src/main/kotlin/com/aliucord/manager/ui/screens/install/InstallScreenState.kt @@ -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 +} diff --git a/app/src/main/kotlin/com/aliucord/manager/ui/screens/install/components/StepGroupCard.kt b/app/src/main/kotlin/com/aliucord/manager/ui/screens/install/components/StepGroupCard.kt new file mode 100644 index 00000000..1d1471e8 --- /dev/null +++ b/app/src/main/kotlin/com/aliucord/manager/ui/screens/install/components/StepGroupCard.kt @@ -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, + 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) + } + } + } + } +} diff --git a/app/src/main/kotlin/com/aliucord/manager/ui/screens/install/components/StepItem.kt b/app/src/main/kotlin/com/aliucord/manager/ui/screens/install/components/StepItem.kt new file mode 100644 index 00000000..95507357 --- /dev/null +++ b/app/src/main/kotlin/com/aliucord/manager/ui/screens/install/components/StepItem.kt @@ -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, + ) + } + } +} diff --git a/app/src/main/kotlin/com/aliucord/manager/ui/screens/install/components/StepStatusIcon.kt b/app/src/main/kotlin/com/aliucord/manager/ui/screens/install/components/StepStatusIcon.kt new file mode 100644 index 00000000..983c52a8 --- /dev/null +++ b/app/src/main/kotlin/com/aliucord/manager/ui/screens/install/components/StepStatusIcon.kt @@ -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) + ) + } +} diff --git a/app/src/main/kotlin/com/aliucord/manager/ui/util/IfModifier.kt b/app/src/main/kotlin/com/aliucord/manager/ui/util/IfModifier.kt new file mode 100644 index 00000000..7e82b946 --- /dev/null +++ b/app/src/main/kotlin/com/aliucord/manager/ui/util/IfModifier.kt @@ -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 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 + } +} diff --git a/app/src/main/kotlin/com/aliucord/manager/ui/util/UnsafeImmutables.kt b/app/src/main/kotlin/com/aliucord/manager/ui/util/UnsafeImmutables.kt new file mode 100644 index 00000000..6d27bbc3 --- /dev/null +++ b/app/src/main/kotlin/com/aliucord/manager/ui/util/UnsafeImmutables.kt @@ -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 Collection.toUnsafeImmutable(): ImmutableCollection = + ImmutableCollectionAdapter(this) + +fun List.toUnsafeImmutable(): ImmutableList = + ImmutableListAdapter(this) + +fun Set.toUnsafeImmutable(): ImmutableSet = + ImmutableSetAdapter(this) + +fun Map.toUnsafeImmutable(): ImmutableMap = + ImmutableMapAdapter(this) + +fun emptyImmutableList(): ImmutableList = persistentListOf() +fun emptyImmutableSet(): ImmutableSet = persistentSetOf() +fun emptyImmutableMap(): ImmutableMap = persistentMapOf() diff --git a/app/src/main/kotlin/com/aliucord/manager/ui/widgets/updater/UpdaterViewModel.kt b/app/src/main/kotlin/com/aliucord/manager/ui/widgets/updater/UpdaterViewModel.kt index 22f0c881..55338188 100644 --- a/app/src/main/kotlin/com/aliucord/manager/ui/widgets/updater/UpdaterViewModel.kt +++ b/app/src/main/kotlin/com/aliucord/manager/ui/widgets/updater/UpdaterViewModel.kt @@ -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 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 989b48f4..7fdb02c6 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -93,6 +93,7 @@ (Cached) Successfully installed Aliucord Aborted Aliucord installation + Failed to verify download Please uninstall your current version of Aliucord in order to continue! Failed to install (Unknown reason) @@ -104,28 +105,32 @@ Application is incompatible with this device Installation timed out - Download APKs - Download Libraries - Patch APKs + Prepare + Download dependencies + Patch APK Install APK + Fetching target Discord version + Checking for older installations + Downloading Discord APK + Downloading Kotlin Stdlib + Downloading Aliucord Injector + Downloading Aliuhook library + Copying dependencies Patching app icons Patching apk manifests - Adding aliucord dex into apk - Replacing libraries + Adding Aliucord injector + Adding Aliuhook library + Aligning APKs Signing APKs Installing APKs + Cleaning up - Fetching target Discord version - Downloading Discord APK - Downloading Kotlin library - Downloading injector - Downloading Aliuhook library - - Success + Queued Ongoing + Skipped + Success Failed - Queued Really exit? Are you sure you really want to abort an in-progress installation? Cached files will be cleared to avoid corruption.