From dba4d6dcff90981403601901b94073b57550d891 Mon Sep 17 00:00:00 2001 From: rushiiMachine <33725716+rushiiMachine@users.noreply.github.com> Date: Mon, 15 Jan 2024 18:09:04 -0800 Subject: [PATCH 1/4] feat(DownloadManager): properly handle downloading --- .../manager/domain/manager/DownloadManager.kt | 171 ++++++++++++++---- .../viewmodel/installer/InstallerViewModel.kt | 51 +++++- app/src/main/res/values/strings.xml | 2 + 3 files changed, 174 insertions(+), 50 deletions(-) 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..db515445 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 @@ -1,33 +1,32 @@ package dev.beefers.vendetta.manager.domain.manager import android.app.DownloadManager +import android.content.BroadcastReceiver import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.database.Cursor import android.net.Uri -import android.os.Environment +import androidx.core.content.ContextCompat 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 dev.beefers.vendetta.manager.ui.activity.MainActivity +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 +41,140 @@ 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") + // Notification click listener to re-open VD + val clickReceiver = DownloadClickReceiver(downloadId) + ContextCompat.registerReceiver( + context, + clickReceiver, + IntentFilter(DownloadManager.ACTION_NOTIFICATION_CLICKED), + ContextCompat.RECEIVER_NOT_EXPORTED, + ) - val downloadId = downloadManager.enqueue(request) + // 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 + context.unregisterReceiver(clickReceiver) + downloadManager.remove(downloadId) + return DownloadResult.Cancelled(systemTriggered = false) + } - var lastProgress = 0L + // Request download status + val cursor = DownloadManager.Query() + .setFilterById(downloadId) + .let(downloadManager::query) - 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 + // No results in cursor, download was cancelled + if (!cursor.moveToFirst()) { + cursor.close() + context.unregisterReceiver(clickReceiver) + return DownloadResult.Cancelled(systemTriggered = true) + } + + 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 -> { + cursor.close() + context.unregisterReceiver(clickReceiver) + return DownloadResult.Success + } + + DownloadManager.STATUS_FAILED -> { + val reasonColumn = cursor.getColumnIndex(DownloadManager.COLUMN_REASON) + val reason = cursor.getInt(reasonColumn) + + context.unregisterReceiver(clickReceiver) + 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) + + if (totalBytes <= 0) return 0f + 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 -> "DEVICE_NOT_FOUND" + else -> "Unknown error code" + } - return out +} + +private class DownloadClickReceiver( + private val targetDownloadId: Long, +) : BroadcastReceiver() { + + override fun onReceive(context: Context, intent: Intent) { + val downloadId = intent.getLongExtra(DownloadManager.EXTRA_DOWNLOAD_ID, -1) + + if (targetDownloadId != downloadId) + return + + val launchIntent = Intent(context, MainActivity::class.java) + context.unregisterReceiver(this) + context.startActivity(launchIntent) } } + +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 From 14319fa8992ffb3eb5faffbdd47dae0907a3d13a Mon Sep 17 00:00:00 2001 From: rushiiMachine <33725716+rushiiMachine@users.noreply.github.com> Date: Mon, 15 Jan 2024 18:18:27 -0800 Subject: [PATCH 2/4] chore: remove download notification click handler Apparently in-progress download notifications don't do anything when clicking them. --- .../manager/domain/manager/DownloadManager.kt | 39 +------------------ 1 file changed, 1 insertion(+), 38 deletions(-) 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 db515445..671cd1f3 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 @@ -1,15 +1,10 @@ package dev.beefers.vendetta.manager.domain.manager import android.app.DownloadManager -import android.content.BroadcastReceiver import android.content.Context -import android.content.Intent -import android.content.IntentFilter import android.database.Cursor import android.net.Uri -import androidx.core.content.ContextCompat import androidx.core.content.getSystemService -import dev.beefers.vendetta.manager.ui.activity.MainActivity import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.delay @@ -67,15 +62,6 @@ class DownloadManager( .setAllowedOverRoaming(true) .let(downloadManager::enqueue) - // Notification click listener to re-open VD - val clickReceiver = DownloadClickReceiver(downloadId) - ContextCompat.registerReceiver( - context, - clickReceiver, - IntentFilter(DownloadManager.ACTION_NOTIFICATION_CLICKED), - ContextCompat.RECEIVER_NOT_EXPORTED, - ) - // Repeatedly request download state until it is finished while (true) { try { @@ -83,7 +69,6 @@ class DownloadManager( delay(100) } catch (_: CancellationException) { // If the running CoroutineScope has been cancelled, then gracefully cancel download - context.unregisterReceiver(clickReceiver) downloadManager.remove(downloadId) return DownloadResult.Cancelled(systemTriggered = false) } @@ -96,7 +81,6 @@ class DownloadManager( // No results in cursor, download was cancelled if (!cursor.moveToFirst()) { cursor.close() - context.unregisterReceiver(clickReceiver) return DownloadResult.Cancelled(systemTriggered = true) } @@ -111,17 +95,13 @@ class DownloadManager( DownloadManager.STATUS_RUNNING -> onProgressUpdate(getDownloadProgress(cursor)) - DownloadManager.STATUS_SUCCESSFUL -> { - cursor.close() - context.unregisterReceiver(clickReceiver) + DownloadManager.STATUS_SUCCESSFUL -> return DownloadResult.Success - } DownloadManager.STATUS_FAILED -> { val reasonColumn = cursor.getColumnIndex(DownloadManager.COLUMN_REASON) val reason = cursor.getInt(reasonColumn) - context.unregisterReceiver(clickReceiver) return DownloadResult.Error(debugReason = convertErrorCode(reason)) } } @@ -156,23 +136,6 @@ class DownloadManager( } -private class DownloadClickReceiver( - private val targetDownloadId: Long, -) : BroadcastReceiver() { - - override fun onReceive(context: Context, intent: Intent) { - val downloadId = intent.getLongExtra(DownloadManager.EXTRA_DOWNLOAD_ID, -1) - - if (targetDownloadId != downloadId) - return - - val launchIntent = Intent(context, MainActivity::class.java) - context.unregisterReceiver(this) - context.startActivity(launchIntent) - } - -} - sealed interface DownloadResult { object Success : DownloadResult data class Cancelled(val systemTriggered: Boolean) : DownloadResult From 39c4528a17b3859f3fb7e17cb52bafeb03f27788 Mon Sep 17 00:00:00 2001 From: rushii <33725716+rushiiMachine@users.noreply.github.com> Date: Wed, 17 Jan 2024 17:34:43 -0800 Subject: [PATCH 3/4] Update DownloadManager.kt --- .../vendetta/manager/domain/manager/DownloadManager.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 671cd1f3..1c279adc 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 @@ -130,8 +130,8 @@ class DownloadManager( 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 -> "DEVICE_NOT_FOUND" - else -> "Unknown error code" + /* DownloadManager.ERROR_BLOCKED */ 1010 -> "NETWORK_BLOCKED" + else -> "UNKNOWN_CODE" } } From 9c06b7d82b6206c83e70545b947613192c6bb364 Mon Sep 17 00:00:00 2001 From: Wing <44992537+wingio@users.noreply.github.com> Date: Fri, 19 Jan 2024 21:22:36 -0500 Subject: [PATCH 4/4] Return null instead of zero for missing total bytes column --- .../vendetta/manager/domain/manager/DownloadManager.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 1c279adc..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 @@ -109,14 +109,14 @@ class DownloadManager( } } - private fun getDownloadProgress(queryCursor: Cursor): Float { + 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 + if (totalBytes <= 0) return null return bytes.toFloat() / totalBytes }