From 97e4d0b1ba503e85ac515cc43ac99035bcaf50ec Mon Sep 17 00:00:00 2001 From: Raymond Lai Date: Tue, 25 Apr 2023 22:56:17 +0800 Subject: [PATCH] [WIP] Add context menu "Open in Terminal" option Fixes #2666 --- app/src/main/AndroidManifest.xml | 6 + .../adapters/AppsRecyclerAdapter.kt | 2 +- .../filemanager/adapters/RecyclerAdapter.java | 2 + .../amaze/filemanager/ui/ItemPopupMenu.java | 5 + .../ui/activities/MainActivity.java | 8 + .../superclasses/PermissionsActivity.java | 4 + .../AbstractChooseAppToOpenFragment.kt | 105 ++++++ .../dialogs/OpenFolderInTerminalFragment.kt | 323 ++++++++++++++++++ .../ui/fragments/MainFragment.java | 1 + .../utils/MainActivityActionMode.kt | 5 + .../filemanager/utils/OpenTerminalUtilsExt.kt | 67 ++++ app/src/main/res/menu/activity_extra.xml | 3 + app/src/main/res/menu/item_extras.xml | 3 + app/src/main/res/values/strings.xml | 1 + 14 files changed, 534 insertions(+), 1 deletion(-) create mode 100644 app/src/main/java/com/amaze/filemanager/ui/dialogs/AbstractChooseAppToOpenFragment.kt create mode 100644 app/src/main/java/com/amaze/filemanager/ui/dialogs/OpenFolderInTerminalFragment.kt create mode 100644 app/src/main/java/com/amaze/filemanager/utils/OpenTerminalUtilsExt.kt 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/OpenFolderInTerminalFragment.kt b/app/src/main/java/com/amaze/filemanager/ui/dialogs/OpenFolderInTerminalFragment.kt new file mode 100644 index 0000000000..37298cee0a --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/ui/dialogs/OpenFolderInTerminalFragment.kt @@ -0,0 +1,323 @@ +package com.amaze.filemanager.ui.dialogs + +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.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.PreferenceActivity +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.triggerOpenFolderInTerminalIntent +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" + + 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) { + activity.triggerOpenFolderInTerminalIntent(installedTerminals.first(), path) + } else { + newInstance(path, installedTerminals).show(activity.supportFragmentManager, javaClass.simpleName) + } + } + + private 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 + } + + private fun startActivity(context: Context, intent: Intent) { + if(TERMUX == intent.component?.packageName) { + ContextCompat.startForegroundService(context, intent) + } else { + ContextCompat.startActivity(context, intent, null) + } + } + + private 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("com.termux", "com.termux.app.RunCommandService") + it.setAction("com.termux.RUN_COMMAND") + it.putExtra( + "com.termux.RUN_COMMAND_PATH", + "/data/data/com.termux/files/usr/bin/bash" + ); + it.putExtra("com.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, + preferenceActivity: PreferenceActivity, + ) { + preferenceActivity.prefs.edit().putString( + KEY_PREFERENCES_LAST, + String.format( + "%s %s", + appDataParcelable.openFileParcelable?.className, + appDataParcelable.openFileParcelable?.packageName, + ), + ).apply() + } + + /** + * Sets default app for mime type selected using 'Always' button from bottom sheet + */ + private fun setDefaultOpenedApp( + appDataParcelable: AppDataParcelable, + preferenceActivity: PreferenceActivity, + ) { + preferenceActivity.prefs.edit().putString( + KEY_PREFERENCES_DEFAULT, + String.format( + "%s %s", + appDataParcelable.openFileParcelable?.className, + appDataParcelable.openFileParcelable?.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() + } + } + } + + private fun clearMimeTypePreference( + mimeType: String, + sharedPreferences: SharedPreferences, + ) { + sharedPreferences.edit().remove(KEY_PREFERENCES_DEFAULT).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 lastClassAndPackageRaw = sharedPreferences + .getString("terminal.${OpenFileDialogFragment.KEY_PREFERENCES_LAST}", null) + val lastClassAndPackage = lastClassAndPackageRaw?.split(" ") + 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() + } + + private fun initList(): MutableList { + val packageManager = requireContext().packageManager + val appDataParcelableList: MutableList = ArrayList() + for (pkg in installedTerminals) { + kotlin.runCatching { packageManager.getPackageInfo(pkg, 0) } + .onFailure { + logger.error("Error getting package info for $pkg", it) + }.getOrNull()?.run { + packageManager.getApplicationInfo(pkg, 0).let { applicationInfo -> + appDataParcelableList.add( + AppDataParcelable( + packageManager.getApplicationLabel(applicationInfo).toString(), + "", + null, + this.packageName, + "", + "", + 0, + 0, false, + null, + ), + ) + } + } + } + return appDataParcelableList + } + + private fun initLastAppData( + lastClassAndPackage: List?, + appDataParcelableList: MutableList, + ): AppDataParcelable? { + if (appDataParcelableList.size == 0) { + AppConfig.toast(requireContext(), "No terminal apps available") + dismiss() + return null + } + + if (appDataParcelableList.size == 1) { + } + + var lastAppData: AppDataParcelable? = if (!lastClassAndPackage.isNullOrEmpty()) { + appDataParcelableList.find { + it.openFileParcelable?.className == lastClassAndPackage[0] + } + } else { + null + } + lastAppData = lastAppData ?: appDataParcelableList[0] + appDataParcelableList.remove(lastAppData) + return lastAppData + } + + private 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, activity as PreferenceActivity) + startActivity(requireContext(), lastAppIntent) + } + alwaysButton.setTextColor((activity as ThemedActivity).accent) + alwaysButton.setOnClickListener { _ -> + setDefaultOpenedApp(it, activity as PreferenceActivity) + startActivity(requireContext(), lastAppIntent) + } + } + } + } + + override fun adjustListViewForTv(viewHolder: AppHolder, mainActivity: MainActivity) { + // do nothing + } +} diff --git a/app/src/main/java/com/amaze/filemanager/ui/fragments/MainFragment.java b/app/src/main/java/com/amaze/filemanager/ui/fragments/MainFragment.java index 29c2a3393b..ebf9497529 100644 --- a/app/src/main/java/com/amaze/filemanager/ui/fragments/MainFragment.java +++ b/app/src/main/java/com/amaze/filemanager/ui/fragments/MainFragment.java @@ -85,6 +85,7 @@ import com.amaze.filemanager.utils.Utils; import com.google.android.material.appbar.AppBarLayout; +import android.annotation.SuppressLint; import android.content.BroadcastReceiver; import android.content.ClipData; import android.content.ClipDescription; 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..5473484a9b --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/utils/OpenTerminalUtilsExt.kt @@ -0,0 +1,67 @@ +package com.amaze.filemanager.utils + +import android.content.Intent +import android.content.pm.PackageManager +import androidx.core.content.ContextCompat +import com.amaze.filemanager.ui.activities.MainActivity + +const val TERMONE_PLUS = "com.termoneplus" +const val ANDROID_TERM = "jackpal.androidterm" +const val MATERIAL_TERMINAL = "yarolegovich.materialterminal" +const val TERMUX = "com.termux" + +fun MainActivity.triggerOpenFolderInTerminalIntent(pkgName: String, path: String) { + when (pkgName) { + TERMONE_PLUS -> { + val intent = Intent().also { + it.action = "$TERMONE_PLUS.RUN_SCRIPT" + it.setClassName(TERMONE_PLUS, "$ANDROID_TERM.RunScript") + it.putExtra("$TERMONE_PLUS.Command", "cd \"$path\"") + } + ContextCompat.startActivity(this, intent, null) + } + + ANDROID_TERM -> { + Intent().also { + it.action = "$ANDROID_TERM.RUN_SCRIPT" + it.setClassName(ANDROID_TERM, "$ANDROID_TERM.RunScript") + it.putExtra("$ANDROID_TERM.iInitialCommand", "cd \"$path\"") + startActivity(intent) + } + } + + TERMUX -> { + Intent().also { + it.setClassName("com.termux", "com.termux.app.RunCommandService") + it.setAction("com.termux.RUN_COMMAND") + it.putExtra( + "com.termux.RUN_COMMAND_PATH", + "/data/data/com.termux/files/usr/bin/bash" + ); + it.putExtra("com.termux.RUN_COMMAND_WORKDIR", path); + ContextCompat.startForegroundService(this, it) + } + } + + else -> { + throw IllegalArgumentException("Unsupported package: $pkgName") + } + } +} + +fun MainActivity.detectInstalledTerminalApps(): Array { + val retval = ArrayList() + + for (pkg in arrayOf(TERMONE_PLUS, ANDROID_TERM, TERMUX, MATERIAL_TERMINAL)) { + packageManager.getLaunchIntentForPackage(pkg)?.run { + if (packageManager.queryIntentActivities( + this, + PackageManager.MATCH_DEFAULT_ONLY, + ).isNotEmpty() + ) { + retval.add(pkg) + } + } + } + return retval.toTypedArray() +} 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