Skip to content

Commit 7eb9ad7

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 1512be9 commit 7eb9ad7

File tree

6 files changed

+216
-4
lines changed

6 files changed

+216
-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: 190 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,190 @@
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+
override var listEntries: List<ListPreferenceTrait.Entry> = emptyList()
65+
override var listValue: String = ""
66+
67+
/** Safely retrieves the default AnkiDroid directory, returning null on failure. */
68+
private val defaultAnkiDir: File?
69+
get() =
70+
try {
71+
CollectionHelper.getDefaultAnkiDroidDirectory(context)
72+
} catch (e: Exception) {
73+
Timber.w(e, "Could not access default AnkiDroid directory")
74+
null
75+
}
76+
77+
/** Builds the list of available directories for selection. */
78+
private fun loadDirectories(): List<ListPreferenceTrait.Entry> =
79+
buildList {
80+
val defaultDir = defaultAnkiDir
81+
defaultDir?.let { add(createEntry(it)) }
82+
try {
83+
CollectionHelper
84+
.getAppSpecificExternalDirectories(context)
85+
.filterNotNull()
86+
.flatMap { findAnkiDroidSubDirectories(it) }
87+
.distinct()
88+
.filter { it.absolutePath != defaultDir?.absolutePath }
89+
.forEach { add(createEntry(it)) }
90+
} catch (e: Exception) {
91+
Timber.e(e, "Error scanning for external directories")
92+
}
93+
val currentPath = value ?: defaultDir?.absolutePath ?: ""
94+
if (currentPath.isNotEmpty() && none { it.value == currentPath }) {
95+
add(0, createEntry(File(currentPath)))
96+
}
97+
}
98+
99+
// TODO: Possibly move loadDirectories() to a background thread if ANR occurs
100+
override fun makeDialogFragment(): DialogFragment {
101+
listEntries = loadDirectories()
102+
entries = listEntries.map { it.key }.toTypedArray()
103+
setEntryValues(listEntries.map { it.value as CharSequence }.toTypedArray())
104+
listValue = value ?: defaultAnkiDir?.absolutePath ?: ""
105+
setValue(listValue)
106+
return FullWidthListPreferenceDialogFragment()
107+
}
108+
109+
/** Creates a display entry. */
110+
private fun createEntry(file: File): ListPreferenceTrait.Entry {
111+
val path = file.absolutePath
112+
val androidIndex = path.indexOf("/Android/")
113+
if (androidIndex == -1) return ListPreferenceTrait.Entry(path, path)
114+
val displayString = "${path.take(androidIndex)}\n${path.substring(androidIndex)}"
115+
val spannable =
116+
SpannableString(displayString).apply {
117+
setSpan(ForegroundColorSpan(Color.GRAY), 0, androidIndex, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
118+
}
119+
return ListPreferenceTrait.Entry(spannable, path)
120+
}
121+
122+
companion object {
123+
private val ANKI_DIR_FILTER = FileFilter { it.isDirectory && it.name.startsWith("AnkiDroid") }
124+
125+
/** Finds subdirectories matching "AnkiDroid*" pattern within the given directory. */
126+
fun findAnkiDroidSubDirectories(f: File): List<File> = f.listFiles(ANKI_DIR_FILTER)?.toList() ?: emptyList()
127+
}
128+
}
129+
130+
/** A DialogFragment that allows custom path input if on a device before Android 11, or on a full release version. */
131+
class FullWidthListPreferenceDialogFragment : ListPreferenceDialogFragmentCompat() {
132+
override fun onPrepareDialogBuilder(builder: AlertDialog.Builder) {
133+
super.onPrepareDialogBuilder(builder)
134+
val isPlayStoreBuild = BuildConfig.FLAVOR == "play"
135+
val isScopedStorageEnforced = android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.R
136+
if (!isPlayStoreBuild || !isScopedStorageEnforced) {
137+
builder.setNeutralButton(R.string.pref_custom_path) { _, _ -> showCustomPathInput() }
138+
}
139+
}
140+
141+
private fun showCustomPathInput() {
142+
val context = requireContext()
143+
val pref = (preference as? ExternalDirectorySelectionPreference) ?: return
144+
AlertDialog
145+
.Builder(context)
146+
.show {
147+
setTitle(R.string.pref_enter_custom_path)
148+
setView(R.layout.dialog_generic_text_input)
149+
positiveButton(android.R.string.ok)
150+
negativeButton(android.R.string.cancel)
151+
}.input(
152+
prefill = pref.value ?: "",
153+
allowEmpty = false,
154+
) { dialog, text ->
155+
val newPath = text.toString().trim()
156+
val directory = File(newPath)
157+
when {
158+
!directory.exists() && !directory.mkdirs() -> {
159+
showThemedToast(
160+
context,
161+
context.getString(R.string.pref_cannot_create_directory),
162+
true,
163+
)
164+
}
165+
!directory.canWrite() -> {
166+
showThemedToast(
167+
context,
168+
context.getString(R.string.pref_directory_not_writable),
169+
true,
170+
)
171+
}
172+
else -> {
173+
dialog.dismiss()
174+
if (pref.callChangeListener(newPath)) {
175+
pref.value = newPath
176+
pref.listValue = newPath
177+
}
178+
}
179+
}
180+
}
181+
}
182+
183+
override fun onStart() {
184+
super.onStart()
185+
dialog?.window?.setLayout(
186+
android.view.ViewGroup.LayoutParams.MATCH_PARENT,
187+
android.view.ViewGroup.LayoutParams.WRAP_CONTENT,
188+
)
189+
}
190+
}

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: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -473,4 +473,11 @@ 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_cannot_create_directory">Cannot create directory</string>
482+
<string name="pref_directory_not_writable">Directory is not writable</string>
476483
</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:app1="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"

tools/release.sh

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,20 @@ if [ "$PUBLIC" = "public" ] && ! [ -f ../ankidroiddocs/changelog.asc ]; then
3030
exit 1
3131
fi
3232

33+
echo "Checking for items in the merge queue..."
34+
QUEUE_COUNT=$(gh search prs --repo ankidroid/Anki-Android "is:queued" --json number --jq 'length')
35+
36+
if [ -z "$QUEUE_COUNT" ]; then
37+
echo "Warning: Could not check the merge queue"
38+
echo "Proceeding, but be careful!"
39+
elif [ "$QUEUE_COUNT" -gt 0 ]; then
40+
echo "Abort: There are currently $QUEUE_COUNT PRs in the merge queue"
41+
echo "Please wait for the queue to clear before attempting a release"
42+
exit 1
43+
else
44+
echo "Merge queue is clear ($QUEUE_COUNT items). Proceeding..."
45+
fi
46+
3347
# Define the location of the manifest file
3448
SRC_DIR="./AnkiDroid"
3549
GRADLEFILE="$SRC_DIR/build.gradle"

0 commit comments

Comments
 (0)