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