diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 4735783704..534d0536ff 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -40,6 +40,12 @@
+
+
+
+
+
+
{
+ protected var fragmentOpenFileDialogBinding: FragmentOpenFileDialogBinding? = null
+ protected val viewBinding get() = fragmentOpenFileDialogBinding!!
+
+ private lateinit var adapter: AppsRecyclerAdapter
+ private lateinit var sharedPreferences: SharedPreferences
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ setStyle(STYLE_NORMAL, R.style.appBottomSheetDialogTheme)
+ }
+
+ override fun onCreateView(
+ inflater: LayoutInflater,
+ container: ViewGroup?,
+ savedInstanceState: Bundle?,
+ ): View? {
+ fragmentOpenFileDialogBinding = FragmentOpenFileDialogBinding.inflate(inflater)
+ initDialogResources(viewBinding.parent)
+ return viewBinding.root
+ }
+
+ override fun onViewCreated(
+ view: View,
+ savedInstanceState: Bundle?,
+ ) {
+ super.onViewCreated(view, savedInstanceState)
+
+ val modelProvider = AppsAdapterPreloadModel(this, true)
+ val sizeProvider = ViewPreloadSizeProvider()
+ val preloader =
+ RecyclerViewPreloader(
+ Glide.with(this),
+ modelProvider,
+ sizeProvider,
+ GlideConstants.MAX_PRELOAD_FILES,
+ )
+ sharedPreferences = PreferenceManager.getDefaultSharedPreferences(requireContext())
+
+ val appDataParcelableList = loadAppList()
+
+ adapter =
+ AppsRecyclerAdapter(
+ this,
+ modelProvider,
+ true,
+ this,
+ appDataParcelableList,
+ )
+ loadViews()
+ viewBinding.appsRecyclerView.addOnScrollListener(preloader)
+ }
+
+ override fun onDestroyView() {
+ super.onDestroyView()
+ fragmentOpenFileDialogBinding = null
+ }
+
+ override fun onPause() {
+ super.onPause()
+ dismiss()
+ }
+
+ private fun loadViews() {
+ viewBinding.run {
+ appsRecyclerView.layoutManager = LinearLayoutManager(requireContext())
+ appsRecyclerView.adapter = adapter
+ doLoadViewsWith(this)
+ }
+ }
+
+ protected open fun doLoadViewsWith(viewBinding: FragmentOpenFileDialogBinding) = Unit
+
+ protected abstract fun loadAppList(): MutableList
+
+ protected abstract fun initLastAppData(
+ lastClassAndPackage: List?,
+ appDataParcelableList: MutableList,
+ ): AppDataParcelable?
+
+ override fun adjustListViewForTv(
+ viewHolder: AppHolder,
+ mainActivity: MainActivity,
+ ) {
+ // do nothing
+ }
+}
diff --git a/app/src/main/java/com/amaze/filemanager/ui/dialogs/OpenFileDialogFragment.kt b/app/src/main/java/com/amaze/filemanager/ui/dialogs/OpenFileDialogFragment.kt
index 72a063dbb6..5ae7766c1c 100644
--- a/app/src/main/java/com/amaze/filemanager/ui/dialogs/OpenFileDialogFragment.kt
+++ b/app/src/main/java/com/amaze/filemanager/ui/dialogs/OpenFileDialogFragment.kt
@@ -54,6 +54,7 @@ import com.amaze.filemanager.ui.provider.UtilitiesProvider
import com.amaze.filemanager.ui.startActivityCatchingSecurityException
import com.amaze.filemanager.ui.views.ThemedTextView
import com.amaze.filemanager.utils.GlideConstants
+import com.amaze.filemanager.utils.queryIntentActivitiesCompat
import com.bumptech.glide.Glide
import com.bumptech.glide.integration.recyclerview.RecyclerViewPreloader
import com.bumptech.glide.util.ViewPreloadSizeProvider
@@ -159,7 +160,7 @@ class OpenFileDialogFragment : BaseBottomSheetFragment(), AdjustListViewForTv {
val packageManager = requireContext().packageManager
val appDataParcelableList: MutableList = ArrayList()
- packageManager.queryIntentActivities(intent, PackageManager.MATCH_ALL).forEach {
+ packageManager.queryIntentActivitiesCompat(intent, PackageManager.MATCH_ALL).forEach {
val openFileParcelable =
OpenFileParcelable(
uri,
diff --git a/app/src/main/java/com/amaze/filemanager/ui/dialogs/OpenFolderInTerminalFragment.kt b/app/src/main/java/com/amaze/filemanager/ui/dialogs/OpenFolderInTerminalFragment.kt
new file mode 100644
index 0000000000..6603bf0134
--- /dev/null
+++ b/app/src/main/java/com/amaze/filemanager/ui/dialogs/OpenFolderInTerminalFragment.kt
@@ -0,0 +1,352 @@
+package com.amaze.filemanager.ui.dialogs
+
+import android.annotation.SuppressLint
+import android.content.Context
+import android.content.Intent
+import android.content.SharedPreferences
+import android.os.Bundle
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import androidx.annotation.VisibleForTesting
+import androidx.core.content.ContextCompat
+import androidx.preference.PreferenceManager
+import androidx.recyclerview.widget.LinearLayoutManager
+import com.amaze.filemanager.R
+import com.amaze.filemanager.adapters.AppsRecyclerAdapter
+import com.amaze.filemanager.adapters.data.AppDataParcelable
+import com.amaze.filemanager.adapters.glide.AppsAdapterPreloadModel
+import com.amaze.filemanager.adapters.holders.AppHolder
+import com.amaze.filemanager.application.AppConfig
+import com.amaze.filemanager.databinding.FragmentOpenFileDialogBinding
+import com.amaze.filemanager.ui.activities.MainActivity
+import com.amaze.filemanager.ui.activities.superclasses.ThemedActivity
+import com.amaze.filemanager.ui.base.BaseBottomSheetFragment
+import com.amaze.filemanager.ui.fragments.AdjustListViewForTv
+import com.amaze.filemanager.utils.ANDROID_TERM
+import com.amaze.filemanager.utils.GlideConstants
+import com.amaze.filemanager.utils.TERMONE_PLUS
+import com.amaze.filemanager.utils.TERMUX
+import com.amaze.filemanager.utils.detectInstalledTerminalApps
+import com.amaze.filemanager.utils.getApplicationInfoCompat
+import com.amaze.filemanager.utils.getPackageInfoCompat
+import com.bumptech.glide.Glide
+import com.bumptech.glide.integration.recyclerview.RecyclerViewPreloader
+import com.bumptech.glide.util.ViewPreloadSizeProvider
+import org.slf4j.LoggerFactory
+
+class OpenFolderInTerminalFragment : BaseBottomSheetFragment(), AdjustListViewForTv {
+ private var fragmentOpenFileDialogBinding: FragmentOpenFileDialogBinding? = null
+ private val viewBinding get() = fragmentOpenFileDialogBinding!!
+
+ private lateinit var path: String
+ private lateinit var installedTerminals: Array
+ private lateinit var adapter: AppsRecyclerAdapter
+ private lateinit var sharedPreferences: SharedPreferences
+
+ companion object {
+ private val logger = LoggerFactory.getLogger(OpenFileDialogFragment::class.java)
+
+ private const val KEY_PREFERENCES_DEFAULT = "terminal._DEFAULT"
+ const val KEY_PREFERENCES_LAST = "terminal._LAST"
+
+ /**
+ * Public facing method. Opens this sheet fragment for user to choose the terminal app.
+ *
+ * Supports Termux, Jack Palovich's terminal app and Termone plus.
+ */
+ fun openTerminalOrShow(
+ path: String,
+ activity: MainActivity,
+ ) {
+ val installedTerminals = activity.detectInstalledTerminalApps()
+ if (installedTerminals.isEmpty()) {
+ AppConfig.toast(activity, "No Terminal App installed")
+ } else if (installedTerminals.size == 1) {
+ startActivity(activity, buildIntent(installedTerminals.first(), path))
+ } else {
+ val packageName = activity.prefs.getString(KEY_PREFERENCES_DEFAULT, null)
+ if (true == packageName?.isNotEmpty()) {
+ startActivity(activity, buildIntent(packageName, path))
+ } else {
+ newInstance(path, installedTerminals).show(
+ activity.supportFragmentManager,
+ OpenFolderInTerminalFragment::class.java.simpleName,
+ )
+ }
+ }
+ }
+
+ @VisibleForTesting
+ internal fun newInstance(
+ path: String,
+ installedTerminals: Array,
+ ): OpenFolderInTerminalFragment {
+ val retval = OpenFolderInTerminalFragment()
+ retval.path = path
+ retval.installedTerminals = installedTerminals
+ retval.arguments =
+ Bundle().also {
+ it.putString("path", path)
+ }
+ return retval
+ }
+
+ @VisibleForTesting
+ internal fun startActivity(
+ context: Context,
+ intent: Intent,
+ ) {
+ if (TERMUX == intent.component?.packageName) {
+ ContextCompat.startForegroundService(context, intent)
+ } else {
+ ContextCompat.startActivity(context, intent, null)
+ }
+ }
+
+ internal fun buildIntentFromPreferences() {
+ }
+
+ @SuppressLint("SdCardPath")
+ @VisibleForTesting
+ internal fun buildIntent(
+ packageName: String,
+ path: String,
+ ): Intent {
+ return when (packageName) {
+ TERMONE_PLUS -> {
+ Intent().also {
+ it.action = "$TERMONE_PLUS.RUN_SCRIPT"
+ it.setClassName(TERMONE_PLUS, "$ANDROID_TERM.RunScript")
+ it.putExtra("$TERMONE_PLUS.Command", "cd \"$path\"")
+ }
+ }
+
+ ANDROID_TERM -> {
+ Intent().also {
+ it.action = "$ANDROID_TERM.RUN_SCRIPT"
+ it.setClassName(ANDROID_TERM, "$ANDROID_TERM.RunScript")
+ it.putExtra("$ANDROID_TERM.iInitialCommand", "cd \"$path\"")
+ }
+ }
+
+ TERMUX -> {
+ Intent().also {
+ it.setClassName(TERMUX, "$TERMUX.app.RunCommandService")
+ it.setAction("$TERMUX.RUN_COMMAND")
+ it.putExtra(
+ "$TERMUX.RUN_COMMAND_PATH",
+ "/data/data/com.termux/files/usr/bin/bash",
+ )
+ it.putExtra("$TERMUX.RUN_COMMAND_WORKDIR", path)
+ }
+ }
+ else -> throw IllegalArgumentException("Unsupported package: $packageName")
+ }
+ }
+
+ /**
+ * Sets last open app preference for bottom sheet file chooser.
+ * Next time same mime type comes, this app will be shown on top of the list if present
+ */
+ fun setLastOpenedApp(
+ appDataParcelable: AppDataParcelable,
+ sharedPreferences: SharedPreferences,
+ ) {
+ sharedPreferences.edit().putString(
+ KEY_PREFERENCES_LAST,
+ appDataParcelable.packageName,
+ ).apply()
+ }
+
+ /**
+ * Sets default app for mime type selected using 'Always' button from bottom sheet
+ */
+ private fun setDefaultOpenedApp(
+ appDataParcelable: AppDataParcelable,
+ sharedPreferences: SharedPreferences,
+ ) {
+ val s = appDataParcelable.packageName
+ sharedPreferences.edit().putString(
+ KEY_PREFERENCES_DEFAULT,
+ appDataParcelable.packageName,
+ ).apply()
+ }
+
+ /**
+ * Clears all default apps set preferences for mime types
+ */
+ fun clearPreferences(sharedPreferences: SharedPreferences) {
+ AppConfig.getInstance().runInBackground {
+ val keys = HashSet()
+ sharedPreferences.all.keys.forEach {
+ if (it.endsWith(KEY_PREFERENCES_DEFAULT) ||
+ it.endsWith(KEY_PREFERENCES_LAST)
+ ) {
+ keys.add(it)
+ }
+ }
+ keys.forEach {
+ sharedPreferences.edit().remove(it).apply()
+ }
+ }
+ }
+ }
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ setStyle(STYLE_NORMAL, R.style.appBottomSheetDialogTheme)
+ }
+
+ override fun onCreateView(
+ inflater: LayoutInflater,
+ container: ViewGroup?,
+ savedInstanceState: Bundle?,
+ ): View {
+ fragmentOpenFileDialogBinding = FragmentOpenFileDialogBinding.inflate(inflater)
+ initDialogResources(viewBinding.parent)
+ return viewBinding.root
+ }
+
+ override fun onDestroyView() {
+ super.onDestroyView()
+ fragmentOpenFileDialogBinding = null
+ }
+
+ override fun onViewCreated(
+ view: View,
+ savedInstanceState: Bundle?,
+ ) {
+ super.onViewCreated(view, savedInstanceState)
+
+ val modelProvider = AppsAdapterPreloadModel(this, true)
+ val sizeProvider = ViewPreloadSizeProvider()
+ val preloader =
+ RecyclerViewPreloader(
+ Glide.with(this),
+ modelProvider,
+ sizeProvider,
+ GlideConstants.MAX_PRELOAD_FILES,
+ )
+ sharedPreferences = PreferenceManager.getDefaultSharedPreferences(requireContext())
+
+ val appDataParcelableList = initList()
+ val lastClassAndPackage =
+ sharedPreferences
+ .getString(KEY_PREFERENCES_LAST, null)
+ val lastAppData: AppDataParcelable =
+ initLastAppData(
+ lastClassAndPackage,
+ appDataParcelableList,
+ ) ?: return
+
+ adapter =
+ AppsRecyclerAdapter(
+ this,
+ modelProvider,
+ true,
+ this,
+ appDataParcelableList,
+ )
+ loadViews(lastAppData)
+ viewBinding.appsRecyclerView.addOnScrollListener(preloader)
+ }
+
+ override fun onPause() {
+ super.onPause()
+ dismiss()
+ }
+
+ @VisibleForTesting
+ internal fun initList(): MutableList {
+ val packageManager = requireContext().packageManager
+ val appDataParcelableList: MutableList = ArrayList()
+ for (pkg in installedTerminals) {
+ kotlin.runCatching {
+ packageManager.getPackageInfoCompat(pkg, 0)
+ }.onFailure {
+ logger.error("Error getting package info for $pkg", it)
+ }.getOrNull()?.run {
+ packageManager.getApplicationInfoCompat(pkg, 0).let { applicationInfo ->
+ appDataParcelableList.add(
+ AppDataParcelable(
+ packageManager.getApplicationLabel(applicationInfo).toString(),
+ "",
+ null,
+ this.packageName,
+ "",
+ "",
+ 0,
+ 0, false,
+ null,
+ ),
+ )
+ }
+ }
+ }
+ return appDataParcelableList
+ }
+
+ @VisibleForTesting
+ internal fun initLastAppData(
+ lastClassAndPackage: String?,
+ appDataParcelableList: MutableList,
+ ): AppDataParcelable? {
+ if (appDataParcelableList.size == 0) {
+ AppConfig.toast(requireContext(), "No terminal apps available")
+ dismiss()
+ return null
+ }
+
+ if (appDataParcelableList.size == 1) {
+ startActivity(buildIntent(appDataParcelableList.first().packageName, path))
+ }
+
+ var lastAppData: AppDataParcelable? =
+ if (!lastClassAndPackage.isNullOrEmpty()) {
+ appDataParcelableList.find {
+ it.packageName == lastClassAndPackage
+ }
+ } else {
+ null
+ }
+ lastAppData = lastAppData ?: appDataParcelableList[0]
+ appDataParcelableList.remove(lastAppData)
+ return lastAppData
+ }
+
+ @VisibleForTesting
+ internal fun loadViews(lastAppData: AppDataParcelable) {
+ lastAppData.let {
+ val lastAppIntent = buildIntent(lastAppData.packageName, path)
+
+ viewBinding.run {
+ appsRecyclerView.layoutManager = LinearLayoutManager(requireContext())
+ appsRecyclerView.adapter = adapter
+
+ lastAppTitle.text = it.label
+ lastAppImage.setImageDrawable(
+ requireActivity().packageManager.getApplicationIcon(it.packageName),
+ )
+
+ justOnceButton.setTextColor((activity as ThemedActivity).accent)
+ justOnceButton.setOnClickListener { _ ->
+ setLastOpenedApp(it, sharedPreferences)
+ startActivity(requireContext(), lastAppIntent)
+ }
+ alwaysButton.setTextColor((activity as ThemedActivity).accent)
+ alwaysButton.setOnClickListener { _ ->
+ setDefaultOpenedApp(it, sharedPreferences)
+ startActivity(requireContext(), lastAppIntent)
+ }
+ }
+ }
+ }
+
+ override fun adjustListViewForTv(
+ viewHolder: AppHolder,
+ mainActivity: MainActivity,
+ ) {
+ // do nothing
+ }
+}
diff --git a/app/src/main/java/com/amaze/filemanager/utils/MainActivityActionMode.kt b/app/src/main/java/com/amaze/filemanager/utils/MainActivityActionMode.kt
index 4af20b454a..d3ceb1ccd0 100644
--- a/app/src/main/java/com/amaze/filemanager/utils/MainActivityActionMode.kt
+++ b/app/src/main/java/com/amaze/filemanager/utils/MainActivityActionMode.kt
@@ -38,6 +38,7 @@ import com.amaze.filemanager.filesystem.PasteHelper
import com.amaze.filemanager.filesystem.files.FileUtils
import com.amaze.filemanager.ui.activities.MainActivity
import com.amaze.filemanager.ui.dialogs.GeneralDialogCreation
+import com.amaze.filemanager.ui.dialogs.OpenFolderInTerminalFragment
import com.amaze.filemanager.ui.selection.SelectionPopupMenu.Companion.invokeSelectionDropdown
import java.io.File
import java.lang.ref.WeakReference
@@ -399,6 +400,10 @@ class MainActivityActionMode(private val mainActivityReference: WeakReference {
+ OpenFolderInTerminalFragment.openTerminalOrShow(checkedItems[0].desc, mainActivity)
+ true
+ }
else -> false
}
}
diff --git a/app/src/main/java/com/amaze/filemanager/utils/OpenTerminalUtilsExt.kt b/app/src/main/java/com/amaze/filemanager/utils/OpenTerminalUtilsExt.kt
new file mode 100644
index 0000000000..33a4187311
--- /dev/null
+++ b/app/src/main/java/com/amaze/filemanager/utils/OpenTerminalUtilsExt.kt
@@ -0,0 +1,29 @@
+package com.amaze.filemanager.utils
+
+import android.content.pm.PackageManager.MATCH_DEFAULT_ONLY
+import com.amaze.filemanager.ui.activities.MainActivity
+
+const val TERMONE_PLUS = "com.termoneplus"
+const val ANDROID_TERM = "jackpal.androidterm"
+const val TERMUX = "com.termux"
+
+val SUPPORTED_TERMINALS =
+ mapOf(
+ Pair(TERMONE_PLUS, "$ANDROID_TERM.RunScript"),
+ Pair(ANDROID_TERM, "$ANDROID_TERM.RunScript"),
+ Pair(TERMUX, "$TERMUX.app.RunCommandService"),
+ )
+
+fun MainActivity.detectInstalledTerminalApps(): Array {
+ val retval = ArrayList()
+ for (pkg in arrayOf(TERMONE_PLUS, ANDROID_TERM, TERMUX)) {
+ packageManager.getLaunchIntentForPackage(pkg)?.run {
+ val resolveInfos = packageManager.queryIntentActivitiesCompat(this, MATCH_DEFAULT_ONLY)
+ if (resolveInfos.isNotEmpty()
+ ) {
+ retval.add(pkg)
+ }
+ }
+ }
+ return retval.toTypedArray()
+}
diff --git a/app/src/main/java/com/amaze/filemanager/utils/PackageManagerCompatExt.kt b/app/src/main/java/com/amaze/filemanager/utils/PackageManagerCompatExt.kt
new file mode 100644
index 0000000000..c9787d2356
--- /dev/null
+++ b/app/src/main/java/com/amaze/filemanager/utils/PackageManagerCompatExt.kt
@@ -0,0 +1,51 @@
+package com.amaze.filemanager.utils
+
+import android.content.Intent
+import android.content.pm.ApplicationInfo
+import android.content.pm.PackageInfo
+import android.content.pm.PackageManager
+import android.content.pm.ResolveInfo
+import android.os.Build.VERSION.SDK_INT
+import android.os.Build.VERSION_CODES.TIRAMISU
+
+/**
+ * Wraps [PackageManager.queryIntentActivities] to SDK compatibility.
+ */
+fun PackageManager.queryIntentActivitiesCompat(
+ intent: Intent,
+ resolveInfoFlags: Int,
+): List {
+ return if (SDK_INT >= TIRAMISU) {
+ queryIntentActivities(intent, PackageManager.ResolveInfoFlags.of(resolveInfoFlags.toLong()))
+ } else {
+ queryIntentActivities(intent, resolveInfoFlags)
+ }
+}
+
+/**
+ * Wraps [PackageManager.getPackageInfo] to SDK compatibility.
+ */
+fun PackageManager.getPackageInfoCompat(
+ pkg: String,
+ packageInfoFlags: Int,
+): PackageInfo {
+ return if (SDK_INT >= TIRAMISU) {
+ getPackageInfo(pkg, PackageManager.PackageInfoFlags.of(packageInfoFlags.toLong()))
+ } else {
+ getPackageInfo(pkg, packageInfoFlags)
+ }
+}
+
+/**
+ * Wraps [PackageManager.getApplicationInfo] to SDK compatibility.
+ */
+fun PackageManager.getApplicationInfoCompat(
+ pkg: String,
+ applicationInfoFlags: Int,
+): ApplicationInfo {
+ return if (SDK_INT >= TIRAMISU) {
+ getApplicationInfo(pkg, PackageManager.ApplicationInfoFlags.of(applicationInfoFlags.toLong()))
+ } else {
+ getApplicationInfo(pkg, applicationInfoFlags)
+ }
+}
diff --git a/app/src/main/res/menu/activity_extra.xml b/app/src/main/res/menu/activity_extra.xml
index 0c601834d7..5bc39b53fd 100644
--- a/app/src/main/res/menu/activity_extra.xml
+++ b/app/src/main/res/menu/activity_extra.xml
@@ -55,6 +55,9 @@
+
+
\ No newline at end of file
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 7d3f97ae2a..5ff381465a 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -71,6 +71,7 @@
About
Extract
Compress
+ Open in Terminal
Yes
No
ⓘ