diff --git a/.kotlin/sessions/kotlin-compiler-3568486238888953764.salive b/.kotlin/sessions/kotlin-compiler-3568486238888953764.salive new file mode 100644 index 00000000..e69de29b diff --git a/app/build.gradle b/app/build.gradle index 56dbabf1..518b257c 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -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' + } diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 0bde8ad7..3b503456 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -354,6 +354,7 @@ android:configChanges="smallestScreenSize|layoutDirection|orientation|screenSize" android:exported="true" android:label="@string/terminal" + android:windowSoftInputMode="adjustResize" android:parentActivityName=".MainActivity"> @@ -586,6 +587,12 @@ android:foregroundServiceType="specialUse" android:exported="true" /> + + + diff --git a/app/src/main/java/com/deniscerri/ytdl/core/RuntimeManager.kt b/app/src/main/java/com/deniscerri/ytdl/core/RuntimeManager.kt index 94c02261..fbd12f45 100644 --- a/app/src/main/java/com/deniscerri/ytdl/core/RuntimeManager.kt +++ b/app/src/main/java/com/deniscerri/ytdl/core/RuntimeManager.kt @@ -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 { + val env = mutableMapOf() + + 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 { + val env = getEnvironment().toMutableMap() + env["HOME"] = Environment.getExternalStorageDirectory().path + return env + } + @Synchronized @Throws(ExecuteException::class) fun updateYTDL( diff --git a/app/src/main/java/com/deniscerri/ytdl/database/repository/DownloadRepository.kt b/app/src/main/java/com/deniscerri/ytdl/database/repository/DownloadRepository.kt index 249c47d3..3ee9b130 100644 --- a/app/src/main/java/com/deniscerri/ytdl/database/repository/DownloadRepository.kt +++ b/app/src/main/java/com/deniscerri/ytdl/database/repository/DownloadRepository.kt @@ -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 diff --git a/app/src/main/java/com/deniscerri/ytdl/database/viewmodel/TerminalViewModel.kt b/app/src/main/java/com/deniscerri/ytdl/database/viewmodel/TerminalViewModel.kt index 5ac08d5e..0f68ad32 100644 --- a/app/src/main/java/com/deniscerri/ytdl/database/viewmodel/TerminalViewModel.kt +++ b/app/src/main/java/com/deniscerri/ytdl/database/viewmodel/TerminalViewModel.kt @@ -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(null) + private var virtualKeysViewRef = WeakReference(null) - fun getTerminals() : Flow> { - return dao.getActiveTerminalDownloadsFlow() - } + val terminalView: TerminalView? get() = terminalViewRef.get() + val virtualKeysView: VirtualKeysView? get() = virtualKeysViewRef.get() - fun getTerminal(id: Long) : Flow { - 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() - .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(null) + private set + + var isBound by mutableStateOf(false) + private set + + private val _isBoundState = MutableStateFlow(false) + val isBoundState: StateFlow = _isBoundState + + private val _serviceConnectedEvent = Channel(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 + } + } } \ No newline at end of file diff --git a/app/src/main/java/com/deniscerri/ytdl/receiver/ScheduleAlarmReceiver.kt b/app/src/main/java/com/deniscerri/ytdl/receiver/ScheduleAlarmReceiver.kt index 81ad8df7..01e45b4f 100644 --- a/app/src/main/java/com/deniscerri/ytdl/receiver/ScheduleAlarmReceiver.kt +++ b/app/src/main/java/com/deniscerri/ytdl/receiver/ScheduleAlarmReceiver.kt @@ -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() { diff --git a/app/src/main/java/com/deniscerri/ytdl/terminal/RunCommandService.kt b/app/src/main/java/com/deniscerri/ytdl/terminal/RunCommandService.kt new file mode 100644 index 00000000..96765529 --- /dev/null +++ b/app/src/main/java/com/deniscerri/ytdl/terminal/RunCommandService.kt @@ -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") + } +} \ No newline at end of file diff --git a/app/src/main/java/com/deniscerri/ytdl/terminal/SessionService.kt b/app/src/main/java/com/deniscerri/ytdl/terminal/SessionService.kt new file mode 100644 index 00000000..eda82bc7 --- /dev/null +++ b/app/src/main/java/com/deniscerri/ytdl/terminal/SessionService.kt @@ -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() + val sessionList = mutableStateMapOf() + 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" + } +} \ No newline at end of file diff --git a/app/src/main/java/com/deniscerri/ytdl/ui/adapter/TerminalDownloadsAdapter.kt b/app/src/main/java/com/deniscerri/ytdl/ui/adapter/TerminalDownloadsAdapter.kt deleted file mode 100644 index 8a0dd13d..00000000 --- a/app/src/main/java/com/deniscerri/ytdl/ui/adapter/TerminalDownloadsAdapter.kt +++ /dev/null @@ -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(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(R.id.progress) - progressBar.tag = "${item.id}##progress" - progressBar.progress = 0 - progressBar.isIndeterminate = true - - // COMMAND ---------------------------------- - val itemTitle = card.findViewById(R.id.title) - itemTitle.text = item.command.trim() - - //OUTPUT - val output = card.findViewById(R.id.output) - output.tag = "${item.id}##output" - - // STOP BUTTON ---------------------------------- - val stopButton = card.findViewById(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 = object : DiffUtil.ItemCallback() { - 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 - } - } - } -} \ No newline at end of file diff --git a/app/src/main/java/com/deniscerri/ytdl/ui/adapter/TerminalSessionsAdapter.kt b/app/src/main/java/com/deniscerri/ytdl/ui/adapter/TerminalSessionsAdapter.kt new file mode 100644 index 00000000..18048c15 --- /dev/null +++ b/app/src/main/java/com/deniscerri/ytdl/ui/adapter/TerminalSessionsAdapter.kt @@ -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(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(R.id.session_name) + sessionName.text = item + + // STOP BUTTON ---------------------------------- + val stopButton = card.findViewById(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 = object : DiffUtil.ItemCallback() { + override fun areItemsTheSame(oldItem: String, newItem: String): Boolean { + return oldItem == newItem + } + + override fun areContentsTheSame(oldItem: String, newItem: String): Boolean { + return oldItem == newItem + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/deniscerri/ytdl/ui/more/settings/downloading/DownloadSettingsModule.kt b/app/src/main/java/com/deniscerri/ytdl/ui/more/settings/downloading/DownloadSettingsModule.kt index 7b8e8da8..71c87c6d 100644 --- a/app/src/main/java/com/deniscerri/ytdl/ui/more/settings/downloading/DownloadSettingsModule.kt +++ b/app/src/main/java/com/deniscerri/ytdl/ui/more/settings/downloading/DownloadSettingsModule.kt @@ -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 diff --git a/app/src/main/java/com/deniscerri/ytdl/ui/more/terminal/KeyShortcutHandler.kt b/app/src/main/java/com/deniscerri/ytdl/ui/more/terminal/KeyShortcutHandler.kt new file mode 100644 index 00000000..b1bca2a9 --- /dev/null +++ b/app/src/main/java/com/deniscerri/ytdl/ui/more/terminal/KeyShortcutHandler.kt @@ -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 + } +} \ No newline at end of file diff --git a/app/src/main/java/com/deniscerri/ytdl/ui/more/terminal/MkSession.kt b/app/src/main/java/com/deniscerri/ytdl/ui/more/terminal/MkSession.kt new file mode 100644 index 00000000..7b8bc855 --- /dev/null +++ b/app/src/main/java/com/deniscerri/ytdl/ui/more/terminal/MkSession.kt @@ -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? +) diff --git a/app/src/main/java/com/deniscerri/ytdl/ui/more/terminal/ShortcutBinding.kt b/app/src/main/java/com/deniscerri/ytdl/ui/more/terminal/ShortcutBinding.kt new file mode 100644 index 00000000..8dda59e7 --- /dev/null +++ b/app/src/main/java/com/deniscerri/ytdl/ui/more/terminal/ShortcutBinding.kt @@ -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() + 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() + 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), +// ), +} \ No newline at end of file diff --git a/app/src/main/java/com/deniscerri/ytdl/ui/more/terminal/TerminalActivity.kt b/app/src/main/java/com/deniscerri/ytdl/ui/more/terminal/TerminalActivity.kt index 078524ad..a1a753b1 100644 --- a/app/src/main/java/com/deniscerri/ytdl/ui/more/terminal/TerminalActivity.kt +++ b/app/src/main/java/com/deniscerri/ytdl/ui/more/terminal/TerminalActivity.kt @@ -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() 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) + } } \ No newline at end of file diff --git a/app/src/main/java/com/deniscerri/ytdl/ui/more/terminal/TerminalBackEnd.kt b/app/src/main/java/com/deniscerri/ytdl/ui/more/terminal/TerminalBackEnd.kt new file mode 100644 index 00000000..10a99346 --- /dev/null +++ b/app/src/main/java/com/deniscerri/ytdl/ui/more/terminal/TerminalBackEnd.kt @@ -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) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/deniscerri/ytdl/ui/more/terminal/TerminalFragment.kt b/app/src/main/java/com/deniscerri/ytdl/ui/more/terminal/TerminalFragment.kt index ebac5157..fb203f81 100644 --- a/app/src/main/java/com/deniscerri/ytdl/ui/more/terminal/TerminalFragment.kt +++ b/app/src/main/java/com/deniscerri/ytdl/ui/more/terminal/TerminalFragment.kt @@ -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() - 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(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(R.id.textsize_seekbar) - topAppBar.setOnMenuItemClickListener { m: MenuItem -> - when(m.itemId){ - R.id.wrap -> { - var scrollView = requireView().findViewById(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(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> { - @SuppressLint("SetTextI18n") - override fun onChanged(value: List) { - 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 - } - } - } - - } - - - } \ No newline at end of file diff --git a/app/src/main/java/com/deniscerri/ytdl/ui/more/terminal/TerminalDownloadsListFragment.kt b/app/src/main/java/com/deniscerri/ytdl/ui/more/terminal/TerminalSessionListFragment.kt similarity index 56% rename from app/src/main/java/com/deniscerri/ytdl/ui/more/terminal/TerminalDownloadsListFragment.kt rename to app/src/main/java/com/deniscerri/ytdl/ui/more/terminal/TerminalSessionListFragment.kt index ced770a9..a333b3ae 100644 --- a/app/src/main/java/com/deniscerri/ytdl/ui/more/terminal/TerminalDownloadsListFragment.kt +++ b/app/src/main/java/com/deniscerri/ytdl/ui/more/terminal/TerminalSessionListFragment.kt @@ -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(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("${event.downloadItemID}##progress") - val outputText = requireView().findViewWithTag("${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) } } \ No newline at end of file diff --git a/app/src/main/java/com/deniscerri/ytdl/ui/more/terminal/TerminalUtils.kt b/app/src/main/java/com/deniscerri/ytdl/ui/more/terminal/TerminalUtils.kt new file mode 100644 index 00000000..085c75a6 --- /dev/null +++ b/app/src/main/java/com/deniscerri/ytdl/ui/more/terminal/TerminalUtils.kt @@ -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() +} \ No newline at end of file diff --git a/app/src/main/java/com/deniscerri/ytdl/ui/more/terminal/virtualkeys/SpecialButton.kt b/app/src/main/java/com/deniscerri/ytdl/ui/more/terminal/virtualkeys/SpecialButton.kt new file mode 100644 index 00000000..4ff35b0a --- /dev/null +++ b/app/src/main/java/com/deniscerri/ytdl/ui/more/terminal/virtualkeys/SpecialButton.kt @@ -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() + + 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) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/deniscerri/ytdl/ui/more/terminal/virtualkeys/SpecialButtonState.java b/app/src/main/java/com/deniscerri/ytdl/ui/more/terminal/virtualkeys/SpecialButtonState.java new file mode 100644 index 00000000..6e6f8a1e --- /dev/null +++ b/app/src/main/java/com/deniscerri/ytdl/ui/more/terminal/virtualkeys/SpecialButtonState.java @@ -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