mirror of
https://github.com/meowarex/rl-mobile.git
synced 2026-08-26 22:17:44 +10:00
Merge pull request #64 from meowarex/dev
Waze Integration + Integrations UI <3
This commit is contained in:
+5
@@ -6,6 +6,7 @@ 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
|
||||
@@ -26,11 +27,15 @@ 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 patchedManifest = ManifestPatcher.patchManifest(
|
||||
manifestBytes = manifest,
|
||||
packageName = options.packageName,
|
||||
appName = options.appName,
|
||||
debuggable = options.debuggable,
|
||||
enableWazeIntegration = enableWazeIntegration,
|
||||
)
|
||||
|
||||
container.log("Repacking apk with patched manifest")
|
||||
|
||||
+13
-1
@@ -95,7 +95,19 @@ class SmaliPatchStep(
|
||||
val entry = zip.openEntry(patchFile)
|
||||
?: throw FileNotFoundException("Missing zip entry: $patchFile")
|
||||
out.canonicalFile.parentFile?.mkdirs()
|
||||
out.writeBytes(entry.read())
|
||||
var smaliText = entry.read()
|
||||
.decodeToString()
|
||||
.replace("\r\n", "\n")
|
||||
for ((token, value) in substitutions) {
|
||||
if (smaliText.contains(token)) {
|
||||
smaliText = smaliText.replace(token, value)
|
||||
container.log("Applied substitution $token -> $value in $patchFile")
|
||||
}
|
||||
}
|
||||
UNRESOLVED_TOKEN.find(smaliText)?.let { match ->
|
||||
throw Error("Unresolved option placeholder ${match.value} in $patchFile")
|
||||
}
|
||||
out.writeText(smaliText)
|
||||
container.log("Extracted extension smali: $relative")
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -18,12 +18,16 @@ object ManifestPatcher {
|
||||
private const val IS_SPLIT_REQUIRED = "isSplitRequired"
|
||||
private const val REQUIRED_SPLIT_TYPES = "requiredSplitTypes"
|
||||
private const val SPLIT_TYPES = "splitTypes"
|
||||
private const val WAZE_PACKAGE = "com.waze"
|
||||
private const val WAZE_INIT_RECEIVER = "radiant.WazeInitReceiver"
|
||||
private const val WAZE_ACTION_INIT = "com.waze.sdk.audio.ACTION_INIT"
|
||||
|
||||
fun patchManifest(
|
||||
manifestBytes: ByteArray,
|
||||
packageName: String,
|
||||
appName: String,
|
||||
debuggable: Boolean,
|
||||
enableWazeIntegration: Boolean,
|
||||
): ByteArray {
|
||||
// Extract original package name to rewrite every reference to it
|
||||
// (permissions, provider authorities) to the new packageName.
|
||||
@@ -84,6 +88,49 @@ object ManifestPatcher {
|
||||
}
|
||||
|
||||
return when (name) {
|
||||
"queries" -> object : NodeVisitor(nv) {
|
||||
private var hasWazePackage = false
|
||||
|
||||
override fun child(ns: String?, name: String): NodeVisitor {
|
||||
val visitor = super.child(ns, name)
|
||||
|
||||
return if (enableWazeIntegration && name == "package") {
|
||||
object : NodeVisitor(visitor) {
|
||||
override fun attr(
|
||||
ns: String?,
|
||||
name: String?,
|
||||
resourceId: Int,
|
||||
type: Int,
|
||||
value: Any?,
|
||||
) {
|
||||
if (name == "name" && value == WAZE_PACKAGE) {
|
||||
hasWazePackage = true
|
||||
}
|
||||
super.attr(ns, name, resourceId, type, value)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
visitor
|
||||
}
|
||||
}
|
||||
|
||||
override fun end() {
|
||||
if (enableWazeIntegration && !hasWazePackage) {
|
||||
super.child(null, "package").apply {
|
||||
attr(
|
||||
ANDROID_NAMESPACE,
|
||||
"name",
|
||||
android.R.attr.name,
|
||||
TYPE_STRING,
|
||||
WAZE_PACKAGE,
|
||||
)
|
||||
end()
|
||||
}
|
||||
}
|
||||
super.end()
|
||||
}
|
||||
}
|
||||
|
||||
"uses-permission" -> object : NodeVisitor(nv) {
|
||||
override fun attr(ns: String?, name: String?, resourceId: Int, type: Int, value: Any?) {
|
||||
if (name != "maxSdkVersion") {
|
||||
@@ -132,6 +179,7 @@ object ManifestPatcher {
|
||||
private var addUseEmbeddedDex = true
|
||||
private var addExtractNativeLibs = true
|
||||
private var addMetadata = true
|
||||
private var hasWazeInitReceiver = false
|
||||
|
||||
override fun attr(ns: String?, name: String, resourceId: Int, type: Int, value: Any?) {
|
||||
if (name == REQUEST_LEGACY_EXTERNAL_STORAGE) addLegacyStorage = false
|
||||
@@ -161,6 +209,22 @@ object ManifestPatcher {
|
||||
|
||||
return when (name) {
|
||||
"activity" -> ReplaceAttrsVisitor(visitor, mapOf("label" to appName))
|
||||
|
||||
"receiver" -> object : NodeVisitor(visitor) {
|
||||
override fun attr(
|
||||
ns: String?,
|
||||
name: String,
|
||||
resourceId: Int,
|
||||
type: Int,
|
||||
value: Any?
|
||||
) {
|
||||
if (name == "name" && value == WAZE_INIT_RECEIVER) {
|
||||
hasWazeInitReceiver = true
|
||||
}
|
||||
super.attr(ns, name, resourceId, type, value)
|
||||
}
|
||||
}
|
||||
|
||||
"provider" -> object : NodeVisitor(visitor) {
|
||||
override fun attr(
|
||||
ns: String?,
|
||||
@@ -219,6 +283,39 @@ object ManifestPatcher {
|
||||
0
|
||||
)
|
||||
|
||||
if (enableWazeIntegration && !hasWazeInitReceiver) {
|
||||
super.child(null, "receiver").apply {
|
||||
attr(
|
||||
ANDROID_NAMESPACE,
|
||||
"name",
|
||||
android.R.attr.name,
|
||||
TYPE_STRING,
|
||||
WAZE_INIT_RECEIVER,
|
||||
)
|
||||
attr(
|
||||
ANDROID_NAMESPACE,
|
||||
"exported",
|
||||
android.R.attr.exported,
|
||||
TYPE_INT_BOOLEAN,
|
||||
1,
|
||||
)
|
||||
child(null, "intent-filter").apply {
|
||||
child(null, "action").apply {
|
||||
attr(
|
||||
ANDROID_NAMESPACE,
|
||||
"name",
|
||||
android.R.attr.name,
|
||||
TYPE_STRING,
|
||||
WAZE_ACTION_INIT,
|
||||
)
|
||||
end()
|
||||
}
|
||||
end()
|
||||
}
|
||||
end()
|
||||
}
|
||||
}
|
||||
|
||||
super.end()
|
||||
}
|
||||
}
|
||||
|
||||
+3
-1
@@ -35,12 +35,14 @@ private fun PatchOptionsScreenPreview(
|
||||
packageName = parameters.packageName,
|
||||
packageNameState = parameters.packageNameState,
|
||||
setPackageName = {},
|
||||
packageNameLocked = parameters.packageName == PatchOptions.Default.packageName,
|
||||
onUnlockPackageName = {},
|
||||
onLockPackageName = {},
|
||||
customTidalApk = parameters.customTidalApk,
|
||||
onSelectCustomTidalApk = {},
|
||||
customPatches = parameters.customPatches,
|
||||
onSelectCustomPatches = {},
|
||||
specs = specs,
|
||||
enabledPatchCount = specs.size,
|
||||
isPatchEnabled = { true },
|
||||
onTogglePatch = { _, _ -> },
|
||||
patchLockState = { PatchLock.Free },
|
||||
|
||||
@@ -24,6 +24,8 @@ enum class KnownPatch(
|
||||
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,
|
||||
@@ -130,6 +132,41 @@ enum class KnownPatch(
|
||||
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(
|
||||
|
||||
+9
-1
@@ -59,12 +59,20 @@ sealed interface PatchOption {
|
||||
data class Choice(
|
||||
override val key: String,
|
||||
@StringRes override val titleRes: Int,
|
||||
@StringRes override val descRes: 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(
|
||||
|
||||
@@ -56,6 +56,9 @@ data class PatchOptions(
|
||||
(optionInts["${spec.id}/${option.key}"] ?: option.defaultIndex)
|
||||
.coerceIn(0, option.entries.lastIndex.coerceAtLeast(0))
|
||||
|
||||
fun colorValue(spec: PatchSpec, option: OptionSpec.Color): Int =
|
||||
optionInts["${spec.id}/${option.key}"] ?: option.default
|
||||
|
||||
fun isToggleOn(spec: PatchSpec, option: OptionSpec.Toggle): Boolean =
|
||||
optionBools["${spec.id}/${option.key}"] ?: option.default
|
||||
|
||||
@@ -135,6 +138,10 @@ data class PatchOptions(
|
||||
val index = if (gatedOff) 0 else choiceIndex(spec, option)
|
||||
put("__${token}__", option.values.getOrNull(index) ?: continue)
|
||||
}
|
||||
is OptionSpec.Color -> {
|
||||
val token = option.token ?: continue
|
||||
put("__${token}__", colorValue(spec, option).toString())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+37
-5
@@ -43,6 +43,24 @@ class PatchOptionsModel(
|
||||
fetchPkgNameStateDebounced()
|
||||
}
|
||||
|
||||
/** Patches flagged [PatchSpec.pathLocked] only work under the stock package name. */
|
||||
fun isBlockedByPackageName(spec: PatchSpec): Boolean =
|
||||
spec.pathLocked && packageName != PatchOptions.Default.packageName
|
||||
|
||||
/** The package-name field starts locked & editing requires confirming the unlock dialog */
|
||||
var packageNameLocked by mutableStateOf(prefilledOptions.packageName == PatchOptions.Default.packageName)
|
||||
private set
|
||||
|
||||
fun unlockPackageName() {
|
||||
packageNameLocked = false
|
||||
}
|
||||
|
||||
/** Re-locking snaps the package name back to the stock default */
|
||||
fun lockPackageName() {
|
||||
packageNameLocked = true
|
||||
changePackageName(PatchOptions.Default.packageName)
|
||||
}
|
||||
|
||||
var appName by mutableStateOf(prefilledOptions.appName)
|
||||
private set
|
||||
|
||||
@@ -83,7 +101,7 @@ class PatchOptionsModel(
|
||||
.coerceIn(0, spec.variants.lastIndex.coerceAtLeast(0))
|
||||
|
||||
fun isPatchEnabled(spec: PatchSpec): Boolean =
|
||||
patchStates[spec.id] ?: spec.defaultEnabled
|
||||
!isBlockedByPackageName(spec) && (patchStates[spec.id] ?: spec.defaultEnabled)
|
||||
|
||||
fun setPatchEnabled(spec: PatchSpec, enabled: Boolean) {
|
||||
val byId = specs.associateBy { it.id }
|
||||
@@ -153,11 +171,19 @@ class PatchOptionsModel(
|
||||
optionInts = optionInts + (keyOf(spec, option) to index)
|
||||
}
|
||||
|
||||
fun colorValue(spec: PatchSpec, option: OptionSpec.Color): Int =
|
||||
optionInts[keyOf(spec, option)] ?: option.default
|
||||
|
||||
fun setColorValue(spec: PatchSpec, option: OptionSpec.Color, value: Int) {
|
||||
optionInts = optionInts + (keyOf(spec, option) to value)
|
||||
}
|
||||
|
||||
fun isAdvancedModified(spec: PatchSpec): Boolean = spec.advancedOptions.any { option ->
|
||||
when (option) {
|
||||
is OptionSpec.Toggle -> toggleValue(spec, option) != option.default
|
||||
is OptionSpec.Slider -> sliderValue(spec, option) != option.default
|
||||
is OptionSpec.Choice -> choiceValue(spec, option) != option.defaultIndex
|
||||
is OptionSpec.Color -> colorValue(spec, option) != option.default
|
||||
}
|
||||
}
|
||||
|
||||
@@ -175,11 +201,14 @@ class PatchOptionsModel(
|
||||
setSlider = ::setSliderValue,
|
||||
choice = ::choiceValue,
|
||||
setChoice = ::setChoiceValue,
|
||||
color = ::colorValue,
|
||||
setColor = ::setColorValue,
|
||||
isModified = ::isAdvancedModified,
|
||||
reset = ::resetAdvanced,
|
||||
)
|
||||
|
||||
fun lockState(spec: PatchSpec): PatchLock {
|
||||
if (isBlockedByPackageName(spec)) return PatchLock.RequiresDefaultPackage
|
||||
if (spec.variants.isNotEmpty()) return PatchLock.Free
|
||||
|
||||
val byId = specs.associateBy { it.id }
|
||||
@@ -206,9 +235,6 @@ class PatchOptionsModel(
|
||||
return PatchLock.Free
|
||||
}
|
||||
|
||||
val enabledPatchCount: Int
|
||||
get() = specs.count { isPatchEnabled(it) }
|
||||
|
||||
var customTidalApk by mutableStateOf<PatchComponent?>(null)
|
||||
private set
|
||||
var customPatches by mutableStateOf<PatchComponent?>(null)
|
||||
@@ -323,7 +349,10 @@ class PatchOptionsModel(
|
||||
debuggable = debuggable,
|
||||
customTidalApk = customTidalApk,
|
||||
customPatches = customPatches,
|
||||
patchStates = patchStates,
|
||||
// Hard-disable package-name-bound patches so the patcher can't apply
|
||||
patchStates = patchStates.toMutableMap().apply {
|
||||
specs.filter { isBlockedByPackageName(it) }.forEach { this[it.id] = false }
|
||||
},
|
||||
selectedVariants = selectedVariants,
|
||||
optionFloats = optionFloats,
|
||||
optionBools = optionBools,
|
||||
@@ -385,4 +414,7 @@ sealed class PatchLock {
|
||||
data object Free : PatchLock()
|
||||
data class LockedOn(val by: PatchSpec) : PatchLock()
|
||||
data class LockedOff(val by: PatchSpec) : PatchLock()
|
||||
|
||||
/** Locked off because the install uses a non-default package name */
|
||||
data object RequiresDefaultPackage : PatchLock()
|
||||
}
|
||||
|
||||
+53
-6
@@ -19,7 +19,9 @@ import com.meowarex.rlmobile.R
|
||||
import com.meowarex.rlmobile.ui.components.*
|
||||
import com.meowarex.rlmobile.ui.screens.componentopts.PatchComponent
|
||||
import com.meowarex.rlmobile.ui.screens.patching.PatchingScreen
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import com.meowarex.rlmobile.ui.screens.patchopts.components.PackageNameStateLabel
|
||||
import com.meowarex.rlmobile.ui.screens.patchopts.components.PackageNameUnlockDialog
|
||||
import com.meowarex.rlmobile.ui.screens.patchopts.components.PatchOptionsAppBar
|
||||
import com.meowarex.rlmobile.ui.screens.patchopts.components.PatchSelectionAccordion
|
||||
import com.meowarex.rlmobile.ui.screens.patchopts.components.options.*
|
||||
@@ -55,13 +57,16 @@ class PatchOptionsScreen(
|
||||
packageNameState = model.packageNameState,
|
||||
setPackageName = model::changePackageName,
|
||||
|
||||
packageNameLocked = model.packageNameLocked,
|
||||
onUnlockPackageName = model::unlockPackageName,
|
||||
onLockPackageName = model::lockPackageName,
|
||||
|
||||
customTidalApk = model.customTidalApk,
|
||||
customPatches = model.customPatches,
|
||||
onSelectCustomTidalApk = { model.selectCustomTidalApk(navigator) },
|
||||
onSelectCustomPatches = { model.selectCustomPatches(navigator) },
|
||||
|
||||
specs = model.specs,
|
||||
enabledPatchCount = model.enabledPatchCount,
|
||||
isPatchEnabled = model::isPatchEnabled,
|
||||
onTogglePatch = model::setPatchEnabled,
|
||||
patchLockState = model::lockState,
|
||||
@@ -93,13 +98,16 @@ fun PatchOptionsScreenContent(
|
||||
packageNameState: PackageNameState,
|
||||
setPackageName: (String) -> Unit,
|
||||
|
||||
packageNameLocked: Boolean,
|
||||
onUnlockPackageName: () -> Unit,
|
||||
onLockPackageName: () -> Unit,
|
||||
|
||||
customTidalApk: PatchComponent?,
|
||||
onSelectCustomTidalApk: () -> Unit,
|
||||
customPatches: PatchComponent?,
|
||||
onSelectCustomPatches: () -> Unit,
|
||||
|
||||
specs: List<PatchSpec>,
|
||||
enabledPatchCount: Int,
|
||||
isPatchEnabled: (PatchSpec) -> Boolean,
|
||||
onTogglePatch: (PatchSpec, Boolean) -> Unit,
|
||||
patchLockState: (PatchSpec) -> PatchLock,
|
||||
@@ -110,6 +118,18 @@ fun PatchOptionsScreenContent(
|
||||
isConfigValid: Boolean,
|
||||
onInstall: () -> Unit,
|
||||
) {
|
||||
var showPkgUnlockDialog by rememberSaveable { mutableStateOf(false) }
|
||||
if (showPkgUnlockDialog) {
|
||||
PackageNameUnlockDialog(
|
||||
blockedTitles = specs.filter { it.pathLocked }.map { it.title },
|
||||
onConfirm = {
|
||||
showPkgUnlockDialog = false
|
||||
onUnlockPackageName()
|
||||
},
|
||||
onDismiss = { showPkgUnlockDialog = false },
|
||||
)
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
topBar = { PatchOptionsAppBar(isUpdate = isUpdate) },
|
||||
) { paddingValues ->
|
||||
@@ -159,6 +179,10 @@ fun PatchOptionsScreenContent(
|
||||
valueIsDefault = packageNameIsDefault,
|
||||
onValueChange = setPackageName,
|
||||
onValueReset = { setPackageName(PatchOptions.Default.packageName) },
|
||||
locked = packageNameLocked,
|
||||
onLockClick = {
|
||||
if (packageNameLocked) showPkgUnlockDialog = true else onLockPackageName()
|
||||
},
|
||||
) {
|
||||
PackageNameStateLabel(
|
||||
state = packageNameState,
|
||||
@@ -167,17 +191,40 @@ fun PatchOptionsScreenContent(
|
||||
}
|
||||
}
|
||||
|
||||
val (integrations, patches) = remember(specs) { specs.partition { it.isIntegration } }
|
||||
|
||||
if (integrations.isNotEmpty()) {
|
||||
PatchSelectionAccordion(
|
||||
iconRes = R.drawable.ic_extension,
|
||||
titleRes = R.string.patchopts_integrations_title,
|
||||
descriptionRes = R.string.patchopts_integrations_desc,
|
||||
specs = integrations,
|
||||
enabledCount = integrations.count(isPatchEnabled),
|
||||
totalCount = integrations.size,
|
||||
isEnabled = isPatchEnabled,
|
||||
onToggle = onTogglePatch,
|
||||
lockState = patchLockState,
|
||||
variantIndex = variantIndex,
|
||||
onSelectVariant = onSelectVariant,
|
||||
optionState = optionState,
|
||||
modifier = Modifier.padding(top = 4.dp),
|
||||
)
|
||||
}
|
||||
|
||||
PatchSelectionAccordion(
|
||||
specs = specs,
|
||||
enabledCount = enabledPatchCount,
|
||||
totalCount = specs.size,
|
||||
iconRes = R.drawable.ic_healing,
|
||||
titleRes = R.string.patchopts_patches_title,
|
||||
descriptionRes = R.string.patchopts_patches_desc,
|
||||
specs = patches,
|
||||
enabledCount = patches.count(isPatchEnabled),
|
||||
totalCount = patches.size,
|
||||
isEnabled = isPatchEnabled,
|
||||
onToggle = onTogglePatch,
|
||||
lockState = patchLockState,
|
||||
variantIndex = variantIndex,
|
||||
onSelectVariant = onSelectVariant,
|
||||
optionState = optionState,
|
||||
modifier = Modifier.padding(top = 4.dp),
|
||||
modifier = if (integrations.isEmpty()) Modifier.padding(top = 4.dp) else Modifier,
|
||||
)
|
||||
|
||||
if (isDevMode) {
|
||||
|
||||
+37
-2
@@ -29,7 +29,16 @@ data class PatchSpec(
|
||||
val variants: List<VariantSpec> = emptyList(),
|
||||
val defaultVariantIndex: Int = 0,
|
||||
val advancedOptions: List<OptionSpec> = emptyList(),
|
||||
)
|
||||
val category: String = CATEGORY_PATCH,
|
||||
val pathLocked: Boolean = false,
|
||||
) {
|
||||
val isIntegration: Boolean get() = category == CATEGORY_INTEGRATION
|
||||
|
||||
companion object {
|
||||
const val CATEGORY_PATCH = "patch"
|
||||
const val CATEGORY_INTEGRATION = "integration"
|
||||
}
|
||||
}
|
||||
|
||||
@Immutable
|
||||
@Serializable
|
||||
@@ -106,6 +115,18 @@ sealed interface OptionSpec {
|
||||
val requiresOption: String? = null,
|
||||
val token: String? = null,
|
||||
) : OptionSpec
|
||||
|
||||
@Immutable
|
||||
@Serializable
|
||||
@SerialName("color")
|
||||
data class Color(
|
||||
override val key: String,
|
||||
override val title: String = "",
|
||||
override val description: String = "",
|
||||
val default: Int = 0,
|
||||
/** Placeholder name (without the surrounding `__`) baked into the `.patch`/extension files. */
|
||||
val token: String? = null,
|
||||
) : OptionSpec
|
||||
}
|
||||
|
||||
@Serializable
|
||||
@@ -151,6 +172,8 @@ fun builtinPatchSpecs(resolve: (Int) -> String): List<PatchSpec> =
|
||||
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,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -187,13 +210,21 @@ private fun PatchOption.toSpec(resolve: (Int) -> String): OptionSpec = when (thi
|
||||
is PatchOption.Choice -> OptionSpec.Choice(
|
||||
key = key,
|
||||
title = resolve(titleRes),
|
||||
description = resolve(descRes),
|
||||
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,
|
||||
)
|
||||
}
|
||||
|
||||
@Immutable
|
||||
@@ -258,6 +289,8 @@ class PatchOptionState(
|
||||
val setSlider: (PatchSpec, OptionSpec.Slider, Float) -> Unit,
|
||||
val choice: (PatchSpec, OptionSpec.Choice) -> Int,
|
||||
val setChoice: (PatchSpec, OptionSpec.Choice, Int) -> Unit,
|
||||
val color: (PatchSpec, OptionSpec.Color) -> Int,
|
||||
val setColor: (PatchSpec, OptionSpec.Color, Int) -> Unit,
|
||||
val isModified: (PatchSpec) -> Boolean,
|
||||
val reset: (PatchSpec) -> Unit,
|
||||
) {
|
||||
@@ -270,6 +303,8 @@ class PatchOptionState(
|
||||
setSlider = { _, _, _ -> },
|
||||
choice = { _, option -> option.defaultIndex },
|
||||
setChoice = { _, _, _ -> },
|
||||
color = { _, option -> option.default },
|
||||
setColor = { _, _, _ -> },
|
||||
isModified = { false },
|
||||
reset = {},
|
||||
)
|
||||
|
||||
+88
@@ -0,0 +1,88 @@
|
||||
package com.meowarex.rlmobile.ui.screens.patchopts.components
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.BasicAlertDialog
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.fromHtml
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.meowarex.rlmobile.R
|
||||
import com.meowarex.rlmobile.ui.screens.patchopts.PatchOptions
|
||||
|
||||
/**
|
||||
* Confirms unlocking the package-name field, warning that a custom name
|
||||
* blocks the listed patches/integrations from working.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun PackageNameUnlockDialog(
|
||||
blockedTitles: List<String>,
|
||||
onConfirm: () -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
BasicAlertDialog(onDismissRequest = onDismiss) {
|
||||
Surface(
|
||||
shape = MaterialTheme.shapes.large,
|
||||
color = MaterialTheme.colorScheme.surfaceContainerHigh,
|
||||
tonalElevation = 6.dp,
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(start = 24.dp, end = 12.dp, top = 20.dp, bottom = 8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(6.dp),
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.patchopts_pkgname_warning_title),
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
)
|
||||
Text(
|
||||
text = AnnotatedString.fromHtml(
|
||||
stringResource(R.string.patchopts_pkgname_warning_msg, PatchOptions.Default.packageName),
|
||||
),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
)
|
||||
if (blockedTitles.isNotEmpty()) {
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(2.dp),
|
||||
modifier = Modifier.padding(start = 4.dp),
|
||||
) {
|
||||
for (title in blockedTitles) {
|
||||
Text(
|
||||
text = "• $title",
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
Text(
|
||||
text = stringResource(R.string.patchopts_pkgname_warning_permanent),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
)
|
||||
Box(modifier = Modifier.fillMaxWidth()) {
|
||||
Row(modifier = Modifier.align(Alignment.CenterEnd)) {
|
||||
TextButton(onClick = onDismiss) {
|
||||
Text(stringResource(R.string.action_cancel))
|
||||
}
|
||||
TextButton(onClick = onConfirm) {
|
||||
Text(stringResource(R.string.action_continue))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+209
@@ -7,6 +7,8 @@ import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
@@ -15,11 +17,15 @@ import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.toArgb
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.Role
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.fromHtml
|
||||
import androidx.compose.ui.text.input.KeyboardCapitalization
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
@@ -196,6 +202,12 @@ fun PatchAdvancedOptionsSheet(
|
||||
onSelect = { state.setChoice(patch, option, it) },
|
||||
)
|
||||
}
|
||||
|
||||
is OptionSpec.Color -> ColorOptionRow(
|
||||
title = option.title,
|
||||
color = state.color(patch, option),
|
||||
onColorChange = { state.setColor(patch, option, it) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -378,6 +390,39 @@ private fun ChoiceOptionRow(
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
}
|
||||
if (entries.size > 3) {
|
||||
var expanded by rememberSaveable { mutableStateOf(false) }
|
||||
val selectedLabel = entries.getOrNull(selectedIndex).orEmpty()
|
||||
|
||||
Box(modifier = Modifier.fillMaxWidth()) {
|
||||
OutlinedButton(
|
||||
onClick = { expanded = true },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Text(
|
||||
text = selectedLabel,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
DropdownMenu(
|
||||
expanded = expanded,
|
||||
onDismissRequest = { expanded = false },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
entries.forEachIndexed { index, entry ->
|
||||
DropdownMenuItem(
|
||||
text = { Text(entry) },
|
||||
onClick = {
|
||||
onSelect(index)
|
||||
expanded = false
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
return@Column
|
||||
}
|
||||
SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) {
|
||||
entries.forEachIndexed { index, entry ->
|
||||
SegmentedButton(
|
||||
@@ -400,6 +445,150 @@ private fun ChoiceOptionRow(
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ColorOptionRow(
|
||||
title: String,
|
||||
color: Int,
|
||||
onColorChange: (Int) -> Unit,
|
||||
) {
|
||||
var showPicker by rememberSaveable { mutableStateOf(false) }
|
||||
val selectedColor = color.withOpaqueAlpha()
|
||||
val materialYouColor = MaterialTheme.colorScheme.primary.toArgb().withOpaqueAlpha()
|
||||
val presets = listOf(
|
||||
stringResource(R.string.patch_waze_color_waze_default),
|
||||
stringResource(R.string.patch_waze_color_tidal_cyan),
|
||||
stringResource(R.string.patch_waze_color_material_you),
|
||||
)
|
||||
val presetColors = listOf(WAZE_DEFAULT, TIDAL_CYAN, materialYouColor)
|
||||
val selectedPreset = presetColors.indexOf(selectedColor)
|
||||
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.align(Alignment.CenterHorizontally),
|
||||
)
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(MaterialTheme.shapes.medium)
|
||||
.clickable { showPicker = true }
|
||||
.padding(vertical = 4.dp),
|
||||
) {
|
||||
ColorSwatch(color = selectedColor)
|
||||
Text(
|
||||
text = selectedColor.toRgbHex(),
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
}
|
||||
SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) {
|
||||
presets.forEachIndexed { index, label ->
|
||||
SegmentedButton(
|
||||
selected = index == selectedPreset,
|
||||
onClick = { onColorChange(presetColors[index]) },
|
||||
shape = SegmentedButtonDefaults.itemShape(index = index, count = presets.size),
|
||||
icon = {},
|
||||
label = {
|
||||
Text(
|
||||
text = label,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
textAlign = TextAlign.Center,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (showPicker) {
|
||||
ColorPickerDialog(
|
||||
initialColor = selectedColor,
|
||||
onDismiss = { showPicker = false },
|
||||
onColorSelected = {
|
||||
onColorChange(it)
|
||||
showPicker = false
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ColorSwatch(color: Int, modifier: Modifier = Modifier) {
|
||||
Box(
|
||||
modifier = modifier
|
||||
.size(32.dp)
|
||||
.clip(CircleShape)
|
||||
.background(Color(color))
|
||||
.border(1.dp, MaterialTheme.colorScheme.outlineVariant, CircleShape),
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ColorPickerDialog(
|
||||
initialColor: Int,
|
||||
onDismiss: () -> Unit,
|
||||
onColorSelected: (Int) -> Unit,
|
||||
) {
|
||||
var color by rememberSaveable(initialColor) { mutableIntStateOf(initialColor.withOpaqueAlpha()) }
|
||||
var hex by rememberSaveable(initialColor) { mutableStateOf(color.toRgbHex()) }
|
||||
val parsedHex = parseRgbHex(hex)
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text(stringResource(R.string.patch_color_picker_title)) },
|
||||
text = {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
ColorSwatch(color = parsedHex ?: color, modifier = Modifier.size(40.dp))
|
||||
OutlinedTextField(
|
||||
value = hex,
|
||||
onValueChange = { value ->
|
||||
hex = value
|
||||
parseRgbHex(value)?.let { color = it }
|
||||
},
|
||||
label = { Text(stringResource(R.string.patch_color_picker_hex)) },
|
||||
singleLine = true,
|
||||
isError = parsedHex == null,
|
||||
keyboardOptions = KeyboardOptions(
|
||||
capitalization = KeyboardCapitalization.Characters,
|
||||
keyboardType = KeyboardType.Ascii,
|
||||
),
|
||||
supportingText = if (parsedHex == null) {
|
||||
{ Text(stringResource(R.string.patch_color_picker_hex_error)) }
|
||||
} else null,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
enabled = parsedHex != null,
|
||||
onClick = { parsedHex?.let(onColorSelected) },
|
||||
) {
|
||||
Text(stringResource(R.string.action_done))
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onDismiss) {
|
||||
Text(stringResource(R.string.action_cancel))
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun OptionText(
|
||||
title: String,
|
||||
@@ -432,6 +621,26 @@ private fun formatSliderValue(option: OptionSpec.Slider, value: Float): String {
|
||||
}
|
||||
}
|
||||
|
||||
private const val OPAQUE_ALPHA = -0x1000000
|
||||
private const val WAZE_DEFAULT = -16747037 // #ff0075e3
|
||||
private const val TIDAL_CYAN = -14549268 // #ff21feec
|
||||
|
||||
private fun Int.withOpaqueAlpha(): Int = (this and 0x00FFFFFF) or OPAQUE_ALPHA
|
||||
|
||||
private fun Int.toRgbHex(): String = "#" +
|
||||
(this and 0x00FFFFFF).toString(16).uppercase().padStart(6, '0')
|
||||
|
||||
private fun parseRgbHex(value: String): Int? {
|
||||
val clean = value.trim()
|
||||
.removePrefix("#")
|
||||
.removePrefix("0x")
|
||||
.removePrefix("0X")
|
||||
if (clean.length != 6 && clean.length != 8) return null
|
||||
val parsed = clean.toLongOrNull(16) ?: return null
|
||||
val rgb = if (clean.length == 8) parsed and 0x00FFFFFFL else parsed
|
||||
return (0xFF000000L or rgb).toInt()
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun OptionLockDialog(lock: OptionLock, onDismiss: () -> Unit) {
|
||||
|
||||
+19
-3
@@ -1,6 +1,8 @@
|
||||
package com.meowarex.rlmobile.ui.screens.patchopts.components
|
||||
|
||||
|
||||
import androidx.annotation.DrawableRes
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.foundation.background
|
||||
@@ -27,6 +29,7 @@ import com.meowarex.rlmobile.R
|
||||
import com.meowarex.rlmobile.ui.screens.patchopts.OptionSpec
|
||||
import com.meowarex.rlmobile.ui.screens.patchopts.PatchLock
|
||||
import com.meowarex.rlmobile.ui.screens.patchopts.PatchOptionState
|
||||
import com.meowarex.rlmobile.ui.screens.patchopts.PatchOptions
|
||||
import com.meowarex.rlmobile.ui.screens.patchopts.PatchSpec
|
||||
import com.meowarex.rlmobile.ui.screens.patchopts.effectiveVariants
|
||||
import com.meowarex.rlmobile.ui.screens.patchopts.resolveVariantIndex
|
||||
@@ -35,6 +38,9 @@ private data class LockInfo(val patch: PatchSpec, val lock: PatchLock)
|
||||
|
||||
@Composable
|
||||
fun PatchSelectionAccordion(
|
||||
@DrawableRes iconRes: Int,
|
||||
@StringRes titleRes: Int,
|
||||
@StringRes descriptionRes: Int,
|
||||
specs: List<PatchSpec>,
|
||||
enabledCount: Int,
|
||||
totalCount: Int,
|
||||
@@ -69,12 +75,17 @@ fun PatchSelectionAccordion(
|
||||
.clickable(role = Role.Button) { expanded = !expanded }
|
||||
.padding(horizontal = 16.dp, vertical = 14.dp),
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(iconRes),
|
||||
contentDescription = null,
|
||||
)
|
||||
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(2.dp),
|
||||
modifier = Modifier.weight(1f),
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.patchopts_patches_title),
|
||||
text = stringResource(titleRes),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
)
|
||||
Text(
|
||||
@@ -106,7 +117,7 @@ fun PatchSelectionAccordion(
|
||||
.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.patchopts_patches_desc),
|
||||
text = stringResource(descriptionRes),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
modifier = Modifier
|
||||
.alpha(.7f)
|
||||
@@ -372,6 +383,11 @@ private fun PatchLockDialog(
|
||||
R.string.patch_lock_blocked_msg,
|
||||
lock.by.title,
|
||||
)
|
||||
PatchLock.RequiresDefaultPackage -> Triple(
|
||||
R.string.patch_lock_pkgname_title,
|
||||
R.string.patch_lock_pkgname_msg,
|
||||
PatchOptions.Default.packageName,
|
||||
)
|
||||
PatchLock.Free -> return
|
||||
}
|
||||
|
||||
@@ -391,7 +407,7 @@ private fun PatchLockDialog(
|
||||
)
|
||||
Text(
|
||||
text = AnnotatedString.fromHtml(stringResource(msgRes, blockerTitle)),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
)
|
||||
Box(modifier = Modifier.fillMaxWidth()) {
|
||||
TextButton(
|
||||
|
||||
+35
-4
@@ -3,8 +3,12 @@ package com.meowarex.rlmobile.ui.screens.patchopts.components.options
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.meowarex.rlmobile.R
|
||||
import com.meowarex.rlmobile.ui.components.Label
|
||||
import com.meowarex.rlmobile.ui.components.ResetToDefaultButton
|
||||
|
||||
@@ -18,6 +22,8 @@ fun TextPatchOption(
|
||||
onValueChange: (String) -> Unit,
|
||||
onValueReset: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
locked: Boolean? = null,
|
||||
onLockClick: () -> Unit = {},
|
||||
extra: (@Composable ColumnScope.() -> Unit)? = null,
|
||||
) {
|
||||
Label(
|
||||
@@ -30,16 +36,41 @@ fun TextPatchOption(
|
||||
onValueChange = onValueChange,
|
||||
isError = valueIsError,
|
||||
singleLine = true,
|
||||
enabled = locked != true,
|
||||
colors = OutlinedTextFieldDefaults.colors(
|
||||
unfocusedContainerColor = MaterialTheme.colorScheme.surfaceContainerLow,
|
||||
focusedContainerColor = MaterialTheme.colorScheme.surfaceContainerLow,
|
||||
errorContainerColor = MaterialTheme.colorScheme.surfaceContainerLow,
|
||||
disabledContainerColor = MaterialTheme.colorScheme.surfaceContainerLow,
|
||||
),
|
||||
trailingIcon = {
|
||||
ResetToDefaultButton(
|
||||
enabled = !valueIsDefault,
|
||||
onClick = onValueReset,
|
||||
)
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
if (locked != true) {
|
||||
ResetToDefaultButton(
|
||||
enabled = !valueIsDefault,
|
||||
onClick = onValueReset,
|
||||
)
|
||||
}
|
||||
if (locked != null) {
|
||||
IconButton(
|
||||
onClick = onLockClick,
|
||||
modifier = Modifier.size(32.dp),
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(
|
||||
if (locked) R.drawable.ic_lock else R.drawable.ic_lock_open,
|
||||
),
|
||||
contentDescription = stringResource(
|
||||
if (locked) R.string.patchopts_field_unlock else R.string.patchopts_field_lock,
|
||||
),
|
||||
tint = if (locked) MaterialTheme.colorScheme.primary
|
||||
else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.size(18.dp),
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.width(8.dp))
|
||||
}
|
||||
}
|
||||
},
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="960"
|
||||
android:viewportHeight="960">
|
||||
<path
|
||||
android:fillColor="#e8eaed"
|
||||
android:pathData="M218,856L104,742Q81,719 81,686Q81,653 104,630L630,104Q653,81 686,81Q719,81 742,104L856,218Q879,241 879,274Q879,307 856,330L330,856Q307,879 274,879Q241,879 218,856ZM508,734L734,508L856,630Q879,653 879,686Q879,719 856,742L742,856Q719,879 686,879Q653,879 630,856L508,734ZM480,600Q497,600 508.5,588.5Q520,577 520,560Q520,543 508.5,531.5Q497,520 480,520Q463,520 451.5,531.5Q440,543 440,560Q440,577 451.5,588.5Q463,600 480,600ZM400,520Q417,520 428.5,508.5Q440,497 440,480Q440,463 428.5,451.5Q417,440 400,440Q383,440 371.5,451.5Q360,463 360,480Q360,497 371.5,508.5Q383,520 400,520ZM560,520Q577,520 588.5,508.5Q600,497 600,480Q600,463 588.5,451.5Q577,440 560,440Q543,440 531.5,451.5Q520,463 520,480Q520,497 531.5,508.5Q543,520 560,520ZM225,451L104,330Q81,307 81,274Q81,241 104,218L218,104Q241,81 274,81Q307,81 330,104L452,226L225,451ZM480,440Q497,440 508.5,428.5Q520,417 520,400Q520,383 508.5,371.5Q497,360 480,360Q463,360 451.5,371.5Q440,383 440,400Q440,417 451.5,428.5Q463,440 480,440Z" />
|
||||
</vector>
|
||||
@@ -252,9 +252,16 @@
|
||||
<string name="patchopts_patches_title">Patches</string>
|
||||
<string name="patchopts_patches_desc">Toggle which patches are applied during installation. Unchecked patches are skipped entirely.</string>
|
||||
<string name="patchopts_patches_summary">%1$d of %2$d enabled</string>
|
||||
<string name="patchopts_integrations_title">Integrations</string>
|
||||
<string name="patchopts_integrations_desc">Connect TIDAL to other systems/apps</string>
|
||||
<string name="patchopts_pkgname_warning_title">Custom Package Name</string>
|
||||
<string name="patchopts_pkgname_warning_msg">Installing under a package name other than <b>%s</b> will block the following from working:</string>
|
||||
<string name="patchopts_pkgname_warning_permanent">The package name is permanent once installed (requires a fresh install)</string>
|
||||
<string name="patchopts_advanced_button">Advanced Options</string>
|
||||
<string name="patchopts_advanced_sheet_label">Advanced Options</string>
|
||||
<string name="patchopts_toggle_snap">Toggle snapping to steps</string>
|
||||
<string name="patchopts_field_unlock">Unlock editing</string>
|
||||
<string name="patchopts_field_lock">Lock and reset to default</string>
|
||||
|
||||
<string name="patch_lyrics_disable_cover_title">Disable Lyrics Cover</string>
|
||||
<string name="patch_lyrics_disable_cover_desc">Removes the mini track cover from the lyrics screen.</string>
|
||||
@@ -286,6 +293,19 @@
|
||||
<string
|
||||
name="patch_debug_menu_unlock_desc"
|
||||
>Reveals TIDAL\'s hidden Debug Menu in Settings. (Unlocks Feature Flags & the legacy Debug Options)</string>
|
||||
<string name="patch_waze_integration_title">Waze Integration</string>
|
||||
<string name="patch_waze_integration_desc">Control TIDAL playback from the Waze app</string>
|
||||
<string name="patch_waze_browse_root_title">Media Menu</string>
|
||||
<string name="patch_waze_accent_color_title">Accent Color</string>
|
||||
<string name="patch_waze_color_waze_default">Waze Default</string>
|
||||
<string name="patch_waze_color_tidal_cyan">TIDAL Cyan</string>
|
||||
<string name="patch_waze_color_material_you">Material You</string>
|
||||
<string name="patch_waze_root_auto">Auto Menu</string>
|
||||
<string name="patch_waze_root_home">Home</string>
|
||||
<string name="patch_waze_root_recents">Recents</string>
|
||||
<string name="patch_color_picker_title">Custom color</string>
|
||||
<string name="patch_color_picker_hex">Hex color</string>
|
||||
<string name="patch_color_picker_hex_error">Use #RRGGBB or #AARRGGBB.</string>
|
||||
<string name="patch_enable_legacy_ui_title">Enable Legacy UI</string>
|
||||
<string
|
||||
name="patch_enable_legacy_ui_desc"
|
||||
@@ -320,6 +340,8 @@
|
||||
<string name="patch_lock_blocked_title">Patch Blocked!</string>
|
||||
<string name="patch_lock_required_msg">Patch Required by <b>%s</b>.</string>
|
||||
<string name="patch_lock_blocked_msg">Patch Blocked by <b>%s</b>.</string>
|
||||
<string name="patch_lock_pkgname_title">Original Package Name Invalid!</string>
|
||||
<string name="patch_lock_pkgname_msg">This only works while the app keeps its original <b>%s</b> package name. Reset the Package Name option to enable it.<br/><br/>An existing install can\'t be renamed (requires a fresh install)</string>
|
||||
<string name="patch_opt_lock_title">Option Locked</string>
|
||||
<string name="patch_opt_lock_needs_variant">Requires the <b>%s</b> style.</string>
|
||||
<string name="patch_opt_lock_needs_option">Requires the <b>%s</b> option.</string>
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"tidalVersionCode": 9090,
|
||||
"tidalApkUrl": "https://github.com/meowarex/rl-mobile/releases/download/latest/tidal-stock.apk",
|
||||
"patchesVersion": "0.9.14"
|
||||
"patchesVersion": "0.9.15"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
.class public final Lradiant/WazeInitReceiver;
|
||||
.super Landroid/content/BroadcastReceiver;
|
||||
.source "WazeInitReceiver.smali"
|
||||
|
||||
|
||||
# direct methods
|
||||
.method public constructor <init>()V
|
||||
.locals 0
|
||||
|
||||
invoke-direct {p0}, Landroid/content/BroadcastReceiver;-><init>()V
|
||||
|
||||
return-void
|
||||
.end method
|
||||
|
||||
|
||||
# virtual methods
|
||||
.method public onReceive(Landroid/content/Context;Landroid/content/Intent;)V
|
||||
.locals 5
|
||||
|
||||
if-eqz p1, :return
|
||||
|
||||
if-eqz p2, :return
|
||||
|
||||
const-string v0, "com.waze.sdk.audio.ACTION_INIT"
|
||||
|
||||
invoke-virtual {p2}, Landroid/content/Intent;->getAction()Ljava/lang/String;
|
||||
|
||||
move-result-object v1
|
||||
|
||||
invoke-virtual {v0, v1}, Ljava/lang/String;->equals(Ljava/lang/Object;)Z
|
||||
|
||||
move-result v0
|
||||
|
||||
if-eqz v0, :return
|
||||
|
||||
const-string v0, "token"
|
||||
|
||||
invoke-virtual {p2, v0}, Landroid/content/Intent;->getStringExtra(Ljava/lang/String;)Ljava/lang/String;
|
||||
|
||||
move-result-object v0
|
||||
|
||||
if-eqz v0, :return
|
||||
|
||||
invoke-virtual {v0}, Ljava/lang/String;->length()I
|
||||
|
||||
move-result v1
|
||||
|
||||
if-lez v1, :return
|
||||
|
||||
invoke-virtual {p1}, Landroid/content/Context;->getApplicationContext()Landroid/content/Context;
|
||||
|
||||
move-result-object v1
|
||||
|
||||
if-eqz v1, :context_ready
|
||||
|
||||
move-object p1, v1
|
||||
|
||||
:context_ready
|
||||
new-instance v1, Landroid/content/Intent;
|
||||
|
||||
invoke-direct {v1}, Landroid/content/Intent;-><init>()V
|
||||
|
||||
const-string v2, "com.waze"
|
||||
|
||||
const-string v3, "com.waze.sdk.SdkService"
|
||||
|
||||
invoke-virtual {v1, v2, v3}, Landroid/content/Intent;->setClassName(Ljava/lang/String;Ljava/lang/String;)Landroid/content/Intent;
|
||||
|
||||
new-instance v2, Lradiant/WazeServiceConnection;
|
||||
|
||||
invoke-direct {v2, p1, v0}, Lradiant/WazeServiceConnection;-><init>(Landroid/content/Context;Ljava/lang/String;)V
|
||||
|
||||
:try_start_0
|
||||
const/4 v3, 0x1
|
||||
|
||||
invoke-virtual {p1, v1, v2, v3}, Landroid/content/Context;->bindService(Landroid/content/Intent;Landroid/content/ServiceConnection;I)Z
|
||||
:try_end_0
|
||||
.catch Ljava/lang/Throwable; {:try_start_0 .. :try_end_0} :catch_0
|
||||
|
||||
goto :return
|
||||
|
||||
:catch_0
|
||||
move-exception v4
|
||||
|
||||
:return
|
||||
return-void
|
||||
.end method
|
||||
@@ -0,0 +1,228 @@
|
||||
.class public final Lradiant/WazeServiceConnection;
|
||||
.super Ljava/lang/Object;
|
||||
.source "WazeServiceConnection.smali"
|
||||
|
||||
# interfaces
|
||||
.implements Landroid/content/ServiceConnection;
|
||||
|
||||
|
||||
# instance fields
|
||||
.field private final context:Landroid/content/Context;
|
||||
|
||||
.field private final token:Ljava/lang/String;
|
||||
|
||||
|
||||
# direct methods
|
||||
.method public constructor <init>(Landroid/content/Context;Ljava/lang/String;)V
|
||||
.locals 0
|
||||
|
||||
invoke-direct {p0}, Ljava/lang/Object;-><init>()V
|
||||
|
||||
iput-object p1, p0, Lradiant/WazeServiceConnection;->context:Landroid/content/Context;
|
||||
|
||||
iput-object p2, p0, Lradiant/WazeServiceConnection;->token:Ljava/lang/String;
|
||||
|
||||
return-void
|
||||
.end method
|
||||
|
||||
.method private addOpenMeIntent(Landroid/os/Bundle;)V
|
||||
.locals 4
|
||||
|
||||
iget-object v0, p0, Lradiant/WazeServiceConnection;->context:Landroid/content/Context;
|
||||
|
||||
invoke-virtual {v0}, Landroid/content/Context;->getPackageManager()Landroid/content/pm/PackageManager;
|
||||
|
||||
move-result-object v1
|
||||
|
||||
invoke-virtual {v0}, Landroid/content/Context;->getPackageName()Ljava/lang/String;
|
||||
|
||||
move-result-object v2
|
||||
|
||||
invoke-virtual {v1, v2}, Landroid/content/pm/PackageManager;->getLaunchIntentForPackage(Ljava/lang/String;)Landroid/content/Intent;
|
||||
|
||||
move-result-object v1
|
||||
|
||||
if-eqz v1, :return
|
||||
|
||||
const/high16 v2, 0x10000000
|
||||
|
||||
invoke-virtual {v1, v2}, Landroid/content/Intent;->addFlags(I)Landroid/content/Intent;
|
||||
|
||||
const v2, 0xc000000
|
||||
|
||||
const/4 v3, 0x0
|
||||
|
||||
invoke-static {v0, v3, v1, v2}, Landroid/app/PendingIntent;->getActivity(Landroid/content/Context;ILandroid/content/Intent;I)Landroid/app/PendingIntent;
|
||||
|
||||
move-result-object v1
|
||||
|
||||
const-string v2, "openMeIntent"
|
||||
|
||||
invoke-virtual {p1, v2, v1}, Landroid/os/Bundle;->putParcelable(Ljava/lang/String;Landroid/os/Parcelable;)V
|
||||
|
||||
:return
|
||||
return-void
|
||||
.end method
|
||||
|
||||
.method private createClientMessenger()Landroid/os/Messenger;
|
||||
.locals 3
|
||||
|
||||
new-instance v0, Landroid/os/Handler;
|
||||
|
||||
invoke-static {}, Landroid/os/Looper;->getMainLooper()Landroid/os/Looper;
|
||||
|
||||
move-result-object v1
|
||||
|
||||
invoke-direct {v0, v1}, Landroid/os/Handler;-><init>(Landroid/os/Looper;)V
|
||||
|
||||
new-instance v2, Landroid/os/Messenger;
|
||||
|
||||
invoke-direct {v2, v0}, Landroid/os/Messenger;-><init>(Landroid/os/Handler;)V
|
||||
|
||||
return-object v2
|
||||
.end method
|
||||
|
||||
.method private createConfigBundle()Landroid/os/Bundle;
|
||||
.locals 3
|
||||
|
||||
new-instance v0, Landroid/os/Bundle;
|
||||
|
||||
invoke-direct {v0}, Landroid/os/Bundle;-><init>()V
|
||||
|
||||
const-string v1, "versionCode"
|
||||
|
||||
const/16 v2, 0xa
|
||||
|
||||
invoke-virtual {v0, v1, v2}, Landroid/os/Bundle;->putInt(Ljava/lang/String;I)V
|
||||
|
||||
const-string v1, "themeColor"
|
||||
|
||||
const v2, __RL_WAZE_THEME_COLOR__ # Waze SDK themeColor
|
||||
|
||||
invoke-virtual {v0, v1, v2}, Landroid/os/Bundle;->putInt(Ljava/lang/String;I)V
|
||||
|
||||
invoke-direct {p0, v0}, Lradiant/WazeServiceConnection;->addOpenMeIntent(Landroid/os/Bundle;)V
|
||||
|
||||
return-object v0
|
||||
.end method
|
||||
|
||||
.method private register(Landroid/os/IBinder;)V
|
||||
.locals 5
|
||||
|
||||
if-eqz p1, :return
|
||||
|
||||
invoke-static {}, Landroid/os/Parcel;->obtain()Landroid/os/Parcel;
|
||||
|
||||
move-result-object v0
|
||||
|
||||
invoke-static {}, Landroid/os/Parcel;->obtain()Landroid/os/Parcel;
|
||||
|
||||
move-result-object v1
|
||||
|
||||
const-string v2, "com.waze.sdk.ISdkService"
|
||||
|
||||
invoke-virtual {v0, v2}, Landroid/os/Parcel;->writeInterfaceToken(Ljava/lang/String;)V
|
||||
|
||||
iget-object v2, p0, Lradiant/WazeServiceConnection;->token:Ljava/lang/String;
|
||||
|
||||
invoke-virtual {v0, v2}, Landroid/os/Parcel;->writeString(Ljava/lang/String;)V
|
||||
|
||||
invoke-direct {p0}, Lradiant/WazeServiceConnection;->createConfigBundle()Landroid/os/Bundle;
|
||||
|
||||
move-result-object v2
|
||||
|
||||
invoke-static {v0, v2}, Lradiant/WazeServiceConnection;->writeParcelable(Landroid/os/Parcel;Landroid/os/Parcelable;)V
|
||||
|
||||
invoke-direct {p0}, Lradiant/WazeServiceConnection;->createClientMessenger()Landroid/os/Messenger;
|
||||
|
||||
move-result-object v2
|
||||
|
||||
invoke-static {v0, v2}, Lradiant/WazeServiceConnection;->writeParcelable(Landroid/os/Parcel;Landroid/os/Parcelable;)V
|
||||
|
||||
const/4 v2, 0x1
|
||||
|
||||
const/4 v3, 0x0
|
||||
|
||||
invoke-interface {p1, v2, v0, v1, v3}, Landroid/os/IBinder;->transact(ILandroid/os/Parcel;Landroid/os/Parcel;I)Z
|
||||
|
||||
move-result v4
|
||||
|
||||
if-eqz v4, :cleanup
|
||||
|
||||
invoke-virtual {v1}, Landroid/os/Parcel;->readException()V
|
||||
|
||||
:cleanup
|
||||
invoke-virtual {v1}, Landroid/os/Parcel;->recycle()V
|
||||
|
||||
invoke-virtual {v0}, Landroid/os/Parcel;->recycle()V
|
||||
|
||||
:return
|
||||
return-void
|
||||
.end method
|
||||
|
||||
.method private safeUnbind()V
|
||||
.locals 1
|
||||
|
||||
:try_start_0
|
||||
iget-object v0, p0, Lradiant/WazeServiceConnection;->context:Landroid/content/Context;
|
||||
|
||||
invoke-virtual {v0, p0}, Landroid/content/Context;->unbindService(Landroid/content/ServiceConnection;)V
|
||||
:try_end_0
|
||||
.catch Ljava/lang/Throwable; {:try_start_0 .. :try_end_0} :catch_0
|
||||
|
||||
goto :return
|
||||
|
||||
:catch_0
|
||||
move-exception v0
|
||||
|
||||
:return
|
||||
return-void
|
||||
.end method
|
||||
|
||||
.method private static writeParcelable(Landroid/os/Parcel;Landroid/os/Parcelable;)V
|
||||
.locals 2
|
||||
|
||||
const/4 v0, 0x0
|
||||
|
||||
if-eqz p1, :null_value
|
||||
|
||||
const/4 v1, 0x1
|
||||
|
||||
invoke-virtual {p0, v1}, Landroid/os/Parcel;->writeInt(I)V
|
||||
|
||||
invoke-interface {p1, p0, v0}, Landroid/os/Parcelable;->writeToParcel(Landroid/os/Parcel;I)V
|
||||
|
||||
return-void
|
||||
|
||||
:null_value
|
||||
invoke-virtual {p0, v0}, Landroid/os/Parcel;->writeInt(I)V
|
||||
|
||||
return-void
|
||||
.end method
|
||||
|
||||
|
||||
# virtual methods
|
||||
.method public onServiceConnected(Landroid/content/ComponentName;Landroid/os/IBinder;)V
|
||||
.locals 1
|
||||
|
||||
:try_start_0
|
||||
invoke-direct {p0, p2}, Lradiant/WazeServiceConnection;->register(Landroid/os/IBinder;)V
|
||||
:try_end_0
|
||||
.catch Ljava/lang/Throwable; {:try_start_0 .. :try_end_0} :catch_0
|
||||
|
||||
goto :unbind
|
||||
|
||||
:catch_0
|
||||
move-exception v0
|
||||
|
||||
:unbind
|
||||
invoke-direct {p0}, Lradiant/WazeServiceConnection;->safeUnbind()V
|
||||
|
||||
return-void
|
||||
.end method
|
||||
|
||||
.method public onServiceDisconnected(Landroid/content/ComponentName;)V
|
||||
.locals 0
|
||||
|
||||
return-void
|
||||
.end method
|
||||
@@ -212,6 +212,39 @@
|
||||
"description": "Inverts the auto-hide behavior on the lyrics screen, playback controls stay visible by default and only hide when you tap them off.",
|
||||
"default": true
|
||||
},
|
||||
{
|
||||
"id": "WazeIntegration",
|
||||
"order": 90,
|
||||
"category": "integration",
|
||||
"pathLocked": true,
|
||||
"fileNames": ["waze-media-browser.patch"],
|
||||
"extensionFiles": ["radiant/WazeInitReceiver.smali", "radiant/WazeServiceConnection.smali"],
|
||||
"title": "Waze Integration",
|
||||
"description": "Control TIDAL playback from the Waze app",
|
||||
"default": true,
|
||||
"advancedOptions": [
|
||||
{
|
||||
"type": "choice",
|
||||
"key": "browse_root",
|
||||
"title": "Media Menu",
|
||||
"entries": ["Auto Menu", "Home", "Recents"],
|
||||
"values": [
|
||||
"ROOT_AUTO",
|
||||
"HOME_V2::home_page_v2_id",
|
||||
"RECENTLY_PLAYED::home/pages/CONTINUE_LISTEN_TO/view-all"
|
||||
],
|
||||
"defaultIndex": 0,
|
||||
"token": "RL_WAZE_ROOT_ID"
|
||||
},
|
||||
{
|
||||
"type": "color",
|
||||
"key": "accent_color",
|
||||
"title": "Accent Color",
|
||||
"default": -16747037,
|
||||
"token": "RL_WAZE_THEME_COLOR"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "DebugMenuUnlock",
|
||||
"order": 100,
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
--- a/pb/c.smali
|
||||
+++ b/pb/c.smali
|
||||
@@ -801,6 +801,26 @@
|
||||
-
|
||||
+
|
||||
.line 236
|
||||
:goto_8
|
||||
+ const-string/jumbo v5, "com.waze"
|
||||
+
|
||||
+ invoke-static {v3, v5}, Lkotlin/jvm/internal/n;->b(Ljava/lang/Object;Ljava/lang/Object;)Z # package match
|
||||
+
|
||||
+ move-result v5
|
||||
+
|
||||
+ if-eqz v5, :rl_waze_done
|
||||
+
|
||||
+ const-string/jumbo v5, "03:63:7f:6c:5d:8f:60:4e:6f:db:79:a6:ff:bf:a5:78:de:4e:31:8f:8d:a2:2f:c6:10:66:65:24:7f:88:07:d7" # Waze cert
|
||||
+
|
||||
+ invoke-static {v2, v5}, Lkotlin/jvm/internal/n;->b(Ljava/lang/Object;Ljava/lang/Object;)Z # cert match
|
||||
+
|
||||
+ move-result v5
|
||||
+
|
||||
+ if-eqz v5, :rl_waze_done
|
||||
+
|
||||
+ goto :goto_9 # trust Waze
|
||||
+
|
||||
+ :rl_waze_done
|
||||
+
|
||||
invoke-static {}, Landroid/os/Process;->myUid()I
|
||||
-
|
||||
+
|
||||
.line 237
|
||||
@@ -957,6 +977,26 @@
|
||||
:goto_b
|
||||
if-eqz v2, :cond_14
|
||||
-
|
||||
+
|
||||
+ const-string/jumbo v1, "com.waze"
|
||||
+
|
||||
+ invoke-static {v3, v1}, Lkotlin/jvm/internal/n;->b(Ljava/lang/Object;Ljava/lang/Object;)Z # package match
|
||||
+
|
||||
+ move-result v1
|
||||
+
|
||||
+ if-eqz v1, :rl_waze_recent_root_done_2
|
||||
+
|
||||
+ new-instance v1, Landroidx/media/MediaBrowserServiceCompat$BrowserRoot;
|
||||
+
|
||||
+ const-string/jumbo v4, "__RL_WAZE_ROOT_ID__"
|
||||
+
|
||||
+ const/4 v5, 0x0
|
||||
+
|
||||
+ invoke-direct {v1, v4, v5}, Landroidx/media/MediaBrowserServiceCompat$BrowserRoot;-><init>(Ljava/lang/String;Landroid/os/Bundle;)V
|
||||
+
|
||||
+ return-object v1
|
||||
+
|
||||
+ :rl_waze_recent_root_done_2
|
||||
+
|
||||
.line 306
|
||||
.line 307
|
||||
iget-object v1, v0, Lpb/c;->c:Ljava/util/Map;
|
||||
Reference in New Issue
Block a user