Skip to content

Commit cd1e5fe

Browse files
Feat: File 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
1 parent 9ae6c9b commit cd1e5fe

File tree

5 files changed

+226
-4
lines changed

5 files changed

+226
-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: 215 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,215 @@
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+
42+
/**
43+
* Displays a list of external directories to select for the AnkiDroid Directory
44+
*
45+
* Improving discoverability of using a SD Card for the directory
46+
*
47+
* Also provides the ability to input a custom path
48+
*
49+
* @see ListPreferenceTrait - this preference can either be a List or an EditText
50+
*/
51+
class ExternalDirectorySelectionPreference(
52+
context: Context,
53+
attrs: AttributeSet?,
54+
) : ListPreference(context, attrs),
55+
ListPreferenceTrait {
56+
init {
57+
dialogLayoutResource = androidx.preference.R.layout.preference_dialog_edittext
58+
summaryProvider =
59+
SummaryProvider<ListPreference> { pref ->
60+
pref.value.takeUnless { it.isNullOrEmpty() } ?: context.getString(R.string.pref_directory_not_set)
61+
}
62+
}
63+
64+
// below are default values for the listEntries and listValue variables, they are set in makeDialogFragment()
65+
override var listEntries: List<ListPreferenceTrait.Entry> = emptyList()
66+
override var listValue: String = ""
67+
68+
/** Safely retrieves the default AnkiDroid directory, returning null on failure. */
69+
private val defaultAnkiDir: File?
70+
get() =
71+
try {
72+
CollectionHelper.getDefaultAnkiDroidDirectory(context)
73+
} catch (e: Exception) {
74+
Timber.w(e, "Could not access default AnkiDroid directory")
75+
null
76+
}
77+
78+
/** Builds the list of available directories for selection. */
79+
private fun loadDirectories(): List<ListPreferenceTrait.Entry> =
80+
buildList {
81+
val defaultDir = defaultAnkiDir
82+
val currentPath = value ?: defaultDir?.absolutePath ?: ""
83+
if (currentPath.isNotEmpty()) {
84+
add(createEntry(File(currentPath)))
85+
}
86+
if (defaultDir != null && defaultDir.absolutePath != currentPath) {
87+
add(createEntry(defaultDir))
88+
}
89+
val scannedEntries =
90+
getScannedDirectories()
91+
.filter {
92+
it.absolutePath != currentPath && it.absolutePath != defaultDir?.absolutePath
93+
}.map { createEntry(it) }
94+
addAll(scannedEntries)
95+
}
96+
97+
/**
98+
* Safely scans all external directories.
99+
* If one directory fails to scan, we log it and continue to the next one
100+
*/
101+
private fun getScannedDirectories(): List<File> {
102+
val roots =
103+
try {
104+
CollectionHelper.getAppSpecificExternalDirectories(context).filterNotNull()
105+
} catch (e: Exception) {
106+
Timber.w(e, "Critical error getting storage roots")
107+
return emptyList()
108+
}
109+
return roots
110+
.flatMap { rootDir ->
111+
try {
112+
findAnkiDroidSubDirectories(rootDir)
113+
} catch (e: Exception) {
114+
Timber.w(e, "Could not scan directory: ${rootDir.absolutePath}")
115+
emptyList()
116+
}
117+
}.distinct()
118+
}
119+
120+
// TODO: Possibly move loadDirectories() to a background thread if ANR occurs
121+
override fun makeDialogFragment(): DialogFragment {
122+
listEntries = loadDirectories()
123+
entries = listEntries.map { it.key }.toTypedArray()
124+
setEntryValues(listEntries.map { it.value as CharSequence }.toTypedArray())
125+
listValue = value ?: defaultAnkiDir?.absolutePath ?: ""
126+
setValue(listValue)
127+
return FullWidthListPreferenceDialogFragment()
128+
}
129+
130+
/** Creates a display entry. */
131+
private fun createEntry(file: File): ListPreferenceTrait.Entry {
132+
val path = file.absolutePath
133+
// Find the standard Android directory to split the path for display
134+
// Eg: "/storage/emulated/0"->Gray "/Android/data/com.ichi2.anki"->Normal
135+
val androidIndex = path.indexOf("/Android/")
136+
// If index is not found, then return the path as is
137+
if (androidIndex == -1) return ListPreferenceTrait.Entry(path, path)
138+
val displayString = "${path.take(androidIndex)}\n${path.substring(androidIndex)}"
139+
val spannable =
140+
SpannableString(displayString).apply {
141+
setSpan(ForegroundColorSpan(Color.GRAY), 0, androidIndex, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
142+
}
143+
return ListPreferenceTrait.Entry(spannable, path)
144+
}
145+
146+
companion object {
147+
// This is only a heuristic implementation
148+
private val ANKI_DIR_FILTER = FileFilter { it.isDirectory && it.name.startsWith("AnkiDroid") }
149+
150+
/** Finds subdirectories matching "AnkiDroid*" pattern within the given directory. */
151+
fun findAnkiDroidSubDirectories(f: File): List<File> = f.listFiles(ANKI_DIR_FILTER)?.toList() ?: emptyList()
152+
}
153+
}
154+
155+
/** A DialogFragment that allows custom path input if on a device before Android 11, or on a full release version. */
156+
class FullWidthListPreferenceDialogFragment : ListPreferenceDialogFragmentCompat() {
157+
override fun onPrepareDialogBuilder(builder: AlertDialog.Builder) {
158+
super.onPrepareDialogBuilder(builder)
159+
val isPlayStoreBuild = BuildConfig.FLAVOR == "play"
160+
val isScopedStorageEnforced = android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.R
161+
if (!isPlayStoreBuild || !isScopedStorageEnforced) {
162+
builder.setNeutralButton(R.string.pref_custom_path) { _, _ -> showCustomPathInput() }
163+
}
164+
}
165+
166+
private fun showCustomPathInput() {
167+
val context = requireContext()
168+
val pref = (preference as? ExternalDirectorySelectionPreference) ?: return
169+
AlertDialog
170+
.Builder(context)
171+
.show {
172+
setTitle(R.string.pref_enter_custom_path)
173+
setView(R.layout.dialog_generic_text_input)
174+
positiveButton(android.R.string.ok)
175+
negativeButton(android.R.string.cancel)
176+
}.input(
177+
prefill = pref.value ?: "",
178+
allowEmpty = false,
179+
) { dialog, text ->
180+
val newPath = text.toString().trim()
181+
val directory = File(newPath)
182+
when {
183+
!directory.exists() && !directory.mkdirs() -> {
184+
showThemedToast(
185+
context,
186+
context.getString(R.string.could_not_create_dir),
187+
true,
188+
)
189+
}
190+
!directory.canWrite() -> {
191+
showThemedToast(
192+
context,
193+
context.getString(R.string.pref_directory_not_writable),
194+
true,
195+
)
196+
}
197+
else -> {
198+
dialog.dismiss()
199+
if (pref.callChangeListener(newPath)) {
200+
pref.value = newPath
201+
pref.listValue = newPath
202+
}
203+
}
204+
}
205+
}
206+
}
207+
208+
override fun onStart() {
209+
super.onStart()
210+
dialog?.window?.setLayout(
211+
android.view.ViewGroup.LayoutParams.MATCH_PARENT,
212+
android.view.ViewGroup.LayoutParams.WRAP_CONTENT,
213+
)
214+
}
215+
}

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)