feat: async UI overhaul
Fixing lag on HomeScreen in the process.
This commit is contained in:
parent
8fdcab8aba
commit
69e82dedc5
17 changed files with 214 additions and 172 deletions
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) }
|
||||
|
|
|
|||
|
|
@ -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) }
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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) }
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue