diff --git a/app/src/main/java/dev/beefers/vendetta/manager/domain/manager/DownloadManager.kt b/app/src/main/java/dev/beefers/vendetta/manager/domain/manager/DownloadManager.kt index e04e5f98..860881a6 100644 --- a/app/src/main/java/dev/beefers/vendetta/manager/domain/manager/DownloadManager.kt +++ b/app/src/main/java/dev/beefers/vendetta/manager/domain/manager/DownloadManager.kt @@ -2,32 +2,26 @@ package dev.beefers.vendetta.manager.domain.manager import android.app.DownloadManager import android.content.Context +import android.database.Cursor import android.net.Uri -import android.os.Environment import androidx.core.content.getSystemService -import io.ktor.client.HttpClient -import io.ktor.client.call.body -import io.ktor.client.plugins.HttpTimeout -import io.ktor.client.request.prepareGet -import io.ktor.http.contentLength -import io.ktor.utils.io.ByteReadChannel -import io.ktor.utils.io.jvm.javaio.toInputStream +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.delay import java.io.File -import java.io.InputStream -import java.io.OutputStream class DownloadManager( private val context: Context, private val prefs: PreferenceManager ) { - suspend fun downloadDiscordApk(version: String, out: File, onProgressUpdate: (Float) -> Unit): File = + suspend fun downloadDiscordApk(version: String, out: File, onProgressUpdate: (Float?) -> Unit): DownloadResult = download("${prefs.mirror.baseUrl}/tracker/download/$version/base", out, onProgressUpdate) - suspend fun downloadSplit(version: String, split: String, out: File, onProgressUpdate: (Float) -> Unit): File = + suspend fun downloadSplit(version: String, split: String, out: File, onProgressUpdate: (Float?) -> Unit): DownloadResult = download("${prefs.mirror.baseUrl}/tracker/download/$version/$split", out, onProgressUpdate) - suspend fun downloadVendetta(out: File, onProgressUpdate: (Float) -> Unit) = + suspend fun downloadVendetta(out: File, onProgressUpdate: (Float?) -> Unit) = download( "https://github.com/vendetta-mod/VendettaXposed/releases/latest/download/app-release.apk", out, @@ -42,48 +36,108 @@ class DownloadManager( /* TODO: Update a progress bar in the update dialog */ } - suspend fun download(url: String, out: File, onProgressUpdate: (Float) -> Unit): File { - val request = DownloadManager.Request(Uri.parse(url)) + /** + * Start a cancellable download with the system [DownloadManager]. + * If the current [CoroutineScope] is cancelled, then the system download will be cancelled + * almost immediately. + * @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. + */ + suspend fun download( + url: String, + out: File, + onProgressUpdate: (Float?) -> Unit + ): DownloadResult { + val downloadManager = context.getSystemService() + ?: throw IllegalStateException("DownloadManager service is not available") + + val downloadId = DownloadManager.Request(Uri.parse(url)) .setTitle("Vendetta Manager") .setDescription("Downloading ${out.name}...") .setDestinationUri(Uri.fromFile(out)) .setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE) .setAllowedOverMetered(true) .setAllowedOverRoaming(true) + .let(downloadManager::enqueue) - val downloadManager = context.getSystemService() - ?: throw IllegalStateException("DownloadManager not available") + // Repeatedly request download state until it is finished + while (true) { + try { + // Hand over control to a suspend function to check for cancellation + delay(100) + } catch (_: CancellationException) { + // If the running CoroutineScope has been cancelled, then gracefully cancel download + downloadManager.remove(downloadId) + return DownloadResult.Cancelled(systemTriggered = false) + } - val downloadId = downloadManager.enqueue(request) + // Request download status + val cursor = DownloadManager.Query() + .setFilterById(downloadId) + .let(downloadManager::query) - var lastProgress = 0L + // No results in cursor, download was cancelled + if (!cursor.moveToFirst()) { + cursor.close() + return DownloadResult.Cancelled(systemTriggered = true) + } - while (true) { - val query = DownloadManager.Query().setFilterById(downloadId) - val cursor = downloadManager.query(query) - - if (cursor.moveToFirst()) { - val status = cursor.getInt(cursor.getColumnIndex(DownloadManager.COLUMN_STATUS)) - if (status == DownloadManager.STATUS_SUCCESSFUL) { - break - } else if (status == DownloadManager.STATUS_FAILED) { - // Handle download failure - break - } else { - val bytesDownloaded = cursor.getLong(cursor.getColumnIndex(DownloadManager.COLUMN_BYTES_DOWNLOADED_SO_FAR)) - val bytesTotal = cursor.getLong(cursor.getColumnIndex(DownloadManager.COLUMN_TOTAL_SIZE_BYTES)) - val progress = if (bytesTotal > 0) bytesDownloaded * 100 / bytesTotal else 0 - - if (progress > lastProgress) { - onProgressUpdate(progress.toFloat() / 100f) // Corrected progress calculation - lastProgress = progress + val statusColumn = cursor.getColumnIndex(DownloadManager.COLUMN_STATUS) + val status = cursor.getInt(statusColumn) + + cursor.use { + when (status) { + DownloadManager.STATUS_PENDING, DownloadManager.STATUS_PAUSED -> + onProgressUpdate(null) + + DownloadManager.STATUS_RUNNING -> + onProgressUpdate(getDownloadProgress(cursor)) + + DownloadManager.STATUS_SUCCESSFUL -> + return DownloadResult.Success + + DownloadManager.STATUS_FAILED -> { + val reasonColumn = cursor.getColumnIndex(DownloadManager.COLUMN_REASON) + val reason = cursor.getInt(reasonColumn) + + return DownloadResult.Error(debugReason = convertErrorCode(reason)) } } } - cursor.close() } + } + + 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) - return out + if (totalBytes <= 0) return null + return bytes.toFloat() / totalBytes } + private fun convertErrorCode(code: Int) = when (code) { + 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 -> "DEVICE_NOT_FOUND" + DownloadManager.ERROR_CANNOT_RESUME -> "CANNOT_RESUME" + DownloadManager.ERROR_FILE_ALREADY_EXISTS -> "FILE_ALREADY_EXISTS" + /* DownloadManager.ERROR_BLOCKED */ 1010 -> "NETWORK_BLOCKED" + else -> "UNKNOWN_CODE" + } + +} + +sealed interface DownloadResult { + object Success : DownloadResult + data class Cancelled(val systemTriggered: Boolean) : DownloadResult + data class Error(val debugReason: String) : DownloadResult } diff --git a/app/src/main/java/dev/beefers/vendetta/manager/ui/viewmodel/installer/InstallerViewModel.kt b/app/src/main/java/dev/beefers/vendetta/manager/ui/viewmodel/installer/InstallerViewModel.kt index cb7f189c..b6b2867a 100644 --- a/app/src/main/java/dev/beefers/vendetta/manager/ui/viewmodel/installer/InstallerViewModel.kt +++ b/app/src/main/java/dev/beefers/vendetta/manager/ui/viewmodel/installer/InstallerViewModel.kt @@ -19,6 +19,7 @@ import com.github.diamondminer88.zip.ZipWriter import dev.beefers.vendetta.manager.BuildConfig import dev.beefers.vendetta.manager.R import dev.beefers.vendetta.manager.domain.manager.DownloadManager +import dev.beefers.vendetta.manager.domain.manager.DownloadResult import dev.beefers.vendetta.manager.domain.manager.InstallManager import dev.beefers.vendetta.manager.domain.manager.InstallMethod import dev.beefers.vendetta.manager.domain.manager.PreferenceManager @@ -189,9 +190,9 @@ class InstallerViewModel( logger.i("base-$version.apk is cached") } else { logger.i("base-$version.apk is not cached, downloading now") - downloadManager.downloadDiscordApk(version, file) { - progress = it - } + downloadManager + .downloadDiscordApk(version, file) { progress = it } + .also(::handleDownloadResult) } logger.i("Move base-$version.apk to working directory") @@ -212,13 +213,15 @@ class InstallerViewModel( cached = true } else { logger.i("config.$libArch-$version.apk is not cached, downloading now") - downloadManager.downloadSplit( + val result = downloadManager.downloadSplit( version = version, split = "config.$libArch", out = file ) { progress = it } + + handleDownloadResult(result) } logger.i("Move config.$libArch-$version.apk to working directory") @@ -238,13 +241,15 @@ class InstallerViewModel( cached = true } else { logger.i("config.en-$version.apk is not cached, downloading now") - downloadManager.downloadSplit( + val result = downloadManager.downloadSplit( version = version, split = "config.en", out = file ) { progress = it } + + handleDownloadResult(result) } logger.i("Move config.en-$version.apk to working directory") @@ -264,13 +269,15 @@ class InstallerViewModel( cached = true } else { logger.i("config.xxhdpi-$version.apk is not cached, downloading now") - downloadManager.downloadSplit( + val result = downloadManager.downloadSplit( version = version, split = "config.xxhdpi", out = file ) { progress = it } + + handleDownloadResult(result) } logger.i("Move config.xxhdpi-$version.apk to working directory") @@ -290,9 +297,9 @@ class InstallerViewModel( cached = true } else { logger.i("vendetta.apk is not cached, downloading now") - downloadManager.downloadVendetta(file) { - progress = it - } + downloadManager + .downloadVendetta(file) { progress = it } + .also(::handleDownloadResult) } logger.i("Move vendetta.apk to working directory") @@ -449,7 +456,9 @@ class InstallerViewModel( logger.e("\nFailed on step ${step.name}\n") logger.e(e.stackTraceToString()) - if(step.group == InstallStepGroup.DL) failedOnDownload = true + + if (step.group == InstallStepGroup.DL && e.message?.contains("cancelled") != true) + failedOnDownload = true currentStep = step isFinished = true @@ -457,6 +466,28 @@ class InstallerViewModel( } } + private fun handleDownloadResult(result: DownloadResult) { + when (result) { + DownloadResult.Success -> {} + + is DownloadResult.Cancelled -> { + if (result.systemTriggered) coroutineScope.launch(Dispatchers.Main) { + context.showToast(R.string.msg_download_cancelled) + } + + throw Error("Download was cancelled") + } + + is DownloadResult.Error -> { + coroutineScope.launch(Dispatchers.Main) { + context.showToast(R.string.msg_download_failed) + } + + throw Error("Download failed: ${result.debugReason}") + } + } + } + enum class InstallStepGroup(@StringRes val nameRes: Int) { DL(R.string.group_download), PATCHING(R.string.group_patch), diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index b964e1da..eccfaa44 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -15,6 +15,8 @@ Failed to obtain Shizuku permissions Would you like to try again using a download mirror? APK was corrupted, try clearing cache then reinstalling + Download was aborted + Download failed Download APKs Patching