diff --git a/app/src/main/java/org/oxycblt/auxio/search/SearchEngine.kt b/app/src/main/java/org/oxycblt/auxio/search/SearchEngine.kt new file mode 100644 index 000000000..831f758f9 --- /dev/null +++ b/app/src/main/java/org/oxycblt/auxio/search/SearchEngine.kt @@ -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 . + */ + +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?, + val albums: List?, + val artists: List?, + val genres: List? + ) + + 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 List.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) + } +} diff --git a/app/src/main/java/org/oxycblt/auxio/search/SearchViewModel.kt b/app/src/main/java/org/oxycblt/auxio/search/SearchViewModel.kt index 9341a7390..7a6b6f968 100644 --- a/app/src/main/java/org/oxycblt/auxio/search/SearchViewModel.kt +++ b/app/src/main/java/org/oxycblt/auxio/search/SearchViewModel.kt @@ -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 { - val sort = Sort(Sort.Mode.ByName, true) + private suspend fun searchImpl(library: Library, query: String): List { val filterMode = searchSettings.searchFilterMode - val results = mutableListOf() - // 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 List.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) } }