feat(YouTube Music): Add Spoof streaming data patch

This commit is contained in:
inotia00 2024-12-15 18:29:05 +09:00
parent 21acf6f003
commit 7dfd817ba3
26 changed files with 664 additions and 649 deletions

View File

@ -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;

View File

@ -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 Whats 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;
}
}
}

View File

@ -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;
}
}

View File

@ -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 Whats 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 Whats 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());
}
}
}

View File

@ -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;
}
}
}

View File

@ -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;
}
}
}

View File

@ -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 + '\'' + '}';
}
}
}

View File

@ -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;
}

View File

@ -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
}
}

View File

@ -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
*/

View File

@ -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;

View File

@ -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
);

View File

@ -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);

View File

@ -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);
}
}

View File

@ -46,6 +46,7 @@ private const val CLIENT_INFO_CLASS_DESCRIPTOR =
val spoofClientPatch = bytecodePatch(
SPOOF_CLIENT.title,
SPOOF_CLIENT.summary,
false,
) {
dependsOn(settingsPatch)

View File

@ -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)
}
)

View File

@ -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",

View File

@ -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;"

View File

@ -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()
}
}

View File

@ -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=\""),
)

View File

@ -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
}
}
)

View File

@ -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",

View File

@ -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

View File

@ -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>

View File

@ -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>

View File

@ -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