diff --git a/ultrasonic/src/main/java/org/moire/ultrasonic/fragment/SettingsFragment.java b/ultrasonic/src/main/java/org/moire/ultrasonic/fragment/SettingsFragment.java index d15d6a0e..b621d8d5 100644 --- a/ultrasonic/src/main/java/org/moire/ultrasonic/fragment/SettingsFragment.java +++ b/ultrasonic/src/main/java/org/moire/ultrasonic/fragment/SettingsFragment.java @@ -15,6 +15,8 @@ import org.moire.ultrasonic.activity.ServerSettingsActivity; import org.moire.ultrasonic.activity.SubsonicTabActivity; import org.moire.ultrasonic.featureflags.Feature; import org.moire.ultrasonic.featureflags.FeatureStorage; +import org.moire.ultrasonic.filepicker.FilePickerDialog; +import org.moire.ultrasonic.filepicker.OnFileSelectedListener; import org.moire.ultrasonic.provider.SearchSuggestionProvider; import org.moire.ultrasonic.service.MediaPlayerController; import org.moire.ultrasonic.util.*; @@ -37,7 +39,7 @@ public class SettingsFragment extends PreferenceFragment private ListPreference maxBitrateWifi; private ListPreference maxBitrateMobile; private ListPreference cacheSize; - private EditTextPreference cacheLocation; + private Preference cacheLocation; private ListPreference preloadCount; private ListPreference bufferLength; private ListPreference incrementTime; @@ -85,7 +87,7 @@ public class SettingsFragment extends PreferenceFragment maxBitrateWifi = (ListPreference) findPreference(Constants.PREFERENCES_KEY_MAX_BITRATE_WIFI); maxBitrateMobile = (ListPreference) findPreference(Constants.PREFERENCES_KEY_MAX_BITRATE_MOBILE); cacheSize = (ListPreference) findPreference(Constants.PREFERENCES_KEY_CACHE_SIZE); - cacheLocation = (EditTextPreference) findPreference(Constants.PREFERENCES_KEY_CACHE_LOCATION); + cacheLocation = findPreference(Constants.PREFERENCES_KEY_CACHE_LOCATION); preloadCount = (ListPreference) findPreference(Constants.PREFERENCES_KEY_PRELOAD_COUNT); bufferLength = (ListPreference) findPreference(Constants.PREFERENCES_KEY_BUFFER_LENGTH); incrementTime = (ListPreference) findPreference(Constants.PREFERENCES_KEY_INCREMENT_TIME); @@ -113,6 +115,7 @@ public class SettingsFragment extends PreferenceFragment setupClearSearchPreference(); setupGaplessControlSettingsV14(); setupFeatureFlagsPreferences(); + setupCacheLocationPreference(); // After API26 foreground services must be used for music playback, and they must have a notification if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { @@ -155,8 +158,6 @@ public class SettingsFragment extends PreferenceFragment setHideMedia(sharedPreferences.getBoolean(key, false)); } else if (Constants.PREFERENCES_KEY_MEDIA_BUTTONS.equals(key)) { setMediaButtonsEnabled(sharedPreferences.getBoolean(key, true)); - } else if (Constants.PREFERENCES_KEY_CACHE_LOCATION.equals(key)) { - setCacheLocation(sharedPreferences.getString(key, "")); } else if (Constants.PREFERENCES_KEY_SEND_BLUETOOTH_NOTIFICATIONS.equals(key)) { setBluetoothPreferences(sharedPreferences.getBoolean(key, true)); } else if (Constants.PREFERENCES_KEY_IMAGE_LOADER_CONCURRENCY.equals(key)) { @@ -164,6 +165,40 @@ public class SettingsFragment extends PreferenceFragment } } + private void setupCacheLocationPreference() { + cacheLocation.setSummary(settings.getString(Constants.PREFERENCES_KEY_CACHE_LOCATION, + FileUtil.getDefaultMusicDirectory(getActivity()).getPath())); + + cacheLocation.setOnPreferenceClickListener(new Preference.OnPreferenceClickListener() { + @Override + public boolean onPreferenceClick(Preference preference) { + // If the user tries to change the cache location, we must first check to see if we have write access. + PermissionUtil.requestInitialPermission(getActivity(), new PermissionUtil.PermissionRequestFinishedCallback() { + @Override + public void onPermissionRequestFinished(boolean hasPermission) { + if (hasPermission) { + FilePickerDialog filePickerDialog = FilePickerDialog.Companion.createFilePickerDialog(getActivity()); + filePickerDialog.setDefaultDirectory(FileUtil.getDefaultMusicDirectory(getActivity()).getPath()); + filePickerDialog.setInitialDirectory(cacheLocation.getSummary().toString()); + filePickerDialog.setOnFileSelectedListener(new OnFileSelectedListener() { + @Override + public void onFileSelected(File file, String path) { + SharedPreferences.Editor editor = cacheLocation.getEditor(); + editor.putString(Constants.PREFERENCES_KEY_CACHE_LOCATION, path); + editor.commit(); + + setCacheLocation(path); + } + }); + filePickerDialog.show(); + } + } + }); + return true; + } + }); + } + private void setupClearSearchPreference() { Preference clearSearchPreference = findPreference(Constants.PREFERENCES_KEY_CLEAR_SEARCH_HISTORY); @@ -315,7 +350,6 @@ public class SettingsFragment extends PreferenceFragment maxBitrateWifi.setSummary(maxBitrateWifi.getEntry()); maxBitrateMobile.setSummary(maxBitrateMobile.getEntry()); cacheSize.setSummary(cacheSize.getEntry()); - cacheLocation.setSummary(cacheLocation.getText()); preloadCount.setSummary(preloadCount.getEntry()); bufferLength.setSummary(bufferLength.getEntry()); incrementTime.setSummary(incrementTime.getEntry()); @@ -396,14 +430,16 @@ public class SettingsFragment extends PreferenceFragment if (!FileUtil.ensureDirectoryExistsAndIsReadWritable(dir)) { PermissionUtil.handlePermissionFailed(getActivity(), new PermissionUtil.PermissionRequestFinishedCallback() { @Override - public void onPermissionRequestFinished() { + public void onPermissionRequestFinished(boolean hasPermission) { String currentPath = settings.getString(Constants.PREFERENCES_KEY_CACHE_LOCATION, FileUtil.getDefaultMusicDirectory(getActivity()).getPath()); cacheLocation.setSummary(currentPath); - cacheLocation.setText(currentPath); } }); } + else { + cacheLocation.setSummary(path); + } // Clear download queue. mediaPlayerControllerLazy.getValue().clear(); diff --git a/ultrasonic/src/main/java/org/moire/ultrasonic/util/PermissionUtil.java b/ultrasonic/src/main/java/org/moire/ultrasonic/util/PermissionUtil.java index 938466cf..74868160 100644 --- a/ultrasonic/src/main/java/org/moire/ultrasonic/util/PermissionUtil.java +++ b/ultrasonic/src/main/java/org/moire/ultrasonic/util/PermissionUtil.java @@ -5,6 +5,8 @@ import android.content.Context; import android.content.DialogInterface; import android.content.Intent; import android.net.Uri; +import android.os.Handler; +import android.os.Looper; import android.util.Log; import androidx.appcompat.app.AlertDialog; @@ -34,7 +36,7 @@ public class PermissionUtil { private static final String TAG = FileUtil.class.getSimpleName(); public interface PermissionRequestFinishedCallback { - void onPermissionRequestFinished(); + void onPermissionRequestFinished(boolean hasPermission); } /** @@ -55,27 +57,79 @@ public class PermissionUtil { if (currentCachePath.compareTo(defaultCachePath) == 0) return; // We must get the context of the Main Activity for the dialogs, as this function may be called from a background thread where displaying dialogs is not available - Context mainContext = MainActivity.getInstance(); + final Context mainContext = MainActivity.getInstance(); if ((PermissionChecker.checkSelfPermission(context, Manifest.permission.WRITE_EXTERNAL_STORAGE) == PERMISSION_DENIED) || (PermissionChecker.checkSelfPermission(context, Manifest.permission.READ_EXTERNAL_STORAGE) == PERMISSION_DENIED)) { // While we request permission, the Music Directory is temporarily reset to its default location setCacheLocation(context, FileUtil.getDefaultMusicDirectory(context).getPath()); - requestPermission(mainContext, currentCachePath, callback); + requestFailedPermission(mainContext, currentCachePath, callback); } else { setCacheLocation(context, FileUtil.getDefaultMusicDirectory(context).getPath()); - showWarning(mainContext, context.getString(R.string.permissions_message_box_title), context.getString(R.string.permissions_access_error), null); - callback.onPermissionRequestFinished(); + new Handler(Looper.getMainLooper()).post(new Runnable() { + @Override + public void run() { + showWarning(mainContext, context.getString(R.string.permissions_message_box_title), context.getString(R.string.permissions_access_error), null); + } + }); + callback.onPermissionRequestFinished(false); } } + /** + * This function requests permission to access the filesystem. + * It can be used to request the permission initially, e.g. when the user decides to use a non-default folder for the cache + * @param context context for the operation + * @param callback callback function to execute after the permission request is finished + */ + public static void requestInitialPermission(final Context context, final PermissionRequestFinishedCallback callback) { + Dexter.withContext(context) + .withPermissions( + Manifest.permission.WRITE_EXTERNAL_STORAGE, + Manifest.permission.READ_EXTERNAL_STORAGE) + .withListener(new MultiplePermissionsListener() { + @Override + public void onPermissionsChecked(MultiplePermissionsReport report) { + if (report.areAllPermissionsGranted()) { + Log.i(TAG, "Permission granted to read / write external storage"); + if (callback != null) callback.onPermissionRequestFinished(true); + return; + } + + if (report.isAnyPermissionPermanentlyDenied()) { + Log.i(TAG, "Found permanently denied permission to read / write external storage, offering settings"); + showSettingsDialog(context); + if (callback != null) callback.onPermissionRequestFinished(false); + return; + } + + Log.i(TAG, "At least one permission is missing to read / write external storage"); + showWarning(context, context.getString(R.string.permissions_message_box_title), + context.getString(R.string.permissions_rationale_description_initial), null); + if (callback != null) callback.onPermissionRequestFinished(false); + } + + @Override + public void onPermissionRationaleShouldBeShown(List permissions, PermissionToken token) { + showWarning(context, context.getString(R.string.permissions_rationale_title), + context.getString(R.string.permissions_rationale_description_initial), token); + } + }).withErrorListener(new PermissionRequestErrorListener() { + @Override + public void onError(DexterError error) { + Log.e(TAG, String.format("An error has occurred during checking permissions with Dexter: %s", error.toString())); + } + }) + .check(); + } + private static void setCacheLocation(Context context, String cacheLocation) { Util.getPreferences(context).edit() .putString(Constants.PREFERENCES_KEY_CACHE_LOCATION, cacheLocation) .apply(); } - private static void requestPermission(final Context context, final String cacheLocation, final PermissionRequestFinishedCallback callback) { + private static void requestFailedPermission(final Context context, final String cacheLocation, final PermissionRequestFinishedCallback callback) { Dexter.withContext(context) .withPermissions( Manifest.permission.WRITE_EXTERNAL_STORAGE, @@ -86,14 +140,14 @@ public class PermissionUtil { if (report.areAllPermissionsGranted()) { Log.i(TAG, String.format("Permission granted to use cache directory %s", cacheLocation)); setCacheLocation(context, cacheLocation); - if (callback != null) callback.onPermissionRequestFinished(); + if (callback != null) callback.onPermissionRequestFinished(true); return; } if (report.isAnyPermissionPermanentlyDenied()) { Log.i(TAG, String.format("Found permanently denied permission to use cache directory %s, offering settings", cacheLocation)); showSettingsDialog(context); - if (callback != null) callback.onPermissionRequestFinished(); + if (callback != null) callback.onPermissionRequestFinished(false); return; } @@ -101,13 +155,13 @@ public class PermissionUtil { setCacheLocation(context, FileUtil.getDefaultMusicDirectory(context).getPath()); showWarning(context, context.getString(R.string.permissions_message_box_title), context.getString(R.string.permissions_permission_missing), null); - if (callback != null) callback.onPermissionRequestFinished(); + if (callback != null) callback.onPermissionRequestFinished(false); } @Override public void onPermissionRationaleShouldBeShown(List permissions, PermissionToken token) { showWarning(context, context.getString(R.string.permissions_rationale_title), - context.getString(R.string.permissions_rationale_description), token); + context.getString(R.string.permissions_rationale_description_failed), token); } }).withErrorListener(new PermissionRequestErrorListener() { @Override diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/filepicker/FilePickerAdapter.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/filepicker/FilePickerAdapter.kt new file mode 100644 index 00000000..442187ba --- /dev/null +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/filepicker/FilePickerAdapter.kt @@ -0,0 +1,287 @@ +package org.moire.ultrasonic.filepicker + +import android.content.Context +import android.graphics.drawable.Drawable +import android.os.Environment +import android.text.TextUtils +import android.util.Log +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.ImageView +import android.widget.TextView +import androidx.appcompat.app.AlertDialog +import androidx.appcompat.widget.AppCompatEditText +import androidx.recyclerview.widget.RecyclerView +import java.io.File +import java.util.LinkedList +import kotlin.Comparator +import org.moire.ultrasonic.R +import org.moire.ultrasonic.util.Util + +/** + * Adapter for the RecyclerView which handles listing, navigating and picking files + * @author this implementation is loosely based on the work of Yogesh Sundaresan, + * original license: http://www.apache.org/licenses/LICENSE-2.0 + */ +internal class FilePickerAdapter : RecyclerView.Adapter { + + private var data: MutableList = LinkedList() + var defaultDirectory: File = Environment.getExternalStorageDirectory() + var initialDirectory: File = Environment.getExternalStorageDirectory() + lateinit var selectedDirectoryChanged: (String, Boolean) -> Unit + var selectedDirectory: File = defaultDirectory + private set + + private var context: Context? = null + private var listerView: FilePickerView? = null + private var isRealDirectory: Boolean = false + + private val physicalPaths: Array + get() = arrayOf( + "/storage/sdcard0", "/storage/sdcard1", "/storage/extsdcard", + "/storage/sdcard0/external_sdcard", "/mnt/extsdcard", "/mnt/sdcard/external_sd", + "/mnt/external_sd", "/mnt/media_rw/sdcard1", "/removable/microsd", "/mnt/emmc", + "/storage/external_SD", "/storage/ext_sd", "/storage/removable/sdcard1", + "/data/sdext", "/data/sdext2", "/data/sdext3", "/data/sdext4", "/sdcard1", + "/sdcard2", "/storage/microsd", "/data/user" + ) + + private var folderIcon: Drawable? = null + private var upIcon: Drawable? = null + private var sdIcon: Drawable? = null + + constructor(defaultDir: File, view: FilePickerView) : this(view) { + this.defaultDirectory = defaultDir + selectedDirectory = defaultDir + } + + constructor(view: FilePickerView) { + this.context = view.context + listerView = view + + upIcon = Util.getDrawableFromAttribute(context, R.attr.filepicker_subdirectory_up) + folderIcon = Util.getDrawableFromAttribute(context, R.attr.filepicker_folder) + sdIcon = Util.getDrawableFromAttribute(context, R.attr.filepicker_sd_card) + } + + fun start() { + fileLister(initialDirectory) + } + + private fun fileLister(currentDirectory: File) { + var fileList = LinkedList() + var storages: List? = null + var storagePaths: List? = null + if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.KITKAT) { + storages = context!!.getExternalFilesDirs(null).filterNotNull() + storagePaths = storages.map { i -> i.absolutePath } + } + + if (currentDirectory.absolutePath == "/" || + currentDirectory.absolutePath == "/storage" || + currentDirectory.absolutePath == "/storage/emulated" || + currentDirectory.absolutePath == "/mnt" + ) { + isRealDirectory = false + fileList = if ( + android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.KITKAT + ) { + getKitKatStorageItems(storages!!) + } else { + getStorageItems() + } + } else { + isRealDirectory = true + val files = currentDirectory.listFiles() + files?.forEach { file -> + if (file.isDirectory) { + fileList.add(FileListItem(file, file.name, folderIcon!!)) + } + } + } + + data = LinkedList(fileList) + + data.sortWith( + Comparator { f1, f2 -> + if (f1.file!!.isDirectory && f2.file!!.isDirectory) + f1.name.compareTo(f2.name, ignoreCase = true) + else if (f1.file!!.isDirectory && !f2.file!!.isDirectory) + -1 + else if (!f1.file!!.isDirectory && f2.file!!.isDirectory) + 1 + else if (!f1.file!!.isDirectory && !f2.file!!.isDirectory) + f1.name.compareTo(f2.name, ignoreCase = true) + else + 0 + } + ) + + selectedDirectory = currentDirectory + selectedDirectoryChanged.invoke( + if (isRealDirectory) selectedDirectory.absolutePath + else context!!.getString(R.string.filepicker_available_drives), + isRealDirectory + ) + + // Add the "Up" navigation to the list + if (currentDirectory.absolutePath != "/" && isRealDirectory) { + // If we are on KitKat or later, only the default App folder is usable, so we can't + // navigate the SD card. Jump to the root if "Up" is selected. + if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.KITKAT) { + if (storagePaths!!.indexOf(currentDirectory.absolutePath) > 0) + data.add(0, FileListItem(File("/"), "..", upIcon!!)) + else + data.add(0, FileListItem(selectedDirectory.parentFile!!, "..", upIcon!!)) + } else { + data.add(0, FileListItem(selectedDirectory.parentFile!!, "..", upIcon!!)) + } + } + + notifyDataSetChanged() + listerView!!.scrollToPosition(0) + } + + private fun getStorageItems(): LinkedList { + val fileList = LinkedList() + var s = System.getenv("EXTERNAL_STORAGE") + if (!TextUtils.isEmpty(s)) { + val f = File(s!!) + fileList.add(FileListItem(f, f.name, sdIcon!!)) + } else { + val paths = physicalPaths + for (path in paths) { + val f = File(path) + if (f.exists()) + fileList.add(FileListItem(f, f.name, sdIcon!!)) + } + } + s = System.getenv("SECONDARY_STORAGE") + if (s != null && !TextUtils.isEmpty(s)) { + val rawSecondaryStorages = + s.split(File.pathSeparator.toRegex()).dropLastWhile { it.isEmpty() }.toTypedArray() + for (path in rawSecondaryStorages) { + val f = File(path) + if (f.exists()) + fileList.add(FileListItem(f, f.name, sdIcon!!)) + } + } + return fileList + } + + private fun getKitKatStorageItems(storages: List): LinkedList { + val fileList = LinkedList() + if (storages.isNotEmpty()) { + for ((index, file) in storages.withIndex()) { + var path = file.absolutePath + path = path.replace("/Android/data/([a-zA-Z_][.\\w]*)/files".toRegex(), "") + if (index == 0) { + fileList.add( + FileListItem( + File(path), + context!!.getString(R.string.filepicker_internal, path), + sdIcon!! + ) + ) + } else { + fileList.add( + FileListItem( + file, + context!!.getString(R.string.filepicker_default_app_folder, path), + sdIcon!! + ) + ) + } + } + } + return fileList + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): FileListHolder { + return FileListHolder( + LayoutInflater.from(context).inflate( + R.layout.filepicker_item_file_lister, listerView, false + ) + ) + } + + override fun onBindViewHolder(holder: FileListHolder, position: Int) { + val actualFile = data[position] + + holder.name.text = actualFile.name + holder.icon.setImageDrawable(actualFile.icon) + } + + override fun getItemCount(): Int { + return data.size + } + + fun goToDefault() { + fileLister(defaultDirectory) + } + + fun createNewFolder() { + val view = View.inflate(context, R.layout.filepicker_dialog_create_folder, null) + val editText = view.findViewById(R.id.edittext) + val builder = AlertDialog.Builder(context!!) + .setView(view) + .setTitle(context!!.getString(R.string.filepicker_enter_folder_name)) + .setPositiveButton(context!!.getString(R.string.filepicker_create)) { _, _ -> } + val dialog = builder.create() + dialog.show() + + dialog.getButton(AlertDialog.BUTTON_POSITIVE).setOnClickListener { + val name = editText.text!!.toString() + + if (TextUtils.isEmpty(name)) { + Util.toast(context!!, context!!.getString(R.string.filepicker_name_invalid)) + } else { + val file = File(selectedDirectory, name) + + if (file.exists()) { + Util.toast(context!!, context!!.getString(R.string.filepicker_already_exists)) + } else { + dialog.dismiss() + if (file.mkdirs()) { + fileLister(file) + } else { + Util.toast( + context!!, + context!!.getString(R.string.filepicker_create_folder_failed) + ) + } + } + } + } + } + + internal inner class FileListItem( + fileParameter: File, + nameParameter: String, + iconParameter: Drawable + ) { + var file: File? = fileParameter + var name: String = nameParameter + var icon: Drawable? = iconParameter + } + + internal inner class FileListHolder( + itemView: View + ) : RecyclerView.ViewHolder(itemView), View.OnClickListener { + + var name: TextView = itemView.findViewById(R.id.name) + var icon: ImageView = itemView.findViewById(R.id.icon) + + init { + itemView.findViewById(R.id.layout).setOnClickListener(this) + } + + override fun onClick(v: View) { + val clickedFile = data[adapterPosition] + selectedDirectory = clickedFile.file!! + fileLister(clickedFile.file!!) + Log.d("FileLister", clickedFile.file!!.absolutePath) + } + } +} diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/filepicker/FilePickerDialog.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/filepicker/FilePickerDialog.kt new file mode 100644 index 00000000..5c743136 --- /dev/null +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/filepicker/FilePickerDialog.kt @@ -0,0 +1,116 @@ +package org.moire.ultrasonic.filepicker + +import android.content.Context +import android.content.DialogInterface.BUTTON_NEGATIVE +import android.content.DialogInterface.BUTTON_NEUTRAL +import android.content.DialogInterface.BUTTON_POSITIVE +import android.view.LayoutInflater +import android.widget.Button +import android.widget.TextView +import androidx.appcompat.app.AlertDialog +import org.moire.ultrasonic.R + +/** + * This dialog can be used to pick a file / folder from the filesystem. + * Currently only supports folders. + * @author this implementation is loosely based on the work of Yogesh Sundaresan, + * original license: http://www.apache.org/licenses/LICENSE-2.0 + */ +class FilePickerDialog { + + private var alertDialog: AlertDialog? = null + private var filePickerView: FilePickerView? = null + private var onFileSelectedListener: OnFileSelectedListener? = null + private var currentPath: TextView? = null + private var newFolderButton: Button? = null + + private constructor(context: Context) { + alertDialog = AlertDialog.Builder(context).create() + initialize(context) + } + + private constructor(context: Context, themeResId: Int) { + alertDialog = AlertDialog.Builder(context, themeResId).create() + initialize(context) + } + + private fun initialize(context: Context) { + val view = LayoutInflater.from(context).inflate(R.layout.filepicker_dialog_main, null) + + alertDialog!!.setView(view) + filePickerView = view.findViewById(R.id.file_list_view) + currentPath = view.findViewById(R.id.current_path) + + newFolderButton = view.findViewById(R.id.filepicker_create_folder) + newFolderButton!!.setOnClickListener { filePickerView!!.createNewFolder() } + + alertDialog!!.setTitle(context.getString(R.string.filepicker_select_folder)) + + alertDialog!!.setButton(BUTTON_POSITIVE, context.getString(R.string.filepicker_select)) { + dialogInterface, _ -> + dialogInterface.dismiss() + if (onFileSelectedListener != null) + onFileSelectedListener!!.onFileSelected( + filePickerView!!.selected, filePickerView!!.selected.absolutePath + ) + } + alertDialog!!.setButton(BUTTON_NEUTRAL, context.getString(R.string.filepicker_default)) { + _, _ -> + filePickerView!!.goToDefaultDirectory() + } + alertDialog!!.setButton(BUTTON_NEGATIVE, context.getString(R.string.common_cancel)) { + dialogInterface, _ -> + dialogInterface.dismiss() + } + } + + /** + * Display the FilePickerDialog + */ + fun show() { + filePickerView!!.start { currentDirectory, isRealPath -> + run { + currentPath?.text = currentDirectory + newFolderButton!!.isEnabled = isRealPath + } + } + alertDialog!!.show() + alertDialog!!.getButton(BUTTON_NEUTRAL).setOnClickListener { + filePickerView!!.goToDefaultDirectory() + } + } + + /** + * Listener to know which file/directory is selected + * + * @param onFileSelectedListener Instance of the Listener + */ + fun setOnFileSelectedListener(onFileSelectedListener: OnFileSelectedListener) { + this.onFileSelectedListener = onFileSelectedListener + } + + /** + * Set the initial directory to show the list of files in that directory + * + * @param path String denoting to the directory + */ + fun setDefaultDirectory(path: String) { + filePickerView!!.setDefaultDirectory(path) + } + + fun setInitialDirectory(path: String) { + filePickerView!!.setInitialDirectory(path) + } + + companion object { + /** + * Creates a default instance of FilePickerDialog + * + * @param context Context of the App + * @return Instance of FileListerDialog + */ + fun createFilePickerDialog(context: Context): FilePickerDialog { + return FilePickerDialog(context) + } + } +} diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/filepicker/FilePickerView.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/filepicker/FilePickerView.kt new file mode 100644 index 00000000..a94f86be --- /dev/null +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/filepicker/FilePickerView.kt @@ -0,0 +1,67 @@ +package org.moire.ultrasonic.filepicker + +import android.content.Context +import android.util.AttributeSet +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView +import java.io.File + +/** + * RecyclerView containing the file list of a directory + * @author this implementation is loosely based on the work of Yogesh Sundaresan, + * original license: http://www.apache.org/licenses/LICENSE-2.0 + */ +internal class FilePickerView : RecyclerView { + + private var adapter: FilePickerAdapter? = null + + val selected: File + get() = adapter!!.selectedDirectory + + constructor(context: Context) : super(context) { + initialize() + } + + constructor(context: Context, attrs: AttributeSet?) : super(context, attrs) { + initialize() + } + + constructor( + context: Context, + attrs: AttributeSet?, + defStyle: Int + ) : super(context, attrs, defStyle) { + initialize() + } + + private fun initialize() { + layoutManager = LinearLayoutManager(context, VERTICAL, false) + adapter = FilePickerAdapter(this) + } + + fun start(selectedDirectoryChangedListener: (String, Boolean) -> Unit) { + setAdapter(adapter) + adapter?.selectedDirectoryChanged = selectedDirectoryChangedListener + adapter!!.start() + } + + fun setDefaultDirectory(file: File) { + adapter!!.defaultDirectory = file + } + + fun setDefaultDirectory(path: String) { + setDefaultDirectory(File(path)) + } + + fun setInitialDirectory(path: String) { + adapter!!.initialDirectory = File(path) + } + + fun goToDefaultDirectory() { + adapter!!.goToDefault() + } + + fun createNewFolder() { + adapter!!.createNewFolder() + } +} diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/filepicker/OnFileSelectedListener.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/filepicker/OnFileSelectedListener.kt new file mode 100644 index 00000000..54d3b5de --- /dev/null +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/filepicker/OnFileSelectedListener.kt @@ -0,0 +1,7 @@ +package org.moire.ultrasonic.filepicker + +import java.io.File + +interface OnFileSelectedListener { + fun onFileSelected(file: File?, path: String?) +} diff --git a/ultrasonic/src/main/res/drawable-hdpi/ic_create_new_folder_dark.png b/ultrasonic/src/main/res/drawable-hdpi/ic_create_new_folder_dark.png new file mode 100644 index 00000000..958d9941 Binary files /dev/null and b/ultrasonic/src/main/res/drawable-hdpi/ic_create_new_folder_dark.png differ diff --git a/ultrasonic/src/main/res/drawable-hdpi/ic_create_new_folder_light.png b/ultrasonic/src/main/res/drawable-hdpi/ic_create_new_folder_light.png new file mode 100644 index 00000000..96f975e8 Binary files /dev/null and b/ultrasonic/src/main/res/drawable-hdpi/ic_create_new_folder_light.png differ diff --git a/ultrasonic/src/main/res/drawable-hdpi/ic_folder_dark.png b/ultrasonic/src/main/res/drawable-hdpi/ic_folder_dark.png new file mode 100644 index 00000000..fe3ed46c Binary files /dev/null and b/ultrasonic/src/main/res/drawable-hdpi/ic_folder_dark.png differ diff --git a/ultrasonic/src/main/res/drawable-hdpi/ic_folder_light.png b/ultrasonic/src/main/res/drawable-hdpi/ic_folder_light.png new file mode 100644 index 00000000..55b9ebf2 Binary files /dev/null and b/ultrasonic/src/main/res/drawable-hdpi/ic_folder_light.png differ diff --git a/ultrasonic/src/main/res/drawable-hdpi/ic_sd_storage_dark.png b/ultrasonic/src/main/res/drawable-hdpi/ic_sd_storage_dark.png new file mode 100644 index 00000000..2bda6319 Binary files /dev/null and b/ultrasonic/src/main/res/drawable-hdpi/ic_sd_storage_dark.png differ diff --git a/ultrasonic/src/main/res/drawable-hdpi/ic_sd_storage_light.png b/ultrasonic/src/main/res/drawable-hdpi/ic_sd_storage_light.png new file mode 100644 index 00000000..7367e397 Binary files /dev/null and b/ultrasonic/src/main/res/drawable-hdpi/ic_sd_storage_light.png differ diff --git a/ultrasonic/src/main/res/drawable-hdpi/ic_subdirectory_up_dark.png b/ultrasonic/src/main/res/drawable-hdpi/ic_subdirectory_up_dark.png new file mode 100644 index 00000000..e58d667f Binary files /dev/null and b/ultrasonic/src/main/res/drawable-hdpi/ic_subdirectory_up_dark.png differ diff --git a/ultrasonic/src/main/res/drawable-hdpi/ic_subdirectory_up_light.png b/ultrasonic/src/main/res/drawable-hdpi/ic_subdirectory_up_light.png new file mode 100644 index 00000000..7a52a7b5 Binary files /dev/null and b/ultrasonic/src/main/res/drawable-hdpi/ic_subdirectory_up_light.png differ diff --git a/ultrasonic/src/main/res/drawable-ldpi/ic_create_new_folder_dark.png b/ultrasonic/src/main/res/drawable-ldpi/ic_create_new_folder_dark.png new file mode 100644 index 00000000..ccf8e987 Binary files /dev/null and b/ultrasonic/src/main/res/drawable-ldpi/ic_create_new_folder_dark.png differ diff --git a/ultrasonic/src/main/res/drawable-ldpi/ic_create_new_folder_light.png b/ultrasonic/src/main/res/drawable-ldpi/ic_create_new_folder_light.png new file mode 100644 index 00000000..19678649 Binary files /dev/null and b/ultrasonic/src/main/res/drawable-ldpi/ic_create_new_folder_light.png differ diff --git a/ultrasonic/src/main/res/drawable-ldpi/ic_folder_dark.png b/ultrasonic/src/main/res/drawable-ldpi/ic_folder_dark.png new file mode 100644 index 00000000..49e4093a Binary files /dev/null and b/ultrasonic/src/main/res/drawable-ldpi/ic_folder_dark.png differ diff --git a/ultrasonic/src/main/res/drawable-ldpi/ic_folder_light.png b/ultrasonic/src/main/res/drawable-ldpi/ic_folder_light.png new file mode 100644 index 00000000..2ed5af4f Binary files /dev/null and b/ultrasonic/src/main/res/drawable-ldpi/ic_folder_light.png differ diff --git a/ultrasonic/src/main/res/drawable-ldpi/ic_sd_storage_dark.png b/ultrasonic/src/main/res/drawable-ldpi/ic_sd_storage_dark.png new file mode 100644 index 00000000..c783e281 Binary files /dev/null and b/ultrasonic/src/main/res/drawable-ldpi/ic_sd_storage_dark.png differ diff --git a/ultrasonic/src/main/res/drawable-ldpi/ic_sd_storage_light.png b/ultrasonic/src/main/res/drawable-ldpi/ic_sd_storage_light.png new file mode 100644 index 00000000..7abd3e9c Binary files /dev/null and b/ultrasonic/src/main/res/drawable-ldpi/ic_sd_storage_light.png differ diff --git a/ultrasonic/src/main/res/drawable-ldpi/ic_subdirectory_up_dark.png b/ultrasonic/src/main/res/drawable-ldpi/ic_subdirectory_up_dark.png new file mode 100644 index 00000000..58fb230d Binary files /dev/null and b/ultrasonic/src/main/res/drawable-ldpi/ic_subdirectory_up_dark.png differ diff --git a/ultrasonic/src/main/res/drawable-ldpi/ic_subdirectory_up_light.png b/ultrasonic/src/main/res/drawable-ldpi/ic_subdirectory_up_light.png new file mode 100644 index 00000000..8d9eff99 Binary files /dev/null and b/ultrasonic/src/main/res/drawable-ldpi/ic_subdirectory_up_light.png differ diff --git a/ultrasonic/src/main/res/drawable-mdpi/ic_create_new_folder_dark.png b/ultrasonic/src/main/res/drawable-mdpi/ic_create_new_folder_dark.png new file mode 100644 index 00000000..03f8aca6 Binary files /dev/null and b/ultrasonic/src/main/res/drawable-mdpi/ic_create_new_folder_dark.png differ diff --git a/ultrasonic/src/main/res/drawable-mdpi/ic_create_new_folder_light.png b/ultrasonic/src/main/res/drawable-mdpi/ic_create_new_folder_light.png new file mode 100644 index 00000000..1c625dc6 Binary files /dev/null and b/ultrasonic/src/main/res/drawable-mdpi/ic_create_new_folder_light.png differ diff --git a/ultrasonic/src/main/res/drawable-mdpi/ic_folder_dark.png b/ultrasonic/src/main/res/drawable-mdpi/ic_folder_dark.png new file mode 100644 index 00000000..7be1ea8a Binary files /dev/null and b/ultrasonic/src/main/res/drawable-mdpi/ic_folder_dark.png differ diff --git a/ultrasonic/src/main/res/drawable-mdpi/ic_folder_light.png b/ultrasonic/src/main/res/drawable-mdpi/ic_folder_light.png new file mode 100644 index 00000000..0e5cacd4 Binary files /dev/null and b/ultrasonic/src/main/res/drawable-mdpi/ic_folder_light.png differ diff --git a/ultrasonic/src/main/res/drawable-mdpi/ic_sd_storage_dark.png b/ultrasonic/src/main/res/drawable-mdpi/ic_sd_storage_dark.png new file mode 100644 index 00000000..2a9cbaf9 Binary files /dev/null and b/ultrasonic/src/main/res/drawable-mdpi/ic_sd_storage_dark.png differ diff --git a/ultrasonic/src/main/res/drawable-mdpi/ic_sd_storage_light.png b/ultrasonic/src/main/res/drawable-mdpi/ic_sd_storage_light.png new file mode 100644 index 00000000..a766925f Binary files /dev/null and b/ultrasonic/src/main/res/drawable-mdpi/ic_sd_storage_light.png differ diff --git a/ultrasonic/src/main/res/drawable-mdpi/ic_subdirectory_up_dark.png b/ultrasonic/src/main/res/drawable-mdpi/ic_subdirectory_up_dark.png new file mode 100644 index 00000000..47514085 Binary files /dev/null and b/ultrasonic/src/main/res/drawable-mdpi/ic_subdirectory_up_dark.png differ diff --git a/ultrasonic/src/main/res/drawable-mdpi/ic_subdirectory_up_light.png b/ultrasonic/src/main/res/drawable-mdpi/ic_subdirectory_up_light.png new file mode 100644 index 00000000..2f758b83 Binary files /dev/null and b/ultrasonic/src/main/res/drawable-mdpi/ic_subdirectory_up_light.png differ diff --git a/ultrasonic/src/main/res/drawable-xhdpi/ic_create_new_folder_dark.png b/ultrasonic/src/main/res/drawable-xhdpi/ic_create_new_folder_dark.png new file mode 100644 index 00000000..74c76d90 Binary files /dev/null and b/ultrasonic/src/main/res/drawable-xhdpi/ic_create_new_folder_dark.png differ diff --git a/ultrasonic/src/main/res/drawable-xhdpi/ic_create_new_folder_light.png b/ultrasonic/src/main/res/drawable-xhdpi/ic_create_new_folder_light.png new file mode 100644 index 00000000..86cb8444 Binary files /dev/null and b/ultrasonic/src/main/res/drawable-xhdpi/ic_create_new_folder_light.png differ diff --git a/ultrasonic/src/main/res/drawable-xhdpi/ic_folder_dark.png b/ultrasonic/src/main/res/drawable-xhdpi/ic_folder_dark.png new file mode 100644 index 00000000..90e27cdc Binary files /dev/null and b/ultrasonic/src/main/res/drawable-xhdpi/ic_folder_dark.png differ diff --git a/ultrasonic/src/main/res/drawable-xhdpi/ic_folder_light.png b/ultrasonic/src/main/res/drawable-xhdpi/ic_folder_light.png new file mode 100644 index 00000000..b6179e71 Binary files /dev/null and b/ultrasonic/src/main/res/drawable-xhdpi/ic_folder_light.png differ diff --git a/ultrasonic/src/main/res/drawable-xhdpi/ic_sd_storage_dark.png b/ultrasonic/src/main/res/drawable-xhdpi/ic_sd_storage_dark.png new file mode 100644 index 00000000..441ad4cf Binary files /dev/null and b/ultrasonic/src/main/res/drawable-xhdpi/ic_sd_storage_dark.png differ diff --git a/ultrasonic/src/main/res/drawable-xhdpi/ic_sd_storage_light.png b/ultrasonic/src/main/res/drawable-xhdpi/ic_sd_storage_light.png new file mode 100644 index 00000000..ddf6a4c4 Binary files /dev/null and b/ultrasonic/src/main/res/drawable-xhdpi/ic_sd_storage_light.png differ diff --git a/ultrasonic/src/main/res/drawable-xhdpi/ic_subdirectory_up_dark.png b/ultrasonic/src/main/res/drawable-xhdpi/ic_subdirectory_up_dark.png new file mode 100644 index 00000000..ad0bb6fe Binary files /dev/null and b/ultrasonic/src/main/res/drawable-xhdpi/ic_subdirectory_up_dark.png differ diff --git a/ultrasonic/src/main/res/drawable-xhdpi/ic_subdirectory_up_light.png b/ultrasonic/src/main/res/drawable-xhdpi/ic_subdirectory_up_light.png new file mode 100644 index 00000000..94e65383 Binary files /dev/null and b/ultrasonic/src/main/res/drawable-xhdpi/ic_subdirectory_up_light.png differ diff --git a/ultrasonic/src/main/res/drawable-xxhdpi/ic_create_new_folder_dark.png b/ultrasonic/src/main/res/drawable-xxhdpi/ic_create_new_folder_dark.png new file mode 100644 index 00000000..a7a92c6c Binary files /dev/null and b/ultrasonic/src/main/res/drawable-xxhdpi/ic_create_new_folder_dark.png differ diff --git a/ultrasonic/src/main/res/drawable-xxhdpi/ic_create_new_folder_light.png b/ultrasonic/src/main/res/drawable-xxhdpi/ic_create_new_folder_light.png new file mode 100644 index 00000000..38ae0196 Binary files /dev/null and b/ultrasonic/src/main/res/drawable-xxhdpi/ic_create_new_folder_light.png differ diff --git a/ultrasonic/src/main/res/drawable-xxhdpi/ic_folder_dark.png b/ultrasonic/src/main/res/drawable-xxhdpi/ic_folder_dark.png new file mode 100644 index 00000000..2e97cddf Binary files /dev/null and b/ultrasonic/src/main/res/drawable-xxhdpi/ic_folder_dark.png differ diff --git a/ultrasonic/src/main/res/drawable-xxhdpi/ic_folder_light.png b/ultrasonic/src/main/res/drawable-xxhdpi/ic_folder_light.png new file mode 100644 index 00000000..32efa066 Binary files /dev/null and b/ultrasonic/src/main/res/drawable-xxhdpi/ic_folder_light.png differ diff --git a/ultrasonic/src/main/res/drawable-xxhdpi/ic_sd_storage_dark.png b/ultrasonic/src/main/res/drawable-xxhdpi/ic_sd_storage_dark.png new file mode 100644 index 00000000..574e4302 Binary files /dev/null and b/ultrasonic/src/main/res/drawable-xxhdpi/ic_sd_storage_dark.png differ diff --git a/ultrasonic/src/main/res/drawable-xxhdpi/ic_sd_storage_light.png b/ultrasonic/src/main/res/drawable-xxhdpi/ic_sd_storage_light.png new file mode 100644 index 00000000..0682783c Binary files /dev/null and b/ultrasonic/src/main/res/drawable-xxhdpi/ic_sd_storage_light.png differ diff --git a/ultrasonic/src/main/res/drawable-xxhdpi/ic_subdirectory_up_dark.png b/ultrasonic/src/main/res/drawable-xxhdpi/ic_subdirectory_up_dark.png new file mode 100644 index 00000000..dec22e7c Binary files /dev/null and b/ultrasonic/src/main/res/drawable-xxhdpi/ic_subdirectory_up_dark.png differ diff --git a/ultrasonic/src/main/res/drawable-xxhdpi/ic_subdirectory_up_light.png b/ultrasonic/src/main/res/drawable-xxhdpi/ic_subdirectory_up_light.png new file mode 100644 index 00000000..fb70223c Binary files /dev/null and b/ultrasonic/src/main/res/drawable-xxhdpi/ic_subdirectory_up_light.png differ diff --git a/ultrasonic/src/main/res/layout/filepicker_dialog_create_folder.xml b/ultrasonic/src/main/res/layout/filepicker_dialog_create_folder.xml new file mode 100644 index 00000000..3737694e --- /dev/null +++ b/ultrasonic/src/main/res/layout/filepicker_dialog_create_folder.xml @@ -0,0 +1,21 @@ + + + + + + + + + \ No newline at end of file diff --git a/ultrasonic/src/main/res/layout/filepicker_dialog_main.xml b/ultrasonic/src/main/res/layout/filepicker_dialog_main.xml new file mode 100644 index 00000000..aed2057e --- /dev/null +++ b/ultrasonic/src/main/res/layout/filepicker_dialog_main.xml @@ -0,0 +1,43 @@ + + + + + + + +