diff --git a/extensions/shared/library/src/main/java/app/revanced/extension/shared/Utils.java b/extensions/shared/library/src/main/java/app/revanced/extension/shared/Utils.java index a9d7b6e9d3..9ec217b643 100644 --- a/extensions/shared/library/src/main/java/app/revanced/extension/shared/Utils.java +++ b/extensions/shared/library/src/main/java/app/revanced/extension/shared/Utils.java @@ -45,6 +45,8 @@ import androidx.annotation.Nullable; import java.text.Bidi; +import java.text.Collator; +import java.text.Normalizer; import java.util.ArrayList; import java.util.Collections; import java.util.List; @@ -79,6 +81,15 @@ public class Utils { @Nullable private static Boolean isDarkModeEnabled; + // Cached Collator instance with its locale. + @Nullable + private static Locale cachedCollatorLocale; + @Nullable + private static Collator cachedCollator; + + private static final Pattern PUNCTUATION_PATTERN = Pattern.compile("\\p{P}+"); + private static final Pattern DIACRITICS_PATTERN = Pattern.compile("\\p{M}"); + private Utils() { } // utility class @@ -976,30 +987,60 @@ static Sort fromKey(@Nullable String key, Sort defaultSort) { } } - private static final Pattern punctuationPattern = Pattern.compile("\\p{P}+"); - /** - * Strips all punctuation and converts to lower case. A null parameter returns an empty string. + * Removes punctuation and converts text to lowercase. Returns an empty string if input is null. */ public static String removePunctuationToLowercase(@Nullable CharSequence original) { if (original == null) return ""; - return punctuationPattern.matcher(original).replaceAll("") + return PUNCTUATION_PATTERN.matcher(original).replaceAll("") .toLowerCase(BaseSettings.REVANCED_LANGUAGE.get().getLocale()); } /** - * Sort a PreferenceGroup and all it's sub groups by title or key. + * Normalizes text for search: applies NFD, removes diacritics, and lowercases (locale-neutral). + * Returns an empty string if input is null. + */ + public static String normalizeTextToLowercase(@Nullable CharSequence original) { + if (original == null) return ""; + return DIACRITICS_PATTERN.matcher(Normalizer.normalize(original, Normalizer.Form.NFD)) + .replaceAll("").toLowerCase(Locale.ROOT); + } + + /** + * Returns a cached Collator for the current locale, or creates a new one if locale changed. + */ + private static Collator getCollator() { + Locale currentLocale = BaseSettings.REVANCED_LANGUAGE.get().getLocale(); + + if (cachedCollator == null || !currentLocale.equals(cachedCollatorLocale)) { + cachedCollatorLocale = currentLocale; + cachedCollator = Collator.getInstance(currentLocale); + cachedCollator.setStrength(Collator.SECONDARY); // Case-insensitive, diacritic-insensitive. + } + + return cachedCollator; + } + + /** + * Sorts a {@link PreferenceGroup} and all nested subgroups by title or key. *

- * Sort order is determined by the preferences key {@link Sort} suffix. + * The sort order is controlled by the {@link Sort} suffix present in the preference key. + * Preferences without a key or without a {@link Sort} suffix remain in their original order. *

- * If a preference has no key or no {@link Sort} suffix, - * then the preferences are left unsorted. + * Sorting is performed using {@link Collator} with the current user locale, + * ensuring correct alphabetical ordering for all supported languages + * (e.g., Ukrainian "і", German "ß", French accented characters, etc.). + * + * @param group the {@link PreferenceGroup} to sort */ @SuppressWarnings("deprecation") public static void sortPreferenceGroups(PreferenceGroup group) { Sort groupSort = Sort.fromKey(group.getKey(), Sort.UNSORTED); List> preferences = new ArrayList<>(); + // Get cached Collator for locale-aware string comparison. + Collator collator = getCollator(); + for (int i = 0, prefCount = group.getPreferenceCount(); i < prefCount; i++) { Preference preference = group.getPreference(i); @@ -1030,10 +1071,11 @@ public static void sortPreferenceGroups(PreferenceGroup group) { preferences.add(new Pair<>(sortValue, preference)); } - //noinspection ComparatorCombinators + // Sort the list using locale-specific collation rules. Collections.sort(preferences, (pair1, pair2) - -> pair1.first.compareTo(pair2.first)); + -> collator.compare(pair1.first, pair2.first)); + // Reassign order values to reflect the new sorted sequence int index = 0; for (Pair pair : preferences) { int order = index++; diff --git a/extensions/shared/library/src/main/java/app/revanced/extension/shared/settings/Setting.java b/extensions/shared/library/src/main/java/app/revanced/extension/shared/settings/Setting.java index 1f24a74896..38b2cee47f 100644 --- a/extensions/shared/library/src/main/java/app/revanced/extension/shared/settings/Setting.java +++ b/extensions/shared/library/src/main/java/app/revanced/extension/shared/settings/Setting.java @@ -392,10 +392,13 @@ public boolean isAvailable() { /** * Get the parent Settings that this setting depends on. - * @return List of parent Settings (e.g., BooleanSetting or EnumSetting), or empty list if no dependencies exist. + * @return List of parent Settings, or empty list if no dependencies exist. + * Defensive: handles null availability or missing getParentSettings() override. */ public List> getParentSettings() { - return availability == null ? Collections.emptyList() : availability.getParentSettings(); + return availability == null + ? Collections.emptyList() + : Objects.requireNonNullElse(availability.getParentSettings(), Collections.emptyList()); } /** diff --git a/extensions/shared/library/src/main/java/app/revanced/extension/shared/settings/search/BaseSearchResultItem.java b/extensions/shared/library/src/main/java/app/revanced/extension/shared/settings/search/BaseSearchResultItem.java index 9b5c9464c8..ab1e2ee6c1 100644 --- a/extensions/shared/library/src/main/java/app/revanced/extension/shared/settings/search/BaseSearchResultItem.java +++ b/extensions/shared/library/src/main/java/app/revanced/extension/shared/settings/search/BaseSearchResultItem.java @@ -75,7 +75,7 @@ private static int getResourceIdentifier(String name) { // Shared method for highlighting text with search query. protected static CharSequence highlightSearchQuery(CharSequence text, Pattern queryPattern) { - if (TextUtils.isEmpty(text)) return text; + if (TextUtils.isEmpty(text) || queryPattern == null) return text; final int adjustedColor = Utils.adjustColorBrightness( Utils.getAppBackgroundColor(), 0.95f, 1.20f); @@ -84,7 +84,10 @@ protected static CharSequence highlightSearchQuery(CharSequence text, Pattern qu Matcher matcher = queryPattern.matcher(text); while (matcher.find()) { - spannable.setSpan(highlightSpan, matcher.start(), matcher.end(), + int start = matcher.start(); + int end = matcher.end(); + if (start == end) continue; // Skip zero matches. + spannable.setSpan(highlightSpan, start, end, SpannableStringBuilder.SPAN_EXCLUSIVE_EXCLUSIVE); } @@ -224,10 +227,14 @@ private String buildSearchableText(Preference pref) { return searchBuilder.toString(); } + /** + * Appends normalized searchable text to the builder. + * Uses full Unicode normalization for accurate search across all languages. + */ private void appendText(StringBuilder builder, CharSequence text) { if (!TextUtils.isEmpty(text)) { if (builder.length() > 0) builder.append(" "); - builder.append(Utils.removePunctuationToLowercase(text)); + builder.append(Utils.normalizeTextToLowercase(text)); } } @@ -272,7 +279,7 @@ public CharSequence getCurrentEffectiveSummary() { */ @Override boolean matchesQuery(String query) { - return searchableText.contains(Utils.removePunctuationToLowercase(query)); + return searchableText.contains(Utils.normalizeTextToLowercase(query)); } /** diff --git a/extensions/shared/library/src/main/java/app/revanced/extension/shared/settings/search/BaseSearchViewController.java b/extensions/shared/library/src/main/java/app/revanced/extension/shared/settings/search/BaseSearchViewController.java index 0a8df925ba..efadb85998 100644 --- a/extensions/shared/library/src/main/java/app/revanced/extension/shared/settings/search/BaseSearchViewController.java +++ b/extensions/shared/library/src/main/java/app/revanced/extension/shared/settings/search/BaseSearchViewController.java @@ -450,7 +450,7 @@ protected void filterAndShowResults(String query) { filteredSearchItems.clear(); - String queryLower = Utils.removePunctuationToLowercase(query); + String queryLower = Utils.normalizeTextToLowercase(query); Pattern queryPattern = Pattern.compile(Pattern.quote(queryLower), Pattern.CASE_INSENSITIVE); // Clear highlighting only for items that were previously visible. diff --git a/extensions/youtube/src/main/java/app/revanced/extension/youtube/patches/spoof/SpoofVideoStreamsPatch.java b/extensions/youtube/src/main/java/app/revanced/extension/youtube/patches/spoof/SpoofVideoStreamsPatch.java index 75374c09e3..2bf442a937 100644 --- a/extensions/youtube/src/main/java/app/revanced/extension/youtube/patches/spoof/SpoofVideoStreamsPatch.java +++ b/extensions/youtube/src/main/java/app/revanced/extension/youtube/patches/spoof/SpoofVideoStreamsPatch.java @@ -22,6 +22,11 @@ public boolean isAvailable() { return Settings.SPOOF_VIDEO_STREAMS_CLIENT_TYPE.isAvailable() && Settings.SPOOF_VIDEO_STREAMS_CLIENT_TYPE.get() == ANDROID_VR_1_43_32; } + + @Override + public List> getParentSettings() { + return List.of(Settings.SPOOF_VIDEO_STREAMS_CLIENT_TYPE); + } } /**