mirror of
https://github.com/meowarex/rl-mobile.git
synced 2026-08-27 14:37:45 +10:00
Alpha
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
+99
@@ -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
|
||||
)
|
||||
}
|
||||
+153
@@ -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()
|
||||
}
|
||||
}
|
||||
+69
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
+148
@@ -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")
|
||||
}
|
||||
Reference in New Issue
Block a user