Feat: Add path picker GUI for AnkiDroid Directory setting#19855
Feat: Add path picker GUI for AnkiDroid Directory setting#19855ShaanNarendran wants to merge 1 commit into
Conversation
|
@david-allison I did apply your patch for this, but I wasn't sure if you were planning on making a pr for it since I didn't really do much so if you are then I will close this. Otherwise, if there's more changes to be made UI wise, I'd be happy to continue working on it |
|
It's been a long while, thanks!!
I don't recall my patch, but I wasn't planning on submitting it any time soon, thanks for seeing it through!! |
|
Will resolve all the changes, I thought I had reacted sorry TT |
a8d65c5 to
7957d5d
Compare
|
The new UI look for the changes I just made are: While using android 10 (i.e. no scoped storage) While using android 15 (playDebug) While using android 15 (fullDebug) Note: it looks truncated but you can scroll down there, as seen in the videos |
|
There should be enough space on the screen that it doesn't need to be truncated |
Do you think it would be better to reduce the existing text like say shorten it by removing "/storage/emulated/0" or should I resize the dialog altogether, the latter will have a lot of changes since the only method I've found is to make a subclass of ListPreferenceTrait and use setlayout there to change the size according to the screen |
7957d5d to
bb41f23
Compare
|
Screen_recording_20251221_044059.webm |
|
@david-allison just a reminder since the needs author reply tag wasn't removed ^^ |
|
Still on my to-do list, I'll get to it shortly! |
david-allison
left a comment
There was a problem hiding this comment.
- Select Custom Path
/storage/emulated/0/Android/data/com.ichi2.anki.debug/files/AnkiDroi
- Open Dialog Again
- 🪲 Dialog does not show the current path as an option
- 🪲 Selecting the top option does not apply the provided path
I would move the 'custom path' to a button at the bottom of the screen, with a very brief name. This means it won't be truncated at the bottom of the list if there's a number of entries
bb41f23 to
ab78a9d
Compare
|
Screen_recording_20251222_221717.webm Edit: video seems to be buffering, hope its just on my end |
david-allison
left a comment
There was a problem hiding this comment.
My main concern is that the logic to switch between EditText and ListPreference is complicated.
Could you look into alternate solutions which require fewer variables/state?
|
I'll force push with the changes soon since there's a lot to be done I think, thank you for such a comprehensive review! |
|
just had a question regarding the file picker ui issue im doing. Right now, I have loadDirectories() running on the main thread and I had asked gemini to try and spot any issues and it said that having loadDirectories() on the main thread can cause ANR with devices having an older SD card and it's better to put it in the background (to me this makes sense since if the sd card can't load all the directories fast enough, the main thread is going to take too long and ANR occurs). I tried looking this up but wasn't able to find anything concrete, so I wanted to know if it's an actual issue and if its a better practice to throw it in the background? Flagging the above for discussion, I'll leave this as a TODO in the code |
ab78a9d to
cb5b6c0
Compare
There was a problem hiding this comment.
This works great on my emulator!
I believe there are still issues with the code to get storage roots.
I wasn't able to get a test SD card working (Android Studio has now disabled the functionality)
but the path should be storage/XXXX-XXXX.
Here is an untested patch which I believe is correct: https://github.com/ankidroid/Anki-Android/wiki/Development-Guide#applying-a-patch
Index: AnkiDroid/src/main/java/com/ichi2/anki/CrashReportService.kt
IDEA additional info:
Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP
<+>UTF-8
===================================================================
diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/CrashReportService.kt b/AnkiDroid/src/main/java/com/ichi2/anki/CrashReportService.kt
--- a/AnkiDroid/src/main/java/com/ichi2/anki/CrashReportService.kt (revision fb49676afff1733fbe9909ecfb0b6fafa1b92be8)
+++ b/AnkiDroid/src/main/java/com/ichi2/anki/CrashReportService.kt (date 1772985047611)
@@ -417,3 +417,34 @@
if (e is Error) throw e
Result.failure(e)
}
+
+/**
+ * Runs the provided block, catching [Exception], logging it and reporting it to [CrashReportService]
+ *
+ * **Example**
+ * ```
+ * runCatchingWithReport("callingMethod", onlyIfSilent = true) {
+ * doSomethingRisky()
+ * }
+ * ```
+ *
+ * **Note**: This differs from [runCatching] - `Error` is thrown
+ *
+ * @param origin Data logged to Timber
+ * @param block Code to execute
+ *
+ * @throws Error If raised, this will be reported and rethrown
+ *
+ * @return A Result containing either the successful result of [block] or the [Exception] thrown
+ */
+fun <T> runCatchingWithLog(
+ origin: String? = null,
+ block: () -> T,
+): Result<T> =
+ try {
+ Result.success(block())
+ } catch (e: Throwable) {
+ Timber.w(e, origin)
+ if (e is Error) throw e
+ Result.failure(e)
+ }
Index: AnkiDroid/src/main/java/com/ichi2/preferences/ExternalDirectorySelectionPreference.kt
IDEA additional info:
Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP
<+>UTF-8
===================================================================
diff --git a/AnkiDroid/src/main/java/com/ichi2/preferences/ExternalDirectorySelectionPreference.kt b/AnkiDroid/src/main/java/com/ichi2/preferences/ExternalDirectorySelectionPreference.kt
--- a/AnkiDroid/src/main/java/com/ichi2/preferences/ExternalDirectorySelectionPreference.kt (revision fb49676afff1733fbe9909ecfb0b6fafa1b92be8)
+++ b/AnkiDroid/src/main/java/com/ichi2/preferences/ExternalDirectorySelectionPreference.kt (date 1772985438962)
@@ -19,7 +19,6 @@
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
@@ -30,6 +29,7 @@
import androidx.preference.ListPreferenceDialogFragmentCompat
import com.ichi2.anki.CollectionHelper
import com.ichi2.anki.R
+import com.ichi2.anki.runCatchingWithLog
import com.ichi2.anki.showThemedToast
import com.ichi2.utils.input
import com.ichi2.utils.negativeButton
@@ -88,11 +88,16 @@
.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" }
+ try {
+ 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" }
+ } catch (e: Exception) {
+ Timber.w(e, "Could not access directory")
+ return false
+ }
}
/**
@@ -100,41 +105,28 @@
* If one directory fails to scan, we log it and continue to the next one
*/
private fun getScannedDirectories(): List<File> {
- // Get all possible storage roots
- val roots = mutableListOf<File>()
- 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)))
- }
+ val storageRoots = runCatchingWithLog("get storage roots") {
+ context.getExternalFilesDirs(null)
}
- } catch (e: Exception) {
- Timber.w(e, "Critical error getting storage roots")
- return emptyList()
+ .getOrNull()
+ .orEmpty()
+ .filterNotNull()
+
+ fun File.getValidAnkiSubfolders(): List<File>? {
+ val subFolders = runCatchingWithLog("Could not list files") {
+ listFiles()
+ }.getOrNull() ?: return null
+
+ return subFolders.filter { isValidAnkiDir(it) }.ifEmpty { null }
}
- val candidates = mutableListOf<File>()
- 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 {
+
+ return storageRoots.flatMap { root ->
+ when (val ankiFolders = root.getValidAnkiSubfolders()) {
// If no anki directories are found, we can list this as it is likely an SD card
- candidates.add(root)
+ null -> listOf(root)
+ else -> ankiFolders
}
- }
- return candidates.distinct()
+ }.distinct()
}
// TODO: Possibly move loadDirectories() to a background thread if ANR occursIt would be really useful to future implementers if you added a singular test in androidTest
Just with the scaffolding code to set up, display the dialog, and see the list results.
fb49676 to
662f2e3
Compare
PatchIndex: AnkiDroid/src/androidTest/java/com/ichi2/anki/preferences/ExternalDirectorySelectionPreferenceTest.kt
IDEA additional info:
Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP
<+>UTF-8
===================================================================
diff --git a/AnkiDroid/src/androidTest/java/com/ichi2/anki/preferences/ExternalDirectorySelectionPreferenceTest.kt b/AnkiDroid/src/androidTest/java/com/ichi2/anki/preferences/ExternalDirectorySelectionPreferenceTest.kt
new file mode 100644
--- /dev/null (date 1773519838146)
+++ b/AnkiDroid/src/androidTest/java/com/ichi2/anki/preferences/ExternalDirectorySelectionPreferenceTest.kt (date 1773519838146)
@@ -0,0 +1,77 @@
+package com.ichi2.anki.preferences
+/*
+ Copyright (c) 2026 Shaan Narendran <shaannaren06@gmail.com>
+
+ 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 <http://www.gnu.org/licenses/>.
+ */
+import android.os.Bundle
+import androidx.fragment.app.testing.launchFragmentInContainer
+import androidx.preference.PreferenceFragmentCompat
+import androidx.preference.PreferenceScreen
+import androidx.test.espresso.Espresso.onView
+import androidx.test.espresso.assertion.ViewAssertions.matches
+import androidx.test.espresso.matcher.RootMatchers.isDialog
+import androidx.test.espresso.matcher.ViewMatchers.isDisplayed
+import androidx.test.espresso.matcher.ViewMatchers.withText
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import com.ichi2.anki.R
+import com.ichi2.anki.testutil.GrantStoragePermission
+import com.ichi2.anki.testutil.grantPermissions
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+import com.ichi2.preferences.ExternalDirectorySelectionPreference
+
+@RunWith(AndroidJUnit4::class)
+class ExternalDirectorySelectionPreferenceTest {
+
+ @get:Rule
+ val runtimePermissionRule = grantPermissions(GrantStoragePermission.storagePermission)
+
+ class TestPreferenceFragment : PreferenceFragmentCompat() {
+ override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
+ val context = preferenceManager.context
+ val screen: PreferenceScreen = preferenceManager.createPreferenceScreen(context)
+ val externalDirPref = ExternalDirectorySelectionPreference(context, null).apply {
+ key = "test_external_dir_pref"
+ title = "AnkiDroid directory"
+ }
+ screen.addPreference(externalDirPref)
+ preferenceScreen = screen
+ }
+
+ @Suppress("DEPRECATION")
+ override fun onDisplayPreferenceDialog(preference: androidx.preference.Preference) {
+ if (preference is ExternalDirectorySelectionPreference) {
+ val dialog = preference.makeDialogFragment()
+ dialog.arguments = Bundle().apply { putString("key", preference.key) }
+ dialog.setTargetFragment(this, 0)
+ dialog.show(parentFragmentManager, "androidx.preference.PreferenceFragment.DIALOG")
+ } else {
+ super.onDisplayPreferenceDialog(preference)
+ }
+ }
+ }
+
+ @Test
+ fun testDialogDisplaysScannedDirectories() {
+ val scenario = launchFragmentInContainer<TestPreferenceFragment>(themeResId = androidx.appcompat.R.style.Theme_AppCompat)
+ scenario.onFragment { fragment ->
+ val pref = fragment.findPreference<ExternalDirectorySelectionPreference>("test_external_dir_pref")!!
+ fragment.onDisplayPreferenceDialog(pref)
+ }
+ onView(withText(R.string.pref_custom_path))
+ .inRoot(isDialog())
+ .check(matches(isDisplayed()))
+ }
+}
\ No newline at end of file |
david-allison
left a comment
There was a problem hiding this comment.
Almost there. One question on /Android/ please provide examples in the code
|
The main thing that I'd like to see is confirming that this works with an SD card. I do have a spare phone which supports it, but it'd be ideal if you could test this with an emulator |
I think I tried in the very beginning but I wasn't able to figure out how to set it up, ill try again now after making the final changes |
|
Timebox a reasonable amount of time (2 hours ABSOLUTE maximum), then let me know |
75547e2 to
757dbff
Compare
757dbff to
ecf77d3
Compare
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>
ecf77d3 to
0ecb12b
Compare
|
so, how do I remove a path? |
There's no functionality there to do so, the main use case for this feature would be so that users can manually input/find paths for external storage like sd card or maybe input an external path that they want to use. I think I could add a remove button but maybe it's too much work for the few who would try to abuse this by adding too many paths? It's just an improvement to what we had before. |
|
The use cases are 1. mispelling, 2. giving up on having 2 or more "profiles", 3. Testing out stuff then deleting the collection |
I can add this, but isn't it dangerous to allow the user to delete paths that might contain their collections? Maybe, they could hide that path or just delete the string so then they'd have to re-enter but then that file would still be in their phone which would cause bloat. Is there a good way to handle this? |





Purpose / Description
This pr adds a path picker gui when a user wishes to select a path through the advanced settings, if the user is using the google play version, it restricts their options to their internal storage or to an external sd card (it automatically gives them the sd card option, whereas before they would have to manually find the path). If the user is on the fullrelease or full debug, then they can give permissions to manage all storage and they can manually enter their path since scoped storage wouldn't be an issue.
Fixes
Approach
David's patch ended up working very nicely and I just implemented his TO-DO's with the exception of one which I wasn't too sure about.
How Has This Been Tested?
Checklist
Please, go through these checks before submitting the PR.