pull/1312/head
deniscerri 1 month ago
commit f4c4282188
No known key found for this signature in database
GPG Key ID: 95C43D517D830350

@ -242,4 +242,10 @@ dependencies {
implementation("commons-io:commons-io:2.5")
implementation("org.apache.commons:commons-compress:1.12")
implementation("androidx.core:core-splashscreen:1.2.0")
implementation 'com.github.termux.termux-app:terminal-view:v0.118.3'
implementation 'com.github.termux.termux-app:termux-shared:v0.118.3'
//noinspection Aligned16KB
implementation 'com.github.termux.termux-app:terminal-emulator:v0.118.3'
}

@ -354,6 +354,7 @@
android:configChanges="smallestScreenSize|layoutDirection|orientation|screenSize"
android:exported="true"
android:label="@string/terminal"
android:windowSoftInputMode="adjustResize"
android:parentActivityName=".MainActivity">
<intent-filter>
<action android:name="ytdlnis.TerminalActivity" />
@ -586,6 +587,12 @@
android:foregroundServiceType="specialUse"
android:exported="true"
/>
<service
android:name=".terminal.SessionService"
android:foregroundServiceType="specialUse"
android:exported="false" />
</application>
</manifest>

@ -2,6 +2,7 @@ package com.deniscerri.ytdl.core
import android.content.Context
import android.os.Build
import android.os.Environment
import com.deniscerri.ytdl.App
import com.deniscerri.ytdl.R
import com.deniscerri.ytdl.core.models.ExecuteException
@ -31,6 +32,7 @@ import java.io.IOException
import java.util.Collections
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
import kotlin.collections.set
import kotlin.concurrent.Volatile
object RuntimeManager {
@ -310,21 +312,7 @@ object RuntimeManager {
val startTime = System.currentTimeMillis()
val processBuilder = ProcessBuilder(fullCommand).redirectErrorStream(redirectErrorStream)
processBuilder.environment().apply {
this["LD_LIBRARY_PATH"] = ENV_LD_LIBRARY_PATH
if (OPEN_SSL_CONF != "") {
this["OPENSSL_CONF"] = OPEN_SSL_CONF
}
this["SSL_CERT_FILE"] = ENV_SSL_CERT_FILE
this["PATH"] = PATH
this["PYTHONHOME"] = ENV_PYTHONHOME
this["HOME"] = ENV_PYTHONHOME
this["TMPDIR"] = TMPDIR
}
if (executeDirectory != null) {
processBuilder.directory(executeDirectory)
}
processBuilder.environment().putAll(getEnvironment())
val outBuffer = StringBuffer()
val errBuffer = StringBuffer()
@ -367,6 +355,29 @@ object RuntimeManager {
}
}
fun getEnvironment() : Map<String, String?> {
val env = mutableMapOf<String, String?>()
env["LD_LIBRARY_PATH"] = ENV_LD_LIBRARY_PATH
if (OPEN_SSL_CONF != "") {
env["OPENSSL_CONF"] = OPEN_SSL_CONF
}
env["SSL_CERT_FILE"] = ENV_SSL_CERT_FILE
env["PATH"] = PATH
env["PYTHONHOME"] = ENV_PYTHONHOME
env["HOME"] = ENV_PYTHONHOME
env["TMPDIR"] = TMPDIR
env["TERM"] = "xterm-256color"
return env
}
fun getEnvironmentForTerminal(): MutableMap<String, String?> {
val env = getEnvironment().toMutableMap()
env["HOME"] = Environment.getExternalStorageDirectory().path
return env
}
@Synchronized
@Throws(ExecuteException::class)
fun updateYTDL(

@ -22,7 +22,7 @@ import com.deniscerri.ytdl.database.models.DownloadSizeMetadata
import com.deniscerri.ytdl.util.Extensions.toListString
import com.deniscerri.ytdl.util.FileUtil
import com.deniscerri.ytdl.util.AlarmScheduler
import com.deniscerri.ytdl.work.download.DownloadWorker
import com.deniscerri.ytdl.work.DownloadWorker
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.distinctUntilChanged
import java.io.File

@ -1,73 +1,136 @@
package com.deniscerri.ytdl.database.viewmodel
import android.app.Application
import android.content.ComponentName
import androidx.lifecycle.AndroidViewModel
import androidx.work.Data
import androidx.work.ExistingWorkPolicy
import androidx.work.OneTimeWorkRequestBuilder
import androidx.work.WorkManager
import com.deniscerri.ytdl.core.RuntimeManager
import com.deniscerri.ytdl.database.DBManager
import com.deniscerri.ytdl.database.dao.TerminalDao
import com.deniscerri.ytdl.database.models.TerminalItem
import com.deniscerri.ytdl.util.NotificationUtil
import com.deniscerri.ytdl.work.download.TerminalDownloadWorker
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.launch
import android.content.Context
import android.content.Intent
import android.content.ServiceConnection
import android.graphics.Typeface
import android.os.Build
import android.os.IBinder
import android.util.TypedValue
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.graphics.ImageBitmap
import androidx.preference.PreferenceManager
import com.deniscerri.ytdl.terminal.SessionService
import com.deniscerri.ytdl.ui.more.terminal.TerminalActivity
import com.deniscerri.ytdl.ui.more.terminal.TerminalBackEnd
import com.google.android.material.R
import com.deniscerri.ytdl.ui.more.terminal.virtualkeys.VirtualKeysListener
import com.deniscerri.ytdl.ui.more.terminal.virtualkeys.VirtualKeysView
import com.deniscerri.ytdl.ui.more.terminal.TerminalUtils
import com.termux.view.TerminalView
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.receiveAsFlow
import java.lang.ref.WeakReference
class TerminalViewModel(private val application: Application) : AndroidViewModel(application) {
private val dbManager: DBManager = DBManager.getInstance(application)
private val dao: TerminalDao = dbManager.terminalDao
private val notificationUtil = NotificationUtil(application)
fun getCount() : Int{
return dao.getActiveTerminalsCount()
}
private var terminalViewRef = WeakReference<TerminalView>(null)
private var virtualKeysViewRef = WeakReference<VirtualKeysView>(null)
fun getTerminals() : Flow<List<TerminalItem>> {
return dao.getActiveTerminalDownloadsFlow()
}
val terminalView: TerminalView? get() = terminalViewRef.get()
val virtualKeysView: VirtualKeysView? get() = virtualKeysViewRef.get()
fun getTerminal(id: Long) : Flow<TerminalItem?> {
return dao.getActiveTerminalFlow(id)
}
fun setTerminalView(view: TerminalView?) { terminalViewRef = WeakReference(view) }
fun setVirtualKeysView(view: VirtualKeysView?) { virtualKeysViewRef = WeakReference(view) }
suspend fun insert(item: TerminalItem) : Long {
return dao.insert(item)
fun setFont(typeface: Typeface) {
TerminalUtils.typeface = typeface
terminalView?.apply {
setTypeface(typeface)
onScreenUpdated()
}
}
suspend fun delete(id: Long) = CoroutineScope(Dispatchers.IO).launch{
dao.delete(id)
}
fun changeSession(context: Context, sessionBinder: SessionService.SessionBinder, sessionId: String) {
val terminal = terminalView ?: return
val activity = context as? TerminalActivity ?: return
val client = TerminalBackEnd(terminal, activity)
val session = sessionBinder.getSession(sessionId)
?: sessionBinder.createSession(sessionId, client)
session.updateTerminalSessionClient(client)
terminal.setBackgroundColor(android.graphics.Color.TRANSPARENT)
val zoom = PreferenceManager.getDefaultSharedPreferences(context)
.getFloat("terminal_zoom", 14f).coerceIn(10f, 30f)
terminal.setTextSize(zoom.toInt())
terminal.setTypeface(TerminalUtils.typeface)
terminal.attachSession(session)
terminal.setTerminalViewClient(client)
terminal.post {
val typedValue = TypedValue()
context.theme.resolveAttribute(R.attr.colorOnSurface, typedValue, true)
terminal.keepScreenOn = true
terminal.requestFocus()
terminal.isFocusableInTouchMode = true
fun startTerminalDownloadWorker(item: TerminalItem) = CoroutineScope(Dispatchers.IO).launch {
val workRequest = OneTimeWorkRequestBuilder<TerminalDownloadWorker>()
.setInputData(
Data.Builder()
.putInt("id", item.id.toInt())
.putString("command", item.command)
.build()
)
.addTag("terminal")
.addTag(item.id.toString())
.build()
WorkManager.getInstance(application).beginUniqueWork(
item.id.toString(),
ExistingWorkPolicy.KEEP,
workRequest
).enqueue()
terminal.mEmulator?.mColors?.mCurrentColors?.apply {
set(256, typedValue.data)
set(257, TerminalUtils.getBackgroundColor(context))
set(258, typedValue.data)
}
}
virtualKeysView?.apply {
virtualKeysViewClient = terminal.mTermSession?.let { VirtualKeysListener(it) }
}
sessionBinder.getService().currentSession.value = sessionId
}
fun cancelTerminalDownload(id: Long) = CoroutineScope(Dispatchers.IO).launch{
RuntimeManager.getInstance().destroyProcessById(id.toString())
WorkManager.getInstance(application).cancelUniqueWork(id.toString())
Thread.sleep(200)
notificationUtil.cancelDownloadNotification(id.toInt())
delete(id)
var sessionBinder by mutableStateOf<SessionService.SessionBinder?>(null)
private set
var isBound by mutableStateOf(false)
private set
private val _isBoundState = MutableStateFlow(false)
val isBoundState: StateFlow<Boolean> = _isBoundState
private val _serviceConnectedEvent = Channel<Unit>(Channel.BUFFERED)
val serviceConnectedEvent = _serviceConnectedEvent.receiveAsFlow()
private val serviceConnection = object : ServiceConnection {
override fun onServiceConnected(name: ComponentName?, service: IBinder?) {
sessionBinder = service as SessionService.SessionBinder
isBound = true
_isBoundState.value = true
_serviceConnectedEvent.trySend(Unit)
}
override fun onServiceDisconnected(name: ComponentName?) {
isBound = false
sessionBinder = null
_isBoundState.value = false
}
}
fun startAndBindService(context: Context) {
val intent = Intent(context, SessionService::class.java)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
context.startForegroundService(intent)
} else {
context.startService(intent)
}
context.bindService(intent, serviceConnection, Context.BIND_AUTO_CREATE)
}
fun unbindService(context: Context) {
if (isBound) {
context.unbindService(serviceConnection)
isBound = false
sessionBinder = null
}
}
}

@ -9,7 +9,7 @@ import androidx.work.ExistingWorkPolicy
import androidx.work.NetworkType
import androidx.work.OneTimeWorkRequestBuilder
import androidx.work.WorkManager
import com.deniscerri.ytdl.work.download.DownloadWorker
import com.deniscerri.ytdl.work.DownloadWorker
import java.util.concurrent.TimeUnit
class ScheduleAlarmReceiver : BroadcastReceiver() {

@ -0,0 +1,11 @@
package com.deniscerri.ytdl.terminal
import android.app.Service
import android.content.Intent
import android.os.IBinder
class RunCommandService : Service() {
override fun onBind(intent: Intent?): IBinder? {
TODO("Not yet implemented")
}
}

@ -0,0 +1,155 @@
package com.deniscerri.ytdl.terminal
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.app.Service
import android.content.Intent
import android.content.pm.ServiceInfo
import android.os.Binder
import android.os.Build
import android.os.IBinder
import androidx.annotation.RequiresApi
import androidx.compose.runtime.mutableStateMapOf
import androidx.compose.runtime.mutableStateOf
import androidx.core.app.NotificationCompat
import com.deniscerri.ytdl.R
import com.deniscerri.ytdl.ui.more.terminal.MkSession
import com.deniscerri.ytdl.ui.more.terminal.TerminalActivity
import com.termux.terminal.TerminalSession
import com.termux.terminal.TerminalSessionClient
class SessionService : Service() {
private val sessions = hashMapOf<String, TerminalSession>()
val sessionList = mutableStateMapOf<String, Int>()
var currentSession = mutableStateOf("main1")
inner class SessionBinder : Binder() {
fun getService(): SessionService = this@SessionService
fun terminateAllSessions() {
sessions.values.forEach { it.finishIfRunning() }
sessions.clear()
sessionList.clear()
updateNotification()
}
fun createSession(
id: String,
client: TerminalSessionClient,
): TerminalSession {
return MkSession.createSession(
context = this@SessionService,
sessionClient = client,
).also {
sessions[id] = it
sessionList[id] = 1
updateNotification()
}
}
fun getSession(id: String): TerminalSession? = sessions[id]
fun terminateSession(id: String) {
sessions[id]?.apply {
if (emulator != null) {
finishIfRunning()
}
}
sessions.remove(id)
sessionList.remove(id)
if (sessions.isEmpty()) {
stopSelf()
} else {
updateNotification()
}
}
}
private val binder = SessionBinder()
private val notificationManager by lazy {
getSystemService(NotificationManager::class.java)
}
override fun onBind(intent: Intent?): IBinder = binder
override fun onDestroy() {
sessions.values.forEach { it.finishIfRunning() }
super.onDestroy()
}
override fun onCreate() {
super.onCreate()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
createNotificationChannel()
}
val notification = createNotification()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
startForeground(1, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_SPECIAL_USE)
} else {
startForeground(1, notification)
}
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
if (intent?.action == "ACTION_EXIT") {
sessions.values.forEach { it.finishIfRunning() }
stopSelf()
}
return super.onStartCommand(intent, flags, startId)
}
private fun createNotification(): Notification {
val intent = Intent(this, TerminalActivity::class.java)
val pendingIntent = PendingIntent.getActivity(
this, 0, intent, PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
)
val exitIntent = Intent(this, SessionService::class.java).apply {
action = "ACTION_EXIT"
}
val exitPendingIntent = PendingIntent.getService(
this, 1, exitIntent, PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
)
return NotificationCompat.Builder(this, CHANNEL_ID)
.setContentTitle("YTDLnis Terminal")
.setContentText(getNotificationContentText())
.setSmallIcon(R.drawable.ic_terminal)
.setContentIntent(pendingIntent)
.addAction(
NotificationCompat.Action.Builder(
null,
"EXIT",
exitPendingIntent
).build()
)
.setOngoing(true)
.build()
}
private val CHANNEL_ID = "session_service_channel"
@RequiresApi(Build.VERSION_CODES.O)
private fun createNotificationChannel() {
val channel = NotificationChannel(
CHANNEL_ID,
"Session Service",
NotificationManager.IMPORTANCE_LOW
).apply {
description = "Notification for Terminal Service"
}
notificationManager.createNotificationChannel(channel)
}
private fun updateNotification() {
val notification = createNotification()
notificationManager.notify(1, notification)
}
private fun getNotificationContentText(): String {
val count = sessions.size
return if (count == 1) "1 session running" else "$count sessions running"
}
}

@ -1,94 +0,0 @@
package com.deniscerri.ytdl.ui.adapter
import android.app.Activity
import android.content.SharedPreferences
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.TextView
import androidx.preference.PreferenceManager
import androidx.recyclerview.widget.AsyncDifferConfig
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.ListAdapter
import androidx.recyclerview.widget.RecyclerView
import com.deniscerri.ytdl.R
import com.deniscerri.ytdl.database.models.TerminalItem
import com.deniscerri.ytdl.util.Extensions.popup
import com.google.android.material.button.MaterialButton
import com.google.android.material.card.MaterialCardView
import com.google.android.material.progressindicator.LinearProgressIndicator
class TerminalDownloadsAdapter(onItemClickListener: OnItemClickListener, activity: Activity) : ListAdapter<TerminalItem?, TerminalDownloadsAdapter.ViewHolder>(AsyncDifferConfig.Builder(
DIFF_CALLBACK
).build()) {
private val onItemClickListener: OnItemClickListener
private val activity: Activity
private val sharedPreferences: SharedPreferences
init {
this.onItemClickListener = onItemClickListener
this.activity = activity
sharedPreferences = PreferenceManager.getDefaultSharedPreferences(activity)
}
class ViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
val cardView: MaterialCardView
init {
cardView = itemView.findViewById(R.id.active_download_card_view)
}
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
val cardView = LayoutInflater.from(parent.context)
.inflate(R.layout.active_terminal_card, parent, false)
return ViewHolder(cardView)
}
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
val item = getItem(position)
val card = holder.cardView
card.popup()
card.tag = "${item!!.id}##card"
// PROGRESS BAR ----------------------------------------------------
val progressBar = card.findViewById<LinearProgressIndicator>(R.id.progress)
progressBar.tag = "${item.id}##progress"
progressBar.progress = 0
progressBar.isIndeterminate = true
// COMMAND ----------------------------------
val itemTitle = card.findViewById<TextView>(R.id.title)
itemTitle.text = item.command.trim()
//OUTPUT
val output = card.findViewById<TextView>(R.id.output)
output.tag = "${item.id}##output"
// STOP BUTTON ----------------------------------
val stopButton = card.findViewById<MaterialButton>(R.id.active_download_stop)
if (stopButton.hasOnClickListeners()) stopButton.setOnClickListener(null)
stopButton.setOnClickListener {onItemClickListener.onCancelClick(item.id)}
card.setOnClickListener {
onItemClickListener.onCardClick(item)
}
}
interface OnItemClickListener {
fun onCancelClick(itemID: Long)
fun onCardClick(item: TerminalItem)
}
companion object {
private val DIFF_CALLBACK: DiffUtil.ItemCallback<TerminalItem> = object : DiffUtil.ItemCallback<TerminalItem>() {
override fun areItemsTheSame(oldItem: TerminalItem, newItem: TerminalItem): Boolean {
val ranged = arrayListOf(oldItem.id, newItem.id)
return ranged[0] == ranged[1]
}
override fun areContentsTheSame(oldItem: TerminalItem, newItem: TerminalItem): Boolean {
return oldItem.command == newItem.command
}
}
}
}

@ -0,0 +1,74 @@
package com.deniscerri.ytdl.ui.adapter
import android.app.Activity
import android.content.SharedPreferences
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.TextView
import androidx.core.view.isVisible
import androidx.preference.PreferenceManager
import androidx.recyclerview.widget.AsyncDifferConfig
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.ListAdapter
import androidx.recyclerview.widget.RecyclerView
import com.deniscerri.ytdl.R
import com.deniscerri.ytdl.util.Extensions.popup
import com.google.android.material.button.MaterialButton
class TerminalSessionsAdapter(onItemClickListener: OnItemClickListener, activity: Activity) : ListAdapter<String, TerminalSessionsAdapter.ViewHolder>(AsyncDifferConfig.Builder(
DIFF_CALLBACK
).build()) {
private val onItemClickListener: OnItemClickListener
private val activity: Activity
private val sharedPreferences: SharedPreferences
init {
this.onItemClickListener = onItemClickListener
this.activity = activity
sharedPreferences = PreferenceManager.getDefaultSharedPreferences(activity)
}
class ViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
val cardView = LayoutInflater.from(parent.context)
.inflate(R.layout.terminal_session_card, parent, false)
return ViewHolder(cardView)
}
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
val item = getItem(position)
val card = holder.itemView
card.popup()
card.tag = "${item!!}##card"
val sessionName = card.findViewById<TextView>(R.id.session_name)
sessionName.text = item
// STOP BUTTON ----------------------------------
val stopButton = card.findViewById<MaterialButton>(R.id.deleteSession)
stopButton.setOnClickListener {onItemClickListener.onDeleteClick(item)}
sessionName.setOnClickListener {
onItemClickListener.onCardClick(item)
}
}
interface OnItemClickListener {
fun onDeleteClick(sessionId: String)
fun onCardClick(sessionId: String)
}
companion object {
private val DIFF_CALLBACK: DiffUtil.ItemCallback<String> = object : DiffUtil.ItemCallback<String>() {
override fun areItemsTheSame(oldItem: String, newItem: String): Boolean {
return oldItem == newItem
}
override fun areContentsTheSame(oldItem: String, newItem: String): Boolean {
return oldItem == newItem
}
}
}
}

@ -18,7 +18,7 @@ import com.deniscerri.ytdl.util.FileUtil
import com.deniscerri.ytdl.util.UiUtil
import com.deniscerri.ytdl.util.AlarmScheduler
import com.deniscerri.ytdl.work.background.CleanUpLeftoverDownloads
import com.deniscerri.ytdl.work.download.DownloadWorker
import com.deniscerri.ytdl.work.DownloadWorker
import java.util.Calendar
import java.util.concurrent.TimeUnit
import androidx.core.content.edit

@ -0,0 +1,62 @@
package com.deniscerri.ytdl.ui.more.terminal
import android.content.ClipboardManager
import android.content.Context.CLIPBOARD_SERVICE
import android.view.KeyEvent
import androidx.lifecycle.ViewModelProvider
import androidx.preference.PreferenceManager
import com.deniscerri.ytdl.App
import com.deniscerri.ytdl.database.viewmodel.TerminalViewModel
object KeyShortcutHandler {
fun handle(keyCode: Int, event: KeyEvent, activity: TerminalActivity): Boolean {
val preferences = PreferenceManager.getDefaultSharedPreferences(App.instance)
for (action in ShortcutAction.entries) {
val raw = preferences.getString(action.prefKey, action.default.serialize())!!
val binding = ShortcutBinding.deserialize(raw)
if (binding.matches(event)) {
return dispatch(action, activity)
}
}
return false
}
private fun dispatch(action: ShortcutAction, activity: TerminalActivity): Boolean {
val terminalViewModel = ViewModelProvider(activity)[TerminalViewModel::class.java]
return when (action) {
ShortcutAction.PASTE -> handlePaste(terminalViewModel)
// ShortcutAction.NEW_SESSION -> handleNewSession(activity, terminalViewModel)
// ShortcutAction.CLOSE_SESSION -> handleCloseSession(activity, terminalViewModel)
// ShortcutAction.SWITCH_SESSION_PREV -> handleSwitchSession(activity, terminalViewModel, forward = false)
// ShortcutAction.SWITCH_SESSION_NEXT -> handleSwitchSession(activity, terminalViewModel, forward = true)
}
}
private fun handlePaste(viewModel: TerminalViewModel): Boolean {
val clipboard: ClipboardManager = App.instance.getSystemService(CLIPBOARD_SERVICE) as ClipboardManager
val clip = clipboard.primaryClip
if (clip != null) {
val clipText = clip.getItemAt(0).text.toString()
if (clipText.trim().isNotEmpty()) {
viewModel.terminalView?.mEmulator?.paste(clipText)
}
}
return true
}
fun generateUniqueSessionId(activity: TerminalActivity): String {
val binder = activity.terminalViewModel.sessionBinder ?: return ""
val service = binder.getService()
val existingIds = service.sessionList.keys.toList()
var index = 1
var newId: String
do {
newId = "main$index"
index++
} while (newId in existingIds)
return newId
}
}

@ -0,0 +1,215 @@
package com.deniscerri.ytdl.ui.more.terminal
import android.content.Context
import com.anggrayudi.storage.file.child
import com.deniscerri.ytdl.BuildConfig
import com.deniscerri.ytdl.core.RuntimeManager
import com.termux.terminal.TerminalEmulator
import com.termux.terminal.TerminalSession
import com.termux.terminal.TerminalSessionClient
import java.io.File
object MkSession {
fun createSession(
context: Context,
sessionClient: TerminalSessionClient,
pendingCommand: PendingCommand? = null
): TerminalSession {
with(context) {
val envVariables = mutableMapOf(
"ANDROID_ART_ROOT" to System.getenv("ANDROID_ART_ROOT"),
"ANDROID_DATA" to System.getenv("ANDROID_DATA"),
"ANDROID_I18N_ROOT" to System.getenv("ANDROID_I18N_ROOT"),
"ANDROID_ROOT" to System.getenv("ANDROID_ROOT"),
"ANDROID_RUNTIME_ROOT" to System.getenv("ANDROID_RUNTIME_ROOT"),
"ANDROID_TZDATA_ROOT" to System.getenv("ANDROID_TZDATA_ROOT"),
"BOOTCLASSPATH" to System.getenv("BOOTCLASSPATH"),
"DEX2OATBOOTCLASSPATH" to System.getenv("DEX2OATBOOTCLASSPATH"),
"EXTERNAL_STORAGE" to System.getenv("EXTERNAL_STORAGE")
)
val runtimeManager = RuntimeManager.getInstance()
runtimeManager.assertInit()
val runtimeVariables = runtimeManager.getEnvironmentForTerminal()
val ldPath = runtimeVariables["LD_LIBRARY_PATH"] ?: ""
val pythonHome = runtimeVariables["PYTHONHOME"] ?: ""
val sslCert = runtimeVariables["SSL_CERT_FILE"] ?: ""
val openSslConf = runtimeVariables["OPENSSL_CONF"] ?: ""
val linker = if (File("/system/bin/linker64").exists()) "/system/bin/linker64" else "/system/bin/linker"
// Build shell FUNCTIONS instead of standalone executable wrapper scripts.
// On Android 10+ (W^X enforcement), files written at runtime under app-private
// storage (codeCacheDir, filesDir, etc.) cannot be mmap'd PROT_EXEC, so any
// attempt to `execve()` a wrapper script written there fails with EACCES
// ("Permission denied"), even with correct chmod bits. A file that is only
// *read* (sourced by the shell) never hits that restriction, so we emit shell
// functions into a single rc file and have `sh` source it via $ENV.
fun shellFunction(name: String, commandToExec: String): String {
return """
|$name() {
| LD_LIBRARY_PATH="$ldPath" \
| PYTHONHOME="$pythonHome" \
| SSL_CERT_FILE="$sslCert" \
| OPENSSL_CONF="$openSslConf" \
| $commandToExec "${'$'}@"
|}
|
""".trimMargin()
}
val rcBuilder = StringBuilder()
// mksh doesn't interpret bash-style \w / \u escapes in PS1 — it re-evaluates
// PS1 as a normal parameter/command substitution each time it's displayed,
// so embed $PWD directly. Colors are plain ANSI escapes; TERM is already
// xterm-256color so they render fine in TerminalView.
//
// The ANSI codes must be wrapped in \x01 / \x02 (mksh's equivalent of bash's
// \[ \[) so the line editor treats them as zero-width. Without this, the editor
// miscounts the prompt's visual length and the cursor drifts to the wrong line
// after running a command.
val esc = "\u001b"
val nonPrintStart = "\u0001"
val nonPrintEnd = "\u0002"
rcBuilder.append(
"""
|PS1='$nonPrintStart$esc[01;32m$nonPrintEnd${'$'}PWD$nonPrintStart$esc[00m$nonPrintEnd ${'$'} '
|alias ls='ls --color=auto' 2>/dev/null
|export CLICOLOR=1
|export LSCOLORS=ExGxFxdxCxDxDxBxBxExEx
|
""".trimMargin()
)
val executables = mapOf(
"python" to runtimeManager.pythonLocation.executable,
"ffmpeg" to runtimeManager.ffmpegLocation.executable,
"deno" to runtimeManager.denoLocation.executable,
"node" to runtimeManager.nodeLocation.executable,
"qjs" to runtimeManager.quickJsLocation.executable,
"aria2" to runtimeManager.aria2Location.executable,
)
executables.forEach { (name, file) ->
if (file.exists()) {
val execCommand = if (file.name.endsWith(".so")) {
"$linker \"${file.absolutePath}\""
} else {
"\"${file.absolutePath}\""
}
rcBuilder.append(shellFunction(name, execCommand))
}
}
val pythonBin = runtimeManager.pythonLocation.executable
val ytdlpBin = runtimeManager.ytdlpPath
if (pythonBin.exists() && ytdlpBin != null && ytdlpBin.exists()) {
val pythonExec = if (pythonBin.name.endsWith(".so")) {
"$linker \"${pythonBin.absolutePath}\""
} else {
"\"${pythonBin.absolutePath}\""
}
val ytdlpExtraArgs = StringBuilder()
if (runtimeManager.ffmpegLocation.isAvailable) {
ytdlpExtraArgs.append(" --ffmpeg-location \"${runtimeManager.ffmpegLocation.executable.absolutePath}\"")
}
if (runtimeManager.nodeLocation.isAvailable) {
ytdlpExtraArgs.append(" --js-runtimes \"node:${runtimeManager.nodeLocation.executable.absolutePath}\"")
}
if (runtimeManager.denoLocation.isAvailable) {
ytdlpExtraArgs.append(" --js-runtimes \"deno:${runtimeManager.denoLocation.executable.absolutePath}\"")
}
if (runtimeManager.quickJsLocation.isAvailable) {
ytdlpExtraArgs.append(" --js-runtimes \"quickjs:${runtimeManager.quickJsLocation.executable.absolutePath}\"")
}
rcBuilder.append(
shellFunction(
"yt-dlp",
"$pythonExec \"${ytdlpBin.absolutePath}\"$ytdlpExtraArgs"
)
)
}
val currentSystemPath = System.getenv("PATH") ?: "/system/bin"
runtimeVariables["PATH"] = currentSystemPath
envVariables.putAll(runtimeVariables)
val localDir = localDir()
val rcFile = localDir.child("shellrc")
rcFile.writeText(
rcBuilder.toString() +
// Probe support in a subshell first: if `set -o multiline` is
// unsupported by this shell build, POSIX allows the shell to exit
// outright on a bad `set` option even mid-script. Running the probe
// in a subshell means a failure there can't abort sourcing of this
// rc file in the parent (interactive) shell — everything above this
// line (functions, aliases, exports, PS1) is already safely loaded
// by the time we get here regardless of the outcome.
"(set -o multiline) >/dev/null 2>&1 && set -o multiline\n"
)
val env = mutableListOf(
"ENV=${rcFile.absolutePath}",
"PUBLIC_HOME=${getExternalFilesDir(null)?.absolutePath}",
"COLORTERM=truecolor",
"TERM=xterm-256color",
"LANG=C.UTF-8",
"DEBUG=${BuildConfig.DEBUG}",
"PREFIX=${filesDir.parentFile!!.path}",
"LINKER=$linker",
"NATIVE_LIB_DIR=${applicationInfo.nativeLibraryDir}",
"PKG=${packageName}",
"PKG_PATH=${applicationInfo.sourceDir}",
)
env.addAll(envVariables.map { "${it.key}=${it.value}" })
localDir.child("stat").apply {
if (exists().not()) {
writeText(TerminalUtils.stat)
}
}
localDir.child("vmstat").apply {
if (exists().not()) {
writeText(TerminalUtils.vmstat)
}
}
pendingCommand?.env?.let {
env.addAll(it)
}
val shell = pendingCommand?.shell ?: "/system/bin/sh"
return TerminalSession(
shell,
envVariables["HOME"],
arrayOf(),
env.toTypedArray(),
TerminalEmulator.DEFAULT_TERMINAL_TRANSCRIPT_ROWS,
sessionClient,
)
}
}
fun Context.localDir(): File {
return File(filesDir.parentFile, "terminal_local").also {
if (!it.exists()) {
it.mkdirs()
}
}
}
}
data class PendingCommand(
val shell: String,
val workingDir: String?,
val env: List<String>?
)

@ -0,0 +1,140 @@
package com.deniscerri.ytdl.ui.more.terminal
import android.view.KeyEvent
/**
* Represents a configurable keyboard shortcut binding.
* Stored as a string in SharedPreferences: "modifiers|keyCode"
* e.g. "CTRL|SHIFT|54" for Ctrl+Shift+V
*/
data class ShortcutBinding(
val ctrl: Boolean = false,
val shift: Boolean = false,
val alt: Boolean = false,
val keyCode: Int = 0,
) {
/** Check if this binding is empty (no key assigned) */
val isEmpty: Boolean get() = keyCode == 0
/** Check if a KeyEvent matches this binding */
fun matches(event: KeyEvent): Boolean {
if (isEmpty) return false
return event.keyCode == keyCode
&& event.isCtrlPressed == ctrl
&& event.isShiftPressed == shift
&& event.isAltPressed == alt
}
/** Serialize to string for SharedPreferences storage */
fun serialize(): String {
if (isEmpty) return ""
val parts = mutableListOf<String>()
if (ctrl) parts.add("CTRL")
if (shift) parts.add("SHIFT")
if (alt) parts.add("ALT")
parts.add(keyCode.toString())
return parts.joinToString("|")
}
/** Human-readable display string */
fun toDisplayString(): String {
if (isEmpty) return "Not set"
val parts = mutableListOf<String>()
if (ctrl) parts.add("Ctrl")
if (shift) parts.add("Shift")
if (alt) parts.add("Alt")
parts.add(KeyEvent.keyCodeToString(keyCode)
.removePrefix("KEYCODE_")
.replace("_", " ")
.lowercase()
.replaceFirstChar { it.uppercase() })
return parts.joinToString(" + ")
}
companion object {
/** Deserialize from SharedPreferences string */
fun deserialize(value: String): ShortcutBinding {
if (value.isBlank()) return ShortcutBinding()
val parts = value.split("|")
var ctrl = false
var shift = false
var alt = false
var keyCode = 0
for (part in parts) {
when (part) {
"CTRL" -> ctrl = true
"SHIFT" -> shift = true
"ALT" -> alt = true
else -> keyCode = part.toIntOrNull() ?: 0
}
}
return ShortcutBinding(ctrl, shift, alt, keyCode)
}
/** Create from a KeyEvent (for capture dialog) */
fun fromKeyEvent(event: KeyEvent): ShortcutBinding {
return ShortcutBinding(
ctrl = event.isCtrlPressed,
shift = event.isShiftPressed,
alt = event.isAltPressed,
keyCode = event.keyCode,
)
}
/** Keys that should not be used as shortcut targets */
private val RESERVED_KEY_CODES = setOf(
KeyEvent.KEYCODE_HOME,
KeyEvent.KEYCODE_BACK,
KeyEvent.KEYCODE_APP_SWITCH,
KeyEvent.KEYCODE_POWER,
KeyEvent.KEYCODE_VOLUME_UP,
KeyEvent.KEYCODE_VOLUME_DOWN,
KeyEvent.KEYCODE_VOLUME_MUTE,
KeyEvent.KEYCODE_MENU,
)
/** Modifier-only key codes (should not finalize a binding) */
val MODIFIER_KEY_CODES = setOf(
KeyEvent.KEYCODE_CTRL_LEFT,
KeyEvent.KEYCODE_CTRL_RIGHT,
KeyEvent.KEYCODE_SHIFT_LEFT,
KeyEvent.KEYCODE_SHIFT_RIGHT,
KeyEvent.KEYCODE_ALT_LEFT,
KeyEvent.KEYCODE_ALT_RIGHT,
KeyEvent.KEYCODE_META_LEFT,
KeyEvent.KEYCODE_META_RIGHT,
)
fun isReservedKey(keyCode: Int): Boolean = keyCode in RESERVED_KEY_CODES
fun isModifierKey(keyCode: Int): Boolean = keyCode in MODIFIER_KEY_CODES
}
}
/**
* Defines all available shortcut actions with their default bindings and preference keys.
*/
enum class ShortcutAction(
val prefKey: String,
val default: ShortcutBinding,
) {
PASTE(
prefKey = "shortcut_paste",
default = ShortcutBinding(ctrl = true, shift = true, keyCode = KeyEvent.KEYCODE_V),
),
// NEW_SESSION(
// prefKey = "shortcut_new_session",
// default = ShortcutBinding(ctrl = true, shift = true, keyCode = KeyEvent.KEYCODE_N),
// ),
// CLOSE_SESSION(
// prefKey = "shortcut_close_session",
// default = ShortcutBinding(ctrl = true, shift = true, keyCode = KeyEvent.KEYCODE_W),
// ),
// SWITCH_SESSION_PREV(
// prefKey = "shortcut_switch_prev",
// default = ShortcutBinding(ctrl = true, shift = true, keyCode = KeyEvent.KEYCODE_DPAD_LEFT),
// ),
// SWITCH_SESSION_NEXT(
// prefKey = "shortcut_switch_next",
// default = ShortcutBinding(ctrl = true, shift = true, keyCode = KeyEvent.KEYCODE_DPAD_RIGHT),
// ),
}

@ -6,8 +6,11 @@ import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.util.Log
import android.view.inputmethod.InputMethodManager
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import androidx.navigation.NavArgument
import androidx.navigation.NavGraph
import androidx.navigation.NavType
@ -23,9 +26,8 @@ import kotlin.properties.Delegates
class TerminalActivity : BaseActivity() {
private lateinit var terminalViewModel: TerminalViewModel
lateinit var terminalViewModel: TerminalViewModel
private lateinit var navHostFragment: NavHostFragment
private var downloadID by Delegates.notNull<Long>()
private lateinit var graph: NavGraph
@SuppressLint("SetTextI18n")
@ -33,7 +35,6 @@ class TerminalActivity : BaseActivity() {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_terminal)
terminalViewModel = ViewModelProvider(this)[TerminalViewModel::class.java]
downloadID = savedInstanceState?.getLong("downloadID") ?: 0L
handleIntent(intent)
}
@ -62,21 +63,26 @@ class TerminalActivity : BaseActivity() {
}
navHostFragment = supportFragmentManager.findFragmentById(R.id.frame_layout) as NavHostFragment
graph = navHostFragment.navController.navInflater.inflate(R.navigation.terminal_graph)
lifecycleScope.launch {
val count = withContext(Dispatchers.IO){
terminalViewModel.getCount()
}
val bundle = Bundle()
if (count == 0){
bundle.putString("share", text ?: "")
graph.setStartDestination(R.id.terminalFragment)
graph.findNode(graph.startDestinationId)?.addArgument("share", NavArgument.Builder()
.setType(NavType.StringType)
.setDefaultValue(text ?: "")
.build()
)
repeatOnLifecycle(Lifecycle.State.STARTED) {
terminalViewModel.isBoundState.collect { isBound ->
if (isBound) {
val bundle = Bundle()
val count = terminalViewModel.sessionBinder?.getService()?.sessionList?.keys?.count() ?: 0
if (count == 0){
bundle.putString("share", text ?: "")
graph.setStartDestination(R.id.terminalFragment)
graph.findNode(graph.startDestinationId)?.addArgument("share", NavArgument.Builder()
.setType(NavType.StringType)
.setDefaultValue(text ?: "")
.build()
)
}
navHostFragment.navController.setGraph(graph, bundle)
}
}
}
navHostFragment.navController.setGraph(graph, bundle)
}
}
@ -84,4 +90,13 @@ class TerminalActivity : BaseActivity() {
private const val TAG = "TerminalActivity"
}
override fun onStart() {
super.onStart()
terminalViewModel.startAndBindService(this)
}
override fun onStop() {
super.onStop()
terminalViewModel.unbindService(this)
}
}

@ -0,0 +1,131 @@
package com.deniscerri.ytdl.ui.more.terminal
import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context.CLIPBOARD_SERVICE
import android.content.res.Configuration
import android.content.res.Resources
import android.util.Log
import android.view.KeyEvent
import android.view.MotionEvent
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.lifecycleScope
import com.deniscerri.ytdl.App
import com.deniscerri.ytdl.database.viewmodel.TerminalViewModel
import com.deniscerri.ytdl.ui.more.terminal.virtualkeys.SpecialButton
import com.termux.shared.view.KeyboardUtils
import com.termux.terminal.TerminalEmulator
import com.termux.terminal.TerminalSession
import com.termux.terminal.TerminalSessionClient
import com.termux.view.TerminalView
import com.termux.view.TerminalViewClient
import kotlinx.coroutines.CoroutineScope
class TerminalBackEnd(
private val terminal: TerminalView,
private val activity: TerminalActivity,
private val onFinished: (() -> Unit)? = null
) : TerminalViewClient, TerminalSessionClient {
private val terminalViewModel by lazy { ViewModelProvider(activity)[TerminalViewModel::class.java] }
override fun onTextChanged(changedSession: TerminalSession) {
terminal.onScreenUpdated()
}
override fun onTitleChanged(changedSession: TerminalSession) {}
override fun onSessionFinished(finishedSession: TerminalSession) {
onFinished?.invoke()
}
override fun onCopyTextToClipboard(session: TerminalSession, text: String) {
val clipboard: ClipboardManager = activity.getSystemService(CLIPBOARD_SERVICE) as ClipboardManager
clipboard.setPrimaryClip(ClipData.newPlainText("Terminal", text))
}
override fun onPasteTextFromClipboard(session: TerminalSession?) {
val clipboard: ClipboardManager = activity.getSystemService(CLIPBOARD_SERVICE) as ClipboardManager
val clip = clipboard.primaryClip
if (clip != null) {
val clipText = clip.getItemAt(0).text.toString()
if (clipText.trim().isNotEmpty() && terminal.mEmulator != null) {
terminal.mEmulator.paste(clipText)
}
}
}
override fun onBell(session: TerminalSession) {
return
}
override fun onColorsChanged(session: TerminalSession) {}
override fun onTerminalCursorStateChange(state: Boolean) {}
override fun getTerminalCursorStyle(): Int = TerminalEmulator.DEFAULT_TERMINAL_CURSOR_STYLE
override fun logError(tag: String?, message: String?) { Log.e(tag ?: "Terminal", message ?: "") }
override fun logWarn(tag: String?, message: String?) { Log.w(tag ?: "Terminal", message ?: "") }
override fun logInfo(tag: String?, message: String?) { Log.i(tag ?: "Terminal", message ?: "") }
override fun logDebug(tag: String?, message: String?) { Log.d(tag ?: "Terminal", message ?: "") }
override fun logVerbose(tag: String?, message: String?) { Log.v(tag ?: "Terminal", message ?: "") }
override fun logStackTraceWithMessage(tag: String?, message: String?, e: Exception?) {
Log.e(tag ?: "Terminal", message ?: "", e)
}
override fun logStackTrace(tag: String?, e: Exception?) {
Log.e(tag ?: "Terminal", "Stack trace", e)
}
override fun onScale(scale: Float): Float {
val fontScale = scale.coerceIn(11f, 45f)
terminal.setTextSize(fontScale.toInt())
return fontScale
}
private val isHardwareKeyboardConnected: Boolean
get() = Resources.getSystem().configuration.keyboard != Configuration.KEYBOARD_NOKEYS
override fun onSingleTapUp(e: MotionEvent) {
if (!(isHardwareKeyboardConnected)) {
showSoftInput()
}
}
override fun shouldBackButtonBeMappedToEscape(): Boolean = false
override fun shouldEnforceCharBasedInput(): Boolean = true
override fun shouldUseCtrlSpaceWorkaround(): Boolean = true
override fun isTerminalViewSelected(): Boolean = true
override fun copyModeChanged(copyMode: Boolean) {}
override fun onKeyDown(keyCode: Int, e: KeyEvent, session: TerminalSession): Boolean {
return KeyShortcutHandler.handle(keyCode, e, activity)
}
override fun onKeyUp(keyCode: Int, e: KeyEvent): Boolean = false
override fun onLongPress(event: MotionEvent): Boolean = false
override fun readControlKey(): Boolean =
terminalViewModel.virtualKeysView?.readSpecialButton(SpecialButton.CTRL, true) == true
override fun readAltKey(): Boolean =
terminalViewModel.virtualKeysView?.readSpecialButton(SpecialButton.ALT, true) == true
override fun readShiftKey(): Boolean =
terminalViewModel.virtualKeysView?.readSpecialButton(SpecialButton.SHIFT, true) == true
override fun readFnKey(): Boolean =
terminalViewModel.virtualKeysView?.readSpecialButton(SpecialButton.FN, true) == true
override fun onCodePoint(codePoint: Int, ctrlDown: Boolean, session: TerminalSession): Boolean = false
override fun onEmulatorSet() {
if (terminal.mEmulator != null) {
terminal.setTerminalCursorBlinkerState(true, true)
}
}
private fun showSoftInput() {
terminal.requestFocus()
KeyboardUtils.showSoftKeyboard(App.instance,terminal)
}
}

@ -1,115 +1,97 @@
package com.deniscerri.ytdl.ui.more.terminal
import android.annotation.SuppressLint
import android.app.ActionBar.LayoutParams
import android.app.Activity
import android.content.ClipboardManager
import android.content.Context.CLIPBOARD_SERVICE
import android.content.Context.INPUT_METHOD_SERVICE
import android.content.Intent
import android.content.SharedPreferences
import android.os.Bundle
import android.util.DisplayMetrics
import android.view.LayoutInflater
import android.view.MenuItem
import android.view.View
import android.view.ViewGroup
import android.view.inputmethod.InputMethodManager
import android.widget.EditText
import android.widget.HorizontalScrollView
import android.widget.LinearLayout
import android.widget.ScrollView
import android.widget.TextView
import android.widget.Toast
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.Typeface
import androidx.core.content.edit
import androidx.core.view.get
import androidx.core.view.isVisible
import androidx.core.content.res.ResourcesCompat
import androidx.core.os.bundleOf
import androidx.core.view.doOnLayout
import androidx.fragment.app.Fragment
import androidx.lifecycle.Observer
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import androidx.navigation.NavOptions
import androidx.navigation.NavOptionsBuilder
import androidx.navigation.fragment.findNavController
import androidx.preference.PreferenceManager
import androidx.work.WorkInfo
import androidx.work.WorkManager
import com.deniscerri.ytdl.R
import com.deniscerri.ytdl.database.models.TerminalItem
import com.deniscerri.ytdl.database.viewmodel.CommandTemplateViewModel
import com.deniscerri.ytdl.database.viewmodel.TerminalViewModel
import com.deniscerri.ytdl.util.Extensions.enableTextHighlight
import com.deniscerri.ytdl.util.Extensions.setCustomTextSize
import com.deniscerri.ytdl.ui.more.terminal.virtualkeys.VirtualKeysConstants
import com.deniscerri.ytdl.ui.more.terminal.virtualkeys.VirtualKeysInfo
import com.deniscerri.ytdl.ui.more.terminal.virtualkeys.VirtualKeysListener
import com.deniscerri.ytdl.ui.more.terminal.virtualkeys.VirtualKeysView
import com.deniscerri.ytdl.util.FileUtil
import com.deniscerri.ytdl.util.NotificationUtil
import com.deniscerri.ytdl.util.UiUtil
import com.google.android.material.appbar.MaterialToolbar
import com.google.android.material.bottomappbar.BottomAppBar
import com.google.android.material.floatingactionbutton.ExtendedFloatingActionButton
import com.google.android.material.slider.Slider
import kotlinx.coroutines.CoroutineScope
import com.termux.terminal.TerminalSession
import com.termux.view.TerminalView
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlin.properties.Delegates
import kotlin.text.toInt
class TerminalFragment : Fragment() {
private lateinit var topAppBar: MaterialToolbar
private lateinit var notificationUtil: NotificationUtil
private lateinit var terminalViewModel: TerminalViewModel
private lateinit var output: TextView
private lateinit var input: EditText
private lateinit var fab: ExtendedFloatingActionButton
private lateinit var scrollView: ScrollView
private lateinit var bottomAppBar: BottomAppBar
private lateinit var commandTemplateViewModel: CommandTemplateViewModel
private lateinit var topAppBar: MaterialToolbar
private lateinit var bottomAppBar: BottomAppBar
private lateinit var terminalView: TerminalView
private lateinit var virtualKeysView: VirtualKeysView
private lateinit var sharedPreferences: SharedPreferences
private var downloadID by Delegates.notNull<Long>()
private lateinit var imm : InputMethodManager
private lateinit var metrics: DisplayMetrics
private lateinit var session : TerminalSession
private var sessionId: String? = null
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
terminalViewModel = ViewModelProvider(this)[TerminalViewModel::class.java]
downloadID = 0
terminalViewModel = ViewModelProvider(requireActivity())[TerminalViewModel::class.java]
return inflater.inflate(R.layout.fragment_terminal, container, false)
}
override fun onSaveInstanceState(outState: Bundle) {
super.onSaveInstanceState(outState)
outState.putString("input", input.text.toString())
outState.putString("output", output.text.toString())
outState.putBoolean("run", fab.text == requireActivity().getString(R.string.run_command))
outState.putLong("downloadID", downloadID)
}
override fun onResume() {
arguments?.remove("id")
arguments?.remove("new")
arguments?.remove("share")
super.onResume()
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
var bundle = savedInstanceState
imm = requireActivity().getSystemService(INPUT_METHOD_SERVICE) as InputMethodManager
scrollView = view.findViewById(R.id.custom_command_scrollview)
commandTemplateViewModel = ViewModelProvider(this)[CommandTemplateViewModel::class.java]
sharedPreferences = PreferenceManager.getDefaultSharedPreferences(requireContext())
topAppBar = requireActivity().findViewById(R.id.custom_command_toolbar)
topAppBar.setNavigationOnClickListener { requireActivity().finish() }
topAppBar.setOnClickListener { scrollView.scrollTo(0,0) }
input = view.findViewById(R.id.command_edittext)
fab = view.findViewById(R.id.command_fab)
terminalView = view.findViewById(R.id.terminalView)
virtualKeysView = view.findViewById(R.id.virtualKeys)
bottomAppBar = view.findViewById(R.id.bottomAppBar)
if (arguments?.getLong("id") != null){
downloadID = requireArguments().getLong("id")
if(downloadID != 0L){
input.visibility = View.GONE
showCancelFab()
}
var bundle = savedInstanceState
if (arguments?.containsKey("id") == true) {
sessionId = arguments?.getString("id")
}
if (arguments?.containsKey("share") == true){
@ -119,13 +101,11 @@ class TerminalFragment : Fragment() {
bundle.putString("input", arguments?.getString("share"))
}
commandTemplateViewModel = ViewModelProvider(this)[CommandTemplateViewModel::class.java]
sharedPreferences = PreferenceManager.getDefaultSharedPreferences(requireContext())
metrics = DisplayMetrics()
requireActivity().windowManager.defaultDisplay.getMetrics(metrics)
terminalViewModel.setTerminalView(terminalView)
terminalViewModel.setVirtualKeysView(virtualKeysView)
initMenu()
bottomAppBar = view.findViewById(R.id.bottomAppBar)
var templateCount = 0
var shortcutCount = 0
@ -134,21 +114,34 @@ class TerminalFragment : Fragment() {
commandTemplateViewModel.getTotalNumber()
}
if (templateCount == 0){
bottomAppBar.menu[0].icon?.alpha = 30
bottomAppBar.menu.findItem(R.id.command_templates).icon?.alpha = 30
}else{
bottomAppBar.menu[0].icon?.alpha = 255
bottomAppBar.menu.findItem(R.id.command_templates).icon?.alpha = 255
}
shortcutCount = withContext(Dispatchers.IO){
commandTemplateViewModel.getTotalShortcutNumber()
}
if (shortcutCount == 0) {
bottomAppBar.menu[1].icon?.alpha = 30
bottomAppBar.menu.findItem(R.id.shortcuts).icon?.alpha = 30
}else{
bottomAppBar.menu[1].icon?.alpha = 255
bottomAppBar.menu.findItem(R.id.shortcuts).icon?.alpha = 255
}
}
val slider = requireActivity().findViewById<Slider>(R.id.textsize_seekbar)
slider?.apply {
valueFrom = 10f
valueTo = 37f
value = sharedPreferences.getFloat("terminal_zoom", 35f)
addOnChangeListener { _, value, _ ->
terminalView.setTextSize(value.toInt())
sharedPreferences.edit { putFloat("terminal_zoom", value) }
}
}
bottomAppBar.setOnMenuItemClickListener {
when(it.itemId){
R.id.command_templates -> {
@ -158,12 +151,9 @@ class TerminalFragment : Fragment() {
lifecycleScope.launch {
UiUtil.showCommandTemplates(requireActivity(), commandTemplateViewModel){ templates ->
templates.forEach {c ->
input.text.insert(input.selectionStart, c.content + " ")
session.write(" ${c.content} ")
terminalView.requestFocus()
}
input.postDelayed({
input.requestFocus()
imm.showSoftInput(input, 0)
}, 200)
}
}
}
@ -173,22 +163,19 @@ class TerminalFragment : Fragment() {
if (shortcutCount > 0){
UiUtil.showShortcuts(requireActivity(), commandTemplateViewModel,
itemSelected = {sh ->
val txt = "${input.text.trim()} $sh"
input.setText(txt)
input.setSelection(input.text.length)
session.write(" $sh ")
},
itemRemoved = {removed ->
input.setText(input.text.replace("(${Regex.escape(removed)})(?!.*\\1)".toRegex(), "").trim())
input.setSelection(input.text.length)
itemRemoved = { removed ->
//TODO
// input.setText(input.text.replace("(${Regex.escape(removed)})(?!.*\\1)".toRegex(), "").trim())
// input.setSelection(input.text.length)
})
}
}
}
R.id.filename_template -> {
UiUtil.showFilenameTemplateDialog(requireActivity(), "") { filenameSelected ->
val txt = "${input.text.replace("-o\\s+(?:\"([^\"]+)\"|(\\S+))".toRegex(), "").trim()} -o \"$filenameSelected\""
input.setText(txt)
input.setSelection(input.text.length)
session.write(""" "$filenameSelected" """)
}
}
R.id.folder -> {
@ -198,138 +185,154 @@ class TerminalFragment : Fragment() {
intent.addFlags(Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION)
commandPathResultLauncher.launch(intent)
}
R.id.text_size -> {
slider?.visibility = if (slider.visibility == View.VISIBLE) View.GONE else View.VISIBLE
}
}
true
}
output = view.findViewById(R.id.custom_command_output)
output.setTextIsSelectable(true)
output.layoutParams!!.width = LayoutParams.WRAP_CONTENT
input.requestFocus()
fab.setOnClickListener {
if (fab.text == requireActivity().getString(R.string.run_command)){
input.visibility = View.GONE
val txt = "${output.text}\n~ $ ${input.text}\n"
output.text = txt
showCancelFab()
imm.hideSoftInputFromWindow(input.windowToken, 0)
lifecycleScope.launch {
val command = input.text.toString().replaceFirst("yt-dlp", "")
downloadID = withContext(Dispatchers.IO){
terminalViewModel.insert(TerminalItem(command = command, log = output.text.toString()))
}
terminalViewModel.startTerminalDownloadWorker(TerminalItem(downloadID, command))
input.visibility = View.GONE
showCancelFab()
runWorkerListener()
}
}else {
terminalViewModel.cancelTerminalDownload(downloadID)
input.visibility = View.VISIBLE
hideCancelFab()
}
}
notificationUtil = NotificationUtil(requireContext())
initMenu()
requireView().post {
if (sharedPreferences.getBoolean("use_code_color_highlighter", true)) {
input.enableTextHighlight()
if (terminalViewModel.isBound && terminalViewModel.sessionBinder != null) {
initSession()
} else {
// If not bound yet, wait for the service connection event
viewLifecycleOwner.lifecycleScope.launch {
viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
terminalViewModel.serviceConnectedEvent.collect {
initSession()
}
}
}
input.append(bundle?.getString("input") ?: "")
input.requestFocus()
input.setSelection(input.text.length)
output.text = bundle?.getString("output") ?: output.text
output.isVisible = output.text.toString().isNotEmpty()
}
if (bundle?.getBoolean("run") == true){
showCancelFab()
}
runWorkerListener()
}
@SuppressLint("UseKtx")
private fun initMenu() {
topAppBar.menu?.get(0)?.isVisible = false
topAppBar.menu?.get(1)?.isVisible = true
topAppBar.menu?.get(2)?.isVisible = true
topAppBar.menu?.get(3)?.isVisible = true
val slider = requireActivity().findViewById<Slider>(R.id.textsize_seekbar)
topAppBar.setOnMenuItemClickListener { m: MenuItem ->
when(m.itemId){
R.id.wrap -> {
var scrollView = requireView().findViewById<HorizontalScrollView>(R.id.horizontalscroll_output)
if(scrollView != null){
val parent = (scrollView.parent as ViewGroup)
scrollView.removeAllViews()
parent.removeView(scrollView)
parent.addView(output, 0)
sharedPreferences.edit().putBoolean("wrap_text_terminal", true).apply()
}else{
val parent = output.parent as ViewGroup
parent.removeView(output)
scrollView = HorizontalScrollView(requireContext())
scrollView.layoutParams = LinearLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.MATCH_PARENT
)
scrollView.addView(output)
scrollView.id = R.id.horizontalscroll_output
parent.addView(scrollView, 0)
sharedPreferences.edit().putBoolean("wrap_text_terminal", false).apply()
topAppBar.menu?.findItem(R.id.export_clipboard)?.isVisible = true
topAppBar.menu?.findItem(R.id.delete)?.isVisible = true
topAppBar.setOnMenuItemClickListener { menuItem: MenuItem ->
when (menuItem.itemId) {
R.id.add -> {
findNavController().navigate(R.id.terminalFragment, bundleOf(Pair("new", true)),
NavOptions.Builder().setPopUpTo(R.id.terminalFragment, true).build())
}
R.id.delete -> {
sessionId?.apply {
terminalViewModel.sessionBinder?.terminateSession(this)
}
}
R.id.wrap -> {
// var scrollView = requireView().findViewById<HorizontalScrollView>(R.id.horizontalscroll_output)
// if(scrollView != null){
// val parent = (scrollView.parent as ViewGroup)
// scrollView.removeAllViews()
// parent.removeView(scrollView)
// parent.addView(output, 0)
// sharedPreferences.edit().putBoolean("wrap_text_terminal", true).apply()
// }else{
// val parent = output.parent as ViewGroup
// parent.removeView(output)
// scrollView = HorizontalScrollView(requireContext())
// scrollView.layoutParams = LinearLayout.LayoutParams(
// ViewGroup.LayoutParams.MATCH_PARENT,
// ViewGroup.LayoutParams.MATCH_PARENT
// )
// scrollView.addView(output)
// scrollView.id = R.id.horizontalscroll_output
// parent.addView(scrollView, 0)
// sharedPreferences.edit().putBoolean("wrap_text_terminal", false).apply()
// }
}
R.id.export_clipboard -> {
lifecycleScope.launch(Dispatchers.IO){
val clipboard: ClipboardManager = requireActivity().getSystemService(CLIPBOARD_SERVICE) as ClipboardManager
clipboard.setText(output.text)
clipboard.setText(session.emulator.screen.transcriptText)
}
}
R.id.text_size -> {
slider?.isVisible = !slider.isVisible
}
}
true
}
slider?.apply {
this.valueFrom = 0f
this.valueTo = 10f
this.value = sharedPreferences.getFloat("terminal_zoom", 2f)
output.setCustomTextSize(this.value + 13f)
input.setCustomTextSize(this.value + 13f)
this.addOnChangeListener { slider, value, fromUser ->
output.setCustomTextSize(value + 13f)
input.setCustomTextSize(value + 13f)
sharedPreferences.edit(true){
putFloat("terminal_zoom", value)
}
}
private fun initSession() {
val sessionBinder = terminalViewModel.sessionBinder ?: return
val service = sessionBinder.getService()
val activity = requireActivity() as TerminalActivity
val client = TerminalBackEnd(terminalView, activity) {
if (isAdded) {
requireActivity().onBackPressedDispatcher.onBackPressed()
}
}
sharedPreferences.getBoolean("wrap_text_terminal", false).apply {
if (this){
bottomAppBar.menu.performIdentifierAction(R.id.wrap, 0)
}
if (!sessionId.isNullOrBlank()) {
terminalViewModel.changeSession(requireContext(), sessionBinder, sessionId!!)
}
}
private fun hideCancelFab() {
kotlin.runCatching {
fab.text = getString(R.string.run_command)
fab.setIconResource(R.drawable.ic_baseline_keyboard_arrow_right_24)
val newSession = arguments?.getBoolean("new") ?: false
val currentSession = sessionBinder.getSession(service.currentSession.value)
session = if (newSession || currentSession == null) {
sessionId = KeyShortcutHandler.generateUniqueSessionId(activity)
sessionBinder.createSession(
sessionId!!,
client
)
} else {
currentSession
}
}
private fun showCancelFab() {
kotlin.runCatching {
fab.text = getString(R.string.cancel_task)
fab.setIconResource(R.drawable.ic_cancel)
session.updateTerminalSessionClient(client)
terminalView.doOnLayout { view ->
if (!isAdded) return@doOnLayout
val termView = view as TerminalView
termView.setTextSize(
sharedPreferences.getFloat("terminal_zoom", 35f).coerceIn(10f, 30f).toInt()
)
termView.setTypeface(TerminalUtils.typeface)
termView.setTerminalViewClient(client)
termView.attachSession(session)
termView.requestFocus()
val color = TerminalUtils.getViewColor(requireContext())
val bgColor = TerminalUtils.getBackgroundColor(requireContext())
termView.mEmulator?.mColors?.mCurrentColors?.apply {
set(256, color)
set(257, bgColor)
set(258, color)
}
terminalViewModel.virtualKeysView?.apply {
virtualKeysViewClient = terminalViewModel.terminalView?.mTermSession?.let {
VirtualKeysListener(
it
)
}
buttonTextColor = TerminalUtils.getViewColor(requireContext())
reload(VirtualKeysInfo(virtualKeys, "", VirtualKeysConstants.CONTROL_CHARS_ALIASES))
}
terminalViewModel.setFont(ResourcesCompat.getFont(requireContext(), R.font.jetbrainsmono_medium)!!)
session.write("yt-dlp ")
}
}
val virtualKeys = "[" +
"\n [\"ESC\", {\"key\": \"/\", \"popup\": \"\\\\\"}, {\"key\": \"-\", \"popup\": \"|\"}, \"HOME\", \"UP\", \"END\", \"PGUP\"]," +
"\n [\"TAB\", \"CTRL\", \"ALT\", \"LEFT\", \"DOWN\", \"RIGHT\", \"PGDN\"]" +
"\n]"
private var commandPathResultLauncher = registerForActivityResult(
ActivityResultContracts.StartActivityForResult()
) { result ->
@ -341,61 +344,7 @@ class TerminalFragment : Fragment() {
Intent.FLAG_GRANT_WRITE_URI_PERMISSION
)
}
input.text.insert(input.selectionStart, FileUtil.formatPath(result.data?.data.toString()))
}
}
private fun runWorkerListener(){
CoroutineScope(Dispatchers.IO).launch {
terminalViewModel.getTerminal(downloadID).collectLatest {
kotlin.runCatching {
requireActivity().runOnUiThread{
if (it != null){
if (!it.log.isNullOrBlank()) {
output.isVisible = true
output.text = it.log
}
output.scrollTo(0, output.height)
scrollView.fullScroll(View.FOCUS_DOWN)
input.visibility = View.GONE
showCancelFab()
}
}
}
}
session.write(""" "${FileUtil.formatPath(result.data?.data.toString())}" """)
}
WorkManager.getInstance(requireContext())
.getWorkInfosForUniqueWorkLiveData(downloadID.toString())
.removeObserver(workerObserver)
WorkManager.getInstance(requireContext())
.getWorkInfosForUniqueWorkLiveData(downloadID.toString())
.observe(viewLifecycleOwner, workerObserver)
}
private val workerObserver = object: Observer<List<WorkInfo>> {
@SuppressLint("SetTextI18n")
override fun onChanged(value: List<WorkInfo>) {
value.forEach { work ->
if (listOf(WorkInfo.State.SUCCEEDED, WorkInfo.State.FAILED, WorkInfo.State.CANCELLED).contains(work.state)) {
requireActivity().runOnUiThread {
kotlin.runCatching {
input.setText("yt-dlp ")
input.visibility = View.VISIBLE
input.requestFocus()
input.setSelection(input.text.length)
hideCancelFab()
}
}
return@forEach
}
}
}
}
}

@ -6,7 +6,7 @@ import android.view.MenuItem
import android.view.View
import android.view.ViewGroup
import android.widget.RelativeLayout
import android.widget.TextView
import androidx.core.os.bundleOf
import androidx.core.view.forEach
import androidx.core.view.get
import androidx.fragment.app.Fragment
@ -14,40 +14,43 @@ import androidx.lifecycle.Lifecycle
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import androidx.navigation.NavArgument
import androidx.navigation.NavOptions
import androidx.navigation.NavType
import androidx.navigation.fragment.findNavController
import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.RecyclerView
import com.deniscerri.ytdl.R
import com.deniscerri.ytdl.database.models.TerminalItem
import com.deniscerri.ytdl.database.viewmodel.TerminalViewModel
import com.deniscerri.ytdl.ui.adapter.TerminalDownloadsAdapter
import com.deniscerri.ytdl.ui.adapter.TerminalSessionsAdapter
import com.deniscerri.ytdl.util.Extensions.enableFastScroll
import com.deniscerri.ytdl.util.WorkerEventBus
import com.google.android.material.appbar.MaterialToolbar
import com.google.android.material.progressindicator.LinearProgressIndicator
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
class TerminalDownloadsListFragment : Fragment(), TerminalDownloadsAdapter.OnItemClickListener {
class TerminalSessionListFragment : Fragment(), TerminalSessionsAdapter.OnItemClickListener {
private var topAppBar: MaterialToolbar? = null
private lateinit var noResults: RelativeLayout
private lateinit var adapter: TerminalSessionsAdapter
private lateinit var terminalViewModel: TerminalViewModel
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
terminalViewModel = ViewModelProvider(this)[TerminalViewModel::class.java]
return inflater.inflate(R.layout.fragment_terminal_download_list, container, false)
terminalViewModel = ViewModelProvider(requireActivity())[TerminalViewModel::class.java]
return inflater.inflate(R.layout.fragment_terminal_session_list, container, false)
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
lifecycleScope.launch {
noResults = view.findViewById(R.id.no_results)
val recycler = view.findViewById<RecyclerView>(R.id.terminal_recycler)
val adapter = TerminalDownloadsAdapter(this@TerminalDownloadsListFragment, requireActivity())
adapter = TerminalSessionsAdapter(this@TerminalSessionListFragment, requireActivity())
recycler.adapter = adapter
recycler.enableFastScroll()
recycler.layoutManager = GridLayoutManager(requireContext(), resources.getInteger(R.integer.grid_size))
@ -59,45 +62,42 @@ class TerminalDownloadsListFragment : Fragment(), TerminalDownloadsAdapter.OnIte
topAppBar?.setOnMenuItemClickListener { m: MenuItem ->
when(m.itemId){
R.id.add -> {
findNavController().navigate(R.id.terminalFragment)
findNavController().navigate(R.id.terminalFragment, bundleOf(Pair("new", true)),
NavOptions.Builder().setPopUpTo(R.id.terminalFragment, true).build())
}
}
true
}
terminalViewModel.getTerminals().collectLatest {
adapter.submitList(it)
noResults.visibility = if (it.isEmpty()) View.VISIBLE else View.GONE
}
}
lifecycleScope.launch {
viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
WorkerEventBus.events.collectLatest { event ->
val progressBar = requireView().findViewWithTag<LinearProgressIndicator>("${event.downloadItemID}##progress")
val outputText = requireView().findViewWithTag<TextView>("${event.downloadItemID}##output")
requireActivity().runOnUiThread {
kotlin.runCatching {
outputText?.text = event.output
progressBar?.setProgressCompat(event.progress, true)
viewLifecycleOwner.lifecycleScope.launch {
viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
terminalViewModel.isBoundState.collect { isBound ->
if (isBound) {
val sessions = terminalViewModel.sessionBinder?.getService()?.sessionList?.keys?.toList()
adapter.submitList(sessions)
noResults.visibility = if (sessions.isNullOrEmpty()) View.VISIBLE else View.GONE
}
}
}
}
}
}
override fun onCancelClick(itemID: Long) {
terminalViewModel.cancelTerminalDownload(itemID)
override fun onDeleteClick(sessionId: String) {
terminalViewModel.sessionBinder?.terminateSession(sessionId)
val sessions = terminalViewModel.sessionBinder?.getService()?.sessionList?.keys?.toList()
if (sessions.isNullOrEmpty()) {
requireActivity().onBackPressedDispatcher.onBackPressed()
} else {
adapter.submitList(sessions)
noResults.visibility = if (sessions.isEmpty()) View.VISIBLE else View.GONE
}
}
override fun onCardClick(item: TerminalItem) {
override fun onCardClick(sessionId: String) {
val bundle = Bundle()
bundle.putLong("id", item.id)
bundle.putString("id", sessionId)
findNavController().navigate(R.id.terminalFragment, bundle)
}
}

@ -0,0 +1,229 @@
package com.deniscerri.ytdl.ui.more.terminal
import android.content.Context
import android.graphics.Color
import android.graphics.Typeface
import android.util.TypedValue
import androidx.compose.runtime.mutableStateOf
import androidx.core.content.ContextCompat
import androidx.core.graphics.ColorUtils
import com.anggrayudi.storage.file.child
import com.deniscerri.ytdl.App
import com.google.android.material.color.MaterialColors
object TerminalUtils {
var typeface: Typeface = Typeface.MONOSPACE
fun init(context: Context) {
val fontFile = context.filesDir.child("font.ttf")
if (fontFile.exists() && fontFile.canRead()) {
typeface = Typeface.createFromFile(fontFile)
}
}
fun getViewColor(context: Context): Int = MaterialColors.getColor(context, android.R.attr.textColorPrimary, Color.BLACK)
fun getBackgroundColor(context: Context): Int = MaterialColors.getColor(context, com.google.android.material.R.attr.colorSurface, Color.BLACK)
fun getComposeColor(): androidx.compose.ui.graphics.Color =
androidx.compose.ui.graphics.Color.White
const val stat = """
cpu 1957 0 2877 93280 262 342 254 87 0 0
cpu0 31 0 226 12027 82 10 4 9 0 0
cpu1 45 0 664 11144 21 263 233 12 0 0
cpu2 494 0 537 11283 27 10 3 8 0 0
cpu3 359 0 234 11723 24 26 5 7 0 0
cpu4 295 0 268 11772 10 12 2 12 0 0
cpu5 270 0 251 11833 15 3 1 10 0 0
cpu6 430 0 520 11386 30 8 1 12 0 0
cpu7 30 0 172 12108 50 8 1 13 0 0
intr 127541 38 290 0 0 0 0 4 0 1 0 0 25329 258 0 5777 277 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 140223
btime 1680020856
processes 772
procs_running 2
procs_blocked 0
softirq 75663 0 5903 6 25375 10774 0 243 11685 0 21677
"""
val vmstat = """
nr_free_pages 1743136
nr_zone_inactive_anon 179281
nr_zone_active_anon 7183
nr_zone_inactive_file 22858
nr_zone_active_file 51328
nr_zone_unevictable 642
nr_zone_write_pending 0
nr_mlock 0
nr_bounce 0
nr_zspages 0
nr_free_cma 0
numa_hit 1259626
numa_miss 0
numa_foreign 0
numa_interleave 720
numa_local 1259626
numa_other 0
nr_inactive_anon 179281
nr_active_anon 7183
nr_inactive_file 22858
nr_active_file 51328
nr_unevictable 642
nr_slab_reclaimable 8091
nr_slab_unreclaimable 7804
nr_isolated_anon 0
nr_isolated_file 0
workingset_nodes 0
workingset_refault_anon 0
workingset_refault_file 0
workingset_activate_anon 0
workingset_activate_file 0
workingset_restore_anon 0
workingset_restore_file 0
workingset_nodereclaim 0
nr_anon_pages 7723
nr_mapped 8905
nr_file_pages 253569
nr_dirty 0
nr_writeback 0
nr_writeback_temp 0
nr_shmem 178741
nr_shmem_hugepages 0
nr_shmem_pmdmapped 0
nr_file_hugepages 0
nr_file_pmdmapped 0
nr_anon_transparent_hugepages 1
nr_vmscan_write 0
nr_vmscan_immediate_reclaim 0
nr_dirtied 0
nr_written 0
nr_throttled_written 0
nr_kernel_misc_reclaimable 0
nr_foll_pin_acquired 0
nr_foll_pin_released 0
nr_kernel_stack 2780
nr_page_table_pages 344
nr_sec_page_table_pages 0
nr_swapcached 0
pgpromote_success 0
pgpromote_candidate 0
nr_dirty_threshold 356564
nr_dirty_background_threshold 178064
pgpgin 890508
pgpgout 0
pswpin 0
pswpout 0
pgalloc_dma 272
pgalloc_dma32 261
pgalloc_normal 1328079
pgalloc_movable 0
pgalloc_device 0
allocstall_dma 0
allocstall_dma32 0
allocstall_normal 0
allocstall_movable 0
allocstall_device 0
pgskip_dma 0
pgskip_dma32 0
pgskip_normal 0
pgskip_movable 0
pgskip_device 0
pgfree 3077011
pgactivate 0
pgdeactivate 0
pglazyfree 0
pgfault 176973
pgmajfault 488
pglazyfreed 0
pgrefill 0
pgreuse 19230
pgsteal_kswapd 0
pgsteal_direct 0
pgsteal_khugepaged 0
pgdemote_kswapd 0
pgdemote_direct 0
pgdemote_khugepaged 0
pgscan_kswapd 0
pgscan_direct 0
pgscan_khugepaged 0
pgscan_direct_throttle 0
pgscan_anon 0
pgscan_file 0
pgsteal_anon 0
pgsteal_file 0
zone_reclaim_failed 0
pginodesteal 0
slabs_scanned 0
kswapd_inodesteal 0
kswapd_low_wmark_hit_quickly 0
kswapd_high_wmark_hit_quickly 0
pageoutrun 0
pgrotated 0
drop_pagecache 0
drop_slab 0
oom_kill 0
numa_pte_updates 0
numa_huge_pte_updates 0
numa_hint_faults 0
numa_hint_faults_local 0
numa_pages_migrated 0
pgmigrate_success 0
pgmigrate_fail 0
thp_migration_success 0
thp_migration_fail 0
thp_migration_split 0
compact_migrate_scanned 0
compact_free_scanned 0
compact_isolated 0
compact_stall 0
compact_fail 0
compact_success 0
compact_daemon_wake 0
compact_daemon_migrate_scanned 0
compact_daemon_free_scanned 0
htlb_buddy_alloc_success 0
htlb_buddy_alloc_fail 0
cma_alloc_success 0
cma_alloc_fail 0
unevictable_pgs_culled 27002
unevictable_pgs_scanned 0
unevictable_pgs_rescued 744
unevictable_pgs_mlocked 744
unevictable_pgs_munlocked 744
unevictable_pgs_cleared 0
unevictable_pgs_stranded 0
thp_fault_alloc 13
thp_fault_fallback 0
thp_fault_fallback_charge 0
thp_collapse_alloc 4
thp_collapse_alloc_failed 0
thp_file_alloc 0
thp_file_fallback 0
thp_file_fallback_charge 0
thp_file_mapped 0
thp_split_page 0
thp_split_page_failed 0
thp_deferred_split_page 1
thp_split_pmd 1
thp_scan_exceed_none_pte 0
thp_scan_exceed_swap_pte 0
thp_scan_exceed_share_pte 0
thp_split_pud 0
thp_zero_page_alloc 0
thp_zero_page_alloc_failed 0
thp_swpout 0
thp_swpout_fallback 0
balloon_inflate 0
balloon_deflate 0
balloon_migrate 0
swap_ra 0
swap_ra_hit 0
ksm_swpin_copy 0
cow_ksm 0
zswpin 0
zswpout 0
direct_map_level2_splits 29
direct_map_level3_splits 0
nr_unstable 0
""".trimIndent()
}

@ -0,0 +1,43 @@
package com.deniscerri.ytdl.ui.more.terminal.virtualkeys
/** The {@link Class} that implements special buttons for {@link VirtualKeysView}. */
class SpecialButton(
/** The special button key. */
val key: String
) {
/** Get [.key] for this [SpecialButton]. */
/**
* Initialize a [SpecialButton].
*
* @param key The unique key name for the special button. The key is registered in [.map]
* with which the [SpecialButton] can be retrieved via a call to [ ][.valueOf].
*/
init {
map.put(key, this)
}
override fun toString(): String {
return key
}
companion object {
private val map = HashMap<String?, SpecialButton?>()
var CTRL: SpecialButton = SpecialButton("CTRL")
val ALT: SpecialButton = SpecialButton("ALT")
val SHIFT: SpecialButton = SpecialButton("SHIFT")
val FN: SpecialButton = SpecialButton("FN")
/**
* Get the [SpecialButton] for `key`.
*
* @param key The unique key name for the special button.
*/
@JvmStatic
fun valueOf(key: String?): SpecialButton? {
return map.get(key)
}
}
}

@ -0,0 +1,55 @@
package com.deniscerri.ytdl.ui.more.terminal.virtualkeys;
import android.widget.Button;
import java.util.ArrayList;
import java.util.List;
/** The {@link Class} that maintains a state of a {@link SpecialButton} */
public class SpecialButtonState {
/** If special button has been created for the {@link VirtualKeysView}. */
boolean isCreated = false;
/** If special button is active. */
boolean isActive = false;
/**
* If special button is locked due to long hold on it and should not be deactivated if its state
* is read.
*/
boolean isLocked = false;
List<Button> buttons = new ArrayList<>();
VirtualKeysView mVirtualKeysView;
/**
* Initialize a {@link SpecialButtonState} to maintain state of a {@link SpecialButton}.
*
* @param extraKeysView The {@link VirtualKeysView} instance in which the {@link SpecialButton} is
* to be registered.
*/
public SpecialButtonState(VirtualKeysView extraKeysView) {
mVirtualKeysView = extraKeysView;
}
/** Set {@link #isCreated}. */
public void setIsCreated(boolean value) {
isCreated = value;
}
/** Set {@link #isActive}. */
public void setIsActive(boolean value) {
isActive = value;
for (Button button : buttons) {
button.setTextColor(
value
? mVirtualKeysView.getButtonActiveTextColor()
: mVirtualKeysView.getButtonTextColor());
}
}
/** Set {@link #isLocked}. */
public void setIsLocked(boolean value) {
isLocked = value;
}
}

@ -0,0 +1,71 @@
package com.deniscerri.ytdl.ui.more.terminal.virtualkeys
import android.view.View
import android.widget.Button
import com.termux.terminal.TerminalSession
class VirtualKeyClient(
private val session: TerminalSession?,
private val virtualKeysView: VirtualKeysView? = null
) : VirtualKeysView.IVirtualKeysView {
companion object {
private val KEY_ESCAPE_SEQUENCES = mapOf(
"ESC" to "\u001B",
"TAB" to "\u0009",
"HOME" to "\u001B[H",
"UP" to "\u001B[A",
"DOWN" to "\u001B[B",
"LEFT" to "\u001B[D",
"RIGHT" to "\u001B[C",
"PGUP" to "\u001B[5~",
"PGDN" to "\u001B[6~",
"END" to "\u001B[4~",
"ENTER" to "\u000D",
"DRAWER" to ""
)
}
override fun onVirtualKeyButtonClick(
view: View,
buttonInfo: VirtualKeyButton,
button: Button
) {
val activeSession = session ?: return
val rawKey = buttonInfo.key.takeIf { it.isNotEmpty() } ?: return
// Read active modifier states if VirtualKeysView context is available
val isCtrlActive = virtualKeysView?.readSpecialButton(SpecialButton.CTRL, true) == true
val isAltActive = virtualKeysView?.readSpecialButton(SpecialButton.ALT, true) == true
var payload = KEY_ESCAPE_SEQUENCES[rawKey] ?: rawKey
if (payload.isEmpty()) return
// Handle CTRL modifier transformations (e.g. CTRL + c -> ASCII 0x03)
if (isCtrlActive && payload.length == 1) {
val char = payload[0]
if (char in 'a'..'z') {
payload = (char.code - 'a'.code + 1).toChar().toString()
} else if (char in 'A'..'Z') {
payload = (char.code - 'A'.code + 1).toChar().toString()
}
}
// Handle ALT modifier transformations (prepends ESC prefix)
if (isAltActive) {
payload = "\u001B$payload"
}
activeSession.write(payload)
}
override fun performVirtualKeyButtonHapticFeedback(
view: View,
buttonInfo: VirtualKeyButton,
button: Button
): Boolean {
// Return false so VirtualKeysView handles standard system haptic feedback
return false
}
}

@ -0,0 +1,168 @@
package com.deniscerri.ytdl.ui.more.terminal.virtualkeys;
import android.text.TextUtils;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import org.json.JSONException;
import org.json.JSONObject;
import java.util.Arrays;
import java.util.stream.Collectors;
public class VirtualKeyButton {
/**
* The key name for the name of the extra key if using a dict to define the extra key. {key: name,
* ...}
*/
public static final String KEY_KEY_NAME = "key";
/**
* The key name for the macro value of the extra key if using a dict to define the extra key.
* {macro: value, ...}
*/
public static final String KEY_MACRO = "macro";
/**
* The key name for the alternate display name of the extra key if using a dict to define the
* extra key. {display: name, ...}
*/
public static final String KEY_DISPLAY_NAME = "display";
/**
* The key name for the nested dict to define popup extra key info if using a dict to define the
* extra key. {popup: {key: name, ...}, ...}
*/
public static final String KEY_POPUP = "popup";
/**
* The key that will be sent to the terminal, either a control character, like defined in {@link
* VirtualKeysConstants#PRIMARY_KEY_CODES_FOR_STRINGS} (LEFT, RIGHT, PGUP...) or some text.
*/
private final String key;
/** If the key is a macro, i.e. a sequence of keys separated by space. */
private final boolean macro;
/** The text that will be displayed on the button. */
private final String display;
/**
* The {@link VirtualKeyButton} containing the information of the popup button (triggered by swipe
* up).
*/
@Nullable private final VirtualKeyButton popup;
/**
* Initialize a {@link VirtualKeyButton}.
*
* @param config The {@link JSONObject} containing the info to create the {@link
* VirtualKeyButton}.
* @param extraKeyDisplayMap The {@link VirtualKeysConstants.VirtualKeyDisplayMap} that defines
* the display text mapping for the keys if a custom value is not defined by {@link
* #KEY_DISPLAY_NAME}.
* @param extraKeyAliasMap The {@link VirtualKeysConstants.VirtualKeyDisplayMap} that defines the
* aliases for the actual key names.
*/
public VirtualKeyButton(
@NonNull JSONObject config,
@NonNull VirtualKeysConstants.VirtualKeyDisplayMap extraKeyDisplayMap,
@NonNull VirtualKeysConstants.VirtualKeyDisplayMap extraKeyAliasMap)
throws JSONException {
this(config, null, extraKeyDisplayMap, extraKeyAliasMap);
}
/**
* Initialize a {@link VirtualKeyButton}.
*
* @param config The {@link JSONObject} containing the info to create the {@link
* VirtualKeyButton}.
* @param popup The {@link VirtualKeyButton} optional {@link #popup} button.
* @param extraKeyDisplayMap The {@link VirtualKeysConstants.VirtualKeyDisplayMap} that defines
* the display text mapping for the keys if a custom value is not defined by {@link
* #KEY_DISPLAY_NAME}.
* @param extraKeyAliasMap The {@link VirtualKeysConstants.VirtualKeyDisplayMap} that defines the
* aliases for the actual key names.
*/
public VirtualKeyButton(
@NonNull JSONObject config,
@Nullable VirtualKeyButton popup,
@NonNull VirtualKeysConstants.VirtualKeyDisplayMap extraKeyDisplayMap,
@NonNull VirtualKeysConstants.VirtualKeyDisplayMap extraKeyAliasMap)
throws JSONException {
String keyFromConfig = getStringFromJson(config, KEY_KEY_NAME);
String macroFromConfig = getStringFromJson(config, KEY_MACRO);
String[] keys;
if (keyFromConfig != null && macroFromConfig != null) {
throw new JSONException(
"Both key and macro can't be set for the same key. key: \""
+ keyFromConfig
+ "\", macro: \""
+ macroFromConfig
+ "\"");
} else if (keyFromConfig != null) {
keys = new String[] {keyFromConfig};
this.macro = false;
} else if (macroFromConfig != null) {
keys = macroFromConfig.split(" ");
this.macro = true;
} else {
throw new JSONException("All keys have to specify either key or macro");
}
for (int i = 0; i < keys.length; i++) {
keys[i] = replaceAlias(extraKeyAliasMap, keys[i]);
}
this.key = TextUtils.join(" ", keys);
String displayFromConfig = getStringFromJson(config, KEY_DISPLAY_NAME);
if (displayFromConfig != null) {
this.display = displayFromConfig;
} else {
this.display =
Arrays.stream(keys)
.map(key -> extraKeyDisplayMap.get(key, key))
.collect(Collectors.joining(" "));
}
this.popup = popup;
}
public String getStringFromJson(@NonNull JSONObject config, @NonNull String key) {
try {
return config.getString(key);
} catch (JSONException e) {
return null;
}
}
/** Replace the alias with its actual key name if found in extraKeyAliasMap. */
public static String replaceAlias(
@NonNull VirtualKeysConstants.VirtualKeyDisplayMap extraKeyAliasMap, String key) {
return extraKeyAliasMap.get(key, key);
}
/** Get {@link #key}. */
public String getKey() {
return key;
}
/** Check whether a {@link #macro} is defined or not. */
public boolean isMacro() {
return macro;
}
/** Get {@link #display}. */
public String getDisplay() {
return display;
}
/** Get {@link #popup}. */
@Nullable
public VirtualKeyButton getPopup() {
return popup;
}
}

@ -0,0 +1,245 @@
package com.deniscerri.ytdl.ui.more.terminal.virtualkeys;
import android.view.KeyEvent;
import java.util.Arrays;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
public class VirtualKeysConstants {
/** Aliases for the keys */
public static final VirtualKeyDisplayMap CONTROL_CHARS_ALIASES =
new VirtualKeyDisplayMap() {
{
put("ESCAPE", "ESC");
put("CONTROL", "CTRL");
put("SHFT", "SHIFT");
put("RETURN", "ENTER"); // Technically different keys, but most applications won't see
// the
// difference
put("FUNCTION", "FN");
// no alias for ALT
// Directions are sometimes written as first and last letter for brevety
put("LT", "LEFT");
put("RT", "RIGHT");
put("DN", "DOWN");
// put("UP", "UP"); well, "UP" is already two letters
put("PAGEUP", "PGUP");
put("PAGE_UP", "PGUP");
put("PAGE UP", "PGUP");
put("PAGE-UP", "PGUP");
// no alias for HOME
// no alias for END
put("PAGEDOWN", "PGDN");
put("PAGE_DOWN", "PGDN");
put("PAGE-DOWN", "PGDN");
put("DELETE", "DEL");
put("BACKSPACE", "BKSP");
// easier for writing in termux.properties
put("BACKSLASH", "\\");
put("QUOTE", "\"");
put("APOSTROPHE", "'");
}
};
/**
* Defines the repetitive keys that can be passed to {@link
* VirtualKeysView#setRepetitiveKeys(List)}.
*/
public static List<String> PRIMARY_REPETITIVE_KEYS =
Arrays.asList("UP", "DOWN", "LEFT", "RIGHT", "BKSP", "DEL");
/** Defines the {@link KeyEvent} for common keys. */
public static Map<String, Integer> PRIMARY_KEY_CODES_FOR_STRINGS =
new HashMap<String, Integer>() {
{
put("SPACE", KeyEvent.KEYCODE_SPACE);
put("ESC", KeyEvent.KEYCODE_ESCAPE);
put("TAB", KeyEvent.KEYCODE_TAB);
put("HOME", KeyEvent.KEYCODE_MOVE_HOME);
put("END", KeyEvent.KEYCODE_MOVE_END);
put("PGUP", KeyEvent.KEYCODE_PAGE_UP);
put("PGDN", KeyEvent.KEYCODE_PAGE_DOWN);
put("INS", KeyEvent.KEYCODE_INSERT);
put("DEL", KeyEvent.KEYCODE_FORWARD_DEL);
put("BKSP", KeyEvent.KEYCODE_DEL);
put("UP", KeyEvent.KEYCODE_DPAD_UP);
put("LEFT", KeyEvent.KEYCODE_DPAD_LEFT);
put("RIGHT", KeyEvent.KEYCODE_DPAD_RIGHT);
put("DOWN", KeyEvent.KEYCODE_DPAD_DOWN);
put("ENTER", KeyEvent.KEYCODE_ENTER);
put("F1", KeyEvent.KEYCODE_F1);
put("F2", KeyEvent.KEYCODE_F2);
put("F3", KeyEvent.KEYCODE_F3);
put("F4", KeyEvent.KEYCODE_F4);
put("F5", KeyEvent.KEYCODE_F5);
put("F6", KeyEvent.KEYCODE_F6);
put("F7", KeyEvent.KEYCODE_F7);
put("F8", KeyEvent.KEYCODE_F8);
put("F9", KeyEvent.KEYCODE_F9);
put("F10", KeyEvent.KEYCODE_F10);
put("F11", KeyEvent.KEYCODE_F11);
put("F12", KeyEvent.KEYCODE_F12);
}
};
public static class VirtualKeyDisplayMap extends CleverMap<String, String> {}
/*
* Multiple maps are available to quickly change
* the style of the keys.
*/
public static class EXTRA_KEY_DISPLAY_MAPS {
/** Keys are displayed in a natural looking way, like "→" for "RIGHT" */
public static final VirtualKeyDisplayMap CLASSIC_ARROWS_DISPLAY =
new VirtualKeyDisplayMap() {
{
// classic arrow keys (for ◀ ▶ ▲ ▼ @see arrowVariationDisplay)
put("LEFT", "←"); // U+2190 ← LEFTWARDS ARROW
put("RIGHT", "→"); // U+2192 → RIGHTWARDS ARROW
put("UP", "↑"); // U+2191 ↑ UPWARDS ARROW
put("DOWN", "↓"); // U+2193 ↓ DOWNWARDS ARROW
}
};
public static final VirtualKeyDisplayMap WELL_KNOWN_CHARACTERS_DISPLAY =
new VirtualKeyDisplayMap() {
{
// well known characters // https://en.wikipedia.org/wiki/{Enter_key,
// Tab_key,
// Delete_key}
put("ENTER", "↲"); // U+21B2 ↲ DOWNWARDS ARROW WITH TIP LEFTWARDS
put("TAB", "↹"); // U+21B9 ↹ LEFTWARDS ARROW TO BAR OVER RIGHTWARDS ARROW TO
// BAR
put("BKSP", "⌫"); // U+232B ⌫ ERASE TO THE LEFT sometimes seen and easy to
// understand
put("DEL", "⌦"); // U+2326 ⌦ ERASE TO THE RIGHT not well known but easy to
// understand
put("DRAWER", "☰"); // U+2630 ☰ TRIGRAM FOR HEAVEN not well known but easy to
// understand
put("KEYBOARD", "⌨"); // U+2328 ⌨ KEYBOARD not well known but easy to understand
put("PASTE", "⎘"); // U+2398
}
};
public static final VirtualKeyDisplayMap LESS_KNOWN_CHARACTERS_DISPLAY =
new VirtualKeyDisplayMap() {
{
// https://en.wikipedia.org/wiki/{Home_key, End_key,
// Page_Up_and_Page_Down_keys}
// home key can mean "goto the beginning of line" or "goto first page"
// depending on
// context, hence the diagonal
put("HOME", "⇱"); // from IEC 9995 // U+21F1 ⇱ NORTH WEST ARROW TO CORNER
put("END", "⇲"); // from IEC 9995 // ⇲ // U+21F2 ⇲ SOUTH EAST ARROW TO CORNER
put("PGUP", "⇑"); // no ISO character exists, U+21D1 ⇑ UPWARDS DOUBLE ARROW will
// do the trick
put("PGDN", "⇓"); // no ISO character exists, U+21D3 ⇓ DOWNWARDS DOUBLE ARROW
// will do the trick
}
};
public static final VirtualKeyDisplayMap ARROW_TRIANGLE_VARIATION_DISPLAY =
new VirtualKeyDisplayMap() {
{
// alternative to classic arrow keys
put("LEFT", "◀"); // U+25C0 ◀ BLACK LEFT-POINTING TRIANGLE
put("RIGHT", "▶"); // U+25B6 ▶ BLACK RIGHT-POINTING TRIANGLE
put("UP", "▲"); // U+25B2 ▲ BLACK UP-POINTING TRIANGLE
put("DOWN", "▼"); // U+25BC ▼ BLACK DOWN-POINTING TRIANGLE
}
};
public static final VirtualKeyDisplayMap NOT_KNOWN_ISO_CHARACTERS =
new VirtualKeyDisplayMap() {
{
// Control chars that are more clear as text //
// https://en.wikipedia.org/wiki/{Function_key, Alt_key, Control_key,
// Esc_key}
// put("FN", "FN"); // no ISO character exists
put("CTRL", "⎈"); // ISO character "U+2388 ⎈ HELM SYMBOL" is unknown to people
// and never printed
// on computers, however "U+25C7 ◇ WHITE DIAMOND" is a nice presentation,
// and
// "^" for terminal app and mac is often used
put("ALT", "⎇"); // ISO character "U+2387 ⎇ ALTERNATIVE KEY SYMBOL'" is unknown
// to people and
// only printed as the Option key "⌥" on Mac computer
put("ESC", "⎋"); // ISO character "U+238B ⎋ BROKEN CIRCLE WITH NORTHWEST ARROW"
// is unknown to
// people and not often printed on computers
}
};
public static final VirtualKeyDisplayMap NICER_LOOKING_DISPLAY =
new VirtualKeyDisplayMap() {
{
// nicer looking for most cases
put("-", "―"); // U+2015 ― HORIZONTAL BAR
}
};
/** Full Iso */
public static final VirtualKeyDisplayMap FULL_ISO_CHAR_DISPLAY =
new VirtualKeyDisplayMap() {
{
putAll(CLASSIC_ARROWS_DISPLAY);
putAll(WELL_KNOWN_CHARACTERS_DISPLAY);
putAll(LESS_KNOWN_CHARACTERS_DISPLAY); // NEW
putAll(NICER_LOOKING_DISPLAY);
putAll(NOT_KNOWN_ISO_CHARACTERS); // NEW
}
};
/** Only arrows */
public static final VirtualKeyDisplayMap ARROWS_ONLY_CHAR_DISPLAY =
new VirtualKeyDisplayMap() {
{
putAll(CLASSIC_ARROWS_DISPLAY);
// putAll(wellKnownCharactersDisplay); // REMOVED
// putAll(lessKnownCharactersDisplay); // REMOVED
putAll(NICER_LOOKING_DISPLAY);
}
};
/** Classic symbols and less known symbols */
public static final VirtualKeyDisplayMap LOTS_OF_ARROWS_CHAR_DISPLAY =
new VirtualKeyDisplayMap() {
{
putAll(CLASSIC_ARROWS_DISPLAY);
putAll(WELL_KNOWN_CHARACTERS_DISPLAY);
putAll(LESS_KNOWN_CHARACTERS_DISPLAY); // NEW
putAll(NICER_LOOKING_DISPLAY);
}
};
/** Some classic symbols everybody knows */
public static final VirtualKeyDisplayMap DEFAULT_CHAR_DISPLAY =
new VirtualKeyDisplayMap() {
{
putAll(CLASSIC_ARROWS_DISPLAY);
putAll(WELL_KNOWN_CHARACTERS_DISPLAY);
putAll(NICER_LOOKING_DISPLAY);
// all other characters are displayed as themselves
}
};
}
/**
* HashMap that implements Python dict.get(key, default) function. Default java.util .get(key) is
* then the same as .get(key, null);
*/
static class CleverMap<K, V> extends HashMap<K, V> {
V get(K key, V defaultValue) {
if (containsKey(key)) return get(key);
else return defaultValue;
}
}
}

@ -0,0 +1,202 @@
package com.deniscerri.ytdl.ui.more.terminal.virtualkeys;
import android.view.View;
import android.widget.Button;
import androidx.annotation.NonNull;
import org.json.JSONArray;
import org.json.JSONException;
import org.json.JSONObject;
/**
* A {@link Class} that defines the info needed by {@link VirtualKeysView} to display the extra key
* views.
*
* <p>The {@code propertiesInfo} passed to the constructors of this class must be json array of
* arrays. Each array element of the json array will be considered a separate row of keys. Each key
* can either be simple string that defines the name of the key or a json dict that defines advance
* info for the key. The syntax can be `'KEY'` or `{key: 'KEY'}`. For example `HOME` or `{key:
* 'HOME', ...}.
*
* <p>In advance json dict mode, the key can also be a sequence of space separated keys instead of
* one key. This can be done by replacing `key` key/value pair of the dict with a `macro` key/value
* pair. The syntax is `{macro: 'KEY COMBINATION'}`. For example {macro: 'HOME RIGHT', ...}.
*
* <p>In advance json dict mode, you can define a nested json dict with the `popup` key which will
* be used as the popup key and will be triggered on swipe up. The syntax can be `{key: 'KEY',
* popup: 'POPUP_KEY'}` or `{key: 'KEY', popup: {macro: 'KEY COMBINATION', display: 'Key combo'}}`.
* For example `{key: 'HOME', popup: {KEY: 'END', ...}, ...}`.
*
* <p>In advance json dict mode, the key can also have a custom display name that can be used as the
* text to display on the button by defining the `display` key. The syntax is `{display:
* 'DISPLAY'}`. For example {display: 'Custom name', ...}.
*
* <p>Examples: {@code # Empty: []
*
* <p><p><p><p><p><p><p><p><p><p><p><p><p><p><p><p><p><p><p><p><p><p><p><p><p><p><p><p><p><p><p><p><p><p><p><p><p><p><p><p><p><p><p><p><p><p><p><p><p><p><p>#
* Single row: [[ESC, TAB, CTRL, ALT, {key: '-', popup: '|'}, DOWN, UP]]
*
* <p><p><p><p><p><p><p><p><p><p><p><p><p><p><p><p><p><p><p><p><p><p><p><p><p><p><p><p><p><p><p><p><p><p><p><p><p><p><p><p><p><p><p><p><p><p><p><p><p><p><p>#
* 2 row: [['ESC','/',{key: '-', popup: '|'},'HOME','UP','END','PGUP'],
* ['TAB','CTRL','ALT','LEFT','DOWN','RIGHT','PGDN']]
*
* <p><p><p><p><p><p><p><p><p><p><p><p><p><p><p><p><p><p><p><p><p><p><p><p><p><p><p><p><p><p><p><p><p><p><p><p><p><p><p><p><p><p><p><p><p><p><p><p><p><p><p>#
* Advance: [[ {key: ESC, popup: {macro: "CTRL f d", display: "tmux exit"}}, {key: CTRL, popup:
* {macro: "CTRL f BKSP", display: "tmux ←"}}, {key: ALT, popup: {macro: "CTRL f TAB", display:
* "tmux →"}}, {key: TAB, popup: {macro: "ALT a", display: A-a}}, {key: LEFT, popup: HOME}, {key:
* DOWN, popup: PGDN}, {key: UP, popup: PGUP}, {key: RIGHT, popup: END}, {macro: "ALT j", display:
* A-j, popup: {macro: "ALT g", display: A-g}}, {key: KEYBOARD, popup: {macro: "CTRL d", display:
* exit}} ]]
*
* <p><p><p><p><p><p><p><p><p><p><p><p><p><p><p><p><p><p><p><p><p><p><p><p><p><p><p><p><p><p><p><p><p><p><p><p><p><p><p><p><p><p><p><p><p><p><p><p><p><p><p>}
*
* <p>Aliases are also allowed for the keys that you can pass as {@code extraKeyAliasMap}. Check
* {@link VirtualKeysConstants#CONTROL_CHARS_ALIASES}.
*
* <p>Its up to the {@link VirtualKeysView.IVirtualKeysView} client on how to handle individual key
* values of an {@link VirtualKeyButton}. They are sent as is via {@link
* VirtualKeysView.IVirtualKeysView#onVirtualKeyButtonClick(View, VirtualKeyButton, Button)}. The
* {TerminalVirtualKeys} which is an implementation of the
* interface, checks if the key is one of {@link VirtualKeysConstants#PRIMARY_KEY_CODES_FOR_STRINGS}
* and generates a {@link android.view.KeyEvent} for it, and if its not, then converts the key to
* code points by calling {@link CharSequence#codePoints()} and passes them to the terminal as
* literal strings.
*
* <p>Examples: {@code "ENTER" will trigger the ENTER keycode "LEFT" will trigger the LEFT keycode
* and be displayed as "←" "→" will input a "→" character "" will input a "" character "-_-" will
* input the string "-_-" }
*
* <p>For more info, check https://wiki.termux.com/wiki/Touch_Keyboard.
*/
public class VirtualKeysInfo {
/** Matrix of buttons to be displayed in {@link VirtualKeysView}. */
private final VirtualKeyButton[][] mButtons;
/**
* Initialize {@link VirtualKeysInfo}.
*
* @param propertiesInfo The {@link String} containing the info to create the {@link
* VirtualKeysInfo}. Check the class javadoc for details.
* @param style The style to pass to {@link #getCharDisplayMapForStyle(String)} to get the {@link
* VirtualKeysConstants.VirtualKeyDisplayMap} that defines the display text mapping for the
* keys if a custom value is not defined by {@link VirtualKeyButton#KEY_DISPLAY_NAME} for a
* key.
* @param extraKeyAliasMap The {@link VirtualKeysConstants.VirtualKeyDisplayMap} that defines the
* aliases for the actual key names. You can create your own or optionally pass {@link
* VirtualKeysConstants#CONTROL_CHARS_ALIASES}.
*/
public VirtualKeysInfo(
@NonNull String propertiesInfo,
String style,
@NonNull VirtualKeysConstants.VirtualKeyDisplayMap extraKeyAliasMap)
throws JSONException {
mButtons =
initVirtualKeysInfo(propertiesInfo, getCharDisplayMapForStyle(style), extraKeyAliasMap);
}
private VirtualKeyButton[][] initVirtualKeysInfo(
@NonNull String propertiesInfo,
@NonNull VirtualKeysConstants.VirtualKeyDisplayMap extraKeyDisplayMap,
@NonNull VirtualKeysConstants.VirtualKeyDisplayMap extraKeyAliasMap)
throws JSONException {
// Convert String propertiesInfo to Array of Arrays
JSONArray arr = new JSONArray(propertiesInfo);
Object[][] matrix = new Object[arr.length()][];
for (int i = 0; i < arr.length(); i++) {
JSONArray line = arr.getJSONArray(i);
matrix[i] = new Object[line.length()];
for (int j = 0; j < line.length(); j++) {
matrix[i][j] = line.get(j);
}
}
// convert matrix to buttons
VirtualKeyButton[][] buttons = new VirtualKeyButton[matrix.length][];
for (int i = 0; i < matrix.length; i++) {
buttons[i] = new VirtualKeyButton[matrix[i].length];
for (int j = 0; j < matrix[i].length; j++) {
Object key = matrix[i][j];
JSONObject jobject = normalizeKeyConfig(key);
VirtualKeyButton button;
if (!jobject.has(VirtualKeyButton.KEY_POPUP)) {
// no popup
button = new VirtualKeyButton(jobject, extraKeyDisplayMap, extraKeyAliasMap);
} else {
// a popup
JSONObject popupJobject = normalizeKeyConfig(jobject.get(VirtualKeyButton.KEY_POPUP));
VirtualKeyButton popup =
new VirtualKeyButton(popupJobject, extraKeyDisplayMap, extraKeyAliasMap);
button = new VirtualKeyButton(jobject, popup, extraKeyDisplayMap, extraKeyAliasMap);
}
buttons[i][j] = button;
}
}
return buttons;
}
/**
* Convert "value" -> {"key": "value"}. Required by {@link
* VirtualKeyButton#VirtualKeyButton(JSONObject, VirtualKeyButton,
* VirtualKeysConstants.VirtualKeyDisplayMap, VirtualKeysConstants.VirtualKeyDisplayMap)}.
*/
private static JSONObject normalizeKeyConfig(Object key) throws JSONException {
JSONObject jobject;
if (key instanceof String) {
jobject = new JSONObject();
jobject.put(VirtualKeyButton.KEY_KEY_NAME, key);
} else if (key instanceof JSONObject) {
jobject = (JSONObject) key;
} else {
throw new JSONException("An key in the extra-key matrix must be a string or an object");
}
return jobject;
}
@NonNull
public static VirtualKeysConstants.VirtualKeyDisplayMap getCharDisplayMapForStyle(String style) {
switch (style) {
case "arrows-only":
return VirtualKeysConstants.EXTRA_KEY_DISPLAY_MAPS.ARROWS_ONLY_CHAR_DISPLAY;
case "arrows-all":
return VirtualKeysConstants.EXTRA_KEY_DISPLAY_MAPS.LOTS_OF_ARROWS_CHAR_DISPLAY;
case "all":
return VirtualKeysConstants.EXTRA_KEY_DISPLAY_MAPS.FULL_ISO_CHAR_DISPLAY;
case "none":
return new VirtualKeysConstants.VirtualKeyDisplayMap();
default:
return VirtualKeysConstants.EXTRA_KEY_DISPLAY_MAPS.DEFAULT_CHAR_DISPLAY;
}
}
/**
* Initialize {@link VirtualKeysInfo}.
*
* @param propertiesInfo The {@link String} containing the info to create the {@link
* VirtualKeysInfo}. Check the class javadoc for details.
* @param extraKeyDisplayMap The {@link VirtualKeysConstants.VirtualKeyDisplayMap} that defines
* the display text mapping for the keys if a custom value is not defined by {@link
* VirtualKeyButton#KEY_DISPLAY_NAME} for a key. You can create your own or optionally pass
* one of the values defined in {@link #getCharDisplayMapForStyle(String)}.
* @param extraKeyAliasMap The {@link VirtualKeysConstants.VirtualKeyDisplayMap} that defines the
* aliases for the actual key names. You can create your own or optionally pass {@link
* VirtualKeysConstants#CONTROL_CHARS_ALIASES}.
*/
public VirtualKeysInfo(
@NonNull String propertiesInfo,
@NonNull VirtualKeysConstants.VirtualKeyDisplayMap extraKeyDisplayMap,
@NonNull VirtualKeysConstants.VirtualKeyDisplayMap extraKeyAliasMap)
throws JSONException {
mButtons = initVirtualKeysInfo(propertiesInfo, extraKeyDisplayMap, extraKeyAliasMap);
}
public VirtualKeyButton[][] getMatrix() {
return mButtons;
}
}

@ -0,0 +1,72 @@
package com.deniscerri.ytdl.ui.more.terminal.virtualkeys
import android.view.View
import android.widget.Button
import com.termux.terminal.TerminalSession
class VirtualKeysListener(
private val session: TerminalSession?,
private val virtualKeysView: VirtualKeysView? = null
) : VirtualKeysView.IVirtualKeysView {
companion object {
private val KEY_ESCAPE_SEQUENCES = mapOf(
"UP" to "\u001B[A",
"DOWN" to "\u001B[B",
"LEFT" to "\u001B[D",
"RIGHT" to "\u001B[C",
"ENTER" to "\u000D",
"PGUP" to "\u001B[5~",
"PGDN" to "\u001B[6~",
"TAB" to "\u0009",
"HOME" to "\u001B[H",
"END" to "\u001B[F",
"ESC" to "\u001B",
"DRAWER" to ""
)
}
override fun onVirtualKeyButtonClick(
view: View,
buttonInfo: VirtualKeyButton,
button: Button
) {
val activeSession = session ?: return
val rawKey = buttonInfo.key.takeIf { it.isNotEmpty() } ?: return
// Resolve special modifier states if VirtualKeysView context is available
val isCtrlActive = virtualKeysView?.readSpecialButton(SpecialButton.CTRL, true) == true
val isAltActive = virtualKeysView?.readSpecialButton(SpecialButton.ALT, true) == true
val isFnActive = virtualKeysView?.readSpecialButton(SpecialButton.FN, true) == true
var payload = KEY_ESCAPE_SEQUENCES[rawKey] ?: rawKey
if (payload.isEmpty()) return
// Handle CTRL modifier combination for standard ASCII characters (A-Z -> Control characters)
if (isCtrlActive && payload.length == 1) {
val char = payload[0]
if (char in 'a'..'z') {
payload = (char.code - 'a'.code + 1).toChar().toString()
} else if (char in 'A'..'Z') {
payload = (char.code - 'A'.code + 1).toChar().toString()
}
}
// Handle ALT modifier prefix (send ESC before character sequence)
if (isAltActive) {
payload = "\u001B$payload"
}
activeSession.write(payload)
}
override fun performVirtualKeyButtonHapticFeedback(
view: View,
buttonInfo: VirtualKeyButton,
button: Button
): Boolean {
// Return false to allow VirtualKeysView to fall back to standard system haptics
return false
}
}

@ -0,0 +1,411 @@
package com.deniscerri.ytdl.ui.more.terminal.virtualkeys
import android.R
import android.annotation.SuppressLint
import android.content.Context
import android.graphics.Color
import android.os.Build
import android.os.Handler
import android.os.Looper
import android.provider.Settings
import android.util.AttributeSet
import android.view.HapticFeedbackConstants
import android.view.MotionEvent
import android.view.View
import android.view.ViewConfiguration
import android.widget.Button
import android.widget.GridLayout
import android.widget.PopupWindow
import com.google.android.material.color.MaterialColors
import java.util.concurrent.Executors
import java.util.concurrent.ScheduledExecutorService
import java.util.concurrent.TimeUnit
import kotlin.math.max
/**
* A [View] showing extra keys (such as Escape, Ctrl, Alt) not normally available on an Android soft
* keyboard.
*/
class VirtualKeysView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null
) : GridLayout(context, attrs) {
companion object {
const val DEFAULT_BUTTON_TEXT_COLOR = -0x1
const val DEFAULT_BUTTON_ACTIVE_TEXT_COLOR = -0xbc2c9
const val DEFAULT_BUTTON_BACKGROUND_COLOR = 0x00000000
const val DEFAULT_BUTTON_ACTIVE_BACKGROUND_COLOR = -0x808081
const val MIN_LONG_PRESS_DURATION = 200
const val MAX_LONG_PRESS_DURATION = 3000
const val FALLBACK_LONG_PRESS_DURATION = 400
const val MIN_LONG_PRESS__REPEAT_DELAY = 5
const val MAX_LONG_PRESS__REPEAT_DELAY = 2000
const val DEFAULT_LONG_PRESS_REPEAT_DELAY = 80
/** General util function to compute the longest column length in a matrix. */
@JvmStatic
fun maximumLength(matrix: Array<Array<VirtualKeyButton>>): Int {
var m = 0
for (row in matrix) {
m = max(m, row.size)
}
return m
}
}
var virtualKeysViewClient: IVirtualKeysView? = null
private var _specialButtons: Map<SpecialButton, SpecialButtonState>? = null
var specialButtons: Map<SpecialButton, SpecialButtonState>?
get() = _specialButtons?.toMap()
set(value) {
_specialButtons = value
specialButtonsKeys = value?.keys?.map { it.key }?.toSet()
}
var specialButtonsKeys: Set<String>? = null
private set
private var _repetitiveKeys: List<String>? = null
var repetitiveKeys: List<String>?
get() = _repetitiveKeys?.toList()
set(value) {
_repetitiveKeys = value
}
var buttonTextColor: Int = MaterialColors.getColor(context, android.R.attr.textColorPrimary, Color.BLACK)
var buttonActiveTextColor: Int = DEFAULT_BUTTON_ACTIVE_TEXT_COLOR
var buttonBackgroundColor: Int = MaterialColors.getColor(context, com.google.android.material.R.attr.colorSurfaceContainer, Color.BLACK)
var buttonActiveBackgroundColor: Int = DEFAULT_BUTTON_ACTIVE_BACKGROUND_COLOR
var isButtonTextAllCaps: Boolean = true
var longPressTimeout: Int = ViewConfiguration.getLongPressTimeout()
set(value) {
field = if (value in MIN_LONG_PRESS_DURATION..MAX_LONG_PRESS_DURATION) {
value
} else {
FALLBACK_LONG_PRESS_DURATION
}
}
var longPressRepeatDelay: Int = DEFAULT_LONG_PRESS_REPEAT_DELAY
set(value) {
field = if (value in MIN_LONG_PRESS__REPEAT_DELAY..MAX_LONG_PRESS__REPEAT_DELAY) {
value
} else {
DEFAULT_LONG_PRESS_REPEAT_DELAY
}
}
private var popupWindow: PopupWindow? = null
private var scheduledExecutor: ScheduledExecutorService? = null
private var handler: Handler? = null
private var specialButtonsLongHoldRunnable: SpecialButtonsLongHoldRunnable? = null
private var longPressCount: Int = 0
init {
repetitiveKeys = VirtualKeysConstants.PRIMARY_REPETITIVE_KEYS
specialButtons = getDefaultSpecialButtons(this)
setButtonColors(
buttonTextColor,
buttonActiveTextColor,
buttonBackgroundColor,
buttonActiveBackgroundColor
)
longPressTimeout = ViewConfiguration.getLongPressTimeout()
longPressRepeatDelay = DEFAULT_LONG_PRESS_REPEAT_DELAY
}
fun setButtonColors(
buttonTextColor: Int,
buttonActiveTextColor: Int,
buttonBackgroundColor: Int,
buttonActiveBackgroundColor: Int
) {
this.buttonTextColor = buttonTextColor
this.buttonActiveTextColor = buttonActiveTextColor
this.buttonBackgroundColor = buttonBackgroundColor
this.buttonActiveBackgroundColor = buttonActiveBackgroundColor
}
fun getDefaultSpecialButtons(extraKeysView: VirtualKeysView): Map<SpecialButton, SpecialButtonState> {
return mapOf(
SpecialButton.CTRL to SpecialButtonState(extraKeysView),
SpecialButton.ALT to SpecialButtonState(extraKeysView),
SpecialButton.SHIFT to SpecialButtonState(extraKeysView),
SpecialButton.FN to SpecialButtonState(extraKeysView)
)
}
@SuppressLint("ClickableViewAccessibility")
fun reload(extraKeysInfo: VirtualKeysInfo?) {
if (extraKeysInfo == null) return
_specialButtons?.values?.forEach { state ->
state.buttons = ArrayList()
}
removeAllViews()
val buttons = extraKeysInfo.matrix ?: return
rowCount = buttons.size
columnCount = maximumLength(buttons)
for (row in buttons.indices) {
for (col in buttons[row].indices) {
val buttonInfo = buttons[row][col]
val button: Button = if (isSpecialButton(buttonInfo)) {
createSpecialButton(buttonInfo.key, true) ?: return
} else {
Button(context, null, R.attr.buttonBarButtonStyle)
}
button.text = buttonInfo.display
button.setTextColor(buttonTextColor)
button.isAllCaps = isButtonTextAllCaps
button.setPadding(0, 0, 0, 0)
button.setOnClickListener { view ->
performVirtualKeyButtonHapticFeedback(view, buttonInfo, button)
onAnyVirtualKeyButtonClick(view, buttonInfo, button)
}
button.setOnTouchListener { view, event ->
when (event.action) {
MotionEvent.ACTION_DOWN -> {
view.setBackgroundColor(buttonActiveBackgroundColor)
startScheduledExecutors(view, buttonInfo, button)
true
}
MotionEvent.ACTION_MOVE -> {
if (buttonInfo.popup != null) {
if (popupWindow == null && event.y < 0) {
stopScheduledExecutors()
view.setBackgroundColor(buttonBackgroundColor)
showPopup(view, buttonInfo.popup!!)
}
if (popupWindow != null && event.y > 0) {
view.setBackgroundColor(buttonActiveBackgroundColor)
dismissPopup()
}
}
true
}
MotionEvent.ACTION_CANCEL -> {
view.setBackgroundColor(buttonBackgroundColor)
stopScheduledExecutors()
true
}
MotionEvent.ACTION_UP -> {
view.setBackgroundColor(buttonBackgroundColor)
stopScheduledExecutors()
if (longPressCount == 0 || popupWindow != null) {
if (popupWindow != null) {
dismissPopup()
buttonInfo.popup?.let { popup ->
onAnyVirtualKeyButtonClick(view, popup, button)
}
} else {
view.performClick()
}
}
true
}
else -> true
}
}
val param = LayoutParams().apply {
width = 0
height = 0
setMargins(0, 0, 0, 0)
columnSpec = spec(col, FILL, 1f)
rowSpec = spec(row, FILL, 1f)
}
button.layoutParams = param
addView(button)
}
}
}
private fun performVirtualKeyButtonHapticFeedback(
view: View,
buttonInfo: VirtualKeyButton,
button: Button
) {
if (virtualKeysViewClient?.performVirtualKeyButtonHapticFeedback(view, buttonInfo, button) == true) {
return
}
val hapticEnabled = Settings.System.getInt(
context.contentResolver,
Settings.System.HAPTIC_FEEDBACK_ENABLED,
0
) != 0
if (hapticEnabled) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
button.performHapticFeedback(HapticFeedbackConstants.KEYBOARD_TAP)
} else {
if (Settings.Global.getInt(context.contentResolver, "zen_mode", 0) != 2) {
button.performHapticFeedback(HapticFeedbackConstants.KEYBOARD_TAP)
}
}
}
}
private fun onAnyVirtualKeyButtonClick(
view: View,
buttonInfo: VirtualKeyButton,
button: Button
) {
if (isSpecialButton(buttonInfo)) {
if (longPressCount > 0) return
val specialButton = runCatching { SpecialButton.valueOf(buttonInfo.key) }.getOrNull() ?: return
val state = _specialButtons?.get(specialButton) ?: return
state.setIsActive(!state.isActive)
if (!state.isActive) state.setIsLocked(false)
} else {
onVirtualKeyButtonClick(view, buttonInfo, button)
}
}
private fun onVirtualKeyButtonClick(view: View, buttonInfo: VirtualKeyButton, button: Button) {
virtualKeysViewClient?.onVirtualKeyButtonClick(view, buttonInfo, button)
}
private fun startScheduledExecutors(view: View, buttonInfo: VirtualKeyButton, button: Button) {
stopScheduledExecutors()
longPressCount = 0
if (_repetitiveKeys?.contains(buttonInfo.key) == true) {
scheduledExecutor = Executors.newSingleThreadScheduledExecutor().apply {
scheduleWithFixedDelay(
{
longPressCount++
onVirtualKeyButtonClick(view, buttonInfo, button)
},
longPressTimeout.toLong(),
longPressRepeatDelay.toLong(),
TimeUnit.MILLISECONDS
)
}
} else if (isSpecialButton(buttonInfo)) {
val specialButton = runCatching { SpecialButton.valueOf(buttonInfo.key) }.getOrNull() ?: return
val state = _specialButtons?.get(specialButton) ?: return
if (handler == null) handler = Handler(Looper.getMainLooper())
specialButtonsLongHoldRunnable = SpecialButtonsLongHoldRunnable(state)
handler?.postDelayed(specialButtonsLongHoldRunnable!!, longPressTimeout.toLong())
}
}
private fun stopScheduledExecutors() {
scheduledExecutor?.shutdownNow()
scheduledExecutor = null
specialButtonsLongHoldRunnable?.let { runnable ->
handler?.removeCallbacks(runnable)
specialButtonsLongHoldRunnable = null
}
}
fun showPopup(view: View, extraButton: VirtualKeyButton) {
val width = view.measuredWidth
val height = view.measuredHeight
val button: Button = if (isSpecialButton(extraButton)) {
createSpecialButton(extraButton.key, false) ?: return
} else {
Button(context, null, R.attr.buttonBarButtonStyle).apply {
setTextColor(buttonTextColor)
}
}
button.apply {
text = extraButton.display
isAllCaps = isButtonTextAllCaps
setPadding(0, 0, 0, 0)
minHeight = 0
minWidth = 0
minimumWidth = 0
minimumHeight = 0
this.width = width
this.height = height
setBackgroundColor(buttonActiveBackgroundColor)
}
popupWindow = PopupWindow(this).apply {
this.width = LayoutParams.WRAP_CONTENT
this.height = LayoutParams.WRAP_CONTENT
contentView = button
isOutsideTouchable = true
isFocusable = false
showAsDropDown(view, 0, -2 * height)
}
}
private fun dismissPopup() {
popupWindow?.let { popup ->
popup.contentView = null
popup.dismiss()
}
popupWindow = null
}
fun isSpecialButton(button: VirtualKeyButton): Boolean {
return specialButtonsKeys?.contains(button.key) == true
}
private fun createSpecialButton(buttonKey: String, needUpdate: Boolean): Button? {
val specialButton = runCatching { SpecialButton.valueOf(buttonKey) }.getOrNull() ?: return null
val state = _specialButtons?.get(specialButton) ?: return null
state.setIsCreated(true)
val button = Button(context, null, R.attr.buttonBarButtonStyle)
button.setTextColor(if (state.isActive) buttonActiveTextColor else buttonTextColor)
if (needUpdate) {
state.buttons.add(button)
}
return button
}
fun readSpecialButton(specialButton: SpecialButton, autoSetInActive: Boolean): Boolean? {
val state = _specialButtons?.get(specialButton) ?: return null
if (!state.isCreated || !state.isActive) return false
if (autoSetInActive && !state.isLocked) {
state.setIsActive(false)
}
return true
}
private inner class SpecialButtonsLongHoldRunnable(
private val state: SpecialButtonState
) : Runnable {
override fun run() {
state.setIsLocked(!state.isActive)
state.setIsActive(!state.isActive)
longPressCount++
}
}
interface IVirtualKeysView {
fun onVirtualKeyButtonClick(view: View, buttonInfo: VirtualKeyButton, button: Button)
fun performVirtualKeyButtonHapticFeedback(view: View, buttonInfo: VirtualKeyButton, button: Button): Boolean
}
}

@ -1,6 +1,6 @@
package com.deniscerri.ytdl.util
import com.deniscerri.ytdl.work.download.DownloadWorker
import com.deniscerri.ytdl.work.DownloadWorker
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.asSharedFlow

@ -1,4 +1,4 @@
package com.deniscerri.ytdl.work.download
package com.deniscerri.ytdl.work
import android.annotation.SuppressLint
import android.app.PendingIntent
@ -36,34 +36,31 @@ import com.deniscerri.ytdl.util.FileUtil
import com.deniscerri.ytdl.util.NotificationUtil
import com.deniscerri.ytdl.util.WorkerEventBus
import com.deniscerri.ytdl.util.extractors.ytdlp.YTDLPUtil
import com.deniscerri.ytdl.work.isRunning
import com.deniscerri.ytdl.work.setForegroundSafely
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.currentCoroutineContext
import kotlinx.coroutines.delay
import kotlinx.coroutines.ensureActive
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import java.io.File
import java.security.MessageDigest
import java.util.Locale
import kotlin.collections.addAll
import kotlin.random.Random
class DownloadWorker(
private val context: Context,
workerParams: WorkerParameters
) : CoroutineWorker(context, workerParams) {
override suspend fun getForegroundInfo(): ForegroundInfo {
val workNotif = NotificationUtil(App.instance).createDefaultWorkerNotification()
val workNotif = NotificationUtil(App.Companion.instance).createDefaultWorkerNotification()
return ForegroundInfo(
1000000000,
@ -81,18 +78,18 @@ class DownloadWorker(
@OptIn(ExperimentalStdlibApi::class)
@SuppressLint("RestrictedApi")
override suspend fun doWork(): Result {
val workManager = WorkManager.getInstance(context)
val workManager = WorkManager.Companion.getInstance(context)
if (workManager.isRunning("download") || isStopped) return Result.Failure()
setForegroundSafely()
val parentContext = currentCoroutineContext()
val workerScope = CoroutineScope(
parentContext + Dispatchers.IO + SupervisorJob(parentContext[Job])
parentContext + Dispatchers.IO + SupervisorJob(parentContext[Job.Key])
)
val notificationUtil = NotificationUtil(App.instance)
val dbManager = DBManager.getInstance(context)
val notificationUtil = NotificationUtil(App.Companion.instance)
val dbManager = DBManager.Companion.getInstance(context)
val dao = dbManager.downloadDao
val historyDao = dbManager.historyDao
val commandTemplateDao = dbManager.commandTemplateDao
@ -156,19 +153,19 @@ class DownloadWorker(
val running = ArrayList(runningYTDLInstances)
val useScheduler = sharedPreferences.getBoolean("use_scheduler", false)
if (items.isEmpty() && running.isEmpty()) {
WorkManager.getInstance(context).cancelWorkById(this@DownloadWorker.id)
WorkManager.Companion.getInstance(context).cancelWorkById(this@DownloadWorker.id)
return@collectLatest
}
if (useScheduler){
if (items.none{it.downloadStartTime > 0L} && running.isEmpty() && !alarmScheduler.isDuringTheScheduledTime()) {
WorkManager.getInstance(context).cancelWorkById(this@DownloadWorker.id)
WorkManager.Companion.getInstance(context).cancelWorkById(this@DownloadWorker.id)
return@collectLatest
}
}
if (priorityItemIDs.isEmpty() && !continueAfterPriorityIds) {
WorkManager.getInstance(context).cancelWorkById(this@DownloadWorker.id)
WorkManager.Companion.getInstance(context).cancelWorkById(this@DownloadWorker.id)
return@collectLatest
}
@ -197,7 +194,7 @@ class DownloadWorker(
dao.update(downloadItem)
if (hasDownloadDelay) {
val delaySec = if (minDelay >= maxDelay) minDelay else Random.nextFloat() * (maxDelay - minDelay) + minDelay
val delaySec = if (minDelay >= maxDelay) minDelay else Random.Default.nextFloat() * (maxDelay - minDelay) + minDelay
if (delaySec > 0) {
workerScope.launch {
@ -299,7 +296,7 @@ class DownloadWorker(
notificationUtil.updateDownloadNotification(
downloadItem.id.toInt(),
line, progress.toInt(), 0, title,
NotificationUtil.DOWNLOAD_SERVICE_CHANNEL_ID
NotificationUtil.Companion.DOWNLOAD_SERVICE_CHANNEL_ID
)
CoroutineScope(Dispatchers.IO).launch {
if (logDownloads) {
@ -335,7 +332,11 @@ class DownloadWorker(
finalPaths.addAll(
outputSequence.asSequence()
.filter { it.startsWith("[SplitChapters]") && it.contains("Destination: ") }
.filter {
it.startsWith("[SplitChapters]") && it.contains(
"Destination: "
)
}
.map { it.split("Destination: ")[1] }
.map { it.removeSuffix("\n") }
.toList()
@ -394,7 +395,11 @@ class DownloadWorker(
e.printStackTrace()
if (e.message?.isNotBlank() == true) {
handler.postDelayed({
Toast.makeText(context, e.message, Toast.LENGTH_SHORT)
Toast.makeText(
context,
e.message,
Toast.LENGTH_SHORT
)
.show()
}, 1000)
}
@ -474,18 +479,18 @@ class DownloadWorker(
)
}
// if (wasQuickDownloaded && createResultItem){
// runCatching {
// eventBus.post(WorkerProgress(100, "Creating Result Items", downloadItem.id))
// runBlocking {
// infoUtil.getFromYTDL(downloadItem.url).forEach { res ->
// if (res != null) {
// resultDao.insert(res)
// }
// }
// }
// }
// }
// if (wasQuickDownloaded && createResultItem){
// runCatching {
// eventBus.post(WorkerProgress(100, "Creating Result Items", downloadItem.id))
// runBlocking {
// infoUtil.getFromYTDL(downloadItem.url).forEach { res ->
// if (res != null) {
// resultDao.insert(res)
// }
// }
// }
// }
// }
dao.delete(downloadItem.id)
@ -575,7 +580,7 @@ class DownloadWorker(
companion object {
val runningYTDLInstances: MutableList<Long> = mutableListOf()
const val TAG = "DownloadWorker"
private val downloadLock = kotlinx.coroutines.sync.Mutex()
private val downloadLock = Mutex()
}
class WorkerProgress(

@ -1,195 +0,0 @@
package com.deniscerri.ytdl.work.download
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.content.SharedPreferences
import android.content.pm.ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC
import android.os.Build
import android.os.Handler
import android.os.Looper
import android.util.Log
import android.widget.Toast
import androidx.preference.PreferenceManager
import androidx.work.CoroutineWorker
import androidx.work.ForegroundInfo
import androidx.work.WorkerParameters
import com.deniscerri.ytdl.R
import com.deniscerri.ytdl.core.RuntimeManager
import com.deniscerri.ytdl.core.models.YTDLRequest
import com.deniscerri.ytdl.database.DBManager
import com.deniscerri.ytdl.database.enums.DownloadType
import com.deniscerri.ytdl.database.models.Format
import com.deniscerri.ytdl.database.models.LogItem
import com.deniscerri.ytdl.database.repository.LogRepository
import com.deniscerri.ytdl.ui.more.terminal.TerminalActivity
import com.deniscerri.ytdl.util.FileUtil
import com.deniscerri.ytdl.util.NotificationUtil
import com.deniscerri.ytdl.util.WorkerEventBus
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import java.io.File
class TerminalDownloadWorker(
private val context: Context,
workerParams: WorkerParameters
) : CoroutineWorker(context, workerParams) {
override suspend fun doWork(): Result {
itemId = inputData.getInt("id", 0)
val command = inputData.getString("command")
val dao = DBManager.getInstance(context).terminalDao
if (itemId == 0) return Result.failure()
if (command!!.isEmpty()) return Result.failure()
val dbManager = DBManager.getInstance(context)
val logRepo = LogRepository(dbManager.logDao)
val notificationUtil = NotificationUtil(context)
val handler = Handler(Looper.getMainLooper())
val intent = Intent(context, TerminalActivity::class.java)
val pendingIntent = PendingIntent.getActivity(context, 0, intent, PendingIntent.FLAG_IMMUTABLE)
val notification = notificationUtil.createDownloadServiceNotification(pendingIntent, command.take(65), NotificationUtil.DOWNLOAD_TERMINAL_RUNNING_NOTIFICATION_ID)
if (Build.VERSION.SDK_INT >= 33) {
setForegroundAsync(ForegroundInfo(itemId, notification, FOREGROUND_SERVICE_TYPE_DATA_SYNC))
}else{
setForegroundAsync(ForegroundInfo(itemId, notification))
}
val sharedPreferences = PreferenceManager.getDefaultSharedPreferences(context)
val downloadLocation = sharedPreferences.getString("command_path", FileUtil.getDefaultCommandPath())
val logDownloads = sharedPreferences.getBoolean("log_downloads", false) && !sharedPreferences.getBoolean("incognito", false)
val initialLogDetails = "Terminal Task\n" +
"Command:\n${command.trim()}\n\n"
val logItem = LogItem(
0,
"Terminal Task",
initialLogDetails,
Format(),
DownloadType.command,
System.currentTimeMillis(),
)
var noCache = false
runCatching {
if (logDownloads){
runBlocking {
logItem.id = logRepo.insert(logItem)
}
}
val callback : (Float, Long, String) -> Unit = { progress, _, line ->
runBlocking {
WorkerEventBus.post(DownloadWorker.WorkerProgress(progress.toInt(), line, itemId.toLong(), logItem.id))
}
val title: String = command.take(65)
notificationUtil.updateTerminalDownloadNotification(
itemId,
line, progress.toInt(), title,
NotificationUtil.DOWNLOAD_SERVICE_CHANNEL_ID
)
CoroutineScope(Dispatchers.IO).launch {
if (logDownloads) logRepo.update(line, logItem.id)
dao.updateLog(line, itemId.toLong())
}
}
val c = command.lowercase()
if (c.startsWith("deno")) {
RuntimeManager.getInstance().executeDeno(command, itemId.toString(), callback = callback)
} else if (c.startsWith("python")) {
RuntimeManager.getInstance().executePython(command, itemId.toString(), callback = callback)
}
else {
val (resp, isNoCache) = buildYTDLPRequest(command, sharedPreferences)
noCache = isNoCache
RuntimeManager.getInstance().execute(resp, itemId.toString(), true, callback = callback)
}
}.onSuccess {
CoroutineScope(Dispatchers.IO).launch {
if(!noCache){
//move file from internal to set download directory
try {
FileUtil.moveFile(File(FileUtil.getCachePath(context) + "/TERMINAL/" + itemId),context, downloadLocation!!, false){ p ->
WorkerEventBus.post(DownloadWorker.WorkerProgress(p, "", itemId.toLong(), logItem.id))
}
}catch (e: Exception){
e.printStackTrace()
handler.postDelayed({
Toast.makeText(context, e.message, Toast.LENGTH_SHORT).show()
}, 1000)
}
}
}
if (logDownloads) logRepo.update(initialLogDetails + it.out, logItem.id, true)
dao.updateLog(it.out, itemId.toLong())
notificationUtil.cancelDownloadNotification(itemId)
delay(1000)
dao.delete(itemId.toLong())
Result.success()
}.onFailure {
if (it.message != null){
if (logDownloads) logRepo.update(it.message!!, logItem.id)
dao.updateLog(it.message!!, itemId.toLong())
}
notificationUtil.cancelDownloadNotification(itemId)
File(FileUtil.getDefaultCommandPath() + "/" + itemId).deleteRecursively()
Log.e(TAG, context.getString(R.string.failed_download), it)
delay(1000)
dao.delete(itemId.toLong())
Result.failure()
}
return Result.success()
}
private fun buildYTDLPRequest(command: String, sharedPreferences: SharedPreferences) : Pair<YTDLRequest, Boolean> {
val request = YTDLRequest(emptyList())
request.addOption(
"--config-locations",
File(context.cacheDir.absolutePath + "/config-TERMINAL[${System.currentTimeMillis()}].txt").apply {
writeText(command)
}.absolutePath
)
if (sharedPreferences.getBoolean("use_cookies", false)){
FileUtil.getCookieFile(context){
request.addOption("--cookies", it)
}
val useHeader = sharedPreferences.getBoolean("use_header", false)
val header = sharedPreferences.getString("useragent_header", "")
if (useHeader && !header.isNullOrBlank()){
request.addOption("--add-header","User-Agent:${header}")
}
}
val commandPath = sharedPreferences.getString("command_path", FileUtil.getDefaultCommandPath())!!
var noCache = !sharedPreferences.getBoolean("cache_downloads", true) && File(FileUtil.formatPath(commandPath)).canWrite()
if (command.contains("-P ")) {
noCache = true
}else {
if (!noCache){
request.addOption("-P", FileUtil.getCachePath(context) + "TERMINAL/" + itemId)
}else if (!request.hasOption("-P")){
request.addOption("-P", FileUtil.formatPath(commandPath))
}
}
return Pair(request, noCache)
}
companion object {
private var itemId : Int = 0
const val TAG = "TerminalDownloadWorker"
}
}

@ -0,0 +1,5 @@
<vector android:height="24dp"
android:viewportHeight="24" android:viewportWidth="24"
android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
<path android:fillColor="?android:colorAccent" android:pathData="M6,19c0,1.1 0.9,2 2,2h8c1.1,0 2,-0.9 2,-2V7H6v12zM19,4h-3.5l-1,-1h-5l-1,1H5v2h14V4z"/>
</vector>

@ -0,0 +1,5 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:height="24dp" android:tint="?attr/colorAccent" android:viewportHeight="24" android:viewportWidth="24" android:width="24dp">
<path android:fillColor="@android:color/white" android:pathData="M9,4v3h5v12h3L17,7h5L22,4L9,4zM3,12h3v7h3v-7h3L12,9L3,9v3z"/>
</vector>

@ -1,117 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="wrap_content"
xmlns:android="http://schemas.android.com/apk/res/android">
<RelativeLayout
android:layout_width="0dp"
android:layout_marginHorizontal="10dp"
android:layout_height="0dp"
app:layout_constraintDimensionRatio="H,2:1"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent">
<com.google.android.material.card.MaterialCardView
android:id="@+id/active_download_card_view"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:clickable="true"
android:focusable="true"
app:cardCornerRadius="20dp"
app:cardElevation="0dp"
app:cardMaxElevation="12dp"
android:checkable="true"
app:strokeWidth="0dp"
app:cardPreventCornerOverlap="true"
android:layout_margin="10dp">
<com.google.android.material.progressindicator.LinearProgressIndicator
android:id="@+id/progress"
android:layout_width="match_parent"
app:trackColor="#000"
android:layout_height="match_parent"
android:layout_gravity="bottom"
android:alpha="0.3"
android:scaleY="200"/>
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="match_parent">
<TextView
android:id="@+id/title"
android:layout_width="0dp"
android:layout_marginHorizontal="10dp"
android:layout_marginTop="10dp"
android:layout_height="wrap_content"
android:ellipsize="end"
android:maxLines="2"
android:paddingStart="10dp"
android:paddingTop="10dp"
android:paddingEnd="10dp"
android:shadowColor="#000"
android:fontFamily="monospace"
android:shadowDx="4"
android:shadowDy="4"
android:shadowRadius="2"
android:textColor="#FFF"
android:textSize="14sp"
android:textStyle="bold"
app:layout_constraintEnd_toStartOf="@+id/active_download_stop"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<com.google.android.material.button.MaterialButton
android:id="@+id/active_download_stop"
style="?attr/materialIconButtonFilledStyle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="10dp"
android:layout_marginEnd="10dp"
app:backgroundTint="?attr/colorSurface"
app:cornerRadius="15dp"
app:icon="@drawable/baseline_close_24"
app:iconSize="30dp"
app:iconTint="?android:textColorPrimary"
app:layout_constraintVertical_bias="0.0"
app:layout_constraintBottom_toTopOf="@+id/output"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<androidx.constraintlayout.widget.Barrier
android:id="@+id/barrier"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:barrierDirection="bottom"
app:constraint_referenced_ids="title,active_download_stop" />
<TextView
android:id="@+id/output"
android:layout_width="match_parent"
android:layout_height="0dp"
android:ellipsize="end"
android:focusable="true"
android:fontFamily="monospace"
android:maxLines="5"
android:padding="10dp"
android:shadowColor="#000"
android:shadowDx="4"
android:shadowDy="4"
android:shadowRadius="2"
android:textColor="#FFF"
android:textSize="11sp"
android:gravity="bottom"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/barrier" />
</androidx.constraintlayout.widget.ConstraintLayout>
</com.google.android.material.card.MaterialCardView>
</RelativeLayout>
</androidx.constraintlayout.widget.ConstraintLayout>

@ -6,56 +6,32 @@
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">
<ScrollView
android:id="@+id/custom_command_scrollview"
<LinearLayout
android:layout_width="0dp"
android:layout_height="0dp"
android:layout_margin="10dp"
app:layout_constraintBottom_toTopOf="@+id/coordinatorLayout"
app:layout_constraintEnd_toEndOf="parent"
android:orientation="vertical"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toTopOf="@id/coordinatorLayout"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent">
app:layout_constraintEnd_toEndOf="parent">
<LinearLayout
<!-- Live Terminal Output -->
<com.termux.view.TerminalView
android:id="@+id/terminalView"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:focusableInTouchMode="true"
android:orientation="vertical">
<HorizontalScrollView
android:id="@+id/horizontalscroll_output"
android:layout_width="match_parent"
android:layout_height="match_parent">
<TextView
android:id="@+id/custom_command_output"
android:visibility="gone"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:fontFamily="monospace"
android:gravity="bottom"
android:scrollbars="horizontal|vertical"
android:scrollHorizontally="true"
android:textIsSelectable="true"
android:textSize="15sp" />
</HorizontalScrollView>
<EditText
android:id="@+id/command_edittext"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@android:color/transparent"
android:fontFamily="monospace"
android:gravity="start"
android:inputType="textMultiLine|textNoSuggestions"
android:maxLines="10000"
android:text="yt-dlp "
android:textSize="15sp" />
</LinearLayout>
android:layout_height="0dp"
android:layout_weight="1"
android:focusable="true"
android:keepScreenOn="true"
android:focusableInTouchMode="true" />
<!-- Extra Keys Toolbar (CTRL, ALT, TAB, ESC, Arrows) -->
<com.deniscerri.ytdl.ui.more.terminal.virtualkeys.VirtualKeysView
android:id="@+id/virtualKeys"
android:layout_width="match_parent"
android:layout_height="70dp"/>
</ScrollView>
</LinearLayout>
<androidx.coordinatorlayout.widget.CoordinatorLayout
android:id="@+id/coordinatorLayout"
@ -66,8 +42,6 @@
app:layout_constraintStart_toStartOf="parent">
<com.google.android.material.bottomappbar.BottomAppBar
android:id="@+id/bottomAppBar"
style="@style/Widget.Material3.BottomAppBar"
@ -76,17 +50,6 @@
android:layout_gravity="bottom"
app:menu="@menu/terminal_menu" />
<com.google.android.material.floatingactionbutton.ExtendedFloatingActionButton
android:id="@+id/command_fab"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/run_command"
android:elevation="0dp"
app:elevation="0dp"
android:stateListAnimator="@null"
app:icon="@drawable/ic_baseline_keyboard_arrow_right_24"
app:layout_anchor="@id/bottomAppBar" />
</androidx.coordinatorlayout.widget.CoordinatorLayout>
</androidx.constraintlayout.widget.ConstraintLayout>

@ -4,7 +4,8 @@
app:layout_behavior="com.google.android.material.appbar.AppBarLayout$ScrollingViewBehavior"
android:layout_height="match_parent"
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools">
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/terminal_recycler"
@ -14,6 +15,7 @@
app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
tools:listitem="@layout/terminal_session_card"
app:layout_constraintTop_toTopOf="parent">
</androidx.recyclerview.widget.RecyclerView>

@ -0,0 +1,49 @@
<com.google.android.material.card.MaterialCardView android:id="@+id/active_download_card_view"
android:layout_width="match_parent"
android:layout_height="50dp"
app:cardCornerRadius="20dp"
app:cardElevation="0dp"
android:checkable="true"
app:strokeWidth="0dp"
app:cardPreventCornerOverlap="true"
android:layout_marginBottom="10dp"
android:layout_marginHorizontal="10dp"
app:cardBackgroundColor="?attr/colorSurfaceContainerLow"
xmlns:tools="http://schemas.android.com/tools"
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:padding="10dp"
android:layout_height="wrap_content">
<TextView
android:id="@+id/session_name"
android:layout_width="0dp"
android:layout_height="wrap_content"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"
android:textStyle="bold"
android:layout_marginEnd="10dp"
app:layout_constraintEnd_toStartOf="@+id/deleteSession"
tools:text="session1"
/>
<com.google.android.material.button.MaterialButton
android:id="@+id/deleteSession"
style="@style/Widget.Material3.Button.IconButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:padding="0dp"
app:icon="@drawable/baseline_delete_24"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintVertical_bias="0.0" />
</androidx.constraintlayout.widget.ConstraintLayout>
</com.google.android.material.card.MaterialCardView>

@ -18,7 +18,7 @@
<item
android:id="@+id/text_size"
android:title="@string/text_size"
android:icon="@drawable/ic_textformat"
android:icon="@drawable/baseline_format_size_24"
app:showAsAction="always" />
<item

@ -5,19 +5,25 @@
<item android:title="@string/command_templates"
android:icon="@drawable/ic_terminal"
app:showAsAction="ifRoom"
app:showAsAction="always"
android:id="@+id/command_templates"/>
<item android:title="@string/shortcuts"
android:icon="@drawable/ic_shortcut"
app:showAsAction="ifRoom"
app:showAsAction="always"
android:id="@+id/shortcuts"/>
<item android:title="@string/file_name_template"
android:icon="@drawable/ic_edit"
app:showAsAction="ifRoom"
app:showAsAction="always"
android:id="@+id/filename_template"/>
<item
android:id="@+id/folder"
android:title="@string/command_directory"
android:icon="@drawable/baseline_folder_24"
app:showAsAction="ifRoom"/>
app:showAsAction="always"/>
<item
android:id="@+id/text_size"
android:title="@string/text_size"
android:icon="@drawable/baseline_format_size_24"
app:showAsAction="always"/>
</menu>

@ -7,16 +7,15 @@
app:showAsAction="always"
android:id="@+id/add"/>
<item android:title="@string/wrap_text"
android:icon="@drawable/baseline_wrap_text_24"
<item android:title="@string/Remove"
android:icon="@drawable/baseline_delete"
app:showAsAction="always"
android:id="@+id/wrap"/>
android:id="@+id/delete"/>
<item
android:id="@+id/text_size"
android:title="@string/text_size"
android:icon="@drawable/ic_textformat"
app:showAsAction="always" />
<!-- <item android:title="@string/wrap_text"-->
<!-- android:icon="@drawable/baseline_wrap_text_24"-->
<!-- app:showAsAction="always"-->
<!-- android:id="@+id/wrap"/>-->
<item
android:id="@+id/export_clipboard"

@ -5,8 +5,8 @@
app:startDestination="@id/terminalDownloadsListFragment">
<fragment
android:id="@+id/terminalDownloadsListFragment"
android:name="com.deniscerri.ytdl.ui.more.terminal.TerminalDownloadsListFragment"
tools:layout="@layout/fragment_terminal_download_list"
android:name="com.deniscerri.ytdl.ui.more.terminal.TerminalSessionListFragment"
tools:layout="@layout/fragment_terminal_session_list"
android:label="TerminalDownloadsListFragment" >
<action
android:id="@+id/action_terminalDownloadsListFragment_to_terminalFragment2"

Loading…
Cancel
Save