From fb49676afff1733fbe9909ecfb0b6fafa1b92be8 Mon Sep 17 00:00:00 2001 From: ShaanNarendran Date: Sat, 20 Dec 2025 06:34:54 +0530 Subject: [PATCH] Feat: Path Picker GUI This feature allows users to choose a file path that exists on their device or an external storage device. If the user is using a full release version, or a pre android 11 version then they have the option to edit their path to a custom one Co-authored-by: David Allison <62114487+david-allison@users.noreply.github.com> --- .../preferences/AdvancedSettingsFragment.kt | 5 +- .../ExternalDirectorySelectionPreference.kt | 227 ++++++++++++++++++ .../ichi2/preferences/ListPreferenceTrait.kt | 2 +- .../src/main/res/values/10-preferences.xml | 6 + .../src/main/res/xml/preferences_advanced.xml | 2 +- 5 files changed, 238 insertions(+), 4 deletions(-) create mode 100644 AnkiDroid/src/main/java/com/ichi2/preferences/ExternalDirectorySelectionPreference.kt diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/preferences/AdvancedSettingsFragment.kt b/AnkiDroid/src/main/java/com/ichi2/anki/preferences/AdvancedSettingsFragment.kt index 9f67e593af1f..389c2afbbf1f 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/preferences/AdvancedSettingsFragment.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/preferences/AdvancedSettingsFragment.kt @@ -34,6 +34,7 @@ import com.ichi2.anki.settings.Prefs import com.ichi2.anki.snackbar.showSnackbar import com.ichi2.anki.utils.openUrl import com.ichi2.compat.CompatHelper +import com.ichi2.preferences.ExternalDirectorySelectionPreference import com.ichi2.utils.show import timber.log.Timber import java.io.File @@ -48,7 +49,7 @@ class AdvancedSettingsFragment : SettingsFragment() { removeUnnecessaryAdvancedPrefs() // Check that input is valid before committing change in the collection path - requirePreference(CollectionHelper.PREF_COLLECTION_PATH).apply { + requirePreference(CollectionHelper.PREF_COLLECTION_PATH).apply { setOnPreferenceChangeListener { _, newValue: Any? -> val newPath = newValue as String try { @@ -67,7 +68,7 @@ class AdvancedSettingsFragment : SettingsFragment() { setTitle(R.string.dialog_collection_path_not_dir) setPositiveButton(R.string.dialog_ok) { _, _ -> } setNegativeButton(R.string.reset_custom_buttons) { _, _ -> - text = CollectionHelper.getDefaultAnkiDroidDirectory(requireContext()).absolutePath + value = CollectionHelper.getDefaultAnkiDroidDirectory(requireContext()).absolutePath } } false diff --git a/AnkiDroid/src/main/java/com/ichi2/preferences/ExternalDirectorySelectionPreference.kt b/AnkiDroid/src/main/java/com/ichi2/preferences/ExternalDirectorySelectionPreference.kt new file mode 100644 index 000000000000..63c3821292d4 --- /dev/null +++ b/AnkiDroid/src/main/java/com/ichi2/preferences/ExternalDirectorySelectionPreference.kt @@ -0,0 +1,227 @@ +/* + * Copyright (c) 2024 David Allison + * Copyright (c) 2026 Shaan Narendran + * + * This program is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License as published by the Free Software + * Foundation; either version 3 of the License, or (at your option) any later + * version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT ANY + * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A + * PARTICULAR PURPOSE. See the GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License along with + * this program. If not, see . + */ + +package com.ichi2.preferences + +import android.content.Context +import android.graphics.Color +import android.os.Environment +import android.text.Spannable +import android.text.SpannableString +import android.text.style.ForegroundColorSpan +import android.util.AttributeSet +import androidx.appcompat.app.AlertDialog +import androidx.fragment.app.DialogFragment +import androidx.preference.ListPreference +import androidx.preference.ListPreferenceDialogFragmentCompat +import com.ichi2.anki.CollectionHelper +import com.ichi2.anki.R +import com.ichi2.anki.showThemedToast +import com.ichi2.utils.input +import com.ichi2.utils.negativeButton +import com.ichi2.utils.positiveButton +import com.ichi2.utils.show +import timber.log.Timber +import java.io.File +import java.nio.file.Files +import java.nio.file.Paths + +/** + * Displays a list of external directories to select for the AnkiDroid Directory + * + * Improving discoverability of using a SD Card for the directory + * + * Also provides the ability to input a custom path + * + * @see ListPreferenceTrait - this preference can either be a List or an EditText + */ +class ExternalDirectorySelectionPreference( + context: Context, + attrs: AttributeSet?, +) : ListPreference(context, attrs), + ListPreferenceTrait { + init { + summaryProvider = + SummaryProvider { pref -> + pref.value.takeUnless { it.isNullOrEmpty() } ?: context.getString(R.string.pref_directory_not_set) + } + } + + // below are default values for the listEntries and listValue variables, they are set in makeDialogFragment() + override var listEntries: List = emptyList() + override var listValue: String = "" + + /** Safely retrieves the default AnkiDroid directory, returning null on failure. */ + private val defaultAnkiDir: File? + get() = + try { + CollectionHelper.getDefaultAnkiDroidDirectory(context) + } catch (e: Exception) { + Timber.w(e, "Could not access default AnkiDroid directory") + null + } + + /** Builds the list of available directories for selection. */ + private fun loadDirectories(): List = + buildList { + if (value?.isNotEmpty() == true) { + add(File(value)) + } + defaultAnkiDir?.let { add(it) } + addAll(getScannedDirectories()) + }.mapNotNull { runCatching { it.absolutePath }.getOrNull() } + .distinct() + .map(::absolutePathToDisplayEntry) + + private fun isValidAnkiDir(dir: File): Boolean { + if (!dir.isDirectory || dir.name.startsWith(".")) return false + if (IGNORED_DIRECTORIES.contains(dir.name.lowercase())) return false + if (dir.name.startsWith("AnkiDroid", ignoreCase = true)) return true + val contents = dir.list() ?: return false + return contents.any { it == "collection.anki2" } + } + + /** + * Safely scans all external directories. + * If one directory fails to scan, we log it and continue to the next one + */ + private fun getScannedDirectories(): List { + // Get all possible storage roots + val roots = mutableListOf() + try { + val appDirs = context.getExternalFilesDirs(null) ?: emptyArray() + for (dir in appDirs) { + if (dir == null) continue + val path = dir.absolutePath + val androidIndex = path.indexOf("/Android/") + if (androidIndex != -1) { + roots.add(File(path.take(androidIndex))) + } + } + } catch (e: Exception) { + Timber.w(e, "Critical error getting storage roots") + return emptyList() + } + val candidates = mutableListOf() + for (root in roots) { + // Find the subfolders of each root and their respective valid directories (containing collection.anki2 + val subFolders = + try { + root.listFiles { f -> f.isDirectory && !f.name.startsWith(".") } + } catch (e: Exception) { + Timber.w(e, "Could not list files in $root") + null + } + val validChildren = subFolders?.filter { isValidAnkiDir(it) } ?: emptyList() + if (validChildren.isNotEmpty()) { + candidates.addAll(validChildren) + } else { + // If no anki directories are found, we can list this as it is likely an SD card + candidates.add(root) + } + } + return candidates.distinct() + } + + // TODO: Possibly move loadDirectories() to a background thread if ANR occurs + override fun makeDialogFragment(): DialogFragment { + listEntries = loadDirectories() + entries = listEntries.map { it.key }.toTypedArray() + setEntryValues(listEntries.map { it.value as CharSequence }.toTypedArray()) + listValue = value ?: defaultAnkiDir?.absolutePath ?: "" + setValue(listValue) + return FullWidthListPreferenceDialogFragment() + } + + /** Creates a display entry. */ + private fun absolutePathToDisplayEntry(path: String): ListPreferenceTrait.Entry { + // Find the standard Android directory to split the path for display + // Eg: "/storage/emulated/0"->Gray "/Android/data/com.ichi2.anki"->Normal + val androidIndex = path.indexOf("/Android/") + // If index is not found, then return the path as is + if (androidIndex == -1) return ListPreferenceTrait.Entry(path, path) + val displayString = "${path.take(androidIndex)}\n${path.substring(androidIndex)}" + val spannable = + SpannableString(displayString).apply { + setSpan(ForegroundColorSpan(Color.GRAY), 0, androidIndex, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE) + } + return ListPreferenceTrait.Entry(spannable, path) + } + + companion object { + private val IGNORED_DIRECTORIES = setOf("collection.media", "backups", "cache", "code_cache") + } +} + +/** A DialogFragment that allows custom path input if on a device before Android 11, or on a full release version. */ +class FullWidthListPreferenceDialogFragment : ListPreferenceDialogFragmentCompat() { + override fun onPrepareDialogBuilder(builder: AlertDialog.Builder) { + super.onPrepareDialogBuilder(builder) + builder.setNeutralButton(R.string.pref_custom_path) { _, _ -> showCustomPathInput() } + } + + private fun showCustomPathInput() { + val context = requireContext() + val pref = (preference as? ExternalDirectorySelectionPreference) ?: return + AlertDialog + .Builder(context) + .show { + setTitle(R.string.pref_enter_custom_path) + setView(R.layout.dialog_generic_text_input) + positiveButton(android.R.string.ok) + negativeButton(android.R.string.cancel) + }.input( + prefill = pref.value ?: "", + allowEmpty = false, + ) { dialog, text -> + try { + val newPath = text.toString().trim() + val pathObj = Paths.get(newPath) + Files.createDirectories(pathObj) + if (!Files.isWritable(pathObj)) { + showThemedToast( + context, + context.getString(R.string.pref_directory_not_writable), + true, + ) + return@input + } + dialog.dismiss() + if (pref.callChangeListener(newPath)) { + pref.value = newPath + pref.listValue = newPath + } + } catch (e: Exception) { + Timber.w(e, "Failed to set custom path") + AlertDialog + .Builder(context) + .setTitle(context.getString(R.string.could_not_create_dir, text.toString())) + .setMessage(android.util.Log.getStackTraceString(e)) + .setPositiveButton(android.R.string.ok, null) + .show() + } + } + } + + override fun onStart() { + super.onStart() + dialog?.window?.setLayout( + android.view.ViewGroup.LayoutParams.MATCH_PARENT, + android.view.ViewGroup.LayoutParams.WRAP_CONTENT, + ) + } +} diff --git a/AnkiDroid/src/main/java/com/ichi2/preferences/ListPreferenceTrait.kt b/AnkiDroid/src/main/java/com/ichi2/preferences/ListPreferenceTrait.kt index f66cf43302e4..6b429ba6adb7 100644 --- a/AnkiDroid/src/main/java/com/ichi2/preferences/ListPreferenceTrait.kt +++ b/AnkiDroid/src/main/java/com/ichi2/preferences/ListPreferenceTrait.kt @@ -68,7 +68,7 @@ interface ListPreferenceTrait : DialogFragmentProvider { var listValue: String data class Entry( - val key: String, + val key: CharSequence, val value: String, ) diff --git a/AnkiDroid/src/main/res/values/10-preferences.xml b/AnkiDroid/src/main/res/values/10-preferences.xml index b9f705d8e286..3bf4652bf82e 100644 --- a/AnkiDroid/src/main/res/values/10-preferences.xml +++ b/AnkiDroid/src/main/res/values/10-preferences.xml @@ -477,4 +477,10 @@ this formatter is used if the bind only applies to the answer">A: %s Open settings + + + Not set + Custom path + Enter custom path + Directory is not writable diff --git a/AnkiDroid/src/main/res/xml/preferences_advanced.xml b/AnkiDroid/src/main/res/xml/preferences_advanced.xml index eb35a4a64a3b..ff99ef8620c2 100644 --- a/AnkiDroid/src/main/res/xml/preferences_advanced.xml +++ b/AnkiDroid/src/main/res/xml/preferences_advanced.xml @@ -25,7 +25,7 @@ xmlns:app="http://schemas.android.com/apk/res-auto" android:title="@string/pref_cat_advanced" android:key="@string/pref_advanced_screen_key"> -