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
import android.app.Application
import coil3.ImageLoader
import coil3.SingletonImageLoader
import coil3.annotation.DelicateCoilApi
import com.aliucord.manager.di.*
import com.aliucord.manager.installers.pm.PMInstaller
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.settings.SettingsModel
import com.aliucord.manager.ui.widgets.updater.UpdaterViewModel
import kotlinx.coroutines.Dispatchers
import org.koin.android.ext.koin.androidContext
import org.koin.core.context.startKoin
import org.koin.core.module.dsl.*
@ -77,5 +81,13 @@ class ManagerApplication : Application() {
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.statement.bodyAsText
import io.ktor.http.isSuccess
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.serialization.json.Json
class HttpService(
val json: Json,
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
val response = try {
@ -24,7 +28,7 @@ class HttpService(
body = response.bodyAsText()
if (T::class == String::class) {
return ApiResponse.Success(body as T)
return@request ApiResponse.Success(body as T)
}
ApiResponse.Success(json.decodeFromString<T>(body))
@ -42,6 +46,6 @@ class HttpService(
Log.e(BuildConfig.TAG, "Failed to fetch: error: $t, body: $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.utils.fold
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
class AboutModel(
@ -16,7 +16,7 @@ class AboutModel(
fetchContributors()
}
fun fetchContributors() = screenModelScope.launchBlock {
fun fetchContributors() = screenModelScope.launchIO {
mutableState.value = AboutScreenState.Loading
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.util.DiscordVersion
import com.aliucord.manager.ui.util.toUnsafeImmutable
import com.aliucord.manager.util.launchBlock
import com.aliucord.manager.util.showToast
import com.aliucord.manager.util.*
import com.github.diamondminer88.zip.ZipReader
import kotlinx.coroutines.joinAll
import kotlinx.coroutines.launch
import kotlinx.coroutines.*
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.serialization.ExperimentalSerializationApi
@ -53,22 +51,31 @@ class HomeModel(
refresh()
}
fun refresh() = screenModelScope.launchBlock {
if (refreshingLock.isLocked) return@launchBlock
fun refresh(delay: Boolean = false) = screenModelScope.launchIO {
if (refreshingLock.isLocked) return@launchIO
if (delay) {
delay(250)
if (refreshingLock.isLocked)
return@launchIO
}
refreshingLock.withLock {
val packages = fetchAliucordPackages()
val jobs = listOf(
screenModelScope.launch { fetchInstallations(packages) },
screenModelScope.launch {
screenModelScope.launch(Dispatchers.IO) {
fetchInstallations(packages)
},
screenModelScope.launch(Dispatchers.IO) {
if (remoteDataJson == null || latestAliuhookVersion == null)
fetchRemoteData()
}
)
jobs.joinAll()
refreshInstallationsUpToDate(packages)
mainThread { refreshInstallationsUpToDate(packages) }
}
}
@ -104,7 +111,7 @@ class HomeModel(
@OptIn(ExperimentalSerializationApi::class)
metadataFile?.let { json.decodeFromStream<InstallMetadata>(it.inputStream()) }
} 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
}
@ -114,8 +121,11 @@ class HomeModel(
return PatchOptionsScreen(prefilledOptions = patchOptions)
}
private fun fetchInstallations(packages: List<PackageInfo>) {
installsState = InstallsState.Fetching
private suspend fun fetchInstallations(packages: List<PackageInfo>) {
mainThread {
if (installsState !is InstallsState.Fetched)
installsState = InstallsState.Fetching
}
try {
val packageManager = application.packageManager
@ -143,19 +153,21 @@ class HomeModel(
)
}
installsState = if (aliucordInstallations.isNotEmpty()) {
InstallsState.Fetched(data = aliucordInstallations.toUnsafeImmutable())
} else {
InstallsState.None
mainThread {
installsState = if (aliucordInstallations.isNotEmpty()) {
InstallsState.Fetched(data = aliucordInstallations.toUnsafeImmutable())
} else {
InstallsState.None
}
}
} catch (t: Throwable) {
Log.e(BuildConfig.TAG, "Failed to query Aliucord installations", t)
installsState = InstallsState.Error
mainThread { installsState = InstallsState.Error }
}
}
private fun refreshInstallationsUpToDate(packages: List<PackageInfo>) {
val installations = (installsState as? InstallsState.Fetched)?.data
private suspend fun refreshInstallationsUpToDate(packages: List<PackageInfo>) {
val installations = mainThread { (installsState as? InstallsState.Fetched)?.data }
?: return
try {
@ -166,22 +178,22 @@ class HomeModel(
data.copy(isUpToDate = isInstallationUpToDate(packageInfo))
}
installsState = InstallsState.Fetched(data = newInstallations.toUnsafeImmutable())
mainThread { installsState = InstallsState.Fetched(data = newInstallations.toUnsafeImmutable()) }
} catch (t: Throwable) {
Log.e(BuildConfig.TAG, "Failed to check installations up-to-date", t)
installsState = InstallsState.Error
mainThread { installsState = InstallsState.Error }
}
}
private suspend fun fetchRemoteData() {
listOf(
screenModelScope.launch {
screenModelScope.launch(Dispatchers.IO) {
github.getBuildData().fold(
success = { remoteDataJson = it },
fail = { Log.w(BuildConfig.TAG, "Failed to fetch remote build data", it) },
)
},
screenModelScope.launch {
screenModelScope.launch(Dispatchers.IO) {
maven.getAliuhookVersion().fold(
success = { latestAliuhookVersion = it },
fail = { Log.w(BuildConfig.TAG, "Failed to fetch latest Aliuhook version", it) },
@ -190,7 +202,7 @@ class HomeModel(
).joinAll()
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.util.paddings.PaddingValuesSides
import com.aliucord.manager.ui.util.paddings.exclude
import com.aliucord.manager.util.pushOnce
import kotlinx.coroutines.launch
import com.aliucord.manager.util.*
import kotlinx.parcelize.IgnoredOnParcel
import kotlinx.parcelize.Parcelize
@ -52,7 +51,7 @@ class HomeScreen : Screen, Parcelable {
// Refresh installations list when the screen changes or activity resumes
LifecycleResumeEffect(Unit) {
model.refresh()
model.refresh(delay = true)
onPauseOrDispose {}
}
@ -66,8 +65,9 @@ class HomeScreen : Screen, Parcelable {
padding = padding,
onClickInstall = { navigator.pushOnce(PatchOptionsScreen()) },
onUpdate = {
scope.launch {
navigator.push(model.createPrefilledPatchOptsScreen(it))
scope.launchIO {
val screen = model.createPrefilledPatchOptsScreen(it)
mainThread { navigator.push(screen) }
}
},
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.R
import com.aliucord.manager.ui.screens.patchopts.PatchOptions.IconReplacement
import com.aliucord.manager.util.launchBlock
import com.aliucord.manager.util.showToast
import com.aliucord.manager.util.*
import dev.zt64.compose.pipette.HsvColor
import java.io.IOException
@ -43,7 +42,7 @@ class IconOptionsModel(
// ---------- Replacement color ---------- //
var selectedImage by mutableStateOf<ByteArray?>(null)
fun changeSelectedImageUri(uri: Uri) = screenModelScope.launchBlock {
fun changeSelectedImageUri(uri: Uri) = screenModelScope.launchIO {
try {
// Check file size first
val query = application.contentResolver.query(uri, null, null, null, null)
@ -55,8 +54,8 @@ class IconOptionsModel(
}
if (size > 1024 * 256) { // 256KiB
application.showToast(R.string.iconopts_failed_image_too_big)
return@launchBlock
mainThread { application.showToast(R.string.iconopts_failed_image_too_big) }
return@launchIO
}
// Read file bytes
@ -65,10 +64,10 @@ class IconOptionsModel(
?.use { it.readBytes() }
?: throw IOException("Failed to open input stream")
selectedImage = bytes
mainThread { selectedImage = bytes }
} catch (t: Throwable) {
application.showToast(R.string.iconopts_failed_image)
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.
*/
fun saveLog() = screenModelScope.launchBlock {
val data = data ?: return@launchBlock
fun saveLog() = screenModelScope.launchIO {
val data = data ?: return@launchIO
val formattedDate = data.getFormattedInstallDate()
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.
*/
fun shareLog() = screenModelScope.launchBlock {
val data = data ?: return@launchBlock
fun shareLog() {
val data = data ?: return
val formattedDate = data.getFormattedInstallDate()
val formattedName = "Aliucord Install $formattedDate.log"
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)
if (result != null) {
data = result
} else {
shouldCloseScreen = true
application.showToast(R.string.network_load_fail)
mainThread {
if (result != null) {
data = result
} else {
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 com.aliucord.manager.R
import com.aliucord.manager.manager.InstallLogManager
import com.aliucord.manager.util.launchBlock
import com.aliucord.manager.util.showToast
import com.aliucord.manager.util.*
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
@ -26,18 +25,21 @@ class LogsListScreenModel(
loadLogsList()
}
fun deleteLogs() = screenModelScope.launchBlock {
fun deleteLogs() = screenModelScope.launchIO {
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()) {
val data = logsManager.fetchInstallData(id = installId)
?: continue
logEntries += LogEntry(
val entry = LogEntry(
id = data.id,
isError = data.isError,
installDate = DateUtils.getRelativeDateTimeString(
@ -53,6 +55,8 @@ class LogsListScreenModel(
?.take(3)
?.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.ui.screens.patchopts.PatchOptions
import com.aliucord.manager.ui.util.toUnsafeImmutable
import com.aliucord.manager.util.launchBlock
import com.aliucord.manager.util.showToast
import com.aliucord.manager.util.*
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.ImmutableMap
import kotlinx.coroutines.*
@ -77,14 +76,14 @@ class PatchingScreenModel(
}
}
fun clearCache() = screenModelScope.launchBlock {
fun clearCache() = screenModelScope.launchIO {
paths.clearCache()
application.showToast(R.string.action_cleared_cache)
mainThread { application.showToast(R.string.action_cleared_cache) }
}
fun getCurrentInstallId(): String? = installId
fun cancelInstall() = screenModelScope.launchBlock(Dispatchers.IO) {
fun cancelInstall() = screenModelScope.launchIO {
runnerJob?.cancel("Manual cancellation")
val incompleteDownloadStep = stepRunner?.steps
@ -95,7 +94,7 @@ class PatchingScreenModel(
paths.patchingWorkingDir().deleteRecursively()
}
fun install() = screenModelScope.launch {
fun install() = screenModelScope.launchBlock {
runnerJob?.cancel("Manual cancellation")
steps = null
@ -104,7 +103,7 @@ class PatchingScreenModel(
startTime = Clock.System.now()
mutableState.value = PatchingScreenState.Working
runnerJob = screenModelScope.launch {
runnerJob = screenModelScope.launch(Dispatchers.Default) {
Log.i(BuildConfig.TAG, "Starting installation with environment:\n" + installLogs.getEnvironmentInfo())
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.screenModelScope
import com.aliucord.manager.manager.PreferencesManager
import com.aliucord.manager.util.debounce
import kotlinx.coroutines.launch
import com.aliucord.manager.util.*
class PatchOptionsModel(
prefilledOptions: PatchOptions,
@ -119,24 +118,26 @@ class PatchOptionsModel(
private val fetchPkgNameStateDebounced: () -> Unit =
screenModelScope.debounce(100L, function = ::fetchPkgNameState)
private fun fetchPkgNameState() {
if (packageName.length !in (3..150) || !PACKAGE_REGEX.matches(this.packageName)) {
packageNameState = PackageNameState.Invalid
private suspend fun fetchPkgNameState() {
val state = if (packageName.length !in (3..150) || !PACKAGE_REGEX.matches(this.packageName)) {
PackageNameState.Invalid
} else {
try {
context.packageManager.getPackageInfo(packageName, 0)
packageNameState = PackageNameState.Taken
PackageNameState.Taken
} catch (_: NameNotFoundException) {
packageNameState = PackageNameState.Ok
PackageNameState.Ok
}
}
mainThread { packageNameState = state }
}
init {
screenModelScope.launch { fetchPkgNameState() }
if (!prefs.showNetworkWarning)
showNetworkWarningDialog = false
screenModelScope.launchBlock { fetchPkgNameState() }
}
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.util.emptyImmutableList
import com.aliucord.manager.ui.util.toUnsafeImmutable
import com.aliucord.manager.util.launchBlock
import com.aliucord.manager.util.showToast
import com.aliucord.manager.util.*
import com.github.diamondminer88.zip.ZipReader
import kotlinx.collections.immutable.ImmutableList
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.plus
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.json.*
import java.io.File
@ -58,7 +57,7 @@ class PluginsModel(
}.toUnsafeImmutable()
}
}.stateIn(
scope = screenModelScope,
scope = screenModelScope + Dispatchers.Default,
started = SharingStarted.WhileSubscribed(replayExpiration = Duration.ZERO),
initialValue = plugins.value,
)
@ -83,36 +82,32 @@ class PluginsModel(
showUninstallDialog = null
}
fun uninstallPlugin(pluginPath: String) = screenModelScope.launchBlock(Dispatchers.IO) {
if (!plugins.value.any { it.path == pluginPath }) {
hideUninstallDialog()
return@launchBlock
fun uninstallPlugin(plugin: PluginItem) = screenModelScope.launchIO {
if (!plugins.value.any { it.path == plugin.path }) {
mainThread { hideUninstallDialog() }
return@launchIO
}
val deleteSuccess = try {
File(pluginPath).delete()
File(plugin.path).delete()
} catch (t: Throwable) {
Log.e(BuildConfig.TAG, "Failed to delete plugin", t)
false
}
if (!deleteSuccess) {
withContext(Dispatchers.Main) {
mainThread {
hideUninstallDialog()
context.showToast(R.string.plugins_error)
}
hideUninstallDialog()
return@launchBlock
return@launchIO
}
withContext(Dispatchers.Main) {
plugins.getAndUpdate {
it.filter { it.path != pluginPath }.toUnsafeImmutable()
}
}
hideUninstallDialog()
plugins.update { (it - plugin).toUnsafeImmutable() }
mainThread { hideUninstallDialog() }
}
fun setPluginEnabled(pluginName: String, enabled: Boolean) = screenModelScope.launchBlock(Dispatchers.IO) {
fun setPluginEnabled(pluginName: String, enabled: Boolean) = screenModelScope.launchIO {
aliucordJsonMutex.withLock {
@OptIn(ExperimentalSerializationApi::class)
try {
@ -126,11 +121,11 @@ class PluginsModel(
.use { json.encodeToStream(settings, it) }
} catch (t: Throwable) {
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 {
if (it.manifest.name == pluginName)
it.enabled = enabled
@ -139,7 +134,7 @@ class PluginsModel(
}
// Called by screen to load initial data
fun refreshData() = screenModelScope.launchBlock(Dispatchers.IO) {
fun refreshData() = screenModelScope.launchIO {
loadPlugins()
loadPluginsEnabled()
}
@ -157,11 +152,11 @@ class PluginsModel(
.mapValues { it.value.jsonPrimitive.boolean }
} catch (t: Throwable) {
Log.e(BuildConfig.TAG, "Failed to load Aliucord.json", t)
withContext(Dispatchers.Main) { error = true }
mainThread { error = true }
emptyMap()
}
withContext(Dispatchers.Main) {
mainThread {
plugins.value.forEach {
if (!pluginToggles.getOrDefault(it.manifest.name, true))
it.enabled = false
@ -185,12 +180,10 @@ class PluginsModel(
)
}
withContext(Dispatchers.Main) {
plugins.value = pluginItems.toUnsafeImmutable()
}
plugins.value = pluginItems.toUnsafeImmutable()
} catch (t: Throwable) {
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 ->
UninstallPluginDialog(
pluginName = plugin.manifest.name,
onConfirm = { model.uninstallPlugin(plugin.path) },
onConfirm = { model.uninstallPlugin(plugin) },
onDismiss = model::hideUninstallDialog
)
}

View file

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

View file

@ -2,7 +2,7 @@ package com.aliucord.manager.ui.widgets.updater
import androidx.compose.foundation.layout.size
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
@ -18,6 +18,8 @@ fun UpdaterDialog(
) {
if (!viewModel.showDialog) return
val isWorking by viewModel.isWorking.collectAsState(initial = false)
AlertDialog(
confirmButton = {
FilledTonalButton(
@ -27,7 +29,7 @@ fun UpdaterDialog(
contentColor = MaterialTheme.colorScheme.onPrimary,
),
) {
if (!viewModel.isWorking) {
if (isWorking) {
Text(stringResource(R.string.action_update))
} else {
CircularProgressIndicator(
@ -42,7 +44,7 @@ fun UpdaterDialog(
dismissButton = {
TextButton(
onClick = viewModel::dismissDialog,
enabled = !viewModel.isWorking
enabled = isWorking,
) {
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.utils.SemVer
import com.aliucord.manager.network.utils.getOrThrow
import com.aliucord.manager.util.showToast
import kotlinx.coroutines.launch
import com.aliucord.manager.util.*
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlin.system.exitProcess
class UpdaterViewModel(
@ -31,18 +32,18 @@ class UpdaterViewModel(
private set
var targetVersion by mutableStateOf<String?>(null)
private set
var isWorking by mutableStateOf(false)
private set
val isWorking: Flow<Boolean>
private field = MutableStateFlow(false)
private var targetApkUrl: String? = null
init {
viewModelScope.launch {
viewModelScope.launchIO {
try {
fetchInfo()
} catch (t: Throwable) {
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
}
fun triggerUpdate() {
if (isWorking) return
val url = targetApkUrl ?: return
fun triggerUpdate() = viewModelScope.launchIO {
if (!isWorking.compareAndSet(expect = false, update = true))
return@launchIO
viewModelScope.launch {
isWorking = true
val url = targetApkUrl ?: return@launchIO
val apkFile = application.cacheDir.resolve("manager.apk")
val apkFile = application.cacheDir.resolve("manager.apk")
try {
apkFile.apply {
parentFile!!.mkdirs()
exists() && delete()
}
try {
apkFile.apply {
parentFile!!.mkdirs()
exists() && delete()
val installer = installers.getInstaller(InstallerSetting.PM)
when (val downloadResult = downloader.download(url, apkFile)) {
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)) {
is IDownloadManager.Result.Success ->
Log.d(BuildConfig.TAG, "Downloaded update")
val installResult = installer.waitInstall(
apks = listOf(apkFile),
silent = true,
)
is IDownloadManager.Result.Cancelled -> {
Log.i(BuildConfig.TAG, "Update cancelled")
return@launch
}
is IDownloadManager.Result.Error ->
throw IllegalStateException("Failed to download update: ${downloadResult.getDebugReason()}", downloadResult.getError())
when (installResult) {
InstallerResult.Success -> {
Log.w(BuildConfig.TAG, "Update completed without restarting app!")
exitProcess(1)
}
val installResult = installer.waitInstall(
apks = listOf(apkFile),
silent = true,
)
is InstallerResult.Cancelled ->
Log.i(BuildConfig.TAG, "Update cancelled")
when (installResult) {
InstallerResult.Success -> {
Log.w(BuildConfig.TAG, "Update completed without restarting app!")
exitProcess(1)
}
is InstallerResult.Error ->
throw Exception("Failed to install update: ${installResult.getDebugReason()}")
}
} catch (t: Throwable) {
Log.e(BuildConfig.TAG, "Failed to perform update!")
t.printStackTrace()
is InstallerResult.Cancelled ->
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()
mainThread {
application.showToast(R.string.updater_update_fail)
launchReleasesPage()
} finally {
isWorking = false
}
} finally {
isWorking.value = false
try {
apkFile.apply { exists() && delete() }
} catch (t: Throwable) {
Log.w(BuildConfig.TAG, "Failed to clean up installed update!", t)
}
try {
apkFile.apply { exists() && delete() }
} catch (t: Throwable) {
Log.w(BuildConfig.TAG, "Failed to clean up installed update!", t)
}
}
}
@ -151,10 +152,12 @@ class UpdaterViewModel(
return
}
targetVersion = version.toString()
targetApkUrl = apkUrl
showDialog = true
Log.d(BuildConfig.TAG, "Found an update! $targetVersion $targetApkUrl")
mainThread {
targetVersion = version.toString()
targetApkUrl = apkUrl
showDialog = true
}
}
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 file = File(downloads, name)
val base = downloads.parentFile!!
return try {
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
} catch (e: Throwable) {
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
}
}

View file

@ -6,17 +6,25 @@ import kotlinx.coroutines.*
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(
context: CoroutineContext = Dispatchers.Main,
context: CoroutineContext = Dispatchers.Default,
noinline block: suspend CoroutineScope.() -> Unit,
) {
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)