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.annotation.IdRes
|
||||||
import androidx.lifecycle.AndroidViewModel
|
import androidx.lifecycle.AndroidViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import java.text.Normalizer
|
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
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.Library
|
||||||
import org.oxycblt.auxio.music.library.Sort
|
import org.oxycblt.auxio.music.library.Sort
|
||||||
import org.oxycblt.auxio.playback.PlaybackSettings
|
import org.oxycblt.auxio.playback.PlaybackSettings
|
||||||
import org.oxycblt.auxio.util.context
|
|
||||||
import org.oxycblt.auxio.util.logD
|
import org.oxycblt.auxio.util.logD
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -47,6 +45,7 @@ class SearchViewModel(application: Application) :
|
||||||
private val musicStore = MusicStore.getInstance()
|
private val musicStore = MusicStore.getInstance()
|
||||||
private val searchSettings = SearchSettings.from(application)
|
private val searchSettings = SearchSettings.from(application)
|
||||||
private val playbackSettings = PlaybackSettings.from(application)
|
private val playbackSettings = PlaybackSettings.from(application)
|
||||||
|
private var searchEngine = SearchEngine.from(application)
|
||||||
private var lastQuery: String? = null
|
private var lastQuery: String? = null
|
||||||
private var currentSearchJob: Job? = null
|
private var currentSearchJob: Job? = null
|
||||||
|
|
||||||
|
@ -101,87 +100,43 @@ class SearchViewModel(application: Application) :
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun searchImpl(library: Library, query: String): List<Item> {
|
private suspend fun searchImpl(library: Library, query: String): List<Item> {
|
||||||
val sort = Sort(Sort.Mode.ByName, true)
|
|
||||||
val filterMode = searchSettings.searchFilterMode
|
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) {
|
val results = searchEngine.search(items, query)
|
||||||
library.artists.searchListImpl(query)?.let {
|
|
||||||
results.add(Header(R.string.lbl_artists))
|
return buildList {
|
||||||
results.addAll(sort.artists(it))
|
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.
|
* Returns the ID of the filter option to currently highlight.
|
||||||
* @return A menu item ID of the filtering option selected.
|
* @return A menu item ID of the filtering option selected.
|
||||||
|
@ -218,10 +173,6 @@ class SearchViewModel(application: Application) :
|
||||||
}
|
}
|
||||||
|
|
||||||
private companion object {
|
private companion object {
|
||||||
/**
|
val SORT = Sort(Sort.Mode.ByName, true)
|
||||||
* Converts the output of [Normalizer] to remove any junk characters added by it's
|
|
||||||
* replacements.
|
|
||||||
*/
|
|
||||||
val NORMALIZATION_SANITIZE_REGEX = Regex("\\p{InCombiningDiacriticalMarks}+")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in a new issue