From 1a5338818b2a338b59baf8f425c1e76bc5c8f443 Mon Sep 17 00:00:00 2001 From: Wing <44992537+wingio@users.noreply.github.com> Date: Thu, 1 Feb 2024 11:29:38 -0500 Subject: [PATCH] (refactor) Separate out installation steps (#71) * Move most download steps * Add most patching steps * Add logger to new step runner * Add last steps * Add debug information to new runner * Implement the new installer * Add documentation * Document installer components --- app/build.gradle.kts | 1 + .../manager/domain/manager/DownloadManager.kt | 2 +- .../vendetta/manager/installer/step/Step.kt | 80 +++ .../manager/installer/step/StepGroup.kt | 24 + .../manager/installer/step/StepRunner.kt | 179 +++++++ .../manager/installer/step/StepStatus.kt | 23 + .../step/download/DownloadBaseStep.kt | 24 + .../step/download/DownloadLangStep.kt | 24 + .../step/download/DownloadLibsStep.kt | 30 ++ .../step/download/DownloadResourcesStep.kt | 24 + .../step/download/DownloadVendettaStep.kt | 24 + .../step/download/base/DownloadStep.kt | 132 +++++ .../installer/step/installing/InstallStep.kt | 49 ++ .../step/patching/AddVendettaStep.kt | 41 ++ .../step/patching/PatchManifestsStep.kt | 65 +++ .../step/patching/PresignApksStep.kt | 68 +++ .../step/patching/ReplaceIconStep.kt | 50 ++ .../vendetta/manager/installer/util/Logger.kt | 62 +++ .../ui/screen/installer/InstallerScreen.kt | 45 +- .../viewmodel/installer/InstallerViewModel.kt | 498 ++---------------- .../ui/widgets/installer/StepGroupCard.kt | 78 +-- .../manager/ui/widgets/installer/StepIcon.kt | 22 +- .../manager/ui/widgets/installer/StepRow.kt | 76 +++ app/src/main/res/values/strings.xml | 1 + gradle/libs.versions.toml | 2 + 25 files changed, 1089 insertions(+), 535 deletions(-) create mode 100644 app/src/main/java/dev/beefers/vendetta/manager/installer/step/Step.kt create mode 100644 app/src/main/java/dev/beefers/vendetta/manager/installer/step/StepGroup.kt create mode 100644 app/src/main/java/dev/beefers/vendetta/manager/installer/step/StepRunner.kt create mode 100644 app/src/main/java/dev/beefers/vendetta/manager/installer/step/StepStatus.kt create mode 100644 app/src/main/java/dev/beefers/vendetta/manager/installer/step/download/DownloadBaseStep.kt create mode 100644 app/src/main/java/dev/beefers/vendetta/manager/installer/step/download/DownloadLangStep.kt create mode 100644 app/src/main/java/dev/beefers/vendetta/manager/installer/step/download/DownloadLibsStep.kt create mode 100644 app/src/main/java/dev/beefers/vendetta/manager/installer/step/download/DownloadResourcesStep.kt create mode 100644 app/src/main/java/dev/beefers/vendetta/manager/installer/step/download/DownloadVendettaStep.kt create mode 100644 app/src/main/java/dev/beefers/vendetta/manager/installer/step/download/base/DownloadStep.kt create mode 100644 app/src/main/java/dev/beefers/vendetta/manager/installer/step/installing/InstallStep.kt create mode 100644 app/src/main/java/dev/beefers/vendetta/manager/installer/step/patching/AddVendettaStep.kt create mode 100644 app/src/main/java/dev/beefers/vendetta/manager/installer/step/patching/PatchManifestsStep.kt create mode 100644 app/src/main/java/dev/beefers/vendetta/manager/installer/step/patching/PresignApksStep.kt create mode 100644 app/src/main/java/dev/beefers/vendetta/manager/installer/step/patching/ReplaceIconStep.kt create mode 100644 app/src/main/java/dev/beefers/vendetta/manager/installer/util/Logger.kt create mode 100644 app/src/main/java/dev/beefers/vendetta/manager/ui/widgets/installer/StepRow.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 5c2ac9da..4d0e83d7 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -103,6 +103,7 @@ dependencies { implementation(files("libs/lspatch.aar")) implementation(libs.kotlinx.datetime) + implementation(libs.kotlinx.collections) implementation(libs.zip.android) { artifact { type = "aar" diff --git a/app/src/main/java/dev/beefers/vendetta/manager/domain/manager/DownloadManager.kt b/app/src/main/java/dev/beefers/vendetta/manager/domain/manager/DownloadManager.kt index 860881a6..4382fba9 100644 --- a/app/src/main/java/dev/beefers/vendetta/manager/domain/manager/DownloadManager.kt +++ b/app/src/main/java/dev/beefers/vendetta/manager/domain/manager/DownloadManager.kt @@ -137,7 +137,7 @@ class DownloadManager( } sealed interface DownloadResult { - object Success : DownloadResult + data object Success : DownloadResult data class Cancelled(val systemTriggered: Boolean) : DownloadResult data class Error(val debugReason: String) : DownloadResult } diff --git a/app/src/main/java/dev/beefers/vendetta/manager/installer/step/Step.kt b/app/src/main/java/dev/beefers/vendetta/manager/installer/step/Step.kt new file mode 100644 index 00000000..0e5deaca --- /dev/null +++ b/app/src/main/java/dev/beefers/vendetta/manager/installer/step/Step.kt @@ -0,0 +1,80 @@ +package dev.beefers.vendetta.manager.installer.step + +import androidx.annotation.StringRes +import androidx.compose.runtime.Stable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import org.koin.core.component.KoinComponent +import kotlin.time.measureTimedValue + +/** + * A distinct step to be ran while patching + */ +@Stable +abstract class Step: KoinComponent { + + /** + * Group this step belongs to + */ + abstract val group: StepGroup + + /** + * Label used in the installer ui + */ + @get:StringRes + abstract val nameRes: Int + + /** + * Current status for this step + */ + var status by mutableStateOf(StepStatus.QUEUED) + protected set + + /** + * How much progress this step has made, use null if unknown + */ + var progress by mutableStateOf(null) + protected set + + /** + * How long this step took to run, in milliseconds + */ + var durationMs by mutableIntStateOf(0) + private set + + /** + * Runs this step + * + * @param runner The host runner, used to share information between steps + */ + protected abstract suspend fun run(runner: StepRunner) + + /** + * Safely runs this step, catching any errors and timing how long it runs. + * + * @param runner The host runner, used to share information between steps + */ + suspend fun runCatching(runner: StepRunner): Throwable? { + if (status != StepStatus.QUEUED) + throw IllegalStateException("Cannot execute a step that has already started") + + status = StepStatus.ONGOING + + val (error, time) = measureTimedValue { + try { + run(runner) + status = StepStatus.SUCCESSFUL + null + } catch (t: Throwable) { + status = StepStatus.UNSUCCESSFUL + t + } + } + + durationMs = time.inWholeMilliseconds.toInt() + return error + } + +} \ No newline at end of file diff --git a/app/src/main/java/dev/beefers/vendetta/manager/installer/step/StepGroup.kt b/app/src/main/java/dev/beefers/vendetta/manager/installer/step/StepGroup.kt new file mode 100644 index 00000000..8ffa25e1 --- /dev/null +++ b/app/src/main/java/dev/beefers/vendetta/manager/installer/step/StepGroup.kt @@ -0,0 +1,24 @@ +package dev.beefers.vendetta.manager.installer.step + +import androidx.annotation.StringRes +import dev.beefers.vendetta.manager.R + +/** + * Represents a group of [Step]s + */ +enum class StepGroup(@StringRes val nameRes: Int) { + /** + * All steps deal with downloading files remotely + */ + DL(R.string.group_download), + + /** + * Steps that modify the APKs + */ + PATCHING(R.string.group_patch), + + /** + * Only contains the [install step][dev.beefers.vendetta.manager.installer.step.installing.InstallStep] + */ + INSTALLING(R.string.group_installing) +} \ No newline at end of file diff --git a/app/src/main/java/dev/beefers/vendetta/manager/installer/step/StepRunner.kt b/app/src/main/java/dev/beefers/vendetta/manager/installer/step/StepRunner.kt new file mode 100644 index 00000000..89a9ffb9 --- /dev/null +++ b/app/src/main/java/dev/beefers/vendetta/manager/installer/step/StepRunner.kt @@ -0,0 +1,179 @@ +package dev.beefers.vendetta.manager.installer.step + +import android.content.Context +import android.os.Build +import android.os.Environment +import androidx.compose.runtime.Stable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import dev.beefers.vendetta.manager.BuildConfig +import dev.beefers.vendetta.manager.domain.manager.PreferenceManager +import dev.beefers.vendetta.manager.installer.step.download.DownloadBaseStep +import dev.beefers.vendetta.manager.installer.step.download.DownloadLangStep +import dev.beefers.vendetta.manager.installer.step.download.DownloadLibsStep +import dev.beefers.vendetta.manager.installer.step.download.DownloadResourcesStep +import dev.beefers.vendetta.manager.installer.step.download.DownloadVendettaStep +import dev.beefers.vendetta.manager.installer.step.installing.InstallStep +import dev.beefers.vendetta.manager.installer.step.patching.AddVendettaStep +import dev.beefers.vendetta.manager.installer.step.patching.PatchManifestsStep +import dev.beefers.vendetta.manager.installer.step.patching.PresignApksStep +import dev.beefers.vendetta.manager.installer.step.patching.ReplaceIconStep +import dev.beefers.vendetta.manager.installer.util.Logger +import dev.beefers.vendetta.manager.utils.DiscordVersion +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.delay +import org.koin.core.component.KoinComponent +import org.koin.core.component.inject +import java.io.File + +/** + * Runs all installation steps in order + * + * Credit to rushii (github.com/rushiiMachine) + * + * @param discordVersion Version of Discord to inject Vendetta into + */ +@Stable +class StepRunner( + private val discordVersion: DiscordVersion +): KoinComponent { + + private val preferenceManager: PreferenceManager by inject() + private val context: Context by inject() + private val debugInfo = """ + Vendetta Manager v${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()} + Device: ${Build.MANUFACTURER} - ${Build.MODEL} (${Build.DEVICE}) + ${if(Build.VERSION.SDK_INT > Build.VERSION_CODES.S) "SOC: ${Build.SOC_MANUFACTURER} ${Build.SOC_MODEL}\n" else "\n\n"} + Adding Vendetta to Discord v$discordVersion + + + """.trimIndent() + + /** + * Logger associated with this runner + */ + val logger = Logger("StepRunner").also { + debugInfo.split("\n").forEach(it.logs::add) // Add debug information to logs but don't print to logcat + } + + /** + * Root directory for all downloaded files + */ + private val cacheDir = + context.externalCacheDir + ?: File(Environment.getExternalStorageDirectory(), Environment.DIRECTORY_DOWNLOADS) + .resolve("VendettaManager") + .also { it.mkdirs() } + + /** + * Where version specific downloads are persisted + */ + private val discordCacheDir = cacheDir.resolve(discordVersion.toVersionCode()) + + /** + * Working directory where apks are directly modified (i.e. replacing the app icon) + */ + private val patchedDir = discordCacheDir.resolve("patched").also { it.deleteRecursively() } + + /** + * Where apks are moved to once signed + */ + private val signedDir = discordCacheDir.resolve("signed").also { it.deleteRecursively() } + + /** + * Output directory for LSPatch + */ + private val lspatchedDir = patchedDir.resolve("lspatched").also { it.deleteRecursively() } + + var currentStep by mutableStateOf(null) + private set + + /** + * Whether or not the patching/installation process has completed. + * Note that this does not mean all steps were finished successfully + */ + var completed by mutableStateOf(false) + private set + + /** + * List of steps to go through for this install + * + * ORDER MATTERS + */ + val steps: ImmutableList = buildList { + // Downloading + add(DownloadBaseStep(discordCacheDir, patchedDir, discordVersion.toVersionCode())) + add(DownloadLibsStep(discordCacheDir, patchedDir, discordVersion.toVersionCode())) + add(DownloadLangStep(discordCacheDir, patchedDir, discordVersion.toVersionCode())) + add(DownloadResourcesStep(discordCacheDir, patchedDir, discordVersion.toVersionCode())) + add(DownloadVendettaStep(patchedDir)) + + // Patching + if (preferenceManager.patchIcon) add(ReplaceIconStep()) + add(PatchManifestsStep()) + add(PresignApksStep(signedDir)) + add(AddVendettaStep(signedDir, lspatchedDir)) + + // Installing + add(InstallStep(lspatchedDir)) + }.toImmutableList() + + /** + * Get a step that has already been successfully executed. + * This is used to retrieve previously executed dependency steps from a later step. + */ + inline fun getCompletedStep(): T { + val step = steps.asSequence() + .filterIsInstance() + .filter { it.status == StepStatus.SUCCESSFUL } + .firstOrNull() + + if (step == null) { + throw IllegalArgumentException("No completed step ${T::class.simpleName} exists in container") + } + + return step + } + + /** + * Clears all cached files + */ + fun clearCache() { + cacheDir.deleteRecursively() + } + + /** + * Run all the [steps] in order + */ + suspend fun runAll(): Throwable? { + for (step in steps) { + if (completed) return null // Failsafe in case runner is incorrectly marked as not completed too early + + currentStep = step + val error = step.runCatching(this) + if (error != null) { + logger.e("Failed on ${step::class.simpleName}", error) + + completed = true + return error + } + + // Add delay for human psychology and + // better group visibility in UI (the active group can change way too fast) + if (!preferenceManager.isDeveloper && step.durationMs < 1000) { + delay(1000L - step.durationMs) + } + } + + completed = true + return null + } + +} \ No newline at end of file diff --git a/app/src/main/java/dev/beefers/vendetta/manager/installer/step/StepStatus.kt b/app/src/main/java/dev/beefers/vendetta/manager/installer/step/StepStatus.kt new file mode 100644 index 00000000..c4153989 --- /dev/null +++ b/app/src/main/java/dev/beefers/vendetta/manager/installer/step/StepStatus.kt @@ -0,0 +1,23 @@ +package dev.beefers.vendetta.manager.installer.step + +enum class StepStatus { + /** + * Currently in progress + */ + ONGOING, + + /** + * Completed with no errors + */ + SUCCESSFUL, + + /** + * Completed with an error + */ + UNSUCCESSFUL, + + /** + * Has not yet been ran + */ + QUEUED +} \ No newline at end of file diff --git a/app/src/main/java/dev/beefers/vendetta/manager/installer/step/download/DownloadBaseStep.kt b/app/src/main/java/dev/beefers/vendetta/manager/installer/step/download/DownloadBaseStep.kt new file mode 100644 index 00000000..622d0bc0 --- /dev/null +++ b/app/src/main/java/dev/beefers/vendetta/manager/installer/step/download/DownloadBaseStep.kt @@ -0,0 +1,24 @@ +package dev.beefers.vendetta.manager.installer.step.download + +import androidx.compose.runtime.Stable +import dev.beefers.vendetta.manager.R +import dev.beefers.vendetta.manager.installer.step.download.base.DownloadStep +import java.io.File + +/** + * Downloads the base Discord APK + */ +@Stable +class DownloadBaseStep( + dir: File, + workingDir: File, + version: String +): DownloadStep() { + + override val nameRes = R.string.step_dl_base + + override val url: String = "$baseUrl/tracker/download/$version/base" + override val destination = dir.resolve("base-$version.apk") + override val workingCopy = workingDir.resolve("base-$version.apk") + +} \ No newline at end of file diff --git a/app/src/main/java/dev/beefers/vendetta/manager/installer/step/download/DownloadLangStep.kt b/app/src/main/java/dev/beefers/vendetta/manager/installer/step/download/DownloadLangStep.kt new file mode 100644 index 00000000..71431fee --- /dev/null +++ b/app/src/main/java/dev/beefers/vendetta/manager/installer/step/download/DownloadLangStep.kt @@ -0,0 +1,24 @@ +package dev.beefers.vendetta.manager.installer.step.download + +import androidx.compose.runtime.Stable +import dev.beefers.vendetta.manager.R +import dev.beefers.vendetta.manager.installer.step.download.base.DownloadStep +import java.io.File + +/** + * Downloads the languages split, will always be English because Discord doesn't store their strings in this split + */ +@Stable +class DownloadLangStep( + dir: File, + workingDir: File, + version: String +): DownloadStep() { + + override val nameRes = R.string.step_dl_lang + + override val url: String = "$baseUrl/tracker/download/$version/config.en" + override val destination = dir.resolve("config.en-$version.apk") + override val workingCopy = workingDir.resolve("config.en-$version.apk") + +} \ No newline at end of file diff --git a/app/src/main/java/dev/beefers/vendetta/manager/installer/step/download/DownloadLibsStep.kt b/app/src/main/java/dev/beefers/vendetta/manager/installer/step/download/DownloadLibsStep.kt new file mode 100644 index 00000000..27c014a2 --- /dev/null +++ b/app/src/main/java/dev/beefers/vendetta/manager/installer/step/download/DownloadLibsStep.kt @@ -0,0 +1,30 @@ +package dev.beefers.vendetta.manager.installer.step.download + +import android.os.Build +import androidx.compose.runtime.Stable +import dev.beefers.vendetta.manager.R +import dev.beefers.vendetta.manager.installer.step.download.base.DownloadStep +import java.io.File + +/** + * Downloads the split containing the native libraries for the current devices architecture + */ +@Stable +class DownloadLibsStep( + dir: File, + workingDir: File, + version: String +): DownloadStep() { + + /** + * Supported CPU architecture for this device, used to download the correct library split + */ + private val arch = Build.SUPPORTED_ABIS.first().replace("-v", "_v") + + override val nameRes = R.string.step_dl_lib + + override val url: String = "$baseUrl/tracker/download/$version/config.$arch" + override val destination = dir.resolve("config.$arch-$version.apk") + override val workingCopy = workingDir.resolve("config.$arch-$version.apk") + +} \ No newline at end of file diff --git a/app/src/main/java/dev/beefers/vendetta/manager/installer/step/download/DownloadResourcesStep.kt b/app/src/main/java/dev/beefers/vendetta/manager/installer/step/download/DownloadResourcesStep.kt new file mode 100644 index 00000000..d914eb19 --- /dev/null +++ b/app/src/main/java/dev/beefers/vendetta/manager/installer/step/download/DownloadResourcesStep.kt @@ -0,0 +1,24 @@ +package dev.beefers.vendetta.manager.installer.step.download + +import androidx.compose.runtime.Stable +import dev.beefers.vendetta.manager.R +import dev.beefers.vendetta.manager.installer.step.download.base.DownloadStep +import java.io.File + +/** + * Downloads the split containing all images, fonts, and other assets + */ +@Stable +class DownloadResourcesStep( + dir: File, + workingDir: File, + version: String +): DownloadStep() { + + override val nameRes = R.string.step_dl_res + + override val url: String = "$baseUrl/tracker/download/$version/config.xxhdpi" + override val destination = dir.resolve("config.xxhdpi-$version.apk") + override val workingCopy = workingDir.resolve("config.xxhdpi-$version.apk") + +} \ No newline at end of file diff --git a/app/src/main/java/dev/beefers/vendetta/manager/installer/step/download/DownloadVendettaStep.kt b/app/src/main/java/dev/beefers/vendetta/manager/installer/step/download/DownloadVendettaStep.kt new file mode 100644 index 00000000..7cc44587 --- /dev/null +++ b/app/src/main/java/dev/beefers/vendetta/manager/installer/step/download/DownloadVendettaStep.kt @@ -0,0 +1,24 @@ +package dev.beefers.vendetta.manager.installer.step.download + +import androidx.compose.runtime.Stable +import dev.beefers.vendetta.manager.R +import dev.beefers.vendetta.manager.installer.step.download.base.DownloadStep +import java.io.File + +/** + * Downloads the Vendetta XPosed module + * + * https://github.com/vendetta-mod/VendettaXposed + */ +@Stable +class DownloadVendettaStep( + workingDir: File +): DownloadStep() { + + override val nameRes = R.string.step_dl_vd + + override val url: String = "https://github.com/vendetta-mod/VendettaXposed/releases/latest/download/app-release.apk" + override val destination = preferenceManager.moduleLocation + override val workingCopy = workingDir.resolve("vendetta.apk") + +} \ No newline at end of file diff --git a/app/src/main/java/dev/beefers/vendetta/manager/installer/step/download/base/DownloadStep.kt b/app/src/main/java/dev/beefers/vendetta/manager/installer/step/download/base/DownloadStep.kt new file mode 100644 index 00000000..dadb2a54 --- /dev/null +++ b/app/src/main/java/dev/beefers/vendetta/manager/installer/step/download/base/DownloadStep.kt @@ -0,0 +1,132 @@ +package dev.beefers.vendetta.manager.installer.step.download.base + +import android.content.Context +import androidx.compose.runtime.Stable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import dev.beefers.vendetta.manager.R +import dev.beefers.vendetta.manager.domain.manager.DownloadManager +import dev.beefers.vendetta.manager.domain.manager.DownloadResult +import dev.beefers.vendetta.manager.domain.manager.PreferenceManager +import dev.beefers.vendetta.manager.installer.step.Step +import dev.beefers.vendetta.manager.installer.step.StepGroup +import dev.beefers.vendetta.manager.installer.step.StepRunner +import dev.beefers.vendetta.manager.installer.step.StepStatus +import dev.beefers.vendetta.manager.utils.mainThread +import dev.beefers.vendetta.manager.utils.showToast +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import org.koin.core.component.inject +import java.io.File +import kotlin.math.roundToInt + +/** + * Specialized step used to download a file + * + * Files are downloaded to [destination] then copied to [workingCopy] for safe patching + */ +@Stable +abstract class DownloadStep: Step() { + + protected val preferenceManager: PreferenceManager by inject() + protected val baseUrl = preferenceManager.mirror.baseUrl + + private val downloadManager: DownloadManager by inject() + private val context: Context by inject() + + /** + * Url of the desired file to download + */ + abstract val url: String + + /** + * Where to download the file to + */ + abstract val destination: File + + /** + * Where the downloaded file should be copied to so that it can be used for patching + */ + abstract val workingCopy: File + + override val group: StepGroup = StepGroup.DL + + var cached by mutableStateOf(false) + private set + + /** + * Verifies that a file was properly downloaded + */ + open suspend fun verify() { + if (!destination.exists()) + error("Downloaded file is missing: ${destination.absolutePath}") + + if (destination.length() <= 0) + error("Downloaded file is empty: ${destination.absolutePath}") + } + + override suspend fun run(runner: StepRunner) { + val fileName = destination.name + runner.logger.i("Checking if $fileName is cached") + if (destination.exists()) { + runner.logger.i("Checking if $fileName isn't empty") + if (destination.length() > 0) { + runner.logger.i("$fileName is cached") + cached = true + + runner.logger.i("Moving $fileName to working directory") + destination.copyTo(workingCopy, true) + + status = StepStatus.SUCCESSFUL + return + } + + runner.logger.i("Deleting empty file: $fileName") + destination.delete() + } + + runner.logger.i("$fileName was not properly cached, downloading now") + var lastProgress: Float? = null + val result = downloadManager.download(url, destination) { newProgress -> + progress = newProgress + if (newProgress != lastProgress && newProgress != null) { + lastProgress = newProgress + runner.logger.d("$fileName download progress: ${(lastProgress!! * 100f).roundToInt()}%") + } + } + + when (result) { + is DownloadResult.Success -> { + try { + runner.logger.i("Verifying downloaded file") + verify() + runner.logger.i("$fileName downloaded successfully") + + runner.logger.i("Moving $fileName to working directory") + destination.copyTo(workingCopy, true) + } catch (t: Throwable) { + mainThread { + context.showToast(R.string.msg_download_verify_failed) + } + + throw t + } + } + + is DownloadResult.Error -> { + withContext(Dispatchers.Main) { + context.showToast(R.string.msg_download_failed) + } + + throw Error("Failed to download: ${result.debugReason}") + } + + is DownloadResult.Cancelled -> { + runner.logger.e("$fileName download cancelled") + status = StepStatus.UNSUCCESSFUL + } + } + } + +} \ No newline at end of file diff --git a/app/src/main/java/dev/beefers/vendetta/manager/installer/step/installing/InstallStep.kt b/app/src/main/java/dev/beefers/vendetta/manager/installer/step/installing/InstallStep.kt new file mode 100644 index 00000000..dc353de3 --- /dev/null +++ b/app/src/main/java/dev/beefers/vendetta/manager/installer/step/installing/InstallStep.kt @@ -0,0 +1,49 @@ +package dev.beefers.vendetta.manager.installer.step.installing + +import android.content.Context +import dev.beefers.vendetta.manager.R +import dev.beefers.vendetta.manager.domain.manager.InstallMethod +import dev.beefers.vendetta.manager.domain.manager.PreferenceManager +import dev.beefers.vendetta.manager.installer.Installer +import dev.beefers.vendetta.manager.installer.session.SessionInstaller +import dev.beefers.vendetta.manager.installer.shizuku.ShizukuInstaller +import dev.beefers.vendetta.manager.installer.step.Step +import dev.beefers.vendetta.manager.installer.step.StepGroup +import dev.beefers.vendetta.manager.installer.step.StepRunner +import dev.beefers.vendetta.manager.utils.isMiui +import org.koin.core.component.inject +import java.io.File + +/** + * Installs all the modified splits with the users desired [Installer] + * + * @see SessionInstaller + * @see ShizukuInstaller + * + * @param lspatchedDir Where all the patched APKs are + */ +class InstallStep( + private val lspatchedDir: File +): Step() { + + private val preferences: PreferenceManager by inject() + private val context: Context by inject() + + override val group = StepGroup.INSTALLING + override val nameRes = R.string.step_installing + + override suspend fun run(runner: StepRunner) { + runner.logger.i("Installing apks") + val files = lspatchedDir.listFiles() + ?.takeIf { it.isNotEmpty() } + ?: throw Error("Missing APKs from LSPatch step; failure likely") + + val installer: Installer = when (preferences.installMethod) { + InstallMethod.DEFAULT -> SessionInstaller(context) + InstallMethod.SHIZUKU -> ShizukuInstaller(context) + } + + installer.installApks(silent = !isMiui, *files) + } + +} \ No newline at end of file diff --git a/app/src/main/java/dev/beefers/vendetta/manager/installer/step/patching/AddVendettaStep.kt b/app/src/main/java/dev/beefers/vendetta/manager/installer/step/patching/AddVendettaStep.kt new file mode 100644 index 00000000..4dbcf0f3 --- /dev/null +++ b/app/src/main/java/dev/beefers/vendetta/manager/installer/step/patching/AddVendettaStep.kt @@ -0,0 +1,41 @@ +package dev.beefers.vendetta.manager.installer.step.patching + +import dev.beefers.vendetta.manager.R +import dev.beefers.vendetta.manager.installer.step.Step +import dev.beefers.vendetta.manager.installer.step.StepGroup +import dev.beefers.vendetta.manager.installer.step.StepRunner +import dev.beefers.vendetta.manager.installer.step.download.DownloadVendettaStep +import dev.beefers.vendetta.manager.installer.util.Patcher +import java.io.File + +/** + * Uses LSPatch to inject the Vendetta XPosed module into Discord + * + * @param signedDir The signed apks to patch + * @param lspatchedDir Output directory for LSPatch + */ +class AddVendettaStep( + private val signedDir: File, + private val lspatchedDir: File +) : Step() { + + override val group = StepGroup.PATCHING + override val nameRes = R.string.step_add_vd + + override suspend fun run(runner: StepRunner) { + val vendetta = runner.getCompletedStep().workingCopy + + runner.logger.i("Adding Vendetta module with LSPatch") + val files = signedDir.listFiles() + ?.takeIf { it.isNotEmpty() } + ?: throw Error("Missing APKs from signing step") + + Patcher.patch( + runner.logger, + outputDir = lspatchedDir, + apkPaths = files.map { it.absolutePath }, + embeddedModules = listOf(vendetta.absolutePath) + ) + } + +} \ No newline at end of file diff --git a/app/src/main/java/dev/beefers/vendetta/manager/installer/step/patching/PatchManifestsStep.kt b/app/src/main/java/dev/beefers/vendetta/manager/installer/step/patching/PatchManifestsStep.kt new file mode 100644 index 00000000..2d5d3661 --- /dev/null +++ b/app/src/main/java/dev/beefers/vendetta/manager/installer/step/patching/PatchManifestsStep.kt @@ -0,0 +1,65 @@ +package dev.beefers.vendetta.manager.installer.step.patching + +import com.github.diamondminer88.zip.ZipReader +import com.github.diamondminer88.zip.ZipWriter +import dev.beefers.vendetta.manager.R +import dev.beefers.vendetta.manager.domain.manager.PreferenceManager +import dev.beefers.vendetta.manager.installer.step.Step +import dev.beefers.vendetta.manager.installer.step.StepGroup +import dev.beefers.vendetta.manager.installer.step.StepRunner +import dev.beefers.vendetta.manager.installer.step.download.DownloadBaseStep +import dev.beefers.vendetta.manager.installer.step.download.DownloadLangStep +import dev.beefers.vendetta.manager.installer.step.download.DownloadLibsStep +import dev.beefers.vendetta.manager.installer.step.download.DownloadResourcesStep +import dev.beefers.vendetta.manager.installer.util.ManifestPatcher +import org.koin.core.component.inject + +/** + * Modifies each APKs manifest in order to change the package and app name as well as whether or not its debuggable + */ +class PatchManifestsStep : Step() { + + private val preferences: PreferenceManager by inject() + + override val group = StepGroup.PATCHING + override val nameRes = R.string.step_patch_manifests + + override suspend fun run(runner: StepRunner) { + val baseApk = runner.getCompletedStep().workingCopy + val libsApk = runner.getCompletedStep().workingCopy + val langApk = runner.getCompletedStep().workingCopy + val resApk = runner.getCompletedStep().workingCopy + + arrayOf(baseApk, libsApk, langApk, resApk).forEach { apk -> + runner.logger.i("Reading AndroidManifest.xml from ${apk.name}") + val manifest = ZipReader(apk) + .use { zip -> zip.openEntry("AndroidManifest.xml")?.read() } + ?: throw IllegalStateException("No manifest in ${apk.name}") + + ZipWriter(apk, true).use { zip -> + runner.logger.i("Changing package and app name in ${apk.name}") + val patchedManifestBytes = if (apk == baseApk) { + ManifestPatcher.patchManifest( + manifestBytes = manifest, + packageName = preferences.packageName, + appName = preferences.appName, + debuggable = preferences.debuggable, + ) + } else { + runner.logger.i("Changing package name in ${apk.name}") + ManifestPatcher.renamePackage(manifest, preferences.packageName) + } + + runner.logger.i("Deleting old AndroidManifest.xml in ${apk.name}") + zip.deleteEntry( + "AndroidManifest.xml", + apk == libsApk + ) // Preserve alignment in libs apk + + runner.logger.i("Adding patched AndroidManifest.xml in ${apk.name}") + zip.writeEntry("AndroidManifest.xml", patchedManifestBytes) + } + } + } + +} \ No newline at end of file diff --git a/app/src/main/java/dev/beefers/vendetta/manager/installer/step/patching/PresignApksStep.kt b/app/src/main/java/dev/beefers/vendetta/manager/installer/step/patching/PresignApksStep.kt new file mode 100644 index 00000000..fed1fde4 --- /dev/null +++ b/app/src/main/java/dev/beefers/vendetta/manager/installer/step/patching/PresignApksStep.kt @@ -0,0 +1,68 @@ +package dev.beefers.vendetta.manager.installer.step.patching + +import android.os.Build +import com.github.diamondminer88.zip.ZipCompression +import com.github.diamondminer88.zip.ZipReader +import com.github.diamondminer88.zip.ZipWriter +import dev.beefers.vendetta.manager.R +import dev.beefers.vendetta.manager.installer.step.Step +import dev.beefers.vendetta.manager.installer.step.StepGroup +import dev.beefers.vendetta.manager.installer.step.StepRunner +import dev.beefers.vendetta.manager.installer.step.download.DownloadBaseStep +import dev.beefers.vendetta.manager.installer.step.download.DownloadLangStep +import dev.beefers.vendetta.manager.installer.step.download.DownloadLibsStep +import dev.beefers.vendetta.manager.installer.step.download.DownloadResourcesStep +import dev.beefers.vendetta.manager.installer.util.Signer +import java.io.File + +/** + * Sign all patched apks before being ran through LSPatch, this is required due to LSPatch not liking unsigned apks. + * + * @param signedDir Where to output all signed apks + */ +class PresignApksStep( + private val signedDir: File +) : Step() { + + override val group = StepGroup.PATCHING + override val nameRes = R.string.step_signing + + override suspend fun run(runner: StepRunner) { + val baseApk = runner.getCompletedStep().workingCopy + val libsApk = runner.getCompletedStep().workingCopy + val langApk = runner.getCompletedStep().workingCopy + val resApk = runner.getCompletedStep().workingCopy + + runner.logger.i("Creating dir for signed apks: ${signedDir.absolutePath}") + signedDir.mkdirs() + val apks = listOf(baseApk, libsApk, langApk, resApk) + + // Align resources.arsc due to targeting api 30 for silent install + if(Build.VERSION.SDK_INT >= 30) { + for (file in apks) { + runner.logger.i("Byte aligning ${file.name}") + val bytes = ZipReader(file).use { + if (it.entryNames.contains("resources.arsc")) { + it.openEntry("resources.arsc")?.read() + } else { + null + } + } ?: continue + + ZipWriter(file, true).use { + runner.logger.i("Removing old resources.arsc") + it.deleteEntry("resources.arsc", true) + + runner.logger.i("Adding aligned resources.arsc") + it.writeEntry("resources.arsc", bytes, ZipCompression.NONE, 4096) + } + } + } + + apks.forEach { + runner.logger.i("Signing ${it.name}") + Signer.signApk(it, File(signedDir, it.name)) + } + } + +} \ No newline at end of file diff --git a/app/src/main/java/dev/beefers/vendetta/manager/installer/step/patching/ReplaceIconStep.kt b/app/src/main/java/dev/beefers/vendetta/manager/installer/step/patching/ReplaceIconStep.kt new file mode 100644 index 00000000..dd929bff --- /dev/null +++ b/app/src/main/java/dev/beefers/vendetta/manager/installer/step/patching/ReplaceIconStep.kt @@ -0,0 +1,50 @@ +package dev.beefers.vendetta.manager.installer.step.patching + +import android.content.Context +import com.github.diamondminer88.zip.ZipWriter +import dev.beefers.vendetta.manager.R +import dev.beefers.vendetta.manager.installer.step.Step +import dev.beefers.vendetta.manager.installer.step.StepGroup +import dev.beefers.vendetta.manager.installer.step.StepRunner +import dev.beefers.vendetta.manager.installer.step.download.DownloadBaseStep +import org.koin.core.component.inject + +/** + * Replaces the existing app icons with Vendetta tinted ones + */ +class ReplaceIconStep : Step() { + + val context: Context by inject() + + override val group = StepGroup.PATCHING + override val nameRes = R.string.step_change_icon + + override suspend fun run(runner: StepRunner) { + val baseApk = runner.getCompletedStep().workingCopy + + ZipWriter(baseApk, true).use { apk -> + runner.logger.i("Replacing icons in ${baseApk.name}") + + val mipmaps = + arrayOf("mipmap-xhdpi-v4", "mipmap-xxhdpi-v4", "mipmap-xxxhdpi-v4") + val icons = arrayOf( + "ic_logo_foreground.png", + "ic_logo_square.png", + "ic_logo_foreground.png" + ) + + for (icon in icons) { + val newIcon = context.assets.open("icons/$icon") + .use { it.readBytes() } + + for (mipmap in mipmaps) { + runner.logger.i("Replacing $mipmap with $icon") + val path = "res/$mipmap/$icon" + apk.deleteEntry(path) + apk.writeEntry(path, newIcon) + } + } + } + } + +} \ No newline at end of file diff --git a/app/src/main/java/dev/beefers/vendetta/manager/installer/util/Logger.kt b/app/src/main/java/dev/beefers/vendetta/manager/installer/util/Logger.kt new file mode 100644 index 00000000..f9f94f1a --- /dev/null +++ b/app/src/main/java/dev/beefers/vendetta/manager/installer/util/Logger.kt @@ -0,0 +1,62 @@ +package dev.beefers.vendetta.manager.installer.util + +import android.util.Log +import androidx.compose.runtime.Stable +import org.lsposed.patch.util.Logger + +/** + * Used to log events done during the patching process + * + * @param tag The tag to use for logcat + */ +@Stable +class Logger( + private val tag: String +): Logger() { + + /** + * All logs made with this [Logger] + */ + val logs = mutableListOf() + + /** + * Prints a debug message to logcat and stores it in [logs] + */ + override fun d(msg: String?) { + logs += msg.toString() + Log.d(tag, msg.toString()) + } + + /** + * Prints an info message to logcat and stores it in [logs] + */ + override fun i(msg: String?) { + logs += msg.toString() + Log.i(tag, msg.toString()) + } + + /** + * Prints an error message to logcat and stores it in [logs] + */ + override fun e(msg: String?) { + logs += msg.toString() + Log.e(tag, msg.toString()) + } + + /** + * Prints an error message and stacktrace with a preceding empty line. + */ + fun e(msg: String?, th: Throwable?) { + newline() + e(msg) + if(th != null) e(th.stackTraceToString()) + } + + /** + * Prints an empty line + */ + private fun newline() { + i("\n") + } + +} \ No newline at end of file diff --git a/app/src/main/java/dev/beefers/vendetta/manager/ui/screen/installer/InstallerScreen.kt b/app/src/main/java/dev/beefers/vendetta/manager/ui/screen/installer/InstallerScreen.kt index d47bd8e5..e117b897 100644 --- a/app/src/main/java/dev/beefers/vendetta/manager/ui/screen/installer/InstallerScreen.kt +++ b/app/src/main/java/dev/beefers/vendetta/manager/ui/screen/installer/InstallerScreen.kt @@ -25,10 +25,8 @@ import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.key import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource @@ -39,6 +37,7 @@ import cafe.adriel.voyager.koin.getScreenModel import cafe.adriel.voyager.navigator.LocalNavigator import cafe.adriel.voyager.navigator.currentOrThrow import dev.beefers.vendetta.manager.R +import dev.beefers.vendetta.manager.installer.step.StepStatus import dev.beefers.vendetta.manager.ui.viewmodel.installer.InstallerViewModel import dev.beefers.vendetta.manager.ui.widgets.dialog.BackWarningDialog import dev.beefers.vendetta.manager.ui.widgets.dialog.DownloadFailedDialog @@ -61,19 +60,15 @@ class InstallerScreen( parametersOf(version) } - var expandedGroup by remember { - mutableStateOf(null) - } - - LaunchedEffect(viewModel.currentStep) { - expandedGroup = viewModel.currentStep?.group + LaunchedEffect(viewModel.runner.currentStep) { + viewModel.expandGroup(viewModel.runner.currentStep?.group) } // Listen for error messages from InstallService val intentListener: (Intent) -> Unit = remember { { val msg = it.getStringExtra("vendetta.extras.EXTRA_MESSAGE") - viewModel.addLogError(msg ?: "") + viewModel.logError(msg) } } @@ -85,7 +80,7 @@ class InstallerScreen( } BackHandler( - enabled = !viewModel.isFinished + enabled = !viewModel.runner.completed ) { viewModel.openBackDialog() } @@ -104,12 +99,12 @@ class InstallerScreen( if(viewModel.failedOnDownload) { DownloadFailedDialog( onTryAgainClick = { - viewModel.failedOnDownload = false + viewModel.dismissDownloadFailedDialog() viewModel.cancelInstall() nav.replace(InstallerScreen(version)) }, onDismiss = { - viewModel.failedOnDownload = false + viewModel.dismissDownloadFailedDialog() } ) } @@ -118,7 +113,7 @@ class InstallerScreen( topBar = { TitleBar( onBackClick = { - if(!viewModel.isFinished) + if(!viewModel.runner.completed) viewModel.openBackDialog() else nav.pop() @@ -132,22 +127,22 @@ class InstallerScreen( .padding(16.dp) .verticalScroll(rememberScrollState()) ) { - for (group in InstallerViewModel.InstallStepGroup.entries) { - StepGroupCard( - name = stringResource(group.nameRes), - isCurrent = expandedGroup == group, - onClick = { expandedGroup = group }, - steps = viewModel.getSteps(group), - ) + for ((group, steps) in viewModel.groupedSteps) { + key(group) { + StepGroupCard( + name = stringResource(group.nameRes), + isCurrent = viewModel.expandedGroup == group, + onClick = { viewModel.expandGroup(group) }, + steps = steps, + ) + } } - if (viewModel.isFinished) { + if (viewModel.runner.completed) { Spacer(modifier = Modifier.height(16.dp)) // Show launch only if success - val installSuccessful = viewModel.currentStep - ?.let(viewModel.steps::get) - ?.status == InstallerViewModel.InstallStatus.SUCCESSFUL + val installSuccessful = viewModel.runner.currentStep?.status == StepStatus.SUCCESSFUL if (installSuccessful) { Button( onClick = { viewModel.launchVendetta() }, diff --git a/app/src/main/java/dev/beefers/vendetta/manager/ui/viewmodel/installer/InstallerViewModel.kt b/app/src/main/java/dev/beefers/vendetta/manager/ui/viewmodel/installer/InstallerViewModel.kt index c30d89c0..44d516cd 100644 --- a/app/src/main/java/dev/beefers/vendetta/manager/ui/viewmodel/installer/InstallerViewModel.kt +++ b/app/src/main/java/dev/beefers/vendetta/manager/ui/viewmodel/installer/InstallerViewModel.kt @@ -27,6 +27,10 @@ import dev.beefers.vendetta.manager.domain.manager.PreferenceManager import dev.beefers.vendetta.manager.installer.Installer import dev.beefers.vendetta.manager.installer.session.SessionInstaller import dev.beefers.vendetta.manager.installer.shizuku.ShizukuInstaller +import dev.beefers.vendetta.manager.installer.step.Step +import dev.beefers.vendetta.manager.installer.step.StepGroup +import dev.beefers.vendetta.manager.installer.step.StepRunner +import dev.beefers.vendetta.manager.installer.step.installing.InstallStep import dev.beefers.vendetta.manager.installer.util.ManifestPatcher import dev.beefers.vendetta.manager.installer.util.Patcher import dev.beefers.vendetta.manager.installer.util.Signer @@ -35,6 +39,8 @@ import dev.beefers.vendetta.manager.utils.copyText import dev.beefers.vendetta.manager.utils.isMiui import dev.beefers.vendetta.manager.utils.mainThread import dev.beefers.vendetta.manager.utils.showToast +import kotlinx.collections.immutable.ImmutableMap +import kotlinx.collections.immutable.toImmutableMap import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.cancel import kotlinx.coroutines.launch @@ -46,59 +52,50 @@ import kotlin.time.measureTimedValue class InstallerViewModel( private val context: Context, - private val downloadManager: DownloadManager, - private val preferences: PreferenceManager, - private val discordVersion: DiscordVersion, - private val installManager: InstallManager + private val installManager: InstallManager, + discordVersion: DiscordVersion ) : ScreenModel { - var backDialogOpened by mutableStateOf(false) - private set - - var failedOnDownload by mutableStateOf(false) - private val installationRunning = AtomicBoolean(false) - private val cacheDir = context.externalCacheDir ?: File(Environment.getExternalStorageDirectory(), Environment.DIRECTORY_DOWNLOADS).resolve("VendettaManager").also { it.mkdirs() } - private var debugInfo = """ - Vendetta Manager v${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()} - - - """.trimIndent() + val runner = StepRunner(discordVersion) - private val logger = object : Logger() { - override fun d(msg: String?) { - if (msg != null) { - Log.d("Installer", msg) - debugInfo += "$msg\n" - } + val groupedSteps: ImmutableMap> = StepGroup.entries + .associateWith { group -> + runner.steps.filter { step -> step.group == group } } + .toImmutableMap() - override fun i(msg: String?) { - if (msg != null) { - Log.i("Installer", msg) - debugInfo += "$msg\n" - } + private val installationRunning = AtomicBoolean(false) + + private val job = screenModelScope.launch(Dispatchers.Main) { + if (installationRunning.getAndSet(true)) { + return@launch } - override fun e(msg: String?) { - if (msg != null) { - Log.e("Installer", msg) - debugInfo += "$msg\n" - } + withContext(Dispatchers.IO) { + runner.runAll() } } - fun addLogError(msg: String) = logger.e("\n$msg") + var backDialogOpened by mutableStateOf(false) + private set + + var failedOnDownload by mutableStateOf(false) + private set + + var expandedGroup by mutableStateOf(null) + private set + + fun logError(msg: String?) { + runner.logger.e("") + runner.logger.e(msg) + } fun copyDebugInfo() { - context.copyText(debugInfo) + context.copyText(runner.logger.logs.joinToString("\n")) } fun clearCache() { - cacheDir.deleteRecursively() + runner.clearCache() context.showToast(R.string.msg_cleared_cache) } @@ -110,6 +107,14 @@ class InstallerViewModel( backDialogOpened = false } + fun dismissDownloadFailedDialog() { + failedOnDownload = false + } + + fun expandGroup(group: StepGroup?) { + expandedGroup = group + } + fun launchVendetta() { installManager.current?.let { val intent = context.packageManager.getLaunchIntentForPackage(it.packageName)?.apply { @@ -119,427 +124,10 @@ class InstallerViewModel( } } - private val job = screenModelScope.launch(Dispatchers.Main) { - if (installationRunning.getAndSet(true)) { - return@launch - } - - withContext(Dispatchers.IO) { - install() - } - } - fun cancelInstall() { runCatching { job.cancel("User exited the installer") } } - private suspend fun install() { - steps += listOf( - InstallStep.DL_BASE_APK to InstallStepData( - InstallStep.DL_BASE_APK.nameRes, - InstallStatus.QUEUED - ), - InstallStep.DL_LIBS_APK to InstallStepData( - InstallStep.DL_LIBS_APK.nameRes, - InstallStatus.QUEUED - ), - InstallStep.DL_LANG_APK to InstallStepData( - InstallStep.DL_LANG_APK.nameRes, - InstallStatus.QUEUED - ), - InstallStep.DL_RESC_APK to InstallStepData( - InstallStep.DL_RESC_APK.nameRes, - InstallStatus.QUEUED - ), - InstallStep.DL_VD to InstallStepData(InstallStep.DL_VD.nameRes, InstallStatus.QUEUED), - InstallStep.ADD_VD to InstallStepData(InstallStep.ADD_VD.nameRes, InstallStatus.QUEUED), - InstallStep.PATCH_MANIFESTS to InstallStepData( - InstallStep.PATCH_MANIFESTS.nameRes, - InstallStatus.QUEUED - ), - InstallStep.SIGN_APK to InstallStepData( - InstallStep.SIGN_APK.nameRes, - InstallStatus.QUEUED - ), - InstallStep.INSTALL_APK to InstallStepData( - InstallStep.INSTALL_APK.nameRes, - InstallStatus.QUEUED - ), - ) - - if (preferences.patchIcon) steps += InstallStep.CHANGE_ICON to InstallStepData( - InstallStep.CHANGE_ICON.nameRes, - InstallStatus.QUEUED - ) - - val version = preferences.discordVersion.ifBlank { discordVersion.toVersionCode() } - val arch = Build.SUPPORTED_ABIS.first() - val discordCacheDir = cacheDir.resolve(version) - val patchedDir = discordCacheDir.resolve("patched").also { it.deleteRecursively() } - val signedDir = discordCacheDir.resolve("signed").also { it.deleteRecursively() } - val lspatchedDir = patchedDir.resolve("lspatched").also { it.deleteRecursively() } - - // Download base.apk - val baseApk = step(InstallStep.DL_BASE_APK) { - discordCacheDir.resolve("base-$version.apk").let { file -> - logger.i("Checking if base-$version.apk is cached") - if (file.exists()) { - cached = true - logger.i("base-$version.apk is cached") - } else { - logger.i("base-$version.apk is not cached, downloading now") - downloadManager - .downloadDiscordApk(version, file) { progress = it } - .also(::handleDownloadResult) - } - - logger.i("Move base-$version.apk to working directory") - file.copyTo( - patchedDir.resolve(file.name), - true - ) - } - } - - // Download libs apk - val libsApk = step(InstallStep.DL_LIBS_APK) { - val libArch = arch.replace("-v", "_v") - discordCacheDir.resolve("config.$libArch-$version.apk").let { file -> - logger.i("Checking if config.$libArch-$version.apk is cached") - if (file.exists()) { - logger.i("config.$libArch-$version.apk is cached") - cached = true - } else { - logger.i("config.$libArch-$version.apk is not cached, downloading now") - val result = downloadManager.downloadSplit( - version = version, - split = "config.$libArch", - out = file - ) { - progress = it - } - - handleDownloadResult(result) - } - - logger.i("Move config.$libArch-$version.apk to working directory") - file.copyTo( - patchedDir.resolve(file.name), - true - ) - } - } - - // Download locale apk - val langApk = step(InstallStep.DL_LANG_APK) { - discordCacheDir.resolve("config.en-$version.apk").let { file -> - logger.i("Checking if config.en-$version.apk is cached") - if (file.exists()) { - logger.i("config.en-$version.apk is cached") - cached = true - } else { - logger.i("config.en-$version.apk is not cached, downloading now") - val result = downloadManager.downloadSplit( - version = version, - split = "config.en", - out = file - ) { - progress = it - } - - handleDownloadResult(result) - } - - logger.i("Move config.en-$version.apk to working directory") - file.copyTo( - patchedDir.resolve(file.name), - true - ) - } - } - - // Download resources apk - val resApk = step(InstallStep.DL_RESC_APK) { - discordCacheDir.resolve("config.xxhdpi-$version.apk").let { file -> - logger.i("Checking if config.xxhdpi-$version.apk is cached") - if (file.exists()) { - logger.i("config.xxhdpi-$version.apk is cached") - cached = true - } else { - logger.i("config.xxhdpi-$version.apk is not cached, downloading now") - val result = downloadManager.downloadSplit( - version = version, - split = "config.xxhdpi", - out = file - ) { - progress = it - } - - handleDownloadResult(result) - } - - logger.i("Move config.xxhdpi-$version.apk to working directory") - file.copyTo( - patchedDir.resolve(file.name), - true - ) - } - } - - // Download vendetta apk - val vendetta = step(InstallStep.DL_VD) { - preferences.moduleLocation.let { file -> - logger.i("Checking if vendetta.apk is cached") - if (file.exists()) { - logger.i("vendetta.apk is cached") - cached = true - } else { - logger.i("vendetta.apk is not cached, downloading now") - downloadManager - .downloadVendetta(file) { progress = it } - .also(::handleDownloadResult) - } - - logger.i("Move vendetta.apk to working directory") - file.copyTo( - patchedDir.resolve(file.name), - true - ) - } - } - - if (preferences.patchIcon) { - step(InstallStep.CHANGE_ICON) { - ZipWriter(baseApk, true).use { baseApk -> - val mipmaps = - arrayOf("mipmap-xhdpi-v4", "mipmap-xxhdpi-v4", "mipmap-xxxhdpi-v4") - val icons = arrayOf( - "ic_logo_foreground.png", - "ic_logo_square.png", - "ic_logo_foreground.png" - ) - - for (icon in icons) { - val newIcon = context.assets.open("icons/$icon") - .use { it.readBytes() } - - for (mipmap in mipmaps) { - logger.i("Replacing $mipmap with $icon") - val path = "res/$mipmap/$icon" - baseApk.deleteEntry(path) - baseApk.writeEntry(path, newIcon) - } - } - } - } - } - - // Patch manifests - step(InstallStep.PATCH_MANIFESTS) { - arrayOf(baseApk, libsApk, langApk, resApk).forEach { apk -> - logger.i("Reading AndroidManifest.xml from ${apk!!.name}") - val manifest = ZipReader(apk) - .use { zip -> zip.openEntry("AndroidManifest.xml")?.read() } - ?: throw IllegalStateException("No manifest in ${apk.name}") - - ZipWriter(apk, true).use { zip -> - logger.i("Changing package and app name in ${apk.name}") - val patchedManifestBytes = if (apk == baseApk) { - ManifestPatcher.patchManifest( - manifestBytes = manifest, - packageName = preferences.packageName, - appName = preferences.appName, - debuggable = preferences.debuggable, - ) - } else { - logger.i("Changing package name in ${apk.name}") - ManifestPatcher.renamePackage(manifest, preferences.packageName) - } - - logger.i("Deleting old AndroidManifest.xml in ${apk.name}") - zip.deleteEntry( - "AndroidManifest.xml", - apk == libsApk - ) // Preserve alignment in libs apk - logger.i("Adding patched AndroidManifest.xml in ${apk.name}") - zip.writeEntry("AndroidManifest.xml", patchedManifestBytes) - } - } - } - - step(InstallStep.SIGN_APK) { - // Align resources.arsc due to targeting api 30 for silent install - logger.i("Creating dir for signed apks") - signedDir.mkdir() - val apks = arrayOf(baseApk, libsApk, langApk, resApk) - if (Build.VERSION.SDK_INT >= 30) { - for (file in apks) { - logger.i("Byte aligning ${file!!.name}") - val bytes = ZipReader(file).use { - if (it.entryNames.contains("resources.arsc")) { - it.openEntry("resources.arsc")?.read() - } else { - null - } - } ?: continue - - ZipWriter(file, true).use { - logger.i("Removing old resources.arsc") - it.deleteEntry("resources.arsc", true) - logger.i("Adding aligned resources.arsc") - it.writeEntry("resources.arsc", bytes, ZipCompression.NONE, 4096) - } - } - } - - apks.forEach { - logger.i("Signing ${it!!.name}") - Signer.signApk(it, File(signedDir, it.name)) - } - } - - step(InstallStep.ADD_VD) { - logger.i("Adding Vendetta module with LSPatch") - val files = signedDir.listFiles() - ?.takeIf { it.isNotEmpty() } - ?: throw Error("Missing APKs from signing step") - - Patcher.patch( - logger, - outputDir = lspatchedDir, - apkPaths = files.map { it.absolutePath }, - embeddedModules = listOf(vendetta!!.absolutePath) - ) - } - - step(InstallStep.INSTALL_APK) { - logger.i("Installing apks") - val files = lspatchedDir.listFiles() - ?.takeIf { it.isNotEmpty() } - ?: throw Error("Missing APKs from LSPatch step; failure likely") - - val installer: Installer = when (preferences.installMethod) { - InstallMethod.DEFAULT -> SessionInstaller(context) - InstallMethod.SHIZUKU -> ShizukuInstaller(context) - } - - installer.installApks(silent = !isMiui, *files) - - isFinished = true - } - } - - private inline fun step(step: InstallStep, block: InstallStepData.() -> T): T? { - if (isFinished) return null - steps[step]!!.status = InstallStatus.ONGOING - currentStep = step - - try { - val value = measureTimedValue { block.invoke(steps[step]!!) } - val millis = value.duration.inWholeMilliseconds - - steps[step]!!.apply { - duration = millis.div(1000f) - status = InstallStatus.SUCCESSFUL - } - return value.value - } catch (e: Throwable) { - steps[step]!!.status = InstallStatus.UNSUCCESSFUL - - if(e.message?.contains("InvalidArchive") == true) mainThread { - context.showToast(R.string.msg_invalid_apk) - } - - logger.e("\nFailed on step ${step.name}\n") - logger.e(e.stackTraceToString()) - - if (step.group == InstallStepGroup.DL && e.message?.contains("cancelled") != true) - failedOnDownload = true - - currentStep = step - isFinished = true - return null - } - } - - private fun handleDownloadResult(result: DownloadResult) { - when (result) { - DownloadResult.Success -> {} - - is DownloadResult.Cancelled -> { - if (result.systemTriggered) screenModelScope.launch(Dispatchers.Main) { - context.showToast(R.string.msg_download_cancelled) - } - - throw Error("Download was cancelled") - } - - is DownloadResult.Error -> { - screenModelScope.launch(Dispatchers.Main) { - context.showToast(R.string.msg_download_failed) - } - - throw Error("Download failed: ${result.debugReason}") - } - } - } - - enum class InstallStepGroup(@StringRes val nameRes: Int) { - DL(R.string.group_download), - PATCHING(R.string.group_patch), - INSTALLING(R.string.group_installing) - } - - enum class InstallStep( - val group: InstallStepGroup, - @StringRes val nameRes: Int - ) { - DL_BASE_APK(InstallStepGroup.DL, R.string.step_dl_base), - DL_LIBS_APK(InstallStepGroup.DL, R.string.step_dl_lib), - DL_LANG_APK(InstallStepGroup.DL, R.string.step_dl_lang), - DL_RESC_APK(InstallStepGroup.DL, R.string.step_dl_res), - DL_VD(InstallStepGroup.DL, R.string.step_dl_vd), - - CHANGE_ICON(InstallStepGroup.PATCHING, R.string.step_change_icon), - PATCH_MANIFESTS(InstallStepGroup.PATCHING, R.string.step_patch_manifests), - SIGN_APK(InstallStepGroup.PATCHING, R.string.step_signing), - ADD_VD(InstallStepGroup.PATCHING, R.string.step_add_vd), - - INSTALL_APK(InstallStepGroup.INSTALLING, R.string.step_installing) - } - - enum class InstallStatus { - ONGOING, - SUCCESSFUL, - UNSUCCESSFUL, - QUEUED - } - - @Stable - class InstallStepData( - @StringRes val nameRes: Int, - status: InstallStatus, - duration: Float = 0f, - cached: Boolean = false, - progress: Float? = null - ) { - var status by mutableStateOf(status) - var duration by mutableFloatStateOf(duration) - var cached by mutableStateOf(cached) - var progress by mutableStateOf(progress) - } - - var currentStep by mutableStateOf(null) - val steps = mutableStateMapOf() - var isFinished by mutableStateOf(false) - - fun getSteps(group: InstallStepGroup): List { - return steps - .filterKeys { it.group === group }.entries - .sortedBy { it.key.ordinal } - .map { it.value } - } - } \ No newline at end of file diff --git a/app/src/main/java/dev/beefers/vendetta/manager/ui/widgets/installer/StepGroupCard.kt b/app/src/main/java/dev/beefers/vendetta/manager/ui/widgets/installer/StepGroupCard.kt index abcee260..a8bf547d 100644 --- a/app/src/main/java/dev/beefers/vendetta/manager/ui/widgets/installer/StepGroupCard.kt +++ b/app/src/main/java/dev/beefers/vendetta/manager/ui/widgets/installer/StepGroupCard.kt @@ -29,30 +29,40 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import dev.beefers.vendetta.manager.R +import dev.beefers.vendetta.manager.installer.step.Step +import dev.beefers.vendetta.manager.installer.step.StepStatus +import dev.beefers.vendetta.manager.installer.step.download.base.DownloadStep import dev.beefers.vendetta.manager.ui.viewmodel.installer.InstallerViewModel +import dev.beefers.vendetta.manager.utils.thenIf +/** + * Collapsable card containing a group of steps + * + * @param name The name of this group + * @param isCurrent Whether this card is expanded + * @param steps The steps belonging to this group + * @param onClick Action taken when the header of the group is clicked + */ @Composable fun StepGroupCard( name: String, isCurrent: Boolean, - steps: List, + steps: List, onClick: () -> Unit ) { val status = when { - steps.all { it.status == InstallerViewModel.InstallStatus.QUEUED } -> InstallerViewModel.InstallStatus.QUEUED - steps.all { it.status == InstallerViewModel.InstallStatus.SUCCESSFUL } -> InstallerViewModel.InstallStatus.SUCCESSFUL - steps.any { it.status == InstallerViewModel.InstallStatus.ONGOING } -> InstallerViewModel.InstallStatus.ONGOING - else -> InstallerViewModel.InstallStatus.UNSUCCESSFUL + steps.all { it.status == StepStatus.QUEUED } -> StepStatus.QUEUED + steps.all { it.status == StepStatus.SUCCESSFUL } -> StepStatus.SUCCESSFUL + steps.any { it.status == StepStatus.ONGOING } -> StepStatus.ONGOING + else -> StepStatus.UNSUCCESSFUL } Column( modifier = Modifier .fillMaxWidth() .clip(RoundedCornerShape(16.dp)) - .run { - if (isCurrent) { - background(MaterialTheme.colorScheme.surfaceColorAtElevation(1.dp)) - } else this + .thenIf(isCurrent) { + background(MaterialTheme.colorScheme.surfaceColorAtElevation(1.dp)) } ) { Row( @@ -69,9 +79,9 @@ fun StepGroupCard( Spacer(modifier = Modifier.weight(1f)) - if (status != InstallerViewModel.InstallStatus.ONGOING && status != InstallerViewModel.InstallStatus.QUEUED) { + if (status != StepStatus.ONGOING && status != StepStatus.QUEUED) { Text( - text = "%.2fs".format(steps.map { it.duration }.sum()), + text = "%.2fs".format(steps.sumOf { it.durationMs } / 1000f), // Displays the duration rounded to the hundredths place. ex. 10.13s style = MaterialTheme.typography.labelMedium ) } @@ -96,44 +106,14 @@ fun StepGroupCard( .padding(16.dp) .padding(start = 4.dp) ) { - steps.forEach { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(16.dp), - ) { - val progress by animateFloatAsState(it.progress ?: 0f, label = "Progress") - - StepIcon(it.status, size = 18.dp, progress = if(it.progress == null) null else progress) - - Text( - text = stringResource(it.nameRes), - style = MaterialTheme.typography.labelLarge, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.weight(1f, true), - ) - - if (it.status != InstallerViewModel.InstallStatus.ONGOING && it.status != InstallerViewModel.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, - ) - } - } + steps.forEach { step -> + StepRow( + name = stringResource(step.nameRes), + status = step.status, + progress = step.progress, + cached = (step as? DownloadStep)?.cached ?: false, + duration = step.durationMs / 1000f + ) } } } diff --git a/app/src/main/java/dev/beefers/vendetta/manager/ui/widgets/installer/StepIcon.kt b/app/src/main/java/dev/beefers/vendetta/manager/ui/widgets/installer/StepIcon.kt index 6f3b4bc9..1d326f43 100644 --- a/app/src/main/java/dev/beefers/vendetta/manager/ui/widgets/installer/StepIcon.kt +++ b/app/src/main/java/dev/beefers/vendetta/manager/ui/widgets/installer/StepIcon.kt @@ -18,13 +18,25 @@ import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.semantics import androidx.compose.ui.unit.Dp import dev.beefers.vendetta.manager.R +import dev.beefers.vendetta.manager.installer.step.StepStatus import dev.beefers.vendetta.manager.ui.viewmodel.installer.InstallerViewModel import kotlin.math.floor import kotlin.math.roundToInt +/** + * Icon representing the status of a step + * + * Ongoing - Progress indicator + * + * Queued - Outlined circle, tinted grey + * + * Successful - Green check + * + * Unsuccessful - Red X + */ @Composable fun StepIcon( - status: InstallerViewModel.InstallStatus, + status: StepStatus, size: Dp, progress: Float? ) { @@ -32,7 +44,7 @@ fun StepIcon( val context = LocalContext.current when (status) { - InstallerViewModel.InstallStatus.ONGOING -> { + StepStatus.ONGOING -> { if(progress != null) { CircularProgressIndicator( progress = progress, @@ -55,7 +67,7 @@ fun StepIcon( } } - InstallerViewModel.InstallStatus.SUCCESSFUL -> { + StepStatus.SUCCESSFUL -> { Icon( imageVector = Icons.Filled.CheckCircle, contentDescription = stringResource(R.string.status_successful), @@ -64,7 +76,7 @@ fun StepIcon( ) } - InstallerViewModel.InstallStatus.UNSUCCESSFUL -> { + StepStatus.UNSUCCESSFUL -> { Icon( imageVector = Icons.Filled.Cancel, contentDescription = stringResource(R.string.status_fail), @@ -73,7 +85,7 @@ fun StepIcon( ) } - InstallerViewModel.InstallStatus.QUEUED -> { + StepStatus.QUEUED -> { Icon( imageVector = Icons.Outlined.Circle, contentDescription = stringResource(R.string.status_queued), diff --git a/app/src/main/java/dev/beefers/vendetta/manager/ui/widgets/installer/StepRow.kt b/app/src/main/java/dev/beefers/vendetta/manager/ui/widgets/installer/StepRow.kt new file mode 100644 index 00000000..044729f3 --- /dev/null +++ b/app/src/main/java/dev/beefers/vendetta/manager/ui/widgets/installer/StepRow.kt @@ -0,0 +1,76 @@ +package dev.beefers.vendetta.manager.ui.widgets.installer + +import androidx.compose.animation.core.animateFloatAsState +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.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import dev.beefers.vendetta.manager.R +import dev.beefers.vendetta.manager.installer.step.StepStatus + +/** + * Displays a steps name, status, and progress + * + * @param name The name of the step + * @param status The steps current status + * @param progress Represents the download progress, as a decimal + * @param cached Whether the file this step downloads was already cached + * @param duration How long the step took to run, in seconds + * @param modifier [Modifier] for this StepRow + */ +@Composable +fun StepRow( + name: String, + status: StepStatus, + progress: Float?, + cached: Boolean, + duration: Float, + modifier: Modifier = Modifier +) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(16.dp), + modifier = modifier + ) { + @Suppress("LocalVariableName") + val _progress by animateFloatAsState(progress ?: 0f, label = "Progress") // Smoothly animate the progress indicator + + StepIcon(status, size = 18.dp, progress = if(progress == null) null else _progress) + + Text( + text = name, + style = MaterialTheme.typography.labelLarge, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f, true), + ) + + if (status != StepStatus.ONGOING && status != StepStatus.QUEUED) { // Only display for completed steps + if (cached) { + Text( + text = stringResource(R.string.installer_cached), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f), + fontStyle = FontStyle.Italic, + fontSize = 11.sp, + maxLines = 1, + ) + } + + Text( + text = "%.2fs".format(duration), // Displays the duration rounded to the hundredths place. ex. 10.13s + style = MaterialTheme.typography.labelSmall, + maxLines = 1, + ) + } + } +} \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index d85a8dd5..0cf0cc3e 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -17,6 +17,7 @@ APK was corrupted, try clearing cache then reinstalling Download was aborted Download failed + Could not verify downloaded file, check logs for more details Download APKs Patching diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index bb41a402..d973512e 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -19,6 +19,7 @@ accompanist = "0.33.2-alpha" coil = "2.4.0" koin = "3.4.0" kotlinx-datetime = "0.5.0" +kotlinx-collections = "0.3.7" ktor = "2.3.3" shizuku = "13.1.0" voyager = "1.1.0-alpha02" @@ -76,6 +77,7 @@ voyager-koin = { group = "cafe.adriel.voyager", name = "voyager-koin", version.r # Misc. kotlinx-datetime = { group = "org.jetbrains.kotlinx", name = "kotlinx-datetime", version.ref = "kotlinx-datetime" } +kotlinx-collections = { group = "org.jetbrains.kotlinx", name = "kotlinx-collections-immutable", version.ref = "kotlinx-collections" } zip-android = { group = "io.github.diamondminer88", name = "zip-android", version.ref = "zip-android" } [bundles]