diff --git a/CHANGELOG.md b/CHANGELOG.md index 096021a6862..2b150f284a7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,8 @@ ([#583](https://github.com/Automattic/pocket-casts-android/pull/583)). * Add categories to recommendations screen ([#675](https://github.com/Automattic/pocket-casts-android/pull/675)). + * Improved the Android Automotive search + ([#681](https://github.com/Automattic/pocket-casts-android/pull/681)). 7.29 ----- diff --git a/modules/services/model/src/main/java/au/com/shiftyjelly/pocketcasts/models/db/dao/EpisodeDao.kt b/modules/services/model/src/main/java/au/com/shiftyjelly/pocketcasts/models/db/dao/EpisodeDao.kt index 745af54ba74..a0449a2c506 100644 --- a/modules/services/model/src/main/java/au/com/shiftyjelly/pocketcasts/models/db/dao/EpisodeDao.kt +++ b/modules/services/model/src/main/java/au/com/shiftyjelly/pocketcasts/models/db/dao/EpisodeDao.kt @@ -133,9 +133,6 @@ abstract class EpisodeDao { @Query("SELECT * FROM episodes WHERE podcast_id = :podcastUuid ORDER BY published_date DESC, added_date DESC LIMIT 1") abstract fun findLatestRx(podcastUuid: String): Maybe - @Query("SELECT * FROM episodes WHERE podcast_id = :podcastUuid AND playing_status != 2 ORDER BY published_date DESC LIMIT 10") - abstract fun findPodcastEpisodesForMediaBrowserSearch(podcastUuid: String): List - @Query("SELECT * FROM episodes WHERE (download_task_id IS NOT NULL OR episode_status == :downloadEpisodeStatusEnum OR (episode_status == :failedEpisodeStatusEnum AND last_download_attempt_date > :failedDownloadCutoff AND archived == 0)) ORDER BY last_download_attempt_date DESC") abstract fun observeDownloadingEpisodesIncludingFailed(failedDownloadCutoff: Long, failedEpisodeStatusEnum: EpisodeStatusEnum = EpisodeStatusEnum.DOWNLOAD_FAILED, downloadEpisodeStatusEnum: EpisodeStatusEnum = EpisodeStatusEnum.DOWNLOADED): Flowable> diff --git a/modules/services/repositories/src/main/java/au/com/shiftyjelly/pocketcasts/repositories/playback/MediaSessionManager.kt b/modules/services/repositories/src/main/java/au/com/shiftyjelly/pocketcasts/repositories/playback/MediaSessionManager.kt index c33993f463e..ff80c3762a6 100644 --- a/modules/services/repositories/src/main/java/au/com/shiftyjelly/pocketcasts/repositories/playback/MediaSessionManager.kt +++ b/modules/services/repositories/src/main/java/au/com/shiftyjelly/pocketcasts/repositories/playback/MediaSessionManager.kt @@ -661,7 +661,7 @@ class MediaSessionManager( * ‘Listen to [filter name] in Pocket Casts’ * ‘Listen to Up Next in Pocket Casts’ * ‘Play Up Next in Pocket Casts’ - * ‘Play New Releasees Next in Pocket Casts’ + * ‘Play New Releases Next in Pocket Casts’ */ private fun performPlayFromSearch(searchTerm: String?) { Timber.d("performSearch $searchTerm") diff --git a/modules/services/repositories/src/main/java/au/com/shiftyjelly/pocketcasts/repositories/playback/PlaybackService.kt b/modules/services/repositories/src/main/java/au/com/shiftyjelly/pocketcasts/repositories/playback/PlaybackService.kt index 55830aaf7e7..a6024ecef43 100644 --- a/modules/services/repositories/src/main/java/au/com/shiftyjelly/pocketcasts/repositories/playback/PlaybackService.kt +++ b/modules/services/repositories/src/main/java/au/com/shiftyjelly/pocketcasts/repositories/playback/PlaybackService.kt @@ -23,6 +23,7 @@ import au.com.shiftyjelly.pocketcasts.models.entity.Episode import au.com.shiftyjelly.pocketcasts.models.entity.Podcast import au.com.shiftyjelly.pocketcasts.models.to.FolderItem import au.com.shiftyjelly.pocketcasts.models.to.SubscriptionStatus +import au.com.shiftyjelly.pocketcasts.models.type.PodcastsSortType import au.com.shiftyjelly.pocketcasts.preferences.Settings import au.com.shiftyjelly.pocketcasts.repositories.extensions.id import au.com.shiftyjelly.pocketcasts.repositories.notification.NotificationDrawer @@ -66,7 +67,6 @@ const val RECENT_ROOT = "__RECENT__" const val SUGGESTED_ROOT = "__SUGGESTED__" const val FOLDER_ROOT_PREFIX = "__FOLDER__" -private const val MAX_SEARCH_RESULT_COUNT = 10 private const val MEDIA_SEARCH_SUPPORTED = "android.media.browse.SEARCH_SUPPORTED" private const val CONTENT_STYLE_SUPPORTED = "android.media.browse.CONTENT_STYLE_SUPPORTED" @@ -569,86 +569,40 @@ open class PlaybackService : MediaBrowserServiceCompat(), CoroutineScope { override fun onSearch(query: String, extras: Bundle?, result: Result>) { result.detach() launch { - result.sendResult(performSearch(query)) + result.sendResult(podcastSearch(query)) } } /** - * Voice search. - * "Play Up Next" - * "Play Material" - * "Play Material in Pocket Casts" + * Search for local and remote podcasts. + * Returning an empty list displays "No media available for browsing here" + * Returning null displays "Something went wrong". There is no way to display our own error message. */ - private fun performSearch(searchTerm: String): List { - val searchResults = mutableListOf() - - val query = searchTerm.trim { it <= ' ' }.lowercase() - if (query.startsWith("up next") || query.startsWith("next episode") || query.startsWith("next podcast")) { - val upNextEpisodes = playbackManager.upNextQueue.queueEpisodes.take(10) - for (episode in upNextEpisodes) { - if (episode is Episode) { - podcastManager.findPodcastByUuid(episode.podcastUuid)?.let { parentPodcast -> - addToResultsIfNotExists(AutoConverter.convertEpisodeToMediaItem(context = this, episode = episode, parentPodcast = parentPodcast), searchResults) - } - } else { - // TODO: UserEpisode - } - } - - return searchResults - } - - val options = MediaSessionManager.calculateSearchQueryOptions(query) - for (option in options) { - val matchingPodcast = podcastManager.searchPodcastByTitle(option) ?: continue - val latestEpisodeUuid = matchingPodcast.latestEpisodeUuid - val latestEpisode = if (latestEpisodeUuid == null) null else episodeManager.findByUuid(latestEpisodeUuid) - - if (latestEpisode != null) { - addToResultsIfNotExists(AutoConverter.convertEpisodeToMediaItem(context = this, episode = latestEpisode, parentPodcast = matchingPodcast), searchResults) - if (searchResults.size >= MAX_SEARCH_RESULT_COUNT) { - return searchResults - } - } - - // add a few of the latest episodes from this podcast - episodeManager.findPodcastEpisodesForMediaBrowserSearch(matchingPodcast.uuid).forEach { episode -> - addToResultsIfNotExists(AutoConverter.convertEpisodeToMediaItem(context = this, episode = episode, parentPodcast = matchingPodcast), searchResults) - } - if (searchResults.size >= MAX_SEARCH_RESULT_COUNT) { - return searchResults - } - } - - for (option in options) { - episodeManager.findFirstBySearchQuery(option)?.let { firstMatch -> - podcastManager.findPodcastByUuid(firstMatch.podcastUuid)?.let { parentPodcast -> - addToResultsIfNotExists(AutoConverter.convertEpisodeToMediaItem(context = this, episode = firstMatch, parentPodcast = parentPodcast), searchResults) - } - - if (searchResults.size >= MAX_SEARCH_RESULT_COUNT) { - return searchResults - } + private suspend fun podcastSearch(term: String): List? { + val termCleaned = term.trim() + // search for local podcasts + val localPodcasts = podcastManager.findSubscribedNoOrder() + .filter { it.title.contains(termCleaned, ignoreCase = true) || it.author.contains(termCleaned, ignoreCase = true) } + .sortedBy { PodcastsSortType.cleanStringForSort(it.title) } + // search for podcasts on the server + val serverPodcasts = try { + // only search the server if the term is over one character long + if (termCleaned.length <= 1) { + emptyList() + } else { + serverManager.searchForPodcastsSuspend(searchTerm = term, resources = resources).searchResults } - } - - if (searchResults.isEmpty()) { // Try the server - serverManager.searchForPodcastsRx(query).blockingGet().searchResults.forEach { podcast -> - val mediaItem = convertPodcastToMediaItem(podcast = podcast, context = this) - addToResultsIfNotExists(mediaItem, searchResults) + } catch (ex: Exception) { + Timber.e(ex) + // display the error message when the server call fails only if there is no local podcasts to display + if (localPodcasts.isEmpty()) { + return null } + emptyList() } - - return searchResults - } - - private fun addToResultsIfNotExists(item: MediaBrowserCompat.MediaItem?, searchResults: MutableList) { - item ?: return - - for (existingItem in searchResults) { - if (existingItem.mediaId == item.mediaId) return - } - - searchResults.add(item) + // merge the local and remote podcasts + val podcasts = (localPodcasts + serverPodcasts).distinctBy { it.uuid } + // convert podcasts to the media browser format + return podcasts.mapNotNull { podcast -> convertPodcastToMediaItem(context = this, podcast = podcast) } } } diff --git a/modules/services/repositories/src/main/java/au/com/shiftyjelly/pocketcasts/repositories/podcast/EpisodeManager.kt b/modules/services/repositories/src/main/java/au/com/shiftyjelly/pocketcasts/repositories/podcast/EpisodeManager.kt index ba9abd5d459..f470695c437 100644 --- a/modules/services/repositories/src/main/java/au/com/shiftyjelly/pocketcasts/repositories/podcast/EpisodeManager.kt +++ b/modules/services/repositories/src/main/java/au/com/shiftyjelly/pocketcasts/repositories/podcast/EpisodeManager.kt @@ -40,7 +40,6 @@ interface EpisodeManager { fun findLatestUnfinishedEpisodeByPodcast(podcast: Podcast): Episode? fun findLatestEpisodeToPlay(): Episode? fun observeEpisodesByPodcastOrderedRx(podcast: Podcast): Flowable> - fun findPodcastEpisodesForMediaBrowserSearch(podcastUuid: String): List fun observeEpisodesWhere(queryAfterWhere: String): Flowable> fun observeDownloadingEpisodes(): LiveData> diff --git a/modules/services/repositories/src/main/java/au/com/shiftyjelly/pocketcasts/repositories/podcast/EpisodeManagerImpl.kt b/modules/services/repositories/src/main/java/au/com/shiftyjelly/pocketcasts/repositories/podcast/EpisodeManagerImpl.kt index 9e3156f6d90..648ab6349e0 100644 --- a/modules/services/repositories/src/main/java/au/com/shiftyjelly/pocketcasts/repositories/podcast/EpisodeManagerImpl.kt +++ b/modules/services/repositories/src/main/java/au/com/shiftyjelly/pocketcasts/repositories/podcast/EpisodeManagerImpl.kt @@ -173,10 +173,6 @@ class EpisodeManagerImpl @Inject constructor( } } - override fun findPodcastEpisodesForMediaBrowserSearch(podcastUuid: String): List { - return episodeDao.findPodcastEpisodesForMediaBrowserSearch(podcastUuid) - } - override fun findEpisodesWhere(queryAfterWhere: String): List { return episodeDao.findEpisodes(SimpleSQLiteQuery("SELECT episodes.* FROM episodes JOIN podcasts ON episodes.podcast_id = podcasts.uuid WHERE podcasts.subscribed = 1 AND $queryAfterWhere")) } diff --git a/modules/services/servers/src/main/java/au/com/shiftyjelly/pocketcasts/servers/ServerManager.kt b/modules/services/servers/src/main/java/au/com/shiftyjelly/pocketcasts/servers/ServerManager.kt index 300eaba5911..f3d9196946f 100644 --- a/modules/services/servers/src/main/java/au/com/shiftyjelly/pocketcasts/servers/ServerManager.kt +++ b/modules/services/servers/src/main/java/au/com/shiftyjelly/pocketcasts/servers/ServerManager.kt @@ -1,9 +1,12 @@ package au.com.shiftyjelly.pocketcasts.servers +import android.content.res.Resources import android.os.Build import android.os.Handler import android.os.Looper import android.text.TextUtils +import au.com.shiftyjelly.pocketcasts.localization.R +import au.com.shiftyjelly.pocketcasts.localization.helper.LocaliseHelper import au.com.shiftyjelly.pocketcasts.models.entity.Podcast import au.com.shiftyjelly.pocketcasts.models.to.Share import au.com.shiftyjelly.pocketcasts.preferences.Settings @@ -13,6 +16,7 @@ import au.com.shiftyjelly.pocketcasts.servers.model.AuthResultModel import au.com.shiftyjelly.pocketcasts.utils.log.LogBuffer import io.reactivex.Single import io.reactivex.SingleEmitter +import kotlinx.coroutines.suspendCancellableCoroutine import okhttp3.Call import okhttp3.Callback import okhttp3.FormBody @@ -28,6 +32,8 @@ import java.io.IOException import java.util.Locale import javax.inject.Inject import javax.inject.Singleton +import kotlin.coroutines.resume +import kotlin.coroutines.resumeWithException @Singleton open class ServerManager @Inject constructor( @@ -119,6 +125,39 @@ open class ServerManager @Inject constructor( ) } + suspend fun searchForPodcastsSuspend(searchTerm: String, resources: Resources): PodcastSearch { + if (searchTerm.isEmpty()) { + return PodcastSearch() + } + return suspendCancellableCoroutine { continuation -> + searchForPodcasts( + searchTerm = searchTerm, + callback = object : ServerCallback { + override fun dataReturned(result: PodcastSearch?) { + if (result == null) { + continuation.resumeWithException(Exception(resources.getString(R.string.error_search_failed))) + } else { + continuation.resume(result) + } + } + + override fun onFailed( + errorCode: Int, + userMessage: String?, + serverMessageId: String?, + serverMessage: String?, + throwable: Throwable?, + ) { + val message = LocaliseHelper.serverMessageIdToMessage(serverMessageId, resources::getString) + ?: userMessage + ?: resources.getString(R.string.error_search_failed) + continuation.resumeWithException(throwable ?: Exception(message)) + } + } + ) + } + } + open fun searchForPodcastsRx(searchTerm: String): Single { return convertCallToRx { emitter -> searchForPodcasts(searchTerm, getRxServerCallback(emitter))