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,227 @@
/*
* 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.os.Environment
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.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.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)

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" }
}

/**
* 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> {
// 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)))
}
}
} catch (e: Exception) {
Timber.w(e, "Critical error getting storage roots")
return emptyList()
}
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 {
// If no anki directories are found, we can list this as it is likely an SD card
candidates.add(root)
}
}
return candidates.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 IGNORED_DIRECTORIES = setOf("collection.media", "backups", "cache", "code_cache")
}
}

/** 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)
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")
AlertDialog
.Builder(context)
.setTitle(context.getString(R.string.could_not_create_dir, text.toString()))
.setMessage(android.util.Log.getStackTraceString(e))
.setPositiveButton(android.R.string.ok, null)
.show()
}
}
}

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,
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 @@ -477,4 +477,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
Loading