feat: async UI overhaul

Fixing lag on HomeScreen in the process.
This commit is contained in:
rushiiMachine 2025-07-05 11:59:11 -07:00
parent 8fdcab8aba
commit 69e82dedc5
No known key found for this signature in database
GPG key ID: DCBE5952BB3B6420
17 changed files with 214 additions and 172 deletions

View file

@ -1,6 +1,9 @@
package com.aliucord.manager package com.aliucord.manager
import android.app.Application import android.app.Application
import coil3.ImageLoader
import coil3.SingletonImageLoader
import coil3.annotation.DelicateCoilApi
import com.aliucord.manager.di.* import com.aliucord.manager.di.*
import com.aliucord.manager.installers.pm.PMInstaller import com.aliucord.manager.installers.pm.PMInstaller
import com.aliucord.manager.manager.* import com.aliucord.manager.manager.*
@ -17,6 +20,7 @@ import com.aliucord.manager.ui.screens.patchopts.PatchOptionsModel
import com.aliucord.manager.ui.screens.plugins.PluginsModel import com.aliucord.manager.ui.screens.plugins.PluginsModel
import com.aliucord.manager.ui.screens.settings.SettingsModel import com.aliucord.manager.ui.screens.settings.SettingsModel
import com.aliucord.manager.ui.widgets.updater.UpdaterViewModel import com.aliucord.manager.ui.widgets.updater.UpdaterViewModel
import kotlinx.coroutines.Dispatchers
import org.koin.android.ext.koin.androidContext import org.koin.android.ext.koin.androidContext
import org.koin.core.context.startKoin import org.koin.core.context.startKoin
import org.koin.core.module.dsl.* import org.koin.core.module.dsl.*
@ -77,5 +81,13 @@ class ManagerApplication : Application() {
singleOf(::PMInstaller) singleOf(::PMInstaller)
}) })
} }
// Limit parallel fetching of images using Coil
@OptIn(DelicateCoilApi::class)
SingletonImageLoader.setUnsafe { context ->
ImageLoader.Builder(context)
.fetcherCoroutineContext(Dispatchers.IO.limitedParallelism(5))
.build()
}
} }
} }

View file

@ -8,13 +8,17 @@ import io.ktor.client.request.HttpRequestBuilder
import io.ktor.client.request.request import io.ktor.client.request.request
import io.ktor.client.statement.bodyAsText import io.ktor.client.statement.bodyAsText
import io.ktor.http.isSuccess import io.ktor.http.isSuccess
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
class HttpService( class HttpService(
val json: Json, val json: Json,
val http: HttpClient, val http: HttpClient,
) { ) {
suspend inline fun <reified T> request(builder: HttpRequestBuilder.() -> Unit = {}): ApiResponse<T> { suspend inline fun <reified T> request(
crossinline builder: HttpRequestBuilder.() -> Unit = {},
): ApiResponse<T> = withContext(Dispatchers.IO) request@{
var body: String? = null var body: String? = null
val response = try { val response = try {
@ -24,7 +28,7 @@ class HttpService(
body = response.bodyAsText() body = response.bodyAsText()
if (T::class == String::class) { if (T::class == String::class) {
return ApiResponse.Success(body as T) return@request ApiResponse.Success(body as T)
} }
ApiResponse.Success(json.decodeFromString<T>(body)) ApiResponse.Success(json.decodeFromString<T>(body))
@ -42,6 +46,6 @@ class HttpService(
Log.e(BuildConfig.TAG, "Failed to fetch: error: $t, body: $body") Log.e(BuildConfig.TAG, "Failed to fetch: error: $t, body: $body")
ApiResponse.Failure(ApiFailure(t, body)) ApiResponse.Failure(ApiFailure(t, body))
} }
return response return@request response
} }
} }

View file

@ -6,7 +6,7 @@ import com.aliucord.manager.network.models.Contributor
import com.aliucord.manager.network.services.HttpService import com.aliucord.manager.network.services.HttpService
import com.aliucord.manager.network.utils.fold import com.aliucord.manager.network.utils.fold
import com.aliucord.manager.ui.util.toUnsafeImmutable import com.aliucord.manager.ui.util.toUnsafeImmutable
import com.aliucord.manager.util.launchBlock import com.aliucord.manager.util.launchIO
import io.ktor.client.request.url import io.ktor.client.request.url
class AboutModel( class AboutModel(
@ -16,7 +16,7 @@ class AboutModel(
fetchContributors() fetchContributors()
} }
fun fetchContributors() = screenModelScope.launchBlock { fun fetchContributors() = screenModelScope.launchIO {
mutableState.value = AboutScreenState.Loading mutableState.value = AboutScreenState.Loading
val response = http.request<List<Contributor>> { url(CONTRIBUTORS_API_URL) } val response = http.request<List<Contributor>> { url(CONTRIBUTORS_API_URL) }

View file

@ -25,11 +25,9 @@ import com.aliucord.manager.ui.screens.patchopts.PatchOptions
import com.aliucord.manager.ui.screens.patchopts.PatchOptionsScreen import com.aliucord.manager.ui.screens.patchopts.PatchOptionsScreen
import com.aliucord.manager.ui.util.DiscordVersion import com.aliucord.manager.ui.util.DiscordVersion
import com.aliucord.manager.ui.util.toUnsafeImmutable import com.aliucord.manager.ui.util.toUnsafeImmutable
import com.aliucord.manager.util.launchBlock import com.aliucord.manager.util.*
import com.aliucord.manager.util.showToast
import com.github.diamondminer88.zip.ZipReader import com.github.diamondminer88.zip.ZipReader
import kotlinx.coroutines.joinAll import kotlinx.coroutines.*
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.sync.withLock
import kotlinx.serialization.ExperimentalSerializationApi import kotlinx.serialization.ExperimentalSerializationApi
@ -53,22 +51,31 @@ class HomeModel(
refresh() refresh()
} }
fun refresh() = screenModelScope.launchBlock { fun refresh(delay: Boolean = false) = screenModelScope.launchIO {
if (refreshingLock.isLocked) return@launchBlock if (refreshingLock.isLocked) return@launchIO
if (delay) {
delay(250)
if (refreshingLock.isLocked)
return@launchIO
}
refreshingLock.withLock { refreshingLock.withLock {
val packages = fetchAliucordPackages() val packages = fetchAliucordPackages()
val jobs = listOf( val jobs = listOf(
screenModelScope.launch { fetchInstallations(packages) }, screenModelScope.launch(Dispatchers.IO) {
screenModelScope.launch { fetchInstallations(packages)
},
screenModelScope.launch(Dispatchers.IO) {
if (remoteDataJson == null || latestAliuhookVersion == null) if (remoteDataJson == null || latestAliuhookVersion == null)
fetchRemoteData() fetchRemoteData()
} }
) )
jobs.joinAll() jobs.joinAll()
refreshInstallationsUpToDate(packages) mainThread { refreshInstallationsUpToDate(packages) }
} }
} }
@ -104,7 +111,7 @@ class HomeModel(
@OptIn(ExperimentalSerializationApi::class) @OptIn(ExperimentalSerializationApi::class)
metadataFile?.let { json.decodeFromStream<InstallMetadata>(it.inputStream()) } metadataFile?.let { json.decodeFromStream<InstallMetadata>(it.inputStream()) }
} catch (t: Throwable) { } catch (t: Throwable) {
Log.w(BuildConfig.TAG, "Failed to parse Aliucord install metadata from package ${packageName}", t) Log.w(BuildConfig.TAG, "Failed to parse Aliucord install metadata from package $packageName", t)
null null
} }
@ -114,8 +121,11 @@ class HomeModel(
return PatchOptionsScreen(prefilledOptions = patchOptions) return PatchOptionsScreen(prefilledOptions = patchOptions)
} }
private fun fetchInstallations(packages: List<PackageInfo>) { private suspend fun fetchInstallations(packages: List<PackageInfo>) {
installsState = InstallsState.Fetching mainThread {
if (installsState !is InstallsState.Fetched)
installsState = InstallsState.Fetching
}
try { try {
val packageManager = application.packageManager val packageManager = application.packageManager
@ -143,19 +153,21 @@ class HomeModel(
) )
} }
installsState = if (aliucordInstallations.isNotEmpty()) { mainThread {
InstallsState.Fetched(data = aliucordInstallations.toUnsafeImmutable()) installsState = if (aliucordInstallations.isNotEmpty()) {
} else { InstallsState.Fetched(data = aliucordInstallations.toUnsafeImmutable())
InstallsState.None } else {
InstallsState.None
}
} }
} catch (t: Throwable) { } catch (t: Throwable) {
Log.e(BuildConfig.TAG, "Failed to query Aliucord installations", t) Log.e(BuildConfig.TAG, "Failed to query Aliucord installations", t)
installsState = InstallsState.Error mainThread { installsState = InstallsState.Error }
} }
} }
private fun refreshInstallationsUpToDate(packages: List<PackageInfo>) { private suspend fun refreshInstallationsUpToDate(packages: List<PackageInfo>) {
val installations = (installsState as? InstallsState.Fetched)?.data val installations = mainThread { (installsState as? InstallsState.Fetched)?.data }
?: return ?: return
try { try {
@ -166,22 +178,22 @@ class HomeModel(
data.copy(isUpToDate = isInstallationUpToDate(packageInfo)) data.copy(isUpToDate = isInstallationUpToDate(packageInfo))
} }
installsState = InstallsState.Fetched(data = newInstallations.toUnsafeImmutable()) mainThread { installsState = InstallsState.Fetched(data = newInstallations.toUnsafeImmutable()) }
} catch (t: Throwable) { } catch (t: Throwable) {
Log.e(BuildConfig.TAG, "Failed to check installations up-to-date", t) Log.e(BuildConfig.TAG, "Failed to check installations up-to-date", t)
installsState = InstallsState.Error mainThread { installsState = InstallsState.Error }
} }
} }
private suspend fun fetchRemoteData() { private suspend fun fetchRemoteData() {
listOf( listOf(
screenModelScope.launch { screenModelScope.launch(Dispatchers.IO) {
github.getBuildData().fold( github.getBuildData().fold(
success = { remoteDataJson = it }, success = { remoteDataJson = it },
fail = { Log.w(BuildConfig.TAG, "Failed to fetch remote build data", it) }, fail = { Log.w(BuildConfig.TAG, "Failed to fetch remote build data", it) },
) )
}, },
screenModelScope.launch { screenModelScope.launch(Dispatchers.IO) {
maven.getAliuhookVersion().fold( maven.getAliuhookVersion().fold(
success = { latestAliuhookVersion = it }, success = { latestAliuhookVersion = it },
fail = { Log.w(BuildConfig.TAG, "Failed to fetch latest Aliuhook version", it) }, fail = { Log.w(BuildConfig.TAG, "Failed to fetch latest Aliuhook version", it) },
@ -190,7 +202,7 @@ class HomeModel(
).joinAll() ).joinAll()
if (remoteDataJson == null || latestAliuhookVersion == null) { if (remoteDataJson == null || latestAliuhookVersion == null) {
application.showToast(R.string.home_network_fail) mainThread { application.showToast(R.string.home_network_fail) }
} }
} }

View file

@ -34,8 +34,7 @@ import com.aliucord.manager.ui.screens.patchopts.PatchOptionsScreen
import com.aliucord.manager.ui.screens.plugins.PluginsScreen import com.aliucord.manager.ui.screens.plugins.PluginsScreen
import com.aliucord.manager.ui.util.paddings.PaddingValuesSides import com.aliucord.manager.ui.util.paddings.PaddingValuesSides
import com.aliucord.manager.ui.util.paddings.exclude import com.aliucord.manager.ui.util.paddings.exclude
import com.aliucord.manager.util.pushOnce import com.aliucord.manager.util.*
import kotlinx.coroutines.launch
import kotlinx.parcelize.IgnoredOnParcel import kotlinx.parcelize.IgnoredOnParcel
import kotlinx.parcelize.Parcelize import kotlinx.parcelize.Parcelize
@ -52,7 +51,7 @@ class HomeScreen : Screen, Parcelable {
// Refresh installations list when the screen changes or activity resumes // Refresh installations list when the screen changes or activity resumes
LifecycleResumeEffect(Unit) { LifecycleResumeEffect(Unit) {
model.refresh() model.refresh(delay = true)
onPauseOrDispose {} onPauseOrDispose {}
} }
@ -66,8 +65,9 @@ class HomeScreen : Screen, Parcelable {
padding = padding, padding = padding,
onClickInstall = { navigator.pushOnce(PatchOptionsScreen()) }, onClickInstall = { navigator.pushOnce(PatchOptionsScreen()) },
onUpdate = { onUpdate = {
scope.launch { scope.launchIO {
navigator.push(model.createPrefilledPatchOptsScreen(it)) val screen = model.createPrefilledPatchOptsScreen(it)
mainThread { navigator.push(screen) }
} }
}, },
onOpenApp = model::openApp, onOpenApp = model::openApp,

View file

@ -11,8 +11,7 @@ import cafe.adriel.voyager.core.model.screenModelScope
import com.aliucord.manager.BuildConfig import com.aliucord.manager.BuildConfig
import com.aliucord.manager.R import com.aliucord.manager.R
import com.aliucord.manager.ui.screens.patchopts.PatchOptions.IconReplacement import com.aliucord.manager.ui.screens.patchopts.PatchOptions.IconReplacement
import com.aliucord.manager.util.launchBlock import com.aliucord.manager.util.*
import com.aliucord.manager.util.showToast
import dev.zt64.compose.pipette.HsvColor import dev.zt64.compose.pipette.HsvColor
import java.io.IOException import java.io.IOException
@ -43,7 +42,7 @@ class IconOptionsModel(
// ---------- Replacement color ---------- // // ---------- Replacement color ---------- //
var selectedImage by mutableStateOf<ByteArray?>(null) var selectedImage by mutableStateOf<ByteArray?>(null)
fun changeSelectedImageUri(uri: Uri) = screenModelScope.launchBlock { fun changeSelectedImageUri(uri: Uri) = screenModelScope.launchIO {
try { try {
// Check file size first // Check file size first
val query = application.contentResolver.query(uri, null, null, null, null) val query = application.contentResolver.query(uri, null, null, null, null)
@ -55,8 +54,8 @@ class IconOptionsModel(
} }
if (size > 1024 * 256) { // 256KiB if (size > 1024 * 256) { // 256KiB
application.showToast(R.string.iconopts_failed_image_too_big) mainThread { application.showToast(R.string.iconopts_failed_image_too_big) }
return@launchBlock return@launchIO
} }
// Read file bytes // Read file bytes
@ -65,10 +64,10 @@ class IconOptionsModel(
?.use { it.readBytes() } ?.use { it.readBytes() }
?: throw IOException("Failed to open input stream") ?: throw IOException("Failed to open input stream")
selectedImage = bytes mainThread { selectedImage = bytes }
} catch (t: Throwable) { } catch (t: Throwable) {
application.showToast(R.string.iconopts_failed_image)
Log.w(BuildConfig.TAG, "Failed to open selected foreground replacement image", t) Log.w(BuildConfig.TAG, "Failed to open selected foreground replacement image", t)
mainThread { application.showToast(R.string.iconopts_failed_image) }
} }
} }

View file

@ -31,8 +31,8 @@ class LogScreenModel(
/** /**
* Formats the log data into a file and writes it to the downloads folder. * Formats the log data into a file and writes it to the downloads folder.
*/ */
fun saveLog() = screenModelScope.launchBlock { fun saveLog() = screenModelScope.launchIO {
val data = data ?: return@launchBlock val data = data ?: return@launchIO
val formattedDate = data.getFormattedInstallDate() val formattedDate = data.getFormattedInstallDate()
val content = data.getLogFileContents() val content = data.getLogFileContents()
@ -43,8 +43,8 @@ class LogScreenModel(
/** /**
* Writes the log to internal cache and launches a share intent of the log file. * Writes the log to internal cache and launches a share intent of the log file.
*/ */
fun shareLog() = screenModelScope.launchBlock { fun shareLog() {
val data = data ?: return@launchBlock val data = data ?: return
val formattedDate = data.getFormattedInstallDate() val formattedDate = data.getFormattedInstallDate()
val formattedName = "Aliucord Install $formattedDate.log" val formattedName = "Aliucord Install $formattedDate.log"
val content = data.getLogFileContents() val content = data.getLogFileContents()
@ -79,14 +79,16 @@ class LogScreenModel(
} }
} }
private fun loadLogData() = screenModelScope.launchBlock { private fun loadLogData() = screenModelScope.launchIO {
val result = logs.fetchInstallData(installId) val result = logs.fetchInstallData(installId)
if (result != null) { mainThread {
data = result if (result != null) {
} else { data = result
shouldCloseScreen = true } else {
application.showToast(R.string.network_load_fail) shouldCloseScreen = true
application.showToast(R.string.network_load_fail)
}
} }
} }
} }

View file

@ -8,8 +8,7 @@ import cafe.adriel.voyager.core.model.ScreenModel
import cafe.adriel.voyager.core.model.screenModelScope import cafe.adriel.voyager.core.model.screenModelScope
import com.aliucord.manager.R import com.aliucord.manager.R
import com.aliucord.manager.manager.InstallLogManager import com.aliucord.manager.manager.InstallLogManager
import com.aliucord.manager.util.launchBlock import com.aliucord.manager.util.*
import com.aliucord.manager.util.showToast
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList import kotlinx.collections.immutable.toImmutableList
@ -26,18 +25,21 @@ class LogsListScreenModel(
loadLogsList() loadLogsList()
} }
fun deleteLogs() = screenModelScope.launchBlock { fun deleteLogs() = screenModelScope.launchIO {
logsManager.deleteAllEntries() logsManager.deleteAllEntries()
logEntries.clear()
application.showToast(R.string.logs_status_delete_success) mainThread {
logEntries.clear()
application.showToast(R.string.logs_status_delete_success)
}
} }
private fun loadLogsList() = screenModelScope.launchBlock { private fun loadLogsList() = screenModelScope.launchIO {
for (installId in logsManager.fetchInstallDataEntries()) { for (installId in logsManager.fetchInstallDataEntries()) {
val data = logsManager.fetchInstallData(id = installId) val data = logsManager.fetchInstallData(id = installId)
?: continue ?: continue
logEntries += LogEntry( val entry = LogEntry(
id = data.id, id = data.id,
isError = data.isError, isError = data.isError,
installDate = DateUtils.getRelativeDateTimeString( installDate = DateUtils.getRelativeDateTimeString(
@ -53,6 +55,8 @@ class LogsListScreenModel(
?.take(3) ?.take(3)
?.toImmutableList(), ?.toImmutableList(),
) )
mainThread { logEntries += entry }
} }
} }
} }

View file

@ -18,8 +18,7 @@ import com.aliucord.manager.patcher.steps.install.InstallStep
import com.aliucord.manager.patcher.util.InsufficientStorageException import com.aliucord.manager.patcher.util.InsufficientStorageException
import com.aliucord.manager.ui.screens.patchopts.PatchOptions import com.aliucord.manager.ui.screens.patchopts.PatchOptions
import com.aliucord.manager.ui.util.toUnsafeImmutable import com.aliucord.manager.ui.util.toUnsafeImmutable
import com.aliucord.manager.util.launchBlock import com.aliucord.manager.util.*
import com.aliucord.manager.util.showToast
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.ImmutableMap import kotlinx.collections.immutable.ImmutableMap
import kotlinx.coroutines.* import kotlinx.coroutines.*
@ -77,14 +76,14 @@ class PatchingScreenModel(
} }
} }
fun clearCache() = screenModelScope.launchBlock { fun clearCache() = screenModelScope.launchIO {
paths.clearCache() paths.clearCache()
application.showToast(R.string.action_cleared_cache) mainThread { application.showToast(R.string.action_cleared_cache) }
} }
fun getCurrentInstallId(): String? = installId fun getCurrentInstallId(): String? = installId
fun cancelInstall() = screenModelScope.launchBlock(Dispatchers.IO) { fun cancelInstall() = screenModelScope.launchIO {
runnerJob?.cancel("Manual cancellation") runnerJob?.cancel("Manual cancellation")
val incompleteDownloadStep = stepRunner?.steps val incompleteDownloadStep = stepRunner?.steps
@ -95,7 +94,7 @@ class PatchingScreenModel(
paths.patchingWorkingDir().deleteRecursively() paths.patchingWorkingDir().deleteRecursively()
} }
fun install() = screenModelScope.launch { fun install() = screenModelScope.launchBlock {
runnerJob?.cancel("Manual cancellation") runnerJob?.cancel("Manual cancellation")
steps = null steps = null
@ -104,7 +103,7 @@ class PatchingScreenModel(
startTime = Clock.System.now() startTime = Clock.System.now()
mutableState.value = PatchingScreenState.Working mutableState.value = PatchingScreenState.Working
runnerJob = screenModelScope.launch { runnerJob = screenModelScope.launch(Dispatchers.Default) {
Log.i(BuildConfig.TAG, "Starting installation with environment:\n" + installLogs.getEnvironmentInfo()) Log.i(BuildConfig.TAG, "Starting installation with environment:\n" + installLogs.getEnvironmentInfo())
try { try {

View file

@ -9,8 +9,7 @@ import androidx.core.content.getSystemService
import cafe.adriel.voyager.core.model.ScreenModel import cafe.adriel.voyager.core.model.ScreenModel
import cafe.adriel.voyager.core.model.screenModelScope import cafe.adriel.voyager.core.model.screenModelScope
import com.aliucord.manager.manager.PreferencesManager import com.aliucord.manager.manager.PreferencesManager
import com.aliucord.manager.util.debounce import com.aliucord.manager.util.*
import kotlinx.coroutines.launch
class PatchOptionsModel( class PatchOptionsModel(
prefilledOptions: PatchOptions, prefilledOptions: PatchOptions,
@ -119,24 +118,26 @@ class PatchOptionsModel(
private val fetchPkgNameStateDebounced: () -> Unit = private val fetchPkgNameStateDebounced: () -> Unit =
screenModelScope.debounce(100L, function = ::fetchPkgNameState) screenModelScope.debounce(100L, function = ::fetchPkgNameState)
private fun fetchPkgNameState() { private suspend fun fetchPkgNameState() {
if (packageName.length !in (3..150) || !PACKAGE_REGEX.matches(this.packageName)) { val state = if (packageName.length !in (3..150) || !PACKAGE_REGEX.matches(this.packageName)) {
packageNameState = PackageNameState.Invalid PackageNameState.Invalid
} else { } else {
try { try {
context.packageManager.getPackageInfo(packageName, 0) context.packageManager.getPackageInfo(packageName, 0)
packageNameState = PackageNameState.Taken PackageNameState.Taken
} catch (_: NameNotFoundException) { } catch (_: NameNotFoundException) {
packageNameState = PackageNameState.Ok PackageNameState.Ok
} }
} }
mainThread { packageNameState = state }
} }
init { init {
screenModelScope.launch { fetchPkgNameState() }
if (!prefs.showNetworkWarning) if (!prefs.showNetworkWarning)
showNetworkWarningDialog = false showNetworkWarningDialog = false
screenModelScope.launchBlock { fetchPkgNameState() }
} }
companion object { companion object {

View file

@ -12,15 +12,14 @@ import com.aliucord.manager.ui.screens.plugins.model.PluginItem
import com.aliucord.manager.ui.screens.plugins.model.PluginManifest import com.aliucord.manager.ui.screens.plugins.model.PluginManifest
import com.aliucord.manager.ui.util.emptyImmutableList import com.aliucord.manager.ui.util.emptyImmutableList
import com.aliucord.manager.ui.util.toUnsafeImmutable import com.aliucord.manager.ui.util.toUnsafeImmutable
import com.aliucord.manager.util.launchBlock import com.aliucord.manager.util.*
import com.aliucord.manager.util.showToast
import com.github.diamondminer88.zip.ZipReader import com.github.diamondminer88.zip.ZipReader
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.* import kotlinx.coroutines.flow.*
import kotlinx.coroutines.plus
import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import kotlinx.serialization.ExperimentalSerializationApi import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.json.* import kotlinx.serialization.json.*
import java.io.File import java.io.File
@ -58,7 +57,7 @@ class PluginsModel(
}.toUnsafeImmutable() }.toUnsafeImmutable()
} }
}.stateIn( }.stateIn(
scope = screenModelScope, scope = screenModelScope + Dispatchers.Default,
started = SharingStarted.WhileSubscribed(replayExpiration = Duration.ZERO), started = SharingStarted.WhileSubscribed(replayExpiration = Duration.ZERO),
initialValue = plugins.value, initialValue = plugins.value,
) )
@ -83,36 +82,32 @@ class PluginsModel(
showUninstallDialog = null showUninstallDialog = null
} }
fun uninstallPlugin(pluginPath: String) = screenModelScope.launchBlock(Dispatchers.IO) { fun uninstallPlugin(plugin: PluginItem) = screenModelScope.launchIO {
if (!plugins.value.any { it.path == pluginPath }) { if (!plugins.value.any { it.path == plugin.path }) {
hideUninstallDialog() mainThread { hideUninstallDialog() }
return@launchBlock return@launchIO
} }
val deleteSuccess = try { val deleteSuccess = try {
File(pluginPath).delete() File(plugin.path).delete()
} catch (t: Throwable) { } catch (t: Throwable) {
Log.e(BuildConfig.TAG, "Failed to delete plugin", t) Log.e(BuildConfig.TAG, "Failed to delete plugin", t)
false false
} }
if (!deleteSuccess) { if (!deleteSuccess) {
withContext(Dispatchers.Main) { mainThread {
hideUninstallDialog()
context.showToast(R.string.plugins_error) context.showToast(R.string.plugins_error)
} }
hideUninstallDialog() return@launchIO
return@launchBlock
} }
withContext(Dispatchers.Main) { plugins.update { (it - plugin).toUnsafeImmutable() }
plugins.getAndUpdate { mainThread { hideUninstallDialog() }
it.filter { it.path != pluginPath }.toUnsafeImmutable()
}
}
hideUninstallDialog()
} }
fun setPluginEnabled(pluginName: String, enabled: Boolean) = screenModelScope.launchBlock(Dispatchers.IO) { fun setPluginEnabled(pluginName: String, enabled: Boolean) = screenModelScope.launchIO {
aliucordJsonMutex.withLock { aliucordJsonMutex.withLock {
@OptIn(ExperimentalSerializationApi::class) @OptIn(ExperimentalSerializationApi::class)
try { try {
@ -126,11 +121,11 @@ class PluginsModel(
.use { json.encodeToStream(settings, it) } .use { json.encodeToStream(settings, it) }
} catch (t: Throwable) { } catch (t: Throwable) {
Log.e(BuildConfig.TAG, "Failed to write Aliucord.json", t) Log.e(BuildConfig.TAG, "Failed to write Aliucord.json", t)
withContext(Dispatchers.Main) { error = true } mainThread { error = true }
} }
} }
withContext(Dispatchers.Main) { mainThread {
plugins.value.forEach { plugins.value.forEach {
if (it.manifest.name == pluginName) if (it.manifest.name == pluginName)
it.enabled = enabled it.enabled = enabled
@ -139,7 +134,7 @@ class PluginsModel(
} }
// Called by screen to load initial data // Called by screen to load initial data
fun refreshData() = screenModelScope.launchBlock(Dispatchers.IO) { fun refreshData() = screenModelScope.launchIO {
loadPlugins() loadPlugins()
loadPluginsEnabled() loadPluginsEnabled()
} }
@ -157,11 +152,11 @@ class PluginsModel(
.mapValues { it.value.jsonPrimitive.boolean } .mapValues { it.value.jsonPrimitive.boolean }
} catch (t: Throwable) { } catch (t: Throwable) {
Log.e(BuildConfig.TAG, "Failed to load Aliucord.json", t) Log.e(BuildConfig.TAG, "Failed to load Aliucord.json", t)
withContext(Dispatchers.Main) { error = true } mainThread { error = true }
emptyMap() emptyMap()
} }
withContext(Dispatchers.Main) { mainThread {
plugins.value.forEach { plugins.value.forEach {
if (!pluginToggles.getOrDefault(it.manifest.name, true)) if (!pluginToggles.getOrDefault(it.manifest.name, true))
it.enabled = false it.enabled = false
@ -185,12 +180,10 @@ class PluginsModel(
) )
} }
withContext(Dispatchers.Main) { plugins.value = pluginItems.toUnsafeImmutable()
plugins.value = pluginItems.toUnsafeImmutable()
}
} catch (t: Throwable) { } catch (t: Throwable) {
Log.e(BuildConfig.TAG, "Failed to load plugins", t) Log.e(BuildConfig.TAG, "Failed to load plugins", t)
withContext(Dispatchers.Main) { error = true } mainThread { error = true }
} }
} }

View file

@ -46,7 +46,7 @@ class PluginsScreen : Screen, Parcelable {
model.showUninstallDialog?.let { plugin -> model.showUninstallDialog?.let { plugin ->
UninstallPluginDialog( UninstallPluginDialog(
pluginName = plugin.manifest.name, pluginName = plugin.manifest.name,
onConfirm = { model.uninstallPlugin(plugin.path) }, onConfirm = { model.uninstallPlugin(plugin) },
onDismiss = model::hideUninstallDialog onDismiss = model::hideUninstallDialog
) )
} }

View file

@ -43,10 +43,13 @@ class SettingsModel(
preferences.keepPatchedApks = value preferences.keepPatchedApks = value
} }
fun clearCache() = screenModelScope.launchBlock { fun clearCache() = screenModelScope.launchIO {
paths.clearCache() paths.clearCache()
patchedApkExists = false
application.showToast(R.string.action_cleared_cache) mainThread {
patchedApkExists = false
application.showToast(R.string.action_cleared_cache)
}
} }
fun copyInstallInfo() { fun copyInstallInfo() {
@ -54,7 +57,7 @@ class SettingsModel(
application.showToast(R.string.action_copied) application.showToast(R.string.action_copied)
} }
fun shareApk() = screenModelScope.launchBlock { fun shareApk() {
val file = paths.patchingWorkingDir().resolve("patched.apk") val file = paths.patchingWorkingDir().resolve("patched.apk")
val fileUri = FileProvider.getUriForFile( val fileUri = FileProvider.getUriForFile(
/* context = */ application, /* context = */ application,

View file

@ -2,7 +2,7 @@ package com.aliucord.manager.ui.widgets.updater
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.material3.* import androidx.compose.material3.*
import androidx.compose.runtime.Composable import androidx.compose.runtime.*
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
@ -18,6 +18,8 @@ fun UpdaterDialog(
) { ) {
if (!viewModel.showDialog) return if (!viewModel.showDialog) return
val isWorking by viewModel.isWorking.collectAsState(initial = false)
AlertDialog( AlertDialog(
confirmButton = { confirmButton = {
FilledTonalButton( FilledTonalButton(
@ -27,7 +29,7 @@ fun UpdaterDialog(
contentColor = MaterialTheme.colorScheme.onPrimary, contentColor = MaterialTheme.colorScheme.onPrimary,
), ),
) { ) {
if (!viewModel.isWorking) { if (isWorking) {
Text(stringResource(R.string.action_update)) Text(stringResource(R.string.action_update))
} else { } else {
CircularProgressIndicator( CircularProgressIndicator(
@ -42,7 +44,7 @@ fun UpdaterDialog(
dismissButton = { dismissButton = {
TextButton( TextButton(
onClick = viewModel::dismissDialog, onClick = viewModel::dismissDialog,
enabled = !viewModel.isWorking enabled = isWorking,
) { ) {
Text(stringResource(R.string.action_dismiss)) Text(stringResource(R.string.action_dismiss))
} }

View file

@ -17,8 +17,9 @@ import com.aliucord.manager.manager.download.KtorDownloadManager
import com.aliucord.manager.network.services.AliucordGithubService import com.aliucord.manager.network.services.AliucordGithubService
import com.aliucord.manager.network.utils.SemVer import com.aliucord.manager.network.utils.SemVer
import com.aliucord.manager.network.utils.getOrThrow import com.aliucord.manager.network.utils.getOrThrow
import com.aliucord.manager.util.showToast import com.aliucord.manager.util.*
import kotlinx.coroutines.launch import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlin.system.exitProcess import kotlin.system.exitProcess
class UpdaterViewModel( class UpdaterViewModel(
@ -31,18 +32,18 @@ class UpdaterViewModel(
private set private set
var targetVersion by mutableStateOf<String?>(null) var targetVersion by mutableStateOf<String?>(null)
private set private set
var isWorking by mutableStateOf(false) val isWorking: Flow<Boolean>
private set private field = MutableStateFlow(false)
private var targetApkUrl: String? = null private var targetApkUrl: String? = null
init { init {
viewModelScope.launch { viewModelScope.launchIO {
try { try {
fetchInfo() fetchInfo()
} catch (t: Throwable) { } catch (t: Throwable) {
Log.e(BuildConfig.TAG, "Failed to check for updates!", t) Log.e(BuildConfig.TAG, "Failed to check for updates!", t)
application.showToast(R.string.updater_check_fail) mainThread { application.showToast(R.string.updater_check_fail) }
} }
} }
} }
@ -51,66 +52,66 @@ class UpdaterViewModel(
showDialog = false showDialog = false
} }
fun triggerUpdate() { fun triggerUpdate() = viewModelScope.launchIO {
if (isWorking) return if (!isWorking.compareAndSet(expect = false, update = true))
val url = targetApkUrl ?: return return@launchIO
viewModelScope.launch { val url = targetApkUrl ?: return@launchIO
isWorking = true val apkFile = application.cacheDir.resolve("manager.apk")
val apkFile = application.cacheDir.resolve("manager.apk") try {
apkFile.apply {
parentFile!!.mkdirs()
exists() && delete()
}
try { val installer = installers.getInstaller(InstallerSetting.PM)
apkFile.apply {
parentFile!!.mkdirs() when (val downloadResult = downloader.download(url, apkFile)) {
exists() && delete() is IDownloadManager.Result.Success ->
Log.d(BuildConfig.TAG, "Downloaded update")
is IDownloadManager.Result.Cancelled -> {
Log.i(BuildConfig.TAG, "Update cancelled")
return@launchIO
} }
val installer = installers.getInstaller(InstallerSetting.PM) is IDownloadManager.Result.Error ->
throw IllegalStateException("Failed to download update: ${downloadResult.getDebugReason()}", downloadResult.getError())
}
when (val downloadResult = downloader.download(url, apkFile)) { val installResult = installer.waitInstall(
is IDownloadManager.Result.Success -> apks = listOf(apkFile),
Log.d(BuildConfig.TAG, "Downloaded update") silent = true,
)
is IDownloadManager.Result.Cancelled -> { when (installResult) {
Log.i(BuildConfig.TAG, "Update cancelled") InstallerResult.Success -> {
return@launch Log.w(BuildConfig.TAG, "Update completed without restarting app!")
} exitProcess(1)
is IDownloadManager.Result.Error ->
throw IllegalStateException("Failed to download update: ${downloadResult.getDebugReason()}", downloadResult.getError())
} }
val installResult = installer.waitInstall( is InstallerResult.Cancelled ->
apks = listOf(apkFile), Log.i(BuildConfig.TAG, "Update cancelled")
silent = true,
)
when (installResult) { is InstallerResult.Error ->
InstallerResult.Success -> { throw Exception("Failed to install update: ${installResult.getDebugReason()}")
Log.w(BuildConfig.TAG, "Update completed without restarting app!") }
exitProcess(1) } catch (t: Throwable) {
} Log.e(BuildConfig.TAG, "Failed to perform update!")
t.printStackTrace()
is InstallerResult.Cancelled -> mainThread {
Log.i(BuildConfig.TAG, "Update cancelled")
is InstallerResult.Error ->
throw Exception("Failed to install update: ${installResult.getDebugReason()}")
}
} catch (t: Throwable) {
Log.e(BuildConfig.TAG, "Failed to perform update!")
t.printStackTrace()
application.showToast(R.string.updater_update_fail) application.showToast(R.string.updater_update_fail)
launchReleasesPage() launchReleasesPage()
} finally { }
isWorking = false } finally {
isWorking.value = false
try { try {
apkFile.apply { exists() && delete() } apkFile.apply { exists() && delete() }
} catch (t: Throwable) { } catch (t: Throwable) {
Log.w(BuildConfig.TAG, "Failed to clean up installed update!", t) Log.w(BuildConfig.TAG, "Failed to clean up installed update!", t)
}
} }
} }
} }
@ -151,10 +152,12 @@ class UpdaterViewModel(
return return
} }
targetVersion = version.toString()
targetApkUrl = apkUrl
showDialog = true
Log.d(BuildConfig.TAG, "Found an update! $targetVersion $targetApkUrl") Log.d(BuildConfig.TAG, "Found an update! $targetVersion $targetApkUrl")
mainThread {
targetVersion = version.toString()
targetApkUrl = apkUrl
showDialog = true
}
} }
private fun launchReleasesPage() { private fun launchReleasesPage() {

View file

@ -28,18 +28,18 @@ fun Context.copyToClipboard(text: String) {
) )
} }
fun Context.saveFile(name: String, text: String): Boolean { suspend fun Context.saveFile(name: String, text: String): Boolean {
val downloads = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS) val downloads = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS)
val file = File(downloads, name) val file = File(downloads, name)
val base = downloads.parentFile!! val base = downloads.parentFile!!
return try { return try {
file.writeText(text) file.writeText(text)
showToast(R.string.installer_file_save_success, file.toRelativeString(base)) mainThread { showToast(R.string.installer_file_save_success, file.toRelativeString(base)) }
true true
} catch (e: Throwable) { } catch (e: Throwable) {
Log.e(BuildConfig.TAG, Log.getStackTraceString(e)) Log.e(BuildConfig.TAG, Log.getStackTraceString(e))
showToast(R.string.installer_file_save_failed, file.toRelativeString(base)) mainThread { showToast(R.string.installer_file_save_failed, file.toRelativeString(base)) }
false false
} }
} }

View file

@ -6,17 +6,25 @@ import kotlinx.coroutines.*
import kotlin.coroutines.CoroutineContext import kotlin.coroutines.CoroutineContext
/** /**
* Launch a Job for a block without returning anything * Launches a Job in a fixed-size CPU-bound thread pool.
* Used for heavy or intensive CPU-bound tasks.
*/ */
inline fun CoroutineScope.launchBlock( inline fun CoroutineScope.launchBlock(
context: CoroutineContext = Dispatchers.Main, context: CoroutineContext = Dispatchers.Default,
noinline block: suspend CoroutineScope.() -> Unit, noinline block: suspend CoroutineScope.() -> Unit,
) { ) {
launch(context, block = block) launch(context, block = block)
} }
/** /**
* Wrapper util to run a block with the main thread context * Launches a Job on a background thread in a dynamically sized thread pool.
* Used for IO or other lightweight tasks that spend most of their time waiting.
*/ */
suspend inline fun mainThread(noinline block: CoroutineScope.() -> Unit) = inline fun CoroutineScope.launchIO(noinline block: suspend CoroutineScope.() -> Unit) =
launchBlock(Dispatchers.IO, block)
/**
* Utility wrapper around [withContext] to switch to the main thread for the [block].
*/
suspend inline fun <T> mainThread(noinline block: suspend CoroutineScope.() -> T): T =
withContext(Dispatchers.Main, block) withContext(Dispatchers.Main, block)