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/KtStepContainer.kt b/app/src/main/kotlin/com/aliucord/manager/installer/steps/KtStepContainer.kt new file mode 100644 index 00000000..35d5d35f --- /dev/null +++ b/app/src/main/kotlin/com/aliucord/manager/installer/steps/KtStepContainer.kt @@ -0,0 +1,19 @@ +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.prepare.FetchInfoStep +import kotlinx.collections.immutable.persistentListOf + +/** + * Used for installing the old Kotlin Discord app. + */ +class KtStepContainer : StepContainer() { + override val steps = persistentListOf( + FetchInfoStep(), + DownloadDiscordStep(), + DownloadInjectorStep(), + DownloadAliuhookStep(), + DownloadKotlinStep(), + ) +} diff --git a/app/src/main/kotlin/com/aliucord/manager/installer/steps/StepContainer.kt b/app/src/main/kotlin/com/aliucord/manager/installer/steps/StepContainer.kt new file mode 100644 index 00000000..431979ab --- /dev/null +++ b/app/src/main/kotlin/com/aliucord/manager/installer/steps/StepContainer.kt @@ -0,0 +1,48 @@ +package com.aliucord.manager.installer.steps + +import com.aliucord.manager.installer.steps.base.Step +import com.aliucord.manager.installer.steps.base.StepState +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 + +abstract class StepContainer : 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 getCompletedStep(): T { + val step = steps.asSequence() + .filterIsInstance() + .filter { it.state == StepState.Success } + .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@StepContainer) + if (error != null) return error + + // Add delay for human psychology and + // better group visibility in UI (the active group can change way too fast) + if (!preferences.devMode && step.durationMs < 1000) { + delay(1000L - step.durationMs) + } + + } + + return null + } +} 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/base/DownloadStep.kt b/app/src/main/kotlin/com/aliucord/manager/installer/steps/base/DownloadStep.kt new file mode 100644 index 00000000..72693934 --- /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.StepContainer +import com.aliucord.manager.installer.steps.StepGroup +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: StepContainer) { + 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.Cancelled + } + } +} 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..456cca7a --- /dev/null +++ b/app/src/main/kotlin/com/aliucord/manager/installer/steps/base/Step.kt @@ -0,0 +1,77 @@ +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.StepContainer +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: StepContainer) + + /** + * 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: StepContainer): 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 { + execute(container) + 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..1024f3f8 --- /dev/null +++ b/app/src/main/kotlin/com/aliucord/manager/installer/steps/base/StepState.kt @@ -0,0 +1,10 @@ +package com.aliucord.manager.installer.steps.base + +enum class StepState { + Pending, + Running, + Success, + Error, + Skipped, + Cancelled, // TODO: something like the discord dnd sign except its not red, but gray maybe +} 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..11ed3dd1 --- /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.StepContainer +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: StepContainer) { + 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..be792237 --- /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.StepContainer +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: StepContainer) { + aliucordHash = container + .getCompletedStep() + .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/patch/ReplaceIconStep.kt b/app/src/main/kotlin/com/aliucord/manager/installer/steps/patch/ReplaceIconStep.kt new file mode 100644 index 00000000..0c0f253d --- /dev/null +++ b/app/src/main/kotlin/com/aliucord/manager/installer/steps/patch/ReplaceIconStep.kt @@ -0,0 +1,22 @@ +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.StepContainer +import com.aliucord.manager.installer.steps.StepGroup +import com.aliucord.manager.installer.steps.base.Step +import org.koin.core.component.KoinComponent +import org.koin.core.component.inject + +@Stable +class ReplaceIconStep : Step(), KoinComponent { + val context: Context by inject() + + override val group = StepGroup.Patch + override val localizedName = R.string.setting_replace_icon + + override suspend fun execute(container: StepContainer) { + TODO("Not yet implemented") + } +} 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..79555f1a --- /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.StepContainer +import com.aliucord.manager.installer.steps.StepGroup +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: StepContainer) { + 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..8b58c948 100644 --- a/app/src/main/kotlin/com/aliucord/manager/manager/DownloadManager.kt +++ b/app/src/main/kotlin/com/aliucord/manager/manager/DownloadManager.kt @@ -8,47 +8,31 @@ 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 @@ -90,13 +74,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 +110,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..20ab4562 --- /dev/null +++ b/app/src/main/kotlin/com/aliucord/manager/manager/PathManager.kt @@ -0,0 +1,56 @@ +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") +} 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/InstallAbortDialog.kt b/app/src/main/kotlin/com/aliucord/manager/ui/components/dialogs/InstallAbortDialog.kt new file mode 100644 index 00000000..271e608a --- /dev/null +++ b/app/src/main/kotlin/com/aliucord/manager/ui/components/dialogs/InstallAbortDialog.kt @@ -0,0 +1,52 @@ +package com.aliucord.manager.ui.components.dialogs + +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Warning +import androidx.compose.material3.* +import androidx.compose.runtime.Composable +import androidx.compose.ui.res.stringResource +import com.aliucord.manager.R + +@Composable +fun InstallAbortDialog( + onConfirm: () -> Unit, + onDismiss: () -> Unit, +) { + AlertDialog( + onDismissRequest = onDismiss, + confirmButton = { + FilledTonalButton( + onClick = onConfirm, + colors = ButtonDefaults.filledTonalButtonColors( + containerColor = MaterialTheme.colorScheme.error, + contentColor = MaterialTheme.colorScheme.onError, + ), + ) { + Text(stringResource(R.string.action_cancel)) + } + }, + dismissButton = { + TextButton( + onClick = onDismiss, + colors = ButtonDefaults.textButtonColors( + contentColor = MaterialTheme.colorScheme.onErrorContainer + ), + ) { + Text(stringResource(R.string.action_exit_anyways)) + } + }, + title = { + Text(stringResource(R.string.installer_abort_title)) + }, + text = { + Text(stringResource(R.string.installer_abort_body)) + }, + icon = { + Icon(Icons.Filled.Warning, contentDescription = null) + }, + containerColor = MaterialTheme.colorScheme.errorContainer, + iconContentColor = MaterialTheme.colorScheme.onErrorContainer, + titleContentColor = MaterialTheme.colorScheme.onErrorContainer, + textContentColor = MaterialTheme.colorScheme.onErrorContainer + ) +} 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..0cb15353 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 @@ -9,25 +9,15 @@ import androidx.compose.runtime.* import androidx.compose.runtime.saveable.rememberSaveable import com.aliucord.manager.ui.screens.install.InstallData -enum class DownloadMethod { DOWNLOAD } - @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/screens/about/AboutScreen.kt b/app/src/main/kotlin/com/aliucord/manager/ui/screens/about/AboutScreen.kt index 2d2b1db8..f7339a12 100644 --- a/app/src/main/kotlin/com/aliucord/manager/ui/screens/about/AboutScreen.kt +++ b/app/src/main/kotlin/com/aliucord/manager/ui/screens/about/AboutScreen.kt @@ -161,7 +161,7 @@ private fun MainContributors(modifier: Modifier = Modifier) { ) { UserEntry("Vendicated", "the ven") UserEntry("Juby210", "Fox") - UserEntry("rushii", "explod", "DiamondMiner88") + UserEntry("rushii", "explod", "rushiiMachine") } } 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 b0413ed7..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 @@ -26,6 +26,8 @@ import com.aliucord.manager.ui.components.dialogs.InstallerDialog import com.aliucord.manager.ui.components.home.InfoCard import com.aliucord.manager.ui.screens.about.AboutScreen import com.aliucord.manager.ui.screens.install.InstallScreen +import com.aliucord.manager.ui.screens.plugins.PluginsScreen +import com.aliucord.manager.ui.screens.settings.SettingsScreen class HomeScreen : Screen { override val key = "Home" @@ -44,15 +46,13 @@ class HomeScreen : Screen { if (showInstallerDialog) { InstallerDialog( onDismiss = { showInstallerDialog = false }, - onConfirm = { data -> + onConfirm = { showInstallerDialog = false - navigator.push(InstallScreen(data)) + navigator.push(InstallScreen()) } ) } - // TODO: add a way to open plugins and settings - Scaffold( topBar = { HomeAppBar() }, ) { paddingValues -> @@ -102,6 +102,20 @@ private fun HomeAppBar() { contentDescription = stringResource(R.string.navigation_about) ) } + + IconButton(onClick = { navigator.push(PluginsScreen()) }) { + Icon( + painter = painterResource(R.drawable.ic_extension), + contentDescription = stringResource(R.string.navigation_about) + ) + } + + IconButton(onClick = { navigator.push(SettingsScreen()) }) { + Icon( + painter = painterResource(R.drawable.ic_settings), + contentDescription = stringResource(R.string.navigation_settings) + ) + } } ) } 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 1416a5a1..5b1474fa 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 @@ -10,11 +10,10 @@ 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.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.manager.* import com.aliucord.manager.network.utils.getOrThrow import com.aliucord.manager.ui.components.installer.InstallStatus import com.aliucord.manager.ui.components.installer.InstallStepData @@ -28,13 +27,12 @@ import kotlin.time.measureTimedValue class InstallModel( private val application: Application, + private val paths: PathManager, 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) var returnToHome by mutableStateOf(false) @@ -53,8 +51,6 @@ class InstallModel( 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() @@ -81,7 +77,7 @@ class InstallModel( } fun clearCache() { - externalCacheDir.deleteRecursively() + paths.clearCache() application.showToast(R.string.action_cleared_cache) } @@ -110,14 +106,6 @@ class InstallModel( } } - 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) @@ -136,19 +124,8 @@ class InstallModel( } } - 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, @@ -173,58 +150,6 @@ class InstallModel( 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) - } - } - } - - // 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) - } - } - } - - // Download Aliuhook aar - val aliuhookAarFile = step(InstallStep.DL_ALIUHOOK) { - // Fetch aliuhook version - val aliuhookVersion = aliucordMaven.getAliuhookVersion().getOrThrow() - - // Download aliuhook aar - cacheDir.resolve("aliuhook-${aliuhookVersion}.aar").also { file -> - if (file.exists()) { - cached = true - } else { - downloadManager.downloadAliuhook(aliuhookVersion, file) - } - } - } - // Replace app icons if (preferences.replaceIcon) { step(InstallStep.PATCH_APP_ICON) { @@ -344,58 +269,11 @@ class InstallModel( } } - 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) - } - // Order matters, define it in the same order as it is patched enum class InstallStep( - val group: InstallStepGroup, - @StringRes val nameResId: Int, ) { - // Kotlin - FETCH_KT_VERSION(InstallStepGroup.APK_DL, R.string.install_step_fetch_kt_version), - DL_KT_APK(InstallStepGroup.APK_DL, R.string.install_step_dl_kt_apk), - DL_KOTLIN(InstallStepGroup.LIB_DL, R.string.install_step_dl_kotlin), - DL_INJECTOR(InstallStepGroup.LIB_DL, R.string.install_step_dl_injector), - DL_ALIUHOOK(InstallStepGroup.LIB_DL, R.string.install_step_dl_aliuhook), - // Common PATCH_APP_ICON(InstallStepGroup.PATCHING, R.string.install_step_patch_icons), PATCH_MANIFEST(InstallStepGroup.PATCHING, R.string.install_step_patch_manifests), @@ -404,15 +282,4 @@ class InstallModel( 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() - - // TODO: cache this instead - fun getSteps(group: InstallStepGroup): List { - return steps - .filterKeys { it.group == group }.entries - .sortedBy { it.key.ordinal } - .map { it.value } - } } 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 9362c51e..173cb42b 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,7 @@ 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.* import androidx.compose.foundation.shape.RoundedCornerShape @@ -15,6 +15,7 @@ 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.text.font.FontFamily import androidx.compose.ui.unit.dp @@ -23,31 +24,20 @@ 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.ui.components.back -import com.aliucord.manager.ui.components.dialogs.DownloadMethod +import com.aliucord.manager.ui.components.dialogs.InstallAbortDialog 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 { + +class InstallScreen : Screen { override val key = "Install" @Composable override fun Content() { val navigator = LocalNavigator.currentOrThrow - val model = getScreenModel(parameters = { parametersOf(data) }) + val model = getScreenModel() var expandedGroup by remember { mutableStateOf(null) } @@ -58,13 +48,35 @@ class InstallScreen(val data: InstallData) : Screen { expandedGroup = model.currentStep?.group } + // Exit warning dialog + var showAbortWarning by remember { mutableStateOf(false) } + if (showAbortWarning) { + InstallAbortDialog( + onDismiss = { showAbortWarning = false }, + onConfirm = { + navigator.back(currentActivity = null) + model.clearCache() + }, + ) + } else { + BackHandler { + showAbortWarning = true + } + } + Scaffold( topBar = { TopAppBar( title = { Text(stringResource(R.string.installer)) }, navigationIcon = { - // TODO: add confirm to exit dialog to button as well as BackHandler - BackButton() + IconButton( + onClick = { showAbortWarning = true }, + ) { + Icon( + painter = painterResource(R.drawable.ic_back), + contentDescription = stringResource(R.string.navigation_back), + ) + } } ) } diff --git a/app/src/main/kotlin/com/aliucord/manager/ui/screens/plugins/PluginsScreen.kt b/app/src/main/kotlin/com/aliucord/manager/ui/screens/plugins/PluginsScreen.kt index 191b810b..fa0f0037 100644 --- a/app/src/main/kotlin/com/aliucord/manager/ui/screens/plugins/PluginsScreen.kt +++ b/app/src/main/kotlin/com/aliucord/manager/ui/screens/plugins/PluginsScreen.kt @@ -25,6 +25,7 @@ import androidx.compose.ui.unit.dp import cafe.adriel.voyager.core.screen.Screen import cafe.adriel.voyager.koin.getScreenModel import com.aliucord.manager.R +import com.aliucord.manager.ui.components.BackButton import com.aliucord.manager.ui.components.plugins.Changelog import com.aliucord.manager.ui.components.plugins.PluginCard @@ -52,75 +53,85 @@ class PluginsScreen : Screen { ) } - Column( - modifier = Modifier - .fillMaxSize() - .padding(horizontal = 16.dp), - verticalArrangement = Arrangement.spacedBy(14.dp) - ) { - PluginSearch( - currentFilter = model.search, - onFilterChange = model::search, - modifier = Modifier.fillMaxWidth(), - ) + Scaffold( + topBar = { + TopAppBar( + title = { Text(stringResource(R.string.plugins_title)) }, + navigationIcon = { BackButton() }, + ) + } + ) { paddingValues -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues) + .padding(horizontal = 16.dp), + verticalArrangement = Arrangement.spacedBy(14.dp) + ) { + PluginSearch( + currentFilter = model.search, + onFilterChange = model::search, + modifier = Modifier.fillMaxWidth(), + ) - if (model.error) { - Box( - modifier = Modifier.fillMaxSize() - ) { - Column( - modifier = Modifier.align(Alignment.Center), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(8.dp), + if (model.error) { + Box( + modifier = Modifier.fillMaxSize() ) { - Icon( - imageVector = Icons.Default.Warning, - contentDescription = null, - tint = MaterialTheme.colorScheme.error, - ) - Text( - text = stringResource(R.string.plugins_error), - color = MaterialTheme.colorScheme.error, - ) + Column( + modifier = Modifier.align(Alignment.Center), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Icon( + imageVector = Icons.Default.Warning, + contentDescription = null, + tint = MaterialTheme.colorScheme.error, + ) + Text( + text = stringResource(R.string.plugins_error), + color = MaterialTheme.colorScheme.error, + ) + } } - } - } else if (model.plugins.isNotEmpty()) { - LazyColumn( - contentPadding = PaddingValues(bottom = 15.dp, top = 6.dp), - verticalArrangement = Arrangement.spacedBy(14.dp) - ) { - items( - // TODO: remember {} this - model.plugins.filter { plugin -> - plugin.manifest.run { - name.contains(model.search, true) - || description.contains(model.search, true) - || authors.any { (name) -> name.contains(model.search, true) } + } else if (model.plugins.isNotEmpty()) { + LazyColumn( + contentPadding = PaddingValues(bottom = 15.dp, top = 6.dp), + verticalArrangement = Arrangement.spacedBy(14.dp) + ) { + items( + // TODO: remember {} this + model.plugins.filter { plugin -> + plugin.manifest.run { + name.contains(model.search, true) + || description.contains(model.search, true) + || authors.any { (name) -> name.contains(model.search, true) } + } } + ) { plugin -> + PluginCard( + plugin = plugin, + enabled = model.enabled[plugin.manifest.name] ?: true, + onClickDelete = { model.showUninstallDialog(plugin) }, + onClickShowChangelog = { model.showChangelogDialog(plugin) }, + onSetEnabled = { model.setPluginEnabled(plugin.manifest.name, it) } + ) } - ) { plugin -> - PluginCard( - plugin = plugin, - enabled = model.enabled[plugin.manifest.name] ?: true, - onClickDelete = { model.showUninstallDialog(plugin) }, - onClickShowChangelog = { model.showChangelogDialog(plugin) }, - onSetEnabled = { model.setPluginEnabled(plugin.manifest.name, it) } - ) } - } - } else { - Box( - modifier = Modifier.fillMaxSize() - ) { - Column( - modifier = Modifier.align(Alignment.Center), - horizontalAlignment = Alignment.CenterHorizontally + } else { + Box( + modifier = Modifier.fillMaxSize() ) { - Icon( - painter = painterResource(R.drawable.ic_extension_off), - contentDescription = null - ) - Text(stringResource(R.string.plugins_none_installed)) + Column( + modifier = Modifier.align(Alignment.Center), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Icon( + painter = painterResource(R.drawable.ic_extension_off), + contentDescription = null + ) + Text(stringResource(R.string.plugins_none_installed)) + } } } } diff --git a/app/src/main/kotlin/com/aliucord/manager/ui/screens/settings/SettingsScreen.kt b/app/src/main/kotlin/com/aliucord/manager/ui/screens/settings/SettingsScreen.kt index e72a34dd..051c775f 100644 --- a/app/src/main/kotlin/com/aliucord/manager/ui/screens/settings/SettingsScreen.kt +++ b/app/src/main/kotlin/com/aliucord/manager/ui/screens/settings/SettingsScreen.kt @@ -20,6 +20,7 @@ import androidx.compose.ui.unit.dp import cafe.adriel.voyager.core.screen.Screen import cafe.adriel.voyager.koin.getScreenModel import com.aliucord.manager.R +import com.aliucord.manager.ui.components.BackButton import com.aliucord.manager.ui.components.Theme import com.aliucord.manager.ui.components.settings.* @@ -30,126 +31,136 @@ class SettingsScreen : Screen { override fun Content() { val model = getScreenModel() - Column( - modifier = Modifier - .verticalScroll(state = rememberScrollState()) - ) { - val preferences = model.preferences - - if (model.showThemeDialog) { - ThemeDialog( - currentTheme = preferences.theme, - onDismissRequest = model::hideThemeDialog, - onConfirm = model::setTheme + Scaffold( + topBar = { + TopAppBar( + title = { Text(stringResource(R.string.navigation_settings)) }, + navigationIcon = { BackButton() }, ) - } + }, + ) { paddingValues -> + Column( + modifier = Modifier + .padding(paddingValues) + .verticalScroll(state = rememberScrollState()) + ) { + val preferences = model.preferences - SettingsHeader(stringResource(R.string.settings_appearance)) + if (model.showThemeDialog) { + ThemeDialog( + currentTheme = preferences.theme, + onDismissRequest = model::hideThemeDialog, + onConfirm = model::setTheme + ) + } - SettingsItem( - modifier = Modifier.clickable(onClick = model::showThemeDialog), - icon = { Icon(painterResource(R.drawable.ic_brush), null) }, - text = { Text(stringResource(R.string.settings_theme)) } - ) { - FilledTonalButton(onClick = model::showThemeDialog) { - Text(preferences.theme.toDisplayName()) + SettingsHeader(stringResource(R.string.settings_appearance)) + + SettingsItem( + modifier = Modifier.clickable(onClick = model::showThemeDialog), + icon = { Icon(painterResource(R.drawable.ic_brush), null) }, + text = { Text(stringResource(R.string.settings_theme)) } + ) { + FilledTonalButton(onClick = model::showThemeDialog) { + Text(preferences.theme.toDisplayName()) + } } - } - SettingsSwitch( - label = stringResource(R.string.setting_dynamic_color), - pref = preferences.dynamicColor, - icon = { Icon(painterResource(R.drawable.ic_palette), null) } - ) { - preferences.dynamicColor = it - } + SettingsSwitch( + label = stringResource(R.string.setting_dynamic_color), + pref = preferences.dynamicColor, + icon = { Icon(painterResource(R.drawable.ic_palette), null) } + ) { + preferences.dynamicColor = it + } - SettingsHeader(stringResource(R.string.settings_advanced)) + SettingsHeader(stringResource(R.string.settings_advanced)) - SettingsTextField( - label = stringResource(R.string.setting_app_name), - pref = preferences.appName, - onPrefChange = model::setAppName - ) - Spacer(modifier = Modifier.height(4.dp)) + SettingsTextField( + label = stringResource(R.string.setting_app_name), + pref = preferences.appName, + onPrefChange = model::setAppName + ) + Spacer(modifier = Modifier.height(4.dp)) - SettingsSwitch( - label = stringResource(R.string.setting_replace_icon), - secondaryLabel = stringResource(R.string.setting_replace_icon_desc), - pref = preferences.replaceIcon, - icon = { Icon(painterResource(R.drawable.ic_app_shortcut), null) } - ) { - preferences.replaceIcon = it - } + SettingsSwitch( + label = stringResource(R.string.setting_replace_icon), + secondaryLabel = stringResource(R.string.setting_replace_icon_desc), + pref = preferences.replaceIcon, + icon = { Icon(painterResource(R.drawable.ic_app_shortcut), null) } + ) { + preferences.replaceIcon = it + } - SettingsSwitch( - label = stringResource(R.string.setting_keep_patched_apks), - secondaryLabel = stringResource(R.string.setting_keep_patched_apks_desc), - icon = { Icon(painterResource(R.drawable.ic_delete_forever), null) }, - pref = preferences.keepPatchedApks, - onPrefChange = { preferences.keepPatchedApks = it }, - ) + SettingsSwitch( + label = stringResource(R.string.setting_keep_patched_apks), + secondaryLabel = stringResource(R.string.setting_keep_patched_apks_desc), + icon = { Icon(painterResource(R.drawable.ic_delete_forever), null) }, + pref = preferences.keepPatchedApks, + onPrefChange = { preferences.keepPatchedApks = it }, + ) - Spacer(modifier = Modifier.height(14.dp)) + Spacer(modifier = Modifier.height(14.dp)) - SettingsSwitch( - label = stringResource(R.string.settings_developer_options), - pref = preferences.devMode, - icon = { Icon(painterResource(R.drawable.ic_code), null) } - ) { - preferences.devMode = it - } + SettingsSwitch( + label = stringResource(R.string.settings_developer_options), + pref = preferences.devMode, + icon = { Icon(painterResource(R.drawable.ic_code), null) } + ) { + preferences.devMode = it + } - AnimatedVisibility( - visible = preferences.devMode, - enter = expandVertically(), - exit = shrinkVertically() - ) { - Column( - verticalArrangement = Arrangement.spacedBy(6.dp) + AnimatedVisibility( + visible = preferences.devMode, + enter = expandVertically(), + exit = shrinkVertically() ) { - SettingsTextField( - label = stringResource(R.string.setting_package_name), - pref = preferences.packageName, - onPrefChange = model::setPackageName, - error = model.packageNameError - ) + Column( + verticalArrangement = Arrangement.spacedBy(6.dp) + ) { + SettingsTextField( + label = stringResource(R.string.setting_package_name), + pref = preferences.packageName, + onPrefChange = model::setPackageName, + error = model.packageNameError + ) - SettingsTextField( - label = stringResource(R.string.setting_target_version), - pref = preferences.version, - onPrefChange = model::setVersion - ) + SettingsTextField( + label = stringResource(R.string.setting_target_version), + pref = preferences.version, + onPrefChange = model::setVersion + ) - SettingsSwitch( - label = stringResource(R.string.setting_debuggable), - secondaryLabel = stringResource(R.string.setting_debuggable_desc), - pref = preferences.debuggable, - icon = { Icon(painterResource(R.drawable.ic_bug), null) }, - onPrefChange = { preferences.debuggable = it }, - ) + SettingsSwitch( + label = stringResource(R.string.setting_debuggable), + secondaryLabel = stringResource(R.string.setting_debuggable_desc), + pref = preferences.debuggable, + icon = { Icon(painterResource(R.drawable.ic_bug), null) }, + onPrefChange = { preferences.debuggable = it }, + ) - SettingsSwitch( - label = stringResource(R.string.setting_hermes_replace_libcpp), - secondaryLabel = stringResource(R.string.setting_hermes_replace_libcpp_desc), - icon = { Icon(painterResource(R.drawable.ic_copy_file), null) }, - pref = preferences.hermesReplaceLibCpp, - onPrefChange = { preferences.hermesReplaceLibCpp = it }, - ) + SettingsSwitch( + label = stringResource(R.string.setting_hermes_replace_libcpp), + secondaryLabel = stringResource(R.string.setting_hermes_replace_libcpp_desc), + icon = { Icon(painterResource(R.drawable.ic_copy_file), null) }, + pref = preferences.hermesReplaceLibCpp, + onPrefChange = { preferences.hermesReplaceLibCpp = it }, + ) + } } - } - Button( - modifier = Modifier - .fillMaxWidth() - .padding(18.dp), - shape = ShapeDefaults.Large, - onClick = model::clearCacheDir - ) { - Text( - text = stringResource(R.string.setting_clear_cache), - textAlign = TextAlign.Center - ) + Button( + modifier = Modifier + .fillMaxWidth() + .padding(18.dp), + shape = ShapeDefaults.Large, + onClick = model::clearCacheDir + ) { + Text( + text = stringResource(R.string.setting_clear_cache), + textAlign = TextAlign.Center + ) + } } } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 8c457cc4..26bf0ea5 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -9,6 +9,7 @@ Website Installer + Cancel Retry Apply Confirm @@ -27,6 +28,7 @@ Expand Copied! Cleared cache! + Exit anyways Grant Permissions In order for Aliucord Manager to function, file permissions are required. Since shared data is stored in ~/Aliucord, permissions are required in order to access it. @@ -91,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) @@ -102,9 +105,9 @@ Application is incompatible with this device Installation timed out - Download APKs - Download Libraries - Patch APKs + Prepare + Download dependencies + Patch APK Install APK Patching app icons @@ -128,6 +131,9 @@ A new update has been released for Aliucord Manager! It may be required in order to function properly. Would you like to update? Update to %1$s + Really exit? + Are you sure you really want to abort an in-progress installation? Cached files will be cleared to avoid corruption. + Download failed (Unknown) Download failed (Invalid response) Download failed (File exists)