feat: Ktor download manager

Using the system DownloadManager on some emulators causes a very odd SSL bug linked to compression, which is impossible to disable on Android's DownloadManager requests due to the Accept-Encoding header always being overwritten.

This commit creates a generic download manager interface for which there is an Android DownloadManager wrapper (like before) in addition to a Ktor wrapper (which disables compression)
This commit is contained in:
rushiiMachine 2024-08-01 19:33:07 -07:00
parent f736062a93
commit 98c0a2e22d
No known key found for this signature in database
GPG key ID: DCBE5952BB3B6420
11 changed files with 310 additions and 99 deletions

View file

@ -6,6 +6,9 @@ import com.aliucord.manager.domain.repository.AliucordMavenRepository
import com.aliucord.manager.domain.repository.GithubRepository
import com.aliucord.manager.installers.pm.PMInstaller
import com.aliucord.manager.manager.InstallerManager
import com.aliucord.manager.manager.PathManager
import com.aliucord.manager.manager.download.AndroidDownloadManager
import com.aliucord.manager.manager.download.KtorDownloadManager
import com.aliucord.manager.network.service.*
import com.aliucord.manager.ui.screens.about.AboutModel
import com.aliucord.manager.ui.screens.home.HomeModel
@ -62,9 +65,12 @@ class ManagerApplication : Application() {
// Managers
modules(module {
single { providePreferences() }
single { provideDownloadManager() }
single { providePathManager() }
singleOf(::PathManager)
singleOf(::InstallerManager)
singleOf(::DownloadManagerProvider)
singleOf(::AndroidDownloadManager)
singleOf(::KtorDownloadManager)
})
// Installers

View file

@ -0,0 +1,40 @@
package com.aliucord.manager.di
import com.aliucord.manager.manager.PreferencesManager
import com.aliucord.manager.manager.download.*
import com.aliucord.manager.patcher.util.Signer.getKoin
import com.aliucord.manager.util.IS_PROBABLY_EMULATOR
import org.koin.core.annotation.KoinInternalApi
import kotlin.reflect.KClass
/**
* Handle providing the correct install manager based on preferences and device type.
*/
class DownloadManagerProvider(private val prefs: PreferencesManager) {
fun getActiveDownloader(): IDownloadManager =
getDownloader(prefs.downloader)
@OptIn(KoinInternalApi::class)
fun getDownloader(type: DownloaderSetting): IDownloadManager =
getKoin().scopeRegistry.rootScope.get(clazz = type.downloaderClass)
companion object {
fun getDefaultDownloader(): DownloaderSetting {
// Ktor downloader has specific fix for emulator
return if (IS_PROBABLY_EMULATOR) {
DownloaderSetting.Ktor
} else {
DownloaderSetting.Android
}
}
}
}
enum class DownloaderSetting(
// @StringRes
// private val localizedName: Int,
val downloaderClass: KClass<out IDownloadManager>,
) {
Android(AndroidDownloadManager::class),
Ktor(KtorDownloadManager::class),
}

View file

@ -1,21 +1,10 @@
package com.aliucord.manager.di
import android.app.Application
import android.content.Context
import com.aliucord.manager.manager.*
import com.aliucord.manager.manager.PreferencesManager
import org.koin.core.scope.Scope
fun Scope.providePreferences(): PreferencesManager {
val ctx: Context = get()
return PreferencesManager(ctx.getSharedPreferences("preferences", Context.MODE_PRIVATE))
}
fun Scope.provideDownloadManager(): DownloadManager {
val application: Application = get()
return DownloadManager(application)
}
fun Scope.providePathManager(): PathManager {
val ctx: Context = get()
return PathManager(ctx)
}

View file

@ -1,6 +1,8 @@
package com.aliucord.manager.manager
import android.content.SharedPreferences
import com.aliucord.manager.di.DownloadManagerProvider
import com.aliucord.manager.di.DownloaderSetting
import com.aliucord.manager.manager.base.BasePreferenceManager
import com.aliucord.manager.ui.components.Theme
@ -8,6 +10,7 @@ class PreferencesManager(preferences: SharedPreferences) : BasePreferenceManager
var theme by enumPreference("theme", Theme.DARK)
var dynamicColor by booleanPreference("dynamic_color", true)
var devMode by booleanPreference("dev_mode", false)
var installer by enumPreference("installer", InstallerSetting.PM)
var downloader by enumPreference<DownloaderSetting>("downloader", DownloadManagerProvider.getDefaultDownloader())
var installer by enumPreference<InstallerSetting>("installer", InstallerSetting.PM)
var keepPatchedApks by booleanPreference("keep_patched_apks", false)
}

View file

@ -1,39 +1,35 @@
package com.aliucord.manager.manager
package com.aliucord.manager.manager.download
import android.app.Application
import android.app.DownloadManager
import android.content.Context
import android.database.Cursor
import android.net.Uri
import android.os.Build
import android.util.Log
import androidx.annotation.StringRes
import androidx.core.content.getSystemService
import com.aliucord.manager.BuildConfig
import com.aliucord.manager.R
import com.aliucord.manager.manager.download.IDownloadManager.ProgressListener
import com.aliucord.manager.manager.download.IDownloadManager.Result
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.delay
import java.io.File
import kotlin.coroutines.cancellation.CancellationException
/**
* Handle downloading remote urls to a path through the system [DownloadManager].
* Handle downloading remote urls to a path through the system's [DownloadManager].
*/
class DownloadManager(application: Application) {
class AndroidDownloadManager(application: Application) : IDownloadManager {
private val downloadManager = application.getSystemService<DownloadManager>()
?: throw IllegalStateException("DownloadManager service is not available")
/**
* Start a cancellable download with the system [DownloadManager].
* Start a cancellable download with the system [IDownloadManager].
* 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. It is assumed that the application has write permissions to this path.
* @param onProgressUpdate An optional [ProgressListener]
*/
suspend fun download(
url: String,
out: File,
onProgressUpdate: ProgressListener? = null,
): Result {
override suspend fun download(url: String, out: File, onProgressUpdate: ProgressListener?): Result {
onProgressUpdate?.onUpdate(null)
out.parentFile?.mkdirs()
@ -44,13 +40,6 @@ class DownloadManager(application: Application) {
.setDestinationUri(Uri.fromFile(out))
.setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE)
.addRequestHeader("User-Agent", "Aliucord Manager/${BuildConfig.VERSION_NAME}")
.apply {
// Disable gzip on emulator due to https compression bug
if (Build.FINGERPRINT.contains("emulator")) {
Log.d(BuildConfig.TAG, "Disabling DownloadManager compression")
addRequestHeader("Accept-Encoding", null)
}
}
.let(downloadManager::enqueue)
// Repeatedly request download state until it is finished
@ -93,7 +82,7 @@ class DownloadManager(application: Application) {
val reasonColumn = cursor.getColumnIndex(DownloadManager.COLUMN_REASON)
val reason = cursor.getInt(reasonColumn)
return Result.Error(reason)
return Error(reason)
}
else -> throw Error("Unreachable")
@ -118,61 +107,29 @@ class DownloadManager(application: Application) {
}
/**
* A callback executed from a coroutine called every 100ms in order to provide
* info about the current download. This should not perform long-running tasks as the delay will be offset.
* Error returned by the system [DownloadManager].
* @param reason The reason code returned by the [DownloadManager.COLUMN_REASON] column.
*/
fun interface ProgressListener {
data class Error(private val reason: Int) : Result.Error() {
/**
* @param progress The current download progress in a `[0,1]` range. If null, then the download is either
* paused, pending, or waiting to retry.
* Convert a [DownloadManager.COLUMN_REASON] code into its name.
*/
fun onUpdate(progress: Float?)
}
override fun getDebugReason(): String = 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)"
}
/**
* The state of a download after execution has been completed and the system-level [DownloadManager] has been cleaned up.
*/
sealed interface Result {
/**
* The download succeeded successfully.
* @param file The path that the download was downloaded to.
*/
data class Success(val file: File) : 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
override fun getLocalizedReason(context: Context): String {
val string = when (reason) { // @formatter:off
DownloadManager.ERROR_HTTP_DATA_ERROR,
DownloadManager.ERROR_TOO_MANY_REDIRECTS,
DownloadManager.ERROR_UNHANDLED_HTTP_CODE ->
@ -186,6 +143,10 @@ class DownloadManager(application: Application) {
else -> R.string.downloader_err_unknown
} // @formatter:on
return context.getString(string)
}
override fun toString(): String = getDebugReason()
}
}

View file

@ -0,0 +1,64 @@
package com.aliucord.manager.manager.download
import android.app.DownloadManager
import android.content.Context
import java.io.File
/**
* Common interface for different implementations of starting and managing the lifetime of downloads.
*/
interface IDownloadManager {
/**
* Start a cancellable download.
* @param url Remote src url
* @param out Target path to download to. It is assumed that the application has write permissions to this path.
* @param onProgressUpdate An optional [ProgressListener] callback.
*/
suspend fun download(url: String, out: File, onProgressUpdate: ProgressListener? = null): Result
/**
* A callback executed from a coroutine called every 100ms in order to provide
* info about the current download. This should not perform long-running tasks as the delay will be offset.
*/
fun interface ProgressListener {
/**
* @param progress The current download progress in a `[0,1]` range. If null, then the download is either
* paused, pending, or waiting to retry.
*/
fun onUpdate(progress: Float?)
}
/**
* The state of a download after execution has been completed and the system-level [DownloadManager] has been cleaned up.
*/
sealed interface Result {
/**
* The download succeeded successfully.
* @param file The path that the download was downloaded to.
*/
data class Success(val file: File) : 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
/**
* This download failed to complete due to an error.
*/
abstract class Error : Result {
/**
* The full internal error representation.
*/
abstract fun getDebugReason(): String
/**
* Simplified + translatable user facing reason for the failure.
* If null is returned, then the [getDebugReason] will be used instead.
*/
open fun getLocalizedReason(context: Context): String? = null
}
}
}

View file

@ -0,0 +1,81 @@
package com.aliucord.manager.manager.download
import com.aliucord.manager.manager.download.IDownloadManager.Result
import io.ktor.client.HttpClient
import io.ktor.client.request.*
import io.ktor.client.statement.bodyAsChannel
import io.ktor.http.HttpHeaders
import io.ktor.http.contentLength
import io.ktor.utils.io.core.isEmpty
import io.ktor.utils.io.core.readBytes
import kotlinx.coroutines.*
import java.io.File
import kotlin.math.min
/**
* Handle downloading remote urls to a path with Ktor.
* This is used as an alternative downloader option due to some bugs with the
* system's DownloadManager that prevents its usage on some emulators and ROMs.
*/
class KtorDownloadManager(private val http: HttpClient) : IDownloadManager {
override suspend fun download(url: String, out: File, onProgressUpdate: IDownloadManager.ProgressListener?): Result {
onProgressUpdate?.onUpdate(null)
out.parentFile?.mkdirs()
val tmpOut = out.resolveSibling(out.name + ".tmp")
.apply { exists() || createNewFile() }
try {
val httpStmt = http.prepareGet(url) {
// Disable compression due to bug on emulators
// This header cannot be set with Android's DownloadManager
header(HttpHeaders.AcceptEncoding, null)
}
httpStmt.execute { resp ->
val channel = resp.bodyAsChannel()
val total = resp.contentLength() ?: 0
var retrieved = 0L
tmpOut.outputStream().use { stream ->
while (!channel.isClosedForRead) {
channel.awaitContent()
val packet = channel.readRemaining(1024 * 1024 * 1)
while (!packet.isEmpty) {
// TODO: reuse bytearray
val bytes = packet.readBytes()
stream.write(bytes)
stream.flush()
retrieved += bytes.size
if (total > 0) {
onProgressUpdate?.onUpdate(retrieved / total.toFloat())
} else {
onProgressUpdate?.onUpdate(null)
}
}
}
}
}
} catch (_: CancellationException) {
tmpOut.delete()
return Result.Cancelled(systemTriggered = false)
} catch (t: Throwable) {
tmpOut.delete()
return Error(t)
}
tmpOut.renameTo(out)
return Result.Success(out)
}
/**
* Wrapper around an exception that occurred from invoking Ktor
*/
class Error(private val throwable: Throwable) : Result.Error() {
override fun getDebugReason(): String = throwable.message ?: "Unknown exception"
override fun toString(): String = throwable.stackTraceToString()
}
}

View file

@ -1,11 +1,13 @@
package com.aliucord.manager.patcher.steps.base
import android.content.Context
import android.widget.Toast
import androidx.compose.runtime.Stable
import com.aliucord.manager.R
import com.aliucord.manager.di.DownloadManagerProvider
import com.aliucord.manager.manager.download.IDownloadManager
import com.aliucord.manager.patcher.StepRunner
import com.aliucord.manager.patcher.steps.StepGroup
import com.aliucord.manager.manager.DownloadManager
import com.aliucord.manager.util.showToast
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
@ -16,7 +18,7 @@ import java.io.File
@Stable
abstract class DownloadStep : Step(), KoinComponent {
private val context: Context by inject()
private val downloads: DownloadManager by inject()
private val downloaders: DownloadManagerProvider by inject()
/**
* The remote url to download
@ -53,12 +55,12 @@ abstract class DownloadStep : Step(), KoinComponent {
targetFile.delete()
}
val result = downloads.download(targetUrl, targetFile) { newProgress ->
val result = downloaders.getActiveDownloader().download(targetUrl, targetFile) { newProgress ->
progress = newProgress ?: -1f
}
when (result) {
is DownloadManager.Result.Success -> {
is IDownloadManager.Result.Success -> {
try {
verify()
} catch (t: Throwable) {
@ -70,15 +72,18 @@ abstract class DownloadStep : Step(), KoinComponent {
}
}
is DownloadManager.Result.Error -> {
is IDownloadManager.Result.Error -> {
withContext(Dispatchers.Main) {
context.showToast(result.localizedReason)
val toastText = result.getLocalizedReason(context)
?: context.getString(R.string.downloader_err_unknown)
Toast.makeText(context, toastText, Toast.LENGTH_LONG).show()
}
throw Error("Failed to download: ${result.debugReason}")
throw Error("Failed to download: $result")
}
is DownloadManager.Result.Cancelled ->
is IDownloadManager.Result.Cancelled ->
state = StepState.Error
}
}

View file

@ -11,14 +11,14 @@ import cafe.adriel.voyager.core.model.StateScreenModel
import cafe.adriel.voyager.core.model.screenModelScope
import com.aliucord.manager.BuildConfig
import com.aliucord.manager.R
import com.aliucord.manager.manager.PathManager
import com.aliucord.manager.manager.PreferencesManager
import com.aliucord.manager.patcher.KotlinPatchRunner
import com.aliucord.manager.patcher.StepRunner
import com.aliucord.manager.patcher.steps.StepGroup
import com.aliucord.manager.patcher.steps.base.Step
import com.aliucord.manager.patcher.steps.base.StepState
import com.aliucord.manager.patcher.steps.install.InstallStep
import com.aliucord.manager.manager.PathManager
import com.aliucord.manager.manager.PreferencesManager
import com.aliucord.manager.ui.screens.patchopts.PatchOptions
import com.aliucord.manager.ui.util.toUnsafeImmutable
import com.aliucord.manager.util.*
@ -193,6 +193,7 @@ class PatchingScreenModel(
Built from commit ${BuildConfig.GIT_COMMIT} on ${BuildConfig.GIT_BRANCH} $gitChanges
Android API: ${Build.VERSION.SDK_INT}
Emulator: $IS_PROBABLY_EMULATOR (guess)
ROM: Android ${Build.VERSION.RELEASE} (Patch ${Build.VERSION.SECURITY_PATCH})
Supported ABIs: ${Build.SUPPORTED_ABIS.joinToString()}
Device: ${Build.MANUFACTURER} ${Build.MODEL} (${Build.DEVICE})

View file

@ -5,8 +5,10 @@ import androidx.compose.runtime.*
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.aliucord.manager.BuildConfig
import com.aliucord.manager.di.DownloadManagerProvider
import com.aliucord.manager.domain.repository.GithubRepository
import com.aliucord.manager.manager.*
import com.aliucord.manager.manager.InstallerManager
import com.aliucord.manager.manager.InstallerSetting
import com.aliucord.manager.network.utils.SemVer
import com.aliucord.manager.network.utils.getOrNull
import kotlinx.coroutines.Dispatchers
@ -14,7 +16,7 @@ import kotlinx.coroutines.launch
class UpdaterViewModel(
private val github: GithubRepository,
private val downloadManager: DownloadManager,
private val downloaders: DownloadManagerProvider,
private val installers: InstallerManager,
private val application: Application,
) : ViewModel() {
@ -41,7 +43,7 @@ class UpdaterViewModel(
application.externalCacheDir!!.resolve("manager.apk").also {
it.delete()
downloadManager.download(url, it)
downloaders.getActiveDownloader().download(url, it)
installers.getInstaller(InstallerSetting.PM).install(
apks = listOf(it),
silent = true,

View file

@ -1,7 +1,10 @@
package com.aliucord.manager.util
import android.os.Build
import androidx.collection.ObjectList
import com.aliucord.manager.BuildConfig
import java.io.BufferedReader
import java.io.IOException
import kotlin.math.pow
import kotlin.math.truncate
@ -31,3 +34,59 @@ val IS_CUSTOM_BUILD by lazy {
|| BuildConfig.GIT_LOCAL_CHANGES
|| BuildConfig.GIT_LOCAL_COMMITS
}
/**
* Whether this device is most likely an emulator.
* src: https://stackoverflow.com/a/21505193/13964629
*/
val IS_PROBABLY_EMULATOR by lazy {
// Android SDK emulator
return@lazy ((Build.MANUFACTURER == "Google" && Build.BRAND == "google" &&
((Build.FINGERPRINT.startsWith("google/sdk_gphone_")
&& Build.FINGERPRINT.endsWith(":user/release-keys")
&& Build.PRODUCT.startsWith("sdk_gphone_")
&& Build.MODEL.startsWith("sdk_gphone_"))
// Alternative
|| (Build.FINGERPRINT.startsWith("google/sdk_gphone64_")
&& (Build.FINGERPRINT.endsWith(":userdebug/dev-keys") || Build.FINGERPRINT.endsWith(":user/release-keys"))
&& Build.PRODUCT.startsWith("sdk_gphone64_")
&& Build.MODEL.startsWith("sdk_gphone64_"))))
|| Build.FINGERPRINT.startsWith("generic")
|| Build.FINGERPRINT.startsWith("unknown")
|| Build.MODEL.contains("google_sdk")
|| Build.MODEL.contains("Emulator")
|| Build.MODEL.contains("Android SDK built for x86")
// Bluestacks
|| "QC_Reference_Phone" == Build.BOARD && !"Xiaomi".equals(Build.MANUFACTURER, ignoreCase = true)
// Bluestacks
|| Build.MANUFACTURER.contains("Genymotion")
|| Build.HOST.startsWith("Build")
// MSI App Player
|| Build.BRAND.startsWith("generic") && Build.DEVICE.startsWith("generic")
|| Build.PRODUCT == "google_sdk"
// Another Android SDK emulator check
|| getSystemProp("ro.kernel.qemu") == "1")
}
/**
* Gets a system property from build.prop
*/
private fun getSystemProp(name: String): String? {
var reader: BufferedReader? = null
return try {
val process = Runtime.getRuntime().exec("getprop $name")
reader = process.inputStream.bufferedReader()
reader.readLine()
} catch (_: Exception) {
null
} finally {
try {
reader?.close()
} catch (_: IOException) {
// ignore
}
}
}