diff --git a/app/build.gradle b/app/build.gradle index 6b51f602983..7c447764e78 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -198,7 +198,7 @@ dependencies { // name and the commit hash with the commit hash of the (pushed) commit you want to test // This works thanks to JitPack: https://jitpack.io/ implementation 'com.github.TeamNewPipe:nanojson:1d9e1aea9049fc9f85e68b43ba39fe7be1c1f751' - implementation 'com.github.TeamNewPipe:NewPipeExtractor:v0.23.1' + implementation 'com.github.bravenewpipe:NewPipeExtractor:67366ea7b1' implementation 'com.github.TeamNewPipe:NoNonsense-FilePicker:5.0.0' /** Checkstyle **/ diff --git a/app/src/main/java/org/schabi/newpipe/fragments/list/channel/ChannelFragment.java b/app/src/main/java/org/schabi/newpipe/fragments/list/channel/ChannelFragment.java index b16f40a4ae5..fe9ec6997cb 100644 --- a/app/src/main/java/org/schabi/newpipe/fragments/list/channel/ChannelFragment.java +++ b/app/src/main/java/org/schabi/newpipe/fragments/list/channel/ChannelFragment.java @@ -38,6 +38,7 @@ import org.schabi.newpipe.extractor.channel.ChannelInfo; import org.schabi.newpipe.extractor.exceptions.ContentNotSupportedException; import org.schabi.newpipe.extractor.linkhandler.ListLinkHandler; +import org.schabi.newpipe.extractor.search.filter.FilterItem; import org.schabi.newpipe.fragments.BaseStateFragment; import org.schabi.newpipe.fragments.detail.TabAdapter; import org.schabi.newpipe.ktx.AnimationType; @@ -461,7 +462,7 @@ private void updateTabs() { .getDefaultSharedPreferences(context); for (final ListLinkHandler linkHandler : currentInfo.getTabs()) { - final String tab = linkHandler.getContentFilters().get(0); + final FilterItem tab = linkHandler.getContentFilters().get(0); if (ChannelTabHelper.showChannelTab(context, preferences, tab)) { final ChannelTabFragment channelTabFragment = ChannelTabFragment.getInstance(serviceId, linkHandler, name); 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 4bae6f1cac4..09c8527637f 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 @@ -3,7 +3,6 @@ import static androidx.recyclerview.widget.ItemTouchHelper.Callback.makeMovementFlags; import static org.schabi.newpipe.ktx.ViewUtils.animate; import static org.schabi.newpipe.util.ExtractorHelper.showMetaInfoInTextView; -import static java.util.Arrays.asList; import android.app.Activity; import android.content.Context; @@ -33,16 +32,18 @@ import androidx.appcompat.app.ActionBar; import androidx.appcompat.app.AlertDialog; import androidx.appcompat.widget.TooltipCompat; -import androidx.collection.SparseArrayCompat; import androidx.core.text.HtmlCompat; +import androidx.fragment.app.DialogFragment; +import androidx.fragment.app.FragmentManager; +import androidx.lifecycle.ViewModelProvider; import androidx.preference.PreferenceManager; import androidx.recyclerview.widget.ItemTouchHelper; import androidx.recyclerview.widget.RecyclerView; +import org.schabi.newpipe.App; import org.schabi.newpipe.R; import org.schabi.newpipe.databinding.FragmentSearchBinding; import org.schabi.newpipe.error.ErrorInfo; -import org.schabi.newpipe.error.ErrorUtil; import org.schabi.newpipe.error.ReCaptchaActivity; import org.schabi.newpipe.error.UserAction; import org.schabi.newpipe.extractor.InfoItem; @@ -53,10 +54,13 @@ import org.schabi.newpipe.extractor.StreamingService; import org.schabi.newpipe.extractor.search.SearchExtractor; import org.schabi.newpipe.extractor.search.SearchInfo; -import org.schabi.newpipe.extractor.services.peertube.linkHandler.PeertubeSearchQueryHandlerFactory; -import org.schabi.newpipe.extractor.services.youtube.linkHandler.YoutubeSearchQueryHandlerFactory; +import org.schabi.newpipe.extractor.search.filter.FilterItem; import org.schabi.newpipe.fragments.BackPressable; import org.schabi.newpipe.fragments.list.BaseListFragment; +import org.schabi.newpipe.fragments.list.search.filter.SearchFilterChipDialogFragment; +import org.schabi.newpipe.fragments.list.search.filter.SearchFilterDialogFragment; +import org.schabi.newpipe.fragments.list.search.filter.SearchFilterLogic; +import org.schabi.newpipe.fragments.list.search.filter.SearchFilterOptionMenuAlikeDialogFragment; import org.schabi.newpipe.ktx.AnimationType; import org.schabi.newpipe.ktx.ExceptionUtils; import org.schabi.newpipe.local.history.HistoryRecordManager; @@ -66,7 +70,6 @@ import org.schabi.newpipe.util.ExtractorHelper; import org.schabi.newpipe.util.KeyboardUtil; import org.schabi.newpipe.util.NavigationHelper; -import org.schabi.newpipe.util.ServiceHelper; import java.util.ArrayList; import java.util.Arrays; @@ -104,9 +107,6 @@ public class SearchFragment extends BaseListFragment suggestionPublisher = PublishSubject.create(); - @State - int filterItemCheckedId = -1; - @State protected int serviceId = Constants.NO_SERVICE_ID; @@ -114,15 +114,9 @@ public class SearchFragment extends BaseListFragment selectedContentFilter = new ArrayList<>(); - @State - String sortFilter; + List selectedSortFilter = new ArrayList<>(); // these represents the last search @State @@ -140,8 +134,6 @@ public class SearchFragment extends BaseListFragment menuItemToFilterName = new SparseArrayCompat<>(); - private StreamingService service; private Page nextPage; private boolean showLocalSuggestions = true; private boolean showRemoteSuggestions = true; @@ -159,7 +151,7 @@ public class SearchFragment extends BaseListFragment userSelectedContentFilterList; + + @State + ArrayList userSelectedSortFilterList = null; + + protected SearchViewModel searchViewModel; + protected SearchFilterLogic.Factory.Variant logicVariant = + SearchFilterLogic.Factory.Variant.SEARCH_FILTER_LOGIC_DEFAULT; + + public static SearchFragment getInstance(final int serviceId, final String searchString) { - final SearchFragment searchFragment = new SearchFragment(); - searchFragment.setQuery(serviceId, searchString, new String[0], ""); + final SearchFragment searchFragment; + final App app = App.getApp(); + + + final String searchUi = PreferenceManager.getDefaultSharedPreferences(app) + .getString(app.getString(R.string.search_filter_ui_key), + app.getString(R.string.search_filter_ui_value)); + if (app.getString(R.string.search_filter_ui_option_menu_legacy_key).equals(searchUi)) { + searchFragment = new SearchFragmentLegacy(); + } else { + searchFragment = new SearchFragment(); + } + + searchFragment.setQuery(serviceId, searchString); if (!TextUtils.isEmpty(searchString)) { searchFragment.setSearchOnResume(); @@ -208,11 +223,53 @@ public void onAttach(@NonNull final Context context) { } @Override - public View onCreateView(final LayoutInflater inflater, @Nullable final ViewGroup container, + public View onCreateView(@NonNull final LayoutInflater inflater, + @Nullable final ViewGroup container, @Nullable final Bundle savedInstanceState) { + + if (userSelectedContentFilterList == null) { + userSelectedContentFilterList = new ArrayList<>(); + } + + if (userSelectedSortFilterList == null) { + userSelectedSortFilterList = new ArrayList<>(); + } + + initViewModel(); + + // observe the content/sort filter items lists + searchViewModel.getSelectedContentFilterItemListLiveData().observe( + getViewLifecycleOwner(), filterItems -> selectedContentFilter = filterItems); + searchViewModel.getSelectedSortFilterItemListLiveData().observe( + getViewLifecycleOwner(), filterItems -> selectedSortFilter = filterItems); + + // the content/sort filters ids lists are only + // observed here to store them via Icepick + searchViewModel.getUserSelectedContentFilterListLiveData().observe( + getViewLifecycleOwner(), filterIds -> userSelectedContentFilterList = filterIds); + searchViewModel.getUserSelectedSortFilterListLiveData().observe( + getViewLifecycleOwner(), filterIds -> userSelectedSortFilterList = filterIds); + + searchViewModel.getDoSearchLiveData().observe( + getViewLifecycleOwner(), doSearch -> { + if (doSearch) { + selectedFilters(selectedContentFilter, selectedSortFilter); + searchViewModel.weConsumedDoSearchLiveData(); + } + }); + return inflater.inflate(R.layout.fragment_search, container, false); } + protected void initViewModel() { + searchViewModel = new ViewModelProvider(this, SearchViewModel.Companion + .getFactory(serviceId, + logicVariant, + userSelectedContentFilterList, + userSelectedSortFilterList)) + .get(SearchViewModel.class); + } + @Override public void onViewCreated(@NonNull final View rootView, final Bundle savedInstanceState) { searchBinding = FragmentSearchBinding.bind(rootView); @@ -221,22 +278,12 @@ public void onViewCreated(@NonNull final View rootView, final Bundle savedInstan initSearchListeners(); } - private void updateService() { - try { - service = NewPipe.getService(serviceId); - } catch (final Exception e) { - ErrorUtil.showUiErrorSnackbar(this, "Getting service for id " + serviceId, e); - } - } - @Override public void onStart() { if (DEBUG) { Log.d(TAG, "onStart() called"); } super.onStart(); - - updateService(); } @Override @@ -268,11 +315,11 @@ public void onResume() { if (!TextUtils.isEmpty(searchString)) { if (wasLoading.getAndSet(false)) { - search(searchString, contentFilter, sortFilter); + search(searchString); return; } else if (infoListAdapter.getItemsList().isEmpty()) { if (savedState == null) { - search(searchString, contentFilter, sortFilter); + search(searchString); return; } else if (!isLoading.get() && !wasSearchFocused && lastPanelError == null) { infoListAdapter.clearStreamItemList(); @@ -325,7 +372,7 @@ public void onActivityResult(final int requestCode, final int resultCode, final if (requestCode == ReCaptchaActivity.RECAPTCHA_REQUEST) { if (resultCode == Activity.RESULT_OK && !TextUtils.isEmpty(searchString)) { - search(searchString, contentFilter, sortFilter); + search(searchString); } else { Log.e(TAG, "ReCaptcha failed"); } @@ -391,6 +438,7 @@ public void onSaveInstanceState(@NonNull final Bundle bundle) { searchString = searchEditText != null ? searchEditText.getText().toString() : searchString; + super.onSaveInstanceState(bundle); } @@ -404,7 +452,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(""); @@ -429,60 +477,22 @@ public void onCreateOptionsMenu(@NonNull final Menu menu, supportActionBar.setDisplayHomeAsUpEnabled(true); } - int itemId = 0; - boolean isFirstItem = true; - final Context c = getContext(); - - if (service == null) { - Log.w(TAG, "onCreateOptionsMenu() called with null service"); - updateService(); - } - - for (final String filter : service.getSearchQHFactory().getAvailableContentFilter()) { - if (filter.equals(YoutubeSearchQueryHandlerFactory.MUSIC_SONGS)) { - final MenuItem musicItem = menu.add(2, - itemId++, - 0, - "YouTube Music"); - musicItem.setEnabled(false); - } else if (filter.equals(PeertubeSearchQueryHandlerFactory.SEPIA_VIDEOS)) { - final MenuItem sepiaItem = menu.add(2, - itemId++, - 0, - "Sepia Search"); - sepiaItem.setEnabled(false); - } - menuItemToFilterName.put(itemId, filter); - final MenuItem item = menu.add(1, - itemId++, - 0, - ServiceHelper.getTranslatedFilterString(filter, c)); - if (isFirstItem) { - item.setChecked(true); - isFirstItem = false; - } - } - menu.setGroupCheckable(1, true, true); + createMenu(menu, inflater); + } - restoreFilterChecked(menu, filterItemCheckedId); + protected void createMenu(@NonNull final Menu menu, + @NonNull final MenuInflater inflater) { + inflater.inflate(R.menu.menu_search_fragment, menu); } @Override public boolean onOptionsItemSelected(@NonNull final MenuItem item) { - final var filter = Collections.singletonList(menuItemToFilterName.get(item.getItemId())); - changeContentFilter(item, filter); - return true; - } - - private void restoreFilterChecked(final Menu menu, final int itemId) { - if (itemId != -1) { - final MenuItem item = menu.findItem(itemId); - if (item == null) { - return; - } - - item.setChecked(true); + if (item.getItemId() == R.id.action_filter) { + hideKeyboardSearch(); + showSelectFiltersDialog(); + return false; } + return true; } /*////////////////////////////////////////////////////////////////////////// @@ -562,7 +572,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); } @@ -619,7 +629,7 @@ public void afterTextChanged(final Editable s) { } else if (event != null && (event.getKeyCode() == KeyEvent.KEYCODE_ENTER || event.getAction() == EditorInfo.IME_ACTION_SEARCH)) { - search(searchEditText.getText().toString(), new String[0], ""); + search(searchEditText.getText().toString()); return true; } return false; @@ -671,7 +681,7 @@ private void showKeyboardSearch() { KeyboardUtil.showKeyboard(activity, searchEditText); } - private void hideKeyboardSearch() { + protected void hideKeyboardSearch() { if (DEBUG) { Log.d(TAG, "hideKeyboardSearch() called"); } @@ -805,9 +815,7 @@ 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 + "]"); } @@ -862,13 +870,12 @@ public void startLoading(final boolean forceLoad) { } searchDisposable = ExtractorHelper.searchFor(serviceId, searchString, - Arrays.asList(contentFilter), - sortFilter) + selectedContentFilter, + selectedSortFilter) .subscribeOn(Schedulers.io()) .observeOn(AndroidSchedulers.mainThread()) .doOnEvent((searchResult, throwable) -> isLoading.set(false)) .subscribe(this::handleResult, this::onItemError); - } @Override @@ -884,8 +891,8 @@ protected void loadMoreItems() { searchDisposable = ExtractorHelper.getMoreSearchItems( serviceId, searchString, - asList(contentFilter), - sortFilter, + selectedContentFilter, + selectedSortFilter, nextPage) .subscribeOn(Schedulers.io()) .observeOn(AndroidSchedulers.mainThread()) @@ -917,25 +924,21 @@ private void onItemError(final Throwable exception) { // Utils //////////////////////////////////////////////////////////////////////////*/ - private void changeContentFilter(final MenuItem item, final List theContentFilter) { - filterItemCheckedId = item.getItemId(); - item.setChecked(true); + public void selectedFilters(@NonNull final List theSelectedContentFilter, + @NonNull final List theSelectedSortFilter) { - contentFilter = theContentFilter.toArray(new String[0]); + selectedContentFilter = theSelectedContentFilter; + selectedSortFilter = theSelectedSortFilter; 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) { + final String theSearchString) { serviceId = theServiceId; searchString = theSearchString; - contentFilter = theContentFilter; - sortFilter = theSortFilter; } /*////////////////////////////////////////////////////////////////////////// @@ -1020,7 +1023,7 @@ private void handleSearchSuggestion() { searchBinding.correctSuggestion.setOnClickListener(v -> { searchBinding.correctSuggestion.setVisibility(View.GONE); - search(searchSuggestion, contentFilter, sortFilter); + search(searchSuggestion); searchEditText.setText(searchSuggestion); }); @@ -1085,4 +1088,22 @@ public void onSuggestionItemSwiped(@NonNull final RecyclerView.ViewHolder viewHo UserAction.DELETE_FROM_HISTORY, "Deleting item failed"))); disposables.add(onDelete); } + + private void showSelectFiltersDialog() { + final FragmentManager fragmentManager = getChildFragmentManager(); + final DialogFragment searchFilterUiDialog; + + final String searchUi = PreferenceManager.getDefaultSharedPreferences(App.getApp()) + .getString(getString(R.string.search_filter_ui_key), + getString(R.string.search_filter_ui_value)); + if (getString(R.string.search_filter_ui_option_menu_style_key).equals(searchUi)) { + searchFilterUiDialog = new SearchFilterOptionMenuAlikeDialogFragment(); + } else if (getString(R.string.search_filter_ui_chip_dialog_key).equals(searchUi)) { + searchFilterUiDialog = new SearchFilterChipDialogFragment(); + } else { // default dialog + searchFilterUiDialog = new SearchFilterDialogFragment(); + } + + searchFilterUiDialog.show(fragmentManager, "fragment_search"); + } } diff --git a/app/src/main/java/org/schabi/newpipe/fragments/list/search/SearchFragmentLegacy.java b/app/src/main/java/org/schabi/newpipe/fragments/list/search/SearchFragmentLegacy.java new file mode 100644 index 00000000000..7186983e2ea --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/fragments/list/search/SearchFragmentLegacy.java @@ -0,0 +1,73 @@ +// Created by evermind-zz 2022, licensed GNU GPL version 3 or later + +package org.schabi.newpipe.fragments.list.search; + +import android.os.Bundle; +import android.view.Menu; +import android.view.MenuInflater; +import android.view.MenuItem; +import android.view.View; + +import org.schabi.newpipe.R; +import org.schabi.newpipe.fragments.list.search.filter.SearchFilterLogic; +import org.schabi.newpipe.fragments.list.search.filter.SearchFilterUIOptionMenu; + +import androidx.annotation.NonNull; +import androidx.appcompat.widget.Toolbar; +import androidx.core.content.ContextCompat; +import icepick.State; + +/** + * Fragment that hosts the action menu based filter 'dialog'. + *

+ * Called ..Legacy because this was the way NewPipe had implemented the search filter dialog. + *

+ * The new UI's are handled by {@link SearchFragment} and implemented by + * using {@link androidx.fragment.app.DialogFragment}. + */ +public class SearchFragmentLegacy extends SearchFragment { + + @State + protected int countOnPrepareOptionsMenuCalls = 0; + private SearchFilterUIOptionMenu searchFilterUi; + + @Override + protected void initViewModel() { + logicVariant = SearchFilterLogic.Factory.Variant.SEARCH_FILTER_LOGIC_LEGACY; + super.initViewModel(); + + searchFilterUi = new SearchFilterUIOptionMenu( + searchViewModel.getSearchFilterLogic(), requireContext()); + } + + @Override + protected void createMenu(@NonNull final Menu menu, + @NonNull final MenuInflater inflater) { + searchFilterUi.createSearchUI(menu); + } + + @Override + public boolean onOptionsItemSelected(@NonNull final MenuItem item) { + return searchFilterUi.onOptionsItemSelected(item); + } + + @Override + protected void initViews(final View rootView, + final Bundle savedInstanceState) { + super.initViews(rootView, savedInstanceState); + final Toolbar toolbar = (Toolbar) searchToolbarContainer.getParent(); + toolbar.setOverflowIcon(ContextCompat.getDrawable(requireContext(), + R.drawable.ic_sort)); + } + + @Override + public void onPrepareOptionsMenu(@NonNull final Menu menu) { + super.onPrepareOptionsMenu(menu); + // workaround: we want to hide the keyboard in case we open the options + // menu. As somehow this method gets triggered twice but only the 2nd + // time is relevant as the options menu is selected by the user. + if (++countOnPrepareOptionsMenuCalls > 1) { + hideKeyboardSearch(); + } + } +} 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..0e0a5f14f2c --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/fragments/list/search/SearchViewModel.kt @@ -0,0 +1,99 @@ +package org.schabi.newpipe.fragments.list.search + +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewmodel.initializer +import androidx.lifecycle.viewmodel.viewModelFactory +import org.schabi.newpipe.extractor.NewPipe +import org.schabi.newpipe.extractor.search.filter.FilterItem +import org.schabi.newpipe.fragments.list.search.filter.InjectFilterItem +import org.schabi.newpipe.fragments.list.search.filter.SearchFilterLogic +import org.schabi.newpipe.fragments.list.search.filter.SearchFilterLogic.Factory.Variant + +/** + * This class hosts the search filters logic. It facilitates + * the communication with the SearchFragment* and the *DialogFragment + * based search filter UI's + */ +class SearchViewModel( + val serviceId: Int, + logicVariant: Variant, + userSelectedContentFilterList: List, + userSelectedSortFilterList: List +) : ViewModel() { + + private val selectedContentFilterMutableLiveData: MutableLiveData> = + MutableLiveData() + private var selectedSortFilterLiveData: MutableLiveData> = + MutableLiveData() + private var userSelectedSortFilterListMutableLiveData: MutableLiveData> = + MutableLiveData() + private var userSelectedContentFilterListMutableLiveData: MutableLiveData> = + MutableLiveData() + private var doSearchMutableLiveData: MutableLiveData = MutableLiveData() + + val selectedContentFilterItemListLiveData: LiveData> + get() = selectedContentFilterMutableLiveData + val selectedSortFilterItemListLiveData: LiveData> + get() = selectedSortFilterLiveData + val userSelectedContentFilterListLiveData: LiveData> + get() = userSelectedContentFilterListMutableLiveData + val userSelectedSortFilterListLiveData: LiveData> + get() = userSelectedSortFilterListMutableLiveData + val doSearchLiveData: LiveData + get() = doSearchMutableLiveData + + var searchFilterLogic: SearchFilterLogic + + init { + // inject before creating SearchFilterLogic + InjectFilterItem.DividerBetweenYoutubeAndYoutubeMusic.run() + + searchFilterLogic = SearchFilterLogic.Factory.create( + logicVariant, + NewPipe.getService(serviceId).searchQHFactory, null + ) + searchFilterLogic.restorePreviouslySelectedFilters( + userSelectedContentFilterList, + userSelectedSortFilterList + ) + + searchFilterLogic.setCallback { userSelectedContentFilter: List, + userSelectedSortFilter: List -> + selectedContentFilterMutableLiveData.value = + userSelectedContentFilter as MutableList + selectedSortFilterLiveData.value = + userSelectedSortFilter as MutableList + userSelectedContentFilterListMutableLiveData.value = + searchFilterLogic.selectedContentFilters + userSelectedSortFilterListMutableLiveData.value = + searchFilterLogic.selectedSortFilters + + doSearchMutableLiveData.value = true + } + } + + fun weConsumedDoSearchLiveData() { + doSearchMutableLiveData.value = false + } + + companion object { + + fun getFactory( + serviceId: Int, + logicVariant: Variant, + userSelectedContentFilterList: ArrayList, + userSelectedSortFilterList: ArrayList + ) = viewModelFactory { + initializer { + SearchViewModel( + serviceId, + logicVariant, + userSelectedContentFilterList, + userSelectedSortFilterList + ) + } + } + } +} diff --git a/app/src/main/java/org/schabi/newpipe/fragments/list/search/filter/BaseCreateSearchFilterUI.java b/app/src/main/java/org/schabi/newpipe/fragments/list/search/filter/BaseCreateSearchFilterUI.java new file mode 100644 index 00000000000..7bf8c455997 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/fragments/list/search/filter/BaseCreateSearchFilterUI.java @@ -0,0 +1,123 @@ +// Created by evermind-zz 2022, licensed GNU GPL version 3 or later + +package org.schabi.newpipe.fragments.list.search.filter; + +import android.content.Context; +import android.view.View; + +import org.schabi.newpipe.R; +import org.schabi.newpipe.extractor.search.filter.FilterGroup; +import org.schabi.newpipe.extractor.search.filter.FilterItem; + +import java.util.ArrayList; +import java.util.List; + +import androidx.annotation.NonNull; + +import static org.schabi.newpipe.fragments.list.search.filter.SearchFilterLogic.ICreateUiForFiltersWorker; + +/** + * Common base for the {@link SearchFilterDialogGenerator} and + * {@link SearchFilterOptionMenuAlikeDialogGenerator}'s + * {@link ICreateUiForFiltersWorker} implementation. + */ +public abstract class BaseCreateSearchFilterUI + implements ICreateUiForFiltersWorker { + + @NonNull + protected final BaseSearchFilterUiDialogGenerator dialogGenBase; + @NonNull + protected final Context context; + protected final List titleViewElements = new ArrayList<>(); + protected final SearchFilterLogic logic; + protected int titleResId; + + protected BaseCreateSearchFilterUI( + @NonNull final BaseSearchFilterUiDialogGenerator dialogGenBase, + @NonNull final SearchFilterLogic logic, + @NonNull final Context context, + final int titleResId) { + this.dialogGenBase = dialogGenBase; + this.logic = logic; + this.context = context; + this.titleResId = titleResId; + } + + @Override + public void createFilterItem(@NonNull final FilterItem filterItem, + @NonNull final FilterGroup filterGroup) { + // no implementation here all creation stuff is done in createFilterGroupBeforeItems + } + + @Override + public void createFilterGroupAfterItems(@NonNull final FilterGroup filterGroup) { + // no implementation here all creation stuff is done in createFilterGroupBeforeItems + } + + @Override + public void finish() { + // no implementation here all creation stuff is done in createFilterGroupBeforeItems + } + + /** + * This method is used to control the visibility of the title 'sort filter' if the + * chosen content filter has no sort filters. + * + * @param areFiltersVisible true if filter visible + */ + @Override + public void filtersVisible(final boolean areFiltersVisible) { + final int visibility = areFiltersVisible ? View.VISIBLE : View.GONE; + for (final View view : titleViewElements) { + if (view != null) { + view.setVisibility(visibility); + } + } + } + + public static class CreateContentFilterUI extends CreateSortFilterUI { + + public CreateContentFilterUI( + @NonNull final BaseSearchFilterUiDialogGenerator dialogGenBase, + @NonNull final Context context, + @NonNull final SearchFilterLogic logic) { + super(dialogGenBase, context, logic); + this.titleResId = R.string.filter_search_content_filters; + } + + @Override + public void createFilterGroupBeforeItems( + @NonNull final FilterGroup filterGroup) { + dialogGenBase.createFilterGroup(filterGroup, + logic::addContentFilterUiWrapperToItemMap, + logic::selectContentFilter); + } + + @Override + public void filtersVisible(final boolean areFiltersVisible) { + // no implementation here. As content filters have to be always visible + } + } + + public static class CreateSortFilterUI extends BaseCreateSearchFilterUI { + + public CreateSortFilterUI( + @NonNull final BaseSearchFilterUiDialogGenerator dialogGenBase, + @NonNull final Context context, + @NonNull final SearchFilterLogic logic) { + super(dialogGenBase, logic, context, R.string.filter_search_sort_filters); + } + + @Override + public void prepare() { + dialogGenBase.createTitle(context.getString(titleResId), titleViewElements); + } + + @Override + public void createFilterGroupBeforeItems(@NonNull final FilterGroup filterGroup) { + dialogGenBase.createFilterGroup(filterGroup, + logic::addSortFilterUiWrapperToItemMap, + logic::selectSortFilter); + } + } +} diff --git a/app/src/main/java/org/schabi/newpipe/fragments/list/search/filter/BaseItemWrapper.java b/app/src/main/java/org/schabi/newpipe/fragments/list/search/filter/BaseItemWrapper.java new file mode 100644 index 00000000000..cba5b3c7f48 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/fragments/list/search/filter/BaseItemWrapper.java @@ -0,0 +1,21 @@ +// Created by evermind-zz 2022, licensed GNU GPL version 3 or later + +package org.schabi.newpipe.fragments.list.search.filter; + +import org.schabi.newpipe.extractor.search.filter.FilterItem; + +import androidx.annotation.NonNull; + +public abstract class BaseItemWrapper implements SearchFilterLogic.IUiItemWrapper { + @NonNull + protected final FilterItem item; + + protected BaseItemWrapper(@NonNull final FilterItem item) { + this.item = item; + } + + @Override + public int getItemId() { + return item.getIdentifier(); + } +} diff --git a/app/src/main/java/org/schabi/newpipe/fragments/list/search/filter/BaseSearchFilterDialogFragment.java b/app/src/main/java/org/schabi/newpipe/fragments/list/search/filter/BaseSearchFilterDialogFragment.java new file mode 100644 index 00000000000..087abd7c830 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/fragments/list/search/filter/BaseSearchFilterDialogFragment.java @@ -0,0 +1,116 @@ +// Created by evermind-zz 2022, licensed GNU GPL version 3 or later + +package org.schabi.newpipe.fragments.list.search.filter; + +import android.os.Bundle; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; + +import org.schabi.newpipe.R; +import org.schabi.newpipe.fragments.list.search.SearchViewModel; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.appcompat.widget.Toolbar; +import androidx.fragment.app.DialogFragment; +import androidx.fragment.app.FragmentManager; +import androidx.lifecycle.ViewModelProvider; + +/** + * Base dialog class for {@link DialogFragment} based search filter dialogs. + */ +public abstract class BaseSearchFilterDialogFragment extends DialogFragment { + + protected BaseSearchFilterUiGenerator dialogGenerator; + protected SearchViewModel searchViewModel; + + private void createSearchFilterUi() { + dialogGenerator = createSearchFilterDialogGenerator(); + dialogGenerator.createSearchUI(); + } + + @Override + public void show(@NonNull final FragmentManager manager, @Nullable final String tag) { + // Avoid multiple instances of the dialog that could be triggered by multiple taps + if (manager.findFragmentByTag(tag) == null) { + super.show(manager, tag); + } + } + + protected abstract BaseSearchFilterUiGenerator createSearchFilterDialogGenerator(); + + /** + * As we have different bindings we need to get this sorted in a method. + * + * @return the {@link Toolbar} null if there is no toolbar available. + */ + @Nullable + protected abstract Toolbar getToolbar(); + + protected abstract View getRootView(@NonNull LayoutInflater inflater, + @Nullable ViewGroup container); + + @Override + public void onCreate(@Nullable final Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + // Make sure that the first parameter is pointing to instance of SearchFragment otherwise + // another SearchViewModel object will be created instead of the existing one used. + // -> the SearchViewModel is first instantiated in SearchFragment. Here we just use it. + searchViewModel = + new ViewModelProvider(requireParentFragment()).get(SearchViewModel.class); + } + + @Override + public View onCreateView(@NonNull final LayoutInflater inflater, + @Nullable final ViewGroup container, + final Bundle savedInstanceState) { + final View rootView = getRootView(inflater, container); + createSearchFilterUi(); + return rootView; + } + + @Override + public void onViewCreated(@NonNull final View view, final Bundle savedInstanceState) { + super.onViewCreated(view, savedInstanceState); + + final Toolbar toolbar = getToolbar(); + if (toolbar != null) { + initToolbar(toolbar); + } + } + + /** + * Initialize the toolbar. + *

+ * This method is only called if {@link #getToolbar()} is implemented to return a toolbar. + * + * @param toolbar the actual toolbar for this dialog fragment + */ + protected void initToolbar(@NonNull final Toolbar toolbar) { + toolbar.setTitle(R.string.filter); + toolbar.setNavigationIcon(R.drawable.ic_arrow_back); + toolbar.inflateMenu(R.menu.menu_search_filter_dialog_fragment); + toolbar.setNavigationOnClickListener(v -> dismiss()); + toolbar.setNavigationContentDescription(R.string.cancel); + + final View okButton = toolbar.findViewById(R.id.search); + okButton.setEnabled(true); + + final View resetButton = toolbar.findViewById(R.id.reset); + resetButton.setEnabled(true); + + toolbar.setOnMenuItemClickListener(item -> { + if (item.getItemId() == R.id.search) { + searchViewModel.getSearchFilterLogic().prepareForSearch(); + dismiss(); + return true; + } else if (item.getItemId() == R.id.reset) { + searchViewModel.getSearchFilterLogic().reset(); + return true; + } + return false; + }); + } +} diff --git a/app/src/main/java/org/schabi/newpipe/fragments/list/search/filter/BaseSearchFilterUiDialogGenerator.java b/app/src/main/java/org/schabi/newpipe/fragments/list/search/filter/BaseSearchFilterUiDialogGenerator.java new file mode 100644 index 00000000000..6beaca67ade --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/fragments/list/search/filter/BaseSearchFilterUiDialogGenerator.java @@ -0,0 +1,84 @@ +// Created by evermind-zz 2022, licensed GNU GPL version 3 or later + +package org.schabi.newpipe.fragments.list.search.filter; + +import android.content.Context; +import android.view.View; +import android.view.ViewGroup; +import android.widget.TextView; + +import org.schabi.newpipe.extractor.search.filter.FilterGroup; + +import java.util.List; + +import androidx.annotation.NonNull; + +import static android.util.TypedValue.COMPLEX_UNIT_DIP; +import static org.schabi.newpipe.fragments.list.search.filter.SearchFilterLogic.ICreateUiForFiltersWorker; + +public abstract class BaseSearchFilterUiDialogGenerator extends BaseSearchFilterUiGenerator { + private static final float FONT_SIZE_TITLE_ITEMS_IN_DIP = 20f; + + protected BaseSearchFilterUiDialogGenerator( + @NonNull final SearchFilterLogic logic, + @NonNull final Context context) { + super(logic, context); + } + + protected abstract void createTitle(@NonNull String name, + @NonNull List titleViewElements); + + protected abstract void createFilterGroup(@NonNull FilterGroup filterGroup, + @NonNull UiWrapperMapDelegate wrapperDelegate, + @NonNull UiSelectorDelegate selectorDelegate); + + @Override + protected ICreateUiForFiltersWorker createContentFilterWorker() { + return new BaseCreateSearchFilterUI.CreateContentFilterUI(this, context, logic); + } + + @Override + protected ICreateUiForFiltersWorker createSortFilterWorker() { + return new BaseCreateSearchFilterUI.CreateSortFilterUI(this, context, logic); + } + + /** + * Create a View that acts as a separator between two other {@link View}-Elements. + * + * @param layoutParams this layout will be modified to have the height of 1 -> to have a + * the actual separator line. + * @return the created {@link SeparatorLineView} + */ + @NonNull + protected SeparatorLineView createSeparatorLine( + @NonNull final ViewGroup.LayoutParams layoutParams) { + final SeparatorLineView separatorLine = new SeparatorLineView(context); + separatorLine.setBackgroundColor(getSeparatorLineColorFromTheme()); + layoutParams.height = 1; // always set the separator to the height of 1 + separatorLine.setLayoutParams(layoutParams); + return separatorLine; + } + + @NonNull + protected TextView createTitleText(@NonNull final String name, + @NonNull final ViewGroup.LayoutParams layoutParams) { + final TextView title = new TextView(context); + title.setText(name); + title.setTextSize(COMPLEX_UNIT_DIP, FONT_SIZE_TITLE_ITEMS_IN_DIP); + title.setLayoutParams(layoutParams); + return title; + } + + /** + * A special view to separate two other {@link View}s. + *

+ * class only needed to distinct this special view from other View based views. + * (eg. instanceof) + */ + protected static final class SeparatorLineView extends View { + + private SeparatorLineView(@NonNull final Context context) { + super(context); + } + } +} diff --git a/app/src/main/java/org/schabi/newpipe/fragments/list/search/filter/BaseSearchFilterUiGenerator.java b/app/src/main/java/org/schabi/newpipe/fragments/list/search/filter/BaseSearchFilterUiGenerator.java new file mode 100644 index 00000000000..3cab63c2858 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/fragments/list/search/filter/BaseSearchFilterUiGenerator.java @@ -0,0 +1,84 @@ +// Created by evermind-zz 2022, licensed GNU GPL version 3 or later + +package org.schabi.newpipe.fragments.list.search.filter; + +import android.content.Context; +import android.util.TypedValue; + +import org.schabi.newpipe.R; + +import androidx.annotation.NonNull; + +import static org.schabi.newpipe.fragments.list.search.filter.SearchFilterLogic.ICreateUiForFiltersWorker; +import static org.schabi.newpipe.fragments.list.search.filter.SearchFilterLogic.IUiItemWrapper; + +/** + * The base class to implement the search filter UI for content + * and sort filter dialogs eg. {@link SearchFilterDialogGenerator} + * or {@link SearchFilterOptionMenuAlikeDialogGenerator}. + */ +public abstract class BaseSearchFilterUiGenerator { + protected final ICreateUiForFiltersWorker contentFilterWorker; + protected final ICreateUiForFiltersWorker sortFilterWorker; + protected final Context context; + protected final SearchFilterLogic logic; + + protected BaseSearchFilterUiGenerator( + @NonNull final SearchFilterLogic logic, + @NonNull final Context context) { + this.context = context; + this.logic = logic; + this.contentFilterWorker = createContentFilterWorker(); + this.sortFilterWorker = createSortFilterWorker(); + } + + /** + * {@link ICreateUiForFiltersWorker}. + * + * @return the class that implements the UI for the content filters. + */ + protected abstract ICreateUiForFiltersWorker createContentFilterWorker(); + + /** + * {@link ICreateUiForFiltersWorker}. + * + * @return the class that implements the UI for the sort filters. + */ + protected abstract ICreateUiForFiltersWorker createSortFilterWorker(); + + protected int getSeparatorLineColorFromTheme() { + final TypedValue value = new TypedValue(); + context.getTheme().resolveAttribute(R.attr.colorAccent, value, true); + return value.data; + } + + /** + * Create the complete UI for the search filter dialog and make sure the initial + * visibility of the UI elements is done. + */ + public void createSearchUI() { + logic.initContentFiltersUi(contentFilterWorker); + logic.initSortFiltersUi(sortFilterWorker); + doMeasurementsIfNeeded(); + // make sure that only sort filters relevant to the selected content filter are shown + logic.showSortFilterContainerUI(); + } + + protected void doMeasurementsIfNeeded() { + // nothing to measure here, if you want to measure something override this method + } + + /** + * Helper interface used as 'function pointer'. + */ + protected interface UiWrapperMapDelegate { + void put(int identifier, IUiItemWrapper menuItemUiWrapper); + } + + /** + * Helper interface used as 'function pointer'. + */ + protected interface UiSelectorDelegate { + void selectFilter(int identifier); + } +} diff --git a/app/src/main/java/org/schabi/newpipe/fragments/list/search/filter/BaseUiItemWrapper.java b/app/src/main/java/org/schabi/newpipe/fragments/list/search/filter/BaseUiItemWrapper.java new file mode 100644 index 00000000000..7a2f876c5e0 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/fragments/list/search/filter/BaseUiItemWrapper.java @@ -0,0 +1,29 @@ +// Created by evermind-zz 2022, licensed GNU GPL version 3 or later + +package org.schabi.newpipe.fragments.list.search.filter; + +import android.view.View; + +import org.schabi.newpipe.extractor.search.filter.FilterItem; + +import androidx.annotation.NonNull; + +public abstract class BaseUiItemWrapper extends BaseItemWrapper { + @NonNull + protected final View view; + + protected BaseUiItemWrapper(@NonNull final FilterItem item, + @NonNull final View view) { + super(item); + this.view = view; + } + + @Override + public void setVisible(final boolean visible) { + if (visible) { + view.setVisibility(View.VISIBLE); + } else { + view.setVisibility(View.GONE); + } + } +} diff --git a/app/src/main/java/org/schabi/newpipe/fragments/list/search/filter/InjectFilterItem.java b/app/src/main/java/org/schabi/newpipe/fragments/list/search/filter/InjectFilterItem.java new file mode 100644 index 00000000000..8b4ccc54de2 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/fragments/list/search/filter/InjectFilterItem.java @@ -0,0 +1,147 @@ +package org.schabi.newpipe.fragments.list.search.filter; + +import org.schabi.newpipe.App; +import org.schabi.newpipe.R; +import org.schabi.newpipe.extractor.NewPipe; +import org.schabi.newpipe.extractor.exceptions.ExtractionException; +import org.schabi.newpipe.extractor.search.filter.FilterContainer; +import org.schabi.newpipe.extractor.search.filter.FilterGroup; +import org.schabi.newpipe.extractor.search.filter.FilterItem; +import org.schabi.newpipe.extractor.search.filter.LibraryStringIds; +import org.schabi.newpipe.extractor.services.youtube.search.filter.YoutubeFilters; + +import java.util.List; + +import androidx.annotation.NonNull; + +/** + * Inject a {@link FilterItem} that actually should not be a real filter. + *

+ * This base class is meant to inject eg {@link DividerItem} (that inherits {@link FilterItem}) + * as Divider between {@link FilterItem}. It will be shown in the UI's. + *

+ * Of course you have to handle {@link DividerItem} or whatever in the Ui's. + * For that for example have a look at {@link SearchFilterDialogSpinnerAdapter}. + */ +public abstract class InjectFilterItem { + + protected InjectFilterItem( + @NonNull final String serviceName, + final int injectedAfterFilterWithId, + @NonNull final FilterItem toBeInjectedFilterItem) { + + prepareAndInject(serviceName, injectedAfterFilterWithId, toBeInjectedFilterItem); + } + + // Please refer a static boolean to determine if already injected + protected abstract boolean isAlreadyInjected(); + + // Please refer a static boolean to determine if already injected + protected abstract void setAsInjected(); + + private void prepareAndInject( + @NonNull final String serviceName, + final int injectedAfterFilterWithId, + @NonNull final FilterItem toBeInjectedFilterItem) { + + if (isAlreadyInjected()) { // already run + return; + } + + try { // using serviceName to test if we are trying to inject into the right service + final List groups = NewPipe.getService(serviceName) + .getSearchQHFactory().getAvailableContentFilter().getFilterGroups(); + injectFilterItemIntoGroup( + groups, + injectedAfterFilterWithId, + toBeInjectedFilterItem); + setAsInjected(); + } catch (final ExtractionException ignored) { + // no the service we want to prepareAndInject -> so ignore + } + } + + private void injectFilterItemIntoGroup( + @NonNull final List groups, + final int injectedAfterFilterWithId, + @NonNull final FilterItem toBeInjectedFilterItem) { + + int indexForFilterId = 0; + boolean isFilterItemFound = false; + FilterGroup groupWithTheSearchFilterItem = null; + + for (final FilterGroup group : groups) { + for (final FilterItem item : group.getFilterItems()) { + if (item.getIdentifier() == injectedAfterFilterWithId) { + isFilterItemFound = true; + break; + } + indexForFilterId++; + } + + if (isFilterItemFound) { + groupWithTheSearchFilterItem = group; + break; + } + } + + if (isFilterItemFound) { + // we want to insert after the FilterItem we've searched + indexForFilterId++; + groupWithTheSearchFilterItem.getFilterItems() + .add(indexForFilterId, toBeInjectedFilterItem); + } + } + + /** + * Inject DividerItem between YouTube content filters and YoutubeMusic content filters. + */ + public static class DividerBetweenYoutubeAndYoutubeMusic extends InjectFilterItem { + + private static boolean isYoutubeMusicDividerInjected = false; + + protected DividerBetweenYoutubeAndYoutubeMusic() { + super(App.getApp().getApplicationContext().getString(R.string.youtube), + YoutubeFilters.ID_CF_MAIN_PLAYLISTS, + new DividerItem(R.string.search_filters_youtube_music) + ); + } + + /** + * Have a static runner method to avoid creating unnecessary objects if already inserted. + */ + public static void run() { + if (!isYoutubeMusicDividerInjected) { + new DividerBetweenYoutubeAndYoutubeMusic(); + } + } + + @Override + protected boolean isAlreadyInjected() { + return isYoutubeMusicDividerInjected; + } + + @Override + protected void setAsInjected() { + isYoutubeMusicDividerInjected = true; + } + } + + /** + * Used to have a title divider between regular {@link FilterItem}s. + */ + public static class DividerItem extends FilterItem { + + private final int resId; + + public DividerItem(final int resId) { + // the LibraryStringIds.. is not needed at all I just need one to satisfy FilterItem. + super(FilterContainer.ITEM_IDENTIFIER_UNKNOWN, LibraryStringIds.SEARCH_FILTERS_ALL); + this.resId = resId; + } + + public int getStringResId() { + return this.resId; + } + } +} diff --git a/app/src/main/java/org/schabi/newpipe/fragments/list/search/filter/SearchFilterChipDialogFragment.java b/app/src/main/java/org/schabi/newpipe/fragments/list/search/filter/SearchFilterChipDialogFragment.java new file mode 100644 index 00000000000..5774c3c1ae5 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/fragments/list/search/filter/SearchFilterChipDialogFragment.java @@ -0,0 +1,40 @@ +// Created by evermind-zz 2022, licensed GNU GPL version 3 or later + +package org.schabi.newpipe.fragments.list.search.filter; + +import android.content.res.Configuration; +import android.os.Bundle; +import android.util.DisplayMetrics; +import android.view.View; +import android.view.ViewGroup; + +import androidx.annotation.NonNull; + +/** + * Every search filter option in this dialog is a {@link com.google.android.material.chip.Chip}. + */ +public class SearchFilterChipDialogFragment extends SearchFilterDialogFragment { + + @Override + protected BaseSearchFilterUiGenerator createSearchFilterDialogGenerator() { + return new SearchFilterChipDialogGenerator( + searchViewModel.getSearchFilterLogic(), binding.verticalScroll, requireContext()); + } + + @Override + public void onViewCreated(@NonNull final View view, final Bundle savedInstanceState) { + super.onViewCreated(view, savedInstanceState); + + final Configuration configuration = getResources().getConfiguration(); + final DisplayMetrics displayMetrics = getResources().getDisplayMetrics(); + final ViewGroup.LayoutParams layoutParams = binding.getRoot().getLayoutParams(); + + if (configuration.orientation == Configuration.ORIENTATION_LANDSCAPE) { + layoutParams.width = (int) (displayMetrics.widthPixels * 0.80f); + } else if (configuration.orientation == Configuration.ORIENTATION_PORTRAIT) { + layoutParams.width = ViewGroup.LayoutParams.MATCH_PARENT; + } + + binding.getRoot().setLayoutParams(layoutParams); + } +} diff --git a/app/src/main/java/org/schabi/newpipe/fragments/list/search/filter/SearchFilterChipDialogGenerator.java b/app/src/main/java/org/schabi/newpipe/fragments/list/search/filter/SearchFilterChipDialogGenerator.java new file mode 100644 index 00000000000..33bbb52d7f9 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/fragments/list/search/filter/SearchFilterChipDialogGenerator.java @@ -0,0 +1,84 @@ +// Created by evermind-zz 2022, licensed GNU GPL version 3 or later + +package org.schabi.newpipe.fragments.list.search.filter; + +import android.content.Context; +import android.view.View; +import android.view.ViewGroup; +import android.widget.GridLayout; +import android.widget.TextView; + +import com.google.android.material.chip.ChipGroup; + +import org.schabi.newpipe.extractor.search.filter.FilterGroup; +import org.schabi.newpipe.util.DeviceUtils; + +import androidx.annotation.NonNull; + +public class SearchFilterChipDialogGenerator extends SearchFilterDialogGenerator { + + public SearchFilterChipDialogGenerator( + @NonNull final SearchFilterLogic logic, + @NonNull final ViewGroup root, + @NonNull final Context context) { + super(logic, root, context); + } + + @Override + protected void createFilterGroup(@NonNull final FilterGroup filterGroup, + @NonNull final UiWrapperMapDelegate wrapperDelegate, + @NonNull final UiSelectorDelegate selectorDelegate) { + final boolean doSpanDataOverMultipleCells = true; + final UiItemWrapperViews viewsWrapper = new UiItemWrapperViews( + filterGroup.getIdentifier()); + + if (filterGroup.getNameId() != null) { + final GridLayout.LayoutParams layoutParams = + clipFreeRightColumnLayoutParams(doSpanDataOverMultipleCells); + final TextView filterLabel = createFilterLabel(filterGroup, layoutParams); + globalLayout.addView(filterLabel); + viewsWrapper.add(filterLabel); + } else if (doWeNeedASeparatorView()) { + final SeparatorLineView separatorLineView = createSeparatorLine(); + globalLayout.addView(separatorLineView); + viewsWrapper.add(separatorLineView); + } + + final ChipGroup chipGroup = new ChipGroup(context); + chipGroup.setLayoutParams( + setDefaultMarginInDp(clipFreeRightColumnLayoutParams(doSpanDataOverMultipleCells), + 8, 2, 4, 2)); + chipGroup.setSingleLine(false); + chipGroup.setSingleSelection(filterGroup.isOnlyOneCheckable()); + + createUiChipElementsForFilterGroupItems( + filterGroup, wrapperDelegate, selectorDelegate, chipGroup); + + + wrapperDelegate.put(filterGroup.getIdentifier(), viewsWrapper); + globalLayout.addView(chipGroup); + viewsWrapper.add(chipGroup); + } + + private boolean doWeNeedASeparatorView() { + // if 0 than there is nothing to separate + if (globalLayout.getChildCount() == 0) { + return false; + } + final View lastView = globalLayout.getChildAt(globalLayout.getChildCount() - 1); + return !(lastView instanceof SeparatorLineView); + } + + private ViewGroup.MarginLayoutParams setDefaultMarginInDp( + @NonNull final ViewGroup.MarginLayoutParams layoutParams, + final int left, final int top, final int right, final int bottom) { + layoutParams.setMargins( + DeviceUtils.dpToPx(left, context), + DeviceUtils.dpToPx(top, context), + DeviceUtils.dpToPx(right, context), + DeviceUtils.dpToPx(bottom, context) + ); + return layoutParams; + } + +} diff --git a/app/src/main/java/org/schabi/newpipe/fragments/list/search/filter/SearchFilterDialogFragment.java b/app/src/main/java/org/schabi/newpipe/fragments/list/search/filter/SearchFilterDialogFragment.java new file mode 100644 index 00000000000..581af4ae5b2 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/fragments/list/search/filter/SearchFilterDialogFragment.java @@ -0,0 +1,41 @@ +// Created by evermind-zz 2022, licensed GNU GPL version 3 or later + +package org.schabi.newpipe.fragments.list.search.filter; + +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; + +import org.schabi.newpipe.databinding.SearchFilterDialogFragmentBinding; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.appcompat.widget.Toolbar; + +/** + * A search filter dialog that also looks like a dialog aka. 'dialog style'. + */ +public class SearchFilterDialogFragment extends BaseSearchFilterDialogFragment { + + protected SearchFilterDialogFragmentBinding binding; + + @Override + protected BaseSearchFilterUiGenerator createSearchFilterDialogGenerator() { + return new SearchFilterDialogGenerator( + searchViewModel.getSearchFilterLogic(), binding.verticalScroll, requireContext()); + } + + @Override + @Nullable + protected Toolbar getToolbar() { + return binding.toolbarLayout.toolbar; + } + + @Override + protected View getRootView(@NonNull final LayoutInflater inflater, + @Nullable final ViewGroup container) { + binding = SearchFilterDialogFragmentBinding + .inflate(inflater, container, false); + return binding.getRoot(); + } +} diff --git a/app/src/main/java/org/schabi/newpipe/fragments/list/search/filter/SearchFilterDialogGenerator.java b/app/src/main/java/org/schabi/newpipe/fragments/list/search/filter/SearchFilterDialogGenerator.java new file mode 100644 index 00000000000..b1ee9c75fea --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/fragments/list/search/filter/SearchFilterDialogGenerator.java @@ -0,0 +1,337 @@ +// Created by evermind-zz 2022, licensed GNU GPL version 3 or later + +package org.schabi.newpipe.fragments.list.search.filter; + +import android.content.Context; +import android.view.Gravity; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.AdapterView; +import android.widget.GridLayout; +import android.widget.Spinner; +import android.widget.TextView; + +import com.google.android.material.chip.Chip; +import com.google.android.material.chip.ChipGroup; + +import org.schabi.newpipe.R; +import org.schabi.newpipe.extractor.search.filter.FilterGroup; +import org.schabi.newpipe.extractor.search.filter.FilterItem; +import org.schabi.newpipe.util.DeviceUtils; +import org.schabi.newpipe.util.ServiceHelper; + +import java.util.List; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +public class SearchFilterDialogGenerator extends BaseSearchFilterUiDialogGenerator { + private static final int CHIP_GROUP_ELEMENTS_THRESHOLD = 2; + private static final int CHIP_MIN_TOUCH_TARGET_SIZE_DP = 40; + protected final GridLayout globalLayout; + + public SearchFilterDialogGenerator( + @NonNull final SearchFilterLogic logic, + @NonNull final ViewGroup root, + @NonNull final Context context) { + super(logic, context); + this.globalLayout = createGridLayout(); + root.addView(globalLayout); + } + + @Override + protected void createTitle(@NonNull final String name, + @NonNull final List titleViewElements) { + final TextView titleView = createTitleText(name); + final View separatorLine = createSeparatorLine(); + final View separatorLine2 = createSeparatorLine(); + + globalLayout.addView(separatorLine); + globalLayout.addView(titleView); + globalLayout.addView(separatorLine2); + + titleViewElements.add(titleView); + titleViewElements.add(separatorLine); + titleViewElements.add(separatorLine2); + } + + @Override + protected void createFilterGroup(@NonNull final FilterGroup filterGroup, + @NonNull final UiWrapperMapDelegate wrapperDelegate, + @NonNull final UiSelectorDelegate selectorDelegate) { + final GridLayout.LayoutParams layoutParams = getLayoutParamsViews(); + boolean doSpanDataOverMultipleCells = false; + final UiItemWrapperViews viewsWrapper = new UiItemWrapperViews( + filterGroup.getIdentifier()); + + final TextView filterLabel; + if (filterGroup.getNameId() != null) { + filterLabel = createFilterLabel(filterGroup, layoutParams); + viewsWrapper.add(filterLabel); + } else { + filterLabel = null; + doSpanDataOverMultipleCells = true; + } + + if (filterGroup.isOnlyOneCheckable()) { + if (filterLabel != null) { + globalLayout.addView(filterLabel); + } + + final Spinner filterDataSpinner = new Spinner(context, Spinner.MODE_DROPDOWN); + + final GridLayout.LayoutParams spinnerLp = + clipFreeRightColumnLayoutParams(doSpanDataOverMultipleCells); + setDefaultMargin(spinnerLp); + filterDataSpinner.setLayoutParams(spinnerLp); + setZeroPadding(filterDataSpinner); + + createUiElementsForSingleSelectableItemsFilterGroup( + filterGroup, wrapperDelegate, selectorDelegate, filterDataSpinner); + + viewsWrapper.add(filterDataSpinner); + globalLayout.addView(filterDataSpinner); + + } else { // multiple items in FilterGroup selectable + final ChipGroup chipGroup = new ChipGroup(context); + doSpanDataOverMultipleCells = chooseParentViewForFilterLabelAndAdd( + filterGroup, doSpanDataOverMultipleCells, filterLabel, chipGroup); + + viewsWrapper.add(chipGroup); + globalLayout.addView(chipGroup); + chipGroup.setLayoutParams( + clipFreeRightColumnLayoutParams(doSpanDataOverMultipleCells)); + chipGroup.setSingleLine(false); + + createUiChipElementsForFilterGroupItems( + filterGroup, wrapperDelegate, selectorDelegate, chipGroup); + } + + wrapperDelegate.put(filterGroup.getIdentifier(), viewsWrapper); + } + + @NonNull + protected TextView createFilterLabel(@NonNull final FilterGroup filterGroup, + @NonNull final GridLayout.LayoutParams layoutParams) { + final TextView filterLabel; + filterLabel = new TextView(context); + + filterLabel.setId(filterGroup.getIdentifier()); + filterLabel.setText( + ServiceHelper.getTranslatedFilterString(filterGroup.getNameId(), context)); + filterLabel.setGravity(Gravity.CENTER_VERTICAL); + setDefaultMargin(layoutParams); + setZeroPadding(filterLabel); + + filterLabel.setLayoutParams(layoutParams); + return filterLabel; + } + + private boolean chooseParentViewForFilterLabelAndAdd( + @NonNull final FilterGroup filterGroup, + final boolean doSpanDataOverMultipleCells, + @Nullable final TextView filterLabel, + @NonNull final ChipGroup possibleParentView) { + + boolean spanOverMultipleCells = doSpanDataOverMultipleCells; + if (filterLabel != null) { + // If we have more than CHIP_GROUP_ELEMENTS_THRESHOLD elements to be + // displayed as Chips add its filterLabel as first element to ChipGroup. + // Now the ChipGroup can be spanned over all the cells to use + // the space better. + if (filterGroup.getFilterItems().size() > CHIP_GROUP_ELEMENTS_THRESHOLD) { + possibleParentView.addView(filterLabel); + spanOverMultipleCells = true; + } else { + globalLayout.addView(filterLabel); + } + } + return spanOverMultipleCells; + } + + private void createUiElementsForSingleSelectableItemsFilterGroup( + @NonNull final FilterGroup filterGroup, + @NonNull final UiWrapperMapDelegate wrapperDelegate, + @NonNull final UiSelectorDelegate selectorDelegate, + @NonNull final Spinner filterDataSpinner) { + filterDataSpinner.setAdapter(new SearchFilterDialogSpinnerAdapter( + context, filterGroup, wrapperDelegate, filterDataSpinner)); + + final AdapterView.OnItemSelectedListener listener; + listener = new AdapterView.OnItemSelectedListener() { + @Override + public void onItemSelected(final AdapterView parent, final View view, + final int position, final long id) { + if (view != null) { + selectorDelegate.selectFilter(view.getId()); + } + } + + @Override + public void onNothingSelected(final AdapterView parent) { + // we are only interested onItemSelected() -> no implementation here + } + }; + + filterDataSpinner.setOnItemSelectedListener(listener); + } + + protected void createUiChipElementsForFilterGroupItems( + @NonNull final FilterGroup filterGroup, + @NonNull final UiWrapperMapDelegate wrapperDelegate, + @NonNull final UiSelectorDelegate selectorDelegate, + @NonNull final ChipGroup chipGroup) { + for (final FilterItem item : filterGroup.getFilterItems()) { + + if (item instanceof InjectFilterItem.DividerItem) { + final InjectFilterItem.DividerItem dividerItem = + (InjectFilterItem.DividerItem) item; + + // For the width MATCH_PARENT is necessary as this allows the + // dividerLabel to fill one row of ChipGroup exclusively + final ChipGroup.LayoutParams layoutParams = new ChipGroup.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT); + final TextView dividerLabel = createDividerLabel(dividerItem, layoutParams); + chipGroup.addView(dividerLabel); + } else { + final Chip chip = createChipView(chipGroup, item); + + final View.OnClickListener listener; + listener = view -> selectorDelegate.selectFilter(view.getId()); + chip.setOnClickListener(listener); + + chipGroup.addView(chip); + wrapperDelegate.put(item.getIdentifier(), + new UiItemWrapperChip(item, chip, chipGroup)); + } + } + } + + @NonNull + private Chip createChipView(@NonNull final ChipGroup chipGroup, + @NonNull final FilterItem item) { + final Chip chip = (Chip) LayoutInflater.from(context).inflate( + R.layout.chip_search_filter, chipGroup, false); + chip.ensureAccessibleTouchTarget( + DeviceUtils.dpToPx(CHIP_MIN_TOUCH_TARGET_SIZE_DP, context)); + chip.setText(ServiceHelper.getTranslatedFilterString(item.getNameId(), context)); + chip.setId(item.getIdentifier()); + chip.setCheckable(true); + return chip; + } + + @NonNull + private TextView createDividerLabel( + @NonNull final InjectFilterItem.DividerItem dividerItem, + @NonNull final ViewGroup.MarginLayoutParams layoutParams) { + final TextView dividerLabel; + dividerLabel = new TextView(context); + dividerLabel.setEnabled(true); + + dividerLabel.setGravity(Gravity.CENTER_VERTICAL); + setDefaultMargin(layoutParams); + dividerLabel.setLayoutParams(layoutParams); + final String menuDividerTitle = + context.getString(dividerItem.getStringResId()); + dividerLabel.setText(menuDividerTitle); + return dividerLabel; + } + + @NonNull + protected SeparatorLineView createSeparatorLine() { + return createSeparatorLine(clipFreeRightColumnLayoutParams(true)); + } + + @NonNull + private TextView createTitleText(final String name) { + final TextView title = createTitleText(name, + clipFreeRightColumnLayoutParams(true)); + title.setGravity(Gravity.CENTER); + return title; + } + + @NonNull + private GridLayout createGridLayout() { + final GridLayout layout = new GridLayout(context); + + layout.setColumnCount(2); + + final GridLayout.LayoutParams layoutParams = new GridLayout.LayoutParams(); + layoutParams.width = ViewGroup.LayoutParams.MATCH_PARENT; + layoutParams.height = ViewGroup.LayoutParams.MATCH_PARENT; + setDefaultMargin(layoutParams); + layout.setLayoutParams(layoutParams); + + return layout; + } + + @NonNull + protected GridLayout.LayoutParams clipFreeRightColumnLayoutParams(final boolean doColumnSpan) { + final GridLayout.LayoutParams layoutParams = new GridLayout.LayoutParams(); + // https://stackoverflow.com/questions/37744672/gridlayout-children-are-being-clipped + layoutParams.width = 0; + layoutParams.height = ViewGroup.LayoutParams.MATCH_PARENT; + layoutParams.setGravity(Gravity.FILL_HORIZONTAL | Gravity.CENTER_VERTICAL); + setDefaultMargin(layoutParams); + + if (doColumnSpan) { + layoutParams.columnSpec = GridLayout.spec(0, 2, 1.0f); + } + + return layoutParams; + } + + @NonNull + private GridLayout.LayoutParams getLayoutParamsViews() { + final GridLayout.LayoutParams layoutParams = new GridLayout.LayoutParams(); + layoutParams.setGravity(Gravity.CENTER_VERTICAL); + setDefaultMargin(layoutParams); + return layoutParams; + } + + @NonNull + protected ViewGroup.MarginLayoutParams setDefaultMargin( + @NonNull final ViewGroup.MarginLayoutParams layoutParams) { + layoutParams.setMargins( + DeviceUtils.dpToPx(4, context), + DeviceUtils.dpToPx(2, context), + DeviceUtils.dpToPx(4, context), + DeviceUtils.dpToPx(2, context) + ); + return layoutParams; + } + + @NonNull + protected View setZeroPadding(@NonNull final View view) { + view.setPadding(0, 0, 0, 0); + return view; + } + + public static class UiItemWrapperChip extends BaseUiItemWrapper { + + @NonNull + private final ChipGroup chipGroup; + + public UiItemWrapperChip(@NonNull final FilterItem item, + @NonNull final View view, + @NonNull final ChipGroup chipGroup) { + super(item, view); + this.chipGroup = chipGroup; + } + + @Override + public boolean isChecked() { + return ((Chip) view).isChecked(); + } + + @Override + public void setChecked(final boolean checked) { + ((Chip) view).setChecked(checked); + + if (checked) { + chipGroup.check(view.getId()); + } + } + } +} diff --git a/app/src/main/java/org/schabi/newpipe/fragments/list/search/filter/SearchFilterDialogSpinnerAdapter.java b/app/src/main/java/org/schabi/newpipe/fragments/list/search/filter/SearchFilterDialogSpinnerAdapter.java new file mode 100644 index 00000000000..dd18dce78ce --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/fragments/list/search/filter/SearchFilterDialogSpinnerAdapter.java @@ -0,0 +1,224 @@ +// Created by evermind-zz 2022, licensed GNU GPL version 3 or later + +package org.schabi.newpipe.fragments.list.search.filter; + +import android.annotation.SuppressLint; +import android.content.Context; +import android.util.SparseIntArray; +import android.view.Gravity; +import android.view.View; +import android.view.ViewGroup; +import android.widget.BaseAdapter; +import android.widget.Spinner; +import android.widget.TextView; + +import org.schabi.newpipe.extractor.search.filter.FilterContainer; +import org.schabi.newpipe.extractor.search.filter.FilterGroup; +import org.schabi.newpipe.extractor.search.filter.FilterItem; +import org.schabi.newpipe.util.DeviceUtils; +import org.schabi.newpipe.util.ServiceHelper; + +import java.util.Objects; + +import androidx.annotation.NonNull; +import androidx.collection.SparseArrayCompat; + +import static org.schabi.newpipe.fragments.list.search.filter.InjectFilterItem.DividerItem; + +public class SearchFilterDialogSpinnerAdapter extends BaseAdapter { + + private final Context context; + private final FilterGroup group; + private final BaseSearchFilterUiGenerator.UiWrapperMapDelegate wrapperDelegate; + private final Spinner spinner; + private final SparseIntArray id2PosMap = new SparseIntArray(); + private final SparseArrayCompat + viewWrapperMap = new SparseArrayCompat<>(); + + public SearchFilterDialogSpinnerAdapter( + @NonNull final Context context, + @NonNull final FilterGroup group, + @NonNull final BaseSearchFilterUiGenerator.UiWrapperMapDelegate wrapperDelegate, + @NonNull final Spinner filterDataSpinner) { + this.context = context; + this.group = group; + this.wrapperDelegate = wrapperDelegate; + this.spinner = filterDataSpinner; + + createViewWrappers(); + } + + @Override + public int getCount() { + return group.getFilterItems().size(); + } + + @Override + public Object getItem(final int position) { + return group.getFilterItems().get(position); + } + + @Override + public long getItemId(final int position) { + return position; + } + + @Override + public View getView(final int position, final View convertView, final ViewGroup parent) { + final FilterItem item = group.getFilterItems().get(position); + final TextView view; + + if (convertView != null) { + view = (TextView) convertView; + } else { + view = createViewItem(); + } + + initViewWithData(position, item, view); + return view; + } + + @SuppressLint("WrongConstant") + private void initViewWithData(final int position, + final FilterItem item, + final TextView view) { + final UiItemWrapperSpinner wrappedView = + viewWrapperMap.get(position); + Objects.requireNonNull(wrappedView); + + view.setId(item.getIdentifier()); + view.setText(ServiceHelper.getTranslatedFilterString(item.getNameId(), context)); + view.setVisibility(wrappedView.getVisibility()); + view.setEnabled(wrappedView.isEnabled()); + + if (item instanceof DividerItem) { + final DividerItem dividerItem = (DividerItem) item; + wrappedView.setEnabled(false); + view.setEnabled(wrappedView.isEnabled()); + final String menuDividerTitle = ">>>" + + context.getString(dividerItem.getStringResId()) + "<<<"; + view.setText(menuDividerTitle); + } + } + + private void createViewWrappers() { + int position = 0; + for (final FilterItem item : this.group.getFilterItems()) { + final int initialVisibility = View.VISIBLE; + final boolean isInitialEnabled = true; + + final UiItemWrapperSpinner wrappedView = + new UiItemWrapperSpinner( + item, + initialVisibility, + isInitialEnabled, + spinner); + + if (item instanceof DividerItem) { + wrappedView.setEnabled(false); + } + + // store wrapper also locally as we refer here regularly + viewWrapperMap.put(position, wrappedView); + // store wrapper globally in SearchFilterLogic + wrapperDelegate.put(item.getIdentifier(), wrappedView); + id2PosMap.put(item.getIdentifier(), position); + position++; + } + } + + @NonNull + private TextView createViewItem() { + final TextView view = new TextView(context); + view.setLayoutParams(new ViewGroup.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT)); + view.setGravity(Gravity.CENTER_VERTICAL); + view.setPadding( + DeviceUtils.dpToPx(8, context), + DeviceUtils.dpToPx(4, context), + DeviceUtils.dpToPx(8, context), + DeviceUtils.dpToPx(4, context) + ); + return view; + } + + public int getItemPositionForFilterId(final int id) { + return id2PosMap.get(id); + } + + @Override + public boolean isEnabled(final int position) { + final UiItemWrapperSpinner wrappedView = + viewWrapperMap.get(position); + Objects.requireNonNull(wrappedView); + return wrappedView.isEnabled(); + } + + private static class UiItemWrapperSpinner + extends BaseItemWrapper { + @NonNull + private final Spinner spinner; + + /** + * We have to store the visibility of the view and if it is enabled. + *

+ * Reason: the Spinner adapter reuses {@link View} elements through the parameter + * convertView in {@link SearchFilterDialogSpinnerAdapter#getView(int, View, ViewGroup)} + * -> this is the Android Adapter's time saving characteristic to rather reuse + * than to recreate a {@link View}. + * -> so we reuse what Android gives us in above mentioned method. + */ + private int visibility; + private boolean enabled; + + UiItemWrapperSpinner(@NonNull final FilterItem item, + final int initialVisibility, + final boolean isInitialEnabled, + @NonNull final Spinner spinner) { + super(item); + this.spinner = spinner; + + this.visibility = initialVisibility; + this.enabled = isInitialEnabled; + } + + @Override + public void setVisible(final boolean visible) { + if (visible) { + visibility = View.VISIBLE; + } else { + visibility = View.GONE; + } + } + + @Override + public boolean isChecked() { + return spinner.getSelectedItem() == item; + } + + @Override + public void setChecked(final boolean checked) { + if (super.getItemId() != FilterContainer.ITEM_IDENTIFIER_UNKNOWN) { + final SearchFilterDialogSpinnerAdapter adapter = + (SearchFilterDialogSpinnerAdapter) spinner.getAdapter(); + spinner.setSelection(adapter.getItemPositionForFilterId(super.getItemId())); + } + } + + public boolean isEnabled() { + return enabled; + } + + public void setEnabled(final boolean enabled) { + this.enabled = enabled; + } + + public int getVisibility() { + return visibility; + } + + public void setVisibility(final int visibility) { + this.visibility = visibility; + } + } +} diff --git a/app/src/main/java/org/schabi/newpipe/fragments/list/search/filter/SearchFilterLogic.java b/app/src/main/java/org/schabi/newpipe/fragments/list/search/filter/SearchFilterLogic.java new file mode 100644 index 00000000000..ca7754e7898 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/fragments/list/search/filter/SearchFilterLogic.java @@ -0,0 +1,830 @@ +// Created by evermind-zz 2022, licensed GNU GPL version 3 or later + +package org.schabi.newpipe.fragments.list.search.filter; + +import org.schabi.newpipe.extractor.linkhandler.SearchQueryHandlerFactory; +import org.schabi.newpipe.extractor.search.filter.FilterContainer; +import org.schabi.newpipe.extractor.search.filter.FilterGroup; +import org.schabi.newpipe.extractor.search.filter.FilterItem; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashSet; +import java.util.List; +import java.util.Objects; +import java.util.Set; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.collection.SparseArrayCompat; + +import static org.schabi.newpipe.extractor.search.filter.FilterContainer.ITEM_IDENTIFIER_UNKNOWN; + +/** + * This class handles all the user interaction with the content and sort filters + * of NewPipeExtractor. + *

+ * It also facilitates the generation of the Ui's according to the implemented + * {@link ICreateUiForFiltersWorker}'s. + */ +public class SearchFilterLogic { + + /** + * This list is used to communicate with NewPipeExtractor. + * It contains only the content filter ids that the user has selected from the UI. + */ + private final List userSelectedContentFilters = new ArrayList<>(); + /** + * This list is used to communicate with NewPipeExtractor. + * It contains only the sort filter ids that the user has selected from the UI. + */ + private final List userSelectedSortFilters = new ArrayList<>(); + private final SearchQueryHandlerFactory searchQHFactory; + private final ExclusiveGroups contentFilterExclusive = new ExclusiveGroups(); + private final ExclusiveGroups sortFilterExclusive = new ExclusiveGroups(); + private final SparseArrayCompat contentFilterIdToUiItemMap = + new SparseArrayCompat<>(); + private final SparseArrayCompat sortFilterIdToUiItemMap = + new SparseArrayCompat<>(); + private final SparseArrayCompat contentFilterFidToSupersetSortFilterMap = + new SparseArrayCompat<>(); + private Callback callback; + /** + * This list is used to store via Icepick and eventual store as preset + * It contains all the content filter ids that the user has selected. It + * contains the same ids than {@link #userSelectedContentFilters} + */ + private List selectedContentFilters = new ArrayList<>(); + /** + * This list is used to store via Icepick and eventual store as preset + * It contains all the sort filter ids that the user has selected and also + * default id of none visible but selected sort filters. + * It is a superset to {@link #userSelectedContentFilters}. + */ + private List selectedSortFilters = new ArrayList<>(); + + /** + * Store a reference of the sort filters Ui creator. This is needed + * as a mechanism to tell if (the sort filter title) should be displayed or not. + *

+ * The work is done via {@link ICreateUiForFiltersWorker#filtersVisible(boolean)} + */ + private ICreateUiForFiltersWorker uiSortFilterWorker; + + + private SearchFilterLogic(@NonNull final SearchQueryHandlerFactory searchQHFactory, + @Nullable final Callback callback) { + this.searchQHFactory = searchQHFactory; + this.callback = callback; + initContentFilters(); + initSortFilters(); + } + + public void setCallback(@Nullable final Callback callback) { + this.callback = callback; + } + + public void reset() { + initContentFilters(); + initSortFilters(); + deselectUiItems(contentFilterIdToUiItemMap); + deselectUiItems(sortFilterIdToUiItemMap); + reselectUiItems(selectedContentFilters, contentFilterIdToUiItemMap); + reselectUiItems(selectedSortFilters, sortFilterIdToUiItemMap); + showSortFilterContainerUI(); + } + + private void reInitExclusiveFilterIds(@NonNull final List selectedFilters, + @NonNull final ExclusiveGroups exclusive) { + checkIfIdsAreValid(selectedFilters, exclusive); + + for (final int id : selectedFilters) { + exclusive.ifInExclusiveGroupRemovePreviouslySelectedId(id); + exclusive.addIdIfBelongsToExclusiveGroup(id); + } + } + + public void restorePreviouslySelectedFilters( + @Nullable final List selectedContentFilterList, + @Nullable final List selectedSortFilterList) { + if (selectedContentFilterList != null && selectedSortFilterList != null + && !selectedContentFilterList.isEmpty()) { + reInitExclusiveFilterIds(selectedContentFilterList, contentFilterExclusive); + reInitExclusiveFilterIds(selectedSortFilterList, sortFilterExclusive); + + this.selectedContentFilters = selectedContentFilterList; + this.selectedSortFilters = selectedSortFilterList; + } + + createContentFilterItemListFromIdentifierList(); + createSortFilterItemListFromIdentifiersList(); + } + + private void reselectUiItems( + @NonNull final List selectedFilters, + @NonNull final SparseArrayCompat filterIdToUiItemMap) { + for (final int id : selectedFilters) { + final IUiItemWrapper iUiItemWrapper = filterIdToUiItemMap.get(id); + if (iUiItemWrapper != null) { + iUiItemWrapper.setChecked(true); + } + } + } + + private void deselectUiItems( + @NonNull final SparseArrayCompat filterIdToUiItemMap) { + for (int index = 0; index < filterIdToUiItemMap.size(); index++) { + final IUiItemWrapper iUiItemWrapper = filterIdToUiItemMap.valueAt(index); + if (iUiItemWrapper != null) { + iUiItemWrapper.setChecked(false); + } + } + } + + // get copy of internal list + @NonNull + public ArrayList getSelectedContentFilters() { + return new ArrayList<>(this.selectedContentFilters); + } + + // get copy of internal list + @NonNull + public ArrayList getSelectedSortFilters() { + return new ArrayList<>(this.selectedSortFilters); + } + + // get copy of internal list, elements are not copied + @NonNull + public List getSelectedContentFilterItems() { + return new ArrayList<>(this.userSelectedContentFilters); + } + + // get copy of internal list, elements are not copied + @NonNull + public List getSelectedSortFiltersItems() { + return new ArrayList<>(this.userSelectedSortFilters); + } + + public void initContentFiltersUi( + @NonNull final ICreateUiForFiltersWorker createUiForFiltersWorker) { + final FilterContainer filters = searchQHFactory.getAvailableContentFilter(); + + if (filters != null && filters.getFilterGroups() != null) { + initFiltersUi(filters.getFilterGroups(), + contentFilterIdToUiItemMap, + createUiForFiltersWorker); + } + + reselectUiItems(selectedContentFilters, contentFilterIdToUiItemMap); + } + + public void initSortFiltersUi( + @NonNull final ICreateUiForFiltersWorker createUiForFiltersWorker) { + final FilterContainer filters = searchQHFactory.getAvailableContentFilter(); + final List sortGroups = getAllSortFilterGroups(filters); + uiSortFilterWorker = createUiForFiltersWorker; + + initFiltersUi(sortGroups, + sortFilterIdToUiItemMap, + createUiForFiltersWorker); + + reselectUiItems(selectedSortFilters, sortFilterIdToUiItemMap); + } + + /** + * Create Ui elements. + * + * @param filterGroups the filter groups that whom a UI should be created + * @param filterIdToUiItemMap points to a {@link FilterItem} or {@link FilterGroup} + * corresponding actual UI element(s). This map will be first + * called clear() on here. + * @param createUiForFiltersWorker the implementation how to create the UI. + */ + private void initFiltersUi( + @NonNull final List filterGroups, + @NonNull final SparseArrayCompat filterIdToUiItemMap, + @NonNull final ICreateUiForFiltersWorker createUiForFiltersWorker) { + + filterIdToUiItemMap.clear(); + Objects.requireNonNull(createUiForFiltersWorker); + createUiForFiltersWorker.prepare(); + for (final FilterGroup filterGroup : filterGroups) { + createUiForFiltersWorker.createFilterGroupBeforeItems(filterGroup); + for (final FilterItem filterItem : filterGroup.getFilterItems()) { + createUiForFiltersWorker.createFilterItem(filterItem, filterGroup); + } + createUiForFiltersWorker.createFilterGroupAfterItems(filterGroup); + } + createUiForFiltersWorker.finish(); + } + + /** + * Init the content filter logical states. + *

+ * - create list with default id that will be preselected + * - create exclusivity lists for exclusive groups + * {@link ExclusiveGroups#filterIdToGroupIdMap} and + * {@link ExclusiveGroups#exclusiveGroupsIdSet} + * - check if {@link #selectedContentFilters} are valid ids + * + * @param filterGroups content or sort filter {@link FilterGroup} array + * @param exclusive corresponding exclusive object (either for content + * or sort) filter array + * @param selectedFilters corresponding selected filter ids + * @param fidToSupersetSortFilterMap null possible, only for content filters relevant + */ + private void initFilters( + @NonNull final List filterGroups, + @NonNull final ExclusiveGroups exclusive, + @NonNull final List selectedFilters, + @Nullable final SparseArrayCompat fidToSupersetSortFilterMap) { + selectedFilters.clear(); + exclusive.clear(); + + for (final FilterGroup filterGroup : filterGroups) { + if (filterGroup.isOnlyOneCheckable()) { + exclusive.addGroupToExclusiveGroupsMap(filterGroup.getIdentifier()); + } + + // is the default selected filter for this group + final int defaultId = filterGroup.getDefaultSelectedFilterId(); + + for (final FilterItem item : filterGroup.getFilterItems()) { + if (fidToSupersetSortFilterMap != null) { + fidToSupersetSortFilterMap.put(item.getIdentifier(), + filterGroup.getAllSortFilters()); + } + exclusive.putFilterIdToItsGroupId(item.getIdentifier(), + filterGroup.getIdentifier()); + } + + if (defaultId != ITEM_IDENTIFIER_UNKNOWN) { + exclusive.handleIdInExclusiveGroup(defaultId, selectedFilters); + } + } + + checkIfIdsAreValid(selectedFilters, exclusive); + } + + private void checkIfIdsAreValid(@NonNull final List selectedFilters, + @NonNull final ExclusiveGroups exclusive) { + for (final int id : selectedFilters) { + if (!exclusive.filterIdToGroupIdMapContainsId(id)) { + throw new RuntimeException("The id " + id + " is invalid"); + } + } + } + + private void initContentFilters() { + final FilterContainer filters = searchQHFactory.getAvailableContentFilter(); + contentFilterFidToSupersetSortFilterMap.clear(); + + if (filters != null && filters.getFilterGroups() != null) { + initFilters(filters.getFilterGroups(), + contentFilterExclusive, selectedContentFilters, + contentFilterFidToSupersetSortFilterMap); + } + } + + private void initSortFilters() { + final FilterContainer filters = searchQHFactory.getAvailableContentFilter(); + final List sortGroups = getAllSortFilterGroups(filters); + initFilters(sortGroups, sortFilterExclusive, selectedSortFilters, null); + } + + /** + * Prepare content filter list with the actual {@link FilterItem}s to send to the library. + *

+ * The list is created through the {@link #userSelectedContentFilters} identifiers list. + * This identifiers refer to {@link FilterItem}s. + *

+ * {@link #userSelectedContentFilters} will be cleared first! + */ + private void createContentFilterItemListFromIdentifierList() { + userSelectedContentFilters.clear(); + final FilterContainer filterContainer = searchQHFactory.getAvailableContentFilter(); + + for (final int contentFilterId : selectedContentFilters) { + final FilterItem contentFilterItem = filterContainer.getFilterItem(contentFilterId); + if (contentFilterItem != null) { + userSelectedContentFilters.add(contentFilterItem); + } + } + } + + /** + * Prepare sort filter list with the actual {@link FilterItem}s to send to the library. + *

+ * The list is created through the {@link #userSelectedSortFilters} identifiers list. + * This identifiers refer to {@link FilterItem}s. + *

+ * {@link #userSelectedSortFilters} will be cleared first! + */ + private void createSortFilterItemListFromIdentifiersList() { + userSelectedSortFilters.clear(); + for (final int sortFilterId : selectedSortFilters) { + for (final int contentFilterId : selectedContentFilters) { + final FilterContainer filterContainer = + searchQHFactory.getContentFilterSortFilterVariant(contentFilterId); + if (filterContainer != null) { + final FilterItem sortFilterItem = filterContainer.getFilterItem(sortFilterId); + if (sortFilterItem != null) { + userSelectedSortFilters.add(sortFilterItem); + } + } + } + } + } + + public void showSortFilterContainerUI() { + showSortFilterIdsContainerUI(selectedContentFilters); + } + + /** + * Show only that sort filter UIs that are available for selected content ids. + * + * @param contentFilterIds content filter ids list + */ + private void showSortFilterIdsContainerUI(@NonNull final List contentFilterIds) { + for (final int contentFilterId : contentFilterIds) { + showSortFilterIdContainerUI(contentFilterId); + } + } + + private void notifySortFiltersVisibility() { + boolean sortFilterVisible = false; + if (uiSortFilterWorker != null) { + for (final int contentFilterId : selectedContentFilters) { + sortFilterVisible = searchQHFactory + .getContentFilterSortFilterVariant(contentFilterId) != null; + if (sortFilterVisible) { + break; + } + } + uiSortFilterWorker.filtersVisible(sortFilterVisible); + } + } + + /** + * Show only the sort filters that are available for a given content filter id. + * + * @param contentFilterId a content filter id and not a sort filter id. + */ + private void showSortFilterIdContainerUI(final int contentFilterId) { + final FilterContainer subsetFilterContainer = + searchQHFactory.getContentFilterSortFilterVariant(contentFilterId); + + final FilterContainer supersetFilterContainer = + contentFilterFidToSupersetSortFilterMap.get(contentFilterId); + if (subsetFilterContainer != null) { + if (supersetFilterContainer == null) { + throw new RuntimeException( + "supersetFilterContainer should never be null here"); + } + + setUiItemsVisibility(supersetFilterContainer, false, sortFilterIdToUiItemMap); + setUiItemsVisibility(subsetFilterContainer, true, sortFilterIdToUiItemMap); + } else { + if (supersetFilterContainer != null) { + setUiItemsVisibility(supersetFilterContainer, false, + sortFilterIdToUiItemMap); + } + } + notifySortFiltersVisibility(); + } + + /** + * This method is only used to show the all sort filters for measurement of the width. + *

+ * See {@link SearchFilterOptionMenuAlikeDialogGenerator} + */ + protected void showAllAvailableSortFilters() { + for (int index = 0; index < contentFilterFidToSupersetSortFilterMap.size(); index++) { + final FilterContainer container = + contentFilterFidToSupersetSortFilterMap.valueAt(index); + if (container != null) { + setUiItemsVisibility(container, true, sortFilterIdToUiItemMap); + } + } + } + + private void setUiItemsVisibility( + @Nullable final FilterContainer filters, + final boolean isVisible, + @NonNull final SparseArrayCompat filterIdToUiItemMap) { + if (filters != null && filters.getFilterGroups() != null) { + for (final FilterGroup filterGroup : filters.getFilterGroups()) { + setUiItemVisible(isVisible, filterIdToUiItemMap, filterGroup.getIdentifier()); + for (final FilterItem item : filterGroup.getFilterItems()) { + setUiItemVisible(isVisible, filterIdToUiItemMap, item.getIdentifier()); + } + } + } + } + + private void setUiItemVisible( + final boolean isVisible, + @NonNull final SparseArrayCompat filterIdToUiItemMap, + final int id) { + final IUiItemWrapper uiWrapper = filterIdToUiItemMap.get(id); + if (uiWrapper != null) { + uiWrapper.setVisible(isVisible); + } + } + + /** + * Get all sort filter groups for the content filters. + * It has to have all content filter groups that are available for a service. + * + * @param filters the content filters + * @return the sort filter groups. Empty list if either param filters or no + * filter groups available + */ + @NonNull + private List getAllSortFilterGroups(@Nullable final FilterContainer filters) { + if (filters != null && filters.getFilterGroups() != null) { + final List sortGroups = new ArrayList<>(); + for (final FilterGroup filterGroup : filters.getFilterGroups()) { + final FilterContainer sf = filterGroup.getAllSortFilters(); + if (sf != null && sf.getFilterGroups() != null) { + sortGroups.addAll(sf.getFilterGroups()); + } + } + return sortGroups; + } + return Collections.emptyList(); + } + + protected void handleIdInNonExclusiveGroup(final int filterId, + @Nullable final IUiItemWrapper uiItemWrapper, + @NonNull final List selectedFilter) { + if (uiItemWrapper != null) { // could be null if there is no UI + if (uiItemWrapper.isChecked()) { + if (!selectedFilter.contains(filterId)) { + selectedFilter.add(filterId); + } + } else { // remove from list + if (selectedFilter.contains(filterId)) { + selectedFilter.remove((Integer) filterId); + } + } + } else { // we have no UI + if (!selectedFilter.contains(filterId)) { + selectedFilter.add(filterId); + } else { + selectedFilter.remove((Integer) filterId); + } + } + } + + public synchronized void selectContentFilter(final int filterId) { + selectFilter(filterId, contentFilterIdToUiItemMap, selectedContentFilters, + contentFilterExclusive); + showSortFilterIdContainerUI(filterId); + } + + public synchronized void selectSortFilter(final int filterId) { + selectFilter(filterId, sortFilterIdToUiItemMap, selectedSortFilters, sortFilterExclusive); + } + + private void selectFilter( + final int id, + @NonNull final SparseArrayCompat filterIdToUiItemMap, + @NonNull final List selectedFilter, + @NonNull final ExclusiveGroups exclusive) { + final IUiItemWrapper uiItemWrapper = + filterIdToUiItemMap.get(id); + + // here we remove/add the by the UI (de)selected id. + if (exclusive.handleIdInExclusiveGroup(id, selectedFilter)) { + if (uiItemWrapper != null && !uiItemWrapper.isChecked()) { + uiItemWrapper.setChecked(true); + } + } else { + handleIdInNonExclusiveGroup(id, uiItemWrapper, selectedFilter); + } + } + + /** + * Prepare the content and sort filters {@link FilterItem}'s lists for a now filtered + * search. + *

+ * If a callback is registered it wil be called with copy's of the local sort and + * content lists. To avoid concurrently modification of the lists. As they are progressed + * through async javarx calls. Note: The members aka {@link FilterItem}'s are not copied. + */ + public void prepareForSearch() { + createContentFilterItemListFromIdentifierList(); + createSortFilterItemListFromIdentifiersList(); + + if (callback != null) { + callback.selectedFilters(new ArrayList<>(userSelectedContentFilters), + new ArrayList<>(userSelectedSortFilters)); + } + } + + /** + * This method is meant to be called to add {@link android.view.View}s that represents + * a content filter. + *

+ * It has to be called within a subclass of {@link SearchFilterLogic} which implements + * {@link ICreateUiForFiltersWorker} itself or as an any inner class. + * + * @param id the id of a content filter + * @param uiItemWrapper the wrapped UI {@link android.view.View} for that content filter + */ + public void addContentFilterUiWrapperToItemMap( + final int id, + @NonNull final IUiItemWrapper uiItemWrapper) { + contentFilterIdToUiItemMap.put(id, uiItemWrapper); + } + + /** + * This method is meant to be called to add {@link android.view.View}s that represents + * a sort filter. + *

+ * It has to be called within a subclass of {@link SearchFilterLogic} which implements + * {@link ICreateUiForFiltersWorker} itself or as an any inner class. + * + * @param id the id of a sort filter + * @param uiItemWrapper the wrapped UI {@link android.view.View} for that sort filter + */ + public void addSortFilterUiWrapperToItemMap( + final int id, + @NonNull final IUiItemWrapper uiItemWrapper) { + sortFilterIdToUiItemMap.put(id, uiItemWrapper); + } + + /** + * Wrap a {@link FilterItem} or {@link FilterGroup} to their + * actual UI element(s) ({@link android.view.View}). + */ + public interface IUiItemWrapper { + /** + * set a view element visible. + * + * @param visible true if visible, false if not visible + */ + void setVisible(boolean visible); + + /** + * @return get the id of the corresponding {@link FilterItem} + */ + int getItemId(); + + /** + * Is the UI element selected. + * + * @return true if selected + */ + boolean isChecked(); + + /** + * select the UI element. + * + * @param checked select UI element + */ + void setChecked(boolean checked); + } + + /** + * Creating user elements for all filters inside a {@link FilterContainer}. + * + * Note: use {@link #addContentFilterUiWrapperToItemMap(int, IUiItemWrapper)} and + * {@link #addSortFilterUiWrapperToItemMap(int, IUiItemWrapper)} to actually make + * {@link SearchFilterLogic} aware of them. + */ + public interface ICreateUiForFiltersWorker { + /** + * Will be called before any {@link FilterContainer} looping. + */ + void prepare(); + + /** + * Create Ui elements specifically related to the {@link FilterGroup} itself. + * But it could also be used for creating items. + *

+ * -> This method is called *before* the {@link #createFilterItem(FilterItem, FilterGroup)} + * + * @param filterGroup one group each time from {@link FilterContainer#getFilterGroups()} + */ + void createFilterGroupBeforeItems(@NonNull FilterGroup filterGroup); + + /** + * Create Ui elements specifically related to a {@link FilterItem} itself. + * + * @param filterItem the actual item you should create a UI element here + * @param filterGroup (optional) one group each time from + * {@link FilterContainer#getFilterGroups()} + */ + void createFilterItem(@NonNull FilterItem filterItem, @NonNull FilterGroup filterGroup); + + /** + * Create Ui elements specifically related to the {@link FilterGroup} itself. + * But it could also be used for creating items. + *

+ * -> This method is called *after* the {@link #createFilterItem(FilterItem, FilterGroup)} + * + * @param filterGroup one group each time from {@link FilterContainer#getFilterGroups()} + */ + void createFilterGroupAfterItems(@NonNull FilterGroup filterGroup); + + /** + * do anything you might want to clean up or whatever. + */ + void finish(); + + /** + * Notify if filters are visible. Eg to show or hide 'sort filter' section title + * + * @param areFiltersVisible true if filter visible + */ + void filtersVisible(boolean areFiltersVisible); + } + + /** + * This callback will be called if a search with additional filters should occur. + */ + public interface Callback { + void selectedFilters(@NonNull List userSelectedContentFilter, + @NonNull List userSelectedSortFilter); + } + + /** + * Track and handle filters of groups in which only one {@link FilterItem} can be selected. + *

+ * We need to track this ourselves as we otherwise rely on androids functionality or lack of + * tracking the before selected item that now is unselected. + */ + private static class ExclusiveGroups { + + final SparseArrayCompat actualSelectedFilterIdInExclusiveGroupMap = + new SparseArrayCompat<>(); + /** + * To quickly determine if a content filter group supports + * only one item selected (exclusiveness), we need a set that resembles that. + */ + private final Set exclusiveGroupsIdSet = new HashSet<>(); + /** + * To quickly determine if a content filter id belongs to an exclusive group. + * This maps works in conjunction with {@link #exclusiveGroupsIdSet} + */ + private final SparseArrayCompat filterIdToGroupIdMap = + new SparseArrayCompat<>(); + + /** + * Clear {@link #exclusiveGroupsIdSet} and {@link #filterIdToGroupIdMap}. + */ + public void clear() { + exclusiveGroupsIdSet.clear(); + filterIdToGroupIdMap.clear(); + actualSelectedFilterIdInExclusiveGroupMap.clear(); + } + + /** + * Check if filter id is valid. + * + * @param filterId the filter id to check + * @return true if valid + */ + public boolean filterIdToGroupIdMapContainsId(final int filterId) { + return filterIdToGroupIdMap.indexOfKey(filterId) >= 0; + } + + public boolean isFilterIdPartOfAnExclusiveGroup(final int filterId) { + if (filterIdToGroupIdMapContainsId(filterId)) { + final int filterGroupId = + Objects.requireNonNull(filterIdToGroupIdMap.get(filterId)); + return exclusiveGroupsIdSet.contains(filterGroupId); + } + return false; + } + + /** + * @param filterId the id of a {@link FilterItem} + * @param selectedFilter the list of filter Ids that could contain the given id + * @return true if exclusive group + */ + private boolean handleIdInExclusiveGroup(final int filterId, + @NonNull final List selectedFilter) { + // case exclusive group selection + if (isFilterIdPartOfAnExclusiveGroup(filterId)) { + final int previousSelectedId = + ifInExclusiveGroupRemovePreviouslySelectedId(filterId); + if (selectedFilter.contains(previousSelectedId)) { + selectedFilter.remove((Integer) previousSelectedId); + selectedFilter.add(filterId); + } else if (previousSelectedId == ITEM_IDENTIFIER_UNKNOWN) { + selectedFilter.add(filterId); + } + addIdIfBelongsToExclusiveGroup(filterId); + return true; + } + return false; + } + + /** + * Insert filter ids with corresponding group ids. + *

+ * We need to know which filter belongs to which group, that we can + * determine if a selected {@link FilterItem} is part of an exclusive + * group or not. + * + * @param filterId filter identifier + * @param filterGroupId group identifier + */ + public void putFilterIdToItsGroupId(final int filterId, final int filterGroupId) { + filterIdToGroupIdMap.put(filterId, filterGroupId); + } + + /** + * Add exclusive groups to the map. + * + * @param groupId the id of the exclusive group + */ + public void addGroupToExclusiveGroupsMap(final int groupId) { + exclusiveGroupsIdSet.add(groupId); + } + + private void addIdIfBelongsToExclusiveGroup(final int filterId) { + final int filterGroupId = + Objects.requireNonNull(filterIdToGroupIdMap.get(filterId)); + if (exclusiveGroupsIdSet.contains(filterGroupId)) { + actualSelectedFilterIdInExclusiveGroupMap.put(filterGroupId, filterId); + } + } + + /** + * check if the filter group id for a given filter id is already in a exclusive group. + *

+ * If so remove the group filter id. + * + * @param filterId the id of a filter that might belong to an exclusive filter group + * @return id of removed filter id from {@link #actualSelectedFilterIdInExclusiveGroupMap} + * otherwise {@link FilterContainer#ITEM_IDENTIFIER_UNKNOWN} + */ + + private int ifInExclusiveGroupRemovePreviouslySelectedId(final int filterId) { + int previousFilterId = ITEM_IDENTIFIER_UNKNOWN; + final int filterGroupId = + Objects.requireNonNull(filterIdToGroupIdMap.get(filterId)); + + final int index = actualSelectedFilterIdInExclusiveGroupMap.indexOfKey(filterGroupId); + if (exclusiveGroupsIdSet.contains(filterGroupId) && index >= 0) { + previousFilterId = actualSelectedFilterIdInExclusiveGroupMap.valueAt(index); + actualSelectedFilterIdInExclusiveGroupMap.removeAt(index); + } + return previousFilterId; + } + } + + public static final class Factory { + private Factory() { + } + + /** + * Create variant of {@link SearchFilterLogic}. + * + * @param logicVariant the variant {@link Variant}. + * @param searchQHFactory of the service + * @param callback if you want to get the data the user has requested by calling + * {@link SearchFilterLogic#prepareForSearch()} + * @return instance of {@link SearchFilterLogic}. + */ + @NonNull + public static SearchFilterLogic create( + @NonNull final Variant logicVariant, + @NonNull final SearchQueryHandlerFactory searchQHFactory, + @Nullable final Callback callback) { + switch (logicVariant) { + + case SEARCH_FILTER_LOGIC_LEGACY: // the case we are using SearchFragmentLegacy + return new SearchFilterLogic(searchQHFactory, callback) { + @Override + protected void handleIdInNonExclusiveGroup( + final int filterId, + @Nullable final IUiItemWrapper uiItemWrapper, + @NonNull final List selectedFilter) { + + if (null != uiItemWrapper) { + // for the action menu based UI we have to toggle first + // to be compatible with the SearchFilterLogic + uiItemWrapper.setChecked(!uiItemWrapper.isChecked()); + } + super.handleIdInNonExclusiveGroup( + filterId, uiItemWrapper, selectedFilter); + } + }; + + default: + case SEARCH_FILTER_LOGIC_DEFAULT: + return new SearchFilterLogic(searchQHFactory, callback); + } + } + + public enum Variant { + SEARCH_FILTER_LOGIC_DEFAULT, + SEARCH_FILTER_LOGIC_LEGACY + } + } +} diff --git a/app/src/main/java/org/schabi/newpipe/fragments/list/search/filter/SearchFilterOptionMenuAlikeDialogFragment.java b/app/src/main/java/org/schabi/newpipe/fragments/list/search/filter/SearchFilterOptionMenuAlikeDialogFragment.java new file mode 100644 index 00000000000..b244cf72506 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/fragments/list/search/filter/SearchFilterOptionMenuAlikeDialogFragment.java @@ -0,0 +1,77 @@ +// Created by evermind-zz 2022, licensed GNU GPL version 3 or later + +package org.schabi.newpipe.fragments.list.search.filter; + +import android.app.Dialog; +import android.os.Bundle; +import android.view.Gravity; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.view.Window; +import android.view.WindowManager; + +import org.schabi.newpipe.databinding.SearchFilterOptionMenuAlikeDialogFragmentBinding; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.appcompat.widget.Toolbar; + +/** + * A search filter dialog that looks like a action menu aka. 'action menu style'. + */ +public class SearchFilterOptionMenuAlikeDialogFragment extends BaseSearchFilterDialogFragment { + + private SearchFilterOptionMenuAlikeDialogFragmentBinding binding; + + @Override + protected BaseSearchFilterUiGenerator createSearchFilterDialogGenerator() { + return new SearchFilterOptionMenuAlikeDialogGenerator( + searchViewModel.getSearchFilterLogic(), binding.verticalScroll, requireContext()); + } + + @Override + @Nullable + protected Toolbar getToolbar() { + return binding.toolbarLayout.toolbar; + } + + @Override + protected View getRootView(@NonNull final LayoutInflater inflater, + final ViewGroup container) { + binding = SearchFilterOptionMenuAlikeDialogFragmentBinding + .inflate(inflater, container, false); + return binding.getRoot(); + } + + @Override + public void onViewCreated(@NonNull final View view, final Bundle savedInstanceState) { + super.onViewCreated(view, savedInstanceState); + // place the dialog in the 'action menu position' + setDialogGravity(Gravity.END | Gravity.TOP); + } + + private void setDialogGravity(final int gravity) { + final Dialog dialog = getDialog(); + if (dialog != null) { + final Window window = dialog.getWindow(); + if (window != null) { + final WindowManager.LayoutParams layoutParams = window.getAttributes(); + layoutParams.width = ViewGroup.LayoutParams.WRAP_CONTENT; + layoutParams.height = ViewGroup.LayoutParams.WRAP_CONTENT; + layoutParams.horizontalMargin = 0; + layoutParams.gravity = gravity; + layoutParams.dimAmount = 0; + layoutParams.flags &= ~WindowManager.LayoutParams.FLAG_DIM_BEHIND; + window.setAttributes(layoutParams); + } + } + } + + @Override + protected void initToolbar(final @NonNull Toolbar toolbar) { + super.initToolbar(toolbar); + // no room for a title + toolbar.setTitle(""); + } +} diff --git a/app/src/main/java/org/schabi/newpipe/fragments/list/search/filter/SearchFilterOptionMenuAlikeDialogGenerator.java b/app/src/main/java/org/schabi/newpipe/fragments/list/search/filter/SearchFilterOptionMenuAlikeDialogGenerator.java new file mode 100644 index 00000000000..adc98a5f325 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/fragments/list/search/filter/SearchFilterOptionMenuAlikeDialogGenerator.java @@ -0,0 +1,365 @@ +// Created by evermind-zz 2022, licensed GNU GPL version 3 or later + +package org.schabi.newpipe.fragments.list.search.filter; + +import android.content.Context; +import android.view.Gravity; +import android.view.View; +import android.view.ViewGroup; +import android.widget.CheckBox; +import android.widget.LinearLayout; +import android.widget.RadioButton; +import android.widget.RadioGroup; +import android.widget.TextView; + +import org.schabi.newpipe.extractor.search.filter.FilterGroup; +import org.schabi.newpipe.extractor.search.filter.FilterItem; +import org.schabi.newpipe.util.DeviceUtils; +import org.schabi.newpipe.util.ServiceHelper; + +import java.util.List; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import static android.util.TypedValue.COMPLEX_UNIT_DIP; +import static org.schabi.newpipe.fragments.list.search.filter.InjectFilterItem.DividerItem; + +public class SearchFilterOptionMenuAlikeDialogGenerator extends BaseSearchFilterUiDialogGenerator { + private static final Integer NO_RESIZE_VIEW_TAG = 1; + private static final float FONT_SIZE_SELECTABLE_VIEW_ITEMS_IN_DIP = 18f; + private static final int VIEW_ITEMS_MIN_WIDTH_IN_DIP = 168; + private final LinearLayout globalLayout; + + public SearchFilterOptionMenuAlikeDialogGenerator( + @NonNull final SearchFilterLogic logic, + @NonNull final ViewGroup root, + @NonNull final Context context) { + super(logic, context); + this.globalLayout = createLinearLayout(); + root.addView(globalLayout); + } + + @Override + protected void doMeasurementsIfNeeded() { + measureWidthOfChildrenAndResizeToWidest(); + } + + /** + * Resize all width of {@link #globalLayout} children without tag {@link #NO_RESIZE_VIEW_TAG}. + *

+ * Initially this method was only used to resize the width of separator line + * views created by {@link #createSeparatorLine()}. But now also the views + * the user will interact with are set to the widest child. + *

+ * Reasons: + * 1. Separator lines should be as wide as the widest UI element but this + * can only be determined on runtime + * 2. Other view elements more specific checkable/selectable should also + * expand their width over the complete dialog width to be easier to select + */ + private void measureWidthOfChildrenAndResizeToWidest() { + logic.showAllAvailableSortFilters(); + + // initialize width with a passable default width + int widestViewInPx = DeviceUtils.dpToPx(VIEW_ITEMS_MIN_WIDTH_IN_DIP, context); + final int noOfChildren = globalLayout.getChildCount(); + + for (int x = 0; x < noOfChildren; x++) { + final View childView = globalLayout.getChildAt(x); + childView.measure(View.MeasureSpec.UNSPECIFIED, View.MeasureSpec.UNSPECIFIED); + final int width = childView.getMeasuredWidth(); + if (width > widestViewInPx) { + widestViewInPx = width; + } + } + + for (int x = 0; x < noOfChildren; x++) { + final View childView = globalLayout.getChildAt(x); + + if (childView.getTag() != NO_RESIZE_VIEW_TAG) { + final ViewGroup.LayoutParams layoutParams = childView.getLayoutParams(); + layoutParams.width = widestViewInPx; + childView.setLayoutParams(layoutParams); + } + } + } + + @Override + protected void createTitle(@NonNull final String name, + @NonNull final List titleViewElements) { + final TextView titleView = createTitleText(name); + titleView.setTag(NO_RESIZE_VIEW_TAG); + final View separatorLine = createSeparatorLine(); + final View separatorLine2 = createSeparatorLine(); + final View separatorLine3 = createSeparatorLine(); + + globalLayout.addView(separatorLine); + globalLayout.addView(separatorLine2); + globalLayout.addView(titleView); + globalLayout.addView(separatorLine3); + + titleViewElements.add(titleView); + titleViewElements.add(separatorLine); + titleViewElements.add(separatorLine2); + titleViewElements.add(separatorLine3); + } + + @Override + protected void createFilterGroup(@NonNull final FilterGroup filterGroup, + @NonNull final UiWrapperMapDelegate wrapperDelegate, + @NonNull final UiSelectorDelegate selectorDelegate) { + final UiItemWrapperViews viewsWrapper = new UiItemWrapperViews( + filterGroup.getIdentifier()); + + final View separatorLine = createSeparatorLine(); + globalLayout.addView(separatorLine); + viewsWrapper.add(separatorLine); + + if (filterGroup.getNameId() != null) { + final TextView filterLabel = + createFilterGroupLabel(filterGroup, getLayoutParamsLabelLeft()); + globalLayout.addView(filterLabel); + viewsWrapper.add(filterLabel); + } + + if (filterGroup.isOnlyOneCheckable()) { + + final RadioGroup radioGroup = new RadioGroup(context); + radioGroup.setLayoutParams(getLayoutParamsViews()); + + createUiElementsForSingleSelectableItemsFilterGroup( + filterGroup, wrapperDelegate, selectorDelegate, radioGroup); + + globalLayout.addView(radioGroup); + viewsWrapper.add(radioGroup); + + } else { // multiple items in FilterGroup selectable + createUiElementsForMultipleSelectableItemsFilterGroup( + filterGroup, wrapperDelegate, selectorDelegate); + } + + wrapperDelegate.put(filterGroup.getIdentifier(), viewsWrapper); + } + + private void createUiElementsForSingleSelectableItemsFilterGroup( + @NonNull final FilterGroup filterGroup, + @NonNull final UiWrapperMapDelegate wrapperDelegate, + @NonNull final UiSelectorDelegate selectorDelegate, + @NonNull final RadioGroup radioGroup) { + for (final FilterItem item : filterGroup.getFilterItems()) { + + final View view; + if (item instanceof DividerItem) { + view = createDividerTextView(item, getLayoutParamsViews()); + } else { + view = createViewItemRadio(item, getLayoutParamsViews()); + + wrapperDelegate.put(item.getIdentifier(), + new UiItemWrapperCheckBoxAndRadioButton( + item, view, radioGroup)); + + final View.OnClickListener listener = v -> { + if (v != null) { + selectorDelegate.selectFilter(v.getId()); + } + }; + view.setOnClickListener(listener); + } + radioGroup.addView(view); + } + } + + private void createUiElementsForMultipleSelectableItemsFilterGroup( + @NonNull final FilterGroup filterGroup, + @NonNull final UiWrapperMapDelegate wrapperDelegate, + @NonNull final UiSelectorDelegate selectorDelegate) { + for (final FilterItem item : filterGroup.getFilterItems()) { + final View view; + if (item instanceof DividerItem) { + view = createDividerTextView(item, getLayoutParamsViews()); + } else { + final CheckBox checkBox = createCheckBox(item, getLayoutParamsViews()); + + wrapperDelegate.put(item.getIdentifier(), + new UiItemWrapperCheckBoxAndRadioButton( + item, checkBox, null)); + + final View.OnClickListener listener = v -> { + if (v != null) { + selectorDelegate.selectFilter(v.getId()); + } + }; + checkBox.setOnClickListener(listener); + + view = checkBox; + } + globalLayout.addView(view); + } + } + + @NonNull + private LinearLayout createLinearLayout() { + final LinearLayout linearLayout = new LinearLayout(context); + + linearLayout.setOrientation(LinearLayout.VERTICAL); + + final LinearLayout.LayoutParams layoutParams = new LinearLayout.LayoutParams(1, 1); + layoutParams.width = ViewGroup.LayoutParams.MATCH_PARENT; + layoutParams.height = ViewGroup.LayoutParams.MATCH_PARENT; + layoutParams.setMargins( + DeviceUtils.dpToPx(2, context), + DeviceUtils.dpToPx(2, context), + DeviceUtils.dpToPx(2, context), + DeviceUtils.dpToPx(2, context)); + linearLayout.setLayoutParams(layoutParams); + + return linearLayout; + } + + @NonNull + private LinearLayout.LayoutParams getLayoutForSeparatorLine() { + final LinearLayout.LayoutParams layoutParams = getLayoutParamsLabelLeft(); + layoutParams.width = 0; + layoutParams.gravity = Gravity.CENTER_HORIZONTAL; + return layoutParams; + } + + @NonNull + private View createSeparatorLine() { + return createSeparatorLine(getLayoutForSeparatorLine()); + } + + @NonNull + private TextView createTitleText(final String name) { + final LinearLayout.LayoutParams layoutParams = getLayoutParamsLabelLeft(); + layoutParams.gravity = Gravity.CENTER_HORIZONTAL; + final TextView title = createTitleText(name, layoutParams); + setPadding(title, 5); + return title; + } + + @NonNull + private View setPadding(@NonNull final View view, final int sizeInDip) { + final int sizeInPx = DeviceUtils.dpToPx(sizeInDip, context); + view.setPadding( + sizeInPx, + sizeInPx, + sizeInPx, + sizeInPx); + return view; + } + + @NonNull + private TextView createFilterGroupLabel(@NonNull final FilterGroup filterGroup, + @NonNull final ViewGroup.LayoutParams layoutParams) { + final TextView filterLabel = new TextView(context); + filterLabel.setId(filterGroup.getIdentifier()); + filterLabel.setText(ServiceHelper + .getTranslatedFilterString(filterGroup.getNameId(), context)); + filterLabel.setGravity(Gravity.TOP); + // resizing not needed as view is not selectable + filterLabel.setTag(NO_RESIZE_VIEW_TAG); + filterLabel.setLayoutParams(layoutParams); + return filterLabel; + } + + @NonNull + private CheckBox createCheckBox(@NonNull final FilterItem item, + @NonNull final ViewGroup.LayoutParams layoutParams) { + final CheckBox checkBox = new CheckBox(context); + checkBox.setLayoutParams(layoutParams); + checkBox.setText(ServiceHelper.getTranslatedFilterString( + item.getNameId(), context)); + checkBox.setId(item.getIdentifier()); + checkBox.setTextSize(COMPLEX_UNIT_DIP, FONT_SIZE_SELECTABLE_VIEW_ITEMS_IN_DIP); + return checkBox; + } + + @NonNull + private TextView createDividerTextView(@NonNull final FilterItem item, + @NonNull final ViewGroup.LayoutParams layoutParams) { + final DividerItem dividerItem = (DividerItem) item; + final TextView view = new TextView(context); + view.setEnabled(true); + final String menuDividerTitle = + context.getString(dividerItem.getStringResId()); + view.setText(menuDividerTitle); + view.setGravity(Gravity.TOP); + view.setLayoutParams(layoutParams); + return view; + } + + @NonNull + private RadioButton createViewItemRadio(@NonNull final FilterItem item, + @NonNull final ViewGroup.LayoutParams layoutParams) { + final RadioButton view = new RadioButton(context); + view.setId(item.getIdentifier()); + view.setText(ServiceHelper.getTranslatedFilterString(item.getNameId(), context)); + view.setLayoutParams(layoutParams); + view.setTextSize(COMPLEX_UNIT_DIP, FONT_SIZE_SELECTABLE_VIEW_ITEMS_IN_DIP); + return view; + } + + @NonNull + private LinearLayout.LayoutParams getLayoutParamsViews() { + final LinearLayout.LayoutParams layoutParams = new LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.MATCH_PARENT); + layoutParams.setMargins( + DeviceUtils.dpToPx(4, context), + DeviceUtils.dpToPx(8, context), + DeviceUtils.dpToPx(4, context), + DeviceUtils.dpToPx(8, context)); + return layoutParams; + } + + @NonNull + private LinearLayout.LayoutParams getLayoutParamsLabelLeft() { + final LinearLayout.LayoutParams layoutParams = new LinearLayout.LayoutParams( + ViewGroup.LayoutParams.WRAP_CONTENT, + ViewGroup.LayoutParams.WRAP_CONTENT); + layoutParams.setMargins( + DeviceUtils.dpToPx(2, context), + DeviceUtils.dpToPx(2, context), + DeviceUtils.dpToPx(2, context), + DeviceUtils.dpToPx(2, context)); + return layoutParams; + } + + private static final class UiItemWrapperCheckBoxAndRadioButton + extends BaseUiItemWrapper { + + @Nullable + private final View group; + + private UiItemWrapperCheckBoxAndRadioButton(@NonNull final FilterItem item, + @NonNull final View view, + @Nullable final View group) { + super(item, view); + this.group = group; + } + + @Override + public boolean isChecked() { + if (view instanceof RadioButton) { + return ((RadioButton) view).isChecked(); + } else if (view instanceof CheckBox) { + return ((CheckBox) view).isChecked(); + } else { + return view.isSelected(); + } + } + + @Override + public void setChecked(final boolean checked) { + if (checked && group instanceof RadioGroup) { + ((RadioGroup) group).check(view.getId()); + } else if (view instanceof CheckBox) { + ((CheckBox) view).setChecked(checked); + } else { + view.setSelected(checked); + } + } + } +} diff --git a/app/src/main/java/org/schabi/newpipe/fragments/list/search/filter/SearchFilterUIOptionMenu.java b/app/src/main/java/org/schabi/newpipe/fragments/list/search/filter/SearchFilterUIOptionMenu.java new file mode 100644 index 00000000000..605c15dafec --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/fragments/list/search/filter/SearchFilterUIOptionMenu.java @@ -0,0 +1,303 @@ +// Created by evermind-zz 2022, licensed GNU GPL version 3 or later + +package org.schabi.newpipe.fragments.list.search.filter; + +import android.annotation.SuppressLint; +import android.content.Context; +import android.util.Log; +import android.view.Menu; +import android.view.MenuItem; +import android.view.View; + +import org.schabi.newpipe.R; +import org.schabi.newpipe.extractor.search.filter.FilterContainer; +import org.schabi.newpipe.extractor.search.filter.FilterGroup; +import org.schabi.newpipe.extractor.search.filter.FilterItem; +import org.schabi.newpipe.extractor.search.filter.LibraryStringIds; +import org.schabi.newpipe.util.ServiceHelper; + +import java.util.ArrayList; +import java.util.List; + +import androidx.annotation.NonNull; +import androidx.appcompat.view.menu.MenuBuilder; +import androidx.core.view.MenuCompat; + +import static android.content.ContentValues.TAG; +import static org.schabi.newpipe.fragments.list.search.filter.InjectFilterItem.DividerItem; +import static org.schabi.newpipe.fragments.list.search.filter.SearchFilterLogic.ICreateUiForFiltersWorker; +import static org.schabi.newpipe.fragments.list.search.filter.SearchFilterLogic.IUiItemWrapper; + +/** + * The implementation of the action menu based 'dialog'. + */ +public class SearchFilterUIOptionMenu extends BaseSearchFilterUiGenerator { + + // Menu groups identifier + private static final int MENU_GROUP_SEARCH_RESET_BUTTONS = 0; + // give them negative ids to not conflict with the ids of the filters + private static final int MENU_ID_SEARCH_BUTTON = -100; + private static final int MENU_ID_RESET_BUTTON = -101; + private Menu menu = null; + // initialize with first group id -> next group after the search/reset buttons group + private int newLastUsedGroupId = MENU_GROUP_SEARCH_RESET_BUTTONS + 1; + private int firstSortFilterGroupId; + + public SearchFilterUIOptionMenu( + @NonNull final SearchFilterLogic logic, + @NonNull final Context context) { + super(logic, context); + } + + int getLastUsedGroupIdThanIncrement() { + return newLastUsedGroupId++; + } + + @SuppressLint("RestrictedApi") + private void alwaysShowMenuItemIcon(final Menu theMenu) { + // always show icons + if (theMenu instanceof MenuBuilder) { + final MenuBuilder builder = ((MenuBuilder) theMenu); + builder.setOptionalIconsVisible(true); + } + } + + public void createSearchUI(@NonNull final Menu theMenu) { + this.menu = theMenu; + alwaysShowMenuItemIcon(theMenu); + + createSearchUI(); + + MenuCompat.setGroupDividerEnabled(theMenu, true); + } + + public boolean onOptionsItemSelected(@NonNull final MenuItem item) { + if (item.getGroupId() == MENU_GROUP_SEARCH_RESET_BUTTONS + && item.getItemId() == MENU_ID_SEARCH_BUTTON) { + logic.prepareForSearch(); + } else { // all other menu groups -> reset, content filters and sort filters + + // main part for holding onto the menu -> not closing it + item.setShowAsAction(MenuItem.SHOW_AS_ACTION_COLLAPSE_ACTION_VIEW); + item.setActionView(new View(context)); + item.setOnActionExpandListener(new MenuItem.OnActionExpandListener() { + + @Override + public boolean onMenuItemActionExpand(final MenuItem item) { + if (item.getGroupId() == MENU_GROUP_SEARCH_RESET_BUTTONS + && item.getItemId() == MENU_ID_RESET_BUTTON) { + logic.reset(); + } else if (item.getGroupId() < firstSortFilterGroupId) { // content filters + final int filterId = item.getItemId(); + logic.selectContentFilter(filterId); + } else { // the sort filters + Log.d(TAG, "onMenuItemActionExpand: sort filters are here"); + logic.selectSortFilter(item.getItemId()); + } + + return false; + } + + @Override + public boolean onMenuItemActionCollapse(final MenuItem item) { + return false; + } + }); + } + + return false; + } + + @Override + protected ICreateUiForFiltersWorker createSortFilterWorker() { + return new CreateSortFilterUI(); + } + + @Override + protected ICreateUiForFiltersWorker createContentFilterWorker() { + return new CreateContentFilterUI(); + } + + private static class UiItemWrapper implements IUiItemWrapper { + + private final MenuItem item; + + UiItemWrapper(final MenuItem item) { + this.item = item; + } + + @Override + public void setVisible(final boolean visible) { + item.setVisible(visible); + } + + @Override + public int getItemId() { + return item.getItemId(); + } + + @Override + public boolean isChecked() { + return item.isChecked(); + } + + @Override + public void setChecked(final boolean checked) { + item.setChecked(checked); + } + } + + private class CreateContentFilterUI implements ICreateUiForFiltersWorker { + + /** + * MenuItem's that should not be checkable. + */ + final List nonCheckableMenuItems = new ArrayList<>(); + + /** + * {@link Menu#setGroupCheckable(int, boolean, boolean)} makes all {@link MenuItem} + * checkable. + *

+ * We do not want a group header or a group divider to be checkable. Therefore this method + * calls above mentioned method and afterwards makes all items uncheckable that are placed + * inside {@link #nonCheckableMenuItems}. + * + * @param isOnlyOneCheckable is in group only one selection allowed. + * @param groupId which group should be affected + */ + private void makeAllowedMenuItemInGroupCheckable(final boolean isOnlyOneCheckable, + final int groupId) { + // this method makes all MenuItem's checkable + menu.setGroupCheckable(groupId, true, isOnlyOneCheckable); + // uncheckable unwanted + for (final MenuItem uncheckableItem : nonCheckableMenuItems) { + if (uncheckableItem != null) { + uncheckableItem.setCheckable(false); + } + } + nonCheckableMenuItems.clear(); + } + + @Override + public void prepare() { + // create the search button + menu.add(MENU_GROUP_SEARCH_RESET_BUTTONS, + MENU_ID_SEARCH_BUTTON, + 0, + context.getString(R.string.search)) + .setEnabled(true) + .setCheckable(false) + .setIcon(R.drawable.ic_search); + + menu.add(MENU_GROUP_SEARCH_RESET_BUTTONS, + MENU_ID_RESET_BUTTON, + 0, + context.getString(R.string.playback_reset)) + .setEnabled(true) + .setCheckable(false) + .setIcon(R.drawable.ic_settings_backup_restore); + } + + @Override + public void createFilterGroupBeforeItems( + @NonNull final FilterGroup filterGroup) { + if (filterGroup.getNameId() != null) { + createNotEnabledAndUncheckableGroupTitleMenuItem( + FilterContainer.ITEM_IDENTIFIER_UNKNOWN, filterGroup.getNameId()); + } + } + + protected MenuItem createNotEnabledAndUncheckableGroupTitleMenuItem( + final int identifier, + final LibraryStringIds nameId) { + final MenuItem item = menu.add( + newLastUsedGroupId, + identifier, + 0, + ServiceHelper.getTranslatedFilterString(nameId, context)); + item.setEnabled(false); + + nonCheckableMenuItems.add(item); + + return item; + + } + + @Override + public void createFilterItem(@NonNull final FilterItem filterItem, + @NonNull final FilterGroup filterGroup) { + final MenuItem item = createMenuItem(filterItem); + + if (filterItem instanceof DividerItem) { + final DividerItem dividerItem = (DividerItem) filterItem; + final String menuDividerTitle = ">>>" + + context.getString(dividerItem.getStringResId()) + + "<<<"; + item.setTitle(menuDividerTitle); + item.setEnabled(false); + nonCheckableMenuItems.add(item); + } + + logic.addContentFilterUiWrapperToItemMap(filterItem.getIdentifier(), + new UiItemWrapper(item)); + } + + protected MenuItem createMenuItem(final FilterItem filterItem) { + return menu.add(newLastUsedGroupId, + filterItem.getIdentifier(), + 0, + ServiceHelper.getTranslatedFilterString(filterItem.getNameId(), context)); + } + + @Override + public void createFilterGroupAfterItems(@NonNull final FilterGroup filterGroup) { + makeAllowedMenuItemInGroupCheckable(filterGroup.isOnlyOneCheckable(), + getLastUsedGroupIdThanIncrement()); + } + + @Override + public void finish() { + firstSortFilterGroupId = newLastUsedGroupId; + } + + @Override + public void filtersVisible(final boolean areFiltersVisible) { + // no implementation here as there is no 'sort filter' title as MenuItem + } + } + + private class CreateSortFilterUI extends CreateContentFilterUI { + + private void addSortFilterUiToItemMap(final int id, + final MenuItem item) { + logic.addSortFilterUiWrapperToItemMap(id, new UiItemWrapper(item)); + } + + @Override + public void prepare() { + firstSortFilterGroupId = newLastUsedGroupId; + } + + @Override + public void createFilterGroupBeforeItems( + @NonNull final FilterGroup filterGroup) { + if (filterGroup.getNameId() != null) { + final MenuItem item = createNotEnabledAndUncheckableGroupTitleMenuItem( + filterGroup.getIdentifier(), filterGroup.getNameId()); + addSortFilterUiToItemMap(filterGroup.getIdentifier(), item); + } + } + + @Override + public void createFilterItem(@NonNull final FilterItem filterItem, + @NonNull final FilterGroup filterGroup) { + final MenuItem item = createMenuItem(filterItem); + addSortFilterUiToItemMap(filterItem.getIdentifier(), item); + } + + @Override + public void finish() { + // no implementation here as we do not need to clean up anything or whatever + } + } +} diff --git a/app/src/main/java/org/schabi/newpipe/fragments/list/search/filter/UiItemWrapperViews.java b/app/src/main/java/org/schabi/newpipe/fragments/list/search/filter/UiItemWrapperViews.java new file mode 100644 index 00000000000..f6b0ed1d3d8 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/fragments/list/search/filter/UiItemWrapperViews.java @@ -0,0 +1,62 @@ +// Created by evermind-zz 2022, licensed GNU GPL version 3 or later + +package org.schabi.newpipe.fragments.list.search.filter; + +import android.view.View; + +import java.util.ArrayList; +import java.util.List; + +import androidx.annotation.NonNull; + +/** + * Wrapper for views that are either just labels or eg. a RadioGroup container + * etc. that represent a {@link org.schabi.newpipe.extractor.search.filter.FilterGroup}. + */ +final class UiItemWrapperViews implements SearchFilterLogic.IUiItemWrapper { + + private final int itemId; + private final List views = new ArrayList<>(); + + UiItemWrapperViews(final int itemId) { + this.itemId = itemId; + } + + public void add(@NonNull final View view) { + this.views.add(view); + } + + @Override + public void setVisible(final boolean visible) { + for (final View view : views) { + if (visible) { + view.setVisibility(View.VISIBLE); + } else { + view.setVisibility(View.GONE); + } + } + } + + @Override + public int getItemId() { + return this.itemId; + } + + @Override + public boolean isChecked() { + boolean isChecked = false; + for (final View view : views) { + if (view.isSelected()) { + isChecked = true; + break; + } + } + return isChecked; + } + + @Override + public void setChecked(final boolean checked) { + // not relevant as here views are wrapped that are either just labels or eg. a + // RadioGroup container etc. that represent a FilterGroup. + } +} diff --git a/app/src/main/java/org/schabi/newpipe/util/ChannelTabHelper.java b/app/src/main/java/org/schabi/newpipe/util/ChannelTabHelper.java index 8e8d3849007..633de1b9a4f 100644 --- a/app/src/main/java/org/schabi/newpipe/util/ChannelTabHelper.java +++ b/app/src/main/java/org/schabi/newpipe/util/ChannelTabHelper.java @@ -8,6 +8,7 @@ import org.schabi.newpipe.R; import org.schabi.newpipe.extractor.channel.tabs.ChannelTabs; import org.schabi.newpipe.extractor.linkhandler.ListLinkHandler; +import org.schabi.newpipe.extractor.search.filter.FilterItem; import java.util.List; import java.util.Set; @@ -20,16 +21,11 @@ private ChannelTabHelper() { * @param tab the channel tab to check * @return whether the tab should contain (playable) streams or not */ - public static boolean isStreamsTab(final String tab) { - switch (tab) { - case ChannelTabs.VIDEOS: - case ChannelTabs.TRACKS: - case ChannelTabs.SHORTS: - case ChannelTabs.LIVESTREAMS: - return true; - default: - return false; - } + public static boolean isStreamsTab(final FilterItem tab) { + return tab.equals(ChannelTabs.VIDEOS) + || tab.equals(ChannelTabs.TRACKS) + || tab.equals(ChannelTabs.SHORTS) + || tab.equals(ChannelTabs.LIVESTREAMS); } /** @@ -37,7 +33,7 @@ public static boolean isStreamsTab(final String tab) { * @return whether the tab should contain (playable) streams or not */ public static boolean isStreamsTab(final ListLinkHandler tab) { - final List contentFilters = tab.getContentFilters(); + final List contentFilters = tab.getContentFilters(); if (contentFilters.isEmpty()) { return false; // this should never happen, but check just to be sure } else { @@ -46,63 +42,57 @@ public static boolean isStreamsTab(final ListLinkHandler tab) { } @StringRes - private static int getShowTabKey(final String tab) { - switch (tab) { - case ChannelTabs.VIDEOS: - return R.string.show_channel_tabs_videos; - case ChannelTabs.TRACKS: - return R.string.show_channel_tabs_tracks; - case ChannelTabs.SHORTS: - return R.string.show_channel_tabs_shorts; - case ChannelTabs.LIVESTREAMS: - return R.string.show_channel_tabs_livestreams; - case ChannelTabs.CHANNELS: - return R.string.show_channel_tabs_channels; - case ChannelTabs.PLAYLISTS: - return R.string.show_channel_tabs_playlists; - case ChannelTabs.ALBUMS: - return R.string.show_channel_tabs_albums; - default: - return -1; + private static int getShowTabKey(final FilterItem tab) { + if (tab.equals(ChannelTabs.VIDEOS)) { + return R.string.show_channel_tabs_videos; + } else if (tab.equals(ChannelTabs.TRACKS)) { + return R.string.show_channel_tabs_tracks; + } else if (tab.equals(ChannelTabs.SHORTS)) { + return R.string.show_channel_tabs_shorts; + } else if (tab.equals(ChannelTabs.LIVESTREAMS)) { + return R.string.show_channel_tabs_livestreams; + } else if (tab.equals(ChannelTabs.CHANNELS)) { + return R.string.show_channel_tabs_channels; + } else if (tab.equals(ChannelTabs.PLAYLISTS)) { + return R.string.show_channel_tabs_playlists; + } else if (tab.equals(ChannelTabs.ALBUMS)) { + return R.string.show_channel_tabs_albums; } + return -1; } @StringRes - private static int getFetchFeedTabKey(final String tab) { - switch (tab) { - case ChannelTabs.VIDEOS: - return R.string.fetch_channel_tabs_videos; - case ChannelTabs.TRACKS: - return R.string.fetch_channel_tabs_tracks; - case ChannelTabs.SHORTS: - return R.string.fetch_channel_tabs_shorts; - case ChannelTabs.LIVESTREAMS: - return R.string.fetch_channel_tabs_livestreams; - default: - return -1; + private static int getFetchFeedTabKey(final FilterItem tab) { + if (tab.equals(ChannelTabs.VIDEOS)) { + return R.string.fetch_channel_tabs_videos; + } else if (tab.equals(ChannelTabs.TRACKS)) { + return R.string.fetch_channel_tabs_tracks; + } else if (tab.equals(ChannelTabs.SHORTS)) { + return R.string.fetch_channel_tabs_shorts; + } else if (tab.equals(ChannelTabs.LIVESTREAMS)) { + return R.string.fetch_channel_tabs_livestreams; } + return -1; } @StringRes - public static int getTranslationKey(final String tab) { - switch (tab) { - case ChannelTabs.VIDEOS: - return R.string.channel_tab_videos; - case ChannelTabs.TRACKS: - return R.string.channel_tab_tracks; - case ChannelTabs.SHORTS: - return R.string.channel_tab_shorts; - case ChannelTabs.LIVESTREAMS: - return R.string.channel_tab_livestreams; - case ChannelTabs.CHANNELS: - return R.string.channel_tab_channels; - case ChannelTabs.PLAYLISTS: - return R.string.channel_tab_playlists; - case ChannelTabs.ALBUMS: - return R.string.channel_tab_albums; - default: - return R.string.unknown_content; + public static int getTranslationKey(final FilterItem tab) { + if (tab.equals(ChannelTabs.VIDEOS)) { + return R.string.channel_tab_videos; + } else if (tab.equals(ChannelTabs.TRACKS)) { + return R.string.channel_tab_tracks; + } else if (tab.equals(ChannelTabs.SHORTS)) { + return R.string.channel_tab_shorts; + } else if (tab.equals(ChannelTabs.LIVESTREAMS)) { + return R.string.channel_tab_livestreams; + } else if (tab.equals(ChannelTabs.CHANNELS)) { + return R.string.channel_tab_channels; + } else if (tab.equals(ChannelTabs.PLAYLISTS)) { + return R.string.channel_tab_playlists; + } else if (tab.equals(ChannelTabs.ALBUMS)) { + return R.string.channel_tab_albums; } + return R.string.unknown_content; } public static boolean showChannelTab(final Context context, @@ -119,7 +109,7 @@ public static boolean showChannelTab(final Context context, public static boolean showChannelTab(final Context context, final SharedPreferences sharedPreferences, - final String tab) { + final FilterItem tab) { final int key = ChannelTabHelper.getShowTabKey(tab); if (key == -1) { return false; @@ -130,7 +120,7 @@ public static boolean showChannelTab(final Context context, public static boolean fetchFeedChannelTab(final Context context, final SharedPreferences sharedPreferences, final ListLinkHandler tab) { - final List contentFilters = tab.getContentFilters(); + final List contentFilters = tab.getContentFilters(); if (contentFilters.isEmpty()) { return false; // this should never happen, but check just to be sure } 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 c2748f725b3..9dd38ddea14 100644 --- a/app/src/main/java/org/schabi/newpipe/util/ExtractorHelper.java +++ b/app/src/main/java/org/schabi/newpipe/util/ExtractorHelper.java @@ -27,6 +27,8 @@ import android.view.View; import android.widget.TextView; +import org.schabi.newpipe.extractor.search.filter.FilterItem; + import androidx.annotation.Nullable; import androidx.core.text.HtmlCompat; import androidx.preference.PreferenceManager; @@ -74,8 +76,8 @@ private static void checkServiceId(final int serviceId) { } public static Single searchFor(final int serviceId, final String searchString, - final List contentFilter, - final String sortFilter) { + final List contentFilter, + final List sortFilter) { checkServiceId(serviceId); return Single.fromCallable(() -> SearchInfo.getInfo(NewPipe.getService(serviceId), @@ -87,8 +89,8 @@ public static Single searchFor(final int serviceId, final String sea public static Single> getMoreSearchItems( final int serviceId, final String searchString, - final List contentFilter, - final String sortFilter, + final List contentFilter, + final List sortFilter, final Page page) { checkServiceId(serviceId); return Single.fromCallable(() -> diff --git a/app/src/main/java/org/schabi/newpipe/util/ServiceHelper.java b/app/src/main/java/org/schabi/newpipe/util/ServiceHelper.java index c712157b35b..2ad02fda0a5 100644 --- a/app/src/main/java/org/schabi/newpipe/util/ServiceHelper.java +++ b/app/src/main/java/org/schabi/newpipe/util/ServiceHelper.java @@ -1,16 +1,8 @@ package org.schabi.newpipe.util; -import static org.schabi.newpipe.extractor.ServiceList.SoundCloud; - import android.content.Context; import android.content.SharedPreferences; -import androidx.annotation.DrawableRes; -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.annotation.StringRes; -import androidx.preference.PreferenceManager; - import com.grack.nanojson.JsonObject; import com.grack.nanojson.JsonParser; import com.grack.nanojson.JsonParserException; @@ -20,15 +12,182 @@ import org.schabi.newpipe.extractor.ServiceList; import org.schabi.newpipe.extractor.StreamingService; import org.schabi.newpipe.extractor.exceptions.ExtractionException; +import org.schabi.newpipe.extractor.search.filter.LibraryStringIds; import org.schabi.newpipe.extractor.services.peertube.PeertubeInstance; +import java.util.EnumMap; +import java.util.Map; +import java.util.Objects; import java.util.Optional; import java.util.concurrent.TimeUnit; +import androidx.annotation.DrawableRes; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.StringRes; +import androidx.preference.PreferenceManager; + +import static org.schabi.newpipe.extractor.ServiceList.SoundCloud; + public final class ServiceHelper { private static final StreamingService DEFAULT_FALLBACK_SERVICE = ServiceList.YouTube; + /** + * Map all available {@link LibraryStringIds} ids to resource ids available in strings.xml. + */ + private static final Map LIBRARY_STRING_ID_TO_RES_ID_MAP = + new EnumMap<>(LibraryStringIds.class); + + static { + LIBRARY_STRING_ID_TO_RES_ID_MAP.put(LibraryStringIds.SEARCH_FILTERS_10_30_MIN, + R.string.search_filters_10_30_min); + LIBRARY_STRING_ID_TO_RES_ID_MAP.put(LibraryStringIds.SEARCH_FILTERS_2_10_MIN, + R.string.search_filters_2_10_min); + LIBRARY_STRING_ID_TO_RES_ID_MAP.put(LibraryStringIds.SEARCH_FILTERS_360, + R.string.search_filters_360); + LIBRARY_STRING_ID_TO_RES_ID_MAP.put(LibraryStringIds.SEARCH_FILTERS_3D, + R.string.search_filters_3d); + LIBRARY_STRING_ID_TO_RES_ID_MAP.put(LibraryStringIds.SEARCH_FILTERS_4_20_MIN, + R.string.search_filters_4_20_min); + LIBRARY_STRING_ID_TO_RES_ID_MAP.put(LibraryStringIds.SEARCH_FILTERS_4K, + R.string.search_filters_4k); + LIBRARY_STRING_ID_TO_RES_ID_MAP.put(LibraryStringIds.SEARCH_FILTERS_ADDED, + R.string.search_filters_added); + LIBRARY_STRING_ID_TO_RES_ID_MAP.put(LibraryStringIds.SEARCH_FILTERS_ALBUMS, + R.string.albums); + LIBRARY_STRING_ID_TO_RES_ID_MAP.put(LibraryStringIds.SEARCH_FILTERS_ANY_TIME, + R.string.search_filters_any_time); + LIBRARY_STRING_ID_TO_RES_ID_MAP.put(LibraryStringIds.SEARCH_FILTERS_ALL, + R.string.all); + LIBRARY_STRING_ID_TO_RES_ID_MAP.put(LibraryStringIds.SEARCH_FILTERS_ARTISTS_AND_LABELS, + R.string.search_filters_artists_and_labels); + LIBRARY_STRING_ID_TO_RES_ID_MAP.put(LibraryStringIds.SEARCH_FILTERS_ASCENDING, + R.string.search_filters_ascending); + LIBRARY_STRING_ID_TO_RES_ID_MAP.put(LibraryStringIds.SEARCH_FILTERS_CCOMMONS, + R.string.search_filters_ccommons); + LIBRARY_STRING_ID_TO_RES_ID_MAP.put(LibraryStringIds.SEARCH_FILTERS_CHANNELS, + R.string.channels); + LIBRARY_STRING_ID_TO_RES_ID_MAP.put(LibraryStringIds.SEARCH_FILTERS_CONFERENCES, + R.string.conferences); + LIBRARY_STRING_ID_TO_RES_ID_MAP.put(LibraryStringIds.SEARCH_FILTERS_CREATION_DATE, + R.string.search_filters_creation_date); + LIBRARY_STRING_ID_TO_RES_ID_MAP.put(LibraryStringIds.SEARCH_FILTERS_DATE, + R.string.search_filters_date); + LIBRARY_STRING_ID_TO_RES_ID_MAP.put(LibraryStringIds.SEARCH_FILTERS_DURATION, + R.string.search_filters_duration); + LIBRARY_STRING_ID_TO_RES_ID_MAP.put(LibraryStringIds.SEARCH_FILTERS_EVENTS, + R.string.events); + LIBRARY_STRING_ID_TO_RES_ID_MAP.put(LibraryStringIds.SEARCH_FILTERS_FEATURES, + R.string.search_filters_features); + LIBRARY_STRING_ID_TO_RES_ID_MAP.put(LibraryStringIds.SEARCH_FILTERS_GREATER_30_MIN, + R.string.search_filters_greater_30_min); + LIBRARY_STRING_ID_TO_RES_ID_MAP.put(LibraryStringIds.SEARCH_FILTERS_HD, + R.string.search_filters_hd); + LIBRARY_STRING_ID_TO_RES_ID_MAP.put(LibraryStringIds.SEARCH_FILTERS_HDR, + R.string.search_filters_hdr); + LIBRARY_STRING_ID_TO_RES_ID_MAP.put(LibraryStringIds.SEARCH_FILTERS_KIND, + R.string.search_filters_kind); + LIBRARY_STRING_ID_TO_RES_ID_MAP.put(LibraryStringIds.SEARCH_FILTERS_LAST_30_DAYS, + R.string.search_filters_last_30_days); + LIBRARY_STRING_ID_TO_RES_ID_MAP.put(LibraryStringIds.SEARCH_FILTERS_LAST_7_DAYS, + R.string.search_filters_last_7_days); + LIBRARY_STRING_ID_TO_RES_ID_MAP.put(LibraryStringIds.SEARCH_FILTERS_LAST_HOUR, + R.string.search_filters_last_hour); + LIBRARY_STRING_ID_TO_RES_ID_MAP.put(LibraryStringIds.SEARCH_FILTERS_LAST_YEAR, + R.string.search_filters_last_year); + LIBRARY_STRING_ID_TO_RES_ID_MAP.put(LibraryStringIds.SEARCH_FILTERS_LENGTH, + R.string.search_filters_length); + LIBRARY_STRING_ID_TO_RES_ID_MAP.put(LibraryStringIds.SEARCH_FILTERS_LESS_2_MIN, + R.string.search_filters_less_2_min); + LIBRARY_STRING_ID_TO_RES_ID_MAP.put(LibraryStringIds.SEARCH_FILTERS_LICENSE, + R.string.search_filters_license); + LIBRARY_STRING_ID_TO_RES_ID_MAP.put(LibraryStringIds.SEARCH_FILTERS_LIKES, + R.string.detail_likes_img_view_description); + LIBRARY_STRING_ID_TO_RES_ID_MAP.put(LibraryStringIds.SEARCH_FILTERS_LIVE, + R.string.duration_live); + LIBRARY_STRING_ID_TO_RES_ID_MAP.put(LibraryStringIds.SEARCH_FILTERS_LOCATION, + R.string.search_filters_location); + LIBRARY_STRING_ID_TO_RES_ID_MAP.put(LibraryStringIds.SEARCH_FILTERS_LONG_GREATER_10_MIN, + R.string.search_filters_long_greater_10_min); + LIBRARY_STRING_ID_TO_RES_ID_MAP.put(LibraryStringIds.SEARCH_FILTERS_MEDIUM_4_10_MIN, + R.string.search_filters_medium_4_10_min); + LIBRARY_STRING_ID_TO_RES_ID_MAP.put(LibraryStringIds.SEARCH_FILTERS_THIS_MONTH, + R.string.search_filters_this_month); + LIBRARY_STRING_ID_TO_RES_ID_MAP.put(LibraryStringIds.SEARCH_FILTERS_ARTISTS, + R.string.artists); + LIBRARY_STRING_ID_TO_RES_ID_MAP.put(LibraryStringIds.SEARCH_FILTERS_SONGS, + R.string.songs); + LIBRARY_STRING_ID_TO_RES_ID_MAP.put(LibraryStringIds.SEARCH_FILTERS_NAME, + R.string.name); + LIBRARY_STRING_ID_TO_RES_ID_MAP.put(LibraryStringIds.SEARCH_FILTERS_NO, + R.string.search_filters_no); + LIBRARY_STRING_ID_TO_RES_ID_MAP.put(LibraryStringIds.SEARCH_FILTERS_OVER_20_MIN, + R.string.search_filters_over_20_min); + LIBRARY_STRING_ID_TO_RES_ID_MAP.put(LibraryStringIds.SEARCH_FILTERS_PAST_DAY, + R.string.search_filters_past_day); + LIBRARY_STRING_ID_TO_RES_ID_MAP.put(LibraryStringIds.SEARCH_FILTERS_PAST_HOUR, + R.string.search_filters_past_hour); + LIBRARY_STRING_ID_TO_RES_ID_MAP.put(LibraryStringIds.SEARCH_FILTERS_PAST_MONTH, + R.string.search_filters_past_month); + LIBRARY_STRING_ID_TO_RES_ID_MAP.put(LibraryStringIds.SEARCH_FILTERS_PAST_WEEK, + R.string.search_filters_past_week); + LIBRARY_STRING_ID_TO_RES_ID_MAP.put(LibraryStringIds.SEARCH_FILTERS_PAST_YEAR, + R.string.search_filters_past_year); + LIBRARY_STRING_ID_TO_RES_ID_MAP.put(LibraryStringIds.SEARCH_FILTERS_PLAYLISTS, + R.string.playlists); + LIBRARY_STRING_ID_TO_RES_ID_MAP.put(LibraryStringIds.SEARCH_FILTERS_PUBLISH_DATE, + R.string.search_filters_publish_date); + LIBRARY_STRING_ID_TO_RES_ID_MAP.put(LibraryStringIds.SEARCH_FILTERS_PUBLISHED, + R.string.search_filters_published); + LIBRARY_STRING_ID_TO_RES_ID_MAP.put(LibraryStringIds.SEARCH_FILTERS_PURCHASED, + R.string.search_filters_published); + LIBRARY_STRING_ID_TO_RES_ID_MAP.put(LibraryStringIds.SEARCH_FILTERS_RATING, + R.string.search_filters_rating); + LIBRARY_STRING_ID_TO_RES_ID_MAP.put(LibraryStringIds.SEARCH_FILTERS_RELEVANCE, + R.string.search_filters_relevance); + LIBRARY_STRING_ID_TO_RES_ID_MAP.put(LibraryStringIds.SEARCH_FILTERS_SENSITIVE, + R.string.search_filters_sensitive); + LIBRARY_STRING_ID_TO_RES_ID_MAP.put(LibraryStringIds.SEARCH_FILTERS_SEPIASEARCH, + R.string.search_filters_sepiasearch); + LIBRARY_STRING_ID_TO_RES_ID_MAP.put(LibraryStringIds.SEARCH_FILTERS_SHORT_LESS_4_MIN, + R.string.search_filters_short_less_4_min); + LIBRARY_STRING_ID_TO_RES_ID_MAP.put(LibraryStringIds.SEARCH_FILTERS_SORT_BY, + R.string.search_filters_sort_by); + LIBRARY_STRING_ID_TO_RES_ID_MAP.put(LibraryStringIds.SEARCH_FILTERS_SORT_ORDER, + R.string.search_filters_sort_order); + LIBRARY_STRING_ID_TO_RES_ID_MAP.put(LibraryStringIds.SEARCH_FILTERS_SUBTITLES, + R.string.search_filters_subtitles); + LIBRARY_STRING_ID_TO_RES_ID_MAP.put(LibraryStringIds.SEARCH_FILTERS_TO_MODIFY_COMMERCIALLY, + R.string.search_filters_to_modify_commercially); + LIBRARY_STRING_ID_TO_RES_ID_MAP.put(LibraryStringIds.SEARCH_FILTERS_TODAY, + R.string.search_filters_today); + LIBRARY_STRING_ID_TO_RES_ID_MAP.put(LibraryStringIds.SEARCH_FILTERS_TRACKS, + R.string.tracks); + LIBRARY_STRING_ID_TO_RES_ID_MAP.put(LibraryStringIds.SEARCH_FILTERS_UNDER_4_MIN, + R.string.search_filters_under_4_min); + LIBRARY_STRING_ID_TO_RES_ID_MAP.put(LibraryStringIds.SEARCH_FILTERS_UPLOAD_DATE, + R.string.search_filters_upload_date); + LIBRARY_STRING_ID_TO_RES_ID_MAP.put(LibraryStringIds.SEARCH_FILTERS_USERS, + R.string.users); + LIBRARY_STRING_ID_TO_RES_ID_MAP.put(LibraryStringIds.SEARCH_FILTERS_VIDEOS, + R.string.videos_string); + LIBRARY_STRING_ID_TO_RES_ID_MAP.put(LibraryStringIds.SEARCH_FILTERS_VIEWS, + R.string.search_filters_views); + LIBRARY_STRING_ID_TO_RES_ID_MAP.put(LibraryStringIds.SEARCH_FILTERS_VOD_VIDEOS, + R.string.search_filters_vod_videos); + LIBRARY_STRING_ID_TO_RES_ID_MAP.put(LibraryStringIds.SEARCH_FILTERS_VR180, + R.string.search_filters_vr180); + LIBRARY_STRING_ID_TO_RES_ID_MAP.put(LibraryStringIds.SEARCH_FILTERS_THIS_WEEK, + R.string.search_filters_this_week); + LIBRARY_STRING_ID_TO_RES_ID_MAP.put(LibraryStringIds.SEARCH_FILTERS_THIS_YEAR, + R.string.search_filters_this_year); + LIBRARY_STRING_ID_TO_RES_ID_MAP.put(LibraryStringIds.SEARCH_FILTERS_YES, + R.string.search_filters_yes); + LIBRARY_STRING_ID_TO_RES_ID_MAP.put(LibraryStringIds.SEARCH_FILTERS_YOUTUBE_MUSIC, + R.string.search_filters_youtube_music); + } - private ServiceHelper() { } + private ServiceHelper() { + } @DrawableRes public static int getIcon(final int serviceId) { @@ -48,38 +207,6 @@ public static int getIcon(final int serviceId) { } } - public static String getTranslatedFilterString(final String filter, final Context c) { - switch (filter) { - case "all": - return c.getString(R.string.all); - case "videos": - case "sepia_videos": - case "music_videos": - return c.getString(R.string.videos_string); - case "channels": - return c.getString(R.string.channels); - case "playlists": - case "music_playlists": - return c.getString(R.string.playlists); - case "tracks": - return c.getString(R.string.tracks); - case "users": - return c.getString(R.string.users); - case "conferences": - return c.getString(R.string.conferences); - case "events": - return c.getString(R.string.events); - case "music_songs": - return c.getString(R.string.songs); - case "music_albums": - return c.getString(R.string.albums); - case "music_artists": - return c.getString(R.string.artists); - default: - return filter; - } - } - /** * Get a resource string with instructions for importing subscriptions for each service. * @@ -107,12 +234,10 @@ public static int getImportInstructions(final int serviceId) { */ @StringRes public static int getImportInstructionsHint(final int serviceId) { - switch (serviceId) { - case 1: - return R.string.import_soundcloud_instructions_hint; - default: - return -1; + if (serviceId == 1) { + return R.string.import_soundcloud_instructions_hint; } + return -1; } public static int getSelectedServiceId(final Context context) { @@ -210,4 +335,14 @@ public static void initServices(final Context context) { initService(context, s.getServiceId()); } } + + public static String getTranslatedFilterString(@NonNull final LibraryStringIds stringId, + @NonNull final Context context) { + if (LIBRARY_STRING_ID_TO_RES_ID_MAP.containsKey(stringId)) { + return context.getString( + Objects.requireNonNull(LIBRARY_STRING_ID_TO_RES_ID_MAP.get(stringId))); + } else { + return stringId.toString(); + } + } } diff --git a/app/src/main/res/color/mtrl_search_filter_chip_background_color.xml b/app/src/main/res/color/mtrl_search_filter_chip_background_color.xml new file mode 100644 index 00000000000..1bea84d4e6c --- /dev/null +++ b/app/src/main/res/color/mtrl_search_filter_chip_background_color.xml @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/app/src/main/res/layout/chip_search_filter.xml b/app/src/main/res/layout/chip_search_filter.xml new file mode 100644 index 00000000000..58fd1b5abe0 --- /dev/null +++ b/app/src/main/res/layout/chip_search_filter.xml @@ -0,0 +1,9 @@ + + + + diff --git a/app/src/main/res/layout/search_filter_dialog_fragment.xml b/app/src/main/res/layout/search_filter_dialog_fragment.xml new file mode 100644 index 00000000000..8d375a04b54 --- /dev/null +++ b/app/src/main/res/layout/search_filter_dialog_fragment.xml @@ -0,0 +1,23 @@ + + + + + + + + + + + diff --git a/app/src/main/res/layout/search_filter_option_menu_alike_dialog_fragment.xml b/app/src/main/res/layout/search_filter_option_menu_alike_dialog_fragment.xml new file mode 100644 index 00000000000..22a260b59ee --- /dev/null +++ b/app/src/main/res/layout/search_filter_option_menu_alike_dialog_fragment.xml @@ -0,0 +1,22 @@ + + + + + + + + + + + diff --git a/app/src/main/res/menu/menu_search_filter_dialog_fragment.xml b/app/src/main/res/menu/menu_search_filter_dialog_fragment.xml new file mode 100644 index 00000000000..7f0ec2009da --- /dev/null +++ b/app/src/main/res/menu/menu_search_filter_dialog_fragment.xml @@ -0,0 +1,15 @@ +

+ + + + + diff --git a/app/src/main/res/menu/menu_search_fragment.xml b/app/src/main/res/menu/menu_search_fragment.xml new file mode 100644 index 00000000000..1461245a516 --- /dev/null +++ b/app/src/main/res/menu/menu_search_fragment.xml @@ -0,0 +1,9 @@ + + + + diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 5260287c1bc..b85fde66307 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -806,4 +806,64 @@ Wiedergabeliste teilen Teile die Wiedergabeliste mit Details wie dem Namen der Wiedergabeliste und den Videotiteln oder als einfache Liste von Video-URLs - %1$s: %2$s - \ No newline at end of file + Sortierfilter + Inhaltsfilter + 10–30 min + 2–10 min + 360° + 3D + 4–20 min + 4K + Hinzugefügt + Künstler & Labels + Jederzeit + Aufsteigend + Creative Commons + Erstellungsdatum + Datum + Dauer + Eigenschaften + > 30 min + HD + HDR + Art + Letzte 30 Tage + Letzte 7 Tage + Letzte Stunde + Letztes Jahr + Länge + < 2 min + Lizenz + Standort + Lang (> 10 min) + Mittel (4–10 min) + Nein + Über 20 min + Vorheriger Tag + Vorige Stunde + Vergangener Monat + Vergangene Woche + Vergangenes Jahr + Erscheinungsdatum + Veröffentlicht + Gekauft + Bewertung + Relevanz + Sensibler Inhalt + SepiaSuche + Kurz (< 4 min) + Sortiert nach + Sortierung + Untertitel + Gewerblich nutzbar + Heute + Unter 4 min + Hochladedatum + Aufrufe + VOD-Videos + VR180 + Dieser Monat + Diese Woche + Dieses Jahr + Ja + diff --git a/app/src/main/res/values/settings_keys.xml b/app/src/main/res/values/settings_keys.xml index 880fa92da7b..61fd11f1940 100644 --- a/app/src/main/res/values/settings_keys.xml +++ b/app/src/main/res/values/settings_keys.xml @@ -1383,6 +1383,28 @@ @string/card + search_filter_ui + @string/search_filter_ui_dialog_key + + dialog + style + legacy + chip + + + @string/search_filter_ui_dialog_key + @string/search_filter_ui_option_menu_style_key + @string/search_filter_ui_option_menu_legacy_key + @string/search_filter_ui_chip_dialog_key + + + + @string/search_filter_ui_dialog + @string/search_filter_ui_style + @string/search_filter_ui_legacy + @string/search_filter_ui_chip_dialog + + tablet_mode auto diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 281df95a4b2..6dbe06562d8 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -845,4 +845,78 @@ Show more Show less + + + 10–30 min + 2–10 min + 360° + 3D + 4–20 min + 4K + Added + artists & labels + Any time + Ascending + Creative Commons + Creation date + Date + Duration + Features + > 30 min + HD + HDR + Kind + Last 30 days + Last 7 days + Last hour + last year + Length + < 2 min + License + Location + Long (> 10 min) + Medium (4–10 min) + No + Over 20 min + Past day + Past hour + Past month + Past week + Past year + Publish date + Published + Purchased + Rating + Relevance + Sensitive + SepiaSearch + Short (< 4 min) + Sort by + Sort order + Subtitles + To modify commercially + Today + Under 4 min + Upload Date + Views + VOD videos + VR180 + This month + This week + This year + Yes + YouTube Music + + + + Filter + Sort filters + Content filters + Select Search Filter UI + Simple Dialog (default) + Action Menu styled Dialog + Action Menu (legacy) + Chip Dialog + + diff --git a/app/src/main/res/values/styles.xml b/app/src/main/res/values/styles.xml index 164f1067224..106f36f9cf2 100644 --- a/app/src/main/res/values/styles.xml +++ b/app/src/main/res/values/styles.xml @@ -155,4 +155,12 @@ + diff --git a/app/src/main/res/xml/appearance_settings.xml b/app/src/main/res/xml/appearance_settings.xml index beb46cdf5e7..7365995b645 100644 --- a/app/src/main/res/xml/appearance_settings.xml +++ b/app/src/main/res/xml/appearance_settings.xml @@ -66,6 +66,16 @@ app:singleLineTitle="false" app:iconSpaceReserved="false" /> + + injectedItemPosition.get()); + assertEquals(expectedInjectedItemPosition, injectedItemPosition.get()); + } + + @NonNull + private Optional getInjectedFilterItem( + @NonNull final FilterContainer filterContainer, + @NonNull final AtomicInteger itemCount) { + + return filterContainer.getFilterGroups().stream() + .map(FilterGroup::getFilterItems) + .flatMap(Collection::stream) + .filter(item -> { + itemCount.getAndIncrement(); + return item instanceof InjectFilterItem.DividerItem; + }) + .findAny(); + } + + public static class InjectDividerTestClass extends InjectFilterItem { + + private static boolean isDividerInjected = false; + + protected InjectDividerTestClass(@NonNull final String serviceName) { + super(serviceName, + YoutubeFilters.ID_CF_MAIN_PLAYLISTS, + new DividerItem(0) + ); + } + + public static void run(final String serviceName) { + if (!isDividerInjected) { + new InjectDividerTestClass(serviceName); + } + } + + @Override + protected boolean isAlreadyInjected() { + return isDividerInjected; + } + + @Override + protected void setAsInjected() { + isDividerInjected = true; + } + } +} diff --git a/app/src/test/java/org/schabi/newpipe/filter/SearchFilterLogicAndUiGeneratorTest.java b/app/src/test/java/org/schabi/newpipe/filter/SearchFilterLogicAndUiGeneratorTest.java new file mode 100644 index 00000000000..d6ed2bfc528 --- /dev/null +++ b/app/src/test/java/org/schabi/newpipe/filter/SearchFilterLogicAndUiGeneratorTest.java @@ -0,0 +1,543 @@ +// Created by evermind-zz 2022, licensed GNU GPL version 3 or later + +package org.schabi.newpipe.filter; + + +import org.junit.Test; +import org.schabi.newpipe.extractor.NewPipe; +import org.schabi.newpipe.extractor.StreamingService; +import org.schabi.newpipe.extractor.exceptions.ExtractionException; +import org.schabi.newpipe.extractor.linkhandler.SearchQueryHandlerFactory; +import org.schabi.newpipe.extractor.search.filter.FilterContainer; +import org.schabi.newpipe.extractor.search.filter.FilterGroup; +import org.schabi.newpipe.extractor.search.filter.FilterItem; +import org.schabi.newpipe.extractor.services.peertube.search.filter.PeertubeFilters; +import org.schabi.newpipe.extractor.services.youtube.search.filter.YoutubeFilters; +import org.schabi.newpipe.fragments.list.search.filter.BaseSearchFilterUiGenerator; +import org.schabi.newpipe.fragments.list.search.filter.SearchFilterLogic; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +import androidx.annotation.NonNull; + +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; +import static org.schabi.newpipe.fragments.list.search.filter.SearchFilterLogic.Callback; +import static org.schabi.newpipe.fragments.list.search.filter.SearchFilterLogic.ICreateUiForFiltersWorker; + +/** + * Test the {@link SearchFilterLogic} and + * {@link org.schabi.newpipe.extractor.search.filter.SearchFiltersBase}. + */ +public class SearchFilterLogicAndUiGeneratorTest { + + private static final int PEERTUBE_SERVICE_ID = 3; + private static final int YOUTUBE_SERVICE_ID = 0; + private final Map universalWrapper = new HashMap<>(); + private BaseSearchFilterUiGenerator generator; + private StreamingService service; + private SearchFilterGeneratorWorkersClass.FilterWorker sortWorker; + private List fromCallbackContentFilterItems; + private List fromCallbackSortFilterItems; + private SearchFilterLogic logic; + + + private void setupEach(final boolean withUiWorker, + final SearchFilterLogic.Callback callback) + throws ExtractionException { + setupEach(withUiWorker, PEERTUBE_SERVICE_ID, callback); + } + + private void setupEach(final boolean withUiWorker, + final int serviceId, + final SearchFilterLogic.Callback callback) + throws ExtractionException { + service = NewPipe.getService(serviceId); + + logic = SearchFilterLogic.Factory.create( + SearchFilterLogic.Factory.Variant.SEARCH_FILTER_LOGIC_DEFAULT, + service.getSearchQHFactory(), + callback); + + if (withUiWorker) { + generator = new SearchFilterGeneratorWorkersClass(service.getSearchQHFactory(), + callback, logic); + } else { + generator = new SearchFilterGeneratorNoWorkersClass(service.getSearchQHFactory(), + callback, logic); + } + } + + @Test + public void resetAndRestoreTest() throws ExtractionException { + setupEach(false, null); + // 1. no data input (eg no previously selected filters set) + final ArrayList contentFilters = logic.getSelectedContentFilters(); + final ArrayList sortFilters = logic.getSelectedSortFilters(); + logic.reset(); + final ArrayList contentFilters2 = logic.getSelectedContentFilters(); + final ArrayList sortFilters2 = logic.getSelectedSortFilters(); + assertTrue(!contentFilters2.isEmpty() && !contentFilters.isEmpty()); + assertTrue(!sortFilters2.isEmpty() && !sortFilters.isEmpty()); + + // 2. test if initially set some data that should be present in output + final ArrayList contentFiltersWithNoneDefaultId = new ArrayList<>(); + contentFiltersWithNoneDefaultId.add(PeertubeFilters.ID_CF_MAIN_VIDEOS); + final ArrayList sortFiltersWithNoneDefaultId = new ArrayList<>(); + sortFiltersWithNoneDefaultId.add(PeertubeFilters.ID_SF_SORT_BY_CREATION_DATE); + + logic.restorePreviouslySelectedFilters(contentFiltersWithNoneDefaultId, + sortFiltersWithNoneDefaultId); + + ArrayList contentFilterResetResult = logic.getSelectedContentFilters(); + ArrayList sortFilterResetResult = logic.getSelectedSortFilters(); + + assertTrue(contentFilterResetResult.contains(PeertubeFilters.ID_CF_MAIN_VIDEOS)); + assertTrue(sortFilterResetResult.contains(PeertubeFilters.ID_SF_SORT_BY_CREATION_DATE)); + assertFalse(contentFilterResetResult.contains(PeertubeFilters.ID_CF_MAIN_CHANNELS)); + assertFalse(sortFilterResetResult.contains(PeertubeFilters.ID_SF_SORT_BY_RELEVANCE)); + + logic.reset(); // now go back to default values + + contentFilterResetResult = logic.getSelectedContentFilters(); + sortFilterResetResult = logic.getSelectedSortFilters(); + assertTrue(contentFilterResetResult.contains(PeertubeFilters.ID_CF_MAIN_VIDEOS)); + assertTrue(sortFilterResetResult.contains(PeertubeFilters.ID_SF_SORT_BY_RELEVANCE)); + + // 3. test if empty input data results in defaults + setupEach(false, null); + logic.restorePreviouslySelectedFilters(new ArrayList<>(), + new ArrayList<>()); + final ArrayList contentFilterResultNoInput = + logic.getSelectedContentFilters(); + final ArrayList sortFilterResultNoInput = + logic.getSelectedSortFilters(); + assertTrue(contentFilterResultNoInput.contains(PeertubeFilters.ID_CF_MAIN_VIDEOS)); + assertTrue(sortFilterResultNoInput.contains(PeertubeFilters.ID_SF_SORT_BY_RELEVANCE)); + + // 4. compare 2 and 3 results + assertArrayEquals(contentFilterResetResult.toArray(), + contentFilterResultNoInput.toArray()); + assertArrayEquals(sortFilterResetResult.toArray(), + sortFilterResultNoInput.toArray()); + } + + @Test + public void checkIfInitResultsInDefaultSortAndContentFiltersTest() throws ExtractionException { + setupEach(false, null); + + // after setupEach() there should be default entries. + final ArrayList defaultContentFilters = + logic.getSelectedContentFilters(); + final ArrayList defaultSortFilters = + logic.getSelectedSortFilters(); + + assertTrue(defaultContentFilters.contains(PeertubeFilters.ID_CF_MAIN_VIDEOS)); + assertTrue(defaultSortFilters.contains(PeertubeFilters.ID_SF_SORT_BY_RELEVANCE)); + } + + @Test + public void contentFilterItemsIdsMatchIdsAndCallbackTest() throws ExtractionException { + setupEach(false, new SearchFilterLogic.Callback() { + @Override + public void selectedFilters(@NonNull final List userSelectedContentFilter, + @NonNull final List userSelectedSortFilter) { + fromCallbackContentFilterItems = userSelectedContentFilter; + fromCallbackSortFilterItems = userSelectedSortFilter; + } + }); + + // reset to null + fromCallbackContentFilterItems = null; + fromCallbackSortFilterItems = null; + + // after setupEach() there should be default entries. + ArrayList defaultContentFiltersIds = logic.getSelectedContentFilters(); + ArrayList defaultSortFiltersIds = logic.getSelectedSortFilters(); + List defaultContentFilterItems = logic.getSelectedContentFilterItems(); + List defaultSortFilterItems = logic.getSelectedSortFiltersItems(); + + assertNotEquals(defaultContentFiltersIds.size(), defaultContentFilterItems.size()); + assertNotEquals(defaultSortFiltersIds.size(), defaultSortFilterItems.size()); + + assertNull(fromCallbackContentFilterItems); + assertNull(fromCallbackSortFilterItems); + + logic.prepareForSearch(); // callback variables are now being initialized + + assertNotNull(fromCallbackContentFilterItems); + assertNotNull(fromCallbackSortFilterItems); + + defaultContentFiltersIds = logic.getSelectedContentFilters(); + defaultSortFiltersIds = logic.getSelectedSortFilters(); + defaultContentFilterItems = logic.getSelectedContentFilterItems(); + defaultSortFilterItems = logic.getSelectedSortFiltersItems(); + + assertTrue(defaultContentFilterItems.size() > 0); + assertTrue(defaultSortFilterItems.size() > 0); + + assertEquals(defaultContentFiltersIds.size(), defaultContentFilterItems.size()); + assertEquals(defaultSortFiltersIds.size(), defaultSortFilterItems.size()); + + compareFilterIdsWithFilterItems(defaultContentFiltersIds, defaultContentFilterItems); + compareFilterIdsWithFilterItems(defaultSortFiltersIds, defaultSortFilterItems); + + compareFilterIdsWithFilterItems(defaultContentFiltersIds, fromCallbackContentFilterItems); + compareFilterIdsWithFilterItems(defaultSortFiltersIds, fromCallbackSortFilterItems); + } + + private void compareFilterIdsWithFilterItems(final ArrayList filterIds, + final List filterItems) { + int idx = 0; + for (final FilterItem item : filterItems) { + final int filterItemId = item.getIdentifier(); + final int filterItemId2 = filterIds.get(idx++); + assertEquals(filterItemId, filterItemId2); + } + } + + @Test(expected = RuntimeException.class) + public void checkIllegalContentFilterIdsTest() throws ExtractionException { + setupEach(false, null); + final ArrayList contentFiltersWithIllegalIds = new ArrayList<>(); + contentFiltersWithIllegalIds.add(10000); + final ArrayList sortFiltersEmpty = new ArrayList<>(); + + logic.restorePreviouslySelectedFilters(contentFiltersWithIllegalIds, + sortFiltersEmpty); + } + + @Test(expected = RuntimeException.class) + public void checkIllegalSortFilterIdsTest() throws ExtractionException { + setupEach(false, null); + // content filter can not be empty + final ArrayList contentFiltersWithValidId = new ArrayList<>(); + contentFiltersWithValidId.add(PeertubeFilters.ID_CF_MAIN_VIDEOS); + final ArrayList sortFiltersWithIllegalIds = new ArrayList<>(); + sortFiltersWithIllegalIds.add(20000); + + logic.restorePreviouslySelectedFilters(contentFiltersWithValidId, + sortFiltersWithIllegalIds); + } + + @Test + public void selectOneContenFilterKeepDefaultSortFilterTest() throws ExtractionException { + setupEach(false, null); + + // set only one content filter, keep default sort filters + logic.selectContentFilter(PeertubeFilters.ID_CF_MAIN_PLAYLISTS); + ArrayList contentFilterResetResult = logic.getSelectedContentFilters(); + ArrayList sortFilterResetResult = logic.getSelectedSortFilters(); + assertTrue(contentFilterResetResult.contains(PeertubeFilters.ID_CF_MAIN_PLAYLISTS)); + assertTrue(sortFilterResetResult.contains(PeertubeFilters.ID_SF_SORT_BY_RELEVANCE)); + + logic.selectContentFilter(PeertubeFilters.ID_CF_MAIN_CHANNELS); + contentFilterResetResult = logic.getSelectedContentFilters(); + sortFilterResetResult = logic.getSelectedSortFilters(); + assertFalse(contentFilterResetResult.contains(PeertubeFilters.ID_CF_MAIN_PLAYLISTS)); + assertTrue(contentFilterResetResult.contains(PeertubeFilters.ID_CF_MAIN_CHANNELS)); + assertTrue(sortFilterResetResult.contains(PeertubeFilters.ID_SF_SORT_BY_RELEVANCE)); + } + + @Test + public void selectOneContentFilterAndOneSortFilterTest() throws ExtractionException { + setupEach(false, null); + + logic.selectContentFilter(PeertubeFilters.ID_CF_MAIN_PLAYLISTS); + logic.selectSortFilter(PeertubeFilters.ID_SF_SORT_BY_CREATION_DATE); + ArrayList contentFilterResetResult = logic.getSelectedContentFilters(); + ArrayList sortFilterResetResult = logic.getSelectedSortFilters(); + assertTrue(contentFilterResetResult.contains(PeertubeFilters.ID_CF_MAIN_PLAYLISTS)); + assertFalse(sortFilterResetResult.contains(PeertubeFilters.ID_SF_SORT_BY_RELEVANCE)); + assertTrue(sortFilterResetResult.contains(PeertubeFilters.ID_SF_SORT_BY_CREATION_DATE)); + + logic.selectContentFilter(PeertubeFilters.ID_CF_MAIN_CHANNELS); + logic.selectSortFilter(PeertubeFilters.ID_SF_SORT_BY_DURATION); + contentFilterResetResult = logic.getSelectedContentFilters(); + sortFilterResetResult = logic.getSelectedSortFilters(); + assertFalse(contentFilterResetResult.contains(PeertubeFilters.ID_CF_MAIN_PLAYLISTS)); + assertTrue(contentFilterResetResult.contains(PeertubeFilters.ID_CF_MAIN_CHANNELS)); + assertTrue(sortFilterResetResult.contains(PeertubeFilters.ID_SF_SORT_BY_DURATION)); + assertFalse(sortFilterResetResult.contains(PeertubeFilters.ID_SF_SORT_BY_CREATION_DATE)); + } + + @Test + public void selectTwoContentFiltersTest() throws ExtractionException { + setupEach(false, null); + + logic.selectContentFilter(PeertubeFilters.ID_CF_MAIN_PLAYLISTS); + ArrayList contentFilterResetResult = logic.getSelectedContentFilters(); + assertTrue(contentFilterResetResult.contains(PeertubeFilters.ID_CF_MAIN_PLAYLISTS)); + assertFalse(contentFilterResetResult.contains(PeertubeFilters.ID_CF_SEPIA_SEPIASEARCH)); + + // 2nd content filters added from another group of course as PeertubeFilter.ID_CF_MAIN_GRP + // is exclusive group -> only one item per group allowed + logic.selectContentFilter(PeertubeFilters.ID_CF_SEPIA_SEPIASEARCH); + contentFilterResetResult = logic.getSelectedContentFilters(); + assertTrue(contentFilterResetResult.contains(PeertubeFilters.ID_CF_MAIN_PLAYLISTS)); + assertTrue(contentFilterResetResult.contains(PeertubeFilters.ID_CF_SEPIA_SEPIASEARCH)); + } + + @Test + public void selectMultipleSortFilterInNonExclusiveGroupTest() throws ExtractionException { + selectMultipleSortFilterInNonExclusiveGroupHelper(false); + } + + @Test + public void selectMultipleSortFilterInNonExclusiveGroupWithUiTest() throws ExtractionException { + selectMultipleSortFilterInNonExclusiveGroupHelper(true); + } + + private void selectMultipleSortFilterInNonExclusiveGroupHelper(final boolean withUiWorker) + throws ExtractionException { + setupEach(withUiWorker, YOUTUBE_SERVICE_ID, null); + + if (withUiWorker) { + universalWrapper.clear(); + generator.createSearchUI(); + simulateUiClicking(YoutubeFilters.ID_CF_MAIN_VIDEOS); + } + logic.selectContentFilter(YoutubeFilters.ID_CF_MAIN_VIDEOS); + final ArrayList contentFilterResetResult = logic.getSelectedContentFilters(); + assertTrue(contentFilterResetResult.contains(YoutubeFilters.ID_CF_MAIN_VIDEOS)); + + // select 1st element from a non-exclusive group + if (withUiWorker) { + simulateUiClicking(YoutubeFilters.ID_SF_FEATURES_3D); + } + logic.selectSortFilter(YoutubeFilters.ID_SF_FEATURES_3D); + ArrayList sortFilterResetResult = logic.getSelectedSortFilters(); + assertTrue(sortFilterResetResult.contains(YoutubeFilters.ID_SF_FEATURES_3D)); + assertFalse(sortFilterResetResult.contains(YoutubeFilters.ID_SF_FEATURES_4K)); + + // select 2nd element from a non-exclusive group + if (withUiWorker) { + simulateUiClicking(YoutubeFilters.ID_SF_FEATURES_4K); + } + logic.selectSortFilter(YoutubeFilters.ID_SF_FEATURES_4K); + sortFilterResetResult = logic.getSelectedSortFilters(); + assertTrue(sortFilterResetResult.contains(YoutubeFilters.ID_SF_FEATURES_3D)); + assertTrue(sortFilterResetResult.contains(YoutubeFilters.ID_SF_FEATURES_4K)); + + // deselect previous selected element + if (withUiWorker) { + simulateUiClicking(YoutubeFilters.ID_SF_FEATURES_4K); + } + logic.selectSortFilter(YoutubeFilters.ID_SF_FEATURES_4K); + sortFilterResetResult = logic.getSelectedSortFilters(); + assertTrue(sortFilterResetResult.contains(YoutubeFilters.ID_SF_FEATURES_3D)); + assertFalse(sortFilterResetResult.contains(YoutubeFilters.ID_SF_FEATURES_4K)); + } + + private void simulateUiClicking(final int id) { + final boolean isSelected = universalWrapper.get(id).isChecked(); + universalWrapper.get(id).setChecked(!isSelected); + } + + private void expectSortFiltersToBeVisible(final int id) { + final FilterContainer sortFilterVariant = service.getSearchQHFactory() + .getContentFilterSortFilterVariant(id); + assertTrue(!sortFilterVariant.getFilterGroups().isEmpty()); + for (final FilterGroup group : sortFilterVariant.getFilterGroups()) { + for (final FilterItem item : group.getFilterItems()) { + final int itemId = item.getIdentifier(); + assertTrue(universalWrapper.containsKey(itemId)); + assertNotNull(universalWrapper.get(itemId)); + assertTrue(universalWrapper.get(itemId).visible); + } + } + assertNotNull(sortWorker.areAnySortFiltersVisible); + assertTrue(sortWorker.areAnySortFiltersVisible.isPresent()); + assertTrue(sortWorker.areAnySortFiltersVisible.get()); + } + + @Test + public void checkIfCorrespondingSortFiltersAreDisplayedTest() + throws ExtractionException { + setupEach(true, PEERTUBE_SERVICE_ID, null); + + universalWrapper.clear(); + generator.createSearchUI(); + + // 1st test: + // default content filter is PeertubeFilters.ID_CF_MAIN_ALL so we expect all sort filters + // visible. Get the filters from service and compare with universalWrapper map + expectSortFiltersToBeVisible(PeertubeFilters.ID_CF_MAIN_VIDEOS); + + // 2nd test: + // content filter with no sort filters aka Ui element should be not visible. + // get all sort filters from and compare with universalWrapper map + // set content filter with no sort filters available + final int contentFilterWithNoSortFilters = PeertubeFilters.ID_CF_MAIN_PLAYLISTS; + logic.selectContentFilter(contentFilterWithNoSortFilters); + final FilterContainer noSortFiltersAkaNull = service.getSearchQHFactory() + .getContentFilterSortFilterVariant(contentFilterWithNoSortFilters); + assertNull(noSortFiltersAkaNull); + + // get content filter with all sort filters visible in two ways + // first way + final FilterContainer allSortFilters = service.getSearchQHFactory() + .getContentFilterSortFilterVariant(PeertubeFilters.ID_CF_MAIN_VIDEOS); + // second way + final Optional allSortFilters2 = service.getSearchQHFactory() + .getAvailableContentFilter() + .getFilterGroups().stream() + .filter(filterGroup + -> (filterGroup.getIdentifier() == PeertubeFilters.ID_CF_MAIN_GRP)) + .findFirst(); + + assertNotNull(allSortFilters); + assertTrue(allSortFilters2.isPresent()); + assertEquals(allSortFilters, allSortFilters2.get().getAllSortFilters()); + assertTrue(!allSortFilters.getFilterGroups().isEmpty()); + assertNotNull(sortWorker.areAnySortFiltersVisible); + assertTrue(sortWorker.areAnySortFiltersVisible.isPresent()); + assertFalse(sortWorker.areAnySortFiltersVisible.get()); + + // expect all sort filters not visible + for (final FilterGroup group : allSortFilters.getFilterGroups()) { + for (final FilterItem item : group.getFilterItems()) { + final int id = item.getIdentifier(); + assertTrue(universalWrapper.containsKey(id)); + assertNotNull(universalWrapper.get(id)); + assertFalse(universalWrapper.get(id).visible); + } + } + + // 3rd test: + // select content filter that should have all sort filters visible again + final int contentFilterWithAllSortFiltersVisible = PeertubeFilters.ID_CF_MAIN_VIDEOS; + logic.selectContentFilter(contentFilterWithAllSortFiltersVisible); + expectSortFiltersToBeVisible(contentFilterWithAllSortFiltersVisible); + } + + // helpers + private static class SearchFilterGeneratorNoWorkersClass extends BaseSearchFilterUiGenerator { + + SearchFilterGeneratorNoWorkersClass(final SearchQueryHandlerFactory linkHandlerFactory, + final Callback callback, + final SearchFilterLogic logic) { + super(logic, null); // context is null as this is no androidTest + } + + @Override + protected ICreateUiForFiltersWorker createSortFilterWorker() { + return null; + } + + @Override + protected ICreateUiForFiltersWorker createContentFilterWorker() { + return null; + } + } + + static class ElementsWrapper implements SearchFilterLogic.IUiItemWrapper { + public final FilterItem item; + public final int groupId; + public boolean isSelected; + public boolean visible; + + ElementsWrapper(final FilterItem item, + final int groupId) { + this.item = item; + this.groupId = groupId; + this.visible = false; + this.isSelected = false; + } + + @Override + public void setVisible(final boolean visible) { + this.visible = visible; + } + + @Override + public int getItemId() { + return item.getIdentifier(); + } + + @Override + public boolean isChecked() { + return isSelected; + } + + @Override + public void setChecked(final boolean checked) { + this.isSelected = checked; + } + } + + private class SearchFilterGeneratorWorkersClass extends SearchFilterGeneratorNoWorkersClass { + + SearchFilterGeneratorWorkersClass(final SearchQueryHandlerFactory linkHandlerFactory, + final Callback callback, + final SearchFilterLogic logic) { + super(linkHandlerFactory, callback, logic); + } + + @Override + protected ICreateUiForFiltersWorker createSortFilterWorker() { + sortWorker = new FilterWorker(true); + return sortWorker; + } + + @Override + protected ICreateUiForFiltersWorker createContentFilterWorker() { + return new FilterWorker(false); + } + + class FilterWorker implements ICreateUiForFiltersWorker { + + private final boolean isSortWorker; + public Optional areAnySortFiltersVisible = null; + + FilterWorker(final boolean isSortWorker) { + this.isSortWorker = isSortWorker; + } + + @Override + public void prepare() { + } + + @Override + public void createFilterGroupBeforeItems(@NonNull final FilterGroup filterGroup) { + for (final FilterItem item : filterGroup.getFilterItems()) { + final ElementsWrapper element = + new ElementsWrapper(item, filterGroup.getIdentifier()); + universalWrapper.put(item.getIdentifier(), element); + if (isSortWorker) { + logic.addSortFilterUiWrapperToItemMap(item.getIdentifier(), element); + } else { + logic.addContentFilterUiWrapperToItemMap(item.getIdentifier(), element); + } + } + } + + @Override + public void createFilterItem(@NonNull final FilterItem filterItem, + @NonNull final FilterGroup filterGroup) { + } + + @Override + public void createFilterGroupAfterItems(@NonNull final FilterGroup filterGroup) { + } + + @Override + public void finish() { + } + + @Override + public void filtersVisible(final boolean areFiltersVisible) { + areAnySortFiltersVisible = Optional.of(areFiltersVisible); + } + } + } +}