mirror of
https://github.com/topjohnwu/Magisk.git
synced 2026-07-03 14:08:39 +02:00
Add terminal view for module flash/action screens with TTY support
Replace plain text LazyColumn with com.termux:terminal-view for module install (FlashZip) and module action screens, enabling real TTY control (ANSI escape sequences, cursor movement, colors) for third-party module scripts. Uses libsu's initialized shell environment with stdout/stderr redirected to a PTY slave, preserving the full Magisk environment while giving scripts a real terminal. MagiskInstaller operations (Direct/Patch/Uninstall/SecondSlot) retain the original LazyColumn plain-text display since they use our own scripts that don't need TTY features. Made-with: Cursor
This commit is contained in:
@@ -51,4 +51,7 @@ dependencies {
|
||||
implementation(libs.navigation3.runtime)
|
||||
implementation(libs.navigationevent.compose)
|
||||
implementation(libs.lifecycle.viewmodel.navigation3)
|
||||
|
||||
// Terminal
|
||||
implementation(libs.termux.terminal.view)
|
||||
}
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools">
|
||||
|
||||
<uses-sdk tools:overrideLibrary="com.termux.view, com.termux.terminal" />
|
||||
|
||||
<application android:localeConfig="@xml/locale_config">
|
||||
<activity
|
||||
android:name=".ui.MainActivity"
|
||||
|
||||
@@ -19,6 +19,7 @@ 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.ui.terminal.TerminalComposeView
|
||||
import top.yukonga.miuix.kmp.basic.Icon
|
||||
import top.yukonga.miuix.kmp.basic.IconButton
|
||||
import top.yukonga.miuix.kmp.basic.MiuixScrollBehavior
|
||||
@@ -34,16 +35,8 @@ import com.topjohnwu.magisk.core.R as CoreR
|
||||
fun FlashScreen(viewModel: FlashViewModel, onBack: () -> Unit) {
|
||||
val flashState by viewModel.flashState.collectAsState()
|
||||
val showReboot by viewModel.showReboot.collectAsState()
|
||||
val items = viewModel.consoleItems
|
||||
val listState = rememberLazyListState()
|
||||
val finished = flashState != FlashViewModel.State.FLASHING
|
||||
|
||||
LaunchedEffect(items.size) {
|
||||
if (items.isNotEmpty()) {
|
||||
listState.animateScrollToItem(items.size - 1)
|
||||
}
|
||||
}
|
||||
|
||||
val statusText = when (flashState) {
|
||||
FlashViewModel.State.FLASHING -> stringResource(CoreR.string.flashing)
|
||||
FlashViewModel.State.SUCCESS -> stringResource(CoreR.string.done)
|
||||
@@ -98,23 +91,44 @@ fun FlashScreen(viewModel: FlashViewModel, onBack: () -> Unit) {
|
||||
},
|
||||
popupHost = { }
|
||||
) { padding ->
|
||||
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()
|
||||
)
|
||||
if (viewModel.useTerminal) {
|
||||
val session by viewModel.termSession.collectAsState()
|
||||
TerminalComposeView(
|
||||
session = session,
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding),
|
||||
onViewCreated = { viewModel.setTerminalView(it) },
|
||||
onEmulatorReady = { viewModel.onEmulatorReady() },
|
||||
)
|
||||
} 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,24 +1,39 @@
|
||||
package com.topjohnwu.magisk.ui.flash
|
||||
|
||||
import android.net.Uri
|
||||
import androidx.compose.runtime.mutableStateListOf
|
||||
import androidx.core.net.toFile
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.termux.terminal.TerminalSession
|
||||
import com.termux.view.TerminalView
|
||||
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.tasks.FlashZip
|
||||
import com.topjohnwu.magisk.core.ktx.writeTo
|
||||
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.ui.terminal.TerminalSessionCallback
|
||||
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() {
|
||||
|
||||
@@ -32,10 +47,32 @@ class FlashViewModel : BaseViewModel() {
|
||||
private val _showReboot = MutableStateFlow(Info.isRooted)
|
||||
val showReboot: StateFlow<Boolean> = _showReboot.asStateFlow()
|
||||
|
||||
val consoleItems = mutableStateListOf<String>()
|
||||
var flashAction: String = ""
|
||||
var flashUri: android.net.Uri? = null
|
||||
var flashUri: Uri? = null
|
||||
|
||||
// --- TerminalView mode (FLASH_ZIP) ---
|
||||
|
||||
private val _termSession = MutableStateFlow<TerminalSession?>(null)
|
||||
val termSession: StateFlow<TerminalSession?> = _termSession.asStateFlow()
|
||||
|
||||
private val emulatorReady = CompletableDeferred<Unit>()
|
||||
val sessionCallback = TerminalSessionCallback()
|
||||
|
||||
val useTerminal get() = flashAction == Const.Value.FLASH_ZIP
|
||||
|
||||
fun setTerminalView(view: TerminalView) {
|
||||
sessionCallback.terminalView = view
|
||||
}
|
||||
|
||||
fun onEmulatorReady() {
|
||||
if (!emulatorReady.isCompleted) {
|
||||
emulatorReady.complete(Unit)
|
||||
}
|
||||
}
|
||||
|
||||
// --- LazyColumn mode (MagiskInstaller) ---
|
||||
|
||||
val consoleItems = mutableStateListOf<String>()
|
||||
private val logItems = mutableListOf<String>().synchronized()
|
||||
private val outItems = object : CallbackList<String>() {
|
||||
override fun onAddElement(e: String?) {
|
||||
@@ -45,40 +82,46 @@ class FlashViewModel : BaseViewModel() {
|
||||
}
|
||||
}
|
||||
|
||||
// --- Shared ---
|
||||
|
||||
fun startFlashing() {
|
||||
val action = flashAction
|
||||
val uri = flashUri
|
||||
|
||||
viewModelScope.launch {
|
||||
val result = when (action) {
|
||||
when (action) {
|
||||
Const.Value.FLASH_ZIP -> {
|
||||
uri ?: return@launch
|
||||
FlashZip(uri, outItems, logItems).exec()
|
||||
flashZipWithPty(uri)
|
||||
}
|
||||
Const.Value.UNINSTALL -> {
|
||||
_showReboot.value = false
|
||||
MagiskInstaller.Uninstall(outItems, logItems).exec()
|
||||
onResult(withContext(Dispatchers.IO) {
|
||||
MagiskInstaller.Uninstall(outItems, logItems).exec()
|
||||
})
|
||||
}
|
||||
Const.Value.FLASH_MAGISK -> {
|
||||
if (Info.isEmulator)
|
||||
MagiskInstaller.Emulator(outItems, logItems).exec()
|
||||
else
|
||||
MagiskInstaller.Direct(outItems, logItems).exec()
|
||||
onResult(withContext(Dispatchers.IO) {
|
||||
if (Info.isEmulator)
|
||||
MagiskInstaller.Emulator(outItems, logItems).exec()
|
||||
else
|
||||
MagiskInstaller.Direct(outItems, logItems).exec()
|
||||
})
|
||||
}
|
||||
Const.Value.FLASH_INACTIVE_SLOT -> {
|
||||
_showReboot.value = false
|
||||
MagiskInstaller.SecondSlot(outItems, logItems).exec()
|
||||
onResult(withContext(Dispatchers.IO) {
|
||||
MagiskInstaller.SecondSlot(outItems, logItems).exec()
|
||||
})
|
||||
}
|
||||
Const.Value.PATCH_FILE -> {
|
||||
uri ?: return@launch
|
||||
_showReboot.value = false
|
||||
MagiskInstaller.Patch(uri, outItems, logItems).exec()
|
||||
}
|
||||
else -> {
|
||||
return@launch
|
||||
onResult(withContext(Dispatchers.IO) {
|
||||
MagiskInstaller.Patch(uri, outItems, logItems).exec()
|
||||
})
|
||||
}
|
||||
}
|
||||
onResult(result)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -86,6 +129,88 @@ class FlashViewModel : BaseViewModel() {
|
||||
_flashState.value = if (success) State.SUCCESS else State.FAILED
|
||||
}
|
||||
|
||||
private suspend fun flashZipWithPty(uri: Uri) {
|
||||
val session = TerminalSession(
|
||||
"/system/bin/sh", "/",
|
||||
arrayOf("sh", "-c", "exec sleep 2147483647"),
|
||||
arrayOf("TERM=xterm-256color"),
|
||||
5000, sessionCallback
|
||||
)
|
||||
_termSession.value = session
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
val (error, prepResult) = result
|
||||
if (prepResult == null) {
|
||||
writeToPty(session, "! ${error ?: "Installation failed"}")
|
||||
_flashState.value = State.FAILED
|
||||
return
|
||||
}
|
||||
|
||||
val (dir, zipFile, displayName) = prepResult
|
||||
val ptyPath = getPtyPath(session)
|
||||
if (ptyPath == null) {
|
||||
_flashState.value = State.FAILED
|
||||
return
|
||||
}
|
||||
|
||||
val success = withContext(Dispatchers.IO) {
|
||||
Shell.cmd(
|
||||
"(export TERM=xterm-256color; " +
|
||||
"echo '- Installing $displayName'; " +
|
||||
"sh $dir/update-binary dummy 1 '${zipFile.absolutePath}'; " +
|
||||
"EXIT=\$?; " +
|
||||
"if [ \$EXIT -ne 0 ]; then echo '! Installation failed'; fi; " +
|
||||
"exit \$EXIT) >$ptyPath 2>&1"
|
||||
).exec().isSuccess
|
||||
}
|
||||
|
||||
Shell.cmd("cd /", "rm -rf $dir ${Const.TMPDIR}").submit()
|
||||
_flashState.value = if (success) State.SUCCESS else State.FAILED
|
||||
}
|
||||
|
||||
private suspend fun getPtyPath(session: TerminalSession): String? {
|
||||
return withContext(Dispatchers.IO) {
|
||||
Shell.cmd("readlink /proc/${session.pid}/fd/0").exec().out.firstOrNull()
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun writeToPty(session: TerminalSession, message: String) {
|
||||
val ptyPath = getPtyPath(session) ?: return
|
||||
withContext(Dispatchers.IO) {
|
||||
Shell.cmd("echo '$message' >$ptyPath").exec()
|
||||
}
|
||||
}
|
||||
|
||||
fun saveLog() {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
val name = "magisk_install_log_%s.log".format(
|
||||
@@ -93,10 +218,15 @@ class FlashViewModel : BaseViewModel() {
|
||||
)
|
||||
val file = MediaStoreUtils.getFile(name)
|
||||
file.uri.outputStream().bufferedWriter().use { writer ->
|
||||
synchronized(logItems) {
|
||||
logItems.forEach {
|
||||
writer.write(it)
|
||||
writer.newLine()
|
||||
val transcript = _termSession.value?.emulator?.screen?.transcriptText
|
||||
if (transcript != null) {
|
||||
writer.write(transcript)
|
||||
} else {
|
||||
synchronized(logItems) {
|
||||
logItems.forEach {
|
||||
writer.write(it)
|
||||
writer.newLine()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -105,4 +235,9 @@ class FlashViewModel : BaseViewModel() {
|
||||
}
|
||||
|
||||
fun restartPressed() = reboot()
|
||||
|
||||
override fun onCleared() {
|
||||
super.onCleared()
|
||||
_termSession.value?.finishIfRunning()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,30 +1,21 @@
|
||||
package com.topjohnwu.magisk.ui.module
|
||||
|
||||
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.ui.terminal.TerminalComposeView
|
||||
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
|
||||
@@ -33,16 +24,9 @@ import com.topjohnwu.magisk.core.R as CoreR
|
||||
@Composable
|
||||
fun ActionScreen(viewModel: ActionViewModel, actionName: String, onBack: () -> Unit) {
|
||||
val actionState by viewModel.actionState.collectAsState()
|
||||
val items = viewModel.consoleItems
|
||||
val listState = rememberLazyListState()
|
||||
val session by viewModel.termSession.collectAsState()
|
||||
val finished = actionState != ActionViewModel.State.RUNNING
|
||||
|
||||
LaunchedEffect(items.size) {
|
||||
if (items.isNotEmpty()) {
|
||||
listState.animateScrollToItem(items.size - 1)
|
||||
}
|
||||
}
|
||||
|
||||
val scrollBehavior = MiuixScrollBehavior()
|
||||
Scaffold(
|
||||
topBar = {
|
||||
@@ -79,24 +63,13 @@ fun ActionScreen(viewModel: ActionViewModel, actionName: String, onBack: () -> U
|
||||
},
|
||||
popupHost = { }
|
||||
) { padding ->
|
||||
LazyColumn(
|
||||
state = listState,
|
||||
TerminalComposeView(
|
||||
session = session,
|
||||
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()
|
||||
)
|
||||
}
|
||||
}
|
||||
.padding(padding),
|
||||
onViewCreated = { viewModel.setTerminalView(it) },
|
||||
onEmulatorReady = { viewModel.onEmulatorReady() },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,23 +1,23 @@
|
||||
package com.topjohnwu.magisk.ui.module
|
||||
|
||||
import androidx.compose.runtime.mutableStateListOf
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.termux.terminal.TerminalSession
|
||||
import com.termux.view.TerminalView
|
||||
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.superuser.CallbackList
|
||||
import com.topjohnwu.magisk.ui.terminal.TerminalSessionCallback
|
||||
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.IOException
|
||||
|
||||
class ActionViewModel : BaseViewModel() {
|
||||
|
||||
@@ -28,34 +28,54 @@ class ActionViewModel : BaseViewModel() {
|
||||
private val _actionState = MutableStateFlow(State.RUNNING)
|
||||
val actionState: StateFlow<State> = _actionState.asStateFlow()
|
||||
|
||||
val consoleItems = mutableStateListOf<String>()
|
||||
private val _termSession = MutableStateFlow<TerminalSession?>(null)
|
||||
val termSession: StateFlow<TerminalSession?> = _termSession.asStateFlow()
|
||||
|
||||
var actionId: String = ""
|
||||
var actionName: String = ""
|
||||
|
||||
private val logItems = mutableListOf<String>().synchronized()
|
||||
private val outItems = object : CallbackList<String>() {
|
||||
override fun onAddElement(e: String?) {
|
||||
e ?: return
|
||||
consoleItems.add(e)
|
||||
logItems.add(e)
|
||||
private val emulatorReady = CompletableDeferred<Unit>()
|
||||
|
||||
val sessionCallback = TerminalSessionCallback()
|
||||
|
||||
fun setTerminalView(view: TerminalView) {
|
||||
sessionCallback.terminalView = view
|
||||
}
|
||||
|
||||
fun onEmulatorReady() {
|
||||
if (!emulatorReady.isCompleted) {
|
||||
emulatorReady.complete(Unit)
|
||||
}
|
||||
}
|
||||
|
||||
fun startRunAction() = viewModelScope.launch {
|
||||
onResult(withContext(Dispatchers.IO) {
|
||||
try {
|
||||
Shell.cmd("run_action '${actionId}'")
|
||||
.to(outItems, logItems)
|
||||
.exec().isSuccess
|
||||
} catch (e: IOException) {
|
||||
Timber.e(e)
|
||||
false
|
||||
}
|
||||
})
|
||||
}
|
||||
fun startRunAction() {
|
||||
viewModelScope.launch {
|
||||
val session = TerminalSession(
|
||||
"/system/bin/sh", "/",
|
||||
arrayOf("sh", "-c", "exec sleep 2147483647"),
|
||||
arrayOf("TERM=xterm-256color"),
|
||||
5000, sessionCallback
|
||||
)
|
||||
_termSession.value = session
|
||||
emulatorReady.await()
|
||||
|
||||
private fun onResult(success: Boolean) {
|
||||
_actionState.value = if (success) State.SUCCESS else State.FAILED
|
||||
val ptyPath = withContext(Dispatchers.IO) {
|
||||
Shell.cmd("readlink /proc/${session.pid}/fd/0").exec().out.firstOrNull()
|
||||
}
|
||||
if (ptyPath == null) {
|
||||
_actionState.value = State.FAILED
|
||||
return@launch
|
||||
}
|
||||
|
||||
val success = withContext(Dispatchers.IO) {
|
||||
Shell.cmd(
|
||||
"(export TERM=xterm-256color; run_action '$actionId') >$ptyPath 2>&1"
|
||||
).exec().isSuccess
|
||||
}
|
||||
|
||||
_actionState.value = if (success) State.SUCCESS else State.FAILED
|
||||
}
|
||||
}
|
||||
|
||||
fun saveLog() {
|
||||
@@ -66,14 +86,24 @@ class ActionViewModel : BaseViewModel() {
|
||||
)
|
||||
val file = MediaStoreUtils.getFile(name)
|
||||
file.uri.outputStream().bufferedWriter().use { writer ->
|
||||
synchronized(logItems) {
|
||||
logItems.forEach {
|
||||
writer.write(it)
|
||||
writer.newLine()
|
||||
val transcript = _termSession.value?.emulator?.screen?.transcriptText
|
||||
if (transcript != null) {
|
||||
writer.write(transcript)
|
||||
} else {
|
||||
synchronized(logItems) {
|
||||
logItems.forEach {
|
||||
writer.write(it)
|
||||
writer.newLine()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
showSnackbar(file.toString())
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCleared() {
|
||||
super.onCleared()
|
||||
_termSession.value?.finishIfRunning()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
package com.topjohnwu.magisk.ui.terminal
|
||||
|
||||
import android.graphics.Color
|
||||
import android.graphics.Typeface
|
||||
import android.util.TypedValue
|
||||
import android.view.KeyEvent
|
||||
import android.view.MotionEvent
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import com.termux.terminal.TerminalSession
|
||||
import com.termux.view.TerminalView
|
||||
import com.termux.view.TerminalViewClient
|
||||
|
||||
@Composable
|
||||
fun TerminalComposeView(
|
||||
session: TerminalSession?,
|
||||
modifier: Modifier = Modifier,
|
||||
onViewCreated: (TerminalView) -> Unit = {},
|
||||
onEmulatorReady: () -> Unit = {},
|
||||
) {
|
||||
AndroidView(
|
||||
factory = { context ->
|
||||
val textSizePx = TypedValue.applyDimension(
|
||||
TypedValue.COMPLEX_UNIT_SP, 12f, context.resources.displayMetrics
|
||||
).toInt()
|
||||
TerminalView(context, null).apply {
|
||||
setBackgroundColor(Color.BLACK)
|
||||
setTextSize(textSizePx)
|
||||
setTypeface(Typeface.MONOSPACE)
|
||||
keepScreenOn = true
|
||||
setTerminalViewClient(ReadOnlyTerminalViewClient(onEmulatorReady))
|
||||
onViewCreated(this)
|
||||
}
|
||||
},
|
||||
update = { view ->
|
||||
if (session != null && view.mTermSession != session) {
|
||||
view.attachSession(session)
|
||||
}
|
||||
},
|
||||
modifier = modifier,
|
||||
)
|
||||
}
|
||||
|
||||
private class ReadOnlyTerminalViewClient(
|
||||
private val onEmulatorReady: () -> Unit,
|
||||
) : TerminalViewClient {
|
||||
override fun onScale(scale: Float) = 1.0f
|
||||
override fun onSingleTapUp(e: MotionEvent) {}
|
||||
override fun shouldBackButtonBeMappedToEscape() = false
|
||||
override fun shouldEnforceCharBasedInput() = false
|
||||
override fun shouldUseCtrlSpaceWorkaround() = false
|
||||
override fun isTerminalViewSelected() = true
|
||||
override fun copyModeChanged(copyMode: Boolean) {}
|
||||
override fun onKeyDown(keyCode: Int, e: KeyEvent, session: TerminalSession) = false
|
||||
override fun onKeyUp(keyCode: Int, e: KeyEvent) = false
|
||||
override fun onLongPress(event: MotionEvent) = false
|
||||
override fun readControlKey() = false
|
||||
override fun readAltKey() = false
|
||||
override fun readShiftKey() = false
|
||||
override fun readFnKey() = false
|
||||
override fun onCodePoint(codePoint: Int, ctrlDown: Boolean, session: TerminalSession) = false
|
||||
override fun onEmulatorSet() { onEmulatorReady() }
|
||||
override fun logError(tag: String, message: String) {}
|
||||
override fun logWarn(tag: String, message: String) {}
|
||||
override fun logInfo(tag: String, message: String) {}
|
||||
override fun logDebug(tag: String, message: String) {}
|
||||
override fun logVerbose(tag: String, message: String) {}
|
||||
override fun logStackTraceWithMessage(tag: String, message: String, e: Exception) {}
|
||||
override fun logStackTrace(tag: String, e: Exception) {}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package com.topjohnwu.magisk.ui.terminal
|
||||
|
||||
import com.termux.terminal.TerminalSession
|
||||
import com.termux.terminal.TerminalSessionClient
|
||||
import com.termux.view.TerminalView
|
||||
import timber.log.Timber
|
||||
|
||||
class TerminalSessionCallback(
|
||||
private val onFinished: (TerminalSession) -> Unit = {},
|
||||
) : TerminalSessionClient {
|
||||
|
||||
var terminalView: TerminalView? = null
|
||||
|
||||
override fun onTextChanged(changedSession: TerminalSession) {
|
||||
terminalView?.onScreenUpdated()
|
||||
}
|
||||
|
||||
override fun onTitleChanged(changedSession: TerminalSession) {}
|
||||
|
||||
override fun onSessionFinished(finishedSession: TerminalSession) {
|
||||
onFinished(finishedSession)
|
||||
}
|
||||
|
||||
override fun onCopyTextToClipboard(session: TerminalSession, text: String) {}
|
||||
|
||||
override fun onPasteTextFromClipboard(session: TerminalSession?) {}
|
||||
|
||||
override fun onBell(session: TerminalSession) {}
|
||||
|
||||
override fun onColorsChanged(session: TerminalSession) {}
|
||||
|
||||
override fun onTerminalCursorStateChange(state: Boolean) {}
|
||||
|
||||
override fun getTerminalCursorStyle(): Int = 0
|
||||
|
||||
override fun logError(tag: String, message: String) { Timber.tag(tag).e(message) }
|
||||
override fun logWarn(tag: String, message: String) { Timber.tag(tag).w(message) }
|
||||
override fun logInfo(tag: String, message: String) { Timber.tag(tag).i(message) }
|
||||
override fun logDebug(tag: String, message: String) { Timber.tag(tag).d(message) }
|
||||
override fun logVerbose(tag: String, message: String) { Timber.tag(tag).v(message) }
|
||||
|
||||
override fun logStackTraceWithMessage(tag: String, message: String, e: Exception) {
|
||||
Timber.tag(tag).e(e, message)
|
||||
}
|
||||
|
||||
override fun logStackTrace(tag: String, e: Exception) {
|
||||
Timber.tag(tag).e(e)
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,7 @@ activity-compose = "1.12.4"
|
||||
miuix = "0.8.5"
|
||||
navigation3 = "1.1.0-alpha05"
|
||||
navigationevent = "1.0.2"
|
||||
termux-terminal = "0.118.0"
|
||||
|
||||
[libraries]
|
||||
bcpkix = { module = "org.bouncycastle:bcpkix-jdk18on", version = "1.83" }
|
||||
@@ -63,6 +64,9 @@ navigation3-runtime = { module = "androidx.navigation3:navigation3-runtime", ver
|
||||
navigationevent-compose = { module = "androidx.navigationevent:navigationevent-compose", version.ref = "navigationevent" }
|
||||
lifecycle-viewmodel-navigation3 = { module = "androidx.lifecycle:lifecycle-viewmodel-navigation3", version.ref = "lifecycle" }
|
||||
|
||||
# Terminal
|
||||
termux-terminal-view = { module = "com.termux.termux-app:terminal-view", version.ref = "termux-terminal" }
|
||||
|
||||
# Build plugins
|
||||
android-gradle-plugin = { module = "com.android.tools.build:gradle", version.ref = "android" }
|
||||
|
||||
|
||||
Reference in New Issue
Block a user