search: split off search algorithm from viewmodel
Split off the search algorithm from the ViewModel into a separate object. Part of an initiative to eliminate all non-parameter usage of contexts in ViewModels.
This commit is contained in:
parent
26f0fb7aba
commit
6c604a9aa5
2 changed files with 155 additions and 81 deletions
123
app/src/main/java/org/oxycblt/auxio/search/SearchEngine.kt
Normal file
123
app/src/main/java/org/oxycblt/auxio/search/SearchEngine.kt
Normal file
|
@ -0,0 +1,123 @@
|
|||
/*
|
||||
* Copyright (c) 2023 Auxio Project
|
||||
*
|
||||
* 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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package org.oxycblt.auxio.search
|
||||
|
||||
import android.content.Context
|
||||
import java.text.Normalizer
|
||||
import org.oxycblt.auxio.music.Album
|
||||
import org.oxycblt.auxio.music.Artist
|
||||
import org.oxycblt.auxio.music.Genre
|
||||
import org.oxycblt.auxio.music.Music
|
||||
import org.oxycblt.auxio.music.Song
|
||||
|
||||
/**
|
||||
* Implements the fuzzy-ish searching algorithm used in the search view.
|
||||
* @author Alexander Capehart
|
||||
*/
|
||||
interface SearchEngine {
|
||||
/**
|
||||
* Begin a search.
|
||||
* @param items The items to search over.
|
||||
* @param query The query to search for.
|
||||
* @return A list of items filtered by the given query.
|
||||
*/
|
||||
suspend fun search(items: Items, query: String): Items
|
||||
|
||||
/**
|
||||
* Input/output data to use with [SearchEngine].
|
||||
* @param songs A list of [Song]s, null if empty.
|
||||
* @param albums A list of [Album]s, null if empty.
|
||||
* @param artists A list of [Artist]s, null if empty.
|
||||
* @param genres A list of [Genre]s, null if empty.
|
||||
*/
|
||||
data class Items(
|
||||
val songs: List<Song>?,
|
||||
val albums: List<Album>?,
|
||||
val artists: List<Artist>?,
|
||||
val genres: List<Genre>?
|
||||
)
|
||||
|
||||
private class Real(private val context: Context) : SearchEngine {
|
||||
override suspend fun search(items: Items, query: String) =
|
||||
Items(
|
||||
songs =
|
||||
items.songs?.searchListImpl(query) { q, song -> song.path.name.contains(q) },
|
||||
albums = items.albums?.searchListImpl(query),
|
||||
artists = items.artists?.searchListImpl(query),
|
||||
genres = items.genres?.searchListImpl(query))
|
||||
|
||||
/**
|
||||
* Search a given [Music] list.
|
||||
* @param query The query to search for. The routine will compare this query to the names of
|
||||
* each object in the list and
|
||||
* @param fallback Additional comparison code to run if the item does not match the query
|
||||
* initially. This can be used to compare against additional attributes to improve search
|
||||
* result quality.
|
||||
*/
|
||||
private inline fun <T : Music> List<T>.searchListImpl(
|
||||
query: String,
|
||||
fallback: (String, T) -> Boolean = { _, _ -> false }
|
||||
) =
|
||||
filter {
|
||||
// See if the plain resolved name matches the query. This works for most
|
||||
// situations.
|
||||
val name = it.resolveName(context)
|
||||
if (name.contains(query, ignoreCase = true)) {
|
||||
return@filter true
|
||||
}
|
||||
|
||||
// See if the sort name matches. This can sometimes be helpful as certain
|
||||
// libraries
|
||||
// will tag sort names to have a alphabetized version of the title.
|
||||
val sortName = it.rawSortName
|
||||
if (sortName != null && sortName.contains(query, ignoreCase = true)) {
|
||||
return@filter true
|
||||
}
|
||||
|
||||
// As a last-ditch effort, see if the normalized name matches. This will replace
|
||||
// any non-alphabetical characters with their alphabetical representations,
|
||||
// which
|
||||
// could make it match the query.
|
||||
val normalizedName =
|
||||
NORMALIZATION_SANITIZE_REGEX.replace(
|
||||
Normalizer.normalize(name, Normalizer.Form.NFKD), "")
|
||||
if (normalizedName.contains(query, ignoreCase = true)) {
|
||||
return@filter true
|
||||
}
|
||||
|
||||
fallback(query, it)
|
||||
}
|
||||
.ifEmpty { null }
|
||||
|
||||
private companion object {
|
||||
/**
|
||||
* Converts the output of [Normalizer] to remove any junk characters added by it's
|
||||
* replacements.
|
||||
*/
|
||||
val NORMALIZATION_SANITIZE_REGEX = Regex("\\p{InCombiningDiacriticalMarks}+")
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* Get a framework-backed implementation.
|
||||
* @param context [Context] required.
|
||||
*/
|
||||
fun from(context: Context): SearchEngine = Real(context)
|
||||
}
|
||||
}
|
|
@ -21,7 +21,6 @@ import android.app.Application
|
|||
import androidx.annotation.IdRes
|
||||
import androidx.lifecycle.AndroidViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import java.text.Normalizer
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
|
@ -35,7 +34,6 @@ import org.oxycblt.auxio.music.MusicStore
|
|||
import org.oxycblt.auxio.music.library.Library
|
||||
import org.oxycblt.auxio.music.library.Sort
|
||||
import org.oxycblt.auxio.playback.PlaybackSettings
|
||||
import org.oxycblt.auxio.util.context
|
||||
import org.oxycblt.auxio.util.logD
|
||||
|
||||
/**
|
||||
|
@ -47,6 +45,7 @@ class SearchViewModel(application: Application) :
|
|||
private val musicStore = MusicStore.getInstance()
|
||||
private val searchSettings = SearchSettings.from(application)
|
||||
private val playbackSettings = PlaybackSettings.from(application)
|
||||
private var searchEngine = SearchEngine.from(application)
|
||||
private var lastQuery: String? = null
|
||||
private var currentSearchJob: Job? = null
|
||||
|
||||
|
@ -101,87 +100,43 @@ class SearchViewModel(application: Application) :
|
|||
}
|
||||
}
|
||||
|
||||
private fun searchImpl(library: Library, query: String): List<Item> {
|
||||
val sort = Sort(Sort.Mode.ByName, true)
|
||||
private suspend fun searchImpl(library: Library, query: String): List<Item> {
|
||||
val filterMode = searchSettings.searchFilterMode
|
||||
val results = mutableListOf<Item>()
|
||||
|
||||
// Note: A null filter mode maps to the "All" filter option, hence the check.
|
||||
val items =
|
||||
if (filterMode == null) {
|
||||
// A nulled filter mode means to not filter anything.
|
||||
SearchEngine.Items(library.songs, library.albums, library.artists, library.genres)
|
||||
} else {
|
||||
SearchEngine.Items(
|
||||
songs = if (filterMode == MusicMode.SONGS) library.songs else null,
|
||||
albums = if (filterMode == MusicMode.ALBUMS) library.albums else null,
|
||||
artists = if (filterMode == MusicMode.ARTISTS) library.artists else null,
|
||||
genres = if (filterMode == MusicMode.GENRES) library.genres else null)
|
||||
}
|
||||
|
||||
if (filterMode == null || filterMode == MusicMode.ARTISTS) {
|
||||
library.artists.searchListImpl(query)?.let {
|
||||
results.add(Header(R.string.lbl_artists))
|
||||
results.addAll(sort.artists(it))
|
||||
val results = searchEngine.search(items, query)
|
||||
|
||||
return buildList {
|
||||
results.artists?.let { artists ->
|
||||
add(Header(R.string.lbl_artists))
|
||||
addAll(SORT.artists(artists))
|
||||
}
|
||||
results.albums?.let { albums ->
|
||||
add(Header(R.string.lbl_albums))
|
||||
addAll(SORT.albums(albums))
|
||||
}
|
||||
results.genres?.let { genres ->
|
||||
add(Header(R.string.lbl_genres))
|
||||
addAll(SORT.genres(genres))
|
||||
}
|
||||
results.songs?.let { songs ->
|
||||
add(Header(R.string.lbl_songs))
|
||||
addAll(SORT.songs(songs))
|
||||
}
|
||||
}
|
||||
|
||||
if (filterMode == null || filterMode == MusicMode.ALBUMS) {
|
||||
library.albums.searchListImpl(query)?.let {
|
||||
results.add(Header(R.string.lbl_albums))
|
||||
results.addAll(sort.albums(it))
|
||||
}
|
||||
}
|
||||
|
||||
if (filterMode == null || filterMode == MusicMode.GENRES) {
|
||||
library.genres.searchListImpl(query)?.let {
|
||||
results.add(Header(R.string.lbl_genres))
|
||||
results.addAll(sort.genres(it))
|
||||
}
|
||||
}
|
||||
|
||||
if (filterMode == null || filterMode == MusicMode.SONGS) {
|
||||
library.songs
|
||||
.searchListImpl(query) { q, song -> song.path.name.contains(q) }
|
||||
?.let {
|
||||
results.add(Header(R.string.lbl_songs))
|
||||
results.addAll(sort.songs(it))
|
||||
}
|
||||
}
|
||||
|
||||
// Handle if we were canceled while searching.
|
||||
return results
|
||||
}
|
||||
|
||||
/**
|
||||
* Search a given [Music] list.
|
||||
* @param query The query to search for. The routine will compare this query to the names of
|
||||
* each object in the list and
|
||||
* @param fallback Additional comparison code to run if the item does not match the query
|
||||
* initially. This can be used to compare against additional attributes to improve search result
|
||||
* quality.
|
||||
*/
|
||||
private inline fun <T : Music> List<T>.searchListImpl(
|
||||
query: String,
|
||||
fallback: (String, T) -> Boolean = { _, _ -> false }
|
||||
) =
|
||||
filter {
|
||||
// See if the plain resolved name matches the query. This works for most situations.
|
||||
val name = it.resolveName(context)
|
||||
if (name.contains(query, ignoreCase = true)) {
|
||||
return@filter true
|
||||
}
|
||||
|
||||
// See if the sort name matches. This can sometimes be helpful as certain libraries
|
||||
// will tag sort names to have a alphabetized version of the title.
|
||||
val sortName = it.rawSortName
|
||||
if (sortName != null && sortName.contains(query, ignoreCase = true)) {
|
||||
return@filter true
|
||||
}
|
||||
|
||||
// As a last-ditch effort, see if the normalized name matches. This will replace
|
||||
// any non-alphabetical characters with their alphabetical representations, which
|
||||
// could make it match the query.
|
||||
val normalizedName =
|
||||
NORMALIZATION_SANITIZE_REGEX.replace(
|
||||
Normalizer.normalize(name, Normalizer.Form.NFKD), "")
|
||||
if (normalizedName.contains(query, ignoreCase = true)) {
|
||||
return@filter true
|
||||
}
|
||||
|
||||
fallback(query, it)
|
||||
}
|
||||
.ifEmpty { null }
|
||||
|
||||
/**
|
||||
* Returns the ID of the filter option to currently highlight.
|
||||
* @return A menu item ID of the filtering option selected.
|
||||
|
@ -218,10 +173,6 @@ class SearchViewModel(application: Application) :
|
|||
}
|
||||
|
||||
private companion object {
|
||||
/**
|
||||
* Converts the output of [Normalizer] to remove any junk characters added by it's
|
||||
* replacements.
|
||||
*/
|
||||
val NORMALIZATION_SANITIZE_REGEX = Regex("\\p{InCombiningDiacriticalMarks}+")
|
||||
val SORT = Sort(Sort.Mode.ByName, true)
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue