feat(YouTube): Add Change form factor, Remove Change layout patch

This commit is contained in:
inotia00 2025-03-14 18:37:56 +09:00
parent 1b3ebe1a58
commit 86e5c1b45c
22 changed files with 579 additions and 212 deletions

View File

@ -24,9 +24,6 @@ import app.revanced.extension.shared.utils.Logger;
import app.revanced.extension.shared.utils.StringRef;
import app.revanced.extension.shared.utils.Utils;
/**
* @noinspection rawtypes
*/
@SuppressWarnings("unused")
public abstract class Setting<T> {
@ -128,6 +125,7 @@ public abstract class Setting<T> {
/**
* @return All settings that have been created, sorted by keys.
* @noinspection Java8ListSort
*/
@NonNull
private static List<Setting<?>> allLoadedSettingsSorted() {
@ -171,7 +169,6 @@ public abstract class Setting<T> {
/**
* Confirmation message to display, if the user tries to change the setting from the default value.
* Currently this works only for Boolean setting types.
*/
@Nullable
public final StringRef userDialogMessage;
@ -271,6 +268,7 @@ public abstract class Setting<T> {
* <p>
* This method will be deleted in the future.
*/
@SuppressWarnings("rawtypes")
public static void migrateFromOldPreferences(@NonNull SharedPrefCategory oldPrefs, @NonNull Setting setting, String settingKey) {
if (!oldPrefs.preferences.contains(settingKey)) {
return; // Nothing to do.
@ -452,6 +450,7 @@ public abstract class Setting<T> {
boolean rebootSettingChanged = false;
int numberOfSettingsImported = 0;
//noinspection rawtypes
for (Setting setting : SETTINGS) {
String key = setting.getImportExportKey();
if (json.has(key)) {

View File

@ -12,6 +12,7 @@ import android.preference.EditTextPreference;
import android.preference.ListPreference;
import android.preference.Preference;
import android.preference.PreferenceFragment;
import android.preference.PreferenceGroup;
import android.preference.PreferenceManager;
import android.preference.PreferenceScreen;
import android.preference.SwitchPreference;
@ -21,6 +22,9 @@ import android.widget.ListView;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import java.util.Objects;
import app.revanced.extension.shared.settings.BaseSettings;
import app.revanced.extension.shared.settings.BooleanSetting;
import app.revanced.extension.shared.settings.Setting;
import app.revanced.extension.shared.utils.Logger;
@ -48,10 +52,7 @@ public abstract class AbstractPreferenceFragment extends PreferenceFragment {
private final SharedPreferences.OnSharedPreferenceChangeListener listener = (sharedPreferences, str) -> {
try {
if (str == null) {
return;
}
Setting<?> setting = Setting.getSettingFromPath(str);
Setting<?> setting = Setting.getSettingFromPath(Objects.requireNonNull(str));
if (setting == null) {
return;
}
@ -59,24 +60,23 @@ public abstract class AbstractPreferenceFragment extends PreferenceFragment {
if (pref == null) {
return;
}
Logger.printDebug(() -> "Preference changed: " + setting.key);
if (!settingImportInProgress && !showingUserDialogMessage) {
if (setting.userDialogMessage != null && !prefIsSetToDefault(pref, setting)) {
// Do not change the setting yet, to allow preserving whatever
// list/text value was previously set if it needs to be reverted.
showSettingUserDialogConfirmation(pref, setting);
return;
} else if (setting.rebootApp) {
showRestartDialog(getContext());
}
}
// Apply 'Setting <- Preference', unless during importing when it needs to be 'Setting -> Preference'.
updatePreference(pref, setting, true, settingImportInProgress);
// Update any other preference availability that may now be different.
updateUIAvailability();
if (settingImportInProgress) {
return;
}
if (!showingUserDialogMessage) {
if (setting.userDialogMessage != null && ((SwitchPreference) pref).isChecked() != (Boolean) setting.defaultValue) {
showSettingUserDialogConfirmation((SwitchPreference) pref, (BooleanSetting) setting);
} else if (setting.rebootApp) {
showRestartDialog(getActivity());
}
}
} catch (Exception ex) {
Logger.printException(() -> "OnSharedPreferenceChangeListener failure", ex);
}
@ -90,14 +90,16 @@ public abstract class AbstractPreferenceFragment extends PreferenceFragment {
* so all app specific {@link Setting} instances are loaded before this method returns.
*/
protected void initialize() {
final int id = getXmlIdentifier("revanced_prefs");
final int identifier = getXmlIdentifier("revanced_prefs");
if (identifier == 0) return;
addPreferencesFromResource(identifier);
if (id == 0) return;
addPreferencesFromResource(id);
Utils.sortPreferenceGroups(getPreferenceScreen());
PreferenceScreen screen = getPreferenceScreen();
Utils.sortPreferenceGroups(screen);
Utils.setPreferenceTitlesToMultiLineIfNeeded(screen);
}
private void showSettingUserDialogConfirmation(SwitchPreference switchPref, BooleanSetting setting) {
private void showSettingUserDialogConfirmation(Preference pref, Setting<?> setting) {
Utils.verifyOnMainThread();
final var context = getActivity();
@ -107,12 +109,19 @@ public abstract class AbstractPreferenceFragment extends PreferenceFragment {
.setTitle(android.R.string.dialog_alert_title)
.setMessage(setting.userDialogMessage.toString())
.setPositiveButton(android.R.string.ok, (dialog, id) -> {
// User confirmed, save to the Setting.
updatePreference(pref, setting, true, false);
// Update availability of other preferences that may be changed.
updateUIAvailability();
if (setting.rebootApp) {
showRestartDialog(context);
}
})
.setNegativeButton(android.R.string.cancel, (dialog, id) -> {
switchPref.setChecked(setting.defaultValue); // Recursive call that resets the Setting value.
// Restore whatever the setting was before the change.
updatePreference(pref, setting, true, true);
})
.setOnDismissListener(dialog -> showingUserDialogMessage = false)
.setCancelable(false)
@ -133,24 +142,48 @@ public abstract class AbstractPreferenceFragment extends PreferenceFragment {
updatePreferenceScreen(getPreferenceScreen(), false, false);
}
/**
* @return If the preference is currently set to the default value of the Setting.
*/
protected boolean prefIsSetToDefault(Preference pref, Setting<?> setting) {
if (pref instanceof SwitchPreference switchPref) {
return switchPref.isChecked() == (Boolean) setting.defaultValue;
}
if (pref instanceof EditTextPreference editPreference) {
return editPreference.getText().equals(setting.defaultValue.toString());
}
if (pref instanceof ListPreference listPref) {
return listPref.getValue().equals(setting.defaultValue.toString());
}
throw new IllegalStateException("Must override method to handle "
+ "preference type: " + pref.getClass());
}
/**
* Syncs all UI Preferences to any {@link Setting} they represent.
*/
private void updatePreferenceScreen(@NonNull PreferenceScreen screen,
private void updatePreferenceScreen(@NonNull PreferenceGroup group,
boolean syncSettingValue,
boolean applySettingToPreference) {
// Alternatively this could iterate thru all Settings and check for any matching Preferences,
// but there are many more Settings than UI preferences so it's more efficient to only check
// the Preferences.
for (int i = 0, prefCount = screen.getPreferenceCount(); i < prefCount; i++) {
Preference pref = screen.getPreference(i);
if (pref instanceof PreferenceScreen preferenceScreen) {
updatePreferenceScreen(preferenceScreen, syncSettingValue, applySettingToPreference);
for (int i = 0, prefCount = group.getPreferenceCount(); i < prefCount; i++) {
Preference pref = group.getPreference(i);
if (pref instanceof PreferenceGroup subGroup) {
updatePreferenceScreen(subGroup, syncSettingValue, applySettingToPreference);
} else if (pref.hasKey()) {
String key = pref.getKey();
Setting<?> setting = Setting.getSettingFromPath(key);
if (setting != null) {
updatePreference(pref, setting, syncSettingValue, applySettingToPreference);
} else if (BaseSettings.ENABLE_DEBUG_LOGGING.get() && (pref instanceof SwitchPreference
|| pref instanceof EditTextPreference || pref instanceof ListPreference)) {
// Probably a typo in the patches preference declaration.
Logger.printException(() -> "Preference key has no setting: " + key);
}
}
}
@ -166,26 +199,26 @@ public abstract class AbstractPreferenceFragment extends PreferenceFragment {
protected void syncSettingWithPreference(@NonNull Preference pref,
@NonNull Setting<?> setting,
boolean applySettingToPreference) {
if (pref instanceof SwitchPreference switchPreference) {
if (pref instanceof SwitchPreference switchPref) {
BooleanSetting boolSetting = (BooleanSetting) setting;
if (applySettingToPreference) {
switchPreference.setChecked(boolSetting.get());
switchPref.setChecked(boolSetting.get());
} else {
BooleanSetting.privateSetValue(boolSetting, switchPreference.isChecked());
BooleanSetting.privateSetValue(boolSetting, switchPref.isChecked());
}
} else if (pref instanceof EditTextPreference editTextPreference) {
} else if (pref instanceof EditTextPreference editPreference) {
if (applySettingToPreference) {
editTextPreference.setText(setting.get().toString());
editPreference.setText(setting.get().toString());
} else {
Setting.privateSetValueFromString(setting, editTextPreference.getText());
Setting.privateSetValueFromString(setting, editPreference.getText());
}
} else if (pref instanceof ListPreference listPreference) {
} else if (pref instanceof ListPreference listPref) {
if (applySettingToPreference) {
listPreference.setValue(setting.get().toString());
listPref.setValue(setting.get().toString());
} else {
Setting.privateSetValueFromString(setting, listPreference.getValue());
Setting.privateSetValueFromString(setting, listPref.getValue());
}
updateListPreferenceSummary(listPreference, setting);
updateListPreferenceSummary(listPref, setting);
} else {
Logger.printException(() -> "Setting cannot be handled: " + pref.getClass() + ": " + pref);
}

View File

@ -3,6 +3,8 @@ package app.revanced.extension.shared.utils;
import android.annotation.SuppressLint;
import android.app.Activity;
import android.app.AlertDialog;
import android.app.Dialog;
import android.app.DialogFragment;
import android.app.Fragment;
import android.content.ClipboardManager;
import android.content.Context;
@ -13,6 +15,7 @@ import android.net.ConnectivityManager;
import android.net.NetworkInfo;
import android.net.Uri;
import android.os.Build;
import android.os.Bundle;
import android.os.Handler;
import android.os.Looper;
import android.preference.Preference;
@ -43,11 +46,11 @@ import java.util.concurrent.Future;
import java.util.concurrent.SynchronousQueue;
import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit;
import java.util.regex.Pattern;
import app.revanced.extension.shared.settings.AppLanguage;
import app.revanced.extension.shared.settings.BaseSettings;
import app.revanced.extension.shared.settings.BooleanSetting;
import kotlin.text.Regex;
@SuppressWarnings("deprecation")
public class Utils {
@ -529,6 +532,81 @@ public class Utils {
}
}
/**
* Ignore this class. It must be public to satisfy Android requirements.
*/
public static final class DialogFragmentWrapper extends DialogFragment {
private Dialog dialog;
@Nullable
private DialogFragmentOnStartAction onStartAction;
@Override
public void onSaveInstanceState(Bundle outState) {
// Do not call super method to prevent state saving.
}
@NonNull
@Override
public Dialog onCreateDialog(Bundle savedInstanceState) {
return dialog;
}
@Override
public void onStart() {
try {
super.onStart();
if (onStartAction != null) {
onStartAction.onStart((AlertDialog) getDialog());
}
} catch (Exception ex) {
Logger.printException(() -> "onStart failure: " + dialog.getClass().getSimpleName(), ex);
}
}
}
/**
* Interface for {@link #showDialog(Activity, AlertDialog, boolean, DialogFragmentOnStartAction)}.
*/
@FunctionalInterface
public interface DialogFragmentOnStartAction {
void onStart(AlertDialog dialog);
}
public static void showDialog(Activity activity, AlertDialog dialog) {
showDialog(activity, dialog, true, null);
}
/**
* Utility method to allow showing an AlertDialog on top of other alert dialogs.
* Calling this will always display the dialog on top of all other dialogs
* previously called using this method.
* <br>
* Be aware the on start action can be called multiple times for some situations,
* such as the user switching apps without dismissing the dialog then switching back to this app.
*<br>
* This method is only useful during app startup and multiple patches may show their own dialog,
* and the most important dialog can be called last (using a delay) so it's always on top.
*<br>
* For all other situations it's better to not use this method and
* call {@link AlertDialog#show()} on the dialog.
*/
public static void showDialog(Activity activity,
AlertDialog dialog,
boolean isCancelable,
@Nullable DialogFragmentOnStartAction onStartAction) {
verifyOnMainThread();
DialogFragmentWrapper fragment = new DialogFragmentWrapper();
fragment.dialog = dialog;
fragment.onStartAction = onStartAction;
fragment.setCancelable(isCancelable);
fragment.show(activity.getFragmentManager(), null);
}
/**
* Safe to call from any thread
*/
@ -737,14 +815,14 @@ public class Utils {
}
}
private static final Regex punctuationRegex = new Regex("\\p{P}+");
private static final Pattern punctuationPattern = Pattern.compile("\\p{P}+");
/**
* Strips all punctuation and converts to lower case. A null parameter returns an empty string.
*/
public static String removePunctuationConvertToLowercase(@Nullable CharSequence original) {
if (original == null) return "";
return punctuationRegex.replace(original, "").toLowerCase();
return punctuationPattern.matcher(original).replaceAll("").toLowerCase();
}
/**
@ -763,8 +841,8 @@ public class Utils {
Preference preference = group.getPreference(i);
final Sort preferenceSort;
if (preference instanceof PreferenceGroup preferenceGroup) {
sortPreferenceGroups(preferenceGroup);
if (preference instanceof PreferenceGroup subGroup) {
sortPreferenceGroups(subGroup);
preferenceSort = groupSort; // Sort value for groups is for it's content, not itself.
} else {
// Allow individual preferences to set a key sorting.
@ -774,13 +852,16 @@ public class Utils {
final String sortValue;
switch (preferenceSort) {
case BY_TITLE ->
case BY_TITLE:
sortValue = removePunctuationConvertToLowercase(preference.getTitle());
case BY_KEY -> sortValue = preference.getKey();
case UNSORTED -> {
break;
case BY_KEY:
sortValue = preference.getKey();
break;
case UNSORTED:
continue; // Keep original sorting.
}
default -> throw new IllegalStateException();
default:
throw new IllegalStateException();
}
preferences.put(sortValue, preference);
@ -790,7 +871,7 @@ public class Utils {
for (Preference pref : preferences.values()) {
int order = index++;
// If the preference is a PreferenceScreen or is an intent preference, move to the top.
// Move any screens, intents, and the one off About preference to the top.
if (pref instanceof PreferenceScreen || pref.getIntent() != null) {
// Arbitrary high number.
order -= 1000;
@ -799,4 +880,32 @@ public class Utils {
pref.setOrder(order);
}
}
/**
* Set all preferences to multiline titles if the device is not using an English variant.
* The English strings are heavily scrutinized and all titles fit on screen
* except 2 or 3 preference strings and those do not affect readability.
* <p>
* Allowing multiline for those 2 or 3 English preferences looks weird and out of place,
* and visually it looks better to clip the text and keep all titles 1 line.
*/
public static void setPreferenceTitlesToMultiLineIfNeeded(PreferenceGroup group) {
if (!isSDKAbove(26)) {
return;
}
String revancedLocale = Utils.getContext().getResources().getConfiguration().locale.getLanguage();
if (revancedLocale.equals(Locale.ENGLISH.getLanguage())) {
return;
}
for (int i = 0, prefCount = group.getPreferenceCount(); i < prefCount; i++) {
Preference pref = group.getPreference(i);
pref.setSingleLineTitle(false);
if (pref instanceof PreferenceGroup subGroup) {
setPreferenceTitlesToMultiLineIfNeeded(subGroup);
}
}
}
}

View File

@ -189,14 +189,13 @@ public final class AlternativeThumbnailsPatch {
// Unknown tab, treat as the home tab;
return homeOption;
}
if (selectedNavButton == NavigationButton.HOME) {
return homeOption;
}
if (selectedNavButton == NavigationButton.SUBSCRIPTIONS || selectedNavButton == NavigationButton.NOTIFICATIONS) {
return subscriptionsOption;
}
// A library tab variant is active.
return libraryOption;
return switch (selectedNavButton) {
case SUBSCRIPTIONS, NOTIFICATIONS -> subscriptionsOption;
case LIBRARY -> libraryOption;
// Home or explore tab.
default -> homeOption;
};
}
/**

View File

@ -556,14 +556,13 @@ public final class KeywordContentFilter extends Filter {
if (selectedNavButton == null) {
return hideHome; // Unknown tab, treat the same as home.
}
if (selectedNavButton == NavigationButton.HOME) {
return hideHome;
}
if (selectedNavButton == NavigationButton.SUBSCRIPTIONS) {
return hideSubscriptions;
}
// User is in the Library or Notifications tab.
return false;
return switch (selectedNavButton) {
case HOME, EXPLORE -> hideHome;
case SUBSCRIPTIONS -> hideSubscriptions;
// User is in the Library or notifications.
default -> false;
};
}
private void updateStats(boolean videoWasHidden, @Nullable String keyword) {

View File

@ -0,0 +1,151 @@
package app.revanced.extension.youtube.patches.general;
import static java.lang.Boolean.FALSE;
import static java.lang.Boolean.TRUE;
import static app.revanced.extension.youtube.shared.NavigationBar.NavigationButton;
import android.view.View;
import androidx.annotation.Nullable;
import org.apache.commons.lang3.BooleanUtils;
import java.util.Objects;
import app.revanced.extension.shared.utils.Logger;
import app.revanced.extension.shared.utils.PackageUtils;
import app.revanced.extension.youtube.settings.Settings;
import app.revanced.extension.youtube.shared.PlayerType;
import app.revanced.extension.youtube.shared.RootView;
@SuppressWarnings("unused")
public class ChangeFormFactorPatch {
public enum FormFactor {
/**
* Unmodified, and same as un-patched.
*/
DEFAULT(null, null, null),
/**
* <pre>
* Some changes include:
* - Explore tab is present.
* - watch history is missing.
* - feed thumbnails fade in.
*/
UNKNOWN(0, null, null),
SMALL(1, null, TRUE),
SMALL_WIDTH_DP(1, 480, TRUE),
LARGE(2, null, FALSE),
LARGE_WIDTH_DP(2, 600, FALSE),
/**
* Cars with 'Google built-in'.
* Layout seems identical to {@link #UNKNOWN}
* even when using an Android Automotive device.
*/
AUTOMOTIVE(3, null, null),
WEARABLE(4, null, null);
@Nullable
final Integer formFactorType;
@Nullable
final Integer widthDp;
@Nullable
final Boolean setMinimumDp;
FormFactor(@Nullable Integer formFactorType, @Nullable Integer widthDp, @Nullable Boolean setMinimumDp) {
this.formFactorType = formFactorType;
this.widthDp = widthDp;
this.setMinimumDp = setMinimumDp;
}
private boolean setMinimumDp() {
return BooleanUtils.isTrue(setMinimumDp);
}
}
private static final FormFactor FORM_FACTOR = Settings.CHANGE_FORM_FACTOR.get();
@Nullable
private static final Integer FORM_FACTOR_TYPE = FORM_FACTOR.formFactorType;
private static final boolean USING_AUTOMOTIVE_TYPE = Objects.requireNonNull(
FormFactor.AUTOMOTIVE.formFactorType).equals(FORM_FACTOR_TYPE);
/**
* Injection point.
*/
public static int getFormFactor(int original) {
if (FORM_FACTOR_TYPE == null) return original;
if (USING_AUTOMOTIVE_TYPE) {
// Do not change if the player is opening or is opened,
// otherwise the video description cannot be opened.
PlayerType current = PlayerType.getCurrent();
if (current.isMaximizedOrFullscreen() || current == PlayerType.WATCH_WHILE_SLIDING_MINIMIZED_MAXIMIZED) {
Logger.printDebug(() -> "Using original form factor for player");
return original;
}
if (!RootView.isSearchBarActive()) {
// Automotive type shows error 400 when opening a channel page and using some explore tab.
// This is a bug in unpatched YouTube that occurs on actual Android Automotive devices.
// Work around the issue by using the original form factor if not in search and the
// navigation back button is present.
if (RootView.isBackButtonVisible()) {
Logger.printDebug(() -> "Using original form factor, as back button is visible without search present");
return original;
}
// Do not change library tab otherwise watch history is hidden.
// Do this check last since the current navigation button is required.
if (NavigationButton.getSelectedNavigationButton() == NavigationButton.LIBRARY) {
return original;
}
}
}
return FORM_FACTOR_TYPE;
}
/**
* Injection point.
*/
public static int getWidthDp(int original) {
if (FORM_FACTOR_TYPE == null) return original;
Integer widthDp = FORM_FACTOR.widthDp;
if (widthDp == null) {
return original;
}
final int smallestScreenWidthDp = PackageUtils.getSmallestScreenWidthDp();
if (smallestScreenWidthDp == 0) {
return original;
}
return FORM_FACTOR.setMinimumDp()
? Math.min(smallestScreenWidthDp, widthDp)
: Math.max(smallestScreenWidthDp, widthDp);
}
public static boolean phoneLayoutEnabled() {
return Objects.equals(FORM_FACTOR.formFactorType, 1);
}
public static boolean tabletLayoutEnabled() {
return Objects.equals(FORM_FACTOR.formFactorType, 2);
}
/**
* Injection point.
*/
public static void navigationTabCreated(NavigationButton button, View tabView) {
// On first startup of the app the navigation buttons are fetched and updated.
// If the user immediately opens the 'You' or opens a video, then the call to
// update the navigtation buttons will use the non automotive form factor
// and the explore tab is missing.
// Fixing this is not so simple because of the concurrent calls for the player and You tab.
// For now, always hide the explore tab.
if (USING_AUTOMOTIVE_TYPE && button == NavigationButton.EXPLORE) {
tabView.setVisibility(View.GONE);
}
}
}

View File

@ -1,79 +0,0 @@
package app.revanced.extension.youtube.patches.general;
import static java.lang.Boolean.FALSE;
import static java.lang.Boolean.TRUE;
import androidx.annotation.Nullable;
import org.apache.commons.lang3.BooleanUtils;
import java.util.Objects;
import app.revanced.extension.shared.utils.PackageUtils;
import app.revanced.extension.youtube.settings.Settings;
@SuppressWarnings("unused")
public final class LayoutSwitchPatch {
public enum FormFactor {
/**
* Unmodified type, and same as un-patched.
*/
ORIGINAL(null, null, null),
SMALL_FORM_FACTOR(1, null, TRUE),
SMALL_FORM_FACTOR_WIDTH_DP(1, 480, TRUE),
LARGE_FORM_FACTOR(2, null, FALSE),
LARGE_FORM_FACTOR_WIDTH_DP(2, 600, FALSE);
@Nullable
final Integer formFactorType;
@Nullable
final Integer widthDp;
@Nullable
final Boolean setMinimumDp;
FormFactor(@Nullable Integer formFactorType, @Nullable Integer widthDp, @Nullable Boolean setMinimumDp) {
this.formFactorType = formFactorType;
this.widthDp = widthDp;
this.setMinimumDp = setMinimumDp;
}
private boolean setMinimumDp() {
return BooleanUtils.isTrue(setMinimumDp);
}
}
private static final FormFactor FORM_FACTOR = Settings.CHANGE_LAYOUT.get();
public static int getFormFactor(int original) {
Integer formFactorType = FORM_FACTOR.formFactorType;
return formFactorType == null
? original
: formFactorType;
}
public static int getWidthDp(int original) {
Integer widthDp = FORM_FACTOR.widthDp;
if (widthDp == null) {
return original;
}
final int smallestScreenWidthDp = PackageUtils.getSmallestScreenWidthDp();
if (smallestScreenWidthDp == 0) {
return original;
}
return FORM_FACTOR.setMinimumDp()
? Math.min(smallestScreenWidthDp, widthDp)
: Math.max(smallestScreenWidthDp, widthDp);
}
public static boolean phoneLayoutEnabled() {
return Objects.equals(FORM_FACTOR.formFactorType, 1);
}
public static boolean tabletLayoutEnabled() {
return Objects.equals(FORM_FACTOR.formFactorType, 2);
}
}

View File

@ -32,9 +32,9 @@ import app.revanced.extension.youtube.patches.alternativethumbnails.AlternativeT
import app.revanced.extension.youtube.patches.alternativethumbnails.AlternativeThumbnailsPatch.StillImagesAvailability;
import app.revanced.extension.youtube.patches.alternativethumbnails.AlternativeThumbnailsPatch.ThumbnailOption;
import app.revanced.extension.youtube.patches.alternativethumbnails.AlternativeThumbnailsPatch.ThumbnailStillTime;
import app.revanced.extension.youtube.patches.general.ChangeFormFactorPatch.FormFactor;
import app.revanced.extension.youtube.patches.general.ChangeStartPagePatch;
import app.revanced.extension.youtube.patches.general.ChangeStartPagePatch.StartPage;
import app.revanced.extension.youtube.patches.general.LayoutSwitchPatch.FormFactor;
import app.revanced.extension.youtube.patches.general.YouTubeMusicActionsPatch;
import app.revanced.extension.youtube.patches.player.ExitFullscreenPatch.FullscreenMode;
import app.revanced.extension.youtube.patches.player.MiniplayerPatch;
@ -154,7 +154,7 @@ public class Settings extends BaseSettings {
public static final BooleanSetting HIDE_GRAY_SEPARATOR = new BooleanSetting("revanced_hide_gray_separator", TRUE);
public static final BooleanSetting REMOVE_VIEWER_DISCRETION_DIALOG = new BooleanSetting("revanced_remove_viewer_discretion_dialog", FALSE);
public static final EnumSetting<FormFactor> CHANGE_LAYOUT = new EnumSetting<>("revanced_change_layout", FormFactor.ORIGINAL, true);
public static final EnumSetting<FormFactor> CHANGE_FORM_FACTOR = new EnumSetting<>("revanced_change_form_factor", FormFactor.DEFAULT, true, "revanced_change_form_factor_user_dialog_message");
public static final BooleanSetting CHANGE_LIVE_RING_CLICK_ACTION = new BooleanSetting("revanced_change_live_ring_click_action", FALSE, true);
public static final BooleanSetting SPOOF_APP_VERSION = new BooleanSetting("revanced_spoof_app_version", false, true, "revanced_spoof_app_version_user_dialog_message");
public static final StringSetting SPOOF_APP_VERSION_TARGET = new StringSetting("revanced_spoof_app_version_target", PatchStatus.SpoofAppVersionDefaultString(), true, parent(SPOOF_APP_VERSION));

View File

@ -75,7 +75,7 @@ public class ReVancedPreferenceFragment extends PreferenceFragment {
private final SharedPreferences.OnSharedPreferenceChangeListener listener = (sharedPreferences, str) -> {
try {
if (str == null) return;
Setting<?> setting = Setting.getSettingFromPath(str);
Setting<?> setting = Setting.getSettingFromPath(Objects.requireNonNull(str));
if (setting == null) return;

View File

@ -8,7 +8,7 @@ import android.preference.Preference;
import android.preference.SwitchPreference;
import app.revanced.extension.shared.settings.Setting;
import app.revanced.extension.youtube.patches.general.LayoutSwitchPatch;
import app.revanced.extension.youtube.patches.general.ChangeFormFactorPatch;
import app.revanced.extension.youtube.patches.utils.PatchStatus;
import app.revanced.extension.youtube.patches.utils.ReturnYouTubeDislikePatch;
import app.revanced.extension.youtube.returnyoutubedislike.ReturnYouTubeDislike;
@ -82,7 +82,7 @@ public class ReVancedSettingsPreference extends ReVancedPreferenceFragment {
*/
private static void TabletLayoutLinks() {
final boolean isTablet = ExtendedUtils.isTablet() &&
!LayoutSwitchPatch.phoneLayoutEnabled();
!ChangeFormFactorPatch.phoneLayoutEnabled();
enableDisablePreferences(
isTablet,

View File

@ -223,6 +223,10 @@ public final class NavigationBar {
* This tab will never be in a selected state, even if the create video UI is on screen.
*/
CREATE("CREATION_TAB_LARGE", "CREATION_TAB_LARGE_CAIRO"),
/**
* Only shown to automotive layout.
*/
EXPLORE("TAB_EXPLORE"),
SUBSCRIPTIONS("PIVOT_SUBSCRIPTIONS", "TAB_SUBSCRIPTIONS_CAIRO"),
/**
* Notifications tab. Only present when

View File

@ -2,9 +2,48 @@ package app.revanced.extension.youtube.shared;
import static app.revanced.extension.youtube.patches.components.RelatedVideoFilter.isActionBarVisible;
import android.graphics.drawable.Drawable;
import android.widget.FrameLayout;
import java.lang.ref.WeakReference;
import app.revanced.extension.shared.utils.Logger;
import app.revanced.extension.shared.utils.Utils;
@SuppressWarnings("unused")
public final class RootView {
/**
* Interface to call obfuscated methods in AppCompat Toolbar class.
*/
public interface AppCompatToolbarPatchInterface {
Drawable patch_getToolbarIcon();
}
private static volatile WeakReference<AppCompatToolbarPatchInterface> toolbarResultsRef
= new WeakReference<>(null);
/**
* Injection point.
*/
public static void setToolbar(FrameLayout layout) {
AppCompatToolbarPatchInterface toolbar = Utils.getChildView(layout, false, (view) ->
view instanceof AppCompatToolbarPatchInterface
);
if (toolbar == null) {
Logger.printException(() -> "Could not find toolbar");
return;
}
toolbarResultsRef = new WeakReference<>(toolbar);
}
public static boolean isBackButtonVisible() {
AppCompatToolbarPatchInterface toolbar = toolbarResultsRef.get();
return toolbar != null && toolbar.patch_getToolbarIcon() != null;
}
/**
* @return If the search bar is on screen. This includes if the player
* is on screen and the search results are behind the player (and not visible).

View File

@ -1,4 +1,4 @@
package app.revanced.patches.youtube.general.layoutswitch
package app.revanced.patches.youtube.general.formfactor
import app.revanced.patcher.extensions.InstructionExtensions.addInstructions
import app.revanced.patcher.extensions.InstructionExtensions.getInstruction
@ -6,30 +6,37 @@ import app.revanced.patcher.patch.bytecodePatch
import app.revanced.patches.shared.createPlayerRequestBodyWithModelFingerprint
import app.revanced.patches.youtube.utils.compatibility.Constants.COMPATIBLE_PACKAGE
import app.revanced.patches.youtube.utils.extension.Constants.GENERAL_PATH
import app.revanced.patches.youtube.utils.patch.PatchList.CHANGE_LAYOUT
import app.revanced.patches.youtube.utils.navigation.hookNavigationButtonCreated
import app.revanced.patches.youtube.utils.navigation.navigationBarHookPatch
import app.revanced.patches.youtube.utils.patch.PatchList.CHANGE_FORM_FACTOR
import app.revanced.patches.youtube.utils.playertype.playerTypeHookPatch
import app.revanced.patches.youtube.utils.settings.ResourceUtils.addPreference
import app.revanced.patches.youtube.utils.settings.settingsPatch
import app.revanced.util.fingerprint.definingClassOrThrow
import app.revanced.util.fingerprint.matchOrThrow
import app.revanced.util.fingerprint.methodOrThrow
import app.revanced.util.getReference
import app.revanced.util.indexOfFirstInstructionOrThrow
import app.revanced.util.indexOfFirstInstructionReversedOrThrow
import com.android.tools.smali.dexlib2.Opcode
import com.android.tools.smali.dexlib2.iface.instruction.OneRegisterInstruction
import com.android.tools.smali.dexlib2.iface.instruction.TwoRegisterInstruction
import com.android.tools.smali.dexlib2.iface.reference.FieldReference
private const val EXTENSION_CLASS_DESCRIPTOR =
"$GENERAL_PATH/LayoutSwitchPatch;"
"$GENERAL_PATH/ChangeFormFactorPatch;"
@Suppress("unused")
val layoutSwitchPatch = bytecodePatch(
CHANGE_LAYOUT.title,
CHANGE_LAYOUT.summary,
val changeFormFactorPatch = bytecodePatch(
CHANGE_FORM_FACTOR.title,
CHANGE_FORM_FACTOR.summary,
) {
compatibleWith(COMPATIBLE_PACKAGE)
dependsOn(settingsPatch)
dependsOn(
settingsPatch,
playerTypeHookPatch,
navigationBarHookPatch,
)
execute {
@ -53,8 +60,9 @@ val layoutSwitchPatch = bytecodePatch(
)
}
layoutSwitchFingerprint.methodOrThrow().apply {
val index = indexOfFirstInstructionReversedOrThrow(Opcode.IF_NEZ)
widthDpUIFingerprint.matchOrThrow().let {
it.method.apply {
val index = it.patternMatch!!.startIndex
val register = getInstruction<OneRegisterInstruction>(index).registerA
addInstructions(
@ -64,6 +72,9 @@ val layoutSwitchPatch = bytecodePatch(
"""
)
}
}
hookNavigationButtonCreated(EXTENSION_CLASS_DESCRIPTOR)
// region add settings
@ -71,9 +82,9 @@ val layoutSwitchPatch = bytecodePatch(
arrayOf(
"PREFERENCE_SCREEN: GENERAL",
"PREFERENCE_CATEGORY: GENERAL_EXPERIMENTAL_FLAGS",
"SETTINGS: CHANGE_LAYOUT"
"SETTINGS: CHANGE_FORM_FACTOR"
),
CHANGE_LAYOUT
CHANGE_FORM_FACTOR
)
// endregion

View File

@ -1,4 +1,4 @@
package app.revanced.patches.youtube.general.layoutswitch
package app.revanced.patches.youtube.general.formfactor
import app.revanced.util.fingerprint.legacyFingerprint
import app.revanced.util.or
@ -11,20 +11,16 @@ internal val formFactorEnumConstructorFingerprint = legacyFingerprint(
strings = listOf(
"UNKNOWN_FORM_FACTOR",
"SMALL_FORM_FACTOR",
"LARGE_FORM_FACTOR"
"LARGE_FORM_FACTOR",
"AUTOMOTIVE_FORM_FACTOR",
)
)
internal val layoutSwitchFingerprint = legacyFingerprint(
name = "layoutSwitchFingerprint",
internal val widthDpUIFingerprint = legacyFingerprint(
name = "widthDpUIFingerprint",
returnType = "I",
accessFlags = AccessFlags.PUBLIC or AccessFlags.STATIC,
parameters = listOf("L"),
opcodes = listOf(
Opcode.INVOKE_VIRTUAL,
Opcode.MOVE_RESULT_OBJECT,
Opcode.INVOKE_STATIC,
Opcode.MOVE_RESULT,
Opcode.IF_NEZ,
Opcode.CONST_4,
Opcode.RETURN,
@ -41,6 +37,11 @@ internal val layoutSwitchFingerprint = legacyFingerprint(
Opcode.CONST_4,
Opcode.RETURN,
Opcode.CONST_4,
Opcode.RETURN
Opcode.RETURN,
),
literals = listOf(
480L,
600L,
720L
)
)

View File

@ -109,8 +109,7 @@ val navigationBarHookPatch = bytecodePatch(
hookNavigationButtonCreated = { extensionClassDescriptor ->
navigationBarHookCallbackFingerprint.methodOrThrow().addInstruction(
0,
"invoke-static { p0, p1 }, " +
"$extensionClassDescriptor->navigationTabCreated" +
"invoke-static { p0, p1 }, $extensionClassDescriptor->navigationTabCreated" +
"(${EXTENSION_NAVIGATION_BUTTON_DESCRIPTOR}Landroid/view/View;)V",
)
}

View File

@ -21,9 +21,9 @@ internal enum class PatchList(
"Bypass URL redirects",
"Adds an option to bypass URL redirects and open the original URL directly."
),
CHANGE_LAYOUT(
"Change layout",
"Adds an option to change the dp in order to use a tablet or phone layout."
CHANGE_FORM_FACTOR(
"Change form factor",
"Adds an option to change the UI appearance to a phone, tablet, or automotive device."
),
CHANGE_LIVE_RING_CLICK_ACTION(
"Change live ring click action",

View File

@ -1,6 +1,7 @@
package app.revanced.patches.youtube.utils.playertype
import app.revanced.patches.youtube.utils.resourceid.reelWatchPlayer
import app.revanced.patches.youtube.utils.resourceid.toolbarContainerId
import app.revanced.util.fingerprint.legacyFingerprint
import app.revanced.util.getReference
import app.revanced.util.indexOfFirstInstruction
@ -9,6 +10,7 @@ import com.android.tools.smali.dexlib2.AccessFlags
import com.android.tools.smali.dexlib2.Opcode
import com.android.tools.smali.dexlib2.iface.Method
import com.android.tools.smali.dexlib2.iface.reference.MethodReference
import com.android.tools.smali.dexlib2.iface.reference.TypeReference
internal val browseIdClassFingerprint = legacyFingerprint(
name = "browseIdClassFingerprint",
@ -61,6 +63,34 @@ internal val searchQueryClassFingerprint = legacyFingerprint(
}
)
internal val toolbarLayoutFingerprint = legacyFingerprint(
name = "toolbarLayoutFingerprint",
literals = listOf(toolbarContainerId),
customFingerprint = { method, _ ->
method.name == "<init>" &&
indexOfMainCollapsingToolbarLayoutInstruction(method) >= 0
}
)
internal fun indexOfMainCollapsingToolbarLayoutInstruction(method: Method) =
method.indexOfFirstInstruction {
opcode == Opcode.CHECK_CAST &&
getReference<TypeReference>()?.type == "Lcom/google/android/apps/youtube/app/ui/actionbar/MainCollapsingToolbarLayout;"
}
/**
* Matches to https://android.googlesource.com/platform/frameworks/support/+/9eee6ba/v7/appcompat/src/android/support/v7/widget/Toolbar.java#963
*/
internal val appCompatToolbarBackButtonFingerprint = legacyFingerprint(
name = "appCompatToolbarBackButtonFingerprint",
accessFlags = AccessFlags.PUBLIC or AccessFlags.FINAL,
returnType = "Landroid/graphics/drawable/Drawable;",
parameters = emptyList(),
customFingerprint = { _, classDef ->
classDef.type == "Landroid/support/v7/widget/Toolbar;"
},
)
internal val videoStateFingerprint = legacyFingerprint(
name = "videoStateFingerprint",
returnType = "V",

View File

@ -5,6 +5,7 @@ import app.revanced.patcher.extensions.InstructionExtensions.addInstructions
import app.revanced.patcher.extensions.InstructionExtensions.getInstruction
import app.revanced.patcher.patch.PatchException
import app.revanced.patcher.patch.bytecodePatch
import app.revanced.patcher.util.proxy.mutableTypes.MutableMethod.Companion.toMutable
import app.revanced.patches.shared.litho.addLithoFilter
import app.revanced.patches.shared.litho.lithoFilterPatch
import app.revanced.patches.youtube.utils.extension.Constants.COMPONENTS_PATH
@ -21,10 +22,13 @@ import app.revanced.util.getReference
import app.revanced.util.indexOfFirstInstructionOrThrow
import app.revanced.util.indexOfFirstLiteralInstructionOrThrow
import app.revanced.util.indexOfFirstStringInstructionOrThrow
import com.android.tools.smali.dexlib2.AccessFlags
import com.android.tools.smali.dexlib2.Opcode
import com.android.tools.smali.dexlib2.builder.MutableMethodImplementation
import com.android.tools.smali.dexlib2.iface.instruction.OneRegisterInstruction
import com.android.tools.smali.dexlib2.iface.instruction.ReferenceInstruction
import com.android.tools.smali.dexlib2.iface.reference.FieldReference
import com.android.tools.smali.dexlib2.immutable.ImmutableMethod
private const val EXTENSION_PLAYER_TYPE_HOOK_CLASS_DESCRIPTOR =
"$UTILS_PATH/PlayerTypeHookPatch;"
@ -32,6 +36,9 @@ private const val EXTENSION_PLAYER_TYPE_HOOK_CLASS_DESCRIPTOR =
private const val EXTENSION_ROOT_VIEW_HOOK_CLASS_DESCRIPTOR =
"$SHARED_PATH/RootView;"
private const val EXTENSION_ROOT_VIEW_TOOLBAR_INTERFACE =
"$SHARED_PATH/RootView${'$'}AppCompatToolbarPatchInterface;"
private const val FILTER_CLASS_DESCRIPTOR =
"$COMPONENTS_PATH/RelatedVideoFilter;"
@ -165,6 +172,53 @@ val playerTypeHookPatch = bytecodePatch(
// endregion
// region patch for hook back button visibility
toolbarLayoutFingerprint.methodOrThrow().apply {
val index = indexOfMainCollapsingToolbarLayoutInstruction(this)
val register = getInstruction<OneRegisterInstruction>(index).registerA
addInstruction(
index + 1,
"invoke-static { v$register }, $EXTENSION_ROOT_VIEW_HOOK_CLASS_DESCRIPTOR->setToolbar(Landroid/widget/FrameLayout;)V"
)
}
// Add interface for extensions code to call obfuscated methods.
appCompatToolbarBackButtonFingerprint.matchOrThrow().let {
it.classDef.apply {
interfaces.add(EXTENSION_ROOT_VIEW_TOOLBAR_INTERFACE)
val definingClass = type
val obfuscatedMethodName = it.originalMethod.name
val returnType = "Landroid/graphics/drawable/Drawable;"
methods.add(
ImmutableMethod(
definingClass,
"patch_getToolbarIcon",
listOf(),
returnType,
AccessFlags.PUBLIC.value or AccessFlags.FINAL.value,
null,
null,
MutableMethodImplementation(2),
).toMutable().apply {
addInstructions(
0,
"""
invoke-virtual { p0 }, $definingClass->$obfuscatedMethodName()$returnType
move-result-object v0
return-object v0
"""
)
}
)
}
}
// endregion
addLithoFilter(FILTER_CLASS_DESCRIPTOR)
}
}

View File

@ -213,6 +213,8 @@ var tapBloomView = -1L
private set
var titleAnchor = -1L
private set
var toolbarContainerId = -1L
private set
var toolTipContentView = -1L
private set
var totalTime = -1L
@ -656,6 +658,10 @@ internal val sharedResourceIdPatch = resourcePatch(
ID,
"title_anchor"
]
toolbarContainerId = resourceMappings[
ID,
"toolbar_container"
]
toolTipContentView = resourceMappings[
LAYOUT,
"tooltip_content_view"

View File

@ -22,19 +22,21 @@
<item>MIDDLE</item>
<item>END</item>
</string-array>
<string-array name="revanced_change_layout_entries">
<item>@string/revanced_change_layout_entry_1</item>
<item>@string/revanced_change_layout_entry_2</item>
<item>@string/revanced_change_layout_entry_3</item>
<item>@string/revanced_change_layout_entry_4</item>
<item>@string/revanced_change_layout_entry_5</item>
<string-array name="revanced_change_form_factor_entries">
<item>@string/revanced_change_form_factor_entry_1</item>
<item>@string/revanced_change_form_factor_entry_2</item>
<item>@string/revanced_change_form_factor_entry_3</item>
<item>@string/revanced_change_form_factor_entry_4</item>
<item>@string/revanced_change_form_factor_entry_5</item>
<item>@string/revanced_change_form_factor_entry_6</item>
</string-array>
<string-array name="revanced_change_layout_entry_values">
<item>ORIGINAL</item>
<item>SMALL_FORM_FACTOR</item>
<item>SMALL_FORM_FACTOR_WIDTH_DP</item>
<item>LARGE_FORM_FACTOR</item>
<item>LARGE_FORM_FACTOR_WIDTH_DP</item>
<string-array name="revanced_change_form_factor_entry_values">
<item>DEFAULT</item>
<item>SMALL</item>
<item>SMALL_WIDTH_DP</item>
<item>LARGE</item>
<item>LARGE_WIDTH_DP</item>
<item>AUTOMOTIVE</item>
</string-array>
<string-array name="revanced_change_start_page_entries">
<item>@string/revanced_change_start_page_entry_default</item>

View File

@ -439,17 +439,27 @@ Limitation: Back button on the toolbar may not work."</string>
<string name="revanced_remove_viewer_discretion_dialog_summary">"Removes the viewer discretion dialog.
This does not bypass the age restriction. It just accepts it automatically."</string>
<string name="revanced_change_layout_title">Change layout</string>
<string name="revanced_change_layout_entry_1">Original</string>
<string name="revanced_change_layout_entry_2">Phone</string>
<string name="revanced_change_layout_entry_3">Phone (Max 480 dp)</string>
<string name="revanced_change_layout_entry_4">Tablet</string>
<string name="revanced_change_layout_entry_5">Tablet (Min 600 dp)</string>
<string name="revanced_change_live_ring_click_action_title">Change live ring click action</string>
<string name="revanced_change_live_ring_click_action_summary_on">"Channel opens when the live ring is clicked.
Limitation: When the Shorts live stream is opened in regular player due to the 'Open Shorts in regular player' setting, channel does not open."</string>
<string name="revanced_change_live_ring_click_action_summary_off">Live stream opens when the live ring is clicked.</string>
<string name="revanced_change_form_factor_title">Layout form factor</string>
<string name="revanced_change_form_factor_entry_1">Default</string>
<string name="revanced_change_form_factor_entry_2">Phone</string>
<string name="revanced_change_form_factor_entry_3">Phone (Max 480 dp)</string>
<string name="revanced_change_form_factor_entry_4">Tablet</string>
<string name="revanced_change_form_factor_entry_5">Tablet (Min 600 dp)</string>
<string name="revanced_change_form_factor_entry_6">Automotive</string>
<string name="revanced_change_form_factor_user_dialog_message">"Changes include:
Tablet layout
• Community posts are hidden.
Automotive layout
• Shorts open in the regular player.
• Feed is organized by topics and channels.
• Video description cannot be opened when 'Spoof streaming data' is turned off."</string>
<string name="revanced_spoof_app_version_title">Spoof app version</string>
<string name="revanced_spoof_app_version_summary_on">Version spoofed</string>
<string name="revanced_spoof_app_version_summary_off">Version not spoofed</string>

View File

@ -269,12 +269,12 @@
<!-- PREFERENCE_CATEGORY: GENERAL_EXPERIMENTAL_FLAGS
<PreferenceCategory android:title="@string/revanced_preference_category_experimental_flag" android:layout="@layout/revanced_settings_preferences_category"/>PREFERENCE_CATEGORY: GENERAL_EXPERIMENTAL_FLAGS -->
<!-- SETTINGS: CHANGE_LAYOUT
<ListPreference android:entries="@array/revanced_change_layout_entries" android:title="@string/revanced_change_layout_title" android:key="revanced_change_layout" android:entryValues="@array/revanced_change_layout_entry_values" />SETTINGS: CHANGE_LAYOUT -->
<!-- SETTINGS: CHANGE_LIVE_RING_CLICK_ACTION
<SwitchPreference android:title="@string/revanced_change_live_ring_click_action_title" android:key="revanced_change_live_ring_click_action" android:summaryOn="@string/revanced_change_live_ring_click_action_summary_on" android:summaryOff="@string/revanced_change_live_ring_click_action_summary_off" />SETTINGS: CHANGE_LIVE_RING_CLICK_ACTION -->
<!-- SETTINGS: CHANGE_FORM_FACTOR
<ListPreference android:entries="@array/revanced_change_form_factor_entries" android:title="@string/revanced_change_form_factor_title" android:key="revanced_change_form_factor" android:entryValues="@array/revanced_change_form_factor_entry_values" />SETTINGS: CHANGE_FORM_FACTOR -->
<!-- SETTINGS: SPOOF_APP_VERSION
<SwitchPreference android:title="@string/revanced_spoof_app_version_title" android:key="revanced_spoof_app_version" android:summaryOn="@string/revanced_spoof_app_version_summary_on" android:summaryOff="@string/revanced_spoof_app_version_summary_off" />
<app.revanced.extension.shared.settings.preference.WideListPreference android:title="@string/revanced_spoof_app_version_target_entry_title" android:key="revanced_spoof_app_version_target" android:entries="@array/revanced_spoof_app_version_target_entries" android:entryValues="@array/revanced_spoof_app_version_target_entry_values" />
@ -910,7 +910,7 @@
</PreferenceCategory>
<PreferenceCategory android:title="@string/revanced_preference_screen_general_title" android:layout="@layout/revanced_settings_preferences_category">
<Preference android:title="Change layout" android:summary="@string/revanced_patches_excluded" android:selectable="false"/>
<Preference android:title="Change form factor" android:summary="@string/revanced_patches_excluded" android:selectable="false"/>
<Preference android:title="Change live ring click action" android:summary="@string/revanced_patches_excluded" android:selectable="false"/>
<Preference android:title="Change start page" android:summary="@string/revanced_patches_excluded" android:selectable="false"/>
<Preference android:title="Disable forced auto audio tracks" android:summary="@string/revanced_patches_excluded" android:selectable="false"/>