diff --git a/.gitignore b/.gitignore index 40e7d2c03..1352b6917 100644 --- a/.gitignore +++ b/.gitignore @@ -8,8 +8,8 @@ captures/ *~ .weblate *.class -**/debug/ -**/release/ +app/debug/ +app/release/ # vscode / eclipse files *.classpath diff --git a/app/src/debug/java/org/schabi/newpipe/settings/DebugSettingsBVDLeakCanary.java b/app/src/debug/java/org/schabi/newpipe/settings/DebugSettingsBVDLeakCanary.java new file mode 100644 index 000000000..a2d65f6f4 --- /dev/null +++ b/app/src/debug/java/org/schabi/newpipe/settings/DebugSettingsBVDLeakCanary.java @@ -0,0 +1,20 @@ +package org.schabi.newpipe.settings; + +import android.content.Intent; + +import leakcanary.LeakCanary; + +/** + * Build variant dependent (BVD) leak canary API implementation for the debug settings fragment. + * This class is loaded via reflection by + * {@link DebugSettingsFragment.DebugSettingsBVDLeakCanaryAPI}. + */ +@SuppressWarnings("unused") // Class is used but loaded via reflection +public class DebugSettingsBVDLeakCanary + implements DebugSettingsFragment.DebugSettingsBVDLeakCanaryAPI { + + @Override + public Intent getNewLeakDisplayActivityIntent() { + return LeakCanary.INSTANCE.newLeakDisplayActivityIntent(); + } +} diff --git a/app/src/debug/java/org/schabi/newpipe/settings/DebugSettingsFragment.java b/app/src/debug/java/org/schabi/newpipe/settings/DebugSettingsFragment.java deleted file mode 100644 index f48be553f..000000000 --- a/app/src/debug/java/org/schabi/newpipe/settings/DebugSettingsFragment.java +++ /dev/null @@ -1,63 +0,0 @@ -package org.schabi.newpipe.settings; - -import android.os.Bundle; - -import androidx.preference.Preference; - -import org.schabi.newpipe.R; -import org.schabi.newpipe.error.ErrorInfo; -import org.schabi.newpipe.error.ErrorUtil; -import org.schabi.newpipe.error.UserAction; -import org.schabi.newpipe.util.PicassoHelper; - -import leakcanary.LeakCanary; - -public class DebugSettingsFragment extends BasePreferenceFragment { - @Override - public void onCreatePreferences(final Bundle savedInstanceState, final String rootKey) { - addPreferencesFromResource(R.xml.debug_settings); - - final Preference showMemoryLeaksPreference - = findPreference(getString(R.string.show_memory_leaks_key)); - final Preference showImageIndicatorsPreference - = findPreference(getString(R.string.show_image_indicators_key)); - final Preference crashTheAppPreference - = findPreference(getString(R.string.crash_the_app_key)); - final Preference showErrorSnackbarPreference - = findPreference(getString(R.string.show_error_snackbar_key)); - final Preference createErrorNotificationPreference - = findPreference(getString(R.string.create_error_notification_key)); - - assert showMemoryLeaksPreference != null; - assert showImageIndicatorsPreference != null; - assert crashTheAppPreference != null; - assert showErrorSnackbarPreference != null; - assert createErrorNotificationPreference != null; - - showMemoryLeaksPreference.setOnPreferenceClickListener(preference -> { - startActivity(LeakCanary.INSTANCE.newLeakDisplayActivityIntent()); - return true; - }); - - showImageIndicatorsPreference.setOnPreferenceChangeListener((preference, newValue) -> { - PicassoHelper.setIndicatorsEnabled((Boolean) newValue); - return true; - }); - - crashTheAppPreference.setOnPreferenceClickListener(preference -> { - throw new RuntimeException(); - }); - - showErrorSnackbarPreference.setOnPreferenceClickListener(preference -> { - ErrorUtil.showUiErrorSnackbar(DebugSettingsFragment.this, - "Dummy", new RuntimeException("Dummy")); - return true; - }); - - createErrorNotificationPreference.setOnPreferenceClickListener(preference -> { - ErrorUtil.createNotification(requireContext(), - new ErrorInfo(new RuntimeException("Dummy"), UserAction.UI_ERROR, "Dummy")); - return true; - }); - } -} diff --git a/app/src/main/java/org/apache/commons/text/similarity/FuzzyScore.java b/app/src/main/java/org/apache/commons/text/similarity/FuzzyScore.java new file mode 100644 index 000000000..bbab7fd78 --- /dev/null +++ b/app/src/main/java/org/apache/commons/text/similarity/FuzzyScore.java @@ -0,0 +1,148 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.commons.text.similarity; + +import java.util.Locale; + +/** + * A matching algorithm that is similar to the searching algorithms implemented in editors such + * as Sublime Text, TextMate, Atom and others. + * + *

+ * One point is given for every matched character. Subsequent matches yield two bonus points. + * A higher score indicates a higher similarity. + *

+ * + *

+ * This code has been adapted from Apache Commons Lang 3.3. + *

+ * + * @since 1.0 + * + * Note: This class was forked from + * + * apache/commons-text (8cfdafc) FuzzyScore.java + * + */ +public class FuzzyScore { + + /** + * Locale used to change the case of text. + */ + private final Locale locale; + + + /** + * This returns a {@link Locale}-specific {@link FuzzyScore}. + * + * @param locale The string matching logic is case insensitive. + A {@link Locale} is necessary to normalize both Strings to lower case. + * @throws IllegalArgumentException + * This is thrown if the {@link Locale} parameter is {@code null}. + */ + public FuzzyScore(final Locale locale) { + if (locale == null) { + throw new IllegalArgumentException("Locale must not be null"); + } + this.locale = locale; + } + + /** + * Find the Fuzzy Score which indicates the similarity score between two + * Strings. + * + *
+     * score.fuzzyScore(null, null)                          = IllegalArgumentException
+     * score.fuzzyScore("not null", null)                    = IllegalArgumentException
+     * score.fuzzyScore(null, "not null")                    = IllegalArgumentException
+     * score.fuzzyScore("", "")                              = 0
+     * score.fuzzyScore("Workshop", "b")                     = 0
+     * score.fuzzyScore("Room", "o")                         = 1
+     * score.fuzzyScore("Workshop", "w")                     = 1
+     * score.fuzzyScore("Workshop", "ws")                    = 2
+     * score.fuzzyScore("Workshop", "wo")                    = 4
+     * score.fuzzyScore("Apache Software Foundation", "asf") = 3
+     * 
+ * + * @param term a full term that should be matched against, must not be null + * @param query the query that will be matched against a term, must not be + * null + * @return result score + * @throws IllegalArgumentException if the term or query is {@code null} + */ + public Integer fuzzyScore(final CharSequence term, final CharSequence query) { + if (term == null || query == null) { + throw new IllegalArgumentException("CharSequences must not be null"); + } + + // fuzzy logic is case insensitive. We normalize the Strings to lower + // case right from the start. Turning characters to lower case + // via Character.toLowerCase(char) is unfortunately insufficient + // as it does not accept a locale. + final String termLowerCase = term.toString().toLowerCase(locale); + final String queryLowerCase = query.toString().toLowerCase(locale); + + // the resulting score + int score = 0; + + // the position in the term which will be scanned next for potential + // query character matches + int termIndex = 0; + + // index of the previously matched character in the term + int previousMatchingCharacterIndex = Integer.MIN_VALUE; + + for (int queryIndex = 0; queryIndex < queryLowerCase.length(); queryIndex++) { + final char queryChar = queryLowerCase.charAt(queryIndex); + + boolean termCharacterMatchFound = false; + for (; termIndex < termLowerCase.length() + && !termCharacterMatchFound; termIndex++) { + final char termChar = termLowerCase.charAt(termIndex); + + if (queryChar == termChar) { + // simple character matches result in one point + score++; + + // subsequent character matches further improve + // the score. + if (previousMatchingCharacterIndex + 1 == termIndex) { + score += 2; + } + + previousMatchingCharacterIndex = termIndex; + + // we can leave the nested loop. Every character in the + // query can match at most one character in the term. + termCharacterMatchFound = true; + } + } + } + + return score; + } + + /** + * Gets the locale. + * + * @return The locale + */ + public Locale getLocale() { + return locale; + } + +} diff --git a/app/src/main/java/org/schabi/newpipe/about/AboutActivity.kt b/app/src/main/java/org/schabi/newpipe/about/AboutActivity.kt index a8fdcae26..1e5bd8799 100644 --- a/app/src/main/java/org/schabi/newpipe/about/AboutActivity.kt +++ b/app/src/main/java/org/schabi/newpipe/about/AboutActivity.kt @@ -185,7 +185,11 @@ class AboutActivity : AppCompatActivity() { SoftwareComponent( "RxJava", "2016 - 2020", "RxJava Contributors", "https://github.com/ReactiveX/RxJava", StandardLicenses.APACHE2 - ) + ), + SoftwareComponent( + "SearchPreference", "2018", "ByteHamster", + "https://github.com/ByteHamster/SearchPreference", StandardLicenses.MIT + ), ) private const val POS_ABOUT = 0 private const val POS_LICENSE = 1 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 15424334d..055c27733 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 @@ -25,7 +25,6 @@ import android.view.View; import android.view.ViewGroup; import android.view.animation.DecelerateInterpolator; import android.view.inputmethod.EditorInfo; -import android.view.inputmethod.InputMethodManager; import android.widget.EditText; import android.widget.TextView; @@ -34,7 +33,6 @@ import androidx.annotation.Nullable; import androidx.appcompat.app.ActionBar; import androidx.appcompat.app.AlertDialog; import androidx.appcompat.widget.TooltipCompat; -import androidx.core.content.ContextCompat; import androidx.core.text.HtmlCompat; import androidx.preference.PreferenceManager; import androidx.recyclerview.widget.ItemTouchHelper; @@ -65,6 +63,7 @@ import org.schabi.newpipe.settings.NewPipeSettings; import org.schabi.newpipe.util.Constants; import org.schabi.newpipe.util.DeviceUtils; import org.schabi.newpipe.util.ExtractorHelper; +import org.schabi.newpipe.util.KeyboardUtil; import org.schabi.newpipe.util.NavigationHelper; import org.schabi.newpipe.util.ServiceHelper; @@ -670,31 +669,15 @@ public class SearchFragment extends BaseListFragment= Build.VERSION_CODES.KITKAT; - - private String captionSettingsKey; @Override public void onCreatePreferences(final Bundle savedInstanceState, final String rootKey) { - addPreferencesFromResource(R.xml.appearance_settings); + addPreferencesFromResourceRegistry(); final String themeKey = getString(R.string.theme_key); // the key of the active theme when settings were opened (or recreated after theme change) @@ -51,16 +46,11 @@ public class AppearanceSettingsFragment extends BasePreferenceFragment { } else { removePreference(nightThemeKey); } - - captionSettingsKey = getString(R.string.caption_settings_key); - if (!CAPTIONING_SETTINGS_ACCESSIBLE) { - removePreference(captionSettingsKey); - } } @Override public boolean onPreferenceTreeClick(final Preference preference) { - if (preference.getKey().equals(captionSettingsKey) && CAPTIONING_SETTINGS_ACCESSIBLE) { + if (preference.getKey().equals(getString(R.string.caption_settings_key))) { try { startActivity(new Intent(Settings.ACTION_CAPTIONING_SETTINGS)); } catch (final ActivityNotFoundException e) { diff --git a/app/src/main/java/org/schabi/newpipe/settings/BasePreferenceFragment.java b/app/src/main/java/org/schabi/newpipe/settings/BasePreferenceFragment.java index a745861ad..619579f3a 100644 --- a/app/src/main/java/org/schabi/newpipe/settings/BasePreferenceFragment.java +++ b/app/src/main/java/org/schabi/newpipe/settings/BasePreferenceFragment.java @@ -28,6 +28,11 @@ public abstract class BasePreferenceFragment extends PreferenceFragmentCompat { super.onCreate(savedInstanceState); } + protected void addPreferencesFromResourceRegistry() { + addPreferencesFromResource( + SettingsResourceRegistry.getInstance().getPreferencesResId(this.getClass())); + } + @Override public void onViewCreated(@NonNull final View rootView, @Nullable final Bundle savedInstanceState) { diff --git a/app/src/main/java/org/schabi/newpipe/settings/ContentSettingsFragment.java b/app/src/main/java/org/schabi/newpipe/settings/ContentSettingsFragment.java index 1c8eb5cd2..47458ad3f 100644 --- a/app/src/main/java/org/schabi/newpipe/settings/ContentSettingsFragment.java +++ b/app/src/main/java/org/schabi/newpipe/settings/ContentSettingsFragment.java @@ -1,5 +1,8 @@ package org.schabi.newpipe.settings; +import static org.schabi.newpipe.extractor.utils.Utils.isBlank; +import static org.schabi.newpipe.util.Localization.assureCorrectAppLanguage; + import android.app.Activity; import android.content.Context; import android.content.Intent; @@ -21,7 +24,6 @@ import org.schabi.newpipe.DownloaderImpl; import org.schabi.newpipe.NewPipeDatabase; import org.schabi.newpipe.R; import org.schabi.newpipe.error.ErrorUtil; -import org.schabi.newpipe.error.ReCaptchaActivity; import org.schabi.newpipe.extractor.NewPipe; import org.schabi.newpipe.extractor.localization.ContentCountry; import org.schabi.newpipe.extractor.localization.Localization; @@ -38,9 +40,6 @@ import java.util.Date; import java.util.Locale; import java.util.Objects; -import static org.schabi.newpipe.extractor.utils.Utils.isBlank; -import static org.schabi.newpipe.util.Localization.assureCorrectAppLanguage; - public class ContentSettingsFragment extends BasePreferenceFragment { private static final String ZIP_MIME_TYPE = "application/zip"; @@ -70,7 +69,7 @@ public class ContentSettingsFragment extends BasePreferenceFragment { importExportDataPathKey = getString(R.string.import_export_data_path); youtubeRestrictedModeEnabledKey = getString(R.string.youtube_restricted_mode_enabled); - addPreferencesFromResource(R.xml.content_settings); + addPreferencesFromResourceRegistry(); final Preference importDataPreference = requirePreference(R.string.import_data); importDataPreference.setOnPreferenceClickListener((Preference p) -> { @@ -105,21 +104,6 @@ public class ContentSettingsFragment extends BasePreferenceFragment { .getPreferredContentCountry(requireContext()); initialLanguage = defaultPreferences.getString(getString(R.string.app_language_key), "en"); - final Preference clearCookiePref = requirePreference(R.string.clear_cookie_key); - clearCookiePref.setOnPreferenceClickListener(preference -> { - defaultPreferences.edit() - .putString(getString(R.string.recaptcha_cookies_key), "").apply(); - DownloaderImpl.getInstance().setCookie(ReCaptchaActivity.RECAPTCHA_COOKIES_KEY, ""); - Toast.makeText(getActivity(), R.string.recaptcha_cookies_cleared, - Toast.LENGTH_SHORT).show(); - clearCookiePref.setVisible(false); - return true; - }); - - if (defaultPreferences.getString(getString(R.string.recaptcha_cookies_key), "").isEmpty()) { - clearCookiePref.setVisible(false); - } - findPreference(getString(R.string.download_thumbnail_key)).setOnPreferenceChangeListener( (preference, newValue) -> { PicassoHelper.setShouldLoadImages((Boolean) newValue); diff --git a/app/src/main/java/org/schabi/newpipe/settings/DebugSettingsFragment.java b/app/src/main/java/org/schabi/newpipe/settings/DebugSettingsFragment.java new file mode 100644 index 000000000..395c7c0f0 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/settings/DebugSettingsFragment.java @@ -0,0 +1,108 @@ +package org.schabi.newpipe.settings; + +import android.content.Intent; +import android.os.Bundle; + +import androidx.preference.Preference; + +import org.schabi.newpipe.R; +import org.schabi.newpipe.error.ErrorInfo; +import org.schabi.newpipe.error.ErrorUtil; +import org.schabi.newpipe.error.UserAction; +import org.schabi.newpipe.util.PicassoHelper; + +import java.util.Optional; + +public class DebugSettingsFragment extends BasePreferenceFragment { + private static final String DUMMY = "Dummy"; + + @Override + public void onCreatePreferences(final Bundle savedInstanceState, final String rootKey) { + addPreferencesFromResourceRegistry(); + + final Preference allowHeapDumpingPreference + = findPreference(getString(R.string.allow_heap_dumping_key)); + final Preference showMemoryLeaksPreference + = findPreference(getString(R.string.show_memory_leaks_key)); + final Preference showImageIndicatorsPreference + = findPreference(getString(R.string.show_image_indicators_key)); + final Preference crashTheAppPreference + = findPreference(getString(R.string.crash_the_app_key)); + final Preference showErrorSnackbarPreference + = findPreference(getString(R.string.show_error_snackbar_key)); + final Preference createErrorNotificationPreference + = findPreference(getString(R.string.create_error_notification_key)); + + assert allowHeapDumpingPreference != null; + assert showMemoryLeaksPreference != null; + assert showImageIndicatorsPreference != null; + assert crashTheAppPreference != null; + assert showErrorSnackbarPreference != null; + assert createErrorNotificationPreference != null; + + final Optional optBVLeakCanary = getBVDLeakCanary(); + + allowHeapDumpingPreference.setEnabled(optBVLeakCanary.isPresent()); + showMemoryLeaksPreference.setEnabled(optBVLeakCanary.isPresent()); + + if (optBVLeakCanary.isPresent()) { + final DebugSettingsBVDLeakCanaryAPI pdLeakCanary = optBVLeakCanary.get(); + + showMemoryLeaksPreference.setOnPreferenceClickListener(preference -> { + startActivity(pdLeakCanary.getNewLeakDisplayActivityIntent()); + return true; + }); + } else { + allowHeapDumpingPreference.setSummary(R.string.leak_canary_not_available); + showMemoryLeaksPreference.setSummary(R.string.leak_canary_not_available); + } + + showImageIndicatorsPreference.setOnPreferenceChangeListener((preference, newValue) -> { + PicassoHelper.setIndicatorsEnabled((Boolean) newValue); + return true; + }); + + crashTheAppPreference.setOnPreferenceClickListener(preference -> { + throw new RuntimeException(DUMMY); + }); + + showErrorSnackbarPreference.setOnPreferenceClickListener(preference -> { + ErrorUtil.showUiErrorSnackbar(DebugSettingsFragment.this, + DUMMY, new RuntimeException(DUMMY)); + return true; + }); + + createErrorNotificationPreference.setOnPreferenceClickListener(preference -> { + ErrorUtil.createNotification(requireContext(), + new ErrorInfo(new RuntimeException(DUMMY), UserAction.UI_ERROR, DUMMY)); + return true; + }); + } + + /** + * Tries to find the {@link DebugSettingsBVDLeakCanaryAPI#IMPL_CLASS} and loads it if available. + * @return An {@link Optional} which is empty if the implementation class couldn't be loaded. + */ + private Optional getBVDLeakCanary() { + try { + // Try to find the implementation of the LeakCanary API + return Optional.of((DebugSettingsBVDLeakCanaryAPI) + Class.forName(DebugSettingsBVDLeakCanaryAPI.IMPL_CLASS) + .getDeclaredConstructor() + .newInstance()); + } catch (final Exception e) { + return Optional.empty(); + } + } + + /** + * Build variant dependent (BVD) leak canary API for this fragment. + * Why is LeakCanary not used directly? Because it can't be assured + */ + public interface DebugSettingsBVDLeakCanaryAPI { + String IMPL_CLASS = + "org.schabi.newpipe.settings.DebugSettingsBVDLeakCanary"; + + Intent getNewLeakDisplayActivityIntent(); + } +} diff --git a/app/src/main/java/org/schabi/newpipe/settings/DownloadSettingsFragment.java b/app/src/main/java/org/schabi/newpipe/settings/DownloadSettingsFragment.java index 681aee409..fe327e1b5 100644 --- a/app/src/main/java/org/schabi/newpipe/settings/DownloadSettingsFragment.java +++ b/app/src/main/java/org/schabi/newpipe/settings/DownloadSettingsFragment.java @@ -54,7 +54,7 @@ public class DownloadSettingsFragment extends BasePreferenceFragment { @Override public void onCreatePreferences(final Bundle savedInstanceState, final String rootKey) { - addPreferencesFromResource(R.xml.download_settings); + addPreferencesFromResourceRegistry(); downloadPathVideoPreference = getString(R.string.download_path_video_key); downloadPathAudioPreference = getString(R.string.download_path_audio_key); diff --git a/app/src/main/java/org/schabi/newpipe/settings/HistorySettingsFragment.java b/app/src/main/java/org/schabi/newpipe/settings/HistorySettingsFragment.java index 33e0ba16b..86e651e2b 100644 --- a/app/src/main/java/org/schabi/newpipe/settings/HistorySettingsFragment.java +++ b/app/src/main/java/org/schabi/newpipe/settings/HistorySettingsFragment.java @@ -8,9 +8,11 @@ import androidx.annotation.NonNull; import androidx.appcompat.app.AlertDialog; import androidx.preference.Preference; +import org.schabi.newpipe.DownloaderImpl; import org.schabi.newpipe.R; 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.local.history.HistoryRecordManager; import org.schabi.newpipe.util.InfoCache; @@ -29,7 +31,7 @@ public class HistorySettingsFragment extends BasePreferenceFragment { @Override public void onCreatePreferences(final Bundle savedInstanceState, final String rootKey) { - addPreferencesFromResource(R.xml.history_settings); + addPreferencesFromResourceRegistry(); cacheWipeKey = getString(R.string.metadata_cache_wipe_key); viewsHistoryClearKey = getString(R.string.clear_views_history_key); @@ -37,6 +39,21 @@ public class HistorySettingsFragment extends BasePreferenceFragment { searchHistoryClearKey = getString(R.string.clear_search_history_key); recordManager = new HistoryRecordManager(getActivity()); disposables = new CompositeDisposable(); + + final Preference clearCookiePref = requirePreference(R.string.clear_cookie_key); + clearCookiePref.setOnPreferenceClickListener(preference -> { + defaultPreferences.edit() + .putString(getString(R.string.recaptcha_cookies_key), "").apply(); + DownloaderImpl.getInstance().setCookie(ReCaptchaActivity.RECAPTCHA_COOKIES_KEY, ""); + Toast.makeText(getActivity(), R.string.recaptcha_cookies_cleared, + Toast.LENGTH_SHORT).show(); + clearCookiePref.setEnabled(false); + return true; + }); + + if (defaultPreferences.getString(getString(R.string.recaptcha_cookies_key), "").isEmpty()) { + clearCookiePref.setEnabled(false); + } } @Override diff --git a/app/src/main/java/org/schabi/newpipe/settings/MainSettingsFragment.java b/app/src/main/java/org/schabi/newpipe/settings/MainSettingsFragment.java index 12599b828..d7fb559d6 100644 --- a/app/src/main/java/org/schabi/newpipe/settings/MainSettingsFragment.java +++ b/app/src/main/java/org/schabi/newpipe/settings/MainSettingsFragment.java @@ -1,8 +1,11 @@ package org.schabi.newpipe.settings; import android.os.Bundle; +import android.view.Menu; +import android.view.MenuInflater; +import android.view.MenuItem; -import androidx.preference.Preference; +import androidx.annotation.NonNull; import org.schabi.newpipe.App; import org.schabi.newpipe.CheckForNewAppVersion; @@ -12,16 +15,58 @@ import org.schabi.newpipe.R; public class MainSettingsFragment extends BasePreferenceFragment { public static final boolean DEBUG = MainActivity.DEBUG; + private SettingsActivity settingsActivity; + @Override public void onCreatePreferences(final Bundle savedInstanceState, final String rootKey) { - addPreferencesFromResource(R.xml.main_settings); + addPreferencesFromResourceRegistry(); + setHasOptionsMenu(true); // Otherwise onCreateOptionsMenu is not called + + // Check if the app is updatable if (!CheckForNewAppVersion.isReleaseApk(App.getApp())) { - final Preference update - = findPreference(getString(R.string.update_pref_screen_key)); - getPreferenceScreen().removePreference(update); + getPreferenceScreen().removePreference( + findPreference(getString(R.string.update_pref_screen_key))); defaultPreferences.edit().putBoolean(getString(R.string.update_app_key), false).apply(); } + + // Hide debug preferences in RELEASE build variant + if (!DEBUG) { + getPreferenceScreen().removePreference( + findPreference(getString(R.string.debug_pref_screen_key))); + } + } + + @Override + public void onCreateOptionsMenu( + @NonNull final Menu menu, + @NonNull final MenuInflater inflater + ) { + super.onCreateOptionsMenu(menu, inflater); + + // -- Link settings activity and register menu -- + settingsActivity = (SettingsActivity) getActivity(); + + inflater.inflate(R.menu.menu_settings_main_fragment, menu); + + final MenuItem menuSearchItem = menu.getItem(0); + + settingsActivity.setMenuSearchItem(menuSearchItem); + + menuSearchItem.setOnMenuItemClickListener(ev -> { + settingsActivity.setSearchActive(true); + return true; + }); + } + + @Override + public void onDestroy() { + // Unlink activity so that we don't get memory problems + if (settingsActivity != null) { + settingsActivity.setMenuSearchItem(null); + settingsActivity = null; + } + super.onDestroy(); } } diff --git a/app/src/main/java/org/schabi/newpipe/settings/NotificationSettingsFragment.kt b/app/src/main/java/org/schabi/newpipe/settings/NotificationSettingsFragment.kt index e03aa4074..6bea8b69e 100644 --- a/app/src/main/java/org/schabi/newpipe/settings/NotificationSettingsFragment.kt +++ b/app/src/main/java/org/schabi/newpipe/settings/NotificationSettingsFragment.kt @@ -7,7 +7,7 @@ import org.schabi.newpipe.R class NotificationSettingsFragment : BasePreferenceFragment() { override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { - addPreferencesFromResource(R.xml.notification_settings) + addPreferencesFromResourceRegistry() if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP) { val colorizePref: Preference? = findPreference(getString(R.string.notification_colorize_key)) diff --git a/app/src/main/java/org/schabi/newpipe/settings/SelectKioskFragment.java b/app/src/main/java/org/schabi/newpipe/settings/SelectKioskFragment.java index a766ee074..383390506 100644 --- a/app/src/main/java/org/schabi/newpipe/settings/SelectKioskFragment.java +++ b/app/src/main/java/org/schabi/newpipe/settings/SelectKioskFragment.java @@ -1,6 +1,5 @@ package org.schabi.newpipe.settings; -import android.content.DialogInterface; import android.os.Bundle; import android.view.LayoutInflater; import android.view.View; @@ -51,16 +50,11 @@ public class SelectKioskFragment extends DialogFragment { private SelectKioskAdapter selectKioskAdapter = null; private OnSelectedListener onSelectedListener = null; - private OnCancelListener onCancelListener = null; public void setOnSelectedListener(final OnSelectedListener listener) { onSelectedListener = listener; } - public void setOnCancelListener(final OnCancelListener listener) { - onCancelListener = listener; - } - /*////////////////////////////////////////////////////////////////////////// // Init //////////////////////////////////////////////////////////////////////////*/ @@ -91,14 +85,6 @@ public class SelectKioskFragment extends DialogFragment { // Handle actions //////////////////////////////////////////////////////////////////////////*/ - @Override - public void onCancel(@NonNull final DialogInterface dialogInterface) { - super.onCancel(dialogInterface); - if (onCancelListener != null) { - onCancelListener.onCancel(); - } - } - private void clickedItem(final SelectKioskAdapter.Entry entry) { if (onSelectedListener != null) { onSelectedListener.onKioskSelected(entry.serviceId, entry.kioskId, entry.kioskName); @@ -114,10 +100,6 @@ public class SelectKioskFragment extends DialogFragment { void onKioskSelected(int serviceId, String kioskId, String kioskName); } - public interface OnCancelListener { - void onCancel(); - } - private class SelectKioskAdapter extends RecyclerView.Adapter { private final List kioskList = new Vector<>(); diff --git a/app/src/main/java/org/schabi/newpipe/settings/SettingsActivity.java b/app/src/main/java/org/schabi/newpipe/settings/SettingsActivity.java index 02e2538c5..3872e5172 100644 --- a/app/src/main/java/org/schabi/newpipe/settings/SettingsActivity.java +++ b/app/src/main/java/org/schabi/newpipe/settings/SettingsActivity.java @@ -1,22 +1,48 @@ package org.schabi.newpipe.settings; +import static org.schabi.newpipe.util.Localization.assureCorrectAppLanguage; + import android.os.Bundle; +import android.text.TextUtils; +import android.util.Log; import android.view.Menu; import android.view.MenuItem; +import android.view.View; +import android.widget.EditText; +import androidx.annotation.IdRes; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; import androidx.appcompat.app.ActionBar; import androidx.appcompat.app.AppCompatActivity; import androidx.fragment.app.Fragment; +import androidx.fragment.app.FragmentManager; import androidx.preference.Preference; import androidx.preference.PreferenceFragmentCompat; +import com.jakewharton.rxbinding4.widget.RxTextView; + +import org.schabi.newpipe.App; +import org.schabi.newpipe.CheckForNewAppVersion; +import org.schabi.newpipe.MainActivity; import org.schabi.newpipe.R; import org.schabi.newpipe.databinding.SettingsLayoutBinding; +import org.schabi.newpipe.settings.preferencesearch.PreferenceParser; +import org.schabi.newpipe.settings.preferencesearch.PreferenceSearchConfiguration; +import org.schabi.newpipe.settings.preferencesearch.PreferenceSearchFragment; +import org.schabi.newpipe.settings.preferencesearch.PreferenceSearchItem; +import org.schabi.newpipe.settings.preferencesearch.PreferenceSearchResultHighlighter; +import org.schabi.newpipe.settings.preferencesearch.PreferenceSearchResultListener; +import org.schabi.newpipe.settings.preferencesearch.PreferenceSearcher; import org.schabi.newpipe.util.DeviceUtils; +import org.schabi.newpipe.util.KeyboardUtil; import org.schabi.newpipe.util.ThemeHelper; import org.schabi.newpipe.views.FocusOverlayView; -import static org.schabi.newpipe.util.Localization.assureCorrectAppLanguage; +import java.util.concurrent.TimeUnit; + +import icepick.Icepick; +import icepick.State; /* * Created by Christian Schabesberger on 31.08.15. @@ -38,21 +64,54 @@ import static org.schabi.newpipe.util.Localization.assureCorrectAppLanguage; * along with NewPipe. If not, see . */ -public class SettingsActivity extends AppCompatActivity - implements BasePreferenceFragment.OnPreferenceStartFragmentCallback { +public class SettingsActivity extends AppCompatActivity implements + PreferenceFragmentCompat.OnPreferenceStartFragmentCallback, + PreferenceSearchResultListener { + private static final String TAG = "SettingsActivity"; + private static final boolean DEBUG = MainActivity.DEBUG; + + @IdRes + private static final int FRAGMENT_HOLDER_ID = R.id.settings_fragment_holder; + + private PreferenceSearchFragment searchFragment; + + @Nullable + private MenuItem menuSearchItem; + + private View searchContainer; + private EditText searchEditText; + + // State + @State + String searchText; + @State + boolean wasSearchActive; + @Override protected void onCreate(final Bundle savedInstanceBundle) { setTheme(ThemeHelper.getSettingsThemeStyle(this)); assureCorrectAppLanguage(this); + super.onCreate(savedInstanceBundle); + Icepick.restoreInstanceState(this, savedInstanceBundle); + final boolean restored = savedInstanceBundle != null; final SettingsLayoutBinding settingsLayoutBinding = SettingsLayoutBinding.inflate(getLayoutInflater()); setContentView(settingsLayoutBinding.getRoot()); + initSearch(settingsLayoutBinding, restored); setSupportActionBar(settingsLayoutBinding.settingsToolbarLayout.toolbar); - if (savedInstanceBundle == null) { + if (restored) { + // Restore state + if (this.wasSearchActive) { + setSearchActive(true); + if (!TextUtils.isEmpty(this.searchText)) { + this.searchEditText.setText(this.searchText); + } + } + } else { getSupportFragmentManager().beginTransaction() .replace(R.id.settings_fragment_holder, new MainSettingsFragment()) .commit(); @@ -63,6 +122,12 @@ public class SettingsActivity extends AppCompatActivity } } + @Override + protected void onSaveInstanceState(@NonNull final Bundle outState) { + super.onSaveInstanceState(outState); + Icepick.saveInstanceState(this, outState); + } + @Override public boolean onCreateOptionsMenu(final Menu menu) { final ActionBar actionBar = getSupportActionBar(); @@ -74,10 +139,25 @@ public class SettingsActivity extends AppCompatActivity return super.onCreateOptionsMenu(menu); } + @Override + public void onBackPressed() { + if (isSearchActive()) { + setSearchActive(false); + return; + } + super.onBackPressed(); + } + @Override public boolean onOptionsItemSelected(final MenuItem item) { final int id = item.getItemId(); if (id == android.R.id.home) { + // Check if the search is active and if so: Close it + if (isSearchActive()) { + setSearchActive(false); + return true; + } + if (getSupportFragmentManager().getBackStackEntryCount() == 0) { finish(); } else { @@ -91,14 +171,221 @@ public class SettingsActivity extends AppCompatActivity @Override public boolean onPreferenceStartFragment(final PreferenceFragmentCompat caller, final Preference preference) { - final Fragment fragment = Fragment - .instantiate(this, preference.getFragment(), preference.getExtras()); + showSettingsFragment(instantiateFragment(preference.getFragment())); + return true; + } + + private Fragment instantiateFragment(@NonNull final String className) { + return getSupportFragmentManager() + .getFragmentFactory() + .instantiate(this.getClassLoader(), className); + } + + private void showSettingsFragment(final Fragment fragment) { getSupportFragmentManager().beginTransaction() .setCustomAnimations(R.animator.custom_fade_in, R.animator.custom_fade_out, R.animator.custom_fade_in, R.animator.custom_fade_out) - .replace(R.id.settings_fragment_holder, fragment) + .replace(FRAGMENT_HOLDER_ID, fragment) .addToBackStack(null) .commit(); - return true; } + + @Override + protected void onDestroy() { + setMenuSearchItem(null); + searchFragment = null; + super.onDestroy(); + } + + /*////////////////////////////////////////////////////////////////////////// + // Search + //////////////////////////////////////////////////////////////////////////*/ + //region Search + + private void initSearch( + final SettingsLayoutBinding settingsLayoutBinding, + final boolean restored + ) { + searchContainer = + settingsLayoutBinding.settingsToolbarLayout.toolbar + .findViewById(R.id.toolbar_search_container); + + // Configure input field for search + searchEditText = searchContainer.findViewById(R.id.toolbar_search_edit_text); + RxTextView.textChanges(searchEditText) + // Wait some time after the last input before actually searching + .debounce(200, TimeUnit.MILLISECONDS) + .subscribe(v -> runOnUiThread(this::onSearchChanged)); + + // Configure clear button + searchContainer.findViewById(R.id.toolbar_search_clear) + .setOnClickListener(ev -> resetSearchText()); + + ensureSearchRepresentsApplicationState(); + + // Build search configuration using SettingsResourceRegistry + final PreferenceSearchConfiguration config = new PreferenceSearchConfiguration(); + + + // Build search items + final PreferenceParser parser = new PreferenceParser(getApplicationContext(), config); + final PreferenceSearcher searcher = new PreferenceSearcher(config); + + // Find all searchable SettingsResourceRegistry fragments + SettingsResourceRegistry.getInstance().getAllEntries().stream() + .filter(SettingsResourceRegistry.SettingRegistryEntry::isSearchable) + // Get the resId + .map(SettingsResourceRegistry.SettingRegistryEntry::getPreferencesResId) + // Parse + .map(parser::parse) + // Add it to the searcher + .forEach(searcher::add); + + if (restored) { + searchFragment = (PreferenceSearchFragment) getSupportFragmentManager() + .findFragmentByTag(PreferenceSearchFragment.NAME); + if (searchFragment != null) { + // Hide/Remove the search fragment otherwise we get an exception + // when adding it (because it's already present) + hideSearchFragment(); + } + } + if (searchFragment == null) { + searchFragment = new PreferenceSearchFragment(); + } + searchFragment.setSearcher(searcher); + } + + /** + * Ensures that the search shows the correct/available search results. + *
+ * Some features are e.g. only available for debug builds, these should not + * be found when searching inside a release. + */ + private void ensureSearchRepresentsApplicationState() { + // Check if the update settings are available + if (!CheckForNewAppVersion.isReleaseApk(App.getApp())) { + SettingsResourceRegistry.getInstance() + .getEntryByPreferencesResId(R.xml.update_settings) + .setSearchable(false); + } + + // Hide debug preferences in RELEASE build variant + if (DEBUG) { + SettingsResourceRegistry.getInstance() + .getEntryByPreferencesResId(R.xml.debug_settings) + .setSearchable(true); + } + } + + public void setMenuSearchItem(final MenuItem menuSearchItem) { + this.menuSearchItem = menuSearchItem; + + // Ensure that the item is in the correct state when adding it. This is due to + // Android's lifecycle (the Activity is recreated before the Fragment that registers this) + if (menuSearchItem != null) { + menuSearchItem.setVisible(!isSearchActive()); + } + } + + public void setSearchActive(final boolean active) { + if (DEBUG) { + Log.d(TAG, "setSearchActive called active=" + active); + } + + // Ignore if search is already in correct state + if (isSearchActive() == active) { + return; + } + + wasSearchActive = active; + + searchContainer.setVisibility(active ? View.VISIBLE : View.GONE); + if (menuSearchItem != null) { + menuSearchItem.setVisible(!active); + } + + if (active) { + getSupportFragmentManager() + .beginTransaction() + .add(FRAGMENT_HOLDER_ID, searchFragment, PreferenceSearchFragment.NAME) + .addToBackStack(PreferenceSearchFragment.NAME) + .commit(); + + KeyboardUtil.showKeyboard(this, searchEditText); + } else if (searchFragment != null) { + hideSearchFragment(); + getSupportFragmentManager() + .popBackStack( + PreferenceSearchFragment.NAME, + FragmentManager.POP_BACK_STACK_INCLUSIVE); + + KeyboardUtil.hideKeyboard(this, searchEditText); + } + + resetSearchText(); + } + + private void hideSearchFragment() { + getSupportFragmentManager().beginTransaction().remove(searchFragment).commit(); + } + + private void resetSearchText() { + searchEditText.setText(""); + } + + private boolean isSearchActive() { + return searchContainer.getVisibility() == View.VISIBLE; + } + + private void onSearchChanged() { + if (!isSearchActive()) { + return; + } + + if (searchFragment != null) { + searchText = this.searchEditText.getText().toString(); + searchFragment.updateSearchResults(searchText); + } + } + + @Override + public void onSearchResultClicked(@NonNull final PreferenceSearchItem result) { + if (DEBUG) { + Log.d(TAG, "onSearchResultClicked called result=" + result); + } + + // Hide the search + setSearchActive(false); + + // -- Highlight the result -- + // Find out which fragment class we need + final Class targetedFragmentClass = + SettingsResourceRegistry.getInstance() + .getFragmentClass(result.getSearchIndexItemResId()); + + if (targetedFragmentClass == null) { + // This should never happen + Log.w(TAG, "Unable to locate fragment class for resId=" + + result.getSearchIndexItemResId()); + return; + } + + // Check if the currentFragment is the one which contains the result + Fragment currentFragment = + getSupportFragmentManager().findFragmentById(FRAGMENT_HOLDER_ID); + if (!targetedFragmentClass.equals(currentFragment.getClass())) { + // If it's not the correct one display the correct one + currentFragment = instantiateFragment(targetedFragmentClass.getName()); + showSettingsFragment(currentFragment); + } + + // Run the highlighting + if (currentFragment instanceof PreferenceFragmentCompat) { + PreferenceSearchResultHighlighter + .highlight(result, (PreferenceFragmentCompat) currentFragment); + } + } + + //endregion } diff --git a/app/src/main/java/org/schabi/newpipe/settings/SettingsResourceRegistry.java b/app/src/main/java/org/schabi/newpipe/settings/SettingsResourceRegistry.java new file mode 100644 index 000000000..c4751abea --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/settings/SettingsResourceRegistry.java @@ -0,0 +1,148 @@ +package org.schabi.newpipe.settings; + +import androidx.annotation.NonNull; +import androidx.annotation.XmlRes; +import androidx.fragment.app.Fragment; + +import org.schabi.newpipe.R; + +import java.util.HashSet; +import java.util.Objects; +import java.util.Set; + +/** + * A registry that contains information about SettingsFragments. + *
+ * includes: + *
    + *
  • Class of the SettingsFragment
  • + *
  • XML-Resource
  • + *
  • ...
  • + *
+ * + * E.g. used by the preference search. + */ +public final class SettingsResourceRegistry { + + private static final SettingsResourceRegistry INSTANCE = new SettingsResourceRegistry(); + + private final Set registeredEntries = new HashSet<>(); + + private SettingsResourceRegistry() { + add(MainSettingsFragment.class, R.xml.main_settings).setSearchable(false); + + add(AppearanceSettingsFragment.class, R.xml.appearance_settings); + add(ContentSettingsFragment.class, R.xml.content_settings); + add(DebugSettingsFragment.class, R.xml.debug_settings).setSearchable(false); + add(DownloadSettingsFragment.class, R.xml.download_settings); + add(HistorySettingsFragment.class, R.xml.history_settings); + add(NotificationSettingsFragment.class, R.xml.notification_settings); + add(UpdateSettingsFragment.class, R.xml.update_settings); + add(VideoAudioSettingsFragment.class, R.xml.video_audio_settings); + } + + private SettingRegistryEntry add( + @NonNull final Class fragmentClass, + @XmlRes final int preferencesResId + ) { + final SettingRegistryEntry entry = + new SettingRegistryEntry(fragmentClass, preferencesResId); + this.registeredEntries.add(entry); + return entry; + } + + public SettingRegistryEntry getEntryByFragmentClass( + final Class fragmentClass + ) { + Objects.requireNonNull(fragmentClass); + return registeredEntries.stream() + .filter(e -> Objects.equals(e.getFragmentClass(), fragmentClass)) + .findFirst() + .orElse(null); + } + + public SettingRegistryEntry getEntryByPreferencesResId(@XmlRes final int preferencesResId) { + return registeredEntries.stream() + .filter(e -> Objects.equals(e.getPreferencesResId(), preferencesResId)) + .findFirst() + .orElse(null); + } + + public int getPreferencesResId(@NonNull final Class fragmentClass) { + final SettingRegistryEntry entry = getEntryByFragmentClass(fragmentClass); + if (entry == null) { + return -1; + } + return entry.getPreferencesResId(); + } + + public Class getFragmentClass(@XmlRes final int preferencesResId) { + final SettingRegistryEntry entry = getEntryByPreferencesResId(preferencesResId); + if (entry == null) { + return null; + } + return entry.getFragmentClass(); + } + + public Set getAllEntries() { + return new HashSet<>(registeredEntries); + } + + public static SettingsResourceRegistry getInstance() { + return INSTANCE; + } + + + public static class SettingRegistryEntry { + @NonNull + private final Class fragmentClass; + @XmlRes + private final int preferencesResId; + + private boolean searchable = true; + + public SettingRegistryEntry( + @NonNull final Class fragmentClass, + @XmlRes final int preferencesResId + ) { + this.fragmentClass = Objects.requireNonNull(fragmentClass); + this.preferencesResId = preferencesResId; + } + + @SuppressWarnings("HiddenField") + public SettingRegistryEntry setSearchable(final boolean searchable) { + this.searchable = searchable; + return this; + } + + public Class getFragmentClass() { + return fragmentClass; + } + + public int getPreferencesResId() { + return preferencesResId; + } + + public boolean isSearchable() { + return searchable; + } + + @Override + public boolean equals(final Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + final SettingRegistryEntry that = (SettingRegistryEntry) o; + return getPreferencesResId() == that.getPreferencesResId() + && getFragmentClass().equals(that.getFragmentClass()); + } + + @Override + public int hashCode() { + return Objects.hash(getFragmentClass(), getPreferencesResId()); + } + } +} diff --git a/app/src/main/java/org/schabi/newpipe/settings/UpdateSettingsFragment.java b/app/src/main/java/org/schabi/newpipe/settings/UpdateSettingsFragment.java index bc183d08a..04bad3815 100644 --- a/app/src/main/java/org/schabi/newpipe/settings/UpdateSettingsFragment.java +++ b/app/src/main/java/org/schabi/newpipe/settings/UpdateSettingsFragment.java @@ -38,7 +38,7 @@ public class UpdateSettingsFragment extends BasePreferenceFragment { @Override public void onCreatePreferences(final Bundle savedInstanceState, final String rootKey) { - addPreferencesFromResource(R.xml.update_settings); + addPreferencesFromResourceRegistry(); findPreference(getString(R.string.update_app_key)) .setOnPreferenceChangeListener(updatePreferenceChange); diff --git a/app/src/main/java/org/schabi/newpipe/settings/VideoAudioSettingsFragment.java b/app/src/main/java/org/schabi/newpipe/settings/VideoAudioSettingsFragment.java index c0d274fe0..039f00c1d 100644 --- a/app/src/main/java/org/schabi/newpipe/settings/VideoAudioSettingsFragment.java +++ b/app/src/main/java/org/schabi/newpipe/settings/VideoAudioSettingsFragment.java @@ -23,7 +23,7 @@ public class VideoAudioSettingsFragment extends BasePreferenceFragment { @Override public void onCreatePreferences(final Bundle savedInstanceState, final String rootKey) { - addPreferencesFromResource(R.xml.video_audio_settings); + addPreferencesFromResourceRegistry(); updateSeekOptions(); diff --git a/app/src/main/java/org/schabi/newpipe/settings/preferencesearch/PreferenceFuzzySearchFunction.java b/app/src/main/java/org/schabi/newpipe/settings/preferencesearch/PreferenceFuzzySearchFunction.java new file mode 100644 index 000000000..7c231cafb --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/settings/preferencesearch/PreferenceFuzzySearchFunction.java @@ -0,0 +1,111 @@ +package org.schabi.newpipe.settings.preferencesearch; + +import android.text.TextUtils; + +import org.apache.commons.text.similarity.FuzzyScore; + +import java.util.Comparator; +import java.util.Locale; +import java.util.Map; +import java.util.function.Function; +import java.util.stream.Stream; + +public class PreferenceFuzzySearchFunction + implements PreferenceSearchConfiguration.PreferenceSearchFunction { + + private static final FuzzyScore FUZZY_SCORE = new FuzzyScore(Locale.ROOT); + + @Override + public Stream search( + final Stream allAvailable, + final String keyword + ) { + final int maxScore = (keyword.length() + 1) * 3 - 2; // First can't get +2 bonus score + + return allAvailable + // General search + // Check all fields if anyone contains something that kind of matches the keyword + .map(item -> new FuzzySearchGeneralDTO(item, keyword)) + .filter(dto -> dto.getScore() / maxScore >= 0.3f) + .map(FuzzySearchGeneralDTO::getItem) + // Specific search - Used for determining order of search results + // Calculate a score based on specific search fields + .map(item -> new FuzzySearchSpecificDTO(item, keyword)) + .sorted(Comparator.comparing(FuzzySearchSpecificDTO::getScore).reversed()) + .map(FuzzySearchSpecificDTO::getItem) + // Limit the amount of search results + .limit(20); + } + + static class FuzzySearchGeneralDTO { + private final PreferenceSearchItem item; + private final float score; + + FuzzySearchGeneralDTO( + final PreferenceSearchItem item, + final String keyword) { + this.item = item; + this.score = FUZZY_SCORE.fuzzyScore( + TextUtils.join(";", item.getAllRelevantSearchFields()), + keyword); + } + + public PreferenceSearchItem getItem() { + return item; + } + + public float getScore() { + return score; + } + } + + static class FuzzySearchSpecificDTO { + private static final Map, Float> WEIGHT_MAP = Map.of( + // The user will most likely look for the title -> prioritize it + PreferenceSearchItem::getTitle, 1.5f, + // The summary is also important as it usually contains a larger desc + // Example: Searching for '4k' → 'show higher resolution' is shown + PreferenceSearchItem::getSummary, 1f, + // Entries are also important as they provide all known/possible values + // Example: Searching where the resolution can be changed to 720p + PreferenceSearchItem::getEntries, 1f + ); + + private final PreferenceSearchItem item; + private final float score; + + FuzzySearchSpecificDTO( + final PreferenceSearchItem item, + final String keyword) { + this.item = item; + + float attributeScoreSum = 0; + int countOfAttributesWithScore = 0; + for (final Map.Entry, Float> we + : WEIGHT_MAP.entrySet()) { + final String valueToProcess = we.getKey().apply(item); + if (valueToProcess.isEmpty()) { + continue; + } + + attributeScoreSum += + FUZZY_SCORE.fuzzyScore(valueToProcess, keyword) * we.getValue(); + countOfAttributesWithScore++; + } + + if (countOfAttributesWithScore != 0) { + this.score = attributeScoreSum / countOfAttributesWithScore; + } else { + this.score = 0; + } + } + + public PreferenceSearchItem getItem() { + return item; + } + + public float getScore() { + return score; + } + } +} diff --git a/app/src/main/java/org/schabi/newpipe/settings/preferencesearch/PreferenceParser.java b/app/src/main/java/org/schabi/newpipe/settings/preferencesearch/PreferenceParser.java new file mode 100644 index 000000000..1f507c7f1 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/settings/preferencesearch/PreferenceParser.java @@ -0,0 +1,199 @@ +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.annotation.XmlRes; +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.stream.Collectors; + +/** + * Parses the corresponding preference-file(s). + */ +public 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 allPreferences; + private final PreferenceSearchConfiguration searchConfiguration; + + public PreferenceParser( + final Context context, + final PreferenceSearchConfiguration searchConfiguration + ) { + this.context = context; + this.allPreferences = PreferenceManager.getDefaultSharedPreferences(context).getAll(); + this.searchConfiguration = searchConfiguration; + } + + public List parse( + @XmlRes final int resId + ) { + final List results = new ArrayList<>(); + final XmlPullParser xpp = context.getResources().getXml(resId); + + try { + xpp.setFeature(XmlPullParser.FEATURE_PROCESS_NAMESPACES, true); + xpp.setFeature(XmlPullParser.FEATURE_REPORT_NAMESPACE_ATTRIBUTES, true); + + final List breadcrumbs = new ArrayList<>(); + while (xpp.getEventType() != XmlPullParser.END_DOCUMENT) { + if (xpp.getEventType() == XmlPullParser.START_TAG) { + final PreferenceSearchItem result = parseSearchResult( + xpp, + joinBreadcrumbs(breadcrumbs), + resId + ); + + if (!searchConfiguration.getParserIgnoreElements().contains(xpp.getName()) + && result.hasData() + && !"true".equals(getAttribute(xpp, NS_SEARCH, "ignore"))) { + results.add(result); + } + if (searchConfiguration.getParserContainerElements().contains(xpp.getName())) { + // This code adds breadcrumbs for certain containers (e.g. PreferenceScreen) + // Example: Video and Audio > Player + 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=" + resId, e); + } + return results; + } + + private String joinBreadcrumbs(final List breadcrumbs) { + return breadcrumbs.stream() + .filter(crumb -> !TextUtils.isEmpty(crumb)) + .collect(Collectors.joining(" > ")); + } + + 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, + @XmlRes 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), + 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()); + } +} diff --git a/app/src/main/java/org/schabi/newpipe/settings/preferencesearch/PreferenceSearchAdapter.java b/app/src/main/java/org/schabi/newpipe/settings/preferencesearch/PreferenceSearchAdapter.java new file mode 100644 index 000000000..02fbf9577 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/settings/preferencesearch/PreferenceSearchAdapter.java @@ -0,0 +1,87 @@ +package org.schabi.newpipe.settings.preferencesearch; + +import android.text.TextUtils; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; + +import androidx.annotation.NonNull; +import androidx.recyclerview.widget.RecyclerView; + +import org.schabi.newpipe.databinding.SettingsPreferencesearchListItemResultBinding; + +import java.util.ArrayList; +import java.util.List; +import java.util.function.Consumer; + +class PreferenceSearchAdapter + extends RecyclerView.Adapter { + private List dataset = new ArrayList<>(); + private Consumer onItemClickListener; + + @NonNull + @Override + public PreferenceViewHolder onCreateViewHolder( + @NonNull final ViewGroup parent, + final int viewType + ) { + return new PreferenceViewHolder( + SettingsPreferencesearchListItemResultBinding.inflate( + LayoutInflater.from(parent.getContext()), + parent, + false)); + } + + @Override + public void onBindViewHolder( + @NonNull final PreferenceViewHolder holder, + final int position + ) { + final PreferenceSearchItem item = dataset.get(position); + + holder.binding.title.setText(item.getTitle()); + + if (TextUtils.isEmpty(item.getSummary())) { + holder.binding.summary.setVisibility(View.GONE); + } else { + holder.binding.summary.setVisibility(View.VISIBLE); + holder.binding.summary.setText(item.getSummary()); + } + + if (TextUtils.isEmpty(item.getBreadcrumbs())) { + holder.binding.breadcrumbs.setVisibility(View.GONE); + } else { + holder.binding.breadcrumbs.setVisibility(View.VISIBLE); + holder.binding.breadcrumbs.setText(item.getBreadcrumbs()); + } + + holder.itemView.setOnClickListener(v -> { + if (onItemClickListener != null) { + onItemClickListener.accept(item); + } + }); + } + + void setContent(final List items) { + dataset = new ArrayList<>(items); + this.notifyDataSetChanged(); + } + + @Override + public int getItemCount() { + return dataset.size(); + } + + void setOnItemClickListener(final Consumer onItemClickListener) { + this.onItemClickListener = onItemClickListener; + } + + static class PreferenceViewHolder extends RecyclerView.ViewHolder { + final SettingsPreferencesearchListItemResultBinding binding; + + PreferenceViewHolder(final SettingsPreferencesearchListItemResultBinding binding) { + super(binding.getRoot()); + this.binding = binding; + } + } +} diff --git a/app/src/main/java/org/schabi/newpipe/settings/preferencesearch/PreferenceSearchConfiguration.java b/app/src/main/java/org/schabi/newpipe/settings/preferencesearch/PreferenceSearchConfiguration.java new file mode 100644 index 000000000..5835dcab5 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/settings/preferencesearch/PreferenceSearchConfiguration.java @@ -0,0 +1,43 @@ +package org.schabi.newpipe.settings.preferencesearch; + +import androidx.preference.PreferenceCategory; +import androidx.preference.PreferenceScreen; + +import java.util.Arrays; +import java.util.List; +import java.util.Objects; +import java.util.stream.Stream; + +public class PreferenceSearchConfiguration { + private PreferenceSearchFunction searcher = new PreferenceFuzzySearchFunction(); + + private final List parserIgnoreElements = Arrays.asList( + PreferenceCategory.class.getSimpleName()); + private final List parserContainerElements = Arrays.asList( + PreferenceCategory.class.getSimpleName(), + PreferenceScreen.class.getSimpleName()); + + + public void setSearcher(final PreferenceSearchFunction searcher) { + this.searcher = Objects.requireNonNull(searcher); + } + + public PreferenceSearchFunction getSearcher() { + return searcher; + } + + public List getParserIgnoreElements() { + return parserIgnoreElements; + } + + public List getParserContainerElements() { + return parserContainerElements; + } + + @FunctionalInterface + public interface PreferenceSearchFunction { + Stream search( + Stream allAvailable, + String keyword); + } +} diff --git a/app/src/main/java/org/schabi/newpipe/settings/preferencesearch/PreferenceSearchFragment.java b/app/src/main/java/org/schabi/newpipe/settings/preferencesearch/PreferenceSearchFragment.java new file mode 100644 index 000000000..308abbc4e --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/settings/preferencesearch/PreferenceSearchFragment.java @@ -0,0 +1,80 @@ +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 org.schabi.newpipe.databinding.SettingsPreferencesearchFragmentBinding; + +import java.util.ArrayList; +import java.util.List; + +/** + * Displays the search results. + */ +public class PreferenceSearchFragment extends Fragment { + public static final String NAME = PreferenceSearchFragment.class.getSimpleName(); + + private PreferenceSearcher searcher; + + private SettingsPreferencesearchFragmentBinding binding; + private PreferenceSearchAdapter adapter; + + public void setSearcher(final PreferenceSearcher searcher) { + this.searcher = searcher; + } + + @Nullable + @Override + public View onCreateView( + @NonNull final LayoutInflater inflater, + @Nullable final ViewGroup container, + @Nullable final Bundle savedInstanceState + ) { + binding = SettingsPreferencesearchFragmentBinding.inflate(inflater, container, false); + + binding.searchResults.setLayoutManager(new LinearLayoutManager(getContext())); + + adapter = new PreferenceSearchAdapter(); + adapter.setOnItemClickListener(this::onItemClicked); + binding.searchResults.setAdapter(adapter); + + return binding.getRoot(); + } + + public void updateSearchResults(final String keyword) { + if (adapter == null || searcher == null) { + return; + } + + final List results = + !TextUtils.isEmpty(keyword) + ? searcher.searchFor(keyword) + : new ArrayList<>(); + + adapter.setContent(new ArrayList<>(results)); + + setEmptyViewShown(results.isEmpty()); + } + + private void setEmptyViewShown(final boolean shown) { + binding.emptyStateView.setVisibility(shown ? View.VISIBLE : View.GONE); + binding.searchResults.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); + } +} diff --git a/app/src/main/java/org/schabi/newpipe/settings/preferencesearch/PreferenceSearchItem.java b/app/src/main/java/org/schabi/newpipe/settings/preferencesearch/PreferenceSearchItem.java new file mode 100644 index 000000000..52935ef8e --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/settings/preferencesearch/PreferenceSearchItem.java @@ -0,0 +1,102 @@ +package org.schabi.newpipe.settings.preferencesearch; + +import androidx.annotation.NonNull; +import androidx.annotation.XmlRes; + +import java.util.Arrays; +import java.util.List; +import java.util.Objects; + +/** + * Represents a preference-item inside the search. + */ +public class PreferenceSearchItem { + /** + * Key of the setting/preference. E.g. used inside {@link android.content.SharedPreferences}. + */ + @NonNull + private final String key; + /** + * Title of the setting, e.g. 'Default resolution' or 'Show higher resolutions'. + */ + @NonNull + private final String title; + /** + * Summary of the setting, e.g. '480p' or 'Only some devices can play 2k/4k'. + */ + @NonNull + private final String summary; + /** + * Possible entries of the setting, e.g. 480p,720p,... + */ + @NonNull + private final String entries; + /** + * Breadcrumbs - a hint where the setting is located e.g. 'Video and Audio > Player' + */ + @NonNull + private final String breadcrumbs; + /** + * The xml-resource where this item was found/built from. + */ + @XmlRes + 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 breadcrumbs, + @XmlRes final int searchIndexItemResId + ) { + this.key = Objects.requireNonNull(key); + this.title = Objects.requireNonNull(title); + this.summary = Objects.requireNonNull(summary); + this.entries = Objects.requireNonNull(entries); + 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 int getSearchIndexItemResId() { + return searchIndexItemResId; + } + + boolean hasData() { + return !key.isEmpty() && !title.isEmpty(); + } + + public List getAllRelevantSearchFields() { + return Arrays.asList( + getTitle(), + getSummary(), + getEntries(), + getBreadcrumbs()); + } + + + @Override + public String toString() { + return "PreferenceItem: " + title + " " + summary + " " + key; + } +} diff --git a/app/src/main/java/org/schabi/newpipe/settings/preferencesearch/PreferenceSearchResultHighlighter.java b/app/src/main/java/org/schabi/newpipe/settings/preferencesearch/PreferenceSearchResultHighlighter.java new file mode 100644 index 000000000..418a3ea46 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/settings/preferencesearch/PreferenceSearchResultHighlighter.java @@ -0,0 +1,127 @@ +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. + *
+ * Note: This function is Thread independent (can be called from outside of the main thread). + * + * @param item The item to highlight + * @param prefsFragment The fragment where the items is located on + */ + 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); + }, 200); + 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); + } +} diff --git a/app/src/main/java/org/schabi/newpipe/settings/preferencesearch/PreferenceSearchResultListener.java b/app/src/main/java/org/schabi/newpipe/settings/preferencesearch/PreferenceSearchResultListener.java new file mode 100644 index 000000000..1f0636454 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/settings/preferencesearch/PreferenceSearchResultListener.java @@ -0,0 +1,7 @@ +package org.schabi.newpipe.settings.preferencesearch; + +import androidx.annotation.NonNull; + +public interface PreferenceSearchResultListener { + void onSearchResultClicked(@NonNull PreferenceSearchItem result); +} diff --git a/app/src/main/java/org/schabi/newpipe/settings/preferencesearch/PreferenceSearcher.java b/app/src/main/java/org/schabi/newpipe/settings/preferencesearch/PreferenceSearcher.java new file mode 100644 index 000000000..176dc5d14 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/settings/preferencesearch/PreferenceSearcher.java @@ -0,0 +1,35 @@ +package org.schabi.newpipe.settings.preferencesearch; + +import android.text.TextUtils; + +import java.util.ArrayList; +import java.util.List; +import java.util.stream.Collectors; + +public class PreferenceSearcher { + private final List allEntries = new ArrayList<>(); + + private final PreferenceSearchConfiguration configuration; + + public PreferenceSearcher(final PreferenceSearchConfiguration configuration) { + this.configuration = configuration; + } + + public void add(final List items) { + allEntries.addAll(items); + } + + List searchFor(final String keyword) { + if (TextUtils.isEmpty(keyword)) { + return new ArrayList<>(); + } + + return configuration.getSearcher() + .search(allEntries.stream(), keyword) + .collect(Collectors.toList()); + } + + public void clear() { + allEntries.clear(); + } +} diff --git a/app/src/main/java/org/schabi/newpipe/settings/preferencesearch/package-info.java b/app/src/main/java/org/schabi/newpipe/settings/preferencesearch/package-info.java new file mode 100644 index 000000000..00929235e --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/settings/preferencesearch/package-info.java @@ -0,0 +1,10 @@ +/** + * Contains classes for searching inside the preferences. + *
+ * This code is based on + * ByteHamster/SearchPreference + * (MIT license) but was heavily modified/refactored for our use. + * + * @author litetex + */ +package org.schabi.newpipe.settings.preferencesearch; diff --git a/app/src/main/java/org/schabi/newpipe/settings/tabs/ChooseTabsFragment.java b/app/src/main/java/org/schabi/newpipe/settings/tabs/ChooseTabsFragment.java index 95f7f50ba..490e299bd 100644 --- a/app/src/main/java/org/schabi/newpipe/settings/tabs/ChooseTabsFragment.java +++ b/app/src/main/java/org/schabi/newpipe/settings/tabs/ChooseTabsFragment.java @@ -44,8 +44,6 @@ import java.util.List; import static org.schabi.newpipe.settings.tabs.Tab.typeFrom; public class ChooseTabsFragment extends Fragment { - private static final int MENU_ITEM_RESTORE_ID = 123456; - private TabsManager tabsManager; private final List tabList = new ArrayList<>(); @@ -110,21 +108,14 @@ public class ChooseTabsFragment extends Fragment { @NonNull final MenuInflater inflater) { super.onCreateOptionsMenu(menu, inflater); - final MenuItem restoreItem = menu.add(Menu.NONE, MENU_ITEM_RESTORE_ID, Menu.NONE, - R.string.restore_defaults); + final MenuItem restoreItem = menu.add(R.string.restore_defaults); restoreItem.setShowAsAction(MenuItem.SHOW_AS_ACTION_ALWAYS); restoreItem.setIcon(AppCompatResources.getDrawable(requireContext(), R.drawable.ic_settings_backup_restore)); - } - - @Override - public boolean onOptionsItemSelected(final MenuItem item) { - if (item.getItemId() == MENU_ITEM_RESTORE_ID) { + restoreItem.setOnMenuItemClickListener(ev -> { restoreDefaults(); return true; - } - - return super.onOptionsItemSelected(item); + }); } /*////////////////////////////////////////////////////////////////////////// diff --git a/app/src/main/java/org/schabi/newpipe/util/KeyboardUtil.java b/app/src/main/java/org/schabi/newpipe/util/KeyboardUtil.java new file mode 100644 index 000000000..71c0d3944 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/util/KeyboardUtil.java @@ -0,0 +1,43 @@ +package org.schabi.newpipe.util; + +import android.app.Activity; +import android.view.inputmethod.InputMethodManager; +import android.widget.EditText; + +import androidx.core.content.ContextCompat; + +/** + * Utility class for the Android keyboard. + *

+ * See also https://stackoverflow.com/q/1109022 + *

+ */ +public final class KeyboardUtil { + private KeyboardUtil() { + } + + public static void showKeyboard(final Activity activity, final EditText editText) { + if (activity == null || editText == null) { + return; + } + + if (editText.requestFocus()) { + final InputMethodManager imm = ContextCompat.getSystemService(activity, + InputMethodManager.class); + imm.showSoftInput(editText, InputMethodManager.SHOW_FORCED); + } + } + + public static void hideKeyboard(final Activity activity, final EditText editText) { + if (activity == null || editText == null) { + return; + } + + final InputMethodManager imm = ContextCompat.getSystemService(activity, + InputMethodManager.class); + imm.hideSoftInputFromWindow(editText.getWindowToken(), + InputMethodManager.RESULT_UNCHANGED_SHOWN); + + editText.clearFocus(); + } +} diff --git a/app/src/main/res/layout/settings_layout.xml b/app/src/main/res/layout/settings_layout.xml index 33237d7b0..1b7b8b5e2 100644 --- a/app/src/main/res/layout/settings_layout.xml +++ b/app/src/main/res/layout/settings_layout.xml @@ -6,14 +6,14 @@ android:orientation="vertical" tools:context="org.schabi.newpipe.MainActivity"> + + - - diff --git a/app/src/main/res/layout/settings_preferencesearch_fragment.xml b/app/src/main/res/layout/settings_preferencesearch_fragment.xml new file mode 100644 index 000000000..89a25b217 --- /dev/null +++ b/app/src/main/res/layout/settings_preferencesearch_fragment.xml @@ -0,0 +1,49 @@ + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/settings_preferencesearch_list_item_result.xml b/app/src/main/res/layout/settings_preferencesearch_list_item_result.xml new file mode 100644 index 000000000..2e20f274c --- /dev/null +++ b/app/src/main/res/layout/settings_preferencesearch_list_item_result.xml @@ -0,0 +1,36 @@ + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/menu/menu_settings_main_fragment.xml b/app/src/main/res/menu/menu_settings_main_fragment.xml new file mode 100644 index 000000000..fbe3b4e09 --- /dev/null +++ b/app/src/main/res/menu/menu_settings_main_fragment.xml @@ -0,0 +1,11 @@ + + + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index f7ad41ba8..64ed6980b 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -441,6 +441,7 @@ Captions Modify player caption text scale and background styles. Requires app restart to take effect + LeakCanary is not available Memory leak monitoring may cause the app to become unresponsive when heap dumping Show memory leaks Report out-of-lifecycle errors diff --git a/app/src/main/res/xml/content_settings.xml b/app/src/main/res/xml/content_settings.xml index 23b782ffd..e754b3a30 100644 --- a/app/src/main/res/xml/content_settings.xml +++ b/app/src/main/res/xml/content_settings.xml @@ -128,13 +128,6 @@ app:singleLineTitle="false" app:iconSpaceReserved="false" /> - - + + diff --git a/app/src/debug/res/xml/main_settings.xml b/app/src/main/res/xml/main_settings.xml similarity index 100% rename from app/src/debug/res/xml/main_settings.xml rename to app/src/main/res/xml/main_settings.xml diff --git a/app/src/main/res/xml/update_settings.xml b/app/src/main/res/xml/update_settings.xml index ef121ec4e..a44555edf 100644 --- a/app/src/main/res/xml/update_settings.xml +++ b/app/src/main/res/xml/update_settings.xml @@ -1,7 +1,6 @@ - - - - - - - - - - - - - - - -