From 39c54317aaa5390e755a5aa79ef7e07d04a8c712 Mon Sep 17 00:00:00 2001 From: ElCruncharino <59633028+ElCruncharino@users.noreply.github.com> Date: Sun, 5 Jul 2026 12:16:40 -0400 Subject: [PATCH 1/4] Add file name filter to the torrent file selection screen Adds a filter bar to the file picker shown while adding a torrent. Plain text matches any part of a file name, and * wildcards are supported to match by extension, e.g. *.mkv. A new checkbox selects or deselects all the currently visible files, so *.mkv + checkbox selects all the mkv files in one go. Closes #450 Closes #462 --- .../view/TorrentProcessingFragment.kt | 63 ++++++++++++++----- .../layout/fragment_torrent_processing.xml | 41 +++++++++++- app/app/src/main/res/values/strings.xml | 1 + 3 files changed, 88 insertions(+), 17 deletions(-) diff --git a/app/app/src/main/java/com/github/livingwithhippos/unchained/torrentfilepicker/view/TorrentProcessingFragment.kt b/app/app/src/main/java/com/github/livingwithhippos/unchained/torrentfilepicker/view/TorrentProcessingFragment.kt index c19b003ae..99c1bbb49 100644 --- a/app/app/src/main/java/com/github/livingwithhippos/unchained/torrentfilepicker/view/TorrentProcessingFragment.kt +++ b/app/app/src/main/java/com/github/livingwithhippos/unchained/torrentfilepicker/view/TorrentProcessingFragment.kt @@ -8,6 +8,7 @@ import android.view.View import android.view.ViewGroup import android.widget.PopupMenu import androidx.annotation.MenuRes +import androidx.core.widget.addTextChangedListener import androidx.hilt.navigation.fragment.hiltNavGraphViewModels import androidx.navigation.fragment.findNavController import androidx.navigation.fragment.navArgs @@ -104,18 +105,11 @@ class TorrentProcessingFragment : UnchainedFragment(), TorrentContentListener { ) findNavController().navigate(action) } else { - val filesList = mutableListOf() val torrentStructure: Node = getFilesNodes(content.item, selectedOnly = false) if (torrentStructure.children.isNotEmpty()) { currentStructure = torrentStructure - Node.traverseDepthFirst(torrentStructure) { item -> - filesList.add(item) - } - (binding.rvTorrentFilePicker.adapter - as TorrentContentFilesSelectionAdapter) - .submitList(filesList) - binding.rvTorrentFilePicker.adapter?.notifyDataSetChanged() + updateFilesList() } binding.loadingLayout.visibility = View.INVISIBLE @@ -169,13 +163,7 @@ class TorrentProcessingFragment : UnchainedFragment(), TorrentContentListener { } is TorrentEvent.SelectionUpdated -> { - currentStructure?.let { structure -> - val filesList = mutableListOf() - Node.traverseDepthFirst(structure) { item -> filesList.add(item) } - (binding.rvTorrentFilePicker.adapter as TorrentContentFilesSelectionAdapter) - .submitList(filesList) - binding.rvTorrentFilePicker.adapter?.notifyDataSetChanged() - } + updateFilesList() } else -> { @@ -237,6 +225,18 @@ class TorrentProcessingFragment : UnchainedFragment(), TorrentContentListener { binding.fabDownload.setOnClickListener { showMenu(it, R.menu.download_mode_picker) } + binding.tiFilter.addTextChangedListener { + // the selection checkbox refers to the currently visible files + binding.cbSelectAll.isChecked = false + updateFilesList() + } + + binding.cbSelectAll.setOnClickListener { + val selected = binding.cbSelectAll.isChecked + getFilteredFiles().forEach { item -> item.selected = selected } + viewModel.updateTorrentStructure() + } + if (args.torrentID != null) { // we are loading an already available torrent args.torrentID?.let { viewModel.fetchTorrentDetails(it) } @@ -266,6 +266,39 @@ class TorrentProcessingFragment : UnchainedFragment(), TorrentContentListener { } } + /** + * Returns the files matching the current filter. With an empty filter the whole structure is + * returned, folders included, otherwise only the matching files are listed, see #450 + */ + private fun getFilteredFiles(): List { + val structure = currentStructure ?: return emptyList() + val query = binding.tiFilter.text?.toString()?.trim() ?: "" + val filesList = mutableListOf() + if (query.isEmpty()) { + Node.traverseDepthFirst(structure) { item -> filesList.add(item) } + } else { + // a query with wildcards must match the whole file name, a plain one any part of it + val regexQuery = + if (query.contains('*')) { + val pattern = query.split('*').joinToString(".*") { Regex.escape(it) } + Regex("^$pattern$", RegexOption.IGNORE_CASE) + } else { + Regex(Regex.escape(query), RegexOption.IGNORE_CASE) + } + Node.traverseDepthFirst(structure) { item -> + if (item.id != TYPE_FOLDER && regexQuery.containsMatchIn(item.name)) + filesList.add(item) + } + } + return filesList + } + + private fun updateFilesList() { + (binding.rvTorrentFilePicker.adapter as TorrentContentFilesSelectionAdapter) + .submitList(getFilteredFiles()) + binding.rvTorrentFilePicker.adapter?.notifyDataSetChanged() + } + private fun showMenu(v: View, @MenuRes menuRes: Int) { val popup = PopupMenu(requireContext(), v) popup.menuInflater.inflate(menuRes, popup.menu) diff --git a/app/app/src/main/res/layout/fragment_torrent_processing.xml b/app/app/src/main/res/layout/fragment_torrent_processing.xml index 905f944d8..414afaa41 100644 --- a/app/app/src/main/res/layout/fragment_torrent_processing.xml +++ b/app/app/src/main/res/layout/fragment_torrent_processing.xml @@ -69,11 +69,48 @@ android:layout_marginTop="10dp" android:text="@string/select_files" android:textAlignment="center" - app:layout_constraintBottom_toTopOf="@id/rvTorrentFilePicker" + app:layout_constraintBottom_toTopOf="@id/tfFilter" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toTopOf="parent" /> + + + + + + + diff --git a/app/app/src/main/res/values/strings.xml b/app/app/src/main/res/values/strings.xml index 372c8d2d9..14916a17d 100644 --- a/app/app/src/main/res/values/strings.xml +++ b/app/app/src/main/res/values/strings.xml @@ -654,6 +654,7 @@ Previous Ready Select files + Filter names, * supported Total files Total size Loading torrent files. Use the download all button on the bottom if you want to download all the content. From d8427791989092ed5366733f775318a63586aef6 Mon Sep 17 00:00:00 2001 From: ElCruncharino <59633028+ElCruncharino@users.noreply.github.com> Date: Sun, 5 Jul 2026 22:40:51 -0400 Subject: [PATCH 2/4] Drop a redundant notifyDataSetChanged after submitList The selection DiffCallback already compares TorrentFileItem.selected, so submitList alone rebinds the rows whose checkbox state changed. The extra notifyDataSetChanged forced every row to fully rebind on top of that, discarding the diffing work. --- .../torrentfilepicker/view/TorrentProcessingFragment.kt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/app/app/src/main/java/com/github/livingwithhippos/unchained/torrentfilepicker/view/TorrentProcessingFragment.kt b/app/app/src/main/java/com/github/livingwithhippos/unchained/torrentfilepicker/view/TorrentProcessingFragment.kt index 99c1bbb49..d38b81fac 100644 --- a/app/app/src/main/java/com/github/livingwithhippos/unchained/torrentfilepicker/view/TorrentProcessingFragment.kt +++ b/app/app/src/main/java/com/github/livingwithhippos/unchained/torrentfilepicker/view/TorrentProcessingFragment.kt @@ -294,9 +294,10 @@ class TorrentProcessingFragment : UnchainedFragment(), TorrentContentListener { } private fun updateFilesList() { + // the selection DiffCallback already compares TorrentFileItem.selected, so submitList + // alone rebinds changed rows without a redundant full notifyDataSetChanged (binding.rvTorrentFilePicker.adapter as TorrentContentFilesSelectionAdapter) .submitList(getFilteredFiles()) - binding.rvTorrentFilePicker.adapter?.notifyDataSetChanged() } private fun showMenu(v: View, @MenuRes menuRes: Int) { From d891ea54ad97cc6f8dea4c902a558fb27ddf1cff Mon Sep 17 00:00:00 2001 From: LivingWithHippos Date: Sat, 11 Jul 2026 11:11:17 +0200 Subject: [PATCH 3/4] check binding before using it --- .../torrentfilepicker/view/TorrentProcessingFragment.kt | 1 + 1 file changed, 1 insertion(+) diff --git a/app/app/src/main/java/com/github/livingwithhippos/unchained/torrentfilepicker/view/TorrentProcessingFragment.kt b/app/app/src/main/java/com/github/livingwithhippos/unchained/torrentfilepicker/view/TorrentProcessingFragment.kt index d38b81fac..5d867a8cf 100644 --- a/app/app/src/main/java/com/github/livingwithhippos/unchained/torrentfilepicker/view/TorrentProcessingFragment.kt +++ b/app/app/src/main/java/com/github/livingwithhippos/unchained/torrentfilepicker/view/TorrentProcessingFragment.kt @@ -271,6 +271,7 @@ class TorrentProcessingFragment : UnchainedFragment(), TorrentContentListener { * returned, folders included, otherwise only the matching files are listed, see #450 */ private fun getFilteredFiles(): List { + if (_binding == null) return emptyList() val structure = currentStructure ?: return emptyList() val query = binding.tiFilter.text?.toString()?.trim() ?: "" val filesList = mutableListOf() From 5c9a6881a3e6b11213a5171e63d147756552dad6 Mon Sep 17 00:00:00 2001 From: LivingWithHippos Date: Sat, 11 Jul 2026 11:11:43 +0200 Subject: [PATCH 4/4] do not skip notifyDataSetChanged also check binding --- .../view/TorrentProcessingFragment.kt | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/app/app/src/main/java/com/github/livingwithhippos/unchained/torrentfilepicker/view/TorrentProcessingFragment.kt b/app/app/src/main/java/com/github/livingwithhippos/unchained/torrentfilepicker/view/TorrentProcessingFragment.kt index 5d867a8cf..2a8485bb2 100644 --- a/app/app/src/main/java/com/github/livingwithhippos/unchained/torrentfilepicker/view/TorrentProcessingFragment.kt +++ b/app/app/src/main/java/com/github/livingwithhippos/unchained/torrentfilepicker/view/TorrentProcessingFragment.kt @@ -295,11 +295,11 @@ class TorrentProcessingFragment : UnchainedFragment(), TorrentContentListener { } private fun updateFilesList() { - // the selection DiffCallback already compares TorrentFileItem.selected, so submitList - // alone rebinds changed rows without a redundant full notifyDataSetChanged - (binding.rvTorrentFilePicker.adapter as TorrentContentFilesSelectionAdapter) - .submitList(getFilteredFiles()) - } + if (_binding == null) return + with(binding.rvTorrentFilePicker.adapter as TorrentContentFilesSelectionAdapter) { + submitList(getFilteredFiles()) + notifyDataSetChanged() + } private fun showMenu(v: View, @MenuRes menuRes: Int) { val popup = PopupMenu(requireContext(), v)