Skip to content

Commit 0c30d9b

Browse files
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>
1 parent 691f542 commit 0c30d9b

File tree

5 files changed

+228
-4
lines changed

5 files changed

+228
-4
lines changed

AnkiDroid/src/main/java/com/ichi2/anki/preferences/AdvancedSettingsFragment.kt

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ import com.ichi2.anki.settings.Prefs
3434
import com.ichi2.anki.snackbar.showSnackbar
3535
import com.ichi2.anki.utils.openUrl
3636
import com.ichi2.compat.CompatHelper
37+
import com.ichi2.preferences.ExternalDirectorySelectionPreference
3738
import com.ichi2.utils.show
3839
import timber.log.Timber
3940
import java.io.File
@@ -48,7 +49,7 @@ class AdvancedSettingsFragment : SettingsFragment() {
4849
removeUnnecessaryAdvancedPrefs()
4950

5051
// Check that input is valid before committing change in the collection path
51-
requirePreference<EditTextPreference>(CollectionHelper.PREF_COLLECTION_PATH).apply {
52+
requirePreference<ExternalDirectorySelectionPreference>(CollectionHelper.PREF_COLLECTION_PATH).apply {
5253
setOnPreferenceChangeListener { _, newValue: Any? ->
5354
val newPath = newValue as String
5455
try {
@@ -67,7 +68,7 @@ class AdvancedSettingsFragment : SettingsFragment() {
6768
setTitle(R.string.dialog_collection_path_not_dir)
6869
setPositiveButton(R.string.dialog_ok) { _, _ -> }
6970
setNegativeButton(R.string.reset_custom_buttons) { _, _ ->
70-
text = CollectionHelper.getDefaultAnkiDroidDirectory(requireContext()).absolutePath
71+
value = CollectionHelper.getDefaultAnkiDroidDirectory(requireContext()).absolutePath
7172
}
7273
}
7374
false
Lines changed: 217 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,217 @@
1+
/*
2+
* Copyright (c) 2024 David Allison <davidallisongithub@gmail.com>
3+
* Copyright (c) 2026 Shaan Narendran <shaannaren06@gmail.com>
4+
*
5+
* This program is free software; you can redistribute it and/or modify it under
6+
* the terms of the GNU General Public License as published by the Free Software
7+
* Foundation; either version 3 of the License, or (at your option) any later
8+
* version.
9+
*
10+
* This program is distributed in the hope that it will be useful, but WITHOUT ANY
11+
* WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A
12+
* PARTICULAR PURPOSE. See the GNU General Public License for more details.
13+
*
14+
* You should have received a copy of the GNU General Public License along with
15+
* this program. If not, see <http://www.gnu.org/licenses/>.
16+
*/
17+
18+
package com.ichi2.preferences
19+
20+
import android.content.Context
21+
import android.graphics.Color
22+
import android.text.Spannable
23+
import android.text.SpannableString
24+
import android.text.style.ForegroundColorSpan
25+
import android.util.AttributeSet
26+
import androidx.appcompat.app.AlertDialog
27+
import androidx.fragment.app.DialogFragment
28+
import androidx.preference.ListPreference
29+
import androidx.preference.ListPreferenceDialogFragmentCompat
30+
import com.ichi2.anki.BuildConfig
31+
import com.ichi2.anki.CollectionHelper
32+
import com.ichi2.anki.R
33+
import com.ichi2.anki.showThemedToast
34+
import com.ichi2.utils.input
35+
import com.ichi2.utils.negativeButton
36+
import com.ichi2.utils.positiveButton
37+
import com.ichi2.utils.show
38+
import timber.log.Timber
39+
import java.io.File
40+
import java.io.FileFilter
41+
import java.nio.file.Files
42+
import java.nio.file.Paths
43+
44+
/**
45+
* Displays a list of external directories to select for the AnkiDroid Directory
46+
*
47+
* Improving discoverability of using a SD Card for the directory
48+
*
49+
* Also provides the ability to input a custom path
50+
*
51+
* @see ListPreferenceTrait - this preference can either be a List or an EditText
52+
*/
53+
class ExternalDirectorySelectionPreference(
54+
context: Context,
55+
attrs: AttributeSet?,
56+
) : ListPreference(context, attrs),
57+
ListPreferenceTrait {
58+
init {
59+
summaryProvider =
60+
SummaryProvider<ListPreference> { pref ->
61+
pref.value.takeUnless { it.isNullOrEmpty() } ?: context.getString(R.string.pref_directory_not_set)
62+
}
63+
}
64+
65+
// below are default values for the listEntries and listValue variables, they are set in makeDialogFragment()
66+
override var listEntries: List<ListPreferenceTrait.Entry> = emptyList()
67+
override var listValue: String = ""
68+
69+
/** Safely retrieves the default AnkiDroid directory, returning null on failure. */
70+
private val defaultAnkiDir: File?
71+
get() =
72+
try {
73+
CollectionHelper.getDefaultAnkiDroidDirectory(context)
74+
} catch (e: Exception) {
75+
Timber.w(e, "Could not access default AnkiDroid directory")
76+
null
77+
}
78+
79+
/** Builds the list of available directories for selection. */
80+
private fun loadDirectories(): List<ListPreferenceTrait.Entry> =
81+
buildList {
82+
if (value?.isNotEmpty() == true) {
83+
add(File(value))
84+
}
85+
defaultAnkiDir?.let { add(it) }
86+
addAll(getScannedDirectories())
87+
}.mapNotNull { runCatching { it.absolutePath }.getOrNull() }
88+
.distinct()
89+
.map(::absolutePathToDisplayEntry)
90+
91+
/**
92+
* Safely scans all external directories.
93+
* If one directory fails to scan, we log it and continue to the next one
94+
*/
95+
private fun getScannedDirectories(): List<File> {
96+
val roots =
97+
try {
98+
CollectionHelper.getAppSpecificExternalDirectories(context).filterNotNull()
99+
} catch (e: Exception) {
100+
Timber.w(e, "Critical error getting storage roots")
101+
return emptyList()
102+
}
103+
val children =
104+
roots
105+
.flatMap { rootDir ->
106+
try {
107+
findAnkiDroidSubDirectories(rootDir)
108+
} catch (e: Exception) {
109+
Timber.w(e, "Could not scan directory: $rootDir")
110+
emptyList()
111+
}
112+
}
113+
return roots +
114+
children
115+
.flatMap { rootDir ->
116+
try {
117+
findAnkiDroidSubDirectories(rootDir)
118+
} catch (e: Exception) {
119+
Timber.w(e, "Could not scan directory: $rootDir")
120+
emptyList()
121+
}
122+
}.distinct()
123+
}
124+
125+
// TODO: Possibly move loadDirectories() to a background thread if ANR occurs
126+
override fun makeDialogFragment(): DialogFragment {
127+
listEntries = loadDirectories()
128+
entries = listEntries.map { it.key }.toTypedArray()
129+
setEntryValues(listEntries.map { it.value as CharSequence }.toTypedArray())
130+
listValue = value ?: defaultAnkiDir?.absolutePath ?: ""
131+
setValue(listValue)
132+
return FullWidthListPreferenceDialogFragment()
133+
}
134+
135+
/** Creates a display entry. */
136+
private fun absolutePathToDisplayEntry(path: String): ListPreferenceTrait.Entry {
137+
// Find the standard Android directory to split the path for display
138+
// Eg: "/storage/emulated/0"->Gray "/Android/data/com.ichi2.anki"->Normal
139+
val androidIndex = path.indexOf("/Android/")
140+
// If index is not found, then return the path as is
141+
if (androidIndex == -1) return ListPreferenceTrait.Entry(path, path)
142+
val displayString = "${path.take(androidIndex)}\n${path.substring(androidIndex)}"
143+
val spannable =
144+
SpannableString(displayString).apply {
145+
setSpan(ForegroundColorSpan(Color.GRAY), 0, androidIndex, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
146+
}
147+
return ListPreferenceTrait.Entry(spannable, path)
148+
}
149+
150+
companion object {
151+
private val ANKI_DIR_FILTER = FileFilter { it.isDirectory }
152+
153+
fun findAnkiDroidSubDirectories(f: File): List<File> = f.listFiles(ANKI_DIR_FILTER)?.toList() ?: emptyList()
154+
}
155+
}
156+
157+
/** A DialogFragment that allows custom path input if on a device before Android 11, or on a full release version. */
158+
class FullWidthListPreferenceDialogFragment : ListPreferenceDialogFragmentCompat() {
159+
override fun onPrepareDialogBuilder(builder: AlertDialog.Builder) {
160+
super.onPrepareDialogBuilder(builder)
161+
val isPlayStoreBuild = BuildConfig.FLAVOR == "play"
162+
val isScopedStorageEnforced = android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.R
163+
if (!isPlayStoreBuild || !isScopedStorageEnforced) {
164+
builder.setNeutralButton(R.string.pref_custom_path) { _, _ -> showCustomPathInput() }
165+
}
166+
}
167+
168+
private fun showCustomPathInput() {
169+
val context = requireContext()
170+
val pref = (preference as? ExternalDirectorySelectionPreference) ?: return
171+
AlertDialog
172+
.Builder(context)
173+
.show {
174+
setTitle(R.string.pref_enter_custom_path)
175+
setView(R.layout.dialog_generic_text_input)
176+
positiveButton(android.R.string.ok)
177+
negativeButton(android.R.string.cancel)
178+
}.input(
179+
prefill = pref.value ?: "",
180+
allowEmpty = false,
181+
) { dialog, text ->
182+
try {
183+
val newPath = text.toString().trim()
184+
val pathObj = Paths.get(newPath)
185+
Files.createDirectories(pathObj)
186+
if (!Files.isWritable(pathObj)) {
187+
showThemedToast(
188+
context,
189+
context.getString(R.string.pref_directory_not_writable),
190+
true,
191+
)
192+
return@input
193+
}
194+
dialog.dismiss()
195+
if (pref.callChangeListener(newPath)) {
196+
pref.value = newPath
197+
pref.listValue = newPath
198+
}
199+
} catch (e: Exception) {
200+
Timber.w(e, "Failed to set custom path")
201+
showThemedToast(
202+
context,
203+
context.getString(R.string.could_not_create_dir) + "\n" + android.util.Log.getStackTraceString(e),
204+
true,
205+
)
206+
}
207+
}
208+
}
209+
210+
override fun onStart() {
211+
super.onStart()
212+
dialog?.window?.setLayout(
213+
android.view.ViewGroup.LayoutParams.MATCH_PARENT,
214+
android.view.ViewGroup.LayoutParams.WRAP_CONTENT,
215+
)
216+
}
217+
}

AnkiDroid/src/main/java/com/ichi2/preferences/ListPreferenceTrait.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,7 @@ interface ListPreferenceTrait : DialogFragmentProvider {
6868
var listValue: String
6969

7070
data class Entry(
71-
val key: String,
71+
val key: CharSequence,
7272
val value: String,
7373
)
7474

AnkiDroid/src/main/res/values/10-preferences.xml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -473,4 +473,10 @@ this formatter is used if the bind only applies to the answer">A: %s</string>
473473

474474
<!--Keyboard shortcuts dialog-->
475475
<string name="open_settings" comment="Description of the shortcut that opens the app settings">Open settings</string>
476+
477+
<!-- External Directory Selection Preference -->
478+
<string name="pref_directory_not_set">Not set</string>
479+
<string name="pref_custom_path" maxLength="41">Custom path</string>
480+
<string name="pref_enter_custom_path" maxLength="41">Enter custom path</string>
481+
<string name="pref_directory_not_writable">Directory is not writable</string>
476482
</resources>

AnkiDroid/src/main/res/xml/preferences_advanced.xml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@
2525
xmlns:app="http://schemas.android.com/apk/res-auto"
2626
android:title="@string/pref_cat_advanced"
2727
android:key="@string/pref_advanced_screen_key">
28-
<EditTextPreference
28+
<com.ichi2.preferences.ExternalDirectorySelectionPreference
2929
android:defaultValue="/sdcard/AnkiDroid"
3030
android:key="@string/pref_ankidroid_directory_key"
3131
android:title="@string/col_path"

0 commit comments

Comments
 (0)