diff --git a/app/src/main/kotlin/com/aliucord/manager/ManagerApplication.kt b/app/src/main/kotlin/com/aliucord/manager/ManagerApplication.kt index a437213..a7decaa 100644 --- a/app/src/main/kotlin/com/aliucord/manager/ManagerApplication.kt +++ b/app/src/main/kotlin/com/aliucord/manager/ManagerApplication.kt @@ -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() + } } } diff --git a/app/src/main/kotlin/com/aliucord/manager/network/services/HttpService.kt b/app/src/main/kotlin/com/aliucord/manager/network/services/HttpService.kt index 926afa5..653dd2c 100644 --- a/app/src/main/kotlin/com/aliucord/manager/network/services/HttpService.kt +++ b/app/src/main/kotlin/com/aliucord/manager/network/services/HttpService.kt @@ -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 request(builder: HttpRequestBuilder.() -> Unit = {}): ApiResponse { + suspend inline fun request( + crossinline builder: HttpRequestBuilder.() -> Unit = {}, + ): ApiResponse = 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(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 } } diff --git a/app/src/main/kotlin/com/aliucord/manager/ui/screens/about/AboutModel.kt b/app/src/main/kotlin/com/aliucord/manager/ui/screens/about/AboutModel.kt index e936943..75db429 100644 --- a/app/src/main/kotlin/com/aliucord/manager/ui/screens/about/AboutModel.kt +++ b/app/src/main/kotlin/com/aliucord/manager/ui/screens/about/AboutModel.kt @@ -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> { url(CONTRIBUTORS_API_URL) } diff --git a/app/src/main/kotlin/com/aliucord/manager/ui/screens/home/HomeModel.kt b/app/src/main/kotlin/com/aliucord/manager/ui/screens/home/HomeModel.kt index 367eae9..3fd4493 100644 --- a/app/src/main/kotlin/com/aliucord/manager/ui/screens/home/HomeModel.kt +++ b/app/src/main/kotlin/com/aliucord/manager/ui/screens/home/HomeModel.kt @@ -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(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) { - installsState = InstallsState.Fetching + private suspend fun fetchInstallations(packages: List) { + 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) { - val installations = (installsState as? InstallsState.Fetched)?.data + private suspend fun refreshInstallationsUpToDate(packages: List) { + 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) } } } diff --git a/app/src/main/kotlin/com/aliucord/manager/ui/screens/home/HomeScreen.kt b/app/src/main/kotlin/com/aliucord/manager/ui/screens/home/HomeScreen.kt index db57eaf..d5d119b 100644 --- a/app/src/main/kotlin/com/aliucord/manager/ui/screens/home/HomeScreen.kt +++ b/app/src/main/kotlin/com/aliucord/manager/ui/screens/home/HomeScreen.kt @@ -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, diff --git a/app/src/main/kotlin/com/aliucord/manager/ui/screens/iconopts/IconOptionsModel.kt b/app/src/main/kotlin/com/aliucord/manager/ui/screens/iconopts/IconOptionsModel.kt index a9026a2..fb8ae85 100644 --- a/app/src/main/kotlin/com/aliucord/manager/ui/screens/iconopts/IconOptionsModel.kt +++ b/app/src/main/kotlin/com/aliucord/manager/ui/screens/iconopts/IconOptionsModel.kt @@ -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(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) } } } diff --git a/app/src/main/kotlin/com/aliucord/manager/ui/screens/log/LogScreenModel.kt b/app/src/main/kotlin/com/aliucord/manager/ui/screens/log/LogScreenModel.kt index 9f5b9bc..1fa14c5 100644 --- a/app/src/main/kotlin/com/aliucord/manager/ui/screens/log/LogScreenModel.kt +++ b/app/src/main/kotlin/com/aliucord/manager/ui/screens/log/LogScreenModel.kt @@ -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) + } } } } diff --git a/app/src/main/kotlin/com/aliucord/manager/ui/screens/logs/LogsListScreenModel.kt b/app/src/main/kotlin/com/aliucord/manager/ui/screens/logs/LogsListScreenModel.kt index 0c53962..31db26c 100644 --- a/app/src/main/kotlin/com/aliucord/manager/ui/screens/logs/LogsListScreenModel.kt +++ b/app/src/main/kotlin/com/aliucord/manager/ui/screens/logs/LogsListScreenModel.kt @@ -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 } } } } diff --git a/app/src/main/kotlin/com/aliucord/manager/ui/screens/patching/PatchingScreenModel.kt b/app/src/main/kotlin/com/aliucord/manager/ui/screens/patching/PatchingScreenModel.kt index 52a132d..43c91d3 100644 --- a/app/src/main/kotlin/com/aliucord/manager/ui/screens/patching/PatchingScreenModel.kt +++ b/app/src/main/kotlin/com/aliucord/manager/ui/screens/patching/PatchingScreenModel.kt @@ -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 { diff --git a/app/src/main/kotlin/com/aliucord/manager/ui/screens/patchopts/PatchOptionsModel.kt b/app/src/main/kotlin/com/aliucord/manager/ui/screens/patchopts/PatchOptionsModel.kt index e74a2aa..38bf51d 100644 --- a/app/src/main/kotlin/com/aliucord/manager/ui/screens/patchopts/PatchOptionsModel.kt +++ b/app/src/main/kotlin/com/aliucord/manager/ui/screens/patchopts/PatchOptionsModel.kt @@ -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 { diff --git a/app/src/main/kotlin/com/aliucord/manager/ui/screens/plugins/PluginsModel.kt b/app/src/main/kotlin/com/aliucord/manager/ui/screens/plugins/PluginsModel.kt index 37f1912..400948b 100644 --- a/app/src/main/kotlin/com/aliucord/manager/ui/screens/plugins/PluginsModel.kt +++ b/app/src/main/kotlin/com/aliucord/manager/ui/screens/plugins/PluginsModel.kt @@ -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 } } } diff --git a/app/src/main/kotlin/com/aliucord/manager/ui/screens/plugins/PluginsScreen.kt b/app/src/main/kotlin/com/aliucord/manager/ui/screens/plugins/PluginsScreen.kt index 8adf922..18fca20 100644 --- a/app/src/main/kotlin/com/aliucord/manager/ui/screens/plugins/PluginsScreen.kt +++ b/app/src/main/kotlin/com/aliucord/manager/ui/screens/plugins/PluginsScreen.kt @@ -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 ) } diff --git a/app/src/main/kotlin/com/aliucord/manager/ui/screens/settings/SettingsModel.kt b/app/src/main/kotlin/com/aliucord/manager/ui/screens/settings/SettingsModel.kt index c0916f5..476e353 100644 --- a/app/src/main/kotlin/com/aliucord/manager/ui/screens/settings/SettingsModel.kt +++ b/app/src/main/kotlin/com/aliucord/manager/ui/screens/settings/SettingsModel.kt @@ -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, diff --git a/app/src/main/kotlin/com/aliucord/manager/ui/widgets/updater/UpdaterDialog.kt b/app/src/main/kotlin/com/aliucord/manager/ui/widgets/updater/UpdaterDialog.kt index 357687a..abb1fc6 100644 --- a/app/src/main/kotlin/com/aliucord/manager/ui/widgets/updater/UpdaterDialog.kt +++ b/app/src/main/kotlin/com/aliucord/manager/ui/widgets/updater/UpdaterDialog.kt @@ -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)) } diff --git a/app/src/main/kotlin/com/aliucord/manager/ui/widgets/updater/UpdaterViewModel.kt b/app/src/main/kotlin/com/aliucord/manager/ui/widgets/updater/UpdaterViewModel.kt index 73fe3d8..d7128fe 100644 --- a/app/src/main/kotlin/com/aliucord/manager/ui/widgets/updater/UpdaterViewModel.kt +++ b/app/src/main/kotlin/com/aliucord/manager/ui/widgets/updater/UpdaterViewModel.kt @@ -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(null) private set - var isWorking by mutableStateOf(false) - private set + val isWorking: Flow + 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() { diff --git a/app/src/main/kotlin/com/aliucord/manager/util/Context.kt b/app/src/main/kotlin/com/aliucord/manager/util/Context.kt index 842ef7b..c6f1186 100644 --- a/app/src/main/kotlin/com/aliucord/manager/util/Context.kt +++ b/app/src/main/kotlin/com/aliucord/manager/util/Context.kt @@ -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 } } diff --git a/app/src/main/kotlin/com/aliucord/manager/util/Coroutines.kt b/app/src/main/kotlin/com/aliucord/manager/util/Coroutines.kt index d7d9092..0b95a38 100644 --- a/app/src/main/kotlin/com/aliucord/manager/util/Coroutines.kt +++ b/app/src/main/kotlin/com/aliucord/manager/util/Coroutines.kt @@ -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 mainThread(noinline block: suspend CoroutineScope.() -> T): T = withContext(Dispatchers.Main, block)