Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ import com.ichi2.anki.settings.Prefs
import com.ichi2.anki.snackbar.showSnackbar
import com.ichi2.anki.utils.openUrl
import com.ichi2.compat.CompatHelper
import com.ichi2.preferences.ExternalDirectorySelectionPreference
import com.ichi2.utils.show
import timber.log.Timber
import java.io.File
Expand All @@ -48,7 +49,7 @@ class AdvancedSettingsFragment : SettingsFragment() {
removeUnnecessaryAdvancedPrefs()

// Check that input is valid before committing change in the collection path
requirePreference<EditTextPreference>(CollectionHelper.PREF_COLLECTION_PATH).apply {
requirePreference<ExternalDirectorySelectionPreference>(CollectionHelper.PREF_COLLECTION_PATH).apply {
setOnPreferenceChangeListener { _, newValue: Any? ->
val newPath = newValue as String
try {
Expand All @@ -67,7 +68,7 @@ class AdvancedSettingsFragment : SettingsFragment() {
setTitle(R.string.dialog_collection_path_not_dir)
setPositiveButton(R.string.dialog_ok) { _, _ -> }
setNegativeButton(R.string.reset_custom_buttons) { _, _ ->
text = CollectionHelper.getDefaultAnkiDroidDirectory(requireContext()).absolutePath
value = CollectionHelper.getDefaultAnkiDroidDirectory(requireContext()).absolutePath
}
}
false
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,207 @@
/*
* Copyright (c) 2024 David Allison <davidallisongithub@gmail.com>
* 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/>.
*/

package com.ichi2.preferences

import android.content.Context
import android.graphics.Color
import android.text.Spannable
import android.text.SpannableString
import android.text.style.ForegroundColorSpan
import android.util.AttributeSet
import androidx.appcompat.app.AlertDialog
import androidx.fragment.app.DialogFragment
import androidx.preference.ListPreference
import androidx.preference.ListPreferenceDialogFragmentCompat
import com.ichi2.anki.BuildConfig
import com.ichi2.anki.CollectionHelper
import com.ichi2.anki.R
import com.ichi2.anki.showThemedToast
import com.ichi2.utils.input
import com.ichi2.utils.negativeButton
import com.ichi2.utils.positiveButton
import com.ichi2.utils.show
import timber.log.Timber
import java.io.File
import java.io.FileFilter
import java.nio.file.Files
import java.nio.file.Paths

/**
* Displays a list of external directories to select for the AnkiDroid Directory
*
* Improving discoverability of using a SD Card for the directory
*
* Also provides the ability to input a custom path
*
* @see ListPreferenceTrait - this preference can either be a List or an EditText
*/
class ExternalDirectorySelectionPreference(
context: Context,
attrs: AttributeSet?,
) : ListPreference(context, attrs),
ListPreferenceTrait {
init {
Copy link
Contributor Author

Choose a reason for hiding this comment

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

I removed the edit text pref layout, it was causing issues with the horizontal line. I do like the current ui but I understand the truncation and vertical scrolling may not be nice for all users so open to suggestions here too

summaryProvider =
SummaryProvider<ListPreference> { pref ->
pref.value.takeUnless { it.isNullOrEmpty() } ?: context.getString(R.string.pref_directory_not_set)
}
}

// below are default values for the listEntries and listValue variables, they are set in makeDialogFragment()
override var listEntries: List<ListPreferenceTrait.Entry> = emptyList()
override var listValue: String = ""

/** Safely retrieves the default AnkiDroid directory, returning null on failure. */
private val defaultAnkiDir: File?
get() =
try {
CollectionHelper.getDefaultAnkiDroidDirectory(context)
} catch (e: Exception) {
Timber.w(e, "Could not access default AnkiDroid directory")
null
}

/** Builds the list of available directories for selection. */
private fun loadDirectories(): List<ListPreferenceTrait.Entry> =
buildList {
if (value?.isNotEmpty() == true) {
add(File(value))
}
defaultAnkiDir?.let { add(it) }
addAll(getScannedDirectories())
}.mapNotNull { runCatching { it.absolutePath }.getOrNull() }
.distinct()
.map(::absolutePathToDisplayEntry)

/**
* Safely scans all external directories.
* If one directory fails to scan, we log it and continue to the next one
*/
private fun getScannedDirectories(): List<File> {
val roots =
try {
CollectionHelper.getAppSpecificExternalDirectories(context).filterNotNull()
} catch (e: Exception) {
Timber.w(e, "Critical error getting storage roots")
return emptyList()
}
val children =
roots.flatMap { rootDir ->
try {
findAnkiDroidSubDirectories(rootDir)
} catch (e: Exception) {
Timber.w(e, "Could not scan directory: $rootDir")
emptyList()
}
}
return (roots + children).distinct()
}

// TODO: Possibly move loadDirectories() to a background thread if ANR occurs
override fun makeDialogFragment(): DialogFragment {
listEntries = loadDirectories()
entries = listEntries.map { it.key }.toTypedArray()
setEntryValues(listEntries.map { it.value as CharSequence }.toTypedArray())
listValue = value ?: defaultAnkiDir?.absolutePath ?: ""
setValue(listValue)
return FullWidthListPreferenceDialogFragment()
}

/** Creates a display entry. */
private fun absolutePathToDisplayEntry(path: String): ListPreferenceTrait.Entry {
// Find the standard Android directory to split the path for display
// Eg: "/storage/emulated/0"->Gray "/Android/data/com.ichi2.anki"->Normal
val androidIndex = path.indexOf("/Android/")
// If index is not found, then return the path as is
if (androidIndex == -1) return ListPreferenceTrait.Entry(path, path)
val displayString = "${path.take(androidIndex)}\n${path.substring(androidIndex)}"
val spannable =
SpannableString(displayString).apply {
setSpan(ForegroundColorSpan(Color.GRAY), 0, androidIndex, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
}
return ListPreferenceTrait.Entry(spannable, path)
}

companion object {
private val ANKI_DIR_FILTER = FileFilter { it.isDirectory }
Copy link
Contributor Author

Choose a reason for hiding this comment

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

I think this is better (could add a ishidden check), the regex was blocking files that were valid and either way we should only be seeing ankidroid files since "CollectionHelper.getAppSpecificExternalDirectories(context).filterNotNull()"
hope this is better, open to improvements


fun findAnkiDroidSubDirectories(f: File): List<File> = f.listFiles(ANKI_DIR_FILTER)?.toList() ?: emptyList()
}
}

/** A DialogFragment that allows custom path input if on a device before Android 11, or on a full release version. */
class FullWidthListPreferenceDialogFragment : ListPreferenceDialogFragmentCompat() {
override fun onPrepareDialogBuilder(builder: AlertDialog.Builder) {
super.onPrepareDialogBuilder(builder)
val isPlayStoreBuild = BuildConfig.FLAVOR == "play"
val isScopedStorageEnforced = android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.R
if (!isPlayStoreBuild || !isScopedStorageEnforced) {
builder.setNeutralButton(R.string.pref_custom_path) { _, _ -> showCustomPathInput() }
}
}

private fun showCustomPathInput() {
val context = requireContext()
val pref = (preference as? ExternalDirectorySelectionPreference) ?: return
AlertDialog
.Builder(context)
.show {
setTitle(R.string.pref_enter_custom_path)
setView(R.layout.dialog_generic_text_input)
positiveButton(android.R.string.ok)
negativeButton(android.R.string.cancel)
}.input(
prefill = pref.value ?: "",
allowEmpty = false,
) { dialog, text ->
try {
val newPath = text.toString().trim()
val pathObj = Paths.get(newPath)
Files.createDirectories(pathObj)
if (!Files.isWritable(pathObj)) {
showThemedToast(
context,
context.getString(R.string.pref_directory_not_writable),
true,
)
return@input
}
dialog.dismiss()
if (pref.callChangeListener(newPath)) {
pref.value = newPath
pref.listValue = newPath
}
} catch (e: Exception) {
Timber.w(e, "Failed to set custom path")
showThemedToast(
context,
context.getString(R.string.could_not_create_dir) + "\n" + android.util.Log.getStackTraceString(e),
true,
)
}
}
}

override fun onStart() {
super.onStart()
dialog?.window?.setLayout(
android.view.ViewGroup.LayoutParams.MATCH_PARENT,
android.view.ViewGroup.LayoutParams.WRAP_CONTENT,
)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ interface ListPreferenceTrait : DialogFragmentProvider {
var listValue: String

data class Entry(
val key: String,
val key: CharSequence,
Copy link
Contributor Author

@ShaanNarendran ShaanNarendran Jan 12, 2026

Choose a reason for hiding this comment

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

I don't think this is the best way to go about it, but unless I do this I get errors in lines 103 and 104

val value: String,
)

Expand Down
6 changes: 6 additions & 0 deletions AnkiDroid/src/main/res/values/10-preferences.xml
Original file line number Diff line number Diff line change
Expand Up @@ -473,4 +473,10 @@ this formatter is used if the bind only applies to the answer">A: %s</string>

<!--Keyboard shortcuts dialog-->
<string name="open_settings" comment="Description of the shortcut that opens the app settings">Open settings</string>

<!-- External Directory Selection Preference -->
<string name="pref_directory_not_set">Not set</string>
<string name="pref_custom_path" maxLength="41">Custom path</string>
<string name="pref_enter_custom_path" maxLength="41">Enter custom path</string>
<string name="pref_directory_not_writable">Directory is not writable</string>
</resources>
2 changes: 1 addition & 1 deletion AnkiDroid/src/main/res/xml/preferences_advanced.xml
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@
xmlns:app="http://schemas.android.com/apk/res-auto"
android:title="@string/pref_cat_advanced"
android:key="@string/pref_advanced_screen_key">
<EditTextPreference
<com.ichi2.preferences.ExternalDirectorySelectionPreference
android:defaultValue="/sdcard/AnkiDroid"
android:key="@string/pref_ankidroid_directory_key"
android:title="@string/col_path"
Expand Down