- * Allows hiding home feed and search results based on keywords and/or channel names. + * Allows hiding home feed and search results based on video title keywords and/or channel names. * * Limitations: * - Searching for a keyword phrase will give no search results. @@ -41,19 +41,14 @@ import app.revanced.integrations.youtube.shared.PlayerType; * (ie: "mr beast" automatically filters "Mr Beast" and "MR BEAST"). * - Keywords present in the layout or video data cannot be used as filters, otherwise all videos * will always be hidden. This patch checks for some words of these words. + * - When using whole word syntax, some keywords may need additional pluralized variations. */ @SuppressWarnings("unused") @RequiresApi(api = Build.VERSION_CODES.N) final class KeywordContentFilter extends Filter { /** - * Minimum keyword/phrase length to prevent excessively broad content filtering. - */ - private static final int MINIMUM_KEYWORD_LENGTH = 3; - - /** - * Strings found in the buffer for every videos. - * Full strings should be specified, as they are compared using {@link String#contains(CharSequence)}. + * Strings found in the buffer for every videos. Full strings should be specified. * * 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. @@ -88,7 +83,7 @@ final class KeywordContentFilter extends Filter { "search_vwc_description_transition_key", "g-high-recZ", // Text and litho components found in the buffer that belong to path filters. - "metadata.eml", + "expandable_metadata.eml", "thumbnail.eml", "avatar.eml", "overflow_button.eml", @@ -107,7 +102,8 @@ final class KeywordContentFilter extends Filter { "search_video_with_context.eml", "video_with_context.eml", // Subscription tab videos. "related_video_with_context.eml", - "video_lockup_with_attachment.eml", // A/B test for subscribed video. + // A/B test for subscribed video, and sometimes when tablet layout is enabled. + "video_lockup_with_attachment.eml", "compact_video.eml", "inline_shorts", "shorts_video_cell", @@ -139,6 +135,12 @@ final class KeywordContentFilter extends Filter { "overflow_button.eml" ); + /** + * Minimum keyword/phrase length to prevent excessively broad content filtering. + * Only applies when not using whole word syntax. + */ + private static final int MINIMUM_KEYWORD_LENGTH = 3; + /** * Threshold for {@link #filteredVideosPercentage} * that indicates all or nearly all videos have been filtered. @@ -150,6 +152,8 @@ final class KeywordContentFilter extends Filter { private static final long ALL_VIDEOS_FILTERED_BACKOFF_MILLISECONDS = 60 * 1000; // 60 seconds + private static final int UTF8_MAX_BYTE_COUNT = 4; + /** * 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} @@ -216,23 +220,167 @@ final class KeywordContentFilter extends Filter { capitalizeNext = false; } } + return new String(codePoints, 0, codePoints.length); } /** - * @return If the phrase will will hide all videos. Not an exhaustive check. + * @return If the string contains any characters from languages that do not use spaces between words. */ - private static boolean phrasesWillHideAllVideos(@NonNull String[] phrases) { - for (String commonString : STRINGS_IN_EVERY_BUFFER) { - if (Utils.containsAny(commonString, phrases)) { + private static boolean isLanguageWithNoSpaces(String text) { + for (int i = 0, length = text.length(); i < length;) { + final int codePoint = text.codePointAt(i); + + Character.UnicodeBlock block = Character.UnicodeBlock.of(codePoint); + if (block == CJK_UNIFIED_IDEOGRAPHS // Chinese and Kanji + || block == HIRAGANA // Japanese Hiragana + || block == KATAKANA // Japanese Katakana + || block == THAI + || block == LAO + || block == MYANMAR + || block == KHMER + || block == TIBETAN) { return true; } + + i += Character.charCount(codePoint); } + return false; } + /** + * @return If the phrase will hide all videos. Not an exhaustive check. + */ + private static boolean phrasesWillHideAllVideos(@NonNull String[] phrases, boolean matchWholeWords) { + for (String phrase : phrases) { + for (String commonString : STRINGS_IN_EVERY_BUFFER) { + if (matchWholeWords) { + byte[] commonStringBytes = commonString.getBytes(StandardCharsets.UTF_8); + int matchIndex = 0; + while (true) { + matchIndex = commonString.indexOf(phrase, matchIndex); + if (matchIndex < 0) break; + + if (keywordMatchIsWholeWord(commonStringBytes, matchIndex, phrase.length())) { + return true; + } + + matchIndex++; + } + } else if (Utils.containsAny(commonString, phrases)) { + return true; + } + } + } + + return false; + } + + /** + * @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. + */ + private static boolean keywordMatchIsWholeWord(byte[] text, int keywordStartIndex, int keywordLength) { + final Integer codePointBefore = getUtf8CodePointBefore(text, keywordStartIndex); + if (codePointBefore != null && Character.isLetter(codePointBefore)) { + return false; + } + + final Integer codePointAfter = getUtf8CodePointAt(text, keywordStartIndex + keywordLength); + //noinspection RedundantIfStatement + if (codePointAfter != null && Character.isLetter(codePointAfter)) { + return false; + } + + return true; + } + + /** + * @return The UTF8 character point immediately before the index, + * or null if the bytes before the index is not a valid UTF8 character. + */ + @Nullable + private static Integer getUtf8CodePointBefore(byte[] data, int index) { + int characterByteCount = 0; + while (--index >= 0 && ++characterByteCount <= UTF8_MAX_BYTE_COUNT) { + if (isValidUtf8(data, index, characterByteCount)) { + return decodeUtf8ToCodePoint(data, index, characterByteCount); + } + } + + return null; + } + + /** + * @return The UTF8 character point at the index, + * or null if the index holds no valid UTF8 character. + */ + @Nullable + private static Integer getUtf8CodePointAt(byte[] data, int index) { + int characterByteCount = 0; + final int dataLength = data.length; + while (index + characterByteCount < dataLength && ++characterByteCount <= UTF8_MAX_BYTE_COUNT) { + if (isValidUtf8(data, index, characterByteCount)) { + return decodeUtf8ToCodePoint(data, index, characterByteCount); + } + } + + return null; + } + + public static boolean isValidUtf8(byte[] data, int startIndex, int numberOfBytes) { + switch (numberOfBytes) { + case 1: // 0xxxxxxx (ASCII) + return (data[startIndex] & 0x80) == 0; + case 2: // 110xxxxx, 10xxxxxx + return (data[startIndex] & 0xE0) == 0xC0 + && (data[startIndex + 1] & 0xC0) == 0x80; + case 3: // 1110xxxx, 10xxxxxx, 10xxxxxx + return (data[startIndex] & 0xF0) == 0xE0 + && (data[startIndex + 1] & 0xC0) == 0x80 + && (data[startIndex + 2] & 0xC0) == 0x80; + case 4: // 11110xxx, 10xxxxxx, 10xxxxxx, 10xxxxxx + return (data[startIndex] & 0xF8) == 0xF0 + && (data[startIndex + 1] & 0xC0) == 0x80 + && (data[startIndex + 2] & 0xC0) == 0x80 + && (data[startIndex + 3] & 0xC0) == 0x80; + } + + throw new IllegalArgumentException("numberOfBytes: " + numberOfBytes); + } + + public static int decodeUtf8ToCodePoint(byte[] data, int startIndex, int numberOfBytes) { + switch (numberOfBytes) { + case 1: + return data[startIndex]; + case 2: + return ((data[startIndex] & 0x1F) << 6) | + (data[startIndex + 1] & 0x3F); + case 3: + return ((data[startIndex] & 0x0F) << 12) | + ((data[startIndex + 1] & 0x3F) << 6) | + (data[startIndex + 2] & 0x3F); + case 4: + return ((data[startIndex] & 0x07) << 18) | + ((data[startIndex + 1] & 0x3F) << 12) | + ((data[startIndex + 2] & 0x3F) << 6) | + (data[startIndex + 3] & 0x3F); + } + throw new IllegalArgumentException("numberOfBytes: " + numberOfBytes); + } + + private static boolean phraseUsesWholeWordSyntax(String phrase) { + return phrase.startsWith("\"") && phrase.endsWith("\""); + } + + private static String stripWholeWordSyntax(String phrase) { + return phrase.substring(1, phrase.length() - 1); + } + private synchronized void parseKeywords() { // Must be synchronized since Litho is multi-threaded. String rawKeywords = Settings.HIDE_KEYWORD_CONTENT_PHRASES.get(); + //noinspection StringEquality if (rawKeywords == lastKeywordPhrasesParsed) { Logger.printDebug(() -> "Using previously initialized search"); @@ -243,20 +391,33 @@ final class KeywordContentFilter extends Filter { String[] split = rawKeywords.split("\n"); if (split.length != 0) { // Linked Set so log statement are more organized and easier to read. - Setkeywords = new LinkedHashSet<>(10 * split.length); + // Map is: Phrase -> isWholeWord + Map keywords = new LinkedHashMap<>(10 * split.length); for (String phrase : split) { - // Remove any trailing white space the user may have accidentally included. + // Remove any trailing spaces the user may have accidentally included. phrase = phrase.stripTrailing(); if (phrase.isBlank()) continue; - if (phrase.length() < MINIMUM_KEYWORD_LENGTH) { + final boolean wholeWordMatching; + if (phraseUsesWholeWordSyntax(phrase)) { + if (phrase.length() == 2) { + continue; // Empty "" phrase + } + phrase = stripWholeWordSyntax(phrase); + wholeWordMatching = true; + } else if (phrase.length() < MINIMUM_KEYWORD_LENGTH && !isLanguageWithNoSpaces(phrase)) { + // Allow phrases of 1 and 2 characters if using a + // language that does not use spaces between words. + // Do not reset the setting. Keep the invalid keywords so the user can fix the mistake. Utils.showToastLong(str("revanced_hide_keyword_toast_invalid_length", phrase, MINIMUM_KEYWORD_LENGTH)); continue; + } else { + wholeWordMatching = false; } - // Add common casing that might appear. + // Common casing that might appear. // // This could be simplified by adding case insensitive search to the prefix search, // which is very simple to add to StringTreSearch for Unicode and ByteTrieSearch for ASCII. @@ -265,7 +426,7 @@ final class KeywordContentFilter extends Filter { // UTF-8 characters can be different byte lengths, which does // not allow comparing two different byte arrays using simple plain array indexes. // - // Instead add all common case variations of the words. + // Instead use all common case variations of the words. String[] phraseVariations = { phrase, phrase.toLowerCase(), @@ -273,20 +434,45 @@ final class KeywordContentFilter extends Filter { capitalizeAllFirstLetters(phrase), phrase.toUpperCase() }; - if (phrasesWillHideAllVideos(phraseVariations)) { - Utils.showToastLong(str("revanced_hide_keyword_toast_invalid_common", phrase)); + + if (phrasesWillHideAllVideos(phraseVariations, wholeWordMatching)) { + String toastMessage; + // If whole word matching is off, but would pass with on, then show a different toast. + if (!wholeWordMatching && !phrasesWillHideAllVideos(phraseVariations, true)) { + toastMessage = "revanced_hide_keyword_toast_invalid_common_whole_word_required"; + } else { + toastMessage = "revanced_hide_keyword_toast_invalid_common"; + } + + Utils.showToastLong(str(toastMessage, phrase)); continue; } - keywords.addAll(Arrays.asList(phraseVariations)); + for (String variation : phraseVariations) { + // Check if the same phrase is declared both with and without quotes. + Boolean existing = keywords.get(variation); + if (existing == null) { + keywords.put(variation, wholeWordMatching); + } else if (existing != wholeWordMatching) { + Utils.showToastLong(str("revanced_hide_keyword_toast_invalid_conflicting", phrase)); + break; + } + } } - for (String keyword : keywords) { - // Use a callback to get the keyword that matched. - // TrieSearch could have this built in, but that's slightly more complicated since - // the strings are stored as a byte array and embedded in the search tree. + for (Map.Entry entry : keywords.entrySet()) { + String keyword = entry.getKey(); + //noinspection ExtractMethodRecommender + final boolean isWholeWord = entry.getValue(); + TrieSearch.TriePatternMatchedCallback callback = - (textSearched, matchedStartIndex, matchedLength, callbackParameter) -> { + (textSearched, startIndex, matchLength, callbackParameter) -> { + if (isWholeWord && !keywordMatchIsWholeWord(textSearched, startIndex, matchLength)) { + return false; + } + + Logger.printDebug(() -> (isWholeWord ? "Matched whole keyword: '" + : "Matched keyword: '") + keyword + "'"); // noinspection unchecked ((MutableReference ) callbackParameter).value = keyword; return true; @@ -295,7 +481,7 @@ final class KeywordContentFilter extends Filter { search.addPattern(stringBytes, callback); } - Logger.printDebug(() -> "Search using: (" + search.getEstimatedMemorySize() + " KB) keywords: " + keywords); + Logger.printDebug(() -> "Search using: (" + search.getEstimatedMemorySize() + " KB) keywords: " + keywords.keySet()); } bufferSearch = search; @@ -382,7 +568,7 @@ final class KeywordContentFilter extends Filter { // Field is intentionally compared using reference equality. //noinspection StringEquality if (Settings.HIDE_KEYWORD_CONTENT_PHRASES.get() != lastKeywordPhrasesParsed) { - // User changed the keywords. + // User changed the keywords or whole word setting. parseKeywords(); } diff --git a/app/src/main/java/app/revanced/integrations/youtube/patches/components/LayoutComponentsFilter.java b/app/src/main/java/app/revanced/integrations/youtube/patches/components/LayoutComponentsFilter.java index 4d7358d4..a0ffde0b 100644 --- a/app/src/main/java/app/revanced/integrations/youtube/patches/components/LayoutComponentsFilter.java +++ b/app/src/main/java/app/revanced/integrations/youtube/patches/components/LayoutComponentsFilter.java @@ -81,7 +81,8 @@ public final class LayoutComponentsFilter extends Filter { Settings.HIDE_COMMUNITY_POSTS, "post_base_wrapper", "image_post_root.eml", - "text_post_root.eml" + "text_post_root.eml", + "images_post_root.eml" ); final var communityGuidelines = new StringFilterGroup( diff --git a/app/src/main/java/app/revanced/integrations/youtube/patches/components/ReturnYouTubeDislikeFilterPatch.java b/app/src/main/java/app/revanced/integrations/youtube/patches/components/ReturnYouTubeDislikeFilterPatch.java index 927e4493..8df0d190 100644 --- a/app/src/main/java/app/revanced/integrations/youtube/patches/components/ReturnYouTubeDislikeFilterPatch.java +++ b/app/src/main/java/app/revanced/integrations/youtube/patches/components/ReturnYouTubeDislikeFilterPatch.java @@ -52,7 +52,7 @@ public final class ReturnYouTubeDislikeFilterPatch extends Filter { @SuppressWarnings("unused") public static void newPlayerResponseVideoId(String videoId, boolean isShortAndOpeningOrPlaying) { try { - if (!isShortAndOpeningOrPlaying || !Settings.RYD_SHORTS.get()) { + if (!isShortAndOpeningOrPlaying || !Settings.RYD_ENABLED.get() || !Settings.RYD_SHORTS.get()) { return; } synchronized (lastVideoIds) { @@ -68,14 +68,22 @@ public final class ReturnYouTubeDislikeFilterPatch extends Filter { private final ByteArrayFilterGroupList videoIdFilterGroup = new ByteArrayFilterGroupList(); public ReturnYouTubeDislikeFilterPatch() { + // When a new Short is opened, the like buttons always seem to load before the dislike. + // But if swiping back to a previous video and liking/disliking, then only that single button reloads. + // So must check for both buttons. addPathCallbacks( - new StringFilterGroup(Settings.RYD_SHORTS, "|shorts_dislike_button.eml|") + new StringFilterGroup(null, "|shorts_like_button.eml"), + new StringFilterGroup(null, "|shorts_dislike_button.eml") ); - // After the dislikes icon name is some binary data and then the video id for that specific short. + + // After the likes icon name is some binary data and then the video id for that specific short. videoIdFilterGroup.addAll( - // Video was previously disliked before video was opened. + // on_shadowed = Video was previously like/disliked before opening. + // off_shadowed = Video was not previously liked/disliked before opening. + new ByteArrayFilterGroup(null, "ic_right_like_on_shadowed"), + new ByteArrayFilterGroup(null, "ic_right_like_off_shadowed"), + new ByteArrayFilterGroup(null, "ic_right_dislike_on_shadowed"), - // Video was not already disliked. new ByteArrayFilterGroup(null, "ic_right_dislike_off_shadowed") ); } @@ -83,6 +91,10 @@ public final class ReturnYouTubeDislikeFilterPatch extends Filter { @Override boolean isFiltered(@Nullable String identifier, String path, byte[] protobufBufferArray, StringFilterGroup matchedGroup, FilterContentType contentType, int contentIndex) { + if (!Settings.RYD_ENABLED.get() || !Settings.RYD_SHORTS.get()) { + return false; + } + FilterGroup.FilterGroupResult result = videoIdFilterGroup.check(protobufBufferArray); if (result.isFiltered()) { String matchedVideoId = findVideoId(protobufBufferArray); @@ -104,6 +116,7 @@ public final class ReturnYouTubeDislikeFilterPatch extends Filter { return videoId; } } + return null; } } @@ -125,6 +138,7 @@ public final class ReturnYouTubeDislikeFilterPatch extends Filter { return true; } } + return false; } } \ No newline at end of file diff --git a/app/src/main/java/app/revanced/integrations/youtube/patches/components/ShortsFilter.java b/app/src/main/java/app/revanced/integrations/youtube/patches/components/ShortsFilter.java index 8ba6c46b..a7935f71 100644 --- a/app/src/main/java/app/revanced/integrations/youtube/patches/components/ShortsFilter.java +++ b/app/src/main/java/app/revanced/integrations/youtube/patches/components/ShortsFilter.java @@ -202,6 +202,10 @@ public final class ShortsFilter extends Filter { new ByteArrayFilterGroup( Settings.HIDE_SHORTS_SUPER_THANKS_BUTTON, "yt_outline_dollar_sign_heart_" + ), + new ByteArrayFilterGroup( + Settings.HIDE_SHORTS_USE_THIS_SOUND_BUTTON, + "yt_outline_camera_" ) ); } diff --git a/app/src/main/java/app/revanced/integrations/youtube/patches/spoof/ClientType.java b/app/src/main/java/app/revanced/integrations/youtube/patches/spoof/ClientType.java new file mode 100644 index 00000000..f5300cb8 --- /dev/null +++ b/app/src/main/java/app/revanced/integrations/youtube/patches/spoof/ClientType.java @@ -0,0 +1,79 @@ +package app.revanced.integrations.youtube.patches.spoof; + +import static app.revanced.integrations.youtube.patches.spoof.DeviceHardwareSupport.allowAV1; +import static app.revanced.integrations.youtube.patches.spoof.DeviceHardwareSupport.allowVP9; + +import android.os.Build; + +import androidx.annotation.Nullable; + +public enum ClientType { + // https://dumps.tadiphone.dev/dumps/oculus/eureka + IOS(5, + // iPhone 15 supports AV1 hardware decoding. + // Only use if this Android device also has hardware decoding. + allowAV1() + ? "iPhone16,2" // 15 Pro Max + : "iPhone11,4", // XS Max + // iOS 14+ forces VP9. + allowVP9() + ? "17.5.1.21F90" + : "13.7.17H35", + allowVP9() + ? "com.google.ios.youtube/19.10.7 (iPhone; U; CPU iOS 17_5_1 like Mac OS X)" + : "com.google.ios.youtube/19.10.7 (iPhone; U; CPU iOS 13_7 like Mac OS X)", + null, + // Version number should be a valid iOS release. + // https://www.ipa4fun.com/history/185230 + "19.10.7" + ), + ANDROID_VR(28, + "Quest 3", + "12", + "com.google.android.apps.youtube.vr.oculus/1.56.21 (Linux; U; Android 12; GB) gzip", + "32", // Android 12.1 + "1.56.21" + ); + + /** + * YouTube + * client type + */ + public final int id; + + /** + * Device model, equivalent to {@link Build#MODEL} (System property: ro.product.model) + */ + public final String model; + + /** + * Device OS version. + */ + public final String osVersion; + + /** + * Player user-agent. + */ + public final String userAgent; + + /** + * Android SDK version, equivalent to {@link Build.VERSION#SDK} (System property: ro.build.version.sdk) + * Field is null if not applicable. + */ + @Nullable + public final String androidSdkVersion; + + /** + * App version. + */ + public final String appVersion; + + ClientType(int id, String model, String osVersion, String userAgent, @Nullable String androidSdkVersion, String appVersion) { + this.id = id; + this.model = model; + this.osVersion = osVersion; + this.userAgent = userAgent; + this.androidSdkVersion = androidSdkVersion; + this.appVersion = appVersion; + } +} diff --git a/app/src/main/java/app/revanced/integrations/youtube/patches/spoof/DeviceHardwareSupport.java b/app/src/main/java/app/revanced/integrations/youtube/patches/spoof/DeviceHardwareSupport.java new file mode 100644 index 00000000..6b147cd6 --- /dev/null +++ b/app/src/main/java/app/revanced/integrations/youtube/patches/spoof/DeviceHardwareSupport.java @@ -0,0 +1,53 @@ +package app.revanced.integrations.youtube.patches.spoof; + +import android.media.MediaCodecInfo; +import android.media.MediaCodecList; +import android.os.Build; + +import app.revanced.integrations.shared.Logger; +import app.revanced.integrations.youtube.settings.Settings; + +public class DeviceHardwareSupport { + public static final boolean DEVICE_HAS_HARDWARE_DECODING_VP9; + public static final boolean DEVICE_HAS_HARDWARE_DECODING_AV1; + + static { + boolean vp9found = false; + boolean av1found = false; + MediaCodecList codecList = new MediaCodecList(MediaCodecList.ALL_CODECS); + final boolean deviceIsAndroidTenOrLater = Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q; + + for (MediaCodecInfo codecInfo : codecList.getCodecInfos()) { + final boolean isHardwareAccelerated = deviceIsAndroidTenOrLater + ? codecInfo.isHardwareAccelerated() + : !codecInfo.getName().startsWith("OMX.google"); // Software decoder. + if (isHardwareAccelerated && !codecInfo.isEncoder()) { + for (String type : codecInfo.getSupportedTypes()) { + if (type.equalsIgnoreCase("video/x-vnd.on2.vp9")) { + vp9found = true; + } else if (type.equalsIgnoreCase("video/av01")) { + av1found = true; + } + } + } + } + + DEVICE_HAS_HARDWARE_DECODING_VP9 = vp9found; + DEVICE_HAS_HARDWARE_DECODING_AV1 = av1found; + + Logger.printDebug(() -> DEVICE_HAS_HARDWARE_DECODING_AV1 + ? "Device supports AV1 hardware decoding\n" + : "Device does not support AV1 hardware decoding\n" + + (DEVICE_HAS_HARDWARE_DECODING_VP9 + ? "Device supports VP9 hardware decoding" + : "Device does not support VP9 hardware decoding")); + } + + public static boolean allowVP9() { + return DEVICE_HAS_HARDWARE_DECODING_VP9 && !Settings.SPOOF_VIDEO_STREAMS_IOS_FORCE_AVC.get(); + } + + public static boolean allowAV1() { + return allowVP9() && DEVICE_HAS_HARDWARE_DECODING_AV1; + } +} diff --git a/app/src/main/java/app/revanced/integrations/youtube/patches/spoof/SpoofClientPatch.java b/app/src/main/java/app/revanced/integrations/youtube/patches/spoof/SpoofClientPatch.java deleted file mode 100644 index 2b29dd97..00000000 --- a/app/src/main/java/app/revanced/integrations/youtube/patches/spoof/SpoofClientPatch.java +++ /dev/null @@ -1,229 +0,0 @@ -package app.revanced.integrations.youtube.patches.spoof; - -import android.media.MediaCodecInfo; -import android.media.MediaCodecList; -import android.net.Uri; -import android.os.Build; -import app.revanced.integrations.shared.Logger; -import app.revanced.integrations.youtube.patches.BackgroundPlaybackPatch; -import app.revanced.integrations.youtube.settings.Settings; -import org.chromium.net.ExperimentalUrlRequest; - -@SuppressWarnings("unused") -public class SpoofClientPatch { - private static final boolean SPOOF_CLIENT_ENABLED = Settings.SPOOF_CLIENT.get(); - private static final ClientType SPOOF_CLIENT_TYPE = Settings.SPOOF_CLIENT_USE_IOS.get() ? ClientType.IOS : ClientType.ANDROID_VR; - private static final boolean SPOOFING_TO_IOS = SPOOF_CLIENT_ENABLED && SPOOF_CLIENT_TYPE == ClientType.IOS; - - /** - * Any unreachable ip address. Used to intentionally fail requests. - */ - private static final String UNREACHABLE_HOST_URI_STRING = "https://127.0.0.0"; - private static final Uri UNREACHABLE_HOST_URI = Uri.parse(UNREACHABLE_HOST_URI_STRING); - - /** - * Injection point. - * Blocks /get_watch requests by returning an unreachable URI. - * - * @param playerRequestUri The URI of the player request. - * @return An unreachable URI if the request is a /get_watch request, otherwise the original URI. - */ - public static Uri blockGetWatchRequest(Uri playerRequestUri) { - if (SPOOF_CLIENT_ENABLED) { - try { - String path = playerRequestUri.getPath(); - - if (path != null && path.contains("get_watch")) { - Logger.printDebug(() -> "Blocking 'get_watch' by returning unreachable uri"); - - return UNREACHABLE_HOST_URI; - } - } catch (Exception ex) { - Logger.printException(() -> "blockGetWatchRequest failure", ex); - } - } - - return playerRequestUri; - } - - /** - * Injection point. - * - * Blocks /initplayback requests. - */ - public static String blockInitPlaybackRequest(String originalUrlString) { - if (SPOOF_CLIENT_ENABLED) { - try { - var originalUri = Uri.parse(originalUrlString); - String path = originalUri.getPath(); - - if (path != null && path.contains("initplayback")) { - Logger.printDebug(() -> "Blocking 'initplayback' by returning unreachable url"); - - return UNREACHABLE_HOST_URI_STRING; - } - } catch (Exception ex) { - Logger.printException(() -> "blockInitPlaybackRequest failure", ex); - } - } - - return originalUrlString; - } - - /** - * Injection point. - */ - public static int getClientTypeId(int originalClientTypeId) { - return SPOOF_CLIENT_ENABLED ? SPOOF_CLIENT_TYPE.id : originalClientTypeId; - } - - /** - * Injection point. - */ - public static String getClientVersion(String originalClientVersion) { - return SPOOF_CLIENT_ENABLED ? SPOOF_CLIENT_TYPE.version : originalClientVersion; - } - - /** - * Injection point. - */ - public static String getClientModel(String originalClientModel) { - return SPOOF_CLIENT_ENABLED ? SPOOF_CLIENT_TYPE.model : originalClientModel; - } - - /** - * Injection point. - * Fix video qualities missing, if spoofing to iOS by using the correct client OS version. - */ - public static String getOsVersion(String originalOsVersion) { - return SPOOFING_TO_IOS ? ClientType.IOS.osVersion : originalOsVersion; - } - - /** - * Injection point. - */ - public static boolean enablePlayerGesture(boolean original) { - return SPOOF_CLIENT_ENABLED || original; - } - - /** - * Injection point. - */ - public static boolean isClientSpoofingEnabled() { - return SPOOF_CLIENT_ENABLED; - } - - /** - * Injection point. - * When spoofing the client to iOS, the playback speed menu is missing from the player response. - * Return true to force create the playback speed menu. - */ - public static boolean forceCreatePlaybackSpeedMenu(boolean original) { - return SPOOFING_TO_IOS || original; - } - - /** - * Injection point. - * When spoofing the client to iOS, background audio only playback of livestreams fails. - * Return true to force enable audio background play. - */ - public static boolean overrideBackgroundAudioPlayback() { - return SPOOFING_TO_IOS && BackgroundPlaybackPatch.playbackIsNotShort(); - } - - /** - * Injection point. - * Fix video qualities missing, if spoofing to iOS by using the correct iOS user-agent. - */ - public static ExperimentalUrlRequest overrideUserAgent(ExperimentalUrlRequest.Builder builder, String url) { - if (SPOOFING_TO_IOS) { - String path = Uri.parse(url).getPath(); - if (path != null && path.contains("player")) { - return builder.addHeader("User-Agent", ClientType.IOS.userAgent).build(); - } - } - - return builder.build(); - } - - private enum ClientType { - // https://dumps.tadiphone.dev/dumps/oculus/eureka - ANDROID_VR(28, - "Quest 3", - "1.56.21", - "12", - "com.google.android.apps.youtube.vr.oculus/1.56.21 (Linux; U; Android 12; GB) gzip" - ), - // 11,4 = iPhone XS Max. - // 16,2 = iPhone 15 Pro Max. - // Since the 15 supports AV1 hardware decoding, only spoof that device if this - // Android device also has hardware decoding. - // - // Version number should be a valid iOS release. - // https://www.ipa4fun.com/history/185230 - IOS(5, - deviceHasAV1HardwareDecoding() ? "iPhone16,2" : "iPhone11,4", - "19.10.7", - "17.5.1.21F90", - "com.google.ios.youtube/19.10.7 (iPhone; U; CPU iOS 17_5_1 like Mac OS X)" - ); - - /** - * YouTube - * client type - */ - final int id; - - /** - * Device model, equivalent to {@link Build#MODEL} (System property: ro.product.model) - */ - final String model; - - /** - * App version. - */ - final String version; - - /** - * Device OS version. - */ - final String osVersion; - - /** - * Player user-agent. - */ - final String userAgent; - - ClientType(int id, String model, String version, String osVersion, String userAgent) { - this.id = id; - this.model = model; - this.version = version; - this.osVersion = osVersion; - this.userAgent = userAgent; - } - } - - private static boolean deviceHasAV1HardwareDecoding() { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - MediaCodecList codecList = new MediaCodecList(MediaCodecList.ALL_CODECS); - - for (MediaCodecInfo codecInfo : codecList.getCodecInfos()) { - if (codecInfo.isHardwareAccelerated() && !codecInfo.isEncoder()) { - String[] supportedTypes = codecInfo.getSupportedTypes(); - for (String type : supportedTypes) { - if (type.equalsIgnoreCase("video/av01")) { - MediaCodecInfo.CodecCapabilities capabilities = codecInfo.getCapabilitiesForType(type); - if (capabilities != null) { - Logger.printDebug(() -> "Device supports AV1 hardware decoding."); - return true; - } - } - } - } - } - } - - Logger.printDebug(() -> "Device does not support AV1 hardware decoding."); - return false; - } -} diff --git a/app/src/main/java/app/revanced/integrations/youtube/patches/spoof/SpoofSignaturePatch.java b/app/src/main/java/app/revanced/integrations/youtube/patches/spoof/SpoofSignaturePatch.java deleted file mode 100644 index 41f03ed7..00000000 --- a/app/src/main/java/app/revanced/integrations/youtube/patches/spoof/SpoofSignaturePatch.java +++ /dev/null @@ -1,242 +0,0 @@ -package app.revanced.integrations.youtube.patches.spoof; - -import android.view.View; -import android.view.ViewGroup; -import android.widget.ImageView; -import androidx.annotation.Nullable; -import app.revanced.integrations.shared.Logger; -import app.revanced.integrations.shared.Utils; -import app.revanced.integrations.youtube.patches.VideoInformation; -import app.revanced.integrations.youtube.settings.Settings; -import app.revanced.integrations.youtube.shared.PlayerType; - -import java.util.concurrent.ExecutionException; -import java.util.concurrent.Future; -import java.util.concurrent.TimeUnit; -import java.util.concurrent.TimeoutException; - -import static app.revanced.integrations.shared.Utils.containsAny; -import static app.revanced.integrations.youtube.patches.spoof.requests.StoryboardRendererRequester.getStoryboardRenderer; - -/** @noinspection unused*/ -@Deprecated -public class SpoofSignaturePatch { - /** - * Parameter (also used by - * yt-dlp) - * to fix playback issues. - */ - private static final String INCOGNITO_PARAMETERS = "CgIQBg=="; - - /** - * Parameters used when playing clips. - */ - private static final String CLIPS_PARAMETERS = "kAIB"; - - /** - * Parameters causing playback issues. - */ - private static final String[] AUTOPLAY_PARAMETERS = { - "YAHI", // Autoplay in feed. - "SAFg" // Autoplay in scrim. - }; - - /** - * Parameter used for autoplay in scrim. - * Prepend this parameter to mute video playback (for autoplay in feed). - */ - private static final String SCRIM_PARAMETER = "SAFgAXgB"; - - /** - * Last video id loaded. Used to prevent reloading the same spec multiple times. - */ - @Nullable - private static volatile String lastPlayerResponseVideoId; - - @Nullable - private static volatile Future
rendererFuture; - - private static volatile boolean useOriginalStoryboardRenderer; - - private static volatile boolean isPlayingShorts; - - @Nullable - private static StoryboardRenderer getRenderer(boolean waitForCompletion) { - Future future = rendererFuture; - if (future != null) { - try { - if (waitForCompletion || future.isDone()) { - return future.get(20000, TimeUnit.MILLISECONDS); // Any arbitrarily large timeout. - } // else, return null. - } catch (TimeoutException ex) { - Logger.printDebug(() -> "Could not get renderer (get timed out)"); - } catch (ExecutionException | InterruptedException ex) { - // Should never happen. - Logger.printException(() -> "Could not get renderer", ex); - } - } - return null; - } - - /** - * Injection point. - * - * Called off the main thread, and called multiple times for each video. - * - * @param parameters Original protobuf parameter value. - */ - public static String spoofParameter(String parameters, String videoId, boolean isShortAndOpeningOrPlaying) { - try { - Logger.printDebug(() -> "Original protobuf parameter value: " + parameters); - - if (parameters == null || !Settings.SPOOF_SIGNATURE.get()) { - return parameters; - } - - // Clip's player parameters contain a lot of information (e.g. video start and end time or whether it loops) - // For this reason, the player parameters of a clip are usually very long (150~300 characters). - // Clips are 60 seconds or less in length, so no spoofing. - //noinspection AssignmentUsedAsCondition - if (useOriginalStoryboardRenderer = parameters.length() > 150 || parameters.startsWith(CLIPS_PARAMETERS)) { - return parameters; - } - - // Shorts do not need to be spoofed. - //noinspection AssignmentUsedAsCondition - if (useOriginalStoryboardRenderer = VideoInformation.playerParametersAreShort(parameters)) { - isPlayingShorts = true; - return parameters; - } - isPlayingShorts = false; - - boolean isPlayingFeed = PlayerType.getCurrent() == PlayerType.INLINE_MINIMAL - && containsAny(parameters, AUTOPLAY_PARAMETERS); - if (isPlayingFeed) { - //noinspection AssignmentUsedAsCondition - if (useOriginalStoryboardRenderer = !Settings.SPOOF_SIGNATURE_IN_FEED.get()) { - // Don't spoof the feed video playback. This will cause video playback issues, - // but only if user continues watching for more than 1 minute. - return parameters; - } - // Spoof the feed video. Video will show up in watch history and video subtitles are missing. - fetchStoryboardRenderer(); - return SCRIM_PARAMETER + INCOGNITO_PARAMETERS; - } - - fetchStoryboardRenderer(); - } catch (Exception ex) { - Logger.printException(() -> "spoofParameter failure", ex); - } - return INCOGNITO_PARAMETERS; - } - - private static void fetchStoryboardRenderer() { - if (!Settings.SPOOF_STORYBOARD_RENDERER.get()) { - lastPlayerResponseVideoId = null; - rendererFuture = null; - return; - } - String videoId = VideoInformation.getPlayerResponseVideoId(); - if (!videoId.equals(lastPlayerResponseVideoId)) { - rendererFuture = Utils.submitOnBackgroundThread(() -> getStoryboardRenderer(videoId)); - lastPlayerResponseVideoId = videoId; - } - // Block until the renderer fetch completes. - // This is desired because if this returns without finishing the fetch - // then video will start playback but the storyboard is not ready yet. - getRenderer(true); - } - - private static String getStoryboardRendererSpec(String originalStoryboardRendererSpec, - boolean returnNullIfLiveStream) { - if (Settings.SPOOF_SIGNATURE.get() && !useOriginalStoryboardRenderer) { - StoryboardRenderer renderer = getRenderer(false); - if (renderer != null) { - if (returnNullIfLiveStream && renderer.isLiveStream) { - return null; - } - - if (renderer.spec != null) { - return renderer.spec; - } - } - } - - return originalStoryboardRendererSpec; - } - - /** - * Injection point. - * Called from background threads and from the main thread. - */ - @Nullable - public static String getStoryboardRendererSpec(String originalStoryboardRendererSpec) { - return getStoryboardRendererSpec(originalStoryboardRendererSpec, false); - } - - /** - * Injection point. - * Uses additional check to handle live streams. - * Called from background threads and from the main thread. - */ - @Nullable - public static String getStoryboardDecoderRendererSpec(String originalStoryboardRendererSpec) { - return getStoryboardRendererSpec(originalStoryboardRendererSpec, true); - } - - /** - * Injection point. - */ - public static int getRecommendedLevel(int originalLevel) { - if (Settings.SPOOF_SIGNATURE.get() && !useOriginalStoryboardRenderer) { - StoryboardRenderer renderer = getRenderer(false); - if (renderer != null) { - if (renderer.recommendedLevel != null) { - return renderer.recommendedLevel; - } - } - } - - return originalLevel; - } - - /** - * Injection point. Forces seekbar to be shown for paid videos or - * if {@link Settings#SPOOF_STORYBOARD_RENDERER} is not enabled. - */ - public static boolean getSeekbarThumbnailOverrideValue() { - if (!Settings.SPOOF_SIGNATURE.get()) { - return false; - } - StoryboardRenderer renderer = getRenderer(false); - if (renderer == null) { - // Spoof storyboard renderer is turned off, - // video is paid, or the storyboard fetch timed out. - // Show empty thumbnails so the seek time and chapters still show up. - return true; - } - return renderer.spec != null; - } - - /** - * Injection point. - * - * @param view seekbar thumbnail view. Includes both shorts and regular videos. - */ - public static void seekbarImageViewCreated(ImageView view) { - try { - if (!Settings.SPOOF_SIGNATURE.get() - || Settings.SPOOF_STORYBOARD_RENDERER.get()) { - return; - } - if (isPlayingShorts) return; - - view.setVisibility(View.GONE); - // Also hide the border around the thumbnail (otherwise a 1 pixel wide bordered frame is visible). - ViewGroup parentLayout = (ViewGroup) view.getParent(); - parentLayout.setPadding(0, 0, 0, 0); - } catch (Exception ex) { - Logger.printException(() -> "seekbarImageViewCreated failure", ex); - } - } -} diff --git a/app/src/main/java/app/revanced/integrations/youtube/patches/spoof/SpoofVideoStreamsPatch.java b/app/src/main/java/app/revanced/integrations/youtube/patches/spoof/SpoofVideoStreamsPatch.java new file mode 100644 index 00000000..d3c96407 --- /dev/null +++ b/app/src/main/java/app/revanced/integrations/youtube/patches/spoof/SpoofVideoStreamsPatch.java @@ -0,0 +1,170 @@ +package app.revanced.integrations.youtube.patches.spoof; + +import android.net.Uri; + +import androidx.annotation.Nullable; + +import java.nio.ByteBuffer; +import java.util.Map; +import java.util.Objects; + +import app.revanced.integrations.shared.Logger; +import app.revanced.integrations.shared.Utils; +import app.revanced.integrations.shared.settings.BaseSettings; +import app.revanced.integrations.shared.settings.Setting; +import app.revanced.integrations.youtube.patches.spoof.requests.StreamingDataRequest; +import app.revanced.integrations.youtube.settings.Settings; + +@SuppressWarnings("unused") +public class SpoofVideoStreamsPatch { + public static final class ForceiOSAVCAvailability implements Setting.Availability { + @Override + public boolean isAvailable() { + return Settings.SPOOF_VIDEO_STREAMS.get() && Settings.SPOOF_VIDEO_STREAMS_CLIENT_TYPE.get() == ClientType.IOS; + } + } + + private static final boolean SPOOF_STREAMING_DATA = Settings.SPOOF_VIDEO_STREAMS.get(); + + /** + * Any unreachable ip address. Used to intentionally fail requests. + */ + private static final String UNREACHABLE_HOST_URI_STRING = "https://127.0.0.0"; + private static final Uri UNREACHABLE_HOST_URI = Uri.parse(UNREACHABLE_HOST_URI_STRING); + + /** + * Injection point. + * Blocks /get_watch requests by returning an unreachable URI. + * + * @param playerRequestUri The URI of the player request. + * @return An unreachable URI if the request is a /get_watch request, otherwise the original URI. + */ + public static Uri blockGetWatchRequest(Uri playerRequestUri) { + if (SPOOF_STREAMING_DATA) { + try { + String path = playerRequestUri.getPath(); + + if (path != null && path.contains("get_watch")) { + Logger.printDebug(() -> "Blocking 'get_watch' by returning unreachable uri"); + + return UNREACHABLE_HOST_URI; + } + } catch (Exception ex) { + Logger.printException(() -> "blockGetWatchRequest failure", ex); + } + } + + return playerRequestUri; + } + + /** + * Injection point. + * + * Blocks /initplayback requests. + */ + public static String blockInitPlaybackRequest(String originalUrlString) { + if (SPOOF_STREAMING_DATA) { + try { + var originalUri = Uri.parse(originalUrlString); + String path = originalUri.getPath(); + + if (path != null && path.contains("initplayback")) { + Logger.printDebug(() -> "Blocking 'initplayback' by returning unreachable url"); + + return UNREACHABLE_HOST_URI_STRING; + } + } catch (Exception ex) { + Logger.printException(() -> "blockInitPlaybackRequest failure", ex); + } + } + + return originalUrlString; + } + + /** + * Injection point. + */ + public static boolean isSpoofingEnabled() { + return SPOOF_STREAMING_DATA; + } + + /** + * Injection point. + */ + public static void fetchStreams(String url, Map
requestHeaders) { + if (SPOOF_STREAMING_DATA) { + try { + Uri uri = Uri.parse(url); + String path = uri.getPath(); + // 'heartbeat' has no video id and appears to be only after playback has started. + if (path != null && path.contains("player") && !path.contains("heartbeat")) { + String videoId = Objects.requireNonNull(uri.getQueryParameter("id")); + StreamingDataRequest.fetchRequest(videoId, requestHeaders); + } + } catch (Exception ex) { + Logger.printException(() -> "buildRequest failure", ex); + } + } + } + + /** + * Injection point. + * Fix playback by replace the streaming data. + * Called after {@link #fetchStreams(String, Map)}. + */ + @Nullable + public static ByteBuffer getStreamingData(String videoId) { + if (SPOOF_STREAMING_DATA) { + try { + StreamingDataRequest request = StreamingDataRequest.getRequestForVideoId(videoId); + if (request != null) { + // This hook is always called off the main thread, + // but this can later be called for the same video id from the main thread. + // This is not a concern, since the fetch will always be finished + // and never block the main thread. + // But if debugging, then still verify this is the situation. + if (BaseSettings.DEBUG.get() && !request.fetchCompleted() && Utils.isCurrentlyOnMainThread()) { + Logger.printException(() -> "Error: Blocking main thread"); + } + + var stream = request.getStream(); + if (stream != null) { + Logger.printDebug(() -> "Overriding video stream: " + videoId); + return stream; + } + } + + Logger.printDebug(() -> "Not overriding streaming data (video stream is null): " + videoId); + } catch (Exception ex) { + Logger.printException(() -> "getStreamingData failure", ex); + } + } + + return null; + } + + /** + * Injection point. + * Called after {@link #getStreamingData(String)}. + */ + @Nullable + public static byte[] removeVideoPlaybackPostBody(Uri uri, int method, byte[] postData) { + if (SPOOF_STREAMING_DATA) { + try { + final int methodPost = 2; + if (method == methodPost) { + String path = uri.getPath(); + String clientNameQueryKey = "c"; + final boolean iosClient = "IOS".equals(uri.getQueryParameter(clientNameQueryKey)); + if (iosClient && path != null && path.contains("videoplayback")) { + return null; + } + } + } catch (Exception ex) { + Logger.printException(() -> "removeVideoPlaybackPostBody failure", ex); + } + } + + return postData; + } +} diff --git a/app/src/main/java/app/revanced/integrations/youtube/patches/spoof/StoryboardRenderer.java b/app/src/main/java/app/revanced/integrations/youtube/patches/spoof/StoryboardRenderer.java deleted file mode 100644 index 5014a5fc..00000000 --- a/app/src/main/java/app/revanced/integrations/youtube/patches/spoof/StoryboardRenderer.java +++ /dev/null @@ -1,36 +0,0 @@ -package app.revanced.integrations.youtube.patches.spoof; - -import androidx.annotation.Nullable; - -import org.jetbrains.annotations.NotNull; - -@Deprecated -public final class StoryboardRenderer { - public final String videoId; - @Nullable - public final String spec; - public final boolean isLiveStream; - /** - * Recommended image quality level, or NULL if no recommendation exists. - */ - @Nullable - public final Integer recommendedLevel; - - public StoryboardRenderer(String videoId, @Nullable String spec, boolean isLiveStream, @Nullable Integer recommendedLevel) { - this.videoId = videoId; - this.spec = spec; - this.isLiveStream = isLiveStream; - this.recommendedLevel = recommendedLevel; - } - - @NotNull - @Override - public String toString() { - return "StoryboardRenderer{" + - "videoId=" + videoId + - ", isLiveStream=" + isLiveStream + - ", spec='" + spec + '\'' + - ", recommendedLevel=" + recommendedLevel + - '}'; - } -} diff --git a/app/src/main/java/app/revanced/integrations/youtube/patches/spoof/requests/PlayerRoutes.java b/app/src/main/java/app/revanced/integrations/youtube/patches/spoof/requests/PlayerRoutes.java index 1927b1d6..299110f4 100644 --- a/app/src/main/java/app/revanced/integrations/youtube/patches/spoof/requests/PlayerRoutes.java +++ b/app/src/main/java/app/revanced/integrations/youtube/patches/spoof/requests/PlayerRoutes.java @@ -1,94 +1,68 @@ package app.revanced.integrations.youtube.patches.spoof.requests; -import app.revanced.integrations.youtube.requests.Requester; -import app.revanced.integrations.youtube.requests.Route; -import app.revanced.integrations.shared.Logger; -import app.revanced.integrations.shared.Utils; import org.json.JSONException; import org.json.JSONObject; import java.io.IOException; import java.net.HttpURLConnection; +import app.revanced.integrations.shared.Logger; +import app.revanced.integrations.youtube.patches.spoof.ClientType; +import app.revanced.integrations.youtube.requests.Requester; +import app.revanced.integrations.youtube.requests.Route; + final class PlayerRoutes { - private static final String YT_API_URL = "https://www.youtube.com/youtubei/v1/"; - static final Route.CompiledRoute GET_STORYBOARD_SPEC_RENDERER = new Route( + private static final String YT_API_URL = "https://youtubei.googleapis.com/youtubei/v1/"; + + static final Route.CompiledRoute GET_STREAMING_DATA = new Route( Route.Method.POST, "player" + - "?fields=storyboards.playerStoryboardSpecRenderer," + - "storyboards.playerLiveStoryboardSpecRenderer," + - "playabilityStatus.status" + "?fields=streamingData" + + "&alt=proto" ).compile(); - static final String ANDROID_INNER_TUBE_BODY; - static final String TV_EMBED_INNER_TUBE_BODY; - /** * TCP connection and HTTP read timeout */ private static final int CONNECTION_TIMEOUT_MILLISECONDS = 10 * 1000; // 10 Seconds. - static { - JSONObject innerTubeBody = new JSONObject(); + private PlayerRoutes() { + } + + static String createInnertubeBody(ClientType clientType) { + JSONObject innerTubeBody = new JSONObject(); try { JSONObject context = new JSONObject(); JSONObject client = new JSONObject(); - client.put("clientName", "ANDROID"); - client.put("clientVersion", Utils.getAppVersionName()); - client.put("androidSdkVersion", 34); + client.put("clientName", clientType.name()); + client.put("clientVersion", clientType.appVersion); + client.put("deviceModel", clientType.model); + client.put("osVersion", clientType.osVersion); + if (clientType.androidSdkVersion != null) { + client.put("androidSdkVersion", clientType.androidSdkVersion); + } context.put("client", client); innerTubeBody.put("context", context); + innerTubeBody.put("contentCheckOk", true); + innerTubeBody.put("racyCheckOk", true); innerTubeBody.put("videoId", "%s"); } catch (JSONException e) { Logger.printException(() -> "Failed to create innerTubeBody", e); } - ANDROID_INNER_TUBE_BODY = innerTubeBody.toString(); - - JSONObject tvEmbedInnerTubeBody = new JSONObject(); - - try { - JSONObject context = new JSONObject(); - - JSONObject client = new JSONObject(); - client.put("clientName", "TVHTML5_SIMPLY_EMBEDDED_PLAYER"); - client.put("clientVersion", "2.0"); - client.put("platform", "TV"); - client.put("clientScreen", "EMBED"); - - JSONObject thirdParty = new JSONObject(); - thirdParty.put("embedUrl", "https://www.youtube.com/watch?v=%s"); - - context.put("thirdParty", thirdParty); - context.put("client", client); - - tvEmbedInnerTubeBody.put("context", context); - tvEmbedInnerTubeBody.put("videoId", "%s"); - } catch (JSONException e) { - Logger.printException(() -> "Failed to create tvEmbedInnerTubeBody", e); - } - - TV_EMBED_INNER_TUBE_BODY = tvEmbedInnerTubeBody.toString(); - } - - private PlayerRoutes() { + return innerTubeBody.toString(); } /** @noinspection SameParameterValue*/ - static HttpURLConnection getPlayerResponseConnectionFromRoute(Route.CompiledRoute route) throws IOException { + static HttpURLConnection getPlayerResponseConnectionFromRoute(Route.CompiledRoute route, ClientType clientType) throws IOException { var connection = Requester.getConnectionFromCompiledRoute(YT_API_URL, route); - connection.setRequestProperty( - "User-Agent", "com.google.android.youtube/" + - Utils.getAppVersionName() + - " (Linux; U; Android 12; GB) gzip" - ); - connection.setRequestProperty("X-Goog-Api-Format-Version", "2"); connection.setRequestProperty("Content-Type", "application/json"); + connection.setRequestProperty("User-Agent", clientType.userAgent); connection.setUseCaches(false); connection.setDoOutput(true); diff --git a/app/src/main/java/app/revanced/integrations/youtube/patches/spoof/requests/StoryboardRendererRequester.java b/app/src/main/java/app/revanced/integrations/youtube/patches/spoof/requests/StoryboardRendererRequester.java deleted file mode 100644 index 0cbec194..00000000 --- a/app/src/main/java/app/revanced/integrations/youtube/patches/spoof/requests/StoryboardRendererRequester.java +++ /dev/null @@ -1,153 +0,0 @@ -package app.revanced.integrations.youtube.patches.spoof.requests; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import app.revanced.integrations.shared.settings.BaseSettings; -import app.revanced.integrations.youtube.patches.spoof.StoryboardRenderer; -import app.revanced.integrations.youtube.requests.Requester; -import app.revanced.integrations.shared.Logger; -import app.revanced.integrations.shared.Utils; -import org.json.JSONException; -import org.json.JSONObject; - -import java.io.IOException; -import java.net.HttpURLConnection; -import java.net.SocketTimeoutException; -import java.nio.charset.StandardCharsets; -import java.util.Objects; - -import static app.revanced.integrations.shared.StringRef.str; -import static app.revanced.integrations.youtube.patches.spoof.requests.PlayerRoutes.*; - -public class StoryboardRendererRequester { - - private StoryboardRendererRequester() { - } - - private static void randomlyWaitIfLocallyDebugging() { - final boolean randomlyWait = false; // Enable to simulate slow connection responses. - if (randomlyWait) { - final long maximumTimeToRandomlyWait = 10000; - Utils.doNothingForDuration(maximumTimeToRandomlyWait); - } - } - - private static void handleConnectionError(@NonNull String toastMessage, @Nullable Exception ex, - boolean showToastOnIOException) { - if (showToastOnIOException) Utils.showToastShort(toastMessage); - Logger.printInfo(() -> toastMessage, ex); - } - - @Nullable - private static JSONObject fetchPlayerResponse(@NonNull String requestBody, boolean showToastOnIOException) { - final long startTime = System.currentTimeMillis(); - try { - Utils.verifyOffMainThread(); - Objects.requireNonNull(requestBody); - - final byte[] innerTubeBody = requestBody.getBytes(StandardCharsets.UTF_8); - - HttpURLConnection connection = PlayerRoutes.getPlayerResponseConnectionFromRoute(GET_STORYBOARD_SPEC_RENDERER); - connection.getOutputStream().write(innerTubeBody, 0, innerTubeBody.length); - - final int responseCode = connection.getResponseCode(); - randomlyWaitIfLocallyDebugging(); - if (responseCode == 200) return Requester.parseJSONObject(connection); - - // Always show a toast for this, as a non 200 response means something is broken. - // Not a normal code path and should not be reached, so no translations are needed. - handleConnectionError("Spoof storyboard not available: " + responseCode, - null, showToastOnIOException || BaseSettings.DEBUG_TOAST_ON_ERROR.get()); - connection.disconnect(); - } catch (SocketTimeoutException ex) { - handleConnectionError(str("revanced_spoof_client_storyboard_timeout"), ex, showToastOnIOException); - } catch (IOException ex) { - handleConnectionError(str("revanced_spoof_client_storyboard_io_exception", ex.getMessage()), - ex, showToastOnIOException); - } catch (Exception ex) { - Logger.printException(() -> "Spoof storyboard fetch failed", ex); // Should never happen. - } finally { - Logger.printDebug(() -> "Request took: " + (System.currentTimeMillis() - startTime) + "ms"); - } - - return null; - } - - private static boolean isPlayabilityStatusOk(@NonNull JSONObject playerResponse) { - try { - return playerResponse.getJSONObject("playabilityStatus").getString("status").equals("OK"); - } catch (JSONException e) { - Logger.printDebug(() -> "Failed to get playabilityStatus for response: " + playerResponse); - } - - return false; - } - - /** - * Fetches the storyboardRenderer from the innerTubeBody. - * @param innerTubeBody The innerTubeBody to use to fetch the storyboardRenderer. - * @return StoryboardRenderer or null if playabilityStatus is not OK. - */ - @Nullable - private static StoryboardRenderer getStoryboardRendererUsingBody(String videoId, - @NonNull String innerTubeBody, - boolean showToastOnIOException) { - final JSONObject playerResponse = fetchPlayerResponse(innerTubeBody, showToastOnIOException); - if (playerResponse != null && isPlayabilityStatusOk(playerResponse)) - return getStoryboardRendererUsingResponse(videoId, playerResponse); - - return null; - } - - @Nullable - private static StoryboardRenderer getStoryboardRendererUsingResponse(@NonNull String videoId, @NonNull JSONObject playerResponse) { - try { - Logger.printDebug(() -> "Parsing response: " + playerResponse); - if (!playerResponse.has("storyboards")) { - Logger.printDebug(() -> "Using empty storyboard"); - return new StoryboardRenderer(videoId, null, false, null); - } - final JSONObject storyboards = playerResponse.getJSONObject("storyboards"); - final boolean isLiveStream = storyboards.has("playerLiveStoryboardSpecRenderer"); - final String storyboardsRendererTag = isLiveStream - ? "playerLiveStoryboardSpecRenderer" - : "playerStoryboardSpecRenderer"; - - final var rendererElement = storyboards.getJSONObject(storyboardsRendererTag); - StoryboardRenderer renderer = new StoryboardRenderer( - videoId, - rendererElement.getString("spec"), - isLiveStream, - rendererElement.has("recommendedLevel") - ? rendererElement.getInt("recommendedLevel") - : null - ); - - Logger.printDebug(() -> "Fetched: " + renderer); - - return renderer; - } catch (JSONException e) { - Logger.printException(() -> "Failed to get storyboardRenderer", e); - } - - return null; - } - - @Nullable - public static StoryboardRenderer getStoryboardRenderer(@NonNull String videoId) { - Objects.requireNonNull(videoId); - - var renderer = getStoryboardRendererUsingBody(videoId, - String.format(ANDROID_INNER_TUBE_BODY, videoId), false); - if (renderer == null) { - Logger.printDebug(() -> videoId + " not available using Android client"); - renderer = getStoryboardRendererUsingBody(videoId, - String.format(TV_EMBED_INNER_TUBE_BODY, videoId, videoId), true); - if (renderer == null) { - Logger.printDebug(() -> videoId + " not available using TV embedded client"); - } - } - - return renderer; - } -} \ No newline at end of file diff --git a/app/src/main/java/app/revanced/integrations/youtube/patches/spoof/requests/StreamingDataRequest.java b/app/src/main/java/app/revanced/integrations/youtube/patches/spoof/requests/StreamingDataRequest.java new file mode 100644 index 00000000..c86b352f --- /dev/null +++ b/app/src/main/java/app/revanced/integrations/youtube/patches/spoof/requests/StreamingDataRequest.java @@ -0,0 +1,215 @@ +package app.revanced.integrations.youtube.patches.spoof.requests; + +import static app.revanced.integrations.youtube.patches.spoof.requests.PlayerRoutes.GET_STREAMING_DATA; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import java.io.BufferedInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.net.HttpURLConnection; +import java.net.SocketTimeoutException; +import java.nio.ByteBuffer; +import java.nio.charset.StandardCharsets; +import java.util.*; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; + +import app.revanced.integrations.shared.Logger; +import app.revanced.integrations.shared.Utils; +import app.revanced.integrations.shared.settings.BaseSettings; +import app.revanced.integrations.youtube.patches.spoof.ClientType; +import app.revanced.integrations.youtube.settings.Settings; + +/** + * Video streaming data. Fetching is tied to the behavior YT uses, + * where this class fetches the streams only when YT fetches. + * + * Effectively the cache expiration of these fetches is the same as the stock app, + * since the stock app would not use expired streams and therefor + * the integrations replace stream hook is called only if YT + * would have used it's own client streams. + */ +public class StreamingDataRequest { + + private static final ClientType[] CLIENT_ORDER_TO_USE; + + static { + ClientType[] allClientTypes = ClientType.values(); + ClientType preferredClient = Settings.SPOOF_VIDEO_STREAMS_CLIENT_TYPE.get(); + + CLIENT_ORDER_TO_USE = new ClientType[allClientTypes.length]; + CLIENT_ORDER_TO_USE[0] = preferredClient; + + int i = 1; + for (ClientType c : allClientTypes) { + if (c != preferredClient) { + CLIENT_ORDER_TO_USE[i++] = c; + } + } + } + + /** + * TCP connection and HTTP read timeout. + */ + private static final int HTTP_TIMEOUT_MILLISECONDS = 10 * 1000; + + /** + * Any arbitrarily large value, but must be at least twice {@link #HTTP_TIMEOUT_MILLISECONDS} + */ + private static final int MAX_MILLISECONDS_TO_WAIT_FOR_FETCH = 20 * 1000; + + private static final Map cache = Collections.synchronizedMap( + new LinkedHashMap<>(100) { + /** + * Cache limit must be greater than the maximum number of videos open at once, + * which theoretically is more than 4 (3 Shorts + one regular minimized video). + * But instead use a much larger value, to handle if a video viewed a while ago + * is somehow still referenced. Each stream is a small array of Strings + * so memory usage is not a concern. + */ + private static final int CACHE_LIMIT = 50; + + @Override + protected boolean removeEldestEntry(Entry eldest) { + return size() > CACHE_LIMIT; // Evict the oldest entry if over the cache limit. + } + }); + + public static void fetchRequest(String videoId, Map fetchHeaders) { + // Always fetch, even if there is a existing request for the same video. + cache.put(videoId, new StreamingDataRequest(videoId, fetchHeaders)); + } + + @Nullable + public static StreamingDataRequest getRequestForVideoId(String videoId) { + return cache.get(videoId); + } + + private static void handleConnectionError(String toastMessage, @Nullable Exception ex, boolean showToast) { + if (showToast) Utils.showToastShort(toastMessage); + Logger.printInfo(() -> toastMessage, ex); + } + + @Nullable + private static HttpURLConnection send(ClientType clientType, String videoId, + Map playerHeaders, + boolean showErrorToasts) { + Objects.requireNonNull(clientType); + Objects.requireNonNull(videoId); + Objects.requireNonNull(playerHeaders); + + final long startTime = System.currentTimeMillis(); + String clientTypeName = clientType.name(); + Logger.printDebug(() -> "Fetching video streams for: " + videoId + " using client: " + clientType.name()); + + try { + HttpURLConnection connection = PlayerRoutes.getPlayerResponseConnectionFromRoute(GET_STREAMING_DATA, clientType); + connection.setConnectTimeout(HTTP_TIMEOUT_MILLISECONDS); + connection.setReadTimeout(HTTP_TIMEOUT_MILLISECONDS); + + String authHeader = playerHeaders.get("Authorization"); + String visitorId = playerHeaders.get("X-Goog-Visitor-Id"); + connection.setRequestProperty("Authorization", authHeader); + connection.setRequestProperty("X-Goog-Visitor-Id", visitorId); + + String innerTubeBody = String.format(PlayerRoutes.createInnertubeBody(clientType), videoId); + byte[] requestBody = innerTubeBody.getBytes(StandardCharsets.UTF_8); + connection.setFixedLengthStreamingMode(requestBody.length); + connection.getOutputStream().write(requestBody); + + final int responseCode = connection.getResponseCode(); + if (responseCode == 200) return connection; + + handleConnectionError(clientTypeName + " not available with response code: " + + responseCode + " message: " + connection.getResponseMessage(), + null, showErrorToasts); + } catch (SocketTimeoutException ex) { + handleConnectionError("Connection timeout", ex, showErrorToasts); + } catch (IOException ex) { + handleConnectionError("Network error", ex, showErrorToasts); + } catch (Exception ex) { + Logger.printException(() -> "send failed", ex); + } finally { + Logger.printDebug(() -> "video: " + videoId + " took: " + (System.currentTimeMillis() - startTime) + "ms"); + } + + return null; + } + + private static ByteBuffer fetch(String videoId, Map playerHeaders) { + final boolean debugEnabled = BaseSettings.DEBUG.get(); + + // Retry with different client if empty response body is received. + int i = 0; + for (ClientType clientType : CLIENT_ORDER_TO_USE) { + // Show an error if the last client type fails, or if the debug is enabled then show for all attempts. + final boolean showErrorToast = (++i == CLIENT_ORDER_TO_USE.length) || debugEnabled; + + HttpURLConnection connection = send(clientType, videoId, playerHeaders, showErrorToast); + if (connection != null) { + try { + // gzip encoding doesn't response with content length (-1), + // but empty response body does. + if (connection.getContentLength() != 0) { + try (InputStream inputStream = new BufferedInputStream(connection.getInputStream())) { + try (ByteArrayOutputStream baos = new ByteArrayOutputStream()) { + byte[] buffer = new byte[2048]; + int bytesRead; + while ((bytesRead = inputStream.read(buffer)) >= 0) { + baos.write(buffer, 0, bytesRead); + } + + return ByteBuffer.wrap(baos.toByteArray()); + } + } + } + } catch (IOException ex) { + Logger.printException(() -> "Fetch failed while processing response data", ex); + } + } + } + + handleConnectionError("Could not fetch any client streams", null, debugEnabled); + return null; + } + + private final String videoId; + private final Future future; + + private StreamingDataRequest(String videoId, Map playerHeaders) { + Objects.requireNonNull(playerHeaders); + this.videoId = videoId; + this.future = Utils.submitOnBackgroundThread(() -> fetch(videoId, playerHeaders)); + } + + public boolean fetchCompleted() { + return future.isDone(); + } + + @Nullable + public ByteBuffer getStream() { + try { + return future.get(MAX_MILLISECONDS_TO_WAIT_FOR_FETCH, TimeUnit.MILLISECONDS); + } catch (TimeoutException ex) { + Logger.printInfo(() -> "getStream timed out", ex); + } catch (InterruptedException ex) { + Logger.printException(() -> "getStream interrupted", ex); + Thread.currentThread().interrupt(); // Restore interrupt status flag. + } catch (ExecutionException ex) { + Logger.printException(() -> "getStream failure", ex); + } + + return null; + } + + @NonNull + @Override + public String toString() { + return "StreamingDataRequest{" + "videoId='" + videoId + '\'' + '}'; + } +} diff --git a/app/src/main/java/app/revanced/integrations/youtube/requests/Requester.java b/app/src/main/java/app/revanced/integrations/youtube/requests/Requester.java index ef409b52..c62e34f5 100644 --- a/app/src/main/java/app/revanced/integrations/youtube/requests/Requester.java +++ b/app/src/main/java/app/revanced/integrations/youtube/requests/Requester.java @@ -23,6 +23,9 @@ public class Requester { public static HttpURLConnection getConnectionFromCompiledRoute(String apiUrl, Route.CompiledRoute route) throws IOException { String url = apiUrl + route.getCompiledRoute(); HttpURLConnection connection = (HttpURLConnection) new URL(url).openConnection(); + // Request data is in the URL parameters and no body is sent. + // The calling code must set a length if using a request body. + connection.setFixedLengthStreamingMode(0); connection.setRequestMethod(route.getMethod().name()); String agentString = System.getProperty("http.agent") + "; ReVanced/" + Utils.getAppVersionName() diff --git a/app/src/main/java/app/revanced/integrations/youtube/returnyoutubedislike/ReturnYouTubeDislike.java b/app/src/main/java/app/revanced/integrations/youtube/returnyoutubedislike/ReturnYouTubeDislike.java index bfff1b15..b63d0484 100644 --- a/app/src/main/java/app/revanced/integrations/youtube/returnyoutubedislike/ReturnYouTubeDislike.java +++ b/app/src/main/java/app/revanced/integrations/youtube/returnyoutubedislike/ReturnYouTubeDislike.java @@ -10,6 +10,9 @@ import android.graphics.drawable.ShapeDrawable; import android.graphics.drawable.shapes.OvalShape; import android.graphics.drawable.shapes.RectShape; import android.icu.text.CompactDecimalFormat; +import android.icu.text.DecimalFormat; +import android.icu.text.DecimalFormatSymbols; +import android.icu.text.NumberFormat; import android.os.Build; import android.text.Spannable; import android.text.SpannableString; @@ -25,17 +28,11 @@ import androidx.annotation.GuardedBy; import androidx.annotation.NonNull; import androidx.annotation.Nullable; -import java.text.NumberFormat; import java.util.HashMap; import java.util.Locale; import java.util.Map; import java.util.Objects; -import java.util.concurrent.ExecutionException; -import java.util.concurrent.ExecutorService; -import java.util.concurrent.Executors; -import java.util.concurrent.Future; -import java.util.concurrent.TimeUnit; -import java.util.concurrent.TimeoutException; +import java.util.concurrent.*; import app.revanced.integrations.shared.Logger; import app.revanced.integrations.shared.Utils; @@ -223,32 +220,29 @@ public class ReturnYouTubeDislike { // Note: Some locales use right to left layout (Arabic, Hebrew, etc). // If making changes to this code, change device settings to a RTL language and verify layout is correct. - String oldLikesString = oldSpannable.toString(); + CharSequence oldLikes = oldSpannable; // YouTube creators can hide the like count on a video, // and the like count appears as a device language specific string that says 'Like'. // Check if the string contains any numbers. - if (!stringContainsNumber(oldLikesString)) { - // Likes are hidden. - // RYD does not provide usable data for these types of videos, - // and the API returns bogus data (zero likes and zero dislikes) - // discussion about this: https://github.com/Anarios/return-youtube-dislike/discussions/530 + if (!Utils.containsNumber(oldLikes)) { + // Likes are hidden by video creator + // + // RYD does not directly provide like data, but can use an estimated likes + // using the same scale factor RYD applied to the raw dislikes. // // example video: https://www.youtube.com/watch?v=UnrU5vxCHxw // RYD data: https://returnyoutubedislikeapi.com/votes?videoId=UnrU5vxCHxw // - // Change the "Likes" string to show that likes and dislikes are hidden. - String hiddenMessageString = str("revanced_ryd_video_likes_hidden_by_video_owner"); - return newSpanUsingStylingOfAnotherSpan(oldSpannable, hiddenMessageString); + Logger.printDebug(() -> "Using estimated likes"); + oldLikes = formatDislikeCount(voteData.getLikeCount()); } SpannableStringBuilder builder = new SpannableStringBuilder(); final boolean compactLayout = Settings.RYD_COMPACT_LAYOUT.get(); if (!compactLayout) { - String leftSeparatorString = Utils.isRightToLeftTextLayout() - ? "\u200F" // u200F = right to left character - : "\u200E"; // u200E = left to right character + String leftSeparatorString = getTextDirectionString(); final Spannable leftSeparatorSpan; if (isRollingNumber) { leftSeparatorSpan = new SpannableString(leftSeparatorString); @@ -267,7 +261,7 @@ public class ReturnYouTubeDislike { } // likes - builder.append(newSpanUsingStylingOfAnotherSpan(oldSpannable, oldLikesString)); + builder.append(newSpanUsingStylingOfAnotherSpan(oldSpannable, oldLikes)); // middle separator String middleSeparatorString = compactLayout @@ -292,6 +286,12 @@ public class ReturnYouTubeDislike { return new SpannableString(builder); } + private static @NonNull String getTextDirectionString() { + return Utils.isRightToLeftTextLayout() + ? "\u200F" // u200F = right to left character + : "\u200E"; // u200E = left to right character + } + /** * @return If the text is likely for a previously created likes/dislikes segmented span. */ @@ -299,20 +299,6 @@ public class ReturnYouTubeDislike { return text.indexOf(MIDDLE_SEPARATOR_CHARACTER) >= 0; } - /** - * Correctly handles any unicode numbers (such as Arabic numbers). - * - * @return if the string contains at least 1 number. - */ - private static boolean stringContainsNumber(@NonNull String text) { - for (int index = 0, length = text.length(); index < length; index++) { - if (Character.isDigit(text.codePointAt(index))) { - return true; - } - } - return false; - } - private static boolean spansHaveEqualTextAndColor(@NonNull Spanned one, @NonNull Spanned two) { // Cannot use equals on the span, because many of the inner styling spans do not implement equals. // Instead, compare the underlying text and the text color to handle when dark mode is changed. @@ -334,6 +320,10 @@ public class ReturnYouTubeDislike { return true; } + private static SpannableString newSpannableWithLikes(@NonNull Spanned sourceStyling, @NonNull RYDVoteData voteData) { + return newSpanUsingStylingOfAnotherSpan(sourceStyling, formatDislikeCount(voteData.getLikeCount())); + } + private static SpannableString newSpannableWithDislikes(@NonNull Spanned sourceStyling, @NonNull RYDVoteData voteData) { return newSpanUsingStylingOfAnotherSpan(sourceStyling, Settings.RYD_DISLIKE_PERCENTAGE.get() @@ -342,11 +332,16 @@ public class ReturnYouTubeDislike { } private static SpannableString newSpanUsingStylingOfAnotherSpan(@NonNull Spanned sourceStyle, @NonNull CharSequence newSpanText) { + if (sourceStyle == newSpanText && sourceStyle instanceof SpannableString) { + return (SpannableString) sourceStyle; // Nothing to do. + } + SpannableString destination = new SpannableString(newSpanText); Object[] spans = sourceStyle.getSpans(0, sourceStyle.length(), Object.class); for (Object span : spans) { destination.setSpan(span, 0, destination.length(), sourceStyle.getSpanFlags(span)); } + return destination; } @@ -354,13 +349,18 @@ public class ReturnYouTubeDislike { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { synchronized (ReturnYouTubeDislike.class) { // number formatter is not thread safe, must synchronize if (dislikeCountFormatter == null) { - // Note: Java number formatters will use the locale specific number characters. - // such as Arabic which formats "1.234" into "۱,۲۳٤" - // But YouTube disregards locale specific number characters - // and instead shows english number characters everywhere. Locale locale = Objects.requireNonNull(Utils.getContext()).getResources().getConfiguration().locale; - Logger.printDebug(() -> "Locale: " + locale); dislikeCountFormatter = CompactDecimalFormat.getInstance(locale, CompactDecimalFormat.CompactStyle.SHORT); + + // YouTube disregards locale specific number characters + // and instead shows english number characters everywhere. + // To use the same behavior, override the digit characters to use English + // so languages such as Arabic will show "1.234" instead of the native "۱,۲۳٤" + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + DecimalFormatSymbols symbols = DecimalFormatSymbols.getInstance(locale); + symbols.setDigitStrings(DecimalFormatSymbols.getInstance(Locale.ENGLISH).getDigitStrings()); + dislikeCountFormatter.setDecimalFormatSymbols(symbols); + } } return dislikeCountFormatter.format(dislikeCount); } @@ -371,19 +371,31 @@ public class ReturnYouTubeDislike { } private static String formatDislikePercentage(float dislikePercentage) { - synchronized (ReturnYouTubeDislike.class) { // number formatter is not thread safe, must synchronize - if (dislikePercentageFormatter == null) { - Locale locale = Objects.requireNonNull(Utils.getContext()).getResources().getConfiguration().locale; - Logger.printDebug(() -> "Locale: " + locale); - dislikePercentageFormatter = NumberFormat.getPercentInstance(locale); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + synchronized (ReturnYouTubeDislike.class) { // number formatter is not thread safe, must synchronize + if (dislikePercentageFormatter == null) { + Locale locale = Objects.requireNonNull(Utils.getContext()).getResources().getConfiguration().locale; + dislikePercentageFormatter = NumberFormat.getPercentInstance(locale); + + // Want to set the digit strings, and the simplest way is to cast to the implementation NumberFormat returns. + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P + && dislikePercentageFormatter instanceof DecimalFormat) { + DecimalFormatSymbols symbols = DecimalFormatSymbols.getInstance(locale); + symbols.setDigitStrings(DecimalFormatSymbols.getInstance(Locale.ENGLISH).getDigitStrings()); + ((DecimalFormat) dislikePercentageFormatter).setDecimalFormatSymbols(symbols); + } + } + if (dislikePercentage >= 0.01) { // at least 1% + dislikePercentageFormatter.setMaximumFractionDigits(0); // show only whole percentage points + } else { + dislikePercentageFormatter.setMaximumFractionDigits(1); // show up to 1 digit precision + } + return dislikePercentageFormatter.format(dislikePercentage); } - if (dislikePercentage >= 0.01) { // at least 1% - dislikePercentageFormatter.setMaximumFractionDigits(0); // show only whole percentage points - } else { - dislikePercentageFormatter.setMaximumFractionDigits(1); // show up to 1 digit precision - } - return dislikePercentageFormatter.format(dislikePercentage); } + + // Will never be reached, as the oldest supported YouTube app requires Android N or greater. + return String.valueOf((int) (dislikePercentage * 100)); } @NonNull @@ -484,7 +496,17 @@ public class ReturnYouTubeDislike { public synchronized Spanned getDislikesSpanForRegularVideo(@NonNull Spanned original, boolean isSegmentedButton, boolean isRollingNumber) { - return waitForFetchAndUpdateReplacementSpan(original, isSegmentedButton, isRollingNumber,false); + return waitForFetchAndUpdateReplacementSpan(original, isSegmentedButton, + isRollingNumber, false, false); + } + + /** + * Called when a Shorts like Spannable is created. + */ + @NonNull + public synchronized Spanned getLikeSpanForShort(@NonNull Spanned original) { + return waitForFetchAndUpdateReplacementSpan(original, false, + false, true, true); } /** @@ -492,14 +514,16 @@ public class ReturnYouTubeDislike { */ @NonNull public synchronized Spanned getDislikeSpanForShort(@NonNull Spanned original) { - return waitForFetchAndUpdateReplacementSpan(original, false, false, true); + return waitForFetchAndUpdateReplacementSpan(original, false, + false, true, false); } @NonNull private Spanned waitForFetchAndUpdateReplacementSpan(@NonNull Spanned original, boolean isSegmentedButton, boolean isRollingNumber, - boolean spanIsForShort) { + boolean spanIsForShort, + boolean spanIsForLikes) { try { RYDVoteData votingData = getFetchData(MAX_MILLISECONDS_TO_BLOCK_UI_WAITING_FOR_FETCH); if (votingData == null) { @@ -526,24 +550,17 @@ public class ReturnYouTubeDislike { return original; } - if (originalDislikeSpan != null && replacementLikeDislikeSpan != null) { - if (spansHaveEqualTextAndColor(original, replacementLikeDislikeSpan)) { - Logger.printDebug(() -> "Ignoring previously created dislikes span of data: " + videoId); - return original; - } - if (spansHaveEqualTextAndColor(original, originalDislikeSpan)) { - Logger.printDebug(() -> "Replacing span with previously created dislike span of data: " + videoId); - return replacementLikeDislikeSpan; - } + if (spanIsForLikes) { + // Scrolling Shorts does not cause the Spans to be reloaded, + // so there is no need to cache the likes for this situations. + Logger.printDebug(() -> "Creating likes span for: " + votingData.videoId); + return newSpannableWithLikes(original, votingData); } - if (isSegmentedButton && isPreviouslyCreatedSegmentedSpan(original.toString())) { - // need to recreate using original, as original has prior outdated dislike values - if (originalDislikeSpan == null) { - // Should never happen. - Logger.printDebug(() -> "Cannot add dislikes - original span is null. videoId: " + videoId); - return original; - } - original = originalDislikeSpan; + + if (originalDislikeSpan != null && replacementLikeDislikeSpan != null + && spansHaveEqualTextAndColor(original, originalDislikeSpan)) { + Logger.printDebug(() -> "Replacing span with previously created dislike span of data: " + videoId); + return replacementLikeDislikeSpan; } // No replacement span exist, create it now. @@ -558,9 +575,10 @@ public class ReturnYouTubeDislike { return replacementLikeDislikeSpan; } - } catch (Exception e) { - Logger.printException(() -> "waitForFetchAndUpdateReplacementSpan failure", e); // should never happen + } catch (Exception ex) { + Logger.printException(() -> "waitForFetchAndUpdateReplacementSpan failure", ex); } + return original; } diff --git a/app/src/main/java/app/revanced/integrations/youtube/returnyoutubedislike/requests/RYDVoteData.java b/app/src/main/java/app/revanced/integrations/youtube/returnyoutubedislike/requests/RYDVoteData.java index 820c0492..239ad2b0 100644 --- a/app/src/main/java/app/revanced/integrations/youtube/returnyoutubedislike/requests/RYDVoteData.java +++ b/app/src/main/java/app/revanced/integrations/youtube/returnyoutubedislike/requests/RYDVoteData.java @@ -3,10 +3,13 @@ package app.revanced.integrations.youtube.returnyoutubedislike.requests; import static app.revanced.integrations.youtube.returnyoutubedislike.ReturnYouTubeDislike.Vote; import androidx.annotation.NonNull; +import androidx.annotation.Nullable; import org.json.JSONException; import org.json.JSONObject; +import app.revanced.integrations.shared.Logger; + /** * ReturnYouTubeDislike API estimated like/dislike/view counts. * @@ -23,38 +26,65 @@ public final class RYDVoteData { public final long viewCount; private final long fetchedLikeCount; - private volatile long likeCount; // read/write from different threads + private volatile long likeCount; // Read/write from different threads. + /** + * Like count can be hidden by video creator, but RYD still tracks the number + * of like/dislikes it received thru it's browser extension and and API. + * The raw like/dislikes can be used to calculate a percentage. + * + * Raw values can be null, especially for older videos with little to no views. + */ + @Nullable + private final Long fetchedRawLikeCount; private volatile float likePercentage; private final long fetchedDislikeCount; - private volatile long dislikeCount; // read/write from different threads + private volatile long dislikeCount; // Read/write from different threads. + @Nullable + private final Long fetchedRawDislikeCount; private volatile float dislikePercentage; + @Nullable + private static Long getLongIfExist(JSONObject json, String key) throws JSONException { + return json.isNull(key) + ? null + : json.getLong(key); + } + /** * @throws JSONException if JSON parse error occurs, or if the values make no sense (ie: negative values) */ public RYDVoteData(@NonNull JSONObject json) throws JSONException { videoId = json.getString("id"); viewCount = json.getLong("viewCount"); + fetchedLikeCount = json.getLong("likes"); + fetchedRawLikeCount = getLongIfExist(json, "rawLikes"); + fetchedDislikeCount = json.getLong("dislikes"); + fetchedRawDislikeCount = getLongIfExist(json, "rawDislikes"); + if (viewCount < 0 || fetchedLikeCount < 0 || fetchedDislikeCount < 0) { throw new JSONException("Unexpected JSON values: " + json); } likeCount = fetchedLikeCount; dislikeCount = fetchedDislikeCount; - updatePercentages(); + + updateUsingVote(Vote.LIKE_REMOVE); // Calculate percentages. } /** - * Estimated like count + * Public like count of the video, as reported by YT when RYD last updated it's data. + * + * If the likes were hidden by the video creator, then this returns an + * estimated likes using the same extrapolation as the dislikes. */ public long getLikeCount() { return likeCount; } /** - * Estimated dislike count + * Estimated total dislike count, extrapolated from the public like count using RYD data. */ public long getDislikeCount() { return dislikeCount; @@ -79,28 +109,56 @@ public final class RYDVoteData { } public void updateUsingVote(Vote vote) { + final int likesToAdd, dislikesToAdd; + switch (vote) { case LIKE: - likeCount = fetchedLikeCount + 1; - dislikeCount = fetchedDislikeCount; + likesToAdd = 1; + dislikesToAdd = 0; break; case DISLIKE: - likeCount = fetchedLikeCount; - dislikeCount = fetchedDislikeCount + 1; + likesToAdd = 0; + dislikesToAdd = 1; break; case LIKE_REMOVE: - likeCount = fetchedLikeCount; - dislikeCount = fetchedDislikeCount; + likesToAdd = 0; + dislikesToAdd = 0; break; default: throw new IllegalStateException(); } - updatePercentages(); - } - private void updatePercentages() { - likePercentage = (likeCount == 0 ? 0 : (float) likeCount / (likeCount + dislikeCount)); - dislikePercentage = (dislikeCount == 0 ? 0 : (float) dislikeCount / (likeCount + dislikeCount)); + // If a video has no public likes but RYD has raw like data, + // then use the raw data instead. + final boolean videoHasNoPublicLikes = fetchedLikeCount == 0; + final boolean hasRawData = fetchedRawLikeCount != null && fetchedRawDislikeCount != null; + + if (videoHasNoPublicLikes && hasRawData && fetchedRawDislikeCount > 0) { + // YT creator has hidden the likes count, and this is an older video that + // RYD does not provide estimated like counts. + // + // But we can calculate the public likes the same way RYD does for newer videos with hidden likes, + // by using the same raw to estimated scale factor applied to dislikes. + // This calculation exactly matches the public likes RYD provides for newer hidden videos. + final float estimatedRawScaleFactor = (float) fetchedDislikeCount / fetchedRawDislikeCount; + likeCount = (long) (estimatedRawScaleFactor * fetchedRawLikeCount) + likesToAdd; + Logger.printDebug(() -> "Using locally calculated estimated likes since RYD did not return an estimate"); + } else { + likeCount = fetchedLikeCount + likesToAdd; + } + // RYD now always returns an estimated dislike count, even if the likes are hidden. + dislikeCount = fetchedDislikeCount + dislikesToAdd; + + // Update percentages. + + final float totalCount = likeCount + dislikeCount; + if (totalCount == 0) { + likePercentage = 0; + dislikePercentage = 0; + } else { + likePercentage = likeCount / totalCount; + dislikePercentage = dislikeCount / totalCount; + } } @NonNull diff --git a/app/src/main/java/app/revanced/integrations/youtube/returnyoutubedislike/requests/ReturnYouTubeDislikeApi.java b/app/src/main/java/app/revanced/integrations/youtube/returnyoutubedislike/requests/ReturnYouTubeDislikeApi.java index bc729e47..cb211ea5 100644 --- a/app/src/main/java/app/revanced/integrations/youtube/returnyoutubedislike/requests/ReturnYouTubeDislikeApi.java +++ b/app/src/main/java/app/revanced/integrations/youtube/returnyoutubedislike/requests/ReturnYouTubeDislikeApi.java @@ -197,7 +197,7 @@ public class ReturnYouTubeDislikeApi { return httpResponseCode == HTTP_STATUS_CODE_RATE_LIMIT; } - @SuppressWarnings("NonAtomicOperationOnVolatileField") // Don't care, fields are estimates. + @SuppressWarnings("NonAtomicOperationOnVolatileField") // Don't care, fields are only estimates. private static void updateRateLimitAndStats(long timeNetworkCallStarted, boolean connectionError, boolean rateLimitHit) { if (connectionError && rateLimitHit) { throw new IllegalArgumentException(); @@ -368,10 +368,12 @@ public class ReturnYouTubeDislikeApi { applyCommonPostRequestSettings(connection); String jsonInputString = "{\"solution\": \"" + solution + "\"}"; + byte[] body = jsonInputString.getBytes(StandardCharsets.UTF_8); + connection.setFixedLengthStreamingMode(body.length); try (OutputStream os = connection.getOutputStream()) { - byte[] input = jsonInputString.getBytes(StandardCharsets.UTF_8); - os.write(input, 0, input.length); + os.write(body); } + final int responseCode = connection.getResponseCode(); if (checkIfRateLimitWasHit(responseCode)) { connection.disconnect(); // disconnect, as no more connections will be made for a little while @@ -440,9 +442,10 @@ public class ReturnYouTubeDislikeApi { applyCommonPostRequestSettings(connection); String voteJsonString = "{\"userId\": \"" + userId + "\", \"videoId\": \"" + videoId + "\", \"value\": \"" + vote.value + "\"}"; + byte[] body = voteJsonString.getBytes(StandardCharsets.UTF_8); + connection.setFixedLengthStreamingMode(body.length); try (OutputStream os = connection.getOutputStream()) { - byte[] input = voteJsonString.getBytes(StandardCharsets.UTF_8); - os.write(input, 0, input.length); + os.write(body); } final int responseCode = connection.getResponseCode(); @@ -490,10 +493,12 @@ public class ReturnYouTubeDislikeApi { applyCommonPostRequestSettings(connection); String jsonInputString = "{\"userId\": \"" + userId + "\", \"videoId\": \"" + videoId + "\", \"solution\": \"" + solution + "\"}"; + byte[] body = jsonInputString.getBytes(StandardCharsets.UTF_8); + connection.setFixedLengthStreamingMode(body.length); try (OutputStream os = connection.getOutputStream()) { - byte[] input = jsonInputString.getBytes(StandardCharsets.UTF_8); - os.write(input, 0, input.length); + os.write(body); } + final int responseCode = connection.getResponseCode(); if (checkIfRateLimitWasHit(responseCode)) { connection.disconnect(); // disconnect, as no more connections will be made for a little while diff --git a/app/src/main/java/app/revanced/integrations/youtube/settings/Settings.java b/app/src/main/java/app/revanced/integrations/youtube/settings/Settings.java index 47ee02c8..85bc6c8e 100644 --- a/app/src/main/java/app/revanced/integrations/youtube/settings/Settings.java +++ b/app/src/main/java/app/revanced/integrations/youtube/settings/Settings.java @@ -1,17 +1,5 @@ package app.revanced.integrations.youtube.settings; -import static java.lang.Boolean.FALSE; -import static java.lang.Boolean.TRUE; -import static app.revanced.integrations.shared.settings.Setting.*; -import static app.revanced.integrations.youtube.patches.MiniplayerPatch.MiniplayerType; -import static app.revanced.integrations.youtube.patches.MiniplayerPatch.MiniplayerType.MODERN_1; -import static app.revanced.integrations.youtube.patches.MiniplayerPatch.MiniplayerType.MODERN_3; -import static app.revanced.integrations.youtube.sponsorblock.objects.CategoryBehaviour.*; - -import java.util.Arrays; -import java.util.HashSet; -import java.util.Set; - import app.revanced.integrations.shared.Logger; import app.revanced.integrations.shared.settings.*; import app.revanced.integrations.shared.settings.preference.SharedPrefCategory; @@ -19,9 +7,23 @@ import app.revanced.integrations.youtube.patches.AlternativeThumbnailsPatch.DeAr import app.revanced.integrations.youtube.patches.AlternativeThumbnailsPatch.StillImagesAvailability; import app.revanced.integrations.youtube.patches.AlternativeThumbnailsPatch.ThumbnailOption; import app.revanced.integrations.youtube.patches.AlternativeThumbnailsPatch.ThumbnailStillTime; +import app.revanced.integrations.youtube.patches.spoof.ClientType; import app.revanced.integrations.youtube.patches.spoof.SpoofAppVersionPatch; +import app.revanced.integrations.youtube.patches.spoof.SpoofVideoStreamsPatch; import app.revanced.integrations.youtube.sponsorblock.SponsorBlockSettings; +import java.util.Arrays; +import java.util.HashSet; +import java.util.Set; + +import static app.revanced.integrations.shared.settings.Setting.*; +import static app.revanced.integrations.youtube.patches.MiniplayerPatch.MiniplayerType; +import static app.revanced.integrations.youtube.patches.MiniplayerPatch.MiniplayerType.MODERN_1; +import static app.revanced.integrations.youtube.patches.MiniplayerPatch.MiniplayerType.MODERN_3; +import static app.revanced.integrations.youtube.sponsorblock.objects.CategoryBehaviour.*; +import static java.lang.Boolean.FALSE; +import static java.lang.Boolean.TRUE; + @SuppressWarnings("deprecation") public class Settings extends BaseSettings { // Video @@ -219,6 +221,7 @@ public class Settings extends BaseSettings { public static final BooleanSetting HIDE_SHORTS_LOCATION_LABEL = new BooleanSetting("revanced_hide_shorts_location_label", FALSE); // Save sound to playlist and Search suggestions may have been A/B tests that were abandoned by YT, and it's not clear if these are still used. public static final BooleanSetting HIDE_SHORTS_SAVE_SOUND_BUTTON = new BooleanSetting("revanced_hide_shorts_save_sound_button", FALSE); + public static final BooleanSetting HIDE_SHORTS_USE_THIS_SOUND_BUTTON = new BooleanSetting("revanced_hide_shorts_use_this_sound_button", TRUE); public static final BooleanSetting HIDE_SHORTS_SEARCH_SUGGESTIONS = new BooleanSetting("revanced_hide_shorts_search_suggestions", FALSE); public static final BooleanSetting HIDE_SHORTS_SUPER_THANKS_BUTTON = new BooleanSetting("revanced_hide_shorts_super_thanks_button", TRUE); public static final BooleanSetting HIDE_SHORTS_LIKE_BUTTON = new BooleanSetting("revanced_hide_shorts_like_button", FALSE); @@ -253,13 +256,16 @@ public class Settings extends BaseSettings { "revanced_spoof_device_dimensions_user_dialog_message"); public static final BooleanSetting BYPASS_URL_REDIRECTS = new BooleanSetting("revanced_bypass_url_redirects", TRUE); public static final BooleanSetting ANNOUNCEMENTS = new BooleanSetting("revanced_announcements", TRUE); - public static final BooleanSetting SPOOF_CLIENT = new BooleanSetting("revanced_spoof_client", TRUE, true, "revanced_spoof_client_user_dialog_message"); - public static final BooleanSetting SPOOF_CLIENT_USE_IOS = new BooleanSetting("revanced_spoof_client_use_ios", TRUE, true, parent(SPOOF_CLIENT)); + public static final BooleanSetting SPOOF_VIDEO_STREAMS = new BooleanSetting("revanced_spoof_video_streams", TRUE, true,"revanced_spoof_video_streams_user_dialog_message"); + public static final BooleanSetting SPOOF_VIDEO_STREAMS_IOS_FORCE_AVC = new BooleanSetting("revanced_spoof_video_streams_ios_force_avc", FALSE, true, + "revanced_spoof_video_streams_ios_force_avc_user_dialog_message", new SpoofVideoStreamsPatch.ForceiOSAVCAvailability()); + public static final EnumSetting SPOOF_VIDEO_STREAMS_CLIENT_TYPE = new EnumSetting<>("revanced_spoof_video_streams_client_type", ClientType.IOS, true, parent(SPOOF_VIDEO_STREAMS)); @Deprecated public static final StringSetting DEPRECATED_ANNOUNCEMENT_LAST_HASH = new StringSetting("revanced_announcement_last_hash", ""); public static final IntegerSetting ANNOUNCEMENT_LAST_ID = new IntegerSetting("revanced_announcement_last_id", -1); public static final BooleanSetting CHECK_WATCH_HISTORY_DOMAIN_NAME = new BooleanSetting("revanced_check_watch_history_domain_name", TRUE, false, false); public static final BooleanSetting REMOVE_TRACKING_QUERY_PARAMETER = new BooleanSetting("revanced_remove_tracking_query_parameter", TRUE); + public static final IntegerSetting CHECK_ENVIRONMENT_WARNINGS_ISSUED = new IntegerSetting("revanced_check_environment_warnings_issued", 0, true, false); // Debugging /** diff --git a/app/src/main/java/app/revanced/integrations/youtube/settings/preference/ForceAVCSpoofingPreference.java b/app/src/main/java/app/revanced/integrations/youtube/settings/preference/ForceAVCSpoofingPreference.java new file mode 100644 index 00000000..8d37017e --- /dev/null +++ b/app/src/main/java/app/revanced/integrations/youtube/settings/preference/ForceAVCSpoofingPreference.java @@ -0,0 +1,61 @@ +package app.revanced.integrations.youtube.settings.preference; + +import static app.revanced.integrations.shared.StringRef.str; +import static app.revanced.integrations.youtube.patches.spoof.DeviceHardwareSupport.DEVICE_HAS_HARDWARE_DECODING_VP9; + +import android.content.Context; +import android.preference.SwitchPreference; +import android.util.AttributeSet; + +@SuppressWarnings({"unused", "deprecation"}) +public class ForceAVCSpoofingPreference extends SwitchPreference { + { + if (!DEVICE_HAS_HARDWARE_DECODING_VP9) { + setSummaryOn(str("revanced_spoof_video_streams_ios_force_avc_no_hardware_vp9_summary_on")); + } + } + + public ForceAVCSpoofingPreference(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) { + super(context, attrs, defStyleAttr, defStyleRes); + } + public ForceAVCSpoofingPreference(Context context, AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + } + public ForceAVCSpoofingPreference(Context context, AttributeSet attrs) { + super(context, attrs); + } + public ForceAVCSpoofingPreference(Context context) { + super(context); + } + + private void updateUI() { + if (DEVICE_HAS_HARDWARE_DECODING_VP9) { + return; + } + + // Temporarily remove the preference key to allow changing this preference without + // causing the settings UI listeners from showing reboot dialogs by the changes made here. + String key = getKey(); + setKey(null); + + // This setting cannot be changed by the user. + super.setEnabled(false); + super.setChecked(true); + + setKey(key); + } + + @Override + public void setEnabled(boolean enabled) { + super.setEnabled(enabled); + + updateUI(); + } + + @Override + public void setChecked(boolean checked) { + super.setChecked(checked); + + updateUI(); + } +} diff --git a/app/src/main/java/app/revanced/integrations/youtube/settings/preference/HtmlPreference.java b/app/src/main/java/app/revanced/integrations/youtube/settings/preference/HtmlPreference.java new file mode 100644 index 00000000..96d29645 --- /dev/null +++ b/app/src/main/java/app/revanced/integrations/youtube/settings/preference/HtmlPreference.java @@ -0,0 +1,35 @@ +package app.revanced.integrations.youtube.settings.preference; + +import static android.text.Html.FROM_HTML_MODE_COMPACT; + +import android.content.Context; +import android.os.Build; +import android.preference.Preference; +import android.text.Html; +import android.util.AttributeSet; + +import androidx.annotation.RequiresApi; + +/** + * Allows using basic html for the summary text. + */ +@SuppressWarnings({"unused", "deprecation"}) +@RequiresApi(api = Build.VERSION_CODES.O) +public class HtmlPreference extends Preference { + { + setSummary(Html.fromHtml(getSummary().toString(), FROM_HTML_MODE_COMPACT)); + } + + public HtmlPreference(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) { + super(context, attrs, defStyleAttr, defStyleRes); + } + public HtmlPreference(Context context, AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + } + public HtmlPreference(Context context, AttributeSet attrs) { + super(context, attrs); + } + public HtmlPreference(Context context) { + super(context); + } +} diff --git a/app/src/main/java/app/revanced/integrations/youtube/settings/preference/SponsorBlockPreferenceFragment.java b/app/src/main/java/app/revanced/integrations/youtube/settings/preference/SponsorBlockPreferenceFragment.java index 371ef389..2de654a2 100644 --- a/app/src/main/java/app/revanced/integrations/youtube/settings/preference/SponsorBlockPreferenceFragment.java +++ b/app/src/main/java/app/revanced/integrations/youtube/settings/preference/SponsorBlockPreferenceFragment.java @@ -257,13 +257,19 @@ public class SponsorBlockPreferenceFragment extends PreferenceFragment { newSegmentStep.setSummary(str("revanced_sb_general_adjusting_sum")); newSegmentStep.getEditText().setInputType(InputType.TYPE_CLASS_NUMBER); newSegmentStep.setOnPreferenceChangeListener((preference1, newValue) -> { - final int newAdjustmentValue = Integer.parseInt(newValue.toString()); - if (newAdjustmentValue == 0) { - Utils.showToastLong(str("revanced_sb_general_adjusting_invalid")); - return false; + try { + final int newAdjustmentValue = Integer.parseInt(newValue.toString()); + if (newAdjustmentValue != 0) { + Settings.SB_CREATE_NEW_SEGMENT_STEP.save(newAdjustmentValue); + return true; + } + } catch (NumberFormatException ex) { + Logger.printInfo(() -> "Invalid new segment step", ex); } - Settings.SB_CREATE_NEW_SEGMENT_STEP.save(newAdjustmentValue); - return true; + + Utils.showToastLong(str("revanced_sb_general_adjusting_invalid")); + updateUI(); + return false; }); category.addPreference(newSegmentStep); @@ -309,8 +315,17 @@ public class SponsorBlockPreferenceFragment extends PreferenceFragment { minSegmentDuration.setSummary(str("revanced_sb_general_min_duration_sum")); minSegmentDuration.getEditText().setInputType(InputType.TYPE_CLASS_NUMBER | InputType.TYPE_NUMBER_FLAG_DECIMAL); minSegmentDuration.setOnPreferenceChangeListener((preference1, newValue) -> { - Settings.SB_SEGMENT_MIN_DURATION.save(Float.valueOf(newValue.toString())); - return true; + try { + Float minTimeDuration = Float.valueOf(newValue.toString()); + Settings.SB_SEGMENT_MIN_DURATION.save(minTimeDuration); + return true; + } catch (NumberFormatException ex) { + Logger.printInfo(() -> "Invalid minimum segment duration", ex); + } + + Utils.showToastLong(str("revanced_sb_general_min_duration_invalid")); + updateUI(); + return false; }); category.addPreference(minSegmentDuration); @@ -323,6 +338,7 @@ public class SponsorBlockPreferenceFragment extends PreferenceFragment { Utils.showToastLong(str("revanced_sb_general_uuid_invalid")); return false; } + Settings.SB_PRIVATE_USER_ID.save(newUUID); updateUI(); fetchAndDisplayStats(); @@ -503,6 +519,7 @@ public class SponsorBlockPreferenceFragment extends PreferenceFragment { statsCategory.addPreference(preference); String formatted = SponsorBlockUtils.getNumberOfSkipsString(stats.segmentCount); preference.setTitle(fromHtml(str("revanced_sb_stats_submissions", formatted))); + preference.setSummary(str("revanced_sb_stats_submissions_sum")); if (stats.totalSegmentCountIncludingIgnored == 0) { preference.setSelectable(false); } else { diff --git a/gradle.properties b/gradle.properties index 79ce6376..d8b4047b 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,4 +1,4 @@ org.gradle.parallel = true org.gradle.caching = true android.useAndroidX = true -version = 1.13.0 +version = 1.14.0-dev.12 diff --git a/stub/src/main/java/org/chromium/net/ExperimentalUrlRequest.java b/stub/src/main/java/org/chromium/net/ExperimentalUrlRequest.java deleted file mode 100644 index cdf2593e..00000000 --- a/stub/src/main/java/org/chromium/net/ExperimentalUrlRequest.java +++ /dev/null @@ -1,8 +0,0 @@ -package org.chromium.net; - -public abstract class ExperimentalUrlRequest { - public abstract class Builder { - public abstract ExperimentalUrlRequest.Builder addHeader(String name, String value); - public abstract ExperimentalUrlRequest build(); - } -} diff --git a/stub/src/main/java/org/chromium/net/UrlRequest.java b/stub/src/main/java/org/chromium/net/UrlRequest.java index 565fc222..4c02f1a4 100644 --- a/stub/src/main/java/org/chromium/net/UrlRequest.java +++ b/stub/src/main/java/org/chromium/net/UrlRequest.java @@ -1,4 +1,8 @@ package org.chromium.net; public abstract class UrlRequest { + public abstract class Builder { + public abstract Builder addHeader(String name, String value); + public abstract UrlRequest build(); + } }