Skip to content

Feat: Add path picker GUI for AnkiDroid Directory setting#19855

Open
ShaanNarendran wants to merge 1 commit into
ankidroid:mainfrom
ShaanNarendran:File-Picker
Open

Feat: Add path picker GUI for AnkiDroid Directory setting#19855
ShaanNarendran wants to merge 1 commit into
ankidroid:mainfrom
ShaanNarendran:File-Picker

Conversation

@ShaanNarendran

Copy link
Copy Markdown
Contributor

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?

image image

Checklist

Please, go through these checks before submitting the PR.

  • You have a descriptive commit message with a short title (first line, max 50 chars).
  • You have commented your code, particularly in hard-to-understand areas
  • You have performed a self-review of your own code
  • UI changes: include screenshots of all affected screens (in particular showing any new or changed strings)
  • UI Changes: You have tested your change using the Google Accessibility Scanner

@ShaanNarendran

Copy link
Copy Markdown
Contributor Author

@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

@david-allison

david-allison commented Dec 19, 2025

Copy link
Copy Markdown
Member

It's been a long while, thanks!!

  • The path is truncated visually in one of the screenshots
  • Add a button to open the old selector
  • Ensure the app private directory and legacy storage is handled correctly

I don't recall my patch, but I wasn't planning on submitting it any time soon, thanks for seeing it through!!

@david-allison david-allison added Needs Author Reply Waiting for a reply from the original author Needs Review labels Dec 19, 2025
@ShaanNarendran

Copy link
Copy Markdown
Contributor Author

Will resolve all the changes, I thought I had reacted sorry TT

@ShaanNarendran ShaanNarendran force-pushed the File-Picker branch 2 times, most recently from a8d65c5 to 7957d5d Compare December 20, 2025 01:05
@ShaanNarendran

Copy link
Copy Markdown
Contributor Author

The new UI look for the changes I just made are:

While using android 10 (i.e. no scoped storage)
Screen_recording_20251220_062115.webm

While using android 15 (playDebug)
Screen_recording_20251220_062210.webm

While using android 15 (fullDebug)
Screen_recording_20251220_062453.webm

Screenshot of new UI:
image

Note: it looks truncated but you can scroll down there, as seen in the videos

@david-allison

Copy link
Copy Markdown
Member

There should be enough space on the screen that it doesn't need to be truncated

@ShaanNarendran

Copy link
Copy Markdown
Contributor Author

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

@ShaanNarendran

Copy link
Copy Markdown
Contributor Author

Screen_recording_20251221_044059.webm
This is how it looks now, with the new subclass to match screen size

@ShaanNarendran

Copy link
Copy Markdown
Contributor Author

@david-allison just a reminder since the needs author reply tag wasn't removed ^^

@david-allison

Copy link
Copy Markdown
Member

Still on my to-do list, I'll get to it shortly!

@david-allison david-allison removed the Needs Author Reply Waiting for a reply from the original author label Dec 22, 2025

@david-allison david-allison left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  • 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

@david-allison david-allison added the Needs Author Reply Waiting for a reply from the original author label Dec 22, 2025
@ShaanNarendran

ShaanNarendran commented Dec 22, 2025

Copy link
Copy Markdown
Contributor Author

Screen_recording_20251222_221717.webm
I've done my best to optimise as much as possible since I did have to move the scan logic to be started when the preference is opened. its my first very big commit so I hope it isn't too hard to review, have tried to document the functions I had created to help with the new custom path button.

Edit: video seems to be buffering, hope its just on my end

@ShaanNarendran

Copy link
Copy Markdown
Contributor Author

Some screenshots of the UI now:
image
image
image

@Haz3-jolt Haz3-jolt removed the Needs Author Reply Waiting for a reply from the original author label Dec 25, 2025
@david-allison david-allison self-requested a review January 5, 2026 13:49

@david-allison david-allison left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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?

@ShaanNarendran

Copy link
Copy Markdown
Contributor Author

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!

@ShaanNarendran

Copy link
Copy Markdown
Contributor Author

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

@david-allison david-allison left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 occurs

It 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.

@ShaanNarendran

Copy link
Copy Markdown
Contributor Author
Patch
Index: 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 david-allison left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Almost there. One question on /Android/ please provide examples in the code

Comment thread AnkiDroid/src/main/java/com/ichi2/anki/utils/ext/File.kt Outdated
@david-allison

david-allison commented Mar 15, 2026

Copy link
Copy Markdown
Member

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

@ShaanNarendran

Copy link
Copy Markdown
Contributor Author

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

@david-allison

Copy link
Copy Markdown
Member

Timebox a reasonable amount of time (2 hours ABSOLUTE maximum), then let me know

@ShaanNarendran ShaanNarendran force-pushed the File-Picker branch 2 times, most recently from 75547e2 to 757dbff Compare March 15, 2026 19:47

@david-allison david-allison left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

On my API 28 Huawei LLD-31, this allowed me to select my SD card, without needing to figure out what the path was. AMAZING JOB!!!!

This does so much good for our users, especially ones who are running on older phones and NEED an SD card to keep it going. Well done!!!!

Image

@david-allison david-allison added Needs Second Approval Has one approval, one more approval to merge and removed Needs Author Reply Waiting for a reply from the original author Needs Review labels Mar 15, 2026
@david-allison david-allison added this to the 2.24 release milestone Mar 15, 2026
@david-allison david-allison changed the title Feat: Add path picker GUI Feat: Add path picker GUI for AnkiDroid Directory setting Apr 6, 2026
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>
@BrayanDSO

Copy link
Copy Markdown
Member

so, how do I remove a path?

@ShaanNarendran

Copy link
Copy Markdown
Contributor Author

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.

@BrayanDSO

Copy link
Copy Markdown
Member

The use cases are 1. mispelling, 2. giving up on having 2 or more "profiles", 3. Testing out stuff then deleting the collection

@ShaanNarendran

Copy link
Copy Markdown
Contributor Author

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?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Has Conflicts Needs Second Approval Has one approval, one more approval to merge

Projects

Status: Backlog

Development

Successfully merging this pull request may close these issues.

Nicer way to enter path to put the collection

4 participants