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:
LoveSy
2026-03-04 22:53:28 +08:00
committed by topjohnwu
parent 6a9bd4531f
commit bc3f6b1a99
9 changed files with 389 additions and 108 deletions
+3
View File
@@ -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
View File
@@ -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)
}
}
+4
View File
@@ -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" }