Skip to content

Commit 1218887

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 1218887

File tree

5 files changed

+222
-4
lines changed

5 files changed

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

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)