Add action.sh for user to manually trigger modules' functionality from app

This commit is contained in:
LoveSy 2024-09-01 23:08:38 +08:00 committed by John Wu
parent a9f8c20703
commit fbebb6ac10
13 changed files with 363 additions and 1 deletions

View File

@ -0,0 +1,98 @@
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 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.FragmentActionMd2Binding
import com.topjohnwu.magisk.ui.flash.FlashViewModel
import timber.log.Timber
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) {
activity?.supportActionBar?.setSubtitle(
when (it) {
ActionViewModel.State.RUNNING -> CoreR.string.running
ActionViewModel.State.SUCCESS -> CoreR.string.done
ActionViewModel.State.FAILED -> CoreR.string.failure
}
)
if (it != ActionViewModel.State.RUNNING) {
binding.closeBtn.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_NOSENSOR
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 (!binding.closeBtn.isVisible) return true
return super.onBackPressed()
}
override fun onPreBind(binding: FragmentActionMd2Binding) = Unit
}

View File

@ -0,0 +1,84 @@
package com.topjohnwu.magisk.ui.module
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.Info
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.tasks.RunAction
import com.topjohnwu.magisk.core.utils.MediaStoreUtils
import com.topjohnwu.magisk.core.utils.MediaStoreUtils.outputStream
import com.topjohnwu.magisk.databinding.set
import com.topjohnwu.magisk.events.SnackbarEvent
import com.topjohnwu.magisk.ui.flash.ConsoleItem
import com.topjohnwu.superuser.CallbackList
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
class ActionViewModel : BaseViewModel() {
enum class State {
RUNNING, SUCCESS, FAILED
}
private val _state = MutableLiveData(State.RUNNING)
val state: LiveData<State> get() = _state
val running = state.map { it == State.RUNNING }
val items = ObservableArrayList<ConsoleItem>()
lateinit var args: ActionFragmentArgs
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 startRunAction() {
viewModelScope.launch {
onResult(RunAction(args.id, outItems, logItems).exec())
}
}
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(
args.name,
System.currentTimeMillis().toTime(timeFormatStandard)
)
val file = MediaStoreUtils.getFile(name)
file.uri.outputStream().bufferedWriter().use { writer ->
synchronized(logItems) {
logItems.forEach {
writer.write(it)
writer.newLine()
}
}
}
SnackbarEvent(file.toString()).publish()
}
}
}

View File

@ -25,6 +25,7 @@ class LocalModuleRvItem(
override val layoutRes = R.layout.item_module_md2
val showNotice: Boolean
val showAction: Boolean
val noticeText: TextHolder
init {
@ -35,6 +36,7 @@ class LocalModuleRvItem(
showNotice = zygiskUnloaded ||
(Info.isZygiskEnabled && isRiru) ||
(!Info.isZygiskEnabled && isZygisk)
showAction = item.hasAction && !showNotice
noticeText =
when {
zygiskUnloaded -> CoreR.string.zygisk_module_unloaded.asText()

View File

@ -4,8 +4,10 @@ import android.net.Uri
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.base.ContentResultCallback
import com.topjohnwu.magisk.core.model.module.LocalModule
@ -96,6 +98,10 @@ class ModuleViewModel : AsyncLoadViewModel() {
}
}
fun runAction(id: String, name: String) {
MainDirections.actionActionFragment(id, name).navigate()
}
companion object {
private val uri = MutableLiveData<Uri?>()
}

View File

@ -0,0 +1,5 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:height="24dp" android:tint="#000000" android:viewportHeight="24" android:viewportWidth="24" android:width="24dp">
<path android:fillColor="@android:color/white" android:pathData="M8,5v14l11,-7z"/>
</vector>

View File

@ -0,0 +1,68 @@
<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools">
<data>
<variable
name="viewModel"
type="com.topjohnwu.magisk.ui.module.ActionViewModel" />
</data>
<androidx.coordinatorlayout.widget.CoordinatorLayout
android:layout_width="match_parent"
android:layout_height="match_parent">
<HorizontalScrollView
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_marginTop="@dimen/internal_action_bar_size"
app:layout_fitsSystemWindowsInsets="top"
tools:layout_marginTop="@dimen/internal_action_bar_size">
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/flash_content"
scrollToLast="@{true}"
android:layout_width="wrap_content"
android:layout_height="match_parent"
android:clipToPadding="false"
android:orientation="vertical"
app:fitsSystemWindowsInsets="start|end|bottom"
app:items="@{viewModel.items}"
app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"
app:layout_behavior="@string/appbar_scrolling_view_behavior"
tools:listitem="@layout/item_console_md2" />
</HorizontalScrollView>
<com.google.android.material.floatingactionbutton.ExtendedFloatingActionButton
android:id="@+id/close_btn"
android:visibility="gone"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="bottom|end"
android:layout_margin="@dimen/l1"
android:layout_marginBottom="@dimen/l1"
android:clickable="true"
android:enabled="true"
android:focusable="true"
android:text="@string/close"
android:textAllCaps="false"
android:textColor="?colorOnPrimary"
android:textStyle="bold"
app:backgroundTint="?colorPrimary"
app:icon="@drawable/ic_close_md2"
app:iconTint="?colorOnPrimary"
app:layout_fitsSystemWindowsInsets="bottom" />
<androidx.coordinatorlayout.widget.CoordinatorLayout
android:id="@+id/snackbar_container"
android:layout_width="match_parent"
android:layout_height="match_parent"
app:fitsSystemWindowsInsets="top|bottom" />
</androidx.coordinatorlayout.widget.CoordinatorLayout>
</layout>

View File

@ -189,12 +189,32 @@
android:textColor="?colorError"
app:layout_constraintBottom_toBottomOf="@+id/module_remove"
app:layout_constraintEnd_toStartOf="@+id/bottom_bar_barrier"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintStart_toEndOf="@id/module_config"
app:layout_constraintTop_toTopOf="@+id/module_remove"
tools:lines="2"
tools:text="@tools:sample/lorem/random"
tools:visibility="visible" />
<Button
android:id="@+id/module_config"
style="@style/WidgetFoundation.Button.Text"
goneUnless="@{item.showAction}"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:clickable="true"
android:enabled="@{item.enabled}"
android:focusable="true"
android:onClick="@{() -> viewModel.runAction(item.item.id, item.item.name)}"
android:text="@string/module_action"
android:textAllCaps="false"
android:visibility="gone"
app:icon="@drawable/ic_action_md2"
app:iconGravity="textStart"
app:layout_constraintBottom_toBottomOf="@+id/module_remove"
app:layout_constraintTop_toTopOf="@+id/module_remove"
app:layout_constraintStart_toStartOf="parent"
app:srcCompat="@drawable/ic_download_md2" />
</androidx.constraintlayout.widget.ConstraintLayout>
</com.google.android.material.card.MaterialCardView>

View File

@ -53,6 +53,21 @@
</fragment>
<fragment
android:id="@+id/actionFragment"
android:name="com.topjohnwu.magisk.ui.module.ActionFragment"
android:label="ActionFragment"
tools:layout="@layout/fragment_action_md2" >
<argument
android:name="id"
app:argType="string" />
<argument
android:name="name"
app:argType="string" />
</fragment>
<fragment
android:id="@+id/installFragment"
android:name="com.topjohnwu.magisk.ui.install.InstallFragment"
@ -152,4 +167,12 @@
app:popEnterAnim="@anim/fragment_enter_pop"
app:popExitAnim="@anim/fragment_exit_pop" />
<action
android:id="@+id/action_actionFragment"
app:destination="@id/actionFragment"
app:enterAnim="@anim/fragment_enter"
app:exitAnim="@anim/fragment_exit"
app:popEnterAnim="@anim/fragment_enter_pop"
app:popExitAnim="@anim/fragment_exit_pop" />
</navigation>

View File

@ -37,6 +37,7 @@ data class LocalModule(
val isRiru: Boolean get() = (id == "riru-core") || riruFolder.exists()
val isZygisk: Boolean get() = zygiskFolder.exists()
val zygiskUnloaded: Boolean get() = unloaded.exists()
val hasAction: Boolean;
var enable: Boolean
get() = !disableFile.exists()
@ -100,6 +101,8 @@ data class LocalModule(
if (name.isEmpty()) {
name = id
}
hasAction = RootUtils.fs.getFile(path, "action.sh").exists()
}
suspend fun fetch(): Boolean {

View File

@ -0,0 +1,42 @@
package com.topjohnwu.magisk.core.tasks
import android.net.Uri
import androidx.core.net.toFile
import com.topjohnwu.magisk.core.AppContext
import com.topjohnwu.magisk.core.Const
import com.topjohnwu.magisk.core.ktx.writeTo
import com.topjohnwu.magisk.core.utils.MediaStoreUtils.displayName
import com.topjohnwu.magisk.core.utils.MediaStoreUtils.inputStream
import com.topjohnwu.magisk.core.utils.unzip
import com.topjohnwu.superuser.Shell
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import timber.log.Timber
import java.io.File
import java.io.FileNotFoundException
import java.io.IOException
open class RunAction(
private val module: String,
private val console: MutableList<String>,
private val logs: MutableList<String>
) {
@Throws(IOException::class)
private suspend fun run(): Boolean {
return Shell.cmd("run_action \'$module\'").to(console, logs).exec().isSuccess
}
open suspend fun exec() = withContext(Dispatchers.IO) {
try {
if (!run()) {
console.add("! Run action failed")
false
} else {
true
}
} catch (e: IOException) {
Timber.e(e)
false
}
}
}

View File

@ -109,6 +109,7 @@
<string name="reboot_safe_mode">Safe mode</string>
<string name="module_version_author">%1$s by %2$s</string>
<string name="module_state_remove">Remove</string>
<string name="module_action">Action</string>
<string name="module_state_restore">Restore</string>
<string name="module_action_install_external">Install from storage</string>
<string name="update_available">Update Available</string>
@ -211,8 +212,10 @@
<string name="repo_install_title">Install %1$s %2$s(%3$d)</string>
<string name="download">Download</string>
<string name="reboot">Reboot</string>
<string name="close">Close</string>
<string name="release_notes">Release notes</string>
<string name="flashing">Flashing…</string>
<string name="running">Running...</string>
<string name="done">Done!</string>
<string name="failure">Failed!</string>
<string name="hide_app_title">Hiding the Magisk app…</string>

View File

@ -54,6 +54,7 @@ A Magisk module is a folder placed in `/data/adb/modules` with the structure bel
│ ├── post-fs-data.sh <--- This script will be executed in post-fs-data
│ ├── service.sh <--- This script will be executed in late_start service
| ├── uninstall.sh <--- This script will be executed when Magisk removes your module
| ├── action.sh <--- This script will be executed when user click the action button in Magisk app
│ ├── system.prop <--- Properties in this file will be loaded as system properties by resetprop
│ ├── sepolicy.rule <--- Additional custom sepolicy rules
│ │

View File

@ -189,6 +189,13 @@ printvar() {
eval echo $1=\$$1
}
run_action() {
local MODID="$1"
cd "/data/adb/modules/$MODID"
ASH_STANDALONE=1 sh ./action.sh
cd /
}
##########################
# Non-root util_functions
##########################