mirror of
https://github.com/topjohnwu/Magisk.git
synced 2026-03-14 05:57:05 -07:00
Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1d4fbd9e75 | ||
|
|
7845da2943 | ||
|
|
6bb48df712 |
4
.gitignore
vendored
4
.gitignore
vendored
@@ -12,7 +12,3 @@ native/out
|
||||
# Android Studio
|
||||
*.iml
|
||||
.idea
|
||||
.cursor
|
||||
ramdisk.img
|
||||
app/core/src/debug
|
||||
app/core/src/release
|
||||
|
||||
@@ -1,27 +1,30 @@
|
||||
plugins {
|
||||
id("com.android.application")
|
||||
kotlin("plugin.parcelize")
|
||||
kotlin("plugin.compose")
|
||||
kotlin("plugin.serialization")
|
||||
id("com.android.legacy-kapt")
|
||||
id("androidx.navigation.safeargs.kotlin")
|
||||
}
|
||||
|
||||
setupMainApk()
|
||||
|
||||
kapt {
|
||||
correctErrorTypes = true
|
||||
useBuildCache = true
|
||||
mapDiagnosticLocations = true
|
||||
javacOptions {
|
||||
option("-Xmaxerrs", "1000")
|
||||
}
|
||||
}
|
||||
|
||||
android {
|
||||
buildFeatures {
|
||||
compose = true
|
||||
dataBinding = true
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
isCoreLibraryDesugaringEnabled = true
|
||||
}
|
||||
|
||||
packaging {
|
||||
jniLibs {
|
||||
excludes += "lib/*/libandroidx.graphics.path.so"
|
||||
}
|
||||
}
|
||||
|
||||
defaultConfig {
|
||||
proguardFile("proguard-rules.pro")
|
||||
}
|
||||
@@ -38,24 +41,22 @@ dependencies {
|
||||
implementation(project(":core"))
|
||||
coreLibraryDesugaring(libs.jdk.libs)
|
||||
|
||||
implementation(libs.indeterminate.checkbox)
|
||||
implementation(libs.rikka.layoutinflater)
|
||||
implementation(libs.rikka.insets)
|
||||
implementation(libs.rikka.recyclerview)
|
||||
|
||||
implementation(libs.navigation.fragment.ktx)
|
||||
implementation(libs.navigation.ui.ktx)
|
||||
|
||||
implementation(libs.constraintlayout)
|
||||
implementation(libs.swiperefreshlayout)
|
||||
implementation(libs.recyclerview)
|
||||
implementation(libs.transition)
|
||||
implementation(libs.fragment.ktx)
|
||||
implementation(libs.appcompat)
|
||||
implementation(libs.material)
|
||||
|
||||
// Compose
|
||||
implementation(platform(libs.compose.bom))
|
||||
implementation(libs.compose.ui)
|
||||
implementation(libs.compose.ui.tooling.preview)
|
||||
debugImplementation(libs.compose.ui.tooling)
|
||||
implementation(libs.activity.compose)
|
||||
implementation(libs.lifecycle.runtime.compose)
|
||||
implementation(libs.lifecycle.viewmodel.compose)
|
||||
implementation(libs.miuix)
|
||||
implementation(libs.miuix.icons)
|
||||
implementation(libs.miuix.navigation3.ui)
|
||||
|
||||
// Navigation3
|
||||
implementation(libs.navigation3.runtime)
|
||||
implementation(libs.navigationevent.compose)
|
||||
implementation(libs.lifecycle.viewmodel.navigation3)
|
||||
|
||||
// Make sure kapt runs with a proper kotlin-stdlib
|
||||
kapt(kotlin("stdlib"))
|
||||
}
|
||||
|
||||
@@ -6,7 +6,6 @@
|
||||
<activity
|
||||
android:name=".ui.MainActivity"
|
||||
android:exported="true"
|
||||
android:windowSoftInputMode="adjustResize"
|
||||
android:theme="@style/SplashTheme">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
|
||||
@@ -12,16 +12,11 @@ abstract class AsyncLoadViewModel : BaseViewModel() {
|
||||
@MainThread
|
||||
fun startLoading() {
|
||||
if (loadingJob?.isActive == true) {
|
||||
// Prevent multiple jobs from running at the same time
|
||||
return
|
||||
}
|
||||
loadingJob = viewModelScope.launch { doLoadWork() }
|
||||
}
|
||||
|
||||
@MainThread
|
||||
fun reload() {
|
||||
loadingJob?.cancel()
|
||||
loadingJob = viewModelScope.launch { doLoadWork() }
|
||||
}
|
||||
|
||||
protected abstract suspend fun doLoadWork()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
package com.topjohnwu.magisk.arch
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.KeyEvent
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.core.view.MenuProvider
|
||||
import androidx.databinding.DataBindingUtil
|
||||
import androidx.databinding.OnRebindCallback
|
||||
import androidx.databinding.ViewDataBinding
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.navigation.NavDirections
|
||||
import com.topjohnwu.magisk.BR
|
||||
|
||||
abstract class BaseFragment<Binding : ViewDataBinding> : Fragment(), ViewModelHolder {
|
||||
|
||||
val activity get() = getActivity() as? NavigationActivity<*>
|
||||
protected lateinit var binding: Binding
|
||||
protected abstract val layoutRes: Int
|
||||
|
||||
private val navigation get() = activity?.navigation
|
||||
open val snackbarView: View? get() = null
|
||||
open val snackbarAnchorView: View? get() = null
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
startObserveLiveData()
|
||||
}
|
||||
|
||||
override fun onCreateView(
|
||||
inflater: LayoutInflater,
|
||||
container: ViewGroup?,
|
||||
savedInstanceState: Bundle?
|
||||
): View? {
|
||||
binding = DataBindingUtil.inflate<Binding>(inflater, layoutRes, container, false).also {
|
||||
it.setVariable(BR.viewModel, viewModel)
|
||||
it.lifecycleOwner = viewLifecycleOwner
|
||||
}
|
||||
if (this is MenuProvider) {
|
||||
activity?.addMenuProvider(this, viewLifecycleOwner, Lifecycle.State.STARTED)
|
||||
}
|
||||
savedInstanceState?.let { viewModel.onRestoreState(it) }
|
||||
return binding.root
|
||||
}
|
||||
|
||||
override fun onSaveInstanceState(outState: Bundle) {
|
||||
viewModel.onSaveState(outState)
|
||||
}
|
||||
|
||||
override fun onStart() {
|
||||
super.onStart()
|
||||
activity?.supportActionBar?.subtitle = null
|
||||
}
|
||||
|
||||
override fun onEventDispatched(event: ViewEvent) = when(event) {
|
||||
is ContextExecutor -> event(requireContext())
|
||||
is ActivityExecutor -> activity?.let { event(it) } ?: Unit
|
||||
is FragmentExecutor -> event(this)
|
||||
else -> Unit
|
||||
}
|
||||
|
||||
open fun onKeyEvent(event: KeyEvent): Boolean {
|
||||
return false
|
||||
}
|
||||
|
||||
open fun onBackPressed(): Boolean = false
|
||||
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
binding.addOnRebindCallback(object : OnRebindCallback<Binding>() {
|
||||
override fun onPreBind(binding: Binding): Boolean {
|
||||
this@BaseFragment.onPreBind(binding)
|
||||
return true
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
viewModel.let {
|
||||
if (it is AsyncLoadViewModel)
|
||||
it.startLoading()
|
||||
}
|
||||
}
|
||||
|
||||
protected open fun onPreBind(binding: Binding) {
|
||||
(binding.root as? ViewGroup)?.startAnimations()
|
||||
}
|
||||
|
||||
fun NavDirections.navigate() {
|
||||
navigation?.currentDestination?.getAction(actionId)?.let { navigation!!.navigate(this) }
|
||||
}
|
||||
}
|
||||
@@ -1,32 +1,83 @@
|
||||
package com.topjohnwu.magisk.arch
|
||||
|
||||
import android.Manifest.permission.POST_NOTIFICATIONS
|
||||
import android.Manifest.permission.REQUEST_INSTALL_PACKAGES
|
||||
import android.Manifest.permission.WRITE_EXTERNAL_STORAGE
|
||||
import android.annotation.SuppressLint
|
||||
import android.os.Bundle
|
||||
import android.widget.Toast
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.databinding.PropertyChangeRegistry
|
||||
import androidx.lifecycle.LiveData
|
||||
import androidx.lifecycle.MutableLiveData
|
||||
import androidx.lifecycle.ViewModel
|
||||
import com.topjohnwu.magisk.core.AppContext
|
||||
import com.topjohnwu.magisk.core.ktx.toast
|
||||
import com.topjohnwu.magisk.ui.navigation.Route
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.SharedFlow
|
||||
import androidx.navigation.NavDirections
|
||||
import com.topjohnwu.magisk.core.R
|
||||
import com.topjohnwu.magisk.databinding.ObservableHost
|
||||
import com.topjohnwu.magisk.events.BackPressEvent
|
||||
import com.topjohnwu.magisk.events.DialogBuilder
|
||||
import com.topjohnwu.magisk.events.DialogEvent
|
||||
import com.topjohnwu.magisk.events.NavigationEvent
|
||||
import com.topjohnwu.magisk.events.PermissionEvent
|
||||
import com.topjohnwu.magisk.events.SnackbarEvent
|
||||
|
||||
abstract class BaseViewModel : ViewModel() {
|
||||
abstract class BaseViewModel : ViewModel(), ObservableHost {
|
||||
|
||||
private val _navEvents = MutableSharedFlow<Route>(extraBufferCapacity = 1)
|
||||
val navEvents: SharedFlow<Route> = _navEvents
|
||||
override var callbacks: PropertyChangeRegistry? = null
|
||||
|
||||
private val _viewEvents = MutableLiveData<ViewEvent>()
|
||||
val viewEvents: LiveData<ViewEvent> get() = _viewEvents
|
||||
|
||||
open fun onSaveState(state: Bundle) {}
|
||||
open fun onRestoreState(state: Bundle) {}
|
||||
open fun onNetworkChanged(network: Boolean) {}
|
||||
|
||||
fun showSnackbar(@StringRes resId: Int) {
|
||||
AppContext.toast(resId, Toast.LENGTH_SHORT)
|
||||
fun withPermission(permission: String, callback: (Boolean) -> Unit) {
|
||||
PermissionEvent(permission, callback).publish()
|
||||
}
|
||||
|
||||
fun showSnackbar(msg: String) {
|
||||
AppContext.toast(msg, Toast.LENGTH_SHORT)
|
||||
inline fun withExternalRW(crossinline callback: () -> Unit) {
|
||||
withPermission(WRITE_EXTERNAL_STORAGE) {
|
||||
if (!it) {
|
||||
SnackbarEvent(R.string.external_rw_permission_denied).publish()
|
||||
} else {
|
||||
callback()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun navigateTo(route: Route) {
|
||||
_navEvents.tryEmit(route)
|
||||
@SuppressLint("InlinedApi")
|
||||
inline fun withInstallPermission(crossinline callback: () -> Unit) {
|
||||
withPermission(REQUEST_INSTALL_PACKAGES) {
|
||||
if (!it) {
|
||||
SnackbarEvent(R.string.install_unknown_denied).publish()
|
||||
} else {
|
||||
callback()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressLint("InlinedApi")
|
||||
inline fun withPostNotificationPermission(crossinline callback: () -> Unit) {
|
||||
withPermission(POST_NOTIFICATIONS) {
|
||||
if (!it) {
|
||||
SnackbarEvent(R.string.post_notifications_denied).publish()
|
||||
} else {
|
||||
callback()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun back() = BackPressEvent().publish()
|
||||
|
||||
fun ViewEvent.publish() {
|
||||
_viewEvents.postValue(this)
|
||||
}
|
||||
|
||||
fun DialogBuilder.show() {
|
||||
DialogEvent(this).publish()
|
||||
}
|
||||
|
||||
fun NavDirections.navigate(pop: Boolean = false) {
|
||||
_viewEvents.postValue(NavigationEvent(this, pop))
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
package com.topjohnwu.magisk.arch
|
||||
|
||||
import android.content.ContentResolver
|
||||
import android.view.KeyEvent
|
||||
import androidx.databinding.ViewDataBinding
|
||||
import androidx.navigation.NavController
|
||||
import androidx.navigation.NavDirections
|
||||
import androidx.navigation.fragment.NavHostFragment
|
||||
import androidx.navigation.navOptions
|
||||
import com.topjohnwu.magisk.utils.AccessibilityUtils
|
||||
|
||||
abstract class NavigationActivity<Binding : ViewDataBinding> : UIActivity<Binding>() {
|
||||
|
||||
abstract val navHostId: Int
|
||||
|
||||
private val navHostFragment by lazy {
|
||||
supportFragmentManager.findFragmentById(navHostId) as NavHostFragment
|
||||
}
|
||||
|
||||
protected val currentFragment get() =
|
||||
navHostFragment.childFragmentManager.fragments.getOrNull(0) as? BaseFragment<*>
|
||||
|
||||
val navigation: NavController get() = navHostFragment.navController
|
||||
|
||||
override fun dispatchKeyEvent(event: KeyEvent): Boolean {
|
||||
return if (binded && currentFragment?.onKeyEvent(event) == true) true else super.dispatchKeyEvent(event)
|
||||
}
|
||||
|
||||
override fun onBackPressed() {
|
||||
if (binded) {
|
||||
if (currentFragment?.onBackPressed() == false) {
|
||||
super.onBackPressed()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun navigate(directions: NavDirections, navigation: NavController, cr: ContentResolver) {
|
||||
if (AccessibilityUtils.isAnimationEnabled(cr)) {
|
||||
navigation.navigate(directions)
|
||||
} else {
|
||||
navigation.navigate(directions, navOptions {})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun NavDirections.navigate() {
|
||||
navigate(this, navigation, contentResolver)
|
||||
}
|
||||
}
|
||||
141
app/apk/src/main/java/com/topjohnwu/magisk/arch/UIActivity.kt
Normal file
141
app/apk/src/main/java/com/topjohnwu/magisk/arch/UIActivity.kt
Normal file
@@ -0,0 +1,141 @@
|
||||
package com.topjohnwu.magisk.arch
|
||||
|
||||
import android.content.Context
|
||||
import android.content.res.Resources
|
||||
import android.graphics.Color
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.appcompat.app.AppCompatDelegate
|
||||
import androidx.core.content.res.use
|
||||
import androidx.core.view.WindowCompat
|
||||
import androidx.databinding.DataBindingUtil
|
||||
import androidx.databinding.ViewDataBinding
|
||||
import androidx.interpolator.view.animation.FastOutSlowInInterpolator
|
||||
import androidx.transition.AutoTransition
|
||||
import androidx.transition.TransitionManager
|
||||
import com.google.android.material.snackbar.Snackbar
|
||||
import com.topjohnwu.magisk.BR
|
||||
import com.topjohnwu.magisk.R
|
||||
import com.topjohnwu.magisk.core.Config
|
||||
import com.topjohnwu.magisk.core.base.ActivityExtension
|
||||
import com.topjohnwu.magisk.core.base.IActivityExtension
|
||||
import com.topjohnwu.magisk.core.isRunningAsStub
|
||||
import com.topjohnwu.magisk.core.ktx.reflectField
|
||||
import com.topjohnwu.magisk.core.wrap
|
||||
import rikka.insets.WindowInsetsHelper
|
||||
import rikka.layoutinflater.view.LayoutInflaterFactory
|
||||
|
||||
abstract class UIActivity<Binding : ViewDataBinding>
|
||||
: AppCompatActivity(), ViewModelHolder, IActivityExtension {
|
||||
|
||||
protected lateinit var binding: Binding
|
||||
protected abstract val layoutRes: Int
|
||||
override val extension = ActivityExtension(this)
|
||||
|
||||
protected val binded get() = ::binding.isInitialized
|
||||
|
||||
open val snackbarView get() = binding.root
|
||||
open val snackbarAnchorView: View? get() = null
|
||||
|
||||
init {
|
||||
AppCompatDelegate.setDefaultNightMode(Config.darkTheme)
|
||||
}
|
||||
|
||||
override fun attachBaseContext(base: Context) {
|
||||
super.attachBaseContext(base.wrap())
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
layoutInflater.factory2 = LayoutInflaterFactory(delegate)
|
||||
.addOnViewCreatedListener(WindowInsetsHelper.LISTENER)
|
||||
|
||||
extension.onCreate(savedInstanceState)
|
||||
if (isRunningAsStub) {
|
||||
// Overwrite private members to avoid nasty "false" stack traces being logged
|
||||
val delegate = delegate
|
||||
val clz = delegate.javaClass
|
||||
clz.reflectField("mActivityHandlesConfigFlagsChecked").set(delegate, true)
|
||||
clz.reflectField("mActivityHandlesConfigFlags").set(delegate, 0)
|
||||
}
|
||||
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
startObserveLiveData()
|
||||
|
||||
// We need to set the window background explicitly since for whatever reason it's not
|
||||
// propagated upstream
|
||||
obtainStyledAttributes(intArrayOf(android.R.attr.windowBackground))
|
||||
.use { it.getDrawable(0) }
|
||||
.also { window.setBackgroundDrawable(it) }
|
||||
|
||||
WindowCompat.setDecorFitsSystemWindows(window, false)
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
window?.decorView?.post {
|
||||
// If navigation bar is short enough (gesture navigation enabled), make it transparent
|
||||
if ((window.decorView.rootWindowInsets?.systemWindowInsetBottom
|
||||
?: 0) < Resources.getSystem().displayMetrics.density * 40) {
|
||||
window.navigationBarColor = Color.TRANSPARENT
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
|
||||
window.navigationBarDividerColor = Color.TRANSPARENT
|
||||
}
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
window.isNavigationBarContrastEnforced = false
|
||||
window.isStatusBarContrastEnforced = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onSaveInstanceState(outState: Bundle) {
|
||||
super.onSaveInstanceState(outState)
|
||||
extension.onSaveInstanceState(outState)
|
||||
}
|
||||
|
||||
fun setContentView() {
|
||||
binding = DataBindingUtil.setContentView<Binding>(this, layoutRes).also {
|
||||
it.setVariable(BR.viewModel, viewModel)
|
||||
it.lifecycleOwner = this
|
||||
}
|
||||
}
|
||||
|
||||
fun setAccessibilityDelegate(delegate: View.AccessibilityDelegate?) {
|
||||
binding.root.rootView.accessibilityDelegate = delegate
|
||||
}
|
||||
|
||||
fun showSnackbar(
|
||||
message: CharSequence,
|
||||
length: Int = Snackbar.LENGTH_SHORT,
|
||||
builder: Snackbar.() -> Unit = {}
|
||||
) = Snackbar.make(snackbarView, message, length)
|
||||
.setAnchorView(snackbarAnchorView).apply(builder).show()
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
viewModel.let {
|
||||
if (it is AsyncLoadViewModel)
|
||||
it.startLoading()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onEventDispatched(event: ViewEvent) = when (event) {
|
||||
is ContextExecutor -> event(this)
|
||||
is ActivityExecutor -> event(this)
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
|
||||
fun ViewGroup.startAnimations() {
|
||||
val transition = AutoTransition()
|
||||
.setInterpolator(FastOutSlowInInterpolator())
|
||||
.setDuration(400)
|
||||
.excludeTarget(R.id.main_toolbar, true)
|
||||
TransitionManager.beginDelayedTransition(
|
||||
this,
|
||||
transition
|
||||
)
|
||||
}
|
||||
21
app/apk/src/main/java/com/topjohnwu/magisk/arch/ViewEvent.kt
Normal file
21
app/apk/src/main/java/com/topjohnwu/magisk/arch/ViewEvent.kt
Normal file
@@ -0,0 +1,21 @@
|
||||
package com.topjohnwu.magisk.arch
|
||||
|
||||
import android.content.Context
|
||||
|
||||
/**
|
||||
* Class for passing events from ViewModels to Activities/Fragments
|
||||
* (see https://medium.com/google-developers/livedata-with-snackbar-navigation-and-other-events-the-singleliveevent-case-ac2622673150)
|
||||
*/
|
||||
abstract class ViewEvent
|
||||
|
||||
interface ContextExecutor {
|
||||
operator fun invoke(context: Context)
|
||||
}
|
||||
|
||||
interface ActivityExecutor {
|
||||
operator fun invoke(activity: UIActivity<*>)
|
||||
}
|
||||
|
||||
interface FragmentExecutor {
|
||||
operator fun invoke(fragment: BaseFragment<*>)
|
||||
}
|
||||
@@ -1,7 +1,10 @@
|
||||
package com.topjohnwu.magisk.arch
|
||||
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import androidx.lifecycle.ViewModelStoreOwner
|
||||
import com.topjohnwu.magisk.core.Info
|
||||
import com.topjohnwu.magisk.core.di.ServiceLocator
|
||||
import com.topjohnwu.magisk.ui.home.HomeViewModel
|
||||
import com.topjohnwu.magisk.ui.install.InstallViewModel
|
||||
@@ -9,6 +12,21 @@ import com.topjohnwu.magisk.ui.log.LogViewModel
|
||||
import com.topjohnwu.magisk.ui.superuser.SuperuserViewModel
|
||||
import com.topjohnwu.magisk.ui.surequest.SuRequestViewModel
|
||||
|
||||
interface ViewModelHolder : LifecycleOwner, ViewModelStoreOwner {
|
||||
|
||||
val viewModel: BaseViewModel
|
||||
|
||||
fun startObserveLiveData() {
|
||||
viewModel.viewEvents.observe(this, this::onEventDispatched)
|
||||
Info.isConnected.observe(this, viewModel::onNetworkChanged)
|
||||
}
|
||||
|
||||
/**
|
||||
* Called for all [ViewEvent]s published by associated viewModel.
|
||||
*/
|
||||
fun onEventDispatched(event: ViewEvent) {}
|
||||
}
|
||||
|
||||
object VMFactory : ViewModelProvider.Factory {
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
override fun <T : ViewModel> create(modelClass: Class<T>): T {
|
||||
@@ -17,10 +35,15 @@ object VMFactory : ViewModelProvider.Factory {
|
||||
LogViewModel::class.java -> LogViewModel(ServiceLocator.logRepo)
|
||||
SuperuserViewModel::class.java -> SuperuserViewModel(ServiceLocator.policyDB)
|
||||
InstallViewModel::class.java ->
|
||||
InstallViewModel(ServiceLocator.networkService)
|
||||
InstallViewModel(ServiceLocator.networkService, ServiceLocator.markwon)
|
||||
SuRequestViewModel::class.java ->
|
||||
SuRequestViewModel(ServiceLocator.policyDB, ServiceLocator.timeoutPrefs)
|
||||
else -> modelClass.newInstance()
|
||||
} as T
|
||||
}
|
||||
}
|
||||
|
||||
inline fun <reified VM : ViewModel> ViewModelHolder.viewModel() =
|
||||
lazy(LazyThreadSafetyMode.NONE) {
|
||||
ViewModelProvider(this, VMFactory)[VM::class.java]
|
||||
}
|
||||
@@ -0,0 +1,346 @@
|
||||
package com.topjohnwu.magisk.databinding
|
||||
|
||||
import android.animation.ValueAnimator
|
||||
import android.content.res.ColorStateList
|
||||
import android.graphics.Paint
|
||||
import android.graphics.drawable.Drawable
|
||||
import android.text.Spanned
|
||||
import android.util.TypedValue
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.ArrayAdapter
|
||||
import android.widget.Button
|
||||
import android.widget.ImageView
|
||||
import android.widget.ProgressBar
|
||||
import android.widget.Spinner
|
||||
import android.widget.TextView
|
||||
import androidx.annotation.DrawableRes
|
||||
import androidx.appcompat.widget.Toolbar
|
||||
import androidx.cardview.widget.CardView
|
||||
import androidx.core.view.isGone
|
||||
import androidx.core.view.isInvisible
|
||||
import androidx.core.view.updateLayoutParams
|
||||
import androidx.core.widget.ImageViewCompat
|
||||
import androidx.databinding.BindingAdapter
|
||||
import androidx.databinding.InverseBindingAdapter
|
||||
import androidx.databinding.InverseBindingListener
|
||||
import androidx.databinding.InverseMethod
|
||||
import androidx.interpolator.view.animation.FastOutSlowInInterpolator
|
||||
import androidx.recyclerview.widget.DividerItemDecoration
|
||||
import androidx.recyclerview.widget.GridLayoutManager
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import androidx.recyclerview.widget.StaggeredGridLayoutManager
|
||||
import com.google.android.material.button.MaterialButton
|
||||
import com.google.android.material.card.MaterialCardView
|
||||
import com.google.android.material.chip.Chip
|
||||
import com.google.android.material.slider.Slider
|
||||
import com.google.android.material.textfield.TextInputLayout
|
||||
import com.topjohnwu.magisk.R
|
||||
import com.topjohnwu.magisk.core.di.ServiceLocator
|
||||
import com.topjohnwu.magisk.core.model.su.SuPolicy
|
||||
import com.topjohnwu.magisk.utils.TextHolder
|
||||
import com.topjohnwu.superuser.internal.UiThreadHandler
|
||||
import com.topjohnwu.widget.IndeterminateCheckBox
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
@BindingAdapter("gone")
|
||||
fun setGone(view: View, gone: Boolean) {
|
||||
view.isGone = gone
|
||||
}
|
||||
|
||||
@BindingAdapter("invisible")
|
||||
fun setInvisible(view: View, invisible: Boolean) {
|
||||
view.isInvisible = invisible
|
||||
}
|
||||
|
||||
@BindingAdapter("goneUnless")
|
||||
fun setGoneUnless(view: View, goneUnless: Boolean) {
|
||||
setGone(view, goneUnless.not())
|
||||
}
|
||||
|
||||
@BindingAdapter("invisibleUnless")
|
||||
fun setInvisibleUnless(view: View, invisibleUnless: Boolean) {
|
||||
setInvisible(view, invisibleUnless.not())
|
||||
}
|
||||
|
||||
@BindingAdapter("markdownText")
|
||||
fun setMarkdownText(tv: TextView, markdown: Spanned) {
|
||||
ServiceLocator.markwon.setParsedMarkdown(tv, markdown)
|
||||
}
|
||||
|
||||
@BindingAdapter("onNavigationClick")
|
||||
fun setOnNavigationClickedListener(view: Toolbar, listener: View.OnClickListener) {
|
||||
view.setNavigationOnClickListener(listener)
|
||||
}
|
||||
|
||||
@BindingAdapter("srcCompat")
|
||||
fun setImageResource(view: ImageView, @DrawableRes resId: Int) {
|
||||
view.setImageResource(resId)
|
||||
}
|
||||
|
||||
@BindingAdapter("srcCompat")
|
||||
fun setImageResource(view: ImageView, drawable: Drawable) {
|
||||
view.setImageDrawable(drawable)
|
||||
}
|
||||
|
||||
@BindingAdapter("onTouch")
|
||||
fun setOnTouchListener(view: View, listener: View.OnTouchListener) {
|
||||
view.setOnTouchListener(listener)
|
||||
}
|
||||
|
||||
@BindingAdapter("scrollToLast")
|
||||
fun setScrollToLast(view: RecyclerView, shouldScrollToLast: Boolean) {
|
||||
|
||||
fun scrollToLast() = UiThreadHandler.handler.postDelayed({
|
||||
view.scrollToPosition(view.adapter?.itemCount?.minus(1) ?: 0)
|
||||
}, 30)
|
||||
|
||||
fun wait(callback: () -> Unit) {
|
||||
UiThreadHandler.handler.postDelayed(callback, 1000)
|
||||
}
|
||||
|
||||
fun RecyclerView.Adapter<*>.setListener() {
|
||||
val observer = object : RecyclerView.AdapterDataObserver() {
|
||||
override fun onItemRangeInserted(positionStart: Int, itemCount: Int) {
|
||||
scrollToLast()
|
||||
}
|
||||
}
|
||||
registerAdapterDataObserver(observer)
|
||||
view.setTag(R.id.recyclerScrollListener, observer)
|
||||
}
|
||||
|
||||
fun RecyclerView.Adapter<*>.removeListener() {
|
||||
val observer =
|
||||
view.getTag(R.id.recyclerScrollListener) as? RecyclerView.AdapterDataObserver ?: return
|
||||
unregisterAdapterDataObserver(observer)
|
||||
}
|
||||
|
||||
fun trySetListener(): Unit = view.adapter?.setListener() ?: wait { trySetListener() }
|
||||
|
||||
if (shouldScrollToLast) {
|
||||
trySetListener()
|
||||
} else {
|
||||
view.adapter?.removeListener()
|
||||
}
|
||||
}
|
||||
|
||||
@BindingAdapter("isEnabled")
|
||||
fun setEnabled(view: View, isEnabled: Boolean) {
|
||||
view.isEnabled = isEnabled
|
||||
}
|
||||
|
||||
@BindingAdapter("error")
|
||||
fun TextInputLayout.setErrorString(error: String) {
|
||||
val newError = error.let { if (it.isEmpty()) null else it }
|
||||
if (this.error == null && newError == null) return
|
||||
this.error = newError
|
||||
}
|
||||
|
||||
// md2
|
||||
|
||||
@BindingAdapter(
|
||||
"android:layout_marginLeft",
|
||||
"android:layout_marginTop",
|
||||
"android:layout_marginRight",
|
||||
"android:layout_marginBottom",
|
||||
"android:layout_marginStart",
|
||||
"android:layout_marginEnd",
|
||||
requireAll = false
|
||||
)
|
||||
fun View.setMargins(
|
||||
marginLeft: Int?,
|
||||
marginTop: Int?,
|
||||
marginRight: Int?,
|
||||
marginBottom: Int?,
|
||||
marginStart: Int?,
|
||||
marginEnd: Int?
|
||||
) = updateLayoutParams<ViewGroup.MarginLayoutParams> {
|
||||
marginLeft?.let { leftMargin = it }
|
||||
marginTop?.let { topMargin = it }
|
||||
marginRight?.let { rightMargin = it }
|
||||
marginBottom?.let { bottomMargin = it }
|
||||
marginStart?.let { this.marginStart = it }
|
||||
marginEnd?.let { this.marginEnd = it }
|
||||
}
|
||||
|
||||
@BindingAdapter("nestedScrollingEnabled")
|
||||
fun RecyclerView.setNestedScrolling(enabled: Boolean) {
|
||||
isNestedScrollingEnabled = enabled
|
||||
}
|
||||
|
||||
@BindingAdapter("isSelected")
|
||||
fun View.isSelected(isSelected: Boolean) {
|
||||
this.isSelected = isSelected
|
||||
}
|
||||
|
||||
@BindingAdapter("dividerVertical", "dividerHorizontal", requireAll = false)
|
||||
fun RecyclerView.setDividers(dividerVertical: Drawable?, dividerHorizontal: Drawable?) {
|
||||
if (dividerHorizontal != null) {
|
||||
DividerItemDecoration(context, LinearLayoutManager.HORIZONTAL).apply {
|
||||
setDrawable(dividerHorizontal)
|
||||
}.let { addItemDecoration(it) }
|
||||
}
|
||||
if (dividerVertical != null) {
|
||||
DividerItemDecoration(context, LinearLayoutManager.VERTICAL).apply {
|
||||
setDrawable(dividerVertical)
|
||||
}.let { addItemDecoration(it) }
|
||||
}
|
||||
}
|
||||
|
||||
@BindingAdapter("icon")
|
||||
fun Button.setIconRes(res: Int) {
|
||||
(this as MaterialButton).setIconResource(res)
|
||||
}
|
||||
|
||||
@BindingAdapter("icon")
|
||||
fun Button.setIcon(drawable: Drawable) {
|
||||
(this as MaterialButton).icon = drawable
|
||||
}
|
||||
|
||||
@BindingAdapter("strokeWidth")
|
||||
fun MaterialCardView.setCardStrokeWidthBound(stroke: Float) {
|
||||
strokeWidth = stroke.roundToInt()
|
||||
}
|
||||
|
||||
@BindingAdapter("onMenuClick")
|
||||
fun Toolbar.setOnMenuClickListener(listener: Toolbar.OnMenuItemClickListener) {
|
||||
setOnMenuItemClickListener(listener)
|
||||
}
|
||||
|
||||
@BindingAdapter("onCloseClicked")
|
||||
fun Chip.setOnCloseClickedListenerBinding(listener: View.OnClickListener) {
|
||||
setOnCloseIconClickListener(listener)
|
||||
}
|
||||
|
||||
@BindingAdapter("progressAnimated")
|
||||
fun ProgressBar.setProgressAnimated(newProgress: Int) {
|
||||
val animator = tag as? ValueAnimator
|
||||
animator?.cancel()
|
||||
|
||||
ValueAnimator.ofInt(progress, newProgress).apply {
|
||||
interpolator = FastOutSlowInInterpolator()
|
||||
addUpdateListener { progress = it.animatedValue as Int }
|
||||
tag = this
|
||||
}.start()
|
||||
}
|
||||
|
||||
@BindingAdapter("android:text")
|
||||
fun TextView.setTextSafe(text: Int) {
|
||||
if (text == 0) this.text = null else setText(text)
|
||||
}
|
||||
|
||||
@BindingAdapter("android:onLongClick")
|
||||
fun View.setOnLongClickListenerBinding(listener: () -> Unit) {
|
||||
setOnLongClickListener {
|
||||
listener()
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
@BindingAdapter("strikeThrough")
|
||||
fun TextView.setStrikeThroughEnabled(useStrikeThrough: Boolean) {
|
||||
paintFlags = if (useStrikeThrough) {
|
||||
paintFlags or Paint.STRIKE_THRU_TEXT_FLAG
|
||||
} else {
|
||||
paintFlags and Paint.STRIKE_THRU_TEXT_FLAG.inv()
|
||||
}
|
||||
}
|
||||
|
||||
@BindingAdapter("spanCount")
|
||||
fun RecyclerView.setSpanCount(count: Int) {
|
||||
when (val lama = layoutManager) {
|
||||
is GridLayoutManager -> lama.spanCount = count
|
||||
is StaggeredGridLayoutManager -> lama.spanCount = count
|
||||
}
|
||||
}
|
||||
|
||||
@BindingAdapter("state")
|
||||
fun setState(view: IndeterminateCheckBox, state: Boolean?) {
|
||||
if (view.state != state)
|
||||
view.state = state
|
||||
}
|
||||
|
||||
@InverseBindingAdapter(attribute = "state")
|
||||
fun getState(view: IndeterminateCheckBox) = view.state
|
||||
|
||||
@BindingAdapter("stateAttrChanged")
|
||||
fun setListeners(
|
||||
view: IndeterminateCheckBox,
|
||||
attrChange: InverseBindingListener
|
||||
) {
|
||||
view.setOnStateChangedListener { _, _ ->
|
||||
attrChange.onChange()
|
||||
}
|
||||
}
|
||||
|
||||
@BindingAdapter("cardBackgroundColorAttr")
|
||||
fun CardView.setCardBackgroundColorAttr(attr: Int) {
|
||||
val tv = TypedValue()
|
||||
context.theme.resolveAttribute(attr, tv, true)
|
||||
setCardBackgroundColor(tv.data)
|
||||
}
|
||||
|
||||
@BindingAdapter("tint")
|
||||
fun ImageView.setTint(color: Int) {
|
||||
ImageViewCompat.setImageTintList(this, ColorStateList.valueOf(color))
|
||||
}
|
||||
|
||||
@BindingAdapter("tintAttr")
|
||||
fun ImageView.setTintAttr(attr: Int) {
|
||||
val tv = TypedValue()
|
||||
context.theme.resolveAttribute(attr, tv, true)
|
||||
ImageViewCompat.setImageTintList(this, ColorStateList.valueOf(tv.data))
|
||||
}
|
||||
|
||||
@BindingAdapter("textColorAttr")
|
||||
fun TextView.setTextColorAttr(attr: Int) {
|
||||
val tv = TypedValue()
|
||||
context.theme.resolveAttribute(attr, tv, true)
|
||||
setTextColor(tv.data)
|
||||
}
|
||||
|
||||
@BindingAdapter("android:text")
|
||||
fun TextView.setText(text: TextHolder) {
|
||||
this.text = text.getText(context.resources)
|
||||
}
|
||||
|
||||
@BindingAdapter("items", "layout")
|
||||
fun Spinner.setAdapter(items: Array<Any>, layoutRes: Int) {
|
||||
adapter = ArrayAdapter(context, layoutRes, items)
|
||||
}
|
||||
|
||||
@BindingAdapter("labelFormatter")
|
||||
fun Slider.setLabelFormatter(formatter: (Float) -> Int) {
|
||||
setLabelFormatter { value -> resources.getString(formatter(value)) }
|
||||
}
|
||||
|
||||
@InverseBindingAdapter(attribute = "android:value")
|
||||
fun Slider.getValueBinding() = value
|
||||
|
||||
@BindingAdapter("android:valueAttrChanged")
|
||||
fun Slider.setListener(attrChange: InverseBindingListener) {
|
||||
addOnSliderTouchListener(object : Slider.OnSliderTouchListener {
|
||||
override fun onStartTrackingTouch(slider: Slider) = Unit
|
||||
override fun onStopTrackingTouch(slider: Slider) = attrChange.onChange()
|
||||
})
|
||||
}
|
||||
|
||||
@InverseMethod("sliderValueToPolicy")
|
||||
fun policyToSliderValue(policy: Int): Float {
|
||||
return when (policy) {
|
||||
SuPolicy.DENY -> 1f
|
||||
SuPolicy.RESTRICT -> 2f
|
||||
SuPolicy.ALLOW -> 3f
|
||||
else -> 1f
|
||||
}
|
||||
}
|
||||
|
||||
fun sliderValueToPolicy(value: Float): Int {
|
||||
return when (value) {
|
||||
1f -> SuPolicy.DENY
|
||||
2f -> SuPolicy.RESTRICT
|
||||
3f -> SuPolicy.ALLOW
|
||||
else -> SuPolicy.DENY
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
package com.topjohnwu.magisk.databinding
|
||||
|
||||
import androidx.annotation.MainThread
|
||||
import androidx.annotation.WorkerThread
|
||||
import androidx.databinding.ListChangeRegistry
|
||||
import androidx.databinding.ObservableList
|
||||
import androidx.recyclerview.widget.DiffUtil
|
||||
import androidx.recyclerview.widget.ListUpdateCallback
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.util.AbstractList
|
||||
|
||||
// Only expose the immutable List types
|
||||
interface DiffList<T : DiffItem<*>> : List<T> {
|
||||
fun calculateDiff(newItems: List<T>): DiffUtil.DiffResult
|
||||
|
||||
@MainThread
|
||||
fun update(newItems: List<T>, diffResult: DiffUtil.DiffResult)
|
||||
|
||||
@WorkerThread
|
||||
suspend fun update(newItems: List<T>)
|
||||
}
|
||||
|
||||
interface FilterList<T : DiffItem<*>> : List<T> {
|
||||
fun filter(filter: (T) -> Boolean)
|
||||
|
||||
@MainThread
|
||||
fun set(newItems: List<T>)
|
||||
}
|
||||
|
||||
fun <T : DiffItem<*>> diffList(): DiffList<T> = DiffObservableList()
|
||||
|
||||
fun <T : DiffItem<*>> filterList(scope: CoroutineScope): FilterList<T> =
|
||||
FilterableDiffObservableList(scope)
|
||||
|
||||
private open class DiffObservableList<T : DiffItem<*>>
|
||||
: AbstractList<T>(), ObservableList<T>, DiffList<T>, ListUpdateCallback {
|
||||
|
||||
protected var list: List<T> = emptyList()
|
||||
private val listeners = ListChangeRegistry()
|
||||
|
||||
override val size: Int get() = list.size
|
||||
|
||||
override fun get(index: Int) = list[index]
|
||||
|
||||
override fun calculateDiff(newItems: List<T>): DiffUtil.DiffResult {
|
||||
return doCalculateDiff(list, newItems)
|
||||
}
|
||||
|
||||
protected fun doCalculateDiff(oldItems: List<T>, newItems: List<T>): DiffUtil.DiffResult {
|
||||
return DiffUtil.calculateDiff(object : DiffUtil.Callback() {
|
||||
override fun getOldListSize() = oldItems.size
|
||||
|
||||
override fun getNewListSize() = newItems.size
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
override fun areItemsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean {
|
||||
val oldItem = oldItems[oldItemPosition]
|
||||
val newItem = newItems[newItemPosition]
|
||||
return (oldItem as DiffItem<Any>).itemSameAs(newItem)
|
||||
}
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
override fun areContentsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean {
|
||||
val oldItem = oldItems[oldItemPosition]
|
||||
val newItem = newItems[newItemPosition]
|
||||
return (oldItem as DiffItem<Any>).contentSameAs(newItem)
|
||||
}
|
||||
}, true)
|
||||
}
|
||||
|
||||
@MainThread
|
||||
override fun update(newItems: List<T>, diffResult: DiffUtil.DiffResult) {
|
||||
list = ArrayList(newItems)
|
||||
diffResult.dispatchUpdatesTo(this)
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
override suspend fun update(newItems: List<T>) {
|
||||
val diffResult = calculateDiff(newItems)
|
||||
withContext(Dispatchers.Main) {
|
||||
update(newItems, diffResult)
|
||||
}
|
||||
}
|
||||
|
||||
override fun addOnListChangedCallback(listener: ObservableList.OnListChangedCallback<out ObservableList<T>>) {
|
||||
listeners.add(listener)
|
||||
}
|
||||
|
||||
override fun removeOnListChangedCallback(listener: ObservableList.OnListChangedCallback<out ObservableList<T>>) {
|
||||
listeners.remove(listener)
|
||||
}
|
||||
|
||||
override fun onChanged(position: Int, count: Int, payload: Any?) {
|
||||
listeners.notifyChanged(this, position, count)
|
||||
}
|
||||
|
||||
override fun onMoved(fromPosition: Int, toPosition: Int) {
|
||||
listeners.notifyMoved(this, fromPosition, toPosition, 1)
|
||||
}
|
||||
|
||||
override fun onInserted(position: Int, count: Int) {
|
||||
modCount += 1
|
||||
listeners.notifyInserted(this, position, count)
|
||||
}
|
||||
|
||||
override fun onRemoved(position: Int, count: Int) {
|
||||
modCount += 1
|
||||
listeners.notifyRemoved(this, position, count)
|
||||
}
|
||||
}
|
||||
|
||||
private class FilterableDiffObservableList<T : DiffItem<*>>(
|
||||
private val scope: CoroutineScope
|
||||
) : DiffObservableList<T>(), FilterList<T> {
|
||||
|
||||
private var sublist: List<T> = emptyList()
|
||||
private var job: Job? = null
|
||||
private var lastFilter: ((T) -> Boolean)? = null
|
||||
|
||||
// ---
|
||||
|
||||
override fun filter(filter: (T) -> Boolean) {
|
||||
lastFilter = filter
|
||||
job?.cancel()
|
||||
job = scope.launch(Dispatchers.Default) {
|
||||
val oldList = sublist
|
||||
val newList = list.filter(filter)
|
||||
val diff = doCalculateDiff(oldList, newList)
|
||||
withContext(Dispatchers.Main) {
|
||||
sublist = newList
|
||||
diff.dispatchUpdatesTo(this@FilterableDiffObservableList)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---
|
||||
|
||||
override fun get(index: Int): T {
|
||||
return sublist[index]
|
||||
}
|
||||
|
||||
override val size: Int
|
||||
get() = sublist.size
|
||||
|
||||
@MainThread
|
||||
override fun set(newItems: List<T>) {
|
||||
onRemoved(0, sublist.size)
|
||||
list = newItems
|
||||
sublist = emptyList()
|
||||
lastFilter?.let { filter(it) }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
package com.topjohnwu.magisk.databinding
|
||||
|
||||
import androidx.databinding.ListChangeRegistry
|
||||
import androidx.databinding.ObservableList
|
||||
import androidx.databinding.ObservableList.OnListChangedCallback
|
||||
import java.util.AbstractList
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
class MergeObservableList<T> : AbstractList<T>(), ObservableList<T> {
|
||||
|
||||
private val lists: MutableList<List<T>> = mutableListOf()
|
||||
private val listeners = ListChangeRegistry()
|
||||
private val callback = Callback<T>()
|
||||
|
||||
override fun addOnListChangedCallback(callback: OnListChangedCallback<out ObservableList<T>>) {
|
||||
listeners.add(callback)
|
||||
}
|
||||
|
||||
override fun removeOnListChangedCallback(callback: OnListChangedCallback<out ObservableList<T>>) {
|
||||
listeners.remove(callback)
|
||||
}
|
||||
|
||||
override fun get(index: Int): T {
|
||||
if (index < 0)
|
||||
throw IndexOutOfBoundsException()
|
||||
var idx = index
|
||||
for (list in lists) {
|
||||
val size = list.size
|
||||
if (idx < size) {
|
||||
return list[idx]
|
||||
}
|
||||
idx -= size
|
||||
}
|
||||
throw IndexOutOfBoundsException()
|
||||
}
|
||||
|
||||
override val size: Int
|
||||
get() = lists.fold(0) { i, it -> i + it.size }
|
||||
|
||||
|
||||
fun insertItem(obj: T): MergeObservableList<T> {
|
||||
val idx = size
|
||||
lists.add(listOf(obj))
|
||||
++modCount
|
||||
listeners.notifyInserted(this, idx, 1)
|
||||
return this
|
||||
}
|
||||
|
||||
fun insertList(list: List<T>): MergeObservableList<T> {
|
||||
val idx = size
|
||||
lists.add(list)
|
||||
++modCount
|
||||
(list as? ObservableList<T>)?.addOnListChangedCallback(callback)
|
||||
if (list.isNotEmpty())
|
||||
listeners.notifyInserted(this, idx, list.size)
|
||||
return this
|
||||
}
|
||||
|
||||
fun removeItem(obj: T): Boolean {
|
||||
var idx = 0
|
||||
for ((i, list) in lists.withIndex()) {
|
||||
if (list !is ObservableList<*>) {
|
||||
if (obj == list[0]) {
|
||||
lists.removeAt(i)
|
||||
++modCount
|
||||
listeners.notifyRemoved(this, idx, 1)
|
||||
return true
|
||||
}
|
||||
}
|
||||
idx += list.size
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
fun removeList(listToRemove: List<T>): Boolean {
|
||||
var idx = 0
|
||||
for ((i, list) in lists.withIndex()) {
|
||||
if (listToRemove === list) {
|
||||
(list as? ObservableList<T>)?.removeOnListChangedCallback(callback)
|
||||
lists.removeAt(i)
|
||||
++modCount
|
||||
listeners.notifyRemoved(this, idx, list.size)
|
||||
return true
|
||||
}
|
||||
idx += list.size
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
override fun clear() {
|
||||
val sz = size
|
||||
for (list in lists) {
|
||||
if (list is ObservableList) {
|
||||
list.removeOnListChangedCallback(callback)
|
||||
}
|
||||
}
|
||||
++modCount
|
||||
lists.clear()
|
||||
if (sz > 0)
|
||||
listeners.notifyRemoved(this, 0, sz)
|
||||
}
|
||||
|
||||
private fun subIndexToIndex(subList: List<*>, index: Int): Int {
|
||||
if (index < 0)
|
||||
throw IndexOutOfBoundsException()
|
||||
var idx = 0
|
||||
for (list in lists) {
|
||||
if (subList === list) {
|
||||
return idx + index
|
||||
}
|
||||
idx += list.size
|
||||
}
|
||||
throw IllegalArgumentException()
|
||||
}
|
||||
|
||||
inner class Callback<T> : OnListChangedCallback<ObservableList<T>>() {
|
||||
override fun onChanged(sender: ObservableList<T>) {
|
||||
++modCount
|
||||
listeners.notifyChanged(this@MergeObservableList)
|
||||
}
|
||||
|
||||
override fun onItemRangeChanged(
|
||||
sender: ObservableList<T>,
|
||||
positionStart: Int,
|
||||
itemCount: Int
|
||||
) {
|
||||
listeners.notifyChanged(this@MergeObservableList,
|
||||
subIndexToIndex(sender, positionStart), itemCount)
|
||||
}
|
||||
|
||||
override fun onItemRangeInserted(
|
||||
sender: ObservableList<T>,
|
||||
positionStart: Int,
|
||||
itemCount: Int
|
||||
) {
|
||||
++modCount
|
||||
listeners.notifyInserted(this@MergeObservableList,
|
||||
subIndexToIndex(sender, positionStart), itemCount)
|
||||
}
|
||||
|
||||
override fun onItemRangeMoved(
|
||||
sender: ObservableList<T>,
|
||||
fromPosition: Int,
|
||||
toPosition: Int,
|
||||
itemCount: Int
|
||||
) {
|
||||
val idx = subIndexToIndex(sender, 0)
|
||||
listeners.notifyMoved(this@MergeObservableList,
|
||||
idx + fromPosition, idx + toPosition, itemCount)
|
||||
}
|
||||
|
||||
override fun onItemRangeRemoved(
|
||||
sender: ObservableList<T>,
|
||||
positionStart: Int,
|
||||
itemCount: Int
|
||||
) {
|
||||
++modCount
|
||||
listeners.notifyRemoved(this@MergeObservableList,
|
||||
subIndexToIndex(sender, positionStart), itemCount)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
package com.topjohnwu.magisk.databinding
|
||||
|
||||
import androidx.databinding.Observable
|
||||
import androidx.databinding.PropertyChangeRegistry
|
||||
|
||||
/**
|
||||
* Modified from https://github.com/skoumalcz/teanity/blob/1.2/core/src/main/java/com/skoumal/teanity/observable/Notifyable.kt
|
||||
*
|
||||
* Interface that allows user to be observed via DataBinding or manually by assigning listeners.
|
||||
*
|
||||
* @see [androidx.databinding.Observable]
|
||||
* */
|
||||
interface ObservableHost : Observable {
|
||||
|
||||
var callbacks: PropertyChangeRegistry?
|
||||
|
||||
/**
|
||||
* Notifies all observers that something has changed. By default implementation this method is
|
||||
* synchronous, hence observers will never be notified in undefined order. Observers might
|
||||
* choose to refresh the view completely, which is beyond the scope of this function.
|
||||
* */
|
||||
fun notifyChange() {
|
||||
synchronized(this) {
|
||||
callbacks ?: return
|
||||
}.notifyCallbacks(this, 0, null)
|
||||
}
|
||||
|
||||
/**
|
||||
* Notifies all observers about field with [fieldId] has been changed. This will happen
|
||||
* synchronously before or after [notifyChange] has been called. It will never be called during
|
||||
* the execution of aforementioned method.
|
||||
* */
|
||||
fun notifyPropertyChanged(fieldId: Int) {
|
||||
synchronized(this) {
|
||||
callbacks ?: return
|
||||
}.notifyCallbacks(this, fieldId, null)
|
||||
}
|
||||
|
||||
override fun addOnPropertyChangedCallback(callback: Observable.OnPropertyChangedCallback) {
|
||||
synchronized(this) {
|
||||
callbacks ?: PropertyChangeRegistry().also { callbacks = it }
|
||||
}.add(callback)
|
||||
}
|
||||
|
||||
override fun removeOnPropertyChangedCallback(callback: Observable.OnPropertyChangedCallback) {
|
||||
synchronized(this) {
|
||||
callbacks ?: return
|
||||
}.remove(callback)
|
||||
}
|
||||
}
|
||||
|
||||
fun ObservableHost.addOnPropertyChangedCallback(
|
||||
fieldId: Int,
|
||||
removeAfterChanged: Boolean = false,
|
||||
callback: () -> Unit
|
||||
) {
|
||||
addOnPropertyChangedCallback(object : Observable.OnPropertyChangedCallback() {
|
||||
override fun onPropertyChanged(sender: Observable?, propertyId: Int) {
|
||||
if (fieldId == propertyId) {
|
||||
callback()
|
||||
if (removeAfterChanged)
|
||||
removeOnPropertyChangedCallback(this)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Injects boilerplate implementation for {@literal @}[androidx.databinding.Bindable] field setters.
|
||||
*
|
||||
* # Examples:
|
||||
* ```kotlin
|
||||
* @get:Bindable
|
||||
* var myField = defaultValue
|
||||
* set(value) = set(value, field, { field = it }, BR.myField) {
|
||||
* doSomething(it)
|
||||
* }
|
||||
* ```
|
||||
* */
|
||||
|
||||
inline fun <reified T> ObservableHost.set(
|
||||
new: T, old: T, setter: (T) -> Unit, fieldId: Int, afterChanged: (T) -> Unit = {}) {
|
||||
if (old != new) {
|
||||
setter(new)
|
||||
notifyPropertyChanged(fieldId)
|
||||
afterChanged(new)
|
||||
}
|
||||
}
|
||||
|
||||
inline fun <reified T> ObservableHost.set(
|
||||
new: T, old: T, setter: (T) -> Unit, vararg fieldIds: Int, afterChanged: (T) -> Unit = {}) {
|
||||
if (old != new) {
|
||||
setter(new)
|
||||
fieldIds.forEach { notifyPropertyChanged(it) }
|
||||
afterChanged(new)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package com.topjohnwu.magisk.databinding
|
||||
|
||||
import androidx.databinding.PropertyChangeRegistry
|
||||
import androidx.databinding.ViewDataBinding
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
|
||||
abstract class RvItem {
|
||||
abstract val layoutRes: Int
|
||||
}
|
||||
|
||||
abstract class ObservableRvItem : RvItem(), ObservableHost {
|
||||
override var callbacks: PropertyChangeRegistry? = null
|
||||
}
|
||||
|
||||
interface ItemWrapper<E> {
|
||||
val item: E
|
||||
}
|
||||
|
||||
interface ViewAwareItem {
|
||||
fun onBind(binding: ViewDataBinding, recyclerView: RecyclerView)
|
||||
}
|
||||
|
||||
interface DiffItem<T : Any> {
|
||||
|
||||
fun itemSameAs(other: T): Boolean {
|
||||
if (this === other) return true
|
||||
return when (this) {
|
||||
is ItemWrapper<*> -> item == (other as ItemWrapper<*>).item
|
||||
is Comparable<*> -> compareValues(this, other as Comparable<*>) == 0
|
||||
else -> this == other
|
||||
}
|
||||
}
|
||||
|
||||
fun contentSameAs(other: T) = true
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
package com.topjohnwu.magisk.databinding
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.util.SparseArray
|
||||
import android.view.LayoutInflater
|
||||
import android.view.ViewGroup
|
||||
import androidx.databinding.BindingAdapter
|
||||
import androidx.databinding.DataBindingUtil
|
||||
import androidx.databinding.ObservableList
|
||||
import androidx.databinding.ObservableList.OnListChangedCallback
|
||||
import androidx.databinding.ViewDataBinding
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
import androidx.lifecycle.findViewTreeLifecycleOwner
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.topjohnwu.magisk.BR
|
||||
|
||||
class RvItemAdapter<T: RvItem>(
|
||||
val items: List<T>,
|
||||
val extraBindings: SparseArray<*>?
|
||||
) : RecyclerView.Adapter<RvItemAdapter.ViewHolder>() {
|
||||
|
||||
private var lifecycleOwner: LifecycleOwner? = null
|
||||
private var recyclerView: RecyclerView? = null
|
||||
private val observer by lazy(LazyThreadSafetyMode.NONE) { ListObserver<T>() }
|
||||
|
||||
override fun onAttachedToRecyclerView(rv: RecyclerView) {
|
||||
lifecycleOwner = rv.findViewTreeLifecycleOwner()
|
||||
recyclerView = rv
|
||||
if (items is ObservableList)
|
||||
items.addOnListChangedCallback(observer)
|
||||
}
|
||||
|
||||
override fun onDetachedFromRecyclerView(rv: RecyclerView) {
|
||||
lifecycleOwner = null
|
||||
recyclerView = null
|
||||
if (items is ObservableList)
|
||||
items.removeOnListChangedCallback(observer)
|
||||
}
|
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, layoutRes: Int): ViewHolder {
|
||||
val inflator = LayoutInflater.from(parent.context)
|
||||
return ViewHolder(DataBindingUtil.inflate(inflator, layoutRes, parent, false))
|
||||
}
|
||||
|
||||
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
|
||||
val item = items[position]
|
||||
holder.binding.setVariable(BR.item, item)
|
||||
extraBindings?.let {
|
||||
for (i in 0 until it.size()) {
|
||||
holder.binding.setVariable(it.keyAt(i), it.valueAt(i))
|
||||
}
|
||||
}
|
||||
holder.binding.lifecycleOwner = lifecycleOwner
|
||||
holder.binding.executePendingBindings()
|
||||
recyclerView?.let {
|
||||
if (item is ViewAwareItem)
|
||||
item.onBind(holder.binding, it)
|
||||
}
|
||||
}
|
||||
|
||||
override fun getItemCount() = items.size
|
||||
|
||||
override fun getItemViewType(position: Int) = items[position].layoutRes
|
||||
|
||||
class ViewHolder(val binding: ViewDataBinding) : RecyclerView.ViewHolder(binding.root)
|
||||
|
||||
inner class ListObserver<T: RvItem> : OnListChangedCallback<ObservableList<T>>() {
|
||||
|
||||
@SuppressLint("NotifyDataSetChanged")
|
||||
override fun onChanged(sender: ObservableList<T>) {
|
||||
notifyDataSetChanged()
|
||||
}
|
||||
|
||||
override fun onItemRangeChanged(
|
||||
sender: ObservableList<T>,
|
||||
positionStart: Int,
|
||||
itemCount: Int
|
||||
) {
|
||||
notifyItemRangeChanged(positionStart, itemCount)
|
||||
}
|
||||
|
||||
override fun onItemRangeInserted(
|
||||
sender: ObservableList<T>?,
|
||||
positionStart: Int,
|
||||
itemCount: Int
|
||||
) {
|
||||
notifyItemRangeInserted(positionStart, itemCount)
|
||||
}
|
||||
|
||||
override fun onItemRangeMoved(
|
||||
sender: ObservableList<T>?,
|
||||
fromPosition: Int,
|
||||
toPosition: Int,
|
||||
itemCount: Int
|
||||
) {
|
||||
for (i in 0 until itemCount) {
|
||||
notifyItemMoved(fromPosition + i, toPosition + i)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onItemRangeRemoved(
|
||||
sender: ObservableList<T>?,
|
||||
positionStart: Int,
|
||||
itemCount: Int
|
||||
) {
|
||||
notifyItemRangeRemoved(positionStart, itemCount)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
inline fun bindExtra(body: (SparseArray<Any?>) -> Unit) = SparseArray<Any?>().also(body)
|
||||
|
||||
@BindingAdapter("items", "extraBindings", requireAll = false)
|
||||
fun <T: RvItem> RecyclerView.setAdapter(items: List<T>?, extraBindings: SparseArray<*>?) {
|
||||
if (items != null) {
|
||||
val rva = (adapter as? RvItemAdapter<*>)
|
||||
if (rva == null || rva.items !== items || rva.extraBindings !== extraBindings) {
|
||||
adapter = RvItemAdapter(items, extraBindings)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package com.topjohnwu.magisk.dialog
|
||||
|
||||
import android.app.Activity
|
||||
import androidx.appcompat.app.AppCompatDelegate
|
||||
import com.topjohnwu.magisk.R
|
||||
import com.topjohnwu.magisk.arch.UIActivity
|
||||
import com.topjohnwu.magisk.core.Config
|
||||
import com.topjohnwu.magisk.events.DialogBuilder
|
||||
import com.topjohnwu.magisk.view.MagiskDialog
|
||||
import com.topjohnwu.magisk.core.R as CoreR
|
||||
|
||||
class DarkThemeDialog : DialogBuilder {
|
||||
|
||||
override fun build(dialog: MagiskDialog) {
|
||||
val activity = dialog.ownerActivity!!
|
||||
dialog.apply {
|
||||
setTitle(CoreR.string.settings_dark_mode_title)
|
||||
setMessage(CoreR.string.settings_dark_mode_message)
|
||||
setButton(MagiskDialog.ButtonType.POSITIVE) {
|
||||
text = CoreR.string.settings_dark_mode_light
|
||||
icon = R.drawable.ic_day
|
||||
onClick { selectTheme(AppCompatDelegate.MODE_NIGHT_NO, activity) }
|
||||
}
|
||||
setButton(MagiskDialog.ButtonType.NEUTRAL) {
|
||||
text = CoreR.string.settings_dark_mode_system
|
||||
icon = R.drawable.ic_day_night
|
||||
onClick { selectTheme(AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM, activity) }
|
||||
}
|
||||
setButton(MagiskDialog.ButtonType.NEGATIVE) {
|
||||
text = CoreR.string.settings_dark_mode_dark
|
||||
icon = R.drawable.ic_night
|
||||
onClick { selectTheme(AppCompatDelegate.MODE_NIGHT_YES, activity) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun selectTheme(mode: Int, activity: Activity) {
|
||||
Config.darkTheme = mode
|
||||
(activity as UIActivity<*>).delegate.localNightMode = mode
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
package com.topjohnwu.magisk.dialog
|
||||
|
||||
import android.widget.Toast
|
||||
import androidx.core.os.postDelayed
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import com.topjohnwu.magisk.core.BuildConfig
|
||||
import com.topjohnwu.magisk.core.Info
|
||||
import com.topjohnwu.magisk.core.R
|
||||
import com.topjohnwu.magisk.core.ktx.reboot
|
||||
import com.topjohnwu.magisk.core.ktx.toast
|
||||
import com.topjohnwu.magisk.core.tasks.MagiskInstaller
|
||||
import com.topjohnwu.magisk.events.DialogBuilder
|
||||
import com.topjohnwu.magisk.ui.home.HomeViewModel
|
||||
import com.topjohnwu.magisk.view.MagiskDialog
|
||||
import com.topjohnwu.superuser.internal.UiThreadHandler
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class EnvFixDialog(private val vm: HomeViewModel, private val code: Int) : DialogBuilder {
|
||||
|
||||
override fun build(dialog: MagiskDialog) {
|
||||
dialog.apply {
|
||||
setTitle(R.string.env_fix_title)
|
||||
setMessage(R.string.env_fix_msg)
|
||||
setButton(MagiskDialog.ButtonType.POSITIVE) {
|
||||
text = android.R.string.ok
|
||||
doNotDismiss = true
|
||||
onClick {
|
||||
dialog.apply {
|
||||
setTitle(R.string.setup_title)
|
||||
setMessage(R.string.setup_msg)
|
||||
resetButtons()
|
||||
setCancelable(false)
|
||||
}
|
||||
dialog.activity.lifecycleScope.launch {
|
||||
MagiskInstaller.FixEnv().exec { success ->
|
||||
dialog.dismiss()
|
||||
context.toast(
|
||||
if (success) R.string.reboot_delay_toast else R.string.setup_fail,
|
||||
Toast.LENGTH_LONG
|
||||
)
|
||||
if (success)
|
||||
UiThreadHandler.handler.postDelayed(5000) { reboot() }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
setButton(MagiskDialog.ButtonType.NEGATIVE) {
|
||||
text = android.R.string.cancel
|
||||
}
|
||||
}
|
||||
|
||||
if (code == 2 || // No rules block, module policy not loaded
|
||||
Info.env.versionCode != BuildConfig.APP_VERSION_CODE ||
|
||||
Info.env.versionString != BuildConfig.APP_VERSION_NAME) {
|
||||
dialog.setMessage(R.string.env_full_fix_msg)
|
||||
dialog.setButton(MagiskDialog.ButtonType.POSITIVE) {
|
||||
text = android.R.string.ok
|
||||
onClick {
|
||||
vm.onMagiskPressed()
|
||||
dialog.dismiss()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package com.topjohnwu.magisk.dialog
|
||||
|
||||
import android.net.Uri
|
||||
import com.topjohnwu.magisk.MainDirections
|
||||
import com.topjohnwu.magisk.core.Const
|
||||
import com.topjohnwu.magisk.core.R
|
||||
import com.topjohnwu.magisk.events.DialogBuilder
|
||||
import com.topjohnwu.magisk.ui.module.ModuleViewModel
|
||||
import com.topjohnwu.magisk.view.MagiskDialog
|
||||
|
||||
class LocalModuleInstallDialog(
|
||||
private val viewModel: ModuleViewModel,
|
||||
private val uri: Uri,
|
||||
private val displayName: String
|
||||
) : DialogBuilder {
|
||||
override fun build(dialog: MagiskDialog) {
|
||||
dialog.apply {
|
||||
setTitle(R.string.confirm_install_title)
|
||||
setMessage(context.getString(R.string.confirm_install, displayName))
|
||||
setButton(MagiskDialog.ButtonType.POSITIVE) {
|
||||
text = android.R.string.ok
|
||||
onClick {
|
||||
viewModel.apply {
|
||||
MainDirections.actionFlashFragment(Const.Value.FLASH_ZIP, uri).navigate()
|
||||
}
|
||||
}
|
||||
}
|
||||
setButton(MagiskDialog.ButtonType.NEGATIVE) {
|
||||
text = android.R.string.cancel
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package com.topjohnwu.magisk.dialog
|
||||
|
||||
import com.topjohnwu.magisk.core.AppContext
|
||||
import com.topjohnwu.magisk.core.Info
|
||||
import com.topjohnwu.magisk.core.R
|
||||
import com.topjohnwu.magisk.core.download.DownloadEngine
|
||||
import com.topjohnwu.magisk.core.download.Subject
|
||||
import com.topjohnwu.magisk.view.MagiskDialog
|
||||
import java.io.File
|
||||
|
||||
class ManagerInstallDialog : MarkDownDialog() {
|
||||
|
||||
override suspend fun getMarkdownText(): String {
|
||||
val text = Info.update.note
|
||||
// Cache the changelog
|
||||
File(AppContext.cacheDir, "${Info.update.versionCode}.md").writeText(text)
|
||||
return text
|
||||
}
|
||||
|
||||
override fun build(dialog: MagiskDialog) {
|
||||
super.build(dialog)
|
||||
dialog.apply {
|
||||
setCancelable(true)
|
||||
setButton(MagiskDialog.ButtonType.POSITIVE) {
|
||||
text = R.string.install
|
||||
onClick { DownloadEngine.startWithActivity(activity, Subject.App()) }
|
||||
}
|
||||
setButton(MagiskDialog.ButtonType.NEGATIVE) {
|
||||
text = android.R.string.cancel
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package com.topjohnwu.magisk.dialog
|
||||
|
||||
import android.view.LayoutInflater
|
||||
import android.widget.TextView
|
||||
import androidx.annotation.CallSuper
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import com.topjohnwu.magisk.R
|
||||
import com.topjohnwu.magisk.core.di.ServiceLocator
|
||||
import com.topjohnwu.magisk.events.DialogBuilder
|
||||
import com.topjohnwu.magisk.view.MagiskDialog
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import timber.log.Timber
|
||||
import java.io.IOException
|
||||
import com.topjohnwu.magisk.core.R as CoreR
|
||||
|
||||
abstract class MarkDownDialog : DialogBuilder {
|
||||
|
||||
abstract suspend fun getMarkdownText(): String
|
||||
|
||||
@CallSuper
|
||||
override fun build(dialog: MagiskDialog) {
|
||||
with(dialog) {
|
||||
val view = LayoutInflater.from(context).inflate(R.layout.markdown_window_md2, null)
|
||||
setView(view)
|
||||
val tv = view.findViewById<TextView>(R.id.md_txt)
|
||||
activity.lifecycleScope.launch {
|
||||
try {
|
||||
val text = withContext(Dispatchers.IO) { getMarkdownText() }
|
||||
ServiceLocator.markwon.setMarkdown(tv, text)
|
||||
} catch (e: IOException) {
|
||||
Timber.e(e)
|
||||
tv.setText(CoreR.string.download_file_error)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package com.topjohnwu.magisk.dialog
|
||||
|
||||
import android.content.Context
|
||||
import com.topjohnwu.magisk.core.R
|
||||
import com.topjohnwu.magisk.core.di.ServiceLocator
|
||||
import com.topjohnwu.magisk.core.download.DownloadEngine
|
||||
import com.topjohnwu.magisk.core.download.Subject
|
||||
import com.topjohnwu.magisk.core.model.module.OnlineModule
|
||||
import com.topjohnwu.magisk.ui.flash.FlashFragment
|
||||
import com.topjohnwu.magisk.view.MagiskDialog
|
||||
import com.topjohnwu.magisk.view.Notifications
|
||||
import kotlinx.parcelize.Parcelize
|
||||
|
||||
class OnlineModuleInstallDialog(private val item: OnlineModule) : MarkDownDialog() {
|
||||
|
||||
private val svc get() = ServiceLocator.networkService
|
||||
|
||||
override suspend fun getMarkdownText(): String {
|
||||
val str = svc.fetchString(item.changelog)
|
||||
return if (str.length > 1000) str.substring(0, 1000) else str
|
||||
}
|
||||
|
||||
@Parcelize
|
||||
class Module(
|
||||
override val module: OnlineModule,
|
||||
override val autoLaunch: Boolean,
|
||||
override val notifyId: Int = Notifications.nextId()
|
||||
) : Subject.Module() {
|
||||
override fun pendingIntent(context: Context) = FlashFragment.installIntent(context, file)
|
||||
}
|
||||
|
||||
override fun build(dialog: MagiskDialog) {
|
||||
super.build(dialog)
|
||||
dialog.apply {
|
||||
|
||||
fun download(install: Boolean) {
|
||||
DownloadEngine.startWithActivity(activity, Module(item, install))
|
||||
}
|
||||
|
||||
val title = context.getString(R.string.repo_install_title,
|
||||
item.name, item.version, item.versionCode)
|
||||
|
||||
setTitle(title)
|
||||
setCancelable(true)
|
||||
setButton(MagiskDialog.ButtonType.NEGATIVE) {
|
||||
text = R.string.download
|
||||
onClick { download(false) }
|
||||
}
|
||||
setButton(MagiskDialog.ButtonType.POSITIVE) {
|
||||
text = R.string.install
|
||||
onClick { download(true) }
|
||||
}
|
||||
setButton(MagiskDialog.ButtonType.NEUTRAL) {
|
||||
text = android.R.string.cancel
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package com.topjohnwu.magisk.dialog
|
||||
|
||||
import com.topjohnwu.magisk.core.R
|
||||
import com.topjohnwu.magisk.events.DialogBuilder
|
||||
import com.topjohnwu.magisk.view.MagiskDialog
|
||||
|
||||
class SecondSlotWarningDialog : DialogBuilder {
|
||||
|
||||
override fun build(dialog: MagiskDialog) {
|
||||
dialog.apply {
|
||||
setTitle(android.R.string.dialog_alert_title)
|
||||
setMessage(R.string.install_inactive_slot_msg)
|
||||
setButton(MagiskDialog.ButtonType.POSITIVE) {
|
||||
text = android.R.string.ok
|
||||
}
|
||||
setCancelable(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package com.topjohnwu.magisk.dialog
|
||||
|
||||
import com.topjohnwu.magisk.core.R
|
||||
import com.topjohnwu.magisk.events.DialogBuilder
|
||||
import com.topjohnwu.magisk.view.MagiskDialog
|
||||
|
||||
class SuperuserRevokeDialog(
|
||||
private val appName: String,
|
||||
private val onSuccess: () -> Unit
|
||||
) : DialogBuilder {
|
||||
|
||||
override fun build(dialog: MagiskDialog) {
|
||||
dialog.apply {
|
||||
setTitle(R.string.su_revoke_title)
|
||||
setMessage(R.string.su_revoke_msg, appName)
|
||||
setButton(MagiskDialog.ButtonType.POSITIVE) {
|
||||
text = android.R.string.ok
|
||||
onClick { onSuccess() }
|
||||
}
|
||||
setButton(MagiskDialog.ButtonType.NEGATIVE) {
|
||||
text = android.R.string.cancel
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package com.topjohnwu.magisk.dialog
|
||||
|
||||
import android.app.ProgressDialog
|
||||
import android.widget.Toast
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import com.topjohnwu.magisk.arch.NavigationActivity
|
||||
import com.topjohnwu.magisk.arch.UIActivity
|
||||
import com.topjohnwu.magisk.core.R
|
||||
import com.topjohnwu.magisk.core.ktx.toast
|
||||
import com.topjohnwu.magisk.core.tasks.MagiskInstaller
|
||||
import com.topjohnwu.magisk.events.DialogBuilder
|
||||
import com.topjohnwu.magisk.ui.flash.FlashFragment
|
||||
import com.topjohnwu.magisk.view.MagiskDialog
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class UninstallDialog : DialogBuilder {
|
||||
|
||||
override fun build(dialog: MagiskDialog) {
|
||||
dialog.apply {
|
||||
setTitle(R.string.uninstall_magisk_title)
|
||||
setMessage(R.string.uninstall_magisk_msg)
|
||||
setButton(MagiskDialog.ButtonType.POSITIVE) {
|
||||
text = R.string.restore_img
|
||||
onClick { restore(dialog.activity) }
|
||||
}
|
||||
setButton(MagiskDialog.ButtonType.NEGATIVE) {
|
||||
text = R.string.complete_uninstall
|
||||
onClick { completeUninstall(dialog) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("DEPRECATION")
|
||||
private fun restore(activity: UIActivity<*>) {
|
||||
val dialog = ProgressDialog(activity).apply {
|
||||
setMessage(activity.getString(R.string.restore_img_msg))
|
||||
show()
|
||||
}
|
||||
|
||||
activity.lifecycleScope.launch {
|
||||
MagiskInstaller.Restore().exec { success ->
|
||||
dialog.dismiss()
|
||||
if (success) {
|
||||
activity.toast(R.string.restore_done, Toast.LENGTH_SHORT)
|
||||
} else {
|
||||
activity.toast(R.string.restore_fail, Toast.LENGTH_LONG)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun completeUninstall(dialog: MagiskDialog) {
|
||||
(dialog.ownerActivity as NavigationActivity<*>)
|
||||
.navigation.navigate(FlashFragment.uninstall())
|
||||
}
|
||||
|
||||
}
|
||||
124
app/apk/src/main/java/com/topjohnwu/magisk/events/ViewEvents.kt
Normal file
124
app/apk/src/main/java/com/topjohnwu/magisk/events/ViewEvents.kt
Normal file
@@ -0,0 +1,124 @@
|
||||
package com.topjohnwu.magisk.events
|
||||
|
||||
import android.content.Context
|
||||
import android.view.View
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.navigation.NavDirections
|
||||
import com.google.android.material.snackbar.Snackbar
|
||||
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.ViewEvent
|
||||
import com.topjohnwu.magisk.core.base.ContentResultCallback
|
||||
import com.topjohnwu.magisk.core.base.relaunch
|
||||
import com.topjohnwu.magisk.utils.TextHolder
|
||||
import com.topjohnwu.magisk.utils.asText
|
||||
import com.topjohnwu.magisk.view.MagiskDialog
|
||||
import com.topjohnwu.magisk.view.Shortcuts
|
||||
|
||||
class PermissionEvent(
|
||||
private val permission: String,
|
||||
private val callback: (Boolean) -> Unit
|
||||
) : ViewEvent(), ActivityExecutor {
|
||||
|
||||
override fun invoke(activity: UIActivity<*>) =
|
||||
activity.withPermission(permission, callback)
|
||||
}
|
||||
|
||||
class BackPressEvent : ViewEvent(), ActivityExecutor {
|
||||
override fun invoke(activity: UIActivity<*>) {
|
||||
activity.onBackPressed()
|
||||
}
|
||||
}
|
||||
|
||||
class DieEvent : ViewEvent(), ActivityExecutor {
|
||||
override fun invoke(activity: UIActivity<*>) {
|
||||
activity.finish()
|
||||
}
|
||||
}
|
||||
|
||||
class ShowUIEvent(private val delegate: View.AccessibilityDelegate?)
|
||||
: ViewEvent(), ActivityExecutor {
|
||||
override fun invoke(activity: UIActivity<*>) {
|
||||
activity.setContentView()
|
||||
activity.setAccessibilityDelegate(delegate)
|
||||
}
|
||||
}
|
||||
|
||||
class RecreateEvent : ViewEvent(), ActivityExecutor {
|
||||
override fun invoke(activity: UIActivity<*>) {
|
||||
activity.relaunch()
|
||||
}
|
||||
}
|
||||
|
||||
class AuthEvent(
|
||||
private val callback: () -> Unit
|
||||
) : ViewEvent(), ActivityExecutor {
|
||||
|
||||
override fun invoke(activity: UIActivity<*>) {
|
||||
activity.withAuthentication { if (it) callback() }
|
||||
}
|
||||
}
|
||||
|
||||
class GetContentEvent(
|
||||
private val type: String,
|
||||
private val callback: ContentResultCallback
|
||||
) : ViewEvent(), ActivityExecutor {
|
||||
override fun invoke(activity: UIActivity<*>) {
|
||||
activity.getContent(type, callback)
|
||||
}
|
||||
}
|
||||
|
||||
class NavigationEvent(
|
||||
private val directions: NavDirections,
|
||||
private val pop: Boolean
|
||||
) : ViewEvent(), ActivityExecutor {
|
||||
override fun invoke(activity: UIActivity<*>) {
|
||||
(activity as? NavigationActivity<*>)?.apply {
|
||||
if (pop) navigation.popBackStack()
|
||||
directions.navigate()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class AddHomeIconEvent : ViewEvent(), ContextExecutor {
|
||||
override fun invoke(context: Context) {
|
||||
Shortcuts.addHomeIcon(context)
|
||||
}
|
||||
}
|
||||
|
||||
class SnackbarEvent(
|
||||
private val msg: TextHolder,
|
||||
private val length: Int = Snackbar.LENGTH_SHORT,
|
||||
private val builder: Snackbar.() -> Unit = {}
|
||||
) : ViewEvent(), ActivityExecutor {
|
||||
|
||||
constructor(
|
||||
@StringRes res: Int,
|
||||
length: Int = Snackbar.LENGTH_SHORT,
|
||||
builder: Snackbar.() -> Unit = {}
|
||||
) : this(res.asText(), length, builder)
|
||||
|
||||
constructor(
|
||||
msg: String,
|
||||
length: Int = Snackbar.LENGTH_SHORT,
|
||||
builder: Snackbar.() -> Unit = {}
|
||||
) : this(msg.asText(), length, builder)
|
||||
|
||||
override fun invoke(activity: UIActivity<*>) {
|
||||
activity.showSnackbar(msg.getText(activity.resources), length, builder)
|
||||
}
|
||||
}
|
||||
|
||||
class DialogEvent(
|
||||
private val builder: DialogBuilder
|
||||
) : ViewEvent(), ActivityExecutor {
|
||||
override fun invoke(activity: UIActivity<*>) {
|
||||
MagiskDialog(activity).apply(builder::build).show()
|
||||
}
|
||||
}
|
||||
|
||||
interface DialogBuilder {
|
||||
fun build(dialog: MagiskDialog)
|
||||
}
|
||||
@@ -1,452 +0,0 @@
|
||||
package com.topjohnwu.magisk.terminal
|
||||
|
||||
import java.util.Arrays
|
||||
|
||||
/**
|
||||
* A circular buffer of [TerminalRow]s which keeps notes about what is visible on a logical screen and the scroll
|
||||
* history.
|
||||
*
|
||||
* See [externalToInternalRow] for how to map from logical screen rows to array indices.
|
||||
*/
|
||||
class TerminalBuffer(columns: Int, totalRows: Int, screenRows: Int) {
|
||||
|
||||
var lines: Array<TerminalRow?>
|
||||
|
||||
/** The length of [lines]. */
|
||||
var totalRows: Int = totalRows
|
||||
private set
|
||||
|
||||
/** The number of rows and columns visible on the screen. */
|
||||
var screenRows: Int = screenRows
|
||||
|
||||
var columns: Int = columns
|
||||
|
||||
/** The number of rows kept in history. */
|
||||
var activeTranscriptRows: Int = 0
|
||||
private set
|
||||
|
||||
/** The index in the circular buffer where the visible screen starts. */
|
||||
private var screenFirstRow = 0
|
||||
|
||||
init {
|
||||
lines = arrayOfNulls(totalRows)
|
||||
blockSet(0, 0, columns, screenRows, ' '.code, TextStyle.NORMAL)
|
||||
}
|
||||
|
||||
val transcriptText: String
|
||||
get() = getSelectedText(0, -activeTranscriptRows, columns, screenRows).trim()
|
||||
|
||||
val transcriptTextWithoutJoinedLines: String
|
||||
get() = getSelectedText(0, -activeTranscriptRows, columns, screenRows, false).trim()
|
||||
|
||||
val transcriptTextWithFullLinesJoined: String
|
||||
get() = getSelectedText(0, -activeTranscriptRows, columns, screenRows, joinBackLines = true, joinFullLines = true).trim()
|
||||
|
||||
fun getSelectedText(selX1: Int, selY1: Int, selX2: Int, selY2: Int, joinBackLines: Boolean = true, joinFullLines: Boolean = false): String {
|
||||
val builder = StringBuilder()
|
||||
|
||||
var y1 = selY1
|
||||
var y2 = selY2
|
||||
if (y1 < -activeTranscriptRows) y1 = -activeTranscriptRows
|
||||
if (y2 >= screenRows) y2 = screenRows - 1
|
||||
|
||||
for (row in y1..y2) {
|
||||
val x1 = if (row == y1) selX1 else 0
|
||||
var x2: Int
|
||||
if (row == y2) {
|
||||
x2 = selX2 + 1
|
||||
if (x2 > columns) x2 = columns
|
||||
} else {
|
||||
x2 = columns
|
||||
}
|
||||
val lineObject = lines[externalToInternalRow(row)]!!
|
||||
val x1Index = lineObject.findStartOfColumn(x1)
|
||||
var x2Index = if (x2 < columns) lineObject.findStartOfColumn(x2) else lineObject.spaceUsed
|
||||
if (x2Index == x1Index) {
|
||||
x2Index = lineObject.findStartOfColumn(x2 + 1)
|
||||
}
|
||||
val line = lineObject.text
|
||||
var lastPrintingCharIndex = -1
|
||||
val rowLineWrap = getLineWrap(row)
|
||||
if (rowLineWrap && x2 == columns) {
|
||||
lastPrintingCharIndex = x2Index - 1
|
||||
} else {
|
||||
for (i in x1Index until x2Index) {
|
||||
val c = line[i]
|
||||
if (c != ' ') lastPrintingCharIndex = i
|
||||
}
|
||||
}
|
||||
|
||||
val len = lastPrintingCharIndex - x1Index + 1
|
||||
if (lastPrintingCharIndex != -1 && len > 0)
|
||||
builder.append(line, x1Index, len)
|
||||
|
||||
val lineFillsWidth = lastPrintingCharIndex == x2Index - 1
|
||||
if ((!joinBackLines || !rowLineWrap) && (!joinFullLines || !lineFillsWidth)
|
||||
&& row < y2 && row < screenRows - 1) builder.append('\n')
|
||||
}
|
||||
return builder.toString()
|
||||
}
|
||||
|
||||
fun getWordAtLocation(x: Int, y: Int): String {
|
||||
var y1 = y
|
||||
var y2 = y
|
||||
while (y1 > 0 && !getSelectedText(0, y1 - 1, columns, y, joinBackLines = true, joinFullLines = true).contains("\n")) {
|
||||
y1--
|
||||
}
|
||||
while (y2 < screenRows && !getSelectedText(0, y, columns, y2 + 1, joinBackLines = true, joinFullLines = true).contains("\n")) {
|
||||
y2++
|
||||
}
|
||||
|
||||
val text = getSelectedText(0, y1, columns, y2, joinBackLines = true, joinFullLines = true)
|
||||
val textOffset = (y - y1) * columns + x
|
||||
|
||||
if (textOffset >= text.length) {
|
||||
return ""
|
||||
}
|
||||
|
||||
val x1 = text.lastIndexOf(' ', textOffset)
|
||||
var x2 = text.indexOf(' ', textOffset)
|
||||
if (x2 == -1) {
|
||||
x2 = text.length
|
||||
}
|
||||
|
||||
if (x1 == x2) {
|
||||
return ""
|
||||
}
|
||||
return text.substring(x1 + 1, x2)
|
||||
}
|
||||
|
||||
val activeRows: Int get() = activeTranscriptRows + screenRows
|
||||
|
||||
/**
|
||||
* Convert a row value from the public external coordinate system to our internal private coordinate system.
|
||||
*
|
||||
* ```
|
||||
* - External coordinate system: -activeTranscriptRows to screenRows-1, with the screen being 0..screenRows-1.
|
||||
* - Internal coordinate system: the screenRows lines starting at screenFirstRow comprise the screen, while the
|
||||
* activeTranscriptRows lines ending at screenFirstRow-1 form the transcript (as a circular buffer).
|
||||
*
|
||||
* External <-> Internal:
|
||||
*
|
||||
* [ ... ] [ ... ]
|
||||
* [ -activeTranscriptRows ] [ screenFirstRow - activeTranscriptRows ]
|
||||
* [ ... ] [ ... ]
|
||||
* [ 0 (visible screen starts here) ] <-> [ screenFirstRow ]
|
||||
* [ ... ] [ ... ]
|
||||
* [ screenRows-1 ] [ screenFirstRow + screenRows-1 ]
|
||||
* ```
|
||||
*
|
||||
* @param externalRow a row in the external coordinate system.
|
||||
* @return The row corresponding to the input argument in the private coordinate system.
|
||||
*/
|
||||
fun externalToInternalRow(externalRow: Int): Int {
|
||||
if (externalRow < -activeTranscriptRows || externalRow > screenRows)
|
||||
throw IllegalArgumentException("extRow=$externalRow, screenRows=$screenRows, activeTranscriptRows=$activeTranscriptRows")
|
||||
val internalRow = screenFirstRow + externalRow
|
||||
return if (internalRow < 0) (totalRows + internalRow) else (internalRow % totalRows)
|
||||
}
|
||||
|
||||
fun setLineWrap(row: Int) {
|
||||
lines[externalToInternalRow(row)]!!.lineWrap = true
|
||||
}
|
||||
|
||||
fun getLineWrap(row: Int): Boolean {
|
||||
return lines[externalToInternalRow(row)]!!.lineWrap
|
||||
}
|
||||
|
||||
fun clearLineWrap(row: Int) {
|
||||
lines[externalToInternalRow(row)]!!.lineWrap = false
|
||||
}
|
||||
|
||||
/**
|
||||
* Resize the screen which this transcript backs. Currently, this only works if the number of columns does not
|
||||
* change or the rows expand (that is, it only works when shrinking the number of rows).
|
||||
*
|
||||
* @param newColumns The number of columns the screen should have.
|
||||
* @param newRows The number of rows the screen should have.
|
||||
* @param cursor An int[2] containing the (column, row) cursor location.
|
||||
*/
|
||||
fun resize(newColumns: Int, newRows: Int, newTotalRows: Int, cursor: IntArray, currentStyle: Long, altScreen: Boolean) {
|
||||
// newRows > totalRows should not normally happen since totalRows is TRANSCRIPT_ROWS (10000):
|
||||
if (newColumns == columns && newRows <= totalRows) {
|
||||
// Fast resize where just the rows changed.
|
||||
var shiftDownOfTopRow = screenRows - newRows
|
||||
if (shiftDownOfTopRow > 0 && shiftDownOfTopRow < screenRows) {
|
||||
// Shrinking. Check if we can skip blank rows at bottom below cursor.
|
||||
for (i in screenRows - 1 downTo 1) {
|
||||
if (cursor[1] >= i) break
|
||||
val r = externalToInternalRow(i)
|
||||
if (lines[r] == null || lines[r]!!.isBlank()) {
|
||||
if (--shiftDownOfTopRow == 0) break
|
||||
}
|
||||
}
|
||||
} else if (shiftDownOfTopRow < 0) {
|
||||
// Negative shift down = expanding. Only move screen up if there is transcript to show:
|
||||
val actualShift = maxOf(shiftDownOfTopRow, -activeTranscriptRows)
|
||||
if (shiftDownOfTopRow != actualShift) {
|
||||
for (i in 0 until actualShift - shiftDownOfTopRow)
|
||||
allocateFullLineIfNecessary((screenFirstRow + screenRows + i) % totalRows).clear(currentStyle)
|
||||
shiftDownOfTopRow = actualShift
|
||||
}
|
||||
}
|
||||
screenFirstRow += shiftDownOfTopRow
|
||||
screenFirstRow = if (screenFirstRow < 0) (screenFirstRow + totalRows) else (screenFirstRow % totalRows)
|
||||
totalRows = newTotalRows
|
||||
activeTranscriptRows = if (altScreen) 0 else maxOf(0, activeTranscriptRows + shiftDownOfTopRow)
|
||||
cursor[1] -= shiftDownOfTopRow
|
||||
screenRows = newRows
|
||||
} else {
|
||||
// Copy away old state and update new:
|
||||
val oldLines = lines
|
||||
lines = arrayOfNulls(newTotalRows)
|
||||
for (i in 0 until newTotalRows)
|
||||
lines[i] = TerminalRow(newColumns, currentStyle)
|
||||
|
||||
val oldActiveTranscriptRows = activeTranscriptRows
|
||||
val oldScreenFirstRow = screenFirstRow
|
||||
val oldScreenRows = screenRows
|
||||
val oldTotalRows = totalRows
|
||||
totalRows = newTotalRows
|
||||
screenRows = newRows
|
||||
activeTranscriptRows = 0
|
||||
screenFirstRow = 0
|
||||
columns = newColumns
|
||||
|
||||
var newCursorRow = -1
|
||||
var newCursorColumn = -1
|
||||
val oldCursorRow = cursor[1]
|
||||
val oldCursorColumn = cursor[0]
|
||||
var newCursorPlaced = false
|
||||
|
||||
var currentOutputExternalRow = 0
|
||||
var currentOutputExternalColumn = 0
|
||||
|
||||
var skippedBlankLines = 0
|
||||
for (externalOldRow in -oldActiveTranscriptRows until oldScreenRows) {
|
||||
var internalOldRow = oldScreenFirstRow + externalOldRow
|
||||
internalOldRow = if (internalOldRow < 0) (oldTotalRows + internalOldRow) else (internalOldRow % oldTotalRows)
|
||||
|
||||
val oldLine = oldLines[internalOldRow]
|
||||
val cursorAtThisRow = externalOldRow == oldCursorRow
|
||||
if (oldLine == null || (!(!newCursorPlaced && cursorAtThisRow)) && oldLine.isBlank()) {
|
||||
skippedBlankLines++
|
||||
continue
|
||||
} else if (skippedBlankLines > 0) {
|
||||
for (i in 0 until skippedBlankLines) {
|
||||
if (currentOutputExternalRow == screenRows - 1) {
|
||||
scrollDownOneLine(0, screenRows, currentStyle)
|
||||
} else {
|
||||
currentOutputExternalRow++
|
||||
}
|
||||
currentOutputExternalColumn = 0
|
||||
}
|
||||
skippedBlankLines = 0
|
||||
}
|
||||
|
||||
var lastNonSpaceIndex = 0
|
||||
var justToCursor = false
|
||||
if (cursorAtThisRow || oldLine.lineWrap) {
|
||||
lastNonSpaceIndex = oldLine.spaceUsed
|
||||
if (cursorAtThisRow) justToCursor = true
|
||||
} else {
|
||||
for (i in 0 until oldLine.spaceUsed)
|
||||
// NEWLY INTRODUCED BUG! Should not index oldLine.styles with char indices
|
||||
if (oldLine.text[i] != ' '/* || oldLine.styles[i] != currentStyle */)
|
||||
lastNonSpaceIndex = i + 1
|
||||
}
|
||||
|
||||
var currentOldCol = 0
|
||||
var styleAtCol = 0L
|
||||
var i = 0
|
||||
while (i < lastNonSpaceIndex) {
|
||||
val c = oldLine.text[i]
|
||||
val codePoint: Int
|
||||
if (Character.isHighSurrogate(c)) {
|
||||
i++
|
||||
codePoint = Character.toCodePoint(c, oldLine.text[i])
|
||||
} else {
|
||||
codePoint = c.code
|
||||
}
|
||||
val displayWidth = WcWidth.width(codePoint)
|
||||
if (displayWidth > 0) styleAtCol = oldLine.getStyle(currentOldCol)
|
||||
|
||||
if (currentOutputExternalColumn + displayWidth > columns) {
|
||||
setLineWrap(currentOutputExternalRow)
|
||||
if (currentOutputExternalRow == screenRows - 1) {
|
||||
if (newCursorPlaced) newCursorRow--
|
||||
scrollDownOneLine(0, screenRows, currentStyle)
|
||||
} else {
|
||||
currentOutputExternalRow++
|
||||
}
|
||||
currentOutputExternalColumn = 0
|
||||
}
|
||||
|
||||
val offsetDueToCombiningChar = if (displayWidth <= 0 && currentOutputExternalColumn > 0) 1 else 0
|
||||
val outputColumn = currentOutputExternalColumn - offsetDueToCombiningChar
|
||||
setChar(outputColumn, currentOutputExternalRow, codePoint, styleAtCol)
|
||||
|
||||
if (displayWidth > 0) {
|
||||
if (oldCursorRow == externalOldRow && oldCursorColumn == currentOldCol) {
|
||||
newCursorColumn = currentOutputExternalColumn
|
||||
newCursorRow = currentOutputExternalRow
|
||||
newCursorPlaced = true
|
||||
}
|
||||
currentOldCol += displayWidth
|
||||
currentOutputExternalColumn += displayWidth
|
||||
if (justToCursor && newCursorPlaced) break
|
||||
}
|
||||
i++
|
||||
}
|
||||
if (externalOldRow != (oldScreenRows - 1) && !oldLine.lineWrap) {
|
||||
if (currentOutputExternalRow == screenRows - 1) {
|
||||
if (newCursorPlaced) newCursorRow--
|
||||
scrollDownOneLine(0, screenRows, currentStyle)
|
||||
} else {
|
||||
currentOutputExternalRow++
|
||||
}
|
||||
currentOutputExternalColumn = 0
|
||||
}
|
||||
}
|
||||
|
||||
cursor[0] = newCursorColumn
|
||||
cursor[1] = newCursorRow
|
||||
}
|
||||
|
||||
// Handle cursor scrolling off screen:
|
||||
if (cursor[0] < 0 || cursor[1] < 0) {
|
||||
cursor[0] = 0
|
||||
cursor[1] = 0
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Block copy lines and associated metadata from one location to another in the circular buffer, taking wraparound
|
||||
* into account.
|
||||
*
|
||||
* @param srcInternal The first line to be copied.
|
||||
* @param len The number of lines to be copied.
|
||||
*/
|
||||
private fun blockCopyLinesDown(srcInternal: Int, len: Int) {
|
||||
if (len == 0) return
|
||||
|
||||
val start = len - 1
|
||||
val lineToBeOverWritten = lines[(srcInternal + start + 1) % totalRows]
|
||||
for (i in start downTo 0)
|
||||
lines[(srcInternal + i + 1) % totalRows] = lines[(srcInternal + i) % totalRows]
|
||||
lines[srcInternal % totalRows] = lineToBeOverWritten
|
||||
}
|
||||
|
||||
/**
|
||||
* Scroll the screen down one line. To scroll the whole screen of a 24 line screen, the arguments would be (0, 24).
|
||||
*
|
||||
* @param topMargin First line that is scrolled.
|
||||
* @param bottomMargin One line after the last line that is scrolled.
|
||||
* @param style the style for the newly exposed line.
|
||||
*/
|
||||
fun scrollDownOneLine(topMargin: Int, bottomMargin: Int, style: Long) {
|
||||
if (topMargin > bottomMargin - 1 || topMargin < 0 || bottomMargin > screenRows)
|
||||
throw IllegalArgumentException("topMargin=$topMargin, bottomMargin=$bottomMargin, screenRows=$screenRows")
|
||||
|
||||
blockCopyLinesDown(screenFirstRow, topMargin)
|
||||
blockCopyLinesDown(externalToInternalRow(bottomMargin), screenRows - bottomMargin)
|
||||
|
||||
screenFirstRow = (screenFirstRow + 1) % totalRows
|
||||
if (activeTranscriptRows < totalRows - screenRows) activeTranscriptRows++
|
||||
|
||||
val blankRow = externalToInternalRow(bottomMargin - 1)
|
||||
if (lines[blankRow] == null) {
|
||||
lines[blankRow] = TerminalRow(columns, style)
|
||||
} else {
|
||||
lines[blankRow]!!.clear(style)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Block copy characters from one position in the screen to another. The two positions can overlap. All characters
|
||||
* of the source and destination must be within the bounds of the screen, or else an InvalidParameterException will
|
||||
* be thrown.
|
||||
*
|
||||
* @param sx source X coordinate
|
||||
* @param sy source Y coordinate
|
||||
* @param w width
|
||||
* @param h height
|
||||
* @param dx destination X coordinate
|
||||
* @param dy destination Y coordinate
|
||||
*/
|
||||
fun blockCopy(sx: Int, sy: Int, w: Int, h: Int, dx: Int, dy: Int) {
|
||||
if (w == 0) return
|
||||
if (sx < 0 || sx + w > columns || sy < 0 || sy + h > screenRows || dx < 0 || dx + w > columns || dy < 0 || dy + h > screenRows)
|
||||
throw IllegalArgumentException()
|
||||
val copyingUp = sy > dy
|
||||
for (y in 0 until h) {
|
||||
val y2 = if (copyingUp) y else (h - (y + 1))
|
||||
val sourceRow = allocateFullLineIfNecessary(externalToInternalRow(sy + y2))
|
||||
allocateFullLineIfNecessary(externalToInternalRow(dy + y2)).copyInterval(sourceRow, sx, sx + w, dx)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Block set characters. All characters must be within the bounds of the screen, or else an
|
||||
* InvalidParameterException will be thrown. Typically this is called with a "val" argument of 32 to clear a block
|
||||
* of characters.
|
||||
*/
|
||||
fun blockSet(sx: Int, sy: Int, w: Int, h: Int, `val`: Int, style: Long) {
|
||||
if (sx < 0 || sx + w > columns || sy < 0 || sy + h > screenRows) {
|
||||
throw IllegalArgumentException(
|
||||
"Illegal arguments! blockSet($sx, $sy, $w, $h, $`val`, $columns, $screenRows)")
|
||||
}
|
||||
for (y in 0 until h)
|
||||
for (x in 0 until w)
|
||||
setChar(sx + x, sy + y, `val`, style)
|
||||
}
|
||||
|
||||
fun allocateFullLineIfNecessary(row: Int): TerminalRow {
|
||||
return lines[row] ?: TerminalRow(columns, 0).also { lines[row] = it }
|
||||
}
|
||||
|
||||
fun setChar(column: Int, row: Int, codePoint: Int, style: Long) {
|
||||
if (row < 0 || row >= screenRows || column < 0 || column >= columns)
|
||||
throw IllegalArgumentException("TerminalBuffer.setChar(): row=$row, column=$column, screenRows=$screenRows, columns=$columns")
|
||||
val internalRow = externalToInternalRow(row)
|
||||
allocateFullLineIfNecessary(internalRow).setChar(column, codePoint, style)
|
||||
}
|
||||
|
||||
fun getStyleAt(externalRow: Int, column: Int): Long {
|
||||
return allocateFullLineIfNecessary(externalToInternalRow(externalRow)).getStyle(column)
|
||||
}
|
||||
|
||||
/** Support for http://vt100.net/docs/vt510-rm/DECCARA and http://vt100.net/docs/vt510-rm/DECCARA */
|
||||
fun setOrClearEffect(bits: Int, setOrClear: Boolean, reverse: Boolean, rectangular: Boolean, leftMargin: Int, rightMargin: Int, top: Int, left: Int,
|
||||
bottom: Int, right: Int) {
|
||||
for (y in top until bottom) {
|
||||
val line = lines[externalToInternalRow(y)]!!
|
||||
val startOfLine = if (rectangular || y == top) left else leftMargin
|
||||
val endOfLine = if (rectangular || y + 1 == bottom) right else rightMargin
|
||||
for (x in startOfLine until endOfLine) {
|
||||
val currentStyle = line.getStyle(x)
|
||||
val foreColor = TextStyle.decodeForeColor(currentStyle)
|
||||
val backColor = TextStyle.decodeBackColor(currentStyle)
|
||||
var effect = TextStyle.decodeEffect(currentStyle)
|
||||
if (reverse) {
|
||||
effect = (effect and bits.inv()) or (bits and effect.inv())
|
||||
} else if (setOrClear) {
|
||||
effect = effect or bits
|
||||
} else {
|
||||
effect = effect and bits.inv()
|
||||
}
|
||||
line.styles[x] = TextStyle.encode(foreColor, backColor, effect)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun clearTranscript() {
|
||||
if (screenFirstRow < activeTranscriptRows) {
|
||||
Arrays.fill(lines, totalRows + screenFirstRow - activeTranscriptRows, totalRows, null)
|
||||
Arrays.fill(lines, 0, screenFirstRow, null)
|
||||
} else {
|
||||
Arrays.fill(lines, screenFirstRow - activeTranscriptRows, screenFirstRow, null)
|
||||
}
|
||||
activeTranscriptRows = 0
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,62 +0,0 @@
|
||||
package com.topjohnwu.magisk.terminal
|
||||
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import com.topjohnwu.superuser.Shell
|
||||
import timber.log.Timber
|
||||
|
||||
private val busyboxPath: String by lazy {
|
||||
Shell.cmd("readlink /proc/self/exe").exec().out.firstOrNull()
|
||||
?: "/data/adb/magisk/busybox"
|
||||
}
|
||||
|
||||
private val mainHandler = Handler(Looper.getMainLooper())
|
||||
|
||||
fun TerminalEmulator.appendOnMain(bytes: ByteArray, len: Int) {
|
||||
mainHandler.post {
|
||||
append(bytes, len)
|
||||
onScreenUpdate?.invoke()
|
||||
}
|
||||
}
|
||||
|
||||
fun TerminalEmulator.appendLineOnMain(line: String) {
|
||||
val bytes = "$line\r\n".toByteArray(Charsets.UTF_8)
|
||||
appendOnMain(bytes, bytes.size)
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a command as root inside a PTY (via busybox script).
|
||||
* Reads raw bytes from the process and feeds them to the terminal emulator.
|
||||
* Must be called from a background thread.
|
||||
* Returns true if the process exits with code 0.
|
||||
*/
|
||||
fun runSuCommand(emulator: TerminalEmulator, command: String): Boolean {
|
||||
return try {
|
||||
val cols = emulator.mColumns
|
||||
val rows = emulator.mRows
|
||||
val wrappedCmd = "export TERM=xterm-256color; stty cols $cols rows $rows 2>/dev/null; $command"
|
||||
val escapedCmd = wrappedCmd.replace("'", "'\\''")
|
||||
|
||||
val process = ProcessBuilder(
|
||||
"su", "-c",
|
||||
"$busyboxPath script -q -c '$escapedCmd' /dev/null"
|
||||
).redirectErrorStream(true).start()
|
||||
|
||||
process.outputStream.close()
|
||||
|
||||
val buffer = ByteArray(4096)
|
||||
process.inputStream.use { input ->
|
||||
while (true) {
|
||||
val n = input.read(buffer)
|
||||
if (n == -1) break
|
||||
emulator.appendOnMain(buffer.copyOf(n), n)
|
||||
}
|
||||
}
|
||||
|
||||
process.waitFor() == 0
|
||||
} catch (e: Exception) {
|
||||
Timber.e(e, "runSuCommand failed")
|
||||
emulator.appendLineOnMain("! Error: ${e.message}")
|
||||
false
|
||||
}
|
||||
}
|
||||
@@ -1,267 +0,0 @@
|
||||
package com.topjohnwu.magisk.terminal
|
||||
|
||||
import java.util.Arrays
|
||||
|
||||
/**
|
||||
* A row in a terminal, composed of a fixed number of cells.
|
||||
*
|
||||
* The text in the row is stored in a char[] array, [text], for quick access during rendering.
|
||||
*/
|
||||
class TerminalRow(private val columns: Int, style: Long) {
|
||||
|
||||
/**
|
||||
* Max combining characters that can exist in a column, that are separate from the base character
|
||||
* itself. Any additional combining characters will be ignored and not added to the column.
|
||||
*
|
||||
* There does not seem to be limit in unicode standard for max number of combination characters
|
||||
* that can be combined but such characters are primarily under 10.
|
||||
*
|
||||
* "Section 3.6 Combination" of unicode standard contains combining characters info.
|
||||
* - https://www.unicode.org/versions/Unicode15.0.0/ch03.pdf
|
||||
* - https://en.wikipedia.org/wiki/Combining_character#Unicode_ranges
|
||||
* - https://stackoverflow.com/questions/71237212/what-is-the-maximum-number-of-unicode-combined-characters-that-may-be-needed-to
|
||||
*
|
||||
* UAX15-D3 Stream-Safe Text Format limits to max 30 combining characters.
|
||||
* > The value of 30 is chosen to be significantly beyond what is required for any linguistic or technical usage.
|
||||
* > While it would have been feasible to chose a smaller number, this value provides a very wide margin,
|
||||
* > yet is well within the buffer size limits of practical implementations.
|
||||
* - https://unicode.org/reports/tr15/#Stream_Safe_Text_Format
|
||||
* - https://stackoverflow.com/a/11983435/14686958
|
||||
*
|
||||
* We choose the value 15 because it should be enough for terminal based applications and keep
|
||||
* the memory usage low for a terminal row, won't affect performance or cause terminal to
|
||||
* lag or hang, and will keep malicious applications from causing harm. The value can be
|
||||
* increased if ever needed for legitimate applications.
|
||||
*/
|
||||
companion object {
|
||||
private const val SPARE_CAPACITY_FACTOR = 1.5f
|
||||
private const val MAX_COMBINING_CHARACTERS_PER_COLUMN = 15
|
||||
}
|
||||
|
||||
/** The text filling this terminal row. */
|
||||
var text: CharArray = CharArray((SPARE_CAPACITY_FACTOR * columns).toInt())
|
||||
|
||||
/** The number of java chars used in [text]. */
|
||||
private var _spaceUsed: Short = 0
|
||||
|
||||
/** If this row has been line wrapped due to text output at the end of line. */
|
||||
var lineWrap: Boolean = false
|
||||
|
||||
/** The style bits of each cell in the row. See [TextStyle]. */
|
||||
val styles: LongArray = LongArray(columns)
|
||||
|
||||
/** If this row might contain chars with width != 1, used for deactivating fast path */
|
||||
var hasNonOneWidthOrSurrogateChars: Boolean = false
|
||||
|
||||
init {
|
||||
clear(style)
|
||||
}
|
||||
|
||||
/** NOTE: The sourceX2 is exclusive. */
|
||||
fun copyInterval(line: TerminalRow, sourceX1: Int, sourceX2: Int, destinationX: Int) {
|
||||
hasNonOneWidthOrSurrogateChars = hasNonOneWidthOrSurrogateChars or line.hasNonOneWidthOrSurrogateChars
|
||||
val x1 = line.findStartOfColumn(sourceX1)
|
||||
val x2 = line.findStartOfColumn(sourceX2)
|
||||
var startingFromSecondHalfOfWideChar = sourceX1 > 0 && line.wideDisplayCharacterStartingAt(sourceX1 - 1)
|
||||
val sourceChars = if (this === line) line.text.copyOf() else line.text
|
||||
var latestNonCombiningWidth = 0
|
||||
var destX = destinationX
|
||||
var srcX1 = sourceX1
|
||||
var i = x1
|
||||
while (i < x2) {
|
||||
val sourceChar = sourceChars[i]
|
||||
var codePoint: Int
|
||||
if (Character.isHighSurrogate(sourceChar)) {
|
||||
i++
|
||||
codePoint = Character.toCodePoint(sourceChar, sourceChars[i])
|
||||
} else {
|
||||
codePoint = sourceChar.code
|
||||
}
|
||||
if (startingFromSecondHalfOfWideChar) {
|
||||
codePoint = ' '.code
|
||||
startingFromSecondHalfOfWideChar = false
|
||||
}
|
||||
val w = WcWidth.width(codePoint)
|
||||
if (w > 0) {
|
||||
destX += latestNonCombiningWidth
|
||||
srcX1 += latestNonCombiningWidth
|
||||
latestNonCombiningWidth = w
|
||||
}
|
||||
setChar(destX, codePoint, line.getStyle(srcX1))
|
||||
i++
|
||||
}
|
||||
}
|
||||
|
||||
val spaceUsed: Int get() = _spaceUsed.toInt()
|
||||
|
||||
/** Note that the column may end of second half of wide character. */
|
||||
fun findStartOfColumn(column: Int): Int {
|
||||
if (column == columns) return spaceUsed
|
||||
|
||||
var currentColumn = 0
|
||||
var currentCharIndex = 0
|
||||
while (true) {
|
||||
var newCharIndex = currentCharIndex
|
||||
val c = text[newCharIndex++]
|
||||
val isHigh = Character.isHighSurrogate(c)
|
||||
val codePoint = if (isHigh) Character.toCodePoint(c, text[newCharIndex++]) else c.code
|
||||
val wcwidth = WcWidth.width(codePoint)
|
||||
if (wcwidth > 0) {
|
||||
currentColumn += wcwidth
|
||||
if (currentColumn == column) {
|
||||
while (newCharIndex < _spaceUsed) {
|
||||
if (Character.isHighSurrogate(text[newCharIndex])) {
|
||||
if (WcWidth.width(Character.toCodePoint(text[newCharIndex], text[newCharIndex + 1])) <= 0) {
|
||||
newCharIndex += 2
|
||||
} else {
|
||||
break
|
||||
}
|
||||
} else if (WcWidth.width(text[newCharIndex].code) <= 0) {
|
||||
newCharIndex++
|
||||
} else {
|
||||
break
|
||||
}
|
||||
}
|
||||
return newCharIndex
|
||||
} else if (currentColumn > column) {
|
||||
return currentCharIndex
|
||||
}
|
||||
}
|
||||
currentCharIndex = newCharIndex
|
||||
}
|
||||
}
|
||||
|
||||
private fun wideDisplayCharacterStartingAt(column: Int): Boolean {
|
||||
var currentCharIndex = 0
|
||||
var currentColumn = 0
|
||||
while (currentCharIndex < _spaceUsed) {
|
||||
val c = text[currentCharIndex++]
|
||||
val codePoint = if (Character.isHighSurrogate(c)) Character.toCodePoint(c, text[currentCharIndex++]) else c.code
|
||||
val wcwidth = WcWidth.width(codePoint)
|
||||
if (wcwidth > 0) {
|
||||
if (currentColumn == column && wcwidth == 2) return true
|
||||
currentColumn += wcwidth
|
||||
if (currentColumn > column) return false
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
fun clear(style: Long) {
|
||||
Arrays.fill(text, ' ')
|
||||
Arrays.fill(styles, style)
|
||||
_spaceUsed = columns.toShort()
|
||||
hasNonOneWidthOrSurrogateChars = false
|
||||
}
|
||||
|
||||
// https://github.com/steven676/Android-Terminal-Emulator/commit/9a47042620bec87617f0b4f5d50568535668fe26
|
||||
fun setChar(columnToSet: Int, codePoint: Int, style: Long) {
|
||||
if (columnToSet < 0 || columnToSet >= styles.size)
|
||||
throw IllegalArgumentException("TerminalRow.setChar(): columnToSet=$columnToSet, codePoint=$codePoint, style=$style")
|
||||
|
||||
styles[columnToSet] = style
|
||||
|
||||
val newCodePointDisplayWidth = WcWidth.width(codePoint)
|
||||
|
||||
// Fast path when we don't have any chars with width != 1
|
||||
if (!hasNonOneWidthOrSurrogateChars) {
|
||||
if (codePoint >= Character.MIN_SUPPLEMENTARY_CODE_POINT || newCodePointDisplayWidth != 1) {
|
||||
hasNonOneWidthOrSurrogateChars = true
|
||||
} else {
|
||||
text[columnToSet] = codePoint.toChar()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
val newIsCombining = newCodePointDisplayWidth <= 0
|
||||
|
||||
val wasExtraColForWideChar = columnToSet > 0 && wideDisplayCharacterStartingAt(columnToSet - 1)
|
||||
|
||||
var col = columnToSet
|
||||
if (newIsCombining) {
|
||||
if (wasExtraColForWideChar) col--
|
||||
} else {
|
||||
if (wasExtraColForWideChar) setChar(col - 1, ' '.code, style)
|
||||
val overwritingWideCharInNextColumn = newCodePointDisplayWidth == 2 && wideDisplayCharacterStartingAt(col + 1)
|
||||
if (overwritingWideCharInNextColumn) setChar(col + 1, ' '.code, style)
|
||||
}
|
||||
|
||||
var textArray = text
|
||||
val oldStartOfColumnIndex = findStartOfColumn(col)
|
||||
val oldCodePointDisplayWidth = WcWidth.width(textArray, oldStartOfColumnIndex)
|
||||
|
||||
val oldCharactersUsedForColumn: Int
|
||||
if (col + oldCodePointDisplayWidth < columns) {
|
||||
val oldEndOfColumnIndex = findStartOfColumn(col + oldCodePointDisplayWidth)
|
||||
oldCharactersUsedForColumn = oldEndOfColumnIndex - oldStartOfColumnIndex
|
||||
} else {
|
||||
oldCharactersUsedForColumn = _spaceUsed - oldStartOfColumnIndex
|
||||
}
|
||||
|
||||
if (newIsCombining) {
|
||||
val combiningCharsCount = WcWidth.zeroWidthCharsCount(textArray, oldStartOfColumnIndex, oldStartOfColumnIndex + oldCharactersUsedForColumn)
|
||||
if (combiningCharsCount >= MAX_COMBINING_CHARACTERS_PER_COLUMN)
|
||||
return
|
||||
}
|
||||
|
||||
var newCharactersUsedForColumn = Character.charCount(codePoint)
|
||||
if (newIsCombining) {
|
||||
newCharactersUsedForColumn += oldCharactersUsedForColumn
|
||||
}
|
||||
|
||||
val oldNextColumnIndex = oldStartOfColumnIndex + oldCharactersUsedForColumn
|
||||
val newNextColumnIndex = oldStartOfColumnIndex + newCharactersUsedForColumn
|
||||
|
||||
val javaCharDifference = newCharactersUsedForColumn - oldCharactersUsedForColumn
|
||||
if (javaCharDifference > 0) {
|
||||
val oldCharactersAfterColumn = _spaceUsed - oldNextColumnIndex
|
||||
if (_spaceUsed + javaCharDifference > textArray.size) {
|
||||
val newText = CharArray(textArray.size + columns)
|
||||
System.arraycopy(textArray, 0, newText, 0, oldNextColumnIndex)
|
||||
System.arraycopy(textArray, oldNextColumnIndex, newText, newNextColumnIndex, oldCharactersAfterColumn)
|
||||
text = newText
|
||||
textArray = newText
|
||||
} else {
|
||||
System.arraycopy(textArray, oldNextColumnIndex, textArray, newNextColumnIndex, oldCharactersAfterColumn)
|
||||
}
|
||||
} else if (javaCharDifference < 0) {
|
||||
System.arraycopy(textArray, oldNextColumnIndex, textArray, newNextColumnIndex, _spaceUsed - oldNextColumnIndex)
|
||||
}
|
||||
_spaceUsed = (_spaceUsed + javaCharDifference).toShort()
|
||||
|
||||
Character.toChars(codePoint, textArray, oldStartOfColumnIndex + if (newIsCombining) oldCharactersUsedForColumn else 0)
|
||||
|
||||
if (oldCodePointDisplayWidth == 2 && newCodePointDisplayWidth == 1) {
|
||||
if (_spaceUsed + 1 > textArray.size) {
|
||||
val newText = CharArray(textArray.size + columns)
|
||||
System.arraycopy(textArray, 0, newText, 0, newNextColumnIndex)
|
||||
System.arraycopy(textArray, newNextColumnIndex, newText, newNextColumnIndex + 1, _spaceUsed - newNextColumnIndex)
|
||||
text = newText
|
||||
textArray = newText
|
||||
} else {
|
||||
System.arraycopy(textArray, newNextColumnIndex, textArray, newNextColumnIndex + 1, _spaceUsed - newNextColumnIndex)
|
||||
}
|
||||
textArray[newNextColumnIndex] = ' '
|
||||
++_spaceUsed
|
||||
} else if (oldCodePointDisplayWidth == 1 && newCodePointDisplayWidth == 2) {
|
||||
if (col == columns - 1) {
|
||||
throw IllegalArgumentException("Cannot put wide character in last column")
|
||||
} else if (col == columns - 2) {
|
||||
_spaceUsed = newNextColumnIndex.toShort()
|
||||
} else {
|
||||
val newNextNextColumnIndex = newNextColumnIndex + if (Character.isHighSurrogate(textArray[newNextColumnIndex])) 2 else 1
|
||||
val nextLen = newNextNextColumnIndex - newNextColumnIndex
|
||||
System.arraycopy(textArray, newNextNextColumnIndex, textArray, newNextColumnIndex, _spaceUsed - newNextNextColumnIndex)
|
||||
_spaceUsed = (_spaceUsed - nextLen).toShort()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal fun isBlank(): Boolean {
|
||||
for (charIndex in 0 until spaceUsed)
|
||||
if (text[charIndex] != ' ') return false
|
||||
return true
|
||||
}
|
||||
|
||||
fun getStyle(column: Int): Long = styles[column]
|
||||
}
|
||||
@@ -1,246 +0,0 @@
|
||||
package com.topjohnwu.magisk.terminal
|
||||
|
||||
import android.graphics.Color
|
||||
import java.util.Properties
|
||||
import kotlin.math.floor
|
||||
import kotlin.math.pow
|
||||
import kotlin.math.sqrt
|
||||
|
||||
object TextStyle {
|
||||
|
||||
const val CHARACTER_ATTRIBUTE_BOLD = 1
|
||||
const val CHARACTER_ATTRIBUTE_ITALIC = 1 shl 1
|
||||
const val CHARACTER_ATTRIBUTE_UNDERLINE = 1 shl 2
|
||||
const val CHARACTER_ATTRIBUTE_BLINK = 1 shl 3
|
||||
const val CHARACTER_ATTRIBUTE_INVERSE = 1 shl 4
|
||||
const val CHARACTER_ATTRIBUTE_INVISIBLE = 1 shl 5
|
||||
const val CHARACTER_ATTRIBUTE_STRIKETHROUGH = 1 shl 6
|
||||
const val CHARACTER_ATTRIBUTE_PROTECTED = 1 shl 7
|
||||
const val CHARACTER_ATTRIBUTE_DIM = 1 shl 8
|
||||
private const val CHARACTER_ATTRIBUTE_TRUECOLOR_FOREGROUND = 1 shl 9
|
||||
private const val CHARACTER_ATTRIBUTE_TRUECOLOR_BACKGROUND = 1 shl 10
|
||||
|
||||
const val COLOR_INDEX_FOREGROUND = 256
|
||||
const val COLOR_INDEX_BACKGROUND = 257
|
||||
const val COLOR_INDEX_CURSOR = 258
|
||||
const val NUM_INDEXED_COLORS = 259
|
||||
|
||||
val NORMAL = encode(COLOR_INDEX_FOREGROUND, COLOR_INDEX_BACKGROUND, 0)
|
||||
|
||||
fun encode(foreColor: Int, backColor: Int, effect: Int): Long {
|
||||
var result = (effect and 0b111111111).toLong()
|
||||
if (foreColor and 0xff000000.toInt() == 0xff000000.toInt()) {
|
||||
result = result or CHARACTER_ATTRIBUTE_TRUECOLOR_FOREGROUND.toLong() or ((foreColor.toLong() and 0x00ffffffL) shl 40)
|
||||
} else {
|
||||
result = result or ((foreColor.toLong() and 0b111111111L) shl 40)
|
||||
}
|
||||
if (backColor and 0xff000000.toInt() == 0xff000000.toInt()) {
|
||||
result = result or CHARACTER_ATTRIBUTE_TRUECOLOR_BACKGROUND.toLong() or ((backColor.toLong() and 0x00ffffffL) shl 16)
|
||||
} else {
|
||||
result = result or ((backColor.toLong() and 0b111111111L) shl 16)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
fun decodeForeColor(style: Long): Int {
|
||||
return if (style and CHARACTER_ATTRIBUTE_TRUECOLOR_FOREGROUND.toLong() == 0L) {
|
||||
((style ushr 40) and 0b111111111L).toInt()
|
||||
} else {
|
||||
0xff000000.toInt() or ((style ushr 40) and 0x00ffffffL).toInt()
|
||||
}
|
||||
}
|
||||
|
||||
fun decodeBackColor(style: Long): Int {
|
||||
return if (style and CHARACTER_ATTRIBUTE_TRUECOLOR_BACKGROUND.toLong() == 0L) {
|
||||
((style ushr 16) and 0b111111111L).toInt()
|
||||
} else {
|
||||
0xff000000.toInt() or ((style ushr 16) and 0x00ffffffL).toInt()
|
||||
}
|
||||
}
|
||||
|
||||
fun decodeEffect(style: Long): Int {
|
||||
return (style and 0b11111111111L).toInt()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Color scheme for a terminal with default colors, which may be overridden (and then reset) from the shell using
|
||||
* Operating System Control (OSC) sequences.
|
||||
*/
|
||||
class TerminalColorScheme {
|
||||
|
||||
val defaultColors: IntArray = IntArray(TextStyle.NUM_INDEXED_COLORS)
|
||||
|
||||
init {
|
||||
reset()
|
||||
}
|
||||
|
||||
fun updateWith(props: Properties) {
|
||||
reset()
|
||||
var cursorPropExists = false
|
||||
for ((keyObj, valueObj) in props) {
|
||||
val key = keyObj as String
|
||||
val value = valueObj as String
|
||||
val colorIndex: Int = when {
|
||||
key == "foreground" -> TextStyle.COLOR_INDEX_FOREGROUND
|
||||
key == "background" -> TextStyle.COLOR_INDEX_BACKGROUND
|
||||
key == "cursor" -> {
|
||||
cursorPropExists = true
|
||||
TextStyle.COLOR_INDEX_CURSOR
|
||||
}
|
||||
key.startsWith("color") -> {
|
||||
try {
|
||||
key.substring(5).toInt()
|
||||
} catch (_: NumberFormatException) {
|
||||
throw IllegalArgumentException("Invalid property: '$key'")
|
||||
}
|
||||
}
|
||||
else -> throw IllegalArgumentException("Invalid property: '$key'")
|
||||
}
|
||||
|
||||
val colorValue = TerminalColors.parse(value)
|
||||
if (colorValue == 0) {
|
||||
throw IllegalArgumentException("Property '$key' has invalid color: '$value'")
|
||||
}
|
||||
|
||||
defaultColors[colorIndex] = colorValue
|
||||
}
|
||||
|
||||
if (!cursorPropExists) {
|
||||
setCursorColorForBackground()
|
||||
}
|
||||
}
|
||||
|
||||
fun setCursorColorForBackground() {
|
||||
val backgroundColor = defaultColors[TextStyle.COLOR_INDEX_BACKGROUND]
|
||||
val brightness = TerminalColors.perceivedBrightness(backgroundColor)
|
||||
if (brightness > 0) {
|
||||
defaultColors[TextStyle.COLOR_INDEX_CURSOR] = if (brightness < 130) {
|
||||
0xffffffff.toInt()
|
||||
} else {
|
||||
0xff000000.toInt()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun reset() {
|
||||
System.arraycopy(DEFAULT_COLORSCHEME, 0, defaultColors, 0, TextStyle.NUM_INDEXED_COLORS)
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val DEFAULT_COLORSCHEME = longArrayOf(
|
||||
// 16 original colors. First 8 are dim.
|
||||
0xff000000, // black
|
||||
0xffcd0000, // dim red
|
||||
0xff00cd00, // dim green
|
||||
0xffcdcd00, // dim yellow
|
||||
0xff6495ed, // dim blue
|
||||
0xffcd00cd, // dim magenta
|
||||
0xff00cdcd, // dim cyan
|
||||
0xffe5e5e5, // dim white
|
||||
// Second 8 are bright:
|
||||
0xff7f7f7f, // medium grey
|
||||
0xffff0000, // bright red
|
||||
0xff00ff00, // bright green
|
||||
0xffffff00, // bright yellow
|
||||
0xff5c5cff, // light blue
|
||||
0xffff00ff, // bright magenta
|
||||
0xff00ffff, // bright cyan
|
||||
0xffffffffL, // bright white
|
||||
|
||||
// 216 color cube, six shades of each color:
|
||||
0xff000000, 0xff00005f, 0xff000087, 0xff0000af, 0xff0000d7, 0xff0000ff, 0xff005f00, 0xff005f5f, 0xff005f87, 0xff005faf, 0xff005fd7, 0xff005fff,
|
||||
0xff008700, 0xff00875f, 0xff008787, 0xff0087af, 0xff0087d7, 0xff0087ff, 0xff00af00, 0xff00af5f, 0xff00af87, 0xff00afaf, 0xff00afd7, 0xff00afff,
|
||||
0xff00d700, 0xff00d75f, 0xff00d787, 0xff00d7af, 0xff00d7d7, 0xff00d7ff, 0xff00ff00, 0xff00ff5f, 0xff00ff87, 0xff00ffaf, 0xff00ffd7, 0xff00ffff,
|
||||
0xff5f0000, 0xff5f005f, 0xff5f0087, 0xff5f00af, 0xff5f00d7, 0xff5f00ff, 0xff5f5f00, 0xff5f5f5f, 0xff5f5f87, 0xff5f5faf, 0xff5f5fd7, 0xff5f5fff,
|
||||
0xff5f8700, 0xff5f875f, 0xff5f8787, 0xff5f87af, 0xff5f87d7, 0xff5f87ff, 0xff5faf00, 0xff5faf5f, 0xff5faf87, 0xff5fafaf, 0xff5fafd7, 0xff5fafff,
|
||||
0xff5fd700, 0xff5fd75f, 0xff5fd787, 0xff5fd7af, 0xff5fd7d7, 0xff5fd7ff, 0xff5fff00, 0xff5fff5f, 0xff5fff87, 0xff5fffaf, 0xff5fffd7, 0xff5fffff,
|
||||
0xff870000, 0xff87005f, 0xff870087, 0xff8700af, 0xff8700d7, 0xff8700ff, 0xff875f00, 0xff875f5f, 0xff875f87, 0xff875faf, 0xff875fd7, 0xff875fff,
|
||||
0xff878700, 0xff87875f, 0xff878787, 0xff8787af, 0xff8787d7, 0xff8787ff, 0xff87af00, 0xff87af5f, 0xff87af87, 0xff87afaf, 0xff87afd7, 0xff87afff,
|
||||
0xff87d700, 0xff87d75f, 0xff87d787, 0xff87d7af, 0xff87d7d7, 0xff87d7ff, 0xff87ff00, 0xff87ff5f, 0xff87ff87, 0xff87ffaf, 0xff87ffd7, 0xff87ffff,
|
||||
0xffaf0000, 0xffaf005f, 0xffaf0087, 0xffaf00af, 0xffaf00d7, 0xffaf00ff, 0xffaf5f00, 0xffaf5f5f, 0xffaf5f87, 0xffaf5faf, 0xffaf5fd7, 0xffaf5fff,
|
||||
0xffaf8700, 0xffaf875f, 0xffaf8787, 0xffaf87af, 0xffaf87d7, 0xffaf87ff, 0xffafaf00, 0xffafaf5f, 0xffafaf87, 0xffafafaf, 0xffafafd7, 0xffafafff,
|
||||
0xffafd700, 0xffafd75f, 0xffafd787, 0xffafd7af, 0xffafd7d7, 0xffafd7ff, 0xffafff00, 0xffafff5f, 0xffafff87, 0xffafffaf, 0xffafffd7, 0xffafffff,
|
||||
0xffd70000, 0xffd7005f, 0xffd70087, 0xffd700af, 0xffd700d7, 0xffd700ff, 0xffd75f00, 0xffd75f5f, 0xffd75f87, 0xffd75faf, 0xffd75fd7, 0xffd75fff,
|
||||
0xffd78700, 0xffd7875f, 0xffd78787, 0xffd787af, 0xffd787d7, 0xffd787ff, 0xffd7af00, 0xffd7af5f, 0xffd7af87, 0xffd7afaf, 0xffd7afd7, 0xffd7afff,
|
||||
0xffd7d700, 0xffd7d75f, 0xffd7d787, 0xffd7d7af, 0xffd7d7d7, 0xffd7d7ff, 0xffd7ff00, 0xffd7ff5f, 0xffd7ff87, 0xffd7ffaf, 0xffd7ffd7, 0xffd7ffff,
|
||||
0xffff0000, 0xffff005f, 0xffff0087, 0xffff00af, 0xffff00d7, 0xffff00ff, 0xffff5f00, 0xffff5f5f, 0xffff5f87, 0xffff5faf, 0xffff5fd7, 0xffff5fff,
|
||||
0xffff8700, 0xffff875f, 0xffff8787, 0xffff87af, 0xffff87d7, 0xffff87ff, 0xffffaf00, 0xffffaf5f, 0xffffaf87, 0xffffafaf, 0xffffafd7, 0xffffafff,
|
||||
0xffffd700, 0xffffd75f, 0xffffd787, 0xffffd7af, 0xffffd7d7, 0xffffd7ff, 0xffffff00, 0xffffff5f, 0xffffff87, 0xffffffaf, 0xffffffd7, 0xffffffffL,
|
||||
|
||||
// 24 grey scale ramp:
|
||||
0xff080808, 0xff121212, 0xff1c1c1c, 0xff262626, 0xff303030, 0xff3a3a3a, 0xff444444, 0xff4e4e4e, 0xff585858, 0xff626262, 0xff6c6c6c, 0xff767676,
|
||||
0xff808080, 0xff8a8a8a, 0xff949494, 0xff9e9e9e, 0xffa8a8a8, 0xffb2b2b2, 0xffbcbcbc, 0xffc6c6c6, 0xffd0d0d0, 0xffdadada, 0xffe4e4e4, 0xffeeeeee,
|
||||
|
||||
// COLOR_INDEX_DEFAULT_FOREGROUND, COLOR_INDEX_DEFAULT_BACKGROUND and COLOR_INDEX_DEFAULT_CURSOR:
|
||||
0xffffffffL, 0xff000000L, 0xffffffffL
|
||||
).map { it.toInt() }.toIntArray()
|
||||
}
|
||||
}
|
||||
|
||||
/** Current terminal colors (if different from default). */
|
||||
class TerminalColors {
|
||||
|
||||
val currentColors: IntArray = IntArray(TextStyle.NUM_INDEXED_COLORS)
|
||||
|
||||
init {
|
||||
reset()
|
||||
}
|
||||
|
||||
fun reset(index: Int) {
|
||||
currentColors[index] = COLOR_SCHEME.defaultColors[index]
|
||||
}
|
||||
|
||||
fun reset() {
|
||||
System.arraycopy(COLOR_SCHEME.defaultColors, 0, currentColors, 0, TextStyle.NUM_INDEXED_COLORS)
|
||||
}
|
||||
|
||||
fun tryParseColor(intoIndex: Int, textParameter: String) {
|
||||
val c = parse(textParameter)
|
||||
if (c != 0) currentColors[intoIndex] = c
|
||||
}
|
||||
|
||||
companion object {
|
||||
val COLOR_SCHEME = TerminalColorScheme()
|
||||
|
||||
internal fun parse(c: String): Int {
|
||||
return try {
|
||||
val (skipInitial, skipBetween) = when {
|
||||
c[0] == '#' -> 1 to 0
|
||||
c.startsWith("rgb:") -> 4 to 1
|
||||
else -> return 0
|
||||
}
|
||||
val charsForColors = c.length - skipInitial - 2 * skipBetween
|
||||
if (charsForColors % 3 != 0) return 0
|
||||
val componentLength = charsForColors / 3
|
||||
val mult = 255.0 / (2.0.pow(componentLength * 4) - 1)
|
||||
|
||||
var currentPosition = skipInitial
|
||||
val rString = c.substring(currentPosition, currentPosition + componentLength)
|
||||
currentPosition += componentLength + skipBetween
|
||||
val gString = c.substring(currentPosition, currentPosition + componentLength)
|
||||
currentPosition += componentLength + skipBetween
|
||||
val bString = c.substring(currentPosition, currentPosition + componentLength)
|
||||
|
||||
val r = (rString.toInt(16) * mult).toInt()
|
||||
val g = (gString.toInt(16) * mult).toInt()
|
||||
val b = (bString.toInt(16) * mult).toInt()
|
||||
(0xFF shl 24) or (r shl 16) or (g shl 8) or b
|
||||
} catch (_: NumberFormatException) {
|
||||
0
|
||||
} catch (_: IndexOutOfBoundsException) {
|
||||
0
|
||||
}
|
||||
}
|
||||
|
||||
fun perceivedBrightness(color: Int): Int {
|
||||
return floor(
|
||||
sqrt(
|
||||
Color.red(color).toDouble().pow(2) * 0.241 +
|
||||
Color.green(color).toDouble().pow(2) * 0.691 +
|
||||
Color.blue(color).toDouble().pow(2) * 0.068
|
||||
)
|
||||
).toInt()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,559 +0,0 @@
|
||||
package com.topjohnwu.magisk.terminal
|
||||
|
||||
/**
|
||||
* Implementation of wcwidth(3) for Unicode 15.
|
||||
*
|
||||
* Implementation from https://github.com/jquast/wcwidth but we return 0 for unprintable characters.
|
||||
*
|
||||
* IMPORTANT:
|
||||
* Must be kept in sync with the following:
|
||||
* https://github.com/termux/wcwidth
|
||||
* https://github.com/termux/libandroid-support
|
||||
* https://github.com/termux/termux-packages/tree/master/packages/libandroid-support
|
||||
*/
|
||||
object WcWidth {
|
||||
|
||||
// From https://github.com/jquast/wcwidth/blob/master/wcwidth/table_zero.py
|
||||
// from https://github.com/jquast/wcwidth/pull/64
|
||||
// at commit 1b9b6585b0080ea5cb88dc9815796505724793fe (2022-12-16):
|
||||
private val ZERO_WIDTH = arrayOf(
|
||||
intArrayOf(0x00300, 0x0036f), // Combining Grave Accent ..Combining Latin Small Le
|
||||
intArrayOf(0x00483, 0x00489), // Combining Cyrillic Titlo..Combining Cyrillic Milli
|
||||
intArrayOf(0x00591, 0x005bd), // Hebrew Accent Etnahta ..Hebrew Point Meteg
|
||||
intArrayOf(0x005bf, 0x005bf), // Hebrew Point Rafe ..Hebrew Point Rafe
|
||||
intArrayOf(0x005c1, 0x005c2), // Hebrew Point Shin Dot ..Hebrew Point Sin Dot
|
||||
intArrayOf(0x005c4, 0x005c5), // Hebrew Mark Upper Dot ..Hebrew Mark Lower Dot
|
||||
intArrayOf(0x005c7, 0x005c7), // Hebrew Point Qamats Qata..Hebrew Point Qamats Qata
|
||||
intArrayOf(0x00610, 0x0061a), // Arabic Sign Sallallahou ..Arabic Small Kasra
|
||||
intArrayOf(0x0064b, 0x0065f), // Arabic Fathatan ..Arabic Wavy Hamza Below
|
||||
intArrayOf(0x00670, 0x00670), // Arabic Letter Superscrip..Arabic Letter Superscrip
|
||||
intArrayOf(0x006d6, 0x006dc), // Arabic Small High Ligatu..Arabic Small High Seen
|
||||
intArrayOf(0x006df, 0x006e4), // Arabic Small High Rounde..Arabic Small High Madda
|
||||
intArrayOf(0x006e7, 0x006e8), // Arabic Small High Yeh ..Arabic Small High Noon
|
||||
intArrayOf(0x006ea, 0x006ed), // Arabic Empty Centre Low ..Arabic Small Low Meem
|
||||
intArrayOf(0x00711, 0x00711), // Syriac Letter Superscrip..Syriac Letter Superscrip
|
||||
intArrayOf(0x00730, 0x0074a), // Syriac Pthaha Above ..Syriac Barrekh
|
||||
intArrayOf(0x007a6, 0x007b0), // Thaana Abafili ..Thaana Sukun
|
||||
intArrayOf(0x007eb, 0x007f3), // Nko Combining Short High..Nko Combining Double Dot
|
||||
intArrayOf(0x007fd, 0x007fd), // Nko Dantayalan ..Nko Dantayalan
|
||||
intArrayOf(0x00816, 0x00819), // Samaritan Mark In ..Samaritan Mark Dagesh
|
||||
intArrayOf(0x0081b, 0x00823), // Samaritan Mark Epentheti..Samaritan Vowel Sign A
|
||||
intArrayOf(0x00825, 0x00827), // Samaritan Vowel Sign Sho..Samaritan Vowel Sign U
|
||||
intArrayOf(0x00829, 0x0082d), // Samaritan Vowel Sign Lon..Samaritan Mark Nequdaa
|
||||
intArrayOf(0x00859, 0x0085b), // Mandaic Affrication Mark..Mandaic Gemination Mark
|
||||
intArrayOf(0x00898, 0x0089f), // Arabic Small High Word A..Arabic Half Madda Over M
|
||||
intArrayOf(0x008ca, 0x008e1), // Arabic Small High Farsi ..Arabic Small High Sign S
|
||||
intArrayOf(0x008e3, 0x00902), // Arabic Turned Damma Belo..Devanagari Sign Anusvara
|
||||
intArrayOf(0x0093a, 0x0093a), // Devanagari Vowel Sign Oe..Devanagari Vowel Sign Oe
|
||||
intArrayOf(0x0093c, 0x0093c), // Devanagari Sign Nukta ..Devanagari Sign Nukta
|
||||
intArrayOf(0x00941, 0x00948), // Devanagari Vowel Sign U ..Devanagari Vowel Sign Ai
|
||||
intArrayOf(0x0094d, 0x0094d), // Devanagari Sign Virama ..Devanagari Sign Virama
|
||||
intArrayOf(0x00951, 0x00957), // Devanagari Stress Sign U..Devanagari Vowel Sign Uu
|
||||
intArrayOf(0x00962, 0x00963), // Devanagari Vowel Sign Vo..Devanagari Vowel Sign Vo
|
||||
intArrayOf(0x00981, 0x00981), // Bengali Sign Candrabindu..Bengali Sign Candrabindu
|
||||
intArrayOf(0x009bc, 0x009bc), // Bengali Sign Nukta ..Bengali Sign Nukta
|
||||
intArrayOf(0x009c1, 0x009c4), // Bengali Vowel Sign U ..Bengali Vowel Sign Vocal
|
||||
intArrayOf(0x009cd, 0x009cd), // Bengali Sign Virama ..Bengali Sign Virama
|
||||
intArrayOf(0x009e2, 0x009e3), // Bengali Vowel Sign Vocal..Bengali Vowel Sign Vocal
|
||||
intArrayOf(0x009fe, 0x009fe), // Bengali Sandhi Mark ..Bengali Sandhi Mark
|
||||
intArrayOf(0x00a01, 0x00a02), // Gurmukhi Sign Adak Bindi..Gurmukhi Sign Bindi
|
||||
intArrayOf(0x00a3c, 0x00a3c), // Gurmukhi Sign Nukta ..Gurmukhi Sign Nukta
|
||||
intArrayOf(0x00a41, 0x00a42), // Gurmukhi Vowel Sign U ..Gurmukhi Vowel Sign Uu
|
||||
intArrayOf(0x00a47, 0x00a48), // Gurmukhi Vowel Sign Ee ..Gurmukhi Vowel Sign Ai
|
||||
intArrayOf(0x00a4b, 0x00a4d), // Gurmukhi Vowel Sign Oo ..Gurmukhi Sign Virama
|
||||
intArrayOf(0x00a51, 0x00a51), // Gurmukhi Sign Udaat ..Gurmukhi Sign Udaat
|
||||
intArrayOf(0x00a70, 0x00a71), // Gurmukhi Tippi ..Gurmukhi Addak
|
||||
intArrayOf(0x00a75, 0x00a75), // Gurmukhi Sign Yakash ..Gurmukhi Sign Yakash
|
||||
intArrayOf(0x00a81, 0x00a82), // Gujarati Sign Candrabind..Gujarati Sign Anusvara
|
||||
intArrayOf(0x00abc, 0x00abc), // Gujarati Sign Nukta ..Gujarati Sign Nukta
|
||||
intArrayOf(0x00ac1, 0x00ac5), // Gujarati Vowel Sign U ..Gujarati Vowel Sign Cand
|
||||
intArrayOf(0x00ac7, 0x00ac8), // Gujarati Vowel Sign E ..Gujarati Vowel Sign Ai
|
||||
intArrayOf(0x00acd, 0x00acd), // Gujarati Sign Virama ..Gujarati Sign Virama
|
||||
intArrayOf(0x00ae2, 0x00ae3), // Gujarati Vowel Sign Voca..Gujarati Vowel Sign Voca
|
||||
intArrayOf(0x00afa, 0x00aff), // Gujarati Sign Sukun ..Gujarati Sign Two-circle
|
||||
intArrayOf(0x00b01, 0x00b01), // Oriya Sign Candrabindu ..Oriya Sign Candrabindu
|
||||
intArrayOf(0x00b3c, 0x00b3c), // Oriya Sign Nukta ..Oriya Sign Nukta
|
||||
intArrayOf(0x00b3f, 0x00b3f), // Oriya Vowel Sign I ..Oriya Vowel Sign I
|
||||
intArrayOf(0x00b41, 0x00b44), // Oriya Vowel Sign U ..Oriya Vowel Sign Vocalic
|
||||
intArrayOf(0x00b4d, 0x00b4d), // Oriya Sign Virama ..Oriya Sign Virama
|
||||
intArrayOf(0x00b55, 0x00b56), // Oriya Sign Overline ..Oriya Ai Length Mark
|
||||
intArrayOf(0x00b62, 0x00b63), // Oriya Vowel Sign Vocalic..Oriya Vowel Sign Vocalic
|
||||
intArrayOf(0x00b82, 0x00b82), // Tamil Sign Anusvara ..Tamil Sign Anusvara
|
||||
intArrayOf(0x00bc0, 0x00bc0), // Tamil Vowel Sign Ii ..Tamil Vowel Sign Ii
|
||||
intArrayOf(0x00bcd, 0x00bcd), // Tamil Sign Virama ..Tamil Sign Virama
|
||||
intArrayOf(0x00c00, 0x00c00), // Telugu Sign Combining Ca..Telugu Sign Combining Ca
|
||||
intArrayOf(0x00c04, 0x00c04), // Telugu Sign Combining An..Telugu Sign Combining An
|
||||
intArrayOf(0x00c3c, 0x00c3c), // Telugu Sign Nukta ..Telugu Sign Nukta
|
||||
intArrayOf(0x00c3e, 0x00c40), // Telugu Vowel Sign Aa ..Telugu Vowel Sign Ii
|
||||
intArrayOf(0x00c46, 0x00c48), // Telugu Vowel Sign E ..Telugu Vowel Sign Ai
|
||||
intArrayOf(0x00c4a, 0x00c4d), // Telugu Vowel Sign O ..Telugu Sign Virama
|
||||
intArrayOf(0x00c55, 0x00c56), // Telugu Length Mark ..Telugu Ai Length Mark
|
||||
intArrayOf(0x00c62, 0x00c63), // Telugu Vowel Sign Vocali..Telugu Vowel Sign Vocali
|
||||
intArrayOf(0x00c81, 0x00c81), // Kannada Sign Candrabindu..Kannada Sign Candrabindu
|
||||
intArrayOf(0x00cbc, 0x00cbc), // Kannada Sign Nukta ..Kannada Sign Nukta
|
||||
intArrayOf(0x00cbf, 0x00cbf), // Kannada Vowel Sign I ..Kannada Vowel Sign I
|
||||
intArrayOf(0x00cc6, 0x00cc6), // Kannada Vowel Sign E ..Kannada Vowel Sign E
|
||||
intArrayOf(0x00ccc, 0x00ccd), // Kannada Vowel Sign Au ..Kannada Sign Virama
|
||||
intArrayOf(0x00ce2, 0x00ce3), // Kannada Vowel Sign Vocal..Kannada Vowel Sign Vocal
|
||||
intArrayOf(0x00d00, 0x00d01), // Malayalam Sign Combining..Malayalam Sign Candrabin
|
||||
intArrayOf(0x00d3b, 0x00d3c), // Malayalam Sign Vertical ..Malayalam Sign Circular
|
||||
intArrayOf(0x00d41, 0x00d44), // Malayalam Vowel Sign U ..Malayalam Vowel Sign Voc
|
||||
intArrayOf(0x00d4d, 0x00d4d), // Malayalam Sign Virama ..Malayalam Sign Virama
|
||||
intArrayOf(0x00d62, 0x00d63), // Malayalam Vowel Sign Voc..Malayalam Vowel Sign Voc
|
||||
intArrayOf(0x00d81, 0x00d81), // Sinhala Sign Candrabindu..Sinhala Sign Candrabindu
|
||||
intArrayOf(0x00dca, 0x00dca), // Sinhala Sign Al-lakuna ..Sinhala Sign Al-lakuna
|
||||
intArrayOf(0x00dd2, 0x00dd4), // Sinhala Vowel Sign Ketti..Sinhala Vowel Sign Ketti
|
||||
intArrayOf(0x00dd6, 0x00dd6), // Sinhala Vowel Sign Diga ..Sinhala Vowel Sign Diga
|
||||
intArrayOf(0x00e31, 0x00e31), // Thai Character Mai Han-a..Thai Character Mai Han-a
|
||||
intArrayOf(0x00e34, 0x00e3a), // Thai Character Sara I ..Thai Character Phinthu
|
||||
intArrayOf(0x00e47, 0x00e4e), // Thai Character Maitaikhu..Thai Character Yamakkan
|
||||
intArrayOf(0x00eb1, 0x00eb1), // Lao Vowel Sign Mai Kan ..Lao Vowel Sign Mai Kan
|
||||
intArrayOf(0x00eb4, 0x00ebc), // Lao Vowel Sign I ..Lao Semivowel Sign Lo
|
||||
intArrayOf(0x00ec8, 0x00ece), // Lao Tone Mai Ek ..(nil)
|
||||
intArrayOf(0x00f18, 0x00f19), // Tibetan Astrological Sig..Tibetan Astrological Sig
|
||||
intArrayOf(0x00f35, 0x00f35), // Tibetan Mark Ngas Bzung ..Tibetan Mark Ngas Bzung
|
||||
intArrayOf(0x00f37, 0x00f37), // Tibetan Mark Ngas Bzung ..Tibetan Mark Ngas Bzung
|
||||
intArrayOf(0x00f39, 0x00f39), // Tibetan Mark Tsa -phru ..Tibetan Mark Tsa -phru
|
||||
intArrayOf(0x00f71, 0x00f7e), // Tibetan Vowel Sign Aa ..Tibetan Sign Rjes Su Nga
|
||||
intArrayOf(0x00f80, 0x00f84), // Tibetan Vowel Sign Rever..Tibetan Mark Halanta
|
||||
intArrayOf(0x00f86, 0x00f87), // Tibetan Sign Lci Rtags ..Tibetan Sign Yang Rtags
|
||||
intArrayOf(0x00f8d, 0x00f97), // Tibetan Subjoined Sign L..Tibetan Subjoined Letter
|
||||
intArrayOf(0x00f99, 0x00fbc), // Tibetan Subjoined Letter..Tibetan Subjoined Letter
|
||||
intArrayOf(0x00fc6, 0x00fc6), // Tibetan Symbol Padma Gda..Tibetan Symbol Padma Gda
|
||||
intArrayOf(0x0102d, 0x01030), // Myanmar Vowel Sign I ..Myanmar Vowel Sign Uu
|
||||
intArrayOf(0x01032, 0x01037), // Myanmar Vowel Sign Ai ..Myanmar Sign Dot Below
|
||||
intArrayOf(0x01039, 0x0103a), // Myanmar Sign Virama ..Myanmar Sign Asat
|
||||
intArrayOf(0x0103d, 0x0103e), // Myanmar Consonant Sign M..Myanmar Consonant Sign M
|
||||
intArrayOf(0x01058, 0x01059), // Myanmar Vowel Sign Vocal..Myanmar Vowel Sign Vocal
|
||||
intArrayOf(0x0105e, 0x01060), // Myanmar Consonant Sign M..Myanmar Consonant Sign M
|
||||
intArrayOf(0x01071, 0x01074), // Myanmar Vowel Sign Geba ..Myanmar Vowel Sign Kayah
|
||||
intArrayOf(0x01082, 0x01082), // Myanmar Consonant Sign S..Myanmar Consonant Sign S
|
||||
intArrayOf(0x01085, 0x01086), // Myanmar Vowel Sign Shan ..Myanmar Vowel Sign Shan
|
||||
intArrayOf(0x0108d, 0x0108d), // Myanmar Sign Shan Counci..Myanmar Sign Shan Counci
|
||||
intArrayOf(0x0109d, 0x0109d), // Myanmar Vowel Sign Aiton..Myanmar Vowel Sign Aiton
|
||||
intArrayOf(0x0135d, 0x0135f), // Ethiopic Combining Gemin..Ethiopic Combining Gemin
|
||||
intArrayOf(0x01712, 0x01714), // Tagalog Vowel Sign I ..Tagalog Sign Virama
|
||||
intArrayOf(0x01732, 0x01733), // Hanunoo Vowel Sign I ..Hanunoo Vowel Sign U
|
||||
intArrayOf(0x01752, 0x01753), // Buhid Vowel Sign I ..Buhid Vowel Sign U
|
||||
intArrayOf(0x01772, 0x01773), // Tagbanwa Vowel Sign I ..Tagbanwa Vowel Sign U
|
||||
intArrayOf(0x017b4, 0x017b5), // Khmer Vowel Inherent Aq ..Khmer Vowel Inherent Aa
|
||||
intArrayOf(0x017b7, 0x017bd), // Khmer Vowel Sign I ..Khmer Vowel Sign Ua
|
||||
intArrayOf(0x017c6, 0x017c6), // Khmer Sign Nikahit ..Khmer Sign Nikahit
|
||||
intArrayOf(0x017c9, 0x017d3), // Khmer Sign Muusikatoan ..Khmer Sign Bathamasat
|
||||
intArrayOf(0x017dd, 0x017dd), // Khmer Sign Atthacan ..Khmer Sign Atthacan
|
||||
intArrayOf(0x0180b, 0x0180d), // Mongolian Free Variation..Mongolian Free Variation
|
||||
intArrayOf(0x0180f, 0x0180f), // Mongolian Free Variation..Mongolian Free Variation
|
||||
intArrayOf(0x01885, 0x01886), // Mongolian Letter Ali Gal..Mongolian Letter Ali Gal
|
||||
intArrayOf(0x018a9, 0x018a9), // Mongolian Letter Ali Gal..Mongolian Letter Ali Gal
|
||||
intArrayOf(0x01920, 0x01922), // Limbu Vowel Sign A ..Limbu Vowel Sign U
|
||||
intArrayOf(0x01927, 0x01928), // Limbu Vowel Sign E ..Limbu Vowel Sign O
|
||||
intArrayOf(0x01932, 0x01932), // Limbu Small Letter Anusv..Limbu Small Letter Anusv
|
||||
intArrayOf(0x01939, 0x0193b), // Limbu Sign Mukphreng ..Limbu Sign Sa-i
|
||||
intArrayOf(0x01a17, 0x01a18), // Buginese Vowel Sign I ..Buginese Vowel Sign U
|
||||
intArrayOf(0x01a1b, 0x01a1b), // Buginese Vowel Sign Ae ..Buginese Vowel Sign Ae
|
||||
intArrayOf(0x01a56, 0x01a56), // Tai Tham Consonant Sign ..Tai Tham Consonant Sign
|
||||
intArrayOf(0x01a58, 0x01a5e), // Tai Tham Sign Mai Kang L..Tai Tham Consonant Sign
|
||||
intArrayOf(0x01a60, 0x01a60), // Tai Tham Sign Sakot ..Tai Tham Sign Sakot
|
||||
intArrayOf(0x01a62, 0x01a62), // Tai Tham Vowel Sign Mai ..Tai Tham Vowel Sign Mai
|
||||
intArrayOf(0x01a65, 0x01a6c), // Tai Tham Vowel Sign I ..Tai Tham Vowel Sign Oa B
|
||||
intArrayOf(0x01a73, 0x01a7c), // Tai Tham Vowel Sign Oa A..Tai Tham Sign Khuen-lue
|
||||
intArrayOf(0x01a7f, 0x01a7f), // Tai Tham Combining Crypt..Tai Tham Combining Crypt
|
||||
intArrayOf(0x01ab0, 0x01ace), // Combining Doubled Circum..Combining Latin Small Le
|
||||
intArrayOf(0x01b00, 0x01b03), // Balinese Sign Ulu Ricem ..Balinese Sign Surang
|
||||
intArrayOf(0x01b34, 0x01b34), // Balinese Sign Rerekan ..Balinese Sign Rerekan
|
||||
intArrayOf(0x01b36, 0x01b3a), // Balinese Vowel Sign Ulu ..Balinese Vowel Sign Ra R
|
||||
intArrayOf(0x01b3c, 0x01b3c), // Balinese Vowel Sign La L..Balinese Vowel Sign La L
|
||||
intArrayOf(0x01b42, 0x01b42), // Balinese Vowel Sign Pepe..Balinese Vowel Sign Pepe
|
||||
intArrayOf(0x01b6b, 0x01b73), // Balinese Musical Symbol ..Balinese Musical Symbol
|
||||
intArrayOf(0x01b80, 0x01b81), // Sundanese Sign Panyecek ..Sundanese Sign Panglayar
|
||||
intArrayOf(0x01ba2, 0x01ba5), // Sundanese Consonant Sign..Sundanese Vowel Sign Pan
|
||||
intArrayOf(0x01ba8, 0x01ba9), // Sundanese Vowel Sign Pam..Sundanese Vowel Sign Pan
|
||||
intArrayOf(0x01bab, 0x01bad), // Sundanese Sign Virama ..Sundanese Consonant Sign
|
||||
intArrayOf(0x01be6, 0x01be6), // Batak Sign Tompi ..Batak Sign Tompi
|
||||
intArrayOf(0x01be8, 0x01be9), // Batak Vowel Sign Pakpak ..Batak Vowel Sign Ee
|
||||
intArrayOf(0x01bed, 0x01bed), // Batak Vowel Sign Karo O ..Batak Vowel Sign Karo O
|
||||
intArrayOf(0x01bef, 0x01bf1), // Batak Vowel Sign U For S..Batak Consonant Sign H
|
||||
intArrayOf(0x01c2c, 0x01c33), // Lepcha Vowel Sign E ..Lepcha Consonant Sign T
|
||||
intArrayOf(0x01c36, 0x01c37), // Lepcha Sign Ran ..Lepcha Sign Nukta
|
||||
intArrayOf(0x01cd0, 0x01cd2), // Vedic Tone Karshana ..Vedic Tone Prenkha
|
||||
intArrayOf(0x01cd4, 0x01ce0), // Vedic Sign Yajurvedic Mi..Vedic Tone Rigvedic Kash
|
||||
intArrayOf(0x01ce2, 0x01ce8), // Vedic Sign Visarga Svari..Vedic Sign Visarga Anuda
|
||||
intArrayOf(0x01ced, 0x01ced), // Vedic Sign Tiryak ..Vedic Sign Tiryak
|
||||
intArrayOf(0x01cf4, 0x01cf4), // Vedic Tone Candra Above ..Vedic Tone Candra Above
|
||||
intArrayOf(0x01cf8, 0x01cf9), // Vedic Tone Ring Above ..Vedic Tone Double Ring A
|
||||
intArrayOf(0x01dc0, 0x01dff), // Combining Dotted Grave A..Combining Right Arrowhea
|
||||
intArrayOf(0x020d0, 0x020f0), // Combining Left Harpoon A..Combining Asterisk Above
|
||||
intArrayOf(0x02cef, 0x02cf1), // Coptic Combining Ni Abov..Coptic Combining Spiritu
|
||||
intArrayOf(0x02d7f, 0x02d7f), // Tifinagh Consonant Joine..Tifinagh Consonant Joine
|
||||
intArrayOf(0x02de0, 0x02dff), // Combining Cyrillic Lette..Combining Cyrillic Lette
|
||||
intArrayOf(0x0302a, 0x0302d), // Ideographic Level Tone M..Ideographic Entering Ton
|
||||
intArrayOf(0x03099, 0x0309a), // Combining Katakana-hirag..Combining Katakana-hirag
|
||||
intArrayOf(0x0a66f, 0x0a672), // Combining Cyrillic Vzmet..Combining Cyrillic Thous
|
||||
intArrayOf(0x0a674, 0x0a67d), // Combining Cyrillic Lette..Combining Cyrillic Payer
|
||||
intArrayOf(0x0a69e, 0x0a69f), // Combining Cyrillic Lette..Combining Cyrillic Lette
|
||||
intArrayOf(0x0a6f0, 0x0a6f1), // Bamum Combining Mark Koq..Bamum Combining Mark Tuk
|
||||
intArrayOf(0x0a802, 0x0a802), // Syloti Nagri Sign Dvisva..Syloti Nagri Sign Dvisva
|
||||
intArrayOf(0x0a806, 0x0a806), // Syloti Nagri Sign Hasant..Syloti Nagri Sign Hasant
|
||||
intArrayOf(0x0a80b, 0x0a80b), // Syloti Nagri Sign Anusva..Syloti Nagri Sign Anusva
|
||||
intArrayOf(0x0a825, 0x0a826), // Syloti Nagri Vowel Sign ..Syloti Nagri Vowel Sign
|
||||
intArrayOf(0x0a82c, 0x0a82c), // Syloti Nagri Sign Altern..Syloti Nagri Sign Altern
|
||||
intArrayOf(0x0a8c4, 0x0a8c5), // Saurashtra Sign Virama ..Saurashtra Sign Candrabi
|
||||
intArrayOf(0x0a8e0, 0x0a8f1), // Combining Devanagari Dig..Combining Devanagari Sig
|
||||
intArrayOf(0x0a8ff, 0x0a8ff), // Devanagari Vowel Sign Ay..Devanagari Vowel Sign Ay
|
||||
intArrayOf(0x0a926, 0x0a92d), // Kayah Li Vowel Ue ..Kayah Li Tone Calya Plop
|
||||
intArrayOf(0x0a947, 0x0a951), // Rejang Vowel Sign I ..Rejang Consonant Sign R
|
||||
intArrayOf(0x0a980, 0x0a982), // Javanese Sign Panyangga ..Javanese Sign Layar
|
||||
intArrayOf(0x0a9b3, 0x0a9b3), // Javanese Sign Cecak Telu..Javanese Sign Cecak Telu
|
||||
intArrayOf(0x0a9b6, 0x0a9b9), // Javanese Vowel Sign Wulu..Javanese Vowel Sign Suku
|
||||
intArrayOf(0x0a9bc, 0x0a9bd), // Javanese Vowel Sign Pepe..Javanese Consonant Sign
|
||||
intArrayOf(0x0a9e5, 0x0a9e5), // Myanmar Sign Shan Saw ..Myanmar Sign Shan Saw
|
||||
intArrayOf(0x0aa29, 0x0aa2e), // Cham Vowel Sign Aa ..Cham Vowel Sign Oe
|
||||
intArrayOf(0x0aa31, 0x0aa32), // Cham Vowel Sign Au ..Cham Vowel Sign Ue
|
||||
intArrayOf(0x0aa35, 0x0aa36), // Cham Consonant Sign La ..Cham Consonant Sign Wa
|
||||
intArrayOf(0x0aa43, 0x0aa43), // Cham Consonant Sign Fina..Cham Consonant Sign Fina
|
||||
intArrayOf(0x0aa4c, 0x0aa4c), // Cham Consonant Sign Fina..Cham Consonant Sign Fina
|
||||
intArrayOf(0x0aa7c, 0x0aa7c), // Myanmar Sign Tai Laing T..Myanmar Sign Tai Laing T
|
||||
intArrayOf(0x0aab0, 0x0aab0), // Tai Viet Mai Kang ..Tai Viet Mai Kang
|
||||
intArrayOf(0x0aab2, 0x0aab4), // Tai Viet Vowel I ..Tai Viet Vowel U
|
||||
intArrayOf(0x0aab7, 0x0aab8), // Tai Viet Mai Khit ..Tai Viet Vowel Ia
|
||||
intArrayOf(0x0aabe, 0x0aabf), // Tai Viet Vowel Am ..Tai Viet Tone Mai Ek
|
||||
intArrayOf(0x0aac1, 0x0aac1), // Tai Viet Tone Mai Tho ..Tai Viet Tone Mai Tho
|
||||
intArrayOf(0x0aaec, 0x0aaed), // Meetei Mayek Vowel Sign ..Meetei Mayek Vowel Sign
|
||||
intArrayOf(0x0aaf6, 0x0aaf6), // Meetei Mayek Virama ..Meetei Mayek Virama
|
||||
intArrayOf(0x0abe5, 0x0abe5), // Meetei Mayek Vowel Sign ..Meetei Mayek Vowel Sign
|
||||
intArrayOf(0x0abe8, 0x0abe8), // Meetei Mayek Vowel Sign ..Meetei Mayek Vowel Sign
|
||||
intArrayOf(0x0abed, 0x0abed), // Meetei Mayek Apun Iyek ..Meetei Mayek Apun Iyek
|
||||
intArrayOf(0x0fb1e, 0x0fb1e), // Hebrew Point Judeo-spani..Hebrew Point Judeo-spani
|
||||
intArrayOf(0x0fe00, 0x0fe0f), // Variation Selector-1 ..Variation Selector-16
|
||||
intArrayOf(0x0fe20, 0x0fe2f), // Combining Ligature Left ..Combining Cyrillic Titlo
|
||||
intArrayOf(0x101fd, 0x101fd), // Phaistos Disc Sign Combi..Phaistos Disc Sign Combi
|
||||
intArrayOf(0x102e0, 0x102e0), // Coptic Epact Thousands M..Coptic Epact Thousands M
|
||||
intArrayOf(0x10376, 0x1037a), // Combining Old Permic Let..Combining Old Permic Let
|
||||
intArrayOf(0x10a01, 0x10a03), // Kharoshthi Vowel Sign I ..Kharoshthi Vowel Sign Vo
|
||||
intArrayOf(0x10a05, 0x10a06), // Kharoshthi Vowel Sign E ..Kharoshthi Vowel Sign O
|
||||
intArrayOf(0x10a0c, 0x10a0f), // Kharoshthi Vowel Length ..Kharoshthi Sign Visarga
|
||||
intArrayOf(0x10a38, 0x10a3a), // Kharoshthi Sign Bar Abov..Kharoshthi Sign Dot Belo
|
||||
intArrayOf(0x10a3f, 0x10a3f), // Kharoshthi Virama ..Kharoshthi Virama
|
||||
intArrayOf(0x10ae5, 0x10ae6), // Manichaean Abbreviation ..Manichaean Abbreviation
|
||||
intArrayOf(0x10d24, 0x10d27), // Hanifi Rohingya Sign Har..Hanifi Rohingya Sign Tas
|
||||
intArrayOf(0x10eab, 0x10eac), // Yezidi Combining Hamza M..Yezidi Combining Madda M
|
||||
intArrayOf(0x10efd, 0x10eff), // (nil) ..(nil)
|
||||
intArrayOf(0x10f46, 0x10f50), // Sogdian Combining Dot Be..Sogdian Combining Stroke
|
||||
intArrayOf(0x10f82, 0x10f85), // Old Uyghur Combining Dot..Old Uyghur Combining Two
|
||||
intArrayOf(0x11001, 0x11001), // Brahmi Sign Anusvara ..Brahmi Sign Anusvara
|
||||
intArrayOf(0x11038, 0x11046), // Brahmi Vowel Sign Aa ..Brahmi Virama
|
||||
intArrayOf(0x11070, 0x11070), // Brahmi Sign Old Tamil Vi..Brahmi Sign Old Tamil Vi
|
||||
intArrayOf(0x11073, 0x11074), // Brahmi Vowel Sign Old Ta..Brahmi Vowel Sign Old Ta
|
||||
intArrayOf(0x1107f, 0x11081), // Brahmi Number Joiner ..Kaithi Sign Anusvara
|
||||
intArrayOf(0x110b3, 0x110b6), // Kaithi Vowel Sign U ..Kaithi Vowel Sign Ai
|
||||
intArrayOf(0x110b9, 0x110ba), // Kaithi Sign Virama ..Kaithi Sign Nukta
|
||||
intArrayOf(0x110c2, 0x110c2), // Kaithi Vowel Sign Vocali..Kaithi Vowel Sign Vocali
|
||||
intArrayOf(0x11100, 0x11102), // Chakma Sign Candrabindu ..Chakma Sign Visarga
|
||||
intArrayOf(0x11127, 0x1112b), // Chakma Vowel Sign A ..Chakma Vowel Sign Uu
|
||||
intArrayOf(0x1112d, 0x11134), // Chakma Vowel Sign Ai ..Chakma Maayyaa
|
||||
intArrayOf(0x11173, 0x11173), // Mahajani Sign Nukta ..Mahajani Sign Nukta
|
||||
intArrayOf(0x11180, 0x11181), // Sharada Sign Candrabindu..Sharada Sign Anusvara
|
||||
intArrayOf(0x111b6, 0x111be), // Sharada Vowel Sign U ..Sharada Vowel Sign O
|
||||
intArrayOf(0x111c9, 0x111cc), // Sharada Sandhi Mark ..Sharada Extra Short Vowe
|
||||
intArrayOf(0x111cf, 0x111cf), // Sharada Sign Inverted Ca..Sharada Sign Inverted Ca
|
||||
intArrayOf(0x1122f, 0x11231), // Khojki Vowel Sign U ..Khojki Vowel Sign Ai
|
||||
intArrayOf(0x11234, 0x11234), // Khojki Sign Anusvara ..Khojki Sign Anusvara
|
||||
intArrayOf(0x11236, 0x11237), // Khojki Sign Nukta ..Khojki Sign Shadda
|
||||
intArrayOf(0x1123e, 0x1123e), // Khojki Sign Sukun ..Khojki Sign Sukun
|
||||
intArrayOf(0x11241, 0x11241), // (nil) ..(nil)
|
||||
intArrayOf(0x112df, 0x112df), // Khudawadi Sign Anusvara ..Khudawadi Sign Anusvara
|
||||
intArrayOf(0x112e3, 0x112ea), // Khudawadi Vowel Sign U ..Khudawadi Sign Virama
|
||||
intArrayOf(0x11300, 0x11301), // Grantha Sign Combining A..Grantha Sign Candrabindu
|
||||
intArrayOf(0x1133b, 0x1133c), // Combining Bindu Below ..Grantha Sign Nukta
|
||||
intArrayOf(0x11340, 0x11340), // Grantha Vowel Sign Ii ..Grantha Vowel Sign Ii
|
||||
intArrayOf(0x11366, 0x1136c), // Combining Grantha Digit ..Combining Grantha Digit
|
||||
intArrayOf(0x11370, 0x11374), // Combining Grantha Letter..Combining Grantha Letter
|
||||
intArrayOf(0x11438, 0x1143f), // Newa Vowel Sign U ..Newa Vowel Sign Ai
|
||||
intArrayOf(0x11442, 0x11444), // Newa Sign Virama ..Newa Sign Anusvara
|
||||
intArrayOf(0x11446, 0x11446), // Newa Sign Nukta ..Newa Sign Nukta
|
||||
intArrayOf(0x1145e, 0x1145e), // Newa Sandhi Mark ..Newa Sandhi Mark
|
||||
intArrayOf(0x114b3, 0x114b8), // Tirhuta Vowel Sign U ..Tirhuta Vowel Sign Vocal
|
||||
intArrayOf(0x114ba, 0x114ba), // Tirhuta Vowel Sign Short..Tirhuta Vowel Sign Short
|
||||
intArrayOf(0x114bf, 0x114c0), // Tirhuta Sign Candrabindu..Tirhuta Sign Anusvara
|
||||
intArrayOf(0x114c2, 0x114c3), // Tirhuta Sign Virama ..Tirhuta Sign Nukta
|
||||
intArrayOf(0x115b2, 0x115b5), // Siddham Vowel Sign U ..Siddham Vowel Sign Vocal
|
||||
intArrayOf(0x115bc, 0x115bd), // Siddham Sign Candrabindu..Siddham Sign Anusvara
|
||||
intArrayOf(0x115bf, 0x115c0), // Siddham Sign Virama ..Siddham Sign Nukta
|
||||
intArrayOf(0x115dc, 0x115dd), // Siddham Vowel Sign Alter..Siddham Vowel Sign Alter
|
||||
intArrayOf(0x11633, 0x1163a), // Modi Vowel Sign U ..Modi Vowel Sign Ai
|
||||
intArrayOf(0x1163d, 0x1163d), // Modi Sign Anusvara ..Modi Sign Anusvara
|
||||
intArrayOf(0x1163f, 0x11640), // Modi Sign Virama ..Modi Sign Ardhacandra
|
||||
intArrayOf(0x116ab, 0x116ab), // Takri Sign Anusvara ..Takri Sign Anusvara
|
||||
intArrayOf(0x116ad, 0x116ad), // Takri Vowel Sign Aa ..Takri Vowel Sign Aa
|
||||
intArrayOf(0x116b0, 0x116b5), // Takri Vowel Sign U ..Takri Vowel Sign Au
|
||||
intArrayOf(0x116b7, 0x116b7), // Takri Sign Nukta ..Takri Sign Nukta
|
||||
intArrayOf(0x1171d, 0x1171f), // Ahom Consonant Sign Medi..Ahom Consonant Sign Medi
|
||||
intArrayOf(0x11722, 0x11725), // Ahom Vowel Sign I ..Ahom Vowel Sign Uu
|
||||
intArrayOf(0x11727, 0x1172b), // Ahom Vowel Sign Aw ..Ahom Sign Killer
|
||||
intArrayOf(0x1182f, 0x11837), // Dogra Vowel Sign U ..Dogra Sign Anusvara
|
||||
intArrayOf(0x11839, 0x1183a), // Dogra Sign Virama ..Dogra Sign Nukta
|
||||
intArrayOf(0x1193b, 0x1193c), // Dives Akuru Sign Anusvar..Dives Akuru Sign Candrab
|
||||
intArrayOf(0x1193e, 0x1193e), // Dives Akuru Virama ..Dives Akuru Virama
|
||||
intArrayOf(0x11943, 0x11943), // Dives Akuru Sign Nukta ..Dives Akuru Sign Nukta
|
||||
intArrayOf(0x119d4, 0x119d7), // Nandinagari Vowel Sign U..Nandinagari Vowel Sign V
|
||||
intArrayOf(0x119da, 0x119db), // Nandinagari Vowel Sign E..Nandinagari Vowel Sign A
|
||||
intArrayOf(0x119e0, 0x119e0), // Nandinagari Sign Virama ..Nandinagari Sign Virama
|
||||
intArrayOf(0x11a01, 0x11a0a), // Zanabazar Square Vowel S..Zanabazar Square Vowel L
|
||||
intArrayOf(0x11a33, 0x11a38), // Zanabazar Square Final C..Zanabazar Square Sign An
|
||||
intArrayOf(0x11a3b, 0x11a3e), // Zanabazar Square Cluster..Zanabazar Square Cluster
|
||||
intArrayOf(0x11a47, 0x11a47), // Zanabazar Square Subjoin..Zanabazar Square Subjoin
|
||||
intArrayOf(0x11a51, 0x11a56), // Soyombo Vowel Sign I ..Soyombo Vowel Sign Oe
|
||||
intArrayOf(0x11a59, 0x11a5b), // Soyombo Vowel Sign Vocal..Soyombo Vowel Length Mar
|
||||
intArrayOf(0x11a8a, 0x11a96), // Soyombo Final Consonant ..Soyombo Sign Anusvara
|
||||
intArrayOf(0x11a98, 0x11a99), // Soyombo Gemination Mark ..Soyombo Subjoiner
|
||||
intArrayOf(0x11c30, 0x11c36), // Bhaiksuki Vowel Sign I ..Bhaiksuki Vowel Sign Voc
|
||||
intArrayOf(0x11c38, 0x11c3d), // Bhaiksuki Vowel Sign E ..Bhaiksuki Sign Anusvara
|
||||
intArrayOf(0x11c3f, 0x11c3f), // Bhaiksuki Sign Virama ..Bhaiksuki Sign Virama
|
||||
intArrayOf(0x11c92, 0x11ca7), // Marchen Subjoined Letter..Marchen Subjoined Letter
|
||||
intArrayOf(0x11caa, 0x11cb0), // Marchen Subjoined Letter..Marchen Vowel Sign Aa
|
||||
intArrayOf(0x11cb2, 0x11cb3), // Marchen Vowel Sign U ..Marchen Vowel Sign E
|
||||
intArrayOf(0x11cb5, 0x11cb6), // Marchen Sign Anusvara ..Marchen Sign Candrabindu
|
||||
intArrayOf(0x11d31, 0x11d36), // Masaram Gondi Vowel Sign..Masaram Gondi Vowel Sign
|
||||
intArrayOf(0x11d3a, 0x11d3a), // Masaram Gondi Vowel Sign..Masaram Gondi Vowel Sign
|
||||
intArrayOf(0x11d3c, 0x11d3d), // Masaram Gondi Vowel Sign..Masaram Gondi Vowel Sign
|
||||
intArrayOf(0x11d3f, 0x11d45), // Masaram Gondi Vowel Sign..Masaram Gondi Virama
|
||||
intArrayOf(0x11d47, 0x11d47), // Masaram Gondi Ra-kara ..Masaram Gondi Ra-kara
|
||||
intArrayOf(0x11d90, 0x11d91), // Gunjala Gondi Vowel Sign..Gunjala Gondi Vowel Sign
|
||||
intArrayOf(0x11d95, 0x11d95), // Gunjala Gondi Sign Anusv..Gunjala Gondi Sign Anusv
|
||||
intArrayOf(0x11d97, 0x11d97), // Gunjala Gondi Virama ..Gunjala Gondi Virama
|
||||
intArrayOf(0x11ef3, 0x11ef4), // Makasar Vowel Sign I ..Makasar Vowel Sign U
|
||||
intArrayOf(0x11f00, 0x11f01), // (nil) ..(nil)
|
||||
intArrayOf(0x11f36, 0x11f3a), // (nil) ..(nil)
|
||||
intArrayOf(0x11f40, 0x11f40), // (nil) ..(nil)
|
||||
intArrayOf(0x11f42, 0x11f42), // (nil) ..(nil)
|
||||
intArrayOf(0x13440, 0x13440), // (nil) ..(nil)
|
||||
intArrayOf(0x13447, 0x13455), // (nil) ..(nil)
|
||||
intArrayOf(0x16af0, 0x16af4), // Bassa Vah Combining High..Bassa Vah Combining High
|
||||
intArrayOf(0x16b30, 0x16b36), // Pahawh Hmong Mark Cim Tu..Pahawh Hmong Mark Cim Ta
|
||||
intArrayOf(0x16f4f, 0x16f4f), // Miao Sign Consonant Modi..Miao Sign Consonant Modi
|
||||
intArrayOf(0x16f8f, 0x16f92), // Miao Tone Right ..Miao Tone Below
|
||||
intArrayOf(0x16fe4, 0x16fe4), // Khitan Small Script Fill..Khitan Small Script Fill
|
||||
intArrayOf(0x1bc9d, 0x1bc9e), // Duployan Thick Letter Se..Duployan Double Mark
|
||||
intArrayOf(0x1cf00, 0x1cf2d), // Znamenny Combining Mark ..Znamenny Combining Mark
|
||||
intArrayOf(0x1cf30, 0x1cf46), // Znamenny Combining Tonal..Znamenny Priznak Modifie
|
||||
intArrayOf(0x1d167, 0x1d169), // Musical Symbol Combining..Musical Symbol Combining
|
||||
intArrayOf(0x1d17b, 0x1d182), // Musical Symbol Combining..Musical Symbol Combining
|
||||
intArrayOf(0x1d185, 0x1d18b), // Musical Symbol Combining..Musical Symbol Combining
|
||||
intArrayOf(0x1d1aa, 0x1d1ad), // Musical Symbol Combining..Musical Symbol Combining
|
||||
intArrayOf(0x1d242, 0x1d244), // Combining Greek Musical ..Combining Greek Musical
|
||||
intArrayOf(0x1da00, 0x1da36), // Signwriting Head Rim ..Signwriting Air Sucking
|
||||
intArrayOf(0x1da3b, 0x1da6c), // Signwriting Mouth Closed..Signwriting Excitement
|
||||
intArrayOf(0x1da75, 0x1da75), // Signwriting Upper Body T..Signwriting Upper Body T
|
||||
intArrayOf(0x1da84, 0x1da84), // Signwriting Location Hea..Signwriting Location Hea
|
||||
intArrayOf(0x1da9b, 0x1da9f), // Signwriting Fill Modifie..Signwriting Fill Modifie
|
||||
intArrayOf(0x1daa1, 0x1daaf), // Signwriting Rotation Mod..Signwriting Rotation Mod
|
||||
intArrayOf(0x1e000, 0x1e006), // Combining Glagolitic Let..Combining Glagolitic Let
|
||||
intArrayOf(0x1e008, 0x1e018), // Combining Glagolitic Let..Combining Glagolitic Let
|
||||
intArrayOf(0x1e01b, 0x1e021), // Combining Glagolitic Let..Combining Glagolitic Let
|
||||
intArrayOf(0x1e023, 0x1e024), // Combining Glagolitic Let..Combining Glagolitic Let
|
||||
intArrayOf(0x1e026, 0x1e02a), // Combining Glagolitic Let..Combining Glagolitic Let
|
||||
intArrayOf(0x1e08f, 0x1e08f), // (nil) ..(nil)
|
||||
intArrayOf(0x1e130, 0x1e136), // Nyiakeng Puachue Hmong T..Nyiakeng Puachue Hmong T
|
||||
intArrayOf(0x1e2ae, 0x1e2ae), // Toto Sign Rising Tone ..Toto Sign Rising Tone
|
||||
intArrayOf(0x1e2ec, 0x1e2ef), // Wancho Tone Tup ..Wancho Tone Koini
|
||||
intArrayOf(0x1e4ec, 0x1e4ef), // (nil) ..(nil)
|
||||
intArrayOf(0x1e8d0, 0x1e8d6), // Mende Kikakui Combining ..Mende Kikakui Combining
|
||||
intArrayOf(0x1e944, 0x1e94a), // Adlam Alif Lengthener ..Adlam Nukta
|
||||
intArrayOf(0xe0100, 0xe01ef), // Variation Selector-17 ..Variation Selector-256
|
||||
)
|
||||
|
||||
// https://github.com/jquast/wcwidth/blob/master/wcwidth/table_wide.py
|
||||
// from https://github.com/jquast/wcwidth/pull/64
|
||||
// at commit 1b9b6585b0080ea5cb88dc9815796505724793fe (2022-12-16):
|
||||
private val WIDE_EASTASIAN = arrayOf(
|
||||
intArrayOf(0x01100, 0x0115f), // Hangul Choseong Kiyeok ..Hangul Choseong Filler
|
||||
intArrayOf(0x0231a, 0x0231b), // Watch ..Hourglass
|
||||
intArrayOf(0x02329, 0x0232a), // Left-pointing Angle Brac..Right-pointing Angle Bra
|
||||
intArrayOf(0x023e9, 0x023ec), // Black Right-pointing Dou..Black Down-pointing Doub
|
||||
intArrayOf(0x023f0, 0x023f0), // Alarm Clock ..Alarm Clock
|
||||
intArrayOf(0x023f3, 0x023f3), // Hourglass With Flowing S..Hourglass With Flowing S
|
||||
intArrayOf(0x025fd, 0x025fe), // White Medium Small Squar..Black Medium Small Squar
|
||||
intArrayOf(0x02614, 0x02615), // Umbrella With Rain Drops..Hot Beverage
|
||||
intArrayOf(0x02648, 0x02653), // Aries ..Pisces
|
||||
intArrayOf(0x0267f, 0x0267f), // Wheelchair Symbol ..Wheelchair Symbol
|
||||
intArrayOf(0x02693, 0x02693), // Anchor ..Anchor
|
||||
intArrayOf(0x026a1, 0x026a1), // High Voltage Sign ..High Voltage Sign
|
||||
intArrayOf(0x026aa, 0x026ab), // Medium White Circle ..Medium Black Circle
|
||||
intArrayOf(0x026bd, 0x026be), // Soccer Ball ..Baseball
|
||||
intArrayOf(0x026c4, 0x026c5), // Snowman Without Snow ..Sun Behind Cloud
|
||||
intArrayOf(0x026ce, 0x026ce), // Ophiuchus ..Ophiuchus
|
||||
intArrayOf(0x026d4, 0x026d4), // No Entry ..No Entry
|
||||
intArrayOf(0x026ea, 0x026ea), // Church ..Church
|
||||
intArrayOf(0x026f2, 0x026f3), // Fountain ..Flag In Hole
|
||||
intArrayOf(0x026f5, 0x026f5), // Sailboat ..Sailboat
|
||||
intArrayOf(0x026fa, 0x026fa), // Tent ..Tent
|
||||
intArrayOf(0x026fd, 0x026fd), // Fuel Pump ..Fuel Pump
|
||||
intArrayOf(0x02705, 0x02705), // White Heavy Check Mark ..White Heavy Check Mark
|
||||
intArrayOf(0x0270a, 0x0270b), // Raised Fist ..Raised Hand
|
||||
intArrayOf(0x02728, 0x02728), // Sparkles ..Sparkles
|
||||
intArrayOf(0x0274c, 0x0274c), // Cross Mark ..Cross Mark
|
||||
intArrayOf(0x0274e, 0x0274e), // Negative Squared Cross M..Negative Squared Cross M
|
||||
intArrayOf(0x02753, 0x02755), // Black Question Mark Orna..White Exclamation Mark O
|
||||
intArrayOf(0x02757, 0x02757), // Heavy Exclamation Mark S..Heavy Exclamation Mark S
|
||||
intArrayOf(0x02795, 0x02797), // Heavy Plus Sign ..Heavy Division Sign
|
||||
intArrayOf(0x027b0, 0x027b0), // Curly Loop ..Curly Loop
|
||||
intArrayOf(0x027bf, 0x027bf), // Double Curly Loop ..Double Curly Loop
|
||||
intArrayOf(0x02b1b, 0x02b1c), // Black Large Square ..White Large Square
|
||||
intArrayOf(0x02b50, 0x02b50), // White Medium Star ..White Medium Star
|
||||
intArrayOf(0x02b55, 0x02b55), // Heavy Large Circle ..Heavy Large Circle
|
||||
intArrayOf(0x02e80, 0x02e99), // Cjk Radical Repeat ..Cjk Radical Rap
|
||||
intArrayOf(0x02e9b, 0x02ef3), // Cjk Radical Choke ..Cjk Radical C-simplified
|
||||
intArrayOf(0x02f00, 0x02fd5), // Kangxi Radical One ..Kangxi Radical Flute
|
||||
intArrayOf(0x02ff0, 0x02ffb), // Ideographic Description ..Ideographic Description
|
||||
intArrayOf(0x03000, 0x0303e), // Ideographic Space ..Ideographic Variation In
|
||||
intArrayOf(0x03041, 0x03096), // Hiragana Letter Small A ..Hiragana Letter Small Ke
|
||||
intArrayOf(0x03099, 0x030ff), // Combining Katakana-hirag..Katakana Digraph Koto
|
||||
intArrayOf(0x03105, 0x0312f), // Bopomofo Letter B ..Bopomofo Letter Nn
|
||||
intArrayOf(0x03131, 0x0318e), // Hangul Letter Kiyeok ..Hangul Letter Araeae
|
||||
intArrayOf(0x03190, 0x031e3), // Ideographic Annotation L..Cjk Stroke Q
|
||||
intArrayOf(0x031f0, 0x0321e), // Katakana Letter Small Ku..Parenthesized Korean Cha
|
||||
intArrayOf(0x03220, 0x03247), // Parenthesized Ideograph ..Circled Ideograph Koto
|
||||
intArrayOf(0x03250, 0x04dbf), // Partnership Sign ..Cjk Unified Ideograph-4d
|
||||
intArrayOf(0x04e00, 0x0a48c), // Cjk Unified Ideograph-4e..Yi Syllable Yyr
|
||||
intArrayOf(0x0a490, 0x0a4c6), // Yi Radical Qot ..Yi Radical Ke
|
||||
intArrayOf(0x0a960, 0x0a97c), // Hangul Choseong Tikeut-m..Hangul Choseong Ssangyeo
|
||||
intArrayOf(0x0ac00, 0x0d7a3), // Hangul Syllable Ga ..Hangul Syllable Hih
|
||||
intArrayOf(0x0f900, 0x0faff), // Cjk Compatibility Ideogr..(nil)
|
||||
intArrayOf(0x0fe10, 0x0fe19), // Presentation Form For Ve..Presentation Form For Ve
|
||||
intArrayOf(0x0fe30, 0x0fe52), // Presentation Form For Ve..Small Full Stop
|
||||
intArrayOf(0x0fe54, 0x0fe66), // Small Semicolon ..Small Equals Sign
|
||||
intArrayOf(0x0fe68, 0x0fe6b), // Small Reverse Solidus ..Small Commercial At
|
||||
intArrayOf(0x0ff01, 0x0ff60), // Fullwidth Exclamation Ma..Fullwidth Right White Pa
|
||||
intArrayOf(0x0ffe0, 0x0ffe6), // Fullwidth Cent Sign ..Fullwidth Won Sign
|
||||
intArrayOf(0x16fe0, 0x16fe4), // Tangut Iteration Mark ..Khitan Small Script Fill
|
||||
intArrayOf(0x16ff0, 0x16ff1), // Vietnamese Alternate Rea..Vietnamese Alternate Rea
|
||||
intArrayOf(0x17000, 0x187f7), // (nil) ..(nil)
|
||||
intArrayOf(0x18800, 0x18cd5), // Tangut Component-001 ..Khitan Small Script Char
|
||||
intArrayOf(0x18d00, 0x18d08), // (nil) ..(nil)
|
||||
intArrayOf(0x1aff0, 0x1aff3), // Katakana Letter Minnan T..Katakana Letter Minnan T
|
||||
intArrayOf(0x1aff5, 0x1affb), // Katakana Letter Minnan T..Katakana Letter Minnan N
|
||||
intArrayOf(0x1affd, 0x1affe), // Katakana Letter Minnan N..Katakana Letter Minnan N
|
||||
intArrayOf(0x1b000, 0x1b122), // Katakana Letter Archaic ..Katakana Letter Archaic
|
||||
intArrayOf(0x1b132, 0x1b132), // (nil) ..(nil)
|
||||
intArrayOf(0x1b150, 0x1b152), // Hiragana Letter Small Wi..Hiragana Letter Small Wo
|
||||
intArrayOf(0x1b155, 0x1b155), // (nil) ..(nil)
|
||||
intArrayOf(0x1b164, 0x1b167), // Katakana Letter Small Wi..Katakana Letter Small N
|
||||
intArrayOf(0x1b170, 0x1b2fb), // Nushu Character-1b170 ..Nushu Character-1b2fb
|
||||
intArrayOf(0x1f004, 0x1f004), // Mahjong Tile Red Dragon ..Mahjong Tile Red Dragon
|
||||
intArrayOf(0x1f0cf, 0x1f0cf), // Playing Card Black Joker..Playing Card Black Joker
|
||||
intArrayOf(0x1f18e, 0x1f18e), // Negative Squared Ab ..Negative Squared Ab
|
||||
intArrayOf(0x1f191, 0x1f19a), // Squared Cl ..Squared Vs
|
||||
intArrayOf(0x1f200, 0x1f202), // Square Hiragana Hoka ..Squared Katakana Sa
|
||||
intArrayOf(0x1f210, 0x1f23b), // Squared Cjk Unified Ideo..Squared Cjk Unified Ideo
|
||||
intArrayOf(0x1f240, 0x1f248), // Tortoise Shell Bracketed..Tortoise Shell Bracketed
|
||||
intArrayOf(0x1f250, 0x1f251), // Circled Ideograph Advant..Circled Ideograph Accept
|
||||
intArrayOf(0x1f260, 0x1f265), // Rounded Symbol For Fu ..Rounded Symbol For Cai
|
||||
intArrayOf(0x1f300, 0x1f320), // Cyclone ..Shooting Star
|
||||
intArrayOf(0x1f32d, 0x1f335), // Hot Dog ..Cactus
|
||||
intArrayOf(0x1f337, 0x1f37c), // Tulip ..Baby Bottle
|
||||
intArrayOf(0x1f37e, 0x1f393), // Bottle With Popping Cork..Graduation Cap
|
||||
intArrayOf(0x1f3a0, 0x1f3ca), // Carousel Horse ..Swimmer
|
||||
intArrayOf(0x1f3cf, 0x1f3d3), // Cricket Bat And Ball ..Table Tennis Paddle And
|
||||
intArrayOf(0x1f3e0, 0x1f3f0), // House Building ..European Castle
|
||||
intArrayOf(0x1f3f4, 0x1f3f4), // Waving Black Flag ..Waving Black Flag
|
||||
intArrayOf(0x1f3f8, 0x1f43e), // Badminton Racquet And Sh..Paw Prints
|
||||
intArrayOf(0x1f440, 0x1f440), // Eyes ..Eyes
|
||||
intArrayOf(0x1f442, 0x1f4fc), // Ear ..Videocassette
|
||||
intArrayOf(0x1f4ff, 0x1f53d), // Prayer Beads ..Down-pointing Small Red
|
||||
intArrayOf(0x1f54b, 0x1f54e), // Kaaba ..Menorah With Nine Branch
|
||||
intArrayOf(0x1f550, 0x1f567), // Clock Face One Oclock ..Clock Face Twelve-thirty
|
||||
intArrayOf(0x1f57a, 0x1f57a), // Man Dancing ..Man Dancing
|
||||
intArrayOf(0x1f595, 0x1f596), // Reversed Hand With Middl..Raised Hand With Part Be
|
||||
intArrayOf(0x1f5a4, 0x1f5a4), // Black Heart ..Black Heart
|
||||
intArrayOf(0x1f5fb, 0x1f64f), // Mount Fuji ..Person With Folded Hands
|
||||
intArrayOf(0x1f680, 0x1f6c5), // Rocket ..Left Luggage
|
||||
intArrayOf(0x1f6cc, 0x1f6cc), // Sleeping Accommodation ..Sleeping Accommodation
|
||||
intArrayOf(0x1f6d0, 0x1f6d2), // Place Of Worship ..Shopping Trolley
|
||||
intArrayOf(0x1f6d5, 0x1f6d7), // Hindu Temple ..Elevator
|
||||
intArrayOf(0x1f6dc, 0x1f6df), // (nil) ..Ring Buoy
|
||||
intArrayOf(0x1f6eb, 0x1f6ec), // Airplane Departure ..Airplane Arriving
|
||||
intArrayOf(0x1f6f4, 0x1f6fc), // Scooter ..Roller Skate
|
||||
intArrayOf(0x1f7e0, 0x1f7eb), // Large Orange Circle ..Large Brown Square
|
||||
intArrayOf(0x1f7f0, 0x1f7f0), // Heavy Equals Sign ..Heavy Equals Sign
|
||||
intArrayOf(0x1f90c, 0x1f93a), // Pinched Fingers ..Fencer
|
||||
intArrayOf(0x1f93c, 0x1f945), // Wrestlers ..Goal Net
|
||||
intArrayOf(0x1f947, 0x1f9ff), // First Place Medal ..Nazar Amulet
|
||||
intArrayOf(0x1fa70, 0x1fa7c), // Ballet Shoes ..Crutch
|
||||
intArrayOf(0x1fa80, 0x1fa88), // Yo-yo ..(nil)
|
||||
intArrayOf(0x1fa90, 0x1fabd), // Ringed Planet ..(nil)
|
||||
intArrayOf(0x1fabf, 0x1fac5), // (nil) ..Person With Crown
|
||||
intArrayOf(0x1face, 0x1fadb), // (nil) ..(nil)
|
||||
intArrayOf(0x1fae0, 0x1fae8), // Melting Face ..(nil)
|
||||
intArrayOf(0x1faf0, 0x1faf8), // Hand With Index Finger A..(nil)
|
||||
intArrayOf(0x20000, 0x2fffd), // Cjk Unified Ideograph-20..(nil)
|
||||
intArrayOf(0x30000, 0x3fffd), // Cjk Unified Ideograph-30..(nil)
|
||||
)
|
||||
|
||||
private fun intable(table: Array<IntArray>, c: Int): Boolean {
|
||||
if (c < table[0][0]) return false
|
||||
var bot = 0
|
||||
var top = table.size - 1
|
||||
while (top >= bot) {
|
||||
val mid = (bot + top) / 2
|
||||
if (table[mid][1] < c) {
|
||||
bot = mid + 1
|
||||
} else if (table[mid][0] > c) {
|
||||
top = mid - 1
|
||||
} else {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/** Return the terminal display width of a code point: 0, 1 or 2. */
|
||||
fun width(ucs: Int): Int {
|
||||
if (ucs == 0 ||
|
||||
ucs == 0x034F ||
|
||||
(ucs in 0x200B..0x200F) ||
|
||||
ucs == 0x2028 ||
|
||||
ucs == 0x2029 ||
|
||||
(ucs in 0x202A..0x202E) ||
|
||||
(ucs in 0x2060..0x2063)) {
|
||||
return 0
|
||||
}
|
||||
|
||||
// C0/C1 control characters
|
||||
// Termux change: Return 0 instead of -1.
|
||||
if (ucs < 32 || (ucs in 0x07F until 0x0A0)) return 0
|
||||
|
||||
if (intable(ZERO_WIDTH, ucs)) return 0
|
||||
|
||||
return if (intable(WIDE_EASTASIAN, ucs)) 2 else 1
|
||||
}
|
||||
|
||||
/** The width at an index position in a java char array. */
|
||||
fun width(chars: CharArray, index: Int): Int {
|
||||
val c = chars[index]
|
||||
return if (Character.isHighSurrogate(c)) width(Character.toCodePoint(c, chars[index + 1])) else width(c.code)
|
||||
}
|
||||
|
||||
/**
|
||||
* The zero width characters count like combining characters in the `chars` array from start
|
||||
* index to end index (exclusive).
|
||||
*/
|
||||
fun zeroWidthCharsCount(chars: CharArray, start: Int, end: Int): Int {
|
||||
if (start < 0 || start >= chars.size) return 0
|
||||
var count = 0
|
||||
var i = start
|
||||
while (i < end && i < chars.size) {
|
||||
if (Character.isHighSurrogate(chars[i])) {
|
||||
if (width(Character.toCodePoint(chars[i], chars[i + 1])) <= 0) {
|
||||
count++
|
||||
}
|
||||
i += 2
|
||||
} else {
|
||||
if (width(chars[i].code) <= 0) {
|
||||
count++
|
||||
}
|
||||
i++
|
||||
}
|
||||
}
|
||||
return count
|
||||
}
|
||||
}
|
||||
@@ -3,103 +3,73 @@ package com.topjohnwu.magisk.ui
|
||||
import android.Manifest
|
||||
import android.Manifest.permission.REQUEST_INSTALL_PACKAGES
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.ApplicationInfo
|
||||
import android.content.res.Resources
|
||||
import android.graphics.Color
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.view.MenuItem
|
||||
import android.view.View
|
||||
import android.view.WindowManager
|
||||
import android.widget.Toast
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.appcompat.app.AppCompatDelegate
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.core.content.pm.ShortcutManagerCompat
|
||||
import androidx.core.content.res.use
|
||||
import androidx.core.view.WindowCompat
|
||||
import androidx.core.view.forEach
|
||||
import androidx.core.view.isGone
|
||||
import androidx.core.view.isVisible
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator
|
||||
import androidx.navigation3.runtime.entryProvider
|
||||
import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator
|
||||
import androidx.navigation3.ui.NavDisplay
|
||||
import com.topjohnwu.magisk.arch.VMFactory
|
||||
import androidx.navigation.NavDirections
|
||||
import com.topjohnwu.magisk.MainDirections
|
||||
import com.topjohnwu.magisk.R
|
||||
import com.topjohnwu.magisk.arch.BaseViewModel
|
||||
import com.topjohnwu.magisk.arch.NavigationActivity
|
||||
import com.topjohnwu.magisk.arch.startAnimations
|
||||
import com.topjohnwu.magisk.arch.viewModel
|
||||
import com.topjohnwu.magisk.core.Config
|
||||
import com.topjohnwu.magisk.core.Const
|
||||
import com.topjohnwu.magisk.core.Info
|
||||
import com.topjohnwu.magisk.core.base.ActivityExtension
|
||||
import com.topjohnwu.magisk.core.base.SplashController
|
||||
import com.topjohnwu.magisk.core.base.SplashScreenHost
|
||||
import com.topjohnwu.magisk.core.isRunningAsStub
|
||||
import com.topjohnwu.magisk.core.ktx.reflectField
|
||||
import com.topjohnwu.magisk.core.ktx.toast
|
||||
import com.topjohnwu.magisk.core.model.module.LocalModule
|
||||
import com.topjohnwu.magisk.core.tasks.AppMigration
|
||||
import com.topjohnwu.magisk.core.wrap
|
||||
import com.topjohnwu.magisk.ui.deny.DenyListScreen
|
||||
import com.topjohnwu.magisk.ui.deny.DenyListViewModel
|
||||
import com.topjohnwu.magisk.ui.flash.FlashScreen
|
||||
import com.topjohnwu.magisk.ui.flash.FlashUtils
|
||||
import com.topjohnwu.magisk.ui.flash.FlashViewModel
|
||||
import com.topjohnwu.magisk.ui.module.ActionScreen
|
||||
import com.topjohnwu.magisk.ui.module.ActionViewModel
|
||||
import com.topjohnwu.magisk.ui.superuser.SuperuserDetailScreen
|
||||
import com.topjohnwu.magisk.ui.superuser.SuperuserViewModel
|
||||
import com.topjohnwu.magisk.ui.navigation.CollectNavEvents
|
||||
import com.topjohnwu.magisk.ui.navigation.LocalNavigator
|
||||
import com.topjohnwu.magisk.ui.navigation.Navigator
|
||||
import com.topjohnwu.magisk.ui.navigation.Route
|
||||
import com.topjohnwu.magisk.ui.navigation.rememberNavigator
|
||||
import com.topjohnwu.magisk.ui.theme.MagiskTheme
|
||||
import com.topjohnwu.magisk.databinding.ActivityMainMd2Binding
|
||||
import com.topjohnwu.magisk.ui.home.HomeFragmentDirections
|
||||
import com.topjohnwu.magisk.ui.theme.Theme
|
||||
import com.topjohnwu.magisk.view.MagiskDialog
|
||||
import com.topjohnwu.magisk.view.Shortcuts
|
||||
import top.yukonga.miuix.kmp.utils.MiuixPopupUtils.Companion.MiuixPopupHost
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.ui.Modifier
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import java.io.File
|
||||
import com.topjohnwu.magisk.core.R as CoreR
|
||||
|
||||
class MainActivity : AppCompatActivity(), SplashScreenHost {
|
||||
class MainViewModel : BaseViewModel()
|
||||
|
||||
override val extension = ActivityExtension(this)
|
||||
class MainActivity : NavigationActivity<ActivityMainMd2Binding>(), SplashScreenHost {
|
||||
|
||||
override val layoutRes = R.layout.activity_main_md2
|
||||
override val viewModel by viewModel<MainViewModel>()
|
||||
override val navHostId: Int = R.id.main_nav_host
|
||||
override val splashController = SplashController(this)
|
||||
|
||||
private val intentState = MutableStateFlow(0)
|
||||
internal val showInvalidState = MutableStateFlow(false)
|
||||
internal val showUnsupported = MutableStateFlow<List<Pair<Int, Int>>>(emptyList())
|
||||
internal val showShortcutPrompt = MutableStateFlow(false)
|
||||
|
||||
init {
|
||||
AppCompatDelegate.setDefaultNightMode(Config.darkTheme)
|
||||
}
|
||||
|
||||
override fun attachBaseContext(base: Context) {
|
||||
super.attachBaseContext(base.wrap())
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
extension.onCreate(savedInstanceState)
|
||||
if (isRunningAsStub) {
|
||||
val delegate = delegate
|
||||
val clz = delegate.javaClass
|
||||
clz.reflectField("mActivityHandlesConfigFlagsChecked").set(delegate, true)
|
||||
clz.reflectField("mActivityHandlesConfigFlags").set(delegate, 0)
|
||||
override val snackbarView: View
|
||||
get() {
|
||||
val fragmentOverride = currentFragment?.snackbarView
|
||||
return fragmentOverride ?: super.snackbarView
|
||||
}
|
||||
override val snackbarAnchorView: View?
|
||||
get() {
|
||||
val fragmentAnchor = currentFragment?.snackbarAnchorView
|
||||
return when {
|
||||
fragmentAnchor?.isVisible == true -> fragmentAnchor
|
||||
binding.mainNavigation.isVisible -> return binding.mainNavigation
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
private var isRootFragment = true
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
setTheme(Theme.selected.themeRes)
|
||||
splashController.preOnCreate()
|
||||
super.onCreate(savedInstanceState)
|
||||
splashController.onCreate(savedInstanceState)
|
||||
|
||||
setupWindow()
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
@@ -107,257 +77,202 @@ class MainActivity : AppCompatActivity(), SplashScreenHost {
|
||||
splashController.onResume()
|
||||
}
|
||||
|
||||
override fun onSaveInstanceState(outState: Bundle) {
|
||||
super.onSaveInstanceState(outState)
|
||||
extension.onSaveInstanceState(outState)
|
||||
}
|
||||
|
||||
private fun setupWindow() {
|
||||
obtainStyledAttributes(intArrayOf(android.R.attr.windowBackground))
|
||||
.use { it.getDrawable(0) }
|
||||
.also { window.setBackgroundDrawable(it) }
|
||||
|
||||
WindowCompat.setDecorFitsSystemWindows(window, false)
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
window?.decorView?.post {
|
||||
if ((window.decorView.rootWindowInsets?.systemWindowInsetBottom
|
||||
?: 0) < Resources.getSystem().displayMetrics.density * 40) {
|
||||
window.navigationBarColor = Color.TRANSPARENT
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
|
||||
window.navigationBarDividerColor = Color.TRANSPARENT
|
||||
}
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
window.isNavigationBarContrastEnforced = false
|
||||
window.isStatusBarContrastEnforced = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressLint("InlinedApi")
|
||||
override fun onCreateUi(savedInstanceState: Bundle?) {
|
||||
setContentView()
|
||||
showUnsupportedMessage()
|
||||
askForHomeShortcut()
|
||||
|
||||
// Ask permission to post notifications for background update check
|
||||
if (Config.checkUpdate) {
|
||||
extension.withPermission(Manifest.permission.POST_NOTIFICATIONS) {
|
||||
withPermission(Manifest.permission.POST_NOTIFICATIONS) {
|
||||
Config.checkUpdate = it
|
||||
}
|
||||
}
|
||||
|
||||
window.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE)
|
||||
|
||||
val initialTab = getInitialTab(intent)
|
||||
navigation.addOnDestinationChangedListener { _, destination, _ ->
|
||||
isRootFragment = when (destination.id) {
|
||||
R.id.homeFragment,
|
||||
R.id.modulesFragment,
|
||||
R.id.superuserFragment,
|
||||
R.id.logFragment -> true
|
||||
else -> false
|
||||
}
|
||||
|
||||
setContent {
|
||||
MagiskTheme {
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
val navigator = rememberNavigator(Route.Main)
|
||||
CompositionLocalProvider(LocalNavigator provides navigator) {
|
||||
HandleFlashIntent(navigator)
|
||||
setDisplayHomeAsUpEnabled(!isRootFragment)
|
||||
requestNavigationHidden(!isRootFragment)
|
||||
|
||||
NavDisplay(
|
||||
backStack = navigator.backStack,
|
||||
onBack = { navigator.pop() },
|
||||
entryDecorators = listOf(
|
||||
rememberSaveableStateHolderNavEntryDecorator(),
|
||||
rememberViewModelStoreNavEntryDecorator()
|
||||
),
|
||||
entryProvider = entryProvider {
|
||||
entry<Route.Main> {
|
||||
MainScreen(initialTab = initialTab)
|
||||
}
|
||||
entry<Route.DenyList> { _ ->
|
||||
val vm: DenyListViewModel = androidx.lifecycle.viewmodel.compose.viewModel(factory = VMFactory)
|
||||
LaunchedEffect(Unit) { vm.startLoading() }
|
||||
DenyListScreen(vm, onBack = { navigator.pop() })
|
||||
}
|
||||
entry<Route.Flash> { key ->
|
||||
val vm: FlashViewModel = androidx.lifecycle.viewmodel.compose.viewModel(factory = VMFactory)
|
||||
LaunchedEffect(key) {
|
||||
if (vm.flashAction.isEmpty()) {
|
||||
vm.flashAction = key.action
|
||||
vm.flashUri = key.additionalData?.let { Uri.parse(it) }
|
||||
vm.startFlashing()
|
||||
}
|
||||
}
|
||||
FlashScreen(vm, action = key.action, onBack = { navigator.pop() })
|
||||
}
|
||||
entry<Route.SuperuserDetail> { key ->
|
||||
val vm: SuperuserViewModel = androidx.lifecycle.viewmodel.compose.viewModel(
|
||||
viewModelStoreOwner = this@MainActivity, factory = VMFactory
|
||||
)
|
||||
LaunchedEffect(Unit) {
|
||||
vm.authenticate = { onSuccess ->
|
||||
extension.withAuthentication { if (it) onSuccess() }
|
||||
}
|
||||
}
|
||||
SuperuserDetailScreen(uid = key.uid, viewModel = vm, onBack = { navigator.pop() })
|
||||
}
|
||||
entry<Route.Action> { key ->
|
||||
val vm: ActionViewModel = androidx.lifecycle.viewmodel.compose.viewModel(factory = VMFactory)
|
||||
LaunchedEffect(key) {
|
||||
if (vm.actionId.isEmpty()) {
|
||||
vm.actionId = key.id
|
||||
vm.actionName = key.name
|
||||
vm.startRunAction()
|
||||
}
|
||||
}
|
||||
ActionScreen(vm, actionName = key.name, onBack = { navigator.pop() })
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
MainActivityDialogs(activity = this@MainActivity)
|
||||
MiuixPopupHost()
|
||||
binding.mainNavigation.menu.forEach {
|
||||
if (it.itemId == destination.id) {
|
||||
it.isChecked = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun HandleFlashIntent(navigator: Navigator) {
|
||||
val intentVersion by intentState.collectAsState()
|
||||
LaunchedEffect(intentVersion) {
|
||||
val currentIntent = intent ?: return@LaunchedEffect
|
||||
if (currentIntent.action == FlashUtils.INTENT_FLASH) {
|
||||
val action = currentIntent.getStringExtra(FlashUtils.EXTRA_FLASH_ACTION)
|
||||
?: return@LaunchedEffect
|
||||
val uri = currentIntent.getStringExtra(FlashUtils.EXTRA_FLASH_URI)
|
||||
navigator.push(Route.Flash(action, uri))
|
||||
currentIntent.action = null
|
||||
}
|
||||
setSupportActionBar(binding.mainToolbar)
|
||||
|
||||
binding.mainNavigation.setOnItemSelectedListener {
|
||||
getScreen(it.itemId)?.navigate()
|
||||
true
|
||||
}
|
||||
binding.mainNavigation.setOnItemReselectedListener {
|
||||
// https://issuetracker.google.com/issues/124538620
|
||||
}
|
||||
binding.mainNavigation.menu.apply {
|
||||
findItem(R.id.superuserFragment)?.isEnabled = Info.showSuperUser
|
||||
findItem(R.id.modulesFragment)?.isEnabled = Info.env.isActive && LocalModule.loaded()
|
||||
}
|
||||
|
||||
val section =
|
||||
if (intent.action == Intent.ACTION_APPLICATION_PREFERENCES)
|
||||
Const.Nav.SETTINGS
|
||||
else
|
||||
intent.getStringExtra(Const.Key.OPEN_SECTION)
|
||||
|
||||
getScreen(section)?.navigate()
|
||||
|
||||
if (!isRootFragment) {
|
||||
requestNavigationHidden(requiresAnimation = savedInstanceState == null)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onNewIntent(intent: Intent) {
|
||||
super.onNewIntent(intent)
|
||||
setIntent(intent)
|
||||
intentState.value += 1
|
||||
override fun onOptionsItemSelected(item: MenuItem): Boolean {
|
||||
when (item.itemId) {
|
||||
android.R.id.home -> onBackPressed()
|
||||
else -> return super.onOptionsItemSelected(item)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
private fun getInitialTab(intent: Intent?): Int {
|
||||
val section = if (intent?.action == Intent.ACTION_APPLICATION_PREFERENCES) {
|
||||
Const.Nav.SETTINGS
|
||||
fun setDisplayHomeAsUpEnabled(isEnabled: Boolean) {
|
||||
binding.mainToolbar.startAnimations()
|
||||
when {
|
||||
isEnabled -> binding.mainToolbar.setNavigationIcon(R.drawable.ic_back_md2)
|
||||
else -> binding.mainToolbar.navigationIcon = null
|
||||
}
|
||||
}
|
||||
|
||||
internal fun requestNavigationHidden(hide: Boolean = true, requiresAnimation: Boolean = true) {
|
||||
val bottomView = binding.mainNavigation
|
||||
if (requiresAnimation) {
|
||||
bottomView.isVisible = true
|
||||
bottomView.isHidden = hide
|
||||
} else {
|
||||
intent?.getStringExtra(Const.Key.OPEN_SECTION)
|
||||
bottomView.isGone = hide
|
||||
}
|
||||
return when (section) {
|
||||
Const.Nav.SUPERUSER -> Tab.SUPERUSER.ordinal
|
||||
Const.Nav.MODULES -> Tab.MODULES.ordinal
|
||||
Const.Nav.SETTINGS -> Tab.SETTINGS.ordinal
|
||||
else -> Tab.HOME.ordinal
|
||||
}
|
||||
|
||||
fun invalidateToolbar() {
|
||||
//binding.mainToolbar.startAnimations()
|
||||
binding.mainToolbar.invalidate()
|
||||
}
|
||||
|
||||
private fun getScreen(name: String?): NavDirections? {
|
||||
return when (name) {
|
||||
Const.Nav.SUPERUSER -> MainDirections.actionSuperuserFragment()
|
||||
Const.Nav.MODULES -> MainDirections.actionModuleFragment()
|
||||
Const.Nav.SETTINGS -> HomeFragmentDirections.actionHomeFragmentToSettingsFragment()
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
private fun getScreen(id: Int): NavDirections? {
|
||||
return when (id) {
|
||||
R.id.homeFragment -> MainDirections.actionHomeFragment()
|
||||
R.id.modulesFragment -> MainDirections.actionModuleFragment()
|
||||
R.id.superuserFragment -> MainDirections.actionSuperuserFragment()
|
||||
R.id.logFragment -> MainDirections.actionLogFragment()
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressLint("InlinedApi")
|
||||
override fun showInvalidStateMessage() {
|
||||
showInvalidState.value = true
|
||||
}
|
||||
|
||||
internal fun handleInvalidStateInstall() {
|
||||
extension.withPermission(REQUEST_INSTALL_PACKAGES) {
|
||||
if (!it) {
|
||||
toast(CoreR.string.install_unknown_denied, Toast.LENGTH_SHORT)
|
||||
showInvalidState.value = true
|
||||
} else {
|
||||
lifecycleScope.launch {
|
||||
if (!AppMigration.restoreApp(this@MainActivity)) {
|
||||
toast(CoreR.string.failure, Toast.LENGTH_LONG)
|
||||
override fun showInvalidStateMessage(): Unit = runOnUiThread {
|
||||
MagiskDialog(this).apply {
|
||||
setTitle(CoreR.string.unsupport_nonroot_stub_title)
|
||||
setMessage(CoreR.string.unsupport_nonroot_stub_msg)
|
||||
setButton(MagiskDialog.ButtonType.POSITIVE) {
|
||||
text = CoreR.string.install
|
||||
onClick {
|
||||
withPermission(REQUEST_INSTALL_PACKAGES) {
|
||||
if (!it) {
|
||||
toast(CoreR.string.install_unknown_denied, Toast.LENGTH_SHORT)
|
||||
showInvalidStateMessage()
|
||||
} else {
|
||||
lifecycleScope.launch {
|
||||
AppMigration.restore(this@MainActivity)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
setCancelable(false)
|
||||
show()
|
||||
}
|
||||
}
|
||||
|
||||
private fun showUnsupportedMessage() {
|
||||
val messages = mutableListOf<Pair<Int, Int>>()
|
||||
|
||||
if (Info.env.isUnsupported) {
|
||||
messages.add(CoreR.string.unsupport_magisk_title to CoreR.string.unsupport_magisk_msg)
|
||||
MagiskDialog(this).apply {
|
||||
setTitle(CoreR.string.unsupport_magisk_title)
|
||||
setMessage(CoreR.string.unsupport_magisk_msg, Const.Version.MIN_VERSION)
|
||||
setButton(MagiskDialog.ButtonType.POSITIVE) { text = android.R.string.ok }
|
||||
setCancelable(false)
|
||||
}.show()
|
||||
}
|
||||
|
||||
if (!Info.isEmulator && Info.env.isActive && System.getenv("PATH")
|
||||
?.split(':')
|
||||
?.filterNot { java.io.File("$it/magisk").exists() }
|
||||
?.any { java.io.File("$it/su").exists() } == true) {
|
||||
messages.add(CoreR.string.unsupport_general_title to CoreR.string.unsupport_other_su_msg)
|
||||
}
|
||||
if (applicationInfo.flags and ApplicationInfo.FLAG_SYSTEM != 0) {
|
||||
messages.add(CoreR.string.unsupport_general_title to CoreR.string.unsupport_system_app_msg)
|
||||
}
|
||||
if (applicationInfo.flags and ApplicationInfo.FLAG_EXTERNAL_STORAGE != 0) {
|
||||
messages.add(CoreR.string.unsupport_general_title to CoreR.string.unsupport_external_storage_msg)
|
||||
?.filterNot { File("$it/magisk").exists() }
|
||||
?.any { File("$it/su").exists() } == true) {
|
||||
MagiskDialog(this).apply {
|
||||
setTitle(CoreR.string.unsupport_general_title)
|
||||
setMessage(CoreR.string.unsupport_other_su_msg)
|
||||
setButton(MagiskDialog.ButtonType.POSITIVE) { text = android.R.string.ok }
|
||||
setCancelable(false)
|
||||
}.show()
|
||||
}
|
||||
|
||||
if (messages.isNotEmpty()) {
|
||||
showUnsupported.value = messages
|
||||
if (applicationInfo.flags and ApplicationInfo.FLAG_SYSTEM != 0) {
|
||||
MagiskDialog(this).apply {
|
||||
setTitle(CoreR.string.unsupport_general_title)
|
||||
setMessage(CoreR.string.unsupport_system_app_msg)
|
||||
setButton(MagiskDialog.ButtonType.POSITIVE) { text = android.R.string.ok }
|
||||
setCancelable(false)
|
||||
}.show()
|
||||
}
|
||||
|
||||
if (applicationInfo.flags and ApplicationInfo.FLAG_EXTERNAL_STORAGE != 0) {
|
||||
MagiskDialog(this).apply {
|
||||
setTitle(CoreR.string.unsupport_general_title)
|
||||
setMessage(CoreR.string.unsupport_external_storage_msg)
|
||||
setButton(MagiskDialog.ButtonType.POSITIVE) { text = android.R.string.ok }
|
||||
setCancelable(false)
|
||||
}.show()
|
||||
}
|
||||
}
|
||||
|
||||
private fun askForHomeShortcut() {
|
||||
if (isRunningAsStub && !Config.askedHome &&
|
||||
ShortcutManagerCompat.isRequestPinShortcutSupported(this)) {
|
||||
// Ask and show dialog
|
||||
Config.askedHome = true
|
||||
showShortcutPrompt.value = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MainActivityDialogs(activity: MainActivity) {
|
||||
val showInvalid by activity.showInvalidState.collectAsState()
|
||||
val unsupportedMessages by activity.showUnsupported.collectAsState()
|
||||
val showShortcut by activity.showShortcutPrompt.collectAsState()
|
||||
|
||||
val invalidDialog = com.topjohnwu.magisk.ui.component.rememberConfirmDialog(
|
||||
onConfirm = {
|
||||
activity.showInvalidState.value = false
|
||||
activity.handleInvalidStateInstall()
|
||||
},
|
||||
onDismiss = {}
|
||||
)
|
||||
|
||||
LaunchedEffect(showInvalid) {
|
||||
if (showInvalid) {
|
||||
invalidDialog.showConfirm(
|
||||
title = activity.getString(CoreR.string.unsupport_nonroot_stub_title),
|
||||
content = activity.getString(CoreR.string.unsupport_nonroot_stub_msg),
|
||||
confirm = activity.getString(CoreR.string.install),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
for ((index, pair) in unsupportedMessages.withIndex()) {
|
||||
val (titleRes, msgRes) = pair
|
||||
val show = rememberSaveable { androidx.compose.runtime.mutableStateOf(true) }
|
||||
com.topjohnwu.magisk.ui.component.rememberConfirmDialog(
|
||||
onConfirm = { show.value = false },
|
||||
).also { dialog ->
|
||||
LaunchedEffect(Unit) {
|
||||
dialog.showConfirm(
|
||||
title = activity.getString(titleRes),
|
||||
content = activity.getString(msgRes),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val shortcutDialog = com.topjohnwu.magisk.ui.component.rememberConfirmDialog(
|
||||
onConfirm = {
|
||||
activity.showShortcutPrompt.value = false
|
||||
Shortcuts.addHomeIcon(activity)
|
||||
},
|
||||
onDismiss = { activity.showShortcutPrompt.value = false }
|
||||
)
|
||||
|
||||
LaunchedEffect(showShortcut) {
|
||||
if (showShortcut) {
|
||||
shortcutDialog.showConfirm(
|
||||
title = activity.getString(CoreR.string.add_shortcut_title),
|
||||
content = activity.getString(CoreR.string.add_shortcut_msg),
|
||||
)
|
||||
MagiskDialog(this).apply {
|
||||
setTitle(CoreR.string.add_shortcut_title)
|
||||
setMessage(CoreR.string.add_shortcut_msg)
|
||||
setButton(MagiskDialog.ButtonType.NEGATIVE) {
|
||||
text = android.R.string.cancel
|
||||
}
|
||||
setButton(MagiskDialog.ButtonType.POSITIVE) {
|
||||
text = android.R.string.ok
|
||||
onClick {
|
||||
Shortcuts.addHomeIcon(this@MainActivity)
|
||||
}
|
||||
}
|
||||
setCancelable(true)
|
||||
}.show()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,228 +0,0 @@
|
||||
package com.topjohnwu.magisk.ui
|
||||
|
||||
import androidx.compose.animation.animateColorAsState
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
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.Spacer
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.asPaddingValues
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.navigationBars
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.pager.HorizontalPager
|
||||
import androidx.compose.foundation.pager.PagerState
|
||||
import androidx.compose.foundation.pager.rememberPagerState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.draw.shadow
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.res.vectorResource
|
||||
import androidx.compose.ui.semantics.Role
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.topjohnwu.magisk.R
|
||||
import com.topjohnwu.magisk.arch.VMFactory
|
||||
import com.topjohnwu.magisk.core.Info
|
||||
import com.topjohnwu.magisk.core.base.SplashScreenHost
|
||||
import com.topjohnwu.magisk.core.model.module.LocalModule
|
||||
import com.topjohnwu.magisk.ui.home.HomeScreen
|
||||
import com.topjohnwu.magisk.ui.home.HomeViewModel
|
||||
import com.topjohnwu.magisk.ui.install.InstallViewModel
|
||||
import com.topjohnwu.magisk.ui.log.LogScreen
|
||||
import com.topjohnwu.magisk.ui.log.LogViewModel
|
||||
import com.topjohnwu.magisk.ui.module.ModuleScreen
|
||||
import com.topjohnwu.magisk.ui.module.ModuleViewModel
|
||||
import com.topjohnwu.magisk.ui.navigation.CollectNavEvents
|
||||
import com.topjohnwu.magisk.ui.navigation.LocalNavigator
|
||||
import com.topjohnwu.magisk.ui.settings.SettingsScreen
|
||||
import com.topjohnwu.magisk.ui.settings.SettingsViewModel
|
||||
import com.topjohnwu.magisk.ui.superuser.SuperuserScreen
|
||||
import com.topjohnwu.magisk.ui.superuser.SuperuserViewModel
|
||||
import kotlinx.coroutines.launch
|
||||
import top.yukonga.miuix.kmp.basic.Icon
|
||||
import top.yukonga.miuix.kmp.basic.Text
|
||||
import top.yukonga.miuix.kmp.theme.MiuixTheme
|
||||
import com.topjohnwu.magisk.core.R as CoreR
|
||||
|
||||
enum class Tab(val titleRes: Int, val iconRes: Int) {
|
||||
MODULES(CoreR.string.modules, R.drawable.ic_module_outlined_md2),
|
||||
SUPERUSER(CoreR.string.superuser, R.drawable.ic_superuser_outlined_md2),
|
||||
HOME(CoreR.string.section_home, R.drawable.ic_home_outlined_md2),
|
||||
LOG(CoreR.string.logs, R.drawable.ic_bug_outlined_md2),
|
||||
SETTINGS(CoreR.string.settings, R.drawable.ic_settings_outlined_md2);
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun MainScreen(initialTab: Int = Tab.HOME.ordinal) {
|
||||
val navigator = LocalNavigator.current
|
||||
val visibleTabs = remember {
|
||||
Tab.entries.filter { tab ->
|
||||
when (tab) {
|
||||
Tab.SUPERUSER -> Info.showSuperUser
|
||||
Tab.MODULES -> Info.env.isActive && LocalModule.loaded()
|
||||
else -> true
|
||||
}
|
||||
}
|
||||
}
|
||||
val initialPage = visibleTabs.indexOf(Tab.entries[initialTab]).coerceAtLeast(0)
|
||||
val pagerState = rememberPagerState(initialPage = initialPage, pageCount = { visibleTabs.size })
|
||||
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
HorizontalPager(
|
||||
state = pagerState,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
beyondViewportPageCount = visibleTabs.size - 1,
|
||||
userScrollEnabled = true,
|
||||
) { page ->
|
||||
when (visibleTabs[page]) {
|
||||
Tab.HOME -> {
|
||||
val vm: HomeViewModel = viewModel(factory = VMFactory)
|
||||
val installVm: InstallViewModel = viewModel(factory = VMFactory)
|
||||
LaunchedEffect(Unit) { vm.startLoading() }
|
||||
CollectNavEvents(vm, navigator)
|
||||
CollectNavEvents(installVm, navigator)
|
||||
HomeScreen(vm, installVm)
|
||||
}
|
||||
Tab.SUPERUSER -> {
|
||||
val activity = LocalContext.current as MainActivity
|
||||
val vm: SuperuserViewModel = viewModel(viewModelStoreOwner = activity, factory = VMFactory)
|
||||
LaunchedEffect(Unit) {
|
||||
vm.authenticate = { onSuccess ->
|
||||
activity.extension.withAuthentication { if (it) onSuccess() }
|
||||
}
|
||||
vm.startLoading()
|
||||
}
|
||||
SuperuserScreen(vm)
|
||||
}
|
||||
Tab.LOG -> {
|
||||
val vm: LogViewModel = viewModel(factory = VMFactory)
|
||||
LaunchedEffect(Unit) { vm.startLoading() }
|
||||
LogScreen(vm)
|
||||
}
|
||||
Tab.MODULES -> {
|
||||
val vm: ModuleViewModel = viewModel(factory = VMFactory)
|
||||
LaunchedEffect(Unit) { vm.startLoading() }
|
||||
CollectNavEvents(vm, navigator)
|
||||
ModuleScreen(vm)
|
||||
}
|
||||
Tab.SETTINGS -> {
|
||||
val activity = LocalContext.current as MainActivity
|
||||
val vm: SettingsViewModel = viewModel(factory = VMFactory)
|
||||
LaunchedEffect(Unit) {
|
||||
vm.authenticate = { onSuccess ->
|
||||
activity.extension.withAuthentication { if (it) onSuccess() }
|
||||
}
|
||||
}
|
||||
CollectNavEvents(vm, navigator)
|
||||
SettingsScreen(vm)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
FloatingNavigationBar(
|
||||
pagerState = pagerState,
|
||||
visibleTabs = visibleTabs,
|
||||
modifier = Modifier.align(Alignment.BottomCenter)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun FloatingNavigationBar(
|
||||
pagerState: PagerState,
|
||||
visibleTabs: List<Tab>,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val scope = rememberCoroutineScope()
|
||||
val shape = RoundedCornerShape(28.dp)
|
||||
val navBarInset = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding()
|
||||
|
||||
Row(
|
||||
modifier = modifier
|
||||
.padding(bottom = navBarInset + 12.dp, start = 24.dp, end = 24.dp)
|
||||
.shadow(elevation = 6.dp, shape = shape)
|
||||
.clip(shape)
|
||||
.background(MiuixTheme.colorScheme.surfaceContainer)
|
||||
.fillMaxWidth()
|
||||
.height(64.dp)
|
||||
.padding(horizontal = 4.dp),
|
||||
horizontalArrangement = Arrangement.SpaceEvenly,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
visibleTabs.forEachIndexed { index, tab ->
|
||||
FloatingNavItem(
|
||||
icon = ImageVector.vectorResource(tab.iconRes),
|
||||
label = stringResource(tab.titleRes),
|
||||
selected = pagerState.currentPage == index,
|
||||
enabled = true,
|
||||
onClick = { scope.launch { pagerState.animateScrollToPage(index) } },
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun FloatingNavItem(
|
||||
icon: ImageVector,
|
||||
label: String,
|
||||
selected: Boolean,
|
||||
enabled: Boolean,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val contentColor by animateColorAsState(
|
||||
targetValue = when {
|
||||
!enabled -> MiuixTheme.colorScheme.disabledOnSecondaryVariant
|
||||
selected -> MiuixTheme.colorScheme.primary
|
||||
else -> MiuixTheme.colorScheme.onSurfaceVariantActions
|
||||
},
|
||||
animationSpec = tween(200),
|
||||
label = "navItemColor"
|
||||
)
|
||||
|
||||
Column(
|
||||
modifier = modifier
|
||||
.clickable(
|
||||
enabled = enabled,
|
||||
indication = null,
|
||||
interactionSource = remember { MutableInteractionSource() },
|
||||
role = Role.Tab,
|
||||
onClick = onClick,
|
||||
),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = icon,
|
||||
contentDescription = label,
|
||||
modifier = Modifier.size(24.dp),
|
||||
tint = contentColor,
|
||||
)
|
||||
Spacer(Modifier.height(2.dp))
|
||||
Text(
|
||||
text = label,
|
||||
fontSize = 11.sp,
|
||||
color = contentColor,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,374 +0,0 @@
|
||||
package com.topjohnwu.magisk.ui.component
|
||||
|
||||
import android.widget.TextView
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.WindowInsetsSides
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.only
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.systemBars
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.layout.windowInsetsPadding
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.MutableState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.layout.Layout
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.graphics.toArgb
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import com.topjohnwu.magisk.core.di.ServiceLocator
|
||||
import kotlinx.coroutines.CancellableContinuation
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.flow.consumeAsFlow
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
import kotlinx.coroutines.withContext
|
||||
import timber.log.Timber
|
||||
import top.yukonga.miuix.kmp.basic.ButtonDefaults
|
||||
import top.yukonga.miuix.kmp.basic.InfiniteProgressIndicator
|
||||
import top.yukonga.miuix.kmp.basic.Text
|
||||
import top.yukonga.miuix.kmp.basic.TextButton
|
||||
import top.yukonga.miuix.kmp.extra.SuperDialog
|
||||
import top.yukonga.miuix.kmp.theme.MiuixTheme
|
||||
import java.io.IOException
|
||||
import kotlin.coroutines.resume
|
||||
|
||||
sealed interface ConfirmResult {
|
||||
data object Confirmed : ConfirmResult
|
||||
data object Canceled : ConfirmResult
|
||||
}
|
||||
|
||||
data class DialogVisuals(
|
||||
val title: String = "",
|
||||
val content: String? = null,
|
||||
val markdown: Boolean = false,
|
||||
val confirm: String? = null,
|
||||
val dismiss: String? = null,
|
||||
)
|
||||
|
||||
interface LoadingDialogHandle {
|
||||
suspend fun <R> withLoading(block: suspend () -> R): R
|
||||
}
|
||||
|
||||
interface ConfirmDialogHandle {
|
||||
fun showConfirm(
|
||||
title: String,
|
||||
content: String? = null,
|
||||
markdown: Boolean = false,
|
||||
confirm: String? = null,
|
||||
dismiss: String? = null
|
||||
)
|
||||
|
||||
suspend fun awaitConfirm(
|
||||
title: String,
|
||||
content: String? = null,
|
||||
markdown: Boolean = false,
|
||||
confirm: String? = null,
|
||||
dismiss: String? = null
|
||||
): ConfirmResult
|
||||
}
|
||||
|
||||
private class LoadingDialogHandleImpl(
|
||||
private val visible: MutableState<Boolean>,
|
||||
private val coroutineScope: CoroutineScope
|
||||
) : LoadingDialogHandle {
|
||||
override suspend fun <R> withLoading(block: suspend () -> R): R {
|
||||
return coroutineScope.async {
|
||||
try {
|
||||
visible.value = true
|
||||
block()
|
||||
} finally {
|
||||
visible.value = false
|
||||
}
|
||||
}.await()
|
||||
}
|
||||
}
|
||||
|
||||
private class ConfirmDialogHandleImpl(
|
||||
private val visible: MutableState<Boolean>,
|
||||
private val coroutineScope: CoroutineScope,
|
||||
private val callback: ConfirmCallback,
|
||||
private val resultChannel: Channel<ConfirmResult>
|
||||
) : ConfirmDialogHandle {
|
||||
|
||||
var visuals by mutableStateOf(DialogVisuals())
|
||||
private set
|
||||
|
||||
private var awaitContinuation: CancellableContinuation<ConfirmResult>? = null
|
||||
|
||||
init {
|
||||
coroutineScope.launch {
|
||||
resultChannel
|
||||
.consumeAsFlow()
|
||||
.onEach { result ->
|
||||
awaitContinuation?.let {
|
||||
awaitContinuation = null
|
||||
if (it.isActive) it.resume(result)
|
||||
}
|
||||
}
|
||||
.onEach { visible.value = false }
|
||||
.collect { result ->
|
||||
when (result) {
|
||||
ConfirmResult.Confirmed -> callback.onConfirm?.invoke()
|
||||
ConfirmResult.Canceled -> callback.onDismiss?.invoke()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun showConfirm(
|
||||
title: String,
|
||||
content: String?,
|
||||
markdown: Boolean,
|
||||
confirm: String?,
|
||||
dismiss: String?
|
||||
) {
|
||||
coroutineScope.launch {
|
||||
visuals = DialogVisuals(title, content, markdown, confirm, dismiss)
|
||||
visible.value = true
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun awaitConfirm(
|
||||
title: String,
|
||||
content: String?,
|
||||
markdown: Boolean,
|
||||
confirm: String?,
|
||||
dismiss: String?
|
||||
): ConfirmResult {
|
||||
coroutineScope.launch {
|
||||
visuals = DialogVisuals(title, content, markdown, confirm, dismiss)
|
||||
visible.value = true
|
||||
}
|
||||
return suspendCancellableCoroutine { cont ->
|
||||
awaitContinuation = cont.apply {
|
||||
invokeOnCancellation { visible.value = false }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
interface ConfirmCallback {
|
||||
val onConfirm: (() -> Unit)?
|
||||
val onDismiss: (() -> Unit)?
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun rememberConfirmCallback(
|
||||
onConfirm: (() -> Unit)? = null,
|
||||
onDismiss: (() -> Unit)? = null
|
||||
): ConfirmCallback {
|
||||
val currentOnConfirm by rememberUpdatedState(onConfirm)
|
||||
val currentOnDismiss by rememberUpdatedState(onDismiss)
|
||||
return remember {
|
||||
object : ConfirmCallback {
|
||||
override val onConfirm get() = currentOnConfirm
|
||||
override val onDismiss get() = currentOnDismiss
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun rememberLoadingDialog(): LoadingDialogHandle {
|
||||
val visible = remember { mutableStateOf(false) }
|
||||
val scope = rememberCoroutineScope()
|
||||
LoadingDialog(visible)
|
||||
return remember { LoadingDialogHandleImpl(visible, scope) }
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun rememberConfirmDialog(
|
||||
onConfirm: (() -> Unit)? = null,
|
||||
onDismiss: (() -> Unit)? = null
|
||||
): ConfirmDialogHandle {
|
||||
return rememberConfirmDialog(rememberConfirmCallback(onConfirm, onDismiss))
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun rememberConfirmDialog(callback: ConfirmCallback): ConfirmDialogHandle {
|
||||
val visible = rememberSaveable { mutableStateOf(false) }
|
||||
val scope = rememberCoroutineScope()
|
||||
val resultChannel = remember { Channel<ConfirmResult>() }
|
||||
|
||||
val handle = remember {
|
||||
ConfirmDialogHandleImpl(visible, scope, callback, resultChannel)
|
||||
}
|
||||
|
||||
if (visible.value) {
|
||||
ConfirmDialogContent(
|
||||
visuals = handle.visuals,
|
||||
confirm = { scope.launch { resultChannel.send(ConfirmResult.Confirmed) } },
|
||||
dismiss = { scope.launch { resultChannel.send(ConfirmResult.Canceled) } },
|
||||
showDialog = visible
|
||||
)
|
||||
}
|
||||
|
||||
return handle
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun LoadingDialog(showDialog: MutableState<Boolean>) {
|
||||
SuperDialog(
|
||||
show = showDialog,
|
||||
onDismissRequest = {},
|
||||
content = {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
contentAlignment = Alignment.CenterStart
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.Start,
|
||||
) {
|
||||
InfiniteProgressIndicator(
|
||||
color = MiuixTheme.colorScheme.onBackground
|
||||
)
|
||||
Text(
|
||||
modifier = Modifier.padding(start = 12.dp),
|
||||
text = stringResource(com.topjohnwu.magisk.core.R.string.loading),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ConfirmDialogContent(
|
||||
visuals: DialogVisuals,
|
||||
confirm: () -> Unit,
|
||||
dismiss: () -> Unit,
|
||||
showDialog: MutableState<Boolean>
|
||||
) {
|
||||
SuperDialog(
|
||||
modifier = Modifier.windowInsetsPadding(WindowInsets.systemBars.only(WindowInsetsSides.Top)),
|
||||
show = showDialog,
|
||||
title = visuals.title,
|
||||
onDismissRequest = {
|
||||
dismiss()
|
||||
showDialog.value = false
|
||||
},
|
||||
content = {
|
||||
Layout(
|
||||
content = {
|
||||
visuals.content?.let { content ->
|
||||
if (visuals.markdown) {
|
||||
MarkdownText(content)
|
||||
} else {
|
||||
Text(
|
||||
text = content,
|
||||
color = MiuixTheme.colorScheme.onSurface,
|
||||
)
|
||||
}
|
||||
}
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
modifier = Modifier.padding(top = 12.dp)
|
||||
) {
|
||||
TextButton(
|
||||
text = visuals.dismiss
|
||||
?: stringResource(android.R.string.cancel),
|
||||
onClick = {
|
||||
dismiss()
|
||||
showDialog.value = false
|
||||
},
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
Spacer(Modifier.width(20.dp))
|
||||
TextButton(
|
||||
text = visuals.confirm
|
||||
?: stringResource(android.R.string.ok),
|
||||
onClick = {
|
||||
confirm()
|
||||
showDialog.value = false
|
||||
},
|
||||
modifier = Modifier.weight(1f),
|
||||
colors = ButtonDefaults.textButtonColorsPrimary()
|
||||
)
|
||||
}
|
||||
}
|
||||
) { measurables, constraints ->
|
||||
if (measurables.size != 2) {
|
||||
val button = measurables[0].measure(constraints)
|
||||
layout(constraints.maxWidth, button.height) {
|
||||
button.place(0, 0)
|
||||
}
|
||||
} else {
|
||||
val button = measurables[1].measure(constraints)
|
||||
val content = measurables[0].measure(
|
||||
constraints.copy(maxHeight = constraints.maxHeight - button.height)
|
||||
)
|
||||
layout(constraints.maxWidth, content.height + button.height) {
|
||||
content.place(0, 0)
|
||||
button.place(0, content.height)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun MarkdownText(text: String) {
|
||||
val contentColor = MiuixTheme.colorScheme.onBackground.toArgb()
|
||||
AndroidView(
|
||||
factory = { context ->
|
||||
TextView(context).apply {
|
||||
setTextColor(contentColor)
|
||||
ServiceLocator.markwon.setMarkdown(this, text)
|
||||
}
|
||||
},
|
||||
update = { textView ->
|
||||
textView.setTextColor(contentColor)
|
||||
ServiceLocator.markwon.setMarkdown(textView, text)
|
||||
},
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(max = 300.dp)
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun MarkdownTextAsync(getMarkdownText: suspend () -> String) {
|
||||
var mdText by remember { mutableStateOf<String?>(null) }
|
||||
var error by remember { mutableStateOf(false) }
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
try {
|
||||
mdText = withContext(Dispatchers.IO) { getMarkdownText() }
|
||||
} catch (e: IOException) {
|
||||
Timber.e(e)
|
||||
error = true
|
||||
}
|
||||
}
|
||||
|
||||
when {
|
||||
error -> Text(stringResource(com.topjohnwu.magisk.core.R.string.download_file_error))
|
||||
mdText != null -> MarkdownText(mdText!!)
|
||||
else -> Box(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
InfiniteProgressIndicator(color = MiuixTheme.colorScheme.onBackground)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,84 +0,0 @@
|
||||
package com.topjohnwu.magisk.ui.component
|
||||
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.ui.unit.IntOffset
|
||||
import androidx.compose.ui.unit.IntRect
|
||||
import androidx.compose.ui.unit.IntSize
|
||||
import androidx.compose.ui.unit.LayoutDirection
|
||||
import androidx.compose.ui.unit.dp
|
||||
import top.yukonga.miuix.kmp.basic.PopupPositionProvider
|
||||
|
||||
object ListPopupDefaults {
|
||||
val MenuPositionProvider = object : PopupPositionProvider {
|
||||
override fun calculatePosition(
|
||||
anchorBounds: IntRect,
|
||||
windowBounds: IntRect,
|
||||
layoutDirection: LayoutDirection,
|
||||
popupContentSize: IntSize,
|
||||
popupMargin: IntRect,
|
||||
alignment: PopupPositionProvider.Align,
|
||||
): IntOffset {
|
||||
val resolved = alignment.resolve(layoutDirection)
|
||||
val offsetX: Int
|
||||
val offsetY: Int
|
||||
when (resolved) {
|
||||
PopupPositionProvider.Align.TopStart -> {
|
||||
offsetX = anchorBounds.left + popupMargin.left
|
||||
offsetY = anchorBounds.bottom + popupMargin.top
|
||||
}
|
||||
PopupPositionProvider.Align.TopEnd -> {
|
||||
offsetX = anchorBounds.right - popupContentSize.width - popupMargin.right
|
||||
offsetY = anchorBounds.bottom + popupMargin.top
|
||||
}
|
||||
PopupPositionProvider.Align.BottomStart -> {
|
||||
offsetX = anchorBounds.left + popupMargin.left
|
||||
offsetY = anchorBounds.top - popupContentSize.height - popupMargin.bottom
|
||||
}
|
||||
PopupPositionProvider.Align.BottomEnd -> {
|
||||
offsetX = anchorBounds.right - popupContentSize.width - popupMargin.right
|
||||
offsetY = anchorBounds.top - popupContentSize.height - popupMargin.bottom
|
||||
}
|
||||
else -> {
|
||||
offsetX = if (resolved == PopupPositionProvider.Align.End) {
|
||||
anchorBounds.right - popupContentSize.width - popupMargin.right
|
||||
} else {
|
||||
anchorBounds.left + popupMargin.left
|
||||
}
|
||||
offsetY = if (windowBounds.bottom - anchorBounds.bottom > popupContentSize.height) {
|
||||
anchorBounds.bottom + popupMargin.bottom
|
||||
} else if (anchorBounds.top - windowBounds.top > popupContentSize.height) {
|
||||
anchorBounds.top - popupContentSize.height - popupMargin.top
|
||||
} else {
|
||||
anchorBounds.top + anchorBounds.height / 2 - popupContentSize.height / 2
|
||||
}
|
||||
}
|
||||
}
|
||||
return IntOffset(
|
||||
x = offsetX.coerceIn(
|
||||
windowBounds.left,
|
||||
(windowBounds.right - popupContentSize.width - popupMargin.right)
|
||||
.coerceAtLeast(windowBounds.left),
|
||||
),
|
||||
y = offsetY.coerceIn(
|
||||
(windowBounds.top + popupMargin.top)
|
||||
.coerceAtMost(windowBounds.bottom - popupContentSize.height - popupMargin.bottom),
|
||||
windowBounds.bottom - popupContentSize.height - popupMargin.bottom,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
override fun getMargins(): PaddingValues = PaddingValues(start = 20.dp)
|
||||
}
|
||||
}
|
||||
|
||||
private fun PopupPositionProvider.Align.resolve(layoutDirection: LayoutDirection): PopupPositionProvider.Align {
|
||||
if (layoutDirection == LayoutDirection.Ltr) return this
|
||||
return when (this) {
|
||||
PopupPositionProvider.Align.Start -> PopupPositionProvider.Align.End
|
||||
PopupPositionProvider.Align.End -> PopupPositionProvider.Align.Start
|
||||
PopupPositionProvider.Align.TopStart -> PopupPositionProvider.Align.TopEnd
|
||||
PopupPositionProvider.Align.TopEnd -> PopupPositionProvider.Align.TopStart
|
||||
PopupPositionProvider.Align.BottomStart -> PopupPositionProvider.Align.BottomEnd
|
||||
PopupPositionProvider.Align.BottomEnd -> PopupPositionProvider.Align.BottomStart
|
||||
}
|
||||
}
|
||||
@@ -46,10 +46,6 @@ class AppProcessInfo(
|
||||
val label = info.getLabel(pm)
|
||||
val iconImage: Drawable = runCatching { info.loadIcon(pm) }.getOrDefault(pm.defaultActivityIcon)
|
||||
val packageName: String get() = info.packageName
|
||||
var firstInstallTime: Long = 0L
|
||||
private set
|
||||
var lastUpdateTime: Long = 0L
|
||||
private set
|
||||
val processes = fetchProcesses(pm)
|
||||
|
||||
override fun compareTo(other: AppProcessInfo) = comparator.compare(this, other)
|
||||
@@ -71,30 +67,19 @@ class AppProcessInfo(
|
||||
private fun Array<out ComponentInfo>?.toProcessList() =
|
||||
orEmpty().map { createProcess(it.getProcName()) }
|
||||
|
||||
private fun Array<ServiceInfo>?.toProcessList(): List<ProcessInfo> {
|
||||
if (this == null) return emptyList()
|
||||
val result = mutableListOf<ProcessInfo>()
|
||||
var hasIsolated = false
|
||||
for (si in this) {
|
||||
if (si.isIsolated) {
|
||||
if (si.useAppZygote) {
|
||||
val proc = info.processName ?: info.packageName
|
||||
result.add(createProcess("${proc}_zygote"))
|
||||
} else {
|
||||
hasIsolated = true
|
||||
}
|
||||
private fun Array<ServiceInfo>?.toProcessList() = orEmpty().map {
|
||||
if (it.isIsolated) {
|
||||
if (it.useAppZygote) {
|
||||
val proc = info.processName ?: info.packageName
|
||||
createProcess("${proc}_zygote")
|
||||
} else {
|
||||
result.add(createProcess(si.getProcName()))
|
||||
val proc = if (SDK_INT >= Build.VERSION_CODES.Q)
|
||||
"${it.getProcName()}:${it.name}" else it.getProcName()
|
||||
createProcess(proc, ISOLATED_MAGIC)
|
||||
}
|
||||
} else {
|
||||
createProcess(it.getProcName())
|
||||
}
|
||||
if (hasIsolated) {
|
||||
val prefix = "${info.processName ?: info.packageName}:"
|
||||
val isEnabled = denyList.any {
|
||||
it.packageName == ISOLATED_MAGIC && it.process.startsWith(prefix)
|
||||
}
|
||||
result.add(ProcessInfo(prefix, ISOLATED_MAGIC, isEnabled))
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
private fun fetchProcesses(pm: PackageManager): Collection<ProcessInfo> {
|
||||
@@ -107,9 +92,6 @@ class AppProcessInfo(
|
||||
pm.getPackageArchiveInfo(info.sourceDir, flag) ?: return emptyList()
|
||||
}
|
||||
|
||||
firstInstallTime = packageInfo.firstInstallTime
|
||||
lastUpdateTime = packageInfo.lastUpdateTime
|
||||
|
||||
val processSet = TreeSet<ProcessInfo>(compareBy({ it.name }, { it.isIsolated }))
|
||||
processSet += packageInfo.activities.toProcessList()
|
||||
processSet += packageInfo.services.toProcessList()
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
package com.topjohnwu.magisk.ui.deny
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.Menu
|
||||
import android.view.MenuInflater
|
||||
import android.view.MenuItem
|
||||
import android.view.View
|
||||
import androidx.appcompat.widget.SearchView
|
||||
import androidx.core.view.MenuProvider
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.topjohnwu.magisk.R
|
||||
import com.topjohnwu.magisk.arch.BaseFragment
|
||||
import com.topjohnwu.magisk.arch.viewModel
|
||||
import com.topjohnwu.magisk.core.ktx.hideKeyboard
|
||||
import com.topjohnwu.magisk.databinding.FragmentDenyMd2Binding
|
||||
import rikka.recyclerview.addEdgeSpacing
|
||||
import rikka.recyclerview.addItemSpacing
|
||||
import rikka.recyclerview.fixEdgeEffect
|
||||
import com.topjohnwu.magisk.core.R as CoreR
|
||||
|
||||
class DenyListFragment : BaseFragment<FragmentDenyMd2Binding>(), MenuProvider {
|
||||
|
||||
override val layoutRes = R.layout.fragment_deny_md2
|
||||
override val viewModel by viewModel<DenyListViewModel>()
|
||||
|
||||
private lateinit var searchView: SearchView
|
||||
|
||||
override fun onStart() {
|
||||
super.onStart()
|
||||
activity?.setTitle(CoreR.string.denylist)
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
|
||||
binding.appList.addOnScrollListener(object : RecyclerView.OnScrollListener() {
|
||||
override fun onScrollStateChanged(recyclerView: RecyclerView, newState: Int) {
|
||||
if (newState != RecyclerView.SCROLL_STATE_IDLE) activity?.hideKeyboard()
|
||||
}
|
||||
})
|
||||
|
||||
binding.appList.apply {
|
||||
addEdgeSpacing(top = R.dimen.l_50, bottom = R.dimen.l1)
|
||||
addItemSpacing(R.dimen.l1, R.dimen.l_50, R.dimen.l1)
|
||||
fixEdgeEffect()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onPreBind(binding: FragmentDenyMd2Binding) = Unit
|
||||
|
||||
override fun onBackPressed(): Boolean {
|
||||
if (searchView.isIconfiedByDefault && !searchView.isIconified) {
|
||||
searchView.isIconified = true
|
||||
return true
|
||||
}
|
||||
return super.onBackPressed()
|
||||
}
|
||||
|
||||
override fun onCreateMenu(menu: Menu, inflater: MenuInflater) {
|
||||
inflater.inflate(R.menu.menu_deny_md2, menu)
|
||||
searchView = menu.findItem(R.id.action_search).actionView as SearchView
|
||||
searchView.queryHint = searchView.context.getString(CoreR.string.hide_filter_hint)
|
||||
searchView.setOnQueryTextListener(object : SearchView.OnQueryTextListener {
|
||||
override fun onQueryTextSubmit(query: String?): Boolean {
|
||||
viewModel.query = query ?: ""
|
||||
return true
|
||||
}
|
||||
|
||||
override fun onQueryTextChange(newText: String?): Boolean {
|
||||
viewModel.query = newText ?: ""
|
||||
return true
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
override fun onMenuItemSelected(item: MenuItem): Boolean {
|
||||
when (item.itemId) {
|
||||
R.id.action_show_system -> {
|
||||
val check = !item.isChecked
|
||||
viewModel.isShowSystem = check
|
||||
item.isChecked = check
|
||||
return true
|
||||
}
|
||||
R.id.action_show_OS -> {
|
||||
val check = !item.isChecked
|
||||
viewModel.isShowOS = check
|
||||
item.isChecked = check
|
||||
return true
|
||||
}
|
||||
}
|
||||
return super.onOptionsItemSelected(item)
|
||||
}
|
||||
|
||||
override fun onPrepareMenu(menu: Menu) {
|
||||
val showSystem = menu.findItem(R.id.action_show_system)
|
||||
val showOS = menu.findItem(R.id.action_show_OS)
|
||||
showOS.isEnabled = showSystem.isChecked
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
package com.topjohnwu.magisk.ui.deny
|
||||
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.databinding.Bindable
|
||||
import com.topjohnwu.magisk.BR
|
||||
import com.topjohnwu.magisk.R
|
||||
import com.topjohnwu.magisk.arch.startAnimations
|
||||
import com.topjohnwu.magisk.databinding.DiffItem
|
||||
import com.topjohnwu.magisk.databinding.ObservableRvItem
|
||||
import com.topjohnwu.magisk.databinding.addOnPropertyChangedCallback
|
||||
import com.topjohnwu.magisk.databinding.set
|
||||
import com.topjohnwu.superuser.Shell
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
class DenyListRvItem(
|
||||
val info: AppProcessInfo
|
||||
) : ObservableRvItem(), DiffItem<DenyListRvItem>, Comparable<DenyListRvItem> {
|
||||
|
||||
override val layoutRes get() = R.layout.item_hide_md2
|
||||
|
||||
val processes = info.processes.map { ProcessRvItem(it) }
|
||||
|
||||
@get:Bindable
|
||||
var isExpanded = false
|
||||
set(value) = set(value, field, { field = it }, BR.expanded)
|
||||
|
||||
var itemsChecked = 0
|
||||
set(value) = set(value, field, { field = it }, BR.checkedPercent)
|
||||
|
||||
val isChecked get() = itemsChecked != 0
|
||||
|
||||
@get:Bindable
|
||||
val checkedPercent get() = (itemsChecked.toFloat() / processes.size * 100).roundToInt()
|
||||
|
||||
private var _state: Boolean? = false
|
||||
set(value) = set(value, field, { field = it }, BR.state)
|
||||
|
||||
@get:Bindable
|
||||
var state: Boolean?
|
||||
get() = _state
|
||||
set(value) = set(value, _state, { _state = it }, BR.state) {
|
||||
if (value == true) {
|
||||
processes
|
||||
.filterNot { it.isEnabled }
|
||||
.filter { isExpanded || it.defaultSelection }
|
||||
.forEach { it.toggle() }
|
||||
} else {
|
||||
Shell.cmd("magisk --denylist rm ${info.packageName}").submit()
|
||||
processes.filter { it.isEnabled }.forEach {
|
||||
if (it.process.isIsolated) {
|
||||
it.toggle()
|
||||
} else {
|
||||
it.isEnabled = !it.isEnabled
|
||||
notifyPropertyChanged(BR.enabled)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
init {
|
||||
processes.forEach { it.addOnPropertyChangedCallback(BR.enabled) { recalculateChecked() } }
|
||||
addOnPropertyChangedCallback(BR.expanded) { recalculateChecked() }
|
||||
recalculateChecked()
|
||||
}
|
||||
|
||||
fun toggleExpand(v: View) {
|
||||
(v.parent as? ViewGroup)?.startAnimations()
|
||||
isExpanded = !isExpanded
|
||||
}
|
||||
|
||||
private fun recalculateChecked() {
|
||||
itemsChecked = processes.count { it.isEnabled }
|
||||
_state = if (isExpanded) {
|
||||
when (itemsChecked) {
|
||||
0 -> false
|
||||
processes.size -> true
|
||||
else -> null
|
||||
}
|
||||
} else {
|
||||
val defaultProcesses = processes.filter { it.defaultSelection }
|
||||
when (defaultProcesses.count { it.isEnabled }) {
|
||||
0 -> false
|
||||
defaultProcesses.size -> true
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun compareTo(other: DenyListRvItem) = comparator.compare(this, other)
|
||||
|
||||
companion object {
|
||||
private val comparator = compareBy<DenyListRvItem>(
|
||||
{ it.itemsChecked == 0 },
|
||||
{ it.info }
|
||||
)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
class ProcessRvItem(
|
||||
val process: ProcessInfo
|
||||
) : ObservableRvItem(), DiffItem<ProcessRvItem> {
|
||||
|
||||
override val layoutRes get() = R.layout.item_hide_process_md2
|
||||
|
||||
val displayName = if (process.isIsolated) "(isolated) ${process.name}" else process.name
|
||||
|
||||
@get:Bindable
|
||||
var isEnabled
|
||||
get() = process.isEnabled
|
||||
set(value) = set(value, process.isEnabled, { process.isEnabled = it }, BR.enabled) {
|
||||
val arg = if (it) "add" else "rm"
|
||||
val (name, pkg) = process
|
||||
Shell.cmd("magisk --denylist $arg $pkg \'$name\'").submit()
|
||||
}
|
||||
|
||||
fun toggle() {
|
||||
isEnabled = !isEnabled
|
||||
}
|
||||
|
||||
val defaultSelection get() =
|
||||
process.isIsolated || process.isAppZygote || process.name == process.packageName
|
||||
|
||||
override fun itemSameAs(other: ProcessRvItem) =
|
||||
process.name == other.process.name && process.packageName == other.process.packageName
|
||||
|
||||
override fun contentSameAs(other: ProcessRvItem) =
|
||||
process.isEnabled == other.process.isEnabled
|
||||
}
|
||||
@@ -1,322 +0,0 @@
|
||||
package com.topjohnwu.magisk.ui.deny
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
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.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.topjohnwu.magisk.ui.component.ListPopupDefaults.MenuPositionProvider
|
||||
import com.topjohnwu.magisk.ui.util.rememberDrawablePainter
|
||||
import androidx.compose.ui.state.ToggleableState
|
||||
import top.yukonga.miuix.kmp.basic.Card
|
||||
import top.yukonga.miuix.kmp.basic.Checkbox
|
||||
import top.yukonga.miuix.kmp.basic.CircularProgressIndicator
|
||||
import top.yukonga.miuix.kmp.basic.DropdownImpl
|
||||
import top.yukonga.miuix.kmp.basic.Icon
|
||||
import top.yukonga.miuix.kmp.basic.IconButton
|
||||
import top.yukonga.miuix.kmp.basic.LinearProgressIndicator
|
||||
import top.yukonga.miuix.kmp.basic.ListPopupColumn
|
||||
import top.yukonga.miuix.kmp.basic.MiuixScrollBehavior
|
||||
import top.yukonga.miuix.kmp.basic.PopupPositionProvider
|
||||
import top.yukonga.miuix.kmp.basic.Scaffold
|
||||
import top.yukonga.miuix.kmp.basic.Text
|
||||
import top.yukonga.miuix.kmp.basic.TopAppBar
|
||||
import top.yukonga.miuix.kmp.extra.SuperListPopup
|
||||
import top.yukonga.miuix.kmp.icon.MiuixIcons
|
||||
import top.yukonga.miuix.kmp.icon.extended.Back
|
||||
import top.yukonga.miuix.kmp.icon.extended.Sort
|
||||
import top.yukonga.miuix.kmp.icon.extended.Tune
|
||||
import top.yukonga.miuix.kmp.theme.MiuixTheme
|
||||
import com.topjohnwu.magisk.core.R as CoreR
|
||||
|
||||
@Composable
|
||||
fun DenyListScreen(viewModel: DenyListViewModel, onBack: () -> Unit) {
|
||||
val loading by viewModel.loading.collectAsState()
|
||||
val apps by viewModel.filteredApps.collectAsState()
|
||||
val query by viewModel.query.collectAsState()
|
||||
val showSystem by viewModel.showSystem.collectAsState()
|
||||
val showOS by viewModel.showOS.collectAsState()
|
||||
val sortBy by viewModel.sortBy.collectAsState()
|
||||
val sortReverse by viewModel.sortReverse.collectAsState()
|
||||
|
||||
val showSortMenu = remember { mutableStateOf(false) }
|
||||
val showFilterMenu = remember { mutableStateOf(false) }
|
||||
|
||||
val scrollBehavior = MiuixScrollBehavior()
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = stringResource(CoreR.string.denylist),
|
||||
navigationIcon = {
|
||||
IconButton(
|
||||
modifier = Modifier.padding(start = 16.dp),
|
||||
onClick = onBack
|
||||
) {
|
||||
Icon(
|
||||
imageVector = MiuixIcons.Back,
|
||||
contentDescription = null,
|
||||
tint = MiuixTheme.colorScheme.onBackground
|
||||
)
|
||||
}
|
||||
},
|
||||
actions = {
|
||||
Box {
|
||||
IconButton(
|
||||
onClick = { showSortMenu.value = true },
|
||||
holdDownState = showSortMenu.value,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = MiuixIcons.Sort,
|
||||
contentDescription = stringResource(CoreR.string.menu_sort),
|
||||
)
|
||||
}
|
||||
SuperListPopup(
|
||||
show = showSortMenu,
|
||||
popupPositionProvider = MenuPositionProvider,
|
||||
alignment = PopupPositionProvider.Align.End,
|
||||
onDismissRequest = { showSortMenu.value = false }
|
||||
) {
|
||||
ListPopupColumn {
|
||||
val sortOptions = listOf(
|
||||
CoreR.string.sort_by_name to SortBy.NAME,
|
||||
CoreR.string.sort_by_package_name to SortBy.PACKAGE_NAME,
|
||||
CoreR.string.sort_by_install_time to SortBy.INSTALL_TIME,
|
||||
CoreR.string.sort_by_update_time to SortBy.UPDATE_TIME,
|
||||
)
|
||||
val totalSize = sortOptions.size + 1
|
||||
sortOptions.forEachIndexed { index, (resId, sort) ->
|
||||
DropdownImpl(
|
||||
text = stringResource(resId),
|
||||
optionSize = totalSize,
|
||||
isSelected = sortBy == sort,
|
||||
index = index,
|
||||
onSelectedIndexChange = {
|
||||
viewModel.setSortBy(sort)
|
||||
showSortMenu.value = false
|
||||
}
|
||||
)
|
||||
}
|
||||
DropdownImpl(
|
||||
text = stringResource(CoreR.string.sort_reverse),
|
||||
optionSize = totalSize,
|
||||
isSelected = sortReverse,
|
||||
index = sortOptions.size,
|
||||
onSelectedIndexChange = {
|
||||
viewModel.toggleSortReverse()
|
||||
showSortMenu.value = false
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Box {
|
||||
IconButton(
|
||||
modifier = Modifier.padding(end = 16.dp),
|
||||
onClick = { showFilterMenu.value = true },
|
||||
holdDownState = showFilterMenu.value,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = MiuixIcons.Tune,
|
||||
contentDescription = stringResource(CoreR.string.hide_filter_hint),
|
||||
)
|
||||
}
|
||||
SuperListPopup(
|
||||
show = showFilterMenu,
|
||||
popupPositionProvider = MenuPositionProvider,
|
||||
alignment = PopupPositionProvider.Align.End,
|
||||
onDismissRequest = { showFilterMenu.value = false }
|
||||
) {
|
||||
ListPopupColumn {
|
||||
DropdownImpl(
|
||||
text = stringResource(CoreR.string.show_system_app),
|
||||
optionSize = 2,
|
||||
isSelected = showSystem,
|
||||
index = 0,
|
||||
onSelectedIndexChange = {
|
||||
viewModel.setShowSystem(!showSystem)
|
||||
showFilterMenu.value = false
|
||||
}
|
||||
)
|
||||
DropdownImpl(
|
||||
text = stringResource(CoreR.string.show_os_app),
|
||||
optionSize = 2,
|
||||
isSelected = showOS,
|
||||
index = 1,
|
||||
onSelectedIndexChange = {
|
||||
if (!showOS && !showSystem) {
|
||||
viewModel.setShowSystem(true)
|
||||
}
|
||||
viewModel.setShowOS(!showOS)
|
||||
showFilterMenu.value = false
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
scrollBehavior = scrollBehavior
|
||||
)
|
||||
},
|
||||
popupHost = { }
|
||||
) { padding ->
|
||||
Column(modifier = Modifier.fillMaxSize().padding(padding)) {
|
||||
SearchInput(
|
||||
query = query,
|
||||
onQueryChange = viewModel::setQuery,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 12.dp, vertical = 4.dp)
|
||||
)
|
||||
|
||||
if (loading) {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Text(
|
||||
text = stringResource(CoreR.string.loading),
|
||||
style = MiuixTheme.textStyles.headline2
|
||||
)
|
||||
Spacer(Modifier.height(16.dp))
|
||||
CircularProgressIndicator()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
LazyColumn(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.nestedScroll(scrollBehavior.nestedScrollConnection)
|
||||
.padding(horizontal = 12.dp),
|
||||
contentPadding = PaddingValues(top = 8.dp, bottom = 88.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
items(
|
||||
items = apps,
|
||||
key = { it.info.packageName }
|
||||
) { app ->
|
||||
DenyAppCard(app)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SearchInput(query: String, onQueryChange: (String) -> Unit, modifier: Modifier = Modifier) {
|
||||
top.yukonga.miuix.kmp.basic.TextField(
|
||||
value = query,
|
||||
onValueChange = onQueryChange,
|
||||
modifier = modifier,
|
||||
label = stringResource(CoreR.string.hide_filter_hint)
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DenyAppCard(app: DenyAppState) {
|
||||
Card(modifier = Modifier.fillMaxWidth()) {
|
||||
Column {
|
||||
if (app.checkedPercent > 0f) {
|
||||
LinearProgressIndicator(
|
||||
progress = app.checkedPercent,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable { app.isExpanded = !app.isExpanded }
|
||||
.padding(12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Image(
|
||||
painter = rememberDrawablePainter(app.info.iconImage),
|
||||
contentDescription = app.info.label,
|
||||
modifier = Modifier.size(40.dp)
|
||||
)
|
||||
Spacer(Modifier.width(12.dp))
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = app.info.label,
|
||||
style = MiuixTheme.textStyles.body1,
|
||||
)
|
||||
Text(
|
||||
text = app.info.packageName,
|
||||
style = MiuixTheme.textStyles.body2,
|
||||
color = MiuixTheme.colorScheme.onSurfaceVariantSummary
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Checkbox(
|
||||
state = when {
|
||||
app.itemsChecked == 0 -> ToggleableState.Off
|
||||
app.checkedPercent < 1f -> ToggleableState.Indeterminate
|
||||
else -> ToggleableState.On
|
||||
},
|
||||
onClick = { app.toggleAll() }
|
||||
)
|
||||
}
|
||||
|
||||
AnimatedVisibility(visible = app.isExpanded) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(start = 52.dp)
|
||||
) {
|
||||
app.processes.forEach { proc ->
|
||||
ProcessRow(proc)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ProcessRow(proc: DenyProcessState) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable { proc.toggle() }
|
||||
.padding(horizontal = 12.dp, vertical = 6.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text = proc.displayName,
|
||||
style = MiuixTheme.textStyles.body2,
|
||||
color = if (proc.isEnabled) MiuixTheme.colorScheme.onSurface
|
||||
else MiuixTheme.colorScheme.onSurfaceVariantSummary,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Checkbox(
|
||||
state = ToggleableState(proc.isEnabled),
|
||||
onClick = { proc.toggle() }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -2,95 +2,54 @@ package com.topjohnwu.magisk.ui.deny
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.pm.PackageManager.MATCH_UNINSTALLED_PACKAGES
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.databinding.Bindable
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.topjohnwu.magisk.BR
|
||||
import com.topjohnwu.magisk.arch.AsyncLoadViewModel
|
||||
import com.topjohnwu.magisk.core.AppContext
|
||||
import com.topjohnwu.magisk.core.ktx.concurrentMap
|
||||
import com.topjohnwu.magisk.databinding.bindExtra
|
||||
import com.topjohnwu.magisk.databinding.filterList
|
||||
import com.topjohnwu.magisk.databinding.set
|
||||
import com.topjohnwu.superuser.Shell
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.asFlow
|
||||
import kotlinx.coroutines.flow.filter
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.flow.toCollection
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
enum class SortBy { NAME, PACKAGE_NAME, INSTALL_TIME, UPDATE_TIME }
|
||||
|
||||
class DenyListViewModel : AsyncLoadViewModel() {
|
||||
|
||||
private val _loading = MutableStateFlow(true)
|
||||
val loading: StateFlow<Boolean> = _loading.asStateFlow()
|
||||
|
||||
private val _allApps = MutableStateFlow<List<DenyAppState>>(emptyList())
|
||||
|
||||
private val _query = MutableStateFlow("")
|
||||
val query: StateFlow<String> = _query.asStateFlow()
|
||||
|
||||
private val _showSystem = MutableStateFlow(false)
|
||||
val showSystem: StateFlow<Boolean> = _showSystem.asStateFlow()
|
||||
|
||||
private val _showOS = MutableStateFlow(false)
|
||||
val showOS: StateFlow<Boolean> = _showOS.asStateFlow()
|
||||
|
||||
private val _sortBy = MutableStateFlow(SortBy.NAME)
|
||||
val sortBy: StateFlow<SortBy> = _sortBy.asStateFlow()
|
||||
|
||||
private val _sortReverse = MutableStateFlow(false)
|
||||
val sortReverse: StateFlow<Boolean> = _sortReverse.asStateFlow()
|
||||
|
||||
val filteredApps: StateFlow<List<DenyAppState>> = combine(
|
||||
_allApps, _query, _showSystem, _showOS, _sortBy, _sortReverse
|
||||
) { args ->
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
val apps = args[0] as List<DenyAppState>
|
||||
val q = args[1] as String
|
||||
val showSys = args[2] as Boolean
|
||||
val showOS = args[3] as Boolean
|
||||
val sort = args[4] as SortBy
|
||||
val reverse = args[5] as Boolean
|
||||
|
||||
val filtered = apps.filter { app ->
|
||||
val passFilter = app.isChecked ||
|
||||
((showSys || !app.info.isSystemApp()) &&
|
||||
((showSys && showOS) || app.info.isApp()))
|
||||
val passQuery = q.isBlank() ||
|
||||
app.info.label.contains(q, true) ||
|
||||
app.info.packageName.contains(q, true) ||
|
||||
app.processes.any { it.process.name.contains(q, true) }
|
||||
passFilter && passQuery
|
||||
var isShowSystem = false
|
||||
set(value) {
|
||||
field = value
|
||||
doQuery(query)
|
||||
}
|
||||
|
||||
val secondary: Comparator<DenyAppState> = when (sort) {
|
||||
SortBy.NAME -> compareBy(String.CASE_INSENSITIVE_ORDER) { it.info.label }
|
||||
SortBy.PACKAGE_NAME -> compareBy(String.CASE_INSENSITIVE_ORDER) { it.info.packageName }
|
||||
SortBy.INSTALL_TIME -> compareByDescending { it.info.firstInstallTime }
|
||||
SortBy.UPDATE_TIME -> compareByDescending { it.info.lastUpdateTime }
|
||||
var isShowOS = false
|
||||
set(value) {
|
||||
field = value
|
||||
doQuery(query)
|
||||
}
|
||||
val comparator = compareBy<DenyAppState> { it.itemsChecked == 0 }
|
||||
.then(if (reverse) secondary.reversed() else secondary)
|
||||
filtered.sortedWith(comparator)
|
||||
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList())
|
||||
|
||||
fun setQuery(q: String) { _query.value = q }
|
||||
fun setShowSystem(v: Boolean) {
|
||||
_showSystem.value = v
|
||||
if (!v) _showOS.value = false
|
||||
var query = ""
|
||||
set(value) {
|
||||
field = value
|
||||
doQuery(value)
|
||||
}
|
||||
|
||||
val items = filterList<DenyListRvItem>(viewModelScope)
|
||||
val extraBindings = bindExtra {
|
||||
it.put(BR.viewModel, this)
|
||||
}
|
||||
fun setShowOS(v: Boolean) { _showOS.value = v }
|
||||
fun setSortBy(s: SortBy) { _sortBy.value = s }
|
||||
fun toggleSortReverse() { _sortReverse.value = !_sortReverse.value }
|
||||
|
||||
@get:Bindable
|
||||
var loading = true
|
||||
private set(value) = set(value, field, { field = it }, BR.loading)
|
||||
|
||||
@SuppressLint("InlinedApi")
|
||||
override suspend fun doLoadWork() {
|
||||
_loading.value = true
|
||||
loading = true
|
||||
val apps = withContext(Dispatchers.Default) {
|
||||
val pm = AppContext.packageManager
|
||||
val denyList = Shell.cmd("magisk --denylist ls").exec().out
|
||||
@@ -100,63 +59,31 @@ class DenyListViewModel : AsyncLoadViewModel() {
|
||||
.filter { AppContext.packageName != it.packageName }
|
||||
.concurrentMap { AppProcessInfo(it, pm, denyList) }
|
||||
.filter { it.processes.isNotEmpty() }
|
||||
.concurrentMap { DenyAppState(it) }
|
||||
.concurrentMap { DenyListRvItem(it) }
|
||||
.toCollection(ArrayList(size))
|
||||
}
|
||||
apps.sortWith(compareBy(
|
||||
{ it.processes.count { p -> p.isEnabled } == 0 },
|
||||
{ it.info }
|
||||
))
|
||||
apps.sort()
|
||||
apps
|
||||
}
|
||||
_allApps.value = apps
|
||||
_loading.value = false
|
||||
items.set(apps)
|
||||
doQuery(query)
|
||||
}
|
||||
}
|
||||
|
||||
class DenyAppState(val info: AppProcessInfo) : Comparable<DenyAppState> {
|
||||
val processes = info.processes.map { DenyProcessState(it) }
|
||||
var isExpanded by mutableStateOf(false)
|
||||
private fun doQuery(s: String) {
|
||||
items.filter {
|
||||
fun filterSystem() = isShowSystem || !it.info.isSystemApp()
|
||||
|
||||
val itemsChecked: Int get() = processes.count { it.isEnabled }
|
||||
val isChecked: Boolean get() = itemsChecked > 0
|
||||
val checkedPercent: Float get() = if (processes.isEmpty()) 0f else itemsChecked.toFloat() / processes.size
|
||||
fun filterOS() = (isShowSystem && isShowOS) || it.info.isApp()
|
||||
|
||||
fun toggleAll() {
|
||||
if (isChecked) {
|
||||
Shell.cmd("magisk --denylist rm ${info.packageName}").submit()
|
||||
processes.filter { it.isEnabled }.forEach { proc ->
|
||||
if (proc.process.isIsolated) {
|
||||
proc.toggle()
|
||||
} else {
|
||||
proc.isEnabled = false
|
||||
}
|
||||
fun filterQuery(): Boolean {
|
||||
fun inName() = it.info.label.contains(s, true)
|
||||
fun inPackage() = it.info.packageName.contains(s, true)
|
||||
fun inProcesses() = it.processes.any { p -> p.process.name.contains(s, true) }
|
||||
return inName() || inPackage() || inProcesses()
|
||||
}
|
||||
} else {
|
||||
processes.filterNot { it.isEnabled }.forEach { it.toggle() }
|
||||
|
||||
(it.isChecked || (filterSystem() && filterOS())) && filterQuery()
|
||||
}
|
||||
}
|
||||
|
||||
override fun compareTo(other: DenyAppState) = comparator.compare(this, other)
|
||||
|
||||
companion object {
|
||||
private val comparator = compareBy<DenyAppState>(
|
||||
{ it.itemsChecked == 0 },
|
||||
{ it.info }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
class DenyProcessState(val process: ProcessInfo) {
|
||||
var isEnabled by mutableStateOf(process.isEnabled)
|
||||
|
||||
val displayName: String =
|
||||
if (process.isIsolated) "(isolated) ${process.name}*" else process.name
|
||||
|
||||
fun toggle() {
|
||||
isEnabled = !isEnabled
|
||||
val arg = if (isEnabled) "add" else "rm"
|
||||
val (name, pkg) = process
|
||||
Shell.cmd("magisk --denylist $arg $pkg \'$name\'").submit()
|
||||
loading = false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
package com.topjohnwu.magisk.ui.flash
|
||||
|
||||
import android.view.View
|
||||
import android.widget.TextView
|
||||
import androidx.core.view.updateLayoutParams
|
||||
import androidx.databinding.ViewDataBinding
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.topjohnwu.magisk.R
|
||||
import com.topjohnwu.magisk.databinding.DiffItem
|
||||
import com.topjohnwu.magisk.databinding.ItemWrapper
|
||||
import com.topjohnwu.magisk.databinding.RvItem
|
||||
import com.topjohnwu.magisk.databinding.ViewAwareItem
|
||||
import kotlin.math.max
|
||||
|
||||
class ConsoleItem(
|
||||
override val item: String
|
||||
) : RvItem(), ViewAwareItem, DiffItem<ConsoleItem>, ItemWrapper<String> {
|
||||
override val layoutRes = R.layout.item_console_md2
|
||||
|
||||
private var parentWidth = -1
|
||||
|
||||
override fun onBind(binding: ViewDataBinding, recyclerView: RecyclerView) {
|
||||
if (parentWidth < 0)
|
||||
parentWidth = (recyclerView.parent as View).width
|
||||
|
||||
val view = binding.root as TextView
|
||||
view.measure(0, 0)
|
||||
|
||||
// We want our recyclerView at least as wide as screen
|
||||
val desiredWidth = max(view.measuredWidth, parentWidth)
|
||||
|
||||
view.updateLayoutParams { width = desiredWidth }
|
||||
|
||||
if (recyclerView.width < desiredWidth) {
|
||||
recyclerView.requestLayout()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
package com.topjohnwu.magisk.ui.flash
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Context
|
||||
import android.content.pm.ActivityInfo
|
||||
import android.net.Uri
|
||||
import android.os.Bundle
|
||||
import android.view.KeyEvent
|
||||
import android.view.Menu
|
||||
import android.view.MenuInflater
|
||||
import android.view.MenuItem
|
||||
import android.view.View
|
||||
import androidx.core.view.MenuProvider
|
||||
import androidx.core.view.isVisible
|
||||
import androidx.navigation.NavDeepLinkBuilder
|
||||
import com.topjohnwu.magisk.MainDirections
|
||||
import com.topjohnwu.magisk.R
|
||||
import com.topjohnwu.magisk.arch.BaseFragment
|
||||
import com.topjohnwu.magisk.arch.viewModel
|
||||
import com.topjohnwu.magisk.core.Const
|
||||
import com.topjohnwu.magisk.core.cmp
|
||||
import com.topjohnwu.magisk.databinding.FragmentFlashMd2Binding
|
||||
import com.topjohnwu.magisk.ui.MainActivity
|
||||
import com.topjohnwu.magisk.core.R as CoreR
|
||||
|
||||
class FlashFragment : BaseFragment<FragmentFlashMd2Binding>(), MenuProvider {
|
||||
|
||||
override val layoutRes = R.layout.fragment_flash_md2
|
||||
override val viewModel by viewModel<FlashViewModel>()
|
||||
override val snackbarView: View get() = binding.snackbarContainer
|
||||
override val snackbarAnchorView: View?
|
||||
get() = if (binding.restartBtn.isShown) binding.restartBtn else super.snackbarAnchorView
|
||||
|
||||
private var defaultOrientation = -1
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
viewModel.args = FlashFragmentArgs.fromBundle(requireArguments())
|
||||
}
|
||||
|
||||
override fun onStart() {
|
||||
super.onStart()
|
||||
activity?.setTitle(CoreR.string.flash_screen_title)
|
||||
|
||||
viewModel.state.observe(this) {
|
||||
activity?.supportActionBar?.setSubtitle(
|
||||
when (it) {
|
||||
FlashViewModel.State.FLASHING -> CoreR.string.flashing
|
||||
FlashViewModel.State.SUCCESS -> CoreR.string.done
|
||||
FlashViewModel.State.FAILED -> CoreR.string.failure
|
||||
}
|
||||
)
|
||||
if (it == FlashViewModel.State.SUCCESS && viewModel.showReboot) {
|
||||
binding.restartBtn.apply {
|
||||
if (!this.isVisible) this.show()
|
||||
if (!this.isFocused) this.requestFocus()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreateMenu(menu: Menu, inflater: MenuInflater) {
|
||||
inflater.inflate(R.menu.menu_flash, menu)
|
||||
}
|
||||
|
||||
override fun onMenuItemSelected(item: MenuItem): Boolean {
|
||||
return viewModel.onMenuItemClicked(item)
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
|
||||
defaultOrientation = activity?.requestedOrientation ?: -1
|
||||
activity?.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_LOCKED
|
||||
if (savedInstanceState == null) {
|
||||
viewModel.startFlashing()
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressLint("WrongConstant")
|
||||
override fun onDestroyView() {
|
||||
if (defaultOrientation != -1) {
|
||||
activity?.requestedOrientation = defaultOrientation
|
||||
}
|
||||
super.onDestroyView()
|
||||
}
|
||||
|
||||
override fun onKeyEvent(event: KeyEvent): Boolean {
|
||||
return when (event.keyCode) {
|
||||
KeyEvent.KEYCODE_VOLUME_UP,
|
||||
KeyEvent.KEYCODE_VOLUME_DOWN -> true
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
|
||||
override fun onBackPressed(): Boolean {
|
||||
if (viewModel.flashing.value == true)
|
||||
return true
|
||||
return super.onBackPressed()
|
||||
}
|
||||
|
||||
override fun onPreBind(binding: FragmentFlashMd2Binding) = Unit
|
||||
|
||||
companion object {
|
||||
|
||||
private fun createIntent(context: Context, args: FlashFragmentArgs) =
|
||||
NavDeepLinkBuilder(context)
|
||||
.setGraph(R.navigation.main)
|
||||
.setComponentName(MainActivity::class.java.cmp(context.packageName))
|
||||
.setDestination(R.id.flashFragment)
|
||||
.setArguments(args.toBundle())
|
||||
.createPendingIntent()
|
||||
|
||||
private fun flashType(isSecondSlot: Boolean) =
|
||||
if (isSecondSlot) Const.Value.FLASH_INACTIVE_SLOT else Const.Value.FLASH_MAGISK
|
||||
|
||||
/* Flashing is understood as installing / flashing magisk itself */
|
||||
|
||||
fun flash(isSecondSlot: Boolean) = MainDirections.actionFlashFragment(
|
||||
action = flashType(isSecondSlot)
|
||||
)
|
||||
|
||||
/* Patching is understood as injecting img files with magisk */
|
||||
|
||||
fun patch(uri: Uri) = MainDirections.actionFlashFragment(
|
||||
action = Const.Value.PATCH_FILE,
|
||||
additionalData = uri
|
||||
)
|
||||
|
||||
/* Uninstalling is understood as removing magisk entirely */
|
||||
|
||||
fun uninstall() = MainDirections.actionFlashFragment(
|
||||
action = Const.Value.UNINSTALL
|
||||
)
|
||||
|
||||
/* Installing is understood as flashing modules / zips */
|
||||
|
||||
fun installIntent(context: Context, file: Uri) = FlashFragmentArgs(
|
||||
action = Const.Value.FLASH_ZIP,
|
||||
additionalData = file,
|
||||
).let { createIntent(context, it) }
|
||||
|
||||
fun install(file: Uri) = MainDirections.actionFlashFragment(
|
||||
action = Const.Value.FLASH_ZIP,
|
||||
additionalData = file,
|
||||
)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,134 +0,0 @@
|
||||
package com.topjohnwu.magisk.ui.flash
|
||||
|
||||
import androidx.compose.foundation.horizontalScroll
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.topjohnwu.magisk.R
|
||||
import com.topjohnwu.magisk.core.Const
|
||||
import com.topjohnwu.magisk.ui.terminal.TerminalScreen
|
||||
import top.yukonga.miuix.kmp.basic.Icon
|
||||
import top.yukonga.miuix.kmp.basic.IconButton
|
||||
import top.yukonga.miuix.kmp.basic.MiuixScrollBehavior
|
||||
import top.yukonga.miuix.kmp.basic.Scaffold
|
||||
import top.yukonga.miuix.kmp.basic.SmallTopAppBar
|
||||
import top.yukonga.miuix.kmp.basic.Text
|
||||
import top.yukonga.miuix.kmp.icon.MiuixIcons
|
||||
import top.yukonga.miuix.kmp.icon.extended.Back
|
||||
import top.yukonga.miuix.kmp.theme.MiuixTheme
|
||||
import com.topjohnwu.magisk.core.R as CoreR
|
||||
|
||||
@Composable
|
||||
fun FlashScreen(viewModel: FlashViewModel, action: String, onBack: () -> Unit) {
|
||||
val flashState by viewModel.flashState.collectAsState()
|
||||
val showReboot by viewModel.showReboot.collectAsState()
|
||||
val finished = flashState != FlashViewModel.State.FLASHING
|
||||
val useTerminal = action == Const.Value.FLASH_ZIP
|
||||
|
||||
val statusText = when (flashState) {
|
||||
FlashViewModel.State.FLASHING -> stringResource(CoreR.string.flashing)
|
||||
FlashViewModel.State.SUCCESS -> stringResource(CoreR.string.done)
|
||||
FlashViewModel.State.FAILED -> stringResource(CoreR.string.failure)
|
||||
}
|
||||
|
||||
val scrollBehavior = MiuixScrollBehavior()
|
||||
Scaffold(
|
||||
topBar = {
|
||||
SmallTopAppBar(
|
||||
title = "${stringResource(CoreR.string.flash_screen_title)} - $statusText",
|
||||
navigationIcon = {
|
||||
IconButton(
|
||||
modifier = Modifier.padding(start = 16.dp),
|
||||
onClick = onBack
|
||||
) {
|
||||
Icon(
|
||||
imageVector = MiuixIcons.Back,
|
||||
contentDescription = null,
|
||||
tint = MiuixTheme.colorScheme.onBackground
|
||||
)
|
||||
}
|
||||
},
|
||||
actions = {
|
||||
if (finished) {
|
||||
IconButton(
|
||||
modifier = Modifier.padding(end = 4.dp),
|
||||
onClick = { viewModel.saveLog() }
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.ic_save_md2),
|
||||
contentDescription = stringResource(CoreR.string.menuSaveLog),
|
||||
tint = MiuixTheme.colorScheme.onBackground
|
||||
)
|
||||
}
|
||||
}
|
||||
if (flashState == FlashViewModel.State.SUCCESS && showReboot) {
|
||||
IconButton(
|
||||
modifier = Modifier.padding(end = 16.dp),
|
||||
onClick = { viewModel.restartPressed() }
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.ic_restart),
|
||||
contentDescription = stringResource(CoreR.string.reboot),
|
||||
tint = MiuixTheme.colorScheme.onBackground
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
scrollBehavior = scrollBehavior
|
||||
)
|
||||
},
|
||||
popupHost = { }
|
||||
) { padding ->
|
||||
if (useTerminal) {
|
||||
TerminalScreen(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding),
|
||||
onEmulatorCreated = { viewModel.onEmulatorCreated(it) },
|
||||
)
|
||||
} else {
|
||||
val items = viewModel.consoleItems
|
||||
val listState = rememberLazyListState()
|
||||
|
||||
LaunchedEffect(items.size) {
|
||||
if (items.isNotEmpty()) {
|
||||
listState.animateScrollToItem(items.size - 1)
|
||||
}
|
||||
}
|
||||
|
||||
LazyColumn(
|
||||
state = listState,
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding)
|
||||
.horizontalScroll(rememberScrollState())
|
||||
.padding(horizontal = 8.dp, vertical = 4.dp)
|
||||
) {
|
||||
itemsIndexed(items) { _, line ->
|
||||
Text(
|
||||
text = line,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontSize = 12.sp,
|
||||
lineHeight = 16.sp,
|
||||
color = MiuixTheme.colorScheme.onSurface,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
package com.topjohnwu.magisk.ui.flash
|
||||
|
||||
import android.app.PendingIntent
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import com.topjohnwu.magisk.core.Const
|
||||
import com.topjohnwu.magisk.core.cmp
|
||||
import com.topjohnwu.magisk.ui.MainActivity
|
||||
|
||||
object FlashUtils {
|
||||
|
||||
const val INTENT_FLASH = "com.topjohnwu.magisk.intent.FLASH"
|
||||
const val EXTRA_FLASH_ACTION = "flash_action"
|
||||
const val EXTRA_FLASH_URI = "flash_uri"
|
||||
|
||||
fun installIntent(context: Context, file: Uri): PendingIntent {
|
||||
val intent = Intent(context, MainActivity::class.java).apply {
|
||||
component = MainActivity::class.java.cmp(context.packageName)
|
||||
action = INTENT_FLASH
|
||||
putExtra(EXTRA_FLASH_ACTION, Const.Value.FLASH_ZIP)
|
||||
putExtra(EXTRA_FLASH_URI, file.toString())
|
||||
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP
|
||||
}
|
||||
return PendingIntent.getActivity(
|
||||
context, file.hashCode(), intent,
|
||||
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,39 +1,30 @@
|
||||
package com.topjohnwu.magisk.ui.flash
|
||||
|
||||
import android.net.Uri
|
||||
import androidx.compose.runtime.mutableStateListOf
|
||||
import androidx.core.net.toFile
|
||||
import android.view.MenuItem
|
||||
import androidx.databinding.Bindable
|
||||
import androidx.databinding.ObservableArrayList
|
||||
import androidx.lifecycle.LiveData
|
||||
import androidx.lifecycle.MutableLiveData
|
||||
import androidx.lifecycle.map
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.topjohnwu.magisk.BR
|
||||
import com.topjohnwu.magisk.R
|
||||
import com.topjohnwu.magisk.arch.BaseViewModel
|
||||
import com.topjohnwu.magisk.core.AppContext
|
||||
import com.topjohnwu.magisk.core.Const
|
||||
import com.topjohnwu.magisk.core.Info
|
||||
import com.topjohnwu.magisk.core.ktx.reboot
|
||||
import com.topjohnwu.magisk.core.ktx.synchronized
|
||||
import com.topjohnwu.magisk.core.ktx.timeFormatStandard
|
||||
import com.topjohnwu.magisk.core.ktx.toTime
|
||||
import com.topjohnwu.magisk.core.ktx.writeTo
|
||||
import com.topjohnwu.magisk.core.tasks.FlashZip
|
||||
import com.topjohnwu.magisk.core.tasks.MagiskInstaller
|
||||
import com.topjohnwu.magisk.core.utils.MediaStoreUtils
|
||||
import com.topjohnwu.magisk.core.utils.MediaStoreUtils.displayName
|
||||
import com.topjohnwu.magisk.core.utils.MediaStoreUtils.inputStream
|
||||
import com.topjohnwu.magisk.core.utils.MediaStoreUtils.outputStream
|
||||
import com.topjohnwu.magisk.terminal.TerminalEmulator
|
||||
import com.topjohnwu.magisk.terminal.appendLineOnMain
|
||||
import com.topjohnwu.magisk.terminal.runSuCommand
|
||||
import com.topjohnwu.magisk.databinding.set
|
||||
import com.topjohnwu.magisk.events.SnackbarEvent
|
||||
import com.topjohnwu.superuser.CallbackList
|
||||
import com.topjohnwu.superuser.Shell
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import timber.log.Timber
|
||||
import java.io.File
|
||||
import java.io.FileNotFoundException
|
||||
import java.io.IOException
|
||||
|
||||
class FlashViewModel : BaseViewModel() {
|
||||
|
||||
@@ -41,161 +32,89 @@ class FlashViewModel : BaseViewModel() {
|
||||
FLASHING, SUCCESS, FAILED
|
||||
}
|
||||
|
||||
private val _flashState = MutableStateFlow(State.FLASHING)
|
||||
val flashState: StateFlow<State> = _flashState.asStateFlow()
|
||||
private val _state = MutableLiveData(State.FLASHING)
|
||||
val state: LiveData<State> get() = _state
|
||||
val flashing = state.map { it == State.FLASHING }
|
||||
|
||||
private val _showReboot = MutableStateFlow(Info.isRooted)
|
||||
val showReboot: StateFlow<Boolean> = _showReboot.asStateFlow()
|
||||
@get:Bindable
|
||||
var showReboot = Info.isRooted
|
||||
set(value) = set(value, field, { field = it }, BR.showReboot)
|
||||
|
||||
var flashAction: String = ""
|
||||
var flashUri: Uri? = null
|
||||
val items = ObservableArrayList<ConsoleItem>()
|
||||
lateinit var args: FlashFragmentArgs
|
||||
|
||||
// --- TerminalScreen mode (FLASH_ZIP) ---
|
||||
|
||||
private var emulator: TerminalEmulator? = null
|
||||
private val emulatorReady = CompletableDeferred<TerminalEmulator>()
|
||||
|
||||
fun onEmulatorCreated(emu: TerminalEmulator) {
|
||||
emulator = emu
|
||||
emulatorReady.complete(emu)
|
||||
}
|
||||
|
||||
// --- LazyColumn mode (MagiskInstaller) ---
|
||||
|
||||
val consoleItems = mutableStateListOf<String>()
|
||||
private val logItems = mutableListOf<String>().synchronized()
|
||||
private val outItems = object : CallbackList<String>() {
|
||||
override fun onAddElement(e: String?) {
|
||||
e ?: return
|
||||
consoleItems.add(e)
|
||||
items.add(ConsoleItem(e))
|
||||
logItems.add(e)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Shared ---
|
||||
|
||||
fun startFlashing() {
|
||||
val action = flashAction
|
||||
val uri = flashUri
|
||||
val (action, uri) = args
|
||||
|
||||
viewModelScope.launch {
|
||||
when (action) {
|
||||
val result = when (action) {
|
||||
Const.Value.FLASH_ZIP -> {
|
||||
uri ?: return@launch
|
||||
flashZip(uri)
|
||||
FlashZip(uri, outItems, logItems).exec()
|
||||
}
|
||||
Const.Value.UNINSTALL -> {
|
||||
_showReboot.value = false
|
||||
onResult(withContext(Dispatchers.IO) {
|
||||
MagiskInstaller.Uninstall(outItems, logItems).exec()
|
||||
})
|
||||
showReboot = false
|
||||
MagiskInstaller.Uninstall(outItems, logItems).exec()
|
||||
}
|
||||
Const.Value.FLASH_MAGISK -> {
|
||||
onResult(withContext(Dispatchers.IO) {
|
||||
if (Info.isEmulator)
|
||||
MagiskInstaller.Emulator(outItems, logItems).exec()
|
||||
else
|
||||
MagiskInstaller.Direct(outItems, logItems).exec()
|
||||
})
|
||||
if (Info.isEmulator)
|
||||
MagiskInstaller.Emulator(outItems, logItems).exec()
|
||||
else
|
||||
MagiskInstaller.Direct(outItems, logItems).exec()
|
||||
}
|
||||
Const.Value.FLASH_INACTIVE_SLOT -> {
|
||||
_showReboot.value = false
|
||||
onResult(withContext(Dispatchers.IO) {
|
||||
MagiskInstaller.SecondSlot(outItems, logItems).exec()
|
||||
})
|
||||
showReboot = false
|
||||
MagiskInstaller.SecondSlot(outItems, logItems).exec()
|
||||
}
|
||||
Const.Value.PATCH_FILE -> {
|
||||
uri ?: return@launch
|
||||
_showReboot.value = false
|
||||
onResult(withContext(Dispatchers.IO) {
|
||||
MagiskInstaller.Patch(uri, outItems, logItems).exec()
|
||||
})
|
||||
showReboot = false
|
||||
MagiskInstaller.Patch(uri, outItems, logItems).exec()
|
||||
}
|
||||
else -> {
|
||||
back()
|
||||
return@launch
|
||||
}
|
||||
}
|
||||
onResult(result)
|
||||
}
|
||||
}
|
||||
|
||||
private fun onResult(success: Boolean) {
|
||||
_flashState.value = if (success) State.SUCCESS else State.FAILED
|
||||
_state.value = if (success) State.SUCCESS else State.FAILED
|
||||
}
|
||||
|
||||
private suspend fun flashZip(uri: Uri) {
|
||||
val emu = emulatorReady.await()
|
||||
|
||||
val installDir = File(AppContext.cacheDir, "flash")
|
||||
val result = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
installDir.deleteRecursively()
|
||||
installDir.mkdirs()
|
||||
|
||||
val zipFile = if (uri.scheme == "file") {
|
||||
uri.toFile()
|
||||
} else {
|
||||
File(installDir, "install.zip").also {
|
||||
try {
|
||||
uri.inputStream().writeTo(it)
|
||||
} catch (e: IOException) {
|
||||
val msg = if (e is FileNotFoundException) "Invalid Uri" else "Cannot copy to cache"
|
||||
return@withContext msg to null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val binary = File(installDir, "update-binary")
|
||||
AppContext.assets.open("module_installer.sh").use { it.writeTo(binary) }
|
||||
|
||||
val name = uri.displayName
|
||||
null to Triple(installDir, zipFile, name)
|
||||
} catch (e: IOException) {
|
||||
Timber.e(e)
|
||||
"Unable to extract files" to null
|
||||
}
|
||||
fun onMenuItemClicked(item: MenuItem): Boolean {
|
||||
when (item.itemId) {
|
||||
R.id.action_save -> savePressed()
|
||||
}
|
||||
|
||||
val (error, prepResult) = result
|
||||
if (prepResult == null) {
|
||||
emu.appendLineOnMain("! ${error ?: "Installation failed"}")
|
||||
_flashState.value = State.FAILED
|
||||
return
|
||||
}
|
||||
|
||||
val (dir, zipFile, displayName) = prepResult
|
||||
|
||||
val success = withContext(Dispatchers.IO) {
|
||||
runSuCommand(
|
||||
emu,
|
||||
"echo '- Installing $displayName'; " +
|
||||
"sh $dir/update-binary dummy 1 '${zipFile.absolutePath}'; " +
|
||||
"EXIT=\$?; " +
|
||||
"if [ \$EXIT -ne 0 ]; then echo '! Installation failed'; fi; " +
|
||||
"exit \$EXIT"
|
||||
)
|
||||
}
|
||||
|
||||
Shell.cmd("cd /", "rm -rf $dir ${Const.TMPDIR}").submit()
|
||||
_flashState.value = if (success) State.SUCCESS else State.FAILED
|
||||
return true
|
||||
}
|
||||
|
||||
fun saveLog() {
|
||||
private fun savePressed() = withExternalRW {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
val name = "magisk_install_log_%s.log".format(
|
||||
System.currentTimeMillis().toTime(timeFormatStandard)
|
||||
)
|
||||
val file = MediaStoreUtils.getFile(name)
|
||||
file.uri.outputStream().bufferedWriter().use { writer ->
|
||||
val transcript = emulator?.screen?.transcriptText
|
||||
if (transcript != null) {
|
||||
writer.write(transcript)
|
||||
} else {
|
||||
synchronized(logItems) {
|
||||
logItems.forEach {
|
||||
writer.write(it)
|
||||
writer.newLine()
|
||||
}
|
||||
synchronized(logItems) {
|
||||
logItems.forEach {
|
||||
writer.write(it)
|
||||
writer.newLine()
|
||||
}
|
||||
}
|
||||
}
|
||||
showSnackbar(file.toString())
|
||||
SnackbarEvent(file.toString()).publish()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
package com.topjohnwu.magisk.ui.home
|
||||
|
||||
import com.topjohnwu.magisk.R
|
||||
import com.topjohnwu.magisk.core.Const
|
||||
import com.topjohnwu.magisk.databinding.RvItem
|
||||
import com.topjohnwu.magisk.core.R as CoreR
|
||||
|
||||
interface Dev {
|
||||
val name: String
|
||||
}
|
||||
|
||||
private interface JohnImpl : Dev {
|
||||
override val name get() = "topjohnwu"
|
||||
}
|
||||
|
||||
private interface VvbImpl : Dev {
|
||||
override val name get() = "vvb2060"
|
||||
}
|
||||
|
||||
private interface YUImpl : Dev {
|
||||
override val name get() = "yujincheng08"
|
||||
}
|
||||
|
||||
private interface RikkaImpl : Dev {
|
||||
override val name get() = "RikkaW"
|
||||
}
|
||||
|
||||
private interface CanyieImpl : Dev {
|
||||
override val name get() = "canyie"
|
||||
}
|
||||
|
||||
sealed class DeveloperItem : Dev {
|
||||
|
||||
abstract val items: List<IconLink>
|
||||
val handle get() = "@${name}"
|
||||
|
||||
object John : DeveloperItem(), JohnImpl {
|
||||
override val items =
|
||||
listOf(
|
||||
object : IconLink.Twitter(), JohnImpl {},
|
||||
IconLink.Github.Project
|
||||
)
|
||||
}
|
||||
|
||||
object Vvb : DeveloperItem(), VvbImpl {
|
||||
override val items =
|
||||
listOf<IconLink>(
|
||||
object : IconLink.Twitter(), VvbImpl {},
|
||||
object : IconLink.Github.User(), VvbImpl {}
|
||||
)
|
||||
}
|
||||
|
||||
object YU : DeveloperItem(), YUImpl {
|
||||
override val items =
|
||||
listOf<IconLink>(
|
||||
object : IconLink.Twitter() { override val name = "shanasaimoe" },
|
||||
object : IconLink.Github.User(), YUImpl {},
|
||||
object : IconLink.Sponsor(), YUImpl {}
|
||||
)
|
||||
}
|
||||
|
||||
object Rikka : DeveloperItem(), RikkaImpl {
|
||||
override val items =
|
||||
listOf<IconLink>(
|
||||
object : IconLink.Twitter() { override val name = "rikkawww" },
|
||||
object : IconLink.Github.User(), RikkaImpl {}
|
||||
)
|
||||
}
|
||||
|
||||
object Canyie : DeveloperItem(), CanyieImpl {
|
||||
override val items =
|
||||
listOf<IconLink>(
|
||||
object : IconLink.Twitter() { override val name = "canyie2977" },
|
||||
object : IconLink.Github.User(), CanyieImpl {}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
sealed class IconLink : RvItem() {
|
||||
|
||||
abstract val icon: Int
|
||||
abstract val title: Int
|
||||
abstract val link: String
|
||||
|
||||
override val layoutRes get() = R.layout.item_icon_link
|
||||
|
||||
abstract class PayPal : IconLink(), Dev {
|
||||
override val icon get() = CoreR.drawable.ic_paypal
|
||||
override val title get() = CoreR.string.paypal
|
||||
override val link get() = "https://paypal.me/$name"
|
||||
|
||||
object Project : PayPal() {
|
||||
override val name: String get() = "magiskdonate"
|
||||
}
|
||||
}
|
||||
|
||||
object Patreon : IconLink() {
|
||||
override val icon get() = CoreR.drawable.ic_patreon
|
||||
override val title get() = CoreR.string.patreon
|
||||
override val link get() = Const.Url.PATREON_URL
|
||||
}
|
||||
|
||||
abstract class Twitter : IconLink(), Dev {
|
||||
override val icon get() = CoreR.drawable.ic_twitter
|
||||
override val title get() = CoreR.string.twitter
|
||||
override val link get() = "https://twitter.com/$name"
|
||||
}
|
||||
|
||||
abstract class Github : IconLink() {
|
||||
override val icon get() = CoreR.drawable.ic_github
|
||||
override val title get() = CoreR.string.github
|
||||
|
||||
abstract class User : Github(), Dev {
|
||||
override val link get() = "https://github.com/$name"
|
||||
}
|
||||
|
||||
object Project : Github() {
|
||||
override val link get() = Const.Url.SOURCE_CODE_URL
|
||||
}
|
||||
}
|
||||
|
||||
abstract class Sponsor : IconLink(), Dev {
|
||||
override val icon get() = CoreR.drawable.ic_favorite
|
||||
override val title get() = CoreR.string.github
|
||||
override val link get() = "https://github.com/sponsors/$name"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
package com.topjohnwu.magisk.ui.home
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.Menu
|
||||
import android.view.MenuInflater
|
||||
import android.view.MenuItem
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.ImageView
|
||||
import android.widget.TextView
|
||||
import androidx.core.view.MenuProvider
|
||||
import com.topjohnwu.magisk.R
|
||||
import com.topjohnwu.magisk.arch.BaseFragment
|
||||
import com.topjohnwu.magisk.arch.viewModel
|
||||
import com.topjohnwu.magisk.core.Info
|
||||
import com.topjohnwu.magisk.core.download.DownloadEngine
|
||||
import com.topjohnwu.magisk.databinding.FragmentHomeMd2Binding
|
||||
import com.topjohnwu.magisk.core.R as CoreR
|
||||
import androidx.navigation.findNavController
|
||||
import com.topjohnwu.magisk.arch.NavigationActivity
|
||||
|
||||
class HomeFragment : BaseFragment<FragmentHomeMd2Binding>(), MenuProvider {
|
||||
|
||||
override val layoutRes = R.layout.fragment_home_md2
|
||||
override val viewModel by viewModel<HomeViewModel>()
|
||||
|
||||
override fun onStart() {
|
||||
super.onStart()
|
||||
activity?.setTitle(CoreR.string.section_home)
|
||||
DownloadEngine.observeProgress(this, viewModel::onProgressUpdate)
|
||||
}
|
||||
|
||||
private fun checkTitle(text: TextView, icon: ImageView) {
|
||||
text.post {
|
||||
if (text.layout?.getEllipsisCount(0) != 0) {
|
||||
with (icon) {
|
||||
layoutParams.width = 0
|
||||
layoutParams.height = 0
|
||||
requestLayout()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreateView(
|
||||
inflater: LayoutInflater,
|
||||
container: ViewGroup?,
|
||||
savedInstanceState: Bundle?
|
||||
): View {
|
||||
super.onCreateView(inflater, container, savedInstanceState)
|
||||
|
||||
// If titles are squished, hide icons
|
||||
with(binding.homeMagiskWrapper) {
|
||||
checkTitle(homeMagiskTitle, homeMagiskIcon)
|
||||
}
|
||||
with(binding.homeManagerWrapper) {
|
||||
checkTitle(homeManagerTitle, homeManagerIcon)
|
||||
}
|
||||
|
||||
return binding.root
|
||||
}
|
||||
|
||||
override fun onCreateMenu(menu: Menu, inflater: MenuInflater) {
|
||||
inflater.inflate(R.menu.menu_home_md2, menu)
|
||||
if (!Info.isRooted)
|
||||
menu.removeItem(R.id.action_reboot)
|
||||
}
|
||||
|
||||
override fun onMenuItemSelected(item: MenuItem): Boolean {
|
||||
when (item.itemId) {
|
||||
R.id.action_settings ->
|
||||
activity?.let {
|
||||
NavigationActivity.navigate(
|
||||
HomeFragmentDirections.actionHomeFragmentToSettingsFragment(),
|
||||
it.findNavController(R.id.main_nav_host),
|
||||
it.contentResolver,
|
||||
)
|
||||
}
|
||||
R.id.action_reboot -> activity?.let { RebootMenu.inflate(it).show() }
|
||||
else -> return super.onOptionsItemSelected(item)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
viewModel.stateManagerProgress = 0
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,11 +1,18 @@
|
||||
package com.topjohnwu.magisk.ui.home
|
||||
|
||||
import android.content.ActivityNotFoundException
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.widget.Toast
|
||||
import androidx.core.net.toUri
|
||||
import androidx.databinding.Bindable
|
||||
import com.topjohnwu.magisk.BR
|
||||
import com.topjohnwu.magisk.R
|
||||
import com.topjohnwu.magisk.arch.ActivityExecutor
|
||||
import com.topjohnwu.magisk.arch.AsyncLoadViewModel
|
||||
import com.topjohnwu.magisk.core.AppContext
|
||||
import com.topjohnwu.magisk.arch.ContextExecutor
|
||||
import com.topjohnwu.magisk.arch.UIActivity
|
||||
import com.topjohnwu.magisk.arch.ViewEvent
|
||||
import com.topjohnwu.magisk.core.BuildConfig
|
||||
import com.topjohnwu.magisk.core.Config
|
||||
import com.topjohnwu.magisk.core.Info
|
||||
@@ -14,11 +21,14 @@ import com.topjohnwu.magisk.core.download.Subject.App
|
||||
import com.topjohnwu.magisk.core.ktx.await
|
||||
import com.topjohnwu.magisk.core.ktx.toast
|
||||
import com.topjohnwu.magisk.core.repository.NetworkService
|
||||
import com.topjohnwu.magisk.databinding.bindExtra
|
||||
import com.topjohnwu.magisk.databinding.set
|
||||
import com.topjohnwu.magisk.dialog.EnvFixDialog
|
||||
import com.topjohnwu.magisk.dialog.ManagerInstallDialog
|
||||
import com.topjohnwu.magisk.dialog.UninstallDialog
|
||||
import com.topjohnwu.magisk.events.SnackbarEvent
|
||||
import com.topjohnwu.magisk.utils.asText
|
||||
import com.topjohnwu.superuser.Shell
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlin.math.roundToInt
|
||||
import com.topjohnwu.magisk.core.R as CoreR
|
||||
|
||||
@@ -30,19 +40,14 @@ class HomeViewModel(
|
||||
LOADING, INVALID, OUTDATED, UP_TO_DATE
|
||||
}
|
||||
|
||||
data class UiState(
|
||||
val isNoticeVisible: Boolean = Config.safetyNotice,
|
||||
val appState: State = State.LOADING,
|
||||
val managerRemoteVersion: String = "",
|
||||
val managerProgress: Int = 0,
|
||||
val showUninstall: Boolean = false,
|
||||
val showManagerInstall: Boolean = false,
|
||||
val showHideRestore: Boolean = false,
|
||||
val envFixCode: Int = 0,
|
||||
)
|
||||
val magiskTitleBarrierIds =
|
||||
intArrayOf(R.id.home_magisk_icon, R.id.home_magisk_title, R.id.home_magisk_button)
|
||||
val appTitleBarrierIds =
|
||||
intArrayOf(R.id.home_manager_icon, R.id.home_manager_title, R.id.home_manager_button)
|
||||
|
||||
private val _uiState = MutableStateFlow(UiState())
|
||||
val uiState: StateFlow<UiState> = _uiState.asStateFlow()
|
||||
@get:Bindable
|
||||
var isNoticeVisible = Config.safetyNotice
|
||||
set(value) = set(value, field, { field = it }, BR.noticeVisible)
|
||||
|
||||
val magiskState
|
||||
get() = when {
|
||||
@@ -52,103 +57,94 @@ class HomeViewModel(
|
||||
else -> State.UP_TO_DATE
|
||||
}
|
||||
|
||||
val magiskInstalledVersion: String
|
||||
@get:Bindable
|
||||
var appState = State.LOADING
|
||||
set(value) = set(value, field, { field = it }, BR.appState)
|
||||
|
||||
val magiskInstalledVersion
|
||||
get() = Info.env.run {
|
||||
if (isActive)
|
||||
"$versionString ($versionCode)" + if (isDebug) " (D)" else ""
|
||||
("$versionString ($versionCode)" + if (isDebug) " (D)" else "").asText()
|
||||
else
|
||||
""
|
||||
CoreR.string.not_available.asText()
|
||||
}
|
||||
|
||||
val managerInstalledVersion: String
|
||||
@get:Bindable
|
||||
var managerRemoteVersion = CoreR.string.loading.asText()
|
||||
set(value) = set(value, field, { field = it }, BR.managerRemoteVersion)
|
||||
|
||||
val managerInstalledVersion
|
||||
get() = "${BuildConfig.APP_VERSION_NAME} (${BuildConfig.APP_VERSION_CODE})" +
|
||||
if (BuildConfig.DEBUG) " (D)" else ""
|
||||
|
||||
@get:Bindable
|
||||
var stateManagerProgress = 0
|
||||
set(value) = set(value, field, { field = it }, BR.stateManagerProgress)
|
||||
|
||||
val extraBindings = bindExtra {
|
||||
it.put(BR.viewModel, this)
|
||||
}
|
||||
|
||||
companion object {
|
||||
private var checkedEnv = false
|
||||
}
|
||||
|
||||
override suspend fun doLoadWork() {
|
||||
_uiState.update { it.copy(appState = State.LOADING) }
|
||||
appState = State.LOADING
|
||||
Info.fetchUpdate(svc)?.apply {
|
||||
val isDebug = Config.updateChannel == Config.Value.DEBUG_CHANNEL
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
appState = if (BuildConfig.APP_VERSION_CODE < versionCode) State.OUTDATED else State.UP_TO_DATE,
|
||||
managerRemoteVersion = "$version ($versionCode)" + if (isDebug) " (D)" else ""
|
||||
)
|
||||
appState = when {
|
||||
BuildConfig.APP_VERSION_CODE < versionCode -> State.OUTDATED
|
||||
else -> State.UP_TO_DATE
|
||||
}
|
||||
|
||||
val isDebug = Config.updateChannel == Config.Value.DEBUG_CHANNEL
|
||||
managerRemoteVersion =
|
||||
("$version (${versionCode})" + if (isDebug) " (D)" else "").asText()
|
||||
} ?: run {
|
||||
_uiState.update { it.copy(appState = State.INVALID, managerRemoteVersion = "") }
|
||||
appState = State.INVALID
|
||||
managerRemoteVersion = CoreR.string.not_available.asText()
|
||||
}
|
||||
ensureEnv()
|
||||
}
|
||||
|
||||
private val networkObserver: (Boolean) -> Unit = { startLoading() }
|
||||
|
||||
init {
|
||||
Info.isConnected.observeForever(networkObserver)
|
||||
}
|
||||
|
||||
override fun onCleared() {
|
||||
super.onCleared()
|
||||
Info.isConnected.removeObserver(networkObserver)
|
||||
}
|
||||
override fun onNetworkChanged(network: Boolean) = startLoading()
|
||||
|
||||
fun onProgressUpdate(progress: Float, subject: Subject) {
|
||||
if (subject is App)
|
||||
_uiState.update { it.copy(managerProgress = progress.times(100f).roundToInt()) }
|
||||
stateManagerProgress = progress.times(100f).roundToInt()
|
||||
}
|
||||
|
||||
fun resetProgress() {
|
||||
_uiState.update { it.copy(managerProgress = 0) }
|
||||
}
|
||||
fun onLinkPressed(link: String) = object : ViewEvent(), ContextExecutor {
|
||||
override fun invoke(context: Context) {
|
||||
val intent = Intent(Intent.ACTION_VIEW, link.toUri())
|
||||
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
try {
|
||||
context.startActivity(intent)
|
||||
} catch (e: ActivityNotFoundException) {
|
||||
context.toast(CoreR.string.open_link_failed_toast, Toast.LENGTH_SHORT)
|
||||
}
|
||||
}
|
||||
}.publish()
|
||||
|
||||
fun onLinkPressed(link: String) {
|
||||
val intent = Intent(Intent.ACTION_VIEW, link.toUri())
|
||||
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
try {
|
||||
AppContext.startActivity(intent)
|
||||
} catch (e: ActivityNotFoundException) {
|
||||
AppContext.toast(CoreR.string.open_link_failed_toast, Toast.LENGTH_SHORT)
|
||||
fun onDeletePressed() = UninstallDialog().show()
|
||||
|
||||
fun onManagerPressed() = when (appState) {
|
||||
State.LOADING -> SnackbarEvent(CoreR.string.loading).publish()
|
||||
State.INVALID -> SnackbarEvent(CoreR.string.no_connection).publish()
|
||||
else -> withExternalRW {
|
||||
withInstallPermission {
|
||||
ManagerInstallDialog().show()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun onDeletePressed() {
|
||||
_uiState.update { it.copy(showUninstall = true) }
|
||||
}
|
||||
|
||||
fun onUninstallConsumed() {
|
||||
_uiState.update { it.copy(showUninstall = false) }
|
||||
}
|
||||
|
||||
fun onManagerPressed() {
|
||||
when (_uiState.value.appState) {
|
||||
State.LOADING -> showSnackbar(CoreR.string.loading)
|
||||
State.INVALID -> showSnackbar(CoreR.string.no_connection)
|
||||
else -> _uiState.update { it.copy(showManagerInstall = true) }
|
||||
}
|
||||
}
|
||||
|
||||
fun onManagerInstallConsumed() {
|
||||
_uiState.update { it.copy(showManagerInstall = false) }
|
||||
}
|
||||
|
||||
fun onHideRestorePressed() {
|
||||
_uiState.update { it.copy(showHideRestore = true) }
|
||||
}
|
||||
|
||||
fun onHideRestoreConsumed() {
|
||||
_uiState.update { it.copy(showHideRestore = false) }
|
||||
}
|
||||
|
||||
fun onEnvFixConsumed() {
|
||||
_uiState.update { it.copy(envFixCode = 0) }
|
||||
fun onMagiskPressed() = withExternalRW {
|
||||
HomeFragmentDirections.actionHomeFragmentToInstallFragment().navigate()
|
||||
}
|
||||
|
||||
fun hideNotice() {
|
||||
Config.safetyNotice = false
|
||||
_uiState.update { it.copy(isNoticeVisible = false) }
|
||||
isNoticeVisible = false
|
||||
}
|
||||
|
||||
private suspend fun ensureEnv() {
|
||||
@@ -156,8 +152,15 @@ class HomeViewModel(
|
||||
val cmd = "env_check ${Info.env.versionString} ${Info.env.versionCode}"
|
||||
val code = Shell.cmd(cmd).await().code
|
||||
if (code != 0) {
|
||||
_uiState.update { it.copy(envFixCode = code) }
|
||||
EnvFixDialog(this, code).show()
|
||||
}
|
||||
checkedEnv = true
|
||||
}
|
||||
|
||||
val showTest = false
|
||||
fun onTestPressed() = object : ViewEvent(), ActivityExecutor {
|
||||
override fun invoke(activity: UIActivity<*>) {
|
||||
/* Entry point to trigger test events within the app */
|
||||
}
|
||||
}.publish()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
package com.topjohnwu.magisk.ui.home
|
||||
|
||||
import android.app.Activity
|
||||
import android.os.Build
|
||||
import android.os.PowerManager
|
||||
import android.view.ContextThemeWrapper
|
||||
import android.view.MenuItem
|
||||
import android.widget.PopupMenu
|
||||
import androidx.core.content.getSystemService
|
||||
import com.topjohnwu.magisk.R
|
||||
import com.topjohnwu.magisk.core.Config
|
||||
import com.topjohnwu.magisk.core.Const
|
||||
import com.topjohnwu.magisk.core.ktx.reboot as systemReboot
|
||||
|
||||
object RebootMenu {
|
||||
|
||||
private fun reboot(item: MenuItem): Boolean {
|
||||
when (item.itemId) {
|
||||
R.id.action_reboot_normal -> systemReboot()
|
||||
R.id.action_reboot_userspace -> systemReboot("userspace")
|
||||
R.id.action_reboot_bootloader -> systemReboot("bootloader")
|
||||
R.id.action_reboot_download -> systemReboot("download")
|
||||
R.id.action_reboot_edl -> systemReboot("edl")
|
||||
R.id.action_reboot_recovery -> systemReboot("recovery")
|
||||
R.id.action_reboot_safe_mode -> {
|
||||
val status = !item.isChecked
|
||||
item.isChecked = status
|
||||
Config.bootloop = if (status) 2 else 0
|
||||
}
|
||||
else -> Unit
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
fun inflate(activity: Activity): PopupMenu {
|
||||
val themeWrapper = ContextThemeWrapper(activity, R.style.Foundation_PopupMenu)
|
||||
val menu = PopupMenu(themeWrapper, activity.findViewById(R.id.action_reboot))
|
||||
activity.menuInflater.inflate(R.menu.menu_reboot, menu.menu)
|
||||
menu.setOnMenuItemClickListener(RebootMenu::reboot)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R &&
|
||||
activity.getSystemService<PowerManager>()?.isRebootingUserspaceSupported == true) {
|
||||
menu.menu.findItem(R.id.action_reboot_userspace).isVisible = true
|
||||
}
|
||||
if (Const.Version.atLeast_28_0()) {
|
||||
menu.menu.findItem(R.id.action_reboot_safe_mode).isChecked = Config.bootloop >= 2
|
||||
} else {
|
||||
menu.menu.findItem(R.id.action_reboot_safe_mode).isVisible = false
|
||||
}
|
||||
return menu
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package com.topjohnwu.magisk.ui.install
|
||||
|
||||
import com.topjohnwu.magisk.R
|
||||
import com.topjohnwu.magisk.arch.BaseFragment
|
||||
import com.topjohnwu.magisk.arch.viewModel
|
||||
import com.topjohnwu.magisk.databinding.FragmentInstallMd2Binding
|
||||
import com.topjohnwu.magisk.core.R as CoreR
|
||||
|
||||
class InstallFragment : BaseFragment<FragmentInstallMd2Binding>() {
|
||||
|
||||
override val layoutRes = R.layout.fragment_install_md2
|
||||
override val viewModel by viewModel<InstallViewModel>()
|
||||
|
||||
override fun onStart() {
|
||||
super.onStart()
|
||||
requireActivity().setTitle(CoreR.string.install)
|
||||
}
|
||||
}
|
||||
@@ -1,48 +1,70 @@
|
||||
package com.topjohnwu.magisk.ui.install
|
||||
|
||||
import android.net.Uri
|
||||
import android.os.Bundle
|
||||
import android.os.Parcelable
|
||||
import android.text.Spanned
|
||||
import android.text.SpannedString
|
||||
import android.widget.Toast
|
||||
import androidx.databinding.Bindable
|
||||
import androidx.lifecycle.LiveData
|
||||
import androidx.lifecycle.MutableLiveData
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.topjohnwu.magisk.BR
|
||||
import com.topjohnwu.magisk.R
|
||||
import com.topjohnwu.magisk.arch.BaseViewModel
|
||||
import com.topjohnwu.magisk.core.AppContext
|
||||
import com.topjohnwu.magisk.core.BuildConfig.APP_VERSION_CODE
|
||||
import com.topjohnwu.magisk.core.Config
|
||||
import com.topjohnwu.magisk.core.Info
|
||||
import com.topjohnwu.magisk.core.base.ContentResultCallback
|
||||
import com.topjohnwu.magisk.core.ktx.toast
|
||||
import com.topjohnwu.magisk.core.repository.NetworkService
|
||||
import com.topjohnwu.magisk.core.Const
|
||||
import com.topjohnwu.magisk.ui.navigation.Route
|
||||
import com.topjohnwu.magisk.databinding.set
|
||||
import com.topjohnwu.magisk.dialog.SecondSlotWarningDialog
|
||||
import com.topjohnwu.magisk.events.GetContentEvent
|
||||
import com.topjohnwu.magisk.ui.flash.FlashFragment
|
||||
import io.noties.markwon.Markwon
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.parcelize.Parcelize
|
||||
import timber.log.Timber
|
||||
import java.io.File
|
||||
import java.io.IOException
|
||||
import com.topjohnwu.magisk.core.R as CoreR
|
||||
|
||||
class InstallViewModel(svc: NetworkService) : BaseViewModel() {
|
||||
|
||||
enum class Method { NONE, PATCH, DIRECT, INACTIVE_SLOT }
|
||||
|
||||
data class UiState(
|
||||
val step: Int = 0,
|
||||
val method: Method = Method.NONE,
|
||||
val notes: String = "",
|
||||
val patchUri: Uri? = null,
|
||||
val requestFilePicker: Boolean = false,
|
||||
val showSecondSlotWarning: Boolean = false,
|
||||
)
|
||||
class InstallViewModel(svc: NetworkService, markwon: Markwon) : BaseViewModel() {
|
||||
|
||||
val isRooted get() = Info.isRooted
|
||||
val skipOptions = Info.isEmulator || (Info.isSAR && !Info.isFDE && Info.ramdisk)
|
||||
val noSecondSlot = !isRooted || !Info.isAB || Info.isEmulator
|
||||
|
||||
private val _uiState = MutableStateFlow(UiState(step = if (skipOptions) 1 else 0))
|
||||
val uiState: StateFlow<UiState> = _uiState.asStateFlow()
|
||||
@get:Bindable
|
||||
var step = if (skipOptions) 1 else 0
|
||||
set(value) = set(value, field, { field = it }, BR.step)
|
||||
|
||||
private var methodId = -1
|
||||
|
||||
@get:Bindable
|
||||
var method
|
||||
get() = methodId
|
||||
set(value) = set(value, methodId, { methodId = it }, BR.method) {
|
||||
when (it) {
|
||||
R.id.method_patch -> {
|
||||
GetContentEvent("*/*", UriCallback()).publish()
|
||||
}
|
||||
R.id.method_inactive_slot -> {
|
||||
SecondSlotWarningDialog().show()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val data: LiveData<Uri?> get() = uri
|
||||
|
||||
@get:Bindable
|
||||
var notes: Spanned = SpannedString("")
|
||||
set(value) = set(value, field, { field = it }, BR.notes)
|
||||
|
||||
init {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
@@ -57,8 +79,9 @@ class InstallViewModel(svc: NetworkService) : BaseViewModel() {
|
||||
note
|
||||
}
|
||||
}
|
||||
val spanned = markwon.toMarkdown(noteText)
|
||||
withContext(Dispatchers.Main) {
|
||||
_uiState.update { it.copy(notes = noteText) }
|
||||
notes = spanned
|
||||
}
|
||||
} catch (e: IOException) {
|
||||
Timber.e(e)
|
||||
@@ -66,63 +89,59 @@ class InstallViewModel(svc: NetworkService) : BaseViewModel() {
|
||||
}
|
||||
}
|
||||
|
||||
fun nextStep() {
|
||||
_uiState.update { it.copy(step = 1) }
|
||||
}
|
||||
|
||||
fun selectMethod(method: Method) {
|
||||
_uiState.update { it.copy(method = method) }
|
||||
when (method) {
|
||||
Method.PATCH -> {
|
||||
AppContext.toast(CoreR.string.patch_file_msg, Toast.LENGTH_LONG)
|
||||
_uiState.update { it.copy(requestFilePicker = true) }
|
||||
}
|
||||
Method.INACTIVE_SLOT -> {
|
||||
_uiState.update { it.copy(showSecondSlotWarning = true) }
|
||||
}
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
|
||||
fun onFilePickerConsumed() {
|
||||
_uiState.update { it.copy(requestFilePicker = false) }
|
||||
}
|
||||
|
||||
fun onSecondSlotWarningConsumed() {
|
||||
_uiState.update { it.copy(showSecondSlotWarning = false) }
|
||||
}
|
||||
|
||||
fun onPatchFileSelected(uri: Uri) {
|
||||
_uiState.update { it.copy(patchUri = uri) }
|
||||
if (_uiState.value.method == Method.PATCH) {
|
||||
install()
|
||||
}
|
||||
}
|
||||
|
||||
fun install() {
|
||||
when (_uiState.value.method) {
|
||||
Method.PATCH -> navigateTo(Route.Flash(
|
||||
action = Const.Value.PATCH_FILE,
|
||||
additionalData = _uiState.value.patchUri!!.toString()
|
||||
))
|
||||
Method.DIRECT -> navigateTo(Route.Flash(
|
||||
action = Const.Value.FLASH_MAGISK
|
||||
))
|
||||
Method.INACTIVE_SLOT -> navigateTo(Route.Flash(
|
||||
action = Const.Value.FLASH_INACTIVE_SLOT
|
||||
))
|
||||
else -> error("Unknown method")
|
||||
when (method) {
|
||||
R.id.method_patch -> FlashFragment.patch(data.value!!).navigate(true)
|
||||
R.id.method_direct -> FlashFragment.flash(false).navigate(true)
|
||||
R.id.method_inactive_slot -> FlashFragment.flash(true).navigate(true)
|
||||
else -> error("Unknown value")
|
||||
}
|
||||
}
|
||||
|
||||
val canInstall: Boolean
|
||||
get() {
|
||||
val state = _uiState.value
|
||||
return when (state.method) {
|
||||
Method.PATCH -> state.patchUri != null
|
||||
Method.DIRECT, Method.INACTIVE_SLOT -> true
|
||||
Method.NONE -> false
|
||||
}
|
||||
override fun onSaveState(state: Bundle) {
|
||||
state.putParcelable(
|
||||
INSTALL_STATE_KEY, InstallState(
|
||||
methodId,
|
||||
step,
|
||||
Config.keepVerity,
|
||||
Config.keepEnc,
|
||||
Config.recovery
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
override fun onRestoreState(state: Bundle) {
|
||||
state.getParcelable<InstallState>(INSTALL_STATE_KEY)?.let {
|
||||
methodId = it.method
|
||||
step = it.step
|
||||
Config.keepVerity = it.keepVerity
|
||||
Config.keepEnc = it.keepEnc
|
||||
Config.recovery = it.recovery
|
||||
}
|
||||
}
|
||||
|
||||
@Parcelize
|
||||
class UriCallback : ContentResultCallback {
|
||||
override fun onActivityLaunch() {
|
||||
AppContext.toast(CoreR.string.patch_file_msg, Toast.LENGTH_LONG)
|
||||
}
|
||||
|
||||
override fun onActivityResult(result: Uri) {
|
||||
uri.value = result
|
||||
}
|
||||
}
|
||||
|
||||
@Parcelize
|
||||
class InstallState(
|
||||
val method: Int,
|
||||
val step: Int,
|
||||
val keepVerity: Boolean,
|
||||
val keepEnc: Boolean,
|
||||
val recovery: Boolean,
|
||||
) : Parcelable
|
||||
|
||||
companion object {
|
||||
private const val INSTALL_STATE_KEY = "install_state"
|
||||
private val uri = MutableLiveData<Uri?>()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
package com.topjohnwu.magisk.ui.log
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.Menu
|
||||
import android.view.MenuInflater
|
||||
import android.view.MenuItem
|
||||
import android.view.View
|
||||
import android.widget.HorizontalScrollView
|
||||
import androidx.core.view.MenuProvider
|
||||
import androidx.core.view.isVisible
|
||||
import com.topjohnwu.magisk.R
|
||||
import com.topjohnwu.magisk.arch.BaseFragment
|
||||
import com.topjohnwu.magisk.arch.viewModel
|
||||
import com.topjohnwu.magisk.databinding.FragmentLogMd2Binding
|
||||
import com.topjohnwu.magisk.ui.MainActivity
|
||||
import com.topjohnwu.magisk.utils.AccessibilityUtils
|
||||
import com.topjohnwu.magisk.utils.MotionRevealHelper
|
||||
import rikka.recyclerview.addEdgeSpacing
|
||||
import rikka.recyclerview.addItemSpacing
|
||||
import rikka.recyclerview.fixEdgeEffect
|
||||
import com.topjohnwu.magisk.core.R as CoreR
|
||||
|
||||
class LogFragment : BaseFragment<FragmentLogMd2Binding>(), MenuProvider {
|
||||
|
||||
override val layoutRes = R.layout.fragment_log_md2
|
||||
override val viewModel by viewModel<LogViewModel>()
|
||||
override val snackbarView: View?
|
||||
get() = if (isMagiskLogVisible) binding.logFilterSuperuser.snackbarContainer
|
||||
else super.snackbarView
|
||||
override val snackbarAnchorView get() = binding.logFilterToggle
|
||||
|
||||
private var actionSave: MenuItem? = null
|
||||
private var isMagiskLogVisible
|
||||
get() = binding.logFilter.isVisible
|
||||
set(value) {
|
||||
MotionRevealHelper.withViews(binding.logFilter, binding.logFilterToggle, value)
|
||||
actionSave?.isVisible = !value
|
||||
with(activity as MainActivity) {
|
||||
invalidateToolbar()
|
||||
requestNavigationHidden(value)
|
||||
setDisplayHomeAsUpEnabled(value)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onStart() {
|
||||
super.onStart()
|
||||
activity?.setTitle(CoreR.string.logs)
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
binding.logFilterToggle.setOnClickListener {
|
||||
isMagiskLogVisible = true
|
||||
}
|
||||
|
||||
binding.logFilterSuperuser.logSuperuser.apply {
|
||||
addEdgeSpacing(bottom = R.dimen.l1)
|
||||
addItemSpacing(R.dimen.l1, R.dimen.l_50, R.dimen.l1)
|
||||
fixEdgeEffect()
|
||||
}
|
||||
|
||||
if (!AccessibilityUtils.isAnimationEnabled(requireContext().contentResolver)) {
|
||||
val scrollView = view.findViewById<HorizontalScrollView>(R.id.log_scroll_magisk)
|
||||
scrollView.setOverScrollMode(View.OVER_SCROLL_NEVER)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
override fun onCreateMenu(menu: Menu, inflater: MenuInflater) {
|
||||
inflater.inflate(R.menu.menu_log_md2, menu)
|
||||
actionSave = menu.findItem(R.id.action_save)?.also {
|
||||
it.isVisible = !isMagiskLogVisible
|
||||
}
|
||||
}
|
||||
|
||||
override fun onMenuItemSelected(item: MenuItem): Boolean {
|
||||
when (item.itemId) {
|
||||
R.id.action_save -> viewModel.saveMagiskLog()
|
||||
R.id.action_clear ->
|
||||
if (!isMagiskLogVisible) viewModel.clearMagiskLog()
|
||||
else viewModel.clearLog()
|
||||
}
|
||||
return super.onOptionsItemSelected(item)
|
||||
}
|
||||
|
||||
|
||||
override fun onPreBind(binding: FragmentLogMd2Binding) = Unit
|
||||
|
||||
override fun onBackPressed(): Boolean {
|
||||
if (binding.logFilter.isVisible) {
|
||||
isMagiskLogVisible = false
|
||||
return true
|
||||
}
|
||||
return super.onBackPressed()
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package com.topjohnwu.magisk.ui.log
|
||||
|
||||
import androidx.databinding.ViewDataBinding
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.google.android.material.textview.MaterialTextView
|
||||
import com.topjohnwu.magisk.R
|
||||
import com.topjohnwu.magisk.databinding.DiffItem
|
||||
import com.topjohnwu.magisk.databinding.ItemWrapper
|
||||
import com.topjohnwu.magisk.databinding.ObservableRvItem
|
||||
import com.topjohnwu.magisk.databinding.ViewAwareItem
|
||||
|
||||
class LogRvItem(
|
||||
override val item: String
|
||||
) : ObservableRvItem(), DiffItem<LogRvItem>, ItemWrapper<String>, ViewAwareItem {
|
||||
|
||||
override val layoutRes = R.layout.item_log_textview
|
||||
|
||||
override fun onBind(binding: ViewDataBinding, recyclerView: RecyclerView) {
|
||||
val view = binding.root as MaterialTextView
|
||||
view.measure(0, 0)
|
||||
val desiredWidth = view.measuredWidth
|
||||
val layoutParams = view.layoutParams
|
||||
layoutParams.width = desiredWidth
|
||||
if (recyclerView.width < desiredWidth) {
|
||||
recyclerView.requestLayout()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,400 +0,0 @@
|
||||
package com.topjohnwu.magisk.ui.log
|
||||
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
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.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
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.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
|
||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.topjohnwu.magisk.core.ktx.timeDateFormat
|
||||
import com.topjohnwu.magisk.core.ktx.toTime
|
||||
import com.topjohnwu.magisk.core.model.su.SuLog
|
||||
import com.topjohnwu.magisk.ui.util.rememberDrawablePainter
|
||||
import top.yukonga.miuix.kmp.basic.Icon
|
||||
import top.yukonga.miuix.kmp.basic.IconButton
|
||||
import top.yukonga.miuix.kmp.basic.Card
|
||||
import top.yukonga.miuix.kmp.basic.CircularProgressIndicator
|
||||
import top.yukonga.miuix.kmp.basic.MiuixScrollBehavior
|
||||
import top.yukonga.miuix.kmp.basic.Scaffold
|
||||
import top.yukonga.miuix.kmp.basic.TabRow
|
||||
import top.yukonga.miuix.kmp.basic.Text
|
||||
import top.yukonga.miuix.kmp.basic.TopAppBar
|
||||
import top.yukonga.miuix.kmp.theme.MiuixTheme
|
||||
import top.yukonga.miuix.kmp.icon.MiuixIcons
|
||||
import top.yukonga.miuix.kmp.icon.extended.Delete
|
||||
import top.yukonga.miuix.kmp.icon.extended.Download
|
||||
import com.topjohnwu.magisk.core.R as CoreR
|
||||
|
||||
@Composable
|
||||
fun LogScreen(viewModel: LogViewModel) {
|
||||
val uiState by viewModel.uiState.collectAsState()
|
||||
var selectedTab by rememberSaveable { mutableIntStateOf(0) }
|
||||
val tabTitles = listOf(
|
||||
stringResource(CoreR.string.superuser),
|
||||
stringResource(CoreR.string.magisk)
|
||||
)
|
||||
val scrollBehavior = MiuixScrollBehavior()
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = stringResource(CoreR.string.logs),
|
||||
actions = {
|
||||
if (selectedTab == 1) {
|
||||
IconButton(onClick = { viewModel.saveMagiskLog() }) {
|
||||
Icon(
|
||||
imageVector = MiuixIcons.Download,
|
||||
contentDescription = stringResource(CoreR.string.save_log),
|
||||
)
|
||||
}
|
||||
}
|
||||
IconButton(
|
||||
modifier = Modifier.padding(end = 16.dp),
|
||||
onClick = {
|
||||
if (selectedTab == 0) viewModel.clearLog()
|
||||
else viewModel.clearMagiskLog()
|
||||
}
|
||||
) {
|
||||
Icon(
|
||||
imageVector = MiuixIcons.Delete,
|
||||
contentDescription = stringResource(CoreR.string.clear_log),
|
||||
)
|
||||
}
|
||||
},
|
||||
scrollBehavior = scrollBehavior
|
||||
)
|
||||
},
|
||||
popupHost = { }
|
||||
) { padding ->
|
||||
Column(modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding)
|
||||
) {
|
||||
TabRow(
|
||||
tabs = tabTitles,
|
||||
selectedTabIndex = selectedTab,
|
||||
onTabSelected = { selectedTab = it },
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 12.dp, vertical = 8.dp)
|
||||
)
|
||||
|
||||
if (uiState.loading) {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
CircularProgressIndicator()
|
||||
}
|
||||
} else {
|
||||
when (selectedTab) {
|
||||
0 -> SuLogTab(
|
||||
logs = uiState.suLogs,
|
||||
nestedScrollConnection = scrollBehavior.nestedScrollConnection
|
||||
)
|
||||
1 -> MagiskLogTab(
|
||||
entries = uiState.magiskLogEntries,
|
||||
nestedScrollConnection = scrollBehavior.nestedScrollConnection
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SuLogTab(logs: List<SuLog>, nestedScrollConnection: NestedScrollConnection) {
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
if (logs.isEmpty()) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 12.dp),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(CoreR.string.log_data_none),
|
||||
style = MiuixTheme.textStyles.body1,
|
||||
color = MiuixTheme.colorScheme.onSurfaceVariantSummary,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
LazyColumn(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.nestedScroll(nestedScrollConnection)
|
||||
.padding(horizontal = 12.dp),
|
||||
contentPadding = PaddingValues(top = 8.dp, bottom = 88.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
items(logs, key = { it.id }) { log ->
|
||||
SuLogCard(log)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SuLogCard(log: SuLog) {
|
||||
val res = LocalContext.current.resources
|
||||
val pm = LocalContext.current.packageManager
|
||||
val icon = remember(log.packageName) {
|
||||
runCatching {
|
||||
pm.getApplicationInfo(log.packageName, 0).loadIcon(pm)
|
||||
}.getOrDefault(pm.defaultActivityIcon)
|
||||
}
|
||||
val allowed = log.action >= 2
|
||||
|
||||
val uidPidText = buildString {
|
||||
append("UID: ${log.toUid} PID: ${log.fromPid}")
|
||||
if (log.target != -1) {
|
||||
val target = if (log.target == 0) "magiskd" else log.target.toString()
|
||||
append(" → $target")
|
||||
}
|
||||
}
|
||||
|
||||
val details = buildString {
|
||||
if (log.context.isNotEmpty()) {
|
||||
append(res.getString(CoreR.string.selinux_context, log.context))
|
||||
}
|
||||
if (log.gids.isNotEmpty()) {
|
||||
if (isNotEmpty()) append("\n")
|
||||
append(res.getString(CoreR.string.supp_group, log.gids))
|
||||
}
|
||||
if (log.command.isNotEmpty()) {
|
||||
if (isNotEmpty()) append("\n")
|
||||
append(log.command)
|
||||
}
|
||||
}
|
||||
|
||||
Card(modifier = Modifier.fillMaxWidth()) {
|
||||
Column(modifier = Modifier.padding(12.dp)) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.Top
|
||||
) {
|
||||
Image(
|
||||
painter = rememberDrawablePainter(icon),
|
||||
contentDescription = log.appName,
|
||||
modifier = Modifier.size(36.dp)
|
||||
)
|
||||
Spacer(Modifier.width(10.dp))
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = log.appName,
|
||||
style = MiuixTheme.textStyles.body1,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Text(
|
||||
text = uidPidText,
|
||||
style = MiuixTheme.textStyles.body2,
|
||||
color = MiuixTheme.colorScheme.onSurfaceVariantSummary,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Column(horizontalAlignment = Alignment.End) {
|
||||
Text(
|
||||
text = log.time.toTime(timeDateFormat),
|
||||
fontSize = 11.sp,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
color = MiuixTheme.colorScheme.onSurfaceVariantSummary,
|
||||
maxLines = 1,
|
||||
)
|
||||
Spacer(Modifier.height(4.dp))
|
||||
SuActionBadge(allowed)
|
||||
}
|
||||
}
|
||||
|
||||
if (details.isNotEmpty()) {
|
||||
Spacer(Modifier.height(6.dp))
|
||||
Text(
|
||||
text = details,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontSize = 12.sp,
|
||||
lineHeight = 16.sp,
|
||||
color = MiuixTheme.colorScheme.onSurfaceVariantSummary,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SuActionBadge(allowed: Boolean) {
|
||||
val bg = if (allowed) MiuixTheme.colorScheme.primary else MiuixTheme.colorScheme.error
|
||||
val fg = if (allowed) MiuixTheme.colorScheme.onPrimary else MiuixTheme.colorScheme.onError
|
||||
val text = if (allowed) "Approved" else "Rejected"
|
||||
Text(
|
||||
text = text,
|
||||
color = fg,
|
||||
fontSize = 10.sp,
|
||||
maxLines = 1,
|
||||
modifier = Modifier
|
||||
.background(bg, RoundedCornerShape(6.dp))
|
||||
.padding(horizontal = 6.dp, vertical = 2.dp)
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MagiskLogTab(
|
||||
entries: List<MagiskLogEntry>,
|
||||
nestedScrollConnection: NestedScrollConnection
|
||||
) {
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
if (entries.isEmpty()) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 12.dp),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(CoreR.string.log_data_magisk_none),
|
||||
style = MiuixTheme.textStyles.body1,
|
||||
color = MiuixTheme.colorScheme.onSurfaceVariantSummary,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
val listState = rememberLazyListState(initialFirstVisibleItemIndex = entries.size - 1)
|
||||
LazyColumn(
|
||||
state = listState,
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.nestedScroll(nestedScrollConnection)
|
||||
.padding(horizontal = 12.dp),
|
||||
contentPadding = PaddingValues(top = 8.dp, bottom = 88.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp)
|
||||
) {
|
||||
items(entries.size, key = { it }) { index ->
|
||||
MagiskLogCard(entries[index])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MagiskLogCard(entry: MagiskLogEntry) {
|
||||
var expanded by remember { mutableStateOf(false) }
|
||||
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable { expanded = !expanded }
|
||||
) {
|
||||
Column(modifier = Modifier.padding(12.dp)) {
|
||||
if (entry.isParsed) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
modifier = Modifier.weight(1f)
|
||||
) {
|
||||
LogLevelBadge(entry.level)
|
||||
Text(
|
||||
text = entry.tag,
|
||||
style = MiuixTheme.textStyles.body1,
|
||||
fontWeight = FontWeight.Normal,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text(
|
||||
text = entry.timestamp,
|
||||
fontSize = 11.sp,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
color = MiuixTheme.colorScheme.onSurfaceVariantSummary,
|
||||
maxLines = 1,
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.height(4.dp))
|
||||
}
|
||||
|
||||
Text(
|
||||
text = entry.message,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontSize = 12.sp,
|
||||
lineHeight = 16.sp,
|
||||
color = MiuixTheme.colorScheme.onSurface,
|
||||
maxLines = if (expanded) Int.MAX_VALUE else 3,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun LogLevelBadge(level: Char) {
|
||||
val (bg, fg) = when (level) {
|
||||
'V' -> Color(0xFF9E9E9E) to Color.White
|
||||
'D' -> Color(0xFF2196F3) to Color.White
|
||||
'I' -> Color(0xFF4CAF50) to Color.White
|
||||
'W' -> Color(0xFFFFC107) to Color.Black
|
||||
'E' -> Color(0xFFF44336) to Color.White
|
||||
'F' -> Color(0xFF9C27B0) to Color.White
|
||||
else -> Color(0xFF757575) to Color.White
|
||||
}
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.clip(RoundedCornerShape(4.dp))
|
||||
.background(bg)
|
||||
.padding(horizontal = 5.dp, vertical = 1.dp),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(
|
||||
text = level.toString(),
|
||||
fontSize = 10.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
color = fg,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,24 +1,24 @@
|
||||
package com.topjohnwu.magisk.ui.log
|
||||
|
||||
import android.system.Os
|
||||
import androidx.databinding.Bindable
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.topjohnwu.magisk.BR
|
||||
import com.topjohnwu.magisk.arch.AsyncLoadViewModel
|
||||
import com.topjohnwu.magisk.core.BuildConfig
|
||||
import com.topjohnwu.magisk.core.Info
|
||||
import com.topjohnwu.magisk.core.R
|
||||
import com.topjohnwu.magisk.core.ktx.timeFormatStandard
|
||||
import com.topjohnwu.magisk.core.ktx.toTime
|
||||
import com.topjohnwu.magisk.core.model.su.SuLog
|
||||
import com.topjohnwu.magisk.core.repository.LogRepository
|
||||
import com.topjohnwu.magisk.core.su.SuEvents
|
||||
import com.topjohnwu.magisk.core.utils.MediaStoreUtils
|
||||
import com.topjohnwu.magisk.core.utils.MediaStoreUtils.outputStream
|
||||
import com.topjohnwu.magisk.databinding.bindExtra
|
||||
import com.topjohnwu.magisk.databinding.diffList
|
||||
import com.topjohnwu.magisk.databinding.set
|
||||
import com.topjohnwu.magisk.events.SnackbarEvent
|
||||
import com.topjohnwu.magisk.view.TextItem
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.debounce
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.io.FileInputStream
|
||||
@@ -26,42 +26,46 @@ import java.io.FileInputStream
|
||||
class LogViewModel(
|
||||
private val repo: LogRepository
|
||||
) : AsyncLoadViewModel() {
|
||||
@get:Bindable
|
||||
var loading = true
|
||||
private set(value) = set(value, field, { field = it }, BR.loading)
|
||||
|
||||
init {
|
||||
@OptIn(kotlinx.coroutines.FlowPreview::class)
|
||||
viewModelScope.launch {
|
||||
SuEvents.logUpdated.debounce(500).collect { reload() }
|
||||
}
|
||||
// --- empty view
|
||||
|
||||
val itemEmpty = TextItem(R.string.log_data_none)
|
||||
val itemMagiskEmpty = TextItem(R.string.log_data_magisk_none)
|
||||
|
||||
// --- su log
|
||||
|
||||
val items = diffList<SuLogRvItem>()
|
||||
val extraBindings = bindExtra {
|
||||
it.put(BR.viewModel, this)
|
||||
}
|
||||
|
||||
data class UiState(
|
||||
val loading: Boolean = true,
|
||||
val magiskLog: String = "",
|
||||
val magiskLogEntries: List<MagiskLogEntry> = emptyList(),
|
||||
val suLogs: List<SuLog> = emptyList(),
|
||||
)
|
||||
|
||||
private val _uiState = MutableStateFlow(UiState())
|
||||
val uiState: StateFlow<UiState> = _uiState.asStateFlow()
|
||||
|
||||
private var magiskLogRaw = ""
|
||||
// --- magisk log
|
||||
val logs = diffList<LogRvItem>()
|
||||
var magiskLogRaw = " "
|
||||
|
||||
override suspend fun doLoadWork() {
|
||||
_uiState.update { it.copy(loading = true) }
|
||||
withContext(Dispatchers.Default) {
|
||||
loading = true
|
||||
|
||||
val (suLogs, suDiff) = withContext(Dispatchers.Default) {
|
||||
magiskLogRaw = repo.fetchMagiskLogs()
|
||||
val suLogs = repo.fetchSuLogs()
|
||||
val entries = MagiskLogParser.parse(magiskLogRaw)
|
||||
_uiState.update { it.copy(
|
||||
loading = false,
|
||||
magiskLog = magiskLogRaw,
|
||||
magiskLogEntries = entries,
|
||||
suLogs = suLogs,
|
||||
) }
|
||||
val newLogs = magiskLogRaw.split('\n').map { LogRvItem(it) }
|
||||
logs.update(newLogs)
|
||||
val suLogs = repo.fetchSuLogs().map { SuLogRvItem(it) }
|
||||
suLogs to items.calculateDiff(suLogs)
|
||||
}
|
||||
|
||||
items.firstOrNull()?.isTop = false
|
||||
items.lastOrNull()?.isBottom = false
|
||||
items.update(suLogs, suDiff)
|
||||
items.firstOrNull()?.isTop = true
|
||||
items.lastOrNull()?.isBottom = true
|
||||
loading = false
|
||||
}
|
||||
|
||||
fun saveMagiskLog() {
|
||||
fun saveMagiskLog() = withExternalRW {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
val filename = "magisk_log_%s.log".format(
|
||||
System.currentTimeMillis().toTime(timeFormatStandard))
|
||||
@@ -93,18 +97,18 @@ class LogViewModel(
|
||||
ProcessBuilder("logcat", "-d").start()
|
||||
.inputStream.reader().use { it.copyTo(file) }
|
||||
}
|
||||
showSnackbar(logFile.toString())
|
||||
SnackbarEvent(logFile.toString()).publish()
|
||||
}
|
||||
}
|
||||
|
||||
fun clearMagiskLog() = repo.clearMagiskLogs {
|
||||
showSnackbar(R.string.logs_cleared)
|
||||
SnackbarEvent(R.string.logs_cleared).publish()
|
||||
startLoading()
|
||||
}
|
||||
|
||||
fun clearLog() = viewModelScope.launch {
|
||||
repo.clearLogs()
|
||||
showSnackbar(R.string.logs_cleared)
|
||||
SnackbarEvent(R.string.logs_cleared).publish()
|
||||
startLoading()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,56 +0,0 @@
|
||||
package com.topjohnwu.magisk.ui.log
|
||||
|
||||
data class MagiskLogEntry(
|
||||
val timestamp: String = "",
|
||||
val pid: Int = 0,
|
||||
val tid: Int = 0,
|
||||
val level: Char = 'I',
|
||||
val tag: String = "",
|
||||
val message: String = "",
|
||||
val isParsed: Boolean = false,
|
||||
)
|
||||
|
||||
object MagiskLogParser {
|
||||
|
||||
// Logcat format: "MM-DD HH:MM:SS.mmm PID TID LEVEL TAG : message"
|
||||
private val logcatRegex = Regex(
|
||||
"""(\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2}\.\d{3})\s+(\d+)\s+(\d+)\s+([VDIWEF])\s+(.+?)\s*:\s+(.*)"""
|
||||
)
|
||||
|
||||
fun parse(raw: String): List<MagiskLogEntry> {
|
||||
if (raw.isBlank()) return emptyList()
|
||||
|
||||
val lines = raw.lines()
|
||||
val result = mutableListOf<MagiskLogEntry>()
|
||||
|
||||
for (line in lines) {
|
||||
if (line.isBlank()) continue
|
||||
|
||||
val match = logcatRegex.find(line)
|
||||
if (match != null) {
|
||||
result.add(
|
||||
MagiskLogEntry(
|
||||
timestamp = match.groupValues[1],
|
||||
pid = match.groupValues[2].toIntOrNull() ?: 0,
|
||||
tid = match.groupValues[3].toIntOrNull() ?: 0,
|
||||
level = match.groupValues[4].firstOrNull() ?: 'I',
|
||||
tag = match.groupValues[5].trim(),
|
||||
message = match.groupValues[6],
|
||||
isParsed = true,
|
||||
)
|
||||
)
|
||||
} else if (result.isNotEmpty() && result.last().isParsed) {
|
||||
// Continuation line — append to previous entry
|
||||
val prev = result.last()
|
||||
result[result.lastIndex] = prev.copy(
|
||||
message = prev.message + "\n" + line.trimEnd()
|
||||
)
|
||||
} else {
|
||||
result.add(
|
||||
MagiskLogEntry(message = line.trimEnd())
|
||||
)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package com.topjohnwu.magisk.ui.log
|
||||
|
||||
import androidx.databinding.Bindable
|
||||
import com.topjohnwu.magisk.BR
|
||||
import com.topjohnwu.magisk.R
|
||||
import com.topjohnwu.magisk.core.AppContext
|
||||
import com.topjohnwu.magisk.core.ktx.timeDateFormat
|
||||
import com.topjohnwu.magisk.core.ktx.toTime
|
||||
import com.topjohnwu.magisk.core.model.su.SuLog
|
||||
import com.topjohnwu.magisk.databinding.DiffItem
|
||||
import com.topjohnwu.magisk.databinding.ObservableRvItem
|
||||
import com.topjohnwu.magisk.databinding.set
|
||||
import com.topjohnwu.magisk.core.R as CoreR
|
||||
|
||||
class SuLogRvItem(val log: SuLog) : ObservableRvItem(), DiffItem<SuLogRvItem> {
|
||||
|
||||
override val layoutRes = R.layout.item_log_access_md2
|
||||
|
||||
val info = genInfo()
|
||||
|
||||
@get:Bindable
|
||||
var isTop = false
|
||||
set(value) = set(value, field, { field = it }, BR.top)
|
||||
|
||||
@get:Bindable
|
||||
var isBottom = false
|
||||
set(value) = set(value, field, { field = it }, BR.bottom)
|
||||
|
||||
override fun itemSameAs(other: SuLogRvItem) = log.appName == other.log.appName
|
||||
|
||||
private fun genInfo(): String {
|
||||
val res = AppContext.resources
|
||||
val sb = StringBuilder()
|
||||
val date = log.time.toTime(timeDateFormat)
|
||||
val toUid = res.getString(CoreR.string.target_uid, log.toUid)
|
||||
val fromPid = res.getString(CoreR.string.pid, log.fromPid)
|
||||
sb.append("$date\n$toUid $fromPid")
|
||||
if (log.target != -1) {
|
||||
val pid = if (log.target == 0) "magiskd" else log.target.toString()
|
||||
val target = res.getString(CoreR.string.target_pid, pid)
|
||||
sb.append(" $target")
|
||||
}
|
||||
if (log.context.isNotEmpty()) {
|
||||
val context = res.getString(CoreR.string.selinux_context, log.context)
|
||||
sb.append("\n$context")
|
||||
}
|
||||
if (log.gids.isNotEmpty()) {
|
||||
val gids = res.getString(CoreR.string.supp_group, log.gids)
|
||||
sb.append("\n$gids")
|
||||
}
|
||||
sb.append("\n${log.command}")
|
||||
return sb.toString()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
package com.topjohnwu.magisk.ui.module
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.pm.ActivityInfo
|
||||
import android.os.Bundle
|
||||
import android.view.KeyEvent
|
||||
import android.view.Menu
|
||||
import android.view.MenuInflater
|
||||
import android.view.MenuItem
|
||||
import android.view.View
|
||||
import android.view.ViewTreeObserver
|
||||
import android.widget.Toast
|
||||
import androidx.core.view.MenuProvider
|
||||
import androidx.core.view.isVisible
|
||||
import com.topjohnwu.magisk.R
|
||||
import com.topjohnwu.magisk.arch.BaseFragment
|
||||
import com.topjohnwu.magisk.arch.viewModel
|
||||
import com.topjohnwu.magisk.core.ktx.toast
|
||||
import com.topjohnwu.magisk.databinding.FragmentActionMd2Binding
|
||||
import com.topjohnwu.magisk.core.R as CoreR
|
||||
|
||||
class ActionFragment : BaseFragment<FragmentActionMd2Binding>(), MenuProvider {
|
||||
|
||||
override val layoutRes = R.layout.fragment_action_md2
|
||||
override val viewModel by viewModel<ActionViewModel>()
|
||||
override val snackbarView: View get() = binding.snackbarContainer
|
||||
|
||||
private var defaultOrientation = -1
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
viewModel.args = ActionFragmentArgs.fromBundle(requireArguments())
|
||||
}
|
||||
|
||||
override fun onStart() {
|
||||
super.onStart()
|
||||
activity?.setTitle(viewModel.args.name)
|
||||
binding.closeBtn.setOnClickListener {
|
||||
activity?.onBackPressed()
|
||||
}
|
||||
|
||||
viewModel.state.observe(this) {
|
||||
if (it != ActionViewModel.State.RUNNING) {
|
||||
binding.closeBtn.apply {
|
||||
if (!this.isVisible) this.show()
|
||||
if (!this.isFocused) this.requestFocus()
|
||||
}
|
||||
}
|
||||
if (it != ActionViewModel.State.SUCCESS) return@observe
|
||||
view?.viewTreeObserver?.addOnWindowFocusChangeListener(
|
||||
object : ViewTreeObserver.OnWindowFocusChangeListener {
|
||||
override fun onWindowFocusChanged(hasFocus: Boolean) {
|
||||
if (hasFocus) return
|
||||
view?.viewTreeObserver?.removeOnWindowFocusChangeListener(this)
|
||||
view?.context?.apply {
|
||||
toast(
|
||||
getString(CoreR.string.done_action, viewModel.args.name),
|
||||
Toast.LENGTH_SHORT
|
||||
)
|
||||
}
|
||||
viewModel.back()
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreateMenu(menu: Menu, inflater: MenuInflater) {
|
||||
inflater.inflate(R.menu.menu_flash, menu)
|
||||
}
|
||||
|
||||
override fun onMenuItemSelected(item: MenuItem): Boolean {
|
||||
return viewModel.onMenuItemClicked(item)
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
|
||||
defaultOrientation = activity?.requestedOrientation ?: -1
|
||||
activity?.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_LOCKED
|
||||
if (savedInstanceState == null) {
|
||||
viewModel.startRunAction()
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressLint("WrongConstant")
|
||||
override fun onDestroyView() {
|
||||
if (defaultOrientation != -1) {
|
||||
activity?.requestedOrientation = defaultOrientation
|
||||
}
|
||||
super.onDestroyView()
|
||||
}
|
||||
|
||||
override fun onKeyEvent(event: KeyEvent): Boolean {
|
||||
return when (event.keyCode) {
|
||||
KeyEvent.KEYCODE_VOLUME_UP, KeyEvent.KEYCODE_VOLUME_DOWN -> true
|
||||
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
|
||||
override fun onBackPressed(): Boolean {
|
||||
if (viewModel.state.value == ActionViewModel.State.RUNNING) return true
|
||||
return super.onBackPressed()
|
||||
}
|
||||
|
||||
override fun onPreBind(binding: FragmentActionMd2Binding) = Unit
|
||||
}
|
||||
@@ -1,72 +0,0 @@
|
||||
package com.topjohnwu.magisk.ui.module
|
||||
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
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.topjohnwu.magisk.R
|
||||
import com.topjohnwu.magisk.ui.terminal.TerminalScreen
|
||||
import top.yukonga.miuix.kmp.basic.Icon
|
||||
import top.yukonga.miuix.kmp.basic.IconButton
|
||||
import top.yukonga.miuix.kmp.basic.MiuixScrollBehavior
|
||||
import top.yukonga.miuix.kmp.basic.Scaffold
|
||||
import top.yukonga.miuix.kmp.basic.SmallTopAppBar
|
||||
import top.yukonga.miuix.kmp.icon.MiuixIcons
|
||||
import top.yukonga.miuix.kmp.icon.extended.Back
|
||||
import top.yukonga.miuix.kmp.theme.MiuixTheme
|
||||
import com.topjohnwu.magisk.core.R as CoreR
|
||||
|
||||
@Composable
|
||||
fun ActionScreen(viewModel: ActionViewModel, actionName: String, onBack: () -> Unit) {
|
||||
val actionState by viewModel.actionState.collectAsState()
|
||||
val finished = actionState != ActionViewModel.State.RUNNING
|
||||
|
||||
val scrollBehavior = MiuixScrollBehavior()
|
||||
Scaffold(
|
||||
topBar = {
|
||||
SmallTopAppBar(
|
||||
title = actionName,
|
||||
navigationIcon = {
|
||||
IconButton(
|
||||
modifier = Modifier.padding(start = 16.dp),
|
||||
onClick = onBack
|
||||
) {
|
||||
Icon(
|
||||
imageVector = MiuixIcons.Back,
|
||||
contentDescription = null,
|
||||
tint = MiuixTheme.colorScheme.onBackground
|
||||
)
|
||||
}
|
||||
},
|
||||
actions = {
|
||||
if (finished) {
|
||||
IconButton(
|
||||
modifier = Modifier.padding(end = 16.dp),
|
||||
onClick = { viewModel.saveLog() }
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.ic_save_md2),
|
||||
contentDescription = stringResource(CoreR.string.menuSaveLog),
|
||||
tint = MiuixTheme.colorScheme.onBackground
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
scrollBehavior = scrollBehavior
|
||||
)
|
||||
},
|
||||
popupHost = { }
|
||||
) { padding ->
|
||||
TerminalScreen(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding),
|
||||
onEmulatorCreated = { viewModel.onEmulatorCreated(it) },
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,20 +1,26 @@
|
||||
package com.topjohnwu.magisk.ui.module
|
||||
|
||||
import android.view.MenuItem
|
||||
import androidx.databinding.ObservableArrayList
|
||||
import androidx.lifecycle.LiveData
|
||||
import androidx.lifecycle.MutableLiveData
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.topjohnwu.magisk.R
|
||||
import com.topjohnwu.magisk.arch.BaseViewModel
|
||||
import com.topjohnwu.magisk.core.ktx.synchronized
|
||||
import com.topjohnwu.magisk.core.ktx.timeFormatStandard
|
||||
import com.topjohnwu.magisk.core.ktx.toTime
|
||||
import com.topjohnwu.magisk.core.utils.MediaStoreUtils
|
||||
import com.topjohnwu.magisk.core.utils.MediaStoreUtils.outputStream
|
||||
import com.topjohnwu.magisk.terminal.TerminalEmulator
|
||||
import com.topjohnwu.magisk.terminal.runSuCommand
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import com.topjohnwu.magisk.events.SnackbarEvent
|
||||
import com.topjohnwu.magisk.ui.flash.ConsoleItem
|
||||
import com.topjohnwu.superuser.CallbackList
|
||||
import com.topjohnwu.superuser.Shell
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import timber.log.Timber
|
||||
import java.io.IOException
|
||||
|
||||
class ActionViewModel : BaseViewModel() {
|
||||
|
||||
@@ -22,49 +28,61 @@ class ActionViewModel : BaseViewModel() {
|
||||
RUNNING, SUCCESS, FAILED
|
||||
}
|
||||
|
||||
private val _actionState = MutableStateFlow(State.RUNNING)
|
||||
val actionState: StateFlow<State> = _actionState.asStateFlow()
|
||||
private val _state = MutableLiveData(State.RUNNING)
|
||||
val state: LiveData<State> get() = _state
|
||||
|
||||
var actionId: String = ""
|
||||
var actionName: String = ""
|
||||
val items = ObservableArrayList<ConsoleItem>()
|
||||
lateinit var args: ActionFragmentArgs
|
||||
|
||||
private var emulator: TerminalEmulator? = null
|
||||
private val emulatorReady = CompletableDeferred<TerminalEmulator>()
|
||||
|
||||
fun onEmulatorCreated(emu: TerminalEmulator) {
|
||||
emulator = emu
|
||||
emulatorReady.complete(emu)
|
||||
}
|
||||
|
||||
fun startRunAction() {
|
||||
viewModelScope.launch {
|
||||
val emu = emulatorReady.await()
|
||||
|
||||
val success = withContext(Dispatchers.IO) {
|
||||
runSuCommand(
|
||||
emu,
|
||||
"cd /data/adb/modules/$actionId && sh ./action.sh"
|
||||
)
|
||||
}
|
||||
|
||||
_actionState.value = if (success) State.SUCCESS else State.FAILED
|
||||
private val logItems = mutableListOf<String>().synchronized()
|
||||
private val outItems = object : CallbackList<String>() {
|
||||
override fun onAddElement(e: String?) {
|
||||
e ?: return
|
||||
items.add(ConsoleItem(e))
|
||||
logItems.add(e)
|
||||
}
|
||||
}
|
||||
|
||||
fun saveLog() {
|
||||
fun startRunAction() = viewModelScope.launch {
|
||||
onResult(withContext(Dispatchers.IO) {
|
||||
try {
|
||||
Shell.cmd("run_action \'${args.id}\'")
|
||||
.to(outItems, logItems)
|
||||
.exec().isSuccess
|
||||
} catch (e: IOException) {
|
||||
Timber.e(e)
|
||||
false
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private fun onResult(success: Boolean) {
|
||||
_state.value = if (success) State.SUCCESS else State.FAILED
|
||||
}
|
||||
|
||||
fun onMenuItemClicked(item: MenuItem): Boolean {
|
||||
when (item.itemId) {
|
||||
R.id.action_save -> savePressed()
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
private fun savePressed() = withExternalRW {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
val name = "%s_action_log_%s.log".format(
|
||||
actionName,
|
||||
args.name,
|
||||
System.currentTimeMillis().toTime(timeFormatStandard)
|
||||
)
|
||||
val file = MediaStoreUtils.getFile(name)
|
||||
file.uri.outputStream().bufferedWriter().use { writer ->
|
||||
val transcript = emulator?.screen?.transcriptText
|
||||
if (transcript != null) {
|
||||
writer.write(transcript)
|
||||
synchronized(logItems) {
|
||||
logItems.forEach {
|
||||
writer.write(it)
|
||||
writer.newLine()
|
||||
}
|
||||
}
|
||||
}
|
||||
showSnackbar(file.toString())
|
||||
SnackbarEvent(file.toString()).publish()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
package com.topjohnwu.magisk.ui.module
|
||||
|
||||
import android.os.Bundle
|
||||
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.core.utils.MediaStoreUtils.displayName
|
||||
import com.topjohnwu.magisk.databinding.FragmentModuleMd2Binding
|
||||
import rikka.recyclerview.addEdgeSpacing
|
||||
import rikka.recyclerview.addInvalidateItemDecorationsObserver
|
||||
import rikka.recyclerview.addItemSpacing
|
||||
import rikka.recyclerview.fixEdgeEffect
|
||||
import com.topjohnwu.magisk.core.R as CoreR
|
||||
|
||||
class ModuleFragment : BaseFragment<FragmentModuleMd2Binding>() {
|
||||
|
||||
override val layoutRes = R.layout.fragment_module_md2
|
||||
override val viewModel by viewModel<ModuleViewModel>()
|
||||
|
||||
override fun onStart() {
|
||||
super.onStart()
|
||||
activity?.title = resources.getString(CoreR.string.modules)
|
||||
viewModel.data.observe(this) {
|
||||
it ?: return@observe
|
||||
val displayName = runCatching { it.displayName }.getOrNull() ?: return@observe
|
||||
viewModel.requestInstallLocalModule(it, displayName)
|
||||
viewModel.data.value = null
|
||||
}
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
|
||||
binding.moduleList.apply {
|
||||
addEdgeSpacing(top = R.dimen.l_50, bottom = R.dimen.l1)
|
||||
addItemSpacing(R.dimen.l1, R.dimen.l_50, R.dimen.l1)
|
||||
fixEdgeEffect()
|
||||
post { addInvalidateItemDecorationsObserver() }
|
||||
}
|
||||
}
|
||||
|
||||
override fun onPreBind(binding: FragmentModuleMd2Binding) = Unit
|
||||
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
package com.topjohnwu.magisk.ui.module
|
||||
|
||||
import androidx.databinding.Bindable
|
||||
import com.topjohnwu.magisk.BR
|
||||
import com.topjohnwu.magisk.R
|
||||
import com.topjohnwu.magisk.core.Info
|
||||
import com.topjohnwu.magisk.core.model.module.LocalModule
|
||||
import com.topjohnwu.magisk.databinding.DiffItem
|
||||
import com.topjohnwu.magisk.databinding.ItemWrapper
|
||||
import com.topjohnwu.magisk.databinding.ObservableRvItem
|
||||
import com.topjohnwu.magisk.databinding.RvItem
|
||||
import com.topjohnwu.magisk.databinding.set
|
||||
import com.topjohnwu.magisk.utils.TextHolder
|
||||
import com.topjohnwu.magisk.utils.asText
|
||||
import com.topjohnwu.magisk.core.R as CoreR
|
||||
|
||||
object InstallModule : RvItem(), DiffItem<InstallModule> {
|
||||
override val layoutRes = R.layout.item_module_download
|
||||
}
|
||||
|
||||
class LocalModuleRvItem(
|
||||
override val item: LocalModule
|
||||
) : ObservableRvItem(), DiffItem<LocalModuleRvItem>, ItemWrapper<LocalModule> {
|
||||
|
||||
override val layoutRes = R.layout.item_module_md2
|
||||
|
||||
val showNotice: Boolean
|
||||
val showAction: Boolean
|
||||
val noticeText: TextHolder
|
||||
|
||||
init {
|
||||
val isZygisk = item.isZygisk
|
||||
val isRiru = item.isRiru
|
||||
val zygiskUnloaded = isZygisk && item.zygiskUnloaded
|
||||
|
||||
showNotice = zygiskUnloaded ||
|
||||
(Info.isZygiskEnabled && isRiru) ||
|
||||
(!Info.isZygiskEnabled && isZygisk)
|
||||
showAction = item.hasAction && !showNotice
|
||||
noticeText =
|
||||
when {
|
||||
zygiskUnloaded -> CoreR.string.zygisk_module_unloaded.asText()
|
||||
isRiru -> CoreR.string.suspend_text_riru.asText(CoreR.string.zygisk.asText())
|
||||
else -> CoreR.string.suspend_text_zygisk.asText(CoreR.string.zygisk.asText())
|
||||
}
|
||||
}
|
||||
|
||||
@get:Bindable
|
||||
var isEnabled = item.enable
|
||||
set(value) = set(value, field, { field = it }, BR.enabled, BR.updateReady) {
|
||||
item.enable = value
|
||||
}
|
||||
|
||||
@get:Bindable
|
||||
var isRemoved = item.remove
|
||||
set(value) = set(value, field, { field = it }, BR.removed, BR.updateReady) {
|
||||
item.remove = value
|
||||
}
|
||||
|
||||
@get:Bindable
|
||||
val showUpdate get() = item.updateInfo != null
|
||||
|
||||
@get:Bindable
|
||||
val updateReady get() = item.outdated && !isRemoved && isEnabled
|
||||
|
||||
val isUpdated = item.updated
|
||||
|
||||
fun fetchedUpdateInfo() {
|
||||
notifyPropertyChanged(BR.showUpdate)
|
||||
notifyPropertyChanged(BR.updateReady)
|
||||
}
|
||||
|
||||
fun delete() {
|
||||
isRemoved = !isRemoved
|
||||
}
|
||||
|
||||
override fun itemSameAs(other: LocalModuleRvItem): Boolean = item.id == other.item.id
|
||||
}
|
||||
@@ -1,449 +0,0 @@
|
||||
package com.topjohnwu.magisk.ui.module
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.animateContentSize
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
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.width
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import android.net.Uri
|
||||
import android.provider.OpenableColumns
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.MutableState
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextDecoration
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.topjohnwu.magisk.core.Info
|
||||
import com.topjohnwu.magisk.core.di.ServiceLocator
|
||||
import com.topjohnwu.magisk.core.download.DownloadEngine
|
||||
import com.topjohnwu.magisk.core.model.module.OnlineModule
|
||||
import com.topjohnwu.magisk.ui.MainActivity
|
||||
import com.topjohnwu.magisk.ui.component.ConfirmResult
|
||||
import com.topjohnwu.magisk.ui.component.MarkdownTextAsync
|
||||
import com.topjohnwu.magisk.ui.component.rememberConfirmDialog
|
||||
import kotlinx.coroutines.launch
|
||||
import top.yukonga.miuix.kmp.basic.ButtonDefaults
|
||||
import top.yukonga.miuix.kmp.basic.Card
|
||||
import top.yukonga.miuix.kmp.basic.TextButton
|
||||
import top.yukonga.miuix.kmp.extra.SuperDialog
|
||||
import top.yukonga.miuix.kmp.basic.CircularProgressIndicator
|
||||
import top.yukonga.miuix.kmp.basic.FloatingActionButton
|
||||
import top.yukonga.miuix.kmp.basic.HorizontalDivider
|
||||
import top.yukonga.miuix.kmp.basic.Icon
|
||||
import top.yukonga.miuix.kmp.basic.IconButton
|
||||
import top.yukonga.miuix.kmp.basic.MiuixScrollBehavior
|
||||
import top.yukonga.miuix.kmp.basic.Scaffold
|
||||
import top.yukonga.miuix.kmp.basic.Switch
|
||||
import top.yukonga.miuix.kmp.basic.Text
|
||||
import top.yukonga.miuix.kmp.basic.TopAppBar
|
||||
import top.yukonga.miuix.kmp.icon.MiuixIcons
|
||||
import top.yukonga.miuix.kmp.icon.extended.Add
|
||||
import top.yukonga.miuix.kmp.icon.extended.Delete
|
||||
import top.yukonga.miuix.kmp.icon.extended.Play
|
||||
import top.yukonga.miuix.kmp.icon.extended.Undo
|
||||
import top.yukonga.miuix.kmp.icon.extended.UploadCloud
|
||||
import top.yukonga.miuix.kmp.theme.MiuixTheme
|
||||
import com.topjohnwu.magisk.core.R as CoreR
|
||||
|
||||
@Composable
|
||||
fun ModuleScreen(viewModel: ModuleViewModel) {
|
||||
val uiState by viewModel.uiState.collectAsState()
|
||||
val scrollBehavior = MiuixScrollBehavior()
|
||||
val colorScheme = MiuixTheme.colorScheme
|
||||
val context = LocalContext.current
|
||||
val scope = rememberCoroutineScope()
|
||||
val activity = context as MainActivity
|
||||
|
||||
var pendingZipUri by remember { mutableStateOf<Uri?>(null) }
|
||||
var pendingZipName by remember { mutableStateOf("") }
|
||||
val localInstallDialog = rememberConfirmDialog()
|
||||
val confirmInstallTitle = stringResource(CoreR.string.confirm_install_title)
|
||||
|
||||
var pendingOnlineModule by remember { mutableStateOf<OnlineModule?>(null) }
|
||||
val showOnlineDialog = rememberSaveable { mutableStateOf(false) }
|
||||
|
||||
val filePicker = rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { uri ->
|
||||
if (uri != null) {
|
||||
val displayName = context.contentResolver.query(uri, null, null, null, null)?.use { cursor ->
|
||||
val idx = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME)
|
||||
if (cursor.moveToFirst() && idx >= 0) cursor.getString(idx) else null
|
||||
} ?: uri.lastPathSegment ?: "module.zip"
|
||||
pendingZipUri = uri
|
||||
pendingZipName = displayName
|
||||
scope.launch {
|
||||
val result = localInstallDialog.awaitConfirm(
|
||||
title = confirmInstallTitle,
|
||||
content = context.getString(CoreR.string.confirm_install, displayName),
|
||||
)
|
||||
if (result == ConfirmResult.Confirmed) {
|
||||
viewModel.confirmLocalInstall(uri)
|
||||
}
|
||||
pendingZipUri = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (showOnlineDialog.value && pendingOnlineModule != null) {
|
||||
OnlineModuleDialog(
|
||||
item = pendingOnlineModule!!,
|
||||
showDialog = showOnlineDialog,
|
||||
onDownload = { install ->
|
||||
showOnlineDialog.value = false
|
||||
DownloadEngine.startWithActivity(
|
||||
activity, activity.extension,
|
||||
OnlineModuleSubject(pendingOnlineModule!!, install)
|
||||
)
|
||||
pendingOnlineModule = null
|
||||
},
|
||||
onDismiss = {
|
||||
showOnlineDialog.value = false
|
||||
pendingOnlineModule = null
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = stringResource(CoreR.string.modules),
|
||||
scrollBehavior = scrollBehavior
|
||||
)
|
||||
},
|
||||
floatingActionButton = {
|
||||
FloatingActionButton(
|
||||
onClick = { filePicker.launch("application/zip") },
|
||||
shadowElevation = 0.dp,
|
||||
modifier = Modifier
|
||||
.padding(bottom = 88.dp, end = 20.dp)
|
||||
.border(0.05.dp, colorScheme.outline.copy(alpha = 0.5f), CircleShape),
|
||||
content = {
|
||||
Icon(
|
||||
imageVector = MiuixIcons.Add,
|
||||
contentDescription = stringResource(CoreR.string.module_action_install_external),
|
||||
modifier = Modifier.size(28.dp),
|
||||
tint = colorScheme.onPrimary
|
||||
)
|
||||
},
|
||||
)
|
||||
},
|
||||
popupHost = { }
|
||||
) { padding ->
|
||||
if (uiState.loading) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
CircularProgressIndicator()
|
||||
}
|
||||
return@Scaffold
|
||||
}
|
||||
|
||||
if (uiState.modules.isEmpty()) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(CoreR.string.module_empty),
|
||||
style = MiuixTheme.textStyles.body1,
|
||||
color = colorScheme.onSurfaceVariantSummary
|
||||
)
|
||||
}
|
||||
return@Scaffold
|
||||
}
|
||||
|
||||
LazyColumn(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.nestedScroll(scrollBehavior.nestedScrollConnection)
|
||||
.padding(padding)
|
||||
.padding(horizontal = 12.dp),
|
||||
contentPadding = PaddingValues(bottom = 160.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
item { Spacer(Modifier.height(4.dp)) }
|
||||
items(uiState.modules, key = { it.module.id }) { item ->
|
||||
ModuleCard(
|
||||
item = item,
|
||||
viewModel = viewModel,
|
||||
onUpdateClick = { onlineModule ->
|
||||
if (onlineModule != null && Info.isConnected.value == true) {
|
||||
pendingOnlineModule = onlineModule
|
||||
showOnlineDialog.value = true
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
item { Spacer(Modifier.height(4.dp)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ModuleCard(item: ModuleItem, viewModel: ModuleViewModel, onUpdateClick: (OnlineModule?) -> Unit) {
|
||||
val infoAlpha = if (!item.isRemoved && item.isEnabled && !item.showNotice) 1f else 0.5f
|
||||
val strikeThrough = if (item.isRemoved) TextDecoration.LineThrough else TextDecoration.None
|
||||
val colorScheme = MiuixTheme.colorScheme
|
||||
val actionIconTint = colorScheme.onSurface.copy(alpha = 0.8f)
|
||||
val actionBg = colorScheme.secondaryContainer.copy(alpha = 0.8f)
|
||||
val updateBg = colorScheme.tertiaryContainer.copy(alpha = 0.6f)
|
||||
val updateTint = colorScheme.onTertiaryContainer.copy(alpha = 0.8f)
|
||||
val removeBg = colorScheme.errorContainer.copy(alpha = 0.6f)
|
||||
val removeTint = colorScheme.onErrorContainer.copy(alpha = 0.8f)
|
||||
var expanded by rememberSaveable(item.module.id) { mutableStateOf(false) }
|
||||
val hasDescription = item.module.description.isNotBlank()
|
||||
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
insideMargin = PaddingValues(16.dp),
|
||||
onClick = { if (hasDescription) expanded = !expanded }
|
||||
) {
|
||||
Column(modifier = Modifier.alpha(infoAlpha)) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.padding(end = 4.dp)
|
||||
) {
|
||||
Text(
|
||||
text = item.module.name,
|
||||
style = MiuixTheme.textStyles.body1,
|
||||
textDecoration = strikeThrough,
|
||||
)
|
||||
Text(
|
||||
text = stringResource(
|
||||
CoreR.string.module_version_author,
|
||||
item.module.version,
|
||||
item.module.author
|
||||
),
|
||||
style = MiuixTheme.textStyles.body2,
|
||||
color = colorScheme.onSurfaceVariantSummary,
|
||||
textDecoration = strikeThrough,
|
||||
)
|
||||
}
|
||||
Switch(
|
||||
checked = item.isEnabled,
|
||||
onCheckedChange = { viewModel.toggleEnabled(item) }
|
||||
)
|
||||
}
|
||||
|
||||
if (hasDescription) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.padding(top = 2.dp)
|
||||
.animateContentSize()
|
||||
) {
|
||||
Text(
|
||||
text = item.module.description,
|
||||
style = MiuixTheme.textStyles.body2,
|
||||
color = colorScheme.onSurfaceVariantSummary,
|
||||
textDecoration = strikeThrough,
|
||||
overflow = if (expanded) TextOverflow.Clip else TextOverflow.Ellipsis,
|
||||
maxLines = if (expanded) Int.MAX_VALUE else 4,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (item.showNotice) {
|
||||
Spacer(Modifier.height(4.dp))
|
||||
Text(
|
||||
text = item.noticeText,
|
||||
style = MiuixTheme.textStyles.body2,
|
||||
color = colorScheme.primary,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
HorizontalDivider(
|
||||
modifier = Modifier.padding(vertical = 8.dp),
|
||||
thickness = 0.5.dp,
|
||||
color = colorScheme.outline.copy(alpha = 0.5f)
|
||||
)
|
||||
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
AnimatedVisibility(
|
||||
visible = item.isEnabled && !item.isRemoved,
|
||||
enter = fadeIn(),
|
||||
exit = fadeOut()
|
||||
) {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
if (item.showAction) {
|
||||
IconButton(
|
||||
backgroundColor = actionBg,
|
||||
minHeight = 35.dp,
|
||||
minWidth = 35.dp,
|
||||
onClick = { viewModel.runAction(item.module.id, item.module.name) },
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(horizontal = 10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
Icon(
|
||||
modifier = Modifier.size(20.dp),
|
||||
imageVector = MiuixIcons.Play,
|
||||
tint = actionIconTint,
|
||||
contentDescription = stringResource(CoreR.string.module_action)
|
||||
)
|
||||
Text(
|
||||
text = stringResource(CoreR.string.module_action),
|
||||
color = actionIconTint,
|
||||
style = MiuixTheme.textStyles.body2,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(Modifier.weight(1f))
|
||||
|
||||
AnimatedVisibility(
|
||||
visible = item.showUpdate && item.updateReady,
|
||||
enter = fadeIn(),
|
||||
exit = fadeOut()
|
||||
) {
|
||||
IconButton(
|
||||
modifier = Modifier.padding(end = 8.dp),
|
||||
backgroundColor = updateBg,
|
||||
minHeight = 35.dp,
|
||||
minWidth = 35.dp,
|
||||
onClick = { onUpdateClick(item.module.updateInfo) },
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(horizontal = 10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
Icon(
|
||||
modifier = Modifier.size(20.dp),
|
||||
imageVector = MiuixIcons.UploadCloud,
|
||||
tint = updateTint,
|
||||
contentDescription = stringResource(CoreR.string.update),
|
||||
)
|
||||
Text(
|
||||
text = stringResource(CoreR.string.update),
|
||||
color = updateTint,
|
||||
style = MiuixTheme.textStyles.body2,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
IconButton(
|
||||
backgroundColor = if (item.isRemoved) actionBg else removeBg,
|
||||
minHeight = 35.dp,
|
||||
minWidth = 35.dp,
|
||||
onClick = { viewModel.toggleRemove(item) },
|
||||
enabled = !item.isUpdated
|
||||
) {
|
||||
val tint = if (item.isRemoved) actionIconTint else removeTint
|
||||
Row(
|
||||
modifier = Modifier.padding(horizontal = 10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
Icon(
|
||||
modifier = Modifier.size(20.dp),
|
||||
imageVector = if (item.isRemoved) MiuixIcons.Undo else MiuixIcons.Delete,
|
||||
tint = tint,
|
||||
contentDescription = null
|
||||
)
|
||||
Text(
|
||||
text = stringResource(
|
||||
if (item.isRemoved) CoreR.string.module_state_restore
|
||||
else CoreR.string.module_state_remove
|
||||
),
|
||||
color = tint,
|
||||
style = MiuixTheme.textStyles.body2,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun OnlineModuleDialog(
|
||||
item: OnlineModule,
|
||||
showDialog: MutableState<Boolean>,
|
||||
onDownload: (install: Boolean) -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
val svc = ServiceLocator.networkService
|
||||
val title = stringResource(
|
||||
CoreR.string.repo_install_title,
|
||||
item.name, item.version, item.versionCode
|
||||
)
|
||||
|
||||
SuperDialog(
|
||||
show = showDialog,
|
||||
title = title,
|
||||
onDismissRequest = onDismiss,
|
||||
) {
|
||||
MarkdownTextAsync {
|
||||
val str = svc.fetchString(item.changelog)
|
||||
if (str.length > 1000) str.substring(0, 1000) else str
|
||||
}
|
||||
Spacer(Modifier.height(16.dp))
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.End,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
TextButton(
|
||||
text = stringResource(android.R.string.cancel),
|
||||
onClick = onDismiss,
|
||||
)
|
||||
Spacer(Modifier.weight(1f))
|
||||
TextButton(
|
||||
text = stringResource(CoreR.string.download),
|
||||
onClick = { onDownload(false) },
|
||||
)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
TextButton(
|
||||
text = stringResource(CoreR.string.install),
|
||||
onClick = { onDownload(true) },
|
||||
colors = ButtonDefaults.textButtonColorsPrimary()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,126 +1,108 @@
|
||||
package com.topjohnwu.magisk.ui.module
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.databinding.Bindable
|
||||
import androidx.lifecycle.MutableLiveData
|
||||
import com.topjohnwu.magisk.BR
|
||||
import com.topjohnwu.magisk.MainDirections
|
||||
import com.topjohnwu.magisk.R
|
||||
import com.topjohnwu.magisk.arch.AsyncLoadViewModel
|
||||
import com.topjohnwu.magisk.core.Const
|
||||
import com.topjohnwu.magisk.core.Info
|
||||
import com.topjohnwu.magisk.core.download.Subject
|
||||
import com.topjohnwu.magisk.core.base.ContentResultCallback
|
||||
import com.topjohnwu.magisk.core.model.module.LocalModule
|
||||
import com.topjohnwu.magisk.core.model.module.OnlineModule
|
||||
import com.topjohnwu.magisk.ui.flash.FlashUtils
|
||||
import com.topjohnwu.magisk.view.Notifications
|
||||
import com.topjohnwu.magisk.databinding.MergeObservableList
|
||||
import com.topjohnwu.magisk.databinding.RvItem
|
||||
import com.topjohnwu.magisk.databinding.bindExtra
|
||||
import com.topjohnwu.magisk.databinding.diffList
|
||||
import com.topjohnwu.magisk.databinding.set
|
||||
import com.topjohnwu.magisk.dialog.LocalModuleInstallDialog
|
||||
import com.topjohnwu.magisk.dialog.OnlineModuleInstallDialog
|
||||
import com.topjohnwu.magisk.events.GetContentEvent
|
||||
import com.topjohnwu.magisk.events.SnackbarEvent
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.parcelize.Parcelize
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.withContext
|
||||
import com.topjohnwu.magisk.ui.navigation.Route
|
||||
import kotlinx.parcelize.Parcelize
|
||||
import com.topjohnwu.magisk.core.R as CoreR
|
||||
|
||||
class ModuleItem(val module: LocalModule) {
|
||||
val showNotice: Boolean
|
||||
val showAction: Boolean
|
||||
val noticeText: String
|
||||
|
||||
init {
|
||||
val isZygisk = module.isZygisk
|
||||
val isRiru = module.isRiru
|
||||
val zygiskUnloaded = isZygisk && module.zygiskUnloaded
|
||||
|
||||
showNotice = zygiskUnloaded ||
|
||||
(Info.isZygiskEnabled && isRiru) ||
|
||||
(!Info.isZygiskEnabled && isZygisk)
|
||||
showAction = module.hasAction && !showNotice
|
||||
noticeText = when {
|
||||
zygiskUnloaded -> "Zygisk module not loaded due to incompatibility"
|
||||
isRiru -> "Module suspended because Zygisk is enabled"
|
||||
else -> "Module suspended because Zygisk isn't enabled"
|
||||
}
|
||||
}
|
||||
|
||||
var isEnabled by mutableStateOf(module.enable)
|
||||
var isRemoved by mutableStateOf(module.remove)
|
||||
var showUpdate by mutableStateOf(module.updateInfo != null)
|
||||
val isUpdated = module.updated
|
||||
val updateReady get() = module.outdated && !isRemoved && isEnabled
|
||||
}
|
||||
|
||||
@Parcelize
|
||||
class OnlineModuleSubject(
|
||||
override val module: OnlineModule,
|
||||
override val autoLaunch: Boolean,
|
||||
override val notifyId: Int = Notifications.nextId()
|
||||
) : Subject.Module() {
|
||||
override fun pendingIntent(context: Context) = FlashUtils.installIntent(context, file)
|
||||
}
|
||||
|
||||
class ModuleViewModel : AsyncLoadViewModel() {
|
||||
|
||||
data class UiState(
|
||||
val loading: Boolean = true,
|
||||
val modules: List<ModuleItem> = emptyList(),
|
||||
)
|
||||
val bottomBarBarrierIds = intArrayOf(R.id.module_update, R.id.module_remove)
|
||||
|
||||
private val _uiState = MutableStateFlow(UiState())
|
||||
val uiState: StateFlow<UiState> = _uiState.asStateFlow()
|
||||
private val itemsInstalled = diffList<LocalModuleRvItem>()
|
||||
|
||||
val items = MergeObservableList<RvItem>()
|
||||
val extraBindings = bindExtra {
|
||||
it.put(BR.viewModel, this)
|
||||
}
|
||||
|
||||
val data get() = uri
|
||||
|
||||
@get:Bindable
|
||||
var loading = true
|
||||
private set(value) = set(value, field, { field = it }, BR.loading)
|
||||
|
||||
override suspend fun doLoadWork() {
|
||||
_uiState.update { it.copy(loading = true) }
|
||||
loading = true
|
||||
val moduleLoaded = Info.env.isActive &&
|
||||
withContext(Dispatchers.IO) { LocalModule.loaded() }
|
||||
withContext(Dispatchers.IO) { LocalModule.loaded() }
|
||||
if (moduleLoaded) {
|
||||
val modules = withContext(Dispatchers.Default) {
|
||||
LocalModule.installed().map { ModuleItem(it) }
|
||||
loadInstalled()
|
||||
if (items.isEmpty()) {
|
||||
items.insertItem(InstallModule)
|
||||
.insertList(itemsInstalled)
|
||||
}
|
||||
_uiState.update { it.copy(loading = false, modules = modules) }
|
||||
loadUpdateInfo()
|
||||
} else {
|
||||
_uiState.update { it.copy(loading = false) }
|
||||
}
|
||||
loading = false
|
||||
loadUpdateInfo()
|
||||
}
|
||||
|
||||
private val networkObserver: (Boolean) -> Unit = { startLoading() }
|
||||
override fun onNetworkChanged(network: Boolean) = startLoading()
|
||||
|
||||
init {
|
||||
Info.isConnected.observeForever(networkObserver)
|
||||
}
|
||||
|
||||
override fun onCleared() {
|
||||
super.onCleared()
|
||||
Info.isConnected.removeObserver(networkObserver)
|
||||
private suspend fun loadInstalled() {
|
||||
withContext(Dispatchers.Default) {
|
||||
val installed = LocalModule.installed().map { LocalModuleRvItem(it) }
|
||||
itemsInstalled.update(installed)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun loadUpdateInfo() {
|
||||
withContext(Dispatchers.IO) {
|
||||
_uiState.value.modules.forEach { item ->
|
||||
if (item.module.fetch()) {
|
||||
item.showUpdate = item.module.updateInfo != null
|
||||
}
|
||||
itemsInstalled.forEach {
|
||||
if (it.item.fetch())
|
||||
it.fetchedUpdateInfo()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun confirmLocalInstall(uri: Uri) {
|
||||
navigateTo(Route.Flash(Const.Value.FLASH_ZIP, uri.toString()))
|
||||
fun downloadPressed(item: OnlineModule?) =
|
||||
if (item != null && Info.isConnected.value == true) {
|
||||
withExternalRW { OnlineModuleInstallDialog(item).show() }
|
||||
} else {
|
||||
SnackbarEvent(CoreR.string.no_connection).publish()
|
||||
}
|
||||
|
||||
fun installPressed() = withExternalRW {
|
||||
GetContentEvent("application/zip", UriCallback()).publish()
|
||||
}
|
||||
|
||||
fun requestInstallLocalModule(uri: Uri, displayName: String) {
|
||||
LocalModuleInstallDialog(this, uri, displayName).show()
|
||||
}
|
||||
|
||||
@Parcelize
|
||||
class UriCallback : ContentResultCallback {
|
||||
override fun onActivityResult(result: Uri) {
|
||||
uri.value = result
|
||||
}
|
||||
}
|
||||
|
||||
fun runAction(id: String, name: String) {
|
||||
navigateTo(Route.Action(id, name))
|
||||
MainDirections.actionActionFragment(id, name).navigate()
|
||||
}
|
||||
|
||||
fun toggleEnabled(item: ModuleItem) {
|
||||
item.isEnabled = !item.isEnabled
|
||||
item.module.enable = item.isEnabled
|
||||
}
|
||||
|
||||
fun toggleRemove(item: ModuleItem) {
|
||||
item.isRemoved = !item.isRemoved
|
||||
item.module.remove = item.isRemoved
|
||||
companion object {
|
||||
private val uri = MutableLiveData<Uri?>()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
package com.topjohnwu.magisk.ui.navigation
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import com.topjohnwu.magisk.arch.BaseViewModel
|
||||
|
||||
@Composable
|
||||
fun CollectNavEvents(viewModel: BaseViewModel, navigator: Navigator) {
|
||||
LaunchedEffect(viewModel) {
|
||||
viewModel.navEvents.collect { route ->
|
||||
navigator.push(route)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,72 +0,0 @@
|
||||
package com.topjohnwu.magisk.ui.navigation
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.mutableStateListOf
|
||||
import androidx.compose.runtime.saveable.Saver
|
||||
import androidx.compose.runtime.saveable.listSaver
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.snapshots.SnapshotStateList
|
||||
import androidx.compose.runtime.staticCompositionLocalOf
|
||||
import androidx.navigation3.runtime.NavKey
|
||||
|
||||
class Navigator(initialKey: NavKey) {
|
||||
val backStack: SnapshotStateList<NavKey> = mutableStateListOf(initialKey)
|
||||
|
||||
fun push(key: NavKey) {
|
||||
backStack.add(key)
|
||||
}
|
||||
|
||||
fun replace(key: NavKey) {
|
||||
if (backStack.isNotEmpty()) {
|
||||
backStack[backStack.lastIndex] = key
|
||||
} else {
|
||||
backStack.add(key)
|
||||
}
|
||||
}
|
||||
|
||||
fun replaceAll(keys: List<NavKey>) {
|
||||
if (keys.isEmpty()) return
|
||||
if (backStack.isNotEmpty()) {
|
||||
backStack.clear()
|
||||
backStack.addAll(keys)
|
||||
}
|
||||
}
|
||||
|
||||
fun pop() {
|
||||
backStack.removeLastOrNull()
|
||||
}
|
||||
|
||||
fun popUntil(predicate: (NavKey) -> Boolean) {
|
||||
while (backStack.isNotEmpty() && !predicate(backStack.last())) {
|
||||
backStack.removeAt(backStack.lastIndex)
|
||||
}
|
||||
}
|
||||
|
||||
fun current(): NavKey? = backStack.lastOrNull()
|
||||
|
||||
fun backStackSize(): Int = backStack.size
|
||||
|
||||
companion object {
|
||||
val Saver: Saver<Navigator, Any> = listSaver(
|
||||
save = { navigator -> navigator.backStack.toList() },
|
||||
restore = { savedList ->
|
||||
val initialKey = savedList.firstOrNull() ?: Route.Main
|
||||
Navigator(initialKey).also {
|
||||
it.backStack.clear()
|
||||
it.backStack.addAll(savedList)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun rememberNavigator(startRoute: NavKey): Navigator {
|
||||
return rememberSaveable(startRoute, saver = Navigator.Saver) {
|
||||
Navigator(startRoute)
|
||||
}
|
||||
}
|
||||
|
||||
val LocalNavigator = staticCompositionLocalOf<Navigator> {
|
||||
error("LocalNavigator not provided")
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
package com.topjohnwu.magisk.ui.navigation
|
||||
|
||||
import android.net.Uri
|
||||
import android.os.Parcelable
|
||||
import androidx.navigation3.runtime.NavKey
|
||||
import kotlinx.parcelize.Parcelize
|
||||
import kotlinx.serialization.Contextual
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
sealed interface Route : NavKey, Parcelable {
|
||||
@Parcelize
|
||||
@Serializable
|
||||
data object Main : Route
|
||||
|
||||
@Parcelize
|
||||
@Serializable
|
||||
data object DenyList : Route
|
||||
|
||||
@Parcelize
|
||||
@Serializable
|
||||
data class Flash(
|
||||
val action: String,
|
||||
val additionalData: String? = null,
|
||||
) : Route
|
||||
|
||||
@Parcelize
|
||||
@Serializable
|
||||
data class SuperuserDetail(val uid: Int) : Route
|
||||
|
||||
@Parcelize
|
||||
@Serializable
|
||||
data class Action(
|
||||
val id: String,
|
||||
val name: String,
|
||||
) : Route
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package com.topjohnwu.magisk.ui.settings
|
||||
|
||||
import android.os.Bundle
|
||||
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 com.topjohnwu.magisk.core.R as CoreR
|
||||
|
||||
class SettingsFragment : BaseFragment<FragmentSettingsMd2Binding>() {
|
||||
|
||||
override val layoutRes = R.layout.fragment_settings_md2
|
||||
override val viewModel by viewModel<SettingsViewModel>()
|
||||
override val snackbarView: View get() = binding.snackbarContainer
|
||||
|
||||
override fun onStart() {
|
||||
super.onStart()
|
||||
|
||||
activity?.title = resources.getString(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 onResume() {
|
||||
super.onResume()
|
||||
viewModel.items.forEach { it.refresh() }
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,333 @@
|
||||
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
|
||||
}
|
||||
@@ -1,625 +0,0 @@
|
||||
package com.topjohnwu.magisk.ui.settings
|
||||
|
||||
import android.os.Build
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
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.layout.width
|
||||
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.rememberCoroutineScope
|
||||
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.input.nestedscroll.nestedScroll
|
||||
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 com.topjohnwu.magisk.ui.component.rememberLoadingDialog
|
||||
import kotlinx.coroutines.launch
|
||||
import top.yukonga.miuix.kmp.basic.ButtonDefaults
|
||||
import top.yukonga.miuix.kmp.basic.Card
|
||||
import top.yukonga.miuix.kmp.basic.MiuixScrollBehavior
|
||||
import top.yukonga.miuix.kmp.basic.Scaffold
|
||||
import top.yukonga.miuix.kmp.basic.SmallTitle
|
||||
import top.yukonga.miuix.kmp.basic.TopAppBar
|
||||
import top.yukonga.miuix.kmp.basic.TextField
|
||||
import top.yukonga.miuix.kmp.basic.TextButton
|
||||
import com.topjohnwu.magisk.ui.theme.ThemeState
|
||||
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 top.yukonga.miuix.kmp.theme.MiuixTheme
|
||||
import com.topjohnwu.magisk.core.R as CoreR
|
||||
|
||||
@Composable
|
||||
fun SettingsScreen(viewModel: SettingsViewModel) {
|
||||
val scrollBehavior = MiuixScrollBehavior()
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = stringResource(CoreR.string.settings),
|
||||
scrollBehavior = scrollBehavior
|
||||
)
|
||||
},
|
||||
popupHost = { }
|
||||
) { padding ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.nestedScroll(scrollBehavior.nestedScrollConnection)
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(padding)
|
||||
.padding(horizontal = 12.dp)
|
||||
.padding(bottom = 88.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()) {
|
||||
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]
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// Color Mode
|
||||
val resources = context.resources
|
||||
val colorModeEntries = remember {
|
||||
resources.getStringArray(CoreR.array.color_mode).toList()
|
||||
}
|
||||
var colorMode by remember { mutableIntStateOf(Config.colorMode) }
|
||||
SuperDropdown(
|
||||
title = stringResource(CoreR.string.settings_color_mode),
|
||||
items = colorModeEntries,
|
||||
selectedIndex = colorMode,
|
||||
onSelectedIndexChange = { index ->
|
||||
colorMode = index
|
||||
Config.colorMode = index
|
||||
ThemeState.colorMode = 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.coerceIn(0, updateChannelEntries.size - 1))
|
||||
}
|
||||
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 ->
|
||||
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 = {
|
||||
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
|
||||
}
|
||||
)
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
// --- 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,
|
||||
title = stringResource(CoreR.string.settings_hide_app_title),
|
||||
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))
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
) {
|
||||
TextButton(
|
||||
text = stringResource(android.R.string.cancel),
|
||||
onClick = onDismiss,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
Spacer(Modifier.width(20.dp))
|
||||
TextButton(
|
||||
text = stringResource(android.R.string.ok),
|
||||
onClick = { if (!isError) onConfirm(appName) },
|
||||
modifier = Modifier.weight(1f),
|
||||
colors = ButtonDefaults.textButtonColorsPrimary()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RestoreDialog(show: Boolean, onDismiss: () -> Unit, onConfirm: () -> Unit) {
|
||||
val showState = rememberSaveable { mutableStateOf(show) }
|
||||
showState.value = show
|
||||
|
||||
SuperDialog(
|
||||
show = showState,
|
||||
title = stringResource(CoreR.string.settings_restore_app_title),
|
||||
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),
|
||||
color = MiuixTheme.colorScheme.onSurface,
|
||||
)
|
||||
Spacer(Modifier.height(16.dp))
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
) {
|
||||
TextButton(
|
||||
text = stringResource(android.R.string.cancel),
|
||||
onClick = onDismiss,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
Spacer(Modifier.width(20.dp))
|
||||
TextButton(
|
||||
text = stringResource(android.R.string.ok),
|
||||
onClick = onConfirm,
|
||||
modifier = Modifier.weight(1f),
|
||||
colors = ButtonDefaults.textButtonColorsPrimary()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,83 +1,132 @@
|
||||
package com.topjohnwu.magisk.ui.settings
|
||||
|
||||
import android.content.Context
|
||||
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.view.Shortcuts
|
||||
import com.topjohnwu.magisk.ui.navigation.Route
|
||||
import com.topjohnwu.superuser.Shell
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
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 kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
class SettingsViewModel : BaseViewModel() {
|
||||
class SettingsViewModel : BaseViewModel(), BaseSettingsItem.Handler {
|
||||
|
||||
private val _denyListEnabled = MutableStateFlow(Config.denyList)
|
||||
val denyListEnabled: StateFlow<Boolean> = _denyListEnabled.asStateFlow()
|
||||
|
||||
val zygiskMismatch get() = Config.zygisk != Info.isZygiskEnabled
|
||||
|
||||
var authenticate: (onSuccess: () -> Unit) -> Unit = { it() }
|
||||
|
||||
fun navigateToDenyList() {
|
||||
navigateTo(Route.DenyList)
|
||||
val items = createItems()
|
||||
val extraBindings = bindExtra {
|
||||
it.put(BR.handler, this)
|
||||
}
|
||||
|
||||
fun requestAddShortcut() {
|
||||
Shortcuts.addHomeIcon(AppContext)
|
||||
}
|
||||
private fun createItems(): List<BaseSettingsItem> {
|
||||
val context = AppContext
|
||||
val hidden = context.packageName != BuildConfig.APP_PACKAGE_NAME
|
||||
|
||||
suspend fun hideApp(context: Context, name: String): Boolean {
|
||||
val success = withContext(Dispatchers.IO) {
|
||||
AppMigration.patchAndHide(context, 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)
|
||||
}
|
||||
if (!success) {
|
||||
context.toast(R.string.failure, Toast.LENGTH_LONG)
|
||||
|
||||
// Magisk
|
||||
if (Info.env.isActive) {
|
||||
list.addAll(listOf(
|
||||
Magisk,
|
||||
SystemlessHosts
|
||||
))
|
||||
if (Const.Version.atLeast_24_0()) {
|
||||
list.addAll(listOf(Zygisk, DenyList, DenyListConfig))
|
||||
}
|
||||
}
|
||||
return success
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
suspend fun restoreApp(context: Context): Boolean {
|
||||
val success = AppMigration.restoreApp(context)
|
||||
if (!success) {
|
||||
context.toast(R.string.failure, Toast.LENGTH_LONG)
|
||||
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()
|
||||
}
|
||||
return success
|
||||
}
|
||||
|
||||
fun createHosts() {
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
private fun openUrlIfNecessary(view: View) {
|
||||
UpdateChannelUrl.refresh()
|
||||
if (UpdateChannelUrl.isEnabled && UpdateChannelUrl.value.isBlank()) {
|
||||
UpdateChannelUrl.onPressed(view, this)
|
||||
}
|
||||
}
|
||||
|
||||
private 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 withAuth(action: () -> Unit) = authenticate(action)
|
||||
|
||||
fun notifyZygiskChange() {
|
||||
if (zygiskMismatch) showSnackbar(R.string.reboot_apply_change)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
package com.topjohnwu.magisk.ui.superuser
|
||||
|
||||
import android.graphics.drawable.Drawable
|
||||
import androidx.databinding.Bindable
|
||||
import com.topjohnwu.magisk.BR
|
||||
import com.topjohnwu.magisk.R
|
||||
import com.topjohnwu.magisk.core.Config
|
||||
import com.topjohnwu.magisk.core.model.su.SuPolicy
|
||||
import com.topjohnwu.magisk.databinding.DiffItem
|
||||
import com.topjohnwu.magisk.databinding.ItemWrapper
|
||||
import com.topjohnwu.magisk.databinding.ObservableRvItem
|
||||
import com.topjohnwu.magisk.databinding.set
|
||||
import com.topjohnwu.magisk.core.R as CoreR
|
||||
|
||||
class PolicyRvItem(
|
||||
private val viewModel: SuperuserViewModel,
|
||||
override val item: SuPolicy,
|
||||
val packageName: String,
|
||||
private val isSharedUid: Boolean,
|
||||
val icon: Drawable,
|
||||
val appName: String
|
||||
) : ObservableRvItem(), DiffItem<PolicyRvItem>, ItemWrapper<SuPolicy> {
|
||||
|
||||
override val layoutRes = R.layout.item_policy_md2
|
||||
|
||||
val title get() = if (isSharedUid) "[SharedUID] $appName" else appName
|
||||
|
||||
private inline fun <reified T> setImpl(new: T, old: T, setter: (T) -> Unit) {
|
||||
if (old != new) {
|
||||
setter(new)
|
||||
}
|
||||
}
|
||||
|
||||
@get:Bindable
|
||||
var isExpanded = false
|
||||
set(value) = set(value, field, { field = it }, BR.expanded)
|
||||
|
||||
val showSlider = Config.suRestrict || item.policy == SuPolicy.RESTRICT
|
||||
|
||||
@get:Bindable
|
||||
var isEnabled
|
||||
get() = item.policy >= SuPolicy.ALLOW
|
||||
set(value) = setImpl(value, isEnabled) {
|
||||
notifyPropertyChanged(BR.enabled)
|
||||
viewModel.updatePolicy(this, if (it) SuPolicy.ALLOW else SuPolicy.DENY)
|
||||
}
|
||||
|
||||
@get:Bindable
|
||||
var sliderValue
|
||||
get() = item.policy
|
||||
set(value) = setImpl(value, sliderValue) {
|
||||
notifyPropertyChanged(BR.sliderValue)
|
||||
notifyPropertyChanged(BR.enabled)
|
||||
viewModel.updatePolicy(this, it)
|
||||
}
|
||||
|
||||
val sliderValueToPolicyString: (Float) -> Int = { value ->
|
||||
when (value.toInt()) {
|
||||
1 -> CoreR.string.deny
|
||||
2 -> CoreR.string.restrict
|
||||
3 -> CoreR.string.grant
|
||||
else -> CoreR.string.deny
|
||||
}
|
||||
}
|
||||
|
||||
@get:Bindable
|
||||
var shouldNotify
|
||||
get() = item.notification
|
||||
private set(value) = setImpl(value, shouldNotify) {
|
||||
item.notification = it
|
||||
viewModel.updateNotify(this)
|
||||
}
|
||||
|
||||
@get:Bindable
|
||||
var shouldLog
|
||||
get() = item.logging
|
||||
private set(value) = setImpl(value, shouldLog) {
|
||||
item.logging = it
|
||||
viewModel.updateLogging(this)
|
||||
}
|
||||
|
||||
fun toggleExpand() {
|
||||
isExpanded = !isExpanded
|
||||
}
|
||||
|
||||
fun toggleNotify() {
|
||||
shouldNotify = !shouldNotify
|
||||
}
|
||||
|
||||
fun toggleLog() {
|
||||
shouldLog = !shouldLog
|
||||
}
|
||||
|
||||
fun revoke() {
|
||||
viewModel.deletePressed(this)
|
||||
}
|
||||
|
||||
override fun itemSameAs(other: PolicyRvItem) = packageName == other.packageName
|
||||
|
||||
override fun contentSameAs(other: PolicyRvItem) = item.policy == other.item.policy
|
||||
|
||||
}
|
||||
@@ -1,221 +0,0 @@
|
||||
package com.topjohnwu.magisk.ui.superuser
|
||||
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
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.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.topjohnwu.magisk.ui.component.ConfirmResult
|
||||
import com.topjohnwu.magisk.ui.component.rememberConfirmDialog
|
||||
import com.topjohnwu.magisk.ui.util.rememberDrawablePainter
|
||||
import kotlinx.coroutines.launch
|
||||
import top.yukonga.miuix.kmp.basic.Card
|
||||
import top.yukonga.miuix.kmp.basic.Icon
|
||||
import top.yukonga.miuix.kmp.basic.IconButton
|
||||
import top.yukonga.miuix.kmp.basic.MiuixScrollBehavior
|
||||
import top.yukonga.miuix.kmp.basic.Scaffold
|
||||
import top.yukonga.miuix.kmp.basic.Switch
|
||||
import top.yukonga.miuix.kmp.basic.Text
|
||||
import top.yukonga.miuix.kmp.basic.TopAppBar
|
||||
import top.yukonga.miuix.kmp.icon.MiuixIcons
|
||||
import top.yukonga.miuix.kmp.icon.extended.Back
|
||||
import top.yukonga.miuix.kmp.theme.MiuixTheme
|
||||
import com.topjohnwu.magisk.core.R as CoreR
|
||||
|
||||
@Composable
|
||||
fun SuperuserDetailScreen(
|
||||
uid: Int,
|
||||
viewModel: SuperuserViewModel,
|
||||
onBack: () -> Unit,
|
||||
) {
|
||||
val uiState by viewModel.uiState.collectAsState()
|
||||
val items = uiState.policies.filter { it.policy.uid == uid }
|
||||
val item = items.firstOrNull()
|
||||
val scrollBehavior = MiuixScrollBehavior()
|
||||
val scope = rememberCoroutineScope()
|
||||
val revokeDialog = rememberConfirmDialog()
|
||||
val revokeTitle = stringResource(CoreR.string.su_revoke_title)
|
||||
val revokeMsg = item?.let { stringResource(CoreR.string.su_revoke_msg, it.appName) } ?: ""
|
||||
|
||||
LaunchedEffect(Unit) { viewModel.refreshSuRestrict() }
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = stringResource(CoreR.string.superuser_setting),
|
||||
navigationIcon = {
|
||||
IconButton(
|
||||
modifier = Modifier.padding(start = 16.dp),
|
||||
onClick = onBack
|
||||
) {
|
||||
Icon(
|
||||
imageVector = MiuixIcons.Back,
|
||||
contentDescription = stringResource(CoreR.string.back),
|
||||
)
|
||||
}
|
||||
},
|
||||
scrollBehavior = scrollBehavior
|
||||
)
|
||||
},
|
||||
popupHost = { }
|
||||
) { padding ->
|
||||
if (item == null) return@Scaffold
|
||||
|
||||
LazyColumn(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.nestedScroll(scrollBehavior.nestedScrollConnection)
|
||||
.padding(padding)
|
||||
.padding(horizontal = 12.dp),
|
||||
contentPadding = PaddingValues(bottom = 88.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
item {
|
||||
Spacer(Modifier.height(4.dp))
|
||||
}
|
||||
|
||||
item {
|
||||
Card(modifier = Modifier.fillMaxWidth()) {
|
||||
Row(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Image(
|
||||
painter = rememberDrawablePainter(item.icon),
|
||||
contentDescription = item.appName,
|
||||
modifier = Modifier.size(48.dp)
|
||||
)
|
||||
Spacer(Modifier.width(16.dp))
|
||||
Column {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
text = item.title,
|
||||
style = MiuixTheme.textStyles.headline2,
|
||||
modifier = Modifier.weight(1f, fill = false),
|
||||
)
|
||||
if (item.isSharedUid) {
|
||||
Spacer(Modifier.width(6.dp))
|
||||
SharedUidBadge()
|
||||
}
|
||||
}
|
||||
Text(
|
||||
text = item.packageName,
|
||||
style = MiuixTheme.textStyles.body2,
|
||||
color = MiuixTheme.colorScheme.onSurfaceVariantSummary
|
||||
)
|
||||
Text(
|
||||
text = "UID: ${item.policy.uid}",
|
||||
style = MiuixTheme.textStyles.body2,
|
||||
color = MiuixTheme.colorScheme.onSurfaceVariantSummary
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
item {
|
||||
Card(modifier = Modifier.fillMaxWidth()) {
|
||||
Column {
|
||||
if (uiState.suRestrict || item.isRestricted) {
|
||||
SwitchRow(
|
||||
title = stringResource(CoreR.string.settings_su_restrict_title),
|
||||
checked = item.isRestricted,
|
||||
onCheckedChange = { viewModel.toggleRestrict(item) }
|
||||
)
|
||||
}
|
||||
SwitchRow(
|
||||
title = stringResource(CoreR.string.superuser_toggle_notification),
|
||||
checked = item.notification,
|
||||
onCheckedChange = { viewModel.updateNotify(item) }
|
||||
)
|
||||
SwitchRow(
|
||||
title = stringResource(CoreR.string.logs),
|
||||
checked = item.logging,
|
||||
onCheckedChange = { viewModel.updateLogging(item) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
item {
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable {
|
||||
if (viewModel.requiresAuth) {
|
||||
viewModel.authenticate { viewModel.performDelete(item, onBack) }
|
||||
} else {
|
||||
scope.launch {
|
||||
val result = revokeDialog.awaitConfirm(
|
||||
title = revokeTitle,
|
||||
content = revokeMsg,
|
||||
)
|
||||
if (result == ConfirmResult.Confirmed) {
|
||||
viewModel.performDelete(item, onBack)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
) {
|
||||
RevokeRow()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SwitchRow(
|
||||
title: String,
|
||||
checked: Boolean,
|
||||
onCheckedChange: () -> Unit,
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 14.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text = title,
|
||||
style = MiuixTheme.textStyles.body1,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
Spacer(Modifier.width(16.dp))
|
||||
Switch(
|
||||
checked = checked,
|
||||
onCheckedChange = { onCheckedChange() }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RevokeRow() {
|
||||
Text(
|
||||
text = stringResource(CoreR.string.superuser_toggle_revoke),
|
||||
style = MiuixTheme.textStyles.body1,
|
||||
color = MiuixTheme.colorScheme.error,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 16.dp)
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package com.topjohnwu.magisk.ui.superuser
|
||||
|
||||
import android.os.Bundle
|
||||
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.FragmentSuperuserMd2Binding
|
||||
import rikka.recyclerview.addEdgeSpacing
|
||||
import rikka.recyclerview.addItemSpacing
|
||||
import rikka.recyclerview.fixEdgeEffect
|
||||
import com.topjohnwu.magisk.core.R as CoreR
|
||||
|
||||
class SuperuserFragment : BaseFragment<FragmentSuperuserMd2Binding>() {
|
||||
|
||||
override val layoutRes = R.layout.fragment_superuser_md2
|
||||
override val viewModel by viewModel<SuperuserViewModel>()
|
||||
|
||||
override fun onStart() {
|
||||
super.onStart()
|
||||
activity?.title = resources.getString(CoreR.string.superuser)
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
|
||||
binding.superuserList.apply {
|
||||
addEdgeSpacing(top = R.dimen.l_50, bottom = R.dimen.l1)
|
||||
addItemSpacing(R.dimen.l1, R.dimen.l_50, R.dimen.l1)
|
||||
fixEdgeEffect()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onPreBind(binding: FragmentSuperuserMd2Binding) {}
|
||||
|
||||
}
|
||||
@@ -1,195 +0,0 @@
|
||||
package com.topjohnwu.magisk.ui.superuser
|
||||
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.IntrinsicSize
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
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.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.topjohnwu.magisk.ui.navigation.LocalNavigator
|
||||
import com.topjohnwu.magisk.ui.navigation.Route
|
||||
import com.topjohnwu.magisk.ui.util.rememberDrawablePainter
|
||||
import top.yukonga.miuix.kmp.basic.Card
|
||||
import top.yukonga.miuix.kmp.basic.CircularProgressIndicator
|
||||
import top.yukonga.miuix.kmp.basic.MiuixScrollBehavior
|
||||
import top.yukonga.miuix.kmp.basic.Scaffold
|
||||
import top.yukonga.miuix.kmp.basic.Switch
|
||||
import top.yukonga.miuix.kmp.basic.Text
|
||||
import top.yukonga.miuix.kmp.basic.TopAppBar
|
||||
import top.yukonga.miuix.kmp.theme.MiuixTheme
|
||||
import com.topjohnwu.magisk.core.R as CoreR
|
||||
|
||||
@Composable
|
||||
fun SuperuserScreen(viewModel: SuperuserViewModel) {
|
||||
val uiState by viewModel.uiState.collectAsState()
|
||||
val scrollBehavior = MiuixScrollBehavior()
|
||||
val navigator = LocalNavigator.current
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = stringResource(CoreR.string.superuser),
|
||||
scrollBehavior = scrollBehavior
|
||||
)
|
||||
},
|
||||
popupHost = { }
|
||||
) { padding ->
|
||||
if (uiState.loading) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
CircularProgressIndicator()
|
||||
}
|
||||
return@Scaffold
|
||||
}
|
||||
|
||||
if (uiState.policies.isEmpty()) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(CoreR.string.superuser_policy_none),
|
||||
style = MiuixTheme.textStyles.body1,
|
||||
color = MiuixTheme.colorScheme.onSurfaceVariantSummary
|
||||
)
|
||||
}
|
||||
return@Scaffold
|
||||
}
|
||||
|
||||
LazyColumn(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.nestedScroll(scrollBehavior.nestedScrollConnection)
|
||||
.padding(padding)
|
||||
.padding(horizontal = 12.dp),
|
||||
contentPadding = PaddingValues(bottom = 88.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
item { Spacer(Modifier.height(4.dp)) }
|
||||
items(uiState.policies, key = { "${it.policy.uid}_${it.packageName}" }) { item ->
|
||||
PolicyCard(
|
||||
item = item,
|
||||
onToggle = { viewModel.togglePolicy(item) },
|
||||
onDetail = { navigator.push(Route.SuperuserDetail(item.policy.uid)) },
|
||||
)
|
||||
}
|
||||
item { Spacer(Modifier.height(4.dp)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PolicyCard(
|
||||
item: PolicyItem,
|
||||
onToggle: () -> Unit,
|
||||
onDetail: () -> Unit,
|
||||
) {
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.alpha(if (item.isEnabled) 1f else 0.5f)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(IntrinsicSize.Min),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.clickable(onClick = onDetail)
|
||||
.padding(16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Image(
|
||||
painter = rememberDrawablePainter(item.icon),
|
||||
contentDescription = item.appName,
|
||||
modifier = Modifier.size(40.dp)
|
||||
)
|
||||
Spacer(Modifier.width(12.dp))
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
text = item.title,
|
||||
style = MiuixTheme.textStyles.body1,
|
||||
modifier = Modifier.weight(1f, fill = false),
|
||||
)
|
||||
if (item.isSharedUid) {
|
||||
Spacer(Modifier.width(6.dp))
|
||||
SharedUidBadge()
|
||||
}
|
||||
}
|
||||
Text(
|
||||
text = item.packageName,
|
||||
style = MiuixTheme.textStyles.body2,
|
||||
color = MiuixTheme.colorScheme.onSurfaceVariantSummary
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxHeight()
|
||||
.padding(vertical = 12.dp)
|
||||
.width(0.5.dp)
|
||||
.background(MiuixTheme.colorScheme.dividerLine)
|
||||
)
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.clickable(onClick = onToggle)
|
||||
.padding(horizontal = 20.dp, vertical = 16.dp),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Switch(
|
||||
checked = item.isEnabled,
|
||||
onCheckedChange = { onToggle() }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun SharedUidBadge(modifier: Modifier = Modifier) {
|
||||
Text(
|
||||
text = "SharedUID",
|
||||
color = MiuixTheme.colorScheme.onPrimary,
|
||||
fontSize = 10.sp,
|
||||
maxLines = 1,
|
||||
modifier = modifier
|
||||
.background(MiuixTheme.colorScheme.primary, RoundedCornerShape(6.dp))
|
||||
.padding(horizontal = 6.dp, vertical = 2.dp)
|
||||
)
|
||||
}
|
||||
@@ -3,13 +3,11 @@ package com.topjohnwu.magisk.ui.superuser
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.pm.PackageManager
|
||||
import android.content.pm.PackageManager.MATCH_UNINSTALLED_PACKAGES
|
||||
import android.graphics.drawable.Drawable
|
||||
import android.os.Process
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.databinding.Bindable
|
||||
import androidx.databinding.ObservableArrayList
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.topjohnwu.magisk.BR
|
||||
import com.topjohnwu.magisk.arch.AsyncLoadViewModel
|
||||
import com.topjohnwu.magisk.core.AppContext
|
||||
import com.topjohnwu.magisk.core.Config
|
||||
@@ -18,69 +16,52 @@ import com.topjohnwu.magisk.core.R
|
||||
import com.topjohnwu.magisk.core.data.magiskdb.PolicyDao
|
||||
import com.topjohnwu.magisk.core.ktx.getLabel
|
||||
import com.topjohnwu.magisk.core.model.su.SuPolicy
|
||||
import com.topjohnwu.magisk.core.su.SuEvents
|
||||
import com.topjohnwu.magisk.databinding.MergeObservableList
|
||||
import com.topjohnwu.magisk.databinding.RvItem
|
||||
import com.topjohnwu.magisk.databinding.bindExtra
|
||||
import com.topjohnwu.magisk.databinding.diffList
|
||||
import com.topjohnwu.magisk.databinding.set
|
||||
import com.topjohnwu.magisk.dialog.SuperuserRevokeDialog
|
||||
import com.topjohnwu.magisk.events.AuthEvent
|
||||
import com.topjohnwu.magisk.events.SnackbarEvent
|
||||
import com.topjohnwu.magisk.utils.asText
|
||||
import com.topjohnwu.magisk.view.TextItem
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.debounce
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.util.Locale
|
||||
|
||||
class PolicyItem(
|
||||
val policy: SuPolicy,
|
||||
val packageName: String,
|
||||
val isSharedUid: Boolean,
|
||||
val icon: Drawable,
|
||||
val appName: String,
|
||||
) {
|
||||
val title get() = appName
|
||||
val showSlider = Config.suRestrict || policy.policy == SuPolicy.RESTRICT
|
||||
|
||||
var isExpanded by mutableStateOf(false)
|
||||
var policyValue by mutableIntStateOf(policy.policy)
|
||||
var notification by mutableStateOf(policy.notification)
|
||||
var logging by mutableStateOf(policy.logging)
|
||||
|
||||
val isEnabled get() = policyValue >= SuPolicy.ALLOW
|
||||
val isRestricted get() = policyValue == SuPolicy.RESTRICT
|
||||
}
|
||||
|
||||
class SuperuserViewModel(
|
||||
private val db: PolicyDao
|
||||
) : AsyncLoadViewModel() {
|
||||
|
||||
var authenticate: (onSuccess: () -> Unit) -> Unit = { it() }
|
||||
private val itemNoData = TextItem(R.string.superuser_policy_none)
|
||||
|
||||
init {
|
||||
@OptIn(kotlinx.coroutines.FlowPreview::class)
|
||||
viewModelScope.launch {
|
||||
SuEvents.policyChanged.debounce(500).collect { reload() }
|
||||
}
|
||||
private val itemsHelpers = ObservableArrayList<TextItem>()
|
||||
private val itemsPolicies = diffList<PolicyRvItem>()
|
||||
|
||||
val items = MergeObservableList<RvItem>()
|
||||
.insertList(itemsHelpers)
|
||||
.insertList(itemsPolicies)
|
||||
val extraBindings = bindExtra {
|
||||
it.put(BR.listener, this)
|
||||
}
|
||||
|
||||
data class UiState(
|
||||
val loading: Boolean = true,
|
||||
val policies: List<PolicyItem> = emptyList(),
|
||||
val suRestrict: Boolean = Config.suRestrict,
|
||||
)
|
||||
|
||||
private val _uiState = MutableStateFlow(UiState())
|
||||
val uiState: StateFlow<UiState> = _uiState.asStateFlow()
|
||||
@get:Bindable
|
||||
var loading = true
|
||||
private set(value) = set(value, field, { field = it }, BR.loading)
|
||||
|
||||
@SuppressLint("InlinedApi")
|
||||
override suspend fun doLoadWork() {
|
||||
if (!Info.showSuperUser) {
|
||||
_uiState.update { it.copy(loading = false) }
|
||||
loading = false
|
||||
return
|
||||
}
|
||||
_uiState.update { it.copy(loading = true) }
|
||||
loading = true
|
||||
withContext(Dispatchers.IO) {
|
||||
db.deleteOutdated()
|
||||
db.delete(AppContext.applicationInfo.uid)
|
||||
val policies = ArrayList<PolicyItem>()
|
||||
val policies = ArrayList<PolicyRvItem>()
|
||||
val pm = AppContext.packageManager
|
||||
for (policy in db.fetchAll()) {
|
||||
val pkgs =
|
||||
@@ -93,14 +74,14 @@ class SuperuserViewModel(
|
||||
val map = pkgs.mapNotNull { pkg ->
|
||||
try {
|
||||
val info = pm.getPackageInfo(pkg, MATCH_UNINSTALLED_PACKAGES)
|
||||
PolicyItem(
|
||||
policy = policy,
|
||||
packageName = info.packageName,
|
||||
isSharedUid = info.sharedUserId != null,
|
||||
icon = info.applicationInfo?.loadIcon(pm) ?: pm.defaultActivityIcon,
|
||||
appName = info.applicationInfo?.getLabel(pm) ?: info.packageName
|
||||
PolicyRvItem(
|
||||
this@SuperuserViewModel, policy,
|
||||
info.packageName,
|
||||
info.sharedUserId != null,
|
||||
info.applicationInfo?.loadIcon(pm) ?: pm.defaultActivityIcon,
|
||||
info.applicationInfo?.getLabel(pm) ?: info.packageName
|
||||
)
|
||||
} catch (_: PackageManager.NameNotFoundException) {
|
||||
} catch (e: PackageManager.NameNotFoundException) {
|
||||
null
|
||||
}
|
||||
}
|
||||
@@ -114,80 +95,86 @@ class SuperuserViewModel(
|
||||
{ it.appName.lowercase(Locale.ROOT) },
|
||||
{ it.packageName }
|
||||
))
|
||||
_uiState.update { it.copy(loading = false, policies = policies, suRestrict = Config.suRestrict) }
|
||||
itemsPolicies.update(policies)
|
||||
}
|
||||
if (itemsPolicies.isNotEmpty())
|
||||
itemsHelpers.clear()
|
||||
else if (itemsHelpers.isEmpty())
|
||||
itemsHelpers.add(itemNoData)
|
||||
loading = false
|
||||
}
|
||||
|
||||
fun refreshSuRestrict() {
|
||||
_uiState.update { it.copy(suRestrict = Config.suRestrict) }
|
||||
}
|
||||
// ---
|
||||
|
||||
val requiresAuth get() = Config.suAuth
|
||||
|
||||
fun performDelete(item: PolicyItem, onDeleted: () -> Unit = {}) {
|
||||
viewModelScope.launch {
|
||||
db.delete(item.policy.uid)
|
||||
_uiState.update { state ->
|
||||
state.copy(policies = state.policies.filter { it.policy.uid != item.policy.uid })
|
||||
}
|
||||
onDeleted()
|
||||
}
|
||||
}
|
||||
|
||||
fun updateNotify(item: PolicyItem) {
|
||||
item.notification = !item.notification
|
||||
item.policy.notification = item.notification
|
||||
viewModelScope.launch {
|
||||
db.update(item.policy)
|
||||
_uiState.value.policies
|
||||
.filter { it.policy.uid == item.policy.uid }
|
||||
.forEach { it.notification = item.notification }
|
||||
val res = if (item.notification) R.string.su_snack_notif_on else R.string.su_snack_notif_off
|
||||
showSnackbar(AppContext.getString(res, item.appName))
|
||||
}
|
||||
}
|
||||
|
||||
fun updateLogging(item: PolicyItem) {
|
||||
item.logging = !item.logging
|
||||
item.policy.logging = item.logging
|
||||
viewModelScope.launch {
|
||||
db.update(item.policy)
|
||||
_uiState.value.policies
|
||||
.filter { it.policy.uid == item.policy.uid }
|
||||
.forEach { it.logging = item.logging }
|
||||
val res = if (item.logging) R.string.su_snack_log_on else R.string.su_snack_log_off
|
||||
showSnackbar(AppContext.getString(res, item.appName))
|
||||
}
|
||||
}
|
||||
|
||||
fun updatePolicy(item: PolicyItem, newPolicy: Int) {
|
||||
fun updateState() {
|
||||
viewModelScope.launch {
|
||||
item.policy.policy = newPolicy
|
||||
item.policyValue = newPolicy
|
||||
db.update(item.policy)
|
||||
_uiState.value.policies
|
||||
.filter { it.policy.uid == item.policy.uid }
|
||||
.forEach { it.policyValue = newPolicy }
|
||||
val res = if (newPolicy >= SuPolicy.ALLOW) R.string.su_snack_grant else R.string.su_snack_deny
|
||||
showSnackbar(AppContext.getString(res, item.appName))
|
||||
fun deletePressed(item: PolicyRvItem) {
|
||||
fun updateState() = viewModelScope.launch {
|
||||
db.delete(item.item.uid)
|
||||
val list = ArrayList(itemsPolicies)
|
||||
list.removeAll { it.item.uid == item.item.uid }
|
||||
itemsPolicies.update(list)
|
||||
if (list.isEmpty() && itemsHelpers.isEmpty()) {
|
||||
itemsHelpers.add(itemNoData)
|
||||
}
|
||||
}
|
||||
|
||||
if (Config.suAuth) {
|
||||
authenticate { updateState() }
|
||||
AuthEvent { updateState() }.publish()
|
||||
} else {
|
||||
SuperuserRevokeDialog(item.title) { updateState() }.show()
|
||||
}
|
||||
}
|
||||
|
||||
fun updateNotify(item: PolicyRvItem) {
|
||||
viewModelScope.launch {
|
||||
db.update(item.item)
|
||||
val res = when {
|
||||
item.item.notification -> R.string.su_snack_notif_on
|
||||
else -> R.string.su_snack_notif_off
|
||||
}
|
||||
itemsPolicies.forEach {
|
||||
if (it.item.uid == item.item.uid) {
|
||||
it.notifyPropertyChanged(BR.shouldNotify)
|
||||
}
|
||||
}
|
||||
SnackbarEvent(res.asText(item.appName)).publish()
|
||||
}
|
||||
}
|
||||
|
||||
fun updateLogging(item: PolicyRvItem) {
|
||||
viewModelScope.launch {
|
||||
db.update(item.item)
|
||||
val res = when {
|
||||
item.item.logging -> R.string.su_snack_log_on
|
||||
else -> R.string.su_snack_log_off
|
||||
}
|
||||
itemsPolicies.forEach {
|
||||
if (it.item.uid == item.item.uid) {
|
||||
it.notifyPropertyChanged(BR.shouldLog)
|
||||
}
|
||||
}
|
||||
SnackbarEvent(res.asText(item.appName)).publish()
|
||||
}
|
||||
}
|
||||
|
||||
fun updatePolicy(item: PolicyRvItem, policy: Int) {
|
||||
val items = itemsPolicies.filter { it.item.uid == item.item.uid }
|
||||
fun updateState() {
|
||||
viewModelScope.launch {
|
||||
val res = if (policy >= SuPolicy.ALLOW) R.string.su_snack_grant else R.string.su_snack_deny
|
||||
item.item.policy = policy
|
||||
db.update(item.item)
|
||||
items.forEach {
|
||||
it.notifyPropertyChanged(BR.enabled)
|
||||
it.notifyPropertyChanged(BR.sliderValue)
|
||||
}
|
||||
SnackbarEvent(res.asText(item.appName)).publish()
|
||||
}
|
||||
}
|
||||
|
||||
if (Config.suAuth) {
|
||||
AuthEvent { updateState() }.publish()
|
||||
} else {
|
||||
updateState()
|
||||
}
|
||||
}
|
||||
|
||||
fun togglePolicy(item: PolicyItem) {
|
||||
val newPolicy = if (item.isEnabled) SuPolicy.DENY else SuPolicy.ALLOW
|
||||
updatePolicy(item, newPolicy)
|
||||
}
|
||||
|
||||
fun toggleRestrict(item: PolicyItem) {
|
||||
val newPolicy = if (item.isRestricted) SuPolicy.ALLOW else SuPolicy.RESTRICT
|
||||
updatePolicy(item, newPolicy)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,58 +1,31 @@
|
||||
package com.topjohnwu.magisk.ui.surequest
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.ActivityInfo
|
||||
import android.content.res.Resources
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.view.Window
|
||||
import android.view.WindowManager
|
||||
import android.view.accessibility.AccessibilityEvent
|
||||
import android.view.accessibility.AccessibilityNodeInfo
|
||||
import android.view.accessibility.AccessibilityNodeProvider
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.appcompat.app.AppCompatDelegate
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import com.topjohnwu.magisk.R
|
||||
import com.topjohnwu.magisk.arch.VMFactory
|
||||
import com.topjohnwu.magisk.core.Config
|
||||
import com.topjohnwu.magisk.core.base.ActivityExtension
|
||||
import com.topjohnwu.magisk.arch.UIActivity
|
||||
import com.topjohnwu.magisk.arch.viewModel
|
||||
import com.topjohnwu.magisk.core.base.UntrackedActivity
|
||||
import com.topjohnwu.magisk.core.su.SuCallbackHandler
|
||||
import com.topjohnwu.magisk.core.su.SuCallbackHandler.REQUEST
|
||||
import com.topjohnwu.magisk.core.wrap
|
||||
import com.topjohnwu.magisk.ui.theme.MagiskTheme
|
||||
import com.topjohnwu.magisk.databinding.ActivityRequestBinding
|
||||
import com.topjohnwu.magisk.ui.theme.Theme
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import top.yukonga.miuix.kmp.utils.MiuixPopupUtils.Companion.MiuixPopupHost
|
||||
|
||||
open class SuRequestActivity : AppCompatActivity(), UntrackedActivity {
|
||||
open class SuRequestActivity : UIActivity<ActivityRequestBinding>(), UntrackedActivity {
|
||||
|
||||
private val extension = ActivityExtension(this)
|
||||
private val viewModel: SuRequestViewModel by lazy {
|
||||
ViewModelProvider(this, VMFactory)[SuRequestViewModel::class.java]
|
||||
}
|
||||
|
||||
init {
|
||||
AppCompatDelegate.setDefaultNightMode(Config.darkTheme)
|
||||
}
|
||||
|
||||
override fun attachBaseContext(base: Context) {
|
||||
super.attachBaseContext(base.wrap())
|
||||
}
|
||||
override val layoutRes: Int = R.layout.activity_request
|
||||
override val viewModel: SuRequestViewModel by viewModel()
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
extension.onCreate(savedInstanceState)
|
||||
supportRequestWindowFeature(Window.FEATURE_NO_TITLE)
|
||||
requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_LOCKED
|
||||
window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
|
||||
@@ -63,11 +36,6 @@ open class SuRequestActivity : AppCompatActivity(), UntrackedActivity {
|
||||
setTheme(Theme.selected.themeRes)
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
viewModel.finishActivity = { finish() }
|
||||
viewModel.authenticate = { onSuccess ->
|
||||
extension.withAuthentication { if (it) onSuccess() }
|
||||
}
|
||||
|
||||
if (intent.action == Intent.ACTION_VIEW) {
|
||||
val action = intent.getStringExtra("action")
|
||||
if (action == REQUEST) {
|
||||
@@ -83,24 +51,6 @@ open class SuRequestActivity : AppCompatActivity(), UntrackedActivity {
|
||||
} else {
|
||||
finish()
|
||||
}
|
||||
|
||||
if (viewModel.useTapjackProtection) {
|
||||
window.decorView.rootView.accessibilityDelegate = EmptyAccessibilityDelegate
|
||||
}
|
||||
|
||||
setContent {
|
||||
MagiskTheme {
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
SuRequestScreen(viewModel = viewModel)
|
||||
MiuixPopupHost()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onSaveInstanceState(outState: Bundle) {
|
||||
super.onSaveInstanceState(outState)
|
||||
extension.onSaveInstanceState(outState)
|
||||
}
|
||||
|
||||
override fun getTheme(): Resources.Theme {
|
||||
@@ -109,7 +59,6 @@ open class SuRequestActivity : AppCompatActivity(), UntrackedActivity {
|
||||
return theme
|
||||
}
|
||||
|
||||
@Deprecated("Use OnBackPressedDispatcher")
|
||||
override fun onBackPressed() {
|
||||
viewModel.denyPressed()
|
||||
}
|
||||
@@ -117,17 +66,4 @@ open class SuRequestActivity : AppCompatActivity(), UntrackedActivity {
|
||||
override fun finish() {
|
||||
super.finishAndRemoveTask()
|
||||
}
|
||||
|
||||
private object EmptyAccessibilityDelegate : View.AccessibilityDelegate() {
|
||||
override fun sendAccessibilityEvent(host: View, eventType: Int) {}
|
||||
override fun performAccessibilityAction(host: View, action: Int, args: Bundle?) = true
|
||||
override fun sendAccessibilityEventUnchecked(host: View, event: AccessibilityEvent) {}
|
||||
override fun dispatchPopulateAccessibilityEvent(host: View, event: AccessibilityEvent) = true
|
||||
override fun onPopulateAccessibilityEvent(host: View, event: AccessibilityEvent) {}
|
||||
override fun onInitializeAccessibilityEvent(host: View, event: AccessibilityEvent) {}
|
||||
override fun onInitializeAccessibilityNodeInfo(host: View, info: AccessibilityNodeInfo) {}
|
||||
override fun addExtraDataToAccessibilityNodeInfo(host: View, info: AccessibilityNodeInfo, extraDataKey: String, arguments: Bundle?) {}
|
||||
override fun onRequestSendAccessibilityEvent(host: ViewGroup, child: View, event: AccessibilityEvent): Boolean = false
|
||||
override fun getAccessibilityNodeProvider(host: View): AccessibilityNodeProvider? = null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,208 +0,0 @@
|
||||
package com.topjohnwu.magisk.ui.surequest
|
||||
|
||||
import android.view.MotionEvent
|
||||
import android.widget.Toast
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
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.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.ExperimentalComposeUiApi
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.input.pointer.pointerInteropFilter
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringArrayResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.topjohnwu.magisk.ui.superuser.SharedUidBadge
|
||||
import com.topjohnwu.magisk.core.ktx.toast
|
||||
import com.topjohnwu.magisk.core.R as CoreR
|
||||
import com.topjohnwu.magisk.ui.util.rememberDrawablePainter
|
||||
import top.yukonga.miuix.kmp.basic.ButtonDefaults
|
||||
import top.yukonga.miuix.kmp.basic.Card
|
||||
import top.yukonga.miuix.kmp.basic.Text
|
||||
import top.yukonga.miuix.kmp.basic.TextButton
|
||||
import top.yukonga.miuix.kmp.basic.Slider
|
||||
import top.yukonga.miuix.kmp.basic.SliderDefaults
|
||||
import top.yukonga.miuix.kmp.theme.MiuixTheme
|
||||
|
||||
@OptIn(ExperimentalComposeUiApi::class)
|
||||
@Composable
|
||||
fun SuRequestScreen(viewModel: SuRequestViewModel) {
|
||||
if (!viewModel.showUi) return
|
||||
|
||||
val context = LocalContext.current
|
||||
val icon = viewModel.icon
|
||||
val title = viewModel.title
|
||||
val packageName = viewModel.packageName
|
||||
val grantEnabled = viewModel.grantEnabled
|
||||
val denyCountdown = viewModel.denyCountdown
|
||||
val selectedPosition = viewModel.selectedItemPosition
|
||||
val timeoutEntries = stringArrayResource(CoreR.array.allow_timeout).toList()
|
||||
// Slider order: Once(1), 10min(2), 20min(3), 30min(4), 60min(5), Forever(0)
|
||||
val sliderToIndex = intArrayOf(1, 2, 3, 4, 5, 0)
|
||||
val indexToSlider = remember {
|
||||
IntArray(sliderToIndex.size).also { arr ->
|
||||
sliderToIndex.forEachIndexed { slider, orig -> arr[orig] = slider }
|
||||
}
|
||||
}
|
||||
val sliderValue = indexToSlider[selectedPosition].toFloat()
|
||||
val sliderLabel by remember(sliderValue) {
|
||||
derivedStateOf { timeoutEntries[sliderToIndex[sliderValue.toInt()]] }
|
||||
}
|
||||
|
||||
val denyText = if (denyCountdown > 0) {
|
||||
"${stringResource(CoreR.string.deny)} ($denyCountdown)"
|
||||
} else {
|
||||
stringResource(CoreR.string.deny)
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.widthIn(min = 320.dp, max = 420.dp)
|
||||
.padding(24.dp)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(20.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.padding(horizontal = 8.dp)
|
||||
) {
|
||||
if (icon != null) {
|
||||
Image(
|
||||
painter = rememberDrawablePainter(icon),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(40.dp)
|
||||
)
|
||||
Spacer(Modifier.width(12.dp))
|
||||
}
|
||||
Column {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
text = title,
|
||||
style = MiuixTheme.textStyles.body1,
|
||||
fontWeight = FontWeight.Bold,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.weight(1f, fill = false),
|
||||
)
|
||||
if (viewModel.isSharedUid) {
|
||||
Spacer(Modifier.width(6.dp))
|
||||
SharedUidBadge()
|
||||
}
|
||||
}
|
||||
Text(
|
||||
text = packageName,
|
||||
style = MiuixTheme.textStyles.body2,
|
||||
color = MiuixTheme.colorScheme.onSurfaceVariantSummary,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(16.dp))
|
||||
|
||||
Text(
|
||||
text = stringResource(CoreR.string.su_request_title),
|
||||
style = MiuixTheme.textStyles.body2,
|
||||
color = MiuixTheme.colorScheme.primary,
|
||||
fontWeight = FontWeight.Bold,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
|
||||
Spacer(Modifier.height(16.dp))
|
||||
|
||||
Text(
|
||||
text = "Permission timeout: $sliderLabel",
|
||||
style = MiuixTheme.textStyles.body2,
|
||||
color = MiuixTheme.colorScheme.onSurfaceVariantSummary,
|
||||
modifier = Modifier.fillMaxWidth().padding(start = 8.dp),
|
||||
)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Slider(
|
||||
value = sliderValue,
|
||||
onValueChange = { value ->
|
||||
viewModel.spinnerTouched()
|
||||
val pos = value.toInt().coerceIn(0, sliderToIndex.lastIndex)
|
||||
viewModel.selectedItemPosition = sliderToIndex[pos]
|
||||
},
|
||||
valueRange = 0f..5f,
|
||||
steps = 4,
|
||||
showKeyPoints = true,
|
||||
height = 20.dp,
|
||||
hapticEffect = SliderDefaults.SliderHapticEffect.Step,
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 4.dp)
|
||||
)
|
||||
Spacer(Modifier.height(16.dp))
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
TextButton(
|
||||
text = denyText,
|
||||
onClick = { viewModel.denyPressed() },
|
||||
modifier = Modifier.weight(1f),
|
||||
cornerRadius = 12.dp,
|
||||
minHeight = 40.dp,
|
||||
insideMargin = PaddingValues(horizontal = 16.dp, vertical = 8.dp),
|
||||
)
|
||||
TextButton(
|
||||
text = stringResource(CoreR.string.grant),
|
||||
enabled = grantEnabled,
|
||||
colors = ButtonDefaults.textButtonColorsPrimary(),
|
||||
onClick = { viewModel.grantPressed() },
|
||||
cornerRadius = 12.dp,
|
||||
minHeight = 40.dp,
|
||||
insideMargin = PaddingValues(horizontal = 16.dp, vertical = 8.dp),
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.then(
|
||||
if (viewModel.useTapjackProtection) {
|
||||
Modifier.pointerInteropFilter { event ->
|
||||
if (event.flags and MotionEvent.FLAG_WINDOW_IS_OBSCURED != 0 ||
|
||||
event.flags and MotionEvent.FLAG_WINDOW_IS_PARTIALLY_OBSCURED != 0
|
||||
) {
|
||||
if (event.action == MotionEvent.ACTION_UP) {
|
||||
context.toast(
|
||||
CoreR.string.touch_filtered_warning,
|
||||
Toast.LENGTH_SHORT
|
||||
)
|
||||
}
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
} else Modifier
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,17 +1,22 @@
|
||||
package com.topjohnwu.magisk.ui.surequest
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Intent
|
||||
import android.content.SharedPreferences
|
||||
import android.content.res.Resources
|
||||
import android.graphics.drawable.Drawable
|
||||
import android.os.Bundle
|
||||
import android.os.CountDownTimer
|
||||
import android.view.MotionEvent
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.view.accessibility.AccessibilityEvent
|
||||
import android.view.accessibility.AccessibilityNodeInfo
|
||||
import android.view.accessibility.AccessibilityNodeProvider
|
||||
import android.widget.Toast
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.databinding.Bindable
|
||||
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.Config
|
||||
@@ -22,6 +27,11 @@ import com.topjohnwu.magisk.core.ktx.toast
|
||||
import com.topjohnwu.magisk.core.model.su.SuPolicy.Companion.ALLOW
|
||||
import com.topjohnwu.magisk.core.model.su.SuPolicy.Companion.DENY
|
||||
import com.topjohnwu.magisk.core.su.SuRequestHandler
|
||||
import com.topjohnwu.magisk.databinding.set
|
||||
import com.topjohnwu.magisk.events.AuthEvent
|
||||
import com.topjohnwu.magisk.events.DieEvent
|
||||
import com.topjohnwu.magisk.events.ShowUIEvent
|
||||
import com.topjohnwu.magisk.utils.TextHolder
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import java.util.concurrent.TimeUnit.SECONDS
|
||||
@@ -31,20 +41,33 @@ class SuRequestViewModel(
|
||||
private val timeoutPrefs: SharedPreferences
|
||||
) : BaseViewModel() {
|
||||
|
||||
var authenticate: (onSuccess: () -> Unit) -> Unit = { it() }
|
||||
var finishActivity: () -> Unit = {}
|
||||
lateinit var icon: Drawable
|
||||
lateinit var title: String
|
||||
lateinit var packageName: String
|
||||
|
||||
var icon by mutableStateOf<Drawable?>(null)
|
||||
var title by mutableStateOf("")
|
||||
var packageName by mutableStateOf("")
|
||||
var isSharedUid by mutableStateOf(false)
|
||||
@get:Bindable
|
||||
val denyText = DenyText()
|
||||
|
||||
var selectedItemPosition by mutableIntStateOf(0)
|
||||
var grantEnabled by mutableStateOf(false)
|
||||
var denyCountdown by mutableIntStateOf(0)
|
||||
@get:Bindable
|
||||
var selectedItemPosition = 0
|
||||
set(value) = set(value, field, { field = it }, BR.selectedItemPosition)
|
||||
|
||||
var showUi by mutableStateOf(false)
|
||||
var useTapjackProtection by mutableStateOf(false)
|
||||
@get:Bindable
|
||||
var grantEnabled = false
|
||||
set(value) = set(value, field, { field = it }, BR.grantEnabled)
|
||||
|
||||
@SuppressLint("ClickableViewAccessibility")
|
||||
val grantTouchListener = View.OnTouchListener { _: View, event: MotionEvent ->
|
||||
// Filter obscured touches by consuming them.
|
||||
if (event.flags and MotionEvent.FLAG_WINDOW_IS_OBSCURED != 0
|
||||
|| event.flags and MotionEvent.FLAG_WINDOW_IS_PARTIALLY_OBSCURED != 0) {
|
||||
if (event.action == MotionEvent.ACTION_UP) {
|
||||
AppContext.toast(R.string.touch_filtered_warning, Toast.LENGTH_SHORT)
|
||||
}
|
||||
return@OnTouchListener Config.suTapjack
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
private val handler = SuRequestHandler(AppContext.packageManager, policyDB)
|
||||
private val millis = SECONDS.toMillis(Config.suDefaultTimeout.toLong())
|
||||
@@ -54,7 +77,7 @@ class SuRequestViewModel(
|
||||
fun grantPressed() {
|
||||
cancelTimer()
|
||||
if (Config.suAuth) {
|
||||
authenticate { respond(ALLOW) }
|
||||
AuthEvent { respond(ALLOW) }.publish()
|
||||
} else {
|
||||
respond(ALLOW)
|
||||
}
|
||||
@@ -64,8 +87,9 @@ class SuRequestViewModel(
|
||||
respond(DENY)
|
||||
}
|
||||
|
||||
fun spinnerTouched() {
|
||||
fun spinnerTouched(): Boolean {
|
||||
cancelTimer()
|
||||
return false
|
||||
}
|
||||
|
||||
fun handleRequest(intent: Intent) {
|
||||
@@ -73,7 +97,7 @@ class SuRequestViewModel(
|
||||
if (handler.start(intent))
|
||||
showDialog()
|
||||
else
|
||||
finishActivity()
|
||||
DieEvent().publish()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -82,26 +106,35 @@ class SuRequestViewModel(
|
||||
val info = handler.pkgInfo
|
||||
val app = info.applicationInfo
|
||||
|
||||
isSharedUid = info.sharedUserId != null
|
||||
if (app == null) {
|
||||
// The request is not coming from an app process, and the UID is a
|
||||
// shared UID. We have no way to know where this request comes from.
|
||||
icon = pm.defaultActivityIcon
|
||||
title = info.sharedUserId.toString()
|
||||
title = "[SharedUID] ${info.sharedUserId}"
|
||||
packageName = info.sharedUserId.toString()
|
||||
} else {
|
||||
val prefix = if (info.sharedUserId == null) "" else "[SharedUID] "
|
||||
icon = app.loadIcon(pm)
|
||||
title = app.getLabel(pm)
|
||||
title = "$prefix${app.getLabel(pm)}"
|
||||
packageName = info.packageName
|
||||
}
|
||||
|
||||
selectedItemPosition = timeoutPrefs.getInt(packageName, 0)
|
||||
|
||||
// Set timer
|
||||
timer.start()
|
||||
useTapjackProtection = Config.suTapjack
|
||||
showUi = true
|
||||
|
||||
// Actually show the UI
|
||||
ShowUIEvent(if (Config.suTapjack) EmptyAccessibilityDelegate else null).publish()
|
||||
initialized = true
|
||||
}
|
||||
|
||||
private fun respond(action: Int) {
|
||||
if (!initialized) return
|
||||
if (!initialized) {
|
||||
// ignore the response until showDialog done
|
||||
return
|
||||
}
|
||||
|
||||
timer.cancel()
|
||||
|
||||
val pos = selectedItemPosition
|
||||
@@ -109,13 +142,14 @@ class SuRequestViewModel(
|
||||
|
||||
viewModelScope.launch {
|
||||
handler.respond(action, Config.Value.TIMEOUT_LIST[pos])
|
||||
finishActivity()
|
||||
// Kill activity after response
|
||||
DieEvent().publish()
|
||||
}
|
||||
}
|
||||
|
||||
private fun cancelTimer() {
|
||||
timer.cancel()
|
||||
denyCountdown = 0
|
||||
denyText.seconds = 0
|
||||
}
|
||||
|
||||
private inner class SuTimer(
|
||||
@@ -127,13 +161,39 @@ class SuRequestViewModel(
|
||||
if (!grantEnabled && remains <= millis - 1000) {
|
||||
grantEnabled = true
|
||||
}
|
||||
denyCountdown = (remains / 1000).toInt() + 1
|
||||
denyText.seconds = (remains / 1000).toInt() + 1
|
||||
}
|
||||
|
||||
override fun onFinish() {
|
||||
denyCountdown = 0
|
||||
denyText.seconds = 0
|
||||
respond(DENY)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
inner class DenyText : TextHolder() {
|
||||
var seconds = 0
|
||||
set(value) = set(value, field, { field = it }, BR.denyText)
|
||||
|
||||
override fun getText(resources: Resources): CharSequence {
|
||||
return if (seconds != 0)
|
||||
"${resources.getString(R.string.deny)} ($seconds)"
|
||||
else
|
||||
resources.getString(R.string.deny)
|
||||
}
|
||||
}
|
||||
|
||||
// Invisible for accessibility services
|
||||
object EmptyAccessibilityDelegate : View.AccessibilityDelegate() {
|
||||
override fun sendAccessibilityEvent(host: View, eventType: Int) {}
|
||||
override fun performAccessibilityAction(host: View, action: Int, args: Bundle?) = true
|
||||
override fun sendAccessibilityEventUnchecked(host: View, event: AccessibilityEvent) {}
|
||||
override fun dispatchPopulateAccessibilityEvent(host: View, event: AccessibilityEvent) = true
|
||||
override fun onPopulateAccessibilityEvent(host: View, event: AccessibilityEvent) {}
|
||||
override fun onInitializeAccessibilityEvent(host: View, event: AccessibilityEvent) {}
|
||||
override fun onInitializeAccessibilityNodeInfo(host: View, info: AccessibilityNodeInfo) {}
|
||||
override fun addExtraDataToAccessibilityNodeInfo(host: View, info: AccessibilityNodeInfo, extraDataKey: String, arguments: Bundle?) {}
|
||||
override fun onRequestSendAccessibilityEvent(host: ViewGroup, child: View, event: AccessibilityEvent): Boolean = false
|
||||
override fun getAccessibilityNodeProvider(host: View): AccessibilityNodeProvider? = null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,283 +0,0 @@
|
||||
package com.topjohnwu.magisk.ui.terminal
|
||||
|
||||
import android.graphics.Canvas
|
||||
import android.graphics.Paint
|
||||
import android.graphics.PorterDuff
|
||||
import android.graphics.Typeface
|
||||
import com.topjohnwu.magisk.terminal.TerminalBuffer
|
||||
import com.topjohnwu.magisk.terminal.TerminalEmulator
|
||||
import com.topjohnwu.magisk.terminal.TerminalRow
|
||||
import com.topjohnwu.magisk.terminal.TextStyle
|
||||
import com.topjohnwu.magisk.terminal.WcWidth
|
||||
|
||||
/**
|
||||
* Renderer of a [TerminalEmulator] into a [Canvas].
|
||||
*
|
||||
* Saves font metrics, so needs to be recreated each time the typeface or font size changes.
|
||||
*/
|
||||
class TerminalRenderer(
|
||||
textSize: Int,
|
||||
typeface: Typeface,
|
||||
) {
|
||||
val textSize: Int = textSize
|
||||
val typeface: Typeface = typeface
|
||||
private val textPaint = Paint()
|
||||
|
||||
/** The width of a single mono spaced character obtained by [Paint.measureText] on a single 'X'. */
|
||||
val fontWidth: Float
|
||||
|
||||
/** The [Paint.getFontSpacing]. See http://www.fampennings.nl/maarten/android/08numgrid/font.png */
|
||||
val fontLineSpacing: Int
|
||||
|
||||
/** The [Paint.ascent]. See http://www.fampennings.nl/maarten/android/08numgrid/font.png */
|
||||
private val fontAscent: Int
|
||||
|
||||
/** The [fontLineSpacing] + [fontAscent]. */
|
||||
val fontLineSpacingAndAscent: Int
|
||||
|
||||
private val asciiMeasures = FloatArray(127)
|
||||
|
||||
init {
|
||||
textPaint.typeface = typeface
|
||||
textPaint.isAntiAlias = true
|
||||
textPaint.textSize = textSize.toFloat()
|
||||
|
||||
fontLineSpacing = kotlin.math.ceil(textPaint.fontSpacing).toInt()
|
||||
fontAscent = kotlin.math.ceil(textPaint.ascent()).toInt()
|
||||
fontLineSpacingAndAscent = fontLineSpacing + fontAscent
|
||||
fontWidth = textPaint.measureText("X")
|
||||
|
||||
val sb = StringBuilder(" ")
|
||||
for (i in asciiMeasures.indices) {
|
||||
sb[0] = i.toChar()
|
||||
asciiMeasures[i] = textPaint.measureText(sb, 0, 1)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the terminal to a canvas with at a specified row scroll, and an optional rectangular selection.
|
||||
*/
|
||||
fun render(
|
||||
mEmulator: TerminalEmulator,
|
||||
canvas: Canvas,
|
||||
topRow: Int,
|
||||
selectionY1: Int,
|
||||
selectionY2: Int,
|
||||
selectionX1: Int,
|
||||
selectionX2: Int,
|
||||
) {
|
||||
val reverseVideo = mEmulator.isReverseVideo
|
||||
val endRow = topRow + mEmulator.mRows
|
||||
val columns = mEmulator.mColumns
|
||||
val cursorCol = mEmulator.cursorCol
|
||||
val cursorRow = mEmulator.cursorRow
|
||||
val cursorVisible = mEmulator.shouldCursorBeVisible()
|
||||
val screen = mEmulator.screen
|
||||
val palette = mEmulator.mColors.currentColors
|
||||
val cursorShape = mEmulator.cursorStyle
|
||||
|
||||
if (reverseVideo) {
|
||||
canvas.drawColor(palette[TextStyle.COLOR_INDEX_FOREGROUND], PorterDuff.Mode.SRC)
|
||||
}
|
||||
|
||||
var heightOffset = fontLineSpacingAndAscent.toFloat()
|
||||
for (row in topRow until endRow) {
|
||||
heightOffset += fontLineSpacing
|
||||
|
||||
val cursorX = if (row == cursorRow && cursorVisible) cursorCol else -1
|
||||
var selx1 = -1
|
||||
var selx2 = -1
|
||||
if (row in selectionY1..selectionY2) {
|
||||
if (row == selectionY1) selx1 = selectionX1
|
||||
selx2 = if (row == selectionY2) selectionX2 else mEmulator.mColumns
|
||||
}
|
||||
|
||||
val lineObject = screen.allocateFullLineIfNecessary(screen.externalToInternalRow(row))
|
||||
val line = lineObject.text
|
||||
val charsUsedInLine = lineObject.spaceUsed
|
||||
|
||||
var lastRunStyle = 0L
|
||||
var lastRunInsideCursor = false
|
||||
var lastRunInsideSelection = false
|
||||
var lastRunStartColumn = -1
|
||||
var lastRunStartIndex = 0
|
||||
var lastRunFontWidthMismatch = false
|
||||
var currentCharIndex = 0
|
||||
var measuredWidthForRun = 0f
|
||||
|
||||
var column = 0
|
||||
while (column < columns) {
|
||||
val charAtIndex = line[currentCharIndex]
|
||||
val charIsHighsurrogate = Character.isHighSurrogate(charAtIndex)
|
||||
val charsForCodePoint = if (charIsHighsurrogate) 2 else 1
|
||||
val codePoint = if (charIsHighsurrogate) {
|
||||
Character.toCodePoint(charAtIndex, line[currentCharIndex + 1])
|
||||
} else {
|
||||
charAtIndex.code
|
||||
}
|
||||
val codePointWcWidth = WcWidth.width(codePoint)
|
||||
val insideCursor = cursorX == column || (codePointWcWidth == 2 && cursorX == column + 1)
|
||||
val insideSelection = column >= selx1 && column <= selx2
|
||||
val style = lineObject.getStyle(column)
|
||||
|
||||
// Check if the measured text width for this code point is not the same as that expected by wcwidth().
|
||||
// This could happen for some fonts which are not truly monospace, or for more exotic characters such as
|
||||
// smileys which android font renders as wide.
|
||||
// If this is detected, we draw this code point scaled to match what wcwidth() expects.
|
||||
val measuredCodePointWidth = if (codePoint < asciiMeasures.size) {
|
||||
asciiMeasures[codePoint]
|
||||
} else {
|
||||
textPaint.measureText(line, currentCharIndex, charsForCodePoint)
|
||||
}
|
||||
val fontWidthMismatch = kotlin.math.abs(measuredCodePointWidth / fontWidth - codePointWcWidth.toFloat()) > 0.01f
|
||||
|
||||
if (style != lastRunStyle || insideCursor != lastRunInsideCursor || insideSelection != lastRunInsideSelection ||
|
||||
fontWidthMismatch || lastRunFontWidthMismatch
|
||||
) {
|
||||
if (column != 0) {
|
||||
val columnWidthSinceLastRun = column - lastRunStartColumn
|
||||
val charsSinceLastRun = currentCharIndex - lastRunStartIndex
|
||||
val cursorColor = if (lastRunInsideCursor) mEmulator.mColors.currentColors[TextStyle.COLOR_INDEX_CURSOR] else 0
|
||||
var invertCursorTextColor = false
|
||||
if (lastRunInsideCursor && cursorShape == TerminalEmulator.TERMINAL_CURSOR_STYLE_BLOCK) {
|
||||
invertCursorTextColor = true
|
||||
}
|
||||
drawTextRun(
|
||||
canvas, line, palette, heightOffset, lastRunStartColumn, columnWidthSinceLastRun,
|
||||
lastRunStartIndex, charsSinceLastRun, measuredWidthForRun,
|
||||
cursorColor, cursorShape, lastRunStyle,
|
||||
reverseVideo || invertCursorTextColor || lastRunInsideSelection,
|
||||
)
|
||||
}
|
||||
measuredWidthForRun = 0f
|
||||
lastRunStyle = style
|
||||
lastRunInsideCursor = insideCursor
|
||||
lastRunInsideSelection = insideSelection
|
||||
lastRunStartColumn = column
|
||||
lastRunStartIndex = currentCharIndex
|
||||
lastRunFontWidthMismatch = fontWidthMismatch
|
||||
}
|
||||
measuredWidthForRun += measuredCodePointWidth
|
||||
column += codePointWcWidth
|
||||
currentCharIndex += charsForCodePoint
|
||||
while (currentCharIndex < charsUsedInLine && WcWidth.width(line, currentCharIndex) <= 0) {
|
||||
// Eat combining chars so that they are treated as part of the last non-combining code point,
|
||||
// instead of e.g. being considered inside the cursor in the next run.
|
||||
currentCharIndex += if (Character.isHighSurrogate(line[currentCharIndex])) 2 else 1
|
||||
}
|
||||
}
|
||||
|
||||
val columnWidthSinceLastRun = columns - lastRunStartColumn
|
||||
val charsSinceLastRun = currentCharIndex - lastRunStartIndex
|
||||
val cursorColor = if (lastRunInsideCursor) mEmulator.mColors.currentColors[TextStyle.COLOR_INDEX_CURSOR] else 0
|
||||
var invertCursorTextColor = false
|
||||
if (lastRunInsideCursor && cursorShape == TerminalEmulator.TERMINAL_CURSOR_STYLE_BLOCK) {
|
||||
invertCursorTextColor = true
|
||||
}
|
||||
drawTextRun(
|
||||
canvas, line, palette, heightOffset, lastRunStartColumn, columnWidthSinceLastRun,
|
||||
lastRunStartIndex, charsSinceLastRun, measuredWidthForRun,
|
||||
cursorColor, cursorShape, lastRunStyle,
|
||||
reverseVideo || invertCursorTextColor || lastRunInsideSelection,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun drawTextRun(
|
||||
canvas: Canvas,
|
||||
text: CharArray,
|
||||
palette: IntArray,
|
||||
y: Float,
|
||||
startColumn: Int,
|
||||
runWidthColumns: Int,
|
||||
startCharIndex: Int,
|
||||
runWidthChars: Int,
|
||||
mes: Float,
|
||||
cursor: Int,
|
||||
cursorStyle: Int,
|
||||
textStyle: Long,
|
||||
reverseVideo: Boolean,
|
||||
) {
|
||||
var foreColor = TextStyle.decodeForeColor(textStyle)
|
||||
val effect = TextStyle.decodeEffect(textStyle)
|
||||
var backColor = TextStyle.decodeBackColor(textStyle)
|
||||
val bold = (effect and (TextStyle.CHARACTER_ATTRIBUTE_BOLD or TextStyle.CHARACTER_ATTRIBUTE_BLINK)) != 0
|
||||
val underline = (effect and TextStyle.CHARACTER_ATTRIBUTE_UNDERLINE) != 0
|
||||
val italic = (effect and TextStyle.CHARACTER_ATTRIBUTE_ITALIC) != 0
|
||||
val strikeThrough = (effect and TextStyle.CHARACTER_ATTRIBUTE_STRIKETHROUGH) != 0
|
||||
val dim = (effect and TextStyle.CHARACTER_ATTRIBUTE_DIM) != 0
|
||||
|
||||
if ((foreColor and 0xff000000.toInt()) != 0xff000000.toInt()) {
|
||||
// Let bold have bright colors if applicable (one of the first 8):
|
||||
if (bold && foreColor in 0..7) foreColor += 8
|
||||
foreColor = palette[foreColor]
|
||||
}
|
||||
|
||||
if ((backColor and 0xff000000.toInt()) != 0xff000000.toInt()) {
|
||||
backColor = palette[backColor]
|
||||
}
|
||||
|
||||
// Reverse video here if _one and only one_ of the reverse flags are set:
|
||||
val reverseVideoHere = reverseVideo xor ((effect and TextStyle.CHARACTER_ATTRIBUTE_INVERSE) != 0)
|
||||
if (reverseVideoHere) {
|
||||
val tmp = foreColor
|
||||
foreColor = backColor
|
||||
backColor = tmp
|
||||
}
|
||||
|
||||
var left = startColumn * fontWidth
|
||||
var right = left + runWidthColumns * fontWidth
|
||||
|
||||
var adjustedMes = mes / fontWidth
|
||||
var savedMatrix = false
|
||||
if (kotlin.math.abs(adjustedMes - runWidthColumns) > 0.01) {
|
||||
canvas.save()
|
||||
canvas.scale(runWidthColumns / adjustedMes, 1f)
|
||||
left *= adjustedMes / runWidthColumns
|
||||
right *= adjustedMes / runWidthColumns
|
||||
savedMatrix = true
|
||||
}
|
||||
|
||||
if (backColor != palette[TextStyle.COLOR_INDEX_BACKGROUND]) {
|
||||
// Only draw non-default background.
|
||||
textPaint.color = backColor
|
||||
canvas.drawRect(left, y - fontLineSpacingAndAscent + fontAscent, right, y, textPaint)
|
||||
}
|
||||
|
||||
if (cursor != 0) {
|
||||
textPaint.color = cursor
|
||||
var cursorHeight = (fontLineSpacingAndAscent - fontAscent).toFloat()
|
||||
if (cursorStyle == TerminalEmulator.TERMINAL_CURSOR_STYLE_UNDERLINE) cursorHeight /= 4f
|
||||
else if (cursorStyle == TerminalEmulator.TERMINAL_CURSOR_STYLE_BAR) right -= ((right - left) * 3) / 4f
|
||||
canvas.drawRect(left, y - cursorHeight, right, y, textPaint)
|
||||
}
|
||||
|
||||
if ((effect and TextStyle.CHARACTER_ATTRIBUTE_INVISIBLE) == 0) {
|
||||
if (dim) {
|
||||
var red = 0xFF and (foreColor shr 16)
|
||||
var green = 0xFF and (foreColor shr 8)
|
||||
var blue = 0xFF and foreColor
|
||||
// Dim color handling used by libvte which in turn took it from xterm
|
||||
// (https://bug735245.bugzilla-attachments.gnome.org/attachment.cgi?id=284267):
|
||||
red = red * 2 / 3
|
||||
green = green * 2 / 3
|
||||
blue = blue * 2 / 3
|
||||
foreColor = -0x1000000 or (red shl 16) or (green shl 8) or blue
|
||||
}
|
||||
|
||||
textPaint.isFakeBoldText = bold
|
||||
textPaint.isUnderlineText = underline
|
||||
textPaint.textSkewX = if (italic) -0.35f else 0f
|
||||
textPaint.isStrikeThruText = strikeThrough
|
||||
textPaint.color = foreColor
|
||||
|
||||
// The text alignment is the default Paint.Align.LEFT.
|
||||
canvas.drawTextRun(
|
||||
text, startCharIndex, runWidthChars, startCharIndex, runWidthChars,
|
||||
left, y - fontLineSpacingAndAscent, false, textPaint,
|
||||
)
|
||||
}
|
||||
|
||||
if (savedMatrix) canvas.restore()
|
||||
}
|
||||
}
|
||||
@@ -1,94 +0,0 @@
|
||||
package com.topjohnwu.magisk.ui.terminal
|
||||
|
||||
import android.graphics.Typeface
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.gestures.Orientation
|
||||
import androidx.compose.foundation.gestures.rememberScrollableState
|
||||
import androidx.compose.foundation.gestures.scrollable
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.ui.draw.drawBehind
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.drawscope.drawIntoCanvas
|
||||
import androidx.compose.ui.graphics.nativeCanvas
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.topjohnwu.magisk.terminal.TerminalEmulator
|
||||
import kotlin.math.max
|
||||
|
||||
@Composable
|
||||
fun TerminalScreen(
|
||||
modifier: Modifier = Modifier,
|
||||
onEmulatorCreated: (TerminalEmulator) -> Unit = {},
|
||||
) {
|
||||
val density = LocalDensity.current
|
||||
val renderer = remember {
|
||||
val textSizePx = with(density) { 12.sp.toPx().toInt() }
|
||||
TerminalRenderer(textSizePx, Typeface.MONOSPACE)
|
||||
}
|
||||
|
||||
var emulator by remember { mutableStateOf<TerminalEmulator?>(null) }
|
||||
var updateTick by remember { mutableIntStateOf(0) }
|
||||
var topRow by remember { mutableIntStateOf(0) }
|
||||
var scrolledToBottom by remember { mutableStateOf(true) }
|
||||
|
||||
BoxWithConstraints(modifier = modifier) {
|
||||
val widthPx = constraints.maxWidth
|
||||
val heightPx = constraints.maxHeight
|
||||
val cols = max(4, (widthPx / renderer.fontWidth).toInt())
|
||||
val rows = max(4, (heightPx - renderer.fontLineSpacingAndAscent) / renderer.fontLineSpacing)
|
||||
val lineHeight = renderer.fontLineSpacing.toFloat()
|
||||
|
||||
LaunchedEffect(cols, rows) {
|
||||
val emu = emulator
|
||||
if (emu == null) {
|
||||
val newEmu = TerminalEmulator(cols, rows, renderer.fontWidth.toInt(), renderer.fontLineSpacing, null)
|
||||
newEmu.onScreenUpdate = {
|
||||
if (scrolledToBottom) topRow = 0
|
||||
updateTick++
|
||||
}
|
||||
emulator = newEmu
|
||||
onEmulatorCreated(newEmu)
|
||||
} else {
|
||||
emu.resize(cols, rows, renderer.fontWidth.toInt(), renderer.fontLineSpacing)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(Color.Black)
|
||||
.scrollable(
|
||||
orientation = Orientation.Vertical,
|
||||
state = rememberScrollableState { delta ->
|
||||
val emu = emulator ?: return@rememberScrollableState 0f
|
||||
val minTop = -emu.screen.activeTranscriptRows
|
||||
val rowDelta = -(delta / lineHeight).toInt()
|
||||
if (rowDelta != 0) {
|
||||
val newTopRow = (topRow + rowDelta).coerceIn(minTop, 0)
|
||||
topRow = newTopRow
|
||||
scrolledToBottom = newTopRow >= 0
|
||||
}
|
||||
delta
|
||||
}
|
||||
)
|
||||
.drawBehind {
|
||||
@Suppress("UNUSED_EXPRESSION")
|
||||
updateTick
|
||||
val emu = emulator ?: return@drawBehind
|
||||
drawIntoCanvas { canvas ->
|
||||
renderer.render(emu, canvas.nativeCanvas, topRow, -1, -1, -1, -1)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
package com.topjohnwu.magisk.ui.theme
|
||||
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import com.topjohnwu.magisk.core.Config
|
||||
import top.yukonga.miuix.kmp.theme.ColorSchemeMode
|
||||
import top.yukonga.miuix.kmp.theme.LocalContentColor
|
||||
import top.yukonga.miuix.kmp.theme.MiuixTheme
|
||||
import top.yukonga.miuix.kmp.theme.ThemeController
|
||||
|
||||
object ThemeState {
|
||||
var colorMode by mutableIntStateOf(Config.colorMode)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun MagiskTheme(
|
||||
content: @Composable () -> Unit
|
||||
) {
|
||||
val isDark = isSystemInDarkTheme()
|
||||
val mode = ThemeState.colorMode
|
||||
val controller = when (mode) {
|
||||
1 -> ThemeController(ColorSchemeMode.Light)
|
||||
2 -> ThemeController(ColorSchemeMode.Dark)
|
||||
3 -> ThemeController(ColorSchemeMode.MonetSystem, isDark = isDark)
|
||||
4 -> ThemeController(ColorSchemeMode.MonetLight)
|
||||
5 -> ThemeController(ColorSchemeMode.MonetDark)
|
||||
else -> ThemeController(ColorSchemeMode.System)
|
||||
}
|
||||
MiuixTheme(controller = controller) {
|
||||
CompositionLocalProvider(
|
||||
LocalContentColor provides MiuixTheme.colorScheme.onBackground,
|
||||
content = content
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package com.topjohnwu.magisk.ui.theme
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.ContextThemeWrapper
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.FrameLayout
|
||||
import com.topjohnwu.magisk.BR
|
||||
import com.topjohnwu.magisk.R
|
||||
import com.topjohnwu.magisk.arch.BaseFragment
|
||||
import com.topjohnwu.magisk.arch.viewModel
|
||||
import com.topjohnwu.magisk.databinding.FragmentThemeMd2Binding
|
||||
import com.topjohnwu.magisk.databinding.ItemThemeBindingImpl
|
||||
import com.topjohnwu.magisk.core.R as CoreR
|
||||
|
||||
class ThemeFragment : BaseFragment<FragmentThemeMd2Binding>() {
|
||||
|
||||
override val layoutRes = R.layout.fragment_theme_md2
|
||||
override val viewModel by viewModel<ThemeViewModel>()
|
||||
|
||||
private fun <T> Array<T>.paired(): List<Pair<T, T?>> {
|
||||
val iterator = iterator()
|
||||
if (!iterator.hasNext()) return emptyList()
|
||||
val result = mutableListOf<Pair<T, T?>>()
|
||||
while (iterator.hasNext()) {
|
||||
val a = iterator.next()
|
||||
val b = if (iterator.hasNext()) iterator.next() else null
|
||||
result.add(a to b)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
override fun onCreateView(
|
||||
inflater: LayoutInflater,
|
||||
container: ViewGroup?,
|
||||
savedInstanceState: Bundle?
|
||||
): View {
|
||||
super.onCreateView(inflater, container, savedInstanceState)
|
||||
|
||||
for ((a, b) in Theme.values().paired()) {
|
||||
val c = inflater.inflate(R.layout.item_theme_container, null, false)
|
||||
val left = c.findViewById<FrameLayout>(R.id.left)
|
||||
val right = c.findViewById<FrameLayout>(R.id.right)
|
||||
|
||||
for ((theme, view) in listOf(a to left, b to right)) {
|
||||
theme ?: continue
|
||||
val themed = ContextThemeWrapper(activity, theme.themeRes)
|
||||
ItemThemeBindingImpl.inflate(LayoutInflater.from(themed), view, true).also {
|
||||
it.setVariable(BR.viewModel, viewModel)
|
||||
it.setVariable(BR.theme, theme)
|
||||
it.lifecycleOwner = viewLifecycleOwner
|
||||
}
|
||||
}
|
||||
|
||||
binding.themeContainer.addView(c)
|
||||
}
|
||||
|
||||
return binding.root
|
||||
}
|
||||
|
||||
override fun onStart() {
|
||||
super.onStart()
|
||||
|
||||
activity?.title = getString(CoreR.string.section_theme)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package com.topjohnwu.magisk.ui.theme
|
||||
|
||||
import com.topjohnwu.magisk.arch.BaseViewModel
|
||||
import com.topjohnwu.magisk.core.Config
|
||||
import com.topjohnwu.magisk.dialog.DarkThemeDialog
|
||||
import com.topjohnwu.magisk.events.RecreateEvent
|
||||
import com.topjohnwu.magisk.view.TappableHeadlineItem
|
||||
|
||||
class ThemeViewModel : BaseViewModel(), TappableHeadlineItem.Listener {
|
||||
|
||||
val themeHeadline = TappableHeadlineItem.ThemeMode
|
||||
|
||||
override fun onItemPressed(item: TappableHeadlineItem) = when (item) {
|
||||
is TappableHeadlineItem.ThemeMode -> DarkThemeDialog().show()
|
||||
}
|
||||
|
||||
fun saveTheme(theme: Theme) {
|
||||
if (!theme.isSelected) {
|
||||
Config.themeOrdinal = theme.ordinal
|
||||
RecreateEvent().publish()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
package com.topjohnwu.magisk.ui.util
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.drawable.Drawable
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
import androidx.compose.ui.graphics.painter.BitmapPainter
|
||||
import androidx.compose.ui.graphics.painter.Painter
|
||||
|
||||
@Composable
|
||||
fun rememberDrawablePainter(drawable: Drawable): Painter {
|
||||
return remember(drawable) {
|
||||
val w = drawable.intrinsicWidth.coerceAtLeast(1)
|
||||
val h = drawable.intrinsicHeight.coerceAtLeast(1)
|
||||
val bitmap = Bitmap.createBitmap(w, h, Bitmap.Config.ARGB_8888)
|
||||
val canvas = android.graphics.Canvas(bitmap)
|
||||
drawable.setBounds(0, 0, w, h)
|
||||
drawable.draw(canvas)
|
||||
BitmapPainter(bitmap.asImageBitmap())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
package com.topjohnwu.magisk.utils
|
||||
|
||||
import android.animation.Animator
|
||||
import android.animation.AnimatorSet
|
||||
import android.animation.ObjectAnimator
|
||||
import android.view.View
|
||||
import androidx.core.animation.addListener
|
||||
import androidx.core.text.layoutDirection
|
||||
import androidx.core.view.isInvisible
|
||||
import androidx.core.view.isVisible
|
||||
import androidx.core.view.marginBottom
|
||||
import androidx.core.view.marginEnd
|
||||
import androidx.interpolator.view.animation.FastOutSlowInInterpolator
|
||||
import com.google.android.material.circularreveal.CircularRevealCompat
|
||||
import com.google.android.material.circularreveal.CircularRevealWidget
|
||||
import com.google.android.material.floatingactionbutton.FloatingActionButton
|
||||
import com.topjohnwu.magisk.core.utils.LocaleSetting
|
||||
import kotlin.math.hypot
|
||||
|
||||
object MotionRevealHelper {
|
||||
|
||||
fun <CV> withViews(
|
||||
revealable: CV,
|
||||
fab: FloatingActionButton,
|
||||
expanded: Boolean
|
||||
) where CV : CircularRevealWidget, CV : View {
|
||||
revealable.revealInfo = revealable.createRevealInfo(!expanded)
|
||||
|
||||
val revealInfo = revealable.createRevealInfo(expanded)
|
||||
val revealAnim = revealable.createRevealAnim(revealInfo)
|
||||
val moveAnim = fab.createMoveAnim(revealInfo)
|
||||
|
||||
AnimatorSet().also {
|
||||
if (expanded) {
|
||||
it.play(revealAnim).after(moveAnim)
|
||||
} else {
|
||||
it.play(moveAnim).after(revealAnim)
|
||||
}
|
||||
}.start()
|
||||
}
|
||||
|
||||
private fun <CV> CV.createRevealAnim(
|
||||
revealInfo: CircularRevealWidget.RevealInfo
|
||||
): Animator where CV : CircularRevealWidget, CV : View =
|
||||
CircularRevealCompat.createCircularReveal(
|
||||
this,
|
||||
revealInfo.centerX,
|
||||
revealInfo.centerY,
|
||||
revealInfo.radius
|
||||
).apply {
|
||||
addListener(onStart = {
|
||||
isVisible = true
|
||||
}, onEnd = {
|
||||
if (revealInfo.radius == 0f) {
|
||||
isInvisible = true
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private fun FloatingActionButton.createMoveAnim(
|
||||
revealInfo: CircularRevealWidget.RevealInfo
|
||||
): Animator = AnimatorSet().also {
|
||||
it.interpolator = FastOutSlowInInterpolator()
|
||||
it.addListener(onStart = { show() }, onEnd = { if (revealInfo.radius != 0f) hide() })
|
||||
|
||||
val rtlMod =
|
||||
if (LocaleSetting.instance.currentLocale.layoutDirection == View.LAYOUT_DIRECTION_RTL)
|
||||
1f else -1f
|
||||
val maxX = revealInfo.centerX - marginEnd - measuredWidth / 2f
|
||||
val targetX = if (revealInfo.radius == 0f) 0f else maxX * rtlMod
|
||||
val moveX = ObjectAnimator.ofFloat(this, View.TRANSLATION_X, targetX)
|
||||
|
||||
val maxY = revealInfo.centerY - marginBottom - measuredHeight / 2f
|
||||
val targetY = if (revealInfo.radius == 0f) 0f else -maxY
|
||||
val moveY = ObjectAnimator.ofFloat(this, View.TRANSLATION_Y, targetY)
|
||||
|
||||
it.playTogether(moveX, moveY)
|
||||
}
|
||||
|
||||
private fun View.createRevealInfo(expanded: Boolean): CircularRevealWidget.RevealInfo {
|
||||
val cX = measuredWidth / 2f
|
||||
val cY = measuredHeight / 2f - paddingBottom
|
||||
return CircularRevealWidget.RevealInfo(cX, cY, if (expanded) hypot(cX, cY) else 0f)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package com.topjohnwu.magisk.utils
|
||||
|
||||
import android.content.res.Resources
|
||||
|
||||
abstract class TextHolder {
|
||||
|
||||
open val isEmpty: Boolean get() = false
|
||||
abstract fun getText(resources: Resources): CharSequence
|
||||
|
||||
// ---
|
||||
|
||||
class String(
|
||||
private val value: CharSequence
|
||||
) : TextHolder() {
|
||||
override val isEmpty get() = value.isEmpty()
|
||||
override fun getText(resources: Resources) = value
|
||||
}
|
||||
|
||||
open class Resource(
|
||||
protected val value: Int
|
||||
) : TextHolder() {
|
||||
override val isEmpty get() = value == 0
|
||||
override fun getText(resources: Resources) = resources.getString(value)
|
||||
}
|
||||
|
||||
class ResourceArgs(
|
||||
value: Int,
|
||||
private vararg val params: Any
|
||||
) : Resource(value) {
|
||||
override fun getText(resources: Resources): kotlin.String {
|
||||
// Replace TextHolder with strings
|
||||
val args = params.map { if (it is TextHolder) it.getText(resources) else it }
|
||||
return resources.getString(value, *args.toTypedArray())
|
||||
}
|
||||
}
|
||||
|
||||
// ---
|
||||
|
||||
companion object {
|
||||
val EMPTY = String("")
|
||||
}
|
||||
}
|
||||
|
||||
fun Int.asText(): TextHolder = TextHolder.Resource(this)
|
||||
fun Int.asText(vararg params: Any): TextHolder = TextHolder.ResourceArgs(this, *params)
|
||||
fun CharSequence.asText(): TextHolder = TextHolder.String(this)
|
||||
232
app/apk/src/main/java/com/topjohnwu/magisk/view/MagiskDialog.kt
Normal file
232
app/apk/src/main/java/com/topjohnwu/magisk/view/MagiskDialog.kt
Normal file
@@ -0,0 +1,232 @@
|
||||
package com.topjohnwu.magisk.view
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.DialogInterface
|
||||
import android.content.res.ColorStateList
|
||||
import android.graphics.drawable.Drawable
|
||||
import android.graphics.drawable.InsetDrawable
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.annotation.DrawableRes
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.appcompat.app.AppCompatDialog
|
||||
import androidx.appcompat.content.res.AppCompatResources
|
||||
import androidx.databinding.Bindable
|
||||
import androidx.databinding.PropertyChangeRegistry
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.google.android.material.color.MaterialColors
|
||||
import com.google.android.material.shape.MaterialShapeDrawable
|
||||
import com.topjohnwu.magisk.BR
|
||||
import com.topjohnwu.magisk.R
|
||||
import com.topjohnwu.magisk.arch.UIActivity
|
||||
import com.topjohnwu.magisk.databinding.DialogMagiskBaseBinding
|
||||
import com.topjohnwu.magisk.databinding.DiffItem
|
||||
import com.topjohnwu.magisk.databinding.ItemWrapper
|
||||
import com.topjohnwu.magisk.databinding.ObservableHost
|
||||
import com.topjohnwu.magisk.databinding.RvItem
|
||||
import com.topjohnwu.magisk.databinding.bindExtra
|
||||
import com.topjohnwu.magisk.databinding.set
|
||||
import com.topjohnwu.magisk.databinding.setAdapter
|
||||
import com.topjohnwu.magisk.view.MagiskDialog.DialogClickListener
|
||||
|
||||
typealias DialogButtonClickListener = (DialogInterface) -> Unit
|
||||
|
||||
class MagiskDialog(
|
||||
context: Activity, theme: Int = 0
|
||||
) : AppCompatDialog(context, theme) {
|
||||
|
||||
private val binding: DialogMagiskBaseBinding =
|
||||
DialogMagiskBaseBinding.inflate(LayoutInflater.from(context))
|
||||
private val data = Data()
|
||||
|
||||
val activity: UIActivity<*> get() = ownerActivity as UIActivity<*>
|
||||
|
||||
init {
|
||||
binding.setVariable(BR.data, data)
|
||||
setCancelable(true)
|
||||
setOwnerActivity(context)
|
||||
}
|
||||
|
||||
inner class Data : ObservableHost {
|
||||
override var callbacks: PropertyChangeRegistry? = null
|
||||
|
||||
@get:Bindable
|
||||
var icon: Drawable? = null
|
||||
set(value) = set(value, field, { field = it }, BR.icon)
|
||||
|
||||
@get:Bindable
|
||||
var title: CharSequence = ""
|
||||
set(value) = set(value, field, { field = it }, BR.title)
|
||||
|
||||
@get:Bindable
|
||||
var message: CharSequence = ""
|
||||
set(value) = set(value, field, { field = it }, BR.message)
|
||||
|
||||
val buttonPositive = ButtonViewModel()
|
||||
val buttonNeutral = ButtonViewModel()
|
||||
val buttonNegative = ButtonViewModel()
|
||||
}
|
||||
|
||||
enum class ButtonType {
|
||||
POSITIVE, NEUTRAL, NEGATIVE
|
||||
}
|
||||
|
||||
interface Button {
|
||||
var icon: Int
|
||||
var text: Any
|
||||
var isEnabled: Boolean
|
||||
var doNotDismiss: Boolean
|
||||
|
||||
fun onClick(listener: DialogButtonClickListener)
|
||||
}
|
||||
|
||||
inner class ButtonViewModel : Button, ObservableHost {
|
||||
override var callbacks: PropertyChangeRegistry? = null
|
||||
|
||||
@get:Bindable
|
||||
override var icon = 0
|
||||
set(value) = set(value, field, { field = it }, BR.icon, BR.gone)
|
||||
|
||||
@get:Bindable
|
||||
var message: String = ""
|
||||
set(value) = set(value, field, { field = it }, BR.message, BR.gone)
|
||||
|
||||
override var text: Any
|
||||
get() = message
|
||||
set(value) {
|
||||
message = when (value) {
|
||||
is Int -> context.getText(value)
|
||||
else -> value
|
||||
}.toString()
|
||||
}
|
||||
|
||||
@get:Bindable
|
||||
val gone get() = icon == 0 && message.isEmpty()
|
||||
|
||||
@get:Bindable
|
||||
override var isEnabled = true
|
||||
set(value) = set(value, field, { field = it }, BR.enabled)
|
||||
|
||||
override var doNotDismiss = false
|
||||
|
||||
private var onClickAction: DialogButtonClickListener = {}
|
||||
|
||||
override fun onClick(listener: DialogButtonClickListener) {
|
||||
onClickAction = listener
|
||||
}
|
||||
|
||||
fun clicked() {
|
||||
onClickAction(this@MagiskDialog)
|
||||
if (!doNotDismiss) {
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
super.setContentView(binding.root)
|
||||
|
||||
val default = MaterialColors.getColor(context, com.google.android.material.R.attr.colorSurface, javaClass.canonicalName)
|
||||
val surfaceColor = MaterialColors.getColor(context, R.attr.colorSurfaceSurfaceVariant, default)
|
||||
val materialShapeDrawable = MaterialShapeDrawable(context, null, androidx.appcompat.R.attr.alertDialogStyle, com.google.android.material.R.style.MaterialAlertDialog_MaterialComponents)
|
||||
materialShapeDrawable.initializeElevationOverlay(context)
|
||||
materialShapeDrawable.fillColor = ColorStateList.valueOf(surfaceColor)
|
||||
materialShapeDrawable.elevation = context.resources.getDimension(R.dimen.margin_generic)
|
||||
materialShapeDrawable.setCornerSize(context.resources.getDimension(R.dimen.l_50))
|
||||
|
||||
val inset = context.resources.getDimensionPixelSize(com.google.android.material.R.dimen.appcompat_dialog_background_inset)
|
||||
window?.apply {
|
||||
setBackgroundDrawable(InsetDrawable(materialShapeDrawable, inset, inset, inset, inset))
|
||||
setLayout(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT)
|
||||
}
|
||||
}
|
||||
|
||||
override fun setTitle(@StringRes titleId: Int) { data.title = context.getString(titleId) }
|
||||
|
||||
override fun setTitle(title: CharSequence?) { data.title = title ?: "" }
|
||||
|
||||
fun setMessage(@StringRes msgId: Int, vararg args: Any) {
|
||||
data.message = context.getString(msgId, *args)
|
||||
}
|
||||
|
||||
fun setMessage(message: CharSequence) { data.message = message }
|
||||
|
||||
fun setIcon(@DrawableRes drawableRes: Int) {
|
||||
data.icon = AppCompatResources.getDrawable(context, drawableRes)
|
||||
}
|
||||
|
||||
fun setIcon(drawable: Drawable) { data.icon = drawable }
|
||||
|
||||
fun setButton(buttonType: ButtonType, builder: Button.() -> Unit) {
|
||||
val button = when (buttonType) {
|
||||
ButtonType.POSITIVE -> data.buttonPositive
|
||||
ButtonType.NEUTRAL -> data.buttonNeutral
|
||||
ButtonType.NEGATIVE -> data.buttonNegative
|
||||
}
|
||||
button.apply(builder)
|
||||
}
|
||||
|
||||
class DialogItem(
|
||||
override val item: CharSequence,
|
||||
val position: Int
|
||||
) : RvItem(), DiffItem<DialogItem>, ItemWrapper<CharSequence> {
|
||||
override val layoutRes = R.layout.item_list_single_line
|
||||
}
|
||||
|
||||
fun interface DialogClickListener {
|
||||
fun onClick(position: Int)
|
||||
}
|
||||
|
||||
fun setListItems(
|
||||
list: Array<out CharSequence>,
|
||||
listener: DialogClickListener
|
||||
) = setView(
|
||||
RecyclerView(context).also {
|
||||
it.isNestedScrollingEnabled = false
|
||||
it.layoutManager = LinearLayoutManager(context)
|
||||
|
||||
val items = list.mapIndexed { i, cs -> DialogItem(cs, i) }
|
||||
val extraBindings = bindExtra { sa ->
|
||||
sa.put(BR.listener, DialogClickListener { pos ->
|
||||
listener.onClick(pos)
|
||||
dismiss()
|
||||
})
|
||||
}
|
||||
it.setAdapter(items, extraBindings)
|
||||
}
|
||||
)
|
||||
|
||||
fun setView(view: View) {
|
||||
binding.dialogBaseContainer.removeAllViews()
|
||||
binding.dialogBaseContainer.addView(
|
||||
view,
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
ViewGroup.LayoutParams.WRAP_CONTENT
|
||||
)
|
||||
}
|
||||
|
||||
fun resetButtons() {
|
||||
ButtonType.values().forEach {
|
||||
setButton(it) {
|
||||
text = ""
|
||||
icon = 0
|
||||
isEnabled = true
|
||||
doNotDismiss = false
|
||||
onClick {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Prevent calling setContentView
|
||||
|
||||
@Deprecated("Please use setView(view)", level = DeprecationLevel.ERROR)
|
||||
override fun setContentView(layoutResID: Int) {}
|
||||
@Deprecated("Please use setView(view)", level = DeprecationLevel.ERROR)
|
||||
override fun setContentView(view: View) {}
|
||||
@Deprecated("Please use setView(view)", level = DeprecationLevel.ERROR)
|
||||
override fun setContentView(view: View, params: ViewGroup.LayoutParams?) {}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package com.topjohnwu.magisk.view
|
||||
|
||||
import com.topjohnwu.magisk.R
|
||||
import com.topjohnwu.magisk.databinding.DiffItem
|
||||
import com.topjohnwu.magisk.databinding.RvItem
|
||||
import com.topjohnwu.magisk.core.R as CoreR
|
||||
|
||||
sealed class TappableHeadlineItem : RvItem(), DiffItem<TappableHeadlineItem> {
|
||||
|
||||
abstract val title: Int
|
||||
abstract val icon: Int
|
||||
|
||||
override val layoutRes = R.layout.item_tappable_headline
|
||||
|
||||
// --- listener
|
||||
|
||||
interface Listener {
|
||||
|
||||
fun onItemPressed(item: TappableHeadlineItem)
|
||||
|
||||
}
|
||||
|
||||
// --- objects
|
||||
|
||||
object ThemeMode : TappableHeadlineItem() {
|
||||
override val title = CoreR.string.settings_dark_mode_title
|
||||
override val icon = R.drawable.ic_day_night
|
||||
}
|
||||
|
||||
}
|
||||
10
app/apk/src/main/java/com/topjohnwu/magisk/view/TextItem.kt
Normal file
10
app/apk/src/main/java/com/topjohnwu/magisk/view/TextItem.kt
Normal file
@@ -0,0 +1,10 @@
|
||||
package com.topjohnwu.magisk.view
|
||||
|
||||
import com.topjohnwu.magisk.R
|
||||
import com.topjohnwu.magisk.databinding.DiffItem
|
||||
import com.topjohnwu.magisk.databinding.ItemWrapper
|
||||
import com.topjohnwu.magisk.databinding.RvItem
|
||||
|
||||
class TextItem(override val item: Int) : RvItem(), DiffItem<TextItem>, ItemWrapper<Int> {
|
||||
override val layoutRes = R.layout.item_text
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
package com.topjohnwu.magisk.widget;
|
||||
|
||||
import android.animation.Animator;
|
||||
import android.animation.ObjectAnimator;
|
||||
import android.animation.StateListAnimator;
|
||||
import android.content.Context;
|
||||
import android.os.Parcel;
|
||||
import android.os.Parcelable;
|
||||
import android.util.AttributeSet;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.customview.view.AbsSavedState;
|
||||
import androidx.interpolator.view.animation.FastOutLinearInInterpolator;
|
||||
|
||||
import com.google.android.material.bottomnavigation.BottomNavigationView;
|
||||
import com.topjohnwu.magisk.R;
|
||||
|
||||
public class ConcealableBottomNavigationView extends BottomNavigationView {
|
||||
|
||||
private static final int[] STATE_SET = {
|
||||
R.attr.state_hidden
|
||||
};
|
||||
|
||||
private boolean isHidden;
|
||||
public ConcealableBottomNavigationView(@NonNull Context context) {
|
||||
this(context, null);
|
||||
}
|
||||
|
||||
public ConcealableBottomNavigationView(@NonNull Context context, @Nullable AttributeSet attrs) {
|
||||
this(context, attrs, com.google.android.material.R.attr.bottomNavigationStyle);
|
||||
}
|
||||
|
||||
public ConcealableBottomNavigationView(@NonNull Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
|
||||
this(context, attrs, defStyleAttr, com.google.android.material.R.style.Widget_Design_BottomNavigationView);
|
||||
}
|
||||
|
||||
public ConcealableBottomNavigationView(@NonNull Context context, @Nullable AttributeSet attrs, int defStyleAttr, int defStyleRes) {
|
||||
super(context, attrs, defStyleAttr, defStyleRes);
|
||||
}
|
||||
|
||||
private void recreateAnimator(int height) {
|
||||
Animator toHidden = ObjectAnimator.ofFloat(this, "translationY", height);
|
||||
toHidden.setDuration(175);
|
||||
toHidden.setInterpolator(new FastOutLinearInInterpolator());
|
||||
Animator toUnhidden = ObjectAnimator.ofFloat(this, "translationY", 0);
|
||||
toUnhidden.setDuration(225);
|
||||
toUnhidden.setInterpolator(new FastOutLinearInInterpolator());
|
||||
|
||||
StateListAnimator animator = new StateListAnimator();
|
||||
|
||||
animator.addState(STATE_SET, toHidden);
|
||||
animator.addState(new int[]{}, toUnhidden);
|
||||
|
||||
setStateListAnimator(animator);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
|
||||
super.onMeasure(widthMeasureSpec, heightMeasureSpec);
|
||||
|
||||
recreateAnimator(getMeasuredHeight());
|
||||
}
|
||||
|
||||
@Override
|
||||
protected int[] onCreateDrawableState(int extraSpace) {
|
||||
final int[] drawableState = super.onCreateDrawableState(extraSpace + 1);
|
||||
if (isHidden()) {
|
||||
mergeDrawableStates(drawableState, STATE_SET);
|
||||
}
|
||||
return drawableState;
|
||||
}
|
||||
|
||||
public boolean isHidden() {
|
||||
return isHidden;
|
||||
}
|
||||
|
||||
public void setHidden(boolean raised) {
|
||||
if (isHidden != raised) {
|
||||
isHidden = raised;
|
||||
refreshDrawableState();
|
||||
}
|
||||
}
|
||||
|
||||
@NonNull
|
||||
@Override
|
||||
protected Parcelable onSaveInstanceState() {
|
||||
SavedState state = new SavedState(super.onSaveInstanceState());
|
||||
state.isHidden = isHidden();
|
||||
return state;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onRestoreInstanceState(Parcelable state) {
|
||||
final SavedState ss = (SavedState) state;
|
||||
super.onRestoreInstanceState(ss.getSuperState());
|
||||
|
||||
if (ss.isHidden) {
|
||||
setHidden(isHidden);
|
||||
}
|
||||
}
|
||||
|
||||
static class SavedState extends AbsSavedState {
|
||||
|
||||
public boolean isHidden;
|
||||
|
||||
public SavedState(Parcel source) {
|
||||
super(source, ConcealableBottomNavigationView.class.getClassLoader());
|
||||
isHidden = source.readByte() != 0;
|
||||
}
|
||||
|
||||
public SavedState(Parcelable superState) {
|
||||
super(superState);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void writeToParcel(Parcel out, int flags) {
|
||||
super.writeToParcel(out, flags);
|
||||
out.writeByte(isHidden ? (byte) 1 : (byte) 0);
|
||||
}
|
||||
|
||||
public static final Creator<SavedState> CREATOR = new Creator<>() {
|
||||
|
||||
@Override
|
||||
public SavedState createFromParcel(Parcel source) {
|
||||
return new SavedState(source);
|
||||
}
|
||||
|
||||
@Override
|
||||
public SavedState[] newArray(int size) {
|
||||
return new SavedState[size];
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
15
app/apk/src/main/res/anim/fragment_enter.xml
Normal file
15
app/apk/src/main/res/anim/fragment_enter.xml
Normal file
@@ -0,0 +1,15 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<set xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<alpha
|
||||
android:duration="@android:integer/config_shortAnimTime"
|
||||
android:fromAlpha="0"
|
||||
android:toAlpha="1" />
|
||||
<scale
|
||||
android:duration="@android:integer/config_shortAnimTime"
|
||||
android:fromXScale="0.9"
|
||||
android:fromYScale="0.9"
|
||||
android:pivotX="50%p"
|
||||
android:pivotY="50%p"
|
||||
android:toXScale="1"
|
||||
android:toYScale="1" />
|
||||
</set>
|
||||
15
app/apk/src/main/res/anim/fragment_enter_pop.xml
Normal file
15
app/apk/src/main/res/anim/fragment_enter_pop.xml
Normal file
@@ -0,0 +1,15 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<set xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<alpha
|
||||
android:duration="@android:integer/config_shortAnimTime"
|
||||
android:fromAlpha="0"
|
||||
android:toAlpha="1" />
|
||||
<scale
|
||||
android:duration="@android:integer/config_shortAnimTime"
|
||||
android:fromXScale="1.1"
|
||||
android:fromYScale="1.1"
|
||||
android:pivotX="50%p"
|
||||
android:pivotY="50%p"
|
||||
android:toXScale="1"
|
||||
android:toYScale="1" />
|
||||
</set>
|
||||
15
app/apk/src/main/res/anim/fragment_exit.xml
Normal file
15
app/apk/src/main/res/anim/fragment_exit.xml
Normal file
@@ -0,0 +1,15 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<set xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<alpha
|
||||
android:duration="@android:integer/config_shortAnimTime"
|
||||
android:fromAlpha="1"
|
||||
android:toAlpha="0" />
|
||||
<scale
|
||||
android:duration="@android:integer/config_shortAnimTime"
|
||||
android:fromXScale="1"
|
||||
android:fromYScale="1"
|
||||
android:pivotX="50%p"
|
||||
android:pivotY="50%p"
|
||||
android:toXScale="1.1"
|
||||
android:toYScale="1.1" />
|
||||
</set>
|
||||
15
app/apk/src/main/res/anim/fragment_exit_pop.xml
Normal file
15
app/apk/src/main/res/anim/fragment_exit_pop.xml
Normal file
@@ -0,0 +1,15 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<set xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<alpha
|
||||
android:duration="@android:integer/config_shortAnimTime"
|
||||
android:fromAlpha="1"
|
||||
android:toAlpha="0" />
|
||||
<scale
|
||||
android:duration="@android:integer/config_shortAnimTime"
|
||||
android:fromXScale="1"
|
||||
android:fromYScale="1"
|
||||
android:pivotX="50%p"
|
||||
android:pivotY="50%p"
|
||||
android:toXScale="0.9"
|
||||
android:toYScale="0.9" />
|
||||
</set>
|
||||
11
app/apk/src/main/res/drawable/bg_line_bottom_rounded.xml
Normal file
11
app/apk/src/main/res/drawable/bg_line_bottom_rounded.xml
Normal file
@@ -0,0 +1,11 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<selector xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<item>
|
||||
<shape android:shape="rectangle">
|
||||
<solid android:color="@android:color/black" />
|
||||
<corners android:bottomLeftRadius="2dp" android:bottomRightRadius="2dp" />
|
||||
</shape>
|
||||
</item>
|
||||
|
||||
</selector>
|
||||
11
app/apk/src/main/res/drawable/bg_line_top_rounded.xml
Normal file
11
app/apk/src/main/res/drawable/bg_line_top_rounded.xml
Normal file
@@ -0,0 +1,11 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<selector xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<item>
|
||||
<shape android:shape="rectangle">
|
||||
<solid android:color="@android:color/black" />
|
||||
<corners android:topLeftRadius="2dp" android:topRightRadius="2dp" />
|
||||
</shape>
|
||||
</item>
|
||||
|
||||
</selector>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user