mirror of
https://github.com/meowarex/rl-mobile.git
synced 2026-08-27 14:37:45 +10:00
Offline Repatch + Force Incompatible (Dev Option)
This commit is contained in:
@@ -22,7 +22,8 @@ class PathManager(
|
||||
|
||||
val patchingDownloadDir = patchingDir.resolve("downloads")
|
||||
|
||||
val cacheDownloadDir = context.cacheDir.resolve("downloads")
|
||||
// Persistent Stash of assets for Offline Repatching
|
||||
val cacheDownloadDir = patchingDir.resolve("downloads-cache")
|
||||
|
||||
val customComponentsDir = patchingDir.resolve("custom")
|
||||
|
||||
@@ -34,11 +35,15 @@ class PathManager(
|
||||
|
||||
val patchedApk = patchingWorkingDir.resolve("patched.apk")
|
||||
|
||||
// Persistent snapshot of the last successful release fetch (build info + patches asset URL)
|
||||
val cachedReleaseInfo = patchingDir.resolve("release-info.json")
|
||||
|
||||
fun clearCache() {
|
||||
val targets = arrayOf(
|
||||
patchingDownloadDir,
|
||||
patchingWorkingDir,
|
||||
cacheDownloadDir,
|
||||
cachedReleaseInfo,
|
||||
context.cacheDir,
|
||||
)
|
||||
for (dir in targets) {
|
||||
@@ -54,6 +59,16 @@ class PathManager(
|
||||
.resolve("patches")
|
||||
.resolve("$version.zip")
|
||||
|
||||
fun hasCachedTidalApk(version: Int, split: String = "base"): Boolean {
|
||||
val rel = "tidal/$version/$split.apk"
|
||||
return patchingDownloadDir.resolve(rel).exists() || cacheDownloadDir.resolve(rel).exists()
|
||||
}
|
||||
|
||||
fun hasCachedSmaliPatches(version: SemVer): Boolean {
|
||||
val rel = "patches/$version.zip"
|
||||
return patchingDownloadDir.resolve(rel).exists() || cacheDownloadDir.resolve(rel).exists()
|
||||
}
|
||||
|
||||
fun customTidalApks() = customTidalApksDir.listFiles()?.asList() ?: emptyList()
|
||||
|
||||
fun customSmaliPatches() = customPatchesDir.listFiles()?.asList() ?: emptyList()
|
||||
|
||||
@@ -12,6 +12,8 @@ class PreferencesManager(preferences: SharedPreferences) : BasePreferenceManager
|
||||
var devMode by booleanPreference("dev_mode", false)
|
||||
var installer by enumPreference<InstallerSetting>("installer", InstallerSetting.PackageInstaller)
|
||||
var keepPatchedApks by booleanPreference("keep_patched_apks", false)
|
||||
// Dev: force-apply path-gated ("incompatible") patches even under a non-stock package name
|
||||
var bypassIncompatible by booleanPreference("bypass_incompatible", false)
|
||||
var showPlayProtectWarning by booleanPreference("show_play_protect_warning", true)
|
||||
var autoUpdateCheck by booleanPreference("auto_update_check", true)
|
||||
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
package com.meowarex.rlmobile.manager
|
||||
|
||||
import android.util.Log
|
||||
import com.meowarex.rlmobile.BuildConfig
|
||||
import com.meowarex.rlmobile.network.models.RLBuildInfo
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.Json
|
||||
|
||||
// Snapshot of the last successful release fetch (build info + patches asset URL)
|
||||
@Serializable
|
||||
data class CachedReleaseInfo(
|
||||
val data: RLBuildInfo,
|
||||
val patchesAssetUrl: String,
|
||||
)
|
||||
|
||||
object ReleaseInfoCache {
|
||||
fun load(paths: PathManager, json: Json): CachedReleaseInfo? = try {
|
||||
paths.cachedReleaseInfo
|
||||
.takeIf { it.exists() }
|
||||
?.let { json.decodeFromString<CachedReleaseInfo>(it.readText()) }
|
||||
} catch (t: Throwable) {
|
||||
Log.w(BuildConfig.TAG, "Failed to read cached release info", t)
|
||||
null
|
||||
}
|
||||
|
||||
fun save(paths: PathManager, json: Json, info: CachedReleaseInfo) = try {
|
||||
paths.cachedReleaseInfo.parentFile?.mkdirs()
|
||||
paths.cachedReleaseInfo.writeText(json.encodeToString(info))
|
||||
} catch (t: Throwable) {
|
||||
Log.w(BuildConfig.TAG, "Failed to cache release info", t)
|
||||
}
|
||||
}
|
||||
+3
-3
@@ -6,7 +6,6 @@ import com.meowarex.rlmobile.patcher.steps.StepGroup
|
||||
import com.meowarex.rlmobile.patcher.steps.base.Step
|
||||
import com.meowarex.rlmobile.patcher.steps.download.CopyDependenciesStep
|
||||
import com.meowarex.rlmobile.patcher.util.ManifestPatcher
|
||||
import com.meowarex.rlmobile.ui.screens.patchopts.KnownPatch
|
||||
import com.meowarex.rlmobile.ui.screens.patchopts.PatchOptions
|
||||
import com.github.diamondminer88.zip.ZipReader
|
||||
import com.github.diamondminer88.zip.ZipWriter
|
||||
@@ -27,8 +26,9 @@ class PatchManifestStep(private val options: PatchOptions) : Step() {
|
||||
?: throw IllegalArgumentException("No manifest found in APK")
|
||||
|
||||
container.log("Patching manifest")
|
||||
val enableWazeIntegration =
|
||||
options.patchStates[KnownPatch.WazeIntegration.name] ?: KnownPatch.WazeIntegration.default.isEnabled
|
||||
val wazeDefault = container.getStep<SmaliPatchStep>().specs
|
||||
.firstOrNull { it.id == "WazeIntegration" }?.defaultEnabled ?: false
|
||||
val enableWazeIntegration = options.patchStates["WazeIntegration"] ?: wazeDefault
|
||||
|
||||
val patchedManifest = ManifestPatcher.patchManifest(
|
||||
manifestBytes = manifest,
|
||||
|
||||
+7
-1
@@ -1,5 +1,6 @@
|
||||
package com.meowarex.rlmobile.patcher.steps.patch
|
||||
|
||||
import android.content.Context
|
||||
import com.meowarex.rlmobile.R
|
||||
import com.meowarex.rlmobile.manager.PathManager
|
||||
import com.meowarex.rlmobile.patcher.StepRunner
|
||||
@@ -10,6 +11,7 @@ import com.meowarex.rlmobile.patcher.steps.download.CopyDependenciesStep
|
||||
import com.meowarex.rlmobile.patcher.steps.download.DownloadPatchesStep
|
||||
import com.meowarex.rlmobile.ui.screens.patchopts.PatchManifest
|
||||
import com.meowarex.rlmobile.ui.screens.patchopts.PatchOptions
|
||||
import com.meowarex.rlmobile.ui.screens.patchopts.PatchSpec
|
||||
import com.meowarex.rlmobile.ui.screens.patchopts.builtinPatchSpecs
|
||||
import com.android.tools.smali.baksmali.Baksmali
|
||||
import com.android.tools.smali.baksmali.BaksmaliOptions
|
||||
@@ -31,6 +33,10 @@ class SmaliPatchStep(
|
||||
) : Step(), IDexProvider, KoinComponent {
|
||||
private val paths: PathManager by inject()
|
||||
private val json: Json by inject()
|
||||
private val context: Context by inject()
|
||||
|
||||
var specs: List<PatchSpec> = emptyList()
|
||||
private set
|
||||
|
||||
override val group = StepGroup.Patch
|
||||
override val localizedName = R.string.patch_step_patch_smali
|
||||
@@ -55,7 +61,7 @@ class SmaliPatchStep(
|
||||
container.log("Failed to parse manifest.json (${t.message}); using built-in patch specs")
|
||||
null
|
||||
}
|
||||
val specs = manifestSpecs ?: builtinPatchSpecs { "" }
|
||||
specs = manifestSpecs ?: builtinPatchSpecs(context, json)
|
||||
container.log(
|
||||
"Loaded ${specs.size} patch specs from " +
|
||||
if (manifestSpecs != null) "manifest.json" else "built-in list"
|
||||
|
||||
+45
-2
@@ -1,19 +1,30 @@
|
||||
package com.meowarex.rlmobile.patcher.steps.prepare
|
||||
|
||||
import android.app.Application
|
||||
import android.util.Log
|
||||
import androidx.compose.runtime.Stable
|
||||
import com.meowarex.rlmobile.R
|
||||
import com.meowarex.rlmobile.manager.CachedReleaseInfo
|
||||
import com.meowarex.rlmobile.manager.PathManager
|
||||
import com.meowarex.rlmobile.manager.ReleaseInfoCache
|
||||
import com.meowarex.rlmobile.network.models.RLBuildInfo
|
||||
import com.meowarex.rlmobile.network.services.RadiantLyricsGithubService
|
||||
import com.meowarex.rlmobile.network.utils.getOrThrow
|
||||
import com.meowarex.rlmobile.patcher.StepRunner
|
||||
import com.meowarex.rlmobile.patcher.steps.StepGroup
|
||||
import com.meowarex.rlmobile.patcher.steps.base.Step
|
||||
import com.meowarex.rlmobile.util.isOnline
|
||||
import kotlinx.serialization.json.Json
|
||||
import org.koin.core.component.KoinComponent
|
||||
import org.koin.core.component.inject
|
||||
import kotlin.coroutines.cancellation.CancellationException
|
||||
|
||||
@Stable
|
||||
class FetchInfoStep : Step(), KoinComponent {
|
||||
private val github: RadiantLyricsGithubService by inject()
|
||||
private val context: Application by inject()
|
||||
private val paths: PathManager by inject()
|
||||
private val json: Json by inject()
|
||||
|
||||
override val group = StepGroup.Prepare
|
||||
override val localizedName = R.string.patch_step_fetch_info
|
||||
@@ -25,6 +36,33 @@ class FetchInfoStep : Step(), KoinComponent {
|
||||
private set
|
||||
|
||||
override suspend fun execute(container: StepRunner) {
|
||||
// Fetch the latest release and refresh the cache (when online)
|
||||
if (context.isOnline()) {
|
||||
try {
|
||||
fetchFromRemote(container)
|
||||
return
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (t: Throwable) {
|
||||
container.log("Failed to fetch latest release: ${Log.getStackTraceString(t)}")
|
||||
container.log("Falling back to cached release info for a local re-patch")
|
||||
}
|
||||
} else {
|
||||
container.log("No network connection — using cached release info for a local re-patch")
|
||||
}
|
||||
|
||||
val cached = ReleaseInfoCache.load(paths, json)
|
||||
?: throw IllegalStateException(
|
||||
"Could not fetch the latest release and no cached release info is available. " +
|
||||
"Connect to the internet and try again."
|
||||
)
|
||||
data = cached.data
|
||||
patchesAssetUrl = cached.patchesAssetUrl
|
||||
container.log("Using cached build info: $data")
|
||||
container.log("Cached patches asset URL: $patchesAssetUrl")
|
||||
}
|
||||
|
||||
private suspend fun fetchFromRemote(container: StepRunner) {
|
||||
container.log("Fetching latest release from ${RadiantLyricsGithubService.REPO_OWNER}/${RadiantLyricsGithubService.REPO_NAME}")
|
||||
val release = github.getLatestRelease(force = true).getOrThrow()
|
||||
|
||||
@@ -33,14 +71,19 @@ class FetchInfoStep : Step(), KoinComponent {
|
||||
?.browserDownloadUrl
|
||||
?: throw IllegalStateException("No ${RadiantLyricsGithubService.DATA_JSON_ASSET_NAME} asset found in latest release ${release.tagName}")
|
||||
|
||||
patchesAssetUrl = release.assets
|
||||
val patchesUrl = release.assets
|
||||
.find { it.name == RadiantLyricsGithubService.PATCHES_ASSET_NAME }
|
||||
?.browserDownloadUrl
|
||||
?: throw IllegalStateException("No ${RadiantLyricsGithubService.PATCHES_ASSET_NAME} asset found in latest release ${release.tagName}")
|
||||
|
||||
container.log("Fetching build info from $dataJsonUrl")
|
||||
data = github.getBuildInfo(dataJsonUrl, force = true).getOrThrow()
|
||||
val buildInfo = github.getBuildInfo(dataJsonUrl, force = true).getOrThrow()
|
||||
|
||||
data = buildInfo
|
||||
patchesAssetUrl = patchesUrl
|
||||
container.log("Fetched build info: $data")
|
||||
container.log("Patches asset URL: $patchesAssetUrl")
|
||||
|
||||
ReleaseInfoCache.save(paths, json, CachedReleaseInfo(buildInfo, patchesUrl))
|
||||
}
|
||||
}
|
||||
|
||||
+4
-1
@@ -9,6 +9,7 @@ import com.meowarex.rlmobile.network.utils.SemVer
|
||||
import com.meowarex.rlmobile.ui.screens.componentopts.PatchComponent
|
||||
import com.meowarex.rlmobile.ui.screens.patchopts.*
|
||||
import com.meowarex.rlmobile.ui.theme.ManagerTheme
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlin.time.Clock
|
||||
|
||||
// This preview has scrollable/interactable content that cannot be tested from an IDE preview
|
||||
@@ -21,7 +22,7 @@ private fun PatchOptionsScreenPreview(
|
||||
parameters: PatchOptionsParameters,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val specs = remember { builtinPatchSpecs { context.getString(it) } }
|
||||
val specs = remember { builtinPatchSpecs(context, Json { ignoreUnknownKeys = true }) }
|
||||
|
||||
ManagerTheme {
|
||||
PatchOptionsScreenContent(
|
||||
@@ -29,6 +30,8 @@ private fun PatchOptionsScreenPreview(
|
||||
isDevMode = parameters.isDevMode,
|
||||
debuggable = parameters.debuggable,
|
||||
setDebuggable = {},
|
||||
bypassIncompatible = false,
|
||||
setBypassIncompatible = {},
|
||||
appName = parameters.appName,
|
||||
appNameIsError = parameters.appNameIsError,
|
||||
setAppName = {},
|
||||
|
||||
@@ -19,7 +19,9 @@ import cafe.adriel.voyager.core.model.screenModelScope
|
||||
import com.github.diamondminer88.zip.ZipReader
|
||||
import com.meowarex.rlmobile.BuildConfig
|
||||
import com.meowarex.rlmobile.R
|
||||
import com.meowarex.rlmobile.manager.PathManager
|
||||
import com.meowarex.rlmobile.manager.PreferencesManager
|
||||
import com.meowarex.rlmobile.manager.ReleaseInfoCache
|
||||
import com.meowarex.rlmobile.network.models.GithubCommit
|
||||
import com.meowarex.rlmobile.network.models.RLBuildInfo
|
||||
import com.meowarex.rlmobile.network.services.RadiantLyricsGithubService
|
||||
@@ -42,6 +44,7 @@ class HomeModel(
|
||||
private val github: RadiantLyricsGithubService,
|
||||
private val json: Json,
|
||||
private val prefs: PreferencesManager,
|
||||
private val paths: PathManager,
|
||||
) : ScreenModel {
|
||||
|
||||
var state by mutableStateOf<HomeState>(HomeState.Loading)
|
||||
@@ -170,16 +173,23 @@ class HomeModel(
|
||||
|
||||
refreshingLock.withLock {
|
||||
val pkg = fetchInstalled()
|
||||
val remote = async(Dispatchers.IO) { if (remoteDataJson == null) fetchRemoteData() }
|
||||
remote.await()
|
||||
// Skip the remote fetch entirely when offline
|
||||
val online = application.isOnline()
|
||||
if (online) {
|
||||
val remote = async(Dispatchers.IO) { if (remoteDataJson == null) fetchRemoteData() }
|
||||
remote.await()
|
||||
}
|
||||
|
||||
val install = pkg?.toInstallData()
|
||||
val latest = remoteDataJson?.tidalVersionCode
|
||||
val offlineRepatchReady = hasOfflineRepatchAssets()
|
||||
|
||||
mainThread {
|
||||
state = HomeState.Loaded(
|
||||
install = install,
|
||||
latestTidalVersionCode = latest,
|
||||
offline = !online,
|
||||
offlineRepatchReady = offlineRepatchReady,
|
||||
)
|
||||
maybeCheckManagerUpdate(pkg)
|
||||
}
|
||||
@@ -257,6 +267,13 @@ class HomeModel(
|
||||
)
|
||||
}
|
||||
|
||||
// check if an offline "Local Repatch" is possible
|
||||
private fun hasOfflineRepatchAssets(): Boolean {
|
||||
val info = ReleaseInfoCache.load(paths, json) ?: return false
|
||||
return paths.hasCachedTidalApk(info.data.tidalVersionCode) &&
|
||||
paths.hasCachedSmaliPatches(info.data.patchesVersion)
|
||||
}
|
||||
|
||||
private suspend fun fetchRemoteData() {
|
||||
val release = try {
|
||||
github.getLatestRelease().fold(
|
||||
|
||||
@@ -210,14 +210,19 @@ private fun ColumnScope.HomeContent(
|
||||
}
|
||||
|
||||
val blockedByManagerUpdate = managerUpdateAvailable && (patchesBehind || tidalBehind)
|
||||
val canLocalRepatch = install != null && state.offlineRepatchReady
|
||||
val onlineEnabled = state.latestTidalVersionCode != null || install != null
|
||||
val buttonEnabled = !blockedByManagerUpdate && (if (state.offline) canLocalRepatch else onlineEnabled)
|
||||
Button(
|
||||
onClick = if (install == null) onInstall else onRepatch,
|
||||
enabled = state.latestTidalVersionCode != null && !blockedByManagerUpdate,
|
||||
enabled = buttonEnabled,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
val label = when {
|
||||
blockedByManagerUpdate -> "Manager Update Required"
|
||||
state.latestTidalVersionCode == null -> "Loading…"
|
||||
state.offline && install != null -> "Local Repatch"
|
||||
state.offline -> "No Network"
|
||||
install == null && state.latestTidalVersionCode == null -> "Loading…"
|
||||
install == null -> "Install"
|
||||
patchesBehind && tidalBehind -> "Update Patches & TIDAL"
|
||||
patchesBehind -> "Update Patches"
|
||||
@@ -234,6 +239,18 @@ private fun ColumnScope.HomeContent(
|
||||
)
|
||||
}
|
||||
|
||||
if (state.offline && install != null) {
|
||||
Text(
|
||||
text = "No Network Connection",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(top = 6.dp),
|
||||
)
|
||||
}
|
||||
|
||||
AnimatedVisibility(visible = install != null) {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(2.dp),
|
||||
|
||||
@@ -8,5 +8,7 @@ sealed interface HomeState {
|
||||
data class Loaded(
|
||||
val install: InstallData?,
|
||||
val latestTidalVersionCode: Int?,
|
||||
val offline: Boolean = false,
|
||||
val offlineRepatchReady: Boolean = false,
|
||||
) : HomeState
|
||||
}
|
||||
|
||||
@@ -1,296 +0,0 @@
|
||||
package com.meowarex.rlmobile.ui.screens.patchopts
|
||||
|
||||
import androidx.annotation.StringRes
|
||||
import com.meowarex.rlmobile.R
|
||||
import com.meowarex.rlmobile.ui.screens.patchopts.PatchDefault.Disabled
|
||||
import com.meowarex.rlmobile.ui.screens.patchopts.PatchDefault.Enabled
|
||||
|
||||
data class PatchVariant(
|
||||
@StringRes val titleRes: Int,
|
||||
val fileNames: List<String>,
|
||||
val extensionFiles: List<String> = emptyList(),
|
||||
)
|
||||
|
||||
|
||||
enum class KnownPatch(
|
||||
val order: Int, // Patch order in the UI List (lower = higher up) [Main Patches: multiples of 10 | Sub Patches: multiples of 1]
|
||||
val fileNames: List<String>,
|
||||
val extensionFiles: List<String> = emptyList(),
|
||||
@StringRes val titleRes: Int,
|
||||
@StringRes val descRes: Int,
|
||||
val default: PatchDefault, // Default state of the patch in the UI List (enabled/disabled)
|
||||
val requires: List<KnownPatch> = emptyList(),
|
||||
val disables: List<KnownPatch> = emptyList(),
|
||||
val variants: List<PatchVariant> = emptyList(),
|
||||
val defaultVariantIndex: Int = 0,
|
||||
val advancedOptions: List<PatchOption> = emptyList(),
|
||||
val category: String = PatchSpec.CATEGORY_PATCH,
|
||||
val pathLocked: Boolean = false,
|
||||
) {
|
||||
LyricsDisableCover(
|
||||
order = 41,
|
||||
fileNames = listOf("lyrics-disable-cover.patch"),
|
||||
titleRes = R.string.patch_lyrics_disable_cover_title,
|
||||
descRes = R.string.patch_lyrics_disable_cover_desc,
|
||||
default = Enabled,
|
||||
),
|
||||
LyricsReplaceLyricsButton(
|
||||
order = 42,
|
||||
fileNames = listOf(
|
||||
"lyrics-replace-lyrics-button.patch",
|
||||
"lyrics-sparkle-conditional-visibility.patch",
|
||||
),
|
||||
titleRes = R.string.patch_lyrics_replace_button_title,
|
||||
descRes = R.string.patch_lyrics_replace_button_desc,
|
||||
default = Enabled,
|
||||
),
|
||||
LyricsReplaceShareButton(
|
||||
order = 43,
|
||||
fileNames = listOf("lyrics-replace-share-button.patch"),
|
||||
titleRes = R.string.patch_lyrics_replace_share_button_title,
|
||||
descRes = R.string.patch_lyrics_replace_share_button_desc,
|
||||
default = Enabled,
|
||||
),
|
||||
LyricsRlApi(
|
||||
order = 20,
|
||||
fileNames = listOf(
|
||||
"lyrics-rl-api.patch",
|
||||
"lyrics-rl-api-observer.patch",
|
||||
),
|
||||
titleRes = R.string.patch_lyrics_rl_api_title,
|
||||
descRes = R.string.patch_lyrics_rl_api_desc,
|
||||
default = Disabled,
|
||||
),
|
||||
LyricsKeepControlsVisible(
|
||||
order = 60,
|
||||
fileNames = listOf("lyrics-keep-controls-visible.patch"),
|
||||
titleRes = R.string.patch_lyrics_keep_controls_title,
|
||||
descRes = R.string.patch_lyrics_keep_controls_desc,
|
||||
default = Enabled,
|
||||
),
|
||||
PlayerBackdrop(
|
||||
order = 30,
|
||||
fileNames = listOf("player-backdrop.patch"),
|
||||
titleRes = R.string.patch_player_backdrop_title,
|
||||
descRes = R.string.patch_player_backdrop_desc,
|
||||
default = Enabled,
|
||||
advancedOptions = listOf(
|
||||
PatchOption.Slider(
|
||||
key = "blur_strength",
|
||||
titleRes = R.string.patch_opt_backdrop_blur_title,
|
||||
descRes = R.string.patch_opt_backdrop_blur_desc,
|
||||
default = 50f,
|
||||
valueRange = 0f..100f,
|
||||
steps = 19, // dots every 5% (0,5,…,100); snap only when locked
|
||||
displayAsPercent = true,
|
||||
token = "RL_BLUR_BITS",
|
||||
encode = SmaliEncode(EncodeKind.FloatBits, scale = 1.8f),
|
||||
),
|
||||
PatchOption.Slider(
|
||||
key = "dimming",
|
||||
titleRes = R.string.patch_opt_backdrop_dimming_title,
|
||||
descRes = R.string.patch_opt_backdrop_dimming_desc,
|
||||
default = 50f, // 50% == the original -0x80000000
|
||||
valueRange = 0f..100f,
|
||||
steps = 19, // dots every 5%
|
||||
displayAsPercent = true,
|
||||
token = "RL_SCRIM_ARGB",
|
||||
encode = SmaliEncode(EncodeKind.ArgbAlpha),
|
||||
),
|
||||
PatchOption.Toggle(
|
||||
key = "cover_everywhere",
|
||||
titleRes = R.string.patch_cover_everywhere_title,
|
||||
descRes = R.string.patch_cover_everywhere_desc,
|
||||
default = false,
|
||||
inline = true,
|
||||
fileNames = listOf(
|
||||
"home-backdrop.patch",
|
||||
"collection-backdrop.patch",
|
||||
"cover-capture.patch",
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
QualityBadgeColors(
|
||||
order = 36,
|
||||
fileNames = listOf("player-quality-badge-colors.patch"),
|
||||
titleRes = R.string.patch_quality_badge_colors_title,
|
||||
descRes = R.string.patch_quality_badge_colors_desc,
|
||||
default = Enabled,
|
||||
),
|
||||
PlayerOneHanded(
|
||||
order = 37,
|
||||
fileNames = listOf("player-one-handed.patch"),
|
||||
titleRes = R.string.patch_player_one_handed_title,
|
||||
descRes = R.string.patch_player_one_handed_desc,
|
||||
default = Disabled,
|
||||
),
|
||||
DebugMenuUnlock(
|
||||
order = 100,
|
||||
fileNames = listOf("debug-menu-unlock.patch"),
|
||||
titleRes = R.string.patch_debug_menu_unlock_title,
|
||||
descRes = R.string.patch_debug_menu_unlock_desc,
|
||||
default = Disabled,
|
||||
),
|
||||
WazeIntegration(
|
||||
order = 90,
|
||||
fileNames = listOf("waze-media-browser.patch"),
|
||||
extensionFiles = listOf(
|
||||
"radiant/WazeInitReceiver.smali",
|
||||
"radiant/WazeServiceConnection.smali",
|
||||
),
|
||||
titleRes = R.string.patch_waze_integration_title,
|
||||
descRes = R.string.patch_waze_integration_desc,
|
||||
default = Disabled,
|
||||
category = PatchSpec.CATEGORY_INTEGRATION,
|
||||
pathLocked = true,
|
||||
advancedOptions = listOf(
|
||||
PatchOption.Choice(
|
||||
key = "browse_root",
|
||||
titleRes = R.string.patch_waze_browse_root_title,
|
||||
entries = listOf(
|
||||
ChoiceEntry(R.string.patch_waze_root_auto, value = "ROOT_AUTO"),
|
||||
ChoiceEntry(R.string.patch_waze_root_home, value = "HOME_V2::home_page_v2_id"),
|
||||
ChoiceEntry(
|
||||
R.string.patch_waze_root_recents,
|
||||
value = "RECENTLY_PLAYED::home/pages/CONTINUE_LISTEN_TO/view-all",
|
||||
),
|
||||
),
|
||||
defaultIndex = 0,
|
||||
token = "RL_WAZE_ROOT_ID",
|
||||
),
|
||||
PatchOption.Color(
|
||||
key = "accent_color",
|
||||
titleRes = R.string.patch_waze_accent_color_title,
|
||||
default = -16747037, // Waze default #ff0075e3
|
||||
token = "RL_WAZE_THEME_COLOR",
|
||||
),
|
||||
),
|
||||
),
|
||||
LyricsProgressPill(
|
||||
order = 40,
|
||||
fileNames = listOf(
|
||||
"lyrics-progress-pill.patch",
|
||||
"lyrics-fade-region.patch",
|
||||
),
|
||||
titleRes = R.string.patch_lyrics_progress_pill_title,
|
||||
descRes = R.string.patch_lyrics_progress_pill_desc,
|
||||
default = Enabled,
|
||||
requires = listOf(LyricsDisableCover, LyricsReplaceLyricsButton, LyricsReplaceShareButton),
|
||||
),
|
||||
MiniPlayerRedesign(
|
||||
order = 50,
|
||||
fileNames = emptyList(),
|
||||
titleRes = R.string.patch_mini_player_redesign_title,
|
||||
descRes = R.string.patch_mini_player_redesign_desc,
|
||||
default = Disabled,
|
||||
defaultVariantIndex = 2,
|
||||
variants = listOf(
|
||||
PatchVariant( // 0: Floating — stock rounded pill
|
||||
titleRes = R.string.patch_mini_player_variant_floating_title,
|
||||
fileNames = emptyList(),
|
||||
),
|
||||
PatchVariant( // 1: Grey — square
|
||||
titleRes = R.string.patch_mini_player_variant_square_grey_title,
|
||||
fileNames = listOf("mini-player-grey.patch"),
|
||||
),
|
||||
PatchVariant( // 2: Black — square black background
|
||||
titleRes = R.string.patch_mini_player_variant_square_black_title,
|
||||
fileNames = listOf("mini-player-black.patch"),
|
||||
),
|
||||
),
|
||||
advancedOptions = listOf(
|
||||
PatchOption.Toggle(
|
||||
key = "dynamic_bg",
|
||||
titleRes = R.string.patch_mini_player_dynamic_bg_title,
|
||||
descRes = R.string.patch_mini_player_dynamic_bg_desc,
|
||||
default = false,
|
||||
inline = true,
|
||||
fileNames = listOf("mini-player-dynamic-bg.patch"),
|
||||
extensionFiles = listOf("radiant/MiniPlayerBackground.smali"),
|
||||
hidesVariants = listOf(2),
|
||||
relabelVariants = mapOf(1 to R.string.patch_mini_player_variant_legacy_title),
|
||||
),
|
||||
// Animated progress border around the floating pill
|
||||
PatchOption.Toggle(
|
||||
key = "border",
|
||||
titleRes = R.string.patch_mini_player_border_title,
|
||||
descRes = R.string.patch_mini_player_border_desc,
|
||||
default = false,
|
||||
requiresVariant = 0,
|
||||
fileNames = listOf("mini-player-floating-border.patch"),
|
||||
extensionFiles = listOf("radiant/MiniSeekerFloating.smali"),
|
||||
),
|
||||
// Swipe up to open the full player
|
||||
PatchOption.Toggle(
|
||||
key = "gestures",
|
||||
titleRes = R.string.patch_mini_player_gestures_title,
|
||||
descRes = R.string.patch_mini_player_gestures_desc,
|
||||
default = true,
|
||||
fileNames = listOf("mini-player-gestures.patch"),
|
||||
extensionFiles = listOf(
|
||||
"radiant/MiniPlayerGestures.smali",
|
||||
"radiant/MiniPlayerGestures\$Gesture.smali",
|
||||
"radiant/MiniPlayerGestures\$FeedbackLayer.smali",
|
||||
"radiant/MiniPlayerGestures\$FeedbackResetAnimator.smali",
|
||||
"radiant/MiniPlayerGestures\$RootGesture.smali",
|
||||
"radiant/MiniPlayerGestures\$ApplyPending.smali",
|
||||
),
|
||||
),
|
||||
PatchOption.Choice(
|
||||
key = "swipe_up_drag",
|
||||
titleRes = R.string.patch_mini_player_drag_title,
|
||||
descRes = R.string.patch_mini_player_drag_desc,
|
||||
entries = listOf(
|
||||
ChoiceEntry(R.string.patch_mini_player_drag_static, value = "0x0"),
|
||||
ChoiceEntry(R.string.patch_mini_player_drag_drag, value = "0x1"),
|
||||
),
|
||||
defaultIndex = 1,
|
||||
requiresOption = "gestures",
|
||||
token = "RL_MINI_PLAYER_SWIPE_UP_DRAG",
|
||||
),
|
||||
// Swipe left/right to skip
|
||||
PatchOption.Toggle(
|
||||
key = "next_prev",
|
||||
titleRes = R.string.patch_mini_player_left_right_gestures_title,
|
||||
descRes = R.string.patch_mini_player_left_right_gestures_desc,
|
||||
default = false,
|
||||
requiresOption = "gestures",
|
||||
fileNames = listOf("mini-player-gestures-left-right.patch"),
|
||||
extensionFiles = listOf(
|
||||
"radiant/MiniPlayerTrackGestures.smali",
|
||||
"radiant/MiniPlayerTrackGestures\$Gesture.smali",
|
||||
"radiant/MiniPlayerTrackGestures\$OffsetLayer.smali",
|
||||
"radiant/MiniPlayerTrackGestures\$ResetAnimator.smali",
|
||||
"radiant/MiniPlayerTrackGestures\$TextDraw.smali",
|
||||
"com/tidal/android/feature/appscaffold/ui/q\$c.smali",
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
EnableLegacyUi(
|
||||
order = 10,
|
||||
fileNames = listOf("enable-legacy-ui.patch"),
|
||||
titleRes = R.string.patch_enable_legacy_ui_title,
|
||||
descRes = R.string.patch_enable_legacy_ui_desc,
|
||||
default = Disabled,
|
||||
requires = listOf(DebugMenuUnlock),
|
||||
disables = listOf(
|
||||
LyricsDisableCover,
|
||||
LyricsReplaceLyricsButton,
|
||||
LyricsReplaceShareButton,
|
||||
LyricsRlApi,
|
||||
LyricsKeepControlsVisible,
|
||||
PlayerBackdrop,
|
||||
QualityBadgeColors,
|
||||
LyricsProgressPill,
|
||||
MiniPlayerRedesign,
|
||||
),
|
||||
);
|
||||
|
||||
companion object {
|
||||
val All: List<KnownPatch> = entries.sortedWith(
|
||||
compareBy({ it.order }, { it.fileNames.firstOrNull() ?: it.name })
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
package com.meowarex.rlmobile.ui.screens.patchopts
|
||||
|
||||
enum class PatchDefault(val isEnabled: Boolean) {
|
||||
Enabled(true),
|
||||
Disabled(false),
|
||||
}
|
||||
@@ -1,81 +0,0 @@
|
||||
package com.meowarex.rlmobile.ui.screens.patchopts
|
||||
|
||||
import androidx.annotation.StringRes
|
||||
|
||||
sealed interface PatchOption {
|
||||
/** Stable identifier, unique within a single patch. */
|
||||
val key: String
|
||||
|
||||
@get:StringRes
|
||||
val titleRes: Int
|
||||
|
||||
@get:StringRes
|
||||
val descRes: Int
|
||||
|
||||
/** A simple on/off switch. */
|
||||
data class Toggle(
|
||||
override val key: String,
|
||||
@StringRes override val titleRes: Int,
|
||||
@StringRes override val descRes: Int,
|
||||
val default: Boolean,
|
||||
/** Patch files applied only while this toggle is on (and its patch is enabled). */
|
||||
val fileNames: List<String> = emptyList(),
|
||||
/** Helper smali extracted only while this toggle is on. */
|
||||
val extensionFiles: List<String> = emptyList(),
|
||||
/** Render inline beneath the variant selector instead of inside the advanced sheet. */
|
||||
val inline: Boolean = false,
|
||||
/** Greyed out (with the lock dialog) unless this variant index is the selected one. */
|
||||
val requiresVariant: Int? = null,
|
||||
/** Greyed out unless the sibling option with this key is currently on. */
|
||||
val requiresOption: String? = null,
|
||||
/** Variant indices hidden from the picker while this toggle is on. */
|
||||
val hidesVariants: List<Int> = emptyList(),
|
||||
/** Variant title overrides (index -> @StringRes) applied while this toggle is on. */
|
||||
val relabelVariants: Map<Int, Int> = emptyMap(),
|
||||
/** Placeholder name (without the surrounding `__`) baked into the `.patch` files. */
|
||||
val token: String? = null,
|
||||
) : PatchOption
|
||||
|
||||
/** A continuous (or stepped) numeric value within [valueRange]. */
|
||||
data class Slider(
|
||||
override val key: String,
|
||||
@StringRes override val titleRes: Int,
|
||||
@StringRes override val descRes: Int,
|
||||
val default: Float,
|
||||
val valueRange: ClosedFloatingPointRange<Float>,
|
||||
/** Number of discrete steps between the range ends (0 = continuous). */
|
||||
val steps: Int = 0,
|
||||
/** Render the value as a rounded percentage (e.g. "50%"). */
|
||||
val displayAsPercent: Boolean = false,
|
||||
/** Optional unit suffix shown after the value (e.g. "dp"). */
|
||||
@StringRes val unitRes: Int? = null,
|
||||
/** Placeholder name (without the surrounding `__`) baked into the `.patch` files. */
|
||||
val token: String? = null,
|
||||
/** How the chosen value becomes the smali literal that replaces the [token]. */
|
||||
val encode: SmaliEncode? = null,
|
||||
) : PatchOption
|
||||
|
||||
/** A single choice out of a small list, rendered as a segmented control. */
|
||||
data class Choice(
|
||||
override val key: String,
|
||||
@StringRes override val titleRes: Int,
|
||||
@StringRes override val descRes: Int = 0,
|
||||
val entries: List<ChoiceEntry>,
|
||||
val defaultIndex: Int = 0,
|
||||
val requiresOption: String? = null,
|
||||
val token: String? = null,
|
||||
) : PatchOption
|
||||
|
||||
data class Color(
|
||||
override val key: String,
|
||||
@StringRes override val titleRes: Int,
|
||||
@StringRes override val descRes: Int = 0,
|
||||
val default: Int,
|
||||
val token: String? = null,
|
||||
) : PatchOption
|
||||
}
|
||||
|
||||
data class ChoiceEntry(
|
||||
@StringRes val labelRes: Int,
|
||||
val value: String? = null,
|
||||
)
|
||||
+24
-4
@@ -45,7 +45,7 @@ class PatchOptionsModel(
|
||||
|
||||
/** Patches flagged [PatchSpec.pathLocked] only work under the stock package name. */
|
||||
fun isBlockedByPackageName(spec: PatchSpec): Boolean =
|
||||
spec.pathLocked && packageName != PatchOptions.Default.packageName
|
||||
spec.pathLocked && packageName != PatchOptions.Default.packageName && !bypassIncompatible
|
||||
|
||||
/** The package-name field starts locked & editing requires confirming the unlock dialog */
|
||||
var packageNameLocked by mutableStateOf(prefilledOptions.packageName == PatchOptions.Default.packageName)
|
||||
@@ -79,10 +79,20 @@ class PatchOptionsModel(
|
||||
debuggable = value
|
||||
}
|
||||
|
||||
/** Dev override: force-allow path-gated ("incompatible") patches under a non-stock package name. */
|
||||
var bypassIncompatible by mutableStateOf(prefs.bypassIncompatible)
|
||||
private set
|
||||
|
||||
fun changeBypassIncompatible(value: Boolean) {
|
||||
bypassIncompatible = value
|
||||
prefs.bypassIncompatible = value
|
||||
validatePatchSelection()
|
||||
}
|
||||
|
||||
// The accordion renders from [specs]. It defaults to the compiled-in (localized) list and is
|
||||
// rebuilt from a custom zip's manifest.json whenever a custom patch set is selected. (Rminder for others about the new custom patch selection flow)
|
||||
|
||||
private val builtinSpecs: List<PatchSpec> = builtinPatchSpecs { context.getString(it) }
|
||||
private val builtinSpecs: List<PatchSpec> = builtinPatchSpecs(context, json)
|
||||
|
||||
var specs by mutableStateOf(builtinSpecs)
|
||||
private set
|
||||
@@ -281,6 +291,7 @@ class PatchOptionsModel(
|
||||
val bytes = ZipReader(file).use { it.openEntry(MANIFEST_NAME)?.read() } ?: return null
|
||||
json.decodeFromString(PatchManifest.serializer(), bytes.decodeToString())
|
||||
.patches
|
||||
.sortedBy { it.order } // manifest is authoritative for UI order (not KnownPatch)
|
||||
.takeIf { it.isNotEmpty() }
|
||||
} catch (t: Throwable) {
|
||||
Log.w(BuildConfig.TAG, "Failed to parse $MANIFEST_NAME; using built-in list", t)
|
||||
@@ -320,10 +331,19 @@ class PatchOptionsModel(
|
||||
null
|
||||
}
|
||||
|
||||
/** Default (non-custom) source: the latest release manifest, falling back to the built-in list. */
|
||||
private fun loadCachedReleaseManifestSpecs(): List<PatchSpec>? =
|
||||
paths.cacheDownloadDir
|
||||
.listFiles { f -> f.name.startsWith("manifest-") && f.name.endsWith(".zip") }
|
||||
?.maxByOrNull { it.lastModified() }
|
||||
?.let { loadManifestSpecs(it) }
|
||||
?.also { Log.i(BuildConfig.TAG, "Loaded ${it.size} patches from cached release manifest (offline)") }
|
||||
|
||||
/** Default (non-custom) source: latest release manifest → last cached manifest → built-in list. */
|
||||
private fun loadDefaultSpecs() = screenModelScope.launchIO {
|
||||
mainThread { specsLoading = true }
|
||||
val loaded = loadLatestReleaseSpecs() ?: builtinSpecs
|
||||
// Only hit the network when actually online
|
||||
val loaded = (if (context.isOnline()) loadLatestReleaseSpecs() else null)
|
||||
?: loadCachedReleaseManifestSpecs() ?: builtinSpecs
|
||||
mainThread {
|
||||
specs = loaded
|
||||
validatePatchSelection()
|
||||
|
||||
+14
-1
@@ -48,6 +48,8 @@ class PatchOptionsScreen(
|
||||
|
||||
debuggable = model.debuggable,
|
||||
setDebuggable = model::changeDebuggable,
|
||||
bypassIncompatible = model.bypassIncompatible,
|
||||
setBypassIncompatible = model::changeBypassIncompatible,
|
||||
|
||||
appName = model.appName,
|
||||
appNameIsError = model.appNameIsError,
|
||||
@@ -89,6 +91,8 @@ fun PatchOptionsScreenContent(
|
||||
|
||||
debuggable: Boolean,
|
||||
setDebuggable: (Boolean) -> Unit,
|
||||
bypassIncompatible: Boolean,
|
||||
setBypassIncompatible: (Boolean) -> Unit,
|
||||
|
||||
appName: String,
|
||||
appNameIsError: Boolean,
|
||||
@@ -121,7 +125,8 @@ fun PatchOptionsScreenContent(
|
||||
var showPkgUnlockDialog by rememberSaveable { mutableStateOf(false) }
|
||||
if (showPkgUnlockDialog) {
|
||||
PackageNameUnlockDialog(
|
||||
blockedTitles = specs.filter { it.pathLocked }.map { it.title },
|
||||
// With "Bypass Incompatible" on, path-gated patches are force-applied, not disabled.
|
||||
blockedTitles = if (bypassIncompatible) emptyList() else specs.filter { it.pathLocked }.map { it.title },
|
||||
onConfirm = {
|
||||
showPkgUnlockDialog = false
|
||||
onUnlockPackageName()
|
||||
@@ -268,6 +273,14 @@ fun PatchOptionsScreenContent(
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
SwitchPatchOption(
|
||||
icon = painterResource(R.drawable.ic_lock_open),
|
||||
name = stringResource(R.string.patchopts_bypass_incompatible_title),
|
||||
description = stringResource(R.string.patchopts_bypass_incompatible_desc),
|
||||
value = bypassIncompatible,
|
||||
onValueChange = setBypassIncompatible,
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(Modifier.weight(1f))
|
||||
|
||||
+12
-67
@@ -1,9 +1,11 @@
|
||||
package com.meowarex.rlmobile.ui.screens.patchopts
|
||||
|
||||
import android.content.Context
|
||||
import androidx.compose.runtime.Immutable
|
||||
import androidx.compose.runtime.Stable
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
@Serializable
|
||||
@@ -157,74 +159,17 @@ data class SmaliEncode(
|
||||
}
|
||||
}
|
||||
|
||||
fun builtinPatchSpecs(resolve: (Int) -> String): List<PatchSpec> =
|
||||
KnownPatch.All.map { patch ->
|
||||
PatchSpec(
|
||||
id = patch.name,
|
||||
order = patch.order,
|
||||
fileNames = patch.fileNames,
|
||||
extensionFiles = patch.extensionFiles,
|
||||
title = resolve(patch.titleRes),
|
||||
description = resolve(patch.descRes),
|
||||
defaultEnabled = patch.default.isEnabled,
|
||||
requires = patch.requires.map { it.name },
|
||||
disables = patch.disables.map { it.name },
|
||||
variants = patch.variants.map { VariantSpec(resolve(it.titleRes), it.fileNames, it.extensionFiles) },
|
||||
defaultVariantIndex = patch.defaultVariantIndex,
|
||||
advancedOptions = patch.advancedOptions.map { it.toSpec(resolve) },
|
||||
category = patch.category,
|
||||
pathLocked = patch.pathLocked,
|
||||
)
|
||||
}
|
||||
/** Asset filename of the bundled fallback manifest snapshot (a copy of the release manifest.json). */
|
||||
const val BUNDLED_MANIFEST_ASSET = "patches-manifest.json"
|
||||
|
||||
private fun PatchOption.toSpec(resolve: (Int) -> String): OptionSpec = when (this) {
|
||||
is PatchOption.Toggle -> OptionSpec.Toggle(
|
||||
key = key,
|
||||
title = resolve(titleRes),
|
||||
description = resolve(descRes),
|
||||
default = default,
|
||||
fileNames = fileNames,
|
||||
extensionFiles = extensionFiles,
|
||||
inline = inline,
|
||||
requiresVariant = requiresVariant,
|
||||
requiresOption = requiresOption,
|
||||
hidesVariants = hidesVariants,
|
||||
relabelVariants = relabelVariants.mapValues { resolve(it.value) },
|
||||
token = token,
|
||||
)
|
||||
|
||||
is PatchOption.Slider -> OptionSpec.Slider(
|
||||
key = key,
|
||||
title = resolve(titleRes),
|
||||
description = resolve(descRes),
|
||||
default = default,
|
||||
min = valueRange.start,
|
||||
max = valueRange.endInclusive,
|
||||
steps = steps,
|
||||
displayAsPercent = displayAsPercent,
|
||||
unit = unitRes?.let(resolve),
|
||||
token = token,
|
||||
encode = encode,
|
||||
)
|
||||
|
||||
is PatchOption.Choice -> OptionSpec.Choice(
|
||||
key = key,
|
||||
title = resolve(titleRes),
|
||||
description = if (descRes != 0) resolve(descRes) else "",
|
||||
entries = entries.map { resolve(it.labelRes) },
|
||||
defaultIndex = defaultIndex,
|
||||
values = entries.map { it.value ?: "" },
|
||||
requiresOption = requiresOption,
|
||||
token = token,
|
||||
)
|
||||
|
||||
is PatchOption.Color -> OptionSpec.Color(
|
||||
key = key,
|
||||
title = resolve(titleRes),
|
||||
description = if (descRes != 0) resolve(descRes) else "",
|
||||
default = default,
|
||||
token = token,
|
||||
)
|
||||
// Fallback patch list (snapshot of the release manifest bundled in the app's assets)
|
||||
fun builtinPatchSpecs(context: Context, json: Json): List<PatchSpec> = try {
|
||||
context.assets.open(BUNDLED_MANIFEST_ASSET).use { it.readBytes() }
|
||||
.let { json.decodeFromString(PatchManifest.serializer(), it.decodeToString()) }
|
||||
.patches
|
||||
.sortedBy { it.order }
|
||||
} catch (t: Throwable) {
|
||||
emptyList()
|
||||
}
|
||||
|
||||
@Immutable
|
||||
|
||||
@@ -5,6 +5,8 @@ import android.app.Activity
|
||||
import android.content.*
|
||||
import android.content.pm.PackageManager
|
||||
import android.content.res.Resources
|
||||
import android.net.ConnectivityManager
|
||||
import android.net.NetworkCapabilities
|
||||
import android.net.Uri
|
||||
import android.os.*
|
||||
import android.provider.Settings
|
||||
@@ -63,6 +65,15 @@ fun Context.getPackageVersion(pkg: String): Pair<String?, Int> {
|
||||
.let { it.versionName to it.versionCode }
|
||||
}
|
||||
|
||||
// Whether the device currently has a validated internet connection
|
||||
fun Context.isOnline(): Boolean {
|
||||
val cm = getSystemService<ConnectivityManager>() ?: return true
|
||||
val network = cm.activeNetwork ?: return false
|
||||
val caps = cm.getNetworkCapabilities(network) ?: return false
|
||||
return caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) &&
|
||||
caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED)
|
||||
}
|
||||
|
||||
fun Context.isPackageInstalled(packageName: String): Boolean {
|
||||
return try {
|
||||
packageManager.getPackageInfo(packageName, 0)
|
||||
|
||||
Reference in New Issue
Block a user