feat: rewrite install steps & steps UI (#63)
This commit is contained in:
parent
a53e21f001
commit
09e1ca200e
40 changed files with 1424 additions and 688 deletions
|
|
@ -59,6 +59,7 @@ class ManagerApplication : Application() {
|
|||
modules(module {
|
||||
single { providePreferences() }
|
||||
single { provideDownloadManager() }
|
||||
single { providePathManager() }
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,39 @@
|
|||
package com.aliucord.manager.installer.steps
|
||||
|
||||
import com.aliucord.manager.installer.steps.base.Step
|
||||
import com.aliucord.manager.installer.steps.download.*
|
||||
import com.aliucord.manager.installer.steps.install.*
|
||||
import com.aliucord.manager.installer.steps.patch.*
|
||||
import com.aliucord.manager.installer.steps.prepare.DowngradeCheckStep
|
||||
import com.aliucord.manager.installer.steps.prepare.FetchInfoStep
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
|
||||
/**
|
||||
* Used for installing the old Kotlin Discord app.
|
||||
*/
|
||||
class KotlinInstallRunner : StepRunner() {
|
||||
override val steps = persistentListOf<Step>(
|
||||
// Prepare
|
||||
FetchInfoStep(),
|
||||
DowngradeCheckStep(),
|
||||
|
||||
// Download
|
||||
DownloadDiscordStep(),
|
||||
DownloadInjectorStep(),
|
||||
DownloadAliuhookStep(),
|
||||
DownloadKotlinStep(),
|
||||
|
||||
// Patch
|
||||
CopyDependenciesStep(),
|
||||
ReplaceIconStep(),
|
||||
PatchManifestStep(),
|
||||
AddInjectorStep(),
|
||||
AddAliuhookStep(),
|
||||
|
||||
// Install
|
||||
AlignmentStep(),
|
||||
SigningStep(),
|
||||
InstallStep(),
|
||||
CleanupStep(),
|
||||
)
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -0,0 +1,52 @@
|
|||
package com.aliucord.manager.installer.steps
|
||||
|
||||
import com.aliucord.manager.installer.steps.base.Step
|
||||
import com.aliucord.manager.manager.PreferencesManager
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.coroutines.delay
|
||||
import org.koin.core.component.KoinComponent
|
||||
import org.koin.core.component.inject
|
||||
|
||||
/**
|
||||
* The minimum time that is required to occur between step switches, to avoid
|
||||
* quickly switching the step groups in the UI. (very disorienting)
|
||||
* Larger delay leads to a perception that it's doing more work than it actually is.
|
||||
*/
|
||||
const val MINIMUM_STEP_DELAY: Long = 600L
|
||||
|
||||
abstract class StepRunner : KoinComponent {
|
||||
private val preferences: PreferencesManager by inject()
|
||||
|
||||
abstract val steps: ImmutableList<Step>
|
||||
|
||||
/**
|
||||
* Get a step that has already been successfully executed.
|
||||
* This is used to retrieve previously executed dependency steps from a later step.
|
||||
*/
|
||||
inline fun <reified T : Step> getStep(): T {
|
||||
val step = steps.asSequence()
|
||||
.filterIsInstance<T>()
|
||||
.filter { it.state.isFinished }
|
||||
.firstOrNull()
|
||||
|
||||
if (step == null) {
|
||||
throw IllegalArgumentException("No completed step ${T::class.simpleName} exists in container")
|
||||
}
|
||||
|
||||
return step
|
||||
}
|
||||
|
||||
suspend fun executeAll(): Throwable? {
|
||||
for (step in steps) {
|
||||
val error = step.executeCatching(this@StepRunner)
|
||||
if (error != null) return error
|
||||
|
||||
// Skip minimum run time when in dev mode
|
||||
if (!preferences.devMode && step.durationMs < MINIMUM_STEP_DELAY) {
|
||||
delay(MINIMUM_STEP_DELAY - step.durationMs)
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,85 @@
|
|||
package com.aliucord.manager.installer.steps.base
|
||||
|
||||
import android.content.Context
|
||||
import androidx.compose.runtime.Stable
|
||||
import com.aliucord.manager.R
|
||||
import com.aliucord.manager.installer.steps.StepGroup
|
||||
import com.aliucord.manager.installer.steps.StepRunner
|
||||
import com.aliucord.manager.manager.DownloadManager
|
||||
import com.aliucord.manager.util.showToast
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.koin.core.component.KoinComponent
|
||||
import org.koin.core.component.inject
|
||||
import java.io.File
|
||||
|
||||
@Stable
|
||||
abstract class DownloadStep : Step(), KoinComponent {
|
||||
private val context: Context by inject()
|
||||
private val downloads: DownloadManager by inject()
|
||||
|
||||
/**
|
||||
* The remote url to download
|
||||
*/
|
||||
abstract val targetUrl: String
|
||||
|
||||
/**
|
||||
* Target path to store the download in. If this file already exists,
|
||||
* then the cached version is used and the step is marked as cancelled/skipped.
|
||||
*/
|
||||
abstract val targetFile: File
|
||||
|
||||
/**
|
||||
* Verify that the download completely successfully without errors.
|
||||
* @throws Throwable If verification fails.
|
||||
*/
|
||||
open suspend fun verify() {
|
||||
if (!targetFile.exists())
|
||||
throw Error("Downloaded file is missing!")
|
||||
|
||||
if (targetFile.length() <= 0)
|
||||
throw Error("Downloaded file is empty!")
|
||||
}
|
||||
|
||||
override val group = StepGroup.Download
|
||||
|
||||
override suspend fun execute(container: StepRunner) {
|
||||
if (targetFile.exists()) {
|
||||
if (targetFile.length() > 0) {
|
||||
state = StepState.Skipped
|
||||
return
|
||||
}
|
||||
|
||||
targetFile.delete()
|
||||
}
|
||||
|
||||
val result = downloads.download(targetUrl, targetFile) { newProgress ->
|
||||
progress = newProgress ?: -1f
|
||||
}
|
||||
|
||||
when (result) {
|
||||
is DownloadManager.Result.Success -> {
|
||||
try {
|
||||
verify()
|
||||
} catch (t: Throwable) {
|
||||
withContext(Dispatchers.Main) {
|
||||
context.showToast(R.string.installer_dl_verify_fail)
|
||||
}
|
||||
|
||||
throw t
|
||||
}
|
||||
}
|
||||
|
||||
is DownloadManager.Result.Error -> {
|
||||
withContext(Dispatchers.Main) {
|
||||
context.showToast(result.localizedReason)
|
||||
}
|
||||
|
||||
throw Error("Failed to download: ${result.debugReason}")
|
||||
}
|
||||
|
||||
is DownloadManager.Result.Cancelled ->
|
||||
state = StepState.Error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,84 @@
|
|||
package com.aliucord.manager.installer.steps.base
|
||||
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.compose.runtime.*
|
||||
import com.aliucord.manager.installer.steps.StepGroup
|
||||
import com.aliucord.manager.installer.steps.StepRunner
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.koin.core.time.measureTimedValue
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
/**
|
||||
* A base install process step. Steps are single-use
|
||||
*/
|
||||
@Stable
|
||||
abstract class Step {
|
||||
/**
|
||||
* The group this step belongs to.
|
||||
*/
|
||||
abstract val group: StepGroup
|
||||
|
||||
/**
|
||||
* The UI name to display this step as
|
||||
*/
|
||||
@get:StringRes
|
||||
abstract val localizedName: Int
|
||||
|
||||
/**
|
||||
* Run the step's logic.
|
||||
* It can be assumed that this is executed in the correct order after other steps.
|
||||
*/
|
||||
protected abstract suspend fun execute(container: StepRunner)
|
||||
|
||||
/**
|
||||
* The current state of this step in the installation process.
|
||||
*/
|
||||
var state by mutableStateOf(StepState.Pending)
|
||||
protected set
|
||||
|
||||
/**
|
||||
* If the current state is [StepState.Running], then the progress of this step.
|
||||
* If the progress isn't currently measurable, then this should be set to `-1`.
|
||||
*/
|
||||
var progress by mutableFloatStateOf(-1f)
|
||||
protected set
|
||||
|
||||
/**
|
||||
* The total execution time once this step has finished execution.
|
||||
*/
|
||||
// TODO: make this a live value
|
||||
var durationMs by mutableIntStateOf(0)
|
||||
private set
|
||||
|
||||
/**
|
||||
* Thin wrapper over [execute] but handling errors.
|
||||
* @return An exception if the step failed to execute.
|
||||
*/
|
||||
suspend fun executeCatching(container: StepRunner): Throwable? {
|
||||
if (state != StepState.Pending)
|
||||
throw IllegalStateException("Cannot execute a step that has already started")
|
||||
|
||||
state = StepState.Running
|
||||
|
||||
// Execute this steps logic while timing it
|
||||
val (error, executionTimeMs) = measureTimedValue {
|
||||
try {
|
||||
withContext(Dispatchers.Default) {
|
||||
execute(container)
|
||||
}
|
||||
|
||||
if (state != StepState.Skipped)
|
||||
state = StepState.Success
|
||||
|
||||
null
|
||||
} catch (t: Throwable) {
|
||||
state = StepState.Error
|
||||
t
|
||||
}
|
||||
}
|
||||
|
||||
durationMs = executionTimeMs.roundToInt()
|
||||
return error
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
package com.aliucord.manager.installer.steps.base
|
||||
|
||||
enum class StepState {
|
||||
Pending,
|
||||
Running,
|
||||
Success,
|
||||
Error,
|
||||
Skipped;
|
||||
|
||||
val isFinished: Boolean
|
||||
get() = this == Success || this == Error || this == Skipped
|
||||
}
|
||||
|
|
@ -0,0 +1,36 @@
|
|||
package com.aliucord.manager.installer.steps.download
|
||||
|
||||
import androidx.compose.runtime.Stable
|
||||
import com.aliucord.manager.R
|
||||
import com.aliucord.manager.domain.repository.AliucordMavenRepository
|
||||
import com.aliucord.manager.installer.steps.StepRunner
|
||||
import com.aliucord.manager.installer.steps.base.DownloadStep
|
||||
import com.aliucord.manager.manager.PathManager
|
||||
import com.aliucord.manager.network.utils.getOrThrow
|
||||
import org.koin.core.component.KoinComponent
|
||||
import org.koin.core.component.inject
|
||||
|
||||
/**
|
||||
* Download a packaged AAR of the latest Aliuhook build from the Aliucord maven.
|
||||
*/
|
||||
@Stable
|
||||
class DownloadAliuhookStep : DownloadStep(), KoinComponent {
|
||||
private val paths: PathManager by inject()
|
||||
private val maven: AliucordMavenRepository by inject()
|
||||
|
||||
/**
|
||||
* This is populated right before the download starts (ref: [execute])
|
||||
*/
|
||||
private lateinit var targetVersion: String
|
||||
|
||||
override val localizedName = R.string.install_step_dl_aliuhook
|
||||
override val targetUrl get() = AliucordMavenRepository.getAliuhookUrl(targetVersion)
|
||||
override val targetFile get() = paths.cachedAliuhookAAR(targetVersion)
|
||||
|
||||
override suspend fun execute(container: StepRunner) {
|
||||
targetVersion = maven.getAliuhookVersion().getOrThrow()
|
||||
|
||||
super.execute(container)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,45 @@
|
|||
package com.aliucord.manager.installer.steps.download
|
||||
|
||||
import androidx.compose.runtime.Stable
|
||||
import com.aliucord.manager.R
|
||||
import com.aliucord.manager.installer.steps.StepRunner
|
||||
import com.aliucord.manager.installer.steps.base.DownloadStep
|
||||
import com.aliucord.manager.installer.steps.prepare.FetchInfoStep
|
||||
import com.aliucord.manager.manager.PathManager
|
||||
import com.aliucord.manager.network.dto.Version
|
||||
import org.koin.core.component.KoinComponent
|
||||
import org.koin.core.component.inject
|
||||
|
||||
/**
|
||||
* Download a compiled dex file to be injected into the APK as the first `classes.dex` to override an entry point class.
|
||||
*/
|
||||
@Stable
|
||||
class DownloadInjectorStep : DownloadStep(), KoinComponent {
|
||||
private val paths: PathManager by inject()
|
||||
|
||||
/**
|
||||
* Populated from a dependency step ([FetchInfoStep]).
|
||||
* This is used as cache invalidation (ref: [Version.aliucordHash])
|
||||
*/
|
||||
private lateinit var aliucordHash: String
|
||||
|
||||
override val localizedName = R.string.install_step_dl_injector
|
||||
override val targetUrl = URL
|
||||
override val targetFile
|
||||
get() = paths.cachedInjectorDex(aliucordHash).resolve("discord.apk")
|
||||
|
||||
override suspend fun execute(container: StepRunner) {
|
||||
aliucordHash = container
|
||||
.getStep<FetchInfoStep>()
|
||||
.data.aliucordHash
|
||||
|
||||
super.execute(container)
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val ORG = "Aliucord"
|
||||
const val MAIN_REPO = "Aliucord"
|
||||
|
||||
const val URL = "https://raw.githubusercontent.com/$ORG/$MAIN_REPO/builds/Injector.dex"
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,39 @@
|
|||
package com.aliucord.manager.installer.steps.install
|
||||
|
||||
import android.os.Build
|
||||
import com.aliucord.manager.R
|
||||
import com.aliucord.manager.installer.steps.StepGroup
|
||||
import com.aliucord.manager.installer.steps.StepRunner
|
||||
import com.aliucord.manager.installer.steps.base.Step
|
||||
import com.aliucord.manager.installer.steps.base.StepState
|
||||
import com.aliucord.manager.installer.steps.patch.CopyDependenciesStep
|
||||
import com.github.diamondminer88.zip.*
|
||||
import org.koin.core.component.KoinComponent
|
||||
|
||||
/**
|
||||
* Align certain files in the APK to a 4KiB boundary.
|
||||
*/
|
||||
class AlignmentStep : Step(), KoinComponent {
|
||||
private val currentDeviceArch = Build.SUPPORTED_ABIS.first()
|
||||
|
||||
override val group = StepGroup.Install
|
||||
override val localizedName = R.string.install_step_alignment
|
||||
|
||||
override suspend fun execute(container: StepRunner) {
|
||||
val apk = container.getStep<CopyDependenciesStep>().patchedApk
|
||||
|
||||
// Align resources.arsc due to targeting API 30 for silent install
|
||||
if (Build.VERSION.SDK_INT >= 30) {
|
||||
val bytes = ZipReader(apk)
|
||||
.use { it.openEntry("resources.arsc")?.read() }
|
||||
?: throw IllegalArgumentException("APK is missing resources.arsc")
|
||||
|
||||
ZipWriter(apk, /* append = */ true).use {
|
||||
it.deleteEntry("resources.arsc")
|
||||
it.writeEntry("resources.arsc", bytes, ZipCompression.NONE, 4096)
|
||||
}
|
||||
} else {
|
||||
state = StepState.Skipped
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,31 @@
|
|||
package com.aliucord.manager.installer.steps.install
|
||||
|
||||
import com.aliucord.manager.R
|
||||
import com.aliucord.manager.installer.steps.StepGroup
|
||||
import com.aliucord.manager.installer.steps.StepRunner
|
||||
import com.aliucord.manager.installer.steps.base.Step
|
||||
import com.aliucord.manager.installer.steps.base.StepState
|
||||
import com.aliucord.manager.manager.PathManager
|
||||
import com.aliucord.manager.manager.PreferencesManager
|
||||
import org.koin.core.component.KoinComponent
|
||||
import org.koin.core.component.inject
|
||||
|
||||
/**
|
||||
* Cleanup patching working directory once the installation has completed.
|
||||
*/
|
||||
class CleanupStep : Step(), KoinComponent {
|
||||
private val paths: PathManager by inject()
|
||||
private val prefs: PreferencesManager by inject()
|
||||
|
||||
override val group = StepGroup.Install
|
||||
override val localizedName = R.string.install_step_cleanup
|
||||
|
||||
override suspend fun execute(container: StepRunner) {
|
||||
if (prefs.keepPatchedApks) {
|
||||
state = StepState.Skipped
|
||||
} else {
|
||||
if (!paths.patchingWorkingDir().deleteRecursively())
|
||||
throw IllegalStateException("Failed to delete patching working dir")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
package com.aliucord.manager.installer.steps.install
|
||||
|
||||
import android.app.Application
|
||||
import com.aliucord.manager.R
|
||||
import com.aliucord.manager.installer.steps.StepGroup
|
||||
import com.aliucord.manager.installer.steps.StepRunner
|
||||
import com.aliucord.manager.installer.steps.base.Step
|
||||
import com.aliucord.manager.installer.steps.patch.CopyDependenciesStep
|
||||
import com.aliucord.manager.installer.util.installApks
|
||||
import com.aliucord.manager.manager.PreferencesManager
|
||||
import org.koin.core.component.KoinComponent
|
||||
import org.koin.core.component.inject
|
||||
|
||||
/**
|
||||
* Install the final APK with the system's PackageManager.
|
||||
*/
|
||||
class InstallStep : Step(), KoinComponent {
|
||||
private val application: Application by inject()
|
||||
private val prefs: PreferencesManager by inject()
|
||||
|
||||
override val group = StepGroup.Install
|
||||
override val localizedName = R.string.install_step_installing
|
||||
|
||||
override suspend fun execute(container: StepRunner) {
|
||||
val apk = container.getStep<CopyDependenciesStep>().patchedApk
|
||||
|
||||
application.installApks(
|
||||
silent = !prefs.devMode,
|
||||
apks = arrayOf(apk),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
package com.aliucord.manager.installer.steps.install
|
||||
|
||||
import com.aliucord.manager.R
|
||||
import com.aliucord.manager.installer.steps.StepGroup
|
||||
import com.aliucord.manager.installer.steps.StepRunner
|
||||
import com.aliucord.manager.installer.steps.base.Step
|
||||
import com.aliucord.manager.installer.steps.patch.CopyDependenciesStep
|
||||
import com.aliucord.manager.installer.util.Signer
|
||||
import org.koin.core.component.KoinComponent
|
||||
|
||||
/**
|
||||
* Sign the APK with a keystore generated on-device.
|
||||
*/
|
||||
class SigningStep : Step(), KoinComponent {
|
||||
override val group = StepGroup.Install
|
||||
override val localizedName = R.string.install_step_signing
|
||||
|
||||
override suspend fun execute(container: StepRunner) {
|
||||
val apk = container.getStep<CopyDependenciesStep>().patchedApk
|
||||
|
||||
Signer.signApk(apk)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,47 @@
|
|||
package com.aliucord.manager.installer.steps.patch
|
||||
|
||||
import android.os.Build
|
||||
import com.aliucord.manager.R
|
||||
import com.aliucord.manager.installer.steps.StepGroup
|
||||
import com.aliucord.manager.installer.steps.StepRunner
|
||||
import com.aliucord.manager.installer.steps.base.Step
|
||||
import com.aliucord.manager.installer.steps.download.DownloadAliuhookStep
|
||||
import com.github.diamondminer88.zip.ZipReader
|
||||
import com.github.diamondminer88.zip.ZipWriter
|
||||
import org.koin.core.component.KoinComponent
|
||||
|
||||
/**
|
||||
* Add the Aliuhook library's native libs along with dex
|
||||
*/
|
||||
class AddAliuhookStep : Step(), KoinComponent {
|
||||
private val currentDeviceArch = Build.SUPPORTED_ABIS.first()
|
||||
|
||||
override val group = StepGroup.Patch
|
||||
override val localizedName = R.string.install_step_add_aliuhook
|
||||
|
||||
override suspend fun execute(container: StepRunner) {
|
||||
val apk = container.getStep<CopyDependenciesStep>().patchedApk
|
||||
val aliuhook = container.getStep<DownloadAliuhookStep>().targetFile
|
||||
|
||||
// Find the amount of .dex files in the apk
|
||||
val dexCount = ZipReader(apk).use {
|
||||
it.entryNames.count { name -> name.endsWith(".dex") }
|
||||
}
|
||||
|
||||
ZipWriter(apk, /* append = */ true).use { patchedApk ->
|
||||
ZipReader(aliuhook).use { aliuhook ->
|
||||
for (libFile in arrayOf("libaliuhook.so", "libc++_shared.so", "liblsplant.so")) {
|
||||
val bytes = aliuhook.openEntry("jni/$currentDeviceArch/$libFile")?.read()
|
||||
?: throw IllegalStateException("Failed to read $libFile from aliuhook aar")
|
||||
|
||||
patchedApk.writeEntry("lib/$currentDeviceArch/$libFile", bytes)
|
||||
}
|
||||
|
||||
val aliuhookDex = aliuhook.openEntry("classes.dex")?.read()
|
||||
?: throw IllegalStateException("No classes.dex in aliuhook aar")
|
||||
|
||||
patchedApk.writeEntry("classes${dexCount + 1}.dex", aliuhookDex)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,46 @@
|
|||
package com.aliucord.manager.installer.steps.patch
|
||||
|
||||
import com.aliucord.manager.R
|
||||
import com.aliucord.manager.installer.steps.StepGroup
|
||||
import com.aliucord.manager.installer.steps.StepRunner
|
||||
import com.aliucord.manager.installer.steps.base.Step
|
||||
import com.aliucord.manager.installer.steps.download.DownloadInjectorStep
|
||||
import com.aliucord.manager.installer.steps.download.DownloadKotlinStep
|
||||
import com.github.diamondminer88.zip.ZipReader
|
||||
import com.github.diamondminer88.zip.ZipWriter
|
||||
import org.koin.core.component.KoinComponent
|
||||
|
||||
/**
|
||||
* Reorder the existing dex files to add the Aliucord injector as the first `classes.dex` file.
|
||||
*/
|
||||
class AddInjectorStep : Step(), KoinComponent {
|
||||
override val group = StepGroup.Patch
|
||||
override val localizedName = R.string.install_step_add_injector
|
||||
|
||||
override suspend fun execute(container: StepRunner) {
|
||||
val apk = container.getStep<CopyDependenciesStep>().patchedApk
|
||||
val injector = container.getStep<DownloadInjectorStep>().targetFile
|
||||
val kotlinStdlib = container.getStep<DownloadKotlinStep>().targetFile
|
||||
|
||||
val (dexCount, firstDexBytes) = ZipReader(apk).use {
|
||||
Pair(
|
||||
// Find the amount of .dex files in apk
|
||||
it.entryNames.count { name -> name.endsWith(".dex") },
|
||||
|
||||
// Get the first dex
|
||||
it.openEntry("classes.dex")?.read()
|
||||
?: throw IllegalStateException("No classes.dex in base apk")
|
||||
)
|
||||
}
|
||||
|
||||
ZipWriter(apk, /* append = */ true).use {
|
||||
// Move copied dex to end of dex list
|
||||
it.deleteEntry("classes.dex")
|
||||
it.writeEntry("classes${dexCount + 1}.dex", firstDexBytes)
|
||||
|
||||
// Add Kotlin & Aliucord's dex
|
||||
it.writeEntry("classes.dex", injector.readBytes())
|
||||
it.writeEntry("classes${dexCount + 2}.dex", kotlinStdlib.readBytes())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,39 @@
|
|||
package com.aliucord.manager.installer.steps.patch
|
||||
|
||||
import com.aliucord.manager.R
|
||||
import com.aliucord.manager.installer.steps.StepGroup
|
||||
import com.aliucord.manager.installer.steps.StepRunner
|
||||
import com.aliucord.manager.installer.steps.base.Step
|
||||
import com.aliucord.manager.installer.steps.download.DownloadDiscordStep
|
||||
import com.aliucord.manager.manager.PathManager
|
||||
import org.koin.core.component.KoinComponent
|
||||
import org.koin.core.component.inject
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* Step to duplicate the Discord APK to be worked on.
|
||||
*/
|
||||
class CopyDependenciesStep : Step(), KoinComponent {
|
||||
private val paths: PathManager by inject()
|
||||
|
||||
/**
|
||||
* The target APK file which can be modified during patching
|
||||
*/
|
||||
val patchedApk: File = paths.patchingWorkingDir()
|
||||
.resolve("patched.apk")
|
||||
|
||||
override val group = StepGroup.Patch
|
||||
override val localizedName = R.string.install_step_copy
|
||||
|
||||
override suspend fun execute(container: StepRunner) {
|
||||
val dir = paths.patchingWorkingDir()
|
||||
|
||||
// TODO: move this to a prepare step
|
||||
if (!dir.deleteRecursively())
|
||||
throw Error("Failed to clear existing patched dir")
|
||||
|
||||
val srcApk = container.getStep<DownloadDiscordStep>().targetFile
|
||||
|
||||
srcApk.copyTo(patchedApk)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,42 @@
|
|||
package com.aliucord.manager.installer.steps.patch
|
||||
|
||||
import com.aliucord.manager.R
|
||||
import com.aliucord.manager.installer.steps.StepGroup
|
||||
import com.aliucord.manager.installer.steps.StepRunner
|
||||
import com.aliucord.manager.installer.steps.base.Step
|
||||
import com.aliucord.manager.installer.util.ManifestPatcher
|
||||
import com.aliucord.manager.manager.PreferencesManager
|
||||
import com.github.diamondminer88.zip.ZipReader
|
||||
import com.github.diamondminer88.zip.ZipWriter
|
||||
import org.koin.core.component.KoinComponent
|
||||
import org.koin.core.component.inject
|
||||
|
||||
/**
|
||||
* Patch the APK's AndroidManifest.xml
|
||||
*/
|
||||
class PatchManifestStep : Step(), KoinComponent {
|
||||
private val prefs: PreferencesManager by inject()
|
||||
|
||||
override val group = StepGroup.Patch
|
||||
override val localizedName = R.string.install_step_patch_manifests
|
||||
|
||||
override suspend fun execute(container: StepRunner) {
|
||||
val apk = container.getStep<CopyDependenciesStep>().patchedApk
|
||||
|
||||
val manifest = ZipReader(apk)
|
||||
.use { zip -> zip.openEntry("AndroidManifest.xml")?.read() }
|
||||
?: throw IllegalArgumentException("No manifest found in APK")
|
||||
|
||||
val patchedManifest = ManifestPatcher.patchManifest(
|
||||
manifestBytes = manifest,
|
||||
packageName = prefs.packageName,
|
||||
appName = prefs.appName,
|
||||
debuggable = prefs.debuggable,
|
||||
)
|
||||
|
||||
ZipWriter(apk, /* append = */ true).use {
|
||||
it.deleteEntry("AndroidManifest.xml")
|
||||
it.writeEntry("AndroidManifest.xml", patchedManifest)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,56 @@
|
|||
package com.aliucord.manager.installer.steps.patch
|
||||
|
||||
import android.content.Context
|
||||
import androidx.compose.runtime.Stable
|
||||
import com.aliucord.manager.R
|
||||
import com.aliucord.manager.installer.steps.StepGroup
|
||||
import com.aliucord.manager.installer.steps.StepRunner
|
||||
import com.aliucord.manager.installer.steps.base.Step
|
||||
import com.aliucord.manager.installer.steps.base.StepState
|
||||
import com.aliucord.manager.manager.PreferencesManager
|
||||
import com.github.diamondminer88.zip.ZipWriter
|
||||
import org.koin.core.component.KoinComponent
|
||||
import org.koin.core.component.inject
|
||||
import java.io.InputStream
|
||||
|
||||
/**
|
||||
* Replace icons
|
||||
*/
|
||||
@Stable
|
||||
class ReplaceIconStep : Step(), KoinComponent {
|
||||
private val context: Context by inject()
|
||||
private val prefs: PreferencesManager by inject()
|
||||
|
||||
override val group = StepGroup.Patch
|
||||
override val localizedName = R.string.setting_replace_icon
|
||||
|
||||
override suspend fun execute(container: StepRunner) {
|
||||
if (!prefs.replaceIcon) {
|
||||
state = StepState.Skipped
|
||||
return
|
||||
}
|
||||
|
||||
val apk = container.getStep<CopyDependenciesStep>().patchedApk
|
||||
|
||||
ZipWriter(apk, /* append = */ true).use {
|
||||
val foregroundIcon = readAsset("icons/ic_logo_foreground.png")
|
||||
val squareIcon = readAsset("icons/ic_logo_square.png")
|
||||
|
||||
val replacements = mapOf(
|
||||
arrayOf("MbV.png", "kbF.png", "_eu.png", "EtS.png") to foregroundIcon,
|
||||
arrayOf("_h_.png", "9MB.png", "Dy7.png", "kC0.png", "oEH.png", "RG0.png", "ud_.png", "W_3.png") to squareIcon
|
||||
)
|
||||
|
||||
for ((files, replacement) in replacements) {
|
||||
for (file in files) {
|
||||
val path = "res/$file"
|
||||
it.deleteEntry(path)
|
||||
it.writeEntry(path, replacement)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun readAsset(fileName: String): ByteArray =
|
||||
context.assets.open(fileName).use(InputStream::readBytes)
|
||||
}
|
||||
|
|
@ -0,0 +1,54 @@
|
|||
package com.aliucord.manager.installer.steps.prepare
|
||||
|
||||
import android.content.Context
|
||||
import com.aliucord.manager.R
|
||||
import com.aliucord.manager.installer.steps.StepGroup
|
||||
import com.aliucord.manager.installer.steps.StepRunner
|
||||
import com.aliucord.manager.installer.steps.base.Step
|
||||
import com.aliucord.manager.installer.steps.base.StepState
|
||||
import com.aliucord.manager.installer.util.uninstallApk
|
||||
import com.aliucord.manager.manager.PreferencesManager
|
||||
import com.aliucord.manager.util.getPackageVersion
|
||||
import com.aliucord.manager.util.showToast
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.koin.core.component.KoinComponent
|
||||
import org.koin.core.component.inject
|
||||
|
||||
/**
|
||||
* Prompt the user to uninstall a previous version of Aliucord if it has a larger version code.
|
||||
* (Prevent conflicts from downgrading)
|
||||
*/
|
||||
class DowngradeCheckStep : Step(), KoinComponent {
|
||||
private val context: Context by inject()
|
||||
private val prefs: PreferencesManager by inject()
|
||||
|
||||
override val group = StepGroup.Prepare
|
||||
override val localizedName = R.string.install_step_downgrade_check
|
||||
|
||||
override suspend fun execute(container: StepRunner) {
|
||||
val (_, currentVersion) = try {
|
||||
context.getPackageVersion(prefs.packageName)
|
||||
}
|
||||
// Package is not installed
|
||||
catch (_: Throwable) {
|
||||
state = StepState.Skipped
|
||||
return
|
||||
}
|
||||
|
||||
val targetVersion = container
|
||||
.getStep<FetchInfoStep>()
|
||||
.data.versionCode.toIntOrNull()
|
||||
?: throw IllegalArgumentException("Invalid fetched Aliucord target Discord version")
|
||||
|
||||
if (currentVersion > targetVersion) {
|
||||
context.uninstallApk(prefs.packageName)
|
||||
|
||||
withContext(Dispatchers.Main) {
|
||||
context.showToast(R.string.installer_uninstall_new)
|
||||
}
|
||||
|
||||
throw Error("Newer version of Aliucord must be uninstalled prior to installing an older version")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,29 @@
|
|||
package com.aliucord.manager.installer.steps.prepare
|
||||
|
||||
import androidx.compose.runtime.Stable
|
||||
import com.aliucord.manager.R
|
||||
import com.aliucord.manager.installer.steps.StepGroup
|
||||
import com.aliucord.manager.installer.steps.StepRunner
|
||||
import com.aliucord.manager.installer.steps.base.Step
|
||||
import com.aliucord.manager.network.dto.Version
|
||||
import com.aliucord.manager.network.service.AliucordGithubService
|
||||
import com.aliucord.manager.network.utils.getOrThrow
|
||||
import org.koin.core.component.KoinComponent
|
||||
import org.koin.core.component.inject
|
||||
|
||||
@Stable
|
||||
class FetchInfoStep : Step(), KoinComponent {
|
||||
private val github: AliucordGithubService by inject()
|
||||
|
||||
override val group = StepGroup.Prepare
|
||||
override val localizedName = R.string.install_step_fetch_kt_version
|
||||
|
||||
/**
|
||||
* Fetched data about the latest Aliucord commit and supported Discord version.
|
||||
*/
|
||||
lateinit var data: Version
|
||||
|
||||
override suspend fun execute(container: StepRunner) {
|
||||
data = github.getDataJson().getOrThrow()
|
||||
}
|
||||
}
|
||||
|
|
@ -4,51 +4,37 @@ import android.app.Application
|
|||
import android.app.DownloadManager
|
||||
import android.database.Cursor
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.util.Log
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.core.content.getSystemService
|
||||
import com.aliucord.manager.BuildConfig
|
||||
import com.aliucord.manager.R
|
||||
import com.aliucord.manager.domain.repository.AliucordMavenRepository
|
||||
import com.aliucord.manager.network.service.AliucordGithubService
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.delay
|
||||
import java.io.File
|
||||
import kotlin.coroutines.cancellation.CancellationException
|
||||
|
||||
/**
|
||||
* Handle downloading remote urls to a file through the system [DownloadManager].
|
||||
* Handle downloading remote urls to a path through the system [DownloadManager].
|
||||
*/
|
||||
class DownloadManager(application: Application) {
|
||||
private val downloadManager = application.getSystemService<DownloadManager>()
|
||||
?: throw IllegalStateException("DownloadManager service is not available")
|
||||
|
||||
// Discord APK downloading
|
||||
suspend fun downloadDiscordApk(version: String, out: File): Result =
|
||||
download("${BuildConfig.BACKEND_URL}/download/discord?v=$version", out)
|
||||
|
||||
// Aliucord Kotlin downloads
|
||||
suspend fun downloadKtInjector(out: File): Result =
|
||||
download(AliucordGithubService.KT_INJECTOR_URL, out)
|
||||
|
||||
suspend fun downloadAliuhook(version: String, out: File): Result =
|
||||
download(AliucordMavenRepository.getAliuhookUrl(version), out)
|
||||
|
||||
suspend fun downloadKotlinDex(out: File): Result =
|
||||
download(AliucordGithubService.KOTLIN_DEX_URL, out)
|
||||
|
||||
/**
|
||||
* Start a cancellable download with the system [DownloadManager].
|
||||
* If the current [CoroutineScope] is cancelled, then the system download will be cancelled within 100ms.
|
||||
* @param url Remote src url
|
||||
* @param out Target path to download to
|
||||
* @param onProgressUpdate Download progress update in a `[0,1]` range, and if null then the download is currently in a pending state.
|
||||
* This is called every 100ms, and should not perform long-running tasks.
|
||||
* @param out Target path to download to. It is assumed that the application has write permissions to this path.
|
||||
* @param onProgressUpdate An optional [ProgressListener]
|
||||
*/
|
||||
suspend fun download(
|
||||
url: String,
|
||||
out: File,
|
||||
onProgressUpdate: ((Float?) -> Unit)? = null,
|
||||
onProgressUpdate: ProgressListener? = null,
|
||||
): Result {
|
||||
onProgressUpdate?.onUpdate(null)
|
||||
out.parentFile?.mkdirs()
|
||||
|
||||
// Create and start a download in the system DownloadManager
|
||||
|
|
@ -56,10 +42,15 @@ class DownloadManager(application: Application) {
|
|||
.setTitle("Aliucord Manager")
|
||||
.setDescription("Downloading ${out.name}...")
|
||||
.setDestinationUri(Uri.fromFile(out))
|
||||
.setAllowedOverMetered(true)
|
||||
.setAllowedOverRoaming(true)
|
||||
.setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE)
|
||||
.addRequestHeader("User-Agent", "Aliucord Manager/${BuildConfig.VERSION_NAME}")
|
||||
.apply {
|
||||
// Disable gzip on emulator due to https compression bug
|
||||
if (Build.FINGERPRINT.contains("emulator")) {
|
||||
Log.d(BuildConfig.TAG, "Disabling DownloadManager compression")
|
||||
addRequestHeader("Accept-Encoding", null)
|
||||
}
|
||||
}
|
||||
.let(downloadManager::enqueue)
|
||||
|
||||
// Repeatedly request download state until it is finished
|
||||
|
|
@ -90,13 +81,13 @@ class DownloadManager(application: Application) {
|
|||
|
||||
when (status) {
|
||||
DownloadManager.STATUS_PENDING, DownloadManager.STATUS_PAUSED ->
|
||||
onProgressUpdate?.invoke(null)
|
||||
onProgressUpdate?.onUpdate(null)
|
||||
|
||||
DownloadManager.STATUS_RUNNING ->
|
||||
onProgressUpdate?.invoke(getDownloadProgress(cursor))
|
||||
onProgressUpdate?.onUpdate(getDownloadProgress(cursor))
|
||||
|
||||
DownloadManager.STATUS_SUCCESSFUL ->
|
||||
return Result.Success
|
||||
return Result.Success(out)
|
||||
|
||||
DownloadManager.STATUS_FAILED -> {
|
||||
val reasonColumn = cursor.getColumnIndex(DownloadManager.COLUMN_REASON)
|
||||
|
|
@ -126,11 +117,27 @@ class DownloadManager(application: Application) {
|
|||
return bytes.toFloat() / totalBytes
|
||||
}
|
||||
|
||||
/**
|
||||
* A callback executed from a coroutine called every 100ms in order to provide
|
||||
* info about the current download. This should not perform long-running tasks as the delay will be offset.
|
||||
*/
|
||||
fun interface ProgressListener {
|
||||
/**
|
||||
* @param progress The current download progress in a `[0,1]` range. If null, then the download is either
|
||||
* paused, pending, or waiting to retry.
|
||||
*/
|
||||
fun onUpdate(progress: Float?)
|
||||
}
|
||||
|
||||
/**
|
||||
* The state of a download after execution has been completed and the system-level [DownloadManager] has been cleaned up.
|
||||
*/
|
||||
sealed interface Result {
|
||||
data object Success : Result
|
||||
/**
|
||||
* The download succeeded successfully.
|
||||
* @param file The path that the download was downloaded to.
|
||||
*/
|
||||
data class Success(val file: File) : Result
|
||||
|
||||
/**
|
||||
* This download was interrupted and the in-progress file has been deleted.
|
||||
|
|
|
|||
|
|
@ -0,0 +1,62 @@
|
|||
package com.aliucord.manager.manager
|
||||
|
||||
import android.content.Context
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* A central place to provide all system paths that are used.
|
||||
*/
|
||||
class PathManager(context: Context) {
|
||||
private val externalCacheDir = context.externalCacheDir
|
||||
?: throw Error("External cache directory isn't supported")
|
||||
|
||||
/**
|
||||
* Standard path: `~/Android/data/com.aliucord.manager/cache`
|
||||
*/
|
||||
private val discordApkCache = externalCacheDir
|
||||
.resolve("discord")
|
||||
|
||||
/**
|
||||
* Delete the entire cache dir and recreate it.
|
||||
*/
|
||||
fun clearCache() {
|
||||
if (!externalCacheDir.deleteRecursively())
|
||||
throw IllegalStateException("Failed to delete cache")
|
||||
|
||||
externalCacheDir.mkdirs()
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new subfolder in the Discord APK cache for a specific version.
|
||||
*/
|
||||
fun discordApkVersionCache(version: Int): File = discordApkCache
|
||||
.resolve(version.toString())
|
||||
.apply { mkdirs() }
|
||||
|
||||
/**
|
||||
* Resolve a specific path for a cached injector.
|
||||
*/
|
||||
fun cachedInjectorDex(aliucordHash: String) = externalCacheDir
|
||||
.resolve("injector").apply { mkdirs() }
|
||||
.resolve("$aliucordHash.dex")
|
||||
|
||||
/**
|
||||
* Resolve a specific path for a versioned cached Aliuhook build
|
||||
*/
|
||||
fun cachedAliuhookAAR(version: String) = externalCacheDir
|
||||
.resolve("aliuhook").apply { mkdirs() }
|
||||
.resolve("$version.aar")
|
||||
|
||||
/**
|
||||
* Singular Kotlin file of the most up-to-date version
|
||||
* since the stdlib is backwards compatible.
|
||||
*/
|
||||
fun cachedKotlinDex() = externalCacheDir
|
||||
.resolve("kotlin.dex")
|
||||
|
||||
/**
|
||||
* The temporary working directory of a currently executing patching process.
|
||||
*/
|
||||
fun patchingWorkingDir() = externalCacheDir
|
||||
.resolve("patched")
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,29 +5,18 @@
|
|||
|
||||
package com.aliucord.manager.ui.components.dialogs
|
||||
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import com.aliucord.manager.ui.screens.install.InstallData
|
||||
|
||||
enum class DownloadMethod { DOWNLOAD }
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.SideEffect
|
||||
|
||||
@Composable
|
||||
fun InstallerDialog(
|
||||
onDismiss: () -> Unit,
|
||||
onConfirm: (InstallData) -> Unit,
|
||||
onConfirm: () -> Unit,
|
||||
) {
|
||||
val downloadMethod by rememberSaveable { mutableStateOf(DownloadMethod.DOWNLOAD) }
|
||||
|
||||
fun triggerConfirm() {
|
||||
onDismiss()
|
||||
onConfirm(
|
||||
InstallData(downloadMethod)
|
||||
)
|
||||
}
|
||||
|
||||
SideEffect(::triggerConfirm)
|
||||
SideEffect(onConfirm)
|
||||
|
||||
// TODO: local install option
|
||||
// TODO: mobile data warning
|
||||
|
||||
// Dialog(
|
||||
// onDismissRequest = onDismiss,
|
||||
|
|
|
|||
|
|
@ -1,194 +0,0 @@
|
|||
package com.aliucord.manager.ui.components.installer
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.text.font.FontStyle
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.*
|
||||
import com.aliucord.manager.R
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlin.math.floor
|
||||
|
||||
enum class InstallStatus {
|
||||
ONGOING,
|
||||
SUCCESSFUL,
|
||||
UNSUCCESSFUL,
|
||||
QUEUED
|
||||
}
|
||||
|
||||
@Stable
|
||||
class InstallStepData(
|
||||
val nameResId: Int,
|
||||
status: InstallStatus,
|
||||
duration: Float = 0f,
|
||||
cached: Boolean = false,
|
||||
) {
|
||||
var status by mutableStateOf(status)
|
||||
var duration by mutableStateOf(duration)
|
||||
var cached by mutableStateOf(cached)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun InstallGroup(
|
||||
name: String,
|
||||
isCurrent: Boolean,
|
||||
subSteps: ImmutableList<InstallStepData>,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
val status = when {
|
||||
subSteps.all { it.status == InstallStatus.QUEUED } ->
|
||||
InstallStatus.QUEUED
|
||||
|
||||
subSteps.all { it.status == InstallStatus.SUCCESSFUL } ->
|
||||
InstallStatus.SUCCESSFUL
|
||||
|
||||
subSteps.any { it.status == InstallStatus.ONGOING } ->
|
||||
InstallStatus.ONGOING
|
||||
|
||||
else -> InstallStatus.UNSUCCESSFUL
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(16.dp))
|
||||
.run {
|
||||
if (isCurrent) {
|
||||
background(MaterialTheme.colorScheme.surfaceColorAtElevation(1.dp))
|
||||
} else this
|
||||
}
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
modifier = Modifier
|
||||
.clickable(true, onClick = onClick)
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp)
|
||||
) {
|
||||
StepIcon(status, 24.dp)
|
||||
|
||||
Text(text = name)
|
||||
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
|
||||
if (status != InstallStatus.ONGOING && status != InstallStatus.QUEUED) Text(
|
||||
"%.2fs".format(subSteps.map { it.duration }.sum()),
|
||||
style = MaterialTheme.typography.labelMedium
|
||||
)
|
||||
|
||||
if (isCurrent) {
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.ic_arrow_up_small),
|
||||
contentDescription = stringResource(R.string.action_collapse)
|
||||
)
|
||||
} else {
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.ic_arrow_down_small),
|
||||
contentDescription = stringResource(R.string.action_expand)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
AnimatedVisibility(visible = isCurrent) {
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
modifier = Modifier
|
||||
.background(MaterialTheme.colorScheme.background.copy(0.6f))
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp)
|
||||
.padding(start = 4.dp)
|
||||
) {
|
||||
subSteps.forEach {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
StepIcon(it.status, size = 18.dp)
|
||||
|
||||
Text(
|
||||
text = stringResource(it.nameResId),
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.weight(1f, true),
|
||||
)
|
||||
|
||||
if (it.status != InstallStatus.ONGOING && it.status != InstallStatus.QUEUED) {
|
||||
if (it.cached) {
|
||||
val style = MaterialTheme.typography.labelSmall.copy(
|
||||
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f),
|
||||
fontStyle = FontStyle.Italic,
|
||||
fontSize = 11.sp
|
||||
)
|
||||
Text(
|
||||
text = stringResource(R.string.installer_cached),
|
||||
style = style,
|
||||
maxLines = 1,
|
||||
)
|
||||
}
|
||||
|
||||
Text(
|
||||
text = "%.2fs".format(it.duration),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
maxLines = 1,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun StepIcon(status: InstallStatus, size: Dp) {
|
||||
val strokeWidth = Dp(floor(size.value / 10) + 1)
|
||||
val context = LocalContext.current
|
||||
|
||||
when (status) {
|
||||
InstallStatus.ONGOING -> CircularProgressIndicator(
|
||||
strokeWidth = strokeWidth,
|
||||
modifier = Modifier
|
||||
.size(size)
|
||||
.semantics {
|
||||
contentDescription = context.getString(R.string.status_ongoing)
|
||||
}
|
||||
)
|
||||
|
||||
InstallStatus.SUCCESSFUL -> Icon(
|
||||
painter = painterResource(R.drawable.ic_check_circle),
|
||||
contentDescription = stringResource(R.string.status_success),
|
||||
tint = Color(0xFF59B463),
|
||||
modifier = Modifier.size(size)
|
||||
)
|
||||
|
||||
InstallStatus.UNSUCCESSFUL -> Icon(
|
||||
painter = painterResource(R.drawable.ic_canceled),
|
||||
contentDescription = stringResource(R.string.status_failed),
|
||||
tint = MaterialTheme.colorScheme.error,
|
||||
modifier = Modifier.size(size)
|
||||
)
|
||||
|
||||
InstallStatus.QUEUED -> Icon(
|
||||
painter = painterResource(R.drawable.ic_circle),
|
||||
contentDescription = stringResource(R.string.status_queued),
|
||||
tint = MaterialTheme.colorScheme.onSurface.copy(0.4f),
|
||||
modifier = Modifier.size(size)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -8,9 +8,9 @@ import cafe.adriel.voyager.core.model.ScreenModel
|
|||
import cafe.adriel.voyager.core.model.screenModelScope
|
||||
import com.aliucord.manager.BuildConfig
|
||||
import com.aliucord.manager.R
|
||||
import com.aliucord.manager.manager.PreferencesManager
|
||||
import com.aliucord.manager.domain.repository.GithubRepository
|
||||
import com.aliucord.manager.installer.util.uninstallApk
|
||||
import com.aliucord.manager.manager.PreferencesManager
|
||||
import com.aliucord.manager.network.utils.fold
|
||||
import com.aliucord.manager.ui.util.DiscordVersion
|
||||
import com.aliucord.manager.util.getPackageVersion
|
||||
|
|
|
|||
|
|
@ -46,9 +46,9 @@ class HomeScreen : Screen {
|
|||
if (showInstallerDialog) {
|
||||
InstallerDialog(
|
||||
onDismiss = { showInstallerDialog = false },
|
||||
onConfirm = { data ->
|
||||
onConfirm = {
|
||||
showInstallerDialog = false
|
||||
navigator.push(InstallScreen(data))
|
||||
navigator.push(InstallScreen())
|
||||
}
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,416 +4,131 @@ import android.annotation.SuppressLint
|
|||
import android.app.Application
|
||||
import android.os.Build
|
||||
import android.util.Log
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.compose.runtime.*
|
||||
import cafe.adriel.voyager.core.model.ScreenModel
|
||||
import cafe.adriel.voyager.core.model.StateScreenModel
|
||||
import cafe.adriel.voyager.core.model.screenModelScope
|
||||
import com.aliucord.manager.BuildConfig
|
||||
import com.aliucord.manager.R
|
||||
import com.aliucord.manager.manager.DownloadManager
|
||||
import com.aliucord.manager.manager.PreferencesManager
|
||||
import com.aliucord.manager.domain.repository.AliucordMavenRepository
|
||||
import com.aliucord.manager.domain.repository.GithubRepository
|
||||
import com.aliucord.manager.installer.util.*
|
||||
import com.aliucord.manager.network.utils.getOrThrow
|
||||
import com.aliucord.manager.ui.components.installer.InstallStatus
|
||||
import com.aliucord.manager.ui.components.installer.InstallStepData
|
||||
import com.aliucord.manager.installer.steps.KotlinInstallRunner
|
||||
import com.aliucord.manager.installer.steps.StepGroup
|
||||
import com.aliucord.manager.installer.steps.base.Step
|
||||
import com.aliucord.manager.manager.PathManager
|
||||
import com.aliucord.manager.ui.util.toUnsafeImmutable
|
||||
import com.aliucord.manager.util.*
|
||||
import com.github.diamondminer88.zip.*
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.ImmutableMap
|
||||
import kotlinx.coroutines.*
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import kotlin.time.measureTimedValue
|
||||
|
||||
class InstallModel(
|
||||
private val application: Application,
|
||||
private val downloadManager: DownloadManager,
|
||||
private val preferences: PreferencesManager,
|
||||
private val githubRepository: GithubRepository,
|
||||
private val aliucordMaven: AliucordMavenRepository,
|
||||
private val installData: InstallData,
|
||||
) : ScreenModel {
|
||||
private val externalCacheDir = application.externalCacheDir!!
|
||||
private val installationRunning = AtomicBoolean(false)
|
||||
private val paths: PathManager,
|
||||
) : StateScreenModel<InstallScreenState>(InstallScreenState.Pending) {
|
||||
private lateinit var startTime: Date
|
||||
private var installJob: Job? = null
|
||||
|
||||
var returnToHome by mutableStateOf(false)
|
||||
|
||||
var isFinished by mutableStateOf(false)
|
||||
var installSteps by mutableStateOf<ImmutableMap<StepGroup, ImmutableList<Step>>?>(null)
|
||||
private set
|
||||
|
||||
var stacktrace by mutableStateOf("")
|
||||
private set
|
||||
|
||||
private val debugInfo: String
|
||||
get() = """
|
||||
Aliucord Manager ${BuildConfig.VERSION_NAME}
|
||||
Built from commit ${BuildConfig.GIT_COMMIT} on ${BuildConfig.GIT_BRANCH} ${if (BuildConfig.GIT_LOCAL_CHANGES || BuildConfig.GIT_LOCAL_COMMITS) "(Changes present)" else ""}
|
||||
|
||||
Running Android ${Build.VERSION.RELEASE}, API level ${Build.VERSION.SDK_INT}
|
||||
Supported ABIs: ${Build.SUPPORTED_ABIS.joinToString()}
|
||||
|
||||
Installing Aliucord kt with the ${installData.downloadMethod} apk method
|
||||
|
||||
Failed on: ${currentStep?.name}
|
||||
""".trimIndent()
|
||||
init {
|
||||
restart()
|
||||
}
|
||||
|
||||
fun copyDebugToClipboard() {
|
||||
val text = "$debugInfo\n\n$stacktrace"
|
||||
// TODO: remove this useless replace
|
||||
.replace("(\\\\*~_)".toRegex(), "\\$1")
|
||||
val content = (state.value as? InstallScreenState.Failed)?.failureLog
|
||||
?: return
|
||||
|
||||
application.copyToClipboard(text)
|
||||
application.copyToClipboard(content)
|
||||
application.showToast(R.string.action_copied)
|
||||
}
|
||||
|
||||
private var debugLogPath by mutableStateOf<String?>(null)
|
||||
fun saveFailureLog() {
|
||||
val failureLog = (state.value as? InstallScreenState.Failed)?.failureLog
|
||||
?: return
|
||||
|
||||
@SuppressLint("SimpleDateFormat")
|
||||
fun saveDebugToFile() {
|
||||
val name = if (debugLogPath != null) {
|
||||
debugLogPath!!
|
||||
} else {
|
||||
"Aliucord Manager ${SimpleDateFormat("yyyy-MM-dd hh-mm-s a").format(Date())}.log"
|
||||
.also { debugLogPath = it }
|
||||
}
|
||||
@SuppressLint("SimpleDateFormat")
|
||||
val formattedDate = SimpleDateFormat("yyyy-MM-dd hh-mm-s a").format(startTime)
|
||||
val fileName = "Aliucord Manager $formattedDate.log"
|
||||
|
||||
application.saveFile(name, "$debugInfo\n\n$stacktrace")
|
||||
application.saveFile(fileName, failureLog)
|
||||
}
|
||||
|
||||
fun clearCache() {
|
||||
externalCacheDir.deleteRecursively()
|
||||
paths.clearCache()
|
||||
application.showToast(R.string.action_cleared_cache)
|
||||
}
|
||||
|
||||
private val installJob = screenModelScope.launch(Dispatchers.Main) {
|
||||
if (installationRunning.getAndSet(true)) {
|
||||
return@launch
|
||||
}
|
||||
fun restart() {
|
||||
installJob?.cancel("Manual cancellation")
|
||||
installSteps = null
|
||||
|
||||
withContext(Dispatchers.IO) {
|
||||
try {
|
||||
installKotlin()
|
||||
startTime = Date()
|
||||
mutableState.value = InstallScreenState.Working
|
||||
|
||||
isFinished = true
|
||||
delay(20000)
|
||||
returnToHome = true
|
||||
} catch (t: Throwable) {
|
||||
stacktrace = Log.getStackTraceString(t)
|
||||
val newInstallJob = screenModelScope.launch {
|
||||
val runner = KotlinInstallRunner()
|
||||
|
||||
Log.e(
|
||||
BuildConfig.TAG,
|
||||
"$debugInfo\n\n${Log.getStackTraceString(t)}"
|
||||
)
|
||||
}
|
||||
installSteps = runner.steps.groupBy { it.group }
|
||||
.mapValues { it.value.toUnsafeImmutable() }
|
||||
.toUnsafeImmutable()
|
||||
|
||||
installationRunning.set(false)
|
||||
}
|
||||
}
|
||||
// Intentionally delay to show the state change of the first step in UI when it runs
|
||||
// without it, on a fast internet it just immediately shows as "Success"
|
||||
delay(600)
|
||||
|
||||
private fun clearOldCache(targetVersion: Int) {
|
||||
externalCacheDir.listFiles { f -> f.isDirectory }
|
||||
?.map { it.name.toIntOrNull() to it }
|
||||
?.filter { it.first != null }
|
||||
?.filter { it.first!! in (126021 + 1) until targetVersion }
|
||||
?.forEach { it.second.deleteRecursively() }
|
||||
}
|
||||
// Execute all the steps and catch any errors
|
||||
when (val error = runner.executeAll()) {
|
||||
// Successfully installed
|
||||
null -> {
|
||||
mutableState.value = InstallScreenState.Success
|
||||
|
||||
private suspend fun uninstallNewAliucord(targetVersion: Int) {
|
||||
val (_, versionCode) = try {
|
||||
application.getPackageVersion(preferences.packageName)
|
||||
} catch (t: Throwable) {
|
||||
return
|
||||
}
|
||||
|
||||
if (targetVersion < versionCode) {
|
||||
application.uninstallApk(preferences.packageName)
|
||||
|
||||
withContext(Dispatchers.Main) {
|
||||
application.showToast(R.string.installer_uninstall_new)
|
||||
}
|
||||
|
||||
throw Error("Pleaser uninstall newer Aliucord prior to installing")
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDispose() {
|
||||
if (installationRunning.getAndSet(false)) {
|
||||
installJob.cancel("ViewModel cleared")
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun installKotlin() {
|
||||
steps += listOfNotNull(
|
||||
InstallStep.FETCH_KT_VERSION,
|
||||
InstallStep.DL_KT_APK,
|
||||
InstallStep.DL_KOTLIN,
|
||||
InstallStep.DL_INJECTOR,
|
||||
InstallStep.DL_ALIUHOOK,
|
||||
if (preferences.replaceIcon) InstallStep.PATCH_APP_ICON else null,
|
||||
InstallStep.PATCH_MANIFEST,
|
||||
InstallStep.PATCH_DEX,
|
||||
InstallStep.PATCH_LIBS,
|
||||
InstallStep.SIGN_APK,
|
||||
InstallStep.INSTALL_APK,
|
||||
).map {
|
||||
it to InstallStepData(it.nameResId, InstallStatus.QUEUED)
|
||||
}
|
||||
|
||||
val dataJson = step(InstallStep.FETCH_KT_VERSION) {
|
||||
githubRepository.getDataJson().getOrThrow()
|
||||
}
|
||||
|
||||
val arch = Build.SUPPORTED_ABIS.first()
|
||||
val cacheDir = externalCacheDir
|
||||
val discordCacheDir = externalCacheDir.resolve(dataJson.versionCode)
|
||||
val patchedDir = discordCacheDir.resolve("patched").also { it.deleteRecursively() }
|
||||
|
||||
dataJson.versionCode.toInt().also {
|
||||
clearOldCache(it)
|
||||
uninstallNewAliucord(it)
|
||||
}
|
||||
|
||||
// Download base.apk
|
||||
val baseApkFile = step(InstallStep.DL_KT_APK) {
|
||||
discordCacheDir.resolve("base.apk").let { file ->
|
||||
if (file.exists()) {
|
||||
cached = true
|
||||
} else {
|
||||
downloadManager.downloadDiscordApk(dataJson.versionCode, file)
|
||||
// Wait 20s before returning to Home
|
||||
delay(20_000)
|
||||
mutableState.value = InstallScreenState.CloseScreen
|
||||
}
|
||||
|
||||
file.copyTo(
|
||||
patchedDir.resolve(file.name),
|
||||
true
|
||||
)
|
||||
}
|
||||
}
|
||||
else -> {
|
||||
Log.e(BuildConfig.TAG, "Failed to perform installation process", error)
|
||||
|
||||
val kotlinFile = step(InstallStep.DL_KOTLIN) {
|
||||
cacheDir.resolve("kotlin.dex").also { file ->
|
||||
if (file.exists()) {
|
||||
cached = true
|
||||
} else {
|
||||
downloadManager.downloadKotlinDex(file)
|
||||
mutableState.value = InstallScreenState.Failed(failureLog = getFailureInfo(error))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Download the injector dex
|
||||
val injectorFile = step(InstallStep.DL_INJECTOR) {
|
||||
cacheDir.resolve("injector-${dataJson.aliucordHash}.dex").also { file ->
|
||||
if (file.exists()) {
|
||||
cached = true
|
||||
} else {
|
||||
downloadManager.downloadKtInjector(file)
|
||||
}
|
||||
}
|
||||
}
|
||||
newInstallJob.invokeOnCompletion { error ->
|
||||
when (error) {
|
||||
// Successfully executed, already handled above
|
||||
null -> {}
|
||||
|
||||
// Download Aliuhook aar
|
||||
val aliuhookAarFile = step(InstallStep.DL_ALIUHOOK) {
|
||||
// Fetch aliuhook version
|
||||
val aliuhookVersion = aliucordMaven.getAliuhookVersion().getOrThrow()
|
||||
|
||||
// Download aliuhook aar
|
||||
cacheDir.resolve("aliuhook-${aliuhookVersion}.aar").also { file ->
|
||||
if (file.exists()) {
|
||||
cached = true
|
||||
} else {
|
||||
downloadManager.downloadAliuhook(aliuhookVersion, file)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Replace app icons
|
||||
if (preferences.replaceIcon) {
|
||||
step(InstallStep.PATCH_APP_ICON) {
|
||||
ZipWriter(baseApkFile, true).use { baseApk ->
|
||||
val foregroundIcon = application.assets.open("icons/ic_logo_foreground.png")
|
||||
.use { it.readBytes() }
|
||||
val squareIcon = application.assets.open("icons/ic_logo_square.png")
|
||||
.use { it.readBytes() }
|
||||
|
||||
val replacements = mapOf(
|
||||
arrayOf("MbV.png", "kbF.png", "_eu.png", "EtS.png") to foregroundIcon,
|
||||
arrayOf("_h_.png", "9MB.png", "Dy7.png", "kC0.png", "oEH.png", "RG0.png", "ud_.png", "W_3.png") to squareIcon
|
||||
)
|
||||
|
||||
for ((files, replacement) in replacements) {
|
||||
for (file in files) {
|
||||
val path = "res/$file"
|
||||
baseApk.deleteEntry(path)
|
||||
baseApk.writeEntry(path, replacement)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Patch manifests
|
||||
step(InstallStep.PATCH_MANIFEST) {
|
||||
val manifest = ZipReader(baseApkFile)
|
||||
.use { zip -> zip.openEntry("AndroidManifest.xml")?.read() }
|
||||
?: throw IllegalStateException("No manifest in base apk")
|
||||
|
||||
ZipWriter(baseApkFile, true).use { zip ->
|
||||
val patchedManifestBytes = ManifestPatcher.patchManifest(
|
||||
manifestBytes = manifest,
|
||||
packageName = preferences.packageName,
|
||||
appName = preferences.appName,
|
||||
debuggable = preferences.debuggable,
|
||||
)
|
||||
|
||||
zip.deleteEntry("AndroidManifest.xml")
|
||||
zip.writeEntry("AndroidManifest.xml", patchedManifestBytes)
|
||||
}
|
||||
}
|
||||
|
||||
// Re-order dex files
|
||||
val dexCount = step(InstallStep.PATCH_DEX) {
|
||||
val (dexCount, firstDexBytes) = ZipReader(baseApkFile).use { zip ->
|
||||
Pair(
|
||||
// Find the amount of .dex files in apk
|
||||
zip.entryNames.count { it.endsWith(".dex") },
|
||||
|
||||
// Get the first dex
|
||||
zip.openEntry("classes.dex")?.read()
|
||||
?: throw IllegalStateException("No classes.dex in base apk")
|
||||
)
|
||||
}
|
||||
|
||||
ZipWriter(baseApkFile, true).use { zip ->
|
||||
// Move copied dex to end of dex list
|
||||
zip.deleteEntry("classes.dex")
|
||||
zip.writeEntry("classes${dexCount + 1}.dex", firstDexBytes)
|
||||
|
||||
// Add Kotlin & Aliucord's dex
|
||||
zip.writeEntry("classes.dex", injectorFile.readBytes())
|
||||
zip.writeEntry("classes${dexCount + 2}.dex", kotlinFile.readBytes())
|
||||
}
|
||||
|
||||
dexCount
|
||||
}
|
||||
|
||||
// Replace libs
|
||||
step(InstallStep.PATCH_LIBS) {
|
||||
ZipWriter(baseApkFile, true).use { baseApk ->
|
||||
ZipReader(aliuhookAarFile).use { aliuhookAar ->
|
||||
for (libFile in arrayOf("libaliuhook.so", "libc++_shared.so", "liblsplant.so")) {
|
||||
val bytes = aliuhookAar.openEntry("jni/$arch/$libFile")?.read()
|
||||
?: throw IllegalStateException("Failed to read $libFile from aliuhook aar")
|
||||
|
||||
baseApk.writeEntry("lib/$arch/$libFile", bytes)
|
||||
}
|
||||
|
||||
// Add Aliuhook's dex file
|
||||
val aliuhookDex = aliuhookAar.openEntry("classes.dex")?.read()
|
||||
?: throw IllegalStateException("No classes.dex in aliuhook aar")
|
||||
|
||||
baseApk.writeEntry("classes${dexCount + 3}.dex", aliuhookDex)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
step(InstallStep.SIGN_APK) {
|
||||
// Align resources.arsc due to targeting api 30 for silent install
|
||||
if (Build.VERSION.SDK_INT >= 31) {
|
||||
val bytes = ZipReader(baseApkFile).use {
|
||||
if (it.entryNames.contains("resources.arsc")) {
|
||||
it.openEntry("resources.arsc")?.read()
|
||||
} else {
|
||||
null
|
||||
}
|
||||
// Job was cancelled before being able to finish setting state
|
||||
is CancellationException -> {
|
||||
Log.w(BuildConfig.TAG, "Installation was cancelled before completing", error)
|
||||
mutableState.value = InstallScreenState.CloseScreen
|
||||
}
|
||||
|
||||
ZipWriter(baseApkFile, true).use {
|
||||
it.deleteEntry("resources.arsc", true)
|
||||
it.writeEntry("resources.arsc", bytes, ZipCompression.NONE, 4096)
|
||||
}
|
||||
}
|
||||
|
||||
Signer.signApk(baseApkFile)
|
||||
}
|
||||
|
||||
step(InstallStep.INSTALL_APK) {
|
||||
application.installApks(silent = !preferences.devMode, baseApkFile)
|
||||
|
||||
if (!preferences.keepPatchedApks) {
|
||||
patchedDir.deleteRecursively()
|
||||
// This should never happen, all install errors are caught
|
||||
else -> throw error
|
||||
}
|
||||
}
|
||||
|
||||
installJob = newInstallJob
|
||||
}
|
||||
|
||||
private inline fun <T> step(step: InstallStep, block: InstallStepData.() -> T): T {
|
||||
steps[step]!!.status = InstallStatus.ONGOING
|
||||
currentStep = step
|
||||
private fun getFailureInfo(stacktrace: Throwable): String {
|
||||
val gitChanges = if (BuildConfig.GIT_LOCAL_CHANGES || BuildConfig.GIT_LOCAL_COMMITS) "(Changes present)" else ""
|
||||
val soc = if (Build.VERSION.SDK_INT >= 31) (Build.SOC_MANUFACTURER + ' ' + Build.SOC_MODEL) else "Unknown"
|
||||
|
||||
try {
|
||||
val value = measureTimedValue { block.invoke(steps[step]!!) }
|
||||
val millis = value.duration.inWholeMilliseconds
|
||||
val header = """
|
||||
Aliucord Manager v${BuildConfig.VERSION_NAME}
|
||||
Built from commit ${BuildConfig.GIT_COMMIT} on ${BuildConfig.GIT_BRANCH} $gitChanges
|
||||
|
||||
// Add delay for human psychology + groups are switched too fast
|
||||
if (!preferences.devMode && millis < 1000) {
|
||||
Thread.sleep(1000 - millis)
|
||||
}
|
||||
Running Android ${Build.VERSION.RELEASE}, API level ${Build.VERSION.SDK_INT}
|
||||
Supported ABIs: ${Build.SUPPORTED_ABIS.joinToString()}
|
||||
Device: ${Build.MANUFACTURER} - ${Build.MODEL} (${Build.DEVICE})
|
||||
SOC: $soc
|
||||
""".trimIndent()
|
||||
|
||||
steps[step]!!.apply {
|
||||
duration = millis.div(1000f)
|
||||
status = InstallStatus.SUCCESSFUL
|
||||
}
|
||||
|
||||
currentStep = step
|
||||
return value.value
|
||||
} catch (t: Throwable) {
|
||||
steps[step]!!.status = InstallStatus.UNSUCCESSFUL
|
||||
|
||||
currentStep = step
|
||||
throw t
|
||||
}
|
||||
}
|
||||
|
||||
enum class InstallStepGroup(
|
||||
@StringRes
|
||||
val nameResId: Int,
|
||||
) {
|
||||
APK_DL(R.string.install_group_apk_dl),
|
||||
LIB_DL(R.string.install_group_lib_dl),
|
||||
PATCHING(R.string.install_group_patch),
|
||||
INSTALLING(R.string.install_group_install)
|
||||
}
|
||||
|
||||
// Order matters, define it in the same order as it is patched
|
||||
enum class InstallStep(
|
||||
val group: InstallStepGroup,
|
||||
|
||||
@StringRes
|
||||
val nameResId: Int,
|
||||
) {
|
||||
// Kotlin
|
||||
FETCH_KT_VERSION(InstallStepGroup.APK_DL, R.string.install_step_fetch_kt_version),
|
||||
DL_KT_APK(InstallStepGroup.APK_DL, R.string.install_step_dl_kt_apk),
|
||||
DL_KOTLIN(InstallStepGroup.LIB_DL, R.string.install_step_dl_kotlin),
|
||||
DL_INJECTOR(InstallStepGroup.LIB_DL, R.string.install_step_dl_injector),
|
||||
DL_ALIUHOOK(InstallStepGroup.LIB_DL, R.string.install_step_dl_aliuhook),
|
||||
|
||||
// Common
|
||||
PATCH_APP_ICON(InstallStepGroup.PATCHING, R.string.install_step_patch_icons),
|
||||
PATCH_MANIFEST(InstallStepGroup.PATCHING, R.string.install_step_patch_manifests),
|
||||
PATCH_DEX(InstallStepGroup.PATCHING, R.string.install_step_patch_dex),
|
||||
PATCH_LIBS(InstallStepGroup.PATCHING, R.string.install_step_patch_libs),
|
||||
SIGN_APK(InstallStepGroup.INSTALLING, R.string.install_step_signing),
|
||||
INSTALL_APK(InstallStepGroup.INSTALLING, R.string.install_step_installing);
|
||||
}
|
||||
|
||||
var currentStep: InstallStep? by mutableStateOf(null)
|
||||
val steps = mutableStateMapOf<InstallStep, InstallStepData>()
|
||||
|
||||
// TODO: cache this instead
|
||||
fun getSteps(group: InstallStepGroup): List<InstallStepData> {
|
||||
return steps
|
||||
.filterKeys { it.group == group }.entries
|
||||
.sortedBy { it.key.ordinal }
|
||||
.map { it.value }
|
||||
return header + "\n\n" + Log.getStackTraceString(stacktrace)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,7 +5,6 @@
|
|||
|
||||
package com.aliucord.manager.ui.screens.install
|
||||
|
||||
import android.os.Parcelable
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.*
|
||||
import androidx.compose.foundation.layout.*
|
||||
|
|
@ -25,44 +24,39 @@ import cafe.adriel.voyager.koin.getScreenModel
|
|||
import cafe.adriel.voyager.navigator.LocalNavigator
|
||||
import cafe.adriel.voyager.navigator.currentOrThrow
|
||||
import com.aliucord.manager.R
|
||||
import com.aliucord.manager.ui.components.BackButton
|
||||
import com.aliucord.manager.installer.steps.StepGroup
|
||||
import com.aliucord.manager.ui.components.back
|
||||
import com.aliucord.manager.ui.components.dialogs.DownloadMethod
|
||||
import com.aliucord.manager.ui.components.dialogs.InstallerAbortDialog
|
||||
import com.aliucord.manager.ui.components.installer.InstallGroup
|
||||
import com.aliucord.manager.ui.components.installer.InstallStatus
|
||||
import com.aliucord.manager.ui.screens.install.InstallModel.InstallStepGroup
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
import kotlinx.parcelize.Parcelize
|
||||
import org.koin.core.parameter.parametersOf
|
||||
import com.aliucord.manager.ui.screens.install.components.StepGroupCard
|
||||
|
||||
@Immutable // this isn't *really* stable, but this never gets modified after being passed to a composable, so...
|
||||
@Parcelize
|
||||
data class InstallData(
|
||||
val downloadMethod: DownloadMethod,
|
||||
var baseApk: String? = null,
|
||||
var splits: List<String>? = null,
|
||||
) : Parcelable
|
||||
|
||||
class InstallScreen(val data: InstallData) : Screen {
|
||||
class InstallScreen : Screen {
|
||||
override val key = "Install"
|
||||
|
||||
@Composable
|
||||
override fun Content() {
|
||||
val navigator = LocalNavigator.currentOrThrow
|
||||
val model = getScreenModel<InstallModel>(parameters = { parametersOf(data) })
|
||||
val model = getScreenModel<InstallModel>()
|
||||
val state = model.state.collectAsState()
|
||||
|
||||
var expandedGroup by remember { mutableStateOf<InstallStepGroup?>(null) }
|
||||
|
||||
if (model.returnToHome)
|
||||
navigator.back(null)
|
||||
|
||||
LaunchedEffect(model.currentStep) {
|
||||
expandedGroup = model.currentStep?.group
|
||||
LaunchedEffect(model.state) {
|
||||
if (model.state.value is InstallScreenState.CloseScreen)
|
||||
navigator.back(currentActivity = null)
|
||||
}
|
||||
|
||||
// Exit warning dialog (dismiss itself if install process state changes, esp. for Success)
|
||||
var showAbortWarning by remember(model.state.collectAsState()) { mutableStateOf(false) }
|
||||
|
||||
// Only show exit warning if currently working
|
||||
val onTryExit: () -> Unit = remember {
|
||||
{
|
||||
if (state.value == InstallScreenState.Working) {
|
||||
showAbortWarning = true
|
||||
} else {
|
||||
navigator.back(currentActivity = null)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Exit warning dialog
|
||||
var showAbortWarning by remember { mutableStateOf(false) }
|
||||
if (showAbortWarning) {
|
||||
InstallerAbortDialog(
|
||||
onDismiss = { showAbortWarning = false },
|
||||
|
|
@ -72,9 +66,7 @@ class InstallScreen(val data: InstallData) : Screen {
|
|||
},
|
||||
)
|
||||
} else {
|
||||
BackHandler {
|
||||
showAbortWarning = true
|
||||
}
|
||||
BackHandler(onBack = onTryExit)
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
|
|
@ -82,9 +74,7 @@ class InstallScreen(val data: InstallData) : Screen {
|
|||
TopAppBar(
|
||||
title = { Text(stringResource(R.string.installer)) },
|
||||
navigationIcon = {
|
||||
IconButton(
|
||||
onClick = { showAbortWarning = true },
|
||||
) {
|
||||
IconButton(onClick = onTryExit) {
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.ic_back),
|
||||
contentDescription = stringResource(R.string.navigation_back),
|
||||
|
|
@ -95,13 +85,7 @@ class InstallScreen(val data: InstallData) : Screen {
|
|||
}
|
||||
) { paddingValues ->
|
||||
Column(Modifier.padding(paddingValues)) {
|
||||
val isCurrentlyProcessing by remember {
|
||||
derivedStateOf {
|
||||
model.steps[model.currentStep]?.status == InstallStatus.ONGOING
|
||||
}
|
||||
}
|
||||
|
||||
if (isCurrentlyProcessing) {
|
||||
if (state.value is InstallScreenState.Working) {
|
||||
LinearProgressIndicator(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
|
|
@ -117,16 +101,26 @@ class InstallScreen(val data: InstallData) : Screen {
|
|||
.fillMaxWidth()
|
||||
.padding(16.dp)
|
||||
) {
|
||||
for (group in InstallStepGroup.entries) key(group) {
|
||||
InstallGroup(
|
||||
name = stringResource(group.nameResId),
|
||||
isCurrent = expandedGroup == group,
|
||||
onClick = { expandedGroup = group },
|
||||
subSteps = model.getSteps(group).toImmutableList(),
|
||||
)
|
||||
var expandedGroup by remember { mutableStateOf<StepGroup?>(StepGroup.Prepare) }
|
||||
|
||||
// Close all groups when successfully finished everything
|
||||
LaunchedEffect(state.value) {
|
||||
if (state.value == InstallScreenState.Success)
|
||||
expandedGroup = null
|
||||
}
|
||||
|
||||
if (model.isFinished && model.stacktrace.isEmpty()) {
|
||||
model.installSteps?.let { groupedSteps ->
|
||||
for ((group, steps) in groupedSteps.entries) key(group) {
|
||||
StepGroupCard(
|
||||
name = stringResource(group.localizedName),
|
||||
subSteps = steps,
|
||||
isExpanded = expandedGroup == group,
|
||||
onExpand = { expandedGroup = group },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (state.value.isFinished) {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.End,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
|
|
@ -137,20 +131,8 @@ class InstallScreen(val data: InstallData) : Screen {
|
|||
}
|
||||
}
|
||||
|
||||
if (model.stacktrace.isNotEmpty()) {
|
||||
SelectionContainer {
|
||||
Text(
|
||||
text = model.stacktrace,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
softWrap = false,
|
||||
modifier = Modifier
|
||||
.clip(RoundedCornerShape(10.dp))
|
||||
.background(MaterialTheme.colorScheme.surfaceColorAtElevation(10.dp))
|
||||
.padding(10.dp)
|
||||
.horizontalScroll(rememberScrollState())
|
||||
)
|
||||
}
|
||||
if (state.value is InstallScreenState.Failed) {
|
||||
val failureLog = (state.value as InstallScreenState.Failed).failureLog
|
||||
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp, Alignment.End),
|
||||
|
|
@ -162,7 +144,7 @@ class InstallScreen(val data: InstallData) : Screen {
|
|||
|
||||
Spacer(Modifier.weight(1f, true))
|
||||
|
||||
OutlinedButton(onClick = model::saveDebugToFile) {
|
||||
OutlinedButton(onClick = model::saveFailureLog) {
|
||||
Text(stringResource(R.string.installer_save_file))
|
||||
}
|
||||
|
||||
|
|
@ -170,6 +152,20 @@ class InstallScreen(val data: InstallData) : Screen {
|
|||
Text(stringResource(R.string.action_copy))
|
||||
}
|
||||
}
|
||||
|
||||
SelectionContainer {
|
||||
Text(
|
||||
text = failureLog,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
softWrap = false,
|
||||
modifier = Modifier
|
||||
.clip(RoundedCornerShape(10.dp))
|
||||
.background(MaterialTheme.colorScheme.surfaceColorAtElevation(10.dp))
|
||||
.padding(10.dp)
|
||||
.horizontalScroll(rememberScrollState())
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,16 @@
|
|||
package com.aliucord.manager.ui.screens.install
|
||||
|
||||
sealed interface InstallScreenState {
|
||||
data object Pending : InstallScreenState
|
||||
data object Working : InstallScreenState
|
||||
data object Success : InstallScreenState
|
||||
|
||||
data class Failed(
|
||||
val failureLog: String,
|
||||
) : InstallScreenState
|
||||
|
||||
data object CloseScreen : InstallScreenState
|
||||
|
||||
val isFinished: Boolean
|
||||
get() = this !is Pending && this !is Working
|
||||
}
|
||||
|
|
@ -0,0 +1,104 @@
|
|||
package com.aliucord.manager.ui.screens.install.components
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.aliucord.manager.R
|
||||
import com.aliucord.manager.installer.steps.base.Step
|
||||
import com.aliucord.manager.installer.steps.base.StepState
|
||||
import com.aliucord.manager.ui.util.thenIf
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
|
||||
@Composable
|
||||
fun StepGroupCard(
|
||||
name: String,
|
||||
subSteps: ImmutableList<Step>,
|
||||
isExpanded: Boolean,
|
||||
onExpand: () -> Unit,
|
||||
) {
|
||||
val groupState by remember {
|
||||
derivedStateOf {
|
||||
when {
|
||||
// If all steps are pending then show pending
|
||||
subSteps.all { it.state == StepState.Pending } -> StepState.Pending
|
||||
// If any step has finished with an error then default to error
|
||||
subSteps.any { it.state == StepState.Error } -> StepState.Error
|
||||
// If all steps have finished as Skipped/Success then show success
|
||||
subSteps.all { it.state.isFinished } -> StepState.Success
|
||||
|
||||
else -> StepState.Running
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(groupState) {
|
||||
if (groupState != StepState.Pending)
|
||||
onExpand()
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(16.dp))
|
||||
.thenIf(isExpanded) {
|
||||
background(MaterialTheme.colorScheme.surfaceColorAtElevation(1.dp))
|
||||
}
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
modifier = Modifier
|
||||
.clickable(true, onClick = onExpand)
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp)
|
||||
) {
|
||||
StepStatusIcon(groupState, 24.dp)
|
||||
|
||||
Text(text = name)
|
||||
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
|
||||
if (groupState.isFinished) Text(
|
||||
"%.2fs".format(subSteps.sumOf { it.durationMs } / 1000f),
|
||||
style = MaterialTheme.typography.labelMedium
|
||||
)
|
||||
|
||||
if (isExpanded) {
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.ic_arrow_up_small),
|
||||
contentDescription = stringResource(R.string.action_collapse)
|
||||
)
|
||||
} else {
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.ic_arrow_down_small),
|
||||
contentDescription = stringResource(R.string.action_expand)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
AnimatedVisibility(visible = isExpanded) {
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
modifier = Modifier
|
||||
.background(MaterialTheme.colorScheme.background.copy(0.6f))
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp)
|
||||
.padding(start = 4.dp)
|
||||
) {
|
||||
for (step in subSteps) key(step) {
|
||||
StepItem(step)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,44 @@
|
|||
package com.aliucord.manager.ui.screens.install.components
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.aliucord.manager.installer.steps.base.Step
|
||||
|
||||
@Composable
|
||||
fun StepItem(
|
||||
step: Step,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||
modifier = modifier,
|
||||
) {
|
||||
StepStatusIcon(step.state, size = 18.dp)
|
||||
|
||||
Text(
|
||||
text = stringResource(step.localizedName),
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.weight(1f, true),
|
||||
)
|
||||
|
||||
// TODO: live step duration counter
|
||||
if (step.state.isFinished) {
|
||||
Text(
|
||||
text = "%.2fs".format(step.durationMs / 1000f),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
maxLines = 1,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,59 @@
|
|||
package com.aliucord.manager.ui.screens.install.components
|
||||
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import com.aliucord.manager.R
|
||||
import com.aliucord.manager.installer.steps.base.StepState
|
||||
import kotlin.math.floor
|
||||
|
||||
@Composable
|
||||
fun StepStatusIcon(status: StepState, size: Dp) {
|
||||
val strokeWidth = Dp(floor(size.value / 10) + 1)
|
||||
val context = LocalContext.current
|
||||
|
||||
when (status) {
|
||||
StepState.Pending -> Icon(
|
||||
painter = painterResource(R.drawable.ic_circle),
|
||||
contentDescription = stringResource(R.string.status_queued),
|
||||
tint = MaterialTheme.colorScheme.onSurface.copy(0.4f),
|
||||
modifier = Modifier.size(size)
|
||||
)
|
||||
|
||||
StepState.Running -> CircularProgressIndicator(
|
||||
strokeWidth = strokeWidth,
|
||||
modifier = Modifier
|
||||
.size(size)
|
||||
.semantics { contentDescription = context.getString(R.string.status_ongoing) }
|
||||
)
|
||||
|
||||
StepState.Success -> Icon(
|
||||
painter = painterResource(R.drawable.ic_check_circle),
|
||||
contentDescription = stringResource(R.string.status_success),
|
||||
tint = Color(0xFF59B463),
|
||||
modifier = Modifier.size(size)
|
||||
)
|
||||
|
||||
StepState.Error -> Icon(
|
||||
painter = painterResource(R.drawable.ic_canceled),
|
||||
contentDescription = stringResource(R.string.status_failed),
|
||||
tint = MaterialTheme.colorScheme.error,
|
||||
modifier = Modifier.size(size)
|
||||
)
|
||||
|
||||
StepState.Skipped -> Icon(
|
||||
painter = painterResource(R.drawable.ic_check_circle),
|
||||
contentDescription = stringResource(R.string.status_skipped),
|
||||
tint = Color(0xFFAEAEAE),
|
||||
modifier = Modifier.size(size)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
@file:Suppress("unused")
|
||||
|
||||
package com.aliucord.manager.ui.util
|
||||
|
||||
import androidx.compose.ui.Modifier
|
||||
|
||||
/**
|
||||
* Apply additional modifiers if [value] is not null.
|
||||
*/
|
||||
inline fun <T> Modifier.thenIf(value: T?, block: Modifier.(T) -> Modifier): Modifier =
|
||||
value?.let { block(it) } ?: this
|
||||
|
||||
/**
|
||||
* Apply additional modifiers if [predicate] is true.
|
||||
*/
|
||||
inline fun Modifier.thenIf(predicate: Boolean, block: Modifier.() -> Modifier): Modifier {
|
||||
return if (predicate) {
|
||||
block()
|
||||
} else {
|
||||
this
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,29 @@
|
|||
@file:Suppress("unused")
|
||||
|
||||
package com.aliucord.manager.ui.util
|
||||
|
||||
import kotlinx.collections.immutable.*
|
||||
import kotlinx.collections.immutable.adapters.*
|
||||
|
||||
/*
|
||||
* Compose-stable wrappers over a list for performance.
|
||||
*
|
||||
* This does NOT guarantee stability. It is merely a stable wrapper over another collection,
|
||||
* and assumes the user knows that it shouldn't change through crucial parts of rendering.
|
||||
*/
|
||||
|
||||
fun <T> Collection<T>.toUnsafeImmutable(): ImmutableCollection<T> =
|
||||
ImmutableCollectionAdapter(this)
|
||||
|
||||
fun <T> List<T>.toUnsafeImmutable(): ImmutableList<T> =
|
||||
ImmutableListAdapter(this)
|
||||
|
||||
fun <T> Set<T>.toUnsafeImmutable(): ImmutableSet<T> =
|
||||
ImmutableSetAdapter(this)
|
||||
|
||||
fun <K, V> Map<K, V>.toUnsafeImmutable(): ImmutableMap<K, V> =
|
||||
ImmutableMapAdapter(this)
|
||||
|
||||
fun <T> emptyImmutableList(): ImmutableList<T> = persistentListOf()
|
||||
fun <T> emptyImmutableSet(): ImmutableSet<T> = persistentSetOf()
|
||||
fun <K, V> emptyImmutableMap(): ImmutableMap<K, V> = persistentMapOf()
|
||||
|
|
@ -5,9 +5,9 @@ import androidx.compose.runtime.*
|
|||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.aliucord.manager.BuildConfig
|
||||
import com.aliucord.manager.manager.DownloadManager
|
||||
import com.aliucord.manager.domain.repository.GithubRepository
|
||||
import com.aliucord.manager.installer.util.installApks
|
||||
import com.aliucord.manager.manager.DownloadManager
|
||||
import com.aliucord.manager.network.utils.SemVer
|
||||
import com.aliucord.manager.network.utils.getOrNull
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
|
|
|
|||
|
|
@ -93,6 +93,7 @@
|
|||
<string name="installer_cached">(Cached)</string>
|
||||
<string name="installer_success">Successfully installed Aliucord</string>
|
||||
<string name="installer_aborted">Aborted Aliucord installation</string>
|
||||
<string name="installer_dl_verify_fail">Failed to verify download</string>
|
||||
<string name="installer_uninstall_new">Please uninstall your current version of Aliucord in order to continue!</string>
|
||||
|
||||
<string name="install_error_unknown">Failed to install (Unknown reason)</string>
|
||||
|
|
@ -104,28 +105,32 @@
|
|||
<string name="install_error_incompatible">Application is incompatible with this device</string>
|
||||
<string name="install_error_timeout">Installation timed out</string>
|
||||
|
||||
<string name="install_group_apk_dl">Download APKs</string>
|
||||
<string name="install_group_lib_dl">Download Libraries</string>
|
||||
<string name="install_group_patch">Patch APKs</string>
|
||||
<string name="install_group_prepare">Prepare</string>
|
||||
<string name="install_group_download">Download dependencies</string>
|
||||
<string name="install_group_patch">Patch APK</string>
|
||||
<string name="install_group_install">Install APK</string>
|
||||
|
||||
<string name="install_step_fetch_kt_version">Fetching target Discord version</string>
|
||||
<string name="install_step_downgrade_check">Checking for older installations</string>
|
||||
<string name="install_step_dl_kt_apk">Downloading Discord APK</string>
|
||||
<string name="install_step_dl_kotlin">Downloading Kotlin Stdlib</string>
|
||||
<string name="install_step_dl_injector">Downloading Aliucord Injector</string>
|
||||
<string name="install_step_dl_aliuhook">Downloading Aliuhook library</string>
|
||||
<string name="install_step_copy">Copying dependencies</string>
|
||||
<string name="install_step_patch_icons">Patching app icons</string>
|
||||
<string name="install_step_patch_manifests">Patching apk manifests</string>
|
||||
<string name="install_step_patch_dex">Adding aliucord dex into apk</string>
|
||||
<string name="install_step_patch_libs">Replacing libraries</string>
|
||||
<string name="install_step_add_injector">Adding Aliucord injector</string>
|
||||
<string name="install_step_add_aliuhook">Adding Aliuhook library</string>
|
||||
<string name="install_step_alignment">Aligning APKs</string>
|
||||
<string name="install_step_signing">Signing APKs</string>
|
||||
<string name="install_step_installing">Installing APKs</string>
|
||||
<string name="install_step_cleanup">Cleaning up</string>
|
||||
|
||||
<string name="install_step_fetch_kt_version">Fetching target Discord version</string>
|
||||
<string name="install_step_dl_kt_apk">Downloading Discord APK</string>
|
||||
<string name="install_step_dl_kotlin">Downloading Kotlin library</string>
|
||||
<string name="install_step_dl_injector">Downloading injector</string>
|
||||
<string name="install_step_dl_aliuhook">Downloading Aliuhook library</string>
|
||||
|
||||
<string name="status_success">Success</string>
|
||||
<string name="status_ongoing">Ongoing</string>
|
||||
<string name="status_failed">Failed</string>
|
||||
<string name="status_queued">Queued</string>
|
||||
<string name="status_ongoing">Ongoing</string>
|
||||
<string name="status_skipped">Skipped</string>
|
||||
<string name="status_success">Success</string>
|
||||
<string name="status_failed">Failed</string>
|
||||
|
||||
<string name="installer_abort_title">Really exit?</string>
|
||||
<string name="installer_abort_body">Are you sure you really want to abort an in-progress installation? Cached files will be cleared to avoid corruption.</string>
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue