Skip to content

Commit

Permalink
feat: Ktor download manager
Browse files Browse the repository at this point in the history
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)
  • Loading branch information
rushiiMachine committed Aug 2, 2024
1 parent f736062 commit 98c0a2e
Show file tree
Hide file tree
Showing 11 changed files with 310 additions and 99 deletions.
10 changes: 8 additions & 2 deletions app/src/main/kotlin/com/aliucord/manager/ManagerApplication.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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),
}
13 changes: 1 addition & 12 deletions app/src/main/kotlin/com/aliucord/manager/di/Managers.kt
Original file line number Diff line number Diff line change
@@ -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)
}
Original file line number Diff line number Diff line change
@@ -1,13 +1,16 @@
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

class PreferencesManager(preferences: SharedPreferences) : BasePreferenceManager(preferences) {
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)
}
Original file line number Diff line number Diff line change
@@ -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()

Expand All @@ -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
Expand Down Expand Up @@ -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")
Expand All @@ -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 {
/**
* @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

data class Error(private val reason: Int) : Result.Error() {
/**
* Error returned by the system [DownloadManager].
* @param reason The reason code returned by the [DownloadManager.COLUMN_REASON] column.
* Convert a [DownloadManager.COLUMN_REASON] code into its name.
*/
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)"
}
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)"
}

/**
* 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 ->
Expand All @@ -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()
}
}
Original file line number Diff line number Diff line change
@@ -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
}
}
}
Loading

0 comments on commit 98c0a2e

Please sign in to comment.