From bc3f6b1a999b2b4888515caace736e8e6990ea3a Mon Sep 17 00:00:00 2001 From: LoveSy Date: Wed, 4 Mar 2026 22:53:28 +0800 Subject: [PATCH] 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 --- app/apk/build.gradle.kts | 3 + app/apk/src/main/AndroidManifest.xml | 2 + .../topjohnwu/magisk/ui/flash/FlashScreen.kt | 64 ++++--- .../magisk/ui/flash/FlashViewModel.kt | 175 ++++++++++++++++-- .../magisk/ui/module/ActionScreen.kt | 43 +---- .../magisk/ui/module/ActionViewModel.kt | 86 ++++++--- .../magisk/ui/terminal/TerminalComposeView.kt | 71 +++++++ .../ui/terminal/TerminalSessionCallback.kt | 49 +++++ app/gradle/libs.versions.toml | 4 + 9 files changed, 389 insertions(+), 108 deletions(-) create mode 100644 app/apk/src/main/java/com/topjohnwu/magisk/ui/terminal/TerminalComposeView.kt create mode 100644 app/apk/src/main/java/com/topjohnwu/magisk/ui/terminal/TerminalSessionCallback.kt diff --git a/app/apk/build.gradle.kts b/app/apk/build.gradle.kts index fa799aa1c..59934e7ac 100644 --- a/app/apk/build.gradle.kts +++ b/app/apk/build.gradle.kts @@ -51,4 +51,7 @@ dependencies { implementation(libs.navigation3.runtime) implementation(libs.navigationevent.compose) implementation(libs.lifecycle.viewmodel.navigation3) + + // Terminal + implementation(libs.termux.terminal.view) } diff --git a/app/apk/src/main/AndroidManifest.xml b/app/apk/src/main/AndroidManifest.xml index a55c4e523..450de134c 100644 --- a/app/apk/src/main/AndroidManifest.xml +++ b/app/apk/src/main/AndroidManifest.xml @@ -2,6 +2,8 @@ + + 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() + ) + } } } } diff --git a/app/apk/src/main/java/com/topjohnwu/magisk/ui/flash/FlashViewModel.kt b/app/apk/src/main/java/com/topjohnwu/magisk/ui/flash/FlashViewModel.kt index 613dd9983..4947f56e7 100644 --- a/app/apk/src/main/java/com/topjohnwu/magisk/ui/flash/FlashViewModel.kt +++ b/app/apk/src/main/java/com/topjohnwu/magisk/ui/flash/FlashViewModel.kt @@ -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 = _showReboot.asStateFlow() - val consoleItems = mutableStateListOf() var flashAction: String = "" - var flashUri: android.net.Uri? = null + var flashUri: Uri? = null + // --- TerminalView mode (FLASH_ZIP) --- + + private val _termSession = MutableStateFlow(null) + val termSession: StateFlow = _termSession.asStateFlow() + + private val emulatorReady = CompletableDeferred() + 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() private val logItems = mutableListOf().synchronized() private val outItems = object : CallbackList() { 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() + } } diff --git a/app/apk/src/main/java/com/topjohnwu/magisk/ui/module/ActionScreen.kt b/app/apk/src/main/java/com/topjohnwu/magisk/ui/module/ActionScreen.kt index b2a8d7b37..d93130988 100644 --- a/app/apk/src/main/java/com/topjohnwu/magisk/ui/module/ActionScreen.kt +++ b/app/apk/src/main/java/com/topjohnwu/magisk/ui/module/ActionScreen.kt @@ -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() }, + ) } } diff --git a/app/apk/src/main/java/com/topjohnwu/magisk/ui/module/ActionViewModel.kt b/app/apk/src/main/java/com/topjohnwu/magisk/ui/module/ActionViewModel.kt index 0fabc5fca..ab54d5907 100644 --- a/app/apk/src/main/java/com/topjohnwu/magisk/ui/module/ActionViewModel.kt +++ b/app/apk/src/main/java/com/topjohnwu/magisk/ui/module/ActionViewModel.kt @@ -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 = _actionState.asStateFlow() - val consoleItems = mutableStateListOf() + private val _termSession = MutableStateFlow(null) + val termSession: StateFlow = _termSession.asStateFlow() + var actionId: String = "" var actionName: String = "" private val logItems = mutableListOf().synchronized() - private val outItems = object : CallbackList() { - override fun onAddElement(e: String?) { - e ?: return - consoleItems.add(e) - logItems.add(e) + private val emulatorReady = CompletableDeferred() + + 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() + } } diff --git a/app/apk/src/main/java/com/topjohnwu/magisk/ui/terminal/TerminalComposeView.kt b/app/apk/src/main/java/com/topjohnwu/magisk/ui/terminal/TerminalComposeView.kt new file mode 100644 index 000000000..0c03d50d7 --- /dev/null +++ b/app/apk/src/main/java/com/topjohnwu/magisk/ui/terminal/TerminalComposeView.kt @@ -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) {} +} diff --git a/app/apk/src/main/java/com/topjohnwu/magisk/ui/terminal/TerminalSessionCallback.kt b/app/apk/src/main/java/com/topjohnwu/magisk/ui/terminal/TerminalSessionCallback.kt new file mode 100644 index 000000000..e793c37b9 --- /dev/null +++ b/app/apk/src/main/java/com/topjohnwu/magisk/ui/terminal/TerminalSessionCallback.kt @@ -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) + } +} diff --git a/app/gradle/libs.versions.toml b/app/gradle/libs.versions.toml index 62619009b..113a90ff2 100644 --- a/app/gradle/libs.versions.toml +++ b/app/gradle/libs.versions.toml @@ -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" }