feat(wintry): Allow inserting custom version code, fetch latest metadata for latest updates, embed lspatch native libraries

This commit is contained in:
pylixonly 2025-07-14 15:35:44 +08:00
parent 5ef7946a9c
commit 9d2458c523
37 changed files with 514 additions and 96 deletions

BIN
app/libs/lspatch.aar Normal file

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View file

@ -48,6 +48,8 @@ class ManagerApplication : Application() {
singleOf(::HttpService)
singleOf(::AliucordGithubService)
singleOf(::AliucordMavenService)
singleOf(::RNATrackerService)
singleOf(::WintryGithubService)
})
// UI Models

View file

@ -116,6 +116,10 @@ class PathManager(
fun cachedKotlinDex() = patchingDownloadDir
.resolve("kotlin.dex")
fun cachedWintryXposed(version: SemVer) = patchingDownloadDir
.resolve("wt-xposed")
.resolve("$version.apk")
/**
* The temporary working directory of a currently executing patching process.
*/

View file

@ -10,6 +10,8 @@ data class GithubRelease(
val assets: List<GithubReleaseAssets>,
@SerialName("tag_name")
val tagName: String,
@SerialName("name")
val name: String,
) {
@Serializable
data class GithubReleaseAssets(

View file

@ -0,0 +1,15 @@
package com.aliucord.manager.network.models
import kotlinx.serialization.Serializable
@Serializable
data class RNATrackerIndex(
val latest: Versions,
) {
@Serializable
data class Versions(
val alpha: Int,
val beta: Int,
val stable: Int,
)
}

View file

@ -0,0 +1,20 @@
package com.aliucord.manager.network.services
import com.aliucord.manager.network.models.RNATrackerIndex
import io.ktor.client.request.url
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
class RNATrackerService(
private val httpService: HttpService,
) {
suspend fun getLatestDiscordVersions() = withContext(Dispatchers.IO) {
httpService.request<RNATrackerIndex> {
url("${BASE_URL}/tracker/index")
}
}
companion object {
const val BASE_URL = "https://tracker.vendetta.rocks"
}
}

View file

@ -0,0 +1,37 @@
package com.aliucord.manager.network.services
import com.aliucord.manager.network.models.GithubRelease
import com.aliucord.manager.network.utils.ApiResponse
import io.ktor.client.request.header
import io.ktor.client.request.url
import io.ktor.http.HttpHeaders
class WintryGithubService(
private val http: HttpService,
) {
/**
* Fetches all the Manager releases with a 60s local cache.
*/
suspend fun getManagerReleases(): ApiResponse<List<GithubRelease>> {
return http.request {
url("https://api.github.com/repos/$ORG/$MANAGER_REPO/releases")
header(HttpHeaders.CacheControl, "public, max-age=60, s-maxage=60")
}
}
/**
* Fetches the latest Xposed release with a 60s local cache.
*/
suspend fun getLatestXposedRelease(): ApiResponse<GithubRelease> {
return http.request {
url("https://api.github.com/repos/$ORG/$XPOSED_REPO/releases/latest")
header(HttpHeaders.CacheControl, "public, max-age=60, s-maxage=60")
}
}
companion object {
const val ORG = "wtcord"
const val MANAGER_REPO = "wt-manager"
const val XPOSED_REPO = "wt-xposed"
}
}

View file

@ -5,7 +5,7 @@ import com.aliucord.manager.ui.screens.patchopts.PatchOptions
import kotlinx.serialization.Serializable
/**
* Data stored inside patched APKs as "aliucord.json" in order to preserve install-time information about Aliucord and the Manager.
* Data stored inside patched APKs as ~~"aliucord.json"~~ "wintry.json" in order to preserve install-time information about Aliucord and the Manager.
*/
@Serializable
data class InstallMetadata(
@ -38,4 +38,14 @@ data class InstallMetadata(
* Version of the smali patches that were applied onto the APK.
*/
val patchesVersion: SemVer?,
/**
* Version of LSPatch used to inject Xposed module into the APK.
*/
val lspatchVersion: Int?,
/**
* Version of WintryXposed embedded into the APK.
*/
val wintryXposedVersion: SemVer?,
)

View file

@ -3,16 +3,21 @@ package com.aliucord.manager.patcher
import com.aliucord.manager.patcher.steps.download.*
import com.aliucord.manager.patcher.steps.install.*
import com.aliucord.manager.patcher.steps.patch.*
import com.aliucord.manager.patcher.steps.prepare.*
import com.aliucord.manager.ui.screens.patchopts.PatchOptions
import kotlinx.collections.immutable.persistentListOf
class WintryPatchRunner(options: PatchOptions) : StepRunner() {
override val steps = persistentListOf(
FetchDiscordRNAStep(options),
DowngradeCheckStep(options),
RestoreDownloadsStep(),
DownloadDiscordRNABaseStep(),
DownloadDiscordRNALangStep(),
DownloadDiscordRNAResourcesStep(),
DownloadDiscordRNALibStep(),
DownloadWintryXposed(),
DownloadWintryXposedStep(),
CopyDependenciesStep(),
PatchIconsStep(options),

View file

@ -0,0 +1,15 @@
package com.aliucord.manager.patcher.steps.base
import com.aliucord.manager.patcher.StepRunner
import com.aliucord.manager.patcher.steps.prepare.FetchDiscordRNAStep
import kotlin.properties.Delegates
abstract class DownloadDiscordRNAStep : DownloadStep() {
protected var version by Delegates.notNull<Int>()
private set
override suspend fun execute(container: StepRunner) {
version = container.getStep<FetchDiscordRNAStep>().targetVersion
super.execute(container)
}
}

View file

@ -22,9 +22,6 @@ abstract class DownloadStep : Step(), KoinComponent {
private val context: Context by inject()
private val downloader: KtorDownloadManager by inject()
// Temporary
protected val version: Int = 289210
/**
* The remote url to download
*/

View file

@ -42,7 +42,7 @@ class CopyDependenciesStep : Step(), KoinComponent {
override val localizedName = R.string.patch_step_copy_deps
override suspend fun execute(container: StepRunner) {
val srcApk = container.getStep<DownloadDiscordStep>().targetFile
val srcApk = container.getStepOrNull<DownloadDiscordRNABaseStep>()?.targetFile ?: container.getStepOrNull<DownloadDiscordStep>()!!.targetFile
val langApk = container.getStepOrNull<DownloadDiscordRNALangStep>()?.targetFile
val libApk = container.getStepOrNull<DownloadDiscordRNALibStep>()?.targetFile
val resApk = container.getStepOrNull<DownloadDiscordRNAResourcesStep>()?.targetFile

View file

@ -1,9 +1,16 @@
package com.aliucord.manager.patcher.steps.download
import com.aliucord.manager.R
import com.aliucord.manager.manager.PathManager
import com.aliucord.manager.network.services.RNATrackerService.Companion.BASE_URL
import com.aliucord.manager.patcher.steps.base.DownloadDiscordRNAStep
import org.koin.core.component.inject
class DownloadDiscordRNABaseStep : DownloadDiscordStep() {
override val targetUrl = "https://tracker.vendetta.rocks/tracker/download/$version/base"
override val targetFile = paths.discordApkVersionCache(version).resolve("base.apk")
class DownloadDiscordRNABaseStep : DownloadDiscordRNAStep() {
private val paths: PathManager by inject()
override val targetUrl get() = "$BASE_URL/tracker/download/$version/base"
override val targetFile get() = paths.discordApkVersionCache(version).resolve("base.apk")
override val localizedName = R.string.downloading_base_apk
}

View file

@ -2,14 +2,15 @@ package com.aliucord.manager.patcher.steps.download
import com.aliucord.manager.R
import com.aliucord.manager.manager.PathManager
import com.aliucord.manager.patcher.steps.base.DownloadStep
import org.koin.core.component.KoinComponent
import com.aliucord.manager.network.services.RNATrackerService.Companion.BASE_URL
import com.aliucord.manager.patcher.steps.base.DownloadDiscordRNAStep
import org.koin.core.component.inject
class DownloadDiscordRNALangStep : DownloadStep(), KoinComponent {
class DownloadDiscordRNALangStep : DownloadDiscordRNAStep() {
private val paths: PathManager by inject()
override val targetUrl = "https://tracker.vendetta.rocks/tracker/download/$version/config.en"
override val targetFile = paths.discordApkVersionCache(version).resolve("config.en.apk")
override val targetUrl get() = "$BASE_URL/tracker/download/$version/config.en"
override val targetFile get() = paths.discordApkVersionCache(version).resolve("config.en.apk")
override val localizedName = R.string.download_libs_apk
}

View file

@ -3,15 +3,15 @@ package com.aliucord.manager.patcher.steps.download
import android.os.Build
import com.aliucord.manager.R
import com.aliucord.manager.manager.PathManager
import com.aliucord.manager.patcher.steps.base.DownloadStep
import org.koin.core.component.KoinComponent
import com.aliucord.manager.network.services.RNATrackerService.Companion.BASE_URL
import com.aliucord.manager.patcher.steps.base.DownloadDiscordRNAStep
import org.koin.core.component.inject
class DownloadDiscordRNALibStep : DownloadStep(), KoinComponent {
class DownloadDiscordRNALibStep : DownloadDiscordRNAStep() {
private val paths: PathManager by inject()
private val arch = Build.SUPPORTED_ABIS.first().replace("-v", "_v")
override val targetUrl = "https://tracker.vendetta.rocks/tracker/download/$version/config.$arch"
override val targetFile = paths.discordApkVersionCache(version).resolve("config.$arch.apk")
override val targetUrl get() = "$BASE_URL/tracker/download/$version/config.$arch"
override val targetFile get() = paths.discordApkVersionCache(version).resolve("config.$arch.apk")
override val localizedName = R.string.downloading_libs_apk
}

View file

@ -2,14 +2,14 @@ package com.aliucord.manager.patcher.steps.download
import com.aliucord.manager.R
import com.aliucord.manager.manager.PathManager
import com.aliucord.manager.patcher.steps.base.DownloadStep
import org.koin.core.component.KoinComponent
import com.aliucord.manager.network.services.RNATrackerService.Companion.BASE_URL
import com.aliucord.manager.patcher.steps.base.DownloadDiscordRNAStep
import org.koin.core.component.inject
class DownloadDiscordRNAResourcesStep : DownloadStep(), KoinComponent {
class DownloadDiscordRNAResourcesStep : DownloadDiscordRNAStep() {
private val paths: PathManager by inject()
override val targetUrl = "https://tracker.vendetta.rocks/tracker/download/$version/config.xxhdpi"
override val targetFile = paths.discordApkVersionCache(version).resolve("config.xxhdpi.apk")
override val targetUrl get() = "$BASE_URL/tracker/download/$version/config.xxhdpi"
override val targetFile get() = paths.discordApkVersionCache(version).resolve("config.xxhdpi.apk")
override val localizedName = R.string.downloading_resources_apk
}

View file

@ -17,6 +17,7 @@ open class DownloadDiscordStep : DownloadStep(), KoinComponent {
protected val paths: PathManager by inject()
override val localizedName = R.string.patch_step_dl_kt_apk
override val targetUrl = getDiscordApkUrl(DISCORD_KT_VERSION)
override val targetFile = paths.discordApkVersionCache(DISCORD_KT_VERSION)
.resolve("discord.apk")

View file

@ -1,14 +0,0 @@
package com.aliucord.manager.patcher.steps.download
import com.aliucord.manager.R
import com.aliucord.manager.manager.PathManager
import com.aliucord.manager.patcher.steps.base.DownloadStep
import org.koin.core.component.inject
class DownloadWintryXposed : DownloadStep() {
private val paths: PathManager by inject()
override val localizedName = R.string.downloading_wintryxposed
override val targetFile = paths.patchingDownloadDir.resolve("xposed.apk")
override val targetUrl = "https://github.com/wtcord/wt-xposed/releases/download/1002/app-release.apk"
}

View file

@ -0,0 +1,31 @@
package com.aliucord.manager.patcher.steps.download
import com.aliucord.manager.R
import com.aliucord.manager.manager.PathManager
import com.aliucord.manager.network.services.WintryGithubService
import com.aliucord.manager.network.utils.SemVer
import com.aliucord.manager.network.utils.getOrThrow
import com.aliucord.manager.patcher.StepRunner
import com.aliucord.manager.patcher.steps.base.DownloadStep
import org.koin.core.component.inject
class DownloadWintryXposedStep : DownloadStep() {
private val paths: PathManager by inject()
private val wintryGithubService: WintryGithubService by inject()
override val localizedName = R.string.downloading_wintryxposed
override val targetFile get() = paths.cachedWintryXposed(targetVersion)
override lateinit var targetUrl: String
lateinit var targetVersion: SemVer
private set
override suspend fun execute(container: StepRunner) {
val latestRelease = wintryGithubService.getLatestXposedRelease().getOrThrow()
container.log("Latest WintryXposed release is ${latestRelease.name}")
targetVersion = SemVer.parse(latestRelease.name)
targetUrl = latestRelease.assets.first { it.name == "app-release.apk" }.browserDownloadUrl
super.execute(container)
}
}

View file

@ -5,7 +5,7 @@ import com.aliucord.manager.patcher.StepRunner
import com.aliucord.manager.patcher.steps.StepGroup
import com.aliucord.manager.patcher.steps.base.Step
import com.aliucord.manager.patcher.steps.download.CopyDependenciesStep
import com.aliucord.manager.patcher.steps.download.DownloadWintryXposed
import com.aliucord.manager.patcher.steps.download.DownloadWintryXposedStep
import com.aliucord.manager.patcher.util.Signer
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
@ -58,7 +58,7 @@ class InjectWintryXposedStep : Step() {
override suspend fun execute(container: StepRunner) {
val apks = container.getStep<CopyDependenciesStep>().patchedApks
val xposed = container.getStep<DownloadWintryXposed>().targetFile
val xposed = container.getStep<DownloadWintryXposedStep>().targetFile
container.log("Adding WintryXposed module with LSPatch")
container.log("WintryXposed path = ${xposed.absolutePath}")

View file

@ -13,6 +13,7 @@ import com.github.diamondminer88.zip.ZipWriter
import kotlinx.serialization.json.Json
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
import org.lsposed.lspatch.share.LSPConfig
/**
* Store the install options and additional data inside the APK for future use,
@ -30,6 +31,7 @@ class SaveMetadataStep(private val options: PatchOptions) : Step(), KoinComponen
val aliuhook = container.getStepOrNull<DownloadAliuhookStep>()
val injector = container.getStepOrNull<DownloadInjectorStep>()
val patches = container.getStepOrNull<DownloadPatchesStep>()
val wintryXposed = container.getStepOrNull<DownloadWintryXposedStep>()
val metadata = InstallMetadata(
options = options,
@ -38,11 +40,13 @@ class SaveMetadataStep(private val options: PatchOptions) : Step(), KoinComponen
aliuhookVersion = aliuhook?.targetVersion,
injectorVersion = injector?.targetVersion,
patchesVersion = patches?.targetVersion,
lspatchVersion = LSPConfig.instance.VERSION_CODE,
wintryXposedVersion = wintryXposed?.targetVersion,
)
container.log("Writing serialized install metadata to APK")
ZipWriter(apk, /* append = */ true).use {
it.writeEntry("aliucord.json", json.encodeToString<InstallMetadata>(metadata))
it.writeEntry("wintry.json", json.encodeToString<InstallMetadata>(metadata))
}
}
}

View file

@ -38,9 +38,15 @@ class DowngradeCheckStep(private val options: PatchOptions) : Step(), KoinCompon
}
container.log("Version of installed Discord app: $currentVersion")
val targetVersion = container
.getStep<FetchInfoStep>()
.data.discordVersionCode
var targetVersion: Int
val fetchInfo = container.getStepOrNull<FetchInfoStep>()
if (fetchInfo != null) {
targetVersion = fetchInfo.data.discordVersionCode
} else {
val rnaFetchInfo = container.getStep<FetchDiscordRNAStep>()
targetVersion = rnaFetchInfo.targetVersion
}
container.log("Target discord version: $targetVersion")

View file

@ -0,0 +1,43 @@
package com.aliucord.manager.patcher.steps.prepare
import com.aliucord.manager.R
import com.aliucord.manager.network.services.RNATrackerService
import com.aliucord.manager.network.utils.getOrThrow
import com.aliucord.manager.patcher.StepRunner
import com.aliucord.manager.patcher.steps.StepGroup
import com.aliucord.manager.patcher.steps.base.Step
import com.aliucord.manager.ui.screens.patchopts.PatchOptions
import com.aliucord.manager.ui.screens.patchopts.VersionPreference
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
import kotlin.properties.Delegates
class FetchDiscordRNAStep(val options: PatchOptions) : Step(), KoinComponent {
private val rnaTrackerService: RNATrackerService by inject()
override val group: StepGroup = StepGroup.Prepare
override val localizedName: Int = R.string.fetching_latest_discord_info
var targetVersion by Delegates.notNull<Int>()
private set
override suspend fun execute(container: StepRunner) {
if (options.versionPreference == VersionPreference.Custom) {
container.log("Custom version selected, skipping fetch.")
targetVersion = options.customVersionCode.toInt()
return
}
val latestVal = rnaTrackerService.getLatestDiscordVersions().getOrThrow()
container.log("Fetched latest discord info: $latestVal")
targetVersion = when (options.versionPreference) {
VersionPreference.Stable -> latestVal.latest.stable
VersionPreference.Beta -> latestVal.latest.beta
VersionPreference.Alpha -> latestVal.latest.alpha
else -> throw IllegalArgumentException("Unsupported version preference: ${options.versionPreference}")
}
container.log("Selected Discord version: $targetVersion")
}
}

View file

@ -35,7 +35,7 @@ object ManifestPatcher {
vmSafeMode: Boolean? = true,
useEmbeddedDex: Boolean? = true,
extractNativeLibs: Boolean? = false,
addAliucordMetadata: Boolean? = true,
addManagerMetadata: Boolean? = true,
modifyPermissions: Boolean? = true,
modifyProviders: Boolean? = true,
modifyActivities: Boolean? = true,
@ -128,7 +128,7 @@ object ManifestPatcher {
private var shouldAddLegacyStorage = requestLegacyExternalStorage ?: false
private var shouldAddUseEmbeddedDex = useEmbeddedDex ?: false
private var shouldAddExtractNativeLibs = extractNativeLibs ?: false
private var shouldAddMetadata = addAliucordMetadata ?: false
private var shouldAddMetadata = addManagerMetadata ?: false
override fun attr(ns: String?, name: String, resourceId: Int, type: Int, value: Any?) {
if (name == NETWORK_SECURITY_CONFIG) return
@ -142,10 +142,16 @@ object ManifestPatcher {
override fun child(ns: String?, name: String): NodeVisitor {
val visitor = super.child(ns, name)
if (shouldAddMetadata && addAliucordMetadata == true) {
if (shouldAddMetadata && addManagerMetadata == true) {
shouldAddMetadata = false
super.child(ANDROID_NAMESPACE, "meta-data").apply {
attr(ANDROID_NAMESPACE, "name", android.R.attr.name, TYPE_STRING, "isAliucord")
attr(
ANDROID_NAMESPACE,
"name",
android.R.attr.name,
TYPE_STRING,
"isWintry" /* originally "isAliucord" */
)
attr(ANDROID_NAMESPACE, "value", android.R.attr.value, TYPE_INT_BOOLEAN, 1)
}
}

View file

@ -32,6 +32,11 @@ private fun PatchOptionsScreenPreview(
packageName = parameters.packageName,
packageNameState = parameters.packageNameState,
setPackageName = {},
versionPreference = parameters.versionPreference,
setVersionPreference = {},
customVersionCode = parameters.customVersionCode,
customVersionCodeIsError = parameters.customVersionCodeIsError,
setCustomVersionCode = {},
isConfigValid = parameters.isConfigValid,
onInstall = {},
)
@ -50,6 +55,9 @@ private data class PatchOptionsParameters(
val appNameIsError: Boolean,
val packageName: String,
val packageNameState: PackageNameState,
val versionPreference: VersionPreference,
val customVersionCode: String,
val customVersionCodeIsError: Boolean,
val isConfigValid: Boolean,
)
@ -67,6 +75,9 @@ private class PatchOptionsParametersProvider : PreviewParameterProvider<PatchOpt
appNameIsError = false,
packageName = PatchOptions.Default.packageName,
packageNameState = PackageNameState.Ok,
versionPreference = VersionPreference.Stable,
customVersionCode = "",
customVersionCodeIsError = false,
isConfigValid = true,
),
PatchOptionsParameters(
@ -80,6 +91,9 @@ private class PatchOptionsParametersProvider : PreviewParameterProvider<PatchOpt
appNameIsError = true,
packageName = "a b",
packageNameState = PackageNameState.Invalid,
versionPreference = VersionPreference.Stable,
customVersionCode = "",
customVersionCodeIsError = false,
isConfigValid = false,
),
PatchOptionsParameters(
@ -93,6 +107,9 @@ private class PatchOptionsParametersProvider : PreviewParameterProvider<PatchOpt
appNameIsError = false,
packageName = PatchOptions.Default.packageName,
packageNameState = PackageNameState.Taken,
versionPreference = VersionPreference.Stable,
customVersionCode = "",
customVersionCodeIsError = false,
isConfigValid = true,
),
)

View file

@ -16,13 +16,12 @@ import cafe.adriel.voyager.core.model.screenModelScope
import com.aliucord.manager.BuildConfig
import com.aliucord.manager.R
import com.aliucord.manager.network.models.BuildInfo
import com.aliucord.manager.network.services.AliucordGithubService
import com.aliucord.manager.network.services.AliucordMavenService
import com.aliucord.manager.network.models.RNATrackerIndex
import com.aliucord.manager.network.services.*
import com.aliucord.manager.network.utils.SemVer
import com.aliucord.manager.network.utils.fold
import com.aliucord.manager.patcher.InstallMetadata
import com.aliucord.manager.ui.screens.patchopts.PatchOptions
import com.aliucord.manager.ui.screens.patchopts.PatchOptionsScreen
import com.aliucord.manager.ui.screens.patchopts.*
import com.aliucord.manager.ui.util.DiscordVersion
import com.aliucord.manager.ui.util.toUnsafeImmutable
import com.aliucord.manager.util.*
@ -38,6 +37,8 @@ class HomeModel(
private val application: Application,
private val github: AliucordGithubService,
private val maven: AliucordMavenService,
private val wtGithub: WintryGithubService,
private val rnaTracker: RNATrackerService,
private val json: Json,
) : ScreenModel {
var installsState by mutableStateOf<InstallsState>(InstallsState.Fetching)
@ -45,6 +46,8 @@ class HomeModel(
private val refreshingLock = Mutex()
private var remoteDataJson: BuildInfo? = null
private var trackerIndexJson: RNATrackerIndex? = null
private var latestWintryXposedVersion: SemVer? = null
private var latestAliuhookVersion: SemVer? = null
init {
@ -106,7 +109,7 @@ class HomeModel(
val metadata = try {
val applicationInfo = application.packageManager.getApplicationInfo(packageName, 0)
val metadataFile = ZipReader(applicationInfo.publicSourceDir)
.use { it.openEntry("aliucord.json")?.read() }
.use { it.openEntry("wintry.json")?.read() }
@OptIn(ExperimentalSerializationApi::class)
metadataFile?.let { json.decodeFromStream<InstallMetadata>(it.inputStream()) }
@ -187,21 +190,35 @@ class HomeModel(
private suspend fun fetchRemoteData() {
listOf(
// // These aren't needed by Wintry
// screenModelScope.launch(Dispatchers.IO) {
// github.getBuildData().fold(
// success = { remoteDataJson = it },
// fail = { Log.w(BuildConfig.TAG, "Failed to fetch remote build data", it) },
// )
// },
// screenModelScope.launch(Dispatchers.IO) {
// maven.getAliuhookVersion().fold(
// success = { latestAliuhookVersion = it },
// fail = { Log.w(BuildConfig.TAG, "Failed to fetch latest Aliuhook version", it) },
// )
// },
screenModelScope.launch(Dispatchers.IO) {
github.getBuildData().fold(
success = { remoteDataJson = it },
fail = { Log.w(BuildConfig.TAG, "Failed to fetch remote build data", it) },
rnaTracker.getLatestDiscordVersions().fold(
success = { trackerIndexJson = it },
fail = { Log.w(BuildConfig.TAG, "Failed to fetch latest Discord RNA versions", it) },
)
},
screenModelScope.launch(Dispatchers.IO) {
maven.getAliuhookVersion().fold(
success = { latestAliuhookVersion = it },
fail = { Log.w(BuildConfig.TAG, "Failed to fetch latest Aliuhook version", it) },
wtGithub.getLatestXposedRelease().fold(
success = { latestWintryXposedVersion = SemVer.parse(it.name) },
fail = { Log.w(BuildConfig.TAG, "Failed to fetch latest WintryXposed version", it) },
)
},
).joinAll()
if (remoteDataJson == null || latestAliuhookVersion == null) {
if (trackerIndexJson == null /* remoteDataJson == null || latestAliuhookVersion == null */ || latestWintryXposedVersion == null) {
mainThread { application.showToast(R.string.home_network_fail) }
}
}
@ -213,34 +230,34 @@ class HomeModel(
return application.packageManager
.getInstalledPackages(PackageManager.GET_META_DATA)
.filter {
// Packages installed via the legacy Installer do not have the metadata marker
val isAliucordPkg = it.packageName == "com.aliucord"
val hasAliucordMeta = it.applicationInfo?.metaData?.containsKey("isAliucord") == true
isAliucordPkg || hasAliucordMeta
// Wintry doesn't have "legacy installer" whatsoever
return@filter it.applicationInfo?.metaData?.containsKey("isWintry") == true
// // Packages installed via the legacy Installer do not have the metadata marker
// val isAliucordPkg = it.packageName == "com.aliucord"
// val hasAliucordMeta = it.applicationInfo?.metaData?.containsKey("isAliucord") == true
// isAliucordPkg || hasAliucordMeta
}
}
/**
* Attempts to determine whether the Aliucord installation is up-to-date.
* If `null` is returned, then the build data was not fetched, and as such
* the status cannot be determined.
* Checks whether the current Wintry installation is up-to-date.
*
* Currently mirrors the behavior of Bunny Manager by directly comparing against
* the latest available Discord and WintryXposed release. This is a temporary approach and will remain
* in place until Wintry adds support for version pinning.
*/
private fun isInstallationUpToDate(pkg: PackageInfo): Boolean? {
// Assume up-to-date when remote data hasn't been fetched yet
val remoteBuildData = remoteDataJson ?: return null
val latestAliuhookVersion = latestAliuhookVersion ?: return null
val trackerData = trackerIndexJson ?: return null
// `longVersionCode` is unnecessary since Discord doesn't use `versionCodeMajor`
@Suppress("DEPRECATION")
val versionCode = pkg.versionCode
// Check if the base APK version is a mismatch
if (remoteBuildData.discordVersionCode != versionCode) return false
// Try to parse install metadata. If none present, install was made via legacy installer.
val apkPath = pkg.applicationInfo?.publicSourceDir ?: return false
val installMetadata = try {
val metadataFile = ZipReader(apkPath).use { it.openEntry("aliucord.json")?.read() }
val metadataFile = ZipReader(apkPath).use { it.openEntry("wintry.json")?.read() }
?: return false
@OptIn(ExperimentalSerializationApi::class)
@ -250,11 +267,13 @@ class HomeModel(
return false
}
// Check that all the installation components are up-to-date (skip not installed)
val injectorUpToDate = installMetadata.injectorVersion == null || remoteBuildData.injectorVersion == installMetadata.injectorVersion
val patchesUpToDate = installMetadata.patchesVersion == null || remoteBuildData.patchesVersion == installMetadata.patchesVersion
val aliuhookUpToDate = installMetadata.aliuhookVersion == null || latestAliuhookVersion == installMetadata.aliuhookVersion
val latestByPref = when (installMetadata.options.versionPreference) {
VersionPreference.Stable -> trackerData.latest.stable
VersionPreference.Beta -> trackerData.latest.beta
VersionPreference.Alpha -> trackerData.latest.alpha
VersionPreference.Custom -> return true
}
return injectorUpToDate && patchesUpToDate && aliuhookUpToDate
return latestByPref == versionCode && installMetadata.wintryXposedVersion == latestWintryXposedVersion
}
}

View file

@ -88,25 +88,21 @@ fun InstalledItemCard(
horizontalArrangement = Arrangement.spacedBy(2.dp),
modifier = Modifier.clip(MaterialTheme.shapes.large),
) {
SegmentedButton(
icon = painterResource(R.drawable.ic_extension),
text = stringResource(R.string.plugins_title),
onClick = onOpenPlugins,
)
SegmentedButton(
icon = painterResource(R.drawable.ic_info),
text = stringResource(R.string.action_open_info),
onClick = onOpenInfo,
)
// // Plugins cannot be managed externally yet in Wintry
// SegmentedButton(
// icon = painterResource(R.drawable.ic_extension),
// text = stringResource(R.string.plugins_title),
// onClick = onOpenPlugins,
// )
// If the up-to-date status cannot be determined, assume it is up-to-date
if (data.isUpToDate ?: true) {
if (data.isUpToDate != false)
SegmentedButton(
icon = painterResource(R.drawable.ic_launch),
text = stringResource(R.string.action_launch),
onClick = onOpenApp,
icon = painterResource(R.drawable.ic_refresh),
text = stringResource(R.string.reinstall),
onClick = onUpdate,
)
} else {
else {
val warningColor = Color(0xFFFFBB33)
SegmentedButton(
@ -117,6 +113,16 @@ fun InstalledItemCard(
onClick = onUpdate,
)
}
SegmentedButton(
icon = painterResource(R.drawable.ic_info),
text = stringResource(R.string.action_open_info),
onClick = onOpenInfo,
)
SegmentedButton(
icon = painterResource(R.drawable.ic_launch),
text = stringResource(R.string.action_launch),
onClick = onOpenApp,
)
}
}
}

View file

@ -10,6 +10,13 @@ import kotlinx.parcelize.TypeParceler
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
enum class VersionPreference {
Stable,
Beta,
Alpha,
Custom;
}
@Immutable
@Parcelize
@Serializable
@ -33,6 +40,16 @@ data class PatchOptions(
* Replacement of the user-facing launcher icon.
*/
val iconReplacement: IconReplacement,
/**
* Version preference to fetch and install
*/
val versionPreference: VersionPreference,
/**
* Version to fetch and install when versionPreference is set to VersionPreference.Custom.
*/
val customVersionCode: String,
) : Parcelable {
@Immutable
@Parcelize
@ -109,6 +126,8 @@ data class PatchOptions(
packageName = "com.aliucord",
debuggable = false,
iconReplacement = IconReplacement.CustomColor(IconReplacement.AliucordColor),
versionPreference = VersionPreference.Stable,
customVersionCode = "126021",
)
}
}

View file

@ -9,6 +9,7 @@ import androidx.core.content.getSystemService
import cafe.adriel.voyager.core.model.ScreenModel
import cafe.adriel.voyager.core.model.screenModelScope
import com.aliucord.manager.manager.PreferencesManager
import com.aliucord.manager.ui.util.DiscordVersion
import com.aliucord.manager.util.*
class PatchOptionsModel(
@ -48,6 +49,26 @@ class PatchOptionsModel(
debuggable = value
}
// ---------- Version preference state ----------
var versionPreference by mutableStateOf(prefilledOptions.versionPreference)
private set
fun changeVersionPreference(value: VersionPreference) {
versionPreference = value
}
// ---------- Custom version code state ----------
var customVersionCode by mutableStateOf(prefilledOptions.customVersionCode)
private set
var customVersionCodeIsError by mutableStateOf(false)
private set
fun changeCustomVersionCode(value: String) {
customVersionCode = value
customVersionCodeIsError = !DiscordVersion.isValid(value)
}
// ---------- Other ----------
var showNetworkWarningDialog by mutableStateOf(!alreadyShownNetworkWarning && isNetworkDangerous())
private set
@ -76,6 +97,8 @@ class PatchOptionsModel(
packageName = packageName,
debuggable = debuggable,
iconReplacement = icon,
versionPreference = versionPreference,
customVersionCode = customVersionCode,
)
}

View file

@ -1,6 +1,7 @@
package com.aliucord.manager.ui.screens.patchopts
import android.os.Parcelable
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.*
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.*
@ -30,8 +31,7 @@ import com.aliucord.manager.ui.screens.patching.PatchingScreen
import com.aliucord.manager.ui.screens.patchopts.components.PackageNameStateLabel
import com.aliucord.manager.ui.screens.patchopts.components.PatchOptionsAppBar
import com.aliucord.manager.ui.screens.patchopts.components.options.*
import com.aliucord.manager.ui.util.InstallNotifications
import com.aliucord.manager.ui.util.spacedByLastAtBottom
import com.aliucord.manager.ui.util.*
import com.aliucord.manager.util.*
import kotlinx.coroutines.delay
import kotlinx.parcelize.IgnoredOnParcel
@ -101,6 +101,13 @@ class PatchOptionsScreen(
packageNameState = model.packageNameState,
setPackageName = model::changePackageName,
versionPreference = model.versionPreference,
setVersionPreference = model::changeVersionPreference,
customVersionCode = model.customVersionCode,
customVersionCodeIsError = model.customVersionCodeIsError,
setCustomVersionCode = model::changeCustomVersionCode,
isConfigValid = model.isConfigValid,
onInstall = onInstall@{
val iconConfig = iconModel.generateConfig()
@ -137,6 +144,13 @@ fun PatchOptionsScreenContent(
packageNameState: PackageNameState,
setPackageName: (String) -> Unit,
versionPreference: VersionPreference,
setVersionPreference: (VersionPreference) -> Unit,
customVersionCode: String,
customVersionCodeIsError: Boolean,
setCustomVersionCode: (String) -> Unit,
isConfigValid: Boolean,
onInstall: () -> Unit,
) {
@ -236,6 +250,40 @@ fun PatchOptionsScreenContent(
)
}
SelectPatchOption(
icon = painterResource(R.drawable.ic_app_shortcut),
name = "Preferred version",
description = "Choose which release channel or input a custom version code to use for installation.",
options = VersionPreference.entries.map { it.name },
selectedOption = versionPreference.name,
onOptionSelected = { selected ->
setVersionPreference(VersionPreference.valueOf(selected))
}
)
AnimatedVisibility(
visible = versionPreference == VersionPreference.Custom,
label = "Custom version code option visibility"
) {
TextPatchOption(
name = "Custom version code",
description = "Input a custom version code to use for installation.",
value = customVersionCode,
valueIsError = customVersionCodeIsError,
valueIsDefault = customVersionCode == PatchOptions.Default.customVersionCode,
onValueChange = setCustomVersionCode,
onValueReset = {
setCustomVersionCode(PatchOptions.Default.customVersionCode)
}
) {
VersionDisplay(
version = DiscordVersion.parseFromString(customVersionCode),
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.padding(start = 4.dp),
)
}
}
if (isDevMode) {
TextDivider(
text = stringResource(R.string.patchopts_divider_advanced),

View file

@ -0,0 +1,64 @@
package com.aliucord.manager.ui.screens.patchopts.components.options
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.text.style.TextOverflow
@Composable
fun SelectPatchOption(
icon: Painter,
name: String,
description: String,
options: List<String>,
selectedOption: String,
onOptionSelected: (String) -> Unit,
enabled: Boolean = true,
modifier: Modifier = Modifier,
) {
var expanded by remember { mutableStateOf(false) }
val interactionSource = remember(::MutableInteractionSource)
IconPatchOption(
icon = icon,
name = name,
description = description,
modifier = modifier.fillMaxWidth()
) {
FilledTonalButton(
onClick = { if (enabled) expanded = true },
enabled = enabled,
interactionSource = interactionSource,
) {
Text(
text = selectedOption,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
DropdownMenu(
expanded = expanded,
onDismissRequest = { expanded = false },
) {
options.forEach { option ->
DropdownMenuItem(
text = {
Text(
text = option,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
},
onClick = {
expanded = false
onOptionSelected(option)
},
enabled = enabled,
)
}
}
}
}
}

View file

@ -10,6 +10,9 @@ import kotlinx.parcelize.Parcelize
@Parcelize
@Immutable
sealed interface DiscordVersion : Comparable<DiscordVersion>, Parcelable {
@Parcelize
data object Invalid : DiscordVersion
@Parcelize
data object Error : DiscordVersion
@ -31,6 +34,7 @@ sealed interface DiscordVersion : Comparable<DiscordVersion>, Parcelable {
return when (this) {
is Error -> 0
is None -> 0
is Invalid -> 0
is Existing -> {
if (other is Existing) {
other.typelessCode.compareTo(typelessCode)
@ -44,6 +48,7 @@ sealed interface DiscordVersion : Comparable<DiscordVersion>, Parcelable {
@Composable
fun toDisplayName() = when (this) {
is Error -> stringResource(R.string.version_load_fail)
is Invalid -> stringResource(R.string.invalid_version_code_format)
is None -> stringResource(R.string.version_none)
is Existing -> when (type) {
Type.STABLE -> stringResource(R.string.version_stable)
@ -61,6 +66,23 @@ sealed interface DiscordVersion : Comparable<DiscordVersion>, Parcelable {
}
companion object {
val DISCORD_VERSION_REGEX = Regex("""^\d{3}[0-2]\d{2}$""")
fun parseFromString(version: String): DiscordVersion {
if (!DISCORD_VERSION_REGEX.matches(version)) {
return Invalid
}
val versionCode = version.toInt()
val type = parseVersionType(versionCode)
return Existing(
type = type,
name = "${versionCode / 1000}.${versionCode % 100}",
code = versionCode,
)
}
fun parseVersionType(versionCode: Int?): Type {
return when (versionCode?.div(100)?.mod(10)) {
0 -> Type.STABLE
@ -69,5 +91,9 @@ sealed interface DiscordVersion : Comparable<DiscordVersion>, Parcelable {
else -> Type.UNKNOWN
}
}
fun isValid(versionCode: String): Boolean {
return DISCORD_VERSION_REGEX.matches(versionCode)
}
}
}

View file

@ -252,4 +252,8 @@
<string name="downloading_resources_apk">Downloading resources apk</string>
<string name="injecting_wintryxposed_with_lspatch">Injecting WintryXposed with LSPatch</string>
<string name="downloading_wintryxposed">Downloading WintryXposed</string>
<string name="version_code">Version Code</string>
<string name="fetching_latest_discord_info">Fetching latest Discord info</string>
<string name="invalid_version_code_format">Invalid version code format</string>
<string name="reinstall">Reinstall</string>
</resources>