Skip to content

Commit 52542e0

Browse files
committed
Added fuzzy searching + Some minor code refactoring
1 parent 7fc0a38 commit 52542e0

5 files changed

Lines changed: 280 additions & 11 deletions

File tree

app/src/main/java/org/schabi/newpipe/settings/SettingsActivity.java

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,15 @@ public boolean onCreateOptionsMenu(final Menu menu) {
111111
return super.onCreateOptionsMenu(menu);
112112
}
113113

114+
@Override
115+
public void onBackPressed() {
116+
if (isSearchActive()) {
117+
setSearchActive(false);
118+
return;
119+
}
120+
super.onBackPressed();
121+
}
122+
114123
@Override
115124
public boolean onOptionsItemSelected(final MenuItem item) {
116125
final int id = item.getItemId();
Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
package org.schabi.newpipe.settings.preferencesearch;
2+
3+
import android.text.TextUtils;
4+
5+
import androidx.annotation.NonNull;
6+
7+
import org.schabi.newpipe.settings.preferencesearch.similarity.FuzzyScore;
8+
9+
import java.util.Comparator;
10+
import java.util.Locale;
11+
import java.util.Map;
12+
import java.util.function.Function;
13+
import java.util.stream.Stream;
14+
15+
public class PreferenceFuzzySearchFunction
16+
implements PreferenceSearchConfiguration.PreferenceSearchFunction {
17+
18+
private static final FuzzyScore FUZZY_SCORE = new FuzzyScore(Locale.ROOT);
19+
20+
@Override
21+
public Stream<PreferenceSearchItem> search(
22+
final Stream<PreferenceSearchItem> allAvailable,
23+
final String keyword
24+
) {
25+
final float maxScore = (keyword.length() + 1) * 3 - 2; // First can't get +2 bonus score
26+
27+
return allAvailable
28+
// General search
29+
// Check all fields if anyone contains something that kind of matches the keyword
30+
.map(item -> new FuzzySearchGeneralDTO(item, keyword))
31+
.filter(dto -> dto.getScore() / maxScore >= 0.3f)
32+
.map(FuzzySearchGeneralDTO::getItem)
33+
// Specific search - Used for determining order of search results
34+
// Calculate a score based on specific search fields
35+
.map(item -> new FuzzySearchSpecificDTO(item, keyword))
36+
.sorted(Comparator.comparing(FuzzySearchSpecificDTO::getScore).reversed())
37+
.map(FuzzySearchSpecificDTO::getItem)
38+
// Limit the amount of search results
39+
.limit(20);
40+
}
41+
42+
private float computeFuzzyScore(
43+
@NonNull final PreferenceSearchItem item,
44+
@NonNull final Function<PreferenceSearchItem, String> resolver,
45+
@NonNull final String keyword
46+
) {
47+
return FUZZY_SCORE.fuzzyScore(resolver.apply(item), keyword);
48+
}
49+
50+
static class FuzzySearchGeneralDTO {
51+
private final PreferenceSearchItem item;
52+
private final float score;
53+
54+
FuzzySearchGeneralDTO(
55+
final PreferenceSearchItem item,
56+
final String keyword) {
57+
this.item = item;
58+
this.score = FUZZY_SCORE.fuzzyScore(
59+
TextUtils.join(";", item.getAllRelevantSearchFields()),
60+
keyword);
61+
}
62+
63+
public PreferenceSearchItem getItem() {
64+
return item;
65+
}
66+
67+
public float getScore() {
68+
return score;
69+
}
70+
}
71+
72+
static class FuzzySearchSpecificDTO {
73+
private static final Map<Function<PreferenceSearchItem, String>, Float> WEIGHT_MAP = Map.of(
74+
// The user will most likely look for the title -> prioritize it
75+
PreferenceSearchItem::getTitle, 1.5f,
76+
// The summary is also important as it usually contains a larger desc
77+
// Example: Searching for '4k' → 'show higher resolution' is shown
78+
PreferenceSearchItem::getSummary, 1f,
79+
// Entries are also important as they provide all known/possible values
80+
// Example: Searching where the resolution can be changed to 720p
81+
PreferenceSearchItem::getEntries, 1f
82+
);
83+
84+
private final PreferenceSearchItem item;
85+
private final float score;
86+
87+
FuzzySearchSpecificDTO(
88+
final PreferenceSearchItem item,
89+
final String keyword) {
90+
this.item = item;
91+
92+
float attributeScoreSum = 0;
93+
int countOfAttributesWithScore = 0;
94+
for (final Map.Entry<Function<PreferenceSearchItem, String>, Float> we
95+
: WEIGHT_MAP.entrySet()) {
96+
final String valueToProcess = we.getKey().apply(item);
97+
if (valueToProcess.isEmpty()) {
98+
continue;
99+
}
100+
101+
attributeScoreSum +=
102+
FUZZY_SCORE.fuzzyScore(valueToProcess, keyword) * we.getValue();
103+
countOfAttributesWithScore++;
104+
}
105+
106+
if (countOfAttributesWithScore != 0) {
107+
this.score = attributeScoreSum / countOfAttributesWithScore;
108+
} else {
109+
this.score = 0;
110+
}
111+
}
112+
113+
public PreferenceSearchItem getItem() {
114+
return item;
115+
}
116+
117+
public float getScore() {
118+
return score;
119+
}
120+
}
121+
}

app/src/main/java/org/schabi/newpipe/settings/preferencesearch/PreferenceSearchConfiguration.java

Lines changed: 1 addition & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -21,16 +21,7 @@ public class PreferenceSearchConfiguration {
2121
private BinaryOperator<String> breadcrumbConcat =
2222
(s1, s2) -> TextUtils.isEmpty(s1) ? s2 : (s1 + " > " + s2);
2323

24-
private PreferenceSearchFunction searcher =
25-
(itemStream, keyword) ->
26-
itemStream
27-
// Filter the items by the keyword
28-
.filter(item -> item.getAllRelevantSearchFields().stream()
29-
.filter(str -> !TextUtils.isEmpty(str))
30-
.anyMatch(str ->
31-
str.toLowerCase().contains(keyword.toLowerCase())))
32-
// Limit the search results
33-
.limit(100);
24+
private PreferenceSearchFunction searcher = new PreferenceFuzzySearchFunction();
3425

3526
private final List<String> parserIgnoreElements = Arrays.asList(
3627
PreferenceCategory.class.getSimpleName());

app/src/main/java/org/schabi/newpipe/settings/preferencesearch/PreferenceSearchFragment.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -81,7 +81,7 @@ public void updateSearchResults(final String keyword) {
8181

8282
adapter.setContent(new ArrayList<>(results));
8383

84-
setEmptyViewShown(!TextUtils.isEmpty(keyword) && results.isEmpty());
84+
setEmptyViewShown(results.isEmpty());
8585
}
8686

8787
private void setEmptyViewShown(final boolean shown) {
Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
/*
2+
* Licensed to the Apache Software Foundation (ASF) under one or more
3+
* contributor license agreements. See the NOTICE file distributed with
4+
* this work for additional information regarding copyright ownership.
5+
* The ASF licenses this file to You under the Apache License, Version 2.0
6+
* (the "License"); you may not use this file except in compliance with
7+
* the License. You may obtain a copy of the License at
8+
*
9+
* http://www.apache.org/licenses/LICENSE-2.0
10+
*
11+
* Unless required by applicable law or agreed to in writing, software
12+
* distributed under the License is distributed on an "AS IS" BASIS,
13+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
* See the License for the specific language governing permissions and
15+
* limitations under the License.
16+
*/
17+
package org.schabi.newpipe.settings.preferencesearch.similarity;
18+
19+
import java.util.Locale;
20+
21+
/**
22+
* A matching algorithm that is similar to the searching algorithms implemented in editors such
23+
* as Sublime Text, TextMate, Atom and others.
24+
*
25+
* <p>
26+
* One point is given for every matched character. Subsequent matches yield two bonus points.
27+
* A higher score indicates a higher similarity.
28+
* </p>
29+
*
30+
* <p>
31+
* This code has been adapted from Apache Commons Lang 3.3.
32+
* </p>
33+
*
34+
* @since 1.0
35+
*
36+
* Note: This class was forked from
37+
* <a href="https://git.io/JyYJg">
38+
* apache/commons-text (8cfdafc) FuzzyScore.java
39+
* </a>
40+
*/
41+
public class FuzzyScore {
42+
43+
/**
44+
* Locale used to change the case of text.
45+
*/
46+
private final Locale locale;
47+
48+
49+
/**
50+
* This returns a {@link Locale}-specific {@link FuzzyScore}.
51+
*
52+
* @param locale The string matching logic is case insensitive.
53+
A {@link Locale} is necessary to normalize both Strings to lower case.
54+
* @throws IllegalArgumentException
55+
* This is thrown if the {@link Locale} parameter is {@code null}.
56+
*/
57+
public FuzzyScore(final Locale locale) {
58+
if (locale == null) {
59+
throw new IllegalArgumentException("Locale must not be null");
60+
}
61+
this.locale = locale;
62+
}
63+
64+
/**
65+
* Find the Fuzzy Score which indicates the similarity score between two
66+
* Strings.
67+
*
68+
* <pre>
69+
* score.fuzzyScore(null, null) = IllegalArgumentException
70+
* score.fuzzyScore("not null", null) = IllegalArgumentException
71+
* score.fuzzyScore(null, "not null") = IllegalArgumentException
72+
* score.fuzzyScore("", "") = 0
73+
* score.fuzzyScore("Workshop", "b") = 0
74+
* score.fuzzyScore("Room", "o") = 1
75+
* score.fuzzyScore("Workshop", "w") = 1
76+
* score.fuzzyScore("Workshop", "ws") = 2
77+
* score.fuzzyScore("Workshop", "wo") = 4
78+
* score.fuzzyScore("Apache Software Foundation", "asf") = 3
79+
* </pre>
80+
*
81+
* @param term a full term that should be matched against, must not be null
82+
* @param query the query that will be matched against a term, must not be
83+
* null
84+
* @return result score
85+
* @throws IllegalArgumentException if the term or query is {@code null}
86+
*/
87+
public Integer fuzzyScore(final CharSequence term, final CharSequence query) {
88+
if (term == null || query == null) {
89+
throw new IllegalArgumentException("CharSequences must not be null");
90+
}
91+
92+
// fuzzy logic is case insensitive. We normalize the Strings to lower
93+
// case right from the start. Turning characters to lower case
94+
// via Character.toLowerCase(char) is unfortunately insufficient
95+
// as it does not accept a locale.
96+
final String termLowerCase = term.toString().toLowerCase(locale);
97+
final String queryLowerCase = query.toString().toLowerCase(locale);
98+
99+
// the resulting score
100+
int score = 0;
101+
102+
// the position in the term which will be scanned next for potential
103+
// query character matches
104+
int termIndex = 0;
105+
106+
// index of the previously matched character in the term
107+
int previousMatchingCharacterIndex = Integer.MIN_VALUE;
108+
109+
for (int queryIndex = 0; queryIndex < queryLowerCase.length(); queryIndex++) {
110+
final char queryChar = queryLowerCase.charAt(queryIndex);
111+
112+
boolean termCharacterMatchFound = false;
113+
for (; termIndex < termLowerCase.length()
114+
&& !termCharacterMatchFound; termIndex++) {
115+
final char termChar = termLowerCase.charAt(termIndex);
116+
117+
if (queryChar == termChar) {
118+
// simple character matches result in one point
119+
score++;
120+
121+
// subsequent character matches further improve
122+
// the score.
123+
if (previousMatchingCharacterIndex + 1 == termIndex) {
124+
score += 2;
125+
}
126+
127+
previousMatchingCharacterIndex = termIndex;
128+
129+
// we can leave the nested loop. Every character in the
130+
// query can match at most one character in the term.
131+
termCharacterMatchFound = true;
132+
}
133+
}
134+
}
135+
136+
return score;
137+
}
138+
139+
/**
140+
* Gets the locale.
141+
*
142+
* @return The locale
143+
*/
144+
public Locale getLocale() {
145+
return locale;
146+
}
147+
148+
}

0 commit comments

Comments
 (0)