mirror of
https://github.com/topjohnwu/Magisk.git
synced 2026-06-02 06:03:44 +02:00
Migrate Settings screen to Jetpack Compose with miuix
Replace the Settings screen's data-binding XML layouts and RecyclerView item system with a declarative Compose UI using miuix components: SuperSwitch for toggles, SuperDropdown for selectors, SuperArrow for navigation items, and SuperDialog for text input dialogs. SettingsViewModel now exposes DenyList state via StateFlow and provides action methods instead of the old Handler/BaseSettingsItem pattern. Remove BaseSettingsItem.kt, SettingsItems.kt, and all associated XML layouts (fragment_settings_md2, item_settings, item_settings_section, dialog_settings_app_name, dialog_settings_download_path, dialog_settings_update_channel). Made-with: Cursor
This commit is contained in:
@@ -1,145 +0,0 @@
|
||||
package com.topjohnwu.magisk.ui.settings
|
||||
|
||||
import android.content.Context
|
||||
import android.content.res.Resources
|
||||
import android.view.View
|
||||
import androidx.databinding.Bindable
|
||||
import com.topjohnwu.magisk.BR
|
||||
import com.topjohnwu.magisk.R
|
||||
import com.topjohnwu.magisk.core.ktx.activity
|
||||
import com.topjohnwu.magisk.databinding.ObservableRvItem
|
||||
import com.topjohnwu.magisk.databinding.set
|
||||
import com.topjohnwu.magisk.utils.TextHolder
|
||||
import com.topjohnwu.magisk.view.MagiskDialog
|
||||
|
||||
sealed class BaseSettingsItem : ObservableRvItem() {
|
||||
|
||||
interface Handler {
|
||||
fun onItemPressed(view: View, item: BaseSettingsItem, andThen: () -> Unit)
|
||||
fun onItemAction(view: View, item: BaseSettingsItem)
|
||||
}
|
||||
|
||||
override val layoutRes get() = R.layout.item_settings
|
||||
|
||||
open val icon: Int get() = 0
|
||||
open val title: TextHolder get() = TextHolder.EMPTY
|
||||
@get:Bindable
|
||||
open val description: TextHolder get() = TextHolder.EMPTY
|
||||
@get:Bindable
|
||||
var isEnabled = true
|
||||
set(value) = set(value, field, { field = it }, BR.enabled, BR.description)
|
||||
|
||||
open fun onPressed(view: View, handler: Handler) {
|
||||
handler.onItemPressed(view, this) {
|
||||
handler.onItemAction(view, this)
|
||||
}
|
||||
}
|
||||
open fun refresh() {}
|
||||
|
||||
// Only for toggle
|
||||
open val showSwitch get() = false
|
||||
@get:Bindable
|
||||
open val isChecked get() = false
|
||||
fun onToggle(view: View, handler: Handler, checked: Boolean) =
|
||||
set(checked, isChecked, { onPressed(view, handler) })
|
||||
|
||||
abstract class Value<T> : BaseSettingsItem() {
|
||||
|
||||
/**
|
||||
* Represents last agreed-upon value by the validation process and the user for current
|
||||
* child. Be very aware that this shouldn't be **set** unless both sides agreed that _that_
|
||||
* is the new value.
|
||||
* */
|
||||
abstract var value: T
|
||||
protected set
|
||||
}
|
||||
|
||||
abstract class Toggle : Value<Boolean>() {
|
||||
|
||||
override val showSwitch get() = true
|
||||
override val isChecked get() = value
|
||||
|
||||
override fun onPressed(view: View, handler: Handler) {
|
||||
// Make sure the checked state is synced
|
||||
notifyPropertyChanged(BR.checked)
|
||||
handler.onItemPressed(view, this) {
|
||||
value = !value
|
||||
notifyPropertyChanged(BR.checked)
|
||||
handler.onItemAction(view, this)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
abstract class Input : Value<String>() {
|
||||
|
||||
@get:Bindable
|
||||
abstract val inputResult: String?
|
||||
|
||||
override fun onPressed(view: View, handler: Handler) {
|
||||
handler.onItemPressed(view, this) {
|
||||
MagiskDialog(view.activity).apply {
|
||||
setTitle(title.getText(view.resources))
|
||||
setView(getView(view.context))
|
||||
setButton(MagiskDialog.ButtonType.POSITIVE) {
|
||||
text = android.R.string.ok
|
||||
onClick {
|
||||
inputResult?.let { result ->
|
||||
doNotDismiss = false
|
||||
value = result
|
||||
handler.onItemAction(view, this@Input)
|
||||
return@onClick
|
||||
}
|
||||
doNotDismiss = true
|
||||
}
|
||||
}
|
||||
setButton(MagiskDialog.ButtonType.NEGATIVE) {
|
||||
text = android.R.string.cancel
|
||||
}
|
||||
}.show()
|
||||
}
|
||||
}
|
||||
|
||||
abstract fun getView(context: Context): View
|
||||
}
|
||||
|
||||
abstract class Selector : Value<Int>() {
|
||||
|
||||
open val entryRes get() = -1
|
||||
open val descriptionRes get() = entryRes
|
||||
open fun entries(res: Resources) = res.getArrayOrEmpty(entryRes)
|
||||
open fun descriptions(res: Resources) = res.getArrayOrEmpty(descriptionRes)
|
||||
|
||||
override val description = object : TextHolder() {
|
||||
override fun getText(resources: Resources): CharSequence {
|
||||
return descriptions(resources).getOrElse(value) { "" }
|
||||
}
|
||||
}
|
||||
|
||||
private fun Resources.getArrayOrEmpty(id: Int): Array<String> =
|
||||
runCatching { getStringArray(id) }.getOrDefault(emptyArray())
|
||||
|
||||
override fun onPressed(view: View, handler: Handler) {
|
||||
handler.onItemPressed(view, this) {
|
||||
MagiskDialog(view.activity).apply {
|
||||
setTitle(title.getText(view.resources))
|
||||
setButton(MagiskDialog.ButtonType.NEGATIVE) {
|
||||
text = android.R.string.cancel
|
||||
}
|
||||
setListItems(entries(view.resources)) {
|
||||
if (value != it) {
|
||||
value = it
|
||||
notifyPropertyChanged(BR.description)
|
||||
handler.onItemAction(view, this@Selector)
|
||||
}
|
||||
}
|
||||
}.show()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
abstract class Blank : BaseSettingsItem()
|
||||
|
||||
abstract class Section : BaseSettingsItem() {
|
||||
override val layoutRes = R.layout.item_settings_section
|
||||
}
|
||||
}
|
||||
@@ -1,40 +1,59 @@
|
||||
package com.topjohnwu.magisk.ui.settings
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import com.topjohnwu.magisk.R
|
||||
import com.topjohnwu.magisk.arch.BaseFragment
|
||||
import com.topjohnwu.magisk.arch.viewModel
|
||||
import com.topjohnwu.magisk.databinding.FragmentSettingsMd2Binding
|
||||
import rikka.recyclerview.addEdgeSpacing
|
||||
import rikka.recyclerview.addItemSpacing
|
||||
import rikka.recyclerview.fixEdgeEffect
|
||||
import android.view.ViewGroup
|
||||
import androidx.compose.ui.platform.ComposeView
|
||||
import androidx.compose.ui.platform.ViewCompositionStrategy
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import com.topjohnwu.magisk.arch.ActivityExecutor
|
||||
import com.topjohnwu.magisk.arch.ContextExecutor
|
||||
import com.topjohnwu.magisk.arch.NavigationActivity
|
||||
import com.topjohnwu.magisk.arch.UIActivity
|
||||
import com.topjohnwu.magisk.arch.VMFactory
|
||||
import com.topjohnwu.magisk.arch.ViewEvent
|
||||
import com.topjohnwu.magisk.arch.ViewModelHolder
|
||||
import com.topjohnwu.magisk.core.Info
|
||||
import com.topjohnwu.magisk.ui.theme.MagiskTheme
|
||||
import com.topjohnwu.magisk.core.R as CoreR
|
||||
|
||||
class SettingsFragment : BaseFragment<FragmentSettingsMd2Binding>() {
|
||||
class SettingsFragment : Fragment(), ViewModelHolder {
|
||||
|
||||
override val layoutRes = R.layout.fragment_settings_md2
|
||||
override val viewModel by viewModel<SettingsViewModel>()
|
||||
override val snackbarView: View get() = binding.snackbarContainer
|
||||
override val viewModel by lazy {
|
||||
ViewModelProvider(this, VMFactory)[SettingsViewModel::class.java]
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
startObserveLiveData()
|
||||
}
|
||||
|
||||
override fun onStart() {
|
||||
super.onStart()
|
||||
|
||||
activity?.title = resources.getString(CoreR.string.settings)
|
||||
(activity as? NavigationActivity<*>)?.setTitle(CoreR.string.settings)
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
binding.settingsList.apply {
|
||||
addEdgeSpacing(bottom = R.dimen.l1)
|
||||
addItemSpacing(R.dimen.l1, R.dimen.l_50, R.dimen.l1)
|
||||
fixEdgeEffect()
|
||||
override fun onCreateView(
|
||||
inflater: LayoutInflater,
|
||||
container: ViewGroup?,
|
||||
savedInstanceState: Bundle?
|
||||
): View {
|
||||
return ComposeView(requireContext()).apply {
|
||||
setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed)
|
||||
setContent {
|
||||
MagiskTheme {
|
||||
SettingsScreen(viewModel = viewModel as SettingsViewModel)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
viewModel.items.forEach { it.refresh() }
|
||||
override fun onEventDispatched(event: ViewEvent) {
|
||||
when (event) {
|
||||
is ContextExecutor -> event(requireContext())
|
||||
is ActivityExecutor -> (activity as? UIActivity<*>)?.let { event(it) }
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -1,333 +0,0 @@
|
||||
package com.topjohnwu.magisk.ui.settings
|
||||
|
||||
import android.content.Context
|
||||
import android.content.res.Resources
|
||||
import android.os.Build
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import androidx.databinding.Bindable
|
||||
import com.topjohnwu.magisk.BR
|
||||
import com.topjohnwu.magisk.R
|
||||
import com.topjohnwu.magisk.core.BuildConfig
|
||||
import com.topjohnwu.magisk.core.Config
|
||||
import com.topjohnwu.magisk.core.Const
|
||||
import com.topjohnwu.magisk.core.Info
|
||||
import com.topjohnwu.magisk.core.ktx.activity
|
||||
import com.topjohnwu.magisk.core.tasks.AppMigration
|
||||
import com.topjohnwu.magisk.core.utils.LocaleSetting
|
||||
import com.topjohnwu.magisk.core.utils.MediaStoreUtils
|
||||
import com.topjohnwu.magisk.databinding.DialogSettingsAppNameBinding
|
||||
import com.topjohnwu.magisk.databinding.DialogSettingsDownloadPathBinding
|
||||
import com.topjohnwu.magisk.databinding.DialogSettingsUpdateChannelBinding
|
||||
import com.topjohnwu.magisk.databinding.set
|
||||
import com.topjohnwu.magisk.utils.TextHolder
|
||||
import com.topjohnwu.magisk.utils.asText
|
||||
import com.topjohnwu.magisk.view.MagiskDialog
|
||||
import com.topjohnwu.superuser.Shell
|
||||
import com.topjohnwu.magisk.core.R as CoreR
|
||||
|
||||
// --- Customization
|
||||
|
||||
object Customization : BaseSettingsItem.Section() {
|
||||
override val title = CoreR.string.settings_customization.asText()
|
||||
}
|
||||
|
||||
object Language : BaseSettingsItem.Selector() {
|
||||
private val names: Array<String> get() = LocaleSetting.available.names
|
||||
private val tags: Array<String> get() = LocaleSetting.available.tags
|
||||
|
||||
override var value
|
||||
get() = tags.indexOf(Config.locale)
|
||||
set(value) {
|
||||
Config.locale = tags[value]
|
||||
}
|
||||
|
||||
override val title = CoreR.string.language.asText()
|
||||
|
||||
override fun entries(res: Resources) = names
|
||||
override fun descriptions(res: Resources) = names
|
||||
}
|
||||
|
||||
object LanguageSystem : BaseSettingsItem.Blank() {
|
||||
override val title = CoreR.string.language.asText()
|
||||
override val description: TextHolder
|
||||
get() {
|
||||
val locale = LocaleSetting.instance.appLocale
|
||||
return locale?.getDisplayName(locale)?.asText() ?: CoreR.string.system_default.asText()
|
||||
}
|
||||
}
|
||||
|
||||
object Theme : BaseSettingsItem.Blank() {
|
||||
override val icon = R.drawable.ic_paint
|
||||
override val title = CoreR.string.section_theme.asText()
|
||||
}
|
||||
|
||||
// --- App
|
||||
|
||||
object AppSettings : BaseSettingsItem.Section() {
|
||||
override val title = CoreR.string.home_app_title.asText()
|
||||
}
|
||||
|
||||
object Hide : BaseSettingsItem.Input() {
|
||||
override val title = CoreR.string.settings_hide_app_title.asText()
|
||||
override val description = CoreR.string.settings_hide_app_summary.asText()
|
||||
override var value = ""
|
||||
|
||||
override val inputResult
|
||||
get() = if (isError) null else result
|
||||
|
||||
@get:Bindable
|
||||
var result = "Settings"
|
||||
set(value) = set(value, field, { field = it }, BR.result, BR.error)
|
||||
|
||||
val maxLength
|
||||
get() = AppMigration.MAX_LABEL_LENGTH
|
||||
|
||||
@get:Bindable
|
||||
val isError
|
||||
get() = result.length > maxLength || result.isBlank()
|
||||
|
||||
override fun getView(context: Context) = DialogSettingsAppNameBinding
|
||||
.inflate(LayoutInflater.from(context)).also { it.data = this }.root
|
||||
}
|
||||
|
||||
object Restore : BaseSettingsItem.Blank() {
|
||||
override val title = CoreR.string.settings_restore_app_title.asText()
|
||||
override val description = CoreR.string.settings_restore_app_summary.asText()
|
||||
|
||||
override fun onPressed(view: View, handler: Handler) {
|
||||
handler.onItemPressed(view, this) {
|
||||
MagiskDialog(view.activity).apply {
|
||||
setTitle(CoreR.string.settings_restore_app_title)
|
||||
setMessage(CoreR.string.restore_app_confirmation)
|
||||
setButton(MagiskDialog.ButtonType.POSITIVE) {
|
||||
text = android.R.string.ok
|
||||
onClick {
|
||||
handler.onItemAction(view, this@Restore)
|
||||
}
|
||||
}
|
||||
setButton(MagiskDialog.ButtonType.NEGATIVE) {
|
||||
text = android.R.string.cancel
|
||||
}
|
||||
setCancelable(true)
|
||||
show()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
object AddShortcut : BaseSettingsItem.Blank() {
|
||||
override val title = CoreR.string.add_shortcut_title.asText()
|
||||
override val description = CoreR.string.setting_add_shortcut_summary.asText()
|
||||
}
|
||||
|
||||
object DownloadPath : BaseSettingsItem.Input() {
|
||||
override var value
|
||||
get() = Config.downloadDir
|
||||
set(value) {
|
||||
Config.downloadDir = value
|
||||
notifyPropertyChanged(BR.description)
|
||||
}
|
||||
|
||||
override val title = CoreR.string.settings_download_path_title.asText()
|
||||
override val description get() = MediaStoreUtils.fullPath(value).asText()
|
||||
|
||||
override var inputResult: String = value
|
||||
set(value) = set(value, field, { field = it }, BR.inputResult, BR.path)
|
||||
|
||||
@get:Bindable
|
||||
val path get() = MediaStoreUtils.fullPath(inputResult)
|
||||
|
||||
override fun getView(context: Context) = DialogSettingsDownloadPathBinding
|
||||
.inflate(LayoutInflater.from(context)).also { it.data = this }.root
|
||||
}
|
||||
|
||||
object UpdateChannel : BaseSettingsItem.Selector() {
|
||||
override var value
|
||||
get() = Config.updateChannel
|
||||
set(value) {
|
||||
Config.updateChannel = value
|
||||
Info.resetUpdate()
|
||||
}
|
||||
|
||||
override val title = CoreR.string.settings_update_channel_title.asText()
|
||||
override val entryRes = CoreR.array.update_channel
|
||||
}
|
||||
|
||||
object UpdateChannelUrl : BaseSettingsItem.Input() {
|
||||
override val title = CoreR.string.settings_update_custom.asText()
|
||||
override val description get() = value.asText()
|
||||
override var value
|
||||
get() = Config.customChannelUrl
|
||||
set(value) {
|
||||
Config.customChannelUrl = value
|
||||
Info.resetUpdate()
|
||||
notifyPropertyChanged(BR.description)
|
||||
}
|
||||
|
||||
override var inputResult: String = value
|
||||
set(value) = set(value, field, { field = it }, BR.inputResult)
|
||||
|
||||
override fun refresh() {
|
||||
isEnabled = UpdateChannel.value == Config.Value.CUSTOM_CHANNEL
|
||||
}
|
||||
|
||||
override fun getView(context: Context) = DialogSettingsUpdateChannelBinding
|
||||
.inflate(LayoutInflater.from(context)).also { it.data = this }.root
|
||||
}
|
||||
|
||||
object UpdateChecker : BaseSettingsItem.Toggle() {
|
||||
override val title = CoreR.string.settings_check_update_title.asText()
|
||||
override val description = CoreR.string.settings_check_update_summary.asText()
|
||||
override var value by Config::checkUpdate
|
||||
}
|
||||
|
||||
object DoHToggle : BaseSettingsItem.Toggle() {
|
||||
override val title = CoreR.string.settings_doh_title.asText()
|
||||
override val description = CoreR.string.settings_doh_description.asText()
|
||||
override var value by Config::doh
|
||||
}
|
||||
|
||||
object SystemlessHosts : BaseSettingsItem.Blank() {
|
||||
override val title = CoreR.string.settings_hosts_title.asText()
|
||||
override val description = CoreR.string.settings_hosts_summary.asText()
|
||||
}
|
||||
|
||||
object RandNameToggle : BaseSettingsItem.Toggle() {
|
||||
override val title = CoreR.string.settings_random_name_title.asText()
|
||||
override val description = CoreR.string.settings_random_name_description.asText()
|
||||
override var value by Config::randName
|
||||
}
|
||||
|
||||
// --- Magisk
|
||||
|
||||
object Magisk : BaseSettingsItem.Section() {
|
||||
override val title = CoreR.string.magisk.asText()
|
||||
}
|
||||
|
||||
object Zygisk : BaseSettingsItem.Toggle() {
|
||||
override val title = CoreR.string.zygisk.asText()
|
||||
override val description get() =
|
||||
if (mismatch) CoreR.string.reboot_apply_change.asText()
|
||||
else CoreR.string.settings_zygisk_summary.asText()
|
||||
override var value
|
||||
get() = Config.zygisk
|
||||
set(value) {
|
||||
Config.zygisk = value
|
||||
notifyPropertyChanged(BR.description)
|
||||
}
|
||||
val mismatch get() = value != Info.isZygiskEnabled
|
||||
}
|
||||
|
||||
object DenyList : BaseSettingsItem.Toggle() {
|
||||
override val title = CoreR.string.settings_denylist_title.asText()
|
||||
override val description get() = CoreR.string.settings_denylist_summary.asText()
|
||||
|
||||
override var value = Config.denyList
|
||||
set(value) {
|
||||
field = value
|
||||
val cmd = if (value) "enable" else "disable"
|
||||
Shell.cmd("magisk --denylist $cmd").submit { result ->
|
||||
if (result.isSuccess) {
|
||||
Config.denyList = value
|
||||
} else {
|
||||
field = !value
|
||||
notifyPropertyChanged(BR.checked)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
object DenyListConfig : BaseSettingsItem.Blank() {
|
||||
override val title = CoreR.string.settings_denylist_config_title.asText()
|
||||
override val description = CoreR.string.settings_denylist_config_summary.asText()
|
||||
}
|
||||
|
||||
// --- Superuser
|
||||
|
||||
object Tapjack : BaseSettingsItem.Toggle() {
|
||||
override val title = CoreR.string.settings_su_tapjack_title.asText()
|
||||
override val description = CoreR.string.settings_su_tapjack_summary.asText()
|
||||
override var value by Config::suTapjack
|
||||
}
|
||||
|
||||
object Authentication : BaseSettingsItem.Toggle() {
|
||||
override val title = CoreR.string.settings_su_auth_title.asText()
|
||||
override var description = CoreR.string.settings_su_auth_summary.asText()
|
||||
override var value by Config::suAuth
|
||||
|
||||
override fun refresh() {
|
||||
isEnabled = Info.isDeviceSecure
|
||||
if (!isEnabled) {
|
||||
description = CoreR.string.settings_su_auth_insecure.asText()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
object Superuser : BaseSettingsItem.Section() {
|
||||
override val title = CoreR.string.superuser.asText()
|
||||
}
|
||||
|
||||
object AccessMode : BaseSettingsItem.Selector() {
|
||||
override val title = CoreR.string.superuser_access.asText()
|
||||
override val entryRes = CoreR.array.su_access
|
||||
override var value by Config::rootMode
|
||||
}
|
||||
|
||||
object MultiuserMode : BaseSettingsItem.Selector() {
|
||||
override val title = CoreR.string.multiuser_mode.asText()
|
||||
override val entryRes = CoreR.array.multiuser_mode
|
||||
override val descriptionRes = CoreR.array.multiuser_summary
|
||||
override var value by Config::suMultiuserMode
|
||||
|
||||
override fun refresh() {
|
||||
isEnabled = Const.USER_ID == 0
|
||||
}
|
||||
}
|
||||
|
||||
object MountNamespaceMode : BaseSettingsItem.Selector() {
|
||||
override val title = CoreR.string.mount_namespace_mode.asText()
|
||||
override val entryRes = CoreR.array.namespace
|
||||
override val descriptionRes = CoreR.array.namespace_summary
|
||||
override var value by Config::suMntNamespaceMode
|
||||
}
|
||||
|
||||
object AutomaticResponse : BaseSettingsItem.Selector() {
|
||||
override val title = CoreR.string.auto_response.asText()
|
||||
override val entryRes = CoreR.array.auto_response
|
||||
override var value by Config::suAutoResponse
|
||||
}
|
||||
|
||||
object RequestTimeout : BaseSettingsItem.Selector() {
|
||||
override val title = CoreR.string.request_timeout.asText()
|
||||
override val entryRes = CoreR.array.request_timeout
|
||||
|
||||
private val entryValues = listOf(10, 15, 20, 30, 45, 60)
|
||||
override var value = entryValues.indexOfFirst { it == Config.suDefaultTimeout }
|
||||
set(value) {
|
||||
field = value
|
||||
Config.suDefaultTimeout = entryValues[value]
|
||||
}
|
||||
}
|
||||
|
||||
object SUNotification : BaseSettingsItem.Selector() {
|
||||
override val title = CoreR.string.superuser_notification.asText()
|
||||
override val entryRes = CoreR.array.su_notification
|
||||
override var value by Config::suNotification
|
||||
}
|
||||
|
||||
object Reauthenticate : BaseSettingsItem.Toggle() {
|
||||
override val title = CoreR.string.settings_su_reauth_title.asText()
|
||||
override val description = CoreR.string.settings_su_reauth_summary.asText()
|
||||
override var value by Config::suReAuth
|
||||
|
||||
override fun refresh() {
|
||||
isEnabled = Build.VERSION.SDK_INT < Build.VERSION_CODES.O
|
||||
}
|
||||
}
|
||||
|
||||
object Restrict : BaseSettingsItem.Toggle() {
|
||||
override val title = CoreR.string.settings_su_restrict_title.asText()
|
||||
override val description = CoreR.string.settings_su_restrict_summary.asText()
|
||||
override var value by Config::suRestrict
|
||||
}
|
||||
@@ -0,0 +1,601 @@
|
||||
package com.topjohnwu.magisk.ui.settings
|
||||
|
||||
import android.app.Activity
|
||||
import android.os.Build
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.DpSize
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.content.pm.ShortcutManagerCompat
|
||||
import com.topjohnwu.magisk.core.BuildConfig
|
||||
import com.topjohnwu.magisk.core.Config
|
||||
import com.topjohnwu.magisk.core.Const
|
||||
import com.topjohnwu.magisk.core.Info
|
||||
import com.topjohnwu.magisk.core.isRunningAsStub
|
||||
import com.topjohnwu.magisk.core.tasks.AppMigration
|
||||
import com.topjohnwu.magisk.core.utils.LocaleSetting
|
||||
import com.topjohnwu.magisk.core.utils.MediaStoreUtils
|
||||
import top.yukonga.miuix.kmp.basic.Card
|
||||
import top.yukonga.miuix.kmp.basic.Scaffold
|
||||
import top.yukonga.miuix.kmp.basic.SmallTitle
|
||||
import top.yukonga.miuix.kmp.basic.TextField
|
||||
import top.yukonga.miuix.kmp.basic.TextButton
|
||||
import top.yukonga.miuix.kmp.extra.SuperArrow
|
||||
import top.yukonga.miuix.kmp.extra.SuperDropdown
|
||||
import top.yukonga.miuix.kmp.extra.SuperSwitch
|
||||
import top.yukonga.miuix.kmp.extra.SuperDialog
|
||||
import com.topjohnwu.magisk.core.R as CoreR
|
||||
|
||||
@Composable
|
||||
fun SettingsScreen(viewModel: SettingsViewModel) {
|
||||
Scaffold { padding ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(padding)
|
||||
.padding(horizontal = 12.dp)
|
||||
.padding(top = 8.dp, bottom = 16.dp)
|
||||
) {
|
||||
CustomizationSection(viewModel)
|
||||
Spacer(Modifier.height(12.dp))
|
||||
AppSettingsSection(viewModel)
|
||||
if (Info.env.isActive) {
|
||||
Spacer(Modifier.height(12.dp))
|
||||
MagiskSection(viewModel)
|
||||
}
|
||||
if (Info.showSuperUser) {
|
||||
Spacer(Modifier.height(12.dp))
|
||||
SuperuserSection(viewModel)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Customization ---
|
||||
|
||||
@Composable
|
||||
private fun CustomizationSection(viewModel: SettingsViewModel) {
|
||||
val context = LocalContext.current
|
||||
|
||||
SmallTitle(text = stringResource(CoreR.string.settings_customization))
|
||||
Card(modifier = Modifier.fillMaxWidth()) {
|
||||
SuperArrow(
|
||||
title = stringResource(CoreR.string.section_theme),
|
||||
onClick = { viewModel.navigateToTheme() }
|
||||
)
|
||||
|
||||
if (LocaleSetting.useLocaleManager) {
|
||||
val locale = LocaleSetting.instance.appLocale
|
||||
val summary = locale?.getDisplayName(locale) ?: stringResource(CoreR.string.system_default)
|
||||
SuperArrow(
|
||||
title = stringResource(CoreR.string.language),
|
||||
summary = summary,
|
||||
onClick = {
|
||||
context.startActivity(LocaleSetting.localeSettingsIntent)
|
||||
}
|
||||
)
|
||||
} else {
|
||||
val names = remember { LocaleSetting.available.names }
|
||||
val tags = remember { LocaleSetting.available.tags }
|
||||
var selectedIndex by remember {
|
||||
mutableIntStateOf(tags.indexOf(Config.locale).coerceAtLeast(0))
|
||||
}
|
||||
SuperDropdown(
|
||||
title = stringResource(CoreR.string.language),
|
||||
items = names.toList(),
|
||||
selectedIndex = selectedIndex,
|
||||
onSelectedIndexChange = { index ->
|
||||
selectedIndex = index
|
||||
Config.locale = tags[index]
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
if (isRunningAsStub && ShortcutManagerCompat.isRequestPinShortcutSupported(context)) {
|
||||
SuperArrow(
|
||||
title = stringResource(CoreR.string.add_shortcut_title),
|
||||
summary = stringResource(CoreR.string.setting_add_shortcut_summary),
|
||||
onClick = { viewModel.requestAddShortcut() }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- App Settings ---
|
||||
|
||||
@Composable
|
||||
private fun AppSettingsSection(viewModel: SettingsViewModel) {
|
||||
val context = LocalContext.current
|
||||
val resources = context.resources
|
||||
val hidden = context.packageName != BuildConfig.APP_PACKAGE_NAME
|
||||
|
||||
SmallTitle(text = stringResource(CoreR.string.home_app_title))
|
||||
Card(modifier = Modifier.fillMaxWidth()) {
|
||||
// Update Channel
|
||||
val updateChannelEntries = remember {
|
||||
resources.getStringArray(CoreR.array.update_channel).toList()
|
||||
}
|
||||
var updateChannel by remember { mutableIntStateOf(Config.updateChannel) }
|
||||
var showUrlDialog by remember { mutableStateOf(false) }
|
||||
|
||||
SuperDropdown(
|
||||
title = stringResource(CoreR.string.settings_update_channel_title),
|
||||
items = updateChannelEntries,
|
||||
selectedIndex = updateChannel,
|
||||
onSelectedIndexChange = { index ->
|
||||
updateChannel = index
|
||||
Config.updateChannel = index
|
||||
Info.resetUpdate()
|
||||
if (index == Config.Value.CUSTOM_CHANNEL && Config.customChannelUrl.isBlank()) {
|
||||
showUrlDialog = true
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
// Update Channel URL (for custom channel)
|
||||
if (updateChannel == Config.Value.CUSTOM_CHANNEL) {
|
||||
UpdateChannelUrlDialog(
|
||||
show = showUrlDialog,
|
||||
onDismiss = { showUrlDialog = false }
|
||||
)
|
||||
SuperArrow(
|
||||
title = stringResource(CoreR.string.settings_update_custom),
|
||||
summary = Config.customChannelUrl.ifBlank { null },
|
||||
onClick = { showUrlDialog = true }
|
||||
)
|
||||
}
|
||||
|
||||
// DoH Toggle
|
||||
var doh by remember { mutableStateOf(Config.doh) }
|
||||
SuperSwitch(
|
||||
title = stringResource(CoreR.string.settings_doh_title),
|
||||
summary = stringResource(CoreR.string.settings_doh_description),
|
||||
checked = doh,
|
||||
onCheckedChange = {
|
||||
doh = it
|
||||
Config.doh = it
|
||||
}
|
||||
)
|
||||
|
||||
// Update Checker
|
||||
var checkUpdate by remember { mutableStateOf(Config.checkUpdate) }
|
||||
SuperSwitch(
|
||||
title = stringResource(CoreR.string.settings_check_update_title),
|
||||
summary = stringResource(CoreR.string.settings_check_update_summary),
|
||||
checked = checkUpdate,
|
||||
onCheckedChange = { newValue ->
|
||||
viewModel.withNotificationPermission {
|
||||
checkUpdate = newValue
|
||||
Config.checkUpdate = newValue
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
// Download Path
|
||||
var showDownloadDialog by remember { mutableStateOf(false) }
|
||||
DownloadPathDialog(
|
||||
show = showDownloadDialog,
|
||||
onDismiss = { showDownloadDialog = false }
|
||||
)
|
||||
SuperArrow(
|
||||
title = stringResource(CoreR.string.settings_download_path_title),
|
||||
summary = MediaStoreUtils.fullPath(Config.downloadDir),
|
||||
onClick = {
|
||||
viewModel.withDownloadPathPermission { showDownloadDialog = true }
|
||||
}
|
||||
)
|
||||
|
||||
// Random Package Name
|
||||
var randName by remember { mutableStateOf(Config.randName) }
|
||||
SuperSwitch(
|
||||
title = stringResource(CoreR.string.settings_random_name_title),
|
||||
summary = stringResource(CoreR.string.settings_random_name_description),
|
||||
checked = randName,
|
||||
onCheckedChange = {
|
||||
randName = it
|
||||
Config.randName = it
|
||||
}
|
||||
)
|
||||
|
||||
// Hide / Restore
|
||||
if (Info.env.isActive && Const.USER_ID == 0) {
|
||||
if (hidden) {
|
||||
var showRestoreDialog by remember { mutableStateOf(false) }
|
||||
RestoreDialog(
|
||||
show = showRestoreDialog,
|
||||
onDismiss = { showRestoreDialog = false },
|
||||
onConfirm = {
|
||||
showRestoreDialog = false
|
||||
viewModel.restoreApp(context as Activity)
|
||||
}
|
||||
)
|
||||
SuperArrow(
|
||||
title = stringResource(CoreR.string.settings_restore_app_title),
|
||||
summary = stringResource(CoreR.string.settings_restore_app_summary),
|
||||
onClick = { showRestoreDialog = true }
|
||||
)
|
||||
} else {
|
||||
var showHideDialog by remember { mutableStateOf(false) }
|
||||
HideAppDialog(
|
||||
show = showHideDialog,
|
||||
onDismiss = { showHideDialog = false },
|
||||
onConfirm = { name ->
|
||||
showHideDialog = false
|
||||
viewModel.hideApp(context as Activity, name)
|
||||
}
|
||||
)
|
||||
SuperArrow(
|
||||
title = stringResource(CoreR.string.settings_hide_app_title),
|
||||
summary = stringResource(CoreR.string.settings_hide_app_summary),
|
||||
onClick = { showHideDialog = true }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Magisk ---
|
||||
|
||||
@Composable
|
||||
private fun MagiskSection(viewModel: SettingsViewModel) {
|
||||
SmallTitle(text = stringResource(CoreR.string.magisk))
|
||||
Card(modifier = Modifier.fillMaxWidth()) {
|
||||
// Systemless Hosts
|
||||
SuperArrow(
|
||||
title = stringResource(CoreR.string.settings_hosts_title),
|
||||
summary = stringResource(CoreR.string.settings_hosts_summary),
|
||||
onClick = { viewModel.createHosts() }
|
||||
)
|
||||
|
||||
if (Const.Version.atLeast_24_0()) {
|
||||
// Zygisk
|
||||
var zygisk by remember { mutableStateOf(Config.zygisk) }
|
||||
SuperSwitch(
|
||||
title = stringResource(CoreR.string.zygisk),
|
||||
summary = stringResource(
|
||||
if (zygisk != Info.isZygiskEnabled) CoreR.string.reboot_apply_change
|
||||
else CoreR.string.settings_zygisk_summary
|
||||
),
|
||||
checked = zygisk,
|
||||
onCheckedChange = {
|
||||
zygisk = it
|
||||
Config.zygisk = it
|
||||
viewModel.notifyZygiskChange()
|
||||
}
|
||||
)
|
||||
|
||||
// DenyList
|
||||
val denyListEnabled by viewModel.denyListEnabled.collectAsState()
|
||||
SuperSwitch(
|
||||
title = stringResource(CoreR.string.settings_denylist_title),
|
||||
summary = stringResource(CoreR.string.settings_denylist_summary),
|
||||
checked = denyListEnabled,
|
||||
onCheckedChange = { viewModel.toggleDenyList(it) }
|
||||
)
|
||||
|
||||
// DenyList Config
|
||||
SuperArrow(
|
||||
title = stringResource(CoreR.string.settings_denylist_config_title),
|
||||
summary = stringResource(CoreR.string.settings_denylist_config_summary),
|
||||
onClick = { viewModel.navigateToDenyList() }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Superuser ---
|
||||
|
||||
@Composable
|
||||
private fun SuperuserSection(viewModel: SettingsViewModel) {
|
||||
val context = LocalContext.current
|
||||
val resources = context.resources
|
||||
|
||||
SmallTitle(text = stringResource(CoreR.string.superuser))
|
||||
Card(modifier = Modifier.fillMaxWidth()) {
|
||||
// Tapjack (SDK < S)
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) {
|
||||
var tapjack by remember { mutableStateOf(Config.suTapjack) }
|
||||
SuperSwitch(
|
||||
title = stringResource(CoreR.string.settings_su_tapjack_title),
|
||||
summary = stringResource(CoreR.string.settings_su_tapjack_summary),
|
||||
checked = tapjack,
|
||||
onCheckedChange = {
|
||||
tapjack = it
|
||||
Config.suTapjack = it
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// Authentication
|
||||
var suAuth by remember { mutableStateOf(Config.suAuth) }
|
||||
SuperSwitch(
|
||||
title = stringResource(CoreR.string.settings_su_auth_title),
|
||||
summary = stringResource(
|
||||
if (Info.isDeviceSecure) CoreR.string.settings_su_auth_summary
|
||||
else CoreR.string.settings_su_auth_insecure
|
||||
),
|
||||
checked = suAuth,
|
||||
enabled = Info.isDeviceSecure,
|
||||
onCheckedChange = { newValue ->
|
||||
viewModel.withAuth {
|
||||
suAuth = newValue
|
||||
Config.suAuth = newValue
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
// Access Mode
|
||||
val accessEntries = remember {
|
||||
resources.getStringArray(CoreR.array.su_access).toList()
|
||||
}
|
||||
var accessMode by remember { mutableIntStateOf(Config.rootMode) }
|
||||
SuperDropdown(
|
||||
title = stringResource(CoreR.string.superuser_access),
|
||||
items = accessEntries,
|
||||
selectedIndex = accessMode,
|
||||
onSelectedIndexChange = {
|
||||
accessMode = it
|
||||
Config.rootMode = it
|
||||
}
|
||||
)
|
||||
|
||||
// Multiuser Mode
|
||||
val multiuserEntries = remember {
|
||||
resources.getStringArray(CoreR.array.multiuser_mode).toList()
|
||||
}
|
||||
val multiuserDescriptions = remember {
|
||||
resources.getStringArray(CoreR.array.multiuser_summary).toList()
|
||||
}
|
||||
var multiuserMode by remember { mutableIntStateOf(Config.suMultiuserMode) }
|
||||
SuperDropdown(
|
||||
title = stringResource(CoreR.string.multiuser_mode),
|
||||
summary = multiuserDescriptions.getOrElse(multiuserMode) { "" },
|
||||
items = multiuserEntries,
|
||||
selectedIndex = multiuserMode,
|
||||
enabled = Const.USER_ID == 0,
|
||||
onSelectedIndexChange = {
|
||||
multiuserMode = it
|
||||
Config.suMultiuserMode = it
|
||||
}
|
||||
)
|
||||
|
||||
// Mount Namespace Mode
|
||||
val namespaceEntries = remember {
|
||||
resources.getStringArray(CoreR.array.namespace).toList()
|
||||
}
|
||||
val namespaceDescriptions = remember {
|
||||
resources.getStringArray(CoreR.array.namespace_summary).toList()
|
||||
}
|
||||
var mntNamespaceMode by remember { mutableIntStateOf(Config.suMntNamespaceMode) }
|
||||
SuperDropdown(
|
||||
title = stringResource(CoreR.string.mount_namespace_mode),
|
||||
summary = namespaceDescriptions.getOrElse(mntNamespaceMode) { "" },
|
||||
items = namespaceEntries,
|
||||
selectedIndex = mntNamespaceMode,
|
||||
onSelectedIndexChange = {
|
||||
mntNamespaceMode = it
|
||||
Config.suMntNamespaceMode = it
|
||||
}
|
||||
)
|
||||
|
||||
// Automatic Response
|
||||
val autoResponseEntries = remember {
|
||||
resources.getStringArray(CoreR.array.auto_response).toList()
|
||||
}
|
||||
var autoResponse by remember { mutableIntStateOf(Config.suAutoResponse) }
|
||||
SuperDropdown(
|
||||
title = stringResource(CoreR.string.auto_response),
|
||||
items = autoResponseEntries,
|
||||
selectedIndex = autoResponse,
|
||||
onSelectedIndexChange = { newIndex ->
|
||||
val doIt = {
|
||||
autoResponse = newIndex
|
||||
Config.suAutoResponse = newIndex
|
||||
}
|
||||
if (Config.suAuth) viewModel.withAuth(doIt) else doIt()
|
||||
}
|
||||
)
|
||||
|
||||
// Request Timeout
|
||||
val timeoutEntries = remember {
|
||||
resources.getStringArray(CoreR.array.request_timeout).toList()
|
||||
}
|
||||
val timeoutValues = remember { listOf(10, 15, 20, 30, 45, 60) }
|
||||
var timeoutIndex by remember {
|
||||
mutableIntStateOf(timeoutValues.indexOf(Config.suDefaultTimeout).coerceAtLeast(0))
|
||||
}
|
||||
SuperDropdown(
|
||||
title = stringResource(CoreR.string.request_timeout),
|
||||
items = timeoutEntries,
|
||||
selectedIndex = timeoutIndex,
|
||||
onSelectedIndexChange = {
|
||||
timeoutIndex = it
|
||||
Config.suDefaultTimeout = timeoutValues[it]
|
||||
}
|
||||
)
|
||||
|
||||
// SU Notification
|
||||
val notifEntries = remember {
|
||||
resources.getStringArray(CoreR.array.su_notification).toList()
|
||||
}
|
||||
var suNotification by remember { mutableIntStateOf(Config.suNotification) }
|
||||
SuperDropdown(
|
||||
title = stringResource(CoreR.string.superuser_notification),
|
||||
items = notifEntries,
|
||||
selectedIndex = suNotification,
|
||||
onSelectedIndexChange = {
|
||||
suNotification = it
|
||||
Config.suNotification = it
|
||||
}
|
||||
)
|
||||
|
||||
// Reauthenticate (SDK < O)
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
|
||||
var reAuth by remember { mutableStateOf(Config.suReAuth) }
|
||||
SuperSwitch(
|
||||
title = stringResource(CoreR.string.settings_su_reauth_title),
|
||||
summary = stringResource(CoreR.string.settings_su_reauth_summary),
|
||||
checked = reAuth,
|
||||
onCheckedChange = {
|
||||
reAuth = it
|
||||
Config.suReAuth = it
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// Restrict (version >= 30.1)
|
||||
if (Const.Version.atLeast_30_1()) {
|
||||
var restrict by remember { mutableStateOf(Config.suRestrict) }
|
||||
SuperSwitch(
|
||||
title = stringResource(CoreR.string.settings_su_restrict_title),
|
||||
summary = stringResource(CoreR.string.settings_su_restrict_summary),
|
||||
checked = restrict,
|
||||
onCheckedChange = {
|
||||
restrict = it
|
||||
Config.suRestrict = it
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Dialogs ---
|
||||
|
||||
@Composable
|
||||
private fun UpdateChannelUrlDialog(show: Boolean, onDismiss: () -> Unit) {
|
||||
val showState = rememberSaveable { mutableStateOf(show) }
|
||||
showState.value = show
|
||||
var url by rememberSaveable { mutableStateOf(Config.customChannelUrl) }
|
||||
|
||||
SuperDialog(
|
||||
show = showState,
|
||||
onDismissRequest = onDismiss,
|
||||
insideMargin = DpSize(24.dp, 24.dp)
|
||||
) {
|
||||
Column(modifier = Modifier.padding(top = 8.dp)) {
|
||||
TextField(
|
||||
value = url,
|
||||
onValueChange = { url = it },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
label = stringResource(CoreR.string.settings_update_custom_msg)
|
||||
)
|
||||
Spacer(Modifier.height(16.dp))
|
||||
TextButton(
|
||||
text = stringResource(android.R.string.ok),
|
||||
onClick = {
|
||||
Config.customChannelUrl = url
|
||||
Info.resetUpdate()
|
||||
onDismiss()
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DownloadPathDialog(show: Boolean, onDismiss: () -> Unit) {
|
||||
val showState = rememberSaveable { mutableStateOf(show) }
|
||||
showState.value = show
|
||||
var path by rememberSaveable { mutableStateOf(Config.downloadDir) }
|
||||
|
||||
SuperDialog(
|
||||
show = showState,
|
||||
onDismissRequest = onDismiss,
|
||||
insideMargin = DpSize(24.dp, 24.dp)
|
||||
) {
|
||||
Column(modifier = Modifier.padding(top = 8.dp)) {
|
||||
top.yukonga.miuix.kmp.basic.Text(
|
||||
text = stringResource(CoreR.string.settings_download_path_message, MediaStoreUtils.fullPath(path)),
|
||||
modifier = Modifier.padding(bottom = 8.dp)
|
||||
)
|
||||
TextField(
|
||||
value = path,
|
||||
onValueChange = { path = it },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
label = stringResource(CoreR.string.settings_download_path_title)
|
||||
)
|
||||
Spacer(Modifier.height(16.dp))
|
||||
TextButton(
|
||||
text = stringResource(android.R.string.ok),
|
||||
onClick = {
|
||||
Config.downloadDir = path
|
||||
onDismiss()
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun HideAppDialog(show: Boolean, onDismiss: () -> Unit, onConfirm: (String) -> Unit) {
|
||||
val showState = rememberSaveable { mutableStateOf(show) }
|
||||
showState.value = show
|
||||
var appName by rememberSaveable { mutableStateOf("Settings") }
|
||||
val isError = appName.length > AppMigration.MAX_LABEL_LENGTH || appName.isBlank()
|
||||
|
||||
SuperDialog(
|
||||
show = showState,
|
||||
onDismissRequest = onDismiss,
|
||||
insideMargin = DpSize(24.dp, 24.dp)
|
||||
) {
|
||||
Column(modifier = Modifier.padding(top = 8.dp)) {
|
||||
TextField(
|
||||
value = appName,
|
||||
onValueChange = { appName = it },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
label = stringResource(CoreR.string.settings_app_name_hint)
|
||||
)
|
||||
Spacer(Modifier.height(16.dp))
|
||||
TextButton(
|
||||
text = stringResource(android.R.string.ok),
|
||||
onClick = { if (!isError) onConfirm(appName) },
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RestoreDialog(show: Boolean, onDismiss: () -> Unit, onConfirm: () -> Unit) {
|
||||
val showState = rememberSaveable { mutableStateOf(show) }
|
||||
showState.value = show
|
||||
|
||||
SuperDialog(
|
||||
show = showState,
|
||||
onDismissRequest = onDismiss,
|
||||
insideMargin = DpSize(24.dp, 24.dp)
|
||||
) {
|
||||
Column(modifier = Modifier.padding(top = 8.dp)) {
|
||||
top.yukonga.miuix.kmp.basic.Text(
|
||||
text = stringResource(CoreR.string.restore_app_confirmation)
|
||||
)
|
||||
Spacer(Modifier.height(16.dp))
|
||||
TextButton(
|
||||
text = stringResource(android.R.string.ok),
|
||||
onClick = onConfirm,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,132 +1,78 @@
|
||||
package com.topjohnwu.magisk.ui.settings
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.provider.Settings
|
||||
import android.view.View
|
||||
import android.widget.Toast
|
||||
import androidx.core.content.pm.ShortcutManagerCompat
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.topjohnwu.magisk.BR
|
||||
import com.topjohnwu.magisk.arch.BaseViewModel
|
||||
import com.topjohnwu.magisk.core.AppContext
|
||||
import com.topjohnwu.magisk.core.BuildConfig
|
||||
import com.topjohnwu.magisk.core.Config
|
||||
import com.topjohnwu.magisk.core.Const
|
||||
import com.topjohnwu.magisk.core.Info
|
||||
import com.topjohnwu.magisk.core.R
|
||||
import com.topjohnwu.magisk.core.isRunningAsStub
|
||||
import com.topjohnwu.magisk.core.ktx.activity
|
||||
import com.topjohnwu.magisk.core.ktx.toast
|
||||
import com.topjohnwu.magisk.core.tasks.AppMigration
|
||||
import com.topjohnwu.magisk.core.utils.LocaleSetting
|
||||
import com.topjohnwu.magisk.core.utils.RootUtils
|
||||
import com.topjohnwu.magisk.databinding.bindExtra
|
||||
import com.topjohnwu.magisk.events.AddHomeIconEvent
|
||||
import com.topjohnwu.magisk.events.AuthEvent
|
||||
import com.topjohnwu.magisk.events.SnackbarEvent
|
||||
import com.topjohnwu.superuser.Shell
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class SettingsViewModel : BaseViewModel(), BaseSettingsItem.Handler {
|
||||
class SettingsViewModel : BaseViewModel() {
|
||||
|
||||
val items = createItems()
|
||||
val extraBindings = bindExtra {
|
||||
it.put(BR.handler, this)
|
||||
private val _denyListEnabled = MutableStateFlow(Config.denyList)
|
||||
val denyListEnabled: StateFlow<Boolean> = _denyListEnabled.asStateFlow()
|
||||
|
||||
val zygiskMismatch get() = Config.zygisk != Info.isZygiskEnabled
|
||||
|
||||
fun navigateToTheme() {
|
||||
SettingsFragmentDirections.actionSettingsFragmentToThemeFragment().navigate()
|
||||
}
|
||||
|
||||
private fun createItems(): List<BaseSettingsItem> {
|
||||
val context = AppContext
|
||||
val hidden = context.packageName != BuildConfig.APP_PACKAGE_NAME
|
||||
|
||||
// Customization
|
||||
val list = mutableListOf(
|
||||
Customization,
|
||||
Theme, if (LocaleSetting.useLocaleManager) LanguageSystem else Language
|
||||
)
|
||||
if (isRunningAsStub && ShortcutManagerCompat.isRequestPinShortcutSupported(context))
|
||||
list.add(AddShortcut)
|
||||
|
||||
// Manager
|
||||
list.addAll(listOf(
|
||||
AppSettings,
|
||||
UpdateChannel, UpdateChannelUrl, DoHToggle, UpdateChecker, DownloadPath, RandNameToggle
|
||||
))
|
||||
if (Info.env.isActive && Const.USER_ID == 0) {
|
||||
if (hidden) list.add(Restore) else list.add(Hide)
|
||||
}
|
||||
|
||||
// Magisk
|
||||
if (Info.env.isActive) {
|
||||
list.addAll(listOf(
|
||||
Magisk,
|
||||
SystemlessHosts
|
||||
))
|
||||
if (Const.Version.atLeast_24_0()) {
|
||||
list.addAll(listOf(Zygisk, DenyList, DenyListConfig))
|
||||
}
|
||||
}
|
||||
|
||||
// Superuser
|
||||
if (Info.showSuperUser) {
|
||||
list.addAll(listOf(
|
||||
Superuser,
|
||||
Tapjack, Authentication, AccessMode, MultiuserMode, MountNamespaceMode,
|
||||
AutomaticResponse, RequestTimeout, SUNotification
|
||||
))
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
|
||||
// Re-authenticate is not feasible on 8.0+
|
||||
list.add(Reauthenticate)
|
||||
}
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
// Can hide overlay windows on 12.0+
|
||||
list.remove(Tapjack)
|
||||
}
|
||||
if (Const.Version.atLeast_30_1()) {
|
||||
list.add(Restrict)
|
||||
}
|
||||
}
|
||||
|
||||
return list
|
||||
fun navigateToDenyList() {
|
||||
SettingsFragmentDirections.actionSettingsFragmentToDenyFragment().navigate()
|
||||
}
|
||||
|
||||
override fun onItemPressed(view: View, item: BaseSettingsItem, doAction: () -> Unit) {
|
||||
when (item) {
|
||||
DownloadPath -> withExternalRW(doAction)
|
||||
UpdateChecker -> withPostNotificationPermission(doAction)
|
||||
Authentication -> AuthEvent(doAction).publish()
|
||||
AutomaticResponse -> if (Config.suAuth) AuthEvent(doAction).publish() else doAction()
|
||||
else -> doAction()
|
||||
}
|
||||
fun requestAddShortcut() {
|
||||
AddHomeIconEvent().publish()
|
||||
}
|
||||
|
||||
override fun onItemAction(view: View, item: BaseSettingsItem) {
|
||||
when (item) {
|
||||
Theme -> SettingsFragmentDirections.actionSettingsFragmentToThemeFragment().navigate()
|
||||
LanguageSystem -> view.activity.startActivity(LocaleSetting.localeSettingsIntent)
|
||||
AddShortcut -> AddHomeIconEvent().publish()
|
||||
SystemlessHosts -> createHosts()
|
||||
DenyListConfig -> SettingsFragmentDirections.actionSettingsFragmentToDenyFragment().navigate()
|
||||
UpdateChannel -> openUrlIfNecessary(view)
|
||||
is Hide -> viewModelScope.launch { AppMigration.hide(view.activity, item.value) }
|
||||
Restore -> viewModelScope.launch { AppMigration.restore(view.activity) }
|
||||
Zygisk -> if (Zygisk.mismatch) SnackbarEvent(R.string.reboot_apply_change).publish()
|
||||
else -> Unit
|
||||
}
|
||||
fun hideApp(activity: Activity, name: String) {
|
||||
viewModelScope.launch { AppMigration.hide(activity, name) }
|
||||
}
|
||||
|
||||
private fun openUrlIfNecessary(view: View) {
|
||||
UpdateChannelUrl.refresh()
|
||||
if (UpdateChannelUrl.isEnabled && UpdateChannelUrl.value.isBlank()) {
|
||||
UpdateChannelUrl.onPressed(view, this)
|
||||
}
|
||||
fun restoreApp(activity: Activity) {
|
||||
viewModelScope.launch { AppMigration.restore(activity) }
|
||||
}
|
||||
|
||||
private fun createHosts() {
|
||||
fun createHosts() {
|
||||
viewModelScope.launch {
|
||||
RootUtils.addSystemlessHosts()
|
||||
AppContext.toast(R.string.settings_hosts_toast, Toast.LENGTH_SHORT)
|
||||
}
|
||||
}
|
||||
|
||||
fun toggleDenyList(enabled: Boolean) {
|
||||
_denyListEnabled.value = enabled
|
||||
val cmd = if (enabled) "enable" else "disable"
|
||||
Shell.cmd("magisk --denylist $cmd").submit { result ->
|
||||
if (result.isSuccess) {
|
||||
Config.denyList = enabled
|
||||
} else {
|
||||
_denyListEnabled.value = !enabled
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun withDownloadPathPermission(action: () -> Unit) = withExternalRW(action)
|
||||
|
||||
fun withNotificationPermission(action: () -> Unit) = withPostNotificationPermission(action)
|
||||
|
||||
fun withAuth(action: () -> Unit) = AuthEvent(action).publish()
|
||||
|
||||
fun notifyZygiskChange() {
|
||||
if (zygiskMismatch) SnackbarEvent(R.string.reboot_apply_change).publish()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,52 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<layout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
xmlns:tools="http://schemas.android.com/tools">
|
||||
|
||||
<data>
|
||||
|
||||
<variable
|
||||
name="data"
|
||||
type="com.topjohnwu.magisk.ui.settings.Hide" />
|
||||
|
||||
</data>
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:padding="@dimen/margin_generic">
|
||||
|
||||
<com.google.android.material.textfield.TextInputLayout
|
||||
style="@style/Widget.MaterialComponents.TextInputLayout.OutlinedBox.Dense"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="@dimen/margin_generic"
|
||||
android:hint="@string/settings_app_name_hint"
|
||||
app:boxStrokeColor="?colorOnSurfaceVariant"
|
||||
app:counterEnabled="true"
|
||||
app:counterMaxLength="@{data.maxLength}"
|
||||
app:counterOverflowTextColor="?colorError"
|
||||
app:error="@{data.error ? @string/settings_app_name_error : @string/empty}"
|
||||
app:errorEnabled="true"
|
||||
app:errorTextColor="?colorError"
|
||||
app:helperText="@string/settings_app_name_helper"
|
||||
app:hintEnabled="true"
|
||||
app:hintTextAppearance="@style/AppearanceFoundation.Tiny"
|
||||
app:hintTextColor="?colorOnSurfaceVariant">
|
||||
|
||||
<com.google.android.material.textfield.TextInputEditText
|
||||
android:id="@+id/dialog_custom_download_text"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:inputType="textCapWords"
|
||||
android:text="@={data.result}"
|
||||
android:textAppearance="@style/AppearanceFoundation.Body"
|
||||
android:textColor="?colorOnSurface"
|
||||
tools:text="@tools:sample/lorem" />
|
||||
|
||||
</com.google.android.material.textfield.TextInputLayout>
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
</layout>
|
||||
@@ -1,54 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<layout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
xmlns:tools="http://schemas.android.com/tools">
|
||||
|
||||
<data>
|
||||
|
||||
<variable
|
||||
name="data"
|
||||
type="com.topjohnwu.magisk.ui.settings.DownloadPath" />
|
||||
|
||||
</data>
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:padding="@dimen/margin_generic">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/dialog_custom_download_title"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@{@string/settings_download_path_message(data.path)}"
|
||||
android:textAppearance="@style/AppearanceFoundation.Caption"
|
||||
tools:text="@string/settings_download_path_message" />
|
||||
|
||||
<com.google.android.material.textfield.TextInputLayout
|
||||
style="@style/Widget.MaterialComponents.TextInputLayout.OutlinedBox.Dense"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="@dimen/margin_generic"
|
||||
android:hint="@string/settings_download_path_title"
|
||||
app:boxStrokeColor="?colorOnSurfaceVariant"
|
||||
app:errorTextColor="?colorError"
|
||||
app:hintEnabled="true"
|
||||
app:hintTextAppearance="@style/AppearanceFoundation.Tiny"
|
||||
app:hintTextColor="?colorOnSurfaceVariant">
|
||||
|
||||
<com.google.android.material.textfield.TextInputEditText
|
||||
android:id="@+id/dialog_custom_download_text"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:inputType="textUri"
|
||||
android:text="@={data.inputResult}"
|
||||
android:textAppearance="@style/AppearanceFoundation.Body"
|
||||
android:textColor="?colorOnSurface"
|
||||
tools:text="@tools:sample/lorem" />
|
||||
|
||||
</com.google.android.material.textfield.TextInputLayout>
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
</layout>
|
||||
@@ -1,46 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<layout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
xmlns:tools="http://schemas.android.com/tools">
|
||||
|
||||
<data>
|
||||
|
||||
<variable
|
||||
name="data"
|
||||
type="com.topjohnwu.magisk.ui.settings.UpdateChannelUrl" />
|
||||
|
||||
</data>
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:padding="@dimen/margin_generic">
|
||||
|
||||
<com.google.android.material.textfield.TextInputLayout
|
||||
style="@style/Widget.MaterialComponents.TextInputLayout.OutlinedBox.Dense"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="@dimen/margin_generic"
|
||||
android:hint="@string/settings_update_custom_msg"
|
||||
app:boxStrokeColor="?colorOnSurfaceVariant"
|
||||
app:errorTextColor="?colorError"
|
||||
app:hintEnabled="true"
|
||||
app:hintTextAppearance="@style/AppearanceFoundation.Tiny"
|
||||
app:hintTextColor="?colorOnSurfaceVariant">
|
||||
|
||||
<com.google.android.material.textfield.TextInputEditText
|
||||
android:id="@+id/dialog_custom_download_text"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:inputType="textUri"
|
||||
android:text="@={data.inputResult}"
|
||||
android:textAppearance="@style/AppearanceFoundation.Body"
|
||||
android:textColor="?colorOnSurface"
|
||||
tools:text="@tools:sample/lorem" />
|
||||
|
||||
</com.google.android.material.textfield.TextInputLayout>
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
</layout>
|
||||
@@ -1,41 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<layout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
xmlns:tools="http://schemas.android.com/tools">
|
||||
|
||||
<data>
|
||||
|
||||
<variable
|
||||
name="viewModel"
|
||||
type="com.topjohnwu.magisk.ui.settings.SettingsViewModel" />
|
||||
|
||||
</data>
|
||||
|
||||
<FrameLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent">
|
||||
|
||||
<androidx.recyclerview.widget.RecyclerView
|
||||
app:items="@{viewModel.items}"
|
||||
app:extraBindings="@{viewModel.extraBindings}"
|
||||
android:id="@+id/settings_list"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:focusableInTouchMode="false"
|
||||
android:clipToPadding="false"
|
||||
android:orientation="vertical"
|
||||
android:paddingTop="@dimen/internal_action_bar_size"
|
||||
app:fitsSystemWindowsInsets="top|bottom"
|
||||
app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"
|
||||
tools:layout_marginTop="24dp"
|
||||
tools:listitem="@layout/item_settings"
|
||||
tools:paddingTop="@dimen/l1" />
|
||||
|
||||
<androidx.coordinatorlayout.widget.CoordinatorLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:id="@+id/snackbar_container"
|
||||
app:fitsSystemWindowsInsets="top|bottom"/>
|
||||
</FrameLayout>
|
||||
|
||||
</layout>
|
||||
@@ -1,90 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<layout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
xmlns:tools="http://schemas.android.com/tools">
|
||||
|
||||
<data>
|
||||
|
||||
<variable
|
||||
name="item"
|
||||
type="com.topjohnwu.magisk.ui.settings.BaseSettingsItem" />
|
||||
|
||||
<variable
|
||||
name="handler"
|
||||
type="com.topjohnwu.magisk.ui.settings.BaseSettingsItem.Handler" />
|
||||
|
||||
</data>
|
||||
|
||||
<com.google.android.material.card.MaterialCardView
|
||||
style="@style/WidgetFoundation.Card"
|
||||
isEnabled="@{item.enabled}"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:alpha="@{item.enabled ? 1f : .5f}"
|
||||
android:clickable="@{item.enabled}"
|
||||
android:focusable="@{item.enabled}"
|
||||
android:onClick="@{(view) -> item.onPressed(view, handler)}"
|
||||
tools:layout_gravity="center">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:gravity="center_vertical"
|
||||
android:orientation="horizontal"
|
||||
android:paddingStart="@{item.icon == 0 ? @dimen/l1 : 0}"
|
||||
android:paddingEnd="@dimen/l1">
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/icon"
|
||||
style="@style/WidgetFoundation.Icon"
|
||||
gone="@{item.icon == 0}"
|
||||
android:background="@null"
|
||||
app:srcCompat="@{item.icon}"
|
||||
tools:srcCompat="@drawable/ic_fingerprint" />
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:gravity="center_vertical"
|
||||
android:orientation="vertical"
|
||||
android:paddingTop="@dimen/l1"
|
||||
android:paddingBottom="@dimen/l1">
|
||||
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="center"
|
||||
android:gravity="start"
|
||||
android:text="@{item.title}"
|
||||
android:textAppearance="@style/AppearanceFoundation.Body"
|
||||
android:textStyle="bold"
|
||||
tools:lines="1"
|
||||
tools:text="@tools:sample/lorem/random" />
|
||||
|
||||
<TextView
|
||||
gone="@{item.description.empty}"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@{item.description}"
|
||||
android:textAppearance="@style/AppearanceFoundation.Tiny.Variant"
|
||||
tools:lines="2"
|
||||
tools:text="@tools:sample/lorem/random" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
<com.google.android.material.switchmaterial.SwitchMaterial
|
||||
android:id="@+id/selector_indicator"
|
||||
goneUnless="@{item.showSwitch}"
|
||||
isEnabled="@{item.enabled}"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:checked="@{item.checked}"
|
||||
android:focusable="@{item.enabled}"
|
||||
android:onCheckedChanged="@{(v, c) -> item.onToggle(v, handler, c)}" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
</com.google.android.material.card.MaterialCardView>
|
||||
|
||||
</layout>
|
||||
@@ -1,39 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<layout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools">
|
||||
|
||||
<data>
|
||||
|
||||
<variable
|
||||
name="item"
|
||||
type="com.topjohnwu.magisk.ui.settings.BaseSettingsItem" />
|
||||
|
||||
</data>
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:paddingTop="@dimen/l1"
|
||||
android:paddingBottom="@dimen/l_50">
|
||||
|
||||
<TextView
|
||||
gone="@{item.title.empty}"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@{item.title}"
|
||||
android:textAppearance="@style/AppearanceFoundation.Large.Secondary"
|
||||
android:textStyle="bold"
|
||||
tools:text="@tools:sample/lorem" />
|
||||
|
||||
<TextView
|
||||
gone="@{item.description.empty}"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@{item.description}"
|
||||
android:textAppearance="@style/AppearanceFoundation.Tiny.Bold"
|
||||
tools:text="@tools:sample/lorem/random" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
</layout>
|
||||
Reference in New Issue
Block a user