Compare commits

...
Sign in to create a new pull request.

42 commits

Author SHA1 Message Date
oSumAtrIX
14a16efa0f
feat(YouTube): Add support for 20.45.36
Co-authored-by: LisoUseInAIKyrios <118716522+lisouseinaikyrios@users.noreply.github.com>
2026-03-21 20:00:24 +01:00
oSumAtrIX
467a62f4ac
fix(YouTube - Playback speed): Fix playback speed menu opening from the feed flyout menu when Restore old playback speed menu is off
Co-authored-by: inotia00 <108592928+inotia00@users.noreply.github.com>
2026-03-21 19:58:24 +01:00
oSumAtrIX
0371f7164f
fix(Settings): Prevent duplicate dialogs on rapid preference clicks
Co-authored-by: ILoveOpenSourceApplications <117499019+iloveopensourceapplications@users.noreply.github.com>
2026-03-21 19:56:43 +01:00
oSumAtrIX
1ebd990051
feat: Add import from & export settings to a file
Co-authored-by: ILoveOpenSourceApplications <117499019+iloveopensourceapplications@users.noreply.github.com>
2026-03-21 19:52:43 +01:00
oSumAtrIX
f063cf69bd
fix(YouTube): Advanced video quality menu does not work
Co-authored-by: LisoUseInAIKyrios <118716522+lisouseinaikyrios@users.noreply.github.com>
2026-03-21 19:48:30 +01:00
oSumAtrIX
09bce22aeb
fix(YouTube - Hide Shorts components): Shorts shelves are sometimes not hidden
Co-authored-by: inotia00 <108592928+inotia00@users.noreply.github.com>
2026-03-21 19:46:20 +01:00
oSumAtrIX
e52114076e
fix(YouTube - Playback speed): Old playback speed menu does not show with experimental app targets
Co-authored-by: LisoUseInAIKyrios <118716522+lisouseinaikyrios@users.noreply.github.com>
2026-03-21 19:40:38 +01:00
oSumAtrIX
58898b92fe
fix(YouTube - Disable Shorts resuming on startup): Resolve patch not working on experimental versions
Co-authored-by: ILoveOpenSourceApplications <117499019+iloveopensourceapplications@users.noreply.github.com>
2026-03-21 19:37:50 +01:00
oSumAtrIX
53318c48ee
fix(YouTube - Video quality): Initial video quality is not overridden
Co-authored-by: inotia00 <108592928+inotia00@users.noreply.github.com>
2026-03-19 20:44:39 +01:00
oSumAtrIX
009cf71462
fix(YouTube - Hide layout components): Resolve "Hide community posts" not working when selecting a channel from subscribed channels bar in Subscriptions tab
Co-authored-by: ILoveOpenSourceApplications <117499019+iloveopensourceapplications@users.noreply.github.com>
2026-03-19 20:44:37 +01:00
oSumAtrIX
136cecc290
fix(YouTube - Hide Shorts components): Resolve "Hide 'Use this sound' button" and "Hide 'Use this template' button" breaking Shorts player
Co-authored-by: ILoveOpenSourceApplications <117499019+iloveopensourceapplications@users.noreply.github.com>
2026-03-19 20:44:35 +01:00
oSumAtrIX
7f1874ebe0
fix(YouTube): Do not show fullscreen black gradient with 21.03+ experimental app targets
Co-authored-by: LisoUseInAIKyrios <118716522+lisouseinaikyrios@users.noreply.github.com>
2026-03-19 20:44:34 +01:00
oSumAtrIX
733f3bb2cd
fix(YouTube - Change form factor): Explore button sometimes shows in Automotive layout
Co-authored-by: inotia00 <108592928+inotia00@users.noreply.github.com>
2026-03-19 20:44:33 +01:00
oSumAtrIX
9fa641ed81
fix(Sanitize sharing links): Sanitize new is sharing parameter
Co-authored-by: LisoUseInAIKyrios <118716522+lisouseinaikyrios@users.noreply.github.com>
2026-03-19 20:44:32 +01:00
oSumAtrIX
7b8a3061a2
feat(YouTube): Add experimental support for 21.11.480
Co-authored-by: LisoUseInAIKyrios <118716522+lisouseinaikyrios@users.noreply.github.com>
2026-03-19 20:44:31 +01:00
oSumAtrIX
3514039e8a
feat(Theme): Use dynamic system accents for Material You colors
Co-authored-by: ILoveOpenSourceApplications <117499019+iloveopensourceapplications@users.noreply.github.com>
2026-03-19 20:44:30 +01:00
oSumAtrIX
14545c874d
fix(YouTube - Hide Shorts components): Resolve "Hide 'Use this sound' button" and "Hide 'Use this template' button" not working
Co-authored-by: ILoveOpenSourceApplications <117499019+iloveopensourceapplications@users.noreply.github.com>
2026-03-19 20:44:29 +01:00
oSumAtrIX
b71e4f95ff
feat(YouTube - Playback speed): Allows disabling tap and hold speed
Co-authored-by: inotia00 <108592928+inotia00@users.noreply.github.com>
2026-03-19 20:44:27 +01:00
oSumAtrIX
5c7fc97059
chore(YouTube): Update force original audio user dialog string
Co-authored-by: BlackGold8282 <blackgold8282@outlook.com>
2026-03-19 20:44:27 +01:00
oSumAtrIX
9b66637a58
fix(YouTube - Shorts autoplay): Shorts do not autoplay when using older app targets
Co-authored-by: LisoUseInAIKyrios <118716522+lisouseinaikyrios@users.noreply.github.com>
2026-03-19 20:44:26 +01:00
oSumAtrIX
cc019ec78b
Merge remote-tracking branch 'origin/dev' into feat/update-youtube-patches
# Conflicts:
#	extensions/shared/library/src/main/java/app/revanced/extension/shared/settings/YouTubeAndMusicSettings.java
2026-03-19 20:44:17 +01:00
semantic-release-bot
b1ae92cddd chore: Release v6.1.1-dev.1 [skip ci]
## [6.1.1-dev.1](https://github.com/ReVanced/revanced-patches/compare/v6.1.0...v6.1.1-dev.1) (2026-03-19)

### Bug Fixes

* **YouTube - Spoof video streams:** Set `ANDROID_REEL` client as default ([#6878](https://github.com/ReVanced/revanced-patches/issues/6878)) ([a9aeb32](a9aeb325de))
2026-03-19 19:26:26 +00:00
Sayanth
a9aeb325de
fix(YouTube - Spoof video streams): Set ANDROID_REEL client as default (#6878) 2026-03-19 20:23:38 +01:00
drobotk
b435564fd4
wip: music 2026-03-13 13:05:04 +01:00
drobotk
7fe7ce437c
fix build and crashes 2026-03-13 12:03:29 +01:00
oSumAtrIX
15096257ca
fix(YouTube - Hide Shorts components): Hide Shorts shelf hides autoplaying videos in the feed
Co-Authored-By: inotia00 <108592928+inotia00@users.noreply.github.com>
2026-03-11 11:24:05 +01:00
oSumAtrIX
23676746a6
fix(YouTube - Hide layout components): Replace "Hide AI comments summary" with "Sanitize category bar"
Co-Authored-By: inotia00 <108592928+inotia00@users.noreply.github.com>
2026-03-11 11:19:47 +01:00
oSumAtrIX
e7196e54b0
feat(YouTube Music): Add Forcibly enable miniplayer patch
Co-Authored-By: ILoveOpenSourceApplications <117499019+iloveopensourceapplications@users.noreply.github.com>
2026-03-11 11:02:56 +01:00
oSumAtrIX
7f52ec2cea
feat(YouTube): Add experimental support for 21.20.493
Co-Authored-By: LisoUseInAIKyrios <118716522+lisouseinaikyrios@users.noreply.github.com>
2026-03-11 10:44:18 +01:00
oSumAtrIX
20079d267a
fix(YouTube - Hide player flyout menu items): Do not hide entire flyout menu for eperimental app targets
Co-Authored-By: LisoUseInAIKyrios <118716522+lisouseinaikyrios@users.noreply.github.com>
2026-03-11 02:32:47 +01:00
oSumAtrIX
af95a58009
feat(YouTube): Add support for 20.44.38 2026-03-11 02:26:25 +01:00
oSumAtrIX
636698c96e
feat(YouTube Music): Add support for 8.44.54 2026-03-11 02:25:27 +01:00
oSumAtrIX
81b24642be
refactor: Rename option 2026-03-11 02:10:08 +01:00
oSumAtrIX
7495528815
fix: Use encoded native byte array buffer to filter Litho components 2026-03-11 02:00:45 +01:00
oSumAtrIX
5aec6cd3b7
feat(YouTube Music): Add experimental support for 9.09.52
Co-authored-by: LisoUseInAIKyrios <118716522+lisouseinaikyrios@users.noreply.github.com>

Co-authored-by: ILoveOpenSourceApplications <117499019+iloveopensourceapplications@users.noreply.github.com>
2026-03-08 22:47:53 +01:00
oSumAtrIX
6d8c94d0d2
fix(YouTube - Hide layout components): Resolve "Hide Explore the podcast" not working
Co-authored-by: ILoveOpenSourceApplications <117499019+iloveopensourceapplications@users.noreply.github.com>
2026-03-08 22:40:58 +01:00
oSumAtrIX
8c2445f92f
feat(YouTube): Add Override 'Open in YouTube Music' button patch
Co-authored-by: ILoveOpenSourceApplications <117499019+iloveopensourceapplications@users.noreply.github.com>
2026-03-08 22:39:22 +01:00
oSumAtrIX
bc08ecf785
feat(YouTube Music): Add Change start page patch
Co-authored-by: ILoveOpenSourceApplications <117499019+iloveopensourceapplications@users.noreply.github.com>
2026-03-08 21:45:31 +01:00
oSumAtrIX
f22ea5507d
feat(YouTube Music): Add Change header patch
Co-authored-by: ILoveOpenSourceApplications <117499019+iloveopensourceapplications@users.noreply.github.com>
2026-03-08 02:07:34 +01:00
oSumAtrIX
f10f5e2910
refactor(YouTube - Add more double tap to seek length options): Use more idiomatic code 2026-03-07 23:39:50 +01:00
oSumAtrIX
3c46a2d2e8
fix(YouTube - Hide Shorts components): Resolve Shorts header not being hidden
Co-authored-by: ILoveOpenSourceApplications <117499019+iloveopensourceapplications@users.noreply.github.com>
2026-03-07 23:36:05 +01:00
oSumAtrIX
b826db02e3
refactor(YouTube - Hide end screen suggested video): Use more idiomatic APIs 2026-03-07 23:35:17 +01:00
167 changed files with 3339 additions and 1596 deletions

View file

@ -1,3 +1,10 @@
## [6.1.1-dev.1](https://github.com/ReVanced/revanced-patches/compare/v6.1.0...v6.1.1-dev.1) (2026-03-19)
### Bug Fixes
* **YouTube - Spoof video streams:** Set `ANDROID_REEL` client as default ([#6878](https://github.com/ReVanced/revanced-patches/issues/6878)) ([a9aeb32](https://github.com/ReVanced/revanced-patches/commit/a9aeb325de1160262c4db9b4b60c6c5e39730620))
# [6.1.0](https://github.com/ReVanced/revanced-patches/compare/v6.0.1...v6.1.0) (2026-03-18)

View file

@ -0,0 +1,50 @@
package app.revanced.extension.music.patches;
import java.util.Objects;
import app.revanced.extension.shared.Logger;
import app.revanced.extension.shared.ResourceType;
import app.revanced.extension.shared.Utils;
import app.revanced.extension.music.settings.Settings;
public class ChangeHeaderPatch {
public enum HeaderLogo {
DEFAULT(null),
REVANCED("revanced_header_dark"),
CUSTOM("revanced_header_custom_dark");
private final String drawableName;
HeaderLogo(String drawableName) {
this.drawableName = drawableName;
}
private Integer getDrawableId() {
if (drawableName == null) {
return null;
}
int id = Utils.getResourceIdentifier(ResourceType.DRAWABLE, drawableName);
if (id == 0) {
Logger.printException(() ->
"Header drawable not found: " + drawableName
);
Settings.HEADER_LOGO.resetToDefault();
return null;
}
return id;
}
}
/**
* Injection point.
*/
@SuppressWarnings("unused")
public static int getHeaderDrawableId(int original) {
return Objects.requireNonNullElse(
Settings.HEADER_LOGO.get().getDrawableId(),
original
);
}
}

View file

@ -0,0 +1,98 @@
package app.revanced.extension.music.patches;
import static java.lang.Boolean.TRUE;
import android.app.Activity;
import android.content.Intent;
import android.os.Bundle;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import app.revanced.extension.shared.Logger;
import app.revanced.extension.music.settings.Settings;
@SuppressWarnings("unused")
public final class ChangeStartPagePatch {
private static final String SHORTCUT_ACTION = "com.google.android.youtube.music.action.shortcut";
private static final String SHORTCUT_CLASS_DESCRIPTOR = "com.google.android.apps.youtube.music.activities.InternalMusicActivity";
private static final String SHORTCUT_TYPE = "com.google.android.youtube.music.action.shortcut_type";
private static final String SHORTCUT_ID_SEARCH = "Eh4IBRDTnQEYmgMiEwiZn+H0r5WLAxVV5OcDHcHRBmPqpd25AQA=";
private static final int SHORTCUT_TYPE_SEARCH = 1;
public enum StartPage {
DEFAULT("", null),
CHARTS("FEmusic_charts", TRUE),
EXPLORE("FEmusic_explore", TRUE),
HISTORY("FEmusic_history", TRUE),
LIBRARY("FEmusic_library_landing", TRUE),
PLAYLISTS("FEmusic_liked_playlists", TRUE),
PODCASTS("FEmusic_non_music_audio", TRUE),
SUBSCRIPTIONS("FEmusic_library_corpus_artists", TRUE),
EPISODES_FOR_LATER("VLSE", TRUE),
LIKED_MUSIC("VLLM", TRUE),
SEARCH("", false);
@NonNull
final String id;
@Nullable
final Boolean isBrowseId;
StartPage(@NonNull String id, @Nullable Boolean isBrowseId) {
this.id = id;
this.isBrowseId = isBrowseId;
}
private boolean isBrowseId() {
return TRUE.equals(isBrowseId);
}
}
private static final String ACTION_MAIN = "android.intent.action.MAIN";
public static String overrideBrowseId(@Nullable String original) {
var startPage = Settings.CHANGE_START_PAGE.get();
if (!startPage.isBrowseId()) {
return original;
}
if (!"FEmusic_home".equals(original)) {
return original;
}
String overrideBrowseId = startPage.id;
if (overrideBrowseId.isEmpty()) {
return original;
}
Logger.printDebug(() -> "Changing browseId to: " + startPage.name());
return overrideBrowseId;
}
public static void overrideIntentActionOnCreate(@NonNull Activity activity,
@Nullable Bundle savedInstanceState) {
if (savedInstanceState != null) return;
var startPage = Settings.CHANGE_START_PAGE.get();
if (startPage != StartPage.SEARCH) return;
var originalIntent = activity.getIntent();
if (originalIntent == null) return;
if (ACTION_MAIN.equals(originalIntent.getAction())) {
Logger.printDebug(() -> "Cold start: Launching search activity directly");
var searchIntent = new Intent();
searchIntent.setAction(SHORTCUT_ACTION);
searchIntent.setClassName(activity, SHORTCUT_CLASS_DESCRIPTOR);
searchIntent.setPackage(activity.getPackageName());
searchIntent.putExtra(SHORTCUT_TYPE, SHORTCUT_TYPE_SEARCH);
searchIntent.putExtra(SHORTCUT_ACTION, SHORTCUT_ID_SEARCH);
activity.startActivity(searchIntent);
}
}
}

View file

@ -0,0 +1,13 @@
package app.revanced.extension.music.patches;
import app.revanced.extension.music.settings.Settings;
@SuppressWarnings("unused")
public class ForciblyEnableMiniplayerPatch {
/**
* Injection point
*/
public static boolean enableForcedMiniplayerPatch(boolean original) {
return Settings.FORCIBLY_ENABLE_MINIPLAYER.get() || original;
}
}

View file

@ -2,6 +2,8 @@ package app.revanced.extension.music.settings;
import static java.lang.Boolean.FALSE;
import static java.lang.Boolean.TRUE;
import static app.revanced.extension.music.patches.ChangeHeaderPatch.*;
import static app.revanced.extension.music.patches.ChangeStartPagePatch.*;
import static app.revanced.extension.shared.settings.Setting.parent;
import app.revanced.extension.shared.settings.YouTubeAndMusicSettings;
@ -16,6 +18,7 @@ public class Settings extends YouTubeAndMusicSettings {
public static final BooleanSetting HIDE_GET_PREMIUM_LABEL = new BooleanSetting("revanced_music_hide_get_premium_label", TRUE, true);
// General
public static final EnumSetting<StartPage> CHANGE_START_PAGE = new EnumSetting<>("revanced_change_start_page", StartPage.DEFAULT, true);
public static final BooleanSetting HIDE_CAST_BUTTON = new BooleanSetting("revanced_music_hide_cast_button", TRUE, true);
public static final BooleanSetting HIDE_CATEGORY_BAR = new BooleanSetting("revanced_music_hide_category_bar", FALSE, true);
public static final BooleanSetting HIDE_HISTORY_BUTTON = new BooleanSetting("revanced_music_hide_history_button", FALSE, true);
@ -28,9 +31,11 @@ public class Settings extends YouTubeAndMusicSettings {
public static final BooleanSetting HIDE_NAVIGATION_BAR_UPGRADE_BUTTON = new BooleanSetting("revanced_music_hide_navigation_bar_upgrade_button", TRUE, true);
public static final BooleanSetting HIDE_NAVIGATION_BAR = new BooleanSetting("revanced_music_hide_navigation_bar", FALSE, true);
public static final BooleanSetting HIDE_NAVIGATION_BAR_LABEL = new BooleanSetting("revanced_music_hide_navigation_bar_labels", FALSE, true);
public static final EnumSetting<HeaderLogo> HEADER_LOGO = new EnumSetting<>("revanced_header_logo", HeaderLogo.DEFAULT, true);
// Player
public static final BooleanSetting CHANGE_MINIPLAYER_COLOR = new BooleanSetting("revanced_music_change_miniplayer_color", FALSE, true);
public static final BooleanSetting FORCIBLY_ENABLE_MINIPLAYER = new BooleanSetting("revanced_music_forcibly_enable_miniplayer", FALSE, true);
public static final BooleanSetting PERMANENT_REPEAT = new BooleanSetting("revanced_music_play_permanent_repeat", FALSE, true);
// Miscellaneous

View file

@ -0,0 +1,21 @@
package app.revanced.extension.shared;
public final class ConversionContext {
/**
* Interface to use obfuscated methods.
*/
public interface ContextInterface {
// Methods implemented by patch.
StringBuilder patch_getPathBuilder();
String patch_getIdentifier();
default boolean isHomeFeedOrRelatedVideo() {
return toString().contains("horizontalCollectionSwipeProtector=null");
}
default boolean isSubscriptionOrLibrary() {
return toString().contains("heightConstraint=null");
}
}
}

View file

@ -358,6 +358,41 @@ public class Utils {
return getContext().getResources().getStringArray(getResourceIdentifierOrThrow(ResourceType.ARRAY, resourceIdentifierName));
}
/**
* Checks if a specific app package is installed and enabled on the device.
*
* @param packageName The application package name to check (e.g., "app.revanced.android.apps.youtube.music").
* @return True if the package is installed and enabled, false otherwise.
*/
public static boolean isPackageEnabled(String packageName) {
Context context = getContext();
if (context == null || !isNotEmpty(packageName)) {
return false;
}
try {
PackageManager pm = context.getPackageManager();
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
return pm.getApplicationInfo(packageName, PackageManager.ApplicationInfoFlags.of(0)).enabled;
} else {
return pm.getApplicationInfo(packageName, 0).enabled;
}
} catch (PackageManager.NameNotFoundException e) {
return false;
}
}
public static boolean startsWithAny(String value, String...targets) {
if (isNotEmpty(value)) {
for (String string : targets) {
if (isNotEmpty(string) && value.startsWith(string)) {
return true;
}
}
}
return false;
}
public interface MatchFilter<T> {
boolean matches(T object);
}

View file

@ -11,6 +11,7 @@ public final class SanitizeSharingLinksPatch {
private static final LinkSanitizer sanitizer = new LinkSanitizer(
"si",
"is", // New (localized?) tracking parameter.
"feature" // Old tracking parameter name, and may be obsolete.
);

View file

@ -12,6 +12,8 @@ import java.util.Map;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import app.revanced.extension.shared.ConversionContext;
import app.revanced.extension.shared.ConversionContext.ContextInterface;
import app.revanced.extension.shared.Logger;
import app.revanced.extension.shared.StringTrieSearch;
import app.revanced.extension.shared.Utils;
@ -172,14 +174,21 @@ public final class CustomFilter extends Filter {
if (!groups.isEmpty()) {
CustomFilterGroup[] groupsArray = groups.toArray(new CustomFilterGroup[0]);
Logger.printDebug(()-> "Using Custom filters: " + Arrays.toString(groupsArray));
Logger.printDebug(() -> "Using Custom filters: " + Arrays.toString(groupsArray));
addPathCallbacks(groupsArray);
}
}
@Override
public boolean isFiltered(String identifier, String accessibility, String path, byte[] buffer,
StringFilterGroup matchedGroup, FilterContentType contentType, int contentIndex) {
public boolean isFiltered(
ContextInterface contextInterface,
String identifier,
String accessibility,
String path,
byte[] buffer,
StringFilterGroup matchedGroup,
FilterContentType contentType,
int contentIndex) {
// All callbacks are custom filter groups.
CustomFilterGroup custom = (CustomFilterGroup) matchedGroup;

View file

@ -1,5 +1,7 @@
package app.revanced.extension.shared.patches.litho;
import app.revanced.extension.shared.ConversionContext;
import app.revanced.extension.shared.ConversionContext.ContextInterface;
import app.revanced.extension.shared.patches.litho.FilterGroup.ByteArrayFilterGroup;
import app.revanced.extension.shared.patches.litho.FilterGroup.StringFilterGroup;
@ -9,16 +11,16 @@ import java.util.List;
/**
* Filters litho based components.
*
* <p>
* Callbacks to filter content are added using {@link #addIdentifierCallbacks(StringFilterGroup...)}
* and {@link #addPathCallbacks(StringFilterGroup...)}.
*
* <p>
* To filter {@link FilterContentType#PROTOBUFFER} or {@link FilterContentType#ACCESSIBILITY}, first add a callback to
* either an identifier or a path.
* Then inside {@link #isFiltered(String, String, String, byte[], StringFilterGroup, FilterContentType, int)}
* Then inside {@link #isFiltered(ContextInterface, String, String, String, byte[], StringFilterGroup, FilterContentType, int)}
* search for the buffer content using either a {@link ByteArrayFilterGroup} (if searching for 1 pattern)
* or a {@link FilterGroupList.ByteArrayFilterGroupList} (if searching for more than 1 pattern).
*
* <p>
* All callbacks must be registered before the constructor completes.
*/
public abstract class Filter {
@ -42,7 +44,7 @@ public abstract class Filter {
public final List<StringFilterGroup> pathCallbacks = new ArrayList<>();
/**
* Adds callbacks to {@link #isFiltered(String, String, String, byte[], StringFilterGroup, FilterContentType, int)}
* Adds callbacks to {@link #isFiltered(ContextInterface, String, String, String, byte[], StringFilterGroup, FilterContentType, int)}
* if any of the groups are found.
*/
protected final void addIdentifierCallbacks(StringFilterGroup... groups) {
@ -50,7 +52,7 @@ public abstract class Filter {
}
/**
* Adds callbacks to {@link #isFiltered(String, String, String, byte[], StringFilterGroup, FilterContentType, int)}
* Adds callbacks to {@link #isFiltered(ContextInterface, String, String, String, byte[], StringFilterGroup, FilterContentType, int)}
* if any of the groups are found.
*/
protected final void addPathCallbacks(StringFilterGroup... groups) {
@ -64,6 +66,7 @@ public abstract class Filter {
* <p>
* Method is called off the main thread.
*
* @param contextInterface The interface to get the Litho conversion context.
* @param identifier Litho identifier.
* @param accessibility Accessibility string, or an empty string if not present for the component.
* @param buffer Protocol buffer.
@ -72,8 +75,8 @@ public abstract class Filter {
* @param contentIndex Matched index of the identifier or path.
* @return True if the litho component should be filtered out.
*/
public boolean isFiltered(String identifier, String accessibility, String path, byte[] buffer,
StringFilterGroup matchedGroup, FilterContentType contentType, int contentIndex) {
public boolean isFiltered(ContextInterface contextInterface, String identifier, String accessibility, String path, byte[] buffer,
StringFilterGroup matchedGroup, FilterContentType contentType, int contentIndex) {
return true;
}
}

View file

@ -5,12 +5,10 @@ import androidx.annotation.Nullable;
import java.nio.ByteBuffer;
import java.nio.charset.StandardCharsets;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import app.revanced.extension.shared.ConversionContext.ContextInterface;
import app.revanced.extension.shared.Logger;
import app.revanced.extension.shared.Utils;
import app.revanced.extension.shared.patches.litho.FilterGroup.StringFilterGroup;
import app.revanced.extension.shared.settings.BaseSettings;
import app.revanced.extension.shared.StringTrieSearch;
@ -65,23 +63,48 @@ public final class LithoFilterPatch {
final int minimumAscii = 32; // 32 = space character
final int maximumAscii = 126; // 127 = delete character
final int minimumAsciiStringLength = 4; // Minimum length of an ASCII string to include.
// Logger ignores text past 4096 bytes on each line. Must wrap lines otherwise logging is clipped.
final int preferredLineLength = 3000; // Preferred length before wrapping on next substring.
final int maxLineLength = 3300; // Hard limit to line wrap in the middle of substring.
String delimitingCharacter = ""; // Non ascii character, to allow easier log filtering.
final int length = buffer.length;
final int lastIndex = length - 1;
int start = 0;
int end = 0;
while (end < length) {
int value = buffer[end];
if (value < minimumAscii || value > maximumAscii || end == length - 1) {
if (end - start >= minimumAsciiStringLength) {
for (int i = start; i < end; i++) {
int currentLineLength = 0;
for (int end = 0; end < length; end++) {
final int value = buffer[end];
final boolean isAscii = (value >= minimumAscii && value <= maximumAscii);
final boolean atEnd = (end == lastIndex);
if (!isAscii || atEnd) {
int wordEnd = end + ((atEnd && isAscii) ? 1 : 0);
if (wordEnd - start >= minimumAsciiStringLength) {
for (int i = start; i < wordEnd; i++) {
builder.append((char) buffer[i]);
currentLineLength++;
// Hard line limit. Hard wrap the current substring to next logger line.
if (currentLineLength >= maxLineLength) {
builder.append('\n');
currentLineLength = 0;
}
}
// Wrap after substring if over preferred limit.
if (currentLineLength >= preferredLineLength) {
builder.append('\n');
currentLineLength = 0;
}
builder.append(delimitingCharacter);
currentLineLength++;
}
start = end + 1;
}
end++;
}
}
}
@ -123,11 +146,6 @@ public final class LithoFilterPatch {
*/
private static final boolean EXTRACT_IDENTIFIER_FROM_BUFFER = false;
/**
* Turns on additional logging, used for development purposes only.
*/
public static final boolean DEBUG_EXTRACT_IDENTIFIER_FROM_BUFFER = false;
/**
* String suffix for components.
* Can be any of: ".eml", ".eml-fe", ".e-b", ".eml-js", "e-js-b"
@ -146,19 +164,6 @@ public final class LithoFilterPatch {
*/
private static final ThreadLocal<byte[]> bufferThreadLocal = new ThreadLocal<>();
/**
* Identifier to protocol buffer mapping. Only used for 20.22+.
* Thread local is needed because filtering is multithreaded and each thread can load
* a different component with the same identifier.
*/
private static final ThreadLocal<Map<String, byte[]>> identifierToBufferThread = new ThreadLocal<>();
/**
* Global shared buffer. Used only if the buffer is not found in the ThreadLocal.
*/
private static final Map<String, byte[]> identifierToBufferGlobal
= Collections.synchronizedMap(createIdentifierToBufferMap());
private static final StringTrieSearch pathSearchTree = new StringTrieSearch();
private static final StringTrieSearch identifierSearchTree = new StringTrieSearch();
@ -211,126 +216,11 @@ public final class LithoFilterPatch {
}
}
private static Map<String, byte[]> createIdentifierToBufferMap() {
// It's unclear how many items should be cached. This is a guess.
return Utils.createSizeRestrictedMap(100);
}
/**
* Helper function that differs from {@link Character#isDigit(char)}
* as this only matches ascii and not Unicode numbers.
*/
private static boolean isAsciiNumber(byte character) {
return '0' <= character && character <= '9';
}
private static boolean isAsciiLowerCaseLetter(byte character) {
return 'a' <= character && character <= 'z';
}
/**
* Injection point. Called off the main thread.
* Targets 20.22+
*/
public static void setProtoBuffer(byte[] buffer) {
if (DEBUG_EXTRACT_IDENTIFIER_FROM_BUFFER) {
StringBuilder builder = new StringBuilder();
LithoFilterParameters.findAsciiStrings(builder, buffer);
Logger.printDebug(() -> "New buffer: " + builder);
}
// The identifier always seems to start very close to the buffer start.
// Highest identifier start index ever observed is 50, with most around 30 to 40.
// The buffer can be very large with up to 200kb has been observed,
// so the search is restricted to only the start.
final int maxBufferStartIndex = 500; // 10x expected upper bound.
// Could use Boyer-Moore-Horspool since the string is ASCII and has a limited number of
// unique characters, but it seems to be slower since the extra overhead of checking the
// bad character array negates any performance gain of skipping a few extra subsearches.
int emlIndex = -1;
final int emlStringLength = LITHO_COMPONENT_EXTENSION_BYTES.length;
final int lastBufferIndexToCheckFrom = Math.min(maxBufferStartIndex, buffer.length - emlStringLength);
for (int i = 0; i < lastBufferIndexToCheckFrom; i++) {
boolean match = true;
for (int j = 0; j < emlStringLength; j++) {
if (buffer[i + j] != LITHO_COMPONENT_EXTENSION_BYTES[j]) {
match = false;
break;
}
}
if (match) {
emlIndex = i;
break;
}
}
if (emlIndex < 0) {
// Buffer is not used for creating a new litho component.
if (DEBUG_EXTRACT_IDENTIFIER_FROM_BUFFER) {
Logger.printDebug(() -> "Could not find eml index");
}
return;
}
int startIndex = emlIndex - 1;
while (startIndex > 0) {
final byte character = buffer[startIndex];
int startIndexFinal = startIndex;
if (isAsciiLowerCaseLetter(character) || isAsciiNumber(character) || character == '_') {
// Valid character for the first path element.
startIndex--;
} else {
startIndex++;
break;
}
}
// Strip away any numbers on the start of the identifier, which can
// be from random data in the buffer before the identifier starts.
while (true) {
final byte character = buffer[startIndex];
if (isAsciiNumber(character)) {
startIndex++;
} else {
break;
}
}
// Find the pipe character after the identifier.
int endIndex = -1;
for (int i = emlIndex, length = buffer.length; i < length; i++) {
if (buffer[i] == '|') {
endIndex = i;
break;
}
}
if (endIndex < 0) {
if (BaseSettings.DEBUG.get()) {
Logger.printException(() -> "Debug: Could not find buffer identifier");
}
return;
}
String identifier = new String(buffer, startIndex, endIndex - startIndex, StandardCharsets.US_ASCII);
if (DEBUG_EXTRACT_IDENTIFIER_FROM_BUFFER) {
Logger.printDebug(() -> "Found buffer for identifier: " + identifier);
}
identifierToBufferGlobal.put(identifier, buffer);
Map<String, byte[]> map = identifierToBufferThread.get();
if (map == null) {
map = createIdentifierToBufferMap();
identifierToBufferThread.set(map);
}
map.put(identifier, buffer);
}
/**
* Injection point. Called off the main thread.
* Targets 20.21 and lower.
*/
public static void setProtoBuffer(@Nullable ByteBuffer buffer) {
public static void setProtobufBuffer(@Nullable ByteBuffer buffer) {
if (buffer == null || !buffer.hasArray()) {
// It appears the buffer can be cleared out just before the call to #filter()
// Ignore this null value and retain the last buffer that was set.
@ -347,44 +237,18 @@ public final class LithoFilterPatch {
/**
* Injection point.
*/
public static boolean isFiltered(String identifier, @Nullable String accessibilityId,
@Nullable String accessibilityText, StringBuilder pathBuilder) {
public static boolean isFiltered(ContextInterface contextInterface, @Nullable byte[] bytes,
@Nullable String accessibilityId, @Nullable String accessibilityText) {
try {
String identifier = contextInterface.patch_getIdentifier();
StringBuilder pathBuilder = contextInterface.patch_getPathBuilder();
if (identifier.isEmpty() || pathBuilder.length() == 0) {
return false;
}
byte[] buffer = null;
if (EXTRACT_IDENTIFIER_FROM_BUFFER) {
final int pipeIndex = identifier.indexOf('|');
if (pipeIndex >= 0) {
// If the identifier contains no pipe, then it's not an ".eml" identifier
// and the buffer is not uniquely identified. Typically, this only happens
// for subcomponents where buffer filtering is not used.
String identifierKey = identifier.substring(0, pipeIndex);
var map = identifierToBufferThread.get();
if (map != null) {
buffer = map.get(identifierKey);
}
if (buffer == null) {
// Buffer for thread local not found. Use the last buffer found from any thread.
buffer = identifierToBufferGlobal.get(identifierKey);
if (DEBUG_EXTRACT_IDENTIFIER_FROM_BUFFER && buffer == null) {
// No buffer is found for some components, such as
// shorts_lockup_cell.eml on channel profiles.
// For now, just ignore this and filter without a buffer.
if (BaseSettings.DEBUG.get()) {
Logger.printException(() -> "Debug: Could not find buffer for identifier: " + identifier);
}
}
}
}
} else {
buffer = bufferThreadLocal.get();
}
byte[] buffer = EXTRACT_IDENTIFIER_FROM_BUFFER
? bytes
: bufferThreadLocal.get();
// Potentially the buffer may have been null or never set up until now.
// Use an empty buffer so the litho id/path filters that do not use a buffer still work.

View file

@ -2,7 +2,7 @@ package app.revanced.extension.shared.settings;
import static app.revanced.extension.shared.StringRef.str;
import android.content.Context;
import android.app.Activity;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
@ -122,18 +122,18 @@ public abstract class Setting<T> {
/**
* Called after all settings have been imported.
*/
void settingsImported(@Nullable Context context);
void settingsImported(@Nullable Activity context);
/**
* Called after all settings have been exported.
*/
void settingsExported(@Nullable Context context);
void settingsExported(@Nullable Activity context);
}
private static final List<ImportExportCallback> importExportCallbacks = new ArrayList<>();
/**
* Adds a callback for {@link #importFromJSON(Context, String)} and {@link #exportToJson(Context)}.
* Adds a callback for {@link #importFromJSON(Activity, String)} and {@link #exportToJson(Activity)}.
*/
public static void addImportExportCallback(ImportExportCallback callback) {
importExportCallbacks.add(Objects.requireNonNull(callback));
@ -413,7 +413,7 @@ public abstract class Setting<T> {
json.put(importExportKey, value);
}
public static String exportToJson(@Nullable Context alertDialogContext) {
public static String exportToJson(@Nullable Activity alertDialogContext) {
try {
JSONObject json = new JSONObject();
for (Setting<?> setting : allLoadedSettingsSorted()) {
@ -439,11 +439,17 @@ public abstract class Setting<T> {
String export = json.toString(0);
// Remove the outer JSON braces to make the output more compact,
// and leave less chance of the user forgetting to copy it
return export.substring(2, export.length() - 2);
if (export.startsWith("{") && export.endsWith("}")) {
// Remove the outer JSON braces to make the output more compact,
// and leave less chance of the user forgetting to copy it
export = export.substring(1, export.length() - 1);
}
export = export.replaceAll("^\\n+", "").replaceAll("\\n+$", "");
return export + ",";
} catch (JSONException e) {
Logger.printException(() -> "Export failure", e); // should never happen
Logger.printException(() -> "Export failure", e); // Should never happen
return "";
}
}
@ -451,10 +457,16 @@ public abstract class Setting<T> {
/**
* @return if any settings that require a reboot were changed.
*/
public static boolean importFromJSON(Context alertDialogContext, String settingsJsonString) {
public static boolean importFromJSON(Activity alertDialogContext, String settingsJsonString) {
try {
if (!settingsJsonString.matches("[\\s\\S]*\\{")) {
settingsJsonString = '{' + settingsJsonString + '}'; // Restore outer JSON braces
settingsJsonString = settingsJsonString.trim();
if (settingsJsonString.endsWith(",")) {
settingsJsonString = settingsJsonString.substring(0, settingsJsonString.length() - 1);
}
if (!settingsJsonString.trim().startsWith("{")) {
settingsJsonString = "{\n" + settingsJsonString + "\n}"; // Restore outer JSON braces
}
JSONObject json = new JSONObject(settingsJsonString);

View file

@ -3,24 +3,38 @@ package app.revanced.extension.shared.settings.preference;
import static app.revanced.extension.shared.StringRef.str;
import android.annotation.SuppressLint;
import android.app.Activity;
import android.app.Dialog;
import android.content.Context;
import android.content.Intent;
import android.content.SharedPreferences;
import android.os.Bundle;
import android.os.SystemClock;
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;
import android.preference.EditTextPreference;
import android.preference.ListPreference;
import android.util.Pair;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.Button;
import android.widget.EditText;
import android.widget.LinearLayout;
import android.widget.ListView;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import java.io.InputStream;
import java.io.OutputStream;
import java.nio.charset.StandardCharsets;
import java.util.Objects;
import java.util.Scanner;
import app.revanced.extension.shared.Logger;
import app.revanced.extension.shared.ResourceType;
@ -33,6 +47,36 @@ import app.revanced.extension.shared.ui.CustomDialog;
@SuppressWarnings("deprecation")
public abstract class AbstractPreferenceFragment extends PreferenceFragment {
private static class DebouncedListView extends ListView {
private long lastClick;
public DebouncedListView(Context context) {
super(context);
setId(android.R.id.list); // Required so PreferenceFragment recognizes it.
// Match the default layout params
setLayoutParams(new ViewGroup.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.MATCH_PARENT
));
}
@Override
public boolean performItemClick(View view, int position, long id) {
final long now = SystemClock.elapsedRealtime();
if (now - lastClick < 500) {
return true; // Ignore fast double click.
}
lastClick = now;
return super.performItemClick(view, position, id);
}
}
@SuppressLint("StaticFieldLeak")
public static AbstractPreferenceFragment instance;
/**
* Indicates that if a preference changes,
* to apply the change from the Setting to the UI component.
@ -56,6 +100,12 @@ public abstract class AbstractPreferenceFragment extends PreferenceFragment {
@Nullable
protected static CharSequence restartDialogTitle, restartDialogMessage, restartDialogButtonText, confirmDialogTitle;
private static final int READ_REQUEST_CODE = 42;
private static final int WRITE_REQUEST_CODE = 43;
private String existingSettings = "";
private EditText currentImportExportEditText;
private final SharedPreferences.OnSharedPreferenceChangeListener listener = (sharedPreferences, str) -> {
try {
if (updatingPreference) {
@ -198,8 +248,7 @@ public abstract class AbstractPreferenceFragment extends PreferenceFragment {
return listPref.getValue().equals(defaultValueString);
}
throw new IllegalStateException("Must override method to handle "
+ "preference type: " + pref.getClass());
throw new IllegalStateException("Must override method to handle preference type: " + pref.getClass());
}
/**
@ -332,10 +381,235 @@ public abstract class AbstractPreferenceFragment extends PreferenceFragment {
dialogPair.first.show();
}
/**
* Import / Export Subroutines
*/
@NonNull
private Button createDialogButton(Context context, String text, int marginLeft, int marginRight, View.OnClickListener listener) {
int height = (int) android.util.TypedValue.applyDimension(android.util.TypedValue.COMPLEX_UNIT_DIP, 36f, context.getResources().getDisplayMetrics());
int paddingHorizontal = (int) android.util.TypedValue.applyDimension(android.util.TypedValue.COMPLEX_UNIT_DIP, 16f, context.getResources().getDisplayMetrics());
float radius = android.util.TypedValue.applyDimension(android.util.TypedValue.COMPLEX_UNIT_DIP, 20f, context.getResources().getDisplayMetrics());
Button btn = new Button(context, null, 0);
btn.setText(text);
btn.setAllCaps(false);
btn.setTextSize(14);
btn.setSingleLine(true);
btn.setEllipsize(android.text.TextUtils.TruncateAt.END);
btn.setGravity(android.view.Gravity.CENTER);
btn.setPadding(paddingHorizontal, 0, paddingHorizontal, 0);
btn.setTextColor(Utils.isDarkModeEnabled() ? android.graphics.Color.WHITE : android.graphics.Color.BLACK);
android.graphics.drawable.GradientDrawable bg = new android.graphics.drawable.GradientDrawable();
bg.setCornerRadius(radius);
bg.setColor(Utils.getCancelOrNeutralButtonBackgroundColor());
btn.setBackground(bg);
LinearLayout.LayoutParams params = new LinearLayout.LayoutParams(0, height, 1.0f);
params.setMargins(marginLeft, 0, marginRight, 0);
btn.setLayoutParams(params);
btn.setOnClickListener(listener);
return btn;
}
public void showImportExportTextDialog() {
try {
Activity context = getActivity();
// Must set text before showing dialog,
// otherwise text is non-selectable if this preference is later reopened.
existingSettings = Setting.exportToJson(context);
currentImportExportEditText = getEditText(context);
// Create a custom dialog with the EditText.
Pair<Dialog, LinearLayout> dialogPair = CustomDialog.create(
context,
str("revanced_pref_import_export_title"), // Title.
null, // No message (EditText replaces it).
currentImportExportEditText, // Pass the EditText.
str("revanced_settings_save"), // OK button text.
() -> importSettingsText(context, currentImportExportEditText.getText().toString()), // OK button action.
() -> {}, // Cancel button action (dismiss only).
str("revanced_settings_import_copy"), // Neutral button (Copy) text.
() -> Utils.setClipboard(currentImportExportEditText.getText().toString()), // Neutral button (Copy) action. Show the user the settings in JSON format.
true // Dismiss dialog when onNeutralClick.
);
LinearLayout fileButtonsContainer = getLinearLayout(context);
int margin = (int) android.util.TypedValue.applyDimension(android.util.TypedValue.COMPLEX_UNIT_DIP, 4f, context.getResources().getDisplayMetrics());
Button btnExport = createDialogButton(context, str("revanced_settings_export_file"), 0, margin, v -> exportActivity());
Button btnImport = createDialogButton(context, str("revanced_settings_import_file"), margin, 0, v -> importActivity());
fileButtonsContainer.addView(btnExport);
fileButtonsContainer.addView(btnImport);
dialogPair.second.addView(fileButtonsContainer, 2);
dialogPair.first.setOnDismissListener(d -> currentImportExportEditText = null);
// If there are no settings yet, then show the on-screen keyboard and bring focus to
// the edit text. This makes it easier to paste saved settings after a reinstallation.
dialogPair.first.setOnShowListener(dialogInterface -> {
if (existingSettings.isEmpty() && currentImportExportEditText != null) {
currentImportExportEditText.postDelayed(() -> {
if (currentImportExportEditText != null) {
currentImportExportEditText.requestFocus();
android.view.inputmethod.InputMethodManager imm = (android.view.inputmethod.InputMethodManager) context.getSystemService(Context.INPUT_METHOD_SERVICE);
if (imm != null) imm.showSoftInput(currentImportExportEditText, android.view.inputmethod.InputMethodManager.SHOW_IMPLICIT);
}
}, 100);
}
});
// Show the dialog.
dialogPair.first.show();
} catch (Exception ex) {
Logger.printException(() -> "showImportExportTextDialog failure", ex);
}
}
@NonNull
private static LinearLayout getLinearLayout(Context context) {
LinearLayout fileButtonsContainer = new LinearLayout(context);
fileButtonsContainer.setOrientation(LinearLayout.HORIZONTAL);
LinearLayout.LayoutParams fbParams = new LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT);
int marginTop = (int) android.util.TypedValue.applyDimension(android.util.TypedValue.COMPLEX_UNIT_DIP, 16f, context.getResources().getDisplayMetrics());
fbParams.setMargins(0, marginTop, 0, 0);
fileButtonsContainer.setLayoutParams(fbParams);
return fileButtonsContainer;
}
@NonNull
private EditText getEditText(Context context) {
EditText editText = new EditText(context);
editText.setText(existingSettings);
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.O) {
editText.setAutofillHints((String) null);
}
editText.setInputType(android.text.InputType.TYPE_CLASS_TEXT |
android.text.InputType.TYPE_TEXT_FLAG_NO_SUGGESTIONS |
android.text.InputType.TYPE_TEXT_FLAG_MULTI_LINE);
editText.setSingleLine(false);
editText.setTextSize(14);
return editText;
}
public void exportActivity() {
try {
Setting.exportToJson(getActivity());
String formatDate = new java.text.SimpleDateFormat("yyyy-MM-dd", java.util.Locale.US).format(new java.util.Date());
String fileName = "revanced_Settings_" + formatDate + ".txt";
Intent intent = new Intent(Intent.ACTION_CREATE_DOCUMENT);
intent.addCategory(Intent.CATEGORY_OPENABLE);
intent.setType("text/plain");
intent.putExtra(Intent.EXTRA_TITLE, fileName);
startActivityForResult(intent, WRITE_REQUEST_CODE);
} catch (Exception ex) {
Logger.printException(() -> "exportActivity failure", ex);
}
}
public void importActivity() {
try {
Intent intent = new Intent(Intent.ACTION_OPEN_DOCUMENT);
intent.addCategory(Intent.CATEGORY_OPENABLE);
intent.setType("*/*");
startActivityForResult(intent, READ_REQUEST_CODE);
} catch (Exception ex) {
Logger.printException(() -> "importActivity failure", ex);
}
}
@Override
public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) {
return new DebouncedListView(getActivity());
}
@Override
public void onActivityResult(int requestCode, int resultCode, Intent data) {
super.onActivityResult(requestCode, resultCode, data);
if (requestCode == WRITE_REQUEST_CODE && resultCode == android.app.Activity.RESULT_OK && data != null) {
exportTextToFile(data.getData());
} else if (requestCode == READ_REQUEST_CODE && resultCode == android.app.Activity.RESULT_OK && data != null) {
importTextFromFile(data.getData());
}
}
protected static void showLocalizedToast(String resourceKey, String fallbackMessage) {
if (Utils.getResourceIdentifier(ResourceType.STRING, resourceKey) != 0) {
Utils.showToastLong(str(resourceKey));
} else {
Utils.showToastLong(fallbackMessage);
}
}
private void exportTextToFile(android.net.Uri uri) {
try {
OutputStream out = getContext().getContentResolver().openOutputStream(uri);
if (out != null) {
String textToExport = existingSettings;
if (currentImportExportEditText != null) {
textToExport = currentImportExportEditText.getText().toString();
}
out.write(textToExport.getBytes(StandardCharsets.UTF_8));
out.close();
showLocalizedToast("revanced_settings_export_file_success", "Settings exported successfully");
}
} catch (Exception e) {
showLocalizedToast("revanced_settings_export_file_failed", "Failed to export settings");
Logger.printException(() -> "exportTextToFile failure", e);
}
}
@SuppressWarnings("CharsetObjectCanBeUsed")
private void importTextFromFile(android.net.Uri uri) {
try {
InputStream in = getContext().getContentResolver().openInputStream(uri);
if (in != null) {
Scanner scanner = new Scanner(in, StandardCharsets.UTF_8.name()).useDelimiter("\\A");
String result = scanner.hasNext() ? scanner.next() : "";
in.close();
if (currentImportExportEditText != null) {
currentImportExportEditText.setText(result);
showLocalizedToast("revanced_settings_import_file_success", "Settings imported successfully, tap Save to apply");
} else {
importSettingsText(getContext(), result);
}
}
} catch (Exception e) {
showLocalizedToast("revanced_settings_import_file_failed", "Failed to import settings");
Logger.printException(() -> "importTextFromFile failure", e);
}
}
private void importSettingsText(Context context, String replacementSettings) {
try {
existingSettings = Setting.exportToJson(null);
if (replacementSettings.equals(existingSettings)) {
return;
}
settingImportInProgress = true;
final boolean rebootNeeded = Setting.importFromJSON(getActivity(), replacementSettings);
if (rebootNeeded) {
showRestartDialog(context);
}
} catch (Exception ex) {
Logger.printException(() -> "importSettingsText failure", ex);
} finally {
settingImportInProgress = false;
}
}
@SuppressLint("ResourceType")
@Override
public void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
instance = this;
try {
PreferenceManager preferenceManager = getPreferenceManager();
preferenceManager.setSharedPreferencesName(Setting.preferences.name);
@ -354,6 +628,9 @@ public abstract class AbstractPreferenceFragment extends PreferenceFragment {
@Override
public void onDestroy() {
if (instance == this) {
instance = null;
}
getPreferenceManager().getSharedPreferences().unregisterOnSharedPreferenceChangeListener(listener);
super.onDestroy();
}

View file

@ -4,40 +4,13 @@ import static app.revanced.extension.shared.StringRef.str;
import android.app.Dialog;
import android.content.Context;
import android.os.Build;
import android.os.Bundle;
import android.preference.EditTextPreference;
import android.preference.Preference;
import android.text.InputType;
import android.util.AttributeSet;
import android.util.Pair;
import android.view.inputmethod.InputMethodManager;
import android.widget.EditText;
import android.widget.LinearLayout;
import app.revanced.extension.shared.Logger;
import app.revanced.extension.shared.Utils;
import app.revanced.extension.shared.settings.Setting;
import app.revanced.extension.shared.ui.CustomDialog;
@SuppressWarnings({"unused", "deprecation"})
public class ImportExportPreference extends EditTextPreference implements Preference.OnPreferenceClickListener {
private String existingSettings;
private void init() {
setSelectable(true);
EditText editText = getEditText();
editText.setTextIsSelectable(true);
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
editText.setAutofillHints((String) null);
}
editText.setInputType(editText.getInputType() | InputType.TYPE_TEXT_FLAG_NO_SUGGESTIONS);
editText.setTextSize(14);
setOnPreferenceClickListener(this);
}
public class ImportExportPreference extends Preference implements Preference.OnPreferenceClickListener {
public ImportExportPreference(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) {
super(context, attrs, defStyleAttr, defStyleRes);
@ -56,78 +29,20 @@ public class ImportExportPreference extends EditTextPreference implements Prefer
init();
}
private void init() {
setOnPreferenceClickListener(this);
}
@Override
public boolean onPreferenceClick(Preference preference) {
try {
// Must set text before showing dialog,
// otherwise text is non-selectable if this preference is later reopened.
existingSettings = Setting.exportToJson(getContext());
getEditText().setText(existingSettings);
if (AbstractPreferenceFragment.instance != null) {
AbstractPreferenceFragment.instance.showImportExportTextDialog();
}
} catch (Exception ex) {
Logger.printException(() -> "showDialog failure", ex);
Logger.printException(() -> "onPreferenceClick failure", ex);
}
return true;
}
@Override
protected void showDialog(Bundle state) {
try {
Context context = getContext();
EditText editText = getEditText();
// Create a custom dialog with the EditText.
Pair<Dialog, LinearLayout> dialogPair = CustomDialog.create(
context,
str("revanced_pref_import_export_title"), // Title.
null, // No message (EditText replaces it).
editText, // Pass the EditText.
str("revanced_settings_import"), // OK button text.
() -> importSettings(context, editText.getText().toString()), // OK button action.
() -> {}, // Cancel button action (dismiss only).
str("revanced_settings_import_copy"), // Neutral button (Copy) text.
() -> {
// Neutral button (Copy) action. Show the user the settings in JSON format.
Utils.setClipboard(editText.getText());
},
true // Dismiss dialog when onNeutralClick.
);
// If there are no settings yet, then show the on screen keyboard and bring focus to
// the edit text. This makes it easier to paste saved settings after a reinstall.
dialogPair.first.setOnShowListener(dialogInterface -> {
if (existingSettings.isEmpty()) {
editText.postDelayed(() -> {
editText.requestFocus();
InputMethodManager inputMethodManager = (InputMethodManager)
editText.getContext().getSystemService(Context.INPUT_METHOD_SERVICE);
inputMethodManager.showSoftInput(editText, InputMethodManager.SHOW_IMPLICIT);
}, 100);
}
});
// Show the dialog.
dialogPair.first.show();
} catch (Exception ex) {
Logger.printException(() -> "showDialog failure", ex);
}
}
private void importSettings(Context context, String replacementSettings) {
try {
if (replacementSettings.equals(existingSettings)) {
return;
}
AbstractPreferenceFragment.settingImportInProgress = true;
final boolean rebootNeeded = Setting.importFromJSON(context, replacementSettings);
if (rebootNeeded) {
AbstractPreferenceFragment.showRestartDialog(context);
}
} catch (Exception ex) {
Logger.printException(() -> "importSettings failure", ex);
} finally {
AbstractPreferenceFragment.settingImportInProgress = false;
}
}
}

View file

@ -28,7 +28,7 @@ public enum ClientType {
Build.VERSION.RELEASE,
String.valueOf(Build.VERSION.SDK_INT),
Build.ID,
"20.44.38",
"20.45.36",
// This client has been used by most open-source YouTube stream extraction tools since 2024, including NewPipe Extractor, SmartTube, and Grayjay.
// This client can log in, but if an access token is used in the request, GVS can more easily identify the request as coming from ReVanced.
// This means that the GVS server can strengthen its validation of the ANDROID_REEL client.

View file

@ -2,13 +2,10 @@ package app.revanced.extension.youtube.patches;
import static app.revanced.extension.youtube.shared.NavigationBar.NavigationButton;
import android.view.View;
import androidx.annotation.Nullable;
import java.util.Objects;
import java.util.Optional;
import app.revanced.extension.shared.Logger;
import app.revanced.extension.youtube.settings.Settings;
import app.revanced.extension.youtube.shared.NavigationBar;
import app.revanced.extension.youtube.shared.PlayerType;
@ -20,7 +17,7 @@ public class ChangeFormFactorPatch {
/**
* Unmodified, and same as un-patched.
*/
DEFAULT(null),
DEFAULT(null, false),
/**
* <pre>
* Some changes include:
@ -28,60 +25,56 @@ public class ChangeFormFactorPatch {
* - watch history is missing.
* - feed thumbnails fade in.
*/
UNKNOWN(0),
SMALL(1),
LARGE(2),
UNKNOWN(0, true),
SMALL(1, false),
LARGE(2, false),
/**
* Cars with 'Google built-in'.
* Layout seems identical to {@link #UNKNOWN}
* even when using an Android Automotive device.
*/
AUTOMOTIVE(3),
WEARABLE(4);
AUTOMOTIVE(3, true),
WEARABLE(4, true);
@Nullable
final Integer formFactorType;
final boolean isBroken;
FormFactor(@Nullable Integer formFactorType) {
FormFactor(@Nullable Integer formFactorType, boolean isBroken) {
this.formFactorType = formFactorType;
this.isBroken = isBroken;
}
}
private static final FormFactor FORM_FACTOR = Settings.CHANGE_FORM_FACTOR.get();
@Nullable
private static final Integer FORM_FACTOR_TYPE = Settings.CHANGE_FORM_FACTOR.get().formFactorType;
private static final boolean USING_AUTOMOTIVE_TYPE = Objects.requireNonNull(
FormFactor.AUTOMOTIVE.formFactorType).equals(FORM_FACTOR_TYPE);
private static final Integer FORM_FACTOR_TYPE = FORM_FACTOR.formFactorType;
private static final boolean IS_BROKEN_FORM_FACTOR = FORM_FACTOR.isBroken;
/**
* Injection point.
* <p>
* Called before {@link #replaceBrokenFormFactor(int)}.
* Called from all endpoints.
*/
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 (!NavigationBar.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 (NavigationBar.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;
}
public static int getUniversalFormFactor(int original) {
if (FORM_FACTOR_TYPE == null) {
return original;
}
if (IS_BROKEN_FORM_FACTOR
&& !PlayerType.getCurrent().isMaximizedOrFullscreen()
&& !NavigationBar.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 tablet form factor if not in search and the
// navigation back button is present.
if (NavigationBar.isBackButtonVisible()
// Do not change library tab otherwise watch history is hidden.
// Do this check last since the current navigation button is required.
|| NavigationButton.getSelectedNavigationButton() == NavigationButton.LIBRARY) {
// The form factor most similar to AUTOMOTIVE is LARGE, so it is replaced with LARGE.
return Optional.ofNullable(FormFactor.LARGE.formFactorType).orElse(original);
}
}
@ -90,16 +83,31 @@ public class ChangeFormFactorPatch {
/**
* Injection point.
* <p>
* Called after {@link #getUniversalFormFactor(int)}.
* Called from the '/get_watch', '/guide', '/next' and '/reel' endpoints.
* <p>
* The '/guide' endpoint relates to navigation buttons.
* If {@link #IS_BROKEN_FORM_FACTOR} is true in this endpoint,
* the explore button (which is deprecated) will be shown.
* <p>
* The '/get_watch' and '/next' endpoints relate to elements below the player (channel bar, comments, related videos).
* If {@link #IS_BROKEN_FORM_FACTOR} is true in this endpoint,
* the video description panel will not open.
* <p>
* The '/reel' endpoint relates to Shorts player.
* If {@link #IS_BROKEN_FORM_FACTOR} is true in this endpoint,
* the Shorts comment panel will not open.
*/
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 navigation 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);
public static int replaceBrokenFormFactor(int original) {
if (FORM_FACTOR_TYPE == null) {
return original;
}
if (IS_BROKEN_FORM_FACTOR) {
// The form factor most similar to AUTOMOTIVE is LARGE, so it is replaced with LARGE.
return Optional.ofNullable(FormFactor.LARGE.formFactorType).orElse(original);
} else {
return original;
}
}
}

View file

@ -0,0 +1,21 @@
package app.revanced.extension.youtube.patches;
import app.revanced.extension.youtube.settings.Settings;
@SuppressWarnings("unused")
public class DisableResumingShortsOnStartupPatch {
/**
* Injection point.
*/
public static boolean disableResumingShortsOnStartup() {
return Settings.DISABLE_RESUMING_SHORTS_ON_STARTUP.get();
}
/**
* Injection point.
*/
public static boolean disableResumingShortsOnStartup(boolean original) {
return original && !Settings.DISABLE_RESUMING_SHORTS_ON_STARTUP.get();
}
}

View file

@ -1,21 +0,0 @@
package app.revanced.extension.youtube.patches;
import app.revanced.extension.youtube.settings.Settings;
@SuppressWarnings("unused")
public class DisableResumingStartupShortsPlayerPatch {
/**
* Injection point.
*/
public static boolean disableResumingStartupShortsPlayer() {
return Settings.DISABLE_RESUMING_SHORTS_PLAYER.get();
}
/**
* Injection point.
*/
public static boolean disableResumingStartupShortsPlayer(boolean original) {
return original && !Settings.DISABLE_RESUMING_SHORTS_PLAYER.get();
}
}

View file

@ -8,6 +8,6 @@ public final class HideRelatedVideoOverlayPatch {
* Injection point.
*/
public static boolean hideRelatedVideoOverlay() {
return Settings.HIDE_RELATED_VIDEOS_OVERLAY.get();
return Settings.HIDE_PLAYER_RELATED_VIDEOS_OVERLAY.get();
}
}

View file

@ -0,0 +1,51 @@
package app.revanced.extension.youtube.patches;
import androidx.annotation.NonNull;
import java.util.List;
import java.util.concurrent.atomic.AtomicBoolean;
import app.revanced.extension.shared.Utils;
import app.revanced.extension.youtube.shared.PlayerType;
/**
* Here is an unintended behavior:
* <p>
* 1. The user does not hide Shorts in the Subscriptions tab, but hides them otherwise.
* 2. Goes to the Subscriptions tab and scrolls to where Shorts is.
* 3. Opens a regular video.
* 4. Minimizes the video and turns off the screen.
* 5. Turns the screen on and maximizes the video.
* 6. Shorts belonging to related videos are not hidden.
* <p>
* Here is an explanation of this special issue:
* <p>
* When the user minimizes the video, turns off the screen, and then turns it back on,
* the components below the player are reloaded, and at this moment the PlayerType is [WATCH_WHILE_MINIMIZED].
* (Shorts belonging to related videos are also reloaded)
* Since the PlayerType is [WATCH_WHILE_MINIMIZED] at this moment, the navigation tab is checked.
* (Even though PlayerType is [WATCH_WHILE_MINIMIZED], this is a Shorts belonging to a related video)
* <p>
* As a workaround for this special issue, if a video actionbar is detected, which is one of the components below the player,
* it is treated as being in the same state as [WATCH_WHILE_MAXIMIZED].
*/
@SuppressWarnings("unused")
public class LayoutReloadObserverPatch {
private static final String COMPACTIFY_VIDEO_ACTION_BAR_PREFIX = "compactify_video_action_bar.e";
private static final String VIDEO_ACTION_BAR_PREFIX = "video_action_bar.e";
public static final AtomicBoolean isActionBarVisible = new AtomicBoolean(false);
public static void onLazilyConvertedElementLoaded(@NonNull String identifier,
@NonNull List<Object> treeNodeResultList) {
if (!Utils.startsWithAny(identifier, COMPACTIFY_VIDEO_ACTION_BAR_PREFIX, VIDEO_ACTION_BAR_PREFIX)) {
return;
}
PlayerType playerType = PlayerType.getCurrent();
if (playerType == PlayerType.WATCH_WHILE_MINIMIZED || playerType == PlayerType.WATCH_WHILE_PICTURE_IN_PICTURE) {
if (isActionBarVisible.compareAndSet(false, true)) {
Utils.runOnMainThreadDelayed(() -> isActionBarVisible.compareAndSet(true, false), 250);
}
}
}
}

View file

@ -0,0 +1,33 @@
package app.revanced.extension.youtube.patches;
import java.util.List;
import app.revanced.extension.shared.Utils;
import app.revanced.extension.shared.ConversionContext.ContextInterface;
@SuppressWarnings("unused")
public class LazilyConvertedElementPatch {
private static final String LAZILY_CONVERTED_ELEMENT = "LazilyConvertedElement";
/**
* Injection point.
*/
public static void onTreeNodeResultLoaded(ContextInterface contextInterface, List<Object> treeNodeResultList) {
if (treeNodeResultList == null || treeNodeResultList.isEmpty()) {
return;
}
String firstElement = treeNodeResultList.get(0).toString();
if (!LAZILY_CONVERTED_ELEMENT.equals(firstElement)) {
return;
}
String identifier = contextInterface.patch_getIdentifier();
if (Utils.isNotEmpty(identifier)) {
onLazilyConvertedElementLoaded(identifier, treeNodeResultList);
}
}
private static void onLazilyConvertedElementLoaded(String identifier, List<Object> treeNodeResultList) {
// Code added by patch.
}
}

View file

@ -0,0 +1,60 @@
package app.revanced.extension.youtube.patches;
import android.content.Intent;
import android.net.Uri;
import androidx.annotation.Nullable;
import app.revanced.extension.shared.Utils;
import app.revanced.extension.youtube.settings.Settings;
@SuppressWarnings("unused")
public class OverrideOpenInYouTubeMusicButtonPatch {
private static final String YOUTUBE_MUSIC_PACKAGE_NAME = "com.google.android.apps.youtube.music";
private static final Boolean overrideButton = Settings.OVERRIDE_OPEN_IN_YOUTUBE_MUSIC_BUTTON.get();
private static final String overridePackageName = getOverridePackageName();
@SuppressWarnings("SameReturnValue")
public static String getOverridePackageName() {
return ""; // Value is replaced during patching.
}
public static @Nullable Intent overrideSetPackage(@Nullable Intent intent, @Nullable String packageName) {
if (intent == null || !overrideButton) return intent;
if (YOUTUBE_MUSIC_PACKAGE_NAME.equals(packageName)) {
if (Utils.isNotEmpty(overridePackageName) && Utils.isPackageEnabled(overridePackageName)) {
return intent.setPackage(overridePackageName);
}
return intent.setPackage(null);
}
return intent.setPackage(packageName);
}
public static @Nullable Intent overrideSetData(@Nullable Intent intent, @Nullable Uri uri) {
if (intent == null || uri == null || !overrideButton) return intent;
String uriString = uri.toString();
if (uriString.contains(YOUTUBE_MUSIC_PACKAGE_NAME)) {
if ("market".equals(uri.getScheme()) || uriString.contains("play.google.com/store/apps")) {
intent.setData(Uri.parse("https://music.youtube.com/"));
if (Utils.isNotEmpty(overridePackageName) && Utils.isPackageEnabled(overridePackageName)) {
intent.setPackage(overridePackageName);
} else {
intent.setPackage(null);
}
return intent;
}
}
return intent.setData(uri);
}
}

View file

@ -54,6 +54,6 @@ public class PlayerControlsPatch {
// noinspection EmptyMethod
private static void fullscreenButtonVisibilityChanged(boolean isVisible) {
// Code added during patching.
// Code added by patch.
}
}

View file

@ -19,24 +19,25 @@ import app.revanced.extension.shared.Utils;
import app.revanced.extension.youtube.patches.litho.ReturnYouTubeDislikeFilter;
import app.revanced.extension.youtube.returnyoutubedislike.ReturnYouTubeDislike;
import app.revanced.extension.youtube.settings.Settings;
import app.revanced.extension.shared.ConversionContext.ContextInterface;
import app.revanced.extension.youtube.shared.PlayerType;
/**
* Handles all interaction of UI patch components.
*
* <p>
* Known limitation:
* The implementation of Shorts litho requires blocking the loading the first Short until RYD has completed.
* This is because it modifies the dislikes text synchronously, and if the RYD fetch has
* not completed yet then the UI will be temporarily frozen.
*
* <p>
* A (yet to be implemented) solution that fixes this problem. Any one of:
* - Modify patch to hook onto the Shorts Litho TextView, and update the dislikes text asynchronously.
* - Find a way to force Litho to rebuild it's component tree,
* and use that hook to force the shorts dislikes to update after the fetch is completed.
* and use that hook to force the shorts dislikes to update after the fetch is completed.
* - Hook into the dislikes button image view, and replace the dislikes thumb down image with a
* generated image of the number of dislikes, then update the image asynchronously. This Could
* also be used for the regular video player to give a better UI layout and completely remove
* the need for the Rolling Number patches.
* generated image of the number of dislikes, then update the image asynchronously. This Could
* also be used for the regular video player to give a better UI layout and completely remove
* the need for the Rolling Number patches.
*/
@SuppressWarnings("unused")
public class ReturnYouTubeDislikePatch {
@ -100,7 +101,7 @@ public class ReturnYouTubeDislikePatch {
/**
* Injection point.
*
* <p>
* Logs if new litho text layout is used.
*/
public static boolean useNewLithoTextCreation(boolean useNewLithoTextCreation) {
@ -113,28 +114,28 @@ public class ReturnYouTubeDislikePatch {
/**
* Injection point.
*
* <p>
* For Litho segmented buttons and Litho Shorts player.
*/
@NonNull
public static CharSequence onLithoTextLoaded(@NonNull Object conversionContext,
@NonNull CharSequence original) {
return onLithoTextLoaded(conversionContext, original, false);
public static CharSequence onLithoTextLoaded(ContextInterface contextInterface,
CharSequence original) {
return onLithoTextLoaded(contextInterface, original, false);
}
/**
* Called when a litho text component is initially created,
* and also when a Span is later reused again (such as scrolling off/on screen).
*
* <p>
* This method is sometimes called on the main thread, but it is usually called _off_ the main thread.
* This method can be called multiple times for the same UI element (including after dislikes was added).
*
* @param original Original char sequence was created or reused by Litho.
* @param original Original char sequence was created or reused by Litho.
* @param isRollingNumber If the span is for a Rolling Number.
* @return The original char sequence (if nothing should change), or a replacement char sequence that contains dislikes.
*/
@NonNull
private static CharSequence onLithoTextLoaded(@NonNull Object conversionContext,
private static CharSequence onLithoTextLoaded(ContextInterface contextInterface,
@NonNull CharSequence original,
boolean isRollingNumber) {
try {
@ -142,17 +143,16 @@ public class ReturnYouTubeDislikePatch {
return original;
}
String conversionContextString = conversionContext.toString();
if (Settings.RYD_ENABLED.get()) { // FIXME: Remove this.
Logger.printDebug(() -> "RYD conversion context: " + conversionContext);
}
if (isRollingNumber && !conversionContextString.contains("video_action_bar.e")) {
String identifier = contextInterface.patch_getIdentifier();
if (isRollingNumber && (identifier == null || !identifier.contains("video_action_bar.e"))) {
return original;
}
if (conversionContextString.contains("segmented_like_dislike_button.e")) {
StringBuilder pathBuilder = contextInterface.patch_getPathBuilder();
String path = pathBuilder.toString();
if (path.contains("segmented_like_dislike_button.e")) {
// Regular video.
ReturnYouTubeDislike videoData = currentVideoData;
if (videoData == null) {
@ -169,13 +169,11 @@ public class ReturnYouTubeDislikePatch {
return original; // No need to check for Shorts in the context.
}
if (Utils.containsAny(conversionContextString,
"|shorts_dislike_button.e", "|reel_dislike_button.e")) {
if (Utils.containsAny(path, "|shorts_dislike_button.e", "|reel_dislike_button.e")) {
return getShortsSpan(original, true);
}
if (Utils.containsAny(conversionContextString,
"|shorts_like_button.e", "|reel_like_button.e")) {
if (Utils.containsAny(path, "|shorts_like_button.e", "|reel_like_button.e")) {
if (!Utils.containsNumber(original)) {
Logger.printDebug(() -> "Replacing hidden likes count");
return getShortsSpan(original, false);
@ -230,10 +228,9 @@ public class ReturnYouTubeDislikePatch {
/**
* Injection point.
*/
public static String onRollingNumberLoaded(@NonNull Object conversionContext,
@NonNull String original) {
public static String onRollingNumberLoaded(ContextInterface contextInterface, String original) {
try {
CharSequence replacement = onLithoTextLoaded(conversionContext, original, true);
CharSequence replacement = onLithoTextLoaded(contextInterface, original, true);
String replacementString = replacement.toString();
if (!replacementString.equals(original)) {
@ -248,7 +245,7 @@ public class ReturnYouTubeDislikePatch {
/**
* Injection point.
*
* <p>
* Called for all usage of Rolling Number.
* Modifies the measured String text width to include the left separator and padding, if needed.
*/
@ -489,7 +486,7 @@ public class ReturnYouTubeDislikePatch {
/**
* Injection point.
*
* <p>
* Called when the user likes or dislikes.
*
* @param vote int that matches {@link Vote#value}

View file

@ -1,5 +1,7 @@
package app.revanced.extension.youtube.patches;
import static app.revanced.extension.youtube.patches.VersionCheckPatch.IS_21_10_OR_GREATER;
import android.app.Activity;
import java.lang.ref.WeakReference;
@ -24,7 +26,12 @@ public class ShortsAutoplayPatch {
/**
* Pause playback after 1 play.
*/
END_SCREEN;
END_SCREEN,
/**
* Play once, then advanced to the next Short.
* Only found in 21.10+
*/
AUTO_ADVANCE;
static void setYTEnumValue(Enum<?> ytBehavior) {
for (ShortsLoopBehavior rvBehavior : values()) {
@ -93,8 +100,12 @@ public class ShortsAutoplayPatch {
autoplay = Settings.SHORTS_AUTOPLAY.get();
}
ShortsLoopBehavior autoPlayBehavior = IS_21_10_OR_GREATER
? ShortsLoopBehavior.AUTO_ADVANCE
: ShortsLoopBehavior.SINGLE_PLAY;
Enum<?> overrideBehavior = (autoplay
? ShortsLoopBehavior.SINGLE_PLAY
? autoPlayBehavior
: ShortsLoopBehavior.REPEAT).ytEnumValue;
if (overrideBehavior != null) {

View file

@ -27,4 +27,6 @@ public class VersionCheckPatch {
public static final boolean IS_20_31_OR_GREATER = isVersionOrGreater("20.31.00");
public static final boolean IS_20_37_OR_GREATER = isVersionOrGreater("20.37.00");
public static final boolean IS_21_10_OR_GREATER = isVersionOrGreater("21.10.00");
}

View file

@ -4,23 +4,22 @@ import app.revanced.extension.youtube.settings.Settings;
@SuppressWarnings("unused")
public class VideoAdsPatch {
private static final boolean SHOW_VIDEO_ADS = !Settings.HIDE_VIDEO_ADS.get();
private static final boolean HIDE_VIDEO_ADS = Settings.HIDE_VIDEO_ADS.get();
/**
* Injection point.
*/
public static boolean shouldShowAds() {
return SHOW_VIDEO_ADS;
public static boolean hideVideoAds() {
return HIDE_VIDEO_ADS;
}
/**
* Injection point.
*/
public static String hideShortsAds(String osName) {
return SHOW_VIDEO_ADS
? osName
: "Android Automotive";
public static String hideVideoAds(String osName) {
return HIDE_VIDEO_ADS
? "Android Automotive"
: osName;
}
}

View file

@ -12,6 +12,7 @@ import androidx.annotation.Nullable;
import java.util.List;
import app.revanced.extension.shared.ConversionContext.ContextInterface;
import app.revanced.extension.shared.patches.litho.Filter;
import app.revanced.extension.shared.Logger;
import app.revanced.extension.shared.Utils;
@ -167,8 +168,14 @@ public final class AdsFilter extends Filter {
}
@Override
public boolean isFiltered(String identifier, String accessibility, String path, byte[] buffer,
StringFilterGroup matchedGroup, FilterContentType contentType, int contentIndex) {
public boolean isFiltered(ContextInterface contextInterface,
String identifier,
String accessibility,
String path,
byte[] buffer,
StringFilterGroup matchedGroup,
FilterContentType contentType,
int contentIndex) {
if (matchedGroup == buyMovieAd) {
return contentIndex == 0 && buyMovieAdBuffer.check(buffer).isFiltered();
}

View file

@ -1,5 +1,7 @@
package app.revanced.extension.youtube.patches.litho;
import app.revanced.extension.shared.ConversionContext;
import app.revanced.extension.shared.ConversionContext.ContextInterface;
import app.revanced.extension.shared.patches.litho.Filter;
import app.revanced.extension.shared.patches.litho.FilterGroup.StringFilterGroup;
import app.revanced.extension.youtube.patches.playback.quality.AdvancedVideoQualityMenuPatch;
@ -21,8 +23,14 @@ public final class AdvancedVideoQualityMenuFilter extends Filter {
}
@Override
public boolean isFiltered(String identifier, String accessibility, String path, byte[] buffer,
StringFilterGroup matchedGroup, FilterContentType contentType, int contentIndex) {
public boolean isFiltered(ContextInterface contextInterface,
String identifier,
String accessibility,
String path,
byte[] buffer,
StringFilterGroup matchedGroup,
FilterContentType contentType,
int contentIndex) {
isVideoQualityMenuVisible = true;
return false;

View file

@ -1,5 +1,11 @@
package app.revanced.extension.youtube.patches.litho;
import androidx.annotation.NonNull;
import java.util.List;
import app.revanced.extension.shared.ConversionContext.ContextInterface;
import app.revanced.extension.shared.Logger;
import app.revanced.extension.shared.patches.litho.Filter;
import app.revanced.extension.shared.patches.litho.FilterGroup.*;
import app.revanced.extension.youtube.settings.Settings;
@ -8,11 +14,10 @@ import app.revanced.extension.youtube.shared.PlayerType;
@SuppressWarnings("unused")
public final class CommentsFilter extends Filter {
private static final String CHIP_BAR_PATH_PREFIX = "chip_bar.e";
private static final String COMMENT_COMPOSER_PATH = "comment_composer.e";
private static final String VIDEO_LOCKUP_WITH_ATTACHMENT_PATH = "video_lockup_with_attachment.e";
private final StringFilterGroup chipBar;
private final ByteArrayFilterGroup aiCommentsSummary;
private final StringFilterGroup comments;
private final StringFilterGroup emojiAndTimestampButtons;
@ -22,16 +27,6 @@ public final class CommentsFilter extends Filter {
"live_chat_summary_banner.e"
);
chipBar = new StringFilterGroup(
Settings.HIDE_COMMENTS_AI_SUMMARY,
"chip_bar.e"
);
aiCommentsSummary = new ByteArrayFilterGroup(
null,
"yt_fill_spark_"
);
var channelGuidelines = new StringFilterGroup(
Settings.HIDE_COMMENTS_CHANNEL_GUIDELINES,
"channel_guidelines_entry_banner"
@ -79,7 +74,6 @@ public final class CommentsFilter extends Filter {
addPathCallbacks(
channelGuidelines,
chatSummary,
chipBar,
commentsByMembers,
comments,
communityGuidelines,
@ -92,25 +86,44 @@ public final class CommentsFilter extends Filter {
}
@Override
public boolean isFiltered(String identifier, String accessibility, String path, byte[] buffer,
StringFilterGroup matchedGroup, FilterContentType contentType, int contentIndex) {
if (matchedGroup == chipBar) {
// Playlist sort button uses same components and must only filter if the player is opened.
return PlayerType.getCurrent().isMaximizedOrFullscreen()
&& aiCommentsSummary.check(buffer).isFiltered();
}
public boolean isFiltered(ContextInterface contextInterface,
String identifier,
String accessibility,
String path,
byte[] buffer,
StringFilterGroup matchedGroup,
FilterContentType contentType,
int contentIndex) {
if (matchedGroup == comments) {
if (path.startsWith(VIDEO_LOCKUP_WITH_ATTACHMENT_PATH)) {
return Settings.HIDE_COMMENTS_SECTION_IN_HOME_FEED.get();
}
return Settings.HIDE_COMMENTS_SECTION.get();
}
if (matchedGroup == emojiAndTimestampButtons) {
} else if (matchedGroup == emojiAndTimestampButtons) {
return path.startsWith(COMMENT_COMPOSER_PATH);
}
return true;
}
/**
* Injection point.
*/
public static void sanitizeCommentsCategoryBar(@NonNull String identifier,
@NonNull List<Object> treeNodeResultList) {
try {
if (Settings.SANITIZE_COMMENTS_CATEGORY_BAR.get()
&& identifier.startsWith(CHIP_BAR_PATH_PREFIX)
// Playlist sort button uses same components and must only filter if the player is opened.
&& PlayerType.getCurrent().isMaximizedOrFullscreen()
) {
int treeNodeResultListSize = treeNodeResultList.size();
if (treeNodeResultListSize > 2) {
treeNodeResultList.subList(1, treeNodeResultListSize - 1).clear();
}
}
} catch (Exception ex) {
Logger.printException(() -> "Failed to sanitize comment category bar", ex);
}
}
}

View file

@ -1,5 +1,6 @@
package app.revanced.extension.youtube.patches.litho;
import app.revanced.extension.shared.ConversionContext.ContextInterface;
import app.revanced.extension.shared.patches.litho.Filter;
import app.revanced.extension.youtube.settings.Settings;
import app.revanced.extension.shared.patches.litho.FilterGroup.*;
@ -57,12 +58,13 @@ public final class DescriptionComponentsFilter extends Filter {
playlistSectionGroupList.addAll(
new ByteArrayFilterGroup(
Settings.HIDE_EXPLORE_COURSE_SECTION,
"yt_outline_creator_academy", // For Disable bold icons.
"yt_outline_creator_academy",
"yt_outline_experimental_graduation_cap"
),
new ByteArrayFilterGroup(
Settings.HIDE_EXPLORE_PODCAST_SECTION,
"FEpodcasts_destination"
"FEpodcasts_destination",
"yt_outline_experimental_podcast"
)
);
@ -132,28 +134,17 @@ public final class DescriptionComponentsFilter extends Filter {
}
@Override
public boolean isFiltered(String identifier, String accessibility, String path, byte[] buffer,
StringFilterGroup matchedGroup, FilterContentType contentType, int contentIndex) {
// The description panel can be opened in both the regular player and Shorts.
// If the description panel is opened in a Shorts, PlayerType is 'HIDDEN',
// so 'PlayerType.getCurrent().isMaximizedOrFullscreen()' does not guarantee that the description panel is open.
// Instead, use the engagement id to check if the description panel is opened.
if (!EngagementPanel.isDescription()
// The user can minimize the player while the engagement panel is open.
//
// In this case, the engagement panel is treated as open.
// (If the player is dismissed, the engagement panel is considered closed)
//
// Therefore, the following exceptions can occur:
// 1. The user opened a regular video and opened the description panel.
// 2. The 'horizontalShelf' elements were hidden.
// 3. The user minimized the player.
// 4. The user manually refreshed the library tab without dismissing the player.
// 5. Since the engagement panel is treated as open, the history shelf is filtered.
//
// To handle these exceptions, filtering is not performed even when the player is minimized.
|| PlayerType.getCurrent() == PlayerType.WATCH_WHILE_MINIMIZED
) {
public boolean isFiltered(ContextInterface contextInterface,
String identifier,
String accessibility,
String path,
byte[] buffer,
StringFilterGroup matchedGroup,
FilterContentType contentType,
int contentIndex) {
// Immediately after the layout is refreshed, litho components are updated before the UI is drawn.
// In this case, EngagementPanel.isDescription() cannot be used, and isActionBarVisible.get() should be used.
if (!EngagementPanel.isDescription() && !(PlayerType.getCurrent().isMaximizedOrFullscreen() || isActionBarVisible.get() || ShortsPlayerState.isOpen())) {
return false;
}

View file

@ -0,0 +1,100 @@
package app.revanced.extension.youtube.patches.litho;
import static app.revanced.extension.youtube.patches.LayoutReloadObserverPatch.isActionBarVisible;
import app.revanced.extension.shared.ConversionContext.ContextInterface;
import app.revanced.extension.shared.patches.litho.Filter;
import app.revanced.extension.shared.patches.litho.FilterGroup.ByteArrayFilterGroup;
import app.revanced.extension.shared.patches.litho.FilterGroup.StringFilterGroup;
import app.revanced.extension.shared.patches.litho.FilterGroupList.ByteArrayFilterGroupList;
import app.revanced.extension.youtube.settings.Settings;
import app.revanced.extension.youtube.shared.EngagementPanel;
import app.revanced.extension.youtube.shared.NavigationBar;
import app.revanced.extension.youtube.shared.NavigationBar.NavigationButton;
import app.revanced.extension.youtube.shared.PlayerType;
@SuppressWarnings("unused")
public final class HorizontalShelvesFilter extends Filter {
private final ByteArrayFilterGroupList descriptionBuffers = new ByteArrayFilterGroupList();
private final ByteArrayFilterGroupList generalBuffers = new ByteArrayFilterGroupList();
public HorizontalShelvesFilter() {
StringFilterGroup horizontalShelves = new StringFilterGroup(null, "horizontal_shelf.e");
addPathCallbacks(horizontalShelves);
descriptionBuffers.addAll(
new ByteArrayFilterGroup(
Settings.HIDE_ATTRIBUTES_SECTION,
// May no longer work on v20.31+, even though the component is still there.
"cell_video_attribute"
),
new ByteArrayFilterGroup(
Settings.HIDE_FEATURED_PLACES_SECTION,
"yt_fill_experimental_star",
"yt_fill_star"
),
new ByteArrayFilterGroup(
Settings.HIDE_GAMING_SECTION,
"yt_outline_experimental_gaming",
"yt_outline_gaming"
),
new ByteArrayFilterGroup(
Settings.HIDE_MUSIC_SECTION,
"yt_outline_experimental_audio",
"yt_outline_audio"
),
new ByteArrayFilterGroup(
Settings.HIDE_QUIZZES_SECTION,
"post_base_wrapper_slim"
)
);
generalBuffers.addAll(
new ByteArrayFilterGroup(
Settings.HIDE_CREATOR_STORE_SHELF,
"shopping_item_card_list"
),
new ByteArrayFilterGroup(
Settings.HIDE_PLAYABLES,
"FEmini_app_destination"
),
new ByteArrayFilterGroup(
Settings.HIDE_TICKET_SHELF,
"ticket_item.e"
)
);
}
private boolean hideShelves(ContextInterface contextInterface) {
if (!Settings.HIDE_HORIZONTAL_SHELVES.get()) {
return false;
}
return contextInterface.isHomeFeedOrRelatedVideo()
|| PlayerType.getCurrent().isMaximizedOrFullscreen()
|| isActionBarVisible.get()
|| NavigationBar.isSearchBarActive()
|| NavigationBar.isBackButtonVisible()
|| NavigationButton.getSelectedNavigationButton() != NavigationButton.LIBRARY;
}
@Override
public boolean isFiltered(ContextInterface contextInterface,
String identifier,
String accessibility,
String path,
byte[] buffer,
StringFilterGroup matchedGroup,
FilterContentType contentType,
int contentIndex) {
if (contentIndex != 0) {
return false;
}
if (generalBuffers.check(buffer).isFiltered()) {
return true;
}
if (descriptionBuffers.check(buffer).isFiltered()) {
return EngagementPanel.isDescription() || PlayerType.getCurrent().isMaximizedOrFullscreen() || isActionBarVisible.get() || ShortsPlayerState.isOpen();
}
return hideShelves(contextInterface);
}
}

View file

@ -12,6 +12,8 @@ import java.util.LinkedHashMap;
import java.util.Map;
import java.util.concurrent.atomic.AtomicReference;
import app.revanced.extension.shared.ConversionContext;
import app.revanced.extension.shared.ConversionContext.ContextInterface;
import app.revanced.extension.shared.Logger;
import app.revanced.extension.shared.Utils;
import app.revanced.extension.shared.ByteTrieSearch;
@ -47,7 +49,7 @@ public final class KeywordContentFilter extends Filter {
/**
* Strings found in the buffer for every video. Full strings should be specified.
*
* <p>
* This list does not include every common buffer string, and this can be added/changed as needed.
* Words must be entered with the exact casing as found in the buffer.
*/
@ -122,7 +124,7 @@ public final class KeywordContentFilter extends Filter {
/**
* Path components to not filter. Cannot filter the buffer when these are present,
* otherwise text in UI controls can be filtered as a keyword (such as using "Playlist" as a keyword).
*
* <p>
* This is also a small performance improvement since
* the buffer of the parent component was already searched and passed.
*/
@ -156,10 +158,10 @@ public final class KeywordContentFilter extends Filter {
* Rolling average of how many videos were filtered by a keyword.
* Used to detect if a keyword passes the initial check against {@link #STRINGS_IN_EVERY_BUFFER}
* but a keyword is still hiding all videos.
*
* <p>
* This check can still fail if some extra UI elements pass the keywords,
* such as the video chapter preview or any other elements.
*
* <p>
* To test this, add a filter that appears in all videos (such as 'ovd='),
* and open the subscription feed. In practice this does not always identify problems
* in the home feed and search, because the home feed has a finite amount of content and
@ -226,7 +228,7 @@ public final class KeywordContentFilter extends Filter {
* @return If the string contains any characters from languages that do not use spaces between words.
*/
private static boolean isLanguageWithNoSpaces(String text) {
for (int i = 0, length = text.length(); i < length;) {
for (int i = 0, length = text.length(); i < length; ) {
final int codePoint = text.codePointAt(i);
Character.UnicodeBlock block = Character.UnicodeBlock.of(codePoint);
@ -277,7 +279,7 @@ public final class KeywordContentFilter extends Filter {
/**
* @return If the start and end indexes are not surrounded by other letters.
* If the indexes are surrounded by numbers/symbols/punctuation it is considered a whole word.
* If the indexes are surrounded by numbers/symbols/punctuation it is considered a whole word.
*/
private static boolean keywordMatchIsWholeWord(byte[] text, int keywordStartIndex, int keywordLength) {
final Integer codePointBefore = getUtf8CodePointBefore(text, keywordStartIndex);
@ -296,7 +298,7 @@ public final class KeywordContentFilter extends Filter {
/**
* @return The UTF8 character point immediately before the index,
* or null if the bytes before the index is not a valid UTF8 character.
* or null if the bytes before the index is not a valid UTF8 character.
*/
@Nullable
private static Integer getUtf8CodePointBefore(byte[] data, int index) {
@ -312,7 +314,7 @@ public final class KeywordContentFilter extends Filter {
/**
* @return The UTF8 character point at the index,
* or null if the index holds no valid UTF8 character.
* or null if the index holds no valid UTF8 character.
*/
@Nullable
private static Integer getUtf8CodePointAt(byte[] data, int index) {
@ -528,7 +530,7 @@ public final class KeywordContentFilter extends Filter {
}
return switch (selectedNavButton) {
case HOME, EXPLORE -> hideHome;
case HOME -> hideHome;
case SUBSCRIPTIONS -> hideSubscriptions;
// User is in the Library or notifications.
default -> false;
@ -556,8 +558,14 @@ public final class KeywordContentFilter extends Filter {
}
@Override
public boolean isFiltered(String identifier, String accessibility, String path, byte[] buffer,
StringFilterGroup matchedGroup, FilterContentType contentType, int contentIndex) {
public boolean isFiltered(ContextInterface contextInterface,
String identifier,
String accessibility,
String path,
byte[] buffer,
StringFilterGroup matchedGroup,
FilterContentType contentType,
int contentIndex) {
if (contentIndex != 0 && matchedGroup == startsWithFilter) {
return false;
}

View file

@ -7,7 +7,6 @@ import android.graphics.drawable.Drawable;
import android.text.SpannableString;
import android.text.SpannableStringBuilder;
import android.text.TextUtils;
import android.util.Pair;
import android.view.View;
import android.view.ViewGroup;
import android.widget.FrameLayout;
@ -16,16 +15,13 @@ import android.widget.TextView;
import androidx.annotation.Nullable;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.atomic.AtomicReference;
import app.revanced.extension.shared.ByteTrieSearch;
import app.revanced.extension.shared.ConversionContext.ContextInterface;
import app.revanced.extension.shared.Logger;
import app.revanced.extension.shared.StringTrieSearch;
import app.revanced.extension.shared.Utils;
import app.revanced.extension.shared.settings.BooleanSetting;
import app.revanced.extension.shared.patches.litho.Filter;
import app.revanced.extension.shared.patches.litho.FilterGroup.ByteArrayFilterGroup;
import app.revanced.extension.shared.patches.litho.FilterGroup.StringFilterGroup;
@ -38,10 +34,6 @@ import app.revanced.extension.youtube.shared.PlayerType;
@SuppressWarnings("unused")
public final class LayoutComponentsFilter extends Filter {
private static final StringTrieSearch mixPlaylistsContextExceptions = new StringTrieSearch(
"V.ED", // Playlist browseId.
"java.lang.ref.WeakReference"
);
private static final ByteArrayFilterGroup mixPlaylistsBufferExceptions = new ByteArrayFilterGroup(
null,
"cell_description_body",
@ -84,11 +76,6 @@ public final class LayoutComponentsFilter extends Filter {
private final StringFilterGroup chipBar;
private final StringFilterGroup channelProfile;
private final StringFilterGroupList channelProfileGroupList;
private final StringFilterGroup horizontalShelves;
private final ByteArrayFilterGroup playablesBuffer;
private final ByteArrayFilterGroup ticketShelfBuffer;
private final ByteArrayFilterGroup playerShoppingShelfBuffer;
private final ByteTrieSearch descriptionSearch;
public LayoutComponentsFilter() {
exceptions.addPatterns(
@ -254,7 +241,7 @@ public final class LayoutComponentsFilter extends Filter {
);
final var relatedVideos = new StringFilterGroup(
Settings.HIDE_RELATED_VIDEOS,
Settings.HIDE_QUICK_ACTIONS_RELATED_VIDEOS,
"fullscreen_related_videos"
);
@ -264,12 +251,6 @@ public final class LayoutComponentsFilter extends Filter {
"mini_game_card.e"
);
// Playable horizontal shelf header.
playablesBuffer = new ByteArrayFilterGroup(
null,
"FEmini_app_destination"
);
final var quickActions = new StringFilterGroup(
Settings.HIDE_QUICK_ACTIONS,
"quick_actions"
@ -317,7 +298,7 @@ public final class LayoutComponentsFilter extends Filter {
);
final var forYouShelf = new StringFilterGroup(
Settings.HIDE_FOR_YOU_SHELF,
Settings.HIDE_HORIZONTAL_SHELVES,
"mixed_content_shelf"
);
@ -361,51 +342,6 @@ public final class LayoutComponentsFilter extends Filter {
)
);
horizontalShelves = new StringFilterGroup(
null, // Setting is checked in isFiltered()
"horizontal_video_shelf.e",
"horizontal_shelf.e",
"horizontal_shelf_inline.e",
"horizontal_tile_shelf.e"
);
ticketShelfBuffer = new ByteArrayFilterGroup(
null,
"ticket_item.e"
);
playerShoppingShelfBuffer = new ByteArrayFilterGroup(
null,
"shopping_item_card_list"
);
// Work around for unique situation where filtering is based on the setting,
// but it must not fall over to other filters if the setting is _not_ enabled.
// This is only needed for the horizontal shelf that is used so extensively everywhere.
descriptionSearch = new ByteTrieSearch();
List.of(
new Pair<>(Settings.HIDE_FEATURED_PLACES_SECTION, "yt_fill_star"),
new Pair<>(Settings.HIDE_FEATURED_PLACES_SECTION, "yt_fill_experimental_star"),
new Pair<>(Settings.HIDE_GAMING_SECTION, "yt_outline_gaming"),
new Pair<>(Settings.HIDE_GAMING_SECTION, "yt_outline_experimental_gaming"),
new Pair<>(Settings.HIDE_MUSIC_SECTION, "yt_outline_audio"),
new Pair<>(Settings.HIDE_MUSIC_SECTION, "yt_outline_experimental_audio"),
new Pair<>(Settings.HIDE_QUIZZES_SECTION, "post_base_wrapper_slim"),
// May no longer work on v20.31+, even though the component is still there.
new Pair<>(Settings.HIDE_ATTRIBUTES_SECTION, "cell_video_attribute")
).forEach(pair -> {
BooleanSetting setting = pair.first;
descriptionSearch.addPattern(pair.second.getBytes(StandardCharsets.UTF_8),
(textSearched, matchedStartIndex, matchedLength, callbackParameter) -> {
//noinspection unchecked
AtomicReference<Boolean> hide = (AtomicReference<Boolean>) callbackParameter;
hide.set(setting.get());
return true;
}
);
}
);
addPathCallbacks(
artistCard,
audioTrackButton,
@ -422,7 +358,6 @@ public final class LayoutComponentsFilter extends Filter {
emergencyBox,
expandableMetadata,
forYouShelf,
horizontalShelves,
imageShelf,
infoPanel,
latestPosts,
@ -445,8 +380,14 @@ public final class LayoutComponentsFilter extends Filter {
}
@Override
public boolean isFiltered(String identifier, String accessibility, String path, byte[] buffer,
StringFilterGroup matchedGroup, FilterContentType contentType, int contentIndex) {
public boolean isFiltered(ContextInterface contextInterface,
String identifier,
String accessibility,
String path,
byte[] buffer,
StringFilterGroup matchedGroup,
FilterContentType contentType,
int contentIndex) {
// This identifier is used not only in players but also in search results:
// https://github.com/ReVanced/revanced-patches/issues/3245
// Until 2024, medical information panels such as Covid-19 also used this identifier and were shown in the search results.
@ -466,13 +407,8 @@ public final class LayoutComponentsFilter extends Filter {
return channelProfileGroupList.check(accessibility).isFiltered();
}
if (matchedGroup == communityPosts
&& NavigationBar.isBackButtonVisible()
&& !NavigationBar.isSearchBarActive()
&& PlayerType.getCurrent() != PlayerType.WATCH_WHILE_MAXIMIZED) {
// Allow community posts on channel profile page,
// or if viewing an individual channel in the feed.
return false;
if (matchedGroup == communityPosts) {
return contextInterface.isHomeFeedOrRelatedVideo() || contextInterface.isSubscriptionOrLibrary();
}
if (exceptions.matches(path)) return false; // Exceptions are not filtered.
@ -484,47 +420,6 @@ public final class LayoutComponentsFilter extends Filter {
&& joinMembershipButton.check(buffer).isFiltered();
}
// Horizontal shelves are used everywhere in the app. And to prevent the generic "hide shelves"
// from incorrectly hiding other stuff that has its own hide filters,
// the more specific shelf filters must check first _and_ they must halt falling over
// to other filters if the buffer matches but the setting is off.
if (matchedGroup == horizontalShelves) {
if (contentIndex != 0) return false;
AtomicReference<Boolean> descriptionFilterResult = new AtomicReference<>(null);
if (descriptionSearch.matches(buffer, descriptionFilterResult)) {
return descriptionFilterResult.get();
}
// Check if others are off before searching.
final boolean hideShelves = Settings.HIDE_HORIZONTAL_SHELVES.get();
final boolean hideTickets = Settings.HIDE_TICKET_SHELF.get();
final boolean hidePlayables = Settings.HIDE_PLAYABLES.get();
final boolean hidePlayerShoppingShelf = Settings.HIDE_CREATOR_STORE_SHELF.get();
if (!hideShelves && !hideTickets && !hidePlayables && !hidePlayerShoppingShelf)
return false;
if (ticketShelfBuffer.check(buffer).isFiltered()) return hideTickets;
if (playablesBuffer.check(buffer).isFiltered()) return hidePlayables;
if (playerShoppingShelfBuffer.check(buffer).isFiltered())
return hidePlayerShoppingShelf;
// 20.31+ when exiting fullscreen after watching for a while or when resuming the app,
// then sometimes the buffer isn't correct and the player shopping shelf is shown.
// If filtering reaches this point then there are no more shelves that could be in the player.
// If shopping shelves are set to hidden and the player is active, then assume
// it's the shopping shelf.
if (hidePlayerShoppingShelf) {
PlayerType type = PlayerType.getCurrent();
if (type == PlayerType.WATCH_WHILE_MAXIMIZED || type == PlayerType.WATCH_WHILE_FULLSCREEN
|| type == PlayerType.WATCH_WHILE_SLIDING_MAXIMIZED_FULLSCREEN) {
return true;
}
}
return hideShelves && hideShelves();
}
if (matchedGroup == chipBar) {
return contentIndex == 0 && NavigationButton.getSelectedNavigationButton() == NavigationButton.LIBRARY;
}
@ -536,7 +431,7 @@ public final class LayoutComponentsFilter extends Filter {
* Injection point.
* Called from a different place then the other filters.
*/
public static boolean filterMixPlaylists(Object conversionContext, @Nullable byte[] buffer) {
public static boolean filterMixPlaylists(@Nullable byte[] buffer) {
// Edit: This hook may no longer be needed, and mix playlist filtering
// might be possible using the existing litho filters.
try {
@ -551,13 +446,7 @@ public final class LayoutComponentsFilter extends Filter {
if (mixPlaylists.check(buffer).isFiltered()
// Prevent hiding the description of some videos accidentally.
&& !mixPlaylistsBufferExceptions.check(buffer).isFiltered()
// Prevent playlist items being hidden, if a mix playlist is present in it.
// Check last since it requires creating a context string.
//
// FIXME: The conversion context passed in does not always generate a valid toString.
// This string check may no longer be needed, or the patch may be broken.
&& !mixPlaylistsContextExceptions.matches(conversionContext.toString())) {
&& !mixPlaylistsBufferExceptions.check(buffer).isFiltered()) {
Logger.printDebug(() -> "Filtered mix playlist");
return true;
}
@ -593,6 +482,7 @@ public final class LayoutComponentsFilter extends Filter {
* Injection point.
*/
public static boolean hideFloatingMicrophoneButton(final boolean original) {
// FIXME? Is this feature still relevant? When/where does this microphone appear?
return original || Settings.HIDE_FLOATING_MICROPHONE_BUTTON.get();
}
@ -757,31 +647,6 @@ public final class LayoutComponentsFilter extends Filter {
: original;
}
private static boolean hideShelves() {
// Horizontal shelves are used for music/game links in video descriptions,
// such as https://youtube.com/watch?v=W8kI1na3S2M
if (PlayerType.getCurrent().isMaximizedOrFullscreen()) {
return false;
}
// Must check search bar after player type, since search results
// can be in the background behind an open player.
if (NavigationBar.isSearchBarActive()) {
return true;
}
// Do not hide if the navigation back button is visible,
// otherwise the content shelves in the explore/music/courses pages are hidden.
if (NavigationBar.isBackButtonVisible()) {
return false;
}
// Check navigation button last.
// Only filter if the library tab is not selected.
// This check is important as the shelf layout is used for the library tab playlists.
return NavigationButton.getSelectedNavigationButton() != NavigationButton.LIBRARY;
}
/**
* Injection point.
*/
@ -919,8 +784,8 @@ public final class LayoutComponentsFilter extends Filter {
/**
* Injection point.
*
* @param typedString Keywords typed in the search bar.
* @return Whether the setting is enabled and the typed string is empty.
* @param typedString Keywords typed in the search bar.
* @return Whether the setting is enabled and the typed string is empty.
*/
public static boolean hideYouMayLikeSection(String typedString) {
return Settings.HIDE_YOU_MAY_LIKE_SECTION.get()
@ -932,13 +797,13 @@ public final class LayoutComponentsFilter extends Filter {
/**
* Injection point.
*
* @param searchTerm This class contains information related to search terms.
* The {@code toString()} method of this class overrides the search term.
* @param endpoint Endpoint related with the search term.
* For search history, this value is:
* '/complete/deleteitems?client=youtube-android-pb&delq=${searchTerm}&deltok=${token}'.
* For search suggestions, this value is null or empty.
* @return Whether search term is a search history or not.
* @param searchTerm This class contains information related to search terms.
* The {@code toString()} method of this class overrides the search term.
* @param endpoint Endpoint related with the search term.
* For search history, this value is:
* '/complete/deleteitems?client=youtube-android-pb&delq=${searchTerm}&deltok=${token}'.
* For search suggestions, this value is null or empty.
* @return Whether search term is a search history or not.
*/
public static boolean isSearchHistory(Object searchTerm, String endpoint) {
boolean isSearchHistory = endpoint != null && endpoint.contains("/delete");

View file

@ -1,5 +1,6 @@
package app.revanced.extension.youtube.patches.litho;
import app.revanced.extension.shared.ConversionContext.ContextInterface;
import app.revanced.extension.shared.patches.litho.Filter;
import app.revanced.extension.shared.patches.litho.FilterGroup.*;
import app.revanced.extension.youtube.patches.playback.speed.CustomPlaybackSpeedPatch;
@ -38,8 +39,14 @@ public final class PlaybackSpeedMenuFilter extends Filter {
}
@Override
public boolean isFiltered(String identifier, String accessibility, String path, byte[] buffer,
StringFilterGroup matchedGroup, FilterContentType contentType, int contentIndex) {
public boolean isFiltered(ContextInterface contextInterface,
String identifier,
String accessibility,
String path,
byte[] buffer,
StringFilterGroup matchedGroup,
FilterContentType contentType,
int contentIndex) {
if (matchedGroup == oldPlaybackMenuGroup) {
isOldPlaybackSpeedMenuVisible = true;
} else {

View file

@ -1,5 +1,6 @@
package app.revanced.extension.youtube.patches.litho;
import app.revanced.extension.shared.ConversionContext.ContextInterface;
import app.revanced.extension.shared.patches.litho.Filter;
import app.revanced.extension.shared.patches.litho.FilterGroup.ByteArrayFilterGroup;
import app.revanced.extension.shared.patches.litho.FilterGroup.StringFilterGroup;
@ -112,8 +113,14 @@ public final class PlayerFlyoutMenuItemsFilter extends Filter {
}
@Override
public boolean isFiltered(String identifier, String accessibility, String path, byte[] buffer,
StringFilterGroup matchedGroup, FilterContentType contentType, int contentIndex) {
public boolean isFiltered(ContextInterface contextInterface,
String identifier,
String accessibility,
String path,
byte[] buffer,
StringFilterGroup matchedGroup,
FilterContentType contentType,
int contentIndex) {
if (matchedGroup == videoQualityMenuFooter) {
return true;
}
@ -127,6 +134,9 @@ public final class PlayerFlyoutMenuItemsFilter extends Filter {
return false;
}
// 21.x+ fix.
if (path.contains("bottom_sheet_list_option.e")) return false;
return flyoutFilterGroupList.check(buffer).isFiltered();
}
}

View file

@ -7,6 +7,8 @@ import androidx.annotation.Nullable;
import java.util.LinkedHashSet;
import java.util.Map;
import app.revanced.extension.shared.ConversionContext;
import app.revanced.extension.shared.ConversionContext.ContextInterface;
import app.revanced.extension.shared.Logger;
import app.revanced.extension.shared.TrieSearch;
import app.revanced.extension.shared.Utils;
@ -19,14 +21,14 @@ import app.revanced.extension.youtube.settings.Settings;
/**
* Searches for video IDs in the proto buffer of Shorts dislike.
*
* <p>
* Because multiple litho dislike spans are created in the background
* (and also anytime litho refreshes the components, which is somewhat arbitrary),
* that makes the value of {@link VideoInformation#getVideoId()} and {@link VideoInformation#getPlayerResponseVideoId()}
* unreliable to determine which video ID a Shorts litho span belongs to.
*
* <p>
* But the correct video ID does appear in the protobuffer just before a Shorts litho span is created.
*
* <p>
* Once a way to asynchronously update litho text is found, this strategy will no longer be needed.
*/
public final class ReturnYouTubeDislikeFilter extends Filter {
@ -88,8 +90,14 @@ public final class ReturnYouTubeDislikeFilter extends Filter {
}
@Override
public boolean isFiltered(String identifier, String accessibility, String path, byte[] buffer,
StringFilterGroup matchedGroup, FilterContentType contentType, int contentIndex) {
public boolean isFiltered(ContextInterface contextInterface,
String identifier,
String accessibility,
String path,
byte[] buffer,
StringFilterGroup matchedGroup,
FilterContentType contentType,
int contentIndex) {
if (!Settings.RYD_ENABLED.get() || !Settings.RYD_SHORTS.get()) {
return false;
}

View file

@ -1,26 +1,25 @@
package app.revanced.extension.youtube.patches.litho;
import static app.revanced.extension.shared.ConversionContext.*;
import static app.revanced.extension.youtube.patches.LayoutReloadObserverPatch.isActionBarVisible;
import static app.revanced.extension.youtube.shared.NavigationBar.NavigationButton;
import android.view.View;
import app.revanced.extension.shared.Utils;
import app.revanced.extension.shared.ConversionContext;
import app.revanced.extension.shared.patches.litho.Filter;
import app.revanced.extension.shared.patches.litho.FilterGroup.*;
import app.revanced.extension.shared.patches.litho.FilterGroup.ByteArrayFilterGroup;
import app.revanced.extension.shared.patches.litho.FilterGroupList.ByteArrayFilterGroupList;
import app.revanced.extension.shared.patches.litho.LithoFilterPatch;
import app.revanced.extension.shared.settings.BooleanSetting;
import app.revanced.extension.shared.patches.litho.FilterGroupList.StringFilterGroupList;
import com.google.android.libraries.youtube.rendering.ui.pivotbar.PivotBar;
import java.lang.ref.WeakReference;
import java.util.Arrays;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import app.revanced.extension.shared.Logger;
import app.revanced.extension.youtube.patches.VersionCheckPatch;
import app.revanced.extension.youtube.settings.Settings;
import app.revanced.extension.youtube.shared.EngagementPanel;
import app.revanced.extension.youtube.shared.NavigationBar;
@ -30,20 +29,6 @@ import app.revanced.extension.youtube.shared.PlayerType;
public final class ShortsFilter extends Filter {
private static final boolean HIDE_SHORTS_NAVIGATION_BAR = Settings.HIDE_SHORTS_NAVIGATION_BAR.get();
private static final String COMPONENT_TYPE = "ComponentType";
private static final String[] REEL_ACTION_BAR_PATHS = {
"reel_action_bar.", // Regular Shorts.
"reels_player_overlay_layout." // Shorts ads.
};
private static final Map<Integer, BooleanSetting> REEL_ACTION_BUTTONS_MAP = new HashMap<>() {
{
// Like button and Dislike button can be hidden with Litho filter.
// put(0, Settings.HIDE_SHORTS_LIKE_BUTTON);
// put(1, Settings.HIDE_SHORTS_DISLIKE_BUTTON);
put(2, Settings.HIDE_SHORTS_COMMENTS_BUTTON);
put(3, Settings.HIDE_SHORTS_SHARE_BUTTON);
put(4, Settings.HIDE_SHORTS_REMIX_BUTTON);
}
};
private final String REEL_CHANNEL_BAR_PATH = "reel_channel_bar.e";
/**
@ -71,11 +56,7 @@ public final class ShortsFilter extends Filter {
private final StringFilterGroup shortsCompactFeedVideo;
private final ByteArrayFilterGroup shortsCompactFeedVideoBuffer;
private final StringFilterGroup channelProfile;
private final ByteArrayFilterGroup channelProfileShelfHeaderBuffer;
private final StringFilterGroup useSoundButton;
private final ByteArrayFilterGroup useSoundButtonBuffer;
private final StringFilterGroup useTemplateButton;
private final ByteArrayFilterGroup useTemplateButtonBuffer;
private final ByteArrayFilterGroup channelProfileShelfHeader;
private final StringFilterGroup autoDubbedLabel;
private final StringFilterGroup subscribeButton;
@ -90,9 +71,12 @@ public final class ShortsFilter extends Filter {
private final StringFilterGroup suggestedAction;
private final ByteArrayFilterGroupList suggestedActionsBuffer = new ByteArrayFilterGroupList();
private final StringFilterGroup useButtons;
private final ByteArrayFilterGroupList useButtonsBuffer = new ByteArrayFilterGroupList();
private final StringFilterGroup shortsActionBar;
private final StringFilterGroup videoActionButton;
private final ByteArrayFilterGroupList videoActionButtonBuffer = new ByteArrayFilterGroupList();
private final StringFilterGroup shortsActionButton;
private final StringFilterGroupList shortsActionButtonGroupList = new StringFilterGroupList();
public ShortsFilter() {
//
@ -112,7 +96,7 @@ public final class ShortsFilter extends Filter {
"shorts_pivot_item"
);
channelProfileShelfHeaderBuffer = new ByteArrayFilterGroup(
channelProfileShelfHeader = new ByteArrayFilterGroup(
Settings.HIDE_SHORTS_CHANNEL,
"Shorts"
);
@ -270,32 +254,7 @@ public final class ShortsFilter extends Filter {
)
);
useSoundButton = new StringFilterGroup(
Settings.HIDE_SHORTS_USE_SOUND_BUTTON,
// First filter needed for "Use this sound" that can appear when viewing Shorts
// through the "Short remixing this video" section.
"floating_action_button.e",
// Second filter needed for "Use this sound" that can appear below the video title.
REEL_METAPANEL_PATH
);
useSoundButtonBuffer = new ByteArrayFilterGroup(
null,
"yt_outline_camera_"
);
useTemplateButton = new StringFilterGroup(
Settings.HIDE_SHORTS_USE_TEMPLATE_BUTTON,
// Second filter needed for "Use this template" that can appear below the video title.
REEL_METAPANEL_PATH
);
useTemplateButtonBuffer = new ByteArrayFilterGroup(
null,
"yt_outline_template_add_"
);
videoActionButton = new StringFilterGroup(
shortsActionButton = new StringFilterGroup(
null,
// Can be any of:
// button.eml
@ -305,6 +264,26 @@ public final class ShortsFilter extends Filter {
"button.e"
);
useButtons = new StringFilterGroup(
null,
REEL_PLAYER_OVERLAY_PATH,
REEL_METAPANEL_PATH,
"floating_action_button.e"
);
useButtonsBuffer.addAll(
new ByteArrayFilterGroup(
Settings.HIDE_SHORTS_USE_SOUND_BUTTON,
"yt_outline_camera_",
"yt_outline_experimental_camera_"
),
new ByteArrayFilterGroup(
Settings.HIDE_SHORTS_USE_TEMPLATE_BUTTON,
"yt_outline_template_add_",
"yt_outline_experimental_template_add_"
)
);
suggestedAction = new StringFilterGroup(
null,
"suggested_action.e"
@ -313,37 +292,27 @@ public final class ShortsFilter extends Filter {
addPathCallbacks(
shortsCompactFeedVideo, shelfHeaderPath, joinButton, subscribeButton, paidPromotionLabel,
livePreview, suggestedAction, pausedOverlayButtons, channelBar, infoPanel, previewComment,
autoDubbedLabel, fullVideoLinkLabel, videoTitle, useSoundButton, soundButton, stickers,
reelCarousel, reelSoundMetadata, likeFountain, likeButton, dislikeButton
autoDubbedLabel, fullVideoLinkLabel, videoTitle, soundButton, stickers, useButtons,
reelCarousel, reelSoundMetadata, likeFountain, likeButton, dislikeButton, shortsActionBar
);
// Legacy hiding of Shorts action buttons. Because of 20.31+ buffer changes
// it's currently not possible to hide these using buffer filtering.
// See alternative hiding strategy in hideActionButtons().
if (!VersionCheckPatch.IS_20_22_OR_GREATER) {
addPathCallbacks(shortsActionBar);
//
// All other action buttons.
//
videoActionButtonBuffer.addAll(
new ByteArrayFilterGroup(
Settings.HIDE_SHORTS_COMMENTS_BUTTON,
"reel_comment_button",
"youtube_shorts_comment_outline"
),
new ByteArrayFilterGroup(
Settings.HIDE_SHORTS_SHARE_BUTTON,
"reel_share_button",
"youtube_shorts_share_outline"
),
new ByteArrayFilterGroup(
Settings.HIDE_SHORTS_REMIX_BUTTON,
"reel_remix_button",
"youtube_shorts_remix_outline"
)
);
}
//
// All other action buttons.
//
shortsActionButtonGroupList.addAll(
new StringFilterGroup(
Settings.HIDE_SHORTS_COMMENTS_BUTTON,
"id.reel_comment_button"
),
new StringFilterGroup(
Settings.HIDE_SHORTS_SHARE_BUTTON,
"id.reel_share_button"
),
new StringFilterGroup(
Settings.HIDE_SHORTS_REMIX_BUTTON,
"id.reel_remix_button"
)
);
//
// Suggested actions.
@ -434,8 +403,14 @@ public final class ShortsFilter extends Filter {
}
@Override
public boolean isFiltered(String identifier, String accessibility, String path, byte[] buffer,
StringFilterGroup matchedGroup, FilterContentType contentType, int contentIndex) {
public boolean isFiltered(ContextInterface contextInterface,
String identifier,
String accessibility,
String path,
byte[] buffer,
StringFilterGroup matchedGroup,
FilterContentType contentType,
int contentIndex) {
if (contentType == FilterContentType.IDENTIFIER) {
if (matchedGroup == shelfHeaderIdentifier) {
// Shelf header reused in history/channel/etc.
@ -443,8 +418,12 @@ public final class ShortsFilter extends Filter {
if (contentIndex != 0) {
return false;
}
}
if (matchedGroup == channelProfile) {
// Check ConversationContext to not hide shelf header in channel profile
// This value does not exist in the shelf header in the channel profile
if (!contextInterface.isHomeFeedOrRelatedVideo()) {
return false;
}
} else if (matchedGroup == channelProfile) {
return true;
}
@ -463,16 +442,16 @@ public final class ShortsFilter extends Filter {
return reelCarouselBuffer.check(buffer).isFiltered();
}
if (matchedGroup == useSoundButton) {
return useSoundButtonBuffer.check(buffer).isFiltered();
}
if (matchedGroup == useTemplateButton) {
return useTemplateButtonBuffer.check(buffer).isFiltered();
}
if (matchedGroup == shortsCompactFeedVideo) {
return shouldHideShortsFeedItems() && shortsCompactFeedVideoBuffer.check(buffer).isFiltered();
return shouldHideShortsFeedItems()
&& shortsCompactFeedVideoBuffer.check(buffer).isFiltered()
// The litho path of the feed video is 'video_lockup_with_attachment.e'.
// It appears shortsCompactFeedVideoBuffer is used after 20 seconds during autoplay in the feed in YouTube 20.44.38.
// If the Shorts shelf is hidden on the Home feed, the video in the feed will be hidden after 20 seconds have passed since autoplay began in the feed.
//
// When a video is autoplaying in the feed, no new components are drawn on the screen.
// Therefore, filtering is skipped when the current PlayerType is [INLINE_MINIMAL].
&& PlayerType.getCurrent() != PlayerType.INLINE_MINIMAL;
}
if (matchedGroup == shelfHeaderPath) {
@ -481,8 +460,10 @@ public final class ShortsFilter extends Filter {
if (contentIndex != 0) {
return false;
}
if (!channelProfileShelfHeaderBuffer.check(buffer).isFiltered()) {
return false;
// Check ConversationContext to not hide shelf header in channel profile
// This value does not exist in the shelf header in the channel profile
if (!contextInterface.isHomeFeedOrRelatedVideo()) {
return channelProfileShelfHeader.check(buffer).isFiltered();
}
return shouldHideShortsFeedItems();
@ -491,8 +472,14 @@ public final class ShortsFilter extends Filter {
// Video action buttons (comment, share, remix) have the same path.
// Like and dislike are separate path filters and don't require buffer searching.
if (matchedGroup == shortsActionBar) {
return videoActionButton.check(path).isFiltered()
&& videoActionButtonBuffer.check(buffer).isFiltered();
if (shortsActionButton.check(path).isFiltered()) {
return shortsActionButtonGroupList.check(accessibility).isFiltered();
}
return false;
}
if (matchedGroup == useButtons) {
return path.contains("|button.e") && useButtonsBuffer.check(buffer).isFiltered();
}
if (matchedGroup == suggestedAction) {
@ -534,7 +521,7 @@ public final class ShortsFilter extends Filter {
}
// Must check player type first, as search bar can be active behind the player.
if (PlayerType.getCurrent().isMaximizedOrFullscreen()) {
if (PlayerType.getCurrent().isMaximizedOrFullscreen() || isActionBarVisible.get()) {
return EngagementPanel.isDescription()
? hideVideoDescription // Player video description panel opened.
: hideHome; // For now, consider Shorts under video player the same as the home feed.
@ -557,65 +544,13 @@ public final class ShortsFilter extends Filter {
}
return switch (selectedNavButton) {
case HOME, EXPLORE -> hideHome;
case HOME -> hideHome;
case SUBSCRIPTIONS -> hideSubscriptions;
case LIBRARY -> hideHistory;
default -> false;
};
}
/**
* Injection point.
* <p>
* Hide action buttons by index.
* <p>
* Regular video action buttons vary in order by video, country, and account.
* Therefore, hiding buttons by index may hide unintended buttons.
* <p>
* Shorts action buttons are almost always in the same order.
* (From top to bottom: Like, Dislike, Comment, Share, Remix).
* Therefore, we can hide Shorts action buttons by index.
*
* @param pathBuilder Same as pathBuilder used in {@link LithoFilterPatch}.
* @param treeNodeResultList List containing Litho components.
*/
public static void hideActionButtons(StringBuilder pathBuilder, List<Object> treeNodeResultList) {
try {
if (pathBuilder == null || pathBuilder.length() == 0 || treeNodeResultList == null) {
return;
}
int size = treeNodeResultList.size();
// The minimum size of the target List is 4.
if (size < 4) {
return;
}
String path = pathBuilder.toString();
if (!Utils.containsAny(path, REEL_ACTION_BAR_PATHS)
// Regular Shorts: [ComponentType, ComponentType, ComponentType, ComponentType, ComponentType]
// Shorts ads: [ComponentType, ComponentType, ComponentType, ComponentType] (No Remix button)
|| !COMPONENT_TYPE.equals(treeNodeResultList.get(0).toString())) {
return;
}
// Removing elements without iterating through the list in reverse order will throw an exception.
for (int i = size - 1; i > -1; i--) {
// treeNodeResult is each button.
Object treeNodeResult = treeNodeResultList.get(i);
if (treeNodeResult != null) {
BooleanSetting setting = REEL_ACTION_BUTTONS_MAP.get(i);
if (setting != null && setting.get()) {
int finalI = i;
Logger.printDebug(() -> "Hiding action button by index: " + finalI + ", key: " + setting.key);
treeNodeResultList.remove(i);
}
}
}
} catch (Exception ex) {
Logger.printException(() -> "hideActionButtons failed", ex);
}
}
/**
* Injection point.
*/

View file

@ -1,5 +1,7 @@
package app.revanced.extension.youtube.patches.litho;
import app.revanced.extension.shared.ConversionContext;
import app.revanced.extension.shared.ConversionContext.ContextInterface;
import app.revanced.extension.shared.patches.litho.Filter;
import app.revanced.extension.shared.patches.litho.FilterGroup.ByteArrayFilterGroup;
import app.revanced.extension.shared.patches.litho.FilterGroup.StringFilterGroup;
@ -158,8 +160,14 @@ public final class VideoActionButtonsFilter extends Filter {
}
@Override
public boolean isFiltered(String identifier, String accessibility, String path, byte[] buffer,
StringFilterGroup matchedGroup, FilterContentType contentType, int contentIndex) {
public boolean isFiltered(ContextInterface contextInterface,
String identifier,
String accessibility,
String path,
byte[] buffer,
StringFilterGroup matchedGroup,
FilterContentType contentType,
int contentIndex) {
if (matchedGroup == likeSubscribeGlow) {
return path.startsWith(VIDEO_ACTION_BAR_PATH_PREFIX) || path.startsWith(COMPACT_CHANNEL_BAR_PATH_PREFIX)
|| path.startsWith(COMPACTIFY_VIDEO_ACTION_BAR_PATH);

View file

@ -11,8 +11,9 @@ import app.revanced.extension.youtube.patches.VideoInformation;
import app.revanced.extension.youtube.patches.VideoInformation.*;
import app.revanced.extension.youtube.settings.Settings;
import app.revanced.extension.youtube.shared.ShortsPlayerState;
import j$.util.Optional;
@SuppressWarnings("unused")
@SuppressWarnings({"rawtypes", "unused"})
public class RememberVideoQualityPatch {
private static final IntegerSetting videoQualityWifi = Settings.VIDEO_QUALITY_DEFAULT_WIFI;
@ -66,6 +67,25 @@ public class RememberVideoQualityPatch {
}
}
/**
* Injection point.
* <p>
* Overrides the initial video quality to not follow the 'Video quality preferences' in YouTube settings.
* (e.g. 'Auto (recommended)' - 360p/480p, 'Higher picture quality' - 720p/1080p...)
* If the maximum video quality available is 1080p and the default video quality is 2160p,
* 1080p is used as an initial video quality.
* <p>
* Called before {@link #newVideoStarted(VideoInformation.PlaybackController)}.
*/
public static Optional getInitialVideoQuality(Optional optional) {
int preferredQuality = getDefaultQualityResolution();
if (preferredQuality != VideoInformation.AUTOMATIC_VIDEO_QUALITY_VALUE) {
Logger.printDebug(() -> "initialVideoQuality: " + preferredQuality);
return Optional.of(preferredQuality);
}
return optional;
}
/**
* Injection point.
* @param userSelectedQualityIndex Element index of {@link VideoInformation#getCurrentQualities()}.

View file

@ -60,6 +60,11 @@ public class CustomPlaybackSpeedPatch {
*/
private static final float PROGRESS_BAR_VALUE_SCALE = 100;
/**
* Disable tap and hold speed, true when TAP_AND_HOLD_SPEED is 0.
*/
private static final boolean DISABLE_TAP_AND_HOLD_SPEED;
/**
* Tap and hold speed.
*/
@ -76,9 +81,9 @@ public class CustomPlaybackSpeedPatch {
private static final float customPlaybackSpeedsMin, customPlaybackSpeedsMax;
/**
* The last time the old playback menu was forcefully called.
* The last time the playback menu was forcefully called.
*/
private static volatile long lastTimeOldPlaybackMenuInvoked;
private static volatile long lastTimePlaybackMenuInvoked;
/**
* Formats speeds to UI strings.
@ -96,7 +101,12 @@ public class CustomPlaybackSpeedPatch {
speedFormatter.setMaximumFractionDigits(2);
final float holdSpeed = Settings.SPEED_TAP_AND_HOLD.get();
if (holdSpeed > 0 && holdSpeed <= PLAYBACK_SPEED_MAXIMUM) {
DISABLE_TAP_AND_HOLD_SPEED = holdSpeed == 0;
if (DISABLE_TAP_AND_HOLD_SPEED) {
// A value for handling exceptions, but this is not used.
TAP_AND_HOLD_SPEED = Settings.SPEED_TAP_AND_HOLD.defaultValue;
} else if (holdSpeed > 0 && holdSpeed <= PLAYBACK_SPEED_MAXIMUM) {
TAP_AND_HOLD_SPEED = holdSpeed;
} else {
showInvalidCustomSpeedToast();
@ -108,6 +118,22 @@ public class CustomPlaybackSpeedPatch {
customPlaybackSpeedsMax = customPlaybackSpeeds[customPlaybackSpeeds.length - 1];
}
/**
* Injection point.
* Called before {@link #getTapAndHoldSpeed()}
*/
public static boolean disableTapAndHoldSpeed(boolean original) {
return !DISABLE_TAP_AND_HOLD_SPEED && original;
}
/**
* Injection point.
*/
public static boolean useNewFlyoutMenu(boolean useNewFlyout) {
// If using old speed turn off A/B flyout that breaks old playback speed menu.
return useNewFlyout && !Settings.RESTORE_OLD_SPEED_MENU.get();
}
/**
* Injection point.
*/
@ -212,6 +238,15 @@ public class CustomPlaybackSpeedPatch {
return false;
}
// This method is sometimes used multiple times.
// To prevent this, ignore method reuse within 1 second.
final long now = System.currentTimeMillis();
if (now - lastTimePlaybackMenuInvoked < 1000) {
Logger.printDebug(() -> "Ignoring call to hideLithoMenuAndShowSpeedMenu");
return true;
}
lastTimePlaybackMenuInvoked = now;
// Dismiss View [R.id.touch_outside] is the 1st ChildView of the 4th ParentView.
// This only shows in phone layout.
var touchInsidedView = parentView4th.getChildAt(0);
@ -235,16 +270,8 @@ public class CustomPlaybackSpeedPatch {
}
public static void showOldPlaybackSpeedMenu() {
// This method is sometimes used multiple times.
// To prevent this, ignore method reuse within 1 second.
final long now = System.currentTimeMillis();
if (now - lastTimeOldPlaybackMenuInvoked < 1000) {
Logger.printDebug(() -> "Ignoring call to showOldPlaybackSpeedMenu");
return;
}
lastTimeOldPlaybackMenuInvoked = now;
// Rest of the implementation added by patch.
Logger.printDebug(() -> "showOldPlaybackSpeedMenu");
}
/**

View file

@ -147,7 +147,6 @@ public class Settings extends YouTubeAndMusicSettings {
public static final BooleanSetting HIDE_CHANNEL_TAB = new BooleanSetting("revanced_hide_channel_tab", FALSE);
public static final StringSetting HIDE_CHANNEL_TAB_FILTER_STRINGS = new StringSetting("revanced_hide_channel_tab_filter_strings", "", true, parent(HIDE_CHANNEL_TAB));
public static final BooleanSetting HIDE_COMMUNITY_BUTTON = new BooleanSetting("revanced_hide_community_button", TRUE);
public static final BooleanSetting HIDE_FOR_YOU_SHELF = new BooleanSetting("revanced_hide_for_you_shelf", FALSE);
public static final BooleanSetting HIDE_JOIN_BUTTON = new BooleanSetting("revanced_hide_join_button", FALSE);
public static final BooleanSetting HIDE_LINKS_PREVIEW = new BooleanSetting("revanced_hide_links_preview", TRUE);
public static final BooleanSetting HIDE_MEMBERS_SHELF = new BooleanSetting("revanced_hide_members_shelf", TRUE);
@ -188,8 +187,8 @@ public class Settings extends YouTubeAndMusicSettings {
public static final BooleanSetting HIDE_PLAYER_CONTROL_BUTTONS_BACKGROUND = new BooleanSetting("revanced_hide_player_control_buttons_background", FALSE, true);
public static final BooleanSetting HIDE_PLAYER_PREVIOUS_NEXT_BUTTONS = new BooleanSetting("revanced_hide_player_previous_next_buttons", FALSE, true);
public static final BooleanSetting HIDE_QUICK_ACTIONS = new BooleanSetting("revanced_hide_quick_actions", FALSE);
public static final BooleanSetting HIDE_RELATED_VIDEOS_OVERLAY = new BooleanSetting("revanced_hide_related_videos_overlay", FALSE, true);
public static final BooleanSetting HIDE_RELATED_VIDEOS = new BooleanSetting("revanced_hide_related_videos", FALSE);
public static final BooleanSetting HIDE_PLAYER_RELATED_VIDEOS_OVERLAY = new BooleanSetting("revanced_hide_player_related_videos_overlay", FALSE, true);
public static final BooleanSetting HIDE_QUICK_ACTIONS_RELATED_VIDEOS = new BooleanSetting("revanced_hide_quick_actions_related_videos", FALSE);
public static final BooleanSetting HIDE_SUBSCRIBERS_COMMUNITY_GUIDELINES = new BooleanSetting("revanced_hide_subscribers_community_guidelines", TRUE);
public static final BooleanSetting HIDE_TIMED_REACTIONS = new BooleanSetting("revanced_hide_timed_reactions", TRUE);
public static final BooleanSetting HIDE_VIDEO_TITLE = new BooleanSetting("revanced_hide_video_title", FALSE);
@ -228,6 +227,7 @@ public class Settings extends YouTubeAndMusicSettings {
public static final BooleanSetting HIDE_COMMENTS_SECTION = new BooleanSetting("revanced_hide_comments_section", FALSE);
public static final BooleanSetting HIDE_COMMENTS_SECTION_IN_HOME_FEED = new BooleanSetting("revanced_hide_comments_section_in_home_feed", FALSE, parentNot(HIDE_COMMENTS_SECTION));
public static final BooleanSetting HIDE_COMMENTS_THANKS_BUTTON = new BooleanSetting("revanced_hide_comments_thanks_button", TRUE);
public static final BooleanSetting SANITIZE_COMMENTS_CATEGORY_BAR = new BooleanSetting("revanced_sanitize_comments_category_bar", FALSE);
// Description
public static final BooleanSetting HIDE_AI_GENERATED_VIDEO_SUMMARY_SECTION = new BooleanSetting("revanced_hide_ai_generated_video_summary_section", FALSE);
@ -296,6 +296,7 @@ public class Settings extends YouTubeAndMusicSettings {
public static final BooleanSetting REMOVE_VIEWER_DISCRETION_DIALOG = new BooleanSetting("revanced_remove_viewer_discretion_dialog", FALSE,
"revanced_remove_viewer_discretion_dialog_user_dialog_message");
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 BooleanSetting OVERRIDE_OPEN_IN_YOUTUBE_MUSIC_BUTTON = new BooleanSetting("revanced_override_open_in_youtube_music_button", TRUE, true);
public static final EnumSetting<StartPage> CHANGE_START_PAGE = new EnumSetting<>("revanced_change_start_page", StartPage.DEFAULT, true);
public static final BooleanSetting CHANGE_START_PAGE_ALWAYS = new BooleanSetting("revanced_change_start_page_always", FALSE, true,
new ChangeStartPageTypeAvailability());
@ -323,7 +324,7 @@ public class Settings extends YouTubeAndMusicSettings {
public static final BooleanSetting WIDE_SEARCHBAR = new BooleanSetting("revanced_wide_searchbar", FALSE, true);
// Shorts
public static final BooleanSetting DISABLE_RESUMING_SHORTS_PLAYER = new BooleanSetting("revanced_disable_resuming_shorts_player", FALSE);
public static final BooleanSetting DISABLE_RESUMING_SHORTS_ON_STARTUP = new BooleanSetting("revanced_disable_resuming_shorts_on_startup", FALSE);
public static final BooleanSetting DISABLE_SHORTS_BACKGROUND_PLAYBACK = new BooleanSetting("revanced_shorts_disable_background_playback", FALSE);
public static final EnumSetting<ShortsPlayerType> SHORTS_PLAYER_TYPE = new EnumSetting<>("revanced_shorts_player_type", ShortsPlayerType.SHORTS_PLAYER);
public static final BooleanSetting HIDE_SHORTS_AI_BUTTON = new BooleanSetting("revanced_hide_shorts_ai_button", FALSE);
@ -391,7 +392,7 @@ public class Settings extends YouTubeAndMusicSettings {
public static final BooleanSetting EXTERNAL_BROWSER = new BooleanSetting("revanced_external_browser", TRUE, true);
public static final BooleanSetting SPOOF_DEVICE_DIMENSIONS = new BooleanSetting("revanced_spoof_device_dimensions", FALSE, true,
"revanced_spoof_device_dimensions_user_dialog_message");
public static final EnumSetting<ClientType> SPOOF_VIDEO_STREAMS_CLIENT_TYPE = new EnumSetting<>("revanced_spoof_video_streams_client_type", ClientType.ANDROID_VR_1_43_32, true, parent(SPOOF_VIDEO_STREAMS));
public static final EnumSetting<ClientType> SPOOF_VIDEO_STREAMS_CLIENT_TYPE = new EnumSetting<>("revanced_spoof_video_streams_client_type", ClientType.ANDROID_REEL, true, parent(SPOOF_VIDEO_STREAMS));
public static final BooleanSetting SPOOF_VIDEO_STREAMS_AV1 = new BooleanSetting("revanced_spoof_video_streams_av1", FALSE, true,
"revanced_spoof_video_streams_av1_user_dialog_message", new SpoofClientAv1Availability());

View file

@ -310,10 +310,6 @@ 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

@ -138,5 +138,6 @@ enum class PlayerType {
fun isMaximizedOrFullscreen(): Boolean {
return this == WATCH_WHILE_MAXIMIZED || this == WATCH_WHILE_FULLSCREEN
|| this == WATCH_WHILE_SLIDING_MAXIMIZED_FULLSCREEN
}
}

View file

@ -2,8 +2,8 @@ package app.revanced.extension.youtube.sponsorblock;
import static app.revanced.extension.shared.StringRef.str;
import android.app.Activity;
import android.app.Dialog;
import android.content.Context;
import android.util.Pair;
import android.util.Patterns;
import android.widget.LinearLayout;
@ -34,12 +34,12 @@ public class SponsorBlockSettings {
public static final Setting.ImportExportCallback SB_IMPORT_EXPORT_CALLBACK = new Setting.ImportExportCallback() {
@Override
public void settingsImported(@Nullable Context context) {
public void settingsImported(@Nullable Activity context) {
SegmentCategory.loadAllCategoriesFromSettings();
SponsorBlockPreferenceGroup.settingsImported = true;
}
@Override
public void settingsExported(@Nullable Context context) {
public void settingsExported(@Nullable Activity context) {
showExportWarningIfNeeded(context);
}
};
@ -184,16 +184,16 @@ public class SponsorBlockSettings {
/**
* Export the categories using flatten JSON (no embedded dictionaries or arrays).
*/
private static void showExportWarningIfNeeded(@Nullable Context dialogContext) {
private static void showExportWarningIfNeeded(@Nullable Activity activity) {
Utils.verifyOnMainThread();
initialize();
// If user has a SponsorBlock user ID then show a warning.
if (dialogContext != null && SponsorBlockSettings.userHasSBPrivateID()
if (activity != null && SponsorBlockSettings.userHasSBPrivateID()
&& !Settings.SB_HIDE_EXPORT_WARNING.get()) {
// Create the custom dialog.
Pair<Dialog, LinearLayout> dialogPair = CustomDialog.create(
dialogContext,
activity,
null, // No title.
str("revanced_sb_settings_revanced_export_user_id_warning"), // Message.
null, // No EditText.
@ -205,11 +205,7 @@ public class SponsorBlockSettings {
true // Dismiss dialog when onNeutralClick.
);
// Set dialog as non-cancelable.
dialogPair.first.setCancelable(false);
// Show the dialog.
dialogPair.first.show();
Utils.showDialog(activity, dialogPair.first, false, null);
}
}

View file

@ -0,0 +1,18 @@
package j$.util;
public final class Optional<T> {
/**
* Returns an {@code Optional} describing the given non-{@code null}
* value.
*
* @param value the value to describe, which must be non-{@code null}
* @param <T> the type of the value
* @return an {@code Optional} with the value present
* @throws NullPointerException if value is {@code null}
*/
public static <T> Optional<T> of(T value) {
return null;
}
}

View file

@ -4,4 +4,4 @@ org.gradle.parallel = true
android.useAndroidX = true
android.uniquePackageNames = false
kotlin.code.style = official
version = 6.1.0
version = 6.1.1-dev.1

View file

@ -29,6 +29,7 @@ val hideMusicVideoAdsPatch = bytecodePatch(
"8.10.52",
"8.37.56",
"8.40.54",
"8.44.54"
),
)

View file

@ -21,6 +21,7 @@ val enableExclusiveAudioPlaybackPatch = bytecodePatch(
"8.10.52",
"8.37.56",
"8.40.54",
"8.44.54"
),
)

View file

@ -32,6 +32,7 @@ val permanentRepeatPatch = bytecodePatch(
"8.10.52",
"8.37.56",
"8.40.54",
"8.44.54"
),
)

View file

@ -6,8 +6,8 @@ import app.revanced.patcher.patch.bytecodePatch
import app.revanced.patches.music.misc.extension.sharedExtensionPatch
import app.revanced.patches.music.misc.gms.Constants.MUSIC_MAIN_ACTIVITY_NAME
import app.revanced.patches.music.misc.gms.Constants.MUSIC_PACKAGE_NAME
import app.revanced.patches.music.misc.gms.musicActivityOnCreateMethod
import app.revanced.patches.music.misc.settings.PreferenceScreen
import app.revanced.patches.music.shared.mainActivityOnCreateMethod
import app.revanced.patches.shared.layout.branding.EXTENSION_CLASS_DESCRIPTOR
import app.revanced.patches.shared.layout.branding.baseCustomBrandingPatch
import app.revanced.patches.shared.misc.mapping.ResourceType
@ -61,11 +61,10 @@ val customBrandingPatch = baseCustomBrandingPatch(
originalAppPackageName = MUSIC_PACKAGE_NAME,
isYouTubeMusic = true,
numberOfPresetAppNames = 5,
getMainActivityOnCreate = { musicActivityOnCreateMethod },
getMainActivityOnCreate = { mainActivityOnCreateMethod },
mainActivityName = MUSIC_MAIN_ACTIVITY_NAME,
activityAliasNameWithIntents = MUSIC_MAIN_ACTIVITY_NAME,
preferenceScreen = PreferenceScreen.GENERAL,
block = {
dependsOn(sharedExtensionPatch, disableSplashAnimationPatch)
@ -75,6 +74,7 @@ val customBrandingPatch = baseCustomBrandingPatch(
"8.10.52",
"8.37.56",
"8.40.54",
"8.44.54"
),
)
},

View file

@ -0,0 +1,79 @@
package app.revanced.patches.music.layout.branding.header
import app.revanced.patcher.extensions.addInstructions
import app.revanced.patcher.extensions.getInstruction
import app.revanced.patcher.extensions.wideLiteral
import app.revanced.patcher.patch.bytecodePatch
import app.revanced.patches.music.misc.settings.PreferenceScreen
import app.revanced.patches.shared.layout.branding.header.changeHeaderPatch
import app.revanced.patches.shared.misc.mapping.ResourceType
import app.revanced.patches.shared.misc.mapping.resourceMappingPatch
import app.revanced.util.forEachInstructionAsSequence
import com.android.tools.smali.dexlib2.iface.instruction.OneRegisterInstruction
private val targetResourceDirectoryNames = mapOf(
"drawable-hdpi" to "121x36 px",
"drawable-xhdpi" to "160x48 px",
"drawable-xxhdpi" to "240x72 px",
"drawable-xxxhdpi" to "320x96 px"
)
private val variants = arrayOf("dark")
private val logoResourceNames = arrayOf(
"revanced_header_minimal",
"revanced_header_rounded",
)
private val headerDrawableNames = arrayOf(
"action_bar_logo_ringo2",
"ytm_logo_ringo2"
)
private const val EXTENSION_CLASS_DESCRIPTOR =
"Lapp/revanced/extension/music/patches/ChangeHeaderPatch;"
private val changeHeaderBytecodePatch = bytecodePatch {
dependsOn(resourceMappingPatch)
apply {
headerDrawableNames.forEach { drawableName ->
val drawableId = ResourceType.DRAWABLE[drawableName]
forEachInstructionAsSequence({ _, method, instruction, index ->
if (instruction.wideLiteral != drawableId) return@forEachInstructionAsSequence null
val register = method.getInstruction<OneRegisterInstruction>(index).registerA
return@forEachInstructionAsSequence index to register
}) { method, (index, register) ->
method.addInstructions(
index + 1,
"""
invoke-static { v$register }, ${EXTENSION_CLASS_DESCRIPTOR}->getHeaderDrawableId(I)I
move-result v$register
""",
)
}
}
}
}
@Suppress("unused")
val changeHeaderPatch = changeHeaderPatch(
targetResourceDirectoryNames = targetResourceDirectoryNames,
changeHeaderBytecodePatch = changeHeaderBytecodePatch,
logoResourceNames = logoResourceNames,
variants = variants,
preferenceScreen = PreferenceScreen.GENERAL,
compatiblePackages = arrayOf(
"com.google.android.apps.youtube.music" to setOf(
"7.29.52",
"8.10.52",
"8.37.56",
"8.40.54",
"8.44.54"
),
),
resourcesAppId = "music",
)

View file

@ -52,6 +52,7 @@ val hideButtonsPatch = bytecodePatch(
"8.10.52",
"8.37.56",
"8.40.54",
"8.44.54"
),
)

View file

@ -35,6 +35,7 @@ val hideCategoryBarPatch = bytecodePatch(
"8.10.52",
"8.37.56",
"8.40.54",
"8.44.54"
),
)

View file

@ -15,7 +15,8 @@ val hideLayoutComponentsPatch = hideLayoutComponentsPatch(
"7.29.52",
"8.10.52",
"8.37.56",
"8.40.54"
"8.40.54",
"8.44.54"
)
),
)

View file

@ -1,6 +1,6 @@
@file:Suppress("SpellCheckingInspection")
package app.revanced.patches.music.layout.miniplayercolor
package app.revanced.patches.music.layout.miniplayer
import app.revanced.patcher.accessFlags
import app.revanced.patcher.extensions.getInstruction
@ -43,11 +43,12 @@ val changeMiniplayerColorPatch = bytecodePatch(
"8.10.52",
"8.37.56",
"8.40.54",
"8.44.54"
),
)
apply {
addResources("music", "layout.miniplayercolor.changeMiniplayerColor")
addResources("music", "layout.miniplayer.changeMiniplayerColor")
PreferenceScreen.PLAYER.addPreferences(
SwitchPreference("revanced_music_change_miniplayer_color"),

View file

@ -1,4 +1,4 @@
package app.revanced.patches.music.layout.miniplayercolor
package app.revanced.patches.music.layout.miniplayer
import app.revanced.patcher.*
import app.revanced.patcher.patch.BytecodePatchContext
@ -28,3 +28,10 @@ internal val ClassDef.switchToggleColorMethodMatch by ClassDefComposing.composin
Opcode.IGET,
)
}
internal val BytecodePatchContext.minimizedPlayerMethod by gettingFirstMethodDeclaratively {
accessFlags(AccessFlags.PUBLIC, AccessFlags.FINAL)
returnType("V")
parameterTypes("L", "L")
instructions("w_st"())
}

View file

@ -0,0 +1,68 @@
@file:Suppress("SpellCheckingInspection")
package app.revanced.patches.music.layout.miniplayer
import app.revanced.patcher.extensions.addInstructions
import app.revanced.patcher.extensions.getInstruction
import app.revanced.patcher.extensions.methodReference
import app.revanced.patcher.patch.bytecodePatch
import app.revanced.patches.all.misc.resources.addResources
import app.revanced.patches.all.misc.resources.addResourcesPatch
import app.revanced.patches.music.misc.extension.sharedExtensionPatch
import app.revanced.patches.music.misc.settings.PreferenceScreen
import app.revanced.patches.music.misc.settings.settingsPatch
import app.revanced.patches.shared.misc.settings.preference.SwitchPreference
import app.revanced.util.indexOfFirstInstructionOrThrow
import com.android.tools.smali.dexlib2.Opcode
import com.android.tools.smali.dexlib2.iface.instruction.OneRegisterInstruction
private const val EXTENSION_CLASS_DESCRIPTOR =
"Lapp/revanced/extension/music/patches/ForciblyEnableMiniplayerPatch;"
@Suppress("unused")
val forciblyEnableMiniplayerPatch = bytecodePatch(
name = "Forcibly enable miniplayer",
description = "Adds an option to forcibly enable the miniplayer when switching between music videos, podcasts, or songs."
) {
dependsOn(
sharedExtensionPatch,
settingsPatch,
addResourcesPatch,
)
compatibleWith(
"com.google.android.apps.youtube.music"(
"7.29.52",
"8.10.52",
"8.37.56",
"8.40.54",
"8.44.54"
),
)
apply {
addResources("music", "layout.miniplayer.forciblyEnableMiniplayer")
PreferenceScreen.PLAYER.addPreferences(
SwitchPreference("revanced_music_forcibly_enable_miniplayer")
)
minimizedPlayerMethod.apply {
val invokeIndex = indexOfFirstInstructionOrThrow {
opcode == Opcode.INVOKE_VIRTUAL && methodReference?.name == "booleanValue"
}
val moveResultIndex = invokeIndex + 1
val moveResultInstr = getInstruction<OneRegisterInstruction>(moveResultIndex)
val targetRegister = moveResultInstr.registerA
addInstructions(
moveResultIndex + 1,
"""
invoke-static { v$targetRegister }, $EXTENSION_CLASS_DESCRIPTOR->forciblyEnableMiniplayerPatch(Z)Z
move-result v$targetRegister
"""
)
}
}
}

View file

@ -57,6 +57,7 @@ val navigationBarPatch = bytecodePatch(
"8.10.52",
"8.37.56",
"8.40.54",
"8.44.54"
),
)

View file

@ -33,6 +33,7 @@ val hideGetMusicPremiumPatch = bytecodePatch(
"8.10.52",
"8.37.56",
"8.40.54",
"8.44.54"
),
)

View file

@ -0,0 +1,114 @@
package app.revanced.patches.music.layout.startpage
import app.revanced.patcher.extensions.addInstruction
import app.revanced.patcher.extensions.addInstructions
import app.revanced.patcher.extensions.fieldReference
import app.revanced.patcher.extensions.getInstruction
import app.revanced.patcher.extensions.instructions
import app.revanced.patcher.extensions.string
import app.revanced.patcher.patch.bytecodePatch
import app.revanced.patches.all.misc.resources.addResources
import app.revanced.patches.all.misc.resources.addResourcesPatch
import app.revanced.patches.music.misc.extension.sharedExtensionPatch
import app.revanced.patches.music.misc.settings.PreferenceScreen
import app.revanced.patches.music.misc.settings.settingsPatch
import app.revanced.patches.music.shared.mainActivityOnCreateMethod
import app.revanced.patches.shared.misc.settings.preference.ListPreference
import app.revanced.patches.shared.misc.settings.preference.PreferenceCategory
import app.revanced.patches.shared.misc.settings.preference.PreferenceScreenPreference
import app.revanced.util.indexOfFirstInstruction
import app.revanced.util.indexOfFirstInstructionReversed
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
private const val EXTENSION_CLASS_DESCRIPTOR =
"Lapp/revanced/extension/music/patches/ChangeStartPagePatch;"
val changeStartPagePatch = bytecodePatch(
name = "Change start page",
description = "Adds an option to set which page the app opens in instead of the homepage.",
) {
dependsOn(
sharedExtensionPatch,
settingsPatch,
addResourcesPatch
)
compatibleWith(
"com.google.android.apps.youtube.music"(
"7.29.52",
"8.10.52",
"8.37.56",
"8.40.54",
"8.44.54"
)
)
apply {
addResources("music", "layout.startpage.changeStartPagePatch")
PreferenceScreen.GENERAL.addPreferences(
PreferenceCategory(
titleKey = null,
sorting = PreferenceScreenPreference.Sorting.UNSORTED,
tag = "app.revanced.extension.shared.settings.preference.NoTitlePreferenceCategory",
preferences = setOf(
ListPreference(
key = "revanced_change_start_page",
tag = "app.revanced.extension.shared.settings.preference.SortedListPreference"
)
)
)
)
coldStartUpMethodMatch.let { match ->
match.method.apply {
val defaultBrowseIdIndex = match[-1]
val browseIdIndex = indexOfFirstInstructionReversed(defaultBrowseIdIndex) {
opcode == Opcode.IGET_OBJECT && fieldReference?.type == "Ljava/lang/String;"
}
if (browseIdIndex != -1) {
val browseIdRegister =
getInstruction<TwoRegisterInstruction>(browseIdIndex).registerA
addInstructions(
browseIdIndex + 1,
"""
invoke-static/range { v$browseIdRegister .. v$browseIdRegister }, $EXTENSION_CLASS_DESCRIPTOR->overrideBrowseId(Ljava/lang/String;)Ljava/lang/String;
move-result-object v$browseIdRegister
"""
)
} else {
instructions.mapIndexedNotNull { index, instr ->
if (instr.opcode == Opcode.RETURN_OBJECT) index else null
}.reversed().forEach { returnIndex ->
val returnRegister =
getInstruction<OneRegisterInstruction>(returnIndex).registerA
addInstructions(
returnIndex,
"""
invoke-static/range { v$returnRegister .. v$returnRegister }, $EXTENSION_CLASS_DESCRIPTOR->overrideBrowseId(Ljava/lang/String;)Ljava/lang/String;
move-result-object v$returnRegister
"""
)
}
}
}
}
mainActivityOnCreateMethod.apply {
val p0 = implementation!!.registerCount - 2
val p1 = p0 + 1
addInstruction(
0,
"invoke-static/range { v$p0 .. v$p1 }, " +
"$EXTENSION_CLASS_DESCRIPTOR->" +
"overrideIntentActionOnCreate(Landroid/app/Activity;Landroid/os/Bundle;)V"
)
}
}
}

View file

@ -0,0 +1,17 @@
package app.revanced.patches.music.layout.startpage
import app.revanced.patcher.composingFirstMethod
import app.revanced.patcher.instructions
import app.revanced.patcher.invoke
import app.revanced.patcher.parameterTypes
import app.revanced.patcher.patch.BytecodePatchContext
import app.revanced.patcher.returnType
internal val BytecodePatchContext.coldStartUpMethodMatch by composingFirstMethod {
returnType("Ljava/lang/String;")
parameterTypes()
instructions(
"FEmusic_library_sideloaded_tracks"(),
"FEmusic_home"()
)
}

View file

@ -7,7 +7,8 @@ import app.revanced.patches.shared.layout.theme.baseThemeResourcePatch
import app.revanced.patches.shared.layout.theme.darkThemeBackgroundColorOption
import app.revanced.patches.shared.misc.settings.overrideThemeColors
private const val EXTENSION_CLASS_DESCRIPTOR = "Lapp/revanced/extension/music/patches/theme/ThemePatch;"
private const val EXTENSION_CLASS_DESCRIPTOR =
"Lapp/revanced/extension/music/patches/theme/ThemePatch;"
@Suppress("unused")
val themePatch = baseThemePatch(
@ -33,7 +34,8 @@ val themePatch = baseThemePatch(
"7.29.52",
"8.10.52",
"8.37.56",
"8.40.54"
"8.40.54",
"8.44.54"
)
)
},

View file

@ -7,14 +7,13 @@ import app.revanced.patcher.invoke
import app.revanced.patcher.parameterTypes
import app.revanced.patcher.patch.BytecodePatchContext
import app.revanced.patcher.returnType
import app.revanced.patcher.strings
import com.android.tools.smali.dexlib2.iface.ClassDef
internal val BytecodePatchContext.checkCertificateMethod by gettingFirstMethodDeclaratively(
"X509",
) {
internal val BytecodePatchContext.checkCertificateMethod by gettingFirstMethodDeclaratively {
returnType("Z")
parameterTypes("Ljava/lang/String;")
instructions("Failed to get certificate"(String::contains))
parameterTypes("L")
strings("X509", "isPartnerSHAFingerprint")
}
internal val BytecodePatchContext.searchMediaItemsConstructorMethod by gettingFirstMethodDeclaratively(

View file

@ -20,6 +20,7 @@ val unlockAndroidAutoMediaBrowserPatch = bytecodePatch(
"8.10.52",
"8.37.56",
"8.40.54",
"8.44.54"
),
)

View file

@ -22,7 +22,8 @@ val forceOriginalAudioPatch = forceOriginalAudioPatch(
"7.29.52",
"8.10.52",
"8.37.56",
"8.40.54"
"8.40.54",
"8.44.54"
)
)
},

View file

@ -21,6 +21,7 @@ val removeBackgroundPlaybackRestrictionsPatch = bytecodePatch(
"8.10.52",
"8.37.56",
"8.40.54",
"8.44.54"
),
)

View file

@ -21,6 +21,7 @@ val enableDebuggingPatch = enableDebuggingPatch(
"8.10.52",
"8.37.56",
"8.40.54",
"8.44.54"
)
)
},

View file

@ -15,7 +15,8 @@ val checkWatchHistoryDomainNameResolutionPatch = checkWatchHistoryDomainNameReso
"7.29.52",
"8.10.52",
"8.37.56",
"8.40.54"
"8.40.54",
"8.44.54"
)
)
},

View file

@ -1,11 +0,0 @@
package app.revanced.patches.music.misc.gms
import app.revanced.patcher.*
import app.revanced.patcher.patch.BytecodePatchContext
internal val BytecodePatchContext.musicActivityOnCreateMethod by gettingFirstMethodDeclaratively {
name("onCreate")
definingClass("/MusicActivity;")
returnType("V")
parameterTypes("Landroid/os/Bundle;")
}

View file

@ -10,6 +10,7 @@ import app.revanced.patches.music.misc.gms.Constants.MUSIC_PACKAGE_NAME
import app.revanced.patches.music.misc.gms.Constants.REVANCED_MUSIC_PACKAGE_NAME
import app.revanced.patches.music.misc.settings.PreferenceScreen
import app.revanced.patches.music.misc.settings.settingsPatch
import app.revanced.patches.music.shared.mainActivityOnCreateMethod
import app.revanced.patches.shared.castContextFetchMethod
import app.revanced.patches.shared.misc.gms.gmsCoreSupportPatch
import app.revanced.patches.shared.misc.settings.preference.IntentPreference
@ -23,7 +24,7 @@ val gmsCoreSupportPatch = gmsCoreSupportPatch(
toPackageName = REVANCED_MUSIC_PACKAGE_NAME,
getPrimeMethod = { primeMethod },
getEarlyReturnMethods = setOf(BytecodePatchContext::castContextFetchMethod::get),
getMainActivityOnCreateMethodToGetInsertIndex = BytecodePatchContext::musicActivityOnCreateMethod::get to { 0 },
getMainActivityOnCreateMethodToGetInsertIndex = BytecodePatchContext::mainActivityOnCreateMethod::get to { 0 },
extensionPatch = sharedExtensionPatch,
gmsCoreSupportResourcePatchFactory = ::gmsCoreSupportResourcePatch,
) {
@ -34,6 +35,7 @@ val gmsCoreSupportPatch = gmsCoreSupportPatch(
"8.10.52",
"8.37.56",
"8.40.54",
"8.44.54"
),
)
}

View file

@ -1,25 +1,16 @@
package app.revanced.patches.music.misc.litho.filter
import app.revanced.patcher.extensions.addInstruction
import app.revanced.patcher.patch.BytecodePatchContext
import app.revanced.patches.music.misc.extension.sharedExtensionPatch
import app.revanced.patches.music.shared.conversionContextToStringMethod
import app.revanced.patches.shared.misc.litho.filter.EXTENSION_CLASS_DESCRIPTOR
import app.revanced.patches.shared.misc.litho.filter.lithoFilterPatch
import app.revanced.patches.shared.misc.litho.filter.protobufBufferReferenceLegacyMethod
import app.revanced.util.indexOfFirstInstructionOrThrow
import com.android.tools.smali.dexlib2.Opcode
import app.revanced.patches.shared.misc.litho.filter.protobufBufferReferenceMethod
val lithoFilterPatch = lithoFilterPatch(
componentCreateInsertionIndex = {
// No supported version clobbers p2 so we can just do our things before the return instruction.
indexOfFirstInstructionOrThrow(Opcode.RETURN_OBJECT)
},
getConversionContextToStringMethod = BytecodePatchContext::conversionContextToStringMethod::get,
insertProtobufHook = {
protobufBufferReferenceLegacyMethod.addInstruction(
insertLegacyProtobufHook = {
protobufBufferReferenceMethod.addInstruction(
0,
"invoke-static { p2 }, $EXTENSION_CLASS_DESCRIPTOR->setProtoBuffer(Ljava/nio/ByteBuffer;)V",
"invoke-static { p2 }, $EXTENSION_CLASS_DESCRIPTOR->setProtobufBuffer(Ljava/nio/ByteBuffer;)V",
)
},
) {

View file

@ -18,7 +18,8 @@ val sanitizeSharingLinksPatch = sanitizeSharingLinksPatch(
"7.29.52",
"8.10.52",
"8.37.56",
"8.40.54"
"8.40.54",
"8.44.54"
)
)
},

View file

@ -3,7 +3,7 @@ package app.revanced.patches.music.misc.spoof
import app.revanced.patches.all.misc.resources.addResources
import app.revanced.patches.all.misc.resources.addResourcesPatch
import app.revanced.patches.music.misc.extension.sharedExtensionPatch
import app.revanced.patches.music.misc.gms.musicActivityOnCreateMethod
import app.revanced.patches.music.shared.mainActivityOnCreateMethod
import app.revanced.patches.music.misc.settings.PreferenceScreen
import app.revanced.patches.music.misc.settings.settingsPatch
import app.revanced.patches.music.playservice.*
@ -14,7 +14,7 @@ import app.revanced.patches.shared.misc.spoof.spoofVideoStreamsPatch
val spoofVideoStreamsPatch = spoofVideoStreamsPatch(
extensionClassDescriptor = "Lapp/revanced/extension/music/patches/spoof/SpoofVideoStreamsPatch;",
getMainActivityOnCreateMethod = { musicActivityOnCreateMethod },
getMainActivityOnCreateMethod = { mainActivityOnCreateMethod },
fixMediaFetchHotConfig = { is_7_16_or_greater },
fixMediaFetchHotConfigAlternative = { is_8_11_or_greater && !is_8_15_or_greater },
fixParsePlaybackResponseFeatureFlag = { is_7_33_or_greater },
@ -33,7 +33,8 @@ val spoofVideoStreamsPatch = spoofVideoStreamsPatch(
"7.29.52",
"8.10.52",
"8.37.56",
"8.40.54"
"8.40.54",
"8.44.54"
)
)
},

View file

@ -0,0 +1,160 @@
package app.revanced.patches.shared.layout.branding.header
import app.revanced.patcher.patch.Package
import app.revanced.patcher.patch.Patch
import app.revanced.patcher.patch.PatchException
import app.revanced.patcher.patch.ResourcePatchContext
import app.revanced.patcher.patch.resourcePatch
import app.revanced.patcher.patch.stringOption
import app.revanced.patches.all.misc.resources.addResources
import app.revanced.patches.all.misc.resources.addResourcesPatch
import app.revanced.patches.shared.misc.settings.preference.BasePreferenceScreen
import app.revanced.patches.shared.misc.settings.preference.ListPreference
import app.revanced.util.ResourceGroup
import app.revanced.util.Utils.trimIndentMultiline
import app.revanced.util.copyResources
import java.io.File
internal const val CUSTOM_HEADER_RESOURCE_NAME = "revanced_header_custom"
@Suppress("unused")
fun changeHeaderPatch(
targetResourceDirectoryNames: Map<String, String>,
changeHeaderBytecodePatch: Patch,
vararg compatiblePackages: Package,
variants: Array<String>,
logoResourceNames: Array<String>,
preferenceScreen: BasePreferenceScreen.Screen,
resourcesAppId: String,
applyBlock: ResourcePatchContext.() -> Unit = {},
): Patch {
val customHeaderResourceFileNames = variants.map { variant ->
"${CUSTOM_HEADER_RESOURCE_NAME}_$variant.png"
}.toTypedArray()
return resourcePatch(
name = "Change header",
description = "Adds an option to change the header logo in the top left corner of the app.",
) {
dependsOn(addResourcesPatch, changeHeaderBytecodePatch)
compatibleWith(packages = compatiblePackages)
val custom by stringOption(
name = "Custom header logo",
description = """
Folder with images to use as a custom header logo.
The folder must contain one or more of the following folders, depending on the DPI of the device:
${targetResourceDirectoryNames.keys.joinToString("\n") { "- $it" }}
Each of the folders must contain all of the following files:
${customHeaderResourceFileNames.joinToString("\n")}
The image dimensions must be as follows:
${targetResourceDirectoryNames.map { (dpi, dim) -> "- $dpi: $dim" }.joinToString("\n")}
""".trimIndentMultiline(),
)
apply {
addResources(resourcesAppId, "layout.branding.header.changeHeaderPatch")
preferenceScreen.addPreferences(
if (custom == null) {
ListPreference("revanced_header_logo")
} else {
ListPreference(
key = "revanced_header_logo",
entriesKey = "revanced_header_logo_custom_entries",
entryValuesKey = "revanced_header_logo_custom_entry_values",
)
},
)
logoResourceNames.forEach { logo ->
variants.forEach { variant ->
copyResources(
"change-header",
ResourceGroup(
"drawable",
logo + "_" + variant + ".xml",
),
)
}
}
// Copy custom template. Images are only used if settings
// are imported and a custom header is enabled.
targetResourceDirectoryNames.keys.forEach { dpi ->
variants.forEach { variant ->
copyResources(
"change-header",
ResourceGroup(
dpi,
resources = customHeaderResourceFileNames,
),
)
}
}
applyBlock()
// Copy user provided images last, so if an exception is thrown due to bad input.
if (custom != null) {
val customFile = File(custom!!.trim())
if (!customFile.exists()) {
throw PatchException(
"The custom header path cannot be found: " +
customFile.absolutePath,
)
}
if (!customFile.isDirectory) {
throw PatchException(
"The custom header path must be a folder: " +
customFile.absolutePath,
)
}
var copiedFiles = false
// For each source folder, copy the files to the target resource directories.
customFile.listFiles { file ->
file.isDirectory && file.name in targetResourceDirectoryNames
}!!.forEach { dpiSourceFolder ->
val targetDpiFolder = get("res").resolve(dpiSourceFolder.name)
if (!targetDpiFolder.exists()) {
// Should never happen.
throw IllegalStateException("Resource not found: $dpiSourceFolder")
}
val customFiles = dpiSourceFolder.listFiles { file ->
file.isFile && file.name in customHeaderResourceFileNames
}!!
if (customFiles.isNotEmpty() && customFiles.size != variants.size) {
throw PatchException(
"Both light/dark mode images " +
"must be specified but only found: " + customFiles.map { it.name },
)
}
customFiles.forEach { imgSourceFile ->
val imgTargetFile = targetDpiFolder.resolve(imgSourceFile.name)
imgSourceFile.copyTo(target = imgTargetFile, overwrite = true)
copiedFiles = true
}
}
if (!copiedFiles) {
throw PatchException(
"Expected to find directories and files: " +
customHeaderResourceFileNames.contentToString() +
"\nBut none were found in the provided option file path: " + customFile.absolutePath,
)
}
}
}
}
}

View file

@ -62,7 +62,10 @@ internal val darkThemeBackgroundColorOption = stringOption(
default = "@android:color/black",
values = mapOf(
"Pure black" to "@android:color/black",
"Material You" to "@android:color/system_neutral1_900",
"Material You (Neutral)" to "@android:color/system_neutral1_900",
"Material You - Primary" to "@android:color/system_accent1_800",
"Material You - Secondary" to "@android:color/system_accent2_800",
"Material You - Tertiary" to "@android:color/system_accent3_800",
"Classic (old YouTube)" to "#212121",
"Catppuccin (Mocha)" to "#181825",
"Dark pink" to "#290025",

View file

@ -0,0 +1,154 @@
@file:Suppress("SpellCheckingInspection")
package app.revanced.patches.shared.misc.litho.context
import app.revanced.com.android.tools.smali.dexlib2.mutable.MutableClassDef
import app.revanced.com.android.tools.smali.dexlib2.mutable.MutableMethod.Companion.toMutable
import app.revanced.patcher.after
import app.revanced.patcher.allOf
import app.revanced.patcher.classDef
import app.revanced.patcher.extensions.addInstructions
import app.revanced.patcher.field
import app.revanced.patcher.firstClassDef
import app.revanced.patcher.firstMethodDeclaratively
import app.revanced.patcher.instructions
import app.revanced.patcher.invoke
import app.revanced.patcher.parameterTypes
import app.revanced.patcher.patch.bytecodePatch
import app.revanced.patcher.returnType
import app.revanced.patches.shared.misc.extension.sharedExtensionPatch
import app.revanced.util.findFieldFromToString
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.immutable.ImmutableMethod
internal const val EXTENSION_CONTEXT_INTERFACE =
$$"Lapp/revanced/extension/shared/ConversionContext$ContextInterface;"
internal lateinit var conversionContextClassDef: MutableClassDef
val conversionContextPatch = bytecodePatch(
description = "Hooks the method to use the conversion context in an extension.",
) {
dependsOn(sharedExtensionPatch())
apply {
conversionContextClassDef = conversionContextToStringMethod.classDef
val identifierField = conversionContextToStringMethod
.findFieldFromToString(IDENTIFIER_PROPERTY)
val stringBuilderField = conversionContextClassDef
.fields.single { field -> field.type == "Ljava/lang/StringBuilder;" }
// The conversionContext class can be used as is in most versions.
if (conversionContextClassDef.superclass == "Ljava/lang/Object;") {
arrayOf(
identifierField,
stringBuilderField
).map {
}
conversionContextClassDef.apply {
// Add interface and helper methods to allow extension code to call obfuscated methods.
interfaces += EXTENSION_CONTEXT_INTERFACE
arrayOf(
Triple(
"patch_getIdentifier",
"Ljava/lang/String;",
identifierField
),
Triple(
"patch_getPathBuilder",
"Ljava/lang/StringBuilder;",
stringBuilderField
)
).forEach { (interfaceMethodName, interfaceMethodReturnType, classFieldReference) ->
ImmutableMethod(
type,
interfaceMethodName,
listOf(),
interfaceMethodReturnType,
AccessFlags.PUBLIC.value or AccessFlags.FINAL.value,
null,
null,
MutableMethodImplementation(2),
).toMutable().apply {
addInstructions(
0,
"""
iget-object v0, p0, $classFieldReference
return-object v0
"""
)
}.let(methods::add)
}
}
} else {
// In some special versions, such as YouTube 20.41, it inherits from an abstract class,
// in which case a helper method is added to the abstract class.
// Since fields cannot be accessed directly in an abstract class, abstract methods are linked.
val stringBuilderMethodName = conversionContextClassDef.firstMethodDeclaratively {
parameterTypes()
returnType("Ljava/lang/String;")
instructions(
allOf(Opcode.IGET_OBJECT(), field { this == identifierField }),
after(Opcode.RETURN_OBJECT()),
)
}.name
val identifierMethodName = conversionContextClassDef.firstMethodDeclaratively {
parameterTypes()
returnType("Ljava/lang/StringBuilder;")
instructions(
allOf(Opcode.IGET_OBJECT(), field { this == stringBuilderField }),
after(Opcode.RETURN_OBJECT()),
)
}.name
conversionContextClassDef = firstClassDef(conversionContextClassDef.superclass!!)
conversionContextClassDef.apply {
// Add interface and helper methods to allow extension code to call obfuscated methods.
interfaces += EXTENSION_CONTEXT_INTERFACE
arrayOf(
Triple(
"patch_getIdentifier",
"Ljava/lang/String;",
identifierMethodName
),
Triple(
"patch_getPathBuilder",
"Ljava/lang/StringBuilder;",
stringBuilderMethodName
)
).forEach { (interfaceMethodName, interfaceMethodReturnType, classMethodName) ->
ImmutableMethod(
type,
interfaceMethodName,
listOf(),
interfaceMethodReturnType,
AccessFlags.PUBLIC.value or AccessFlags.FINAL.value,
null,
null,
MutableMethodImplementation(2),
).toMutable().apply {
addInstructions(
0,
"""
invoke-virtual {p0}, $type->$classMethodName()$interfaceMethodReturnType
move-result-object v0
return-object v0
"""
)
}.let(methods::add)
}
}
}
}
}

View file

@ -0,0 +1,25 @@
package app.revanced.patches.shared.misc.litho.context
import app.revanced.patcher.gettingFirstImmutableMethodDeclaratively
import app.revanced.patcher.instructions
import app.revanced.patcher.invoke
import app.revanced.patcher.name
import app.revanced.patcher.parameterTypes
import app.revanced.patcher.patch.BytecodePatchContext
import app.revanced.patcher.returnType
internal const val IDENTIFIER_PROPERTY = ", identifierProperty="
internal val BytecodePatchContext.conversionContextToStringMethod by gettingFirstImmutableMethodDeclaratively(
", widthConstraint=",
", heightConstraint=",
", templateLoggerFactory=",
", rootDisposableContainer=",
IDENTIFIER_PROPERTY
) {
name("toString")
parameterTypes()
returnType("Ljava/lang/String;")
instructions("ConversionContext{"(String::contains))
}

View file

@ -1,9 +1,13 @@
package app.revanced.patches.shared.misc.litho.filter
import app.revanced.patcher.*
import app.revanced.patcher.firstMethodComposite
import app.revanced.patcher.instructions
import app.revanced.patcher.patch.BytecodePatchContext
import app.revanced.patcher.returnType
import com.android.tools.smali.dexlib2.AccessFlags
import com.android.tools.smali.dexlib2.Opcode
import com.android.tools.smali.dexlib2.iface.ClassDef
import com.android.tools.smali.dexlib2.iface.reference.MethodReference
internal val BytecodePatchContext.accessibilityIdMethodMatch by composingFirstMethod {
@ -16,29 +20,59 @@ internal val BytecodePatchContext.accessibilityIdMethodMatch by composingFirstMe
)
}
internal fun BytecodePatchContext.getAccessibilityTextMethodMatch(accessibilityIdMethod: MethodReference) = firstMethodComposite {
returnType("V")
custom {
// 'public final synthetic' or 'public final bridge synthetic'.
AccessFlags.SYNTHETIC.isSet(accessFlags)
internal fun BytecodePatchContext.getAccessibilityTextMethodMatch(accessibilityIdMethod: MethodReference) =
firstMethodComposite {
returnType("V")
custom {
// 'public final synthetic' or 'public final bridge synthetic'.
AccessFlags.SYNTHETIC.isSet(accessFlags)
}
instructions(
allOf(
Opcode.INVOKE_INTERFACE(),
method { parameterTypes.isEmpty() && returnType == "Ljava/lang/String;" }
),
afterAtMost(5, method { this == accessibilityIdMethod })
)
}
context(_: BytecodePatchContext)
internal fun ClassDef.getEmptyComponentMethod() = firstMethodDeclaratively {
accessFlags(AccessFlags.PUBLIC, AccessFlags.STATIC)
returnType("L")
parameterTypes("L")
}
internal val BytecodePatchContext.emptyComponentParentMethod by gettingFirstMethodDeclaratively {
accessFlags(AccessFlags.PRIVATE, AccessFlags.CONSTRUCTOR)
parameterTypes()
instructions("EmptyComponent"())
}
fun BytecodePatchContext.getComponentCreateMethodMatch(accessibilityIdMethod: MethodReference) = firstMethodComposite {
returnType("L")
instructions(
allOf(
Opcode.INVOKE_INTERFACE(),
method { parameterTypes.isEmpty() && returnType == "Ljava/lang/String;" }
Opcode.IF_EQZ(),
afterAtMost(
5,
allOf(Opcode.CHECK_CAST(), type(accessibilityIdMethod.definingClass))
),
afterAtMost(5, method { this == accessibilityIdMethod })
Opcode.RETURN_OBJECT(),
"Element missing correct type extension"(),
"Element missing type"()
)
}
internal val BytecodePatchContext.lithoFilterInitMethod by gettingFirstMethodDeclaratively {
definingClass("/LithoFilterPatch;")
accessFlags(AccessFlags.STATIC, AccessFlags.CONSTRUCTOR)
}
internal val BytecodePatchContext.protobufBufferReferenceMethodMatch by composingFirstMethod {
internal val BytecodePatchContext.protobufBufferEncodeMethod by gettingFirstMethodDeclaratively {
accessFlags(AccessFlags.PUBLIC, AccessFlags.FINAL)
returnType("V")
parameterTypes("[B")
returnType("[B")
parameterTypes()
var methodDefiningClass = ""
custom {
@ -51,32 +85,17 @@ internal val BytecodePatchContext.protobufBufferReferenceMethodMatch by composin
Opcode.IGET_OBJECT(),
field { definingClass == methodDefiningClass && type == "Lcom/google/android/libraries/elements/adl/UpbMessage;" },
),
method { definingClass == "Lcom/google/android/libraries/elements/adl/UpbMessage;" && name == "jniDecode" },
method { definingClass == "Lcom/google/android/libraries/elements/adl/UpbMessage;" && name == "jniEncode" },
)
}
/**
* Matches a method that use the protobuf of our component.
*/
internal val BytecodePatchContext.protobufBufferReferenceLegacyMethod by gettingFirstMethodDeclaratively {
internal val BytecodePatchContext.protobufBufferReferenceMethod by gettingFirstMethodDeclaratively {
accessFlags(AccessFlags.PUBLIC, AccessFlags.FINAL)
returnType("V")
parameterTypes("I", "Ljava/nio/ByteBuffer;")
opcodes(Opcode.IPUT, Opcode.INVOKE_VIRTUAL, Opcode.MOVE_RESULT, Opcode.SUB_INT_2ADDR)
}
internal val BytecodePatchContext.emptyComponentMethod by gettingFirstImmutableMethodDeclaratively {
accessFlags(AccessFlags.PRIVATE, AccessFlags.CONSTRUCTOR)
parameterTypes()
instructions("EmptyComponent"())
custom { immutableClassDef.methods.filter { AccessFlags.STATIC.isSet(it.accessFlags) }.size == 1 }
}
internal val BytecodePatchContext.componentCreateMethod by gettingFirstMethod(
"Element missing correct type extension",
"Element missing type",
)
internal val BytecodePatchContext.lithoThreadExecutorMethod by gettingFirstMethodDeclaratively {
accessFlags(AccessFlags.PUBLIC, AccessFlags.CONSTRUCTOR)
parameterTypes("I", "I", "I")

View file

@ -2,34 +2,22 @@
package app.revanced.patches.shared.misc.litho.filter
import app.revanced.util.getFreeRegisterProvider
import app.revanced.com.android.tools.smali.dexlib2.iface.value.MutableEncodedValue.Companion.toMutable
import app.revanced.patcher.afterAtMost
import app.revanced.patcher.allOf
import app.revanced.patcher.classDef
import app.revanced.patcher.custom
import app.revanced.util.getFreeRegisterProvider
import app.revanced.patcher.extensions.addInstructions
import app.revanced.patcher.extensions.getInstruction
import app.revanced.patcher.extensions.methodReference
import app.revanced.patcher.extensions.removeInstructions
import app.revanced.patcher.extensions.typeReference
import app.revanced.patcher.firstImmutableClassDef
import app.revanced.patcher.firstMethodComposite
import app.revanced.patcher.firstClassDef
import app.revanced.patcher.immutableClassDef
import app.revanced.patcher.instructions
import app.revanced.patcher.invoke
import app.revanced.patcher.method
import app.revanced.patcher.patch.BytecodePatchBuilder
import app.revanced.patcher.patch.BytecodePatchContext
import app.revanced.patcher.patch.bytecodePatch
import app.revanced.patcher.returnType
import app.revanced.patches.shared.misc.extension.sharedExtensionPatch
import app.revanced.patches.shared.misc.litho.context.EXTENSION_CONTEXT_INTERFACE
import app.revanced.patches.shared.misc.litho.context.conversionContextPatch
import app.revanced.util.addInstructionsAtControlFlowLabel
import app.revanced.util.findFieldFromToString
import app.revanced.util.indexOfFirstInstructionReversedOrThrow
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.instruction.OneRegisterInstruction
import com.android.tools.smali.dexlib2.iface.instruction.ReferenceInstruction
import com.android.tools.smali.dexlib2.immutable.value.ImmutableBooleanEncodedValue
@ -51,26 +39,20 @@ internal const val EXTENSION_CLASS_DESCRIPTOR =
/**
* A patch that allows to filter Litho components based on their identifier or path.
*
* @param componentCreateInsertionIndex The index to insert the filtering code in the component create method.
* @param insertProtobufHook This method injects a setProtoBuffer call in the protobuf decoding logic.
* @param getConversionContextToStringMethod The getter of the conversion context toString method.
* @param insertLegacyProtobufHook Hook legacy protobuf buffer into the extension to be used for filtering for older versions of the app.
* @param getExtractIdentifierFromBuffer Whether to extract the identifier from the protobuf buffer.
* @param executeBlock The additional execution block of the patch.
* @param block The additional block to build the patch.
*/
internal fun lithoFilterPatch(
componentCreateInsertionIndex: Method.() -> Int,
insertProtobufHook: BytecodePatchContext.() -> Unit,
insertLegacyProtobufHook: BytecodePatchContext.() -> Unit,
executeBlock: BytecodePatchContext.() -> Unit = {},
getConversionContextToStringMethod: BytecodePatchContext.() -> Method,
getExtractIdentifierFromBuffer: () -> Boolean = { false },
block: BytecodePatchBuilder.() -> Unit = {},
) = bytecodePatch(
description = "Hooks the method which parses the bytes into a ComponentContext to filter components.",
) {
dependsOn(
sharedExtensionPatch(),
)
dependsOn(sharedExtensionPatch(), conversionContextPatch)
/**
* The following patch inserts a hook into the method that parses the bytes into a ComponentContext.
@ -92,7 +74,7 @@ internal fun lithoFilterPatch(
* class SomeOtherClass {
* // Called before ComponentContextParser.parseComponent() method.
* public void someOtherMethod(ByteBuffer byteBuffer) {
* ExtensionClass.setProtoBuffer(byteBuffer); // Inserted by this patch.
* ExtensionClass.setProtobugBuffer(byteBuffer); // Inserted by this patch.
* ...
* }
* }
@ -134,45 +116,23 @@ internal fun lithoFilterPatch(
}
}
// Tell the extension whether to extract the identifier from the buffer.
if (getExtractIdentifierFromBuffer()) {
lithoFilterInitMethod.classDef.fields.first { it.name == "EXTRACT_IDENTIFIER_FROM_BUFFER" }
.initialValue = ImmutableBooleanEncodedValue.forBoolean(true).toMutable()
}
// region Pass the buffer into extension.
// Add an interceptor to steal the protobuf of our component.
insertProtobufHook()
insertLegacyProtobufHook()
// Hook the method that parses bytes into a ComponentContext.
// Allow the method to run to completion, and override the
// return value with an empty component if it should be filtered.
// It is important to allow the original code to always run to completion,
// otherwise high memory usage and poor app performance can occur.
// endregion
val conversionContextToStringMethod = getConversionContextToStringMethod()
// region Modify the create component method and
// if the component is filtered then return an empty component.
// Find the identifier/path fields of the conversion context.
val conversionContextIdentifierField = conversionContextToStringMethod
.findFieldFromToString("identifierProperty=")
val builderMethodDescriptor =
emptyComponentParentMethod.immutableClassDef.getEmptyComponentMethod()
val conversionContextPathBuilderField = conversionContextToStringMethod.immutableClassDef
.fields.single { field -> field.type == "Ljava/lang/StringBuilder;" }
// Find class and methods to create an empty component.
val builderMethodDescriptor = emptyComponentMethod.immutableClassDef.methods.single {
// The only static method in the class.
method ->
AccessFlags.STATIC.isSet(method.accessFlags)
}
val emptyComponentField = firstImmutableClassDef {
// Only one field that matches.
type == builderMethodDescriptor.returnType
}.fields.single()
val emptyComponentField = firstClassDef(builderMethodDescriptor.returnType).fields.single()
// Find the method call that gets the value of 'buttonViewModel.accessibilityId'.
val accessibilityIdMethod = accessibilityIdMethodMatch.let {
it.immutableMethod.getInstruction<ReferenceInstruction>(it[0]).methodReference!!
it.method.getInstruction<ReferenceInstruction>(it[0]).methodReference!!
}
// There's a method in the same class that gets the value of 'buttonViewModel.accessibilityText'.
@ -182,35 +142,26 @@ internal fun lithoFilterPatch(
it.method.getInstruction<ReferenceInstruction>(it[0]).methodReference
}
componentCreateMethod.apply {
val insertIndex = componentCreateInsertionIndex()
getComponentCreateMethodMatch(accessibilityIdMethod).let {
val insertIndex = it[2]
val buttonViewModelIndex = it[1]
val nullCheckIndex = it[0]
// Directly access the class related with the buttonViewModel from this method.
// This is within 10 lines of insertIndex.
val buttonViewModelIndex = indexOfFirstInstructionReversedOrThrow(insertIndex) {
opcode == Opcode.CHECK_CAST &&
typeReference?.type == accessibilityIdMethod.definingClass
}
val buttonViewModelRegister =
getInstruction<OneRegisterInstruction>(buttonViewModelIndex).registerA
it.method.getInstruction<OneRegisterInstruction>(buttonViewModelIndex).registerA
val accessibilityIdIndex = buttonViewModelIndex + 2
// This is an index that checks if there is accessibility-related text.
// This is within 10 lines of buttonViewModelIndex.
val nullCheckIndex = indexOfFirstInstructionReversedOrThrow(
buttonViewModelIndex, Opcode.IF_EQZ
)
val registerProvider = getFreeRegisterProvider(
val registerProvider = it.method.getFreeRegisterProvider(
insertIndex, 3, buttonViewModelRegister
)
val contextRegister = registerProvider.getFreeRegister()
val bufferRegister = registerProvider.getFreeRegister()
val freeRegister = registerProvider.getFreeRegister()
val identifierRegister = registerProvider.getFreeRegister()
val pathRegister = registerProvider.getFreeRegister()
// Find a free register to store the accessibilityId and accessibilityText.
// This is before the insertion index.
val accessibilityRegisterProvider = getFreeRegisterProvider(
val accessibilityRegisterProvider = it.method.getFreeRegisterProvider(
nullCheckIndex,
2,
registerProvider.getUsedAndUnAvailableRegisters()
@ -218,23 +169,31 @@ internal fun lithoFilterPatch(
val accessibilityIdRegister = accessibilityRegisterProvider.getFreeRegister()
val accessibilityTextRegister = accessibilityRegisterProvider.getFreeRegister()
addInstructionsAtControlFlowLabel(
it.method.addInstructionsAtControlFlowLabel(
insertIndex,
"""
move-object/from16 v$freeRegister, p2 # ConversionContext parameter
# In YouTube 20.41 the field is the abstract superclass.
# Verify it's the expected subclass just in case.
instance-of v$identifierRegister, v$freeRegister, ${conversionContextToStringMethod.immutableClassDef.type}
if-eqz v$identifierRegister, :unfiltered
iget-object v$identifierRegister, v$freeRegister, $conversionContextIdentifierField
iget-object v$pathRegister, v$freeRegister, $conversionContextPathBuilderField
invoke-static { v$identifierRegister, v$accessibilityIdRegister, v$accessibilityTextRegister, v$pathRegister }, ${EXTENSION_CLASS_DESCRIPTOR}->isFiltered(Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/StringBuilder;)Z
move-object/from16 v$bufferRegister, p3
# Verify it's the expected subclass just in case.
instance-of v$freeRegister, v$bufferRegister, ${protobufBufferEncodeMethod.definingClass}
if-eqz v$freeRegister, :empty_buffer
check-cast v$bufferRegister, ${protobufBufferEncodeMethod.definingClass}
invoke-virtual { v$bufferRegister }, $protobufBufferEncodeMethod
move-result-object v$bufferRegister
goto :hook
:empty_buffer
const/4 v$freeRegister, 0x0
new-array v$bufferRegister, v$freeRegister, [B
:hook
move-object/from16 v$contextRegister, p2
invoke-static { v$contextRegister, v$bufferRegister, v$accessibilityIdRegister, v$accessibilityTextRegister }, $EXTENSION_CLASS_DESCRIPTOR->isFiltered(${EXTENSION_CONTEXT_INTERFACE}[BLjava/lang/String;Ljava/lang/String;)Z
move-result v$freeRegister
if-eqz v$freeRegister, :unfiltered
# Return an empty component
# Return an empty component.
move-object/from16 v$freeRegister, p1
invoke-static { v$freeRegister }, $builderMethodDescriptor
move-result-object v$freeRegister
@ -247,7 +206,7 @@ internal fun lithoFilterPatch(
)
// If there is text related to accessibility, get the accessibilityId and accessibilityText.
addInstructions(
it.method.addInstructions(
accessibilityIdIndex,
"""
# Get accessibilityId
@ -262,7 +221,7 @@ internal fun lithoFilterPatch(
// If there is no accessibility-related text,
// both accessibilityId and accessibilityText use empty values.
addInstructions(
it.method.addInstructions(
nullCheckIndex,
"""
const-string v$accessibilityIdRegister, ""
@ -271,6 +230,13 @@ internal fun lithoFilterPatch(
)
}
if (getExtractIdentifierFromBuffer()) {
lithoFilterInitMethod.classDef.fields.first { it.name == "EXTRACT_IDENTIFIER_FROM_BUFFER" }
.initialValue = ImmutableBooleanEncodedValue.forBoolean(true).toMutable()
}
// endregion
// TODO: Check if needed in music.
// Change Litho thread executor to 1 thread to fix layout issue in unpatched YouTube.
lithoThreadExecutorMethod.addInstructions(

View file

@ -18,6 +18,7 @@ import app.revanced.patches.shared.misc.settings.preference.SwitchPreference
import app.revanced.patches.youtube.misc.contexthook.Endpoint
import app.revanced.patches.youtube.misc.contexthook.addOSNameHook
import app.revanced.patches.shared.misc.litho.filter.addLithoFilter
import app.revanced.patches.youtube.layout.hide.shelves.hideHorizontalShelvesPatch
import app.revanced.patches.youtube.misc.contexthook.hookClientContextPatch
import app.revanced.patches.youtube.misc.engagement.addEngagementPanelIdHook
import app.revanced.patches.youtube.misc.engagement.engagementPanelHookPatch
@ -50,6 +51,7 @@ private val hideAdsResourcePatch = resourcePatch {
addResourcesPatch,
hookClientContextPatch,
engagementPanelHookPatch,
hideHorizontalShelvesPatch
)
apply {
@ -94,7 +96,9 @@ val hideAdsPatch = bytecodePatch(
"20.26.46",
"20.31.42",
"20.37.48",
"20.40.45"
"20.40.45",
"20.44.38",
"20.45.36"
),
)
@ -212,6 +216,7 @@ val hideAdsPatch = bytecodePatch(
setOf(
Endpoint.BROWSE,
Endpoint.SEARCH,
Endpoint.NEXT,
).forEach { endpoint ->
addOSNameHook(
endpoint,

View file

@ -1,9 +1,18 @@
package app.revanced.patches.youtube.ad.video
import app.revanced.patcher.gettingFirstMethodDeclaratively
import app.revanced.patcher.parameterTypes
import app.revanced.patcher.patch.BytecodePatchContext
import app.revanced.patcher.returnType
internal val BytecodePatchContext.loadVideoAdsMethod by gettingFirstMethodDeclaratively(
"TriggerBundle doesn't have the required metadata specified by the trigger ",
"Ping migration no associated ping bindings for activated trigger: ",
)
internal val BytecodePatchContext.playerBytesAdLayoutMethod by gettingFirstMethodDeclaratively(
"Bootstrapped layout construction resulted in non PlayerBytesLayout. PlayerAds count: "
) {
returnType("V")
parameterTypes("L")
}

View file

@ -1,16 +1,20 @@
package app.revanced.patches.youtube.ad.video
import app.revanced.patcher.extensions.ExternalLabel
import app.revanced.patcher.extensions.addInstructionsWithLabels
import app.revanced.patcher.extensions.getInstruction
import app.revanced.patcher.patch.bytecodePatch
import app.revanced.patches.all.misc.resources.addResources
import app.revanced.patches.all.misc.resources.addResourcesPatch
import app.revanced.patches.shared.misc.settings.preference.SwitchPreference
import app.revanced.patches.youtube.misc.contexthook.Endpoint
import app.revanced.patches.youtube.misc.contexthook.addOSNameHook
import app.revanced.patches.youtube.misc.contexthook.hookClientContextPatch
import app.revanced.patches.youtube.misc.extension.sharedExtensionPatch
import app.revanced.patches.youtube.misc.settings.PreferenceScreen
import app.revanced.patches.youtube.misc.settings.settingsPatch
private const val EXTENSION_CLASS_DESCRIPTOR =
"Lapp/revanced/extension/youtube/patches/VideoAdsPatch;"
@Suppress("ObjectPropertyName")
val videoAdsPatch = bytecodePatch(
name = "Video ads",
@ -20,6 +24,7 @@ val videoAdsPatch = bytecodePatch(
sharedExtensionPatch,
settingsPatch,
addResourcesPatch,
hookClientContextPatch
)
compatibleWith(
@ -29,7 +34,9 @@ val videoAdsPatch = bytecodePatch(
"20.26.46",
"20.31.42",
"20.37.48",
"20.40.45"
"20.40.45",
"20.44.38",
"20.45.36"
),
)
@ -40,15 +47,33 @@ val videoAdsPatch = bytecodePatch(
SwitchPreference("revanced_hide_video_ads"),
)
loadVideoAdsMethod.addInstructionsWithLabels(
0,
"""
invoke-static { }, Lapp/revanced/extension/youtube/patches/VideoAdsPatch;->shouldShowAds()Z
move-result v0
if-nez v0, :show_video_ads
return-void
""",
ExternalLabel("show_video_ads", loadVideoAdsMethod.getInstruction(0)),
)
setOf(
loadVideoAdsMethod,
playerBytesAdLayoutMethod,
).forEach { method ->
method.addInstructionsWithLabels(
0,
"""
invoke-static { }, $EXTENSION_CLASS_DESCRIPTOR->hideVideoAds()Z
move-result v0
if-eqz v0, :show_video_ads
return-void
:show_video_ads
nop
"""
)
}
setOf(
Endpoint.GET_WATCH,
Endpoint.PLAYER,
Endpoint.REEL,
).forEach { endpoint ->
addOSNameHook(
endpoint,
"$EXTENSION_CLASS_DESCRIPTOR->hideVideoAds(Ljava/lang/String;)Ljava/lang/String;",
)
}
}
}

View file

@ -58,7 +58,9 @@ val copyVideoURLPatch = bytecodePatch(
"20.26.46",
"20.31.42",
"20.37.48",
"20.40.45"
"20.40.45",
"20.44.38",
"20.45.36"
),
)

View file

@ -45,7 +45,9 @@ val removeViewerDiscretionDialogPatch = bytecodePatch(
"20.26.46",
"20.31.42",
"20.37.48",
"20.40.45"
"20.40.45",
"20.44.38",
"20.45.36"
),
)

View file

@ -22,18 +22,15 @@ val addMoreDoubleTapToSeekLengthOptionsPatch = resourcePatch(
"20.26.46",
"20.31.42",
"20.37.48",
"20.40.45"
"20.40.45",
"20.44.38",
"20.45.36"
)
)
execute {
// Values are hard coded to keep patching simple.
val doubleTapLengthOptionsString = "3, 5, 10, 15, 20, 30, 60, 120, 180, 240"
val doubleTapLengths = doubleTapLengthOptionsString
.replace(" ", "")
.split(",")
if (doubleTapLengths.isEmpty()) throw PatchException("Invalid double-tap length elements")
val doubleTapLengths = listOf(3, 5, 10, 15, 20, 30, 60, 120, 180, 240)
document("res/values/arrays.xml").use { document ->
fun Element.removeAllChildren() {
@ -56,10 +53,9 @@ val addMoreDoubleTapToSeekLengthOptionsPatch = resourcePatch(
entries.removeAllChildren()
doubleTapLengths.forEach { length ->
val item = document.createElement("item")
item.textContent = length
entries.appendChild(item)
values.appendChild(item.cloneNode(true))
document.createElement("item").apply { textContent = length.toString() }
.also(entries::appendChild)
.cloneNode(true).let(values::appendChild)
}
}
}

View file

@ -37,7 +37,9 @@ val disableDoubleTapActionsPatch = bytecodePatch(
"20.26.46",
"20.31.42",
"20.37.48",
"20.40.45"
"20.40.45",
"20.44.38",
"20.45.36"
),
)

View file

@ -80,7 +80,9 @@ val downloadsPatch = bytecodePatch(
"20.26.46",
"20.31.42",
"20.37.48",
"20.40.45"
"20.40.45",
"20.44.38",
"20.45.36"
),
)

View file

@ -58,7 +58,9 @@ val disableHapticFeedbackPatch = bytecodePatch(
"20.26.46",
"20.31.42",
"20.37.48",
"20.40.45"
"20.40.45",
"20.44.38",
"20.45.36"
),
)

View file

@ -23,7 +23,9 @@ val seekbarPatch = bytecodePatch(
"20.26.46",
"20.31.42",
"20.37.48",
"20.40.45"
"20.40.45",
"20.44.38",
"20.45.36"
),
)
}

View file

@ -109,7 +109,9 @@ val swipeControlsPatch = bytecodePatch(
"20.26.46",
"20.31.42",
"20.37.48",
"20.40.45"
"20.40.45",
"20.44.38",
"20.45.36"
),
)

View file

@ -31,7 +31,9 @@ val disableAutoCaptionsPatch = bytecodePatch(
"20.26.46",
"20.31.42",
"20.37.48",
"20.40.45"
"20.40.45",
"20.44.38",
"20.45.36"
),
)

View file

@ -31,7 +31,9 @@ val customBrandingPatch = baseCustomBrandingPatch(
"20.26.46",
"20.31.42",
"20.37.48",
"20.40.45"
"20.40.45",
"20.44.38",
"20.45.36"
),
)
},

View file

@ -3,27 +3,24 @@ package app.revanced.patches.youtube.layout.branding.header
import app.revanced.patcher.extensions.addInstructions
import app.revanced.patcher.extensions.getInstruction
import app.revanced.patcher.extensions.wideLiteral
import app.revanced.patcher.patch.PatchException
import app.revanced.patcher.patch.bytecodePatch
import app.revanced.patcher.patch.resourcePatch
import app.revanced.patcher.patch.stringOption
import app.revanced.patcher.util.Document
import app.revanced.patches.all.misc.resources.addResources
import app.revanced.patches.all.misc.resources.addResourcesPatch
import app.revanced.patches.shared.layout.branding.addBrandLicensePatch
import app.revanced.patches.shared.layout.branding.header.CUSTOM_HEADER_RESOURCE_NAME
import app.revanced.patches.shared.layout.branding.header.changeHeaderPatch
import app.revanced.patches.shared.misc.mapping.ResourceType
import app.revanced.patches.shared.misc.mapping.resourceMappingPatch
import app.revanced.patches.shared.misc.settings.preference.ListPreference
import app.revanced.patches.youtube.misc.settings.PreferenceScreen
import app.revanced.util.ResourceGroup
import app.revanced.util.Utils.trimIndentMultiline
import app.revanced.util.copyResources
import app.revanced.util.findElementByAttributeValueOrThrow
import app.revanced.util.forEachInstructionAsSequence
import com.android.tools.smali.dexlib2.iface.instruction.OneRegisterInstruction
import java.io.File
private val variants = arrayOf("light", "dark")
internal val variants = arrayOf("light", "dark")
private val logoResourceNames = arrayOf(
"revanced_header_minimal",
"revanced_header_rounded",
)
private val targetResourceDirectoryNames = mapOf(
"drawable-hdpi" to "194x72 px",
@ -32,25 +29,6 @@ private val targetResourceDirectoryNames = mapOf(
"drawable-xxxhdpi" to "512x192 px",
)
/**
* Header logos built into this patch.
*/
private val logoResourceNames = arrayOf(
"revanced_header_minimal",
"revanced_header_rounded",
)
/**
* Custom header resource/file name.
*/
private const val CUSTOM_HEADER_RESOURCE_NAME = "revanced_header_custom"
/**
* Custom header resource/file names.
*/
private val customHeaderResourceFileNames = variants.map { variant ->
"${CUSTOM_HEADER_RESOURCE_NAME}_$variant.png"
}.toTypedArray()
private const val EXTENSION_CLASS_DESCRIPTOR =
"Lapp/revanced/extension/youtube/patches/ChangeHeaderPatch;"
@ -97,182 +75,63 @@ private val changeHeaderBytecodePatch = bytecodePatch {
}
}
@Suppress("unused")
val changeHeaderPatch = resourcePatch(
name = "Change header",
description = "Adds an option to change the header logo in the top left corner of the app.",
) {
dependsOn(addResourcesPatch, changeHeaderBytecodePatch)
compatibleWith(
"com.google.android.youtube"(
val changeHeaderPatch = changeHeaderPatch(
targetResourceDirectoryNames = targetResourceDirectoryNames,
changeHeaderBytecodePatch = changeHeaderBytecodePatch,
compatiblePackages = arrayOf(
"com.google.android.youtube" to setOf(
"20.14.43",
"20.21.37",
"20.26.46",
"20.31.42",
"20.37.48",
"20.40.45"
),
)
val custom by stringOption(
name = "Custom header logo",
description = """
Folder with images to use as a custom header logo.
The folder must contain one or more of the following folders, depending on the DPI of the device:
${targetResourceDirectoryNames.keys.joinToString("\n") { "- $it" }}
Each of the folders must contain all of the following files:
${customHeaderResourceFileNames.joinToString("\n")}
The image dimensions must be as follows:
${targetResourceDirectoryNames.map { (dpi, dim) -> "- $dpi: $dim" }.joinToString("\n")}
""".trimIndentMultiline(),
)
apply {
addResources("youtube", "layout.branding.changeHeaderPatch")
PreferenceScreen.GENERAL.addPreferences(
if (custom == null) {
ListPreference("revanced_header_logo")
} else {
ListPreference(
key = "revanced_header_logo",
entriesKey = "revanced_header_logo_custom_entries",
entryValuesKey = "revanced_header_logo_custom_entry_values",
)
},
"20.40.45",
"20.44.38",
"20.45.36"
)
),
variants = variants,
logoResourceNames = logoResourceNames,
preferenceScreen = PreferenceScreen.GENERAL,
resourcesAppId = "youtube",
) {
// Logo is replaced using an attribute reference.
document("res/values/attrs.xml").use { document ->
val resources = document.childNodes.item(0)
logoResourceNames.forEach { logo ->
variants.forEach { variant ->
copyResources(
"change-header",
ResourceGroup(
"drawable",
logo + "_" + variant + ".xml",
),
)
}
fun addAttributeReference(logoName: String) {
val item = document.createElement("attr")
item.setAttribute("format", "reference")
item.setAttribute("name", logoName)
resources.appendChild(item)
}
// Copy custom template. Images are only used if settings
// are imported and a custom header is enabled.
targetResourceDirectoryNames.keys.forEach { dpi ->
variants.forEach { variant ->
copyResources(
"change-header",
ResourceGroup(
dpi,
*customHeaderResourceFileNames,
),
)
}
}
logoResourceNames.forEach { logoName -> addAttributeReference(logoName) }
// Logo is replaced using an attribute reference.
document("res/values/attrs.xml").use { document ->
val resources = document.childNodes.item(0)
addAttributeReference(CUSTOM_HEADER_RESOURCE_NAME)
}
fun addAttributeReference(logoName: String) {
val item = document.createElement("attr")
item.setAttribute("format", "reference")
// Add custom drawables to all styles that use the regular and premium logo.
document("res/values/styles.xml").use { document ->
arrayOf(
"Base.Theme.YouTube.Light" to "light",
"Base.Theme.YouTube.Dark" to "dark",
"CairoLightThemeRingo2Updates" to "light",
"CairoDarkThemeRingo2Updates" to "dark",
).forEach { (style, mode) ->
val styleElement = document.childNodes.findElementByAttributeValueOrThrow("name", style)
fun addDrawableElement(document: Document, logoName: String, mode: String) {
val item = document.createElement("item")
item.setAttribute("name", logoName)
resources.appendChild(item)
item.textContent = "@drawable/${logoName}_$mode"
styleElement.appendChild(item)
}
logoResourceNames.forEach { logoName ->
addAttributeReference(logoName)
}
logoResourceNames.forEach { logoName -> addDrawableElement(document, logoName, mode) }
addAttributeReference(CUSTOM_HEADER_RESOURCE_NAME)
}
// Add custom drawables to all styles that use the regular and premium logo.
document("res/values/styles.xml").use { document ->
arrayOf(
"Base.Theme.YouTube.Light" to "light",
"Base.Theme.YouTube.Dark" to "dark",
"CairoLightThemeRingo2Updates" to "light",
"CairoDarkThemeRingo2Updates" to "dark",
).forEach { (style, mode) ->
val styleElement = document.childNodes.findElementByAttributeValueOrThrow(
"name",
style,
)
fun addDrawableElement(document: Document, logoName: String, mode: String) {
val item = document.createElement("item")
item.setAttribute("name", logoName)
item.textContent = "@drawable/${logoName}_$mode"
styleElement.appendChild(item)
}
logoResourceNames.forEach { logoName ->
addDrawableElement(document, logoName, mode)
}
addDrawableElement(document, CUSTOM_HEADER_RESOURCE_NAME, mode)
}
}
// Copy user provided images last, so if an exception is thrown due to bad input.
if (custom != null) {
val customFile = File(custom!!.trim())
if (!customFile.exists()) {
throw PatchException(
"The custom header path cannot be found: " +
customFile.absolutePath,
)
}
if (!customFile.isDirectory) {
throw PatchException(
"The custom header path must be a folder: " +
customFile.absolutePath,
)
}
var copiedFiles = false
// For each source folder, copy the files to the target resource directories.
customFile.listFiles { file ->
file.isDirectory && file.name in targetResourceDirectoryNames
}!!.forEach { dpiSourceFolder ->
val targetDpiFolder = get("res").resolve(dpiSourceFolder.name)
if (!targetDpiFolder.exists()) {
// Should never happen.
throw IllegalStateException("Resource not found: $dpiSourceFolder")
}
val customFiles = dpiSourceFolder.listFiles { file ->
file.isFile && file.name in customHeaderResourceFileNames
}!!
if (customFiles.isNotEmpty() && customFiles.size != variants.size) {
throw PatchException(
"Both light/dark mode images " +
"must be specified but only found: " + customFiles.map { it.name },
)
}
customFiles.forEach { imgSourceFile ->
val imgTargetFile = targetDpiFolder.resolve(imgSourceFile.name)
imgSourceFile.copyTo(target = imgTargetFile, overwrite = true)
copiedFiles = true
}
}
if (!copiedFiles) {
throw PatchException(
"Expected to find directories and files: " +
customHeaderResourceFileNames.contentToString() +
"\nBut none were found in the provided option file path: " + customFile.absolutePath,
)
}
addDrawableElement(document, CUSTOM_HEADER_RESOURCE_NAME, mode)
}
}
}
}

View file

@ -33,7 +33,9 @@ val hideVideoActionButtonsPatch = resourcePatch(
"20.26.46",
"20.31.42",
"20.37.48",
"20.40.45"
"20.40.45",
"20.44.38",
"20.45.36"
),
)

View file

@ -0,0 +1,18 @@
package app.revanced.patches.youtube.layout.buttons.music
import app.revanced.patcher.accessFlags
import app.revanced.patcher.definingClass
import app.revanced.patcher.gettingFirstMethodDeclaratively
import app.revanced.patcher.name
import app.revanced.patcher.parameterTypes
import app.revanced.patcher.patch.BytecodePatchContext
import app.revanced.patcher.returnType
import com.android.tools.smali.dexlib2.AccessFlags
internal val BytecodePatchContext.getOverridePackageNameMethod by gettingFirstMethodDeclaratively {
name("getOverridePackageName")
definingClass(EXTENSION_CLASS_DESCRIPTOR)
accessFlags(AccessFlags.PUBLIC, AccessFlags.STATIC)
returnType("Ljava/lang/String;")
parameterTypes()
}

View file

@ -0,0 +1,126 @@
package app.revanced.patches.youtube.layout.buttons.music
import app.revanced.patcher.extensions.getInstruction
import app.revanced.patcher.extensions.methodReference
import app.revanced.patcher.extensions.replaceInstruction
import app.revanced.patcher.patch.bytecodePatch
import app.revanced.patcher.patch.resourcePatch
import app.revanced.patcher.patch.stringOption
import app.revanced.patches.all.misc.resources.addResources
import app.revanced.patches.all.misc.resources.addResourcesPatch
import app.revanced.patches.shared.misc.settings.preference.PreferenceCategory
import app.revanced.patches.shared.misc.settings.preference.SwitchPreference
import app.revanced.patches.youtube.misc.settings.PreferenceScreen
import app.revanced.patches.youtube.misc.settings.settingsPatch
import app.revanced.util.forEachInstructionAsSequence
import app.revanced.util.returnEarly
import com.android.tools.smali.dexlib2.Opcode
import com.android.tools.smali.dexlib2.iface.instruction.FiveRegisterInstruction
import com.android.tools.smali.dexlib2.iface.instruction.RegisterRangeInstruction
import org.w3c.dom.Element
internal const val EXTENSION_CLASS_DESCRIPTOR =
"Lapp/revanced/extension/youtube/patches/OverrideOpenInYouTubeMusicButtonPatch;"
val packageNameOption = stringOption(
name = "YouTube Music package name",
description = "The package name of the YouTube Music app to open when clicking the 'Open in YouTube Music' button.",
default = "app.revanced.android.apps.youtube.music",
values = mapOf(
"Original package name" to "com.google.android.apps.youtube.music",
"ReVanced default package name" to "app.revanced.android.apps.youtube.music"
),
required = true,
)
private val overrideOpenInYouTubeMusicManifestResourcePatch = resourcePatch {
apply {
val packageName by packageNameOption
document("AndroidManifest.xml").use { document ->
val queriesList = document.getElementsByTagName("queries")
val queries = if (queriesList.length > 0) queriesList.item(0) as Element
else document.createElement("queries").also(document::appendChild)
document.createElement("package").apply {
setAttribute("android:name", packageName)
}.let(queries::appendChild)
}
}
}
@Suppress("unused")
val overrideOpenInYouTubeMusicButtonPatch = bytecodePatch(
name = "Override 'Open in YouTube Music' button",
description = "Overrides the button to open YouTube Music under a different package name. " +
"By default, it overrides to the ReVanced default package name of YouTube Music.",
) {
dependsOn(
settingsPatch,
addResourcesPatch,
overrideOpenInYouTubeMusicManifestResourcePatch
)
compatibleWith(
"com.google.android.youtube"(
"20.14.43",
"20.21.37",
"20.26.46",
"20.31.42",
"20.37.48",
"20.40.45",
"20.44.38",
"20.45.36"
),
)
val packageName by packageNameOption()
apply {
addResources("youtube", "layout.buttons.music.overrideOpenInYouTubeMusicButtonPatch")
PreferenceScreen.GENERAL.addPreferences(
PreferenceCategory(
titleKey = null,
tag = "app.revanced.extension.shared.settings.preference.NoTitlePreferenceCategory",
preferences = setOf(SwitchPreference(key = "revanced_override_open_in_youtube_music_button"))
)
)
getOverridePackageNameMethod.returnEarly(packageName!!)
forEachInstructionAsSequence({ _, _, instruction, index ->
if (instruction.opcode != Opcode.INVOKE_VIRTUAL) return@forEachInstructionAsSequence null
val reference = instruction.methodReference ?: return@forEachInstructionAsSequence null
if (reference.definingClass != "Landroid/content/Intent;") return@forEachInstructionAsSequence null
when (reference.name) {
"setPackage" if reference.parameterTypes == listOf("Ljava/lang/String;") ->
index to "overrideSetPackage(Landroid/content/Intent;Ljava/lang/String;)Landroid/content/Intent;"
"setData" if reference.parameterTypes == listOf("Landroid/net/Uri;") ->
index to "overrideSetData(Landroid/content/Intent;Landroid/net/Uri;)Landroid/content/Intent;"
else -> null
}
}) { method, (index, methodDescriptor) ->
if (method.definingClass == EXTENSION_CLASS_DESCRIPTOR) return@forEachInstructionAsSequence
val invokeString = when (val instruction = method.getInstruction(index)) {
is RegisterRangeInstruction ->
"invoke-static/range { v${instruction.startRegister} .. v${instruction.startRegister + instruction.registerCount - 1} }"
is FiveRegisterInstruction ->
"invoke-static { v${instruction.registerC}, v${instruction.registerD} }"
else -> return@forEachInstructionAsSequence
}
method.replaceInstruction(
index,
"$invokeString, $EXTENSION_CLASS_DESCRIPTOR->$methodDescriptor"
)
}
}
}

View file

@ -60,7 +60,9 @@ val navigationBarPatch = bytecodePatch(
"20.26.46",
"20.31.42",
"20.37.48",
"20.40.45"
"20.40.45",
"20.44.38",
"20.45.36"
),
)

View file

@ -43,7 +43,9 @@ val hidePlayerOverlayButtonsPatch = bytecodePatch(
"20.26.46",
"20.31.42",
"20.37.48",
"20.40.45"
"20.40.45",
"20.44.38",
"20.45.36"
),
)
@ -146,7 +148,8 @@ val hidePlayerOverlayButtonsPatch = bytecodePatch(
titleAnchorMethodMatch.let {
it.method.apply {
val titleAnchorIndex = it[-1]
val titleAnchorRegister = getInstruction<OneRegisterInstruction>(titleAnchorIndex).registerA
val titleAnchorRegister =
getInstruction<OneRegisterInstruction>(titleAnchorIndex).registerA
addInstruction(
titleAnchorIndex + 1,
@ -154,7 +157,8 @@ val hidePlayerOverlayButtonsPatch = bytecodePatch(
)
val playerCollapseButtonIndex = it[1]
val playerCollapseButtonRegister = getInstruction<OneRegisterInstruction>(playerCollapseButtonIndex).registerA
val playerCollapseButtonRegister =
getInstruction<OneRegisterInstruction>(playerCollapseButtonIndex).registerA
addInstruction(
playerCollapseButtonIndex + 1,
@ -173,7 +177,8 @@ val hidePlayerOverlayButtonsPatch = bytecodePatch(
// so match on move-result-object after findViewById instead of check-cast.
val moveResultIndex = it[2]
val insertIndex = moveResultIndex + 1
val insertRegister = getInstruction<OneRegisterInstruction>(moveResultIndex).registerA
val insertRegister =
getInstruction<OneRegisterInstruction>(moveResultIndex).registerA
addInstructionsWithLabels(
insertIndex,

View file

@ -7,6 +7,9 @@ import app.revanced.patcher.patch.bytecodePatch
import app.revanced.patches.all.misc.resources.addResources
import app.revanced.patches.all.misc.resources.addResourcesPatch
import app.revanced.patches.shared.misc.settings.preference.ListPreference
import app.revanced.patches.youtube.misc.contexthook.Endpoint
import app.revanced.patches.youtube.misc.contexthook.addClientFormFactorHook
import app.revanced.patches.youtube.misc.contexthook.hookClientContextPatch
import app.revanced.patches.youtube.misc.extension.sharedExtensionPatch
import app.revanced.patches.youtube.misc.navigation.hookNavigationButtonCreated
import app.revanced.patches.youtube.misc.navigation.navigationBarHookPatch
@ -27,6 +30,7 @@ val changeFormFactorPatch = bytecodePatch(
sharedExtensionPatch,
settingsPatch,
addResourcesPatch,
hookClientContextPatch,
navigationBarHookPatch
)
@ -37,7 +41,9 @@ val changeFormFactorPatch = bytecodePatch(
"20.26.46",
"20.31.42",
"20.37.48",
"20.40.45"
"20.40.45",
"20.44.38",
"20.45.36"
),
)
@ -48,8 +54,6 @@ val changeFormFactorPatch = bytecodePatch(
ListPreference("revanced_change_form_factor"),
)
hookNavigationButtonCreated(EXTENSION_CLASS_DESCRIPTOR)
val formFactorEnumConstructorClass = formFactorEnumConstructorMethod.definingClass
val createPlayerRequestBodyWithModelMatch = firstMethodComposite {
@ -70,11 +74,23 @@ val changeFormFactorPatch = bytecodePatch(
addInstructions(
index + 1,
"""
invoke-static { v$register }, $EXTENSION_CLASS_DESCRIPTOR->getFormFactor(I)I
invoke-static { v$register }, $EXTENSION_CLASS_DESCRIPTOR->getUniversalFormFactor(I)I
move-result v$register
""",
)
}
}
setOf(
Endpoint.GET_WATCH,
Endpoint.NEXT,
Endpoint.GUIDE,
Endpoint.REEL,
).forEach { endpoint ->
addClientFormFactorHook(
endpoint,
"$EXTENSION_CLASS_DESCRIPTOR->replaceBrokenFormFactor(I)I",
)
}
}
}

Some files were not shown because too many files have changed in this diff Show more