Skip to content
This repository has been archived by the owner on Jul 7, 2024. It is now read-only.

feat(DownloadManager): properly handle downloading #66

Merged
merged 4 commits into from
Jan 20, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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<DownloadManager>()
?: 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<DownloadManager>()
?: 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
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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")
Expand All @@ -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")
Expand All @@ -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")
Expand All @@ -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")
Expand All @@ -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")
Expand Down Expand Up @@ -449,14 +456,38 @@ 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
return null
}
}

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),
Expand Down
2 changes: 2 additions & 0 deletions app/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@
<string name="msg_shizuku_denied">Failed to obtain Shizuku permissions</string>
<string name="msg_change_mirror">Would you like to try again using a download mirror?</string>
<string name="msg_invalid_apk">APK was corrupted, try clearing cache then reinstalling</string>
<string name="msg_download_cancelled">Download was aborted</string>
<string name="msg_download_failed">Download failed</string>

<string name="group_download">Download APKs</string>
<string name="group_patch">Patching</string>
Expand Down
Loading