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);
+ }
}
/**