450 lines
17 KiB
Kotlin
450 lines
17 KiB
Kotlin
package dev.beefers.vendetta.manager.ui.viewmodel.installer
|
|
|
|
import android.content.Context
|
|
import android.os.Build
|
|
import android.util.Log
|
|
import androidx.annotation.StringRes
|
|
import androidx.compose.runtime.Stable
|
|
import androidx.compose.runtime.getValue
|
|
import androidx.compose.runtime.mutableStateMapOf
|
|
import androidx.compose.runtime.mutableStateOf
|
|
import androidx.compose.runtime.setValue
|
|
import cafe.adriel.voyager.core.model.ScreenModel
|
|
import cafe.adriel.voyager.core.model.coroutineScope
|
|
import com.github.diamondminer88.zip.ZipCompression
|
|
import com.github.diamondminer88.zip.ZipReader
|
|
import com.github.diamondminer88.zip.ZipWriter
|
|
import dev.beefers.vendetta.manager.BuildConfig
|
|
import dev.beefers.vendetta.manager.R
|
|
import dev.beefers.vendetta.manager.domain.manager.DownloadManager
|
|
import dev.beefers.vendetta.manager.domain.manager.PreferenceManager
|
|
import dev.beefers.vendetta.manager.installer.util.ManifestPatcher
|
|
import dev.beefers.vendetta.manager.installer.util.Patcher
|
|
import dev.beefers.vendetta.manager.installer.util.installApks
|
|
import dev.beefers.vendetta.manager.network.utils.Signer
|
|
import dev.beefers.vendetta.manager.utils.copyText
|
|
import dev.beefers.vendetta.manager.utils.showToast
|
|
import kotlinx.coroutines.Dispatchers
|
|
import kotlinx.coroutines.launch
|
|
import kotlinx.coroutines.withContext
|
|
import org.lsposed.patch.util.Logger
|
|
import java.io.File
|
|
import java.util.concurrent.atomic.AtomicBoolean
|
|
import kotlin.time.ExperimentalTime
|
|
import kotlin.time.measureTimedValue
|
|
|
|
class InstallerViewModel(
|
|
private val context: Context,
|
|
private val downloadManager: DownloadManager,
|
|
private val preferences: PreferenceManager
|
|
) : ScreenModel {
|
|
private val installationRunning = AtomicBoolean(false)
|
|
private val cacheDir = context.externalCacheDir!!
|
|
private var debugInfo = """
|
|
Vendetta 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()}
|
|
|
|
|
|
""".trimIndent()
|
|
|
|
private val logger = object : Logger() {
|
|
override fun d(msg: String?) {
|
|
if (msg != null) {
|
|
Log.d("Installer", msg)
|
|
debugInfo += "$msg\n"
|
|
}
|
|
}
|
|
|
|
override fun i(msg: String?) {
|
|
if (msg != null) {
|
|
Log.i("Installer", msg)
|
|
debugInfo += "$msg\n"
|
|
}
|
|
}
|
|
|
|
override fun e(msg: String?) {
|
|
if (msg != null) {
|
|
Log.e("Installer", msg)
|
|
debugInfo += "$msg\n"
|
|
}
|
|
}
|
|
}
|
|
|
|
fun copyDebugInfo() {
|
|
context.copyText(debugInfo)
|
|
}
|
|
|
|
fun clearCache() {
|
|
cacheDir.deleteRecursively()
|
|
context.showToast(R.string.msg_cleared_cache)
|
|
}
|
|
|
|
private val job = coroutineScope.launch(Dispatchers.Main) {
|
|
if (installationRunning.getAndSet(true)) {
|
|
return@launch
|
|
}
|
|
|
|
withContext(Dispatchers.IO) {
|
|
install()
|
|
}
|
|
}
|
|
|
|
private suspend fun install() {
|
|
steps += listOf(
|
|
InstallStep.DL_BASE_APK to InstallStepData(
|
|
InstallStep.DL_BASE_APK.nameRes,
|
|
InstallStatus.QUEUED
|
|
),
|
|
InstallStep.DL_LIBS_APK to InstallStepData(
|
|
InstallStep.DL_LIBS_APK.nameRes,
|
|
InstallStatus.QUEUED
|
|
),
|
|
InstallStep.DL_LANG_APK to InstallStepData(
|
|
InstallStep.DL_LANG_APK.nameRes,
|
|
InstallStatus.QUEUED
|
|
),
|
|
InstallStep.DL_RESC_APK to InstallStepData(
|
|
InstallStep.DL_RESC_APK.nameRes,
|
|
InstallStatus.QUEUED
|
|
),
|
|
InstallStep.DL_VD to InstallStepData(InstallStep.DL_VD.nameRes, InstallStatus.QUEUED),
|
|
InstallStep.ADD_VD to InstallStepData(InstallStep.ADD_VD.nameRes, InstallStatus.QUEUED),
|
|
InstallStep.PATCH_MANIFESTS to InstallStepData(
|
|
InstallStep.PATCH_MANIFESTS.nameRes,
|
|
InstallStatus.QUEUED
|
|
),
|
|
InstallStep.SIGN_APK to InstallStepData(
|
|
InstallStep.SIGN_APK.nameRes,
|
|
InstallStatus.QUEUED
|
|
),
|
|
InstallStep.INSTALL_APK to InstallStepData(
|
|
InstallStep.INSTALL_APK.nameRes,
|
|
InstallStatus.QUEUED
|
|
),
|
|
)
|
|
|
|
if (preferences.patchIcon) steps += InstallStep.CHANGE_ICON to InstallStepData(
|
|
InstallStep.CHANGE_ICON.nameRes,
|
|
InstallStatus.QUEUED
|
|
)
|
|
|
|
val version = preferences.discordVersion.ifBlank { "168018" }
|
|
val arch = Build.SUPPORTED_ABIS.first()
|
|
val discordCacheDir = cacheDir.resolve(version)
|
|
val patchedDir = discordCacheDir.resolve("patched").also { it.deleteRecursively() }
|
|
val signedDir = discordCacheDir.resolve("signed").also { it.deleteRecursively() }
|
|
val lspatchedDir = patchedDir.resolve("lspatched").also { it.deleteRecursively() }
|
|
|
|
// Download base.apk
|
|
val baseApk = step(InstallStep.DL_BASE_APK) {
|
|
discordCacheDir.resolve("base-$version.apk").let { file ->
|
|
logger.i("Checking if base-$version.apk is cached")
|
|
if (file.exists()) {
|
|
cached = true
|
|
logger.i("base-$version.apk is cached")
|
|
} else {
|
|
logger.i("base-$version.apk is not cached, downloading now")
|
|
downloadManager.downloadDiscordApk(version, file)
|
|
}
|
|
|
|
logger.i("Move base-$version.apk to working directory")
|
|
file.copyTo(
|
|
patchedDir.resolve(file.name),
|
|
true
|
|
)
|
|
}
|
|
}
|
|
|
|
// Download libs apk
|
|
val libsApk = step(InstallStep.DL_LIBS_APK) {
|
|
val libArch = arch.replace("-v", "_v")
|
|
discordCacheDir.resolve("config.$libArch-$version.apk").let { file ->
|
|
logger.i("Checking if config.$libArch-$version.apk is cached")
|
|
if (file.exists()) {
|
|
logger.i("config.$libArch-$version.apk is cached")
|
|
cached = true
|
|
} else {
|
|
logger.i("config.$libArch-$version.apk is not cached, downloading now")
|
|
downloadManager.downloadSplit(
|
|
version = version,
|
|
split = "config.$libArch",
|
|
out = file
|
|
)
|
|
}
|
|
|
|
logger.i("Move config.$libArch-$version.apk to working directory")
|
|
file.copyTo(
|
|
patchedDir.resolve(file.name),
|
|
true
|
|
)
|
|
}
|
|
}
|
|
|
|
// Download locale apk
|
|
val langApk = step(InstallStep.DL_LANG_APK) {
|
|
discordCacheDir.resolve("config.en-$version.apk").let { file ->
|
|
logger.i("Checking if config.en-$version.apk is cached")
|
|
if (file.exists()) {
|
|
logger.i("config.en-$version.apk is cached")
|
|
cached = true
|
|
} else {
|
|
logger.i("config.en-$version.apk is not cached, downloading now")
|
|
downloadManager.downloadSplit(
|
|
version = version,
|
|
split = "config.en",
|
|
out = file
|
|
)
|
|
}
|
|
|
|
logger.i("Move config.en-$version.apk to working directory")
|
|
file.copyTo(
|
|
patchedDir.resolve(file.name),
|
|
true
|
|
)
|
|
}
|
|
}
|
|
|
|
// Download resources apk
|
|
val resApk = step(InstallStep.DL_RESC_APK) {
|
|
discordCacheDir.resolve("config.xxhdpi-$version.apk").let { file ->
|
|
logger.i("Checking if config.xxhdpi-$version.apk is cached")
|
|
if (file.exists()) {
|
|
logger.i("config.xxhdpi-$version.apk is cached")
|
|
cached = true
|
|
} else {
|
|
logger.i("config.xxhdpi-$version.apk is not cached, downloading now")
|
|
downloadManager.downloadSplit(
|
|
version = version,
|
|
split = "config.xxhdpi",
|
|
out = file
|
|
)
|
|
}
|
|
|
|
logger.i("Move config.xxhdpi-$version.apk to working directory")
|
|
file.copyTo(
|
|
patchedDir.resolve(file.name),
|
|
true
|
|
)
|
|
}
|
|
}
|
|
|
|
// Download vendetta apk
|
|
val vendetta = step(InstallStep.DL_VD) {
|
|
discordCacheDir.resolve("vendetta.apk").let { file ->
|
|
logger.i("Checking if vendetta.apk is cached")
|
|
if (file.exists()) {
|
|
logger.i("vendetta.apk is cached")
|
|
cached = true
|
|
} else {
|
|
logger.i("vendetta.apk is not cached, downloading now")
|
|
downloadManager.downloadVendetta(file)
|
|
}
|
|
|
|
logger.i("Move vendetta.apk to working directory")
|
|
file.copyTo(
|
|
patchedDir.resolve(file.name),
|
|
true
|
|
)
|
|
}
|
|
}
|
|
|
|
if (preferences.patchIcon) {
|
|
step(InstallStep.CHANGE_ICON) {
|
|
ZipWriter(baseApk, true).use { baseApk ->
|
|
val mipmaps =
|
|
arrayOf("mipmap-xhdpi-v4", "mipmap-xxhdpi-v4", "mipmap-xxxhdpi-v4")
|
|
val icons = arrayOf(
|
|
"ic_logo_foreground.png",
|
|
"ic_logo_square.png",
|
|
"ic_logo_foreground.png"
|
|
)
|
|
|
|
for (icon in icons) {
|
|
val newIcon = context.assets.open("icons/$icon")
|
|
.use { it.readBytes() }
|
|
|
|
for (mipmap in mipmaps) {
|
|
logger.i("Replacing $mipmap with $icon")
|
|
val path = "res/$mipmap/$icon"
|
|
baseApk.deleteEntry(path)
|
|
baseApk.writeEntry(path, newIcon)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Patch manifests
|
|
step(InstallStep.PATCH_MANIFESTS) {
|
|
arrayOf(baseApk, libsApk, langApk, resApk).forEach { apk ->
|
|
logger.i("Reading AndroidManifest.xml from ${apk!!.name}")
|
|
val manifest = ZipReader(apk)
|
|
.use { zip -> zip.openEntry("AndroidManifest.xml")?.read() }
|
|
?: throw IllegalStateException("No manifest in ${apk.name}")
|
|
|
|
ZipWriter(apk, true).use { zip ->
|
|
logger.i("Changing package and app name in ${apk.name}")
|
|
val patchedManifestBytes = if (apk == baseApk) {
|
|
ManifestPatcher.patchManifest(
|
|
manifestBytes = manifest,
|
|
packageName = preferences.packageName,
|
|
appName = preferences.appName,
|
|
debuggable = preferences.debuggable,
|
|
)
|
|
} else {
|
|
logger.i("Changing package name in ${apk.name}")
|
|
ManifestPatcher.renamePackage(manifest, preferences.packageName)
|
|
}
|
|
|
|
logger.i("Deleting old AndroidManifest.xml in ${apk.name}")
|
|
zip.deleteEntry(
|
|
"AndroidManifest.xml",
|
|
apk == libsApk
|
|
) // Preserve alignment in libs apk
|
|
logger.i("Adding patched AndroidManifest.xml in ${apk.name}")
|
|
zip.writeEntry("AndroidManifest.xml", patchedManifestBytes)
|
|
}
|
|
}
|
|
}
|
|
|
|
step(InstallStep.SIGN_APK) {
|
|
// Align resources.arsc due to targeting api 30 for silent install
|
|
logger.i("Creating dir for signed apks")
|
|
signedDir.mkdir()
|
|
val apks = arrayOf(baseApk, libsApk, langApk, resApk)
|
|
if (Build.VERSION.SDK_INT >= 31) {
|
|
for (file in apks) {
|
|
logger.i("Byte aligning ${file!!.name}")
|
|
val bytes = ZipReader(file).use {
|
|
if (it.entryNames.contains("resources.arsc")) {
|
|
it.openEntry("resources.arsc")?.read()
|
|
} else {
|
|
null
|
|
}
|
|
} ?: continue
|
|
|
|
ZipWriter(file, true).use {
|
|
logger.i("Removing old resources.arsc")
|
|
it.deleteEntry("resources.arsc", true)
|
|
logger.i("Adding aligned resources.arsc")
|
|
it.writeEntry("resources.arsc", bytes, ZipCompression.NONE, 4096)
|
|
}
|
|
}
|
|
}
|
|
|
|
apks.forEach {
|
|
logger.i("Signing ${it!!.name}")
|
|
Signer.signApk(it, File(signedDir, it.name))
|
|
}
|
|
}
|
|
|
|
step(InstallStep.ADD_VD) {
|
|
val files = mutableListOf<File>()
|
|
signedDir.list { _, name ->
|
|
files.add(signedDir.resolve(name))
|
|
}
|
|
Patcher.patch(
|
|
logger,
|
|
outputDir = lspatchedDir,
|
|
apkPaths = files.map { it.absolutePath },
|
|
embeddedModules = listOf(vendetta!!.absolutePath)
|
|
)
|
|
}
|
|
|
|
step(InstallStep.INSTALL_APK) {
|
|
logger.i("Gathering final apks")
|
|
val files = mutableListOf<File>()
|
|
lspatchedDir.list { _, name ->
|
|
files.add(lspatchedDir.resolve(name))
|
|
}
|
|
logger.i("Installing apks")
|
|
context.installApks(true, *files.toTypedArray())
|
|
isFinished = true
|
|
}
|
|
}
|
|
|
|
@OptIn(ExperimentalTime::class)
|
|
private inline fun <T> step(step: InstallStep, block: InstallStepData.() -> T): T? {
|
|
if (isFinished) return null
|
|
steps[step]!!.status = InstallStatus.ONGOING
|
|
currentStep = step
|
|
|
|
try {
|
|
val value = measureTimedValue { block.invoke(steps[step]!!) }
|
|
val millis = value.duration.inWholeMilliseconds
|
|
|
|
steps[step]!!.apply {
|
|
duration = millis.div(1000f)
|
|
status = InstallStatus.SUCCESSFUL
|
|
}
|
|
return value.value
|
|
} catch (e: Throwable) {
|
|
steps[step]!!.status = InstallStatus.UNSUCCESSFUL
|
|
|
|
logger.e("\nFailed on step ${step.name}\n")
|
|
logger.e(e.stackTraceToString())
|
|
|
|
currentStep = step
|
|
isFinished = true
|
|
return null
|
|
}
|
|
}
|
|
|
|
enum class InstallStepGroup(@StringRes val nameRes: Int) {
|
|
DL(R.string.group_download),
|
|
PATCHING(R.string.group_patch),
|
|
INSTALLING(R.string.group_installing)
|
|
}
|
|
|
|
enum class InstallStep(
|
|
val group: InstallStepGroup,
|
|
@StringRes val nameRes: Int
|
|
) {
|
|
DL_BASE_APK(InstallStepGroup.DL, R.string.step_dl_base),
|
|
DL_LIBS_APK(InstallStepGroup.DL, R.string.step_dl_lib),
|
|
DL_LANG_APK(InstallStepGroup.DL, R.string.step_dl_lang),
|
|
DL_RESC_APK(InstallStepGroup.DL, R.string.step_dl_res),
|
|
DL_VD(InstallStepGroup.DL, R.string.step_dl_vd),
|
|
|
|
CHANGE_ICON(InstallStepGroup.PATCHING, R.string.step_change_icon),
|
|
PATCH_MANIFESTS(InstallStepGroup.PATCHING, R.string.step_patch_manifests),
|
|
SIGN_APK(InstallStepGroup.PATCHING, R.string.step_signing),
|
|
ADD_VD(InstallStepGroup.PATCHING, R.string.step_add_vd),
|
|
|
|
INSTALL_APK(InstallStepGroup.INSTALLING, R.string.step_installing)
|
|
}
|
|
|
|
enum class InstallStatus {
|
|
ONGOING,
|
|
SUCCESSFUL,
|
|
UNSUCCESSFUL,
|
|
QUEUED
|
|
}
|
|
|
|
@Stable
|
|
class InstallStepData(
|
|
@StringRes val nameRes: Int,
|
|
status: InstallStatus,
|
|
duration: Float = 0f,
|
|
cached: Boolean = false
|
|
) {
|
|
var status by mutableStateOf(status)
|
|
var duration by mutableStateOf(duration)
|
|
var cached by mutableStateOf(cached)
|
|
}
|
|
|
|
var currentStep by mutableStateOf<InstallStep?>(null)
|
|
val steps = mutableStateMapOf<InstallStep, InstallStepData>()
|
|
var isFinished by mutableStateOf(false)
|
|
|
|
fun getSteps(group: InstallStepGroup): List<InstallStepData> {
|
|
return steps
|
|
.filterKeys { it.group === group }.entries
|
|
.sortedBy { it.key.ordinal }
|
|
.map { it.value }
|
|
}
|
|
|
|
} |