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:
Alexander Capehart 2023-01-23 08:42:18 -07:00
parent 26f0fb7aba
commit 6c604a9aa5
No known key found for this signature in database
GPG key ID: 37DBE3621FE9AD47
2 changed files with 155 additions and 81 deletions

View 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)
}
}

View file

@ -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)
}
}