This commit is contained in:
2026-05-20 19:47:33 +10:00
commit dbb6302bd1
313 changed files with 17869 additions and 0 deletions
@@ -0,0 +1,74 @@
package com.meowarex.rlmobile.manager
import android.content.Context
import android.content.pm.PackageManager
import android.os.Build
import com.meowarex.rlmobile.R
import com.meowarex.rlmobile.util.showToast
import com.rosan.dhizuku.api.Dhizuku
import com.rosan.dhizuku.api.DhizukuRequestPermissionListener
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.sync.Mutex
import java.util.concurrent.atomic.AtomicBoolean
import kotlin.coroutines.resume
/**
* Handles setting up Dhizuku and obtaining permissions.
*/
class DhizukuManager(private val context: Context) {
private var dhizukuPermissionLock = Mutex()
private val dhizukuAvailable = AtomicBoolean(false)
/**
* Determines whether Dhizuku is available and the binder has been retrieved.
*/
fun dhizukuAvailable(): Boolean {
// Dhziuku requires at least Android 8.0
if (Build.VERSION.SDK_INT < 26) return false
if (!dhizukuAvailable.get()) {
return Dhizuku.init(context)
.also(dhizukuAvailable::set)
}
return true
}
/**
* Checks whether Dhizuku permissions have been granted to this app.
*/
fun checkPermissions(): Boolean {
if (!dhizukuAvailable()) return false
return Dhizuku.isPermissionGranted()
}
/**
* Requests and waits for Dhizuku permissions if they have not already been granted.
*/
suspend fun requestPermissions(): Boolean {
if (!dhizukuAvailable()) return false
// Lock and check if the previous holder already obtained permissions
dhizukuPermissionLock.lock()
try {
if (checkPermissions()) {
dhizukuPermissionLock.unlock()
return true
}
} catch (_: Exception) {
dhizukuPermissionLock.unlock()
}
return suspendCancellableCoroutine { continuation ->
Dhizuku.requestPermission(object : DhizukuRequestPermissionListener() {
override fun onRequestPermission(grantResult: Int) {
if (grantResult != PackageManager.PERMISSION_GRANTED)
context.showToast(R.string.permissions_dhizuku_denied)
continuation.resume(grantResult == PackageManager.PERMISSION_GRANTED)
dhizukuPermissionLock.unlock()
}
})
}
}
}
@@ -0,0 +1,188 @@
package com.meowarex.rlmobile.manager
import android.annotation.SuppressLint
import android.app.Application
import android.os.Build
import android.os.storage.StorageManager
import android.util.Log
import androidx.compose.runtime.Immutable
import androidx.core.content.getSystemService
import com.meowarex.rlmobile.BuildConfig
import com.meowarex.rlmobile.ui.screens.patchopts.PatchOptions
import com.meowarex.rlmobile.util.*
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.decodeFromStream
import java.io.IOException
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
import kotlin.time.Duration
import kotlin.time.Instant
/**
* Central manager for storing all attempted installations and
* their associated logs/crashes (not including manager crashes themselves).
*/
class InstallLogManager(
private val application: Application,
private val prefs: PreferencesManager,
private val json: Json,
) {
val logsDir = application.filesDir.resolve("install-logs").apply { mkdir() }
/**
* Lists all the install data entries that exist on disk, sorted decreasing by
* the file creation date.
* @return List of installation ids, most recent installation first.
*/
fun fetchInstallDataEntries(): List<String> {
val files = logsDir.listFiles { it.extension == "json" } ?: emptyArray()
return files
.sortedByDescending { it.lastModified() }
.map { it.nameWithoutExtension }
}
/**
* Loads the install log from disk, if it exists.
*/
fun fetchInstallData(id: String): InstallLogData? {
val path = logsDir.resolve("$id.json")
if (!path.exists()) return null
return try {
json.decodeFromStream(path.inputStream())
} catch (t: Throwable) {
Log.e(BuildConfig.TAG, "Failed to open install log $id", t)
null
}
}
fun deleteAllEntries() {
logsDir.deleteRecursively()
logsDir.mkdir()
}
/**
* Writes an install log entry to disk.
*/
suspend fun storeInstallData(
id: String,
installDate: Instant,
installDuration: Duration,
options: PatchOptions,
log: String,
error: Throwable?,
) {
val path = logsDir.resolve("$id.json")
val data = InstallLogData(
id = id,
installDate = installDate,
installDuration = installDuration,
installOptions = options,
environmentInfo = getEnvironmentInfo(),
installationLog = log,
errorStacktrace = error?.let { Log.getStackTraceString(it).trimEnd() },
)
try {
path.writeText(json.encodeToString(data))
} catch (e: IOException) {
Log.e(BuildConfig.TAG, "Failed to write log to disk", e)
}
}
/**
* Creates a list of details about the current installation environment.
*/
@Suppress("KotlinConstantConditions", "SimplifyBooleanWithConstants")
@SuppressLint("UsableSpace")
suspend fun getEnvironmentInfo(): String {
val storageManager = application.getSystemService<StorageManager>()!!
val buildType = when {
BuildConfig.RELEASE -> "(Release)"
BuildConfig.GIT_LOCAL_CHANGES || BuildConfig.GIT_LOCAL_COMMITS -> "(Changes present)"
else -> ""
}
val soc = if (Build.VERSION.SDK_INT >= 31) (Build.SOC_MANUFACTURER + ' ' + Build.SOC_MODEL) else "Unavailable"
val playProtect = when (application.isPlayProtectEnabled()) {
null -> "Unavailable"
true -> "Enabled"
false -> "Disabled"
}
val diskFreeSize = application.filesDir.usableSpace
val cacheQuotaSize = if (Build.VERSION.SDK_INT >= 26) {
storageManager.getCacheQuotaBytes(storageManager.getUuidForPath(application.cacheDir))
} else {
0L
}
return """
Radiant Lyrics Manager v${BuildConfig.VERSION_NAME}
Built from commit ${BuildConfig.GIT_COMMIT} on ${BuildConfig.GIT_BRANCH} $buildType
Developer mode: ${if (prefs.devMode) "On" else "Off"}
External storage: ${if (prefs.devMode || prefs.keepPatchedApks) "Yes" else "No"}
Disk Free: ${diskFreeSize.formatShortFileSize()}
Cache Quota: ${cacheQuotaSize.formatShortFileSize()}
Android API: ${Build.VERSION.SDK_INT}
Supported ABIs: ${Build.SUPPORTED_ABIS.joinToString()}
ROM: Android ${Build.VERSION.RELEASE} (Patch ${Build.VERSION.SECURITY_PATCH})
Device: ${Build.MANUFACTURER} ${Build.MODEL} (${Build.DEVICE})
Emulator: ${if (IS_PROBABLY_EMULATOR) "Yes" else "No"} (guess)
Play Protect: $playProtect
SOC: $soc
""".trimIndent()
}
}
@Immutable
@Serializable
data class InstallLogData(
val id: String,
val installDate: Instant,
val installDuration: Duration,
val installOptions: PatchOptions,
val environmentInfo: String,
val installationLog: String,
val errorStacktrace: String?,
) {
val isError: Boolean
get() = errorStacktrace != null
fun getFormattedInstallDate(): String {
@SuppressLint("SimpleDateFormat")
return SimpleDateFormat("yyyy-MM-dd HH-mm-ss", Locale.ENGLISH)
.format(Date(installDate.toEpochMilliseconds()))
}
fun getLogFileContents(): String = buildString {
appendLine("////////////////// Environment Info //////////////////")
appendLine(environmentInfo)
append("\n\n")
appendLine("////////////////// Installation Info //////////////////")
appendLine()
append("Install ID: ")
appendLine(id)
append("Install time: ")
appendLine(getFormattedInstallDate())
append("Result: ")
appendLine(if (isError) "Failure" else "Success")
append("\n\n")
appendLine("////////////////// Error Stacktrace //////////////////")
appendLine()
appendLine(errorStacktrace ?: "None")
append("\n\n")
appendLine("////////////////// Installation Log //////////////////")
appendLine()
appendLine(installationLog)
}
}
@@ -0,0 +1,64 @@
package com.meowarex.rlmobile.manager
import androidx.compose.runtime.Composable
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import com.meowarex.rlmobile.R
import com.meowarex.rlmobile.installers.Installer
import com.meowarex.rlmobile.installers.dhizuku.DhizukuInstaller
import com.meowarex.rlmobile.installers.intent.IntentInstaller
import com.meowarex.rlmobile.installers.pm.PMInstaller
import com.meowarex.rlmobile.installers.root.RootInstaller
import com.meowarex.rlmobile.installers.shizuku.ShizukuInstaller
import org.koin.core.annotation.KoinInternalApi
import org.koin.core.component.KoinComponent
import kotlin.reflect.KClass
/**
* Handle providing the correct install manager based on preferences.
*/
class InstallerManager(
private val prefs: PreferencesManager,
) : KoinComponent {
fun getActiveInstaller(): Installer =
getInstaller(prefs.installer)
@OptIn(KoinInternalApi::class)
fun getInstaller(type: InstallerSetting): Installer =
getKoin().scopeRegistry.rootScope.get(clazz = type.installerClass)
}
enum class InstallerSetting(val installerClass: KClass<out Installer>) {
PackageInstaller(PMInstaller::class),
Root(RootInstaller::class),
Intent(IntentInstaller::class),
Shizuku(ShizukuInstaller::class),
Dhizuku(DhizukuInstaller::class);
@Composable
fun title() = when (this) {
PackageInstaller -> stringResource(R.string.installer_pm)
Root -> stringResource(R.string.installer_root)
Intent -> stringResource(R.string.installer_intent)
Shizuku -> stringResource(R.string.installer_shizuku)
Dhizuku -> stringResource(R.string.installer_dhizuku)
}
@Composable
fun description() = when (this) {
PackageInstaller -> stringResource(R.string.installer_pm_desc)
Root -> stringResource(R.string.installer_root_desc)
Intent -> stringResource(R.string.installer_intent_desc)
Shizuku -> stringResource(R.string.installer_shizuku_desc)
Dhizuku -> stringResource(R.string.installer_dhizuku_desc)
}
@Composable
fun icon() = when (this) {
PackageInstaller -> painterResource(R.drawable.ic_android)
Root -> painterResource(R.drawable.ic_hashtag)
Intent -> painterResource(R.drawable.ic_launch)
Shizuku -> painterResource(R.drawable.ic_shizuku)
Dhizuku -> painterResource(R.drawable.ic_dhizuku)
}
}
@@ -0,0 +1,72 @@
package com.meowarex.rlmobile.manager
import androidx.compose.runtime.*
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.*
import kotlin.coroutines.resume
typealias ResultComposable<R> = @Composable (onResult: (R) -> Unit) -> Unit
/**
* This is used to display dialogs on top of the current activity at any point in the code.
* The main use case for this is dialogs for which a result is needed during patching steps.
*
* The only other alternative to this setup is binding `Flow`s from the steps back to the patching screen model
* and then displaying them in the UI, which is too much boilerplate.
*/
@Stable
class OverlayManager {
private val coroutineScope = CoroutineScope(Dispatchers.Main) + SupervisorJob()
private var overlays = mutableStateListOf<ResultComposable<Any?>>()
private var overlayResults = MutableSharedFlow<Pair<ResultComposable<Any?>, Any?>>(extraBufferCapacity = 5)
/**
* Display all the currently queued overlays.
*/
@Composable
fun Overlays() {
for (composable in overlays) {
key(System.identityHashCode(composable)) {
val composable by rememberUpdatedState(composable)
composable { result ->
if (!overlayResults.tryEmit(composable to result))
error("overlayResults flow full!")
overlays -= composable
}
}
}
}
/**
* Adds a composable to the overlay stack which will be displayed over the top of any content.
*
* This content will be displayed until the `onResult` callback is called,
* after which this method will finish suspending with the result from the invoked callback.
*
* If the coroutine scope this method was called in gets cancelled, then the overlay will be
* removed and no result will be returned (cancelled).
*/
@Suppress("UNCHECKED_CAST")
suspend fun <R> startComposableForResult(composable: ResultComposable<R>): R {
return suspendCancellableCoroutine { continuation ->
val job = overlayResults
.filter { (c, _) -> c === composable }
.onEach { (_, result) -> continuation.resume(result as R) }
.cancellable()
.launchIn(coroutineScope)
continuation.invokeOnCancellation {
coroutineScope.launch {
overlays -= composable
job.cancel()
}
}
coroutineScope.launch {
overlays += composable
}
}
}
}
@@ -0,0 +1,53 @@
package com.meowarex.rlmobile.manager
import android.app.Application
import android.os.Environment
import com.meowarex.rlmobile.network.utils.SemVer
import java.io.File
class PathManager(
private val context: Application,
) {
val rlMobileDir = Environment.getExternalStorageDirectory().resolve("RadiantLyrics")
val pluginsDir = rlMobileDir.resolve("plugins")
val coreSettingsFile = rlMobileDir.resolve("settings/RadiantLyrics.json")
val legacyKeystoreFile = rlMobileDir.resolve("ks.keystore")
val keystoreFile = context.filesDir.resolve("rlmobile.keystore")
val patchingDir = context.filesDir.resolve("patching")
val patchingDownloadDir = patchingDir.resolve("downloads")
val cacheDownloadDir = context.cacheDir.resolve("downloads")
val customComponentsDir = patchingDir.resolve("custom")
val customInjectorsDir = customComponentsDir.resolve("injector")
val customPatchesDir = customComponentsDir.resolve("patches")
val patchingWorkingDir = patchingDir.resolve("patched")
val patchedApk = patchingWorkingDir.resolve("patched.apk")
fun clearCache() {
for (dir in arrayOf(patchingDir, cacheDownloadDir, context.cacheDir))
dir.deleteRecursively()
}
fun cachedTidalApk(version: Int, split: String = "base"): File = patchingDownloadDir
.resolve("tidal/$version")
.resolve("$split.apk")
fun cachedSmaliPatches(version: SemVer) = patchingDownloadDir
.resolve("patches")
.resolve("$version.zip")
fun customInjectors() = customInjectorsDir.listFiles()?.asList() ?: emptyList()
fun customSmaliPatches() = customPatchesDir.listFiles()?.asList() ?: emptyList()
}
@@ -0,0 +1,17 @@
package com.meowarex.rlmobile.manager
import android.content.SharedPreferences
import androidx.compose.runtime.Stable
import com.meowarex.rlmobile.manager.base.BasePreferenceManager
import com.meowarex.rlmobile.ui.theme.Theme
@Stable
class PreferencesManager(preferences: SharedPreferences) : BasePreferenceManager(preferences) {
var theme by enumPreference("theme", Theme.System)
var dynamicColor by booleanPreference("dynamic_color", true)
var devMode by booleanPreference("dev_mode", false)
var installer by enumPreference<InstallerSetting>("installer", InstallerSetting.PackageInstaller)
var keepPatchedApks by booleanPreference("keep_patched_apks", false)
var showNetworkWarning by booleanPreference("show_network_warning", true)
var showPlayProtectWarning by booleanPreference("show_play_protect_warning", true)
}
@@ -0,0 +1,96 @@
package com.meowarex.rlmobile.manager
import android.content.Context
import android.content.pm.PackageManager
import com.meowarex.rlmobile.R
import com.meowarex.rlmobile.util.showToast
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.sync.Mutex
import rikka.shizuku.Shizuku
import java.util.concurrent.atomic.AtomicBoolean
import kotlin.coroutines.resume
import kotlin.random.Random
/**
* Handles setting up Shizuku and obtaining permissions.
*/
class ShizukuManager(private val context: Context) {
private var shizukuPermissionLock = Mutex()
private val shizukuAvailable = AtomicBoolean(false)
init {
Shizuku.addBinderReceivedListenerSticky {
shizukuAvailable.set(true)
}
Shizuku.addBinderDeadListener {
shizukuAvailable.set(false)
if (shizukuPermissionLock.isLocked)
shizukuPermissionLock.unlock()
}
}
/**
* Determines whether Shizuku is available and the binder has been retrieved.
*/
fun shizukuAvailable(): Boolean {
if (!shizukuAvailable.get()) {
return Shizuku.pingBinder()
.also(shizukuAvailable::set)
}
return true
}
/**
* Checks whether Shizuku permissions have been granted to this app.
*/
fun checkPermissions(): Boolean {
if (!shizukuAvailable()) return false
// Old shizuku does not have permission checks
if (Shizuku.isPreV11()) return true
return Shizuku.checkSelfPermission() == PackageManager.PERMISSION_GRANTED
}
/**
* Requests and waits for Shizuku permissions if they have not already been granted.
*/
suspend fun requestPermissions(): Boolean {
if (!shizukuAvailable()) return false
// Lock and check if the previous holder already obtained permissions
shizukuPermissionLock.lock()
try {
if (checkPermissions()) {
shizukuPermissionLock.unlock()
return true
}
} catch (_: Exception) {
shizukuPermissionLock.unlock()
}
return suspendCancellableCoroutine { continuation ->
val currentRequestCode = Random.nextInt()
val onPermissionRequestResult =
Shizuku.OnRequestPermissionResultListener { requestCode, grantResult ->
if (requestCode != currentRequestCode)
return@OnRequestPermissionResultListener
if (grantResult == PackageManager.PERMISSION_DENIED)
context.showToast(R.string.permissions_shizuku_denied)
continuation.resume(grantResult == PackageManager.PERMISSION_GRANTED)
shizukuPermissionLock.unlock()
}
continuation.invokeOnCancellation {
Shizuku.removeRequestPermissionResultListener(onPermissionRequestResult)
shizukuPermissionLock.unlock()
}
Shizuku.addRequestPermissionResultListener(onPermissionRequestResult)
Shizuku.requestPermission(currentRequestCode)
}
}
}
@@ -0,0 +1,99 @@
package com.meowarex.rlmobile.manager.base
import android.content.SharedPreferences
import androidx.compose.runtime.*
import androidx.core.content.edit
import kotlin.reflect.KProperty
abstract class BasePreferenceManager(
private val prefs: SharedPreferences,
) {
protected fun getString(key: String, defaultValue: String) = prefs.getString(key, defaultValue) ?: defaultValue
private fun getBoolean(key: String, defaultValue: Boolean) = prefs.getBoolean(key, defaultValue)
private fun getInt(key: String, defaultValue: Int) = prefs.getInt(key, defaultValue)
private fun getFloat(key: String, defaultValue: Float) = prefs.getFloat(key, defaultValue)
protected inline fun <reified E : Enum<E>> getEnum(key: String, defaultValue: E): E {
return try {
enumValueOf<E>(getString(key, defaultValue.name))
} catch (_: IllegalArgumentException) {
defaultValue
}
}
protected fun putString(key: String, value: String?) = prefs.edit { putString(key, value) }
private fun putBoolean(key: String, value: Boolean) = prefs.edit { putBoolean(key, value) }
private fun putInt(key: String, value: Int) = prefs.edit { putInt(key, value) }
private fun putFloat(key: String, value: Float) = prefs.edit { putFloat(key, value) }
protected inline fun <reified E : Enum<E>> putEnum(key: String, value: E) = putString(key, value.name)
protected class Preference<T>(
private val key: String,
defaultValue: T,
getter: (key: String, defaultValue: T) -> T,
private val setter: (key: String, newValue: T) -> Unit,
) {
var value by mutableStateOf(getter(key, defaultValue))
private set
operator fun getValue(thisRef: Any?, property: KProperty<*>) = value
operator fun setValue(thisRef: Any?, property: KProperty<*>, newValue: T) {
value = newValue
setter(key, newValue)
}
}
@Suppress("unused")
protected fun stringPreference(
key: String,
defaultValue: String,
) = Preference(
key = key,
defaultValue = defaultValue,
getter = ::getString,
setter = ::putString
)
@Suppress("unused")
protected fun booleanPreference(
key: String,
defaultValue: Boolean,
) = Preference(
key = key,
defaultValue = defaultValue,
getter = ::getBoolean,
setter = ::putBoolean
)
@Suppress("unused")
protected fun intPreference(
key: String,
defaultValue: Int,
) = Preference(
key = key,
defaultValue = defaultValue,
getter = ::getInt,
setter = ::putInt
)
@Suppress("unused")
protected fun floatPreference(
key: String,
defaultValue: Float,
) = Preference(
key = key,
defaultValue = defaultValue,
getter = ::getFloat,
setter = ::putFloat
)
@Suppress("unused")
protected inline fun <reified E : Enum<E>> enumPreference(
key: String,
defaultValue: E,
) = Preference(
key = key,
defaultValue = defaultValue,
getter = ::getEnum,
setter = ::putEnum
)
}
@@ -0,0 +1,153 @@
package com.meowarex.rlmobile.manager.download
import android.app.Application
import android.app.DownloadManager
import android.content.Context
import android.database.Cursor
import android.net.Uri
import androidx.core.content.getSystemService
import androidx.core.net.toUri
import com.meowarex.rlmobile.BuildConfig
import com.meowarex.rlmobile.R
import com.meowarex.rlmobile.manager.download.IDownloadManager.ProgressListener
import com.meowarex.rlmobile.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's [DownloadManager].
*/
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 [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]
*/
override suspend fun download(url: String, out: File, onProgressUpdate: ProgressListener?): Result {
onProgressUpdate?.onUpdate(null)
out.parentFile?.mkdirs()
// Create and start a download in the system DownloadManager
val downloadId = DownloadManager.Request(url.toUri())
.setTitle("Radiant Lyrics Manager")
.setDescription("Downloading ${out.name}...")
.setDestinationUri(Uri.fromFile(out))
.setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE)
.addRequestHeader("User-Agent", "Radiant Lyrics Manager/${BuildConfig.VERSION_NAME}")
.let(downloadManager::enqueue)
// Repeatedly request download state until it is finished
while (true) {
try {
// Hand over control to a suspend function to check for cancellation
// At the same time, delay 100ms to slow down the potentially infinite loop
delay(100)
} catch (_: CancellationException) {
// If the running CoroutineScope has been cancelled, then gracefully cancel download
downloadManager.remove(downloadId)
return Result.Cancelled(systemTriggered = false)
}
// Request download status
val cursor = DownloadManager.Query()
.setFilterById(downloadId)
.let(downloadManager::query)
cursor.use {
// No results in cursor, download was cancelled
if (!cursor.moveToFirst()) {
return Result.Cancelled(systemTriggered = true)
}
val statusColumn = cursor.getColumnIndex(DownloadManager.COLUMN_STATUS)
val status = cursor.getInt(statusColumn)
when (status) {
DownloadManager.STATUS_PENDING, DownloadManager.STATUS_PAUSED ->
onProgressUpdate?.onUpdate(null)
DownloadManager.STATUS_RUNNING ->
onProgressUpdate?.onUpdate(getDownloadProgress(cursor))
DownloadManager.STATUS_SUCCESSFUL ->
return Result.Success(out)
DownloadManager.STATUS_FAILED -> {
val reasonColumn = cursor.getColumnIndex(DownloadManager.COLUMN_REASON)
val reason = cursor.getInt(reasonColumn)
return Error(reason)
}
else -> throw Error("Unreachable")
}
}
}
}
/**
* Get the download progress of the current row in a [DownloadManager.Query].
* @return Download progress in the range of `[0,1]`
*/
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
}
/**
* Error returned by the system [DownloadManager].
* @param reason The reason code returned by the [DownloadManager.COLUMN_REASON] column.
*/
data class Error(val reason: Int) : Result.Error() {
/**
* Convert a [DownloadManager.COLUMN_REASON] code into its name.
*/
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)"
}
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 ->
R.string.downloader_err_response
DownloadManager.ERROR_INSUFFICIENT_SPACE ->
R.string.downloader_err_storage_space
DownloadManager.ERROR_FILE_ALREADY_EXISTS ->
R.string.downloader_err_file_exists
else -> R.string.downloader_err_unknown
} // @formatter:on
return context.getString(string)
}
override fun toString(): String = getDebugReason()
}
}
@@ -0,0 +1,69 @@
package com.meowarex.rlmobile.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
/**
* Gets the underlying raw error (if available).
*/
open fun getError(): Throwable? = null
}
}
}
@@ -0,0 +1,148 @@
package com.meowarex.rlmobile.manager.download
import android.app.Application
import android.content.Context
import android.os.Build
import android.os.storage.StorageManager
import android.util.Log
import androidx.annotation.StringRes
import androidx.core.content.getSystemService
import com.meowarex.rlmobile.BuildConfig
import com.meowarex.rlmobile.R
import com.meowarex.rlmobile.manager.download.IDownloadManager.Result
import com.meowarex.rlmobile.patcher.util.InsufficientStorageException
import com.meowarex.rlmobile.util.IS_PROBABLY_EMULATOR
import io.ktor.client.HttpClient
import io.ktor.client.request.header
import io.ktor.client.request.prepareGet
import io.ktor.client.statement.bodyAsChannel
import io.ktor.client.statement.bodyAsText
import io.ktor.http.*
import io.ktor.utils.io.readAvailable
import kotlinx.coroutines.CancellationException
import java.io.File
import java.io.IOException
import java.net.SocketTimeoutException
/**
* 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,
private val application: Application,
) : 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")
try {
val httpStmt = http.prepareGet(url) {
header(HttpHeaders.CacheControl, "no-cache, no-store")
// Disable compression due to bug on emulators
// This header cannot be set with Android's DownloadManager
if (IS_PROBABLY_EMULATOR) {
header(HttpHeaders.AcceptEncoding, null)
}
}
httpStmt.execute { resp ->
if (!resp.status.isSuccess()) {
val body = try {
resp.bodyAsText().take(2048)
} catch (e: Exception) {
Log.e(BuildConfig.TAG, "Failed to read downloader error response", e)
"<failed to read>"
}
throw DownloadException(url = url, status = resp.status, body = body)
}
val channel = resp.bodyAsChannel()
val total = resp.contentLength() ?: 0
var retrieved = 0L
val buf = ByteArray(1024 * 1024 * 1)
var bufLen: Int
tmpOut.outputStream().use { stream ->
// Preallocate space for this file
if (total > 0 && Build.VERSION.SDK_INT >= 26) {
val storageManager = application.getSystemService<StorageManager>()!!
try {
storageManager.allocateBytes(stream.fd, total)
} catch (e: IOException) {
throw InsufficientStorageException(e.message)
}
}
while (!channel.isClosedForRead) {
bufLen = channel.readAvailable(buf)
if (bufLen <= 0) break
stream.write(buf, 0, bufLen)
stream.flush()
retrieved += bufLen
if (total > 0) {
if (retrieved > total)
throw IOException("Total bytes received exceeds header total!")
onProgressUpdate?.onUpdate(retrieved / total.toFloat())
} else {
onProgressUpdate?.onUpdate(null)
}
}
}
}
} catch (_: CancellationException) {
tmpOut.delete()
return Result.Cancelled(systemTriggered = false)
} catch (e: DownloadException) {
tmpOut.delete()
return Error(
error = e,
localizedError = R.string.downloader_err_code,
localizedErrorArgs = arrayOf(e.status.value),
)
} catch (e: SocketTimeoutException) {
tmpOut.delete()
return Error(e, localizedError = R.string.downloader_err_timeout)
} catch (e: InsufficientStorageException) {
tmpOut.delete()
return Error(e, localizedError = R.string.downloader_err_storage_space)
} 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 error: Throwable,
@StringRes
private val localizedError: Int? = null,
private val localizedErrorArgs: Array<Any> = arrayOf(),
) : Result.Error() {
override fun toString(): String = error.stackTraceToString()
override fun getDebugReason(): String = error.message ?: "Unknown exception"
override fun getLocalizedReason(context: Context): String? =
localizedError?.let { context.getString(it, *localizedErrorArgs) }
override fun getError(): Throwable? = error
}
private class DownloadException(val url: String, val status: HttpStatusCode, val body: String) :
IOException("Failed to download $url, received status code $status, response: $body")
}