Added preference search "framework"

This commit is contained in:
litetex 2021-12-24 21:33:40 +01:00
parent 4a061f20ed
commit 12a78a826d
9 changed files with 840 additions and 0 deletions

View File

@ -0,0 +1,201 @@
package org.schabi.newpipe.settings.preferencesearch;
import android.content.Context;
import android.text.TextUtils;
import android.util.Log;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.preference.PreferenceManager;
import org.xmlpull.v1.XmlPullParser;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.Map;
import java.util.Objects;
/**
* Parses the corresponding preference-file(s).
*/
class PreferenceParser {
private static final String TAG = "PreferenceParser";
private static final String NS_ANDROID = "http://schemas.android.com/apk/res/android";
private static final String NS_SEARCH = "http://schemas.android.com/apk/preferencesearch";
private final Context context;
private final Map<String, ?> allPreferences;
private final PreferenceSearchConfiguration searchConfiguration;
PreferenceParser(
final Context context,
final PreferenceSearchConfiguration searchConfiguration
) {
this.context = context;
this.allPreferences = PreferenceManager.getDefaultSharedPreferences(context).getAll();
this.searchConfiguration = searchConfiguration;
}
public List<PreferenceSearchItem> parse(
final PreferenceSearchConfiguration.SearchIndexItem item
) {
Objects.requireNonNull(item, "item can't be null");
final List<PreferenceSearchItem> results = new ArrayList<>();
final XmlPullParser xpp = context.getResources().getXml(item.getResId());
try {
xpp.setFeature(XmlPullParser.FEATURE_PROCESS_NAMESPACES, true);
xpp.setFeature(XmlPullParser.FEATURE_REPORT_NAMESPACE_ATTRIBUTES, true);
final List<String> breadcrumbs = new ArrayList<>();
if (!TextUtils.isEmpty(item.getBreadcrumb())) {
breadcrumbs.add(item.getBreadcrumb());
}
while (xpp.getEventType() != XmlPullParser.END_DOCUMENT) {
if (xpp.getEventType() == XmlPullParser.START_TAG) {
final PreferenceSearchItem result = parseSearchResult(
xpp,
joinBreadcrumbs(breadcrumbs),
item.getResId()
);
if (!searchConfiguration.getParserIgnoreElements().contains(xpp.getName())
&& result.hasData()
&& !"true".equals(getAttribute(xpp, NS_SEARCH, "ignore"))) {
results.add(result);
}
if (searchConfiguration.getParserContainerElements().contains(xpp.getName())) {
breadcrumbs.add(result.getTitle() == null ? "" : result.getTitle());
}
} else if (xpp.getEventType() == XmlPullParser.END_TAG
&& searchConfiguration.getParserContainerElements()
.contains(xpp.getName())) {
breadcrumbs.remove(breadcrumbs.size() - 1);
}
xpp.next();
}
} catch (final Exception e) {
Log.w(TAG, "Failed to parse resid=" + item.getResId(), e);
}
return results;
}
private String joinBreadcrumbs(final List<String> breadcrumbs) {
return breadcrumbs.stream()
.filter(crumb -> !TextUtils.isEmpty(crumb))
.reduce("", searchConfiguration.getBreadcrumbConcat());
}
private String getAttribute(
final XmlPullParser xpp,
@NonNull final String attribute
) {
final String nsSearchAttr = getAttribute(xpp, NS_SEARCH, attribute);
if (nsSearchAttr != null) {
return nsSearchAttr;
}
return getAttribute(xpp, NS_ANDROID, attribute);
}
private String getAttribute(
final XmlPullParser xpp,
@NonNull final String namespace,
@NonNull final String attribute
) {
return xpp.getAttributeValue(namespace, attribute);
}
private PreferenceSearchItem parseSearchResult(
final XmlPullParser xpp,
final String breadcrumbs,
final int searchIndexItemResId
) {
final String key = readString(getAttribute(xpp, "key"));
final String[] entries = readStringArray(getAttribute(xpp, "entries"));
final String[] entryValues = readStringArray(getAttribute(xpp, "entryValues"));
return new PreferenceSearchItem(
key,
tryFillInPreferenceValue(
readString(getAttribute(xpp, "title")),
key,
entries,
entryValues),
tryFillInPreferenceValue(
readString(getAttribute(xpp, "summary")),
key,
entries,
entryValues),
TextUtils.join(",", entries),
readString(getAttribute(xpp, NS_SEARCH, "keywords")),
breadcrumbs,
searchIndexItemResId
);
}
private String[] readStringArray(@Nullable final String s) {
if (s == null) {
return new String[0];
}
if (s.startsWith("@")) {
try {
return context.getResources().getStringArray(Integer.parseInt(s.substring(1)));
} catch (final Exception e) {
Log.w(TAG, "Unable to readStringArray from '" + s + "'", e);
}
}
return new String[0];
}
private String readString(@Nullable final String s) {
if (s == null) {
return "";
}
if (s.startsWith("@")) {
try {
return context.getString(Integer.parseInt(s.substring(1)));
} catch (final Exception e) {
Log.w(TAG, "Unable to readString from '" + s + "'", e);
}
}
return s;
}
private String tryFillInPreferenceValue(
@Nullable final String s,
@Nullable final String key,
final String[] entries,
final String[] entryValues
) {
if (s == null) {
return "";
}
if (key == null) {
return s;
}
// Resolve value
Object prefValue = allPreferences.get(key);
if (prefValue == null) {
return s;
}
/*
* Resolve ListPreference values
*
* entryValues = Values/Keys that are saved
* entries = Actual human readable names
*/
if (entries.length > 0 && entryValues.length == entries.length) {
final int entryIndex = Arrays.asList(entryValues).indexOf(prefValue);
if (entryIndex != -1) {
prefValue = entries[entryIndex];
}
}
return String.format(s, prefValue.toString());
}
}

View File

@ -0,0 +1,91 @@
package org.schabi.newpipe.settings.preferencesearch;
import android.text.TextUtils;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.TextView;
import androidx.annotation.NonNull;
import androidx.recyclerview.widget.RecyclerView;
import org.schabi.newpipe.R;
import java.util.ArrayList;
import java.util.List;
import java.util.function.Consumer;
class PreferenceSearchAdapter
extends RecyclerView.Adapter<PreferenceSearchAdapter.PreferenceViewHolder> {
private List<PreferenceSearchItem> dataset = new ArrayList<>();
private Consumer<PreferenceSearchItem> onItemClickListener;
@NonNull
@Override
public PreferenceSearchAdapter.PreferenceViewHolder onCreateViewHolder(
@NonNull final ViewGroup parent,
final int viewType
) {
return new PreferenceViewHolder(
LayoutInflater
.from(parent.getContext())
.inflate(R.layout.settings_preferencesearch_list_item_result, parent, false));
}
@Override
public void onBindViewHolder(
@NonNull final PreferenceSearchAdapter.PreferenceViewHolder holder,
final int position
) {
final PreferenceSearchItem item = dataset.get(position);
holder.title.setText(item.getTitle());
if (TextUtils.isEmpty(item.getSummary())) {
holder.summary.setVisibility(View.GONE);
} else {
holder.summary.setVisibility(View.VISIBLE);
holder.summary.setText(item.getSummary());
}
if (TextUtils.isEmpty(item.getBreadcrumbs())) {
holder.breadcrumbs.setVisibility(View.GONE);
} else {
holder.breadcrumbs.setVisibility(View.VISIBLE);
holder.breadcrumbs.setText(item.getBreadcrumbs());
}
holder.itemView.setOnClickListener(v -> {
if (onItemClickListener != null) {
onItemClickListener.accept(item);
}
});
}
void setContent(final List<PreferenceSearchItem> items) {
dataset = new ArrayList<>(items);
this.notifyDataSetChanged();
}
@Override
public int getItemCount() {
return dataset.size();
}
void setOnItemClickListener(final Consumer<PreferenceSearchItem> onItemClickListener) {
this.onItemClickListener = onItemClickListener;
}
static class PreferenceViewHolder extends RecyclerView.ViewHolder {
final TextView title;
final TextView summary;
final TextView breadcrumbs;
PreferenceViewHolder(final View v) {
super(v);
title = v.findViewById(R.id.title);
summary = v.findViewById(R.id.summary);
breadcrumbs = v.findViewById(R.id.breadcrumbs);
}
}
}

View File

@ -0,0 +1,163 @@
package org.schabi.newpipe.settings.preferencesearch;
import android.os.Parcel;
import android.os.Parcelable;
import android.text.TextUtils;
import androidx.annotation.XmlRes;
import androidx.preference.PreferenceCategory;
import androidx.preference.PreferenceScreen;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.Objects;
import java.util.function.BinaryOperator;
import java.util.stream.Stream;
public class PreferenceSearchConfiguration {
private final ArrayList<SearchIndexItem> itemsToIndex = new ArrayList<>();
private BinaryOperator<String> breadcrumbConcat =
(s1, s2) -> TextUtils.isEmpty(s1) ? s2 : (s1 + " > " + s2);
private PreferenceSearchFunction searcher =
(itemStream, keyword) ->
itemStream
// Filter the items by the keyword
.filter(item -> item.getAllRelevantSearchFields().stream()
.filter(str -> !TextUtils.isEmpty(str))
.anyMatch(str ->
str.toLowerCase().contains(keyword.toLowerCase())))
// Limit the search results
.limit(100);
private final List<String> parserIgnoreElements = Arrays.asList(
PreferenceCategory.class.getSimpleName());
private final List<String> parserContainerElements = Arrays.asList(
PreferenceCategory.class.getSimpleName(),
PreferenceScreen.class.getSimpleName());
public void setBreadcrumbConcat(final BinaryOperator<String> breadcrumbConcat) {
this.breadcrumbConcat = Objects.requireNonNull(breadcrumbConcat);
}
public void setSearcher(final PreferenceSearchFunction searcher) {
this.searcher = Objects.requireNonNull(searcher);
}
/**
* Adds a new file to the index.
*
* @param resId The preference file to index
* @return SearchIndexItem
*/
public SearchIndexItem index(@XmlRes final int resId) {
final SearchIndexItem item = new SearchIndexItem(resId, this);
itemsToIndex.add(item);
return item;
}
List<SearchIndexItem> getFiles() {
return itemsToIndex;
}
public BinaryOperator<String> getBreadcrumbConcat() {
return breadcrumbConcat;
}
public PreferenceSearchFunction getSearchMatcher() {
return searcher;
}
public List<String> getParserIgnoreElements() {
return parserIgnoreElements;
}
public List<String> getParserContainerElements() {
return parserContainerElements;
}
/**
* Adds a given R.xml resource to the search index.
*/
public static final class SearchIndexItem implements Parcelable {
private String breadcrumb = "";
@XmlRes
private final int resId;
private final PreferenceSearchConfiguration searchConfiguration;
/**
* Includes the given R.xml resource in the index.
*
* @param resId The resource to index
* @param searchConfiguration The configuration for the search
*/
private SearchIndexItem(
@XmlRes final int resId,
final PreferenceSearchConfiguration searchConfiguration
) {
this.resId = resId;
this.searchConfiguration = searchConfiguration;
}
/**
* Adds a breadcrumb.
*
* @param breadcrumb The breadcrumb to add
* @return For chaining
*/
@SuppressWarnings("HiddenField")
public SearchIndexItem withBreadcrumb(final String breadcrumb) {
this.breadcrumb =
searchConfiguration.getBreadcrumbConcat().apply(this.breadcrumb, breadcrumb);
return this;
}
@XmlRes
int getResId() {
return resId;
}
String getBreadcrumb() {
return breadcrumb;
}
public static final Creator<SearchIndexItem> CREATOR = new Creator<>() {
@Override
public SearchIndexItem createFromParcel(final Parcel in) {
return new SearchIndexItem(in);
}
@Override
public SearchIndexItem[] newArray(final int size) {
return new SearchIndexItem[size];
}
};
private SearchIndexItem(final Parcel parcel) {
this.breadcrumb = parcel.readString();
this.resId = parcel.readInt();
this.searchConfiguration = null;
}
@Override
public void writeToParcel(final Parcel dest, final int flags) {
dest.writeString(this.breadcrumb);
dest.writeInt(this.resId);
}
@Override
public int describeContents() {
return 0;
}
}
@FunctionalInterface
public interface PreferenceSearchFunction {
Stream<PreferenceSearchItem> search(
Stream<PreferenceSearchItem> allAvailable,
String keyword);
}
}

View File

@ -0,0 +1,116 @@
package org.schabi.newpipe.settings.preferencesearch;
import android.os.Bundle;
import android.text.TextUtils;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.fragment.app.Fragment;
import androidx.recyclerview.widget.LinearLayoutManager;
import androidx.recyclerview.widget.RecyclerView;
import org.schabi.newpipe.R;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
/**
* Displays the search results.
*/
public class PreferenceSearchFragment extends Fragment {
public static final String NAME = PreferenceSearchFragment.class.getSimpleName();
private final PreferenceSearchConfiguration searchConfiguration;
private final PreferenceSearcher searcher;
private SearchViewHolder viewHolder;
private PreferenceSearchAdapter adapter;
public PreferenceSearchFragment(final PreferenceSearchConfiguration searchConfiguration) {
this.searchConfiguration = searchConfiguration;
this.searcher = new PreferenceSearcher(searchConfiguration);
}
@Override
public void onCreate(@Nullable final Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
final PreferenceParser parser =
new PreferenceParser(
getContext(),
searchConfiguration);
searchConfiguration.getFiles().stream()
.map(parser::parse)
.forEach(searcher::add);
}
@Nullable
@Override
public View onCreateView(
@NonNull final LayoutInflater inflater,
@Nullable final ViewGroup container,
@Nullable final Bundle savedInstanceState
) {
final View rootView =
inflater.inflate(R.layout.settings_preferencesearch_fragment, container, false);
viewHolder = new SearchViewHolder(rootView);
viewHolder.recyclerView.setLayoutManager(new LinearLayoutManager(getContext()));
adapter = new PreferenceSearchAdapter();
adapter.setOnItemClickListener(this::onItemClicked);
viewHolder.recyclerView.setAdapter(adapter);
return rootView;
}
public void updateSearchResults(final String keyword) {
if (adapter == null) {
return;
}
final List<PreferenceSearchItem> results =
!TextUtils.isEmpty(keyword)
? searcher.searchFor(keyword)
: new ArrayList<>();
adapter.setContent(new ArrayList<>(results));
setEmptyViewShown(!TextUtils.isEmpty(keyword) && results.isEmpty());
}
private void setEmptyViewShown(final boolean shown) {
viewHolder.emptyStateView.setVisibility(shown ? View.VISIBLE : View.GONE);
viewHolder.recyclerView.setVisibility(shown ? View.GONE : View.VISIBLE);
}
public void onItemClicked(final PreferenceSearchItem item) {
if (!(getActivity() instanceof PreferenceSearchResultListener)) {
throw new ClassCastException(
getActivity().toString() + " must implement SearchPreferenceResultListener");
}
((PreferenceSearchResultListener) getActivity()).onSearchResultClicked(item);
}
@Override
public void onDestroy() {
searcher.close();
super.onDestroy();
}
private static class SearchViewHolder {
private final RecyclerView recyclerView;
private final View emptyStateView;
SearchViewHolder(final View root) {
recyclerView = Objects.requireNonNull(root.findViewById(R.id.list));
emptyStateView = Objects.requireNonNull(root.findViewById(R.id.empty_state_view));
}
}
}

View File

@ -0,0 +1,91 @@
package org.schabi.newpipe.settings.preferencesearch;
import androidx.annotation.NonNull;
import java.util.Arrays;
import java.util.List;
import java.util.Objects;
/**
* Represents a preference-item inside the search.
*/
public class PreferenceSearchItem {
@NonNull
private final String key;
@NonNull
private final String title;
@NonNull
private final String summary;
@NonNull
private final String entries;
@NonNull
private final String keywords;
@NonNull
private final String breadcrumbs;
private final int searchIndexItemResId;
public PreferenceSearchItem(
@NonNull final String key,
@NonNull final String title,
@NonNull final String summary,
@NonNull final String entries,
@NonNull final String keywords,
@NonNull final String breadcrumbs,
final int searchIndexItemResId
) {
this.key = Objects.requireNonNull(key);
this.title = Objects.requireNonNull(title);
this.summary = Objects.requireNonNull(summary);
this.entries = Objects.requireNonNull(entries);
this.keywords = Objects.requireNonNull(keywords);
this.breadcrumbs = Objects.requireNonNull(breadcrumbs);
this.searchIndexItemResId = searchIndexItemResId;
}
public String getKey() {
return key;
}
public String getTitle() {
return title;
}
public String getSummary() {
return summary;
}
public String getEntries() {
return entries;
}
public String getBreadcrumbs() {
return breadcrumbs;
}
public String getKeywords() {
return keywords;
}
public int getSearchIndexItemResId() {
return searchIndexItemResId;
}
boolean hasData() {
return !key.isEmpty() && !title.isEmpty();
}
public List<String> getAllRelevantSearchFields() {
return Arrays.asList(
getTitle(),
getSummary(),
getEntries(),
getBreadcrumbs(),
getKeywords());
}
@Override
public String toString() {
return "PreferenceItem: " + title + " " + summary + " " + key;
}
}

View File

@ -0,0 +1,125 @@
package org.schabi.newpipe.settings.preferencesearch;
import android.content.res.Resources;
import android.content.res.TypedArray;
import android.graphics.PorterDuff;
import android.graphics.PorterDuffColorFilter;
import android.graphics.drawable.Drawable;
import android.graphics.drawable.RippleDrawable;
import android.os.Build;
import android.os.Handler;
import android.os.Looper;
import android.util.Log;
import android.util.TypedValue;
import androidx.appcompat.content.res.AppCompatResources;
import androidx.preference.Preference;
import androidx.preference.PreferenceFragmentCompat;
import androidx.preference.PreferenceGroup;
import androidx.recyclerview.widget.RecyclerView;
import org.schabi.newpipe.R;
public final class PreferenceSearchResultHighlighter {
private static final String TAG = "PrefSearchResHighlter";
private PreferenceSearchResultHighlighter() {
}
/**
* Highlight the specified preference.
*
* @param item
* @param prefsFragment
*/
public static void highlight(
final PreferenceSearchItem item,
final PreferenceFragmentCompat prefsFragment
) {
new Handler(Looper.getMainLooper()).post(() -> doHighlight(item, prefsFragment));
}
private static void doHighlight(
final PreferenceSearchItem item,
final PreferenceFragmentCompat prefsFragment
) {
final Preference prefResult = prefsFragment.findPreference(item.getKey());
if (prefResult == null) {
Log.w(TAG, "Preference '" + item.getKey() + "' not found on '" + prefsFragment + "'");
return;
}
final RecyclerView recyclerView = prefsFragment.getListView();
final RecyclerView.Adapter<?> adapter = recyclerView.getAdapter();
if (adapter instanceof PreferenceGroup.PreferencePositionCallback) {
final int position = ((PreferenceGroup.PreferencePositionCallback) adapter)
.getPreferenceAdapterPosition(prefResult);
if (position != RecyclerView.NO_POSITION) {
recyclerView.scrollToPosition(position);
recyclerView.postDelayed(() -> {
final RecyclerView.ViewHolder holder =
recyclerView.findViewHolderForAdapterPosition(position);
if (holder != null) {
final Drawable background = holder.itemView.getBackground();
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP
&& background instanceof RippleDrawable) {
showRippleAnimation((RippleDrawable) background);
return;
}
}
highlightFallback(prefsFragment, prefResult);
}, 150);
return;
}
}
highlightFallback(prefsFragment, prefResult);
}
/**
* Alternative highlighting (shows an arrow in front of the setting)if ripple does not work.
*
* @param prefsFragment
* @param prefResult
*/
private static void highlightFallback(
final PreferenceFragmentCompat prefsFragment,
final Preference prefResult
) {
// Get primary color from text for highlight icon
final TypedValue typedValue = new TypedValue();
final Resources.Theme theme = prefsFragment.getActivity().getTheme();
theme.resolveAttribute(android.R.attr.textColorPrimary, typedValue, true);
final TypedArray arr = prefsFragment.getActivity()
.obtainStyledAttributes(
typedValue.data,
new int[]{android.R.attr.textColorPrimary});
final int color = arr.getColor(0, 0xffE53935);
arr.recycle();
// Show highlight icon
final Drawable oldIcon = prefResult.getIcon();
final boolean oldSpaceReserved = prefResult.isIconSpaceReserved();
final Drawable highlightIcon =
AppCompatResources.getDrawable(
prefsFragment.requireContext(),
R.drawable.ic_play_arrow);
highlightIcon.setColorFilter(new PorterDuffColorFilter(color, PorterDuff.Mode.SRC_IN));
prefResult.setIcon(highlightIcon);
prefsFragment.scrollToPreference(prefResult);
new Handler(Looper.getMainLooper()).postDelayed(() -> {
prefResult.setIcon(oldIcon);
prefResult.setIconSpaceReserved(oldSpaceReserved);
}, 1000);
}
private static void showRippleAnimation(final RippleDrawable rippleDrawable) {
rippleDrawable.setState(
new int[]{android.R.attr.state_pressed, android.R.attr.state_enabled});
new Handler(Looper.getMainLooper())
.postDelayed(() -> rippleDrawable.setState(new int[]{}), 1000);
}
}

View File

@ -0,0 +1,7 @@
package org.schabi.newpipe.settings.preferencesearch;
import androidx.annotation.NonNull;
public interface PreferenceSearchResultListener {
void onSearchResultClicked(@NonNull PreferenceSearchItem result);
}

View File

@ -0,0 +1,36 @@
package org.schabi.newpipe.settings.preferencesearch;
import android.text.TextUtils;
import java.util.ArrayList;
import java.util.List;
import java.util.stream.Collectors;
class PreferenceSearcher implements AutoCloseable {
private final List<PreferenceSearchItem> allEntries = new ArrayList<>();
private final PreferenceSearchConfiguration configuration;
PreferenceSearcher(final PreferenceSearchConfiguration configuration) {
this.configuration = configuration;
}
void add(final List<PreferenceSearchItem> items) {
allEntries.addAll(items);
}
List<PreferenceSearchItem> searchFor(final String keyword) {
if (TextUtils.isEmpty(keyword)) {
return new ArrayList<>();
}
return configuration.getSearchMatcher()
.search(allEntries.stream(), keyword)
.collect(Collectors.toList());
}
@Override
public void close() {
allEntries.clear();
}
}

View File

@ -0,0 +1,10 @@
/**
* Contains classes for searching inside the preferences.
* <br/>
* This code is based on
* <a href="https://github.com/ByteHamster/SearchPreference">ByteHamster/SearchPreference</a>
* (MIT license) but was heavily modified/refactored for our use.
*
* @author litetex
*/
package org.schabi.newpipe.settings.preferencesearch;