From e2bce99e91c6964060353d70a4c35c2f724d578d Mon Sep 17 00:00:00 2001 From: evermind Date: Fri, 19 Aug 2022 00:19:25 +0200 Subject: [PATCH 01/13] searchfilters: dynamically generate UIs and evaluate selected sort and content filters SearchFilterLogic.java: ======================= This class handles all the user interaction with the content and sort filters of NewPipeExtractor. The class works standalone to just get the default selected filters eg. during init phase. See in SearchFragment#initializeFilterData() BaseSearchFilterUiGenerator.java: ================================= It extends SearchFilterLogic and is used as a base class to implement the UI interface for content and sort filter dialogs eg. SearchFilterDialogGenerator or SearchFilterOptionMenuAlikeDialogGenerator. --- .../filter/BaseSearchFilterUiGenerator.java | 84 ++ .../list/search/filter/SearchFilterLogic.java | 830 ++++++++++++++++++ 2 files changed, 914 insertions(+) create mode 100644 app/src/main/java/org/schabi/newpipe/fragments/list/search/filter/BaseSearchFilterUiGenerator.java create mode 100644 app/src/main/java/org/schabi/newpipe/fragments/list/search/filter/SearchFilterLogic.java 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/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 + } + } +} From 651a333393ccbf566affd1597671bbcb78448a23 Mon Sep 17 00:00:00 2001 From: evermind Date: Tue, 11 Oct 2022 15:42:23 +0200 Subject: [PATCH 02/13] searchfilters: unit test for SearchFilterLogic and BaseSearchFilterUiGenerator --- .../SearchFilterLogicAndUiGeneratorTest.java | 543 ++++++++++++++++++ 1 file changed, 543 insertions(+) create mode 100644 app/src/test/java/org/schabi/newpipe/filter/SearchFilterLogicAndUiGeneratorTest.java 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); + } + } + } +} From 3c038aaf7ce4c3bf8c87fce74e1665f626aec356 Mon Sep 17 00:00:00 2001 From: evermind Date: Mon, 31 Oct 2022 23:02:08 +0100 Subject: [PATCH 03/13] searchfilters: common base classes for DialogFragment based UI's --- .../filter/BaseCreateSearchFilterUI.java | 123 ++++++++++++++++++ .../list/search/filter/BaseItemWrapper.java | 21 +++ .../BaseSearchFilterDialogFragment.java | 116 +++++++++++++++++ .../BaseSearchFilterUiDialogGenerator.java | 84 ++++++++++++ .../list/search/filter/BaseUiItemWrapper.java | 29 +++++ .../search/filter/UiItemWrapperViews.java | 62 +++++++++ 6 files changed, 435 insertions(+) create mode 100644 app/src/main/java/org/schabi/newpipe/fragments/list/search/filter/BaseCreateSearchFilterUI.java create mode 100644 app/src/main/java/org/schabi/newpipe/fragments/list/search/filter/BaseItemWrapper.java create mode 100644 app/src/main/java/org/schabi/newpipe/fragments/list/search/filter/BaseSearchFilterDialogFragment.java create mode 100644 app/src/main/java/org/schabi/newpipe/fragments/list/search/filter/BaseSearchFilterUiDialogGenerator.java create mode 100644 app/src/main/java/org/schabi/newpipe/fragments/list/search/filter/BaseUiItemWrapper.java create mode 100644 app/src/main/java/org/schabi/newpipe/fragments/list/search/filter/UiItemWrapperViews.java 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/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/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. + } +} From 7c650f6e9dcccd6d05493995fb81687141e88853 Mon Sep 17 00:00:00 2001 From: evermind Date: Tue, 22 Nov 2022 23:18:40 +0100 Subject: [PATCH 04/13] searchfilters: Moving DividerItem from NewPipeExtractor into NewPipe DividerItem was inserted in the content filter framework in the NewPipeExtractor to have a section title for YoutubeMusic. But as UI releated stuff seems a bit out of place in the Extractor I came up with injecting the DividerItem aka section title in the frontend without having to change too much in the frontend. --- .../list/search/filter/InjectFilterItem.java | 147 ++++++++++++++++++ .../newpipe/filter/InjectFilterItemTest.java | 85 ++++++++++ 2 files changed, 232 insertions(+) create mode 100644 app/src/main/java/org/schabi/newpipe/fragments/list/search/filter/InjectFilterItem.java create mode 100644 app/src/test/java/org/schabi/newpipe/filter/InjectFilterItemTest.java 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/test/java/org/schabi/newpipe/filter/InjectFilterItemTest.java b/app/src/test/java/org/schabi/newpipe/filter/InjectFilterItemTest.java new file mode 100644 index 00000000000..ceb508efad9 --- /dev/null +++ b/app/src/test/java/org/schabi/newpipe/filter/InjectFilterItemTest.java @@ -0,0 +1,85 @@ +package org.schabi.newpipe.filter; + +import org.junit.Test; +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.services.youtube.search.filter.YoutubeFilters; +import org.schabi.newpipe.fragments.list.search.filter.InjectFilterItem; + +import java.util.Collection; +import java.util.Optional; +import java.util.concurrent.atomic.AtomicInteger; + +import androidx.annotation.NonNull; + +import static junit.framework.TestCase.assertFalse; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +public class InjectFilterItemTest { + + static final String SERVICE_NAME = "YouTube"; + + @Test + public void injectIntoFilterGroupTest() throws ExtractionException { + final FilterContainer filterContainer = NewPipe.getService(SERVICE_NAME) + .getSearchQHFactory().getAvailableContentFilter(); + + final AtomicInteger itemCount = new AtomicInteger(); + assertFalse(getInjectedFilterItem(filterContainer, itemCount).isPresent()); + + InjectDividerTestClass.run(SERVICE_NAME); + + final int expectedInjectedItemPosition = 5; + final AtomicInteger injectedItemPosition = new AtomicInteger(); + assertTrue(getInjectedFilterItem(filterContainer, injectedItemPosition).isPresent()); + assertTrue(itemCount.get() > 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; + } + } +} From 05ffe276c05fd352ae9063a60ae87c006920461c Mon Sep 17 00:00:00 2001 From: evermind Date: Tue, 11 Oct 2022 15:07:18 +0200 Subject: [PATCH 05/13] searchfilters: 1st Ui: default dialog for search content and sort filters --- .../filter/SearchFilterDialogFragment.java | 41 +++ .../filter/SearchFilterDialogGenerator.java | 337 ++++++++++++++++++ .../SearchFilterDialogSpinnerAdapter.java | 224 ++++++++++++ ...rl_search_filter_chip_background_color.xml | 9 + .../main/res/layout/chip_search_filter.xml | 9 + .../layout/search_filter_dialog_fragment.xml | 23 ++ .../menu_search_filter_dialog_fragment.xml | 15 + app/src/main/res/values/styles.xml | 8 + 8 files changed, 666 insertions(+) create mode 100644 app/src/main/java/org/schabi/newpipe/fragments/list/search/filter/SearchFilterDialogFragment.java create mode 100644 app/src/main/java/org/schabi/newpipe/fragments/list/search/filter/SearchFilterDialogGenerator.java create mode 100644 app/src/main/java/org/schabi/newpipe/fragments/list/search/filter/SearchFilterDialogSpinnerAdapter.java create mode 100644 app/src/main/res/color/mtrl_search_filter_chip_background_color.xml create mode 100644 app/src/main/res/layout/chip_search_filter.xml create mode 100644 app/src/main/res/layout/search_filter_dialog_fragment.xml create mode 100644 app/src/main/res/menu/menu_search_filter_dialog_fragment.xml 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/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/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/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 @@ + From 466ddb60c020ef89556081571bae7c3e4c2f3b22 Mon Sep 17 00:00:00 2001 From: evermind Date: Tue, 11 Oct 2022 15:32:44 +0200 Subject: [PATCH 06/13] searchfilters: 2nd Ui: Action menu feel alike dialog Dialog looks similar to a action menu based approach but is faster. --- ...chFilterOptionMenuAlikeDialogFragment.java | 77 ++++ ...hFilterOptionMenuAlikeDialogGenerator.java | 365 ++++++++++++++++++ ...lter_option_menu_alike_dialog_fragment.xml | 22 ++ 3 files changed, 464 insertions(+) create mode 100644 app/src/main/java/org/schabi/newpipe/fragments/list/search/filter/SearchFilterOptionMenuAlikeDialogFragment.java create mode 100644 app/src/main/java/org/schabi/newpipe/fragments/list/search/filter/SearchFilterOptionMenuAlikeDialogGenerator.java create mode 100644 app/src/main/res/layout/search_filter_option_menu_alike_dialog_fragment.xml 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/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 @@ + + + + + + + + + + + From a0d576ffc3b44d100d625f05b93a091a114ea4ff Mon Sep 17 00:00:00 2001 From: evermind Date: Tue, 11 Oct 2022 15:39:47 +0200 Subject: [PATCH 07/13] searchfilters: 3rd Ui: action based UI (enhanched legacy menu) This approach is more or less a hack but if all else fails. Could later be dropped or right away. --- .../list/search/SearchFragmentLegacy.java | 73 +++++ .../filter/SearchFilterUIOptionMenu.java | 303 ++++++++++++++++++ 2 files changed, 376 insertions(+) create mode 100644 app/src/main/java/org/schabi/newpipe/fragments/list/search/SearchFragmentLegacy.java create mode 100644 app/src/main/java/org/schabi/newpipe/fragments/list/search/filter/SearchFilterUIOptionMenu.java 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/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 + } + } +} From a434af69879e623de01ee6d3751d1bc54959463f Mon Sep 17 00:00:00 2001 From: evermind Date: Thu, 1 Dec 2022 13:16:34 +0100 Subject: [PATCH 08/13] searchfilters: 4th dialog: every search filter option is a Chip --- .../SearchFilterChipDialogFragment.java | 40 +++++++++ .../SearchFilterChipDialogGenerator.java | 84 +++++++++++++++++++ 2 files changed, 124 insertions(+) create mode 100644 app/src/main/java/org/schabi/newpipe/fragments/list/search/filter/SearchFilterChipDialogFragment.java create mode 100644 app/src/main/java/org/schabi/newpipe/fragments/list/search/filter/SearchFilterChipDialogGenerator.java 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; + } + +} From 8858e7e3a77c19426bd32ceede5dbe24a791ef6e Mon Sep 17 00:00:00 2001 From: evermind Date: Mon, 21 Nov 2022 07:46:06 +0100 Subject: [PATCH 09/13] searchfilters: ViewModel for SearchFragment* and SearchFilter*DialogFragment The ViewModel that hosts the search filters logic. It facilitates the communication with the SearchFragment* and the SearchFilter*DialogFragment based search filter UI's --- .../fragments/list/search/SearchViewModel.kt | 99 +++++++++++++++++++ 1 file changed, 99 insertions(+) create mode 100644 app/src/main/java/org/schabi/newpipe/fragments/list/search/SearchViewModel.kt 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 + ) + } + } + } +} From 94511671cfb89a2a9067f638897c47e003d05fa7 Mon Sep 17 00:00:00 2001 From: evermind Date: Mon, 31 Oct 2022 23:12:16 +0100 Subject: [PATCH 10/13] searchfilters: rework getTranslatedFilterString() method to use NewPipeExtractor's LibraryStringIds class --- .../schabi/newpipe/util/ServiceHelper.java | 227 ++++++++++++++---- app/src/main/res/values-de/strings.xml | 62 ++++- app/src/main/res/values/strings.xml | 62 +++++ 3 files changed, 304 insertions(+), 47 deletions(-) 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/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/strings.xml b/app/src/main/res/values/strings.xml index 281df95a4b2..e1fd0ea5671 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -845,4 +845,66 @@ 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 + From 6bcca69563a0e7325cfffd1408cadfff322f79c7 Mon Sep 17 00:00:00 2001 From: evermind Date: Fri, 19 Aug 2022 00:19:25 +0200 Subject: [PATCH 11/13] searchfilters: replace old filter interaction and integrate new dialog into SearchFragment There is also a configuration option to choose between different search UI's --- .../fragments/list/search/SearchFragment.java | 235 ++++++++++-------- .../schabi/newpipe/util/ExtractorHelper.java | 10 +- .../main/res/menu/menu_search_fragment.xml | 9 + app/src/main/res/values/settings_keys.xml | 22 ++ app/src/main/res/values/strings.xml | 12 + app/src/main/res/xml/appearance_settings.xml | 10 + 6 files changed, 187 insertions(+), 111 deletions(-) create mode 100644 app/src/main/res/menu/menu_search_fragment.xml 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/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/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/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 e1fd0ea5671..6dbe06562d8 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -907,4 +907,16 @@ 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/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" /> + + Date: Sat, 30 Dec 2023 22:44:06 +0100 Subject: [PATCH 12/13] use FilterItem for tabs --- .../list/channel/ChannelFragment.java | 3 +- .../schabi/newpipe/util/ChannelTabHelper.java | 112 ++++++++---------- 2 files changed, 53 insertions(+), 62 deletions(-) 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/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 } From 4eb71a88cccc62d8111107bbcb41f33b2fdc1e81 Mon Sep 17 00:00:00 2001 From: evermind Date: Mon, 1 Jan 2024 23:11:47 +0100 Subject: [PATCH 13/13] just for the build to complete -- drop commit before merge --- app/build.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 **/