From e5b0307ef96f68e266844c20506989959052cee6 Mon Sep 17 00:00:00 2001 From: rushiiMachine <33725716+rushiiMachine@users.noreply.github.com> Date: Thu, 18 Jan 2024 17:10:00 -0800 Subject: [PATCH] feat: better download manager --- app/build.gradle.kts | 3 +- .../com/aliucord/manager/MainActivity.kt | 2 +- .../com/aliucord/manager/di/Managers.kt | 4 +- .../manager/domain/manager/DownloadManager.kt | 113 ----------- .../manager/manager/DownloadManager.kt | 184 ++++++++++++++++++ .../manager/PreferencesManager.kt | 4 +- .../manager/base/BasePreferencesManager.kt | 2 +- .../manager/ui/screens/home/HomeModel.kt | 2 +- .../ui/screens/install/InstallModel.kt | 4 +- .../ui/screens/settings/SettingsModel.kt | 2 +- .../ui/widgets/updater/UpdaterViewModel.kt | 2 +- app/src/main/res/values/strings.xml | 5 + 12 files changed, 202 insertions(+), 125 deletions(-) delete mode 100644 app/src/main/kotlin/com/aliucord/manager/domain/manager/DownloadManager.kt create mode 100644 app/src/main/kotlin/com/aliucord/manager/manager/DownloadManager.kt rename app/src/main/kotlin/com/aliucord/manager/{domain => }/manager/PreferencesManager.kt (87%) rename app/src/main/kotlin/com/aliucord/manager/{domain => }/manager/base/BasePreferencesManager.kt (98%) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index ed9a6d8..5940a1c 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -25,7 +25,8 @@ android { buildConfigField("String", "TAG", "\"AliucordManager\"") buildConfigField("String", "SUPPORT_SERVER", "\"EsNDvBaHVU\"") - buildConfigField("boolean", "RN_ENABLED", "true") + + buildConfigField("String", "BACKEND_URL", "\"https://aliucord.com/\"") buildConfigField("String", "GIT_BRANCH", "\"${getCurrentBranch()}\"") buildConfigField("String", "GIT_COMMIT", "\"${getLatestCommit()}\"") diff --git a/app/src/main/kotlin/com/aliucord/manager/MainActivity.kt b/app/src/main/kotlin/com/aliucord/manager/MainActivity.kt index aac1d9c..296a85a 100644 --- a/app/src/main/kotlin/com/aliucord/manager/MainActivity.kt +++ b/app/src/main/kotlin/com/aliucord/manager/MainActivity.kt @@ -14,7 +14,7 @@ import androidx.compose.foundation.isSystemInDarkTheme import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen import cafe.adriel.voyager.navigator.Navigator import cafe.adriel.voyager.transitions.FadeTransition -import com.aliucord.manager.domain.manager.PreferencesManager +import com.aliucord.manager.manager.PreferencesManager import com.aliucord.manager.ui.components.* import com.aliucord.manager.ui.components.dialogs.StoragePermissionsDialog import com.aliucord.manager.ui.screens.home.HomeScreen diff --git a/app/src/main/kotlin/com/aliucord/manager/di/Managers.kt b/app/src/main/kotlin/com/aliucord/manager/di/Managers.kt index 6b2503a..534b1a4 100644 --- a/app/src/main/kotlin/com/aliucord/manager/di/Managers.kt +++ b/app/src/main/kotlin/com/aliucord/manager/di/Managers.kt @@ -2,8 +2,8 @@ package com.aliucord.manager.di import android.app.Application import android.content.Context -import com.aliucord.manager.domain.manager.DownloadManager -import com.aliucord.manager.domain.manager.PreferencesManager +import com.aliucord.manager.manager.DownloadManager +import com.aliucord.manager.manager.PreferencesManager import org.koin.core.scope.Scope fun Scope.providePreferences(): PreferencesManager { diff --git a/app/src/main/kotlin/com/aliucord/manager/domain/manager/DownloadManager.kt b/app/src/main/kotlin/com/aliucord/manager/domain/manager/DownloadManager.kt deleted file mode 100644 index 0cba33b..0000000 --- a/app/src/main/kotlin/com/aliucord/manager/domain/manager/DownloadManager.kt +++ /dev/null @@ -1,113 +0,0 @@ -package com.aliucord.manager.domain.manager - -import android.app.Application -import android.app.DownloadManager -import android.content.* -import androidx.core.content.getSystemService -import androidx.core.net.toUri -import com.aliucord.manager.BuildConfig -import com.aliucord.manager.domain.repository.AliucordMavenRepository -import com.aliucord.manager.network.service.AliucordGithubService -import java.io.File -import kotlin.coroutines.* - -class DownloadManager( - private val application: Application, -) { - private val downloadManager = application.getSystemService()!! - - // Discord APK downloading - suspend fun downloadDiscordApk(version: String, out: File): File = - download("$BACKEND_HOST/download/discord?v=$version", out) - - // Aliucord Kotlin downloads - suspend fun downloadKtInjector(out: File): File = - download(AliucordGithubService.KT_INJECTOR_URL, out) - - suspend fun downloadAliuhook(version: String, out: File): File = - download(AliucordMavenRepository.getAliuhookUrl(version), out) - - suspend fun downloadKotlinDex(out: File): File = - download(AliucordGithubService.KOTLIN_DEX_URL, out) - - suspend fun download(url: String, out: File): File { - out.parentFile?.mkdirs() - - return suspendCoroutine { continuation -> - val receiver = object : BroadcastReceiver() { - var downloadId = 0L - - override fun onReceive(context: Context, intent: Intent) { - val id = intent.getLongExtra(DownloadManager.EXTRA_DOWNLOAD_ID, -1) - - if (downloadId != id) return - - val (status, reason) = DownloadManager.Query().run { - setFilterById(downloadId) - - val cursor = downloadManager.query(this) - .apply { moveToFirst() } - - // Notification's "cancel" was clicked - if (cursor.count == 0) { - -1 to -1 - } else { - val status = cursor.run { getInt(getColumnIndex(DownloadManager.COLUMN_STATUS)) } - val reason = cursor.run { getInt(getColumnIndex(DownloadManager.COLUMN_REASON)) } - status to reason - } - } - - when (status) { - // Cancelled - -1 -> { - context.unregisterReceiver(this) - continuation.resumeWithException(Error("Download was cancelled manually")) - } - - DownloadManager.STATUS_SUCCESSFUL -> { - context.unregisterReceiver(this) - continuation.resume(out) - } - - DownloadManager.STATUS_FAILED -> { - context.unregisterReceiver(this) - continuation.resumeWithException(Error("Failed to download $url because of: ${reasonToString(reason)}")) - } - - else -> {} - } - } - } - - application.registerReceiver(receiver, IntentFilter(DownloadManager.ACTION_DOWNLOAD_COMPLETE)) - - receiver.downloadId = DownloadManager.Request(url.toUri()).run { - setTitle("Aliucord Manager: Downloading ${out.name}") - setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE) - setDestinationUri(out.toUri()) - addRequestHeader("User-Agent", "Aliucord Manager/${BuildConfig.VERSION_NAME}") - downloadManager.enqueue(this) - } - } - } - - private fun reasonToString(int: Int): String { - return when (int) { - DownloadManager.ERROR_UNKNOWN -> "Unknown Error" - DownloadManager.ERROR_FILE_ERROR -> "File Error" - DownloadManager.ERROR_UNHANDLED_HTTP_CODE -> "Unhandled HTTP Code" - DownloadManager.ERROR_HTTP_DATA_ERROR -> "HTTP Data Error" - DownloadManager.ERROR_TOO_MANY_REDIRECTS -> "Too many redirects" - DownloadManager.ERROR_INSUFFICIENT_SPACE -> "Insufficient space" - DownloadManager.ERROR_DEVICE_NOT_FOUND -> "Device not found" - DownloadManager.ERROR_CANNOT_RESUME -> "Cannot resume download" - DownloadManager.ERROR_FILE_ALREADY_EXISTS -> "File already exists" - else -> "Unknown error" - } - } - - companion object { - private const val BACKEND_HOST = "https://aliucord.com/" - } -} diff --git a/app/src/main/kotlin/com/aliucord/manager/manager/DownloadManager.kt b/app/src/main/kotlin/com/aliucord/manager/manager/DownloadManager.kt new file mode 100644 index 0000000..990d4e0 --- /dev/null +++ b/app/src/main/kotlin/com/aliucord/manager/manager/DownloadManager.kt @@ -0,0 +1,184 @@ +package com.aliucord.manager.manager + +import android.app.Application +import android.app.DownloadManager +import android.database.Cursor +import android.net.Uri +import androidx.annotation.StringRes +import androidx.core.content.getSystemService +import com.aliucord.manager.BuildConfig +import com.aliucord.manager.R +import com.aliucord.manager.domain.repository.AliucordMavenRepository +import com.aliucord.manager.network.service.AliucordGithubService +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.delay +import java.io.File +import kotlin.coroutines.cancellation.CancellationException + +/** + * Handle downloading remote urls to a file through the system [DownloadManager]. + */ +class DownloadManager(application: Application) { + private val downloadManager = application.getSystemService() + ?: throw IllegalStateException("DownloadManager service is not available") + + // Discord APK downloading + suspend fun downloadDiscordApk(version: String, out: File): Result = + download("${BuildConfig.BACKEND_URL}/download/discord?v=$version", out) + + // Aliucord Kotlin downloads + suspend fun downloadKtInjector(out: File): Result = + download(AliucordGithubService.KT_INJECTOR_URL, out) + + suspend fun downloadAliuhook(version: String, out: File): Result = + download(AliucordMavenRepository.getAliuhookUrl(version), out) + + suspend fun downloadKotlinDex(out: File): Result = + download(AliucordGithubService.KOTLIN_DEX_URL, out) + + /** + * Start a cancellable download with the system [DownloadManager]. + * If the current [CoroutineScope] is cancelled, then the system download will be cancelled within 100ms. + * @param url Remote src url + * @param out Target path to download to + * @param onProgressUpdate Download progress update in a `[0,1]` range, and if null then the download is currently in a pending state. + * This is called every 100ms, and should not perform long-running tasks. + */ + suspend fun download( + url: String, + out: File, + onProgressUpdate: ((Float?) -> Unit)? = null, + ): Result { + out.parentFile?.mkdirs() + + // Create and start a download in the system DownloadManager + val downloadId = DownloadManager.Request(Uri.parse(url)) + .setTitle("Aliucord Manager") + .setDescription("Downloading ${out.name}...") + .setDestinationUri(Uri.fromFile(out)) + .setAllowedOverMetered(true) + .setAllowedOverRoaming(true) + .setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE) + .addRequestHeader("User-Agent", "Aliucord Manager/${BuildConfig.VERSION_NAME}") + .let(downloadManager::enqueue) + + // Repeatedly request download state until it is finished + while (true) { + try { + // Hand over control to a suspend function to check for cancellation + // At the same time, delay 100ms to slow down the potentially infinite loop + delay(100) + } catch (_: CancellationException) { + // If the running CoroutineScope has been cancelled, then gracefully cancel download + downloadManager.remove(downloadId) + return Result.Cancelled(systemTriggered = false) + } + + // Request download status + val cursor = DownloadManager.Query() + .setFilterById(downloadId) + .let(downloadManager::query) + + cursor.use { + // No results in cursor, download was cancelled + if (!cursor.moveToFirst()) { + return Result.Cancelled(systemTriggered = true) + } + + val statusColumn = cursor.getColumnIndex(DownloadManager.COLUMN_STATUS) + val status = cursor.getInt(statusColumn) + + when (status) { + DownloadManager.STATUS_PENDING, DownloadManager.STATUS_PAUSED -> + onProgressUpdate?.invoke(null) + + DownloadManager.STATUS_RUNNING -> + onProgressUpdate?.invoke(getDownloadProgress(cursor)) + + DownloadManager.STATUS_SUCCESSFUL -> + return Result.Success + + DownloadManager.STATUS_FAILED -> { + val reasonColumn = cursor.getColumnIndex(DownloadManager.COLUMN_REASON) + val reason = cursor.getInt(reasonColumn) + + return Result.Error(reason) + } + + else -> throw Error("Unreachable") + } + } + } + } + + /** + * Get the download progress of the current row in a [DownloadManager.Query]. + * @return Download progress in the range of `[0,1]` + */ + private fun getDownloadProgress(queryCursor: Cursor): Float { + val bytesColumn = queryCursor.getColumnIndex(DownloadManager.COLUMN_BYTES_DOWNLOADED_SO_FAR) + val bytes = queryCursor.getLong(bytesColumn) + + val totalBytesColumn = queryCursor.getColumnIndex(DownloadManager.COLUMN_TOTAL_SIZE_BYTES) + val totalBytes = queryCursor.getLong(totalBytesColumn) + + if (totalBytes <= 0) return 0f + return bytes.toFloat() / totalBytes + } + + /** + * The state of a download after execution has been completed and the system-level [DownloadManager] has been cleaned up. + */ + sealed interface Result { + data object Success : Result + + /** + * This download was interrupted and the in-progress file has been deleted. + * @param systemTriggered Whether the cancellation happened from the system (ie. clicked cancel on the download notification) + * Otherwise, this was caused by a coroutine cancellation. + */ + data class Cancelled(val systemTriggered: Boolean) : Result + + /** + * Error returned by the system [DownloadManager]. + * @param reason The reason code returned by the [DownloadManager.COLUMN_REASON] column. + */ + data class Error(private val reason: Int) : Result { + /** + * Convert a [DownloadManager.COLUMN_REASON] code into its name. + */ + val debugReason = when (reason) { + DownloadManager.ERROR_UNKNOWN -> "Unknown" + DownloadManager.ERROR_FILE_ERROR -> "File Error" + DownloadManager.ERROR_UNHANDLED_HTTP_CODE -> "Unhandled HTTP code" + DownloadManager.ERROR_HTTP_DATA_ERROR -> "HTTP data error" + DownloadManager.ERROR_TOO_MANY_REDIRECTS -> "Too many redirects" + DownloadManager.ERROR_INSUFFICIENT_SPACE -> "Insufficient space" + DownloadManager.ERROR_DEVICE_NOT_FOUND -> "Target file's device not found" + DownloadManager.ERROR_CANNOT_RESUME -> "Cannot resume" + DownloadManager.ERROR_FILE_ALREADY_EXISTS -> "File exists" + /* DownloadManager.ERROR_BLOCKED */ 1010 -> "Network policy block" + else -> "Unknown code ($reason)" + } + + /** + * Simplified + translatable user facing errors + */ + @StringRes + val localizedReason = when (reason) { // @formatter:off + DownloadManager.ERROR_HTTP_DATA_ERROR, + DownloadManager.ERROR_TOO_MANY_REDIRECTS, + DownloadManager.ERROR_UNHANDLED_HTTP_CODE -> + R.string.downloader_err_response + + DownloadManager.ERROR_INSUFFICIENT_SPACE -> + R.string.downloader_err_storage_space + + DownloadManager.ERROR_FILE_ALREADY_EXISTS -> + R.string.downloader_err_file_exists + + else -> R.string.downloader_err_unknown + } // @formatter:on + } + } +} diff --git a/app/src/main/kotlin/com/aliucord/manager/domain/manager/PreferencesManager.kt b/app/src/main/kotlin/com/aliucord/manager/manager/PreferencesManager.kt similarity index 87% rename from app/src/main/kotlin/com/aliucord/manager/domain/manager/PreferencesManager.kt rename to app/src/main/kotlin/com/aliucord/manager/manager/PreferencesManager.kt index ff2f3f1..a15db04 100644 --- a/app/src/main/kotlin/com/aliucord/manager/domain/manager/PreferencesManager.kt +++ b/app/src/main/kotlin/com/aliucord/manager/manager/PreferencesManager.kt @@ -1,7 +1,7 @@ -package com.aliucord.manager.domain.manager +package com.aliucord.manager.manager import android.content.SharedPreferences -import com.aliucord.manager.domain.manager.base.BasePreferenceManager +import com.aliucord.manager.manager.base.BasePreferenceManager import com.aliucord.manager.ui.components.Theme class PreferencesManager(preferences: SharedPreferences) : BasePreferenceManager(preferences) { diff --git a/app/src/main/kotlin/com/aliucord/manager/domain/manager/base/BasePreferencesManager.kt b/app/src/main/kotlin/com/aliucord/manager/manager/base/BasePreferencesManager.kt similarity index 98% rename from app/src/main/kotlin/com/aliucord/manager/domain/manager/base/BasePreferencesManager.kt rename to app/src/main/kotlin/com/aliucord/manager/manager/base/BasePreferencesManager.kt index 827dc4c..dd4f622 100644 --- a/app/src/main/kotlin/com/aliucord/manager/domain/manager/base/BasePreferencesManager.kt +++ b/app/src/main/kotlin/com/aliucord/manager/manager/base/BasePreferencesManager.kt @@ -1,4 +1,4 @@ -package com.aliucord.manager.domain.manager.base +package com.aliucord.manager.manager.base import android.content.SharedPreferences import androidx.compose.runtime.* 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 5f8ee18..9586fe0 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 @@ -8,7 +8,7 @@ import cafe.adriel.voyager.core.model.ScreenModel import cafe.adriel.voyager.core.model.screenModelScope import com.aliucord.manager.BuildConfig import com.aliucord.manager.R -import com.aliucord.manager.domain.manager.PreferencesManager +import com.aliucord.manager.manager.PreferencesManager import com.aliucord.manager.domain.repository.GithubRepository import com.aliucord.manager.installer.util.uninstallApk import com.aliucord.manager.network.utils.fold diff --git a/app/src/main/kotlin/com/aliucord/manager/ui/screens/install/InstallModel.kt b/app/src/main/kotlin/com/aliucord/manager/ui/screens/install/InstallModel.kt index eb9877c..1416a5a 100644 --- a/app/src/main/kotlin/com/aliucord/manager/ui/screens/install/InstallModel.kt +++ b/app/src/main/kotlin/com/aliucord/manager/ui/screens/install/InstallModel.kt @@ -10,8 +10,8 @@ import cafe.adriel.voyager.core.model.ScreenModel import cafe.adriel.voyager.core.model.screenModelScope import com.aliucord.manager.BuildConfig import com.aliucord.manager.R -import com.aliucord.manager.domain.manager.DownloadManager -import com.aliucord.manager.domain.manager.PreferencesManager +import com.aliucord.manager.manager.DownloadManager +import com.aliucord.manager.manager.PreferencesManager import com.aliucord.manager.domain.repository.AliucordMavenRepository import com.aliucord.manager.domain.repository.GithubRepository import com.aliucord.manager.installer.util.* 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 5e8cef5..caa721c 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 @@ -5,7 +5,7 @@ import androidx.compose.runtime.* import cafe.adriel.voyager.core.model.ScreenModel import cafe.adriel.voyager.core.model.screenModelScope import com.aliucord.manager.R -import com.aliucord.manager.domain.manager.PreferencesManager +import com.aliucord.manager.manager.PreferencesManager import com.aliucord.manager.ui.components.Theme import com.aliucord.manager.util.showToast import com.aliucord.manager.util.throttle 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 8ab960c..22f0c88 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 @@ -5,7 +5,7 @@ import androidx.compose.runtime.* import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.aliucord.manager.BuildConfig -import com.aliucord.manager.domain.manager.DownloadManager +import com.aliucord.manager.manager.DownloadManager import com.aliucord.manager.domain.repository.GithubRepository import com.aliucord.manager.installer.util.installApks import com.aliucord.manager.network.utils.SemVer diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index d1726ac..8c457cc 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -127,4 +127,9 @@ A new update has been released for Aliucord Manager! It may be required in order to function properly. Would you like to update? Update to %1$s + + Download failed (Unknown) + Download failed (Invalid response) + Download failed (File exists) + Download failed (Insufficient space)