mirror of
https://github.com/inotia00/revanced-patches.git
synced 2025-05-28 12:50:19 +02:00
feat(YouTube Music): Add Spoof streaming data
patch
This commit is contained in:
parent
21acf6f003
commit
7dfd817ba3
@ -1,19 +1,44 @@
|
||||
package app.revanced.extension.music.patches.misc;
|
||||
|
||||
import app.revanced.extension.music.patches.misc.client.AppClient.ClientType;
|
||||
import app.revanced.extension.music.settings.Settings;
|
||||
|
||||
@SuppressWarnings("unused")
|
||||
public class SpoofClientPatch {
|
||||
private static final int CLIENT_ID_IOS_MUSIC = 26;
|
||||
/**
|
||||
* The hardcoded client version of the iOS app used for InnerTube requests with this client.
|
||||
*
|
||||
* <p>
|
||||
* It can be extracted by getting the latest release version of the app on
|
||||
* <a href="https://apps.apple.com/us/app/youtube-music/id1017492454/">the App
|
||||
* Store page of the YouTube app</a>, in the {@code What¡¯s New} section.
|
||||
* </p>
|
||||
*/
|
||||
private static final String CLIENT_VERSION_IOS_MUSIC = "6.21";
|
||||
/**
|
||||
* See <a href="https://gist.github.com/adamawolf/3048717">this GitHub Gist</a> for more
|
||||
* information.
|
||||
* </p>
|
||||
*/
|
||||
private static final String DEVICE_MODEL_IOS_MUSIC = "iPhone16,2";
|
||||
private static final String OS_VERSION_IOS_MUSIC = "17.7.2.21H221";
|
||||
private static final String USER_AGENT_VERSION_IOS_MUSIC = "17_7_2";
|
||||
private static final String USER_AGENT_IOS_MUSIC = "com.google.ios.youtubemusic/" +
|
||||
CLIENT_VERSION_IOS_MUSIC +
|
||||
"(" +
|
||||
DEVICE_MODEL_IOS_MUSIC +
|
||||
"; U; CPU iOS " +
|
||||
USER_AGENT_VERSION_IOS_MUSIC +
|
||||
" like Mac OS X)";
|
||||
|
||||
private static final boolean SPOOF_CLIENT_ENABLED = Settings.SPOOF_CLIENT.get();
|
||||
private static final ClientType clientType = ClientType.IOS_MUSIC;
|
||||
|
||||
/**
|
||||
* Injection point.
|
||||
*/
|
||||
public static int getClientTypeId(int originalClientTypeId) {
|
||||
if (SPOOF_CLIENT_ENABLED) {
|
||||
return clientType.id;
|
||||
return CLIENT_ID_IOS_MUSIC;
|
||||
}
|
||||
|
||||
return originalClientTypeId;
|
||||
@ -24,7 +49,7 @@ public class SpoofClientPatch {
|
||||
*/
|
||||
public static String getClientVersion(String originalClientVersion) {
|
||||
if (SPOOF_CLIENT_ENABLED) {
|
||||
return clientType.clientVersion;
|
||||
return CLIENT_VERSION_IOS_MUSIC;
|
||||
}
|
||||
|
||||
return originalClientVersion;
|
||||
@ -35,7 +60,7 @@ public class SpoofClientPatch {
|
||||
*/
|
||||
public static String getClientModel(String originalClientModel) {
|
||||
if (SPOOF_CLIENT_ENABLED) {
|
||||
return clientType.deviceModel;
|
||||
return DEVICE_MODEL_IOS_MUSIC;
|
||||
}
|
||||
|
||||
return originalClientModel;
|
||||
@ -46,7 +71,7 @@ public class SpoofClientPatch {
|
||||
*/
|
||||
public static String getOsVersion(String originalOsVersion) {
|
||||
if (SPOOF_CLIENT_ENABLED) {
|
||||
return clientType.osVersion;
|
||||
return OS_VERSION_IOS_MUSIC;
|
||||
}
|
||||
|
||||
return originalOsVersion;
|
||||
@ -57,7 +82,7 @@ public class SpoofClientPatch {
|
||||
*/
|
||||
public static String getUserAgent(String originalUserAgent) {
|
||||
if (SPOOF_CLIENT_ENABLED) {
|
||||
return clientType.userAgent;
|
||||
return USER_AGENT_IOS_MUSIC;
|
||||
}
|
||||
|
||||
return originalUserAgent;
|
||||
|
@ -1,122 +0,0 @@
|
||||
package app.revanced.extension.music.patches.misc.client;
|
||||
|
||||
import android.os.Build;
|
||||
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
public class AppClient {
|
||||
|
||||
/**
|
||||
* The hardcoded client version of the iOS app used for InnerTube requests with this client.
|
||||
*
|
||||
* <p>
|
||||
* It can be extracted by getting the latest release version of the app on
|
||||
* <a href="https://apps.apple.com/us/app/music-watch-listen-stream/id544007664/">the App
|
||||
* Store page of the YouTube app</a>, in the {@code What’s New} section.
|
||||
* </p>
|
||||
*/
|
||||
private static final String CLIENT_VERSION_IOS = "6.21";
|
||||
private static final String DEVICE_MAKE_IOS = "Apple";
|
||||
/**
|
||||
* See <a href="https://gist.github.com/adamawolf/3048717">this GitHub Gist</a> for more
|
||||
* information.
|
||||
* </p>
|
||||
*/
|
||||
private static final String DEVICE_MODEL_IOS = "iPhone16,2";
|
||||
private static final String OS_NAME_IOS = "iOS";
|
||||
private static final String OS_VERSION_IOS = "17.7.2.21H221";
|
||||
private static final String USER_AGENT_VERSION_IOS = "17_7_2";
|
||||
private static final String USER_AGENT_IOS = "com.google.ios.youtubemusic/" +
|
||||
CLIENT_VERSION_IOS +
|
||||
"(" +
|
||||
DEVICE_MODEL_IOS +
|
||||
"; U; CPU iOS " +
|
||||
USER_AGENT_VERSION_IOS +
|
||||
" like Mac OS X)";
|
||||
|
||||
private AppClient() {
|
||||
}
|
||||
|
||||
public enum ClientType {
|
||||
IOS_MUSIC(26,
|
||||
DEVICE_MAKE_IOS,
|
||||
DEVICE_MODEL_IOS,
|
||||
CLIENT_VERSION_IOS,
|
||||
OS_NAME_IOS,
|
||||
OS_VERSION_IOS,
|
||||
null,
|
||||
USER_AGENT_IOS,
|
||||
true
|
||||
);
|
||||
|
||||
/**
|
||||
* YouTube
|
||||
* <a href="https://github.com/zerodytrash/YouTube-Internal-Clients?tab=readme-ov-file#clients">client type</a>
|
||||
*/
|
||||
public final int id;
|
||||
|
||||
/**
|
||||
* Device manufacturer.
|
||||
*/
|
||||
@Nullable
|
||||
public final String deviceMake;
|
||||
|
||||
/**
|
||||
* Device model, equivalent to {@link Build#MODEL} (System property: ro.product.model)
|
||||
*/
|
||||
public final String deviceModel;
|
||||
|
||||
/**
|
||||
* Device OS name.
|
||||
*/
|
||||
@Nullable
|
||||
public final String osName;
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
public final Integer androidSdkVersion;
|
||||
|
||||
/**
|
||||
* App version.
|
||||
*/
|
||||
public final String clientVersion;
|
||||
|
||||
/**
|
||||
* If the client can access the API logged in.
|
||||
*/
|
||||
public final boolean canLogin;
|
||||
|
||||
ClientType(int id,
|
||||
@Nullable String deviceMake,
|
||||
String deviceModel,
|
||||
String clientVersion,
|
||||
@Nullable String osName,
|
||||
String osVersion,
|
||||
Integer androidSdkVersion,
|
||||
String userAgent,
|
||||
boolean canLogin
|
||||
) {
|
||||
this.id = id;
|
||||
this.deviceMake = deviceMake;
|
||||
this.deviceModel = deviceModel;
|
||||
this.clientVersion = clientVersion;
|
||||
this.osName = osName;
|
||||
this.osVersion = osVersion;
|
||||
this.androidSdkVersion = androidSdkVersion;
|
||||
this.userAgent = userAgent;
|
||||
this.canLogin = canLogin;
|
||||
}
|
||||
}
|
||||
}
|
@ -1,8 +1,14 @@
|
||||
package app.revanced.extension.shared.patches;
|
||||
|
||||
import app.revanced.extension.shared.patches.client.AppClient.ClientType;
|
||||
|
||||
@SuppressWarnings("unused")
|
||||
public class PatchStatus {
|
||||
public static boolean HideFullscreenAdsDefaultBoolean() {
|
||||
return false;
|
||||
}
|
||||
|
||||
public static ClientType SpoofStreamingDataDefaultClient() {
|
||||
return ClientType.IOS;
|
||||
}
|
||||
}
|
||||
|
@ -1,16 +1,12 @@
|
||||
package app.revanced.extension.youtube.patches.misc.client;
|
||||
package app.revanced.extension.shared.patches.client;
|
||||
|
||||
import static app.revanced.extension.shared.utils.StringRef.str;
|
||||
import static app.revanced.extension.shared.utils.ResourceUtils.getString;
|
||||
|
||||
import android.os.Build;
|
||||
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
public class AppClient {
|
||||
|
||||
// ANDROID
|
||||
private static final String OS_NAME_ANDROID = "Android";
|
||||
|
||||
// IOS
|
||||
/**
|
||||
* The hardcoded client version of the iOS app used for InnerTube requests with this client.
|
||||
@ -21,8 +17,7 @@ public class AppClient {
|
||||
* Store page of the YouTube app</a>, in the {@code What’s New} section.
|
||||
* </p>
|
||||
*/
|
||||
private static final String CLIENT_VERSION_IOS = "19.47.7";
|
||||
private static final String DEVICE_MAKE_IOS = "Apple";
|
||||
private static final String CLIENT_VERSION_IOS = "19.49.5";
|
||||
/**
|
||||
* The device machine id for the iPhone 16 Pro Max (iPhone17,2), used to get HDR with AV1 hardware decoding.
|
||||
*
|
||||
@ -31,11 +26,13 @@ public class AppClient {
|
||||
* information.
|
||||
* </p>
|
||||
*/
|
||||
private static final String DEVICE_MODEL_IOS = "iPhone17,2"
|
||||
private static final String OS_NAME_IOS = "iOS";
|
||||
private static final String OS_VERSION_IOS = "18.1.1.22B91"
|
||||
private static final String USER_AGENT_VERSION_IOS = "18_1_1"
|
||||
|
||||
private static final String DEVICE_MODEL_IOS = "iPhone17,2";
|
||||
/**
|
||||
* The minimum supported OS version for the iOS YouTube client is iOS 14.0.
|
||||
* Using an invalid OS version will use the AVC codec.
|
||||
*/
|
||||
private static final String OS_VERSION_IOS = "18.1.1.22B91";
|
||||
private static final String USER_AGENT_VERSION_IOS = "18_1_1";
|
||||
private static final String USER_AGENT_IOS = "com.google.ios.youtube/" +
|
||||
CLIENT_VERSION_IOS +
|
||||
"(" +
|
||||
@ -44,6 +41,25 @@ public class AppClient {
|
||||
USER_AGENT_VERSION_IOS +
|
||||
" like Mac OS X)";
|
||||
|
||||
// IOS_MUSIC
|
||||
/**
|
||||
* The hardcoded client version of the iOS app used for InnerTube requests with this client.
|
||||
*
|
||||
* <p>
|
||||
* It can be extracted by getting the latest release version of the app on
|
||||
* <a href="https://apps.apple.com/us/app/youtube-music/id1017492454/">the App
|
||||
* Store page of the YouTube app</a>, in the {@code What’s New} section.
|
||||
* </p>
|
||||
*/
|
||||
private static final String CLIENT_VERSION_IOS_MUSIC = "7.31.2";
|
||||
private static final String USER_AGENT_IOS_MUSIC = "com.google.ios.youtubemusic/" +
|
||||
CLIENT_VERSION_IOS +
|
||||
"(" +
|
||||
DEVICE_MODEL_IOS +
|
||||
"; U; CPU iOS " +
|
||||
USER_AGENT_VERSION_IOS +
|
||||
" like Mac OS X)";
|
||||
|
||||
// ANDROID VR
|
||||
/**
|
||||
* The hardcoded client version of the Android VR app used for InnerTube requests with this client.
|
||||
@ -54,7 +70,7 @@ public class AppClient {
|
||||
* Store page of the YouTube app</a>, in the {@code Additional details} section.
|
||||
* </p>
|
||||
*/
|
||||
private static final String CLIENT_VERSION_ANDROID_VR = "1.60.19";
|
||||
private static final String CLIENT_VERSION_ANDROID_VR = "1.61.47";
|
||||
/**
|
||||
* The device machine id for the Meta Quest 3, used to get opus codec with the Android VR client.
|
||||
*
|
||||
@ -69,7 +85,7 @@ public class AppClient {
|
||||
* The SDK version for Android 12 is 31,
|
||||
* but for some reason the build.props for the {@code Quest 3} state that the SDK version is 32.
|
||||
*/
|
||||
private static final int ANDROID_SDK_VERSION_ANDROID_VR = 32;
|
||||
private static final String ANDROID_SDK_VERSION_ANDROID_VR = "32";
|
||||
/**
|
||||
* Package name for YouTube VR (Google DayDream): com.google.android.apps.youtube.vr (Deprecated)
|
||||
* Package name for YouTube VR (Meta Quests): com.google.android.apps.youtube.vr.oculus
|
||||
@ -82,7 +98,7 @@ public class AppClient {
|
||||
"; GB) gzip";
|
||||
|
||||
// ANDROID UNPLUGGED
|
||||
private static final String CLIENT_VERSION_ANDROID_UNPLUGGED = "8.47.0";
|
||||
private static final String CLIENT_VERSION_ANDROID_UNPLUGGED = "8.49.0";
|
||||
/**
|
||||
* The device machine id for the Chromecast with Google TV 4K.
|
||||
*
|
||||
@ -93,7 +109,7 @@ public class AppClient {
|
||||
*/
|
||||
private static final String DEVICE_MODEL_ANDROID_UNPLUGGED = "Google TV Streamer";
|
||||
private static final String OS_VERSION_ANDROID_UNPLUGGED = "14";
|
||||
private static final int ANDROID_SDK_VERSION_ANDROID_UNPLUGGED = 34;
|
||||
private static final String ANDROID_SDK_VERSION_ANDROID_UNPLUGGED = "34";
|
||||
private static final String USER_AGENT_ANDROID_UNPLUGGED = "com.google.android.apps.youtube.unplugged/" +
|
||||
CLIENT_VERSION_ANDROID_UNPLUGGED +
|
||||
" (Linux; U; Android " +
|
||||
@ -105,61 +121,52 @@ public class AppClient {
|
||||
|
||||
public enum ClientType {
|
||||
IOS(5,
|
||||
DEVICE_MAKE_IOS,
|
||||
DEVICE_MODEL_IOS,
|
||||
CLIENT_VERSION_IOS,
|
||||
OS_NAME_IOS,
|
||||
OS_VERSION_IOS,
|
||||
null,
|
||||
USER_AGENT_IOS,
|
||||
null,
|
||||
CLIENT_VERSION_IOS,
|
||||
false
|
||||
),
|
||||
ANDROID_VR(28,
|
||||
null,
|
||||
DEVICE_MODEL_ANDROID_VR,
|
||||
CLIENT_VERSION_ANDROID_VR,
|
||||
OS_NAME_ANDROID,
|
||||
OS_VERSION_ANDROID_VR,
|
||||
ANDROID_SDK_VERSION_ANDROID_VR,
|
||||
USER_AGENT_ANDROID_VR,
|
||||
ANDROID_SDK_VERSION_ANDROID_VR,
|
||||
CLIENT_VERSION_ANDROID_VR,
|
||||
true
|
||||
),
|
||||
ANDROID_UNPLUGGED(29,
|
||||
null,
|
||||
DEVICE_MODEL_ANDROID_UNPLUGGED,
|
||||
CLIENT_VERSION_ANDROID_UNPLUGGED,
|
||||
OS_NAME_ANDROID,
|
||||
OS_VERSION_ANDROID_UNPLUGGED,
|
||||
ANDROID_SDK_VERSION_ANDROID_UNPLUGGED,
|
||||
USER_AGENT_ANDROID_UNPLUGGED,
|
||||
ANDROID_SDK_VERSION_ANDROID_UNPLUGGED,
|
||||
CLIENT_VERSION_ANDROID_UNPLUGGED,
|
||||
true
|
||||
),
|
||||
IOS_MUSIC(
|
||||
26,
|
||||
DEVICE_MODEL_IOS,
|
||||
OS_VERSION_IOS,
|
||||
USER_AGENT_IOS_MUSIC,
|
||||
null,
|
||||
CLIENT_VERSION_IOS_MUSIC,
|
||||
true
|
||||
);
|
||||
|
||||
public final String friendlyName;
|
||||
|
||||
/**
|
||||
* YouTube
|
||||
* <a href="https://github.com/zerodytrash/YouTube-Internal-Clients?tab=readme-ov-file#clients">client type</a>
|
||||
*/
|
||||
public final int id;
|
||||
|
||||
/**
|
||||
* Device manufacturer.
|
||||
*/
|
||||
@Nullable
|
||||
public final String deviceMake;
|
||||
public final String clientName;
|
||||
|
||||
/**
|
||||
* Device model, equivalent to {@link Build#MODEL} (System property: ro.product.model)
|
||||
*/
|
||||
public final String deviceModel;
|
||||
|
||||
/**
|
||||
* Device OS name.
|
||||
*/
|
||||
@Nullable
|
||||
public final String osName;
|
||||
|
||||
/**
|
||||
* Device OS version.
|
||||
*/
|
||||
@ -174,7 +181,8 @@ public class AppClient {
|
||||
* Android SDK version, equivalent to {@link Build.VERSION#SDK} (System property: ro.build.version.sdk)
|
||||
* Field is null if not applicable.
|
||||
*/
|
||||
public final Integer androidSdkVersion;
|
||||
@Nullable
|
||||
public final String androidSdkVersion;
|
||||
|
||||
/**
|
||||
* App version.
|
||||
@ -187,25 +195,25 @@ public class AppClient {
|
||||
public final boolean canLogin;
|
||||
|
||||
ClientType(int id,
|
||||
@Nullable String deviceMake,
|
||||
String deviceModel,
|
||||
String clientVersion,
|
||||
@Nullable String osName,
|
||||
String osVersion,
|
||||
Integer androidSdkVersion,
|
||||
String userAgent,
|
||||
@Nullable String androidSdkVersion,
|
||||
String clientVersion,
|
||||
boolean canLogin
|
||||
) {
|
||||
this.friendlyName = str("revanced_spoof_streaming_data_type_entry_" + name().toLowerCase());
|
||||
this.id = id;
|
||||
this.deviceMake = deviceMake;
|
||||
this.clientName = name();
|
||||
this.deviceModel = deviceModel;
|
||||
this.clientVersion = clientVersion;
|
||||
this.osName = osName;
|
||||
this.osVersion = osVersion;
|
||||
this.androidSdkVersion = androidSdkVersion;
|
||||
this.userAgent = userAgent;
|
||||
this.canLogin = canLogin;
|
||||
}
|
||||
|
||||
public final String getFriendlyName() {
|
||||
return getString("revanced_spoof_streaming_data_type_entry_" + name().toLowerCase());
|
||||
}
|
||||
}
|
||||
}
|
@ -1,4 +1,4 @@
|
||||
package app.revanced.extension.youtube.patches.misc;
|
||||
package app.revanced.extension.shared.patches.spoof;
|
||||
|
||||
import android.net.Uri;
|
||||
import android.text.TextUtils;
|
||||
@ -7,19 +7,15 @@ import androidx.annotation.Nullable;
|
||||
|
||||
import java.nio.ByteBuffer;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
|
||||
import app.revanced.extension.shared.settings.Setting;
|
||||
import app.revanced.extension.shared.utils.Logger;
|
||||
import app.revanced.extension.shared.utils.Utils;
|
||||
import app.revanced.extension.youtube.patches.misc.client.AppClient.ClientType;
|
||||
import app.revanced.extension.youtube.patches.misc.requests.StreamingDataRequest;
|
||||
import app.revanced.extension.youtube.settings.Settings;
|
||||
import app.revanced.extension.shared.settings.BaseSettings;
|
||||
import app.revanced.extension.shared.patches.spoof.requests.StreamingDataRequest;
|
||||
|
||||
@SuppressWarnings("unused")
|
||||
public class SpoofStreamingDataPatch {
|
||||
private static final boolean SPOOF_STREAMING_DATA = Settings.SPOOF_STREAMING_DATA.get();
|
||||
|
||||
private static final boolean SPOOF_STREAMING_DATA = BaseSettings.SPOOF_STREAMING_DATA.get();
|
||||
/**
|
||||
* Any unreachable ip address. Used to intentionally fail requests.
|
||||
*/
|
||||
@ -90,10 +86,19 @@ public class SpoofStreamingDataPatch {
|
||||
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);
|
||||
// 'refresh' has no video id and appears to happen when waiting for a livestream to start.
|
||||
if (path != null && path.contains("player") && !path.contains("heartbeat")
|
||||
&& !path.contains("refresh")) {
|
||||
String id = uri.getQueryParameter("id");
|
||||
if (id == null) {
|
||||
Logger.printException(() -> "Ignoring request that has no video id." +
|
||||
" Url: " + url + " headers: " + requestHeaders);
|
||||
return;
|
||||
}
|
||||
|
||||
StreamingDataRequest.fetchRequest(id, requestHeaders);
|
||||
}
|
||||
} catch (Exception ex) {
|
||||
Logger.printException(() -> "buildRequest failure", ex);
|
||||
@ -104,7 +109,7 @@ public class SpoofStreamingDataPatch {
|
||||
/**
|
||||
* Injection point.
|
||||
* Fix playback by replace the streaming data.
|
||||
* Called after {@link #fetchStreams(String, Map)} .
|
||||
* Called after {@link #fetchStreams(String, Map)}.
|
||||
*/
|
||||
@Nullable
|
||||
public static ByteBuffer getStreamingData(String videoId) {
|
||||
@ -117,9 +122,10 @@ public class SpoofStreamingDataPatch {
|
||||
// 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 (Settings.ENABLE_DEBUG_LOGGING.get() && !request.fetchCompleted() && Utils.isCurrentlyOnMainThread()) {
|
||||
if (BaseSettings.ENABLE_DEBUG_LOGGING.get() && !request.fetchCompleted() && Utils.isCurrentlyOnMainThread()) {
|
||||
Logger.printException(() -> "Error: Blocking main thread");
|
||||
}
|
||||
|
||||
var stream = request.getStream();
|
||||
if (stream != null) {
|
||||
Logger.printDebug(() -> "Overriding video stream: " + videoId);
|
||||
@ -164,7 +170,7 @@ public class SpoofStreamingDataPatch {
|
||||
*/
|
||||
public static String appendSpoofedClient(String videoFormat) {
|
||||
try {
|
||||
if (SPOOF_STREAMING_DATA && Settings.SPOOF_STREAMING_DATA_STATS_FOR_NERDS.get()
|
||||
if (SPOOF_STREAMING_DATA && BaseSettings.SPOOF_STREAMING_DATA_STATS_FOR_NERDS.get()
|
||||
&& !TextUtils.isEmpty(videoFormat)) {
|
||||
// Force LTR layout, to match the same LTR video time/length layout YouTube uses for all languages
|
||||
return "\u202D" + videoFormat + String.format("\u2009(%s)", StreamingDataRequest.getLastSpoofedClientName()); // u202D = left to right override
|
||||
@ -175,11 +181,4 @@ public class SpoofStreamingDataPatch {
|
||||
|
||||
return videoFormat;
|
||||
}
|
||||
|
||||
public static final class iOSAvailability implements Setting.Availability {
|
||||
@Override
|
||||
public boolean isAvailable() {
|
||||
return Settings.SPOOF_STREAMING_DATA.get() && Settings.SPOOF_STREAMING_DATA_TYPE.get() == ClientType.IOS;
|
||||
}
|
||||
}
|
||||
}
|
@ -1,81 +1,69 @@
|
||||
package app.revanced.extension.youtube.patches.misc.requests;
|
||||
package app.revanced.extension.shared.patches.spoof.requests;
|
||||
|
||||
import org.json.JSONException;
|
||||
import org.json.JSONObject;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.net.HttpURLConnection;
|
||||
import java.util.Objects;
|
||||
|
||||
import app.revanced.extension.shared.patches.client.AppClient.ClientType;
|
||||
import app.revanced.extension.shared.requests.Requester;
|
||||
import app.revanced.extension.shared.requests.Route;
|
||||
import app.revanced.extension.shared.utils.Logger;
|
||||
import app.revanced.extension.shared.utils.Utils;
|
||||
import app.revanced.extension.youtube.patches.misc.client.AppClient.ClientType;
|
||||
|
||||
@SuppressWarnings("deprecation")
|
||||
@SuppressWarnings({"ExtractMethodRecommender", "deprecation"})
|
||||
public final class PlayerRoutes {
|
||||
/**
|
||||
* The base URL of requests of non-web clients to the InnerTube internal API.
|
||||
*/
|
||||
private static final String YOUTUBEI_V1_GAPIS_URL = "https://youtubei.googleapis.com/youtubei/v1/";
|
||||
|
||||
public static final Route.CompiledRoute GET_PLAYLIST_PAGE = new Route(
|
||||
Route.Method.POST,
|
||||
"next" +
|
||||
"?fields=contents.singleColumnWatchNextResults.playlist.playlist"
|
||||
).compile();
|
||||
static final Route.CompiledRoute GET_STREAMING_DATA = new Route(
|
||||
Route.Method.POST,
|
||||
"player" +
|
||||
"?fields=streamingData" +
|
||||
"&alt=proto"
|
||||
).compile();
|
||||
|
||||
static final Route.CompiledRoute GET_PLAYLIST_PAGE = new Route(
|
||||
Route.Method.POST,
|
||||
"next" +
|
||||
"?fields=contents.singleColumnWatchNextResults.playlist.playlist"
|
||||
).compile();
|
||||
|
||||
private static final String YT_API_URL = "https://youtubei.googleapis.com/youtubei/v1/";
|
||||
/**
|
||||
* TCP connection and HTTP read timeout
|
||||
*/
|
||||
private static final int CONNECTION_TIMEOUT_MILLISECONDS = 10 * 1000; // 10 Seconds.
|
||||
|
||||
private static final String LOCALE_LANGUAGE = Utils.getContext().getResources()
|
||||
.getConfiguration().locale.getLanguage();
|
||||
|
||||
private PlayerRoutes() {
|
||||
}
|
||||
|
||||
static String createInnertubeBody(ClientType clientType, String videoId) {
|
||||
return createInnertubeBody(clientType, videoId, null);
|
||||
public static String createInnertubeBody(ClientType clientType) {
|
||||
return createInnertubeBody(clientType, false);
|
||||
}
|
||||
|
||||
static String createInnertubeBody(ClientType clientType, String videoId, String playlistId) {
|
||||
public static String createInnertubeBody(ClientType clientType, boolean playlistId) {
|
||||
JSONObject innerTubeBody = new JSONObject();
|
||||
|
||||
try {
|
||||
JSONObject context = new JSONObject();
|
||||
|
||||
JSONObject client = new JSONObject();
|
||||
client.put("clientName", clientType.name());
|
||||
client.put("clientName", clientType.clientName);
|
||||
client.put("clientVersion", clientType.clientVersion);
|
||||
client.put("deviceModel", clientType.deviceModel);
|
||||
client.put("osVersion", clientType.osVersion);
|
||||
if (clientType.deviceMake != null) {
|
||||
client.put("deviceMake", clientType.deviceMake);
|
||||
}
|
||||
if (clientType.osName != null) {
|
||||
client.put("osName", clientType.osName);
|
||||
}
|
||||
if (clientType.androidSdkVersion != null) {
|
||||
client.put("androidSdkVersion", clientType.androidSdkVersion.toString());
|
||||
client.put("androidSdkVersion", clientType.androidSdkVersion);
|
||||
}
|
||||
String languageCode = Objects.requireNonNull(Utils.getContext()).getResources().getConfiguration().locale.getLanguage();
|
||||
client.put("hl", languageCode);
|
||||
client.put("hl", LOCALE_LANGUAGE);
|
||||
|
||||
JSONObject context = new JSONObject();
|
||||
context.put("client", client);
|
||||
|
||||
innerTubeBody.put("context", context);
|
||||
innerTubeBody.put("contentCheckOk", true);
|
||||
innerTubeBody.put("racyCheckOk", true);
|
||||
innerTubeBody.put("videoId", videoId);
|
||||
if (playlistId != null) {
|
||||
innerTubeBody.put("playlistId", playlistId);
|
||||
innerTubeBody.put("videoId", "%s");
|
||||
if (playlistId) {
|
||||
innerTubeBody.put("playlistId", "%s");
|
||||
}
|
||||
} catch (JSONException e) {
|
||||
Logger.printException(() -> "Failed to create innerTubeBody", e);
|
||||
@ -87,13 +75,11 @@ public final class PlayerRoutes {
|
||||
/**
|
||||
* @noinspection SameParameterValue
|
||||
*/
|
||||
static HttpURLConnection getPlayerResponseConnectionFromRoute(Route.CompiledRoute route, ClientType clientType) throws IOException {
|
||||
var connection = Requester.getConnectionFromCompiledRoute(YOUTUBEI_V1_GAPIS_URL, route);
|
||||
public static HttpURLConnection getPlayerResponseConnectionFromRoute(Route.CompiledRoute route, ClientType clientType) throws IOException {
|
||||
var connection = Requester.getConnectionFromCompiledRoute(YT_API_URL, route);
|
||||
|
||||
connection.setRequestProperty("Content-Type", "application/json");
|
||||
connection.setRequestProperty("User-Agent", clientType.userAgent);
|
||||
connection.setRequestProperty("X-YouTube-Client-Name", clientType.id);
|
||||
connection.setRequestProperty("X-YouTube-Client-Version", clientType.clientVersion);
|
||||
|
||||
connection.setUseCaches(false);
|
||||
connection.setDoOutput(true);
|
||||
@ -102,4 +88,4 @@ public final class PlayerRoutes {
|
||||
connection.setReadTimeout(CONNECTION_TIMEOUT_MILLISECONDS);
|
||||
return connection;
|
||||
}
|
||||
}
|
||||
}
|
@ -1,8 +1,7 @@
|
||||
package app.revanced.extension.youtube.patches.misc.requests;
|
||||
package app.revanced.extension.shared.patches.spoof.requests;
|
||||
|
||||
import static app.revanced.extension.youtube.patches.misc.requests.PlayerRoutes.GET_STREAMING_DATA;
|
||||
import static app.revanced.extension.shared.patches.spoof.requests.PlayerRoutes.GET_STREAMING_DATA;
|
||||
|
||||
import androidx.annotation.GuardedBy;
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
@ -11,60 +10,57 @@ 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.Collections;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
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.extension.shared.patches.components.ByteArrayFilterGroup;
|
||||
import app.revanced.extension.shared.patches.client.AppClient.ClientType;
|
||||
|
||||
import app.revanced.extension.shared.utils.Logger;
|
||||
import app.revanced.extension.shared.utils.Utils;
|
||||
import app.revanced.extension.youtube.patches.misc.client.AppClient.ClientType;
|
||||
import app.revanced.extension.youtube.settings.Settings;
|
||||
import app.revanced.extension.shared.settings.BaseSettings;
|
||||
|
||||
/**
|
||||
* Video streaming data. Fetching is tied to the behavior YT uses,
|
||||
* where this class fetches the streams only when YT fetches.
|
||||
* <p>
|
||||
* 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 extension replace stream hook is called only if YT
|
||||
* did use its own client streams.
|
||||
*/
|
||||
public class StreamingDataRequest {
|
||||
private static final ClientType[] ALL_CLIENT_TYPES = ClientType.values();
|
||||
|
||||
private static final ClientType[] CLIENT_ORDER_TO_USE;
|
||||
|
||||
static {
|
||||
ClientType preferredClient = Settings.SPOOF_STREAMING_DATA_TYPE.get();
|
||||
CLIENT_ORDER_TO_USE = new ClientType[ALL_CLIENT_TYPES.length];
|
||||
|
||||
CLIENT_ORDER_TO_USE[0] = preferredClient;
|
||||
|
||||
int i = 1;
|
||||
for (ClientType c : ALL_CLIENT_TYPES) {
|
||||
if (c != preferredClient) {
|
||||
CLIENT_ORDER_TO_USE[i++] = c;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static final String AUTHORIZATION_HEADER = "Authorization";
|
||||
private static final String[] REQUEST_HEADER_KEYS = {
|
||||
AUTHORIZATION_HEADER, // Available only to logged-in users.
|
||||
"X-GOOG-API-FORMAT-VERSION",
|
||||
"X-Goog-Visitor-Id"
|
||||
};
|
||||
private static final ByteArrayFilterGroup liveStreams =
|
||||
new ByteArrayFilterGroup(
|
||||
BaseSettings.SPOOF_STREAMING_DATA_IOS_SKIP_LIVESTREAM_PLAYBACK,
|
||||
"yt_live_broadcast",
|
||||
"yt_premiere_broadcast"
|
||||
);
|
||||
private static ClientType lastSpoofedClientType;
|
||||
|
||||
public static String getLastSpoofedClientName() {
|
||||
return lastSpoofedClientType == null
|
||||
? "Unknown"
|
||||
: lastSpoofedClientType.friendlyName;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
|
||||
@GuardedBy("itself")
|
||||
private static final Map<String, StreamingDataRequest> cache = Collections.synchronizedMap(
|
||||
new LinkedHashMap<>(100) {
|
||||
/**
|
||||
@ -82,12 +78,43 @@ public class StreamingDataRequest {
|
||||
}
|
||||
});
|
||||
|
||||
public static void fetchRequest(@NonNull String videoId, Map<String, String> fetchHeaders) {
|
||||
public static String getLastSpoofedClientName() {
|
||||
return lastSpoofedClientType == null
|
||||
? "Unknown"
|
||||
: lastSpoofedClientType.getFriendlyName();
|
||||
}
|
||||
|
||||
static {
|
||||
ClientType[] allClientTypes = ClientType.values();
|
||||
ClientType preferredClient = BaseSettings.SPOOF_STREAMING_DATA_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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private final String videoId;
|
||||
private final Future<ByteBuffer> future;
|
||||
|
||||
private StreamingDataRequest(String videoId, Map<String, String> playerHeaders) {
|
||||
Objects.requireNonNull(playerHeaders);
|
||||
this.videoId = videoId;
|
||||
this.future = Utils.submitOnBackgroundThread(() -> fetch(videoId, playerHeaders));
|
||||
}
|
||||
|
||||
public static void fetchRequest(String videoId, Map<String, String> fetchHeaders) {
|
||||
// Always fetch, even if there is an existing request for the same video.
|
||||
cache.put(videoId, new StreamingDataRequest(videoId, fetchHeaders));
|
||||
}
|
||||
|
||||
@Nullable
|
||||
public static StreamingDataRequest getRequestForVideoId(@Nullable String videoId) {
|
||||
public static StreamingDataRequest getRequestForVideoId(String videoId) {
|
||||
return cache.get(videoId);
|
||||
}
|
||||
|
||||
@ -95,42 +122,6 @@ public class StreamingDataRequest {
|
||||
Logger.printInfo(() -> toastMessage, ex);
|
||||
}
|
||||
|
||||
// Available only to logged in users.
|
||||
private static final String AUTHORIZATION_HEADER = "Authorization";
|
||||
|
||||
private static final String[] REQUEST_HEADER_KEYS = {
|
||||
AUTHORIZATION_HEADER,
|
||||
"X-GOOG-API-FORMAT-VERSION",
|
||||
"X-Goog-Visitor-Id"
|
||||
};
|
||||
|
||||
private static void writeInnerTubeBody(HttpURLConnection connection, ClientType clientType,
|
||||
String videoId, Map<String, String> playerHeaders) {
|
||||
try {
|
||||
connection.setConnectTimeout(HTTP_TIMEOUT_MILLISECONDS);
|
||||
connection.setReadTimeout(HTTP_TIMEOUT_MILLISECONDS);
|
||||
|
||||
if (playerHeaders != null) {
|
||||
for (String key : REQUEST_HEADER_KEYS) {
|
||||
if (!clientType.canLogin && key.equals(AUTHORIZATION_HEADER)) {
|
||||
continue;
|
||||
}
|
||||
String value = playerHeaders.get(key);
|
||||
if (value != null) {
|
||||
connection.setRequestProperty(key, value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
String innerTubeBody = PlayerRoutes.createInnertubeBody(clientType, videoId);
|
||||
byte[] requestBody = innerTubeBody.getBytes(StandardCharsets.UTF_8);
|
||||
connection.setFixedLengthStreamingMode(requestBody.length);
|
||||
connection.getOutputStream().write(requestBody);
|
||||
} catch (IOException ex) {
|
||||
handleConnectionError("Network error", ex);
|
||||
}
|
||||
}
|
||||
|
||||
@Nullable
|
||||
private static HttpURLConnection send(ClientType clientType, String videoId,
|
||||
Map<String, String> playerHeaders) {
|
||||
@ -139,19 +130,44 @@ public class StreamingDataRequest {
|
||||
Objects.requireNonNull(playerHeaders);
|
||||
|
||||
final long startTime = System.currentTimeMillis();
|
||||
String clientTypeName = clientType.name();
|
||||
Logger.printDebug(() -> "Fetching video streams for: " + videoId + " using client: " + clientType.name());
|
||||
Logger.printDebug(() -> "Fetching video streams for: " + videoId + " using client: " + clientType);
|
||||
|
||||
try {
|
||||
HttpURLConnection connection = PlayerRoutes.getPlayerResponseConnectionFromRoute(GET_STREAMING_DATA, clientType);
|
||||
writeInnerTubeBody(connection, clientType, videoId, playerHeaders);
|
||||
connection.setConnectTimeout(HTTP_TIMEOUT_MILLISECONDS);
|
||||
connection.setReadTimeout(HTTP_TIMEOUT_MILLISECONDS);
|
||||
|
||||
for (String key : REQUEST_HEADER_KEYS) {
|
||||
String value = playerHeaders.get(key);
|
||||
if (value != null) {
|
||||
if (key.equals(AUTHORIZATION_HEADER)) {
|
||||
if (!clientType.canLogin) {
|
||||
Logger.printDebug(() -> "Not including request header: " + key);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
connection.setRequestProperty(key, value);
|
||||
}
|
||||
}
|
||||
|
||||
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(),
|
||||
// This situation likely means the patches are outdated.
|
||||
// Use a toast message that suggests updating.
|
||||
handleConnectionError("Playback error (App is outdated?) " + clientType + ": "
|
||||
+ responseCode + " response: " + connection.getResponseMessage(),
|
||||
null);
|
||||
} catch (SocketTimeoutException ex) {
|
||||
handleConnectionError("Connection timeout", ex);
|
||||
} catch (IOException ex) {
|
||||
handleConnectionError("Network error", ex);
|
||||
} catch (Exception ex) {
|
||||
Logger.printException(() -> "send failed", ex);
|
||||
} finally {
|
||||
@ -161,43 +177,39 @@ public class StreamingDataRequest {
|
||||
return null;
|
||||
}
|
||||
|
||||
private static final ByteArrayFilterGroup liveStreams =
|
||||
new ByteArrayFilterGroup(
|
||||
Settings.SPOOF_STREAMING_DATA_IOS_SKIP_LIVESTREAM_PLAYBACK,
|
||||
"yt_live_broadcast",
|
||||
"yt_premiere_broadcast"
|
||||
);
|
||||
|
||||
private static ByteBuffer fetch(@NonNull String videoId, Map<String, String> playerHeaders) {
|
||||
private static ByteBuffer fetch(String videoId, Map<String, String> playerHeaders) {
|
||||
lastSpoofedClientType = null;
|
||||
|
||||
// Retry with different client if empty response body is received.
|
||||
for (ClientType clientType : CLIENT_ORDER_TO_USE) {
|
||||
HttpURLConnection connection = send(clientType, videoId, playerHeaders);
|
||||
if (connection != null) {
|
||||
try {
|
||||
// gzip encoding doesn't response with content length (-1),
|
||||
// but empty response body does.
|
||||
if (connection.getContentLength() == 0) {
|
||||
Logger.printDebug(() -> "Received empty response" + "\nClient: " + clientType + "\nVideo: " + videoId);
|
||||
} else {
|
||||
try (InputStream inputStream = new BufferedInputStream(connection.getInputStream());
|
||||
ByteArrayOutputStream baos = new ByteArrayOutputStream()) {
|
||||
|
||||
// gzip encoding doesn't response with content length (-1),
|
||||
// but empty response body does.
|
||||
if (connection == null || connection.getContentLength() == 0)
|
||||
continue;
|
||||
byte[] buffer = new byte[4096];
|
||||
int bytesRead;
|
||||
while ((bytesRead = inputStream.read(buffer)) >= 0) {
|
||||
baos.write(buffer, 0, bytesRead);
|
||||
}
|
||||
if (clientType == ClientType.IOS && liveStreams.check(buffer).isFiltered()) {
|
||||
Logger.printDebug(() -> "Ignore IOS spoofing as it is a livestream (video: " + videoId + ")");
|
||||
continue;
|
||||
}
|
||||
lastSpoofedClientType = clientType;
|
||||
|
||||
try (
|
||||
InputStream inputStream = new BufferedInputStream(connection.getInputStream());
|
||||
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);
|
||||
}
|
||||
if (clientType == ClientType.IOS && liveStreams.check(buffer).isFiltered()) {
|
||||
Logger.printDebug(() -> "Ignore IOS spoofing as it is a livestream (video: " + videoId + ")");
|
||||
continue;
|
||||
}
|
||||
lastSpoofedClientType = clientType;
|
||||
|
||||
return ByteBuffer.wrap(baos.toByteArray());
|
||||
} catch (IOException ex) {
|
||||
Logger.printException(() -> "Fetch failed while processing response data", ex);
|
||||
}
|
||||
}
|
||||
|
||||
@ -205,15 +217,6 @@ public class StreamingDataRequest {
|
||||
return null;
|
||||
}
|
||||
|
||||
private final String videoId;
|
||||
private final Future<ByteBuffer> future;
|
||||
|
||||
private StreamingDataRequest(String videoId, Map<String, String> playerHeaders) {
|
||||
Objects.requireNonNull(playerHeaders);
|
||||
this.videoId = videoId;
|
||||
this.future = Utils.submitOnBackgroundThread(() -> fetch(videoId, playerHeaders));
|
||||
}
|
||||
|
||||
public boolean fetchCompleted() {
|
||||
return future.isDone();
|
||||
}
|
||||
@ -239,4 +242,4 @@ public class StreamingDataRequest {
|
||||
public String toString() {
|
||||
return "StreamingDataRequest{" + "videoId='" + videoId + '\'' + '}';
|
||||
}
|
||||
}
|
||||
}
|
@ -11,9 +11,11 @@ import java.io.InputStreamReader;
|
||||
import java.net.HttpURLConnection;
|
||||
import java.net.URL;
|
||||
|
||||
import app.revanced.extension.shared.utils.PackageUtils;
|
||||
|
||||
@SuppressWarnings("unused")
|
||||
public class Requester {
|
||||
public Requester() {
|
||||
private Requester() {
|
||||
}
|
||||
|
||||
public static HttpURLConnection getConnectionFromRoute(String apiUrl, Route route, String... params) throws IOException {
|
||||
@ -27,7 +29,9 @@ public class Requester {
|
||||
// The calling code must set a length if using a request body.
|
||||
connection.setFixedLengthStreamingMode(0);
|
||||
connection.setRequestMethod(route.getMethod().name());
|
||||
connection.setRequestProperty("User-Agent", System.getProperty("http.agent") + ";");
|
||||
String agentString = System.getProperty("http.agent")
|
||||
+ "; RVX/" + PackageUtils.getAppVersionName();
|
||||
connection.setRequestProperty("User-Agent", agentString);
|
||||
|
||||
return connection;
|
||||
}
|
||||
|
@ -2,10 +2,10 @@ package app.revanced.extension.shared.requests;
|
||||
|
||||
public class Route {
|
||||
private final String route;
|
||||
private final Route.Method method;
|
||||
private final Method method;
|
||||
private final int paramCount;
|
||||
|
||||
public Route(Route.Method method, String route) {
|
||||
public Route(Method method, String route) {
|
||||
this.method = method;
|
||||
this.route = route;
|
||||
this.paramCount = countMatches(route, '{');
|
||||
@ -14,11 +14,11 @@ public class Route {
|
||||
throw new IllegalArgumentException("Not enough parameters");
|
||||
}
|
||||
|
||||
public Route.Method getMethod() {
|
||||
public Method getMethod() {
|
||||
return method;
|
||||
}
|
||||
|
||||
public Route.CompiledRoute compile(String... params) {
|
||||
public CompiledRoute compile(String... params) {
|
||||
if (params.length != paramCount)
|
||||
throw new IllegalArgumentException("Error compiling route [" + route + "], incorrect amount of parameters provided. " +
|
||||
"Expected: " + paramCount + ", provided: " + params.length);
|
||||
@ -29,21 +29,7 @@ public class Route {
|
||||
int paramEnd = compiledRoute.indexOf("}");
|
||||
compiledRoute.replace(paramStart, paramEnd + 1, params[i]);
|
||||
}
|
||||
return new Route.CompiledRoute(this, compiledRoute.toString());
|
||||
}
|
||||
|
||||
private int countMatches(CharSequence seq, char c) {
|
||||
int count = 0;
|
||||
for (int i = 0; i < seq.length(); i++) {
|
||||
if (seq.charAt(i) == c)
|
||||
count++;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
public enum Method {
|
||||
GET,
|
||||
POST
|
||||
return new CompiledRoute(this, compiledRoute.toString());
|
||||
}
|
||||
|
||||
public static class CompiledRoute {
|
||||
@ -59,8 +45,22 @@ public class Route {
|
||||
return compiledRoute;
|
||||
}
|
||||
|
||||
public Route.Method getMethod() {
|
||||
public Method getMethod() {
|
||||
return baseRoute.method;
|
||||
}
|
||||
}
|
||||
|
||||
private int countMatches(CharSequence seq, char c) {
|
||||
int count = 0;
|
||||
for (int i = 0; i < seq.length(); i++) {
|
||||
if (seq.charAt(i) == c)
|
||||
count++;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
public enum Method {
|
||||
GET,
|
||||
POST
|
||||
}
|
||||
}
|
@ -4,8 +4,10 @@ import static java.lang.Boolean.FALSE;
|
||||
import static java.lang.Boolean.TRUE;
|
||||
|
||||
import static app.revanced.extension.shared.patches.PatchStatus.HideFullscreenAdsDefaultBoolean;
|
||||
import static app.revanced.extension.shared.patches.PatchStatus.SpoofStreamingDataDefaultClient;
|
||||
|
||||
import app.revanced.extension.shared.patches.ReturnYouTubeUsernamePatch.DisplayFormat;
|
||||
import app.revanced.extension.shared.patches.client.AppClient.ClientType;
|
||||
|
||||
/**
|
||||
* Settings shared across multiple apps.
|
||||
@ -35,6 +37,11 @@ public class BaseSettings {
|
||||
public static final EnumSetting<DisplayFormat> RETURN_YOUTUBE_USERNAME_DISPLAY_FORMAT = new EnumSetting<>("revanced_return_youtube_username_display_format", DisplayFormat.USERNAME_ONLY, true);
|
||||
public static final StringSetting RETURN_YOUTUBE_USERNAME_YOUTUBE_DATA_API_V3_DEVELOPER_KEY = new StringSetting("revanced_return_youtube_username_youtube_data_api_v3_developer_key", "", true, false);
|
||||
|
||||
public static final BooleanSetting SPOOF_STREAMING_DATA = new BooleanSetting("revanced_spoof_streaming_data", TRUE, true, "revanced_spoof_streaming_data_user_dialog_message");
|
||||
public static final EnumSetting<ClientType> SPOOF_STREAMING_DATA_TYPE = new EnumSetting<>("revanced_spoof_streaming_data_type", SpoofStreamingDataDefaultClient(), true);
|
||||
public static final BooleanSetting SPOOF_STREAMING_DATA_IOS_SKIP_LIVESTREAM_PLAYBACK = new BooleanSetting("revanced_spoof_streaming_data_ios_skip_livestream_playback", TRUE, true);
|
||||
public static final BooleanSetting SPOOF_STREAMING_DATA_STATS_FOR_NERDS = new BooleanSetting("revanced_spoof_streaming_data_stats_for_nerds", TRUE);
|
||||
|
||||
/**
|
||||
* @noinspection DeprecatedIsStillUsed
|
||||
*/
|
||||
|
@ -9,8 +9,8 @@ import org.apache.commons.lang3.BooleanUtils;
|
||||
|
||||
import app.revanced.extension.shared.utils.Logger;
|
||||
import app.revanced.extension.shared.utils.Utils;
|
||||
import app.revanced.extension.youtube.patches.misc.requests.PlaylistRequest;
|
||||
import app.revanced.extension.youtube.patches.utils.PatchStatus;
|
||||
import app.revanced.extension.youtube.patches.video.requests.PlaylistRequest;
|
||||
import app.revanced.extension.youtube.settings.Settings;
|
||||
import app.revanced.extension.youtube.shared.VideoInformation;
|
||||
import app.revanced.extension.youtube.whitelist.Whitelist;
|
||||
|
@ -1,6 +1,6 @@
|
||||
package app.revanced.extension.youtube.patches.misc.requests;
|
||||
package app.revanced.extension.youtube.patches.video.requests;
|
||||
|
||||
import static app.revanced.extension.youtube.patches.misc.requests.PlayerRoutes.GET_PLAYLIST_PAGE;
|
||||
import static app.revanced.extension.shared.patches.spoof.requests.PlayerRoutes.GET_PLAYLIST_PAGE;
|
||||
|
||||
import android.annotation.SuppressLint;
|
||||
|
||||
@ -16,6 +16,7 @@ import java.net.HttpURLConnection;
|
||||
import java.net.SocketTimeoutException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.HashMap;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.concurrent.ExecutionException;
|
||||
@ -23,10 +24,11 @@ import java.util.concurrent.Future;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.TimeoutException;
|
||||
|
||||
import app.revanced.extension.shared.patches.client.AppClient.ClientType;
|
||||
import app.revanced.extension.shared.patches.spoof.requests.PlayerRoutes;
|
||||
import app.revanced.extension.shared.requests.Requester;
|
||||
import app.revanced.extension.shared.utils.Logger;
|
||||
import app.revanced.extension.shared.utils.Utils;
|
||||
import app.revanced.extension.youtube.patches.misc.client.AppClient.ClientType;
|
||||
import app.revanced.extension.youtube.shared.VideoInformation;
|
||||
|
||||
public class PlaylistRequest {
|
||||
@ -82,8 +84,9 @@ public class PlaylistRequest {
|
||||
try {
|
||||
HttpURLConnection connection = PlayerRoutes.getPlayerResponseConnectionFromRoute(GET_PLAYLIST_PAGE, clientType);
|
||||
|
||||
String innerTubeBody = PlayerRoutes.createInnertubeBody(
|
||||
clientType,
|
||||
String innerTubeBody = String.format(
|
||||
Locale.ENGLISH,
|
||||
PlayerRoutes.createInnertubeBody(clientType, true),
|
||||
videoId,
|
||||
"RD" + videoId
|
||||
);
|
@ -37,9 +37,7 @@ import app.revanced.extension.youtube.patches.general.ChangeStartPagePatch.Start
|
||||
import app.revanced.extension.youtube.patches.general.LayoutSwitchPatch.FormFactor;
|
||||
import app.revanced.extension.youtube.patches.general.MiniplayerPatch;
|
||||
import app.revanced.extension.youtube.patches.general.YouTubeMusicActionsPatch;
|
||||
import app.revanced.extension.youtube.patches.misc.SpoofStreamingDataPatch;
|
||||
import app.revanced.extension.youtube.patches.misc.WatchHistoryPatch.WatchHistoryType;
|
||||
import app.revanced.extension.youtube.patches.misc.client.AppClient.ClientType;
|
||||
import app.revanced.extension.youtube.patches.shorts.AnimationFeedbackPatch.AnimationType;
|
||||
import app.revanced.extension.youtube.patches.utils.PatchStatus;
|
||||
import app.revanced.extension.youtube.shared.PlaylistIdPrefix;
|
||||
@ -563,11 +561,6 @@ public class Settings extends BaseSettings {
|
||||
public static final EnumSetting<WatchHistoryType> WATCH_HISTORY_TYPE = new EnumSetting<>("revanced_watch_history_type", WatchHistoryType.REPLACE);
|
||||
|
||||
// PreferenceScreen: Miscellaneous - Spoof streaming data
|
||||
// The order of the settings should not be changed otherwise the app may crash
|
||||
public static final BooleanSetting SPOOF_STREAMING_DATA = new BooleanSetting("revanced_spoof_streaming_data", TRUE, true, "revanced_spoof_streaming_data_user_dialog_message");
|
||||
public static final BooleanSetting SPOOF_STREAMING_DATA_IOS_SKIP_LIVESTREAM_PLAYBACK = new BooleanSetting("revanced_spoof_streaming_data_ios_skip_livestream_playback", TRUE, true, new SpoofStreamingDataPatch.iOSAvailability());
|
||||
public static final EnumSetting<ClientType> SPOOF_STREAMING_DATA_TYPE = new EnumSetting<>("revanced_spoof_streaming_data_type", ClientType.IOS, true, parent(SPOOF_STREAMING_DATA));
|
||||
public static final BooleanSetting SPOOF_STREAMING_DATA_STATS_FOR_NERDS = new BooleanSetting("revanced_spoof_streaming_data_stats_for_nerds", TRUE, parent(SPOOF_STREAMING_DATA));
|
||||
|
||||
// PreferenceScreen: Return YouTube Dislike
|
||||
public static final BooleanSetting RYD_ENABLED = new BooleanSetting("ryd_enabled", TRUE);
|
||||
|
@ -8,9 +8,9 @@ import android.preference.Preference;
|
||||
import android.preference.PreferenceManager;
|
||||
import android.util.AttributeSet;
|
||||
|
||||
import app.revanced.extension.shared.patches.client.AppClient.ClientType;
|
||||
import app.revanced.extension.shared.settings.Setting;
|
||||
import app.revanced.extension.shared.utils.Utils;
|
||||
import app.revanced.extension.youtube.patches.misc.client.AppClient.ClientType;
|
||||
import app.revanced.extension.youtube.settings.Settings;
|
||||
|
||||
@SuppressWarnings({"deprecation", "unused"})
|
||||
@ -74,5 +74,6 @@ public class SpoofStreamingDataSideEffectsPreference extends Preference {
|
||||
|
||||
setSummary(str(summaryTextKey));
|
||||
setEnabled(Settings.SPOOF_STREAMING_DATA.get());
|
||||
setSelectable(false);
|
||||
}
|
||||
}
|
@ -46,6 +46,7 @@ private const val CLIENT_INFO_CLASS_DESCRIPTOR =
|
||||
val spoofClientPatch = bytecodePatch(
|
||||
SPOOF_CLIENT.title,
|
||||
SPOOF_CLIENT.summary,
|
||||
false,
|
||||
) {
|
||||
dependsOn(settingsPatch)
|
||||
|
||||
|
@ -0,0 +1,58 @@
|
||||
package app.revanced.patches.music.utils.fix.streamingdata
|
||||
|
||||
import app.revanced.patcher.extensions.InstructionExtensions.getInstruction
|
||||
import app.revanced.patcher.extensions.InstructionExtensions.replaceInstruction
|
||||
import app.revanced.patches.music.utils.compatibility.Constants.COMPATIBLE_PACKAGE
|
||||
import app.revanced.patches.music.utils.compatibility.Constants.YOUTUBE_MUSIC_PACKAGE_NAME
|
||||
import app.revanced.patches.music.utils.patch.PatchList.SPOOF_STREAMING_DATA
|
||||
import app.revanced.patches.music.utils.settings.CategoryType
|
||||
import app.revanced.patches.music.utils.settings.ResourceUtils.updatePatchStatus
|
||||
import app.revanced.patches.music.utils.settings.addSwitchPreference
|
||||
import app.revanced.patches.music.utils.settings.settingsPatch
|
||||
import app.revanced.patches.shared.extension.Constants.PATCHES_PATH
|
||||
import app.revanced.patches.shared.spoof.streamingdata.baseSpoofStreamingDataPatch
|
||||
import app.revanced.patches.shared.spoof.useragent.baseSpoofUserAgentPatch
|
||||
import app.revanced.util.findMethodOrThrow
|
||||
import com.android.tools.smali.dexlib2.iface.instruction.OneRegisterInstruction
|
||||
import com.android.tools.smali.dexlib2.iface.instruction.ReferenceInstruction
|
||||
import com.android.tools.smali.dexlib2.iface.reference.FieldReference
|
||||
|
||||
private const val DEFAULT_CLIENT_TYPE = "ANDROID_VR"
|
||||
|
||||
val spoofStreamingDataPatch = baseSpoofStreamingDataPatch(
|
||||
{
|
||||
compatibleWith(COMPATIBLE_PACKAGE)
|
||||
|
||||
dependsOn(
|
||||
baseSpoofUserAgentPatch(YOUTUBE_MUSIC_PACKAGE_NAME),
|
||||
settingsPatch,
|
||||
)
|
||||
},
|
||||
{
|
||||
findMethodOrThrow("$PATCHES_PATH/PatchStatus;") {
|
||||
name == "SpoofStreamingDataDefaultClient"
|
||||
}.apply {
|
||||
val register = getInstruction<OneRegisterInstruction>(0).registerA
|
||||
val type = (getInstruction<ReferenceInstruction>(0).reference as FieldReference).type
|
||||
replaceInstruction(
|
||||
0,
|
||||
"sget-object v$register, $type->$DEFAULT_CLIENT_TYPE:$type"
|
||||
)
|
||||
}
|
||||
|
||||
addSwitchPreference(
|
||||
CategoryType.MISC,
|
||||
"revanced_spoof_streaming_data",
|
||||
"true"
|
||||
)
|
||||
addSwitchPreference(
|
||||
CategoryType.MISC,
|
||||
"revanced_spoof_streaming_data_stats_for_nerds",
|
||||
"true",
|
||||
"revanced_spoof_streaming_data"
|
||||
)
|
||||
|
||||
updatePatchStatus(SPOOF_STREAMING_DATA)
|
||||
|
||||
}
|
||||
)
|
@ -143,7 +143,11 @@ internal enum class PatchList(
|
||||
),
|
||||
SPOOF_CLIENT(
|
||||
"Spoof client",
|
||||
"Adds options to spoof the client to allow track playback."
|
||||
"Adds options to spoof the client to allow playback."
|
||||
),
|
||||
SPOOF_STREAMING_DATA(
|
||||
"Spoof streaming data",
|
||||
"Adds options to spoof the streaming data to allow playback."
|
||||
),
|
||||
TRANSLATIONS_FOR_YOUTUBE_MUSIC(
|
||||
"Translations for YouTube Music",
|
||||
|
@ -6,6 +6,7 @@ internal object Constants {
|
||||
const val PATCHES_PATH = "$EXTENSION_PATH/patches"
|
||||
const val COMPONENTS_PATH = "$PATCHES_PATH/components"
|
||||
const val SPANS_PATH = "$PATCHES_PATH/spans"
|
||||
const val SPOOF_PATH = "$PATCHES_PATH/spoof"
|
||||
|
||||
const val EXTENSION_UTILS_PATH = "$EXTENSION_PATH/utils"
|
||||
const val EXTENSION_SETTING_CLASS_DESCRIPTOR = "$EXTENSION_PATH/settings/Setting;"
|
||||
|
@ -0,0 +1,215 @@
|
||||
package app.revanced.patches.shared.spoof.streamingdata
|
||||
|
||||
import app.revanced.patcher.extensions.InstructionExtensions.addInstructions
|
||||
import app.revanced.patcher.extensions.InstructionExtensions.addInstructionsWithLabels
|
||||
import app.revanced.patcher.extensions.InstructionExtensions.getInstruction
|
||||
import app.revanced.patcher.extensions.InstructionExtensions.instructions
|
||||
import app.revanced.patcher.patch.BytecodePatchBuilder
|
||||
import app.revanced.patcher.patch.BytecodePatchContext
|
||||
import app.revanced.patcher.patch.PatchException
|
||||
import app.revanced.patcher.patch.bytecodePatch
|
||||
import app.revanced.patcher.util.smali.ExternalLabel
|
||||
import app.revanced.patches.shared.extension.Constants.SPOOF_PATH
|
||||
import app.revanced.util.findInstructionIndicesReversedOrThrow
|
||||
import app.revanced.util.fingerprint.definingClassOrThrow
|
||||
import app.revanced.util.fingerprint.matchOrThrow
|
||||
import app.revanced.util.fingerprint.methodOrThrow
|
||||
import app.revanced.util.getReference
|
||||
import com.android.tools.smali.dexlib2.Opcode
|
||||
import com.android.tools.smali.dexlib2.iface.instruction.FiveRegisterInstruction
|
||||
import com.android.tools.smali.dexlib2.iface.instruction.OneRegisterInstruction
|
||||
import com.android.tools.smali.dexlib2.iface.instruction.TwoRegisterInstruction
|
||||
import com.android.tools.smali.dexlib2.iface.reference.FieldReference
|
||||
|
||||
const val EXTENSION_CLASS_DESCRIPTOR =
|
||||
"$SPOOF_PATH/SpoofStreamingDataPatch;"
|
||||
|
||||
fun baseSpoofStreamingDataPatch(
|
||||
block: BytecodePatchBuilder.() -> Unit = {},
|
||||
executeBlock: BytecodePatchContext.() -> Unit = {},
|
||||
) = bytecodePatch(
|
||||
name = "Spoof streaming data",
|
||||
description = "Adds options to spoof the streaming data to allow playback."
|
||||
) {
|
||||
block()
|
||||
|
||||
execute {
|
||||
// region Block /initplayback requests to fall back to /get_watch requests.
|
||||
|
||||
buildInitPlaybackRequestFingerprint.matchOrThrow().let {
|
||||
it.method.apply {
|
||||
val moveUriStringIndex = it.patternMatch!!.startIndex
|
||||
val targetRegister =
|
||||
getInstruction<OneRegisterInstruction>(moveUriStringIndex).registerA
|
||||
|
||||
addInstructions(
|
||||
moveUriStringIndex + 1,
|
||||
"""
|
||||
invoke-static { v$targetRegister }, $EXTENSION_CLASS_DESCRIPTOR->blockInitPlaybackRequest(Ljava/lang/String;)Ljava/lang/String;
|
||||
move-result-object v$targetRegister
|
||||
""",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// endregion
|
||||
|
||||
// region Block /get_watch requests to fall back to /player requests.
|
||||
|
||||
buildPlayerRequestURIFingerprint.methodOrThrow().apply {
|
||||
val invokeToStringIndex = indexOfToStringInstruction(this)
|
||||
val uriRegister =
|
||||
getInstruction<FiveRegisterInstruction>(invokeToStringIndex).registerC
|
||||
|
||||
addInstructions(
|
||||
invokeToStringIndex,
|
||||
"""
|
||||
invoke-static { v$uriRegister }, $EXTENSION_CLASS_DESCRIPTOR->blockGetWatchRequest(Landroid/net/Uri;)Landroid/net/Uri;
|
||||
move-result-object v$uriRegister
|
||||
""",
|
||||
)
|
||||
}
|
||||
|
||||
// endregion
|
||||
|
||||
// region Get replacement streams at player requests.
|
||||
|
||||
buildRequestFingerprint.methodOrThrow().apply {
|
||||
val newRequestBuilderIndex = indexOfNewUrlRequestBuilderInstruction(this)
|
||||
val urlRegister =
|
||||
getInstruction<FiveRegisterInstruction>(newRequestBuilderIndex).registerD
|
||||
|
||||
val entrySetIndex = indexOfEntrySetInstruction(this)
|
||||
val mapRegister = if (entrySetIndex < 0)
|
||||
urlRegister + 1
|
||||
else
|
||||
getInstruction<FiveRegisterInstruction>(entrySetIndex).registerC
|
||||
|
||||
var smaliInstructions =
|
||||
"invoke-static { v$urlRegister, v$mapRegister }, " +
|
||||
"$EXTENSION_CLASS_DESCRIPTOR->" +
|
||||
"fetchStreams(Ljava/lang/String;Ljava/util/Map;)V"
|
||||
|
||||
if (entrySetIndex < 0) smaliInstructions = """
|
||||
move-object/from16 v$mapRegister, p1
|
||||
|
||||
""" + smaliInstructions
|
||||
|
||||
// Copy request headers for streaming data fetch.
|
||||
addInstructions(newRequestBuilderIndex + 2, smaliInstructions)
|
||||
}
|
||||
|
||||
// endregion
|
||||
|
||||
// region Replace the streaming data.
|
||||
|
||||
createStreamingDataFingerprint.matchOrThrow(createStreamingDataParentFingerprint).let { result ->
|
||||
result.method.apply {
|
||||
val setStreamingDataIndex = result.patternMatch!!.startIndex
|
||||
val setStreamingDataField =
|
||||
getInstruction(setStreamingDataIndex).getReference<FieldReference>().toString()
|
||||
|
||||
val playerProtoClass =
|
||||
getInstruction(setStreamingDataIndex + 1).getReference<FieldReference>()!!.definingClass
|
||||
val protobufClass =
|
||||
protobufClassParseByteBufferFingerprint.definingClassOrThrow()
|
||||
|
||||
val getStreamingDataField = instructions.find { instruction ->
|
||||
instruction.opcode == Opcode.IGET_OBJECT &&
|
||||
instruction.getReference<FieldReference>()?.definingClass == playerProtoClass
|
||||
}?.getReference<FieldReference>()
|
||||
?: throw PatchException("Could not find getStreamingDataField")
|
||||
|
||||
val videoDetailsIndex = result.patternMatch!!.endIndex
|
||||
val videoDetailsClass =
|
||||
getInstruction(videoDetailsIndex).getReference<FieldReference>()!!.type
|
||||
|
||||
val insertIndex = videoDetailsIndex + 1
|
||||
val videoDetailsRegister =
|
||||
getInstruction<TwoRegisterInstruction>(videoDetailsIndex).registerA
|
||||
|
||||
val overrideRegister = getInstruction<TwoRegisterInstruction>(insertIndex).registerA
|
||||
val freeRegister = implementation!!.registerCount - parameters.size - 2
|
||||
|
||||
addInstructionsWithLabels(
|
||||
insertIndex,
|
||||
"""
|
||||
invoke-static { }, $EXTENSION_CLASS_DESCRIPTOR->isSpoofingEnabled()Z
|
||||
move-result v$freeRegister
|
||||
if-eqz v$freeRegister, :disabled
|
||||
|
||||
# Get video id.
|
||||
# From YouTube 17.34.36 to YouTube 19.16.39, the field names and field types are the same.
|
||||
iget-object v$freeRegister, v$videoDetailsRegister, $videoDetailsClass->c:Ljava/lang/String;
|
||||
if-eqz v$freeRegister, :disabled
|
||||
|
||||
# Get streaming data.
|
||||
invoke-static { v$freeRegister }, $EXTENSION_CLASS_DESCRIPTOR->getStreamingData(Ljava/lang/String;)Ljava/nio/ByteBuffer;
|
||||
move-result-object v$freeRegister
|
||||
if-eqz v$freeRegister, :disabled
|
||||
|
||||
# Parse streaming data.
|
||||
sget-object v$overrideRegister, $playerProtoClass->a:$playerProtoClass
|
||||
invoke-static { v$overrideRegister, v$freeRegister }, $protobufClass->parseFrom(${protobufClass}Ljava/nio/ByteBuffer;)$protobufClass
|
||||
move-result-object v$freeRegister
|
||||
check-cast v$freeRegister, $playerProtoClass
|
||||
|
||||
# Set streaming data.
|
||||
iget-object v$freeRegister, v$freeRegister, $getStreamingDataField
|
||||
if-eqz v$freeRegister, :disabled
|
||||
iput-object v$freeRegister, p0, $setStreamingDataField
|
||||
|
||||
""",
|
||||
ExternalLabel("disabled", getInstruction(insertIndex))
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// endregion
|
||||
|
||||
// region Remove /videoplayback request body to fix playback.
|
||||
// This is needed when using iOS client as streaming data source.
|
||||
|
||||
buildMediaDataSourceFingerprint.methodOrThrow().apply {
|
||||
val targetIndex = instructions.lastIndex
|
||||
|
||||
addInstructions(
|
||||
targetIndex,
|
||||
"""
|
||||
# Field a: Stream uri.
|
||||
# Field c: Http method.
|
||||
# Field d: Post data.
|
||||
move-object/from16 v0, p0
|
||||
iget-object v1, v0, $definingClass->a:Landroid/net/Uri;
|
||||
iget v2, v0, $definingClass->c:I
|
||||
iget-object v3, v0, $definingClass->d:[B
|
||||
invoke-static { v1, v2, v3 }, $EXTENSION_CLASS_DESCRIPTOR->removeVideoPlaybackPostBody(Landroid/net/Uri;I[B)[B
|
||||
move-result-object v1
|
||||
iput-object v1, v0, $definingClass->d:[B
|
||||
""",
|
||||
)
|
||||
}
|
||||
|
||||
// endregion
|
||||
|
||||
// region Append spoof info.
|
||||
|
||||
nerdsStatsVideoFormatBuilderFingerprint.methodOrThrow().apply {
|
||||
findInstructionIndicesReversedOrThrow(Opcode.RETURN_OBJECT).forEach { index ->
|
||||
val register = getInstruction<OneRegisterInstruction>(index).registerA
|
||||
|
||||
addInstructions(
|
||||
index, """
|
||||
invoke-static {v$register}, $EXTENSION_CLASS_DESCRIPTOR->appendSpoofedClient(Ljava/lang/String;)Ljava/lang/String;
|
||||
move-result-object v$register
|
||||
"""
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// endregion
|
||||
|
||||
executeBlock()
|
||||
|
||||
}
|
||||
}
|
@ -1,4 +1,4 @@
|
||||
package app.revanced.patches.youtube.utils.fix.streamingdata
|
||||
package app.revanced.patches.shared.spoof.streamingdata
|
||||
|
||||
import app.revanced.util.fingerprint.legacyFingerprint
|
||||
import app.revanced.util.getReference
|
||||
@ -10,38 +10,6 @@ import com.android.tools.smali.dexlib2.iface.Method
|
||||
import com.android.tools.smali.dexlib2.iface.reference.FieldReference
|
||||
import com.android.tools.smali.dexlib2.iface.reference.MethodReference
|
||||
|
||||
internal val buildBrowseRequestFingerprint = legacyFingerprint(
|
||||
name = "buildBrowseRequestFingerprint",
|
||||
customFingerprint = { method, _ ->
|
||||
method.implementation != null &&
|
||||
indexOfRequestFinishedListenerInstruction(method) >= 0 &&
|
||||
!method.definingClass.startsWith("Lorg/") &&
|
||||
indexOfNewUrlRequestBuilderInstruction(method) >= 0 &&
|
||||
// YouTube 17.34.36 ~ YouTube 18.35.36
|
||||
(indexOfEntrySetInstruction(method) >= 0 ||
|
||||
// YouTube 18.36.39 ~
|
||||
method.parameters[1].type == "Ljava/util/Map;")
|
||||
}
|
||||
)
|
||||
|
||||
internal fun indexOfRequestFinishedListenerInstruction(method: Method) =
|
||||
method.indexOfFirstInstruction {
|
||||
opcode == Opcode.INVOKE_VIRTUAL &&
|
||||
getReference<MethodReference>()?.name == "setRequestFinishedListener"
|
||||
}
|
||||
|
||||
internal fun indexOfNewUrlRequestBuilderInstruction(method: Method) =
|
||||
method.indexOfFirstInstruction {
|
||||
opcode == Opcode.INVOKE_VIRTUAL &&
|
||||
getReference<MethodReference>().toString() == "Lorg/chromium/net/CronetEngine;->newUrlRequestBuilder(Ljava/lang/String;Lorg/chromium/net/UrlRequest${'$'}Callback;Ljava/util/concurrent/Executor;)Lorg/chromium/net/UrlRequest${'$'}Builder;"
|
||||
}
|
||||
|
||||
internal fun indexOfEntrySetInstruction(method: Method) =
|
||||
method.indexOfFirstInstruction {
|
||||
opcode == Opcode.INVOKE_INTERFACE &&
|
||||
getReference<MethodReference>().toString() == "Ljava/util/Map;->entrySet()Ljava/util/Set;"
|
||||
}
|
||||
|
||||
internal val buildInitPlaybackRequestFingerprint = legacyFingerprint(
|
||||
name = "buildInitPlaybackRequestFingerprint",
|
||||
returnType = "Lorg/chromium/net/UrlRequest\$Builder;",
|
||||
@ -91,6 +59,38 @@ internal fun indexOfToStringInstruction(method: Method) =
|
||||
getReference<MethodReference>().toString() == "Landroid/net/Uri;->toString()Ljava/lang/String;"
|
||||
}
|
||||
|
||||
internal val buildRequestFingerprint = legacyFingerprint(
|
||||
name = "buildRequestFingerprint",
|
||||
customFingerprint = { method, _ ->
|
||||
method.implementation != null &&
|
||||
indexOfRequestFinishedListenerInstruction(method) >= 0 &&
|
||||
!method.definingClass.startsWith("Lorg/") &&
|
||||
indexOfNewUrlRequestBuilderInstruction(method) >= 0 &&
|
||||
// Earlier targets
|
||||
(indexOfEntrySetInstruction(method) >= 0 ||
|
||||
// Later targets
|
||||
method.parameters[1].type == "Ljava/util/Map;")
|
||||
}
|
||||
)
|
||||
|
||||
internal fun indexOfRequestFinishedListenerInstruction(method: Method) =
|
||||
method.indexOfFirstInstruction {
|
||||
opcode == Opcode.INVOKE_VIRTUAL &&
|
||||
getReference<MethodReference>()?.name == "setRequestFinishedListener"
|
||||
}
|
||||
|
||||
internal fun indexOfNewUrlRequestBuilderInstruction(method: Method) =
|
||||
method.indexOfFirstInstruction {
|
||||
opcode == Opcode.INVOKE_VIRTUAL &&
|
||||
getReference<MethodReference>().toString() == "Lorg/chromium/net/CronetEngine;->newUrlRequestBuilder(Ljava/lang/String;Lorg/chromium/net/UrlRequest${'$'}Callback;Ljava/util/concurrent/Executor;)Lorg/chromium/net/UrlRequest${'$'}Builder;"
|
||||
}
|
||||
|
||||
internal fun indexOfEntrySetInstruction(method: Method) =
|
||||
method.indexOfFirstInstruction {
|
||||
opcode == Opcode.INVOKE_INTERFACE &&
|
||||
getReference<MethodReference>().toString() == "Ljava/util/Map;->entrySet()Ljava/util/Set;"
|
||||
}
|
||||
|
||||
internal val createStreamingDataFingerprint = legacyFingerprint(
|
||||
name = "createStreamingDataFingerprint",
|
||||
accessFlags = AccessFlags.PUBLIC or AccessFlags.CONSTRUCTOR,
|
||||
@ -103,19 +103,21 @@ internal val createStreamingDataFingerprint = legacyFingerprint(
|
||||
Opcode.SGET_OBJECT,
|
||||
Opcode.IPUT_OBJECT
|
||||
),
|
||||
customFingerprint = { method, _ ->
|
||||
method.indexOfFirstInstruction {
|
||||
opcode == Opcode.SGET_OBJECT &&
|
||||
getReference<FieldReference>()?.name == "playerThreedRenderer"
|
||||
} >= 0
|
||||
},
|
||||
)
|
||||
|
||||
internal val createStreamingDataParentFingerprint = legacyFingerprint(
|
||||
name = "createStreamingDataParentFingerprint",
|
||||
accessFlags = AccessFlags.PUBLIC or AccessFlags.FINAL,
|
||||
returnType = "L",
|
||||
parameters = emptyList(),
|
||||
strings = listOf("Invalid playback type; streaming data is not playable"),
|
||||
)
|
||||
|
||||
internal val nerdsStatsVideoFormatBuilderFingerprint = legacyFingerprint(
|
||||
name = "nerdsStatsVideoFormatBuilderFingerprint",
|
||||
returnType = "Ljava/lang/String;",
|
||||
accessFlags = AccessFlags.PUBLIC or AccessFlags.STATIC,
|
||||
parameters = listOf("Lcom/google/android/libraries/youtube/innertube/model/media/FormatStreamModel;"),
|
||||
parameters = listOf("L"),
|
||||
strings = listOf("codecs=\""),
|
||||
)
|
||||
|
@ -1,222 +1,23 @@
|
||||
package app.revanced.patches.youtube.utils.fix.streamingdata
|
||||
|
||||
import app.revanced.patcher.extensions.InstructionExtensions.addInstructions
|
||||
import app.revanced.patcher.extensions.InstructionExtensions.addInstructionsWithLabels
|
||||
import app.revanced.patcher.extensions.InstructionExtensions.getInstruction
|
||||
import app.revanced.patcher.extensions.InstructionExtensions.instructions
|
||||
import app.revanced.patcher.patch.PatchException
|
||||
import app.revanced.patcher.patch.bytecodePatch
|
||||
import app.revanced.patcher.util.smali.ExternalLabel
|
||||
import app.revanced.patches.shared.spoof.streamingdata.baseSpoofStreamingDataPatch
|
||||
import app.revanced.patches.shared.spoof.useragent.baseSpoofUserAgentPatch
|
||||
import app.revanced.patches.youtube.utils.compatibility.Constants.COMPATIBLE_PACKAGE
|
||||
import app.revanced.patches.youtube.utils.extension.Constants.MISC_PATH
|
||||
import app.revanced.patches.youtube.utils.compatibility.Constants.YOUTUBE_PACKAGE_NAME
|
||||
import app.revanced.patches.youtube.utils.patch.PatchList.SPOOF_STREAMING_DATA
|
||||
import app.revanced.patches.youtube.utils.settings.ResourceUtils.addPreference
|
||||
import app.revanced.patches.youtube.utils.settings.settingsPatch
|
||||
import app.revanced.util.findInstructionIndicesReversedOrThrow
|
||||
import app.revanced.util.fingerprint.definingClassOrThrow
|
||||
import app.revanced.util.fingerprint.matchOrThrow
|
||||
import app.revanced.util.fingerprint.methodOrThrow
|
||||
import app.revanced.util.getReference
|
||||
import com.android.tools.smali.dexlib2.Opcode
|
||||
import com.android.tools.smali.dexlib2.iface.instruction.FiveRegisterInstruction
|
||||
import com.android.tools.smali.dexlib2.iface.instruction.OneRegisterInstruction
|
||||
import com.android.tools.smali.dexlib2.iface.instruction.TwoRegisterInstruction
|
||||
import com.android.tools.smali.dexlib2.iface.reference.FieldReference
|
||||
|
||||
private const val EXTENSION_CLASS_DESCRIPTOR =
|
||||
"$MISC_PATH/SpoofStreamingDataPatch;"
|
||||
|
||||
val spoofStreamingDataPatch = bytecodePatch(
|
||||
SPOOF_STREAMING_DATA.title,
|
||||
SPOOF_STREAMING_DATA.summary,
|
||||
) {
|
||||
compatibleWith(COMPATIBLE_PACKAGE)
|
||||
|
||||
dependsOn(
|
||||
baseSpoofUserAgentPatch("com.google.android.youtube"),
|
||||
settingsPatch
|
||||
)
|
||||
|
||||
execute {
|
||||
// region Block /get_watch requests to fall back to /player requests.
|
||||
|
||||
buildPlayerRequestURIFingerprint.methodOrThrow().apply {
|
||||
val invokeToStringIndex = indexOfToStringInstruction(this)
|
||||
val uriRegister =
|
||||
getInstruction<FiveRegisterInstruction>(invokeToStringIndex).registerC
|
||||
|
||||
addInstructions(
|
||||
invokeToStringIndex,
|
||||
"""
|
||||
invoke-static { v$uriRegister }, $EXTENSION_CLASS_DESCRIPTOR->blockGetWatchRequest(Landroid/net/Uri;)Landroid/net/Uri;
|
||||
move-result-object v$uriRegister
|
||||
""",
|
||||
)
|
||||
}
|
||||
|
||||
// endregion
|
||||
|
||||
// region Block /initplayback requests to fall back to /get_watch requests.
|
||||
|
||||
buildInitPlaybackRequestFingerprint.matchOrThrow().let {
|
||||
it.method.apply {
|
||||
val moveUriStringIndex = it.patternMatch!!.startIndex
|
||||
val targetRegister =
|
||||
getInstruction<OneRegisterInstruction>(moveUriStringIndex).registerA
|
||||
|
||||
addInstructions(
|
||||
moveUriStringIndex + 1,
|
||||
"""
|
||||
invoke-static { v$targetRegister }, $EXTENSION_CLASS_DESCRIPTOR->blockInitPlaybackRequest(Ljava/lang/String;)Ljava/lang/String;
|
||||
move-result-object v$targetRegister
|
||||
""",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// endregion
|
||||
|
||||
// region Fetch replacement streams.
|
||||
|
||||
buildBrowseRequestFingerprint.methodOrThrow().apply {
|
||||
val newRequestBuilderIndex = indexOfNewUrlRequestBuilderInstruction(this)
|
||||
val urlRegister =
|
||||
getInstruction<FiveRegisterInstruction>(newRequestBuilderIndex).registerD
|
||||
|
||||
val entrySetIndex = indexOfEntrySetInstruction(this)
|
||||
val mapRegister = if (entrySetIndex < 0)
|
||||
urlRegister + 1
|
||||
else
|
||||
getInstruction<FiveRegisterInstruction>(entrySetIndex).registerC
|
||||
|
||||
var smaliInstructions =
|
||||
"invoke-static { v$urlRegister, v$mapRegister }, " +
|
||||
"$EXTENSION_CLASS_DESCRIPTOR->" +
|
||||
"fetchStreams(Ljava/lang/String;Ljava/util/Map;)V"
|
||||
|
||||
if (entrySetIndex < 0) smaliInstructions = """
|
||||
move-object/from16 v$mapRegister, p1
|
||||
|
||||
""" + smaliInstructions
|
||||
|
||||
// Copy request headers for streaming data fetch.
|
||||
addInstructions(newRequestBuilderIndex + 2, smaliInstructions)
|
||||
}
|
||||
|
||||
// endregion
|
||||
|
||||
// region Replace the streaming data.
|
||||
|
||||
createStreamingDataFingerprint.matchOrThrow().let { result ->
|
||||
result.method.apply {
|
||||
val setStreamingDataIndex = result.patternMatch!!.startIndex
|
||||
val setStreamingDataField =
|
||||
getInstruction(setStreamingDataIndex).getReference<FieldReference>().toString()
|
||||
|
||||
val playerProtoClass =
|
||||
getInstruction(setStreamingDataIndex + 1).getReference<FieldReference>()!!.definingClass
|
||||
val protobufClass =
|
||||
protobufClassParseByteBufferFingerprint.definingClassOrThrow()
|
||||
|
||||
val getStreamingDataField = instructions.find { instruction ->
|
||||
instruction.opcode == Opcode.IGET_OBJECT &&
|
||||
instruction.getReference<FieldReference>()?.definingClass == playerProtoClass
|
||||
}?.getReference<FieldReference>()
|
||||
?: throw PatchException("Could not find getStreamingDataField")
|
||||
|
||||
val videoDetailsIndex = result.patternMatch!!.endIndex
|
||||
val videoDetailsClass =
|
||||
getInstruction(videoDetailsIndex).getReference<FieldReference>()!!.type
|
||||
|
||||
val insertIndex = videoDetailsIndex + 1
|
||||
val videoDetailsRegister =
|
||||
getInstruction<TwoRegisterInstruction>(videoDetailsIndex).registerA
|
||||
|
||||
val overrideRegister = getInstruction<TwoRegisterInstruction>(insertIndex).registerA
|
||||
val freeRegister = implementation!!.registerCount - parameters.size - 2
|
||||
|
||||
addInstructionsWithLabels(
|
||||
insertIndex,
|
||||
"""
|
||||
invoke-static { }, $EXTENSION_CLASS_DESCRIPTOR->isSpoofingEnabled()Z
|
||||
move-result v$freeRegister
|
||||
if-eqz v$freeRegister, :disabled
|
||||
|
||||
# Get video id.
|
||||
# From YouTube 17.34.36 to YouTube 19.16.39, the field names and field types are the same.
|
||||
iget-object v$freeRegister, v$videoDetailsRegister, $videoDetailsClass->c:Ljava/lang/String;
|
||||
if-eqz v$freeRegister, :disabled
|
||||
|
||||
# Get streaming data.
|
||||
invoke-static { v$freeRegister }, $EXTENSION_CLASS_DESCRIPTOR->getStreamingData(Ljava/lang/String;)Ljava/nio/ByteBuffer;
|
||||
move-result-object v$freeRegister
|
||||
if-eqz v$freeRegister, :disabled
|
||||
|
||||
# Parse streaming data.
|
||||
sget-object v$overrideRegister, $playerProtoClass->a:$playerProtoClass
|
||||
invoke-static { v$overrideRegister, v$freeRegister }, $protobufClass->parseFrom(${protobufClass}Ljava/nio/ByteBuffer;)$protobufClass
|
||||
move-result-object v$freeRegister
|
||||
check-cast v$freeRegister, $playerProtoClass
|
||||
|
||||
# Set streaming data.
|
||||
iget-object v$freeRegister, v$freeRegister, $getStreamingDataField
|
||||
if-eqz v$freeRegister, :disabled
|
||||
iput-object v$freeRegister, p0, $setStreamingDataField
|
||||
|
||||
""",
|
||||
ExternalLabel("disabled", getInstruction(insertIndex))
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// endregion
|
||||
|
||||
// region Remove /videoplayback request body to fix playback.
|
||||
// This is needed when using iOS client as streaming data source.
|
||||
|
||||
buildMediaDataSourceFingerprint.methodOrThrow().apply {
|
||||
val targetIndex = instructions.lastIndex
|
||||
|
||||
addInstructions(
|
||||
targetIndex,
|
||||
"""
|
||||
# Field a: Stream uri.
|
||||
# Field c: Http method.
|
||||
# Field d: Post data.
|
||||
# From YouTube 17.34.36 to YouTube 19.16.39, the field names and field types are the same.
|
||||
move-object/from16 v0, p0
|
||||
iget-object v1, v0, $definingClass->a:Landroid/net/Uri;
|
||||
iget v2, v0, $definingClass->c:I
|
||||
iget-object v3, v0, $definingClass->d:[B
|
||||
invoke-static { v1, v2, v3 }, $EXTENSION_CLASS_DESCRIPTOR->removeVideoPlaybackPostBody(Landroid/net/Uri;I[B)[B
|
||||
move-result-object v1
|
||||
iput-object v1, v0, $definingClass->d:[B
|
||||
""",
|
||||
)
|
||||
}
|
||||
|
||||
// endregion
|
||||
|
||||
// region Append spoof info.
|
||||
|
||||
nerdsStatsVideoFormatBuilderFingerprint.methodOrThrow().apply {
|
||||
findInstructionIndicesReversedOrThrow(Opcode.RETURN_OBJECT).forEach { index ->
|
||||
val register = getInstruction<OneRegisterInstruction>(index).registerA
|
||||
|
||||
addInstructions(
|
||||
index, """
|
||||
invoke-static {v$register}, $EXTENSION_CLASS_DESCRIPTOR->appendSpoofedClient(Ljava/lang/String;)Ljava/lang/String;
|
||||
move-result-object v$register
|
||||
"""
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// endregion
|
||||
|
||||
// region add settings
|
||||
val spoofStreamingDataPatch = baseSpoofStreamingDataPatch(
|
||||
{
|
||||
compatibleWith(COMPATIBLE_PACKAGE)
|
||||
|
||||
dependsOn(
|
||||
baseSpoofUserAgentPatch(YOUTUBE_PACKAGE_NAME),
|
||||
settingsPatch
|
||||
)
|
||||
},
|
||||
{
|
||||
addPreference(
|
||||
arrayOf(
|
||||
"SETTINGS: SPOOF_STREAMING_DATA"
|
||||
@ -224,7 +25,5 @@ val spoofStreamingDataPatch = bytecodePatch(
|
||||
SPOOF_STREAMING_DATA
|
||||
)
|
||||
|
||||
// endregion
|
||||
|
||||
}
|
||||
}
|
||||
)
|
||||
|
@ -223,7 +223,7 @@ internal enum class PatchList(
|
||||
),
|
||||
SPOOF_STREAMING_DATA(
|
||||
"Spoof streaming data",
|
||||
"Adds options to spoof the streaming data to allow video playback."
|
||||
"Adds options to spoof the streaming data to allow playback."
|
||||
),
|
||||
SWIPE_CONTROLS(
|
||||
"Swipe controls",
|
||||
|
@ -55,6 +55,10 @@ internal fun Pair<String, Fingerprint>.matchOrNull(parentFingerprint: Pair<Strin
|
||||
second.matchOrNull(parentClassDef)
|
||||
}
|
||||
|
||||
context(BytecodePatchContext)
|
||||
internal fun Pair<String, Fingerprint>.methodOrNull(): MutableMethod? =
|
||||
matchOrNull()?.method
|
||||
|
||||
context(BytecodePatchContext)
|
||||
internal fun Pair<String, Fingerprint>.methodOrThrow(): MutableMethod =
|
||||
second.methodOrNull ?: throw first.exception
|
||||
@ -63,6 +67,14 @@ context(BytecodePatchContext)
|
||||
internal fun Pair<String, Fingerprint>.methodOrThrow(parentFingerprint: Pair<String, Fingerprint>): MutableMethod =
|
||||
matchOrThrow(parentFingerprint).method
|
||||
|
||||
context(BytecodePatchContext)
|
||||
internal fun Pair<String, Fingerprint>.originalMethodOrThrow(): Method =
|
||||
second.originalMethodOrNull ?: throw first.exception
|
||||
|
||||
context(BytecodePatchContext)
|
||||
internal fun Pair<String, Fingerprint>.originalMethodOrThrow(parentFingerprint: Pair<String, Fingerprint>): Method =
|
||||
matchOrThrow(parentFingerprint).originalMethod
|
||||
|
||||
context(BytecodePatchContext)
|
||||
internal fun Pair<String, Fingerprint>.mutableClassOrThrow(): MutableClass =
|
||||
second.classDefOrNull ?: throw first.exception
|
||||
|
@ -450,7 +450,16 @@ Tap on the continue button and disable battery optimizations."</string>
|
||||
Limitations:
|
||||
• OPUS audio codec may not be supported.
|
||||
• Seekbar thumbnail may not be present.
|
||||
• Watch history does not work with a brand account.</string>
|
||||
• Watch history does not work with a brand account."</string>
|
||||
|
||||
<string name="revanced_spoof_streaming_data_title">Spoof streaming data</string>
|
||||
<string name="revanced_spoof_streaming_data_summary">Spoof the streaming data to prevent playback issues.</string>
|
||||
<string name="revanced_spoof_streaming_data_stats_for_nerds_title">Show in Stats for nerds</string>
|
||||
<string name="revanced_spoof_streaming_data_stats_for_nerds_summary">Shows the client used to fetch streaming data in Stats for nerds.</string>
|
||||
<string name="revanced_spoof_streaming_data_type_entry_ios">iOS</string>
|
||||
<string name="revanced_spoof_streaming_data_type_entry_ios_music">iOS Music</string>
|
||||
<string name="revanced_spoof_streaming_data_type_entry_android_unplugged">Android TV</string>
|
||||
<string name="revanced_spoof_streaming_data_type_entry_android_vr">Android VR</string>
|
||||
|
||||
<string name="revanced_sanitize_sharing_links_title">Sanitize sharing links</string>
|
||||
<string name="revanced_sanitize_sharing_links_summary">Removes tracking query parameters from URLs when sharing links.</string>
|
||||
|
@ -1895,6 +1895,7 @@ Tap on the continue button and disable battery optimizations."</string>
|
||||
<string name="revanced_spoof_streaming_data_user_dialog_message">Turning off this setting may cause video playback issues.</string>
|
||||
<string name="revanced_spoof_streaming_data_type_title">Default client</string>
|
||||
<string name="revanced_spoof_streaming_data_type_entry_ios">iOS</string>
|
||||
<string name="revanced_spoof_streaming_data_type_entry_ios_music">iOS Music</string>
|
||||
<string name="revanced_spoof_streaming_data_type_entry_android_unplugged">Android TV</string>
|
||||
<string name="revanced_spoof_streaming_data_type_entry_android_vr">Android VR</string>
|
||||
<string name="revanced_spoof_streaming_data_side_effects_title">Spoofing side effects</string>
|
||||
|
@ -778,10 +778,10 @@
|
||||
<!-- SETTINGS: SPOOF_STREAMING_DATA
|
||||
<PreferenceScreen android:title="@string/revanced_preference_screen_spoof_streaming_data_title" android:key="revanced_preference_screen_spoof_streaming_data" android:summary="@string/revanced_preference_screen_spoof_streaming_data_summary">
|
||||
<SwitchPreference android:title="@string/revanced_spoof_streaming_data_title" android:key="revanced_spoof_streaming_data" android:summaryOn="@string/revanced_spoof_streaming_data_summary_on" android:summaryOff="@string/revanced_spoof_streaming_data_summary_off" />
|
||||
<ListPreference android:entries="@array/revanced_spoof_streaming_data_type_entries" android:title="@string/revanced_spoof_streaming_data_type_title" android:key="revanced_spoof_streaming_data_type" android:entryValues="@array/revanced_spoof_streaming_data_type_entry_values" />
|
||||
<ListPreference android:entries="@array/revanced_spoof_streaming_data_type_entries" android:title="@string/revanced_spoof_streaming_data_type_title" android:key="revanced_spoof_streaming_data_type" android:entryValues="@array/revanced_spoof_streaming_data_type_entry_values" android:dependency="revanced_spoof_streaming_data" />
|
||||
<app.revanced.extension.youtube.settings.preference.SpoofStreamingDataSideEffectsPreference android:title="@string/revanced_spoof_streaming_data_side_effects_title" />
|
||||
<SwitchPreference android:title="@string/revanced_spoof_streaming_data_ios_skip_livestream_playback_title" android:key="revanced_spoof_streaming_data_ios_skip_livestream_playback" android:summaryOn="@string/revanced_spoof_streaming_data_ios_skip_livestream_playback_summary_on" android:summaryOff="@string/revanced_spoof_streaming_data_ios_skip_livestream_playback_summary_off" />
|
||||
<SwitchPreference android:title="@string/revanced_spoof_streaming_data_stats_for_nerds_title" android:key="revanced_spoof_streaming_data_stats_for_nerds" android:summaryOn="@string/revanced_spoof_streaming_data_stats_for_nerds_summary_on" android:summaryOff="@string/revanced_spoof_streaming_data_stats_for_nerds_summary_off" />
|
||||
<SwitchPreference android:title="@string/revanced_spoof_streaming_data_ios_skip_livestream_playback_title" android:key="revanced_spoof_streaming_data_ios_skip_livestream_playback" android:summaryOn="@string/revanced_spoof_streaming_data_ios_skip_livestream_playback_summary_on" android:summaryOff="@string/revanced_spoof_streaming_data_ios_skip_livestream_playback_summary_off" android:dependency="revanced_spoof_streaming_data" />
|
||||
<SwitchPreference android:title="@string/revanced_spoof_streaming_data_stats_for_nerds_title" android:key="revanced_spoof_streaming_data_stats_for_nerds" android:summaryOn="@string/revanced_spoof_streaming_data_stats_for_nerds_summary_on" android:summaryOff="@string/revanced_spoof_streaming_data_stats_for_nerds_summary_off" android:dependency="revanced_spoof_streaming_data" />
|
||||
</PreferenceScreen>SETTINGS: SPOOF_STREAMING_DATA -->
|
||||
|
||||
<!-- SETTINGS: WATCH_HISTORY
|
||||
|
Loading…
x
Reference in New Issue
Block a user