diff --git a/app/src/main/java/org/schabi/newpipe/fragments/list/search/SearchFragment.java b/app/src/main/java/org/schabi/newpipe/fragments/list/search/SearchFragment.java index 5175e009685..0acb24cef87 100644 --- a/app/src/main/java/org/schabi/newpipe/fragments/list/search/SearchFragment.java +++ b/app/src/main/java/org/schabi/newpipe/fragments/list/search/SearchFragment.java @@ -26,7 +26,6 @@ import android.view.animation.DecelerateInterpolator; import android.view.inputmethod.EditorInfo; import android.widget.EditText; -import android.widget.TextView; import androidx.annotation.NonNull; import androidx.annotation.Nullable; @@ -34,6 +33,7 @@ import androidx.appcompat.app.AlertDialog; import androidx.appcompat.widget.TooltipCompat; import androidx.core.text.HtmlCompat; +import androidx.lifecycle.ViewModelProvider; import androidx.preference.PreferenceManager; import androidx.recyclerview.widget.ItemTouchHelper; import androidx.recyclerview.widget.RecyclerView; @@ -69,22 +69,17 @@ import java.util.ArrayList; import java.util.Arrays; -import java.util.Collections; import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Queue; -import java.util.concurrent.TimeUnit; -import java.util.stream.Collectors; import icepick.State; import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers; import io.reactivex.rxjava3.core.Observable; -import io.reactivex.rxjava3.core.Single; import io.reactivex.rxjava3.disposables.CompositeDisposable; import io.reactivex.rxjava3.disposables.Disposable; import io.reactivex.rxjava3.schedulers.Schedulers; -import io.reactivex.rxjava3.subjects.PublishSubject; public class SearchFragment extends BaseListFragment> implements BackPressable { @@ -92,18 +87,7 @@ public class SearchFragment extends BaseListFragment=). - * (local ones will be fetched regardless of the length) - */ - private static final int THRESHOLD_NETWORK_SUGGESTION = 1; - - /** - * How much time have to pass without emitting a item (i.e. the user stop typing) - * to fetch/show the suggestions, in milliseconds. - */ - private static final int SUGGESTIONS_DEBOUNCE = 120; //ms - private final PublishSubject suggestionPublisher = PublishSubject.create(); + private SearchViewModel searchViewModel; @State int filterItemCheckedId = -1; @@ -148,7 +132,6 @@ public class SearchFragment extends BaseListFragment { + if (response.isOnNext()) { + if (response.getValue() != null) { + handleSuggestions(response.getValue()); + } + } else if (response.isOnError() + && response.getError() != null + && !ExceptionUtils.isInterruptedCaused(response.getError())) { + showSnackBarError(new ErrorInfo(response.getError(), + UserAction.GET_SUGGESTIONS, searchString, serviceId)); + } + }); + searchToolbarContainer = activity.findViewById(R.id.toolbar_search_container); searchEditText = searchToolbarContainer.findViewById(R.id.toolbar_search_edit_text); searchClear = searchToolbarContainer.findViewById(R.id.toolbar_search_clear); @@ -385,9 +360,7 @@ public void readFrom(@NonNull final Queue savedObjects) throws Exception @Override public void onSaveInstanceState(@NonNull final Bundle bundle) { - searchString = searchEditText != null - ? searchEditText.getText().toString() - : searchString; + searchString = searchEditText != null ? searchEditText.getText().toString() : searchString; super.onSaveInstanceState(bundle); } @@ -401,7 +374,7 @@ public void reloadContent() { || (searchEditText != null && !TextUtils.isEmpty(searchEditText.getText()))) { search(!TextUtils.isEmpty(searchString) ? searchString - : searchEditText.getText().toString(), this.contentFilter, ""); + : searchEditText.getText().toString()); } else { if (searchEditText != null) { searchEditText.setText(""); @@ -564,7 +537,7 @@ private void initSearchListeners() { suggestionListAdapter.setListener(new SuggestionListAdapter.OnSuggestionItemSelected() { @Override public void onSuggestionItemSelected(final SuggestionItem item) { - search(item.query, new String[0], ""); + search(item.query); searchEditText.setText(item.query); } @@ -603,31 +576,25 @@ public void afterTextChanged(final Editable s) { s.removeSpan(span); } - final String newText = searchEditText.getText().toString(); - suggestionPublisher.onNext(newText); + searchViewModel.updateSearchQuery(searchEditText.getText().toString()); } }; searchEditText.addTextChangedListener(textWatcher); - searchEditText.setOnEditorActionListener( - (TextView v, int actionId, KeyEvent event) -> { - if (DEBUG) { - Log.d(TAG, "onEditorAction() called with: v = [" + v + "], " - + "actionId = [" + actionId + "], event = [" + event + "]"); - } - if (actionId == EditorInfo.IME_ACTION_PREVIOUS) { - hideKeyboardSearch(); - } else if (event != null - && (event.getKeyCode() == KeyEvent.KEYCODE_ENTER - || event.getAction() == EditorInfo.IME_ACTION_SEARCH)) { - search(searchEditText.getText().toString(), new String[0], ""); - return true; - } - return false; - }); - - if (suggestionDisposable == null || suggestionDisposable.isDisposed()) { - initSuggestionObserver(); - } + searchEditText.setOnEditorActionListener((v, actionId, event) -> { + if (DEBUG) { + Log.d(TAG, "onEditorAction() called with: v = [" + v + "], " + + "actionId = [" + actionId + "], event = [" + event + "]"); + } + if (actionId == EditorInfo.IME_ACTION_PREVIOUS) { + hideKeyboardSearch(); + } else if (event != null + && (event.getKeyCode() == KeyEvent.KEYCODE_ENTER + || event.getAction() == EditorInfo.IME_ACTION_SEARCH)) { + search(searchEditText.getText().toString()); + return true; + } + return false; + }); } private void unsetSearchListeners() { @@ -693,8 +660,8 @@ private void showDeleteSuggestionDialog(final SuggestionItem item) { final Disposable onDelete = historyRecordManager.deleteSearchHistory(query) .observeOn(AndroidSchedulers.mainThread()) .subscribe( - howManyDeleted -> suggestionPublisher - .onNext(searchEditText.getText().toString()), + howManyDeleted -> searchViewModel + .updateSearchQuery(searchEditText.getText().toString()), throwable -> showSnackBarError(new ErrorInfo(throwable, UserAction.DELETE_FROM_HISTORY, "Deleting item failed"))); @@ -716,98 +683,12 @@ public boolean onBackPressed() { return false; } - - private Observable> getLocalSuggestionsObservable( - final String query, final int similarQueryLimit) { - return historyRecordManager - .getRelatedSearches(query, similarQueryLimit, 25) - .toObservable() - .map(searchHistoryEntries -> - searchHistoryEntries.stream() - .map(entry -> new SuggestionItem(true, entry)) - .collect(Collectors.toList())); - } - - private Observable> getRemoteSuggestionsObservable(final String query) { - return ExtractorHelper - .suggestionsFor(serviceId, query) - .toObservable() - .map(strings -> { - final List result = new ArrayList<>(); - for (final String entry : strings) { - result.add(new SuggestionItem(false, entry)); - } - return result; - }); - } - - private void initSuggestionObserver() { - if (DEBUG) { - Log.d(TAG, "initSuggestionObserver() called"); - } - if (suggestionDisposable != null) { - suggestionDisposable.dispose(); - } - - suggestionDisposable = suggestionPublisher - .debounce(SUGGESTIONS_DEBOUNCE, TimeUnit.MILLISECONDS) - .startWithItem(searchString == null ? "" : searchString) - .switchMap(query -> { - // Only show remote suggestions if they are enabled in settings and - // the query length is at least THRESHOLD_NETWORK_SUGGESTION - final boolean shallShowRemoteSuggestionsNow = showRemoteSuggestions - && query.length() >= THRESHOLD_NETWORK_SUGGESTION; - - if (showLocalSuggestions && shallShowRemoteSuggestionsNow) { - return Observable.zip( - getLocalSuggestionsObservable(query, 3), - getRemoteSuggestionsObservable(query), - (local, remote) -> { - remote.removeIf(remoteItem -> local.stream().anyMatch( - localItem -> localItem.equals(remoteItem))); - local.addAll(remote); - return local; - }) - .materialize(); - } else if (showLocalSuggestions) { - return getLocalSuggestionsObservable(query, 25) - .materialize(); - } else if (shallShowRemoteSuggestionsNow) { - return getRemoteSuggestionsObservable(query) - .materialize(); - } else { - return Single.fromCallable(Collections::emptyList) - .toObservable() - .materialize(); - } - }) - .subscribeOn(Schedulers.io()) - .observeOn(AndroidSchedulers.mainThread()) - .subscribe( - listNotification -> { - if (listNotification.isOnNext()) { - if (listNotification.getValue() != null) { - handleSuggestions(listNotification.getValue()); - } - } else if (listNotification.isOnError() - && listNotification.getError() != null - && !ExceptionUtils.isInterruptedCaused( - listNotification.getError())) { - showSnackBarError(new ErrorInfo(listNotification.getError(), - UserAction.GET_SUGGESTIONS, searchString, serviceId)); - } - }, throwable -> showSnackBarError(new ErrorInfo( - throwable, UserAction.GET_SUGGESTIONS, searchString, serviceId))); - } - @Override protected void doInitialLoadLogic() { // no-op } - private void search(final String theSearchString, - final String[] theContentFilter, - final String theSortFilter) { + private void search(final String theSearchString) { if (DEBUG) { Log.d(TAG, "search() called with: query = [" + theSearchString + "]"); } @@ -849,7 +730,7 @@ private void search(final String theSearchString, throwable -> showSnackBarError(new ErrorInfo(throwable, UserAction.SEARCHED, theSearchString, serviceId)) )); - suggestionPublisher.onNext(theSearchString); + searchViewModel.updateSearchQuery(theSearchString); startLoading(false); } @@ -924,18 +805,16 @@ private void changeContentFilter(final MenuItem item, final List theCont contentFilter = theContentFilter.toArray(new String[0]); if (!TextUtils.isEmpty(searchString)) { - search(searchString, contentFilter, sortFilter); + search(searchString); } } - private void setQuery(final int theServiceId, - final String theSearchString, - final String[] theContentFilter, - final String theSortFilter) { + private void setQuery(final int theServiceId, final String theSearchString, + final String[] theContentFilter) { serviceId = theServiceId; searchString = theSearchString; contentFilter = theContentFilter; - sortFilter = theSortFilter; + sortFilter = ""; } /*////////////////////////////////////////////////////////////////////////// @@ -1020,7 +899,7 @@ private void handleSearchSuggestion() { searchBinding.correctSuggestion.setOnClickListener(v -> { searchBinding.correctSuggestion.setVisibility(View.GONE); - search(searchSuggestion, contentFilter, sortFilter); + search(searchSuggestion); searchEditText.setText(searchSuggestion); }); @@ -1079,8 +958,8 @@ public void onSuggestionItemSwiped(@NonNull final RecyclerView.ViewHolder viewHo final Disposable onDelete = historyRecordManager.deleteSearchHistory(query) .observeOn(AndroidSchedulers.mainThread()) .subscribe( - howManyDeleted -> suggestionPublisher - .onNext(searchEditText.getText().toString()), + howManyDeleted -> searchViewModel + .updateSearchQuery(searchEditText.getText().toString()), throwable -> showSnackBarError(new ErrorInfo(throwable, UserAction.DELETE_FROM_HISTORY, "Deleting item failed"))); disposables.add(onDelete); diff --git a/app/src/main/java/org/schabi/newpipe/fragments/list/search/SearchViewModel.kt b/app/src/main/java/org/schabi/newpipe/fragments/list/search/SearchViewModel.kt new file mode 100644 index 00000000000..aeb0a0589cb --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/fragments/list/search/SearchViewModel.kt @@ -0,0 +1,101 @@ +package org.schabi.newpipe.fragments.list.search + +import android.app.Application +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewmodel.initializer +import androidx.lifecycle.viewmodel.viewModelFactory +import io.reactivex.rxjava3.core.Notification +import io.reactivex.rxjava3.core.Observable +import io.reactivex.rxjava3.subjects.PublishSubject +import org.schabi.newpipe.App +import org.schabi.newpipe.local.history.HistoryRecordManager +import org.schabi.newpipe.util.ExtractorHelper +import java.util.concurrent.TimeUnit + +class SearchViewModel( + application: Application, + private val serviceId: Int, + private val showLocalSuggestions: Boolean, + private val showRemoteSuggestions: Boolean +) : ViewModel() { + private val historyRecordManager = HistoryRecordManager(application) + private val suggestionPublisher = PublishSubject.create() + + private val suggestionMutableLiveData = MutableLiveData>>() + val suggestionLiveData: LiveData>> + get() = suggestionMutableLiveData + + private val suggestionDisposable = suggestionPublisher + .startWithItem("") + .distinctUntilChanged() + .debounce(SUGGESTIONS_DEBOUNCE, TimeUnit.MILLISECONDS) + .switchMap { query: String -> + // Only show remote suggestions if they are enabled in settings and + // the query length is at least THRESHOLD_NETWORK_SUGGESTION + val shallShowRemoteSuggestionsNow = showRemoteSuggestions && + query.length >= THRESHOLD_NETWORK_SUGGESTION + if (showLocalSuggestions && shallShowRemoteSuggestionsNow) { + Observable.zip( + getLocalSuggestionsObservable(query, 3), + getRemoteSuggestionsObservable(query) + ) { local, remote -> (local + remote).distinct() }.materialize() + } else if (showLocalSuggestions) { + getLocalSuggestionsObservable(query, 25).materialize() + } else if (shallShowRemoteSuggestionsNow) { + getRemoteSuggestionsObservable(query).materialize() + } else { + Observable.just(emptyList()).materialize() + } + } + .subscribe({ + suggestionMutableLiveData.postValue(it) + }) { + suggestionMutableLiveData.postValue(Notification.createOnError(it)) + } + + override fun onCleared() { + suggestionDisposable.dispose() + } + + fun updateSearchQuery(query: String) { + suggestionPublisher.onNext(query) + } + + private fun getLocalSuggestionsObservable(query: String, similarQueryLimit: Int): Observable> { + return historyRecordManager.getRelatedSearches(query, similarQueryLimit, 25) + .toObservable() + .map { entries -> entries.map { SuggestionItem(true, it) } } + } + + private fun getRemoteSuggestionsObservable(query: String): Observable> { + return ExtractorHelper.getSuggestionsFor(serviceId, query) + .toObservable() + .map { entries -> entries.map { SuggestionItem(false, it) } } + } + + companion object { + /** + * How much time have to pass without emitting a item (i.e. the user stop typing) + * to fetch/show the suggestions, in milliseconds. + */ + private const val SUGGESTIONS_DEBOUNCE = 120L // ms + + /** + * The suggestions will only be fetched from network if the query meet this threshold (>=). + * (local ones will be fetched regardless of the length) + */ + private const val THRESHOLD_NETWORK_SUGGESTION = 1 + + fun getFactory( + serviceId: Int, + showLocalSuggestions: Boolean, + showRemoteSuggestions: Boolean + ) = viewModelFactory { + initializer { + SearchViewModel(App.getApp(), serviceId, showLocalSuggestions, showRemoteSuggestions) + } + } + } +} diff --git a/app/src/main/java/org/schabi/newpipe/util/ExtractorHelper.java b/app/src/main/java/org/schabi/newpipe/util/ExtractorHelper.java index 27009efd192..e614c5c755d 100644 --- a/app/src/main/java/org/schabi/newpipe/util/ExtractorHelper.java +++ b/app/src/main/java/org/schabi/newpipe/util/ExtractorHelper.java @@ -100,7 +100,7 @@ public static Single> getMoreSearchItems( } - public static Single> suggestionsFor(final int serviceId, final String query) { + public static Single> getSuggestionsFor(final int serviceId, final String query) { checkServiceId(serviceId); return Single.fromCallable(() -> { final SuggestionExtractor extractor = NewPipe.getService(serviceId)