feat(YouTube & YT Music - Spoof video streams): Match Morphe

Add dynamic prefs (not used)
This commit is contained in:
Aaron Veil 2026-03-08 14:18:14 +03:00
parent 30119997b6
commit 43cab65290
155 changed files with 13406 additions and 3129 deletions

View file

@ -44,7 +44,7 @@ dependencies {
implementation(libs.protobuf.javalite)
//noinspection UseTomlInstead
implementation("com.eclipsesource.j2v8:j2v8:6.3.4@aar")
implementation("com.github.ynab:J2V8:6.2.1-16kb.2@aar")
implementation(libs.nanohttpd)
implementation(libs.protobuf.javalite)

View file

@ -0,0 +1,31 @@
package app.morphe.extension.music.patches.spoof;
import static app.morphe.extension.music.settings.Settings.SPOOF_VIDEO_STREAMS_CLIENT_TYPE;
import static app.morphe.extension.shared.spoof.ClientType.ANDROID_VR_1_47_48;
import static app.morphe.extension.shared.spoof.ClientType.ANDROID_VR_1_54_20;
import static app.morphe.extension.shared.spoof.ClientType.TV;
import static app.morphe.extension.shared.spoof.ClientType.VISIONOS;
import java.util.List;
import app.morphe.extension.shared.spoof.ClientType;
@SuppressWarnings("unused")
public class SpoofVideoStreamsPatch {
/**
* Injection point.
*/
public static void setClientOrderToUse() {
// For some users No SDK can fail at 1 minute. Only use it if the user has explicitly set it.
List<ClientType> availableClients = List.of(
TV,
ANDROID_VR_1_47_48,
VISIONOS,
ANDROID_VR_1_54_20
);
app.morphe.extension.shared.spoof.SpoofVideoStreamsPatch.setClientsToUse(
availableClients, SPOOF_VIDEO_STREAMS_CLIENT_TYPE.get());
}
}

View file

@ -4,6 +4,7 @@ import static java.lang.Boolean.FALSE;
import static java.lang.Boolean.TRUE;
import static app.morphe.extension.music.sponsorblock.objects.CategoryBehaviour.IGNORE;
import static app.morphe.extension.music.sponsorblock.objects.CategoryBehaviour.SKIP_AUTOMATICALLY;
import static app.morphe.extension.shared.settings.Setting.parent;
import static app.morphe.extension.shared.utils.StringRef.str;
import androidx.annotation.NonNull;
@ -19,13 +20,19 @@ import app.morphe.extension.shared.settings.FloatSetting;
import app.morphe.extension.shared.settings.IntegerSetting;
import app.morphe.extension.shared.settings.LongSetting;
import app.morphe.extension.shared.settings.Setting;
import app.morphe.extension.shared.settings.SharedYouTubeSettings;
import app.morphe.extension.shared.settings.StringSetting;
import app.morphe.extension.shared.spoof.ClientType;
import app.morphe.extension.shared.utils.Logger;
import app.morphe.extension.shared.utils.Utils;
@SuppressWarnings("unused")
public class Settings extends BaseSettings {
public class Settings extends SharedYouTubeSettings {
public static final EnumSetting<ClientType> SPOOF_VIDEO_STREAMS_CLIENT_TYPE =
new EnumSetting<>("morphe_spoof_video_streams_client_type",
ClientType.ANDROID_VR_1_47_48, true, parent(SPOOF_VIDEO_STREAMS));
// PreferenceScreen: Account
public static final BooleanSetting HIDE_ACCOUNT_MENU = new BooleanSetting("revanced_hide_account_menu", FALSE);
public static final StringSetting HIDE_ACCOUNT_MENU_FILTER_STRINGS = new StringSetting("revanced_hide_account_menu_filter_strings", "");
@ -203,8 +210,8 @@ public class Settings extends BaseSettings {
public static final BooleanSetting DISABLE_MUSIC_VIDEO_IN_ALBUM = new BooleanSetting("revanced_disable_music_video_in_album", FALSE, true);
public static final EnumSetting<RedirectType> DISABLE_MUSIC_VIDEO_IN_ALBUM_REDIRECT_TYPE = new EnumSetting<>("revanced_disable_music_video_in_album_redirect_type", RedirectType.REDIRECT, true);
public static final BooleanSetting SETTINGS_IMPORT_EXPORT = new BooleanSetting("revanced_settings_import_export", FALSE, false);
public static final BooleanSetting SPOOF_STREAMING_DATA_SIGN_IN_ANDROID_NO_SDK_ABOUT = new BooleanSetting("revanced_spoof_streaming_data_sign_in_android_no_sdk_about", FALSE, false);
public static final BooleanSetting SPOOF_STREAMING_DATA_SIGN_IN_ANDROID_VR_ABOUT = new BooleanSetting("revanced_spoof_streaming_data_sign_in_android_vr_about", FALSE, false);
public static final BooleanSetting SPOOF_VIDEO_STREAMS_SIGN_IN_ANDROID_VR_ABOUT =
new BooleanSetting("morphe_spoof_video_streams_sign_in_android_vr_about", FALSE, false);
public static final BooleanSetting APP_INFO = new BooleanSetting("revanced_app_info", FALSE, false);
// PreferenceScreen: Return YouTube Dislike
@ -268,6 +275,12 @@ public class Settings extends BaseSettings {
SPOOF_APP_VERSION_FOR_LYRICS_TARGET.resetToDefault();
}
// TV Simply may require PoToken
if (SPOOF_VIDEO_STREAMS_CLIENT_TYPE.get() == ClientType.TV_SIMPLY) {
Logger.printInfo(() -> "Migrating from TV Simply to TV");
SPOOF_VIDEO_STREAMS_CLIENT_TYPE.save(ClientType.TV);
}
// endregion
// region SB import/export callbacks
@ -301,17 +314,17 @@ public class Settings extends BaseSettings {
HIDE_ACCOUNT_MENU_FILTER_STRINGS.key,
OPEN_DEFAULT_APP_SETTINGS,
OPTIONAL_SPONSOR_BLOCK_SETTINGS_PREFIX,
REPLACE_NAVIGATION_BUTTON_ABOUT.key,
RETURN_YOUTUBE_USERNAME_ABOUT.key,
RETURN_YOUTUBE_USERNAME_DISPLAY_FORMAT.key,
RETURN_YOUTUBE_USERNAME_YOUTUBE_DATA_API_V3_DEVELOPER_KEY.key,
REPLACE_NAVIGATION_BUTTON_ABOUT.key,
SB_API_URL.key,
SETTINGS_IMPORT_EXPORT.key,
SPOOF_APP_VERSION_TARGET.key,
SPOOF_APP_VERSION_FOR_LYRICS_TARGET.key,
SPOOF_STREAMING_DATA_DEFAULT_CLIENT.key,
SPOOF_STREAMING_DATA_SIGN_IN_ANDROID_NO_SDK_ABOUT.key,
SPOOF_STREAMING_DATA_SIGN_IN_ANDROID_VR_ABOUT.key,
SPOOF_APP_VERSION_TARGET.key,
SPOOF_VIDEO_STREAMS_CLIENT_TYPE.key,
SPOOF_VIDEO_STREAMS_PLAYER_JS_HASH.key,
SPOOF_VIDEO_STREAMS_SIGN_IN_ANDROID_VR_ABOUT.key,
WATCH_HISTORY_TYPE.key,
};

View file

@ -19,9 +19,8 @@ import static app.morphe.extension.music.settings.Settings.SB_API_URL;
import static app.morphe.extension.music.settings.Settings.SETTINGS_IMPORT_EXPORT;
import static app.morphe.extension.music.settings.Settings.SPOOF_APP_VERSION_TARGET;
import static app.morphe.extension.music.settings.Settings.SPOOF_APP_VERSION_FOR_LYRICS_TARGET;
import static app.morphe.extension.music.settings.Settings.SPOOF_STREAMING_DATA_DEFAULT_CLIENT;
import static app.morphe.extension.music.settings.Settings.SPOOF_STREAMING_DATA_SIGN_IN_ANDROID_NO_SDK_ABOUT;
import static app.morphe.extension.music.settings.Settings.SPOOF_STREAMING_DATA_SIGN_IN_ANDROID_VR_ABOUT;
import static app.morphe.extension.music.settings.Settings.SPOOF_VIDEO_STREAMS_CLIENT_TYPE;
import static app.morphe.extension.music.settings.Settings.SPOOF_VIDEO_STREAMS_SIGN_IN_ANDROID_VR_ABOUT;
import static app.morphe.extension.music.settings.Settings.WATCH_HISTORY_TYPE;
import static app.morphe.extension.music.utils.ExtendedUtils.getDialogBuilder;
import static app.morphe.extension.music.utils.ExtendedUtils.getLayoutParams;
@ -30,6 +29,7 @@ import static app.morphe.extension.shared.patches.PatchStatus.PatchVersion;
import static app.morphe.extension.shared.settings.BaseSettings.RETURN_YOUTUBE_USERNAME_DISPLAY_FORMAT;
import static app.morphe.extension.shared.settings.BaseSettings.RETURN_YOUTUBE_USERNAME_YOUTUBE_DATA_API_V3_DEVELOPER_KEY;
import static app.morphe.extension.shared.settings.Setting.getSettingFromPath;
import static app.morphe.extension.shared.settings.SharedYouTubeSettings.SPOOF_VIDEO_STREAMS_PLAYER_JS_HASH;
import static app.morphe.extension.shared.utils.ResourceUtils.getStringArray;
import static app.morphe.extension.shared.utils.StringRef.str;
import static app.morphe.extension.shared.utils.Utils.isSDKAbove;
@ -151,7 +151,8 @@ public class ReVancedPreferenceFragment extends PreferenceFragment {
|| settings.equals(CUSTOM_PLAYER_BACKGROUND_COLOR_SECONDARY)
|| settings.equals(ENABLE_CUSTOM_NAVIGATION_BAR_COLOR_VALUE)
|| settings.equals(HIDE_ACCOUNT_MENU_FILTER_STRINGS)
|| settings.equals(RETURN_YOUTUBE_USERNAME_YOUTUBE_DATA_API_V3_DEVELOPER_KEY)) {
|| settings.equals(RETURN_YOUTUBE_USERNAME_YOUTUBE_DATA_API_V3_DEVELOPER_KEY)
|| settings.equals(SPOOF_VIDEO_STREAMS_PLAYER_JS_HASH)) {
ResettableEditTextPreference.showDialog(mActivity, stringSetting);
} else if (settings.equals(EXTERNAL_DOWNLOADER_PACKAGE_NAME)) {
ExternalDownloaderPreference.showDialog(mActivity);
@ -172,9 +173,7 @@ public class ReVancedPreferenceFragment extends PreferenceFragment {
YouTubeDataAPIDialogBuilder.showDialog(mActivity);
} else if (settings.equals(REPLACE_NAVIGATION_BUTTON_ABOUT)) {
ResettableListPreference.showDialog(mActivity, CHANGE_START_PAGE, 0);
} else if (settings.equals(SPOOF_STREAMING_DATA_SIGN_IN_ANDROID_NO_SDK_ABOUT)) {
SpoofStreamingDataSignInDialogBuilder.showNoSDKDialog(mActivity);
} else if (settings.equals(SPOOF_STREAMING_DATA_SIGN_IN_ANDROID_VR_ABOUT)) {
} else if (settings.equals(SPOOF_VIDEO_STREAMS_SIGN_IN_ANDROID_VR_ABOUT)) {
SpoofStreamingDataSignInDialogBuilder.showVRDialog(mActivity);
} else {
Logger.printDebug(() -> "Failed to find the right value: " + dataString);
@ -183,7 +182,7 @@ public class ReVancedPreferenceFragment extends PreferenceFragment {
if (settings.equals(CHANGE_START_PAGE)
|| settings.equals(DISABLE_MUSIC_VIDEO_IN_ALBUM_REDIRECT_TYPE)
|| settings.equals(RETURN_YOUTUBE_USERNAME_DISPLAY_FORMAT)
|| settings.equals(SPOOF_STREAMING_DATA_DEFAULT_CLIENT)
|| settings.equals(SPOOF_VIDEO_STREAMS_CLIENT_TYPE)
|| settings.equals(WATCH_HISTORY_TYPE)
) {
ResettableListPreference.showDialog(mActivity, enumSetting, 0);

View file

@ -38,33 +38,6 @@ public class SpoofStreamingDataSignInDialogBuilder {
}
public static void showVRDialog(Activity mActivity) {
AlertDialog.Builder builder = Utils.getDialogBuilder(mActivity);
String dialogTitle =
str("revanced_spoof_streaming_data_sign_in_android_vr_dialog_title");
String dialogMessage =
str("revanced_spoof_streaming_data_sign_in_android_vr_dialog_message");
String resetButtonText =
str("revanced_spoof_streaming_data_sign_in_android_vr_dialog_reset_text");
builder.setTitle(dialogTitle);
builder.setMessage(dialogMessage);
builder.setNeutralButton(resetButtonText, (dialog, id) -> YouTubeVRAuthPatch.clearAll());
if (YouTubeVRAuthPatch.isDeviceCodeAvailable()) {
String okButtonText =
str("revanced_spoof_streaming_data_sign_in_android_vr_dialog_get_authorization_token_text");
builder.setPositiveButton(okButtonText, (dialog, id) -> {
YouTubeVRAuthPatch.setRefreshToken();
YouTubeVRAuthPatch.setAccessToken(mActivity);
});
} else {
String okButtonText =
str("revanced_spoof_streaming_data_sign_in_android_vr_dialog_get_activation_code_text");
builder.setPositiveButton(okButtonText, (dialog, id) -> {
YouTubeVRAuthPatch.setActivationCode(mActivity);
});
}
builder.show();
new SpoofVideoStreamsSignInPreference(mActivity).onPreferenceClick(null);
}
}
}

View file

@ -0,0 +1,34 @@
package app.morphe.extension.music.settings.preference;
import android.content.Context;
import android.util.AttributeSet;
import app.morphe.extension.shared.settings.preference.OAuth2Preference;
import app.morphe.extension.music.settings.Settings;
@SuppressWarnings("unused")
public final class SpoofVideoStreamsSignInPreference extends OAuth2Preference {
public SpoofVideoStreamsSignInPreference(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
}
public SpoofVideoStreamsSignInPreference(Context context, AttributeSet attrs) {
super(context, attrs);
}
public SpoofVideoStreamsSignInPreference(Context context) {
super(context);
}
public SpoofVideoStreamsSignInPreference(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) {
super(context, attrs, defStyleAttr, defStyleRes);
}
@Override
protected boolean isSettingEnabled() {
return Settings.SPOOF_VIDEO_STREAMS.get() &&
Settings.SPOOF_VIDEO_STREAMS_CLIENT_TYPE.get().supportsOAuth2;
}
}

View file

@ -0,0 +1,44 @@
package app.morphe.extension.shared;
import androidx.annotation.Nullable;
public final class Logger {
private Logger() {
}
public static void printDebug(app.morphe.extension.shared.utils.Logger.LogMessage message) {
app.morphe.extension.shared.utils.Logger.printDebug(message);
}
public static void printDebug(app.morphe.extension.shared.utils.Logger.LogMessage message, @Nullable Exception ex) {
app.morphe.extension.shared.utils.Logger.printDebug(message, ex);
}
public static void printInfo(app.morphe.extension.shared.utils.Logger.LogMessage message) {
app.morphe.extension.shared.utils.Logger.printInfo(message);
}
public static void printInfo(app.morphe.extension.shared.utils.Logger.LogMessage message, @Nullable Exception ex) {
app.morphe.extension.shared.utils.Logger.printInfo(message, ex);
}
public static void printWarn(app.morphe.extension.shared.utils.Logger.LogMessage message) {
app.morphe.extension.shared.utils.Logger.printWarn(message);
}
public static void printWarn(app.morphe.extension.shared.utils.Logger.LogMessage message, @Nullable Exception ex) {
app.morphe.extension.shared.utils.Logger.printWarn(message, ex);
}
public static void printException(app.morphe.extension.shared.utils.Logger.LogMessage message) {
app.morphe.extension.shared.utils.Logger.printException(message);
}
public static void printException(app.morphe.extension.shared.utils.Logger.LogMessage message, @Nullable Throwable ex) {
app.morphe.extension.shared.utils.Logger.printException(message, ex);
}
public static void printWTF(app.morphe.extension.shared.utils.Logger.LogMessage message) {
app.morphe.extension.shared.utils.Logger.printWTF(message);
}
}

View file

@ -0,0 +1,60 @@
package app.morphe.extension.shared;
import androidx.annotation.NonNull;
import java.util.HashMap;
import java.util.Map;
public enum ResourceType {
ANIM("anim"),
ANIMATOR("animator"),
ARRAY("array"),
ATTR("attr"),
BOOL("bool"),
COLOR("color"),
DIMEN("dimen"),
DRAWABLE("drawable"),
FONT("font"),
FRACTION("fraction"),
ID("id"),
INTEGER("integer"),
INTERPOLATOR("interpolator"),
LAYOUT("layout"),
MENU("menu"),
MIPMAP("mipmap"),
NAVIGATION("navigation"),
PLURALS("plurals"),
RAW("raw"),
STRING("string"),
STYLE("style"),
STYLEABLE("styleable"),
TRANSITION("transition"),
VALUES("values"),
XML("xml");
private static final Map<String, ResourceType> VALUE_MAP;
static {
ResourceType[] values = values();
VALUE_MAP = new HashMap<>(2 * values.length);
for (ResourceType type : values) {
VALUE_MAP.put(type.type, type);
}
}
public final String type;
@NonNull
public static ResourceType fromValue(String value) {
ResourceType type = VALUE_MAP.get(value);
if (type == null) {
throw new IllegalArgumentException("Unknown resource type: " + value);
}
return type;
}
ResourceType(String type) {
this.type = type;
}
}

View file

@ -0,0 +1,43 @@
package app.morphe.extension.shared;
import androidx.annotation.NonNull;
public final class ResourceUtils {
private ResourceUtils() {
}
public static int getIdentifier(@NonNull String name, @NonNull ResourceType resourceType) {
return app.morphe.extension.shared.utils.ResourceUtils.getIdentifier(
name,
app.morphe.extension.shared.utils.ResourceUtils.ResourceType.valueOf(resourceType.name())
);
}
public static int getIdentifierOrThrow(@NonNull String name, @NonNull ResourceType resourceType) {
int identifier = getIdentifier(name, resourceType);
if (identifier == 0) {
throw new IllegalArgumentException("Resource not found: " + resourceType + " " + name);
}
return identifier;
}
public static int getColorIdentifier(@NonNull String name) {
return app.morphe.extension.shared.utils.ResourceUtils.getColorIdentifier(name);
}
public static int getIntegerIdentifier(@NonNull String name) {
return app.morphe.extension.shared.utils.ResourceUtils.getIntegerIdentifier(name);
}
public static String getRawResource(@NonNull String name) {
return app.morphe.extension.shared.utils.ResourceUtils.getRawResource(name);
}
public static int getColor(@NonNull String name) {
return app.morphe.extension.shared.utils.ResourceUtils.getColor(name);
}
public static int getInteger(@NonNull String name) {
return app.morphe.extension.shared.utils.ResourceUtils.getInteger(name);
}
}

View file

@ -0,0 +1,7 @@
package app.morphe.extension.shared;
public class StringRef extends app.morphe.extension.shared.utils.StringRef {
public StringRef(String resName) {
super(resName);
}
}

View file

@ -0,0 +1,13 @@
package app.morphe.extension.shared;
import app.morphe.extension.shared.utils.PackageUtils;
public class Utils extends app.morphe.extension.shared.utils.Utils {
public static boolean isNotEmpty(String value) {
return value != null && !value.isEmpty();
}
public static String getAppVersionName() {
return PackageUtils.getAppVersionName();
}
}

View file

@ -0,0 +1,83 @@
/*
* Copyright 2026 Morphe.
* https://github.com/MorpheApp/morphe-patches
*/
package app.morphe.extension.shared.oauth2.object;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import org.json.JSONException;
import org.json.JSONObject;
/**
* OAuth2 access token data.
*/
public class AccessTokenData {
/**
* Access token in the authorization header, usually starting with 'ya29'.
*/
public final String accessToken;
/**
* How long to cache access token objects.
*/
public final int expiresIn;
/**
* Refresh token is used to reissue access token.
* <p>
* Access token expire within minutes or hours, but refresh token have no expiration time.
* Theoretically, access token can be renewed an unlimited number of times with a refresh token
* since a refresh token is similar to a GitHub PAT, which has no expiration date, this is not included in debug logs.
*/
@Nullable
public final String refreshToken;
/**
* Typically, this value is '5' (5 seconds).
* YouTube TV and YouTube VR send a request every 5 seconds to fetch a refresh token.
*/
public final String scope;
/**
* Type of authorization header.
* Typically, this value is 'Bearer'.
*/
public final String tokenType;
/**
* When this code was fetched.
*/
public final long fetchTime;
public AccessTokenData(JSONObject json) throws JSONException {
this(json.getString("refresh_token"), json);
}
public AccessTokenData(@Nullable String refreshToken, JSONObject json) throws JSONException {
this.refreshToken = refreshToken;
accessToken = json.getString("access_token");
expiresIn = json.getInt("expires_in");
scope = json.getString("scope");
tokenType = json.getString("token_type");
fetchTime = System.currentTimeMillis();
}
public boolean isExpired() {
return expiresIn * 1000L < System.currentTimeMillis() - fetchTime;
}
@NonNull
@Override
public String toString() {
// Do not include refresh token in toString().
return "AccessToken{"
+ "accessToken='" + accessToken + '\''
+ ", expiresIn='" + expiresIn + '\''
+ ", scope='" + scope + '\''
+ ", tokenType='" + tokenType + '\''
+ '}';
}
}

View file

@ -0,0 +1,75 @@
/*
* Copyright 2026 Morphe.
* https://github.com/MorpheApp/morphe-patches
*/
package app.morphe.extension.shared.oauth2.object;
import androidx.annotation.NonNull;
import org.json.JSONException;
import org.json.JSONObject;
/**
* OAuth2 activation code data.
*/
public class ActivationCodeData {
/**
* Used in requests for refresh token.
*/
public final String deviceCode;
/**
* Activation code (Similar to SMS verification code).
* User should enter this code in {@link #verificationUrl} and log in.
*/
public final String userCode;
/**
* How long to cache activation code objects.
* Typically, this value is '1800' (1800 seconds).
*/
public final int expiresIn;
/**
* Typically, this value is '5' (5 seconds).
* YouTube TV and YouTube VR send a request every 5 seconds to fetch a refresh token.
*/
public final int interval;
/**
* Users enter their activation code here and log in.
* Typically, this value is '<a href="https://www.google.com/device">...</a>'.
*/
public final String verificationUrl;
/**
* When this code was fetched.
*/
public final long fetchTime;
public ActivationCodeData(@NonNull JSONObject json) throws JSONException {
deviceCode = json.getString("device_code");
userCode = json.getString("user_code");
expiresIn = json.getInt("expires_in");
interval = json.getInt("interval");
verificationUrl = json.getString("verification_url");
fetchTime = System.currentTimeMillis();
}
public boolean isExpired() {
return expiresIn * 1000L < System.currentTimeMillis() - fetchTime;
}
@NonNull
@Override
public String toString() {
return "ActivationCodeData{"
+ "deviceCode='" + deviceCode + '\''
+ ", userCode='" + userCode + '\''
+ ", expiresIn=" + expiresIn
+ ", interval=" + interval
+ ", verificationUrl='" + verificationUrl + '\''
+ '}';
}
}

View file

@ -0,0 +1,377 @@
/*
* Copyright 2026 Morphe.
* https://github.com/MorpheApp/morphe-patches
*/
package app.morphe.extension.shared.oauth2.requests;
import static app.morphe.extension.shared.StringRef.str;
import static app.morphe.extension.shared.oauth2.requests.OAuth2Routes.getJsonConnectionFromRoute;
import static app.morphe.extension.shared.oauth2.requests.OAuth2Routes.getUrlConnectionFromRoute;
import android.net.Uri;
import androidx.annotation.GuardedBy;
import androidx.annotation.Nullable;
import org.json.JSONObject;
import java.io.IOException;
import java.net.HttpURLConnection;
import java.net.SocketTimeoutException;
import java.nio.charset.StandardCharsets;
import java.util.Objects;
import java.util.UUID;
import app.morphe.extension.shared.Logger;
import app.morphe.extension.shared.Utils;
import app.morphe.extension.shared.oauth2.object.AccessTokenData;
import app.morphe.extension.shared.oauth2.object.ActivationCodeData;
import app.morphe.extension.shared.requests.Requester;
import app.morphe.extension.shared.settings.BaseSettings;
import app.morphe.extension.shared.settings.SharedYouTubeSettings;
public class OAuth2Requester {
/**
* Response code of a successful API call.
*/
private static final int HTTP_STATUS_CODE_SUCCESS = 200;
/**
* Response code of a failed API call.
*/
private static final int HTTP_STATUS_CODE_FAILED = 400;
/**
* The client id of the Android YouTube VR client.
* This value is unique and does not change.
*/
private static final String CLIENT_ID =
"652469312169-4lvs9bnhr9lpns9v451j5oivd81vjvu1.apps.googleusercontent.com";
/**
* The client secret of the Android YouTube VR client.
* This value is unique and does not change.
*/
private static final String CLIENT_SECRET = "3fTWrBJI5Uojm1TK7_iJCW5Z";
/**
* Device model enum name for the Android YouTube VR app.
* <p>
* Available values are [UNKNOWN], [QUEST1], [QUEST2], [QUEST_PRO],
* [MOOHAN], [PICO4], [QUEST3], [QUEST3S], [PICO4_ULTRA], and [ANDROID_XR].
*/
private static final String DEVICE_MODEL = "QUEST1";
/**
* Access token scope.
* Permissions are granted only for YouTube.
*/
private static final String OAUTH2_SCOPE =
"https://www.googleapis.com/auth/youtube";
/**
* Used when issuing an access token without a refresh token.
* An unexpired device code is required.
*/
private static final String GRANT_TYPE_DEFAULT =
"http://oauth.net/grant_type/device/1.0";
/**
* Used when issuing an access token with a refresh token.
*/
private static final String GRANT_TYPE_REFRESH = "refresh_token";
@Nullable
@GuardedBy("OAuth2Requester.class")
private static ActivationCodeData lastFetchedActivationCodeData;
@Nullable
@GuardedBy("OAuth2Requester.class")
private static AccessTokenData lastFetchedAccessTokenData;
/**
* The value of 'Authorization' in the header.
* Bearer token is used on mobile devices.
*/
@GuardedBy("OAuth2Requester.class")
private static String authorization = "";
private OAuth2Requester() {
}
public static void setAuthorization(AccessTokenData accessTokenData) {
synchronized (OAuth2Requester.class) {
// Bearer y29.xxx...
authorization = accessTokenData.tokenType + " " + accessTokenData.accessToken;
}
}
private static void handleConnectionError(String toastMessage, @Nullable Exception ex) {
if (BaseSettings.DEBUG_TOAST_ON_ERROR.get()) {
Utils.showToastLong(toastMessage);
}
if (ex != null) {
Logger.printInfo(() -> toastMessage, ex);
}
}
private static void clearAll(boolean clearedByUser) {
if (!clearedByUser) {
Utils.showToastShort(str("morphe_oauth2_toast_invalid"));
}
synchronized (OAuth2Requester.class) {
SharedYouTubeSettings.OAUTH2_REFRESH_TOKEN.resetToDefault();
lastFetchedActivationCodeData = null;
lastFetchedAccessTokenData = null;
authorization = "";
}
Utils.showToastShort(str("morphe_oauth2_toast_reset"));
}
private static boolean isActivationCodeDataAvailable(ActivationCodeData activationCodeData) {
return activationCodeData != null && !activationCodeData.isExpired();
}
public static boolean isActivationCodeDataAvailable() {
synchronized (OAuth2Requester.class) {
return isActivationCodeDataAvailable(lastFetchedActivationCodeData);
}
}
private static boolean isAccessTokenDataAvailable() {
synchronized (OAuth2Requester.class) {
AccessTokenData accessTokenDataData = lastFetchedAccessTokenData;
return accessTokenDataData != null && !accessTokenDataData.isExpired();
}
}
/**
* Check the validity of the access token before the video starts.
* Blocking call, and must be made off the main thread.
*/
public static synchronized String getAndUpdateAccessTokenIfNeeded() {
Utils.verifyOffMainThread();
synchronized (OAuth2Requester.class) {
String refreshToken = SharedYouTubeSettings.OAUTH2_REFRESH_TOKEN.get();
// Refresh token is empty, the user has not signed in to VR.
if (refreshToken.isEmpty()) {
return authorization;
}
// Access token has not expired, do nothing.
if (isAccessTokenDataAvailable()) {
return authorization;
}
// Access token has expired, so reissue it.
updateAccessTokenData(refreshToken);
return authorization;
}
}
/**
* Revoke token using OAuth2 API.
* Safe to call from any thread.
*/
public static void revokeToken(String refreshToken) {
Utils.runOnBackgroundThread(() -> {
synchronized (OAuth2Requester.class) {
if (!refreshToken.isEmpty()) {
if (!OAuth2Requester.revokeRefreshToken(refreshToken)) {
Logger.printException(() -> "Failed to revoke refresh token");
}
}
clearAll(true);
}
});
}
@Nullable
public static ActivationCodeData getActivationCodeData() {
Utils.verifyOffMainThread();
synchronized (OAuth2Requester.class) {
try {
HttpURLConnection connection = getJsonConnectionFromRoute(OAuth2Routes.DEVICE_CODE);
JSONObject jsonObject = new JSONObject();
jsonObject.put("client_id", CLIENT_ID);
jsonObject.put("scope", OAUTH2_SCOPE);
// Android YouTube VR app also uses random UUIDs.
jsonObject.put("device_id", UUID.randomUUID().toString());
jsonObject.put("device_model", DEVICE_MODEL);
byte[] body = jsonObject.toString().getBytes(StandardCharsets.UTF_8);
connection.setFixedLengthStreamingMode(body.length);
connection.getOutputStream().write(body);
final int responseCode = connection.getResponseCode();
if (responseCode == HTTP_STATUS_CODE_SUCCESS) {
ActivationCodeData fetchedActivationCodeData = new ActivationCodeData(Requester.parseJSONObjectAndDisconnect(connection));
Logger.printDebug(() -> "deviceCode: " + fetchedActivationCodeData);
lastFetchedActivationCodeData = fetchedActivationCodeData;
return fetchedActivationCodeData;
}
handleConnectionError(str("morphe_oauth2_connection_failure_status", responseCode), null);
} catch (SocketTimeoutException ex) {
handleConnectionError(str("morphe_oauth2_connection_failure_timeout"), ex);
} catch (IOException ex) {
handleConnectionError(str("morphe_oauth2_connection_failure_generic"), ex);
} catch (Exception ex) {
Logger.printException(() -> "getActivationCodeData failure", ex);
}
return null;
}
}
@Nullable
public static AccessTokenData getRefreshTokenData(boolean showErrorToastIfAccessNotApproved) {
Utils.verifyOffMainThread();
synchronized (OAuth2Requester.class) {
try {
ActivationCodeData activationCodeData = lastFetchedActivationCodeData;
if (!isActivationCodeDataAvailable(activationCodeData)) {
Logger.printDebug(() -> "Activation code has expired");
clearAll(false);
return null;
}
HttpURLConnection connection = getJsonConnectionFromRoute(OAuth2Routes.ACCESS_TOKEN);
JSONObject jsonObject = new JSONObject();
jsonObject.put("client_id", CLIENT_ID);
jsonObject.put("client_secret", CLIENT_SECRET);
jsonObject.put("code", activationCodeData.deviceCode);
jsonObject.put("grant_type", GRANT_TYPE_DEFAULT);
byte[] body = jsonObject.toString().getBytes(StandardCharsets.UTF_8);
connection.setFixedLengthStreamingMode(body.length);
connection.getOutputStream().write(body);
final int responseCode = connection.getResponseCode();
if (responseCode == HTTP_STATUS_CODE_SUCCESS) {
JSONObject json = Requester.parseJSONObjectAndDisconnect(connection);
String errorKey = "error";
if (json.has(errorKey)) {
String error = json.getString("error");
Logger.printDebug(() -> "getRefreshTokenData error:" + error);
if (error.equalsIgnoreCase("authorization_pending")) {
if (showErrorToastIfAccessNotApproved) {
Utils.showToastLong(str("morphe_oauth2_connection_failure_auth_not_approved"));
}
return null;
}
Utils.showToastLong(str("morphe_oauth2_connection_failure_auth_error", error));
return null;
}
AccessTokenData fetchedAccessTokenData = new AccessTokenData(json);
Logger.printDebug(() -> "getRefreshTokenData updated lastFetchedAccessTokenData");
lastFetchedAccessTokenData = fetchedAccessTokenData;
return fetchedAccessTokenData;
}
handleConnectionError(str("morphe_oauth2_connection_failure_status", responseCode), null);
} catch (SocketTimeoutException ex) {
handleConnectionError(str("morphe_oauth2_connection_failure_timeout"), ex);
} catch (IOException ex) {
handleConnectionError(str("morphe_oauth2_connection_failure_generic"), ex);
} catch (Exception ex) {
Logger.printException(() -> "getRefreshTokenData failure", ex);
}
return null;
}
}
private static void updateAccessTokenData(String refreshToken) {
Utils.verifyOffMainThread();
Objects.requireNonNull(refreshToken);
try {
HttpURLConnection connection = getJsonConnectionFromRoute(OAuth2Routes.ACCESS_TOKEN);
JSONObject jsonObject = new JSONObject();
jsonObject.put("client_id", CLIENT_ID);
jsonObject.put("client_secret", CLIENT_SECRET);
jsonObject.put("refresh_token", refreshToken);
jsonObject.put("grant_type", GRANT_TYPE_REFRESH);
byte[] body = jsonObject.toString().getBytes(StandardCharsets.UTF_8);
connection.setFixedLengthStreamingMode(body.length);
connection.getOutputStream().write(body);
final int responseCode = connection.getResponseCode();
if (responseCode == HTTP_STATUS_CODE_SUCCESS) {
synchronized (OAuth2Requester.class) {
AccessTokenData fetchedAccessTokenData = new AccessTokenData(refreshToken,
Requester.parseJSONObjectAndDisconnect(connection));
// Access token expires after 1 hour. Value is mostly safe to
// disclose in a log file, but it's still not logged.
Logger.printDebug(() -> "updateAccessTokenData updated lastFetchedAccessTokenData");
lastFetchedAccessTokenData = fetchedAccessTokenData;
setAuthorization(fetchedAccessTokenData);
}
} else if (responseCode == HTTP_STATUS_CODE_FAILED) {
// Tokens are revoked for the following reasons:
// 1. The user changes their password
// 2. The user logs out of the session in their Google Account settings
// 3. The refresh token has not been used for 6 months
//
// In this case, a response code of 400 is returned
// Since the refresh token is no longer valid, all locally stored tokens are removed.
Logger.printDebug(() -> "Invalid token, clear all");
handleConnectionError(str("morphe_oauth2_connection_failure_status_400", responseCode), null);
clearAll(false);
} else {
handleConnectionError(str("morphe_oauth2_connection_failure_status", responseCode), null);
}
} catch (SocketTimeoutException ex) {
handleConnectionError(str("morphe_oauth2_connection_failure_timeout"), ex);
} catch (IOException ex) {
handleConnectionError(str("morphe_oauth2_connection_failure_generic"), ex);
} catch (Exception ex) {
Logger.printException(() -> "updateAccessTokenData failure", ex);
}
}
private static boolean revokeRefreshToken(String refreshToken) {
Utils.verifyOffMainThread();
try {
HttpURLConnection connection = getUrlConnectionFromRoute(OAuth2Routes.REVOKE_TOKEN);
Uri bodyUri = new Uri.Builder()
.appendQueryParameter("token", refreshToken)
.build();
String query = Objects.toString(bodyUri.getEncodedQuery());
byte[] body = query.getBytes(StandardCharsets.UTF_8);
connection.setFixedLengthStreamingMode(body.length);
connection.getOutputStream().write(body);
final int responseCode = connection.getResponseCode();
if (responseCode == HTTP_STATUS_CODE_SUCCESS) {
return true;
}
handleConnectionError(str("morphe_oauth2_connection_failure_status", responseCode), null);
} catch (SocketTimeoutException ex) {
handleConnectionError(str("morphe_oauth2_connection_failure_timeout"), ex);
} catch (IOException ex) {
handleConnectionError(str("morphe_oauth2_connection_failure_generic"), ex);
} catch (Exception ex) {
Logger.printException(() -> "revokeAccessToken failure", ex);
}
return false;
}
}

View file

@ -0,0 +1,59 @@
/*
* Copyright 2026 Morphe.
* https://github.com/MorpheApp/morphe-patches
*/
package app.morphe.extension.shared.oauth2.requests;
import static app.morphe.extension.shared.requests.Route.Method.POST;
import java.io.IOException;
import java.net.HttpURLConnection;
import app.morphe.extension.shared.requests.Requester;
import app.morphe.extension.shared.requests.Route;
@SuppressWarnings("unused")
final class OAuth2Routes {
private static final String OAUTH2_GOOGLE_API_URL = "https://oauth2.googleapis.com/";
private static final String OAUTH2_YOUTUBE_API_URL = "https://www.youtube.com/o/oauth2/";
private static final String USER_AGENT =
"com.google.android.apps.youtube.vr.oculus/1.47.48(Linux; U; Android 10; en_US; Quest Build/QQ3A.200805.001) gzip";
/**
* TCP connection and HTTP read timeout.
*/
private static final int CONNECTION_TIMEOUT_MILLISECONDS = 10 * 1000; // 10 Seconds.
static final Route ACCESS_TOKEN = new Route(POST, "token");
static final Route DEVICE_CODE = new Route(POST, "device/code");
static final Route REVOKE_TOKEN = new Route(POST, "revoke");
private OAuth2Routes() {
}
private static void applyCommonPostRequestSettings(HttpURLConnection connection) {
connection.setRequestProperty("Pragma", "no-cache");
connection.setRequestProperty("Cache-Control", "no-cache");
connection.setRequestProperty("User-Agent", USER_AGENT);
connection.setUseCaches(false);
connection.setDoOutput(true);
connection.setConnectTimeout(CONNECTION_TIMEOUT_MILLISECONDS);
connection.setReadTimeout(CONNECTION_TIMEOUT_MILLISECONDS);
}
static HttpURLConnection getJsonConnectionFromRoute(Route route, String... params) throws IOException {
HttpURLConnection connection = Requester.getConnectionFromRoute(OAUTH2_YOUTUBE_API_URL, route, params);
connection.setRequestProperty("Content-Type", "application/json");
connection.setRequestProperty("Accept", "application/json");
applyCommonPostRequestSettings(connection);
return connection;
}
static HttpURLConnection getUrlConnectionFromRoute(Route route, String... params) throws IOException {
HttpURLConnection connection = Requester.getConnectionFromRoute(OAUTH2_GOOGLE_API_URL, route, params);
connection.setRequestProperty("Content-Type", "application/x-www-form-urlencoded");
applyCommonPostRequestSettings(connection);
return connection;
}
}

View file

@ -15,6 +15,12 @@ public class AppCheckPatch {
private static final String MAIN_ACTIVITY_CLASS_DESCRIPTOR_YOUTUBE =
"com.google.android.apps.youtube.app.watchwhile.MainActivity";
private static final String MAIN_ACTIVITY_CLASS_DESCRIPTOR_YOUTUBE_MUSIC =
"com.google.android.apps.youtube.music.activities.MusicActivity";
public static final boolean IS_YOUTUBE =
classExists(MAIN_ACTIVITY_CLASS_DESCRIPTOR_YOUTUBE);
public static final boolean IS_YOUTUBE_MUSIC =
classExists(MAIN_ACTIVITY_CLASS_DESCRIPTOR_YOUTUBE_MUSIC);
}

View file

@ -1,406 +1,138 @@
package app.morphe.extension.shared.patches.spoof;
import static app.morphe.extension.shared.innertube.utils.J2V8Support.supportJ2V8;
import static app.morphe.extension.shared.innertube.utils.StreamingDataOuterClassUtils.prioritizeResolution;
import static app.morphe.extension.shared.utils.Utils.isSDKAbove;
import static app.morphe.extension.shared.spoof.js.J2V8Support.supportJ2V8;
import android.net.Uri;
import android.text.TextUtils;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import com.google.protos.youtube.api.innertube.StreamingDataOuterClass.StreamingData;
import com.liskovsoft.googlecommon.common.helpers.YouTubeHelper;
import org.apache.commons.lang3.StringUtils;
import java.nio.ByteBuffer;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.concurrent.CompletableFuture;
import app.morphe.extension.shared.innertube.client.YouTubeClient.ClientType;
import app.morphe.extension.shared.innertube.utils.ThrottlingParameterUtils;
import app.morphe.extension.shared.patches.AppCheckPatch;
import app.morphe.extension.shared.patches.PatchStatus;
import app.morphe.extension.shared.patches.auth.YouTubeAuthPatch;
import app.morphe.extension.shared.patches.auth.YouTubeVRAuthPatch;
import app.morphe.extension.shared.patches.spoof.requests.StreamingDataRequest;
import app.morphe.extension.shared.settings.BaseSettings;
import app.morphe.extension.shared.settings.Setting;
import app.morphe.extension.shared.spoof.ClientType;
import app.morphe.extension.shared.spoof.SpoofVideoStreamsPatch;
import app.morphe.extension.shared.spoof.js.JavaScriptManager;
import app.morphe.extension.shared.utils.Logger;
import app.morphe.extension.shared.utils.Utils;
import app.morphe.extension.youtube.shared.VideoInformation;
import app.morphe.extension.youtube.settings.Settings;
@SuppressWarnings("unused")
public class SpoofStreamingDataPatch {
/**
* Optional post-processor for the stream (e.g. replace audio with translation when VOT is on).
* Set by the YouTube extension so playback uses one player and native speed.
*/
public interface StreamPostProcessor {
@Nullable
StreamingData process(@NonNull StreamingData stream, @NonNull String videoId);
}
private static volatile StreamPostProcessor streamPostProcessor;
public static void setStreamPostProcessor(@Nullable StreamPostProcessor processor) {
streamPostProcessor = processor;
}
private static final boolean J2V8_LIBRARY_AVAILABILITY = supportJ2V8();
public static final boolean SPOOF_STREAMING_DATA =
BaseSettings.SPOOF_STREAMING_DATA.get() && PatchStatus.SpoofStreamingData();
private static final boolean J2V8_LIBRARY_AVAILABILITY = supportJ2V8();
private static final boolean SPOOF_STREAMING_DATA_PRIORITIZE_VIDEO_QUALITY =
SPOOF_STREAMING_DATA && BaseSettings.SPOOF_STREAMING_DATA_PRIORITIZE_VIDEO_QUALITY.get();
private static final boolean SPOOF_STREAMING_DATA_USE_JS =
SPOOF_STREAMING_DATA && J2V8_LIBRARY_AVAILABILITY && BaseSettings.SPOOF_STREAMING_DATA_USE_JS.get();
private static final boolean SPOOF_STREAMING_DATA_USE_JS_ALL =
SPOOF_STREAMING_DATA_USE_JS && BaseSettings.SPOOF_STREAMING_DATA_USE_JS_ALL.get();
/**
* Domain used for internet connectivity verification.
* It has an empty response body and is only used to check for a 204 response code.
* <p>
* If an unreachable IP address (127.0.0.1) is used, no response code is provided.
* <p>
* YouTube handles unreachable IP addresses without issue.
* YouTube Music has an issue with waiting for the Cronet connect timeout of 30_000L on mobile networks.
* <p>
* Using a VPN or DNS can temporarily resolve this issue,
* But the ideal workaround is to avoid using unreachable IP addresses.
*/
private static final String INTERNET_CONNECTION_CHECK_URI_STRING =
"https://www.google.com/gen_204";
private static final Uri INTERNET_CONNECTION_CHECK_URI =
Uri.parse(INTERNET_CONNECTION_CHECK_URI_STRING);
/**
* Parameters used when playing scrim.
*/
private static final String SCRIM_PARAMETERS = "SAFgAXgB";
/**
* Parameters used when playing clips.
*/
private static final String CLIPS_PARAMETERS = "kAIB";
/**
* Prefix present in all Short player parameters signature.
*/
private static final String SHORTS_PLAYER_PARAMETERS = "8AEB";
@Nullable
private static volatile String playerResponseParameter = null;
/**
* Injection point.
* /att/get requests are used to obtain a PoToken challenge.
* See: <a href="https://github.com/FreeTubeApp/FreeTube/blob/4b7208430bc1032019a35a35eb7c8a84987ddbd7/src/botGuardScript.js#L15">botGuardScript.js#L15</a>
* <p>
* Since the Spoof streaming data patch was implemented because a valid PoToken cannot be obtained,
* Blocking /att/get requests are not a problem.
*/
public static String blockGetAttRequest(String originalUrlString) {
if (SPOOF_STREAMING_DATA) {
try {
var originalUri = Uri.parse(originalUrlString);
String path = originalUri.getPath();
if (path != null && path.contains("att/get")) {
Logger.printDebug(() -> "Blocking 'att/get' by returning internet connection check uri");
return INTERNET_CONNECTION_CHECK_URI_STRING;
}
} catch (Exception ex) {
Logger.printException(() -> "blockGetAttRequest failure", ex);
}
}
return originalUrlString;
}
/**
* Injection point.
* Blocks /get_watch requests by returning an internet connection check URI.
*
* @param playerRequestUri The URI of the player request.
* @return An internet connection check URI if the request is a /get_watch request, otherwise the original URI.
*/
public static Uri blockGetWatchRequest(Uri playerRequestUri) {
if (SPOOF_STREAMING_DATA) {
try {
String path = playerRequestUri.getPath();
if (path != null && path.contains("get_watch")) {
Logger.printDebug(() -> "Blocking 'get_watch' by returning internet connection check uri");
return INTERNET_CONNECTION_CHECK_URI;
}
} catch (Exception ex) {
Logger.printException(() -> "blockGetWatchRequest failure", ex);
}
}
return playerRequestUri;
}
/**
* Injection point.
* <p>
* Blocks /initplayback requests.
*/
public static String blockInitPlaybackRequest(String originalUrlString) {
if (SPOOF_STREAMING_DATA) {
try {
var originalUri = Uri.parse(originalUrlString);
String path = originalUri.getPath();
if (path != null && path.contains("initplayback")) {
Logger.printDebug(() -> "Blocking 'initplayback' by returning internet connection check uri");
return INTERNET_CONNECTION_CHECK_URI_STRING;
}
} catch (Exception ex) {
Logger.printException(() -> "blockInitPlaybackRequest failure", ex);
}
}
return originalUrlString;
}
/**
* Injection point.
* Fix audio stuttering in YouTube Music.
*/
public static boolean disableSABR() {
private static boolean isSpoofingEnabled() {
return SPOOF_STREAMING_DATA;
}
/**
* Injection point.
* This method is only invoked when playing a livestream on an iOS client.
*/
public static String blockGetAttRequest(String originalUrlString) {
return SpoofVideoStreamsPatch.blockGetAttRequest(originalUrlString);
}
public static Uri blockGetWatchRequest(Uri playerRequestUri) {
return SpoofVideoStreamsPatch.blockGetWatchRequest(playerRequestUri);
}
public static String blockInitPlaybackRequest(String originalUrlString) {
return SpoofVideoStreamsPatch.blockInitPlaybackRequest(originalUrlString);
}
public static boolean disableSABR() {
return SpoofVideoStreamsPatch.disableSABR();
}
public static boolean fixHLSCurrentTime(boolean original) {
if (!SPOOF_STREAMING_DATA) {
return original;
}
return false;
return SpoofVideoStreamsPatch.fixHLSCurrentTime(original);
}
/**
* Injection point.
* Turns off a feature flag that interferes with spoofing.
*/
public static boolean useMediaFetchHotConfigReplacement(boolean original) {
if (!SPOOF_STREAMING_DATA) {
return original;
}
return false;
return SpoofVideoStreamsPatch.useMediaFetchHotConfigReplacement(original);
}
/**
* Injection point.
* Turns off a feature flag that interferes with video playback.
*/
public static boolean usePlaybackStartFeatureFlag(boolean original) {
if (!SPOOF_STREAMING_DATA) {
return original;
}
return false;
return SpoofVideoStreamsPatch.usePlaybackStartFeatureFlag(original);
}
/**
* Injection point.
*/
public static void fetchStreams(String url, Map<String, String> requestHeader) {
if (SPOOF_STREAMING_DATA) {
Uri uri = Uri.parse(url);
String path = uri.getPath();
if (path == null || !path.contains("player")) {
return;
}
String id = uri.getQueryParameter("id");
if (StringUtils.isEmpty(id)) {
return;
}
String tParameter = YouTubeHelper.generateTParameter(uri.getQueryParameter("t"));
String reasonSkipped;
if (playerResponseParameter != null &&
SPOOF_STREAMING_DATA_USE_JS &&
!SPOOF_STREAMING_DATA_USE_JS_ALL) {
String playerParameter = Objects.requireNonNull(playerResponseParameter);
if (playerParameter.startsWith(SHORTS_PLAYER_PARAMETERS)) {
reasonSkipped = "Shorts";
} else if ("1".equals(uri.getQueryParameter("inline")) || playerParameter.equals(SCRIM_PARAMETERS)) {
reasonSkipped = "Autoplay in feed";
} else if (playerParameter.length() > 150 || playerParameter.startsWith(CLIPS_PARAMETERS)) {
reasonSkipped = "Clips";
} else {
reasonSkipped = "";
}
} else {
reasonSkipped = "";
}
YouTubeAuthPatch.checkAccessToken();
YouTubeVRAuthPatch.checkAccessToken();
StreamingDataRequest.fetchRequest(
id,
tParameter,
requestHeader,
reasonSkipped
);
if (AppCheckPatch.IS_YOUTUBE) {
app.morphe.extension.youtube.patches.spoof.SpoofVideoStreamsPatch.setClientOrderToUse();
} else {
app.morphe.extension.music.patches.spoof.SpoofVideoStreamsPatch.setClientOrderToUse();
}
SpoofVideoStreamsPatch.fetchStreams(url, requestHeader);
}
public static boolean isValidVideoId(@Nullable String videoId) {
return videoId != null && !videoId.isEmpty() && !"zzzzzzzzzzz".equals(videoId);
}
/**
* Injection point.
* Fix playback by replace the streaming data.
* Called after {@link #fetchStreams(String, Map)}.
*/
@Nullable
public static StreamingData getStreamingData(@NonNull String videoId) {
if (SPOOF_STREAMING_DATA && isValidVideoId(videoId)) {
try {
StreamingDataRequest request = StreamingDataRequest.getRequestForVideoId(videoId);
Logger.printInfo(() -> "getStreamingData videoId=" + videoId + " request=" + (request != null ? "ok" : "null") + " postProcessor=" + (streamPostProcessor != null));
if (request != null) {
if (BaseSettings.DEBUG.get() && !request.fetchCompleted() && Utils.isCurrentlyOnMainThread()) {
Logger.printException(() -> "Error: Blocking main thread");
}
var stream = request.getStream();
final boolean hasStream = (stream != null);
Logger.printInfo(() -> "getStreamingData videoId=" + videoId + " stream=" + (hasStream ? "ok" : "null"));
if (stream != null) {
if (streamPostProcessor != null) {
stream = streamPostProcessor.process(stream, videoId);
}
if (stream != null) {
Logger.printDebug(() -> "Overriding video stream: " + videoId);
return stream;
}
}
}
Logger.printInfo(() -> "getStreamingData videoId=" + videoId + " return null (request/stream null or postProcessor returned null)");
} catch (Exception ex) {
Logger.printException(() -> "getStreamingData failure", ex);
}
if (!isSpoofingEnabled()) {
return null;
}
return null;
try {
byte[] playerResponse = SpoofVideoStreamsPatch.getStreamingData(videoId);
if (playerResponse == null) {
return null;
}
return StreamingDataOuterClassPatch.parseFrom(ByteBuffer.wrap(playerResponse));
} catch (Exception ex) {
Logger.printException(() -> "getStreamingData failure", ex);
return null;
}
}
/**
* Injection point.
* Called after {@link #getStreamingData(String)}.
*/
public static List<Object> prioritizeVideoQuality(@Nullable String videoId, @NonNull List<Object> adaptiveFormats) {
if (SPOOF_STREAMING_DATA_PRIORITIZE_VIDEO_QUALITY && isValidVideoId(videoId)) {
return prioritizeResolution(adaptiveFormats);
}
return adaptiveFormats;
}
/**
* Injection point.
* Called after {@link #getStreamingData(String)}.
*/
@Nullable
public static byte[] removeVideoPlaybackPostBody(Uri uri, int method, byte[] postData) {
if (SPOOF_STREAMING_DATA) {
try {
final int methodPost = 2;
if (method == methodPost) {
String path = uri.getPath();
if (path != null && path.contains("videoplayback")) {
return null;
}
}
} catch (Exception ex) {
Logger.printException(() -> "removeVideoPlaybackPostBody failure", ex);
}
}
return postData;
return SpoofVideoStreamsPatch.removeVideoPlaybackPostBody(uri, method, postData);
}
/**
* Injection point.
*/
@Nullable
public static String newPlayerResponseParameter(@NonNull String newlyLoadedVideoId, @Nullable String playerParameter) {
return newPlayerResponseParameter(newlyLoadedVideoId, playerParameter, null, false);
return playerParameter;
}
/**
* Injection point.
* <p>
* Since {@link SpoofStreamingDataPatch} is on a shared path,
* {@link VideoInformation#newPlayerResponseParameter(String, String, String, boolean)} is not used.
*/
@Nullable
public static String newPlayerResponseParameter(@NonNull String newlyLoadedVideoId, @Nullable String playerParameter,
@Nullable String newlyLoadedPlaylistId, boolean isShortAndOpeningOrPlaying) {
playerResponseParameter = playerParameter;
return playerParameter; // Return the original value since we are observing and not modifying.
public static String newPlayerResponseParameter(@NonNull String newlyLoadedVideoId,
@Nullable String playerParameter,
@Nullable String newlyLoadedPlaylistId,
boolean isShortAndOpeningOrPlaying) {
return playerParameter;
}
/**
* Injection point.
* <p>
* It takes about 3-5 seconds to download the JavaScript and initialize the Cipher class.
* Initialize it before the video starts.
* Used for {@link ClientType#TV} and {@link ClientType#TV_EMBEDDED}.
*/
public static void initializeJavascript() {
if (SPOOF_STREAMING_DATA_USE_JS) {
// Download JavaScript and initialize the Cipher class
if (isSDKAbove(24)) {
CompletableFuture.runAsync(ThrottlingParameterUtils::initializeJavascript);
} else {
Utils.runOnBackgroundThread(ThrottlingParameterUtils::initializeJavascript);
}
if (isSpoofingEnabled()
&& Settings.SPOOF_VIDEO_STREAMS_CLIENT_TYPE.get().requireJS
&& J2V8_LIBRARY_AVAILABILITY) {
app.morphe.extension.shared.utils.Utils.runOnBackgroundThread(JavaScriptManager::getSignatureTimestamp);
}
}
/**
* Injection point.
*/
public static String appendSpoofedClient(String format) {
try {
if (SPOOF_STREAMING_DATA && BaseSettings.SPOOF_STREAMING_DATA_STATS_FOR_NERDS.get()
&& !TextUtils.isEmpty(format)) {
// Force LTR layout, to match the same LTR video time/length layout YouTube uses for all languages
return "\u202D" + format + String.format("\u2009(%s)", StreamingDataRequest.getLastSpoofedClientName()); // u202D = left to right override
}
} catch (Exception ex) {
Logger.printException(() -> "appendSpoofedClient failure", ex);
}
return format;
return SpoofVideoStreamsPatch.appendSpoofedClient(format);
}
public static boolean multiAudioTrackAvailable() {
if (!PatchStatus.SpoofStreamingData()) {
return true;
}
if (!BaseSettings.SPOOF_STREAMING_DATA.get()) {
return true;
}
return BaseSettings.SPOOF_STREAMING_DATA_DEFAULT_CLIENT.get().getSupportsMultiAudioTracks();
return !SpoofVideoStreamsPatch.spoofingToClientWithNoMultiAudioStreams();
}
public static final class ClientAndroidVRAvailability implements Setting.Availability {
@Override
public boolean isAvailable() {
return BaseSettings.SPOOF_STREAMING_DATA.get() &&
BaseSettings.SPOOF_STREAMING_DATA_DEFAULT_CLIENT.get().name().startsWith("ANDROID_VR");
return BaseSettings.SPOOF_STREAMING_DATA.get()
&& BaseSettings.SPOOF_STREAMING_DATA_DEFAULT_CLIENT.get().name().startsWith("ANDROID_VR");
}
@Override
@ -412,18 +144,14 @@ public class SpoofStreamingDataPatch {
public static final class ClientJSAvailability implements Setting.Availability {
@Override
public boolean isAvailable() {
return BaseSettings.SPOOF_STREAMING_DATA.get() &&
J2V8_LIBRARY_AVAILABILITY &&
BaseSettings.SPOOF_STREAMING_DATA_USE_JS.get() &&
BaseSettings.SPOOF_STREAMING_DATA_DEFAULT_CLIENT.get().getRequireJS();
return BaseSettings.SPOOF_STREAMING_DATA.get()
&& J2V8_LIBRARY_AVAILABILITY
&& Settings.SPOOF_VIDEO_STREAMS_CLIENT_TYPE.get().requireJS;
}
@Override
public List<Setting<?>> getParentSettings() {
return List.of(
BaseSettings.SPOOF_STREAMING_DATA,
BaseSettings.SPOOF_STREAMING_DATA_USE_JS
);
return List.of(BaseSettings.SPOOF_STREAMING_DATA);
}
}
@ -432,8 +160,6 @@ public class SpoofStreamingDataPatch {
@Override
public boolean isAvailable() {
// Check conditions of launch and now. Otherwise if spoofing is changed
// without a restart the setting will show as available when it's not.
return AVAILABLE_ON_LAUNCH && SpoofStreamingDataPatch.multiAudioTrackAvailable();
}
@ -446,21 +172,19 @@ public class SpoofStreamingDataPatch {
public static final class J2V8Availability implements Setting.Availability {
@Override
public boolean isAvailable() {
return BaseSettings.SPOOF_STREAMING_DATA.get() &&
J2V8_LIBRARY_AVAILABILITY;
return BaseSettings.SPOOF_STREAMING_DATA.get() && J2V8_LIBRARY_AVAILABILITY;
}
}
public static final class ShowReloadVideoButtonAvailability implements Setting.Availability {
private static final boolean AVAILABLE_ON_LAUNCH = BaseSettings.SPOOF_STREAMING_DATA.get() &&
BaseSettings.SPOOF_STREAMING_DATA_RELOAD_VIDEO_BUTTON.get();
private static final boolean AVAILABLE_ON_LAUNCH = BaseSettings.SPOOF_STREAMING_DATA.get()
&& BaseSettings.SPOOF_STREAMING_DATA_RELOAD_VIDEO_BUTTON.get();
@Override
public boolean isAvailable() {
// Check conditions of launch and now. Otherwise if spoofing is changed
// without a restart the setting will show as available when it's not.
return AVAILABLE_ON_LAUNCH && BaseSettings.SPOOF_STREAMING_DATA.get() &&
BaseSettings.SPOOF_STREAMING_DATA_RELOAD_VIDEO_BUTTON.get();
return AVAILABLE_ON_LAUNCH
&& BaseSettings.SPOOF_STREAMING_DATA.get()
&& BaseSettings.SPOOF_STREAMING_DATA_RELOAD_VIDEO_BUTTON.get();
}
@Override

View file

@ -1,561 +0,0 @@
package app.morphe.extension.shared.patches.spoof.requests
import androidx.annotation.GuardedBy
import app.morphe.extension.shared.innertube.client.YouTubeClient
import app.morphe.extension.shared.innertube.client.YouTubeClient.ClientType
import app.morphe.extension.shared.innertube.requests.InnerTubeRequestBody.createApplicationRequestBody
import app.morphe.extension.shared.innertube.requests.InnerTubeRequestBody.createJSRequestBody
import app.morphe.extension.shared.innertube.requests.InnerTubeRequestBody.getInnerTubeResponseConnectionFromRoute
import app.morphe.extension.shared.innertube.requests.InnerTubeRoutes.getStreamingDataRoute
import app.morphe.extension.shared.innertube.utils.PlayerResponseOuterClass.PlayerResponse
import app.morphe.extension.shared.innertube.utils.StreamingDataOuterClassUtils.getAdaptiveFormats
import app.morphe.extension.shared.innertube.utils.StreamingDataOuterClassUtils.getFormats
import app.morphe.extension.shared.innertube.utils.StreamingDataOuterClassUtils.setServerAbrStreamingUrl
import app.morphe.extension.shared.innertube.utils.StreamingDataOuterClassUtils.setUrl
import app.morphe.extension.shared.innertube.utils.ThrottlingParameterUtils
import app.morphe.extension.shared.patches.AppCheckPatch.IS_YOUTUBE
import app.morphe.extension.shared.patches.auth.YouTubeAuthPatch
import app.morphe.extension.shared.patches.auth.YouTubeVRAuthPatch
import app.morphe.extension.shared.patches.components.ByteArrayFilterGroup
import app.morphe.extension.shared.patches.spoof.StreamingDataOuterClassPatch.parseFrom
import app.morphe.extension.shared.settings.BaseSettings
import app.morphe.extension.shared.utils.Logger
import app.morphe.extension.shared.utils.StringRef.str
import app.morphe.extension.shared.utils.Utils
import com.google.protos.youtube.api.innertube.StreamingDataOuterClass.StreamingData
import org.apache.commons.lang3.StringUtils
import java.io.BufferedInputStream
import java.io.ByteArrayOutputStream
import java.io.IOException
import java.net.HttpURLConnection
import java.net.SocketTimeoutException
import java.nio.ByteBuffer
import java.util.Collections
import java.util.Objects
import java.util.concurrent.ExecutionException
import java.util.concurrent.Future
import java.util.concurrent.TimeUnit
import java.util.concurrent.TimeoutException
/**
* Video streaming data. Fetching is tied to the behavior YT uses,
* where this class fetches the streams only when YT fetches.
*
* Effectively the cache expiration of these fetches is the same as the stock app,
* since the stock app would not use expired streams and therefor
* the extension replace stream hook is called only if YT
* did use its own client streams.
*/
@Suppress("deprecation")
class StreamingDataRequest private constructor(
videoId: String,
tParameter: String,
requestHeader: Map<String, String>,
reasonSkipped: String,
) {
private val videoId: String
private val future: Future<StreamingData?>
init {
Objects.requireNonNull(requestHeader)
this.videoId = videoId
this.future = Utils.submitOnBackgroundThread {
fetch(
videoId = videoId,
tParameter = tParameter,
requestHeader = requestHeader,
reasonSkipped = reasonSkipped,
)
}
}
fun fetchCompleted(): Boolean {
return future.isDone
}
val stream: StreamingData?
get() {
try {
return future[MAX_MILLISECONDS_TO_WAIT_FOR_FETCH.toLong(), TimeUnit.MILLISECONDS]
} catch (ex: TimeoutException) {
Logger.printInfo(
{ "getStream timed out" },
ex
)
} catch (ex: InterruptedException) {
Logger.printException(
{ "getStream interrupted" },
ex
)
Thread.currentThread().interrupt() // Restore interrupt status flag.
} catch (ex: ExecutionException) {
Logger.printException(
{ "getStream failure" },
ex
)
}
return null
}
override fun toString(): String {
return "StreamingDataRequest{videoId='$videoId'}"
}
companion object {
private const val AUTHORIZATION_HEADER = "Authorization"
private const val MAX_MILLISECONDS_TO_WAIT_FOR_FETCH = 20 * 1000
private val SPOOF_STREAMING_DATA_DEFAULT_CLIENT: ClientType =
BaseSettings.SPOOF_STREAMING_DATA_DEFAULT_CLIENT.get()
private val CLIENT_ORDER_TO_USE: Array<ClientType> =
YouTubeClient.availableClientTypes(SPOOF_STREAMING_DATA_DEFAULT_CLIENT)
private val SPOOF_STREAMING_DATA_USE_JS_BYPASS_FAKE_BUFFERING: Boolean =
BaseSettings.SPOOF_STREAMING_DATA_USE_JS_BYPASS_FAKE_BUFFERING.get()
private val liveStreams: ByteArrayFilterGroup =
ByteArrayFilterGroup(
null,
"yt_live_broadcast",
"yt_premiere_broadcast"
)
private var appendSignIn: Boolean = false
private var lastSpoofedClient: ClientType? = null
@GuardedBy("itself")
val cache: MutableMap<String, StreamingDataRequest> = Collections.synchronizedMap(
object : LinkedHashMap<String, StreamingDataRequest>(100) {
private val CACHE_LIMIT = 50
override fun removeEldestEntry(eldest: Map.Entry<String, StreamingDataRequest>): Boolean {
return size > CACHE_LIMIT // Evict the oldest entry if over the cache limit.
}
})
@JvmStatic
val lastSpoofedClientName: String
get() = lastSpoofedClient?.friendlyName?.let {
if (appendSignIn) "$it Signed in" else it
} ?: "Unknown"
@JvmStatic
fun fetchRequest(
videoId: String,
tParameter: String,
fetchHeaders: Map<String, String>,
reasonSkipped: String,
) {
// Always fetch, even if there is an existing request for the same video.
cache[videoId] =
StreamingDataRequest(
videoId,
tParameter,
fetchHeaders,
reasonSkipped,
)
}
@JvmStatic
fun getRequestForVideoId(videoId: String): StreamingDataRequest? {
return cache[videoId]
}
/** Invalidates the cached request for a videoId. The next time this video is loaded, a new request will be created (if the app makes a new player request). Used by VOT when enabling translation before reloading. */
@JvmStatic
fun invalidateCacheForVideoId(videoId: String) {
cache.remove(videoId)
}
private fun handleConnectionError(
toastMessage: String,
ex: Exception?,
showToast: Boolean = false,
) {
if (showToast) Utils.showToastShort(toastMessage)
Logger.printInfo({ toastMessage }, ex)
}
private fun replaceHeader(
clientType: ClientType,
requestHeader: Map<String, String>,
): Map<String, String> {
appendSignIn = false
if (clientType == ClientType.ANDROID_VR) {
val finalRequestHeader: MutableMap<String, String> =
LinkedHashMap(requestHeader.size)
for (key in requestHeader.keys) {
val value = requestHeader[key]
if (value != null) {
if (key == AUTHORIZATION_HEADER) {
if (YouTubeVRAuthPatch.isAuthorizationAvailable()) {
finalRequestHeader[AUTHORIZATION_HEADER] = YouTubeVRAuthPatch.getAuthorization()
appendSignIn = true
}
continue
}
finalRequestHeader[key] = value
}
}
return finalRequestHeader
} else if (!IS_YOUTUBE &&
clientType == ClientType.ANDROID_NO_SDK) {
val finalRequestHeader: MutableMap<String, String> =
LinkedHashMap(requestHeader.size)
for (key in requestHeader.keys) {
val value = requestHeader[key]
if (value != null) {
if (key == AUTHORIZATION_HEADER) {
if (YouTubeAuthPatch.isAuthorizationAvailable()) {
finalRequestHeader[AUTHORIZATION_HEADER] = YouTubeAuthPatch.getAuthorization()
appendSignIn = true
}
continue
}
finalRequestHeader[key] = value
}
}
return finalRequestHeader
}
return requestHeader
}
private fun getPlayabilityStatus(
videoId: String,
streamBytes: ByteArray
): String? {
val startTime = System.currentTimeMillis()
try {
val playerResponse: PlayerResponse? = PlayerResponse.parseFrom(streamBytes)
if (playerResponse != null) {
val playabilityStatus = playerResponse.getPlayabilityStatus()
if (playabilityStatus != null) {
return playabilityStatus.getStatus().name
}
}
} catch (ex: Exception) {
Logger.printException({ "Get playability status failed" }, ex)
} finally {
Logger.printDebug { "Get playability status end (videoId: $videoId, took: ${(System.currentTimeMillis() - startTime)} ms)" }
}
return null
}
private fun getDeobfuscatedUrlArrayList(
videoId: String,
streamBytes: ByteArray,
): Triple<ArrayList<String>?, ArrayList<String>?, String?>? {
val startTime = System.currentTimeMillis()
try {
val playerResponse: PlayerResponse? = PlayerResponse.parseFrom(streamBytes)
if (playerResponse != null) {
val streamingData = playerResponse.getStreamingData()
if (streamingData == null) {
Logger.printDebug { "StreamingData is null, legacy client will be used" }
return null
}
val adaptiveFormatsCount = streamingData.adaptiveFormatsCount
if (adaptiveFormatsCount < 1) {
Logger.printDebug { "AdaptiveFormats is empty, legacy client will be used" }
return null
}
val deobfuscatedAdaptiveFormatsArrayList: ArrayList<String> =
ArrayList(adaptiveFormatsCount)
for (i in 0..<adaptiveFormatsCount) {
val adaptiveFormats = streamingData.getAdaptiveFormats(i)
if (adaptiveFormats != null) {
val deobfuscatedUrl =
ThrottlingParameterUtils.deobfuscateStreamingUrl(
videoId,
adaptiveFormats.url,
adaptiveFormats.signatureCipher,
)
if (deobfuscatedUrl.isNullOrEmpty()) {
Logger.printDebug { "Failed to decrypt n-sig or signatureCipher, please check if latest regular expressions are being used" }
return null
}
deobfuscatedAdaptiveFormatsArrayList.add(i, deobfuscatedUrl)
}
}
val deobfuscatedFormatsArrayList: ArrayList<String> = ArrayList(0)
val formatsCount = streamingData.formatsCount
if (formatsCount > 0) {
for (i in 0..<formatsCount) {
val formats = streamingData.getFormats(i)
if (formats == null) {
Logger.printDebug { "Formats is empty" }
deobfuscatedFormatsArrayList.clear()
break
}
val deobfuscatedUrl =
ThrottlingParameterUtils.deobfuscateStreamingUrl(
videoId,
formats.url,
formats.signatureCipher,
)
if (deobfuscatedUrl.isNullOrEmpty()) {
Logger.printDebug { "Failed to decrypt n-sig or signatureCipher" }
deobfuscatedFormatsArrayList.clear()
break
}
deobfuscatedFormatsArrayList.add(i, deobfuscatedUrl)
}
}
var serverAbrStreamingUrl = streamingData.serverAbrStreamingUrl
if (!serverAbrStreamingUrl.isNullOrEmpty()) {
serverAbrStreamingUrl = ThrottlingParameterUtils
.deobfuscateStreamingUrl(
videoId,
serverAbrStreamingUrl,
null,
)
}
return Triple(
deobfuscatedAdaptiveFormatsArrayList,
deobfuscatedFormatsArrayList,
serverAbrStreamingUrl
)
}
} catch (ex: Exception) {
Logger.printException({ "Get deobfuscatedUrls failed" }, ex)
} finally {
Logger.printDebug { "Get deobfuscatedUrls end (videoId: $videoId, took: ${(System.currentTimeMillis() - startTime)} ms)" }
}
return null
}
private fun deobfuscateStreamingData(
deobfuscatedUrlArrayList: ArrayList<String>,
isAdaptiveFormats: Boolean,
streamingData: StreamingData
): StreamingData {
val formats = if (isAdaptiveFormats) {
getAdaptiveFormats(streamingData)
} else {
getFormats(streamingData)
}
if (formats != null) {
for (i in 0..<formats.size) {
val format = formats[i]
val url = deobfuscatedUrlArrayList[i]
setUrl(format, url)
}
}
return streamingData
}
private fun send(
clientType: ClientType,
videoId: String,
tParameter: String,
requestHeader: Map<String, String>,
): HttpURLConnection? {
val startTime = System.currentTimeMillis()
Logger.printDebug { "Fetching video streams for: $videoId using client: $clientType" }
try {
val requestBody = if (clientType.requireJS) {
// Javascript is used to get the signatureTimestamp
createJSRequestBody(
clientType = clientType,
videoId = videoId,
isGVS = true,
isInlinePlayback = SPOOF_STREAMING_DATA_USE_JS_BYPASS_FAKE_BUFFERING,
)
} else {
createApplicationRequestBody(
clientType = clientType,
videoId = videoId,
)
}
val connection =
getInnerTubeResponseConnectionFromRoute(
getStreamingDataRoute(
tParameter,
SPOOF_STREAMING_DATA_USE_JS_BYPASS_FAKE_BUFFERING
),
clientType,
replaceHeader(clientType, requestHeader)
)
connection.setFixedLengthStreamingMode(requestBody.size)
connection.outputStream.write(requestBody)
val responseCode = connection.responseCode
if (responseCode == 200) return connection
// 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.responseMessage),
null
)
} catch (ex: SocketTimeoutException) {
handleConnectionError("Connection timeout", ex)
} catch (ex: IOException) {
handleConnectionError("Network error", ex)
} catch (ex: Exception) {
Logger.printException({ "send failed" }, ex)
} finally {
Logger.printDebug { "Fetching video streams request end (videoId: $videoId, took: ${(System.currentTimeMillis() - startTime)} ms)" }
}
return null
}
private fun fetch(
videoId: String,
tParameter: String,
requestHeader: Map<String, String>,
reasonSkipped: String,
): StreamingData? {
Utils.verifyOffMainThread()
lastSpoofedClient = null
// Retry with different client if empty response body is received.
for (clientType in CLIENT_ORDER_TO_USE) {
if (clientType.requireAuth &&
StringUtils.isEmpty(requestHeader[AUTHORIZATION_HEADER])
) {
Logger.printDebug { "Skipped login-required client (clientType: $clientType, videoId: $videoId)" }
continue
}
if (clientType.requireJS && reasonSkipped.isNotEmpty()) {
Logger.printDebug { "Skipped javascript required client (reasonSkipped: $reasonSkipped, clientType: $clientType, videoId: $videoId)" }
continue
}
val connection = send(
clientType,
videoId,
tParameter,
requestHeader,
)
if (connection != null) {
try {
// gzip encoding doesn't response with content length (-1),
// but empty response body does.
if (connection.contentLength == 0) {
Logger.printDebug { "Received empty response (clientType: $clientType, videoId: $videoId)" }
} else {
BufferedInputStream(connection.inputStream).use { inputStream ->
ByteArrayOutputStream().use { stream ->
val buffer = ByteArray(2048)
var bytesRead: Int
while ((inputStream.read(buffer)
.also { bytesRead = it }) >= 0
) {
stream.write(buffer, 0, bytesRead)
}
// Android Creator can't play livestreams, but it doesn't have an empty response (no formats available).
// Since it doesn't have an empty response, the app doesn't try to fetch with another client, and it tries to play the livestream.
// However, the response doesn't contain any formats available, so an exception is thrown.
// As a workaround for this issue, if Android Creator is used for fetching, it should check if the video is a livestream.
if (clientType == ClientType.ANDROID_CREATOR
&& liveStreams.check(buffer).isFiltered //
) {
Logger.printDebug { "Ignore Android Studio spoofing as it is a livestream (videoId: $videoId)" }
} else {
lastSpoofedClient = null
// Parses the Proto Buffer and returns StreamingData (GeneratedMessage).
val streamBytes: ByteArray = stream.toByteArray()
val playabilityStatus =
getPlayabilityStatus(videoId, streamBytes)
if (playabilityStatus == "OK") {
var streamingData =
parseFrom(ByteBuffer.wrap(streamBytes))
if (streamingData != null) {
if (clientType.requireJS) {
// ArrayList containing the deobfuscated streamingUrl
val arrayLists = getDeobfuscatedUrlArrayList(
videoId,
streamBytes
)
if (arrayLists != null) {
// MutableMap containing the deobfuscated streamingUrl.
// This is used for clients where streamingUrl is obfuscated.
val deobfuscatedAdaptiveFormatsArrayList =
arrayLists.first
val deobfuscatedFormatsArrayList =
arrayLists.second
val serverAbrStreamingUrl =
arrayLists.third
if (!deobfuscatedAdaptiveFormatsArrayList.isNullOrEmpty()) {
streamingData =
deobfuscateStreamingData(
deobfuscatedUrlArrayList = deobfuscatedAdaptiveFormatsArrayList,
isAdaptiveFormats = true,
streamingData = streamingData
)
}
if (!deobfuscatedFormatsArrayList.isNullOrEmpty()) {
streamingData =
deobfuscateStreamingData(
deobfuscatedUrlArrayList = deobfuscatedFormatsArrayList,
isAdaptiveFormats = false,
streamingData = streamingData
)
}
if (!serverAbrStreamingUrl.isNullOrEmpty()) {
setServerAbrStreamingUrl(
streamingData,
serverAbrStreamingUrl
)
}
lastSpoofedClient = clientType
return streamingData
}
} else {
lastSpoofedClient = clientType
return streamingData
}
} else {
Logger.printDebug { "Ignore empty streamingData, (clientType: $clientType, videoId: $videoId)" }
}
} else if (playabilityStatus == "LIVE_STREAM_OFFLINE") {
Logger.printDebug { "Ignore UPCOMING video (videoId: $videoId)" }
return null
} else {
Logger.printDebug { "Ignore unplayable video, (playabilityStatus: $playabilityStatus, clientType: $clientType, videoId: $videoId)" }
}
}
}
}
}
} catch (ex: IOException) {
Logger.printException({ "Fetch failed while processing response data" }, ex)
}
}
}
handleConnectionError(
str("revanced_spoof_streaming_data_failed_forbidden"),
null,
true
)
if (!SPOOF_STREAMING_DATA_DEFAULT_CLIENT.name.startsWith("TV")) {
handleConnectionError(
str("revanced_spoof_streaming_data_failed_forbidden_suggestion"),
null,
true
)
}
return null
}
}
}

View file

@ -5,7 +5,6 @@ import static java.lang.Boolean.TRUE;
import static app.morphe.extension.shared.patches.AppCheckPatch.IS_YOUTUBE;
import static app.morphe.extension.shared.settings.Setting.parent;
import app.morphe.extension.shared.innertube.client.YouTubeClient.ClientType;
import app.morphe.extension.shared.patches.ReturnYouTubeUsernamePatch.DisplayFormat;
import app.morphe.extension.shared.patches.WatchHistoryPatch.WatchHistoryType;
import app.morphe.extension.shared.patches.spoof.SpoofStreamingDataPatch;
@ -13,6 +12,7 @@ import app.morphe.extension.shared.patches.spoof.SpoofStreamingDataPatch.ClientA
import app.morphe.extension.shared.patches.spoof.SpoofStreamingDataPatch.ClientJSAvailability;
import app.morphe.extension.shared.patches.spoof.SpoofStreamingDataPatch.J2V8Availability;
import app.morphe.extension.shared.patches.spoof.SpoofStreamingDataPatch.ShowReloadVideoButtonAvailability;
import app.morphe.extension.shared.spoof.ClientType;
/**
* Settings shared across multiple apps.
@ -46,9 +46,8 @@ public class BaseSettings {
public static final BooleanSetting SPOOF_STREAMING_DATA_USE_JS_BYPASS_FAKE_BUFFERING = new BooleanSetting("revanced_spoof_streaming_data_use_js_bypass_fake_buffering", FALSE, true, new ClientJSAvailability());
// Client type must be last spoof setting due to cyclic references.
// TV_SIMPLY for YouTube so 4K/HDR formats are returned when spoofing (e.g. devices without GMS).
public static final EnumSetting<ClientType> SPOOF_STREAMING_DATA_DEFAULT_CLIENT = new EnumSetting<>("revanced_spoof_streaming_data_default_client",
IS_YOUTUBE ? ClientType.TV : ClientType.ANDROID_MUSIC_NO_SDK, true, parent(SPOOF_STREAMING_DATA));
IS_YOUTUBE ? ClientType.TV : ClientType.ANDROID_VR_1_47_48, true, parent(SPOOF_STREAMING_DATA));
public static final BooleanSetting ENABLE_COMMENTS_SCROLL_TOP = new BooleanSetting("revanced_enable_comments_scroll_top", FALSE, true);
public static final BooleanSetting DISABLE_AUTO_AUDIO_TRACKS = new BooleanSetting("revanced_disable_auto_audio_tracks", TRUE);

View file

@ -0,0 +1,52 @@
package app.morphe.extension.shared.settings;
import static java.lang.Boolean.FALSE;
import static java.lang.Boolean.TRUE;
import static app.morphe.extension.shared.settings.Setting.migrateOldSettingToNew;
import static app.morphe.extension.shared.settings.Setting.parent;
import app.morphe.extension.shared.spoof.SpoofVideoStreamsPatch.JavaScriptClientAvailability;
import app.morphe.extension.shared.spoof.SpoofVideoStreamsPatch.JavaScriptHashAvailability;
import app.morphe.extension.shared.spoof.js.JavaScriptVariant;
/**
* Settings shared by YouTube and YouTube Music.
* <p>
* To ensure this class is loaded when the UI is created, app specific setting bundles should extend
* or reference this class.
*/
public class SharedYouTubeSettings extends BaseSettings {
public static final BooleanSetting SETTINGS_SEARCH_HISTORY = new BooleanSetting("morphe_settings_search_history", TRUE, true);
public static final StringSetting SETTINGS_SEARCH_ENTRIES = new StringSetting("morphe_settings_search_entries", "");
public static final BooleanSetting DISABLE_DRC_AUDIO = new BooleanSetting("morphe_disable_drc_audio", FALSE, true);
public static final BooleanSetting DISABLE_QUIC_PROTOCOL = new BooleanSetting("morphe_disable_quic_protocol", FALSE, true);
public static final BooleanSetting SPOOF_VIDEO_STREAMS = new BooleanSetting("morphe_spoof_video_streams", TRUE, true, "morphe_spoof_video_streams_user_dialog_message");
public static final BooleanSetting SPOOF_VIDEO_STREAMS_STATS_FOR_NERDS = new BooleanSetting("morphe_spoof_video_streams_stats_for_nerds", TRUE, parent(SPOOF_VIDEO_STREAMS));
public static final EnumSetting<JavaScriptVariant> SPOOF_VIDEO_STREAMS_PLAYER_JS_VARIANT = new EnumSetting<>("morphe_spoof_video_streams_player_js_variant", JavaScriptVariant.PHONE, true,
new JavaScriptClientAvailability());
public static final BooleanSetting SPOOF_VIDEO_STREAMS_DISABLE_PLAYER_JS_UPDATE = new BooleanSetting("morphe_spoof_video_streams_disable_player_js_update", FALSE, true,
"morphe_spoof_video_streams_disable_player_js_update_user_dialog_message", new JavaScriptClientAvailability());
public static final StringSetting SPOOF_VIDEO_STREAMS_PLAYER_JS_HASH = new StringSetting("morphe_spoof_video_streams_player_js_hash", "", true,
new JavaScriptHashAvailability());
public static final LongSetting SPOOF_VIDEO_STREAMS_PLAYER_JS_SAVED_MILLISECONDS = new LongSetting("morphe_spoof_video_streams_player_js_saved_milliseconds", -1L, false, false);
public static final StringSetting OAUTH2_REFRESH_TOKEN = new StringSetting("morphe_oauth2_refresh_token", "", false, false);
public static final BooleanSetting SANITIZE_SHARING_LINKS = new BooleanSetting("morphe_sanitize_sharing_links", TRUE);
public static final BooleanSetting REPLACE_MUSIC_LINKS_WITH_YOUTUBE = new BooleanSetting("morphe_replace_music_with_youtube", FALSE);
public static final BooleanSetting CHECK_WATCH_HISTORY_DOMAIN_NAME = new BooleanSetting("morphe_check_watch_history_domain_name", TRUE, false, false);
public static final StringSetting DISABLED_FEATURE_FLAGS = new StringSetting("morphe_disabled_feature_flags", "", true, parent(DEBUG));
// Renamed settings
private static final BooleanSetting DEPRECATED_SANITIZE_URL_QUERY = new BooleanSetting("morphe_sanitize_url_query", TRUE);
static {
// TODO: Eventually remove these migrations
migrateOldSettingToNew(DEPRECATED_SANITIZE_URL_QUERY, SANITIZE_SHARING_LINKS);
}
}

View file

@ -0,0 +1,333 @@
package app.morphe.extension.shared.settings.preference;
import static app.morphe.extension.shared.StringRef.str;
import static app.morphe.extension.shared.oauth2.requests.OAuth2Requester.isActivationCodeDataAvailable;
import android.app.Activity;
import android.app.Application;
import android.content.Context;
import android.content.Intent;
import android.net.Uri;
import android.os.Bundle;
import android.preference.Preference;
import android.util.AttributeSet;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import app.morphe.extension.shared.Logger;
import app.morphe.extension.shared.Utils;
import app.morphe.extension.shared.oauth2.object.AccessTokenData;
import app.morphe.extension.shared.oauth2.object.ActivationCodeData;
import app.morphe.extension.shared.oauth2.requests.OAuth2Requester;
import app.morphe.extension.shared.settings.SharedYouTubeSettings;
import app.morphe.extension.shared.spoof.SpoofVideoStreamsPatch;
import app.morphe.extension.shared.ui.CustomDialog;
@SuppressWarnings("deprecation")
public abstract class OAuth2Preference extends Preference implements Preference.OnPreferenceClickListener {
{
setOnPreferenceClickListener(this);
}
/**
* How many times to try to get a refresh token after the user returns to the app.
*/
private static final int GET_REFRESH_TOKENS_MAX_ATTEMPTS = 5;
/**
* Interval of fetched {@link ActivationCodeData#interval}.
*/
private int getTokenIntervalCheckMilliseconds;
/**
* How many get token attempts are left. Used to prevent re-trying if the user abandons
* the website sign-in process.
*/
private int getTokenAttemptsLeft;
/**
* The last time token auth was checked. Required to prevent "too_fast" errors
* if re-checking too quickly.
*/
private long lastGetTokenAttemptTime;
/**
* If a get token attempt is scheduled for the future.
*/
private boolean getTokenAttemptScheduled;
/**
* Callback when the app is resumed. Used to automatically finish the client side sign in process.
*/
private final Application.ActivityLifecycleCallbacks ACTIVITY_LIFECYCLE_CALLBACKS
= new Application.ActivityLifecycleCallbacks() {
public void onActivityResumed(@NonNull Activity activity) {
// Check for auth approval when the app resumes.
// This could be done in the background on a timer, but the Google update interval is
// usually 5 seconds which means if the user returns to the app there could be up to 5-second
// delay before showing the success dialog.
//
// Instead, check when the app resumes with some logic to wait a few seconds if the
// user is changing back and forth before auth is approved.
Logger.printDebug(() -> "onActivityResumed");
if (isActivationCodeDataAvailable()) {
if (getTokenAttemptScheduled) {
return;
}
final long now = System.currentTimeMillis();
if (System.currentTimeMillis() > (lastGetTokenAttemptTime + getTokenIntervalCheckMilliseconds)) {
lastGetTokenAttemptTime = now;
getRefreshToken();
} else {
getTokenAttemptScheduled = true;
// Too soon to check again. Schedule a check in the future.
final long delayMillis = getTokenIntervalCheckMilliseconds - (now - lastGetTokenAttemptTime);
Logger.printDebug(() -> "Scheduling get token check in: " + delayMillis + "ms");
Utils.runOnMainThreadDelayed(() -> {
lastGetTokenAttemptTime = System.currentTimeMillis();
getTokenAttemptScheduled = false;
getRefreshToken();
}, delayMillis);
}
}
}
public void onActivityCreated(@NonNull Activity a, @Nullable Bundle b) {}
public void onActivityStarted(@NonNull Activity a) {}
public void onActivityPaused(@NonNull Activity a) {}
public void onActivityStopped(@NonNull Activity a) {}
public void onActivitySaveInstanceState(@NonNull Activity a, @NonNull Bundle b) {}
public void onActivityDestroyed(@NonNull Activity a) {}
};
private void registerApplicationOnResumeCallback() {
SpoofVideoStreamsPatch.getApplication().registerActivityLifecycleCallbacks(
ACTIVITY_LIFECYCLE_CALLBACKS
);
}
private void unregisterApplicationOnResumeCallback() {
SpoofVideoStreamsPatch.getApplication().unregisterActivityLifecycleCallbacks(
ACTIVITY_LIFECYCLE_CALLBACKS
);
}
@Override
protected void onPrepareForRemoval() {
super.onPrepareForRemoval();
// Remove just in case the user never finished signing in.
unregisterApplicationOnResumeCallback();
}
protected boolean isRefreshTokenSaved() {
return !SharedYouTubeSettings.OAUTH2_REFRESH_TOKEN.get().isEmpty();
}
protected void updateUI(boolean currentlySignedIn) {
final boolean isSettingEnabled = isSettingEnabled();
setEnabled(isSettingEnabled);
String summaryKey;
if (isSettingEnabled) {
summaryKey = currentlySignedIn
? "morphe_spoof_video_streams_sign_in_android_vr_about_summary_signed_in"
: "morphe_spoof_video_streams_sign_in_android_vr_about_summary";
} else {
summaryKey = "morphe_spoof_video_streams_sign_in_android_vr_about_summary_disabled";
}
setSummary(str(summaryKey));
}
protected void updateUI() {
updateUI(isRefreshTokenSaved());
}
public OAuth2Preference(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) {
super(context, attrs, defStyleAttr, defStyleRes);
updateUI();
}
public OAuth2Preference(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
updateUI();
}
public OAuth2Preference(Context context, AttributeSet attrs) {
super(context, attrs);
updateUI();
}
public OAuth2Preference(Context context) {
super(context);
updateUI();
}
/**
* @return If the app spoof settings are configured in a way to allow using VR sign in.
*/
protected abstract boolean isSettingEnabled();
@Override
public boolean onPreferenceClick(Preference preference) {
String titleKey;
String messageKey;
String okButtonTextKey;
Runnable okButtonRunnable;
if (isRefreshTokenSaved()) {
titleKey = "morphe_spoof_video_streams_sign_in_android_vr_about_summary_signed_in";
messageKey = "morphe_spoof_video_streams_sign_in_android_vr_success_dialog_message";
okButtonTextKey = "morphe_spoof_video_streams_sign_in_android_vr_dialog_reset";
okButtonRunnable = () -> {
OAuth2Requester.revokeToken(SharedYouTubeSettings.OAUTH2_REFRESH_TOKEN.get());
// Don't wait for revoke to finish and clear the token now so UI is up to date.
SharedYouTubeSettings.OAUTH2_REFRESH_TOKEN.resetToDefault();
updateUI(false);
};
} else {
titleKey = "morphe_spoof_video_streams_sign_in_android_vr_dialog_title";
messageKey = "morphe_spoof_video_streams_sign_in_android_vr_dialog_not_signed_in_message";
okButtonTextKey = "morphe_spoof_video_streams_sign_in_android_vr_dialog_continue";
okButtonRunnable = this::showActivationCodeDialog;
}
CustomDialog.create(
getContext(),
// Title.
str(titleKey),
// Message.
BulletPointPreference.formatIntoBulletPoints(str(messageKey)),
// No EditText.
null,
// OK button text.
str(okButtonTextKey),
// OK button action.
okButtonRunnable,
// Cancel button action.
() -> {
},
// Neutral button text.
null,
// Neutral button action.
null,
// Dismiss dialog when onNeutralClick.
true
).first.show();
return true;
}
private void showActivationCodeDialog() {
getTokenAttemptsLeft = GET_REFRESH_TOKENS_MAX_ATTEMPTS;
Context context = getContext();
Utils.runOnBackgroundThread(() -> {
ActivationCodeData activationCodeData = OAuth2Requester.getActivationCodeData();
if (activationCodeData == null) {
Utils.showToastLong(str("morphe_spoof_video_streams_sign_in_android_vr_toast_get_activation_code_failed"));
return;
}
Utils.runOnMainThread(() -> {
String userCode = activationCodeData.userCode;
String verificationUrl = activationCodeData.verificationUrl;
getTokenIntervalCheckMilliseconds = 1000 * activationCodeData.interval;
CustomDialog.create(
context,
// Title.
str("morphe_spoof_video_streams_sign_in_android_vr_activation_code_dialog_title"),
// Message.
str("morphe_spoof_video_streams_sign_in_android_vr_activation_code_dialog_message", userCode),
// No EditText.
null,
// OK button text.
str("morphe_spoof_video_streams_sign_in_android_vr_activation_code_dialog_open_website"),
// OK button action.
() -> {
// Automatically fetch the auth token after the user returns.
registerApplicationOnResumeCallback();
Utils.setClipboard(userCode);
Intent i = new Intent(Intent.ACTION_VIEW);
i.setData(Uri.parse(verificationUrl));
context.startActivity(i);
},
// Cancel button action (dismiss only).
null,
// Neutral button text.
null,
// Neutral button action.
null,
// Dismiss dialog when onNeutralClick.
false
).first.show();
});
});
}
private void getRefreshToken() {
final boolean reachedMaxGetTokenAttempts = --getTokenAttemptsLeft <= 0;
if (reachedMaxGetTokenAttempts) {
unregisterApplicationOnResumeCallback();
}
Context context = getContext();
Utils.runOnBackgroundThread(() -> {
AccessTokenData accessTokenData = OAuth2Requester.getRefreshTokenData(reachedMaxGetTokenAttempts);
Utils.runOnMainThread(() -> {
if (accessTokenData == null) {
Logger.printDebug(() -> "No refresh token found");
if (reachedMaxGetTokenAttempts) {
Utils.showToastLong(str("morphe_spoof_video_streams_sign_in_android_vr_toast_get_authorization_code_failed"));
}
return;
}
String refreshToken = accessTokenData.refreshToken;
if (refreshToken == null || refreshToken.isEmpty()) {
Logger.printException(() -> "No refresh token found");
return;
}
unregisterApplicationOnResumeCallback();
SharedYouTubeSettings.OAUTH2_REFRESH_TOKEN.save(refreshToken);
OAuth2Requester.setAuthorization(accessTokenData);
updateUI(true);
CustomDialog.create(
context,
// Title.
str("morphe_spoof_video_streams_sign_in_android_vr_success_dialog_title"),
// Message.
BulletPointPreference.formatIntoBulletPoints(
str("morphe_spoof_video_streams_sign_in_android_vr_success_dialog_message")
),
// No EditText.
null,
// OK button text.
null,
// OK button action.
() -> {
},
// Cancel button action.
null,
// Neutral button text.
null,
// Neutral button action.
null,
// Dismiss dialog when onNeutralClick.
true
).first.show();
});
});
}
}

View file

@ -1,5 +1,6 @@
package app.morphe.extension.shared.settings.preference;
import android.annotation.SuppressLint;
import android.content.Context;
import android.content.SharedPreferences;
import android.preference.PreferenceFragment;
@ -9,8 +10,8 @@ import androidx.annotation.Nullable;
import java.util.Objects;
import app.morphe.extension.shared.utils.Logger;
import app.morphe.extension.shared.utils.Utils;
import app.morphe.extension.shared.Logger;
import app.morphe.extension.shared.Utils;
/**
* Shared categories, and helper methods.
@ -37,19 +38,27 @@ public class SharedPrefCategory {
removeKey(key);
}
@SuppressLint("ApplySharedPref") // Must use commit to ensure default value is not saved to preferences.
private void saveObjectAsString(@NonNull String key, @Nullable Object value) {
preferences.edit().putString(key, (value == null ? null : value.toString())).apply();
preferences.edit().putString(key, (value == null ? null : value.toString())).commit();
}
@SuppressLint("ApplySharedPref") // Must use commit to ensure default value is not saved to preferences.
public void clear() {
preferences.edit().clear().commit();
}
/**
* Removes any preference data type that has the specified key.
*/
@SuppressLint("ApplySharedPref") // Must use commit to ensure default value is not saved to preferences.
public void removeKey(@NonNull String key) {
preferences.edit().remove(Objects.requireNonNull(key)).apply();
preferences.edit().remove(Objects.requireNonNull(key)).commit();
}
@SuppressLint("ApplySharedPref") // Must use commit to ensure default value is not saved to preferences.
public void saveBoolean(@NonNull String key, boolean value) {
preferences.edit().putBoolean(key, value).apply();
preferences.edit().putBoolean(key, value).commit();
}
/**
@ -110,7 +119,7 @@ public class SharedPrefCategory {
return (T) Enum.valueOf(_default.getClass(), enumName);
} catch (IllegalArgumentException ex) {
// Info level to allow removing enum values in the future without showing any user errors.
Logger.printInfo(() -> "Using default, and ignoring unknown enum value: " + enumName);
Logger.printInfo(() -> "Using default, and ignoring unknown enum value: " + enumName);
removeKey(key);
}
}

View file

@ -0,0 +1,402 @@
/*
* Copyright 2026 Morphe.
* https://github.com/MorpheApp/morphe-patches
*
* Original hard forked code:
* https://github.com/ReVanced/revanced-patches/commit/724e6d61b2ecd868c1a9a37d465a688e83a74799
*/
package app.morphe.extension.shared.spoof;
import static app.morphe.extension.shared.patches.AppCheckPatch.IS_YOUTUBE;
import static app.morphe.extension.shared.patches.AppCheckPatch.IS_YOUTUBE_MUSIC;
import android.os.Build;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import java.util.Locale;
import java.util.Objects;
import app.morphe.extension.shared.Logger;
import app.morphe.extension.shared.Utils;
@SuppressWarnings("ConstantLocale")
public enum ClientType {
/**
* Video not playable: Paid, Movie, Private, Age-restricted.
* Uses non-adaptive bitrate.
* AV1 codec available.
*/
ANDROID_REEL(
3,
"ANDROID",
"com.google.android.youtube",
Build.MANUFACTURER,
Build.MODEL,
"Android",
Build.VERSION.RELEASE,
String.valueOf(Build.VERSION.SDK_INT),
Build.ID,
// A hardcoded client version is used for YouTube Music.
IS_YOUTUBE ? Utils.getAppVersionName() : "20.26.46",
null,
// This client has been used by most open-source YouTube stream extraction tools since 2024, including NewPipe Extractor, SmartTube, and Grayjay.
// This client can log in, but if an access token is used in the request, GVS can more easily identify the request as coming from Morphe.
// This means that the GVS server can strengthen its validation of the ANDROID_REEL client.
// For this reason, ANDROID_REEL is used as a logout client.
false,
false,
true,
false,
false,
false,
"Android Reel"
),
/**
* Video not playable in YouTube: All videos (This client requires login, but cannot log in with YouTube's access token).
* Video not playable in YouTube Music: None.
* Uses non-adaptive bitrate.
*/
ANDROID_MUSIC_NO_SDK(
21,
"ANDROID_MUSIC",
ANDROID_REEL.deviceMake,
ANDROID_REEL.deviceModel,
ANDROID_REEL.osName,
ANDROID_REEL.osVersion,
"7.12.52",
null,
"com.google.android.apps.youtube.music/7.12.52 (Linux; U; Android " + Build.VERSION.RELEASE + ") gzip",
IS_YOUTUBE_MUSIC,
true,
false,
false,
false,
true,
"Android Music No SDK"
),
/**
* Video not playable: Kids, Paid, Movie, Private, Age-restricted.
* Uses non-adaptive bitrate.
* AV1 codec available.
*/
// https://dumps.tadiphone.dev/dumps/oculus/eureka
ANDROID_VR_1_54_20(
28,
"ANDROID_VR",
"com.google.android.apps.youtube.vr.oculus",
"Oculus",
"Quest 3",
"Android",
"14",
"34",
"UP1A.231005.007.A1",
"1.54.20",
null,
false,
false,
false,
true,
false,
true,
"Android VR 1.54"
),
/**
* Uses non adaptive bitrate.
* AV1 codec not available.
*/
// https://dumps.tadiphone.dev/dumps/oculus/monterey
ANDROID_VR_1_47_48(
ANDROID_VR_1_54_20.id,
ANDROID_VR_1_54_20.clientName,
Objects.requireNonNull(ANDROID_VR_1_54_20.packageName),
ANDROID_VR_1_54_20.deviceMake,
"Quest",
ANDROID_VR_1_54_20.osName,
"10",
"29",
"QQ3A.200805.001",
"1.47.48",
ANDROID_VR_1_54_20.clientPlatform,
ANDROID_VR_1_54_20.canLogin,
ANDROID_VR_1_54_20.requireLogin,
ANDROID_VR_1_54_20.supportsMultiAudioTracks,
ANDROID_VR_1_54_20.supportsOAuth2,
ANDROID_VR_1_54_20.requireJS,
ANDROID_VR_1_54_20.usePlayerEndpoint,
"Android VR 1.47"
),
/**
* Video not playable: Livestream.
* Uses non-adaptive bitrate.
* AV1 codec and HDR codec are not available, and the maximum resolution is 720p.
*/
// https://dumps.tadiphone.dev/dumps/google/mustang
ANDROID_CREATOR(
14,
"ANDROID_CREATOR",
"com.google.android.apps.youtube.creator",
"Google",
"Pixel 10 Pro XL",
"Android",
"16",
"36",
"BD3A.251005.003.W3",
"26.10.000",
null,
true,
true,
false,
false,
false,
true,
"Android Studio"
),
/**
* Video not playable: None.
* Uses non adaptive bitrate.
* AV1 codec available.
*/
TV(7,
"TVHTML5",
"Samsung",
"SmartTV",
"Tizen",
"2.4.0",
"5.20150304",
"TV",
// Currently, it is the only User-Agent available for signed out among TV clients, but sign in is still required for certain IP bands or countries.
"Mozilla/5.0 (SMART-TV; Linux; Tizen 2.4.0) AppleWebKit/538.1 (KHTML, like Gecko) Version/2.4.0 TV Safari/538.1",
true,
false,
true,
false,
true,
true,
"TV"
),
/**
* May stop working at any time.
*/
VISIONOS(101,
"VISIONOS",
"Apple",
"RealityDevice14,1",
"visionOS",
"1.3.21O771",
"0.1",
null,
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 Safari/605.1.15",
false,
false,
false,
false,
false,
true,
"visionOS"
),
/**
* Here only to migrate data.
*/
@Deprecated
TV_SIMPLY(75,
"TVHTML5_SIMPLY",
"Microsoft",
"Xbox 360",
"Xbox",
"6.1",
"1.0",
"GAME_CONSOLE",
"Mozilla/5.0 (compatible; MSIE 9.0; Windows NT 6.1; Trident/5.0; Xbox)",
true,
// PoToken is required to play videos while signed out.
true,
true,
false,
true,
true,
"TV Simply"
);
/**
* YouTube
* <a href="https://github.com/zerodytrash/YouTube-Internal-Clients?tab=readme-ov-file#clients">client type</a>
*/
public final int id;
public final String clientName;
/**
* App package name.
*/
@Nullable
private final String packageName;
/**
* Player user-agent.
*/
public final String userAgent;
/**
* Device model, equivalent to {@link Build#MANUFACTURER} (System property: ro.product.vendor.manufacturer)
*/
public final String deviceMake;
/**
* Device model, equivalent to {@link Build#MODEL} (System property: ro.product.vendor.model)
*/
public final String deviceModel;
/**
* Device OS name.
*/
public final String osName;
/**
* Device OS version.
*/
public final String osVersion;
/**
* Android SDK version, equivalent to {@link Build.VERSION#SDK} (System property: ro.build.version.sdk)
* Field is null if not applicable.
*/
@Nullable
public final String androidSdkVersion;
/**
* App version.
*/
public final String clientVersion;
/**
* Client platform enum.
*/
public final String clientPlatform;
/**
* If the client can access the API logged in.
*/
public final boolean canLogin;
/**
* If the client should use authentication if available.
*/
public final boolean requireLogin;
/**
* If the client supports oauth2.0 for limited-input device.
*/
public final boolean supportsOAuth2;
/**
* If the client supports multiple audio tracks.
*/
public final boolean supportsMultiAudioTracks;
/**
* The streaming url has an obfuscated 'n' parameter.
* If true, JavaScript must be fetched to decrypt the 'n' parameter.
*/
public final boolean requireJS;
/**
* Whether to use the '/player' endpoint.
*/
public final boolean usePlayerEndpoint;
/**
* Friendly name displayed in stats for nerds.
*/
public final String friendlyName;
/**
* Android constructor.
*/
ClientType(int id,
String clientName,
@NonNull String packageName,
String deviceMake,
String deviceModel,
String osName,
String osVersion,
@NonNull String androidSdkVersion,
@NonNull String buildId,
String clientVersion,
String clientPlatform,
boolean canLogin,
boolean requireLogin,
boolean supportsMultiAudioTracks,
boolean supportsOAuth2,
boolean requireJS,
boolean usePlayerEndpoint,
String friendlyName) {
this.id = id;
this.clientName = clientName;
this.packageName = packageName;
this.deviceMake = deviceMake;
this.deviceModel = deviceModel;
this.osName = osName;
this.osVersion = osVersion;
this.androidSdkVersion = androidSdkVersion;
this.clientVersion = clientVersion;
this.clientPlatform = clientPlatform;
this.canLogin = canLogin;
this.requireLogin = requireLogin;
this.supportsMultiAudioTracks = supportsMultiAudioTracks;
this.supportsOAuth2 = supportsOAuth2;
this.requireJS = requireJS;
this.usePlayerEndpoint = usePlayerEndpoint;
this.friendlyName = friendlyName;
Locale defaultLocale = Locale.getDefault();
this.userAgent = String.format(Locale.ENGLISH,
"%s/%s (Linux; U; Android %s; %s; %s; Build/%s)",
packageName,
clientVersion,
osVersion,
defaultLocale,
deviceModel,
buildId
);
Logger.printDebug(() -> "userAgent: " + this.userAgent);
}
ClientType(int id,
String clientName,
String deviceMake,
String deviceModel,
String osName,
String osVersion,
String clientVersion,
String clientPlatform,
String userAgent,
boolean canLogin,
boolean requireLogin,
boolean supportsMultiAudioTracks,
boolean supportsOAuth2,
boolean requireJS,
boolean usePlayerEndpoint,
String friendlyName) {
this.id = id;
this.clientName = clientName;
this.deviceMake = deviceMake;
this.deviceModel = deviceModel;
this.osName = osName;
this.osVersion = osVersion;
this.clientVersion = clientVersion;
this.clientPlatform = clientPlatform;
this.userAgent = userAgent;
this.canLogin = canLogin;
this.requireLogin = requireLogin;
this.supportsMultiAudioTracks = supportsMultiAudioTracks;
this.supportsOAuth2 = supportsOAuth2;
this.requireJS = requireJS;
this.usePlayerEndpoint = usePlayerEndpoint;
this.friendlyName = friendlyName;
this.packageName = null;
this.androidSdkVersion = null;
}
}

View file

@ -0,0 +1,383 @@
package app.morphe.extension.shared.spoof;
import android.app.Activity;
import android.app.Application;
import android.net.Uri;
import android.text.TextUtils;
import androidx.annotation.Nullable;
import java.lang.ref.WeakReference;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import app.morphe.extension.shared.Logger;
import app.morphe.extension.shared.Utils;
import app.morphe.extension.shared.settings.AppLanguage;
import app.morphe.extension.shared.settings.BaseSettings;
import app.morphe.extension.shared.settings.Setting;
import app.morphe.extension.shared.settings.SharedYouTubeSettings;
import app.morphe.extension.shared.spoof.requests.StreamingDataRequest;
@SuppressWarnings("unused")
public class SpoofVideoStreamsPatch {
public static final class JavaScriptClientAvailability implements Setting.Availability {
@Override
public boolean isAvailable() {
return SharedYouTubeSettings.SPOOF_VIDEO_STREAMS.isAvailable() && preferredClient.requireJS;
}
@Override
public List<Setting<?>> getParentSettings() {
return List.of(SharedYouTubeSettings.SPOOF_VIDEO_STREAMS);
}
}
public static final class JavaScriptHashAvailability implements Setting.Availability {
@Override
public boolean isAvailable() {
return SharedYouTubeSettings.SPOOF_VIDEO_STREAMS.isAvailable() && preferredClient.requireJS &&
SharedYouTubeSettings.SPOOF_VIDEO_STREAMS_DISABLE_PLAYER_JS_UPDATE.get();
}
@Override
public List<Setting<?>> getParentSettings() {
return List.of(
SharedYouTubeSettings.SPOOF_VIDEO_STREAMS,
SharedYouTubeSettings.SPOOF_VIDEO_STREAMS_DISABLE_PLAYER_JS_UPDATE
);
}
}
/**
* Domain used for internet connectivity verification.
* It has an empty response body and is only used to check for a 204 response code.
* <p>
* If an unreachable IP address (127.0.0.1) is used, no response code is provided.
* <p>
* YouTube handles unreachable IP addresses without issue.
* YouTube Music has an issue with waiting for the Cronet connect timeout of 30s on mobile networks.
* <p>
* Using a VPN or DNS can temporarily resolve this issue,
* But the ideal workaround is to avoid using an unreachable IP address.
*/
private static final String INTERNET_CONNECTION_CHECK_URI_STRING = "https://www.google.com/gen_204";
private static final Uri INTERNET_CONNECTION_CHECK_URI = Uri.parse(INTERNET_CONNECTION_CHECK_URI_STRING);
private static final boolean SPOOF_VIDEO_STREAMS = SharedYouTubeSettings.SPOOF_VIDEO_STREAMS.get();
@Nullable
private static volatile AppLanguage languageOverride;
private static volatile ClientType preferredClient = ClientType.ANDROID_VR_1_47_48;
private static WeakReference<Application> mainActivityRef = new WeakReference<>(null);
/**
* Injection point.
*/
public static void setMainActivity(Activity activity) {
mainActivityRef = new WeakReference<>(activity.getApplication());
}
public static Application getApplication() {
return mainActivityRef.get();
}
/**
* @return If this patch was included during patching.
*/
public static boolean isPatchIncluded() {
return false; // Modified during patching.
}
@Nullable
public static AppLanguage getLanguageOverride() {
return languageOverride;
}
/**
* @param language Language override for non-authenticated requests.
*/
public static void setLanguageOverride(@Nullable AppLanguage language) {
languageOverride = language;
}
public static void setClientsToUse(List<ClientType> availableClients, ClientType client) {
preferredClient = Objects.requireNonNull(client);
StreamingDataRequest.setClientOrderToUse(availableClients, client);
}
public static ClientType getPreferredClient() {
return preferredClient;
}
public static boolean spoofingToClientWithNoMultiAudioStreams() {
return isPatchIncluded()
&& SPOOF_VIDEO_STREAMS
&& !preferredClient.supportsMultiAudioTracks;
}
/**
* Injection point.
* Blocks /get_watch requests by returning an unreachable URI.
*
* @param playerRequestUri The URI of the player request.
* @return An unreachable URI if the request is a /get_watch request, otherwise the original URI.
*/
public static Uri blockGetWatchRequest(Uri playerRequestUri) {
if (SPOOF_VIDEO_STREAMS) {
try {
String path = playerRequestUri.getPath();
if (path != null && path.contains("get_watch")) {
Logger.printDebug(() -> "Blocking 'get_watch' by returning internet connection check URI");
return INTERNET_CONNECTION_CHECK_URI;
}
} catch (Exception ex) {
Logger.printException(() -> "blockGetWatchRequest failure", ex);
}
}
return playerRequestUri;
}
/**
* Injection point.
* <p>
* Blocks /get_watch requests by returning an unreachable URI.
* /att/get requests are used to obtain a PoToken challenge.
* See: <a href="https://github.com/FreeTubeApp/FreeTube/blob/4b7208430bc1032019a35a35eb7c8a84987ddbd7/src/botGuardScript.js#L15">botGuardScript.js#L15</a>
* <p>
* Since the Spoof streaming data patch was implemented because a valid PoToken cannot be obtained,
* Blocking /att/get requests are not a problem.
*/
public static String blockGetAttRequest(String originalUrlString) {
if (SPOOF_VIDEO_STREAMS) {
try {
var originalUri = Uri.parse(originalUrlString);
String path = originalUri.getPath();
if (path != null && path.contains("att/get")) {
Logger.printDebug(() -> "Blocking 'att/get' by returning internet connection check URI");
return INTERNET_CONNECTION_CHECK_URI_STRING;
}
} catch (Exception ex) {
Logger.printException(() -> "blockGetAttRequest failure", ex);
}
}
return originalUrlString;
}
/**
* Injection point.
* <p>
* Blocks /initplayback requests.
*/
public static String blockInitPlaybackRequest(String originalUrlString) {
if (SPOOF_VIDEO_STREAMS) {
try {
var originalUri = Uri.parse(originalUrlString);
String path = originalUri.getPath();
if (path != null && path.contains("initplayback")) {
Logger.printDebug(() -> "Blocking 'initplayback' by returning internet connection check URI");
return INTERNET_CONNECTION_CHECK_URI_STRING;
}
} catch (Exception ex) {
Logger.printException(() -> "blockInitPlaybackRequest failure", ex);
}
}
return originalUrlString;
}
/**
* Injection point.
*/
public static boolean isSpoofingEnabled() {
return SPOOF_VIDEO_STREAMS;
}
/**
* Injection point.
* Only invoked when playing a livestream on an Apple client.
*/
public static boolean fixHLSCurrentTime(boolean original) {
if (!SPOOF_VIDEO_STREAMS) {
return original;
}
return false;
}
/**
* Injection point.
* Fix audio stuttering in YouTube Music.
*/
public static boolean disableSABR() {
return SPOOF_VIDEO_STREAMS;
}
/**
* Injection point.
* Turns off a feature flag that interferes with spoofing.
*/
public static boolean useMediaFetchHotConfigReplacement(boolean original) {
if (original) {
Logger.printDebug(() -> "useMediaFetchHotConfigReplacement is set on");
}
if (!SPOOF_VIDEO_STREAMS) {
return original;
}
return false;
}
/**
* Injection point.
* Turns off a feature flag that interferes with video playback.
*/
public static boolean usePlaybackStartFeatureFlag(boolean original) {
if (original) {
Logger.printDebug(() -> "usePlaybackStartFeatureFlag is set on");
}
if (!SPOOF_VIDEO_STREAMS) {
return original;
}
return false;
}
/**
* Injection point.
* Turns off a feature flag that interferes with video playback.
*/
public static boolean useMediaSessionFeatureFlag(boolean original) {
if (original) {
Logger.printDebug(() -> "useMediaSessionFeatureFlag is set on");
}
if (!SPOOF_VIDEO_STREAMS) {
return original;
}
return false;
}
/**
* Injection point.
*/
public static void fetchStreams(String url, Map<String, String> requestHeaders) {
if (SPOOF_VIDEO_STREAMS) {
try {
Uri uri = Uri.parse(url);
String path = uri.getPath();
if (path == null || !path.contains("player")) {
return;
}
// 'get_drm_license' has no video ID and appears to happen when waiting for a paid video to start.
// 'heartbeat' has no video and appears to be only after playback has started.
// 'refresh' has no video ID and appears to happen when waiting for a livestream to start.
// 'ad_break' has no video ID.
if (path.contains("get_drm_license") || path.contains("heartbeat")
|| path.contains("refresh") || path.contains("ad_break")) {
Logger.printDebug(() -> "Ignoring path: " + path);
return;
}
String id = uri.getQueryParameter("id");
if (id == null) {
Logger.printException(() -> "Ignoring request with no ID: " + url);
return;
}
StreamingDataRequest.fetchRequest(id, requestHeaders);
} catch (Exception ex) {
Logger.printException(() -> "buildRequest failure", ex);
}
}
}
/**
* Injection point.
* Fix playback by replace the streaming data.
* Called after {@link #fetchStreams(String, Map)}.
*/
@Nullable
public static byte[] getStreamingData(String videoId) {
if (SPOOF_VIDEO_STREAMS) {
try {
StreamingDataRequest request = StreamingDataRequest.getRequestForVideoId(videoId);
if (request != null) {
// This hook is always called off the main thread,
// but this can later be called for the same video ID from the main thread.
// This is not a concern, since the fetch will always be finished
// and never block the main thread.
// But if debugging, then still verify this is the situation.
if (BaseSettings.DEBUG.get() && !request.fetchCompleted() && Utils.isCurrentlyOnMainThread()) {
Logger.printException(() -> "Error: Blocking main thread");
}
var stream = request.getStream();
if (stream != null) {
Logger.printDebug(() -> "Overriding video stream: " + videoId);
return stream;
}
}
Logger.printDebug(() -> "Not overriding streaming data (video stream is null): " + videoId);
} catch (Exception ex) {
Logger.printException(() -> "getStreamingData failure", ex);
}
}
return null;
}
/**
* Injection point.
* Called after {@link #getStreamingData(String)}.
*/
@Nullable
public static byte[] removeVideoPlaybackPostBody(Uri uri, int method, byte[] postData) {
if (SPOOF_VIDEO_STREAMS) {
try {
final int methodPost = 2;
if (method == methodPost) {
String path = uri.getPath();
if (path != null && path.contains("videoplayback")) {
return null;
}
}
} catch (Exception ex) {
Logger.printException(() -> "removeVideoPlaybackPostBody failure", ex);
}
}
return postData;
}
/**
* Injection point.
*/
public static String appendSpoofedClient(String videoFormat) {
try {
if (SPOOF_VIDEO_STREAMS && SharedYouTubeSettings.SPOOF_VIDEO_STREAMS_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 + "\u2009(" // u202D = left to right override
+ StreamingDataRequest.getLastSpoofedClientName() + ")";
}
} catch (Exception ex) {
Logger.printException(() -> "appendSpoofedClient failure", ex);
}
return videoFormat;
}
}

View file

@ -0,0 +1,39 @@
package app.morphe.extension.shared.spoof.js;
import java.io.File;
import app.morphe.extension.shared.Logger;
import app.morphe.extension.shared.Utils;
/**
* In general, there is no error in 'System.loadLibrary()' because the patched app contains the J2V8 native library (libj2v8.so).
* If the app is installed via mount, it cannot find the native library, causing a runtime exception and a fatal crash.
* Therefore, before loading the library, the app first checks whether the native library exists.
* If the native library does not exist, the JavaScript client is skipped.
*/
public class J2V8Support {
private static final boolean DEVICE_SUPPORT_J2V8;
static {
boolean j2v8Support = false;
try {
String libraryDir = Utils.getContext()
.getApplicationContext()
.getApplicationInfo()
.nativeLibraryDir;
File j2v8File = new File(libraryDir + "/libj2v8.so");
j2v8Support = j2v8File.exists();
} catch (Exception ex) {
Logger.printException(() -> "J2V8 native library not found", ex);
}
DEVICE_SUPPORT_J2V8 = j2v8Support;
Logger.printDebug(() -> DEVICE_SUPPORT_J2V8
? "Device supports J2V8"
: "Device does not support J2V8");
}
public static boolean supportJ2V8() {
return DEVICE_SUPPORT_J2V8;
}
}

View file

@ -0,0 +1,515 @@
/*
* Copyright 2026 Morphe.
* https://github.com/MorpheApp/morphe-patches
*/
package app.morphe.extension.shared.spoof.js;
import static app.morphe.extension.shared.Utils.isNotEmpty;
import android.os.Build;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import java.io.BufferedReader;
import java.io.FileInputStream;
import java.io.InputStreamReader;
import java.io.UnsupportedEncodingException;
import java.net.HttpURLConnection;
import java.net.URLDecoder;
import java.nio.file.Files;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Objects;
import java.util.concurrent.ExecutionException;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import app.morphe.extension.shared.Logger;
import app.morphe.extension.shared.Utils;
import app.morphe.extension.shared.innertube.PlayerResponseOuterClass.Format;
import app.morphe.extension.shared.innertube.PlayerResponseOuterClass.StreamingData;
import app.morphe.extension.shared.requests.Requester;
import app.morphe.extension.shared.settings.BaseSettings;
import app.morphe.extension.shared.settings.LongSetting;
import app.morphe.extension.shared.settings.Setting;
import app.morphe.extension.shared.settings.SharedYouTubeSettings;
import app.morphe.extension.shared.settings.StringSetting;
import app.morphe.extension.shared.settings.preference.AbstractPreferenceFragment;
import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.net.URL;
/**
* The functions used in this class are referenced below:
* - <a href="https://github.com/TeamNewPipe/NewPipeExtractor/blob/d9e9911e78d5a6db45c1daeeea5280d10ca3f70d/extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/YoutubeJavaScriptExtractor.java">TeamNewPipe/NewPipeExtractor#YoutubeJavaScriptExtractor</a>
* - <a href="https://github.com/TeamNewPipe/NewPipeExtractor/blob/d9e9911e78d5a6db45c1daeeea5280d10ca3f70d/extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/YoutubeJavaScriptPlayerManager.java">TeamNewPipe/NewPipeExtractor#YoutubeJavaScriptPlayerManager</a>
* - <a href="https://github.com/TeamNewPipe/NewPipeExtractor/blob/d9e9911e78d5a6db45c1daeeea5280d10ca3f70d/extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/YoutubeSignatureUtils.java">TeamNewPipe/NewPipeExtractor#YoutubeSignatureUtils</a>
* - <a href="https://github.com/TeamNewPipe/NewPipeExtractor/blob/d9e9911e78d5a6db45c1daeeea5280d10ca3f70d/extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/YoutubeThrottlingParameterUtils.java">TeamNewPipe/NewPipeExtractor#YoutubeThrottlingParameterUtils</a>
*/
public final class JavaScriptManager {
/**
* Regular expression pattern to find the signature timestamp.
*/
private static final Pattern SIGNATURE_TIMESTAMP_PATTERN = Pattern.compile("signatureTimestamp[=:](\\d+)");
/**
* Regular expression pattern to find the 'n' parameter in streamingUrl.
*/
private static final Pattern THROTTLING_PARAM_N_PATTERN = Pattern.compile("[&?]n=([^&]+)");
/**
* Regular expression pattern to find the 's' parameter in signatureCipher.
*/
private static final Pattern THROTTLING_PARAM_S_PATTERN = Pattern.compile("s=([^&]+)");
/**
* Regular expression pattern to find the 'url' parameter in signatureCipher.
*/
private static final Pattern THROTTLING_PARAM_URL_PATTERN = Pattern.compile("&url=([^&]+)");
/**
* Regular expression pattern to find variables used in JavaScript url.
*/
private static final Pattern PLAYER_JS_HASH_PATTERN =
Pattern.compile("player\\\\/([a-z0-9]{8})\\\\/");
/**
* JavaScript cached time setting.
*/
private static final LongSetting PLAYER_JS_SAVED_MILLISECONDS =
SharedYouTubeSettings.SPOOF_VIDEO_STREAMS_PLAYER_JS_SAVED_MILLISECONDS;
/**
* JavaScript hash setting.
*/
private static final StringSetting PLAYER_JS_HASH =
SharedYouTubeSettings.SPOOF_VIDEO_STREAMS_PLAYER_JS_HASH;
/**
* Variant of JavaScript url.
*/
private static final JavaScriptVariant PLAYER_JS_VARIANT =
SharedYouTubeSettings.SPOOF_VIDEO_STREAMS_PLAYER_JS_VARIANT.get();
/**
* Format of JavaScript url.
*/
private static final String BASE_JS_PLAYER_URL_FORMAT = PLAYER_JS_VARIANT.format;
/**
* Url used to find variables used in JavaScript url.
*/
private static final String IFRAME_API_URL = "https://www.youtube.com/iframe_api";
/**
* Player JavaScript is approximately 3MB in size,
* So downloading it every time the app is launched results in unnecessary data usage.
* Player JavaScript has a lifespan of approximately one month, and new versions are released from the server every 3 days.
* Downloaded player JavaScript is saved in the cache directory and remains valid for approximately 3 days.
*/
private static final long PLAYER_JS_CACHE_EXPIRATION_MILLISECONDS = 3 * 24 * 60 * 60 * 1000L; // 3 days.
/**
* User-agent of the Mobile client.
*/
private static final String USER_AGENT =
"Mozilla/5.0 (Android 13; Mobile; rv:100.0) Gecko/100.0 Firefox/100.0";
/**
* Class used to deobfuscate, powered by SmartTube.
*/
@Nullable
private volatile static PlayerDataExtractor cachedPlayerDataExtractor = null;
/**
* JavaScript contents.
*/
@Nullable
private volatile static String cachedPlayerJs = null;
/**
* JavaScript file to be saved in the cache directory.
*/
@Nullable
private volatile static File cachedPlayerJsFile = null;
/**
* JavaScript url hash.
*/
@Nullable
private volatile static String cachedPlayerJsHash = null;
/**
* JavaScript url.
*/
@Nullable
private volatile static String cachedPlayerJsUrl = null;
/**
* Field value included when sending a request.
*/
@Nullable
private volatile static Integer cachedSignatureTimestamp = null;
private JavaScriptManager() {
}
private static void handleDebugToast(String message) {
if (BaseSettings.DEBUG.get() && BaseSettings.DEBUG_TOAST_ON_ERROR.get()) {
Utils.showToastShort(message);
} else {
Logger.printInfo(() -> message);
}
}
@Nullable
private static PlayerDataExtractor getPlayerDataExtractor() {
if (cachedPlayerDataExtractor == null) {
String playerJs = getPlayerJs();
if (isNotEmpty(playerJs)) {
cachedPlayerDataExtractor = new PlayerDataExtractor(playerJs, Objects.requireNonNull(cachedPlayerJsHash));
} else {
Logger.printException(() -> "playerJs not found");
}
}
return cachedPlayerDataExtractor;
}
@Nullable
private static String getPlayerJs() {
if (cachedPlayerJs == null) {
String playerJsUrl = getPlayerJsUrl();
if (isNotEmpty(playerJsUrl)) {
File cacheFile = Objects.requireNonNull(cachedPlayerJsFile);
String cacheFileName = cacheFile.getName();
long currentTime = System.currentTimeMillis();
// There is a player JavaScript saved in the cache, and it was saved within 3 days.
// Use the player JavaScript saved in the cache.
if (cacheFile.exists() && (currentTime - cacheFile.lastModified()) < PLAYER_JS_CACHE_EXPIRATION_MILLISECONDS) {
String cachedData = readFromFile(cacheFile);
if (isNotEmpty(cachedData)) {
Logger.printDebug(() -> "Player js cache found: " + cacheFileName);
cachedPlayerJs = cachedData;
return cachedData;
}
}
// There is no player JavaScript save in the cache,
// or if there is a player JavaScript, it has been more than 3 days since it was last saved.
// Download the latest player JavaScript.
String playerJs = downloadUrl(playerJsUrl);
if (isNotEmpty(playerJs)) {
cachedPlayerJs = playerJs;
saveToFile(cacheFile, playerJs);
Logger.printDebug(() -> "Saved Player js cache: " + cacheFileName);
}
} else {
Logger.printException(() -> "playerJsUrl not found");
}
}
return cachedPlayerJs;
}
@Nullable
private static String getPlayerJsUrl() {
if (cachedPlayerJsUrl == null) {
long currentTime = System.currentTimeMillis();
long lastSavedTime = PLAYER_JS_SAVED_MILLISECONDS.get();
if (!PLAYER_JS_HASH.get().isEmpty()
// If 'Disable player JavaScript update' is enabled, the 'Player JavaScript hash' will always be used.
// In other words, the cache expiration is not checked, and the YouTube iframe API is not fetched either.
&& (SharedYouTubeSettings.SPOOF_VIDEO_STREAMS_DISABLE_PLAYER_JS_UPDATE.get()
|| currentTime - lastSavedTime < PLAYER_JS_CACHE_EXPIRATION_MILLISECONDS)) {
// There is a hash saved in the settings and it was saved within 3 days.
// Use the hash saved in the settings.
cachedPlayerJsHash = PLAYER_JS_HASH.get();
String playerJsHash = cachedPlayerJsHash;
Logger.printDebug(() -> "Player js hash found in cache: " + playerJsHash);
} else {
// There is no hash save in the settings,
// or if there is a hash, it has been more than 3 days since it was last saved.
// To get the latest hash, fetch the iframe API.
String iframeContent = downloadUrl(IFRAME_API_URL);
if (isNotEmpty(iframeContent)) {
Matcher matcher = PLAYER_JS_HASH_PATTERN.matcher(iframeContent);
if (matcher.find()) {
cachedPlayerJsHash = matcher.group(1);
// The simplest way to prevent the restart dialog from showing.
AbstractPreferenceFragment.settingImportInProgress = true;
Setting.privateSetValueFromString(PLAYER_JS_HASH, cachedPlayerJsHash);
PLAYER_JS_HASH.saveToPreferences();
AbstractPreferenceFragment.settingImportInProgress = false;
PLAYER_JS_SAVED_MILLISECONDS.save(currentTime);
} else {
Logger.printException(() -> "iframeContent not found");
}
}
}
if (isNotEmpty(cachedPlayerJsHash)) {
cachedPlayerJsFile = new File(Utils.getContext().getCacheDir(), "player_js_" + PLAYER_JS_VARIANT.name().toLowerCase() + "_" + cachedPlayerJsHash + ".js");
cachedPlayerJsUrl = String.format(BASE_JS_PLAYER_URL_FORMAT, cachedPlayerJsHash);
}
}
return cachedPlayerJsUrl;
}
@Nullable
public static Integer getSignatureTimestamp() {
if (cachedSignatureTimestamp == null) {
try {
String playerJs = getPlayerJs();
if (isNotEmpty(playerJs)) {
Matcher matcher = SIGNATURE_TIMESTAMP_PATTERN.matcher(playerJs);
if (matcher.find()) {
String signatureTimestamp = matcher.group(1);
if (isNotEmpty(signatureTimestamp)) {
cachedSignatureTimestamp = Integer.parseInt(signatureTimestamp);
} else {
Logger.printException(() -> "SignatureTimestamp is null or empty");
}
} else {
Logger.printException(() -> "SignatureTimestamp not found");
}
}
} catch (Exception ex) {
Logger.printException(() -> "Failed to set SignatureTimestamp", ex);
}
}
return cachedSignatureTimestamp;
}
@Nullable
public static String getJavaScriptHash() {
return cachedPlayerJsHash;
}
@NonNull
public static String getJavaScriptVariant() {
return PLAYER_JS_VARIANT.name();
}
@Nullable
public static String downloadUrl(@NonNull String url) {
String content = null;
try {
final long start = System.currentTimeMillis();
content = Utils.submitOnBackgroundThread(() -> {
final int connectionTimeoutMillis = 5000;
HttpURLConnection connection = (HttpURLConnection) new URL(url).openConnection();
connection.setFixedLengthStreamingMode(0);
connection.setRequestMethod("GET");
connection.setRequestProperty("User-Agent", USER_AGENT);
connection.setConnectTimeout(connectionTimeoutMillis);
connection.setReadTimeout(connectionTimeoutMillis);
final int responseCode = connection.getResponseCode();
if (responseCode == HttpURLConnection.HTTP_OK) {
return Requester.parseStringAndDisconnect(connection);
}
connection.disconnect();
return null;
}).get();
Logger.printDebug(() -> "Download took: " + (System.currentTimeMillis() - start) + "ms for URL: " + url);
} catch (ExecutionException | InterruptedException ex) {
Logger.printException(() -> "Could not download URL: " + url, ex);
}
return content;
}
/**
* JavaScript clients require deciphering.
* Deciphering is performed in format, adaptiveFormat, and serverAbrStreamingUrl.
* Deciphering is performed by executing the player JavaScript with the V8 runtime.
* A single execution of the V8 runtime requires significant computational time.
* To minimize computational time, all query parameters requiring deciphering are stored in a single request (List),
* and all query parameters are deobfuscated with a single run of the runtime.
*
* @param streamingData StreamingData containing obfuscated parameters.
* @return StreamingData builder containing deobfuscated parameters.
*/
@Nullable
public static StreamingData.Builder getDeobfuscatedStreamingData(StreamingData streamingData) {
StreamingData.Builder streamingDataBuilder = streamingData.toBuilder();
String serverAbrStreamingUrl = streamingData.getServerAbrStreamingUrl();
// Initialize streamingDataBuilder before adding formats.
streamingDataBuilder.clearFormats();
// Deobfuscate formats.
boolean deobfuscateResult = deobfuscateFormat(
streamingDataBuilder,
streamingData.getFormatsList(),
serverAbrStreamingUrl,
false
);
if (!deobfuscateResult) {
return null;
}
// Initialize streamingDataBuilder before adding adaptiveFormats.
streamingDataBuilder.clearAdaptiveFormats();
// Deobfuscate adaptiveFormats.
deobfuscateResult = deobfuscateFormat(
streamingDataBuilder,
streamingData.getAdaptiveFormatsList(),
serverAbrStreamingUrl,
true
);
if (!deobfuscateResult) {
return null;
}
return streamingDataBuilder;
}
private static boolean deobfuscateFormat(StreamingData.Builder streamingDataBuilder,
List<Format> formats,
String serverAbrStreamingUrl,
boolean isAdaptiveFormats) {
PlayerDataExtractor playerDataExtractor = getPlayerDataExtractor();
if (playerDataExtractor != null && formats != null && !formats.isEmpty()) {
// In streamingData, all n-parameters have the same value.
String obfuscatedNParameter = null;
List<String> obfuscatedSParameters = new ArrayList<>();
List<String> obfuscatedUrlParameters = new ArrayList<>();
// formats or adaptiveFormats have a signatureCipher or a url.
// Therefore, the computation time can be reduced by checking whether the first format has a signatureCipher or not.
boolean hasSignatureCipher = isNotEmpty(formats.get(0).getSignatureCipher());
for (Format format : formats) {
// If a signatureCipher is present, the url field must be assembled while iterating over each format.
if (hasSignatureCipher) {
String signatureCipher = format.getSignatureCipher();
Matcher sParamMatcher = THROTTLING_PARAM_S_PATTERN.matcher(signatureCipher);
Matcher urlParamMatcher = THROTTLING_PARAM_URL_PATTERN.matcher(signatureCipher);
if (sParamMatcher.find() && urlParamMatcher.find()) {
// The s-parameter from signatureCipher.
String obfuscatedSParameter = sParamMatcher.group(1);
// The url-parameter from signatureCipher.
String obfuscatedUrl = urlParamMatcher.group(1);
if (isNotEmpty(obfuscatedSParameter) && isNotEmpty(obfuscatedUrl)) {
obfuscatedUrl = decodeURL(obfuscatedUrl);
obfuscatedSParameters.add(decodeURL(obfuscatedSParameter));
obfuscatedUrlParameters.add(obfuscatedUrl);
if (obfuscatedNParameter == null) {
obfuscatedNParameter = getNQueryParameter(obfuscatedUrl);
}
}
}
} else { // If a url is present, simply iterate over each format and replace the n-parameters.
String nQueryParameter = getNQueryParameter(format.getUrl());
if (isNotEmpty(nQueryParameter)) {
obfuscatedNParameter = nQueryParameter;
break;
}
}
}
// streamingData always has one n-parameter.
if (isNotEmpty(obfuscatedNParameter)) {
var results = playerDataExtractor.bulkSigExtract(
Collections.singletonList(obfuscatedNParameter),
obfuscatedSParameters
);
// Since there is only one obfuscated n-parameter, there is also only one deobfuscated n-parameter
List<String> deobfuscatedNParameters = results.first;
if (deobfuscatedNParameters.isEmpty()) {
handleDebugToast("Debug: Failed to deobfuscate n-parameter");
return false;
}
String deobfuscatedNParameter = deobfuscatedNParameters.get(0);
List<String> deobfuscatedSParameters = results.second;
if (hasSignatureCipher && deobfuscatedSParameters.isEmpty()) {
handleDebugToast("Debug: Failed to deobfuscate signatureCipher");
return false;
}
int i = 0;
for (Format format : formats) {
Format.Builder formatBuilder = format.toBuilder();
String obfuscatedUrl = hasSignatureCipher
// Assemble the url.
? obfuscatedUrlParameters.get(i) + "&sig=" + deobfuscatedSParameters.get(i)
: format.getUrl();
formatBuilder.setUrl(obfuscatedUrl.replace(obfuscatedNParameter, deobfuscatedNParameter));
formatBuilder.clearSignatureCipher();
Format newFormat = formatBuilder.build();
if (isAdaptiveFormats) {
streamingDataBuilder.addAdaptiveFormats(newFormat);
} else {
streamingDataBuilder.addFormats(newFormat);
}
i++;
}
streamingDataBuilder.setServerAbrStreamingUrl(isNotEmpty(serverAbrStreamingUrl)
? serverAbrStreamingUrl.replace(obfuscatedNParameter, deobfuscatedNParameter)
: ""
);
return true;
}
} else if (!isAdaptiveFormats) {
// The TV client may not have formats in live streams, which is normal.
// Since formats aren't used for playback, there's no problem using the default instance.
streamingDataBuilder.addFormats(Format.newBuilder().build());
return true;
}
return false;
}
private static String getNQueryParameter(String url) {
Matcher matcher = THROTTLING_PARAM_N_PATTERN.matcher(url);
return matcher.find() ? matcher.group(1) : "";
}
private static String decodeURL(String urlDecoded) {
try {
//noinspection CharsetObjectCanBeUsed
urlDecoded = URLDecoder.decode(urlDecoded, "UTF-8");
} catch (UnsupportedEncodingException ex) {
Logger.printException(() -> "Failed to decode url", ex);
}
return urlDecoded;
}
private static String readFromFile(File file) {
StringBuilder sb = new StringBuilder();
try {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
//noinspection ReadWriteStringCanBeUsed
return new String(Files.readAllBytes(file.toPath()), StandardCharsets.UTF_8);
} else {
FileInputStream fis = new FileInputStream(file);
BufferedReader reader = new BufferedReader(new InputStreamReader(fis));
String line;
while ((line = reader.readLine()) != null) {
sb.append(line).append("\n");
}
return sb.toString();
}
} catch (IOException ex) {
Logger.printException(() -> "Failed to read file", ex);
return null;
}
}
private static void saveToFile(File file, String content) {
try (FileOutputStream fos = new FileOutputStream(file)) {
fos.write(content.getBytes(StandardCharsets.UTF_8));
} catch (IOException ex) {
Logger.printException(() -> "Failed to save file", ex);
}
}
}

View file

@ -0,0 +1,23 @@
/*
* Copyright 2026 Morphe.
* https://github.com/MorpheApp/morphe-patches
*/
package app.morphe.extension.shared.spoof.js;
public enum JavaScriptVariant {
// Used in Google Drive, this variant is the only one that does not have player ad-related code.
// In theory, it seems like the best variant, but it hasn't been tested enough.
HOUSE_BRAND("https://youtube.googleapis.com/s/player/%s/house_brand_player.vflset/en_US/base.js"),
PHONE("https://m.youtube.com/s/player/%s/player-plasma-ias-phone-en_US.vflset/base.js"),
TV_ES6("https://www.youtube.com/s/player/%s/tv-player-es6.vflset/tv-player-es6.js"),
WEB("https://www.youtube.com/s/player/%s/player_ias.vflset/en_US/base.js"),
WEB_ES6("https://www.youtube.com/s/player/%s/player_es6.vflset/en_US/base.js"),
WEB_EMBED("https://www.youtube.com/s/player/%s/player_embed.vflset/en_US/base.js");
public final String format;
JavaScriptVariant(String format) {
this.format = format;
}
}

View file

@ -0,0 +1,103 @@
package app.morphe.extension.shared.spoof.js;
import android.util.Pair;
import androidx.annotation.NonNull;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import app.morphe.extension.shared.Logger;
import app.morphe.extension.shared.spoof.js.nsigsolver.impl.V8ChallengeProvider;
import app.morphe.extension.shared.spoof.js.nsigsolver.provider.*;
/**
* The functions used in this class are referenced below:
* - <a href="https://github.com/yuliskov/MediaServiceCore/blob/c0415f34ea59b2c35c8d72cdf21ff22d82218c1c/youtubeapi/src/main/java/com/liskovsoft/youtubeapi/app/playerdata/PlayerDataExtractor.kt">yuliskov/MediaServiceCore#PlayerDataExtractor</a>
*/
public class PlayerDataExtractor {
public PlayerDataExtractor(String playerJS, String playerJSHash) {
V8ChallengeProvider.getInstance().setPlayerJS(playerJS, playerJSHash);
V8ChallengeProvider.getInstance().warmup();
checkAllData();
}
public Pair<List<String>, List<String>> bulkSigExtract(List<String> nParams, List<String> sParams) {
List<String> nProcessed = new ArrayList<>();
List<String> sProcessed = new ArrayList<>();
List<JsChallengeRequest> requests = getJsChallengeRequests(nParams, sParams);
List<JsChallengeProviderResponse> result = V8ChallengeProvider.getInstance().bulkSolve(requests);
for (JsChallengeProviderResponse item : result) {
var response = item.getResponse();
if (response == null) continue;
var type = response.getType();
if (type == JsChallengeType.N) {
if (nParams != null && !nParams.isEmpty()) {
for (String key : nParams) {
nProcessed.add(response.getOutput().getResults().get(key));
}
}
} else if (type == JsChallengeType.SIG) {
if (sParams != null && !sParams.isEmpty()) {
for (String key : sParams) {
sProcessed.add(response.getOutput().getResults().get(key));
}
}
}
}
return new Pair<>(nProcessed, sProcessed);
}
@NonNull
private static List<JsChallengeRequest> getJsChallengeRequests(List<String> nParams, List<String> sParams) {
JsChallengeRequest nRequest = null;
if (nParams != null && !nParams.isEmpty()) {
List<String> filtered = new ArrayList<>();
for (String s : nParams) {
if (s != null && !s.isEmpty() && !filtered.contains(s)) {
filtered.add(s);
}
}
if (!filtered.isEmpty()) {
nRequest = new JsChallengeRequest(JsChallengeType.N, new ChallengeInput(filtered));
}
}
JsChallengeRequest sRequest = null;
if (sParams != null && !sParams.isEmpty()) {
List<String> filtered = new ArrayList<>();
for (String s : sParams) {
if (s != null && !s.isEmpty() && !filtered.contains(s)) filtered.add(s);
}
if (!filtered.isEmpty()) {
sRequest = new JsChallengeRequest(JsChallengeType.SIG, new ChallengeInput(filtered));
}
}
List<JsChallengeRequest> requests = new ArrayList<>();
if (nRequest != null) requests.add(nRequest);
if (sRequest != null) requests.add(sRequest);
return requests;
}
private void checkAllData() {
List<String> param = Collections.singletonList("5cNpZqIJ7ixNqU68Y7S");
try {
List<JsChallengeRequest> requests = new ArrayList<>();
requests.add(new JsChallengeRequest(JsChallengeType.N, new ChallengeInput(param)));
requests.add(new JsChallengeRequest(JsChallengeType.SIG, new ChallengeInput(param)));
V8ChallengeProvider.getInstance().bulkSolve(requests);
} catch (Exception ex) {
Logger.printException(() -> "Deobfuscation test failed", ex);
}
}
}

View file

@ -0,0 +1,11 @@
package app.morphe.extension.shared.spoof.js.nsigsolver.common;
public class CacheError extends Exception {
public CacheError(String message) {
super(message);
}
public CacheError(String message, Exception cause) {
super(message, cause);
}
}

View file

@ -0,0 +1,81 @@
package app.morphe.extension.shared.spoof.js.nsigsolver.common;
import app.morphe.extension.shared.settings.preference.SharedPrefCategory;
import java.lang.ref.WeakReference;
import java.util.HashMap;
import java.util.Map;
public class CacheService {
private static final String PREF_NAME = "yt_cache_service";
private static final String KEY_DELIM = "%KEY%";
private static final Map<String, WeakReference<SharedPrefCategory>> prefs = new HashMap<>();
// Singleton instance
private static final CacheService INSTANCE = new CacheService();
private CacheService() {}
public static CacheService getInstance() {
return INSTANCE;
}
public void clear(String section) throws CacheError {
SharedPrefCategory pref = getSharedPrefs(getPrefsName(section));
pref.clear();
}
public CachedData get(String section, String key) throws CacheError {
SharedPrefCategory pref = getSharedPrefs(getPrefsName(section));
String code = pref.getString(getCodeKey(key), "");
String version = pref.getString(getVersionKey(key), "");
String variant = pref.getString(getVariantKey(key), "");
if (!code.isEmpty()) {
return new CachedData(code, version, variant);
} else {
return null;
}
}
public void save(String section, String key, CachedData content) throws CacheError {
SharedPrefCategory pref = getSharedPrefs(getPrefsName(section));
pref.saveString(getCodeKey(key), content.getCode());
pref.saveString(getVersionKey(key), content.getVersion());
pref.saveString(getVariantKey(key), content.getVariant());
}
private SharedPrefCategory getSharedPrefs(String name) {
WeakReference<SharedPrefCategory> ref = prefs.get(name);
SharedPrefCategory existing = (ref != null) ? ref.get() : null;
if (existing != null) {
return existing;
}
SharedPrefCategory newPrefs = new SharedPrefCategory(name);
prefs.put(name, new WeakReference<>(newPrefs));
return newPrefs;
}
private String getCodeKey(String key) {
return key + KEY_DELIM + "code";
}
private String getVersionKey(String key) {
return key + KEY_DELIM + "version";
}
private String getVariantKey(String key) {
return key + KEY_DELIM + "variant";
}
private String getPrefsName(String section) throws CacheError {
if (section.contains("/")) {
throw new CacheError("Slashes aren't allowed inside the pref name: " + section);
}
return PREF_NAME + KEY_DELIM + section;
}
}

View file

@ -0,0 +1,29 @@
package app.morphe.extension.shared.spoof.js.nsigsolver.common;
public class CachedData {
private final String code;
private final String version;
private final String variant;
public CachedData(String code, String version, String variant) {
this.code = code;
this.version = version;
this.variant = variant;
}
public CachedData(String code) {
this(code, null, null);
}
public String getCode() {
return code;
}
public String getVersion() {
return version;
}
public String getVariant() {
return variant;
}
}

View file

@ -0,0 +1,44 @@
package app.morphe.extension.shared.spoof.js.nsigsolver.common;
import android.content.Context;
import java.util.List;
import app.morphe.extension.shared.ResourceUtils;
import app.morphe.extension.shared.Utils;
public class ScriptUtils {
public static class ScriptLoaderError extends Exception {
public ScriptLoaderError(String message, Exception cause) {
super(message, cause);
}
}
public static String loadScript(String filename, String errorMsg) throws ScriptLoaderError {
Context context = Utils.getContext();
if (context == null) {
throw new ScriptLoaderError(
formatError(errorMsg, "Context isn't available"),
null
);
}
String fixedFilename = filename
.replace("nsigsolver/", "")
.replace(".js", "");
return ResourceUtils.getRawResource(fixedFilename);
}
public static String loadScript(List<String> filenames, String errorMsg) throws ScriptLoaderError {
StringBuilder sb = new StringBuilder();
for (String filename : filenames) {
sb.append(loadScript(filename, errorMsg));
}
return sb.toString();
}
public static String formatError(String firstMsg, String secondMsg) {
return (firstMsg != null) ? firstMsg + ": " + secondMsg : secondMsg;
}
}

View file

@ -0,0 +1,152 @@
/*
* Copyright 2026 Morphe.
* https://github.com/MorpheApp/morphe-patches
*/
package app.morphe.extension.shared.spoof.js.nsigsolver.impl;
import com.eclipsesource.v8.V8;
import com.eclipsesource.v8.V8ScriptExecutionException;
import java.util.Arrays;
import java.util.List;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import app.morphe.extension.shared.Logger;
import app.morphe.extension.shared.Utils;
import app.morphe.extension.shared.spoof.js.nsigsolver.common.CacheError;
import app.morphe.extension.shared.spoof.js.nsigsolver.common.ScriptUtils;
import app.morphe.extension.shared.spoof.js.nsigsolver.provider.JsChallengeProviderError;
import app.morphe.extension.shared.spoof.js.nsigsolver.runtime.JsRuntimeChalBaseJCP;
import app.morphe.extension.shared.spoof.js.nsigsolver.runtime.Script;
import app.morphe.extension.shared.spoof.js.nsigsolver.runtime.ScriptSource;
import app.morphe.extension.shared.spoof.js.nsigsolver.runtime.ScriptType;
import app.morphe.extension.shared.spoof.js.nsigsolver.runtime.ScriptVariant;
public class V8ChallengeProvider extends JsRuntimeChalBaseJCP {
private static final V8ChallengeProvider INSTANCE = new V8ChallengeProvider();
private final List<String> v8NpmLibFilename = Arrays.asList(
LIB_PREFIX + "polyfill.js",
LIB_PREFIX + "meriyah-6.1.4.min.js",
LIB_PREFIX + "astring-1.9.0.min.js"
);
private ExecutorService v8Executor = Executors.newSingleThreadExecutor();
private V8 v8Runtime;
private int executeCount = 0;
private V8ChallengeProvider() {}
public static V8ChallengeProvider getInstance() {
return INSTANCE;
}
// Override builtinSource to inject V8 specific logic
@Override
protected Script builtinSource(ScriptType scriptType) {
// Try V8 specific source first for LIB
if (scriptType == ScriptType.LIB) {
Script v8Script = v8NpmSource(scriptType);
if (v8Script != null) return v8Script;
}
return super.builtinSource(scriptType);
}
private Script v8NpmSource(ScriptType scriptType) {
try {
String code = ScriptUtils.loadScript(v8NpmLibFilename, "Failed to read v8 challenge solver lib script");
return new Script(scriptType, ScriptVariant.V8_NPM, ScriptSource.BUILTIN, SCRIPT_VERSION, code);
} catch (ScriptUtils.ScriptLoaderError e) {
Logger.printException(() -> "Failed to read v8 npm source", e);
return null;
}
}
@Override
protected String runJsRuntime(String stdin) throws JsChallengeProviderError {
warmup();
return runJS(stdin, false);
}
private String runJS(String stdin, boolean warmup) throws JsChallengeProviderError {
try {
String results = v8Executor.submit(() -> {
// Null checking and setting in the v8 runtime are done on the same thread
if (v8Runtime == null) {
v8Runtime = V8.createV8Runtime();
}
// Run js to get decipher results
String result = v8Runtime.executeStringScript(stdin);
// The decipher function and global functions are remembered by the V8 runtime's Bytecode Caching
// This ensures that the V8 runtime is faster than other runtimes, such as QuickJS,
// when deciphering dozens of formats simultaneously with yt-dlp-ejs
//
// To prevent memory leaks, the V8 runtime's cached bytecode must be periodically flushed
// The V8 runtime is reset every time the execution count exceeds 12
//
// Note: There is a delay of approximately 100-200ms when the runtime is regenerated
if (executeCount > 12 && !warmup && !v8Runtime.isReleased()) {
v8Runtime.lowMemoryNotification();
v8Runtime.release(false);
v8Runtime = null;
executeCount = 0;
Logger.printDebug(() -> "Close the V8 runtime");
}
executeCount++;
return result;
}).get();
// The results of the warmup are not used anywhere
if (warmup) {
return "";
}
if (Utils.isNotEmpty(results)) {
return results;
} else {
var message = "V8 runtime error: empty response";
Logger.printException(() -> message);
throw new JsChallengeProviderError(message);
}
} catch (InterruptedException | ExecutionException e) {
Throwable cause = e.getCause();
if (cause instanceof V8ScriptExecutionException v8e) {
if (v8e.getMessage() != null && v8e.getMessage().contains("Invalid or unexpected token")) {
try {
cacheService.clear(CACHE_SECTION);
} catch (CacheError ce) {
// ignore
}
}
Logger.printException(() -> "V8 runtime error, warmup: " + warmup, v8e);
throw new JsChallengeProviderError("V8 runtime error: " + v8e.getMessage(), v8e);
}
Logger.printException(() -> "Execution failed, warmup: " + warmup, e);
throw new JsChallengeProviderError("Execution failed", e);
}
}
public void warmup() {
// If v8Executor terminates for an unexpected reason, it will be recreated
if (v8Executor.isShutdown() || v8Executor.isTerminated()) {
try {
v8Executor = Executors.newSingleThreadExecutor();
} catch (Exception ex) {
Logger.printException(() -> "Failed to create V8 executor", ex);
}
}
try {
// Declare a global function
runJS(constructCommonStdin(), true);
} catch (Exception e) {
// ignore warmup errors
}
}
}

View file

@ -0,0 +1,15 @@
package app.morphe.extension.shared.spoof.js.nsigsolver.provider;
import java.util.List;
public class ChallengeInput {
private final List<String> challenges;
public ChallengeInput(List<String> challenges) {
this.challenges = challenges;
}
public List<String> getChallenges() {
return challenges;
}
}

View file

@ -0,0 +1,15 @@
package app.morphe.extension.shared.spoof.js.nsigsolver.provider;
import java.util.Map;
public class ChallengeOutput {
private final Map<String, String> results;
public ChallengeOutput(Map<String, String> results) {
this.results = results;
}
public Map<String, String> getResults() {
return results;
}
}

View file

@ -0,0 +1,11 @@
package app.morphe.extension.shared.spoof.js.nsigsolver.provider;
public class ContentProviderError extends Exception {
public ContentProviderError(String message) {
super(message);
}
public ContentProviderError(String message, Exception cause) {
super(message, cause);
}
}

View file

@ -0,0 +1,47 @@
package app.morphe.extension.shared.spoof.js.nsigsolver.provider;
import app.morphe.extension.shared.spoof.js.nsigsolver.common.CacheService;
import java.util.ArrayList;
import java.util.List;
public abstract class JsChallengeProvider {
protected final CacheService cacheService = CacheService.getInstance();
protected abstract List<JsChallengeType> getSupportedTypes();
private void validateRequest(JsChallengeRequest request) throws JsChallengeProviderRejectedRequest {
if (!getSupportedTypes().contains(request.getType())) {
throw new JsChallengeProviderRejectedRequest(
"JS Challenge type " + request.getType() + " is not supported by the provider " + this.getClass().getSimpleName()
);
}
}
/**
* Solve multiple JS challenges and return the results
*/
public List<JsChallengeProviderResponse> bulkSolve(List<JsChallengeRequest> requests) {
List<JsChallengeProviderResponse> responses = new ArrayList<>();
List<JsChallengeRequest> validatedRequests = new ArrayList<>();
for (JsChallengeRequest request : requests) {
try {
validateRequest(request);
validatedRequests.add(request);
} catch (JsChallengeProviderRejectedRequest e) {
responses.add(new JsChallengeProviderResponse(request, e));
}
}
// Add results from real implementation
responses.addAll(realBulkSolve(validatedRequests));
return responses;
}
/**
* Subclasses can override this method to handle bulk solving
*/
protected abstract List<JsChallengeProviderResponse> realBulkSolve(List<JsChallengeRequest> requests);
}

View file

@ -0,0 +1,11 @@
package app.morphe.extension.shared.spoof.js.nsigsolver.provider;
public class JsChallengeProviderError extends ContentProviderError {
public JsChallengeProviderError(String message) {
super(message);
}
public JsChallengeProviderError(String message, Exception cause) {
super(message, cause);
}
}

View file

@ -0,0 +1,11 @@
package app.morphe.extension.shared.spoof.js.nsigsolver.provider;
public class JsChallengeProviderRejectedRequest extends ContentProviderError {
public JsChallengeProviderRejectedRequest(String message) {
super(message);
}
public JsChallengeProviderRejectedRequest(String message, Exception cause) {
super(message, cause);
}
}

View file

@ -0,0 +1,33 @@
package app.morphe.extension.shared.spoof.js.nsigsolver.provider;
public class JsChallengeProviderResponse {
private final JsChallengeRequest request;
private final JsChallengeResponse response;
private final Exception error;
public JsChallengeProviderResponse(JsChallengeRequest request, JsChallengeResponse response, Exception error) {
this.request = request;
this.response = response;
this.error = error;
}
public JsChallengeProviderResponse(JsChallengeRequest request, JsChallengeResponse response) {
this(request, response, null);
}
public JsChallengeProviderResponse(JsChallengeRequest request, Exception error) {
this(request, null, error);
}
public JsChallengeRequest getRequest() {
return request;
}
public JsChallengeResponse getResponse() {
return response;
}
public Exception getError() {
return error;
}
}

View file

@ -0,0 +1,29 @@
package app.morphe.extension.shared.spoof.js.nsigsolver.provider;
public class JsChallengeRequest {
private final JsChallengeType type;
private final ChallengeInput input;
private final String videoId;
public JsChallengeRequest(JsChallengeType type, ChallengeInput input, String videoId) {
this.type = type;
this.input = input;
this.videoId = videoId;
}
public JsChallengeRequest(JsChallengeType type, ChallengeInput input) {
this(type, input, null);
}
public JsChallengeType getType() {
return type;
}
public ChallengeInput getInput() {
return input;
}
public String getVideoId() {
return videoId;
}
}

View file

@ -0,0 +1,19 @@
package app.morphe.extension.shared.spoof.js.nsigsolver.provider;
public class JsChallengeResponse {
private final JsChallengeType type;
private final ChallengeOutput output;
public JsChallengeResponse(JsChallengeType type, ChallengeOutput output) {
this.type = type;
this.output = output;
}
public JsChallengeType getType() {
return type;
}
public ChallengeOutput getOutput() {
return output;
}
}

View file

@ -0,0 +1,16 @@
package app.morphe.extension.shared.spoof.js.nsigsolver.provider;
public enum JsChallengeType {
N("n"),
SIG("sig");
private final String value;
JsChallengeType(String value) {
this.value = value;
}
public String getValue() {
return value;
}
}

View file

@ -0,0 +1,298 @@
package app.morphe.extension.shared.spoof.js.nsigsolver.runtime;
import static app.morphe.extension.shared.Utils.isNotEmpty;
import com.google.gson.Gson;
import com.google.gson.JsonSyntaxException;
import com.google.gson.reflect.TypeToken;
import java.lang.reflect.Type;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import app.morphe.extension.shared.Logger;
import app.morphe.extension.shared.spoof.js.JavaScriptManager;
import app.morphe.extension.shared.spoof.js.nsigsolver.common.*;
import app.morphe.extension.shared.spoof.js.nsigsolver.provider.*;
public abstract class JsRuntimeChalBaseJCP extends JsChallengeProvider {
public static final String CACHE_SECTION = "challenge-solver";
protected static final String SCRIPT_VERSION = "0.0.1";
public static final String LIB_PREFIX = "nsigsolver/";
private static final Type SOLVER_OUTPUT_TYPE = new TypeToken<SolverOutput>() {}.getType();
private String playerJS = "";
private String playerJSHash = "";
private final String repository = "yt-dlp/ejs";
private final Map<ScriptType, String> scriptFilenames;
private final Map<ScriptType, String> minScriptFilenames;
private Script libScript;
private Script coreScript;
// LRU Cache equivalent
protected final Map<String, String> cache = Collections.synchronizedMap(
new LinkedHashMap<>(30, 0.75f, true) {
private static final int CACHE_LIMIT = 15;
@Override
protected boolean removeEldestEntry(Map.Entry<String, String> eldest) {
return size() > CACHE_LIMIT;
}
}
);
public JsRuntimeChalBaseJCP() {
Map<ScriptType, String> sMap = new HashMap<>();
sMap.put(ScriptType.LIB, LIB_PREFIX + "yt.solver.lib.js");
sMap.put(ScriptType.CORE, LIB_PREFIX + "yt.solver.core.js");
scriptFilenames = Collections.unmodifiableMap(sMap);
Map<ScriptType, String> mMap = new HashMap<>();
mMap.put(ScriptType.LIB, "yt.solver.lib.min.js");
mMap.put(ScriptType.CORE, "yt.solver.core.min.js");
minScriptFilenames = Collections.unmodifiableMap(mMap);
}
@Override
protected List<JsChallengeType> getSupportedTypes() {
return Arrays.asList(JsChallengeType.N, JsChallengeType.SIG);
}
protected abstract String runJsRuntime(String stdin) throws JsChallengeProviderError;
public void setPlayerJS(String jsCode, String playerJSHash) {
this.playerJS = jsCode;
this.playerJSHash = playerJSHash;
}
@Override
protected List<JsChallengeProviderResponse> realBulkSolve(List<JsChallengeRequest> requests) {
List<JsChallengeProviderResponse> responses = new ArrayList<>();
try {
CachedData data = cacheService.get(CACHE_SECTION, "player:" + playerJSHash);
String player = (data != null) ? data.getCode() : null;
boolean cached;
if (player != null) {
cached = true;
} else {
player = playerJS;
cached = false;
}
String stdin = constructStdin(player, cached, requests);
String stdout = runJsRuntime(stdin);
Gson gson = new Gson();
SolverOutput output;
try {
output = gson.fromJson(stdout, SOLVER_OUTPUT_TYPE);
} catch (JsonSyntaxException ex) {
Logger.printException(() -> "Cannot parse solver output", ex);
throw new JsChallengeProviderError("Cannot parse solver output", ex);
}
if ("error".equals(output.getType())) {
String message = output.getError() != null ? output.getError() : "Unknown solver output error";
throw new JsChallengeProviderError(message);
}
String preprocessed = output.getPreprocessedPlayer();
if (preprocessed != null) {
cacheService.save(CACHE_SECTION, "player:" + playerJSHash, new CachedData(preprocessed));
}
List<ResponseData> outputResponses = output.getResponses();
if (outputResponses != null && outputResponses.size() == requests.size()) {
for (int i = 0; i < requests.size(); i++) {
JsChallengeRequest request = requests.get(i);
ResponseData responseData = outputResponses.get(i);
if ("error".equals(responseData.getType())) {
String message = responseData.getError() != null ? responseData.getError() : "Unknown solver output error";
responses.add(new JsChallengeProviderResponse(
request,
new JsChallengeProviderError(message)
));
} else {
responses.add(new JsChallengeProviderResponse(
request,
new JsChallengeResponse(request.getType(), new ChallengeOutput(responseData.getData()))
));
}
}
}
} catch (Exception ex) {
// If any global error occurs, fail all requests
for (JsChallengeRequest request : requests) {
responses.add(new JsChallengeProviderResponse(request, ex));
}
Logger.printException(() -> "BulkSolve failed", ex);
}
return responses;
}
private String constructStdin(String playerJS, boolean preprocessed, List<JsChallengeRequest> requests) {
List<Map<String, Object>> jsonRequests = new ArrayList<>();
for (JsChallengeRequest request : requests) {
Map<String, Object> reqMap = new HashMap<>();
reqMap.put("type", request.getType().getValue());
reqMap.put("challenges", request.getInput().getChallenges());
jsonRequests.add(reqMap);
}
Map<String, Object> data = new HashMap<>();
if (preprocessed) {
data.put("type", "preprocessed");
data.put("preprocessed_player", playerJS);
data.put("requests", jsonRequests);
} else {
data.put("type", "player");
data.put("player", playerJS);
data.put("requests", jsonRequests);
data.put("output_preprocessed", true);
}
Gson gson = new Gson();
String jsonData = gson.toJson(data);
return String.format("\nJSON.stringify(jsc(%s));\n", jsonData);
}
protected String constructCommonStdin() {
try {
return String.join("\n",
getLibScript().getCode(),
getCoreScript().getCode(),
"\"\";"
);
} catch (JsChallengeProviderRejectedRequest ex) {
Logger.printException(() -> "Failed to construct stdin", ex);
return "";
}
}
private Script getLibScript() throws JsChallengeProviderRejectedRequest {
if (libScript == null) {
libScript = getScript(ScriptType.LIB);
}
return libScript;
}
private Script getCoreScript() throws JsChallengeProviderRejectedRequest {
if (coreScript == null) {
coreScript = getScript(ScriptType.CORE);
}
return coreScript;
}
private interface ScriptSourceProvider {
Script get(ScriptType type);
}
// This replicates the iterator sequence in Kotlin
private Script getScript(ScriptType scriptType) throws JsChallengeProviderRejectedRequest {
// Strategy pattern for sources
ScriptSourceProvider[] providers = new ScriptSourceProvider[] {
this::cachedSource,
this::builtinSource,
this::webReleaseSource
};
for (ScriptSourceProvider provider : providers) {
Script script = provider.get(scriptType);
if (script == null) continue;
if (!SCRIPT_VERSION.equals(script.getVersion())) {
Logger.printDebug(() -> "Challenge solver " + scriptType.getValue() + " script version " + script.getVersion() +
" is not supported (source: " + script.getSource().getValue() + ", supported version: " + SCRIPT_VERSION + ")");
continue;
}
Logger.printDebug(() -> "Using challenge solver " + script.getType().getValue() + " script v" + script.getVersion() +
" (source: " + script.getSource().getValue() + ", variant: " + script.getVariant().getValue() + ")");
return script;
}
throw new JsChallengeProviderRejectedRequest("No usable challenge solver " + scriptType.getValue() + " script available");
}
private Script cachedSource(ScriptType scriptType) {
try {
CachedData data = cacheService.get(CACHE_SECTION, scriptType.getValue());
if (data == null) return null;
return new Script(
scriptType,
ScriptVariant.fromString(data.getVariant()),
ScriptSource.CACHE,
data.getVersion() != null ? data.getVersion() : "unknown",
data.getCode()
);
} catch (CacheError e) {
return null;
}
}
protected Script builtinSource(ScriptType scriptType) {
String fileName = scriptFilenames.get(scriptType);
if (isNotEmpty(fileName)) {
try {
String code = ScriptUtils.loadScript(fileName, "Failed to read builtin challenge solver " + scriptType.getValue());
return new Script(
scriptType,
ScriptVariant.UNMINIFIED,
ScriptSource.BUILTIN,
SCRIPT_VERSION,
code
);
} catch (ScriptUtils.ScriptLoaderError e) {
return null;
}
} else {
return null;
}
}
private Script webReleaseSource(ScriptType scriptType) {
String fileName = minScriptFilenames.get(scriptType);
if (isNotEmpty(fileName)) {
synchronized (cache) {
String code = cache.get(fileName);
if (code == null) {
String url = "https://github.com/" + repository + "/releases/download/" + SCRIPT_VERSION + "/" + fileName;
code = JavaScriptManager.downloadUrl(url);
Logger.printDebug(() -> "Downloading challenge solver " + scriptType.getValue() + " script from " + url);
if (isNotEmpty(code)) {
cache.put(fileName, code);
try {
cacheService.save(CACHE_SECTION, scriptType.getValue(), new CachedData(code));
} catch (CacheError ex) {
Logger.printException(() -> "Failed to save to cache", ex);
}
} else {
return null;
}
}
return new Script(
scriptType,
ScriptVariant.MINIFIED,
ScriptSource.WEB,
SCRIPT_VERSION,
code
);
}
} else {
return null;
}
}
}

View file

@ -0,0 +1,33 @@
package app.morphe.extension.shared.spoof.js.nsigsolver.runtime;
import java.util.Map;
public class ResponseData {
private String type;
private String error;
private Map<String, String> data;
public String getType() {
return type;
}
public void setType(String type) {
this.type = type;
}
public String getError() {
return error;
}
public void setError(String error) {
this.error = error;
}
public Map<String, String> getData() {
return data;
}
public void setData(Map<String, String> data) {
this.data = data;
}
}

View file

@ -0,0 +1,69 @@
package app.morphe.extension.shared.spoof.js.nsigsolver.runtime;
import androidx.annotation.NonNull;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
public class Script {
private final ScriptType type;
private final ScriptVariant variant;
private final ScriptSource source;
private final String version;
private final String code;
private String cachedHash;
public Script(ScriptType type, ScriptVariant variant, ScriptSource source, String version, String code) {
this.type = type;
this.variant = variant;
this.source = source;
this.version = version;
this.code = code;
}
public ScriptType getType() {
return type;
}
public ScriptVariant getVariant() {
return variant;
}
public ScriptSource getSource() {
return source;
}
public String getVersion() {
return version;
}
public String getCode() {
return code;
}
public String getHash() {
if (cachedHash == null) {
try {
MessageDigest digest = MessageDigest.getInstance("SHA3-512");
byte[] bytes = digest.digest(code.getBytes(StandardCharsets.UTF_8));
StringBuilder sb = new StringBuilder();
for (byte b : bytes) {
sb.append(String.format("%02x", b));
}
cachedHash = sb.toString();
} catch (NoSuchAlgorithmException e) {
cachedHash = "";
}
}
return cachedHash;
}
@NonNull
@Override
public String toString() {
String hash = getHash();
String shortHash = hash.length() > 7 ? hash.substring(0, 7) : hash;
return "<Script " + type.getValue() + " v" + version + " (source: " + source.getValue() + ") variant=" + variant.getValue() + " size=" + code.length() + " hash=" + shortHash + "...>";
}
}

View file

@ -0,0 +1,20 @@
package app.morphe.extension.shared.spoof.js.nsigsolver.runtime;
public enum ScriptSource {
PYPACKAGE("python package"),
BINARY("binary"),
CACHE("cache"),
WEB("web"),
BUILTIN("builtin");
private final String value;
ScriptSource(String value) {
this.value = value;
}
public String getValue() {
return value;
}
}

View file

@ -0,0 +1,16 @@
package app.morphe.extension.shared.spoof.js.nsigsolver.runtime;
public enum ScriptType {
LIB("lib"),
CORE("core");
private final String value;
ScriptType(String value) {
this.value = value;
}
public String getValue() {
return value;
}
}

View file

@ -0,0 +1,29 @@
package app.morphe.extension.shared.spoof.js.nsigsolver.runtime;
public enum ScriptVariant {
UNKNOWN("unknown"),
MINIFIED("minified"),
UNMINIFIED("unminified"),
DENO_NPM("deno_npm"),
BUN_NPM("bun_npm"),
V8_NPM("v8_npm");
private final String value;
ScriptVariant(String value) {
this.value = value;
}
public String getValue() {
return value;
}
public static ScriptVariant fromString(String text) {
for (ScriptVariant b : ScriptVariant.values()) {
if (b.value.equalsIgnoreCase(text)) {
return b;
}
}
return UNKNOWN;
}
}

View file

@ -0,0 +1,42 @@
package app.morphe.extension.shared.spoof.js.nsigsolver.runtime;
import java.util.List;
public class SolverOutput {
private String type;
private String error;
private String preprocessed_player;
private List<ResponseData> responses;
public String getType() {
return type;
}
public void setType(String type) {
this.type = type;
}
public String getError() {
return error;
}
public void setError(String error) {
this.error = error;
}
public String getPreprocessedPlayer() {
return preprocessed_player;
}
public void setPreprocessedPlayer(String preprocessed_player) {
this.preprocessed_player = preprocessed_player;
}
public List<ResponseData> getResponses() {
return responses;
}
public void setResponses(List<ResponseData> responses) {
this.responses = responses;
}
}

View file

@ -0,0 +1,150 @@
/*
* Copyright 2026 Morphe.
* https://github.com/MorpheApp/morphe-patches
*
* Original hard forked code:
* https://github.com/ReVanced/revanced-patches/commit/724e6d61b2ecd868c1a9a37d465a688e83a74799
*/
package app.morphe.extension.shared.spoof.requests;
import org.json.JSONException;
import org.json.JSONObject;
import java.io.IOException;
import java.net.HttpURLConnection;
import java.util.Locale;
import app.morphe.extension.shared.Logger;
import app.morphe.extension.shared.requests.Requester;
import app.morphe.extension.shared.requests.Route;
import app.morphe.extension.shared.settings.AppLanguage;
import app.morphe.extension.shared.spoof.ClientType;
import app.morphe.extension.shared.spoof.SpoofVideoStreamsPatch;
import app.morphe.extension.shared.spoof.js.JavaScriptManager;
final class PlayerRoutes {
static final Route.CompiledRoute GET_PLAYER_STREAMING_DATA = new Route(
Route.Method.POST,
"player" +
"?fields=playabilityStatus,streamingData" +
"&alt=proto"
).compile();
static final Route.CompiledRoute GET_REEL_STREAMING_DATA = new Route(
Route.Method.POST,
"reel/reel_item_watch" +
"?fields=playerResponse.playabilityStatus,playerResponse.streamingData" +
"&alt=proto"
).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 PlayerRoutes() {
}
static String createInnertubeBody(ClientType clientType, String videoId) {
JSONObject innerTubeBody = new JSONObject();
try {
JSONObject context = new JSONObject();
AppLanguage language = SpoofVideoStreamsPatch.getLanguageOverride();
if (language == null) {
// Force original audio has not overridden the language.
language = AppLanguage.DEFAULT;
}
Locale streamLocale = language.getLocale();
JSONObject client = new JSONObject();
client.put("deviceMake", clientType.deviceMake);
client.put("deviceModel", clientType.deviceModel);
client.put("clientName", clientType.clientName);
client.put("clientVersion", clientType.clientVersion);
client.put("osName", clientType.osName);
client.put("osVersion", clientType.osVersion);
if (clientType.androidSdkVersion != null) {
client.put("androidSdkVersion", clientType.androidSdkVersion);
}
if (clientType.clientPlatform != null) {
client.put("platform", clientType.clientPlatform);
}
client.put("hl", streamLocale.getLanguage());
client.put("gl", streamLocale.getCountry());
context.put("client", client);
innerTubeBody.put("context", context);
if (clientType.usePlayerEndpoint) {
innerTubeBody.put("contentCheckOk", true);
innerTubeBody.put("racyCheckOk", true);
innerTubeBody.put("videoId", videoId);
} else {
JSONObject playerRequest = new JSONObject();
playerRequest.put("contentCheckOk", true);
playerRequest.put("racyCheckOk", true);
playerRequest.put("videoId", videoId);
innerTubeBody.put("playerRequest", playerRequest);
innerTubeBody.put("disablePlayerResponse", false);
}
if (clientType.requireJS) {
JSONObject configInfo = new JSONObject();
configInfo.put("appInstallData", "");
client.put("configInfo", configInfo);
JSONObject user = new JSONObject();
user.put("lockedSafetyMode", false);
context.put("user", user);
JSONObject contentPlaybackContext = new JSONObject();
contentPlaybackContext.put(
"referer",
String.format("https://www.youtube.com/tv#/watch?v=%s", videoId)
);
contentPlaybackContext.put("html5Preference", "HTML5_PREF_WANTS");
Integer signatureTimestamp = JavaScriptManager.getSignatureTimestamp();
if (signatureTimestamp != null) {
contentPlaybackContext.put("signatureTimestamp", signatureTimestamp);
}
JSONObject devicePlaybackCapabilities = new JSONObject();
devicePlaybackCapabilities.put("supportsVp9Encoding", true);
devicePlaybackCapabilities.put("supportXhr", true);
JSONObject playbackContext = new JSONObject();
playbackContext.put("contentPlaybackContext", contentPlaybackContext);
playbackContext.put("devicePlaybackCapabilities", devicePlaybackCapabilities);
innerTubeBody.put("playbackContext", playbackContext);
}
} catch (JSONException e) {
Logger.printException(() -> "Failed to create innerTubeBody", e);
}
return innerTubeBody.toString();
}
@SuppressWarnings("SameParameterValue")
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);
// Not a typo. "Client-Name" uses the client type id.
connection.setRequestProperty("X-YouTube-Client-Name", String.valueOf(clientType.id));
connection.setRequestProperty("X-YouTube-Client-Version", clientType.clientVersion);
connection.setUseCaches(false);
connection.setDoOutput(true);
connection.setConnectTimeout(CONNECTION_TIMEOUT_MILLISECONDS);
connection.setReadTimeout(CONNECTION_TIMEOUT_MILLISECONDS);
return connection;
}
}

View file

@ -0,0 +1,377 @@
/*
* Copyright 2026 Morphe.
* https://github.com/MorpheApp/morphe-patches
*
* Original hard forked code:
* https://github.com/ReVanced/revanced-patches/commit/724e6d61b2ecd868c1a9a37d465a688e83a74799
*/
package app.morphe.extension.shared.spoof.requests;
import static app.morphe.extension.shared.StringRef.str;
import static app.morphe.extension.shared.Utils.isNotEmpty;
import static app.morphe.extension.shared.spoof.js.J2V8Support.supportJ2V8;
import static app.morphe.extension.shared.spoof.js.JavaScriptManager.getDeobfuscatedStreamingData;
import static app.morphe.extension.shared.spoof.js.JavaScriptManager.getJavaScriptHash;
import static app.morphe.extension.shared.spoof.js.JavaScriptManager.getJavaScriptVariant;
import static app.morphe.extension.shared.spoof.requests.PlayerRoutes.GET_PLAYER_STREAMING_DATA;
import static app.morphe.extension.shared.spoof.requests.PlayerRoutes.GET_REEL_STREAMING_DATA;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import java.io.IOException;
import java.io.InputStream;
import java.net.HttpURLConnection;
import java.net.SocketTimeoutException;
import java.nio.charset.StandardCharsets;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.Future;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
import app.morphe.extension.shared.Logger;
import app.morphe.extension.shared.Utils;
import app.morphe.extension.shared.innertube.PlayerResponseOuterClass.PlayerResponse;
import app.morphe.extension.shared.innertube.PlayerResponseOuterClass.StreamingData;
import app.morphe.extension.shared.innertube.ReelItemWatchResponseOuterClass.ReelItemWatchResponse;
import app.morphe.extension.shared.oauth2.requests.OAuth2Requester;
import app.morphe.extension.shared.requests.Route;
import app.morphe.extension.shared.settings.BaseSettings;
import app.morphe.extension.shared.settings.SharedYouTubeSettings;
import app.morphe.extension.shared.spoof.ClientType;
/**
* 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 volatile ClientType[] clientOrderToUse = ClientType.values();
public static void setClientOrderToUse(List<ClientType> availableClients, ClientType preferredClient) {
Objects.requireNonNull(preferredClient);
int availableClientSize = availableClients.size();
if (!availableClients.contains(preferredClient)) {
availableClientSize++;
}
clientOrderToUse = new ClientType[availableClientSize];
clientOrderToUse[0] = preferredClient;
int i = 1;
for (ClientType c : availableClients) {
if (c.requireJS && !supportJ2V8()) {
Logger.printDebug(() -> "Could not find J2V8 runtime. Skipping JavaScript client: " + c.name());
continue;
}
if (c != preferredClient) {
clientOrderToUse[i++] = c;
}
}
Logger.printDebug(() -> "Available spoof clients: " + Arrays.toString(clientOrderToUse));
}
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"
};
/**
* 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;
/**
* Cache limit must be greater than the maximum number of videos open at once,
* which theoretically is more than 4 (3 Shorts + one regular minimized video).
* But instead use a much larger value, to handle if a video viewed a while ago
* is somehow still referenced. Each stream is a small array of Strings
* so memory usage is not a concern.
*/
private static final Map<String, StreamingDataRequest> cache = Collections.synchronizedMap(
Utils.createSizeRestrictedMap(50));
private static volatile ClientType lastSpoofedClientType;
/**
* Used only for stats for nerds to show VR sign-in was used.
*/
private static volatile boolean authHeadersOverrides;
public static String getLastSpoofedClientName() {
ClientType client = lastSpoofedClientType;
if (client == null) {
return "Unknown";
} else {
String clientName = client.friendlyName;
if (client.supportsOAuth2 && authHeadersOverrides) {
clientName += " Signed in";
}
return clientName;
}
}
private final String videoId;
private final Future<byte[]> 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(String videoId) {
return cache.get(videoId);
}
private static void handleConnectionError(String toastMessage, @Nullable Exception ex, boolean showToast) {
if (showToast) Utils.showToastShort(toastMessage);
Logger.printInfo(() -> toastMessage, ex);
}
private static void handleDebugToast(String toastMessage, ClientType clientType) {
if (BaseSettings.DEBUG.get() && BaseSettings.DEBUG_TOAST_ON_ERROR.get()) {
Utils.showToastShort(String.format(toastMessage, clientType));
}
}
@Nullable
private static HttpURLConnection send(ClientType clientType,
String videoId,
Map<String, String> playerHeaders,
boolean showErrorToasts) {
Objects.requireNonNull(clientType);
Objects.requireNonNull(videoId);
Objects.requireNonNull(playerHeaders);
final long startTime = System.currentTimeMillis();
try {
Route.CompiledRoute route = clientType.usePlayerEndpoint ? GET_PLAYER_STREAMING_DATA : GET_REEL_STREAMING_DATA;
HttpURLConnection connection = PlayerRoutes.getPlayerResponseConnectionFromRoute(route, clientType);
connection.setConnectTimeout(HTTP_TIMEOUT_MILLISECONDS);
connection.setReadTimeout(HTTP_TIMEOUT_MILLISECONDS);
boolean authHeadersIncludes = false;
authHeadersOverrides = false;
for (String key : REQUEST_HEADER_KEYS) {
String value = playerHeaders.get(key);
if (value != null) {
if (key.equals(AUTHORIZATION_HEADER)) {
if (clientType.supportsOAuth2) {
String authorization = OAuth2Requester.getAndUpdateAccessTokenIfNeeded();
if (authorization.isEmpty()) {
// Access token is empty, the user has not signed in to VR.
// YouTube/YouTube Music access tokens cannot be used with YouTube VR.
// Do not set the header.
Logger.printDebug(() -> "Not including request header: " + key);
continue;
} else {
// Access token is not empty, the user has signed in to VR.
// Set the header.
value = authorization;
authHeadersOverrides = true;
}
} else if (!clientType.canLogin) {
Logger.printDebug(() -> "Not including request header: " + key);
continue;
}
authHeadersIncludes = true;
}
Logger.printDebug(() -> "Including request header: " + key);
connection.setRequestProperty(key, value);
}
}
if (!authHeadersIncludes && clientType.requireLogin) {
Logger.printDebug(() -> "Skipping client since user is not logged in: " + clientType
+ " videoId: " + videoId);
return null;
}
Logger.printDebug(() -> "Fetching video streams for: " + videoId + " using client: " + clientType);
String innerTubeBody = 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;
// 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, showErrorToasts);
} catch (SocketTimeoutException ex) {
handleConnectionError("Connection timeout", ex, showErrorToasts);
} catch (IOException ex) {
handleConnectionError("Network error", ex, showErrorToasts);
} catch (Exception ex) {
Logger.printException(() -> "send failed", ex);
} finally {
Logger.printDebug(() -> "video: " + videoId + " took: " + (System.currentTimeMillis() - startTime) + "ms");
}
return null;
}
@Nullable
private static byte[] buildPlayerResponseBuffer(ClientType clientType,
HttpURLConnection connection) {
// gzip encoding doesn't response with content length (-1),
// but empty response body does.
if (connection.getContentLength() == 0) {
handleDebugToast("Debug: Ignoring empty spoof stream client (%s)", clientType);
return null;
}
try (InputStream inputStream = connection.getInputStream()) {
PlayerResponse playerResponse = clientType.usePlayerEndpoint
? PlayerResponse.parseFrom(inputStream)
: ReelItemWatchResponse.parseFrom(inputStream).getPlayerResponse();
var playabilityStatus = playerResponse.getPlayabilityStatus();
String status = playabilityStatus.getStatus().name();
if (!"OK".equals(status)) {
handleDebugToast("Debug: Ignoring unplayable video (%s)", clientType);
String reason = playabilityStatus.getReason();
if (isNotEmpty(reason)) {
Logger.printDebug(() -> String.format("Debug: Ignoring unplayable video (%s), reason: %s", clientType, reason));
}
return null;
}
PlayerResponse.Builder responseBuilder = playerResponse.toBuilder();
if (!playerResponse.hasStreamingData()) {
handleDebugToast("Debug: Ignoring empty streaming data (%s)", clientType);
return null;
}
// Android Studio only supports the HLS protocol for live streams.
// HLS protocol can theoretically be played with ExoPlayer,
// but the related code has not yet been implemented.
// If DASH protocol is not available, the client will be skipped.
StreamingData streamingData = playerResponse.getStreamingData();
if (streamingData.getAdaptiveFormatsCount() == 0) {
handleDebugToast("Debug: Ignoring empty adaptiveFormat (%s)", clientType);
return null;
}
if (clientType.requireJS) {
var deobfuscatedStreamingData = getDeobfuscatedStreamingData(streamingData);
if (deobfuscatedStreamingData == null) {
handleDebugToast("Debug: Ignoring obfuscated streamingData (%s)", clientType);
return null;
}
responseBuilder.setStreamingData(deobfuscatedStreamingData);
}
return responseBuilder.build().toByteArray();
} catch (IOException ex) {
Logger.printException(() -> "Failed to write player response to buffer array", ex);
return null;
}
}
private static byte[] fetch(String videoId, Map<String, String> playerHeaders) {
final boolean debugEnabled = BaseSettings.DEBUG.get();
final long fetchStartTime = System.currentTimeMillis();
// Retry with different client if empty response body is received.
int i = 0;
for (ClientType clientType : clientOrderToUse) {
// Show an error if the last client type fails, or if debug is enabled then show for all attempts.
final boolean showErrorToast = (++i == clientOrderToUse.length) || debugEnabled;
HttpURLConnection connection = send(clientType, videoId, playerHeaders, showErrorToast);
if (connection != null) {
byte[] playerResponseBuffer = buildPlayerResponseBuffer(clientType, connection);
if (playerResponseBuffer != null) {
lastSpoofedClientType = clientType;
if (clientType.requireJS) {
Logger.printDebug(() -> "End of fetch for JavaScript required client" +
", video: " + videoId +
", hash: " + getJavaScriptHash() +
", variant: " + getJavaScriptVariant() +
", took: " + (System.currentTimeMillis() - fetchStartTime) + "ms");
}
return playerResponseBuffer;
}
}
}
lastSpoofedClientType = null;
handleConnectionError(str("morphe_spoof_video_streams_no_clients_toast"), null, true);
var preferredClient = clientOrderToUse[0];
if (preferredClient != ClientType.ANDROID_VR_1_47_48 && preferredClient != ClientType.ANDROID_VR_1_54_20
&& !SharedYouTubeSettings.OAUTH2_REFRESH_TOKEN.get().isBlank()) {
handleConnectionError(str("morphe_spoof_video_streams_no_clients_suggest_vr_toast"), null, true);
}
return null;
}
public boolean fetchCompleted() {
return future.isDone();
}
@Nullable
public byte[] getStream() {
try {
return future.get(MAX_MILLISECONDS_TO_WAIT_FOR_FETCH, TimeUnit.MILLISECONDS);
} catch (TimeoutException ex) {
Logger.printInfo(() -> "getStream timed out", ex);
} catch (InterruptedException ex) {
Logger.printException(() -> "getStream interrupted", ex);
Thread.currentThread().interrupt(); // Restore interrupt status flag.
} catch (ExecutionException ex) {
Logger.printException(() -> "getStream failure", ex);
}
return null;
}
@NonNull
@Override
public String toString() {
return "StreamingDataRequest{" + "videoId='" + videoId + '\'' + '}';
}
}

View file

@ -0,0 +1,56 @@
package app.morphe.extension.youtube.patches.spoof;
import static app.morphe.extension.shared.spoof.ClientType.ANDROID_CREATOR;
import static app.morphe.extension.shared.spoof.ClientType.ANDROID_VR_1_47_48;
import static app.morphe.extension.shared.spoof.ClientType.ANDROID_VR_1_54_20;
import static app.morphe.extension.shared.spoof.ClientType.TV;
import static app.morphe.extension.shared.spoof.ClientType.VISIONOS;
import java.util.List;
import app.morphe.extension.shared.settings.Setting;
import app.morphe.extension.shared.spoof.ClientType;
import app.morphe.extension.youtube.settings.Settings;
@SuppressWarnings("unused")
public class SpoofVideoStreamsPatch {
public static final class SpoofClientAv1Availability implements Setting.Availability {
@Override
public boolean isAvailable() {
return Settings.SPOOF_VIDEO_STREAMS_CLIENT_TYPE.isAvailable()
&& Settings.SPOOF_VIDEO_STREAMS_CLIENT_TYPE.get() == ANDROID_VR_1_47_48;
}
@Override
public List<Setting<?>> getParentSettings() {
return List.of(Settings.SPOOF_VIDEO_STREAMS_CLIENT_TYPE);
}
}
/**
* Injection point.
*/
public static void setClientOrderToUse() {
ClientType client = Settings.SPOOF_VIDEO_STREAMS_CLIENT_TYPE.get();
// Use VR 1.54 client that has AV1 if user settings allow it.
// AVC cannot be forced with VR 1.54 because it uses VP9 and AV1.
// If both settings are on, then force AVC takes priority and VR 1.47 is used.
if (client == ANDROID_VR_1_47_48 && Settings.SPOOF_VIDEO_STREAMS_AV1.get()
&& !Settings.FORCE_AVC_CODEC.get()) {
client = ANDROID_VR_1_54_20;
}
// For some users No SDK can fail at 1 minute. Only use it if the user has explicitly set it.
List<ClientType> availableClients = List.of(
TV,
ANDROID_VR_1_47_48,
VISIONOS,
ANDROID_CREATOR
);
app.morphe.extension.shared.spoof.SpoofVideoStreamsPatch.setClientsToUse(
availableClients, client);
}
}

View file

@ -22,15 +22,16 @@ import java.util.HashSet;
import java.util.Set;
import app.morphe.extension.shared.patches.spoof.SpoofStreamingDataPatch.HideAudioFlyoutMenuAvailability;
import app.morphe.extension.shared.settings.BaseSettings;
import app.morphe.extension.shared.settings.BooleanSetting;
import app.morphe.extension.shared.settings.EnumSetting;
import app.morphe.extension.shared.settings.FloatSetting;
import app.morphe.extension.shared.settings.IntegerSetting;
import app.morphe.extension.shared.settings.LongSetting;
import app.morphe.extension.shared.settings.Setting;
import app.morphe.extension.shared.settings.SharedYouTubeSettings;
import app.morphe.extension.shared.settings.StringSetting;
import app.morphe.extension.shared.settings.preference.SharedPrefCategory;
import app.morphe.extension.shared.spoof.ClientType;
import app.morphe.extension.shared.utils.Logger;
import app.morphe.extension.shared.utils.Utils;
import app.morphe.extension.youtube.patches.alternativethumbnails.AlternativeThumbnailsPatch.DeArrowAvailability;
@ -45,6 +46,7 @@ import app.morphe.extension.youtube.patches.player.ExitFullscreenPatch.Fullscree
import app.morphe.extension.youtube.patches.player.MiniplayerPatch;
import app.morphe.extension.youtube.patches.shorts.AnimationFeedbackPatch.AnimationType;
import app.morphe.extension.youtube.patches.shorts.ShortsRepeatStatePatch.ShortsLoopBehavior;
import app.morphe.extension.youtube.patches.spoof.SpoofVideoStreamsPatch;
import app.morphe.extension.youtube.patches.swipe.SwipeControlsPatch.SwipeOverlayBrightnessColorAvailability;
import app.morphe.extension.youtube.patches.swipe.SwipeControlsPatch.SwipeOverlayVolumeColorAvailability;
import app.morphe.extension.youtube.patches.swipe.SwipeControlsPatch.SwipeOverlaySpeedColorAvailability;
@ -58,7 +60,25 @@ import app.morphe.extension.youtube.sponsorblock.SponsorBlockSettings;
import app.morphe.extension.youtube.swipecontrols.SwipeControlsConfigurationProvider.SwipeOverlayStyle;
@SuppressWarnings("unused")
public class Settings extends BaseSettings {
public class Settings extends SharedYouTubeSettings {
public static final EnumSetting<ClientType> SPOOF_VIDEO_STREAMS_CLIENT_TYPE =
new EnumSetting<>("morphe_spoof_video_streams_client_type", ClientType.ANDROID_VR_1_47_48, true, parent(SPOOF_VIDEO_STREAMS));
public static final BooleanSetting FORCE_AVC_CODEC = new BooleanSetting(
"morphe_force_avc_codec",
FALSE,
true,
"morphe_force_avc_codec_user_dialog_message"
);
public static final BooleanSetting SPOOF_VIDEO_STREAMS_AV1 = new BooleanSetting(
"morphe_spoof_video_streams_av1",
FALSE,
true,
"morphe_spoof_video_streams_av1_user_dialog_message",
new SpoofVideoStreamsPatch.SpoofClientAv1Availability()
);
public static final BooleanSetting SPOOF_VIDEO_STREAMS_SIGN_IN_ANDROID_VR_ABOUT =
new BooleanSetting("morphe_spoof_video_streams_sign_in_android_vr_about", FALSE, false);
// PreferenceScreen: Ads
public static final BooleanSetting HIDE_CREATOR_STORE_SHELF = new BooleanSetting("revanced_hide_creator_store_shelf", TRUE);
public static final BooleanSetting HIDE_END_SCREEN_STORE_BANNER = new BooleanSetting("revanced_hide_end_screen_store_banner", TRUE, true);
@ -801,6 +821,17 @@ public class Settings extends BaseSettings {
SPOOF_APP_VERSION_TARGET.resetToDefault();
}
// VR 1.54 is not selectable in the settings, and it's selected by spoof stream patch if needed.
if (SPOOF_VIDEO_STREAMS_CLIENT_TYPE.get() == ClientType.ANDROID_VR_1_54_20) {
SPOOF_VIDEO_STREAMS_CLIENT_TYPE.resetToDefault();
}
// TV Simply may require PoToken
if (SPOOF_VIDEO_STREAMS_CLIENT_TYPE.get() == ClientType.TV_SIMPLY) {
Logger.printInfo(() -> "Migrating from TV Simply to TV");
SPOOF_VIDEO_STREAMS_CLIENT_TYPE.save(ClientType.TV);
}
// Categories were previously saved without a 'sb_' key prefix, so they need an additional adjustment.
Set<Setting<?>> sbCategories = new HashSet<>(Arrays.asList(
SB_CATEGORY_SPONSOR,

View file

@ -12,6 +12,7 @@ import android.util.Pair;
import android.widget.LinearLayout;
import app.morphe.extension.shared.ui.CustomDialog;
import app.morphe.extension.shared.settings.SharedYouTubeSettings;
import app.morphe.extension.youtube.settings.Settings;
@SuppressWarnings({"deprecation", "unused"})
@ -20,9 +21,8 @@ public class AboutFakeBufferingPreference extends Preference implements Preferen
private void init() {
setSelectable(true);
setOnPreferenceClickListener(this);
setEnabled(Settings.SPOOF_STREAMING_DATA.get() &&
Settings.SPOOF_STREAMING_DATA_USE_JS.get() &&
Settings.SPOOF_STREAMING_DATA_DEFAULT_CLIENT.get().getRequireJS());
setEnabled(SharedYouTubeSettings.SPOOF_VIDEO_STREAMS.get() &&
Settings.SPOOF_VIDEO_STREAMS_CLIENT_TYPE.get().requireJS);
}
public AboutFakeBufferingPreference(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) {
@ -77,4 +77,4 @@ public class AboutFakeBufferingPreference extends Preference implements Preferen
dialogPair.first.show();
return true;
}
}
}

View file

@ -1,36 +1,62 @@
package app.morphe.extension.youtube.settings.preference;
import static app.morphe.extension.shared.utils.StringRef.str;
import static app.morphe.extension.shared.StringRef.str;
import android.content.Context;
import android.preference.SwitchPreference;
import android.util.AttributeSet;
import app.morphe.extension.shared.patches.spoof.SpoofStreamingDataPatch;
import app.morphe.extension.shared.settings.SharedYouTubeSettings;
import app.morphe.extension.shared.spoof.ClientType;
import app.morphe.extension.shared.spoof.SpoofVideoStreamsPatch;
@SuppressWarnings({"deprecation", "unused"})
public class ForceOriginalAudioSwitchPreference extends SwitchPreference {
// Spoof stream patch is not included, or is not currently spoofing to Android Studio.
private static final boolean available = !SpoofVideoStreamsPatch.isPatchIncluded()
|| !(SharedYouTubeSettings.SPOOF_VIDEO_STREAMS.get()
&& SpoofVideoStreamsPatch.getPreferredClient() == ClientType.ANDROID_CREATOR);
{
String summaryOn = SpoofStreamingDataPatch.multiAudioTrackAvailable()
? "revanced_disable_auto_audio_tracks_summary_on"
: "revanced_disable_auto_audio_tracks_summary_on_disclaimer";
setSummaryOn(str(summaryOn));
setSummaryOff(str("revanced_disable_auto_audio_tracks_summary_off"));
if (!available) {
// Show why force audio is not available.
String summary = str("morphe_force_original_audio_not_available");
super.setSummary(summary);
super.setSummaryOn(summary);
super.setSummaryOff(summary);
super.setEnabled(false);
}
}
public ForceOriginalAudioSwitchPreference(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) {
super(context, attrs, defStyleAttr, defStyleRes);
}
public ForceOriginalAudioSwitchPreference(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
}
public ForceOriginalAudioSwitchPreference(Context context, AttributeSet attrs) {
super(context, attrs);
}
public ForceOriginalAudioSwitchPreference(Context context) {
super(context);
}
@Override
public void setEnabled(boolean enabled) {
if (!available) {
return;
}
super.setEnabled(enabled);
}
@Override
public void setSummary(CharSequence summary) {
if (!available) {
return;
}
super.setSummary(summary);
}
}

View file

@ -1,23 +1,21 @@
package app.morphe.extension.youtube.settings.preference;
import static app.morphe.extension.shared.utils.StringRef.str;
import static app.morphe.extension.shared.StringRef.str;
import android.content.Context;
import android.preference.SwitchPreference;
import android.util.AttributeSet;
import app.morphe.extension.shared.patches.spoof.SpoofStreamingDataPatch;
import app.morphe.extension.shared.spoof.SpoofVideoStreamsPatch;
@SuppressWarnings({"deprecation", "unused"})
public class HideAudioFlyoutMenuPreference extends SwitchPreference {
{
if (SpoofStreamingDataPatch.multiAudioTrackAvailable()) {
String summaryOn = str("revanced_hide_player_flyout_menu_audio_track_summary_on");
String summaryOff = str("revanced_hide_player_flyout_menu_audio_track_summary_off");
setSummaryOn(summaryOn);
setSummaryOff(summaryOff);
} else { // Audio menu is not available if spoofing to Android client type.
String summary = str("revanced_hide_player_flyout_menu_audio_track_not_available");
// Audio menu is not available if spoofing to most client types.
if (SpoofVideoStreamsPatch.spoofingToClientWithNoMultiAudioStreams()) {
String summary = str("morphe_hide_player_flyout_audio_track_not_available");
setSummary(summary);
setSummaryOn(summary);
setSummaryOff(summary);
}
@ -26,15 +24,12 @@ public class HideAudioFlyoutMenuPreference extends SwitchPreference {
public HideAudioFlyoutMenuPreference(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) {
super(context, attrs, defStyleAttr, defStyleRes);
}
public HideAudioFlyoutMenuPreference(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
}
public HideAudioFlyoutMenuPreference(Context context, AttributeSet attrs) {
super(context, attrs);
}
public HideAudioFlyoutMenuPreference(Context context) {
super(context);
}

View file

@ -1,29 +1,10 @@
package app.morphe.extension.youtube.settings.preference;
import static app.morphe.extension.shared.utils.StringRef.str;
import android.content.Context;
import android.content.SharedPreferences;
import android.preference.Preference;
import android.preference.PreferenceManager;
import android.util.AttributeSet;
import app.morphe.extension.shared.settings.Setting;
import app.morphe.extension.shared.utils.Utils;
import app.morphe.extension.youtube.settings.Settings;
@SuppressWarnings({"deprecation", "unused"})
public class SpoofStreamingDataSideEffectsPreference extends Preference {
private final SharedPreferences.OnSharedPreferenceChangeListener listener = (sharedPreferences, str) -> {
// Because this listener may run before the ReVanced settings fragment updates Settings,
// this could show the prior config and not the current.
//
// Push this call to the end of the main run queue,
// so all other listeners are done and Settings is up to date.
Utils.runOnMainThread(this::updateUI);
};
@SuppressWarnings("unused")
public final class SpoofStreamingDataSideEffectsPreference extends SpoofVideoStreamsSideEffectsPreference {
public SpoofStreamingDataSideEffectsPreference(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) {
super(context, attrs, defStyleAttr, defStyleRes);
}
@ -39,34 +20,4 @@ public class SpoofStreamingDataSideEffectsPreference extends Preference {
public SpoofStreamingDataSideEffectsPreference(Context context) {
super(context);
}
private void addChangeListener() {
Setting.preferences.preferences.registerOnSharedPreferenceChangeListener(listener);
}
private void removeChangeListener() {
Setting.preferences.preferences.unregisterOnSharedPreferenceChangeListener(listener);
}
@Override
protected void onAttachedToHierarchy(PreferenceManager preferenceManager) {
super.onAttachedToHierarchy(preferenceManager);
updateUI();
addChangeListener();
}
@Override
protected void onPrepareForRemoval() {
super.onPrepareForRemoval();
removeChangeListener();
}
private void updateUI() {
final String clientName = Settings.SPOOF_STREAMING_DATA_DEFAULT_CLIENT.get().name().toLowerCase();
final String summaryTextKey = "revanced_spoof_streaming_data_side_effects_" + clientName;
setSummary(str(summaryTextKey));
setEnabled(Settings.SPOOF_STREAMING_DATA.get());
setSelectable(false);
}
}
}

View file

@ -1,104 +1,32 @@
package app.morphe.extension.youtube.settings.preference;
import static app.morphe.extension.shared.utils.StringRef.str;
import android.app.Dialog;
import android.content.Context;
import android.preference.Preference;
import android.util.AttributeSet;
import android.util.Pair;
import android.widget.LinearLayout;
import app.morphe.extension.shared.innertube.client.YouTubeClient;
import app.morphe.extension.shared.patches.auth.YouTubeVRAuthPatch;
import app.morphe.extension.shared.ui.CustomDialog;
import app.morphe.extension.shared.settings.preference.OAuth2Preference;
import app.morphe.extension.youtube.settings.Settings;
@SuppressWarnings({"FieldCanBeLocal", "deprecation", "unused"})
public class SpoofStreamingDataSignInPreference extends Preference implements Preference.OnPreferenceClickListener {
private void init() {
setSelectable(true);
setOnPreferenceClickListener(this);
setEnabled(Settings.SPOOF_STREAMING_DATA_DEFAULT_CLIENT.get() == YouTubeClient.ClientType.ANDROID_VR);
}
@SuppressWarnings("unused")
public final class SpoofStreamingDataSignInPreference extends OAuth2Preference {
public SpoofStreamingDataSignInPreference(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) {
super(context, attrs, defStyleAttr, defStyleRes);
init();
}
public SpoofStreamingDataSignInPreference(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
init();
}
public SpoofStreamingDataSignInPreference(Context context, AttributeSet attrs) {
super(context, attrs);
init();
}
public SpoofStreamingDataSignInPreference(Context context) {
super(context);
init();
}
@Override
public boolean onPreferenceClick(Preference preference) {
Context context = getContext();
Pair<Dialog, LinearLayout> dialogPair;
String dialogTitle = str("revanced_spoof_streaming_data_sign_in_android_vr_dialog_title");
String dialogMessage = str("revanced_spoof_streaming_data_sign_in_android_vr_dialog_message");
String resetButtonText = str("revanced_spoof_streaming_data_sign_in_android_vr_dialog_reset_text");
if (YouTubeVRAuthPatch.isDeviceCodeAvailable()) {
dialogPair = CustomDialog.create(
context,
// Title.
dialogTitle,
// Message.
dialogMessage,
// No EditText.
null,
// OK button text.
str("revanced_spoof_streaming_data_sign_in_android_vr_dialog_get_authorization_token_text"),
// OK button action.
() -> {
YouTubeVRAuthPatch.setRefreshToken();
YouTubeVRAuthPatch.setAccessToken(context);
},
// Cancel button action.
null,
// Neutral button text.
resetButtonText,
// Neutral button action.
YouTubeVRAuthPatch::clearAll,
// Dismiss dialog when onNeutralClick.
true
);
} else {
dialogPair = CustomDialog.create(
context,
// Title.
dialogTitle,
// Message.
dialogMessage,
// No EditText.
null,
// OK button text.
str("revanced_spoof_streaming_data_sign_in_android_vr_dialog_get_activation_code_text"),
// OK button action.
() -> YouTubeVRAuthPatch.setActivationCode(context),
// Cancel button action.
null,
// Neutral button text.
resetButtonText,
// Neutral button action.
YouTubeVRAuthPatch::clearAll,
// Dismiss dialog when onNeutralClick.
true
);
}
dialogPair.first.show();
return true;
protected boolean isSettingEnabled() {
return Settings.SPOOF_VIDEO_STREAMS.get()
&& Settings.SPOOF_VIDEO_STREAMS_CLIENT_TYPE.get().supportsOAuth2;
}
}
}

View file

@ -0,0 +1,125 @@
/*
* Copyright 2026 Morphe.
* https://github.com/MorpheApp/morphe-patches
*
* Original hard forked code:
* https://github.com/ReVanced/revanced-patches/commit/724e6d61b2ecd868c1a9a37d465a688e83a74799
*/
package app.morphe.extension.youtube.settings.preference;
import static app.morphe.extension.shared.StringRef.str;
import android.content.Context;
import android.content.SharedPreferences;
import android.preference.Preference;
import android.preference.PreferenceManager;
import android.util.AttributeSet;
import androidx.annotation.Nullable;
import app.morphe.extension.shared.Logger;
import app.morphe.extension.shared.Utils;
import app.morphe.extension.shared.settings.Setting;
import app.morphe.extension.shared.settings.SharedYouTubeSettings;
import app.morphe.extension.shared.settings.preference.BulletPointPreference;
import app.morphe.extension.shared.spoof.ClientType;
import app.morphe.extension.youtube.settings.Settings;
@SuppressWarnings({"deprecation", "unused"})
public class SpoofVideoStreamsSideEffectsPreference extends Preference {
@Nullable
private ClientType currentClientType;
private final SharedPreferences.OnSharedPreferenceChangeListener listener = (sharedPreferences, str) -> {
// Because this listener may run before the Morphe settings fragment updates Settings,
// this could show the prior config and not the current.
//
// Push this call to the end of the main run queue,
// so all other listeners are done and Settings is up to date.
Utils.runOnMainThread(this::updateUI);
};
public SpoofVideoStreamsSideEffectsPreference(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) {
super(context, attrs, defStyleAttr, defStyleRes);
}
public SpoofVideoStreamsSideEffectsPreference(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
}
public SpoofVideoStreamsSideEffectsPreference(Context context, AttributeSet attrs) {
super(context, attrs);
}
public SpoofVideoStreamsSideEffectsPreference(Context context) {
super(context);
}
private void addChangeListener() {
Setting.preferences.preferences.registerOnSharedPreferenceChangeListener(listener);
}
private void removeChangeListener() {
Setting.preferences.preferences.unregisterOnSharedPreferenceChangeListener(listener);
}
@Override
protected void onAttachedToHierarchy(PreferenceManager preferenceManager) {
super.onAttachedToHierarchy(preferenceManager);
updateUI();
addChangeListener();
}
@Override
protected void onPrepareForRemoval() {
super.onPrepareForRemoval();
removeChangeListener();
}
private void updateUI() {
ClientType clientType = Settings.SPOOF_VIDEO_STREAMS_CLIENT_TYPE.get();
if (currentClientType == clientType) {
return;
}
currentClientType = clientType;
Logger.printDebug(() -> "Updating spoof stream side effects preference");
setEnabled(SharedYouTubeSettings.SPOOF_VIDEO_STREAMS.get());
String summary = "";
switch (clientType) {
case ANDROID_CREATOR ->
summary = str("morphe_spoof_video_streams_about_no_audio_tracks")
+ '\n' + str("morphe_spoof_video_streams_about_no_stable_volume")
+ '\n' + str("morphe_spoof_video_streams_about_no_av1")
+ '\n' + str("morphe_spoof_video_streams_about_no_force_original_audio");
case ANDROID_REEL ->
summary = str("morphe_spoof_video_streams_about_playback_failure");
// VR 1.54 is not exposed in the UI and should never be reached here.
case ANDROID_VR_1_47_48, ANDROID_VR_1_54_20 ->
summary = str("morphe_spoof_video_streams_about_no_audio_tracks")
+ '\n' + str("morphe_spoof_video_streams_about_no_stable_volume");
case TV ->
summary = str("morphe_spoof_video_streams_about_js");
case TV_SIMPLY ->
summary = str("morphe_spoof_video_streams_about_js")
+ '\n' + str("morphe_spoof_video_streams_about_playback_failure");
case VISIONOS ->
summary = str("morphe_spoof_video_streams_about_experimental")
+ '\n' + str("morphe_spoof_video_streams_about_no_audio_tracks")
+ '\n' + str("morphe_spoof_video_streams_about_no_av1");
default -> Logger.printException(() -> "Unknown client: " + clientType);
}
// Only Android Reel and Android VR supports 360° VR immersive mode.
if (!clientType.name().startsWith("ANDROID_VR") && clientType != ClientType.ANDROID_REEL) {
summary += '\n' + str("morphe_spoof_video_streams_about_no_immersive_mode");
}
// Use better formatting for bullet points.
setSummary(BulletPointPreference.formatIntoBulletPoints(summary));
}
}

View file

@ -0,0 +1,33 @@
package app.morphe.extension.youtube.settings.preference;
import android.content.Context;
import android.util.AttributeSet;
import app.morphe.extension.shared.settings.preference.OAuth2Preference;
import app.morphe.extension.youtube.settings.Settings;
@SuppressWarnings("unused")
public final class SpoofVideoStreamsSignInPreference extends OAuth2Preference {
public SpoofVideoStreamsSignInPreference(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) {
super(context, attrs, defStyleAttr, defStyleRes);
}
public SpoofVideoStreamsSignInPreference(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
}
public SpoofVideoStreamsSignInPreference(Context context, AttributeSet attrs) {
super(context, attrs);
}
public SpoofVideoStreamsSignInPreference(Context context) {
super(context);
}
@Override
protected boolean isSettingEnabled() {
return Settings.SPOOF_VIDEO_STREAMS.get() &&
Settings.SPOOF_VIDEO_STREAMS_CLIENT_TYPE.get().supportsOAuth2;
}
}

View file

@ -2,7 +2,6 @@ package app.morphe.extension.youtube.settings.preference;
import static app.morphe.extension.shared.patches.PatchStatus.PatchVersion;
import static app.morphe.extension.shared.patches.PatchStatus.PatchedTime;
import static app.morphe.extension.shared.settings.BaseSettings.SPOOF_STREAMING_DATA_USE_JS;
import static app.morphe.extension.shared.utils.StringRef.str;
import static app.morphe.extension.youtube.settings.Settings.HIDE_PREVIEW_COMMENT;
import static app.morphe.extension.youtube.settings.Settings.HIDE_PREVIEW_COMMENT_TYPE;
@ -10,7 +9,6 @@ import static app.morphe.extension.youtube.settings.Settings.HIDE_PREVIEW_COMMEN
import android.annotation.SuppressLint;
import android.app.Dialog;
import android.content.SharedPreferences;
import android.preference.ListPreference;
import android.preference.Preference;
import android.preference.PreferenceScreen;
import android.preference.SwitchPreference;
@ -18,12 +16,10 @@ import android.widget.Toolbar;
import java.util.Date;
import app.morphe.extension.shared.innertube.client.YouTubeClient;
import app.morphe.extension.shared.settings.Setting;
import app.morphe.extension.shared.settings.preference.LogBufferManager;
import app.morphe.extension.shared.settings.preference.ToolbarPreferenceFragment;
import app.morphe.extension.shared.utils.Logger;
import app.morphe.extension.shared.utils.ResourceUtils;
import app.morphe.extension.shared.utils.Utils;
import app.morphe.extension.youtube.patches.general.ChangeFormFactorPatch;
import app.morphe.extension.youtube.patches.utils.PatchStatus;
@ -65,9 +61,6 @@ public class YouTubePreferenceFragment extends ToolbarPreferenceFragment {
ExtendedUtils.setPlayerFlyoutMenuAdditionalSettings();
} else if (setting.equals(HIDE_PREVIEW_COMMENT) || setting.equals(HIDE_PREVIEW_COMMENT_TYPE)) {
ExtendedUtils.setCommentPreviewSettings();
} else if (setting.equals(SPOOF_STREAMING_DATA_USE_JS)) {
YouTubeClient.availableClientTypes(Settings.SPOOF_STREAMING_DATA_DEFAULT_CLIENT.get());
setSpoofStreamingDataPreference();
}
}
@ -315,20 +308,8 @@ public class YouTubePreferenceFragment extends ToolbarPreferenceFragment {
}
private void setSpoofStreamingDataPreference() {
if (findPreference(Settings.SPOOF_STREAMING_DATA_DEFAULT_CLIENT.key) instanceof ListPreference listPreference
&& findPreference(SPOOF_STREAMING_DATA_USE_JS.key) instanceof SwitchPreference switchPreference) {
boolean useJS = SPOOF_STREAMING_DATA_USE_JS.get() || switchPreference.isChecked();
String entriesKey = useJS
? "revanced_spoof_streaming_data_default_client_with_js_entries"
: "revanced_spoof_streaming_data_default_client_entries";
String entryValueKey = useJS
? "revanced_spoof_streaming_data_default_client_with_js_entry_values"
: "revanced_spoof_streaming_data_default_client_entry_values";
listPreference.setEntries(ResourceUtils.getArrayIdentifier(entriesKey));
listPreference.setEntryValues(ResourceUtils.getArrayIdentifier(entryValueKey));
}
// Morphe-style spoof video streams keeps a fixed client list and exposes
// the JavaScript variant as a separate preference.
}
private void setWhitelistPreference() {

View file

@ -0,0 +1,43 @@
syntax = "proto3";
package app.morphe.extension.shared.innertube;
option optimize_for = LITE_RUNTIME;
option java_package = "app.morphe.extension.shared.innertube";
message PlayerResponse {
oneof data {
PlayabilityStatus playability_status = 2;
StreamingData streaming_data = 4;
}
}
message PlayabilityStatus {
Status status = 1;
string reason = 2;
}
enum Status {
OK = 0;
ERROR = 1;
UNPLAYABLE = 2;
LOGIN_REQUIRED = 3;
CONTENT_CHECK_REQUIRED = 4;
AGE_CHECK_REQUIRED = 5;
LIVE_STREAM_OFFLINE = 6;
FULLSCREEN_ONLY = 7;
GL_PLAYBACK_REQUIRED = 8;
AGE_VERIFICATION_REQUIRED = 9;
}
message StreamingData {
repeated Format formats = 2;
repeated Format adaptiveFormats = 3;
string serverAbrStreamingUrl = 15;
}
message Format {
string url = 2;
string signatureCipher = 48;
}

View file

@ -0,0 +1,14 @@
syntax = "proto3";
import "app/morphe/extension/shared/innertube/player_response.proto";
package app.morphe.extension.shared.innertube;
option optimize_for = LITE_RUNTIME;
option java_package = "app.morphe.extension.shared.innertube";
message ReelItemWatchResponse {
oneof data {
PlayerResponse player_response = 4;
}
}

View file

@ -1,5 +1,5 @@
[versions]
morphe-patcher = "1.0.1"
morphe-patcher = "1.2.0"
# Tracking https://github.com/google/smali/issues/100.
smali = "b6365a84f4"
gson = "2.13.2"

View file

@ -0,0 +1,5 @@
package app.morphe.patches.music.misc.spoof
import app.morphe.patches.shared.misc.spoof.userAgentClientSpoofPatch
val userAgentClientSpoofPatch = userAgentClientSpoofPatch("com.google.android.apps.youtube.music")

View file

@ -1,7 +1,7 @@
package app.morphe.patches.music.utils.fix.streamingdata
import app.morphe.patcher.extensions.InstructionExtensions.addInstructions
import app.morphe.patcher.extensions.InstructionExtensions.getInstruction
import app.morphe.patches.music.utils.compatibility.Constants.YOUTUBE_MUSIC_PACKAGE_NAME
import app.morphe.patches.music.utils.mainactivity.mainActivityFingerprint
import app.morphe.patches.music.utils.playservice.is_7_16_or_greater
import app.morphe.patches.music.utils.playservice.is_7_33_or_greater
import app.morphe.patches.music.utils.playservice.is_8_12_or_greater
@ -13,82 +13,58 @@ import app.morphe.patches.music.utils.settings.addSwitchPreference
import app.morphe.patches.music.utils.settings.settingsPatch
import app.morphe.patches.music.utils.webview.webViewPatch
import app.morphe.patches.music.video.information.videoInformationPatch
import app.morphe.patches.music.video.playerresponse.Hook
import app.morphe.patches.music.video.playerresponse.addPlayerResponseMethodHook
import app.morphe.patches.shared.buildRequestFingerprint
import app.morphe.patches.shared.buildRequestParentFingerprint
import app.morphe.patches.shared.indexOfNewUrlRequestBuilderInstruction
import app.morphe.patches.shared.spoof.streamingdata.EXTENSION_CLASS_DESCRIPTOR
import app.morphe.patches.shared.spoof.streamingdata.spoofStreamingDataPatch
import app.morphe.util.fingerprint.methodOrThrow
import com.android.tools.smali.dexlib2.iface.instruction.FiveRegisterInstruction
import app.morphe.patches.shared.misc.spoof.spoofVideoStreamsPatch
import app.morphe.patches.shared.spoof.useragent.baseSpoofUserAgentPatch
val spoofStreamingDataPatch = spoofStreamingDataPatch(
block = {
dependsOn(
settingsPatch,
versionCheckPatch,
videoInformationPatch,
webViewPatch,
)
},
isYouTube = {
false
},
outlineIcon = {
false
},
fixMediaFetchHotConfigChanges = {
val spoofStreamingDataPatch = spoofVideoStreamsPatch(
extensionClassDescriptor = "Lapp/morphe/extension/music/patches/spoof/SpoofVideoStreamsPatch;",
mainActivityOnCreateFingerprint = mainActivityFingerprint.second,
fixMediaFetchHotConfig = {
is_7_16_or_greater
},
fixMediaFetchHotConfigAlternativeChanges = {
fixMediaFetchHotConfigAlternative = {
// In 8.15 the flag was merged with 7.33 start playback flag.
is_8_12_or_greater && !is_8_15_or_greater
},
fixParsePlaybackResponseFeatureFlag = {
is_7_33_or_greater
},
executeBlock = {
// region Get replacement streams at player requests.
buildRequestFingerprint.methodOrThrow(buildRequestParentFingerprint).apply {
val newRequestBuilderIndex = indexOfNewUrlRequestBuilderInstruction(this)
val urlRegister =
getInstruction<FiveRegisterInstruction>(newRequestBuilderIndex).registerD
addInstructions(
newRequestBuilderIndex,
"invoke-static { v$urlRegister, p1 }, $EXTENSION_CLASS_DESCRIPTOR->fetchStreams(Ljava/lang/String;Ljava/util/Map;)V"
)
}
// endregion
addPlayerResponseMethodHook(
Hook.ProtoBufferParameterBeforeVideoId(
"$EXTENSION_CLASS_DESCRIPTOR->newPlayerResponseParameter(Ljava/lang/String;Ljava/lang/String;)Ljava/lang/String;"
)
block = {
dependsOn(
settingsPatch,
versionCheckPatch,
videoInformationPatch,
webViewPatch,
baseSpoofUserAgentPatch(YOUTUBE_MUSIC_PACKAGE_NAME),
)
},
executeBlock = {
addSwitchPreference(
CategoryType.MISC,
"revanced_spoof_streaming_data",
"morphe_spoof_video_streams",
"true"
)
addPreferenceWithIntent(
CategoryType.MISC,
"revanced_spoof_streaming_data_default_client",
"revanced_spoof_streaming_data",
"morphe_spoof_video_streams_client_type",
"morphe_spoof_video_streams",
false,
)
addPreferenceWithIntent(
CategoryType.MISC,
"revanced_spoof_streaming_data_sign_in_android_no_sdk_about",
"revanced_spoof_streaming_data"
"morphe_spoof_video_streams_sign_in_android_vr_about",
"morphe_spoof_video_streams"
)
addSwitchPreference(
CategoryType.MISC,
"morphe_spoof_video_streams_disable_player_js_update",
"false",
"morphe_spoof_video_streams"
)
addPreferenceWithIntent(
CategoryType.MISC,
"revanced_spoof_streaming_data_sign_in_android_vr_about",
"revanced_spoof_streaming_data"
"morphe_spoof_video_streams_player_js_hash",
"morphe_spoof_video_streams_disable_player_js_update"
)
},
)

View file

@ -274,7 +274,8 @@ internal object ResourceUtils {
fun addPreferenceWithIntent(
category: String,
key: String,
dependencyKey: String
dependencyKey: String,
setSummary: Boolean,
) {
context.document(SETTINGS_HEADER_PATH).use { document ->
val tags = document.getElementsByTagName(PREFERENCE_SCREEN_TAG_NAME)
@ -285,7 +286,9 @@ internal object ResourceUtils {
.forEach {
it.adoptChild("Preference") {
setAttribute("android:title", "@string/$key" + "_title")
if (setSummary) {
setAttribute("android:summary", "@string/$key" + "_summary")
}
setAttribute("android:key", key)
if (dependencyKey.isNotEmpty()) {
setAttribute("android:dependency", dependencyKey)

View file

@ -391,11 +391,12 @@ internal fun addSwitchPreference(
internal fun addPreferenceWithIntent(
category: CategoryType,
key: String,
dependencyKey: String = ""
dependencyKey: String = "",
setSummary: Boolean = true,
) {
val categoryValue = category.value
ResourceUtils.addPreferenceCategory(categoryValue)
ResourceUtils.addPreferenceWithIntent(categoryValue, key, dependencyKey)
ResourceUtils.addPreferenceWithIntent(categoryValue, key, dependencyKey, setSummary)
}
internal fun addLinkPreference(

View file

@ -0,0 +1,54 @@
/*
* Copyright 2026 Morphe.
* https://github.com/MorpheApp/morphe-patches
*/
package app.morphe.patches.shared.misc.fix.proto
import app.morphe.patcher.Fingerprint
import app.morphe.patcher.methodCall
import app.morphe.patcher.opcode
import com.android.tools.smali.dexlib2.AccessFlags
import com.android.tools.smali.dexlib2.Opcode
internal object EmptyRegistryFingerprint : Fingerprint(
definingClass = "Lcom/google/protobuf/ExtensionRegistryLite;",
name ="getGeneratedRegistry",
accessFlags = listOf(AccessFlags.PUBLIC, AccessFlags.STATIC),
parameters = listOf(),
returnType = "Lcom/google/protobuf/ExtensionRegistryLite;"
)
internal object MessageLiteWriteToFingerprint : Fingerprint(
definingClass = "Lcom/google/protobuf/MessageLite;",
name = "writeTo",
accessFlags = listOf(AccessFlags.PUBLIC, AccessFlags.ABSTRACT),
parameters = listOf("L"),
returnType = "V"
)
internal object ProtobufClassParseByteArrayFingerprint : Fingerprint(
name = "parseFrom",
accessFlags = listOf(AccessFlags.PUBLIC, AccessFlags.STATIC),
returnType = "L",
parameters = listOf("L", "[B")
)
internal object StreamingDataOuterClassFingerprint : Fingerprint(
definingClass = "Lcom/google/protos/youtube/api/innertube/StreamingDataOuterClass\$StreamingData;",
accessFlags = listOf(AccessFlags.PUBLIC, AccessFlags.FINAL),
returnType = "V",
parameters = listOf(),
filters = listOf(
methodCall(
opcode = Opcode.INVOKE_INTERFACE,
parameters = listOf(),
returnType = "Z"
),
opcode(Opcode.IF_NEZ),
methodCall(
opcode = Opcode.INVOKE_STATIC,
name = "mutableCopy"
)
)
)

View file

@ -0,0 +1,96 @@
/*
* Copyright 2026 Morphe.
* https://github.com/MorpheApp/morphe-patches
*/
package app.morphe.patches.shared.misc.fix.proto
import app.morphe.patcher.extensions.InstructionExtensions.addInstructions
import app.morphe.patcher.extensions.InstructionExtensions.getInstruction
import app.morphe.patcher.patch.bytecodePatch
import app.morphe.patcher.util.proxy.mutableTypes.MutableMethod
import app.morphe.patcher.util.proxy.mutableTypes.MutableMethod.Companion.toMutable
import app.morphe.util.cloneMutable
import app.morphe.util.getReference
import com.android.tools.smali.dexlib2.AccessFlags
import com.android.tools.smali.dexlib2.builder.MutableMethodImplementation
import com.android.tools.smali.dexlib2.iface.instruction.ReferenceInstruction
import com.android.tools.smali.dexlib2.iface.reference.MethodReference
import com.android.tools.smali.dexlib2.immutable.ImmutableMethod
import com.android.tools.smali.dexlib2.immutable.ImmutableMethodParameter
import java.lang.ref.WeakReference
internal lateinit var parseByteArrayMethod: MutableMethod
internal lateinit var immutableMethodRef : WeakReference<MethodReference>
internal lateinit var mutableCopyMethodRef : WeakReference<MethodReference>
/**
* Some classes that exist in YouTube are not merged.
* Instead of relocating all classes in the extension,
* Only the methods necessary for the patch to function are added.
*/
internal val fixProtoLibraryPatch = bytecodePatch(
description = "Fix method not found exception that can occur when proto libraries used in extensions are merged."
) {
execute {
EmptyRegistryFingerprint.let {
it.method.apply {
it.classDef.methods.add(
ImmutableMethod(
definingClass,
"getEmptyRegistry",
emptyList(),
"Lcom/google/protobuf/ExtensionRegistryLite;",
AccessFlags.PUBLIC.value or AccessFlags.STATIC.value,
annotations,
null,
MutableMethodImplementation(2),
).toMutable().apply {
addInstructions(
0,
"""
new-instance v0, Lcom/google/protobuf/ExtensionRegistryLite;
invoke-direct {v0}, Lcom/google/protobuf/ExtensionRegistryLite;-><init>()V
return-object v0
"""
)
}
)
}
}
MessageLiteWriteToFingerprint.let {
it.method.apply {
it.classDef.methods.add(
cloneMutable(
parameters = listOf(
ImmutableMethodParameter(
"Lcom/google/protobuf/CodedOutputStream;",
null,
null
)
)
)
)
}
}
parseByteArrayMethod = ProtobufClassParseByteArrayFingerprint.method
StreamingDataOuterClassFingerprint.let {
it.method.apply {
val immutableMethodIndex = it.instructionMatches.first().index
val mutableCopyMethodIndex = it.instructionMatches.last().index
val immutableMethodReference =
getInstruction<ReferenceInstruction>(immutableMethodIndex).reference as MethodReference
val mutableCopyMethodReference =
getInstruction<ReferenceInstruction>(mutableCopyMethodIndex).reference as MethodReference
immutableMethodRef = WeakReference(immutableMethodReference)
mutableCopyMethodRef = WeakReference(mutableCopyMethodReference)
}
}
}
}

View file

@ -0,0 +1,63 @@
package app.morphe.patches.shared.misc.settings.preference
import app.morphe.util.resource.BaseResource
import org.w3c.dom.Document
import org.w3c.dom.Element
/**
* Base preference class for all preferences.
*
* @param key The key of the preference. If null, other parameters must be specified.
* @param titleKey The key of the preference title.
* @param icon The preference icon resource name.
* @param layout Layout declaration.
* @param summaryKey The key of the preference summary.
* @param tag The tag or full class name of the preference.
*/
@Suppress("MemberVisibilityCanBePrivate")
abstract class BasePreference(
val key: String? = null,
val titleKey: String? = "${key}_title",
val summaryKey: String? = "${key}_summary",
icon: String? = null,
iconBold: String? = null,
layout: String? = null,
val tag: String
) {
var icon: String? = icon
internal set
var iconBold: String? = iconBold
internal set
var layout: String? = layout
internal set
/**
* Serialize preference element to XML.
* Overriding methods should invoke super and operate on its return value.
*
* @param resourceCallback A callback for additional resources.
* @param ownerDocument Target document to create elements from.
*
* @return The serialized element.
*/
open fun serialize(ownerDocument: Document, resourceCallback: (BaseResource) -> Unit): Element =
ownerDocument.createElement(tag).apply {
key?.let { setAttribute("android:key", it) }
titleKey?.let { setAttribute("android:title", "@string/${titleKey}") }
summaryKey?.let { addSummary(it) }
if (icon != null || iconBold != null) {
setAttribute("android:icon", icon ?: iconBold)
setAttribute("app:iconSpaceReserved", "true")
}
layout?.let { setAttribute("android:layout", layout) }
}
companion object {
fun Element.addSummary(summaryKey: String, summaryType: SummaryType = SummaryType.DEFAULT) =
setAttribute("android:${summaryType.type}", "@string/$summaryKey")
}
}

View file

@ -0,0 +1,105 @@
package app.morphe.patches.shared.misc.settings.preference
import app.morphe.patches.shared.misc.settings.preference.PreferenceScreenPreference.Sorting
import java.io.Closeable
abstract class BasePreferenceScreen(
private val root: MutableSet<Screen> = mutableSetOf(),
) : Closeable {
override fun close() {
if (root.isEmpty()) return
root.forEach { preference ->
commit(preference.transform())
}
}
/**
* Finalize and insert root preference into resource patch
*/
abstract fun commit(screen: PreferenceScreenPreference)
open inner class Screen(
key: String? = null,
titleKey: String = "${key}_title",
private val summaryKey: String? = "${key}_summary",
icon: String? = null,
iconBold: String? = null,
layout: String? = null,
preferences: MutableSet<BasePreference> = mutableSetOf(),
val categories: MutableSet<Category> = mutableSetOf(),
private val sorting: Sorting = Sorting.BY_TITLE,
) : BasePreferenceCollection(key, titleKey, icon, iconBold, layout, preferences) {
override fun transform(): PreferenceScreenPreference {
return PreferenceScreenPreference(
key,
titleKey,
summaryKey,
icon,
iconBold,
layout,
sorting,
// Screens and preferences are sorted at runtime by extension code,
// so title sorting uses the localized language in use.
preferences = preferences + categories.map { it.transform() },
)
}
private fun ensureScreenInserted() {
// Add to screens if not yet done
if (!root.contains(this)) {
root.add(this)
}
}
fun addPreferences(vararg preferences: BasePreference) {
ensureScreenInserted()
this.preferences.addAll(preferences)
}
open inner class Category(
key: String? = null,
titleKey: String = "${key}_title",
icon: String? = null,
iconBold: String? = null,
layout: String? = null,
preferences: MutableSet<BasePreference> = mutableSetOf(),
) : BasePreferenceCollection(key, titleKey, icon, iconBold, layout, preferences) {
override fun transform(): PreferenceCategory {
return PreferenceCategory(
key = key,
titleKey = titleKey,
icon = icon,
iconBold = iconBold,
layout = layout,
sorting = sorting,
preferences = preferences,
)
}
fun addPreferences(vararg preferences: BasePreference) {
ensureScreenInserted()
// Add to the categories if not done yet.
if (!categories.contains(this)) {
categories.add(this)
}
this.preferences.addAll(preferences)
}
}
}
abstract class BasePreferenceCollection(
val key: String? = null,
val titleKey: String = "${key}_title",
val icon: String? = null,
val iconBold: String? = null,
val layout: String? = null,
val preferences: MutableSet<BasePreference> = mutableSetOf(),
) {
abstract fun transform(): BasePreference
}
}

View file

@ -0,0 +1,9 @@
package app.morphe.patches.shared.misc.settings.preference
enum class InputType(val type: String) {
TEXT("text"),
TEXT_CAP_CHARACTERS("textCapCharacters"),
TEXT_MULTI_LINE("textMultiLine"),
NUMBER("number"),
NUMBER_DECIMAL("numberDecimal"),
}

View file

@ -0,0 +1,58 @@
package app.morphe.patches.shared.misc.settings.preference
import app.morphe.util.resource.BaseResource
import org.w3c.dom.Document
/**
* A preference that opens an intent.
*
* @param key Optional preference key.
* @param titleKey The preference title key.
* @param summaryKey The preference summary key.
* @param icon The preference icon resource name.
* @param layout Layout declaration.
* @param tag The preference tag.
* @param intent The intent to open.
*/
class IntentPreference(
key: String? = null,
titleKey: String = "${key}_title",
summaryKey: String? = "${key}_summary",
icon: String? = null,
iconBold: String? = null,
layout: String? = null,
tag: String = "Preference",
val intent: Intent,
) : BasePreference(key, titleKey, summaryKey, icon, iconBold, layout, tag) {
override fun serialize(ownerDocument: Document, resourceCallback: (BaseResource) -> Unit) =
super.serialize(ownerDocument, resourceCallback).apply {
appendChild(ownerDocument.createElement("intent").also { intentNode ->
intentNode.setAttribute("android:data", intent.data)
intentNode.setAttribute("android:targetClass", intent.targetClass)
intentNode.setAttribute("android:targetPackage", intent.targetPackageSupplier())
})
}
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
if (!super.equals(other)) return false
other as IntentPreference
return intent == other.intent
}
override fun hashCode(): Int {
var result = super.hashCode()
result = 31 * result + intent.hashCode()
return result
}
data class Intent(
internal val data: String,
internal val targetClass: String,
internal val targetPackageSupplier: () -> String,
)
}

View file

@ -0,0 +1,75 @@
package app.morphe.patches.shared.misc.settings.preference
import app.morphe.util.resource.ArrayResource
import app.morphe.util.resource.BaseResource
import org.w3c.dom.Document
/**
* List preference.
*
* @param key The preference key. If null, other parameters must be specified.
* @param titleKey The preference title key.
* @param icon The preference icon resource name.
* @param layout Layout declaration.
* @param tag The preference class type.
* @param entriesKey The entries array key.
* @param entryValuesKey The entry values array key.
*/
@Suppress("MemberVisibilityCanBePrivate")
class ListPreference(
key: String? = null,
titleKey: String = "${key}_title",
icon: String? = null,
iconBold: String? = null,
layout: String? = null,
tag: String = "app.morphe.extension.shared.settings.preference.CustomDialogListPreference",
val entriesKey: String? = "${key}_entries",
val entryValuesKey: String? = "${key}_entry_values"
) : BasePreference(key, titleKey, null, icon, iconBold, layout, tag) {
var entries: ArrayResource? = null
private set
var entryValues: ArrayResource? = null
private set
/**
* List preference.
*
* @param key The preference key. If null, other parameters must be specified.
* @param titleKey The preference title key.
* @param summaryKey The preference summary key.
* @param tag The preference tag.
* @param entries The entries array.
* @param entryValues The entry values array.
*/
constructor(
key: String? = null,
titleKey: String = "${key}_title",
summaryKey: String? = "${key}_summary",
tag: String = "ListPreference",
entries: ArrayResource,
entryValues: ArrayResource
) : this(key, titleKey, summaryKey, tag, entries.name, entryValues.name) {
this.entries = entries
this.entryValues = entryValues
}
override fun serialize(ownerDocument: Document, resourceCallback: (BaseResource) -> Unit) =
super.serialize(ownerDocument, resourceCallback).apply {
val entriesArrayName = entries?.also { resourceCallback.invoke(it) }?.name ?: entriesKey
val entryValuesArrayName = entryValues?.also { resourceCallback.invoke(it) }?.name ?: entryValuesKey
entriesArrayName?.let {
setAttribute(
"android:entries",
"@array/$it"
)
}
entryValuesArrayName?.let {
setAttribute(
"android:entryValues",
"@array/$it"
)
}
}
}

View file

@ -0,0 +1,33 @@
package app.morphe.patches.shared.misc.settings.preference
import app.morphe.util.resource.BaseResource
import org.w3c.dom.Document
/**
* A non-interactive preference.
*
* Typically used to present static text, but also used for custom extension code that responds to taps.
*
* @param key The preference key.
* @param summaryKey The preference summary key.
* @param icon The preference icon resource name.
* @param layout Layout declaration.
* @param tag The tag or full class name of the preference.
* @param selectable If the preference is selectable and responds to tap events.
*/
@Suppress("MemberVisibilityCanBePrivate")
class NonInteractivePreference(
key: String,
titleKey: String = "${key}_title",
summaryKey: String? = "${key}_summary",
icon: String? = null,
iconBold: String? = null,
layout: String? = null,
tag: String = "Preference",
val selectable: Boolean = false,
) : BasePreference(key, titleKey, summaryKey, icon, iconBold, layout, tag) {
override fun serialize(ownerDocument: Document, resourceCallback: (BaseResource) -> Unit) =
super.serialize(ownerDocument, resourceCallback).apply {
setAttribute("android:selectable", selectable.toString())
}
}

View file

@ -0,0 +1,35 @@
package app.morphe.patches.shared.misc.settings.preference
import app.morphe.patches.shared.misc.settings.preference.PreferenceScreenPreference.Sorting
import app.morphe.util.resource.BaseResource
import org.w3c.dom.Document
/**
* A preference category.
*
* @param key The key of the preference. If null, other parameters must be specified.
* @param titleKey The key of the preference title.
* @param icon The preference icon resource name.
* @param layout Layout declaration.
* @param tag The tag or full class name of the preference.
* @param preferences The preferences in this category.
*/
@Suppress("MemberVisibilityCanBePrivate")
open class PreferenceCategory(
key: String? = null,
titleKey: String? = "${key}_title",
icon: String? = null,
iconBold: String? = null,
layout: String? = null,
sorting: Sorting = Sorting.BY_TITLE,
tag: String = "PreferenceCategory",
val preferences: Set<BasePreference>
) : BasePreference(sorting.appendSortType(key), titleKey, null, icon, iconBold, layout, tag) {
override fun serialize(ownerDocument: Document, resourceCallback: (BaseResource) -> Unit) =
super.serialize(ownerDocument, resourceCallback).apply {
preferences.forEach {
appendChild(it.serialize(ownerDocument, resourceCallback))
}
}
}

View file

@ -0,0 +1,73 @@
package app.morphe.patches.shared.misc.settings.preference
import app.morphe.util.resource.BaseResource
import org.w3c.dom.Document
/**
* A preference screen.
*
* @param key The key of the preference. If null, other parameters must be specified.
* @param titleKey The key of the preference title.
* @param summaryKey The key of the preference summary.
* @param icon The preference icon resource name.
* @param layout Layout declaration.
* @param sorting Sorting to use. If the sorting is not [Sorting.UNSORTED],
* then the key parameter will be modified to include the sort type.
* @param tag The tag or full class name of the preference.
* @param preferences The preferences in this screen.
*/
@Suppress("MemberVisibilityCanBePrivate")
open class PreferenceScreenPreference(
key: String? = null,
titleKey: String = "${key}_title",
summaryKey: String? = "${key}_summary",
icon: String? = null,
iconBold: String? = null,
layout: String? = null,
sorting: Sorting = Sorting.BY_TITLE,
tag: String = "PreferenceScreen",
val preferences: Set<BasePreference>,
// Alternatively, instead of repurposing the key for sorting,
// an extra bundle parameter can be added to the preferences XML declaration.
// This would require bundling and referencing an additional XML file
// or adding new attributes to the attrs.xml file.
// Since the key value is not currently used by the extensions,
// for now it's much simpler to modify the key to include the sort parameter.
) : BasePreference(sorting.appendSortType(key), titleKey, summaryKey, icon, iconBold, layout, tag) {
override fun serialize(ownerDocument: Document, resourceCallback: (BaseResource) -> Unit) =
super.serialize(ownerDocument, resourceCallback).apply {
preferences.forEach {
appendChild(it.serialize(ownerDocument, resourceCallback))
}
}
/**
* How a PreferenceScreen should be sorted.
*/
enum class Sorting(val keySuffix: String) {
/**
* Sort by the localized preference title.
*/
BY_TITLE("_sort_by_title"),
/**
* Sort by the preference keys.
*/
BY_KEY("_sort_by_key"),
/**
* Unspecified sorting.
*/
UNSORTED("_sort_by_unsorted");
/**
* @return The key with this sort type appended to to the end,
* or if key is null then null is returned.
*/
fun appendSortType(key: String?): String? {
if (key == null) return null
if (this == UNSORTED) return key
return key + keySuffix
}
}
}

View file

@ -0,0 +1,5 @@
package app.morphe.patches.shared.misc.settings.preference
enum class SummaryType(val type: String) {
DEFAULT("summary"), ON("summaryOn"), OFF("summaryOff")
}

View file

@ -0,0 +1,33 @@
package app.morphe.patches.shared.misc.settings.preference
import app.morphe.util.resource.BaseResource
import org.w3c.dom.Document
/**
* A switch preference.
*
* @param key The preference key. If null, other parameters must be specified.
* @param titleKey The preference title key.
* @param icon The preference icon resource name.
* @param layout Layout declaration.
* @param tag The preference tag.
* @param summaryOnKey The preference summary-on key.
* @param summaryOffKey The preference summary-off key.
*/
@Suppress("MemberVisibilityCanBePrivate")
class SwitchPreference(
key: String? = null,
titleKey: String = "${key}_title",
tag: String = "SwitchPreference",
icon: String? = null,
iconBold: String? = null,
layout: String? = null,
val summaryOnKey: String = "${key}_summary_on",
val summaryOffKey: String = "${key}_summary_off"
) : BasePreference(key, titleKey, null, icon, iconBold, layout, tag) {
override fun serialize(ownerDocument: Document, resourceCallback: (BaseResource) -> Unit) =
super.serialize(ownerDocument, resourceCallback).apply {
addSummary(summaryOnKey, SummaryType.ON)
addSummary(summaryOffKey, SummaryType.OFF)
}
}

View file

@ -0,0 +1,33 @@
package app.morphe.patches.shared.misc.settings.preference
import app.morphe.util.resource.BaseResource
import org.w3c.dom.Document
/**
* A text preference.
*
* @param key The preference key. If null, other parameters must be specified.
* @param titleKey The preference title key.
* @param summaryKey The preference summary key.
* @param icon The preference icon resource name.
* @param layout Layout declaration.
* @param tag The preference tag.
* @param inputType The preference input type.
*/
@Suppress("MemberVisibilityCanBePrivate")
class TextPreference(
key: String? = null,
titleKey: String = "${key}_title",
summaryKey: String? = "${key}_summary",
icon: String? = null,
iconBold: String? = null,
layout: String? = null,
tag: String = "app.morphe.extension.shared.settings.preference.ResettableEditTextPreference",
val inputType: InputType = InputType.TEXT
) : BasePreference(key, titleKey, summaryKey, icon, iconBold, layout, tag) {
override fun serialize(ownerDocument: Document, resourceCallback: (BaseResource) -> Unit) =
super.serialize(ownerDocument, resourceCallback).apply {
setAttribute("android:inputType", inputType.type)
}
}

View file

@ -0,0 +1,195 @@
package app.morphe.patches.shared.misc.spoof
import app.morphe.patcher.Fingerprint
import app.morphe.patcher.OpcodesFilter
import app.morphe.patcher.literal
import app.morphe.patcher.methodCall
import app.morphe.patcher.string
import app.morphe.util.getReference
import app.morphe.util.indexOfFirstInstruction
import com.android.tools.smali.dexlib2.AccessFlags
import com.android.tools.smali.dexlib2.Opcode
import com.android.tools.smali.dexlib2.iface.Method
import com.android.tools.smali.dexlib2.iface.reference.MethodReference
internal object BuildInitPlaybackRequestFingerprint : Fingerprint(
returnType = "Lorg/chromium/net/UrlRequest\$Builder;",
filters = OpcodesFilter.opcodesToFilters(
Opcode.MOVE_RESULT_OBJECT,
Opcode.IGET_OBJECT, // Moves the request URI string to a register to build the request with.
),
strings = listOf(
"Content-Type",
"Range",
)
)
internal object BuildPlayerRequestURIFingerprint : Fingerprint(
returnType = "Ljava/lang/String;",
filters = OpcodesFilter.opcodesToFilters(
Opcode.INVOKE_VIRTUAL, // Register holds player request URI.
Opcode.MOVE_RESULT_OBJECT,
Opcode.IPUT_OBJECT,
Opcode.IGET_OBJECT,
Opcode.MONITOR_EXIT,
Opcode.RETURN_OBJECT,
),
strings = listOf(
"key",
"asig",
)
)
internal object BuildRequestFingerprint : Fingerprint(
accessFlags = listOf(AccessFlags.PUBLIC, AccessFlags.STATIC),
returnType = "Lorg/chromium/net/UrlRequest", // UrlRequest; or UrlRequest$Builder;
filters = listOf(
methodCall(name = "newUrlRequestBuilder")
), // UrlRequest; or UrlRequest$Builder;
custom = { methodDef, _ ->
// Different targets have slightly different parameters
// Earlier targets have parameters = listOf(:),
// L
// Ljava/util/Map;
// [B
// L
// L
// L
// Lorg/chromium/net/UrlRequest$Callback;
// Later targets have parameters = listOf(:),
// L
// Ljava/util/Map;
// [B
// L
// L
// L
// Lorg/chromium/net/UrlRequest\$Callback;
// L
// 20.16+ uses a refactored and extracted method:
// L
// Ljava/util/Map;
// [B
// L
// Lorg/chromium/net/UrlRequest$Callback;
// L
val parameterTypes = methodDef.parameterTypes
val parameterTypesSize = parameterTypes.size
(parameterTypesSize == 6 || parameterTypesSize == 7 || parameterTypesSize == 8) &&
parameterTypes[1] == "Ljava/util/Map;" // URL headers.
&& indexOfNewUrlRequestBuilderInstruction(methodDef) >= 0
}
)
internal object CreateStreamingDataFingerprint : Fingerprint(
accessFlags = listOf(AccessFlags.PUBLIC, AccessFlags.CONSTRUCTOR),
parameters = listOf("L"),
filters = OpcodesFilter.opcodesToFilters(
Opcode.IPUT_OBJECT,
Opcode.IGET_OBJECT,
Opcode.IF_NEZ,
Opcode.SGET_OBJECT,
Opcode.IPUT_OBJECT,
),
custom = { method, classDef ->
classDef.fields.any { field ->
field.name == "a" && field.type.endsWith("/StreamingDataOuterClass\$StreamingData;")
}
}
)
internal object BuildMediaDataSourceFingerprint : Fingerprint(
accessFlags = listOf(AccessFlags.PUBLIC, AccessFlags.CONSTRUCTOR),
returnType = "V",
parameters = listOf(
"Landroid/net/Uri;",
"J",
"I",
"[B",
"Ljava/util/Map;",
"J",
"J",
"Ljava/lang/String;",
"I",
"Ljava/lang/Object;",
)
)
internal object HlsCurrentTimeFingerprint : Fingerprint(
accessFlags = listOf(AccessFlags.PUBLIC, AccessFlags.FINAL),
parameters = listOf("Z", "L"),
filters = listOf(
literal(45355374L) // HLS current time feature flag.
)
)
internal const val DISABLED_BY_SABR_STREAMING_URI_STRING = "DISABLED_BY_SABR_STREAMING_URI"
internal object MediaFetchEnumConstructorFingerprint : Fingerprint(
returnType = "V",
strings = listOf(
"ENABLED",
"DISABLED_FOR_PLAYBACK",
DISABLED_BY_SABR_STREAMING_URI_STRING
)
)
internal object NerdsStatsVideoFormatBuilderFingerprint : Fingerprint(
accessFlags = listOf(AccessFlags.PUBLIC, AccessFlags.STATIC),
returnType = "Ljava/lang/String;",
parameters = listOf("L"),
filters = listOf(
string("codecs=\"")
)
)
// Feature flag that turns on Platypus programming language code compiled to native C++.
// This code appears to replace the player config after the streams are loaded.
// Flag is present in YouTube 19.34, but is missing Platypus stream replacement code until 19.43.
// Flag and Platypus code is also present in newer versions of YouTube Music.
internal object MediaFetchHotConfigFingerprint : Fingerprint(
filters = listOf(
literal(45645570L)
)
)
// YT 20.10+, YT Music 8.11 - 8.14.
// Flag is missing in YT Music 8.15+, and it is not known if a replacement flag/feature exists.
internal object MediaFetchHotConfigAlternativeFingerprint : Fingerprint(
filters = listOf(
literal(45683169L)
)
)
// Feature flag that enables different code for parsing and starting video playback,
// but its exact purpose is not known. If this flag is enabled while stream spoofing
// then videos will never start playback and load forever.
// Flag does not seem to affect playback if spoofing is off.
internal object PlaybackStartDescriptorFeatureFlagFingerprint : Fingerprint(
parameters = listOf(),
returnType = "Z",
filters = listOf(
literal(45665455L)
)
)
internal object MediaSessionFeatureFlagFingerprint : Fingerprint(
parameters = listOf(),
returnType = "Z",
filters = listOf(
literal(45640404L)
)
)
internal fun indexOfNewUrlRequestBuilderInstruction(method: Method) = method.indexOfFirstInstruction {
val reference = getReference<MethodReference>()
opcode == Opcode.INVOKE_VIRTUAL && reference?.definingClass == "Lorg/chromium/net/CronetEngine;"
&& reference.name == "newUrlRequestBuilder"
&& reference.parameterTypes.size == 3
&& reference.parameterTypes[0] == "Ljava/lang/String;"
&& reference.parameterTypes[1] == "Lorg/chromium/net/UrlRequest\$Callback;"
&& reference.parameterTypes[2] == "Ljava/util/concurrent/Executor;"
}

View file

@ -0,0 +1,441 @@
/*
* Copyright 2026 Morphe.
* https://github.com/MorpheApp/morphe-patches
*
* Original hard forked code:
* https://github.com/ReVanced/revanced-patches/commit/724e6d61b2ecd868c1a9a37d465a688e83a74799
*/
package app.morphe.patches.shared.misc.spoof
import app.morphe.patcher.Fingerprint
import app.morphe.patcher.OpcodesFilter.Companion.opcodesToFilters
import app.morphe.patcher.extensions.InstructionExtensions.addInstruction
import app.morphe.patcher.extensions.InstructionExtensions.addInstructions
import app.morphe.patcher.extensions.InstructionExtensions.addInstructionsWithLabels
import app.morphe.patcher.extensions.InstructionExtensions.getInstruction
import app.morphe.patcher.patch.BytecodePatchBuilder
import app.morphe.patcher.patch.BytecodePatchContext
import app.morphe.patcher.patch.bytecodePatch
import app.morphe.patcher.patch.rawResourcePatch
import app.morphe.patcher.util.proxy.mutableTypes.MutableMethod
import app.morphe.patcher.util.proxy.mutableTypes.MutableMethod.Companion.toMutable
import app.morphe.patches.shared.misc.fix.proto.fixProtoLibraryPatch
import app.morphe.patches.shared.misc.fix.proto.parseByteArrayMethod
import app.morphe.util.ResourceGroup
import app.morphe.util.copyResources
import app.morphe.util.findFreeRegister
import app.morphe.util.findInstructionIndicesReversedOrThrow
import app.morphe.util.getReference
import app.morphe.util.indexOfFirstInstructionOrThrow
import app.morphe.util.indexOfFirstInstructionReversedOrThrow
import app.morphe.util.inputStreamFromBundledResource
import app.morphe.util.insertLiteralOverride
import app.morphe.util.setExtensionIsPatchIncluded
import com.android.tools.smali.dexlib2.AccessFlags
import com.android.tools.smali.dexlib2.Opcode
import com.android.tools.smali.dexlib2.builder.MutableMethodImplementation
import com.android.tools.smali.dexlib2.iface.instruction.FiveRegisterInstruction
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.instruction.TwoRegisterInstruction
import com.android.tools.smali.dexlib2.iface.reference.FieldReference
import com.android.tools.smali.dexlib2.immutable.ImmutableMethod
import com.android.tools.smali.dexlib2.immutable.ImmutableMethodParameter
import org.w3c.dom.Element
import java.lang.ref.WeakReference
import java.nio.file.Files
internal const val EXTENSION_CLASS_DESCRIPTOR =
"Lapp/morphe/extension/shared/spoof/SpoofVideoStreamsPatch;"
private lateinit var buildRequestMethodRef : WeakReference<MutableMethod>
private var buildRequestMethodURLRegister = -1
private val spoofVideoStreamsRawResourcePatch = rawResourcePatch {
execute {
// region copy the j2v8 library.
setOf(
"arm64-v8a",
"armeabi-v7a",
"x86",
"x86_64"
).forEach { arch ->
val architectureDirectory = get("lib/$arch")
// For YouTube Music, there is only one architecture in the app.
// Copy only if the architecture folder exists.
if (architectureDirectory.exists()) {
val inputStream = inputStreamFromBundledResource(
"spoof/jniLibs",
"$arch/libj2v8.so"
)
if (inputStream != null) {
Files.copy(
inputStream,
architectureDirectory.resolve("libj2v8.so").toPath(),
)
}
}
}
copyResources(
"spoof",
ResourceGroup(
"raw",
"astring-1.9.0.min.js",
"meriyah-6.1.4.min.js",
"polyfill.js",
"yt.solver.core.js", // yt-dlp-ejs 0.5.1: https://github.com/MorpheApp/ejs/releases/tag/0.5.1
)
)
// Fix compile error in YouTube Music.
document("AndroidManifest.xml").use { document ->
val applicationNode =
document
.getElementsByTagName("application")
.item(0) as Element
applicationNode.setAttribute("android:extractNativeLibs", "true")
}
// endregion
}
}
internal fun spoofVideoStreamsPatch(
extensionClassDescriptor: String,
mainActivityOnCreateFingerprint: Fingerprint,
fixMediaFetchHotConfig: BytecodePatchBuilder.() -> Boolean = { false },
fixMediaFetchHotConfigAlternative: BytecodePatchBuilder.() -> Boolean = { false },
fixParsePlaybackResponseFeatureFlag: BytecodePatchBuilder.() -> Boolean = { false },
fixMediaSessionFeatureFlag: BytecodePatchBuilder.() -> Boolean = { false },
block: BytecodePatchBuilder.() -> Unit,
executeBlock: BytecodePatchContext.() -> Unit = {},
) = bytecodePatch(
// This patch is part of the 'GmsCore support' patch.
// name = "Spoof video streams",
description = "Adds options to spoof the client video streams to fix playback."
) {
block()
dependsOn(
fixProtoLibraryPatch,
spoofVideoStreamsRawResourcePatch,
)
execute {
mainActivityOnCreateFingerprint.method.addInstructions(
0,
"""
invoke-static/range { p0 .. p0 }, $EXTENSION_CLASS_DESCRIPTOR->setMainActivity(Landroid/app/Activity;)V
invoke-static { }, $extensionClassDescriptor->setClientOrderToUse()V
"""
)
// region Enable extension helper method used by other patches
setExtensionIsPatchIncluded(EXTENSION_CLASS_DESCRIPTOR)
// endregion
// region Block /initplayback requests to fall back to /get_watch requests.
BuildInitPlaybackRequestFingerprint.let {
it.method.apply {
val moveUriStringIndex = it.instructionMatches.first().index
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.let {
it.method.apply {
val invokeToStringIndex = it.instructionMatches.first().index
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.method.apply {
buildRequestMethodRef = WeakReference(this)
val newRequestBuilderIndex = BuildRequestFingerprint.instructionMatches.first().index
buildRequestMethodURLRegister = getInstruction<FiveRegisterInstruction>(newRequestBuilderIndex).registerD
val freeRegister = findFreeRegister(newRequestBuilderIndex, buildRequestMethodURLRegister)
addInstructions(
newRequestBuilderIndex,
"""
move-object v$freeRegister, p1
invoke-static { v$buildRequestMethodURLRegister, v$freeRegister }, $EXTENSION_CLASS_DESCRIPTOR->fetchStreams(Ljava/lang/String;Ljava/util/Map;)V
"""
)
}
// endregion
// region Replace the streaming data with the replacement streams.
CreateStreamingDataFingerprint.method.apply {
val setStreamDataMethodName = "patch_setStreamingData"
val resultMethodType = CreateStreamingDataFingerprint.classDef.type
val videoDetailsIndex = CreateStreamingDataFingerprint.instructionMatches.last().index
val videoDetailsRegister = getInstruction<TwoRegisterInstruction>(videoDetailsIndex).registerA
val videoDetailsClass = getInstruction(videoDetailsIndex).getReference<FieldReference>()!!.type
addInstruction(
videoDetailsIndex + 1,
"invoke-direct { p0, v$videoDetailsRegister }, " +
"$resultMethodType->$setStreamDataMethodName($videoDetailsClass)V",
)
val setStreamingDataIndex = CreateStreamingDataFingerprint.instructionMatches.first().index
val playerProtoClass = getInstruction(setStreamingDataIndex + 1)
.getReference<FieldReference>()!!.definingClass
val setStreamingDataField = getInstruction(setStreamingDataIndex).getReference<FieldReference>()
val getStreamingDataField = getInstruction(
indexOfFirstInstructionOrThrow {
opcode == Opcode.IGET_OBJECT && getReference<FieldReference>()?.definingClass == playerProtoClass
},
).getReference<FieldReference>()
// Use a helper method to avoid the need of picking out multiple free registers from the hooked code.
CreateStreamingDataFingerprint.classDef.methods.add(
ImmutableMethod(
resultMethodType,
setStreamDataMethodName,
listOf(ImmutableMethodParameter(videoDetailsClass, null, "videoDetails")),
"V",
AccessFlags.PRIVATE.value or AccessFlags.FINAL.value,
null,
null,
MutableMethodImplementation(9),
).toMutable().apply {
addInstructionsWithLabels(
0,
"""
invoke-static { }, $EXTENSION_CLASS_DESCRIPTOR->isSpoofingEnabled()Z
move-result v0
if-eqz v0, :disabled
# Get video ID.
iget-object v2, p1, $videoDetailsClass->c:Ljava/lang/String;
if-eqz v2, :disabled
# Get streaming data.
invoke-static { v2 }, $EXTENSION_CLASS_DESCRIPTOR->getStreamingData(Ljava/lang/String;)[B
move-result-object v3
if-eqz v3, :disabled
# Parse streaming data.
sget-object v4, $playerProtoClass->a:$playerProtoClass
invoke-static { v4, v3 }, $parseByteArrayMethod
move-result-object v5
check-cast v5, $playerProtoClass
# Set streaming data.
iget-object v6, v5, $getStreamingDataField
if-eqz v6, :disabled
iput-object v6, p0, $setStreamingDataField
:disabled
return-void
"""
)
}
)
}
// endregion
// region block getAtt request
buildRequestMethodRef.get()!!.apply {
val insertIndex = indexOfNewUrlRequestBuilderInstruction(this)
addInstructions(
insertIndex, """
invoke-static { v$buildRequestMethodURLRegister }, $EXTENSION_CLASS_DESCRIPTOR->blockGetAttRequest(Ljava/lang/String;)Ljava/lang/String;
move-result-object v$buildRequestMethodURLRegister
"""
)
}
// endregion
// region Remove video playback request body to fix playback.
// It is assumed, YouTube makes a request with a body tuned for Android.
// Requesting streams intended for other platforms with a body tuned for Android could be the cause of 400 errors.
// A proper fix may include modifying the request body to match the platforms expected body.
BuildMediaDataSourceFingerprint.method.apply {
val targetIndex =
indexOfFirstInstructionReversedOrThrow(Opcode.RETURN_VOID)
// Instructions are added just before the method returns,
// so there's no concern of clobbering in-use registers.
addInstructions(
targetIndex,
"""
# Field a: Stream uri.
# Field c: Http method.
# Field d: Post data.
move-object v0, p0 # method has over 15 registers and must copy p0 to a lower register.
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.method.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 Fix iOS livestream current time.
HlsCurrentTimeFingerprint.let {
it.method.insertLiteralOverride(
it.instructionMatches.first().index,
"$EXTENSION_CLASS_DESCRIPTOR->fixHLSCurrentTime(Z)Z"
)
}
// endregion
// region Disable SABR playback.
// If SABR is disabled, it seems 'MediaFetchHotConfig' may no longer need an override (not confirmed).
val (mediaFetchEnumClass, sabrFieldReference) = with(MediaFetchEnumConstructorFingerprint.method) {
val stringIndex = MediaFetchEnumConstructorFingerprint.stringMatches.first {
it.string == DISABLED_BY_SABR_STREAMING_URI_STRING
}.index
val mediaFetchEnumClass = definingClass
val sabrFieldIndex = indexOfFirstInstructionOrThrow(stringIndex) {
opcode == Opcode.SPUT_OBJECT &&
getReference<FieldReference>()?.type == mediaFetchEnumClass
}
Pair(
mediaFetchEnumClass,
getInstruction<ReferenceInstruction>(sabrFieldIndex).reference
)
}
val sabrFingerprint = Fingerprint(
returnType = mediaFetchEnumClass,
filters = opcodesToFilters(
Opcode.SGET_OBJECT,
Opcode.RETURN_OBJECT,
),
custom = { method, _ ->
!method.parameterTypes.isEmpty()
}
)
sabrFingerprint.method.addInstructionsWithLabels(
0,
"""
invoke-static { }, $EXTENSION_CLASS_DESCRIPTOR->disableSABR()Z
move-result v0
if-eqz v0, :ignore
sget-object v0, $sabrFieldReference
return-object v0
:ignore
nop
"""
)
// endregion
// region turn off stream config replacement feature flag.
if (fixMediaFetchHotConfig()) {
MediaFetchHotConfigFingerprint.let {
it.method.insertLiteralOverride(
it.instructionMatches.first().index,
"$EXTENSION_CLASS_DESCRIPTOR->useMediaFetchHotConfigReplacement(Z)Z"
)
}
}
if (fixMediaFetchHotConfigAlternative()) {
MediaFetchHotConfigAlternativeFingerprint.let {
it.method.insertLiteralOverride(
it.instructionMatches.first().index,
"$EXTENSION_CLASS_DESCRIPTOR->useMediaFetchHotConfigReplacement(Z)Z"
)
}
}
if (fixParsePlaybackResponseFeatureFlag()) {
PlaybackStartDescriptorFeatureFlagFingerprint.let {
it.method.insertLiteralOverride(
it.instructionMatches.first().index,
"$EXTENSION_CLASS_DESCRIPTOR->usePlaybackStartFeatureFlag(Z)Z"
)
}
}
if (fixMediaSessionFeatureFlag()) {
MediaSessionFeatureFlagFingerprint.let {
it.method.insertLiteralOverride(
it.instructionMatches.first().index,
"$EXTENSION_CLASS_DESCRIPTOR->useMediaSessionFeatureFlag(Z)Z"
)
}
}
// endregion
executeBlock()
}
}

View file

@ -0,0 +1,81 @@
package app.morphe.patches.shared.misc.spoof
import app.morphe.patcher.extensions.InstructionExtensions.getInstruction
import app.morphe.patcher.extensions.InstructionExtensions.replaceInstruction
import app.morphe.patches.all.misc.transformation.IMethodCall
import app.morphe.patches.all.misc.transformation.filterMapInstruction35c
import app.morphe.patches.all.misc.transformation.transformInstructionsPatch
import app.morphe.util.getReference
import app.morphe.util.indexOfFirstInstruction
import com.android.tools.smali.dexlib2.Opcode
import com.android.tools.smali.dexlib2.iface.instruction.OneRegisterInstruction
import com.android.tools.smali.dexlib2.iface.reference.MethodReference
import com.android.tools.smali.dexlib2.iface.reference.StringReference
private const val USER_AGENT_STRING_BUILDER_APPEND_METHOD_REFERENCE =
"Ljava/lang/StringBuilder;->append(Ljava/lang/String;)Ljava/lang/StringBuilder;"
fun userAgentClientSpoofPatch(originalPackageName: String) = transformInstructionsPatch(
filterMap = { classDef, _, instruction, instructionIndex ->
filterMapInstruction35c<MethodCall>(
"Lapp/morphe/extension",
classDef,
instruction,
instructionIndex,
)
},
transform = transform@{ mutableMethod, entry ->
val (_, _, instructionIndex) = entry
// Replace the result of context.getPackageName(), if it is used in a user agent string.
mutableMethod.apply {
// After context.getPackageName() the result is moved to a register.
val targetRegister = (
getInstruction(instructionIndex + 1)
as? OneRegisterInstruction ?: return@transform
).registerA
// IndexOutOfBoundsException is technically possible here,
// but no such occurrences are present in the app.
val referee = getInstruction(instructionIndex + 2).getReference<MethodReference>()?.toString()
// Only replace string builder usage.
if (referee != USER_AGENT_STRING_BUILDER_APPEND_METHOD_REFERENCE) {
return@transform
}
// Do not change the package name in methods that use resources, or for methods that use GmsCore.
// Changing these package names will result in playback limitations,
// particularly Android VR background audio only playback.
val resourceOrGmsStringInstructionIndex = indexOfFirstInstruction {
val reference = getReference<StringReference>()
opcode == Opcode.CONST_STRING &&
(reference?.string == "android.resource://" || reference?.string == "gcore_")
}
if (resourceOrGmsStringInstructionIndex >= 0) {
return@transform
}
// Overwrite the result of context.getPackageName() with the original package name.
replaceInstruction(
instructionIndex + 1,
"const-string v$targetRegister, \"$originalPackageName\"",
)
}
},
)
@Suppress("unused")
private enum class MethodCall(
override val definedClassName: String,
override val methodName: String,
override val methodParams: Array<String>,
override val returnType: String,
) : IMethodCall {
GetPackageName(
"Landroid/content/Context;",
"getPackageName",
emptyArray(),
"Ljava/lang/String;",
),
}

View file

@ -496,37 +496,6 @@ fun spoofStreamingDataPatch(
// endregion
// region Player buttons
if (isYouTube()) {
val directory = if (outlineIcon())
"outline"
else
"default"
arrayOf(
ResourceGroup(
"drawable",
"revanced_reload_video.xml",
)
).forEach { resourceGroup ->
context.copyResources("youtube/spoof/$directory", resourceGroup)
}
progressBarVisibilityFingerprint
.methodOrThrow(progressBarVisibilityParentFingerprint).apply {
val index = indexOfProgressBarVisibilityInstruction(this)
val register = getInstruction<FiveRegisterInstruction>(index).registerD
addInstructionsAtControlFlowLabel(
index,
"invoke-static {v$register}, $EXTENSION_RELOAD_VIDEO_CLASS_DESCRIPTOR->setProgressBarVisibility(I)V"
)
}
}
// endregion
// region Append spoof info.
nerdsStatsFormatBuilderFingerprint.methodOrThrow().apply {

View file

@ -231,6 +231,7 @@ private val rvxPreferenceKey = mapOf(
// Other settings
"gms_core_settings" to "M 432.54 840 C 427.307 840 422.563 838.283 418.31 834.85 C 414.05 831.41 411.51 827.077 410.69 821.85 L 397.23 725.54 C 382.51 720.873 366.357 713.643 348.77 703.85 C 331.177 694.05 316.28 683.537 304.08 672.31 L 216.54 712.31 C 211.307 714.257 206.077 714.5 200.85 713.04 C 195.617 711.58 191.617 708.31 188.85 703.23 L 140.08 618.46 C 137.307 613.387 136.447 608.36 137.5 603.38 C 138.553 598.407 141.54 594.203 146.46 590.77 L 225.69 531.77 C 224.357 523.717 223.267 515.217 222.42 506.27 C 221.573 497.323 221.15 488.823 221.15 480.77 C 221.15 473.23 221.573 464.987 222.42 456.04 C 223.267 447.093 224.357 437.823 225.69 428.23 L 146.46 369.23 C 141.54 365.797 138.68 361.463 137.88 356.23 C 137.087 350.997 138.077 345.843 140.85 340.77 L 188.85 258.31 C 191.617 253.743 195.617 250.6 200.85 248.88 C 206.077 247.167 211.307 247.283 216.54 249.23 L 303.31 287.69 C 317.05 276.463 332.203 266.08 348.77 256.54 C 365.337 247 381.233 239.973 396.46 235.46 L 410.69 138.15 C 411.51 132.923 414.05 128.59 418.31 125.15 C 422.563 121.717 427.307 120 432.54 120 L 527.46 120 C 532.693 120 537.437 121.717 541.69 125.15 C 545.95 128.59 548.49 132.923 549.31 138.15 L 562.77 235.23 C 579.537 241.437 595.473 248.757 610.58 257.19 C 625.68 265.63 640.027 275.797 653.62 287.69 L 744.23 249.23 C 749.463 247.283 754.657 247.167 759.81 248.88 C 764.963 250.6 768.923 253.743 771.69 258.31 L 819.92 341.54 C 822.693 346.613 823.553 351.807 822.5 357.12 C 821.447 362.427 818.46 366.463 813.54 369.23 L 731.23 429.31 C 733.59 438.537 735.063 447.37 735.65 455.81 C 736.243 464.243 736.54 472.307 736.54 480 C 736.54 487.18 736.117 494.95 735.27 503.31 C 734.423 511.67 733.077 520.977 731.23 531.23 L 811.23 590.77 C 816.157 593.537 819.273 597.573 820.58 602.88 C 821.887 608.193 821.153 613.387 818.38 618.46 L 771.15 702.46 C 767.87 707.54 763.487 710.81 758 712.27 C 752.513 713.73 747.41 713.487 742.69 711.54 L 653.62 671.54 C 639.36 683.793 624.627 694.6 609.42 703.96 C 594.22 713.32 578.67 720.257 562.77 724.77 L 549.31 821.85 C 548.49 827.077 545.95 831.41 541.69 834.85 C 537.437 838.283 532.693 840 527.46 840 L 432.54 840 Z M 438.31 809.23 L 520.92 809.23 L 535.69 698 C 556.15 692.667 574.933 685.103 592.04 675.31 C 609.14 665.517 626.46 652.363 644 635.85 L 746.92 680.31 L 786.92 610.62 L 696 543.15 C 698.667 530.797 700.707 519.63 702.12 509.65 C 703.527 499.677 704.23 489.793 704.23 480 C 704.23 469.18 703.563 459.04 702.23 449.58 C 700.897 440.113 698.82 429.713 696 418.38 L 788.46 349.38 L 748.46 279.69 L 643.23 324.15 C 630.463 309.897 613.9 296.553 593.54 284.12 C 573.18 271.68 553.64 264.307 534.92 262 L 521.69 150.77 L 438.31 150.77 L 425.85 261.23 C 404.203 265.383 384.573 272.487 366.96 282.54 C 349.347 292.593 332.103 306.207 315.23 323.38 L 211.54 279.69 L 171.54 349.38 L 262.46 416.08 C 259.28 425.873 256.987 436.14 255.58 446.88 C 254.167 457.627 253.46 468.923 253.46 480.77 C 253.46 491.59 254.167 502.117 255.58 512.35 C 256.987 522.577 259.023 532.843 261.69 543.15 L 171.54 610.62 L 211.54 680.31 L 314.46 636.62 C 330.46 653.127 347.397 666.28 365.27 676.08 C 383.143 685.873 403.08 693.437 425.08 698.77 L 438.31 809.23 Z M 430.15 587.46 L 529.85 587.46 C 540.384 587.46 549.427 583.683 556.98 576.13 C 564.534 568.583 568.31 559.54 568.31 549 L 568.31 544.76 C 568.31 540.353 566.887 536.766 564.04 534 C 561.2 531.233 557.777 529.85 553.77 529.85 L 551.31 529.85 C 547.23 529.85 543.914 531.27 541.36 534.11 C 538.814 536.95 537.54 540.373 537.54 544.38 C 537.54 547.46 536.257 550.283 533.69 552.85 C 531.13 555.41 528.31 556.69 525.23 556.69 L 434.77 556.69 C 431.69 556.69 428.87 555.41 426.31 552.85 C 423.744 550.283 422.46 547.46 422.46 544.38 L 422.46 415.62 C 422.46 412.54 423.744 409.716 426.31 407.15 C 428.87 404.59 431.69 403.31 434.77 403.31 L 525.23 403.31 C 528.31 403.31 531.13 404.59 533.69 407.15 C 536.257 409.716 537.54 412.54 537.54 415.62 C 537.54 419.72 538.814 423.166 541.36 425.96 C 543.914 428.753 547.23 430.15 551.31 430.15 L 553.77 430.15 C 557.777 430.15 561.2 428.766 564.04 426 C 566.887 423.233 568.31 419.646 568.31 415.24 L 568.31 411 C 568.31 400.46 564.534 391.416 556.98 383.87 C 549.427 376.316 540.384 372.54 529.85 372.54 L 430.15 372.54 C 419.617 372.54 410.574 376.316 403.02 383.87 C 395.467 391.416 391.69 400.46 391.69 411 L 391.69 549 C 391.69 559.54 395.467 568.583 403.02 576.13 C 410.574 583.683 419.617 587.46 430.15 587.46 Z",
"morphe_spoof_video_streams_screen" to "M 270.77 793.85 L 270.77 824.62 Q 270.77 833.85 278.46 841.54 Q 286.15 849.23 295.38 849.23 L 664.62 849.23 Q 673.85 849.23 681.54 841.54 Q 689.23 833.85 689.23 824.62 L 689.23 793.85 L 270.77 793.85 Z M 687.54 569.69 Q 669.38 566.69 656.69 558.77 Q 644 550.85 635.46 539.77 L 620.54 547.38 Q 615.54 550.15 610.42 548.77 Q 605.31 547.38 602.31 543.15 L 600.38 540.92 Q 597.38 536.69 598.94 531.55 Q 600.49 526.41 604.38 523.23 L 618.62 512.69 Q 612.08 497.08 612.08 479.54 Q 612.08 462 618.62 445.62 L 604.38 435.08 Q 600.49 432.1 598.94 426.86 Q 597.38 421.62 600.41 416.72 L 602.31 415.15 Q 604.54 410.15 609.65 409.15 Q 614.77 408.15 619.77 410.92 L 635.46 418.54 Q 644 407.46 656.46 399.54 Q 668.92 391.62 687.54 388.62 L 689.23 370.23 Q 689.16 364.45 692.93 360.84 Q 696.71 357.23 702.35 357.23 L 706.46 357.23 Q 711.83 357.23 715.27 360.93 Q 718.7 364.62 719.54 370.23 L 721.23 388.62 Q 739.85 391.62 751.92 399.54 Q 764 407.46 773.31 418.54 L 788.23 410.92 Q 793.23 408.15 798.35 409.54 Q 803.46 410.92 806.46 415.92 L 808.38 417.38 Q 810.62 421.62 809.45 426.86 Q 808.28 432.1 804.38 435.08 L 790.15 445.62 Q 796.69 462 796.69 479.54 Q 796.69 497.08 790.15 512.69 L 804.38 523.23 Q 808.28 527.18 809.45 532.32 Q 810.62 537.46 807.62 541.69 L 805.69 543.92 Q 803.46 548.15 798.35 549.15 Q 793.23 550.15 788.23 547.38 L 773.31 539.77 Q 764 550.85 751.31 558.77 Q 738.62 566.69 721.23 569.69 L 719.54 588.08 Q 718.67 593.34 715.07 596.82 Q 711.46 600.31 706.08 600.31 L 701.85 600.31 Q 696.46 600.31 692.79 596.7 Q 689.13 593.09 689.23 588.08 L 687.54 569.69 Z M 704 543.23 Q 730.85 543.23 749.85 524.62 Q 768.85 506 768.85 479.15 Q 768.85 452.31 749.95 433.69 Q 731.05 415.08 704.38 415.08 Q 677.15 415.08 658.54 433.76 Q 639.92 452.44 639.92 478.77 Q 639.92 506 658.54 524.62 Q 677.15 543.23 704 543.23 Z M 270.77 166.15 L 689.23 166.15 L 689.23 135.38 Q 689.23 126.15 681.54 118.46 Q 673.85 110.77 664.62 110.77 L 295.38 110.77 Q 286.15 110.77 278.46 118.46 Q 270.77 126.15 270.77 135.38 L 270.77 166.15 Z M 270.77 166.15 L 270.77 110.77 L 270.77 166.15 Z M 270.77 793.85 L 270.77 849.23 L 270.77 793.85 Z M 295.38 880 Q 272.94 880 256.47 863.53 Q 240 847.06 240 824.62 L 240 135.38 Q 240 112.94 256.47 96.47 Q 272.94 80 295.38 80 L 664.62 80 Q 687.06 80 703.53 96.47 Q 720 112.94 720 135.38 L 720 246 Q 720 252.58 715.54 256.98 Q 711.08 261.38 704.43 261.38 Q 697.77 261.38 693.5 256.98 Q 689.23 252.58 689.23 246 L 689.23 196.92 L 270.77 196.92 L 270.77 763.08 L 689.23 763.08 L 689.23 714 Q 689.23 707.42 693.69 703.02 Q 698.15 698.62 704.8 698.62 Q 711.46 698.62 715.73 703.02 Q 720 707.42 720 714 L 720 824.62 Q 720 847.06 703.53 863.53 Q 687.06 880 664.62 880 L 295.38 880 Z",
"revanced_alt_thumbnail_home" to "revanced_hide_navigation_home_button",
"revanced_alt_thumbnail_library" to "revanced_preference_screen_video",
"revanced_alt_thumbnail_player" to "revanced_preference_screen_player",
@ -423,7 +424,6 @@ private val rvxPreferenceKey = mapOf(
"revanced_preference_screen_settings_menu" to "M 413.384 840 L 397.231 725.539 Q 375.154 718.539 348.769 703.846 Q 322.385 689.154 304.077 672.308 L 198.384 720.154 L 131.538 601.538 L 225.692 531.769 Q 223.692 519.692 222.423 506.269 Q 221.154 492.846 221.154 480.769 Q 221.154 469.462 222.423 456.038 Q 223.692 442.615 225.692 428.231 L 131.538 357.692 L 198.384 241.384 L 303.308 287.692 Q 323.923 270.846 348.769 256.538 Q 373.615 242.231 396.461 235.461 L 413.384 120 L 546.616 120 L 562.769 235.231 Q 587.923 244.538 610.577 257.192 Q 633.231 269.846 653.615 287.692 L 762.385 241.384 L 828.462 357.692 L 731.231 429.308 Q 734.769 443.154 735.654 455.808 Q 736.539 468.462 736.539 480 Q 736.539 490.769 735.269 503.308 Q 734 515.846 731.231 531.231 L 827.693 601.538 L 760.846 720.154 L 653.615 671.539 Q 632.231 689.923 609.423 703.962 Q 586.616 718 562.769 724.769 L 546.616 840 L 413.384 840 Z M 438.308 809.231 L 520.923 809.231 L 535.692 698 Q 566.385 690 592.039 675.308 Q 617.692 660.615 644 635.846 L 746.923 680.308 L 786.923 610.615 L 696 543.154 Q 700 524.615 702.115 509.654 Q 704.231 494.692 704.231 480 Q 704.231 463.769 702.231 449.577 Q 700.231 435.385 696 418.385 L 788.462 349.385 L 748.462 279.692 L 643.231 324.154 Q 624.077 302.769 593.539 284.115 Q 563 265.461 534.923 262 L 521.692 150.769 L 438.308 150.769 L 425.846 261.231 Q 393.385 267.461 366.961 282.538 Q 340.538 297.615 315.231 323.385 L 211.538 279.692 L 171.538 349.385 L 262.461 416.077 Q 257.692 430.769 255.577 446.885 Q 253.461 463 253.461 480.769 Q 253.461 497 255.577 512.346 Q 257.692 527.692 261.692 543.154 L 171.538 610.615 L 211.538 680.308 L 314.461 636.615 Q 338.461 661.385 365.269 676.077 Q 392.077 690.769 425.077 698.769 L 438.308 809.231 Z M 477.692 575.385 Q 517.846 575.385 545.462 547.769 Q 573.077 520.154 573.077 480 Q 573.077 439.846 545.462 412.231 Q 517.846 384.615 477.692 384.615 Q 438.308 384.615 410.308 412.231 Q 382.307 439.846 382.307 480 Q 382.307 520.154 410.308 547.769 Q 438.308 575.385 477.692 575.385 Z M 480 480 Z",
"revanced_preference_screen_shorts_player" to "revanced_preference_screen_shorts",
"revanced_preference_screen_snack_bar" to "revanced_preference_screen_action_buttons",
"revanced_preference_screen_spoof_streaming_data" to "M 270.77 793.85 L 270.77 824.62 Q 270.77 833.85 278.46 841.54 Q 286.15 849.23 295.38 849.23 L 664.62 849.23 Q 673.85 849.23 681.54 841.54 Q 689.23 833.85 689.23 824.62 L 689.23 793.85 L 270.77 793.85 Z M 687.54 569.69 Q 669.38 566.69 656.69 558.77 Q 644 550.85 635.46 539.77 L 620.54 547.38 Q 615.54 550.15 610.42 548.77 Q 605.31 547.38 602.31 543.15 L 600.38 540.92 Q 597.38 536.69 598.94 531.55 Q 600.49 526.41 604.38 523.23 L 618.62 512.69 Q 612.08 497.08 612.08 479.54 Q 612.08 462 618.62 445.62 L 604.38 435.08 Q 600.49 432.1 598.94 426.86 Q 597.38 421.62 600.41 416.72 L 602.31 415.15 Q 604.54 410.15 609.65 409.15 Q 614.77 408.15 619.77 410.92 L 635.46 418.54 Q 644 407.46 656.46 399.54 Q 668.92 391.62 687.54 388.62 L 689.23 370.23 Q 689.16 364.45 692.93 360.84 Q 696.71 357.23 702.35 357.23 L 706.46 357.23 Q 711.83 357.23 715.27 360.93 Q 718.7 364.62 719.54 370.23 L 721.23 388.62 Q 739.85 391.62 751.92 399.54 Q 764 407.46 773.31 418.54 L 788.23 410.92 Q 793.23 408.15 798.35 409.54 Q 803.46 410.92 806.46 415.92 L 808.38 417.38 Q 810.62 421.62 809.45 426.86 Q 808.28 432.1 804.38 435.08 L 790.15 445.62 Q 796.69 462 796.69 479.54 Q 796.69 497.08 790.15 512.69 L 804.38 523.23 Q 808.28 527.18 809.45 532.32 Q 810.62 537.46 807.62 541.69 L 805.69 543.92 Q 803.46 548.15 798.35 549.15 Q 793.23 550.15 788.23 547.38 L 773.31 539.77 Q 764 550.85 751.31 558.77 Q 738.62 566.69 721.23 569.69 L 719.54 588.08 Q 718.67 593.34 715.07 596.82 Q 711.46 600.31 706.08 600.31 L 701.85 600.31 Q 696.46 600.31 692.79 596.7 Q 689.13 593.09 689.23 588.08 L 687.54 569.69 Z M 704 543.23 Q 730.85 543.23 749.85 524.62 Q 768.85 506 768.85 479.15 Q 768.85 452.31 749.95 433.69 Q 731.05 415.08 704.38 415.08 Q 677.15 415.08 658.54 433.76 Q 639.92 452.44 639.92 478.77 Q 639.92 506 658.54 524.62 Q 677.15 543.23 704 543.23 Z M 270.77 166.15 L 689.23 166.15 L 689.23 135.38 Q 689.23 126.15 681.54 118.46 Q 673.85 110.77 664.62 110.77 L 295.38 110.77 Q 286.15 110.77 278.46 118.46 Q 270.77 126.15 270.77 135.38 L 270.77 166.15 Z M 270.77 166.15 L 270.77 110.77 L 270.77 166.15 Z M 270.77 793.85 L 270.77 849.23 L 270.77 793.85 Z M 295.38 880 Q 272.94 880 256.47 863.53 Q 240 847.06 240 824.62 L 240 135.38 Q 240 112.94 256.47 96.47 Q 272.94 80 295.38 80 L 664.62 80 Q 687.06 80 703.53 96.47 Q 720 112.94 720 135.38 L 720 246 Q 720 252.58 715.54 256.98 Q 711.08 261.38 704.43 261.38 Q 697.77 261.38 693.5 256.98 Q 689.23 252.58 689.23 246 L 689.23 196.92 L 270.77 196.92 L 270.77 763.08 L 689.23 763.08 L 689.23 714 Q 689.23 707.42 693.69 703.02 Q 698.15 698.62 704.8 698.62 Q 711.46 698.62 715.73 703.02 Q 720 707.42 720 714 L 720 824.62 Q 720 847.06 703.53 863.53 Q 687.06 880 664.62 880 L 295.38 880 Z",
"revanced_preference_screen_toolbar" to "M 215.38 800 Q 192.33 800 176.16 783.84 Q 160 767.67 160 744.62 L 160 215.38 Q 160 192.33 176.16 176.16 Q 192.33 160 215.38 160 L 744.62 160 Q 767.67 160 783.84 176.16 Q 800 192.33 800 215.38 L 800 744.62 Q 800 767.67 783.84 783.84 Q 767.67 800 744.62 800 L 215.38 800 Z M 190.77 323.15 L 769.23 323.15 L 769.23 215.38 Q 769.23 206.15 761.54 198.46 Q 753.85 190.77 744.62 190.77 L 215.38 190.77 Q 206.15 190.77 198.46 198.46 Q 190.77 206.15 190.77 215.38 L 190.77 323.15 Z M 769.23 353.92 L 190.77 353.92 L 190.77 744.62 Q 190.77 753.85 198.46 761.54 Q 206.15 769.23 215.38 769.23 L 744.62 769.23 Q 753.85 769.23 761.54 761.54 Q 769.23 753.85 769.23 744.62 L 769.23 353.92 Z M 190.77 323.15 L 190.77 353.92 L 190.77 323.15 Z M 190.77 323.15 L 190.77 190.77 L 190.77 323.15 Z M 190.77 353.92 L 190.77 769.23 L 190.77 353.92 Z",
"revanced_preference_screen_video_description" to "M 389.15 530.77 L 527.23 530.77 Q 533.81 530.77 538.21 526.31 Q 542.62 521.85 542.62 515.2 Q 542.62 508.54 538.21 504.27 Q 533.81 500 527.23 500 L 389.15 500 Q 382.58 500 378.17 504.46 Q 373.77 508.92 373.77 515.57 Q 373.77 522.23 378.17 526.5 Q 382.58 530.77 389.15 530.77 Z M 389.15 424.62 L 671.08 424.62 Q 677.65 424.62 682.06 420.16 Q 686.46 415.7 686.46 409.04 Q 686.46 402.38 682.06 398.12 Q 677.65 393.85 671.08 393.85 L 389.15 393.85 Q 382.58 393.85 378.17 398.3 Q 373.77 402.76 373.77 409.42 Q 373.77 416.08 378.17 420.35 Q 382.58 424.62 389.15 424.62 Z M 389.15 318.46 L 671.08 318.46 Q 677.65 318.46 682.06 314 Q 686.46 309.55 686.46 302.89 Q 686.46 296.23 682.06 291.96 Q 677.65 287.69 671.08 287.69 L 389.15 287.69 Q 382.58 287.69 378.17 292.15 Q 373.77 296.61 373.77 303.27 Q 373.77 309.92 378.17 314.19 Q 382.58 318.46 389.15 318.46 Z M 296.92 698.46 Q 273.87 698.46 257.7 682.3 Q 241.54 666.13 241.54 643.08 L 241.54 175.38 Q 241.54 152.33 257.7 136.16 Q 273.87 120 296.92 120 L 764.62 120 Q 787.67 120 803.84 136.16 Q 820 152.33 820 175.38 L 820 643.08 Q 820 666.13 803.84 682.3 Q 787.67 698.46 764.62 698.46 L 296.92 698.46 Z M 296.92 667.69 L 764.62 667.69 Q 773.85 667.69 781.54 660 Q 789.23 652.31 789.23 643.08 L 789.23 175.38 Q 789.23 166.15 781.54 158.46 Q 773.85 150.77 764.62 150.77 L 296.92 150.77 Q 287.69 150.77 280 158.46 Q 272.31 166.15 272.31 175.38 L 272.31 643.08 Q 272.31 652.31 280 660 Q 287.69 667.69 296.92 667.69 Z M 195.38 800 Q 172.33 800 156.16 783.84 Q 140 767.67 140 744.62 L 140 261.54 Q 140 254.96 144.46 250.56 Q 148.92 246.15 155.57 246.15 Q 162.23 246.15 166.5 250.56 Q 170.77 254.96 170.77 261.54 L 170.77 744.62 Q 170.77 753.85 178.46 761.54 Q 186.15 769.23 195.38 769.23 L 678.46 769.23 Q 685.04 769.23 689.44 773.69 Q 693.85 778.15 693.85 784.8 Q 693.85 791.46 689.44 795.73 Q 685.04 800 678.46 800 L 195.38 800 Z M 272.31 150.77 L 272.31 667.69 L 272.31 150.77 Z",
"revanced_preference_screen_video_filter" to "revanced_preference_screen_video",

View file

@ -0,0 +1,5 @@
package app.morphe.patches.youtube.misc.spoof
import app.morphe.patches.shared.misc.spoof.userAgentClientSpoofPatch
val userAgentClientSpoofPatch = userAgentClientSpoofPatch("com.google.android.youtube")

View file

@ -1,96 +1,46 @@
package app.morphe.patches.youtube.utils.fix.streamingdata
import app.morphe.patches.shared.spoof.streamingdata.EXTENSION_CLASS_DESCRIPTOR
import app.morphe.patches.shared.spoof.streamingdata.EXTENSION_RELOAD_VIDEO_BUTTON_CLASS_DESCRIPTOR
import app.morphe.patches.shared.spoof.streamingdata.spoofStreamingDataPatch
import app.morphe.patches.shared.misc.spoof.spoofVideoStreamsPatch
import app.morphe.patches.shared.spoof.useragent.baseSpoofUserAgentPatch
import app.morphe.patches.youtube.player.overlaybuttons.overlayButtonsPatch
import app.morphe.patches.youtube.utils.compatibility.Constants.YOUTUBE_PACKAGE_NAME
import app.morphe.patches.youtube.utils.dismiss.dismissPlayerHookPatch
import app.morphe.patches.youtube.utils.playercontrols.addTopControl
import app.morphe.patches.youtube.utils.playercontrols.injectControl
import app.morphe.patches.youtube.utils.playercontrols.playerControlsPatch
import app.morphe.patches.youtube.utils.mainactivity.mainActivityFingerprint
import app.morphe.patches.youtube.utils.playservice.is_19_34_or_greater
import app.morphe.patches.youtube.utils.playservice.is_19_50_or_greater
import app.morphe.patches.youtube.utils.playservice.is_20_10_or_greater
import app.morphe.patches.youtube.utils.playservice.is_20_14_or_greater
import app.morphe.patches.youtube.utils.playservice.versionCheckPatch
import app.morphe.patches.youtube.utils.request.buildRequestPatch
import app.morphe.patches.youtube.utils.request.hookBuildRequest
import app.morphe.patches.youtube.utils.request.hookBuildRequestUrl
import app.morphe.patches.youtube.utils.settings.ResourceUtils.addPreference
import app.morphe.patches.youtube.utils.settings.settingsPatch
import app.morphe.patches.youtube.video.information.videoInformationPatch
import app.morphe.patches.youtube.video.playerresponse.Hook
import app.morphe.patches.youtube.video.playerresponse.addPlayerResponseMethodHook
import app.morphe.patches.youtube.video.videoid.videoIdPatch
import app.morphe.util.getStringOptionValue
import app.morphe.util.lowerCaseOrThrow
val spoofStreamingDataPatch = spoofStreamingDataPatch(
block = {
dependsOn(
settingsPatch,
versionCheckPatch,
baseSpoofUserAgentPatch(YOUTUBE_PACKAGE_NAME),
buildRequestPatch,
playerControlsPatch,
videoIdPatch,
videoInformationPatch,
dismissPlayerHookPatch,
)
},
isYouTube = {
true
},
outlineIcon = {
val iconType = overlayButtonsPatch
.getStringOptionValue("iconType")
.lowerCaseOrThrow()
iconType == "thin"
},
fixMediaFetchHotConfigChanges = {
val spoofStreamingDataPatch = spoofVideoStreamsPatch(
extensionClassDescriptor = "Lapp/morphe/extension/youtube/patches/spoof/SpoofVideoStreamsPatch;",
mainActivityOnCreateFingerprint = mainActivityFingerprint.second,
fixMediaFetchHotConfig = {
is_19_34_or_greater
},
fixMediaFetchHotConfigAlternativeChanges = {
fixMediaFetchHotConfigAlternative = {
// In 20.14 the flag was merged with 19.50 start playback flag.
is_20_10_or_greater && !is_20_14_or_greater
},
fixParsePlaybackResponseFeatureFlag = {
is_19_50_or_greater
},
executeBlock = {
// region Get replacement streams at player requests.
hookBuildRequest("$EXTENSION_CLASS_DESCRIPTOR->fetchStreams(Ljava/lang/String;Ljava/util/Map;)V")
hookBuildRequestUrl("$EXTENSION_CLASS_DESCRIPTOR->blockGetAttRequest(Ljava/lang/String;)Ljava/lang/String;")
// endregion
addPlayerResponseMethodHook(
Hook.ProtoBufferParameterBeforeVideoId(
"$EXTENSION_CLASS_DESCRIPTOR->newPlayerResponseParameter(Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Z)Ljava/lang/String;"
)
block = {
dependsOn(
settingsPatch,
versionCheckPatch,
baseSpoofUserAgentPatch(YOUTUBE_PACKAGE_NAME),
videoIdPatch,
videoInformationPatch,
)
// region Player buttons
injectControl(EXTENSION_RELOAD_VIDEO_BUTTON_CLASS_DESCRIPTOR)
// endregion
},
executeBlock = {
addPreference(
arrayOf(
"SETTINGS: SPOOF_STREAMING_DATA"
"SETTINGS: SPOOF_VIDEO_STREAMS"
)
)
},
finalizeBlock = {
addTopControl(
"youtube/spoof/shared",
"@+id/revanced_reload_video_button",
"@+id/revanced_reload_video_button"
)
}
)

View file

@ -2,6 +2,7 @@
package app.morphe.util
import app.morphe.patcher.Fingerprint
import app.morphe.patcher.FingerprintBuilder
import app.morphe.patcher.Match
import app.morphe.patcher.extensions.InstructionExtensions.addInstruction
@ -148,6 +149,30 @@ private fun Method.findInstructionIndexFromToString(fieldName: String, isField:
return fieldSetIndex
}
context(BytecodePatchContext)
internal fun setExtensionIsPatchIncluded(patchExtensionClassType: String) {
val methodName = "isPatchIncluded"
val returnType = "Z"
val fingerprint = Fingerprint(
definingClass = patchExtensionClassType,
name = methodName,
returnType = returnType,
parameters = listOf(),
custom = { method, _ ->
AccessFlags.STATIC.isSet(method.accessFlags)
}
)
if (fingerprint.methodOrNull == null) {
throw PatchException(
"Could not find required extension method: $patchExtensionClassType->$methodName()$returnType"
)
}
fingerprint.method.returnEarly(true)
}
/**
* Find the method used for a toString() StringBuilder write of a given String name.
*

View file

@ -48,6 +48,33 @@ fun Node.cloneNodes(parent: Node) {
parent.removeChild(this)
}
/**
* Removes a node from its parent.
*
* @return The node that was removed (object this method was called on).
*/
fun Node.removeFromParent() : Node = parentNode.removeChild(this)
/**
* Returns a sequence for all child nodes.
*/
fun NodeList.asSequence() = (0 until this.length).asSequence().map { this.item(it) }
/**
* Returns a sequence for all child nodes.
*/
@Suppress("UNCHECKED_CAST")
fun Node.childElementsSequence() =
this.childNodes.asSequence().filter { it.nodeType == Node.ELEMENT_NODE } as Sequence<Element>
/**
* Performs the given [action] on each child element.
*/
inline fun Node.forEachChildElement(action: (Element) -> Unit) =
childElementsSequence().forEach {
action(it)
}
/**
* Recursively traverse the DOM tree starting from the given root node.
*

View file

@ -0,0 +1,37 @@
package app.morphe.util.resource
import app.morphe.util.childElementsSequence
import org.w3c.dom.Document
import org.w3c.dom.Node
/**
* An array resource.
*
* @param name The name of the array resource.
* @param items The items of the array resource.
*/
@Suppress("MemberVisibilityCanBePrivate")
class ArrayResource(
name: String,
val items: List<String>,
) : BaseResource(name, "string-array") {
override fun serialize(ownerDocument: Document, resourceCallback: (BaseResource) -> Unit) =
super.serialize(ownerDocument, resourceCallback).apply {
setAttribute("name", name)
items.forEach { item ->
appendChild(ownerDocument.createElement("item").also { itemNode ->
itemNode.textContent = item
})
}
}
companion object {
fun fromNode(node: Node): ArrayResource {
val key = node.attributes.getNamedItem("name").textContent
val items = node.childElementsSequence().map { it.textContent }.toList()
return ArrayResource(key, items)
}
}
}

View file

@ -0,0 +1,43 @@
package app.morphe.util.resource
import org.w3c.dom.Document
import org.w3c.dom.Element
/**
* Base resource class for all resources.
*
* @param name The name of the resource.
* @param tag The tag of the resource.
*/
@Suppress("MemberVisibilityCanBePrivate")
abstract class BaseResource(
val name: String,
val tag: String
) {
/**
* Serialize resource element to XML.
* Overriding methods should invoke super and operate on its return value.
* @param ownerDocument Target document to create elements from.
* @param resourceCallback Called when a resource has been processed.
*/
open fun serialize(ownerDocument: Document, resourceCallback: (BaseResource) -> Unit = { }): Element {
return ownerDocument.createElement(tag).apply {
setAttribute("name", name)
}
}
override fun hashCode(): Int {
var result = name.hashCode()
result = 31 * result + tag.hashCode()
return result
}
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as BaseResource
return name == other.name
}
}

View file

@ -73,22 +73,18 @@
</string-array>
<string-array name="revanced_spoof_app_version_for_lyrics_target_entry_values">
</string-array>
<string-array name="revanced_spoof_streaming_data_default_client_entries">
<item>@string/revanced_spoof_streaming_data_client_entry_android_music_no_sdk</item>
<item>@string/revanced_spoof_streaming_data_client_entry_android_no_sdk</item>
<item>@string/revanced_spoof_streaming_data_client_entry_android_vr</item>
<item>@string/revanced_spoof_streaming_data_client_entry_tv</item>
<item>@string/revanced_spoof_streaming_data_client_entry_tv_legacy</item>
<item>@string/revanced_spoof_streaming_data_client_entry_tv_simply</item>
<item>@string/revanced_spoof_streaming_data_client_entry_visionos</item>
</string-array>
<string-array name="revanced_spoof_streaming_data_default_client_entry_values">
<item>ANDROID_MUSIC_NO_SDK</item>
<item>ANDROID_NO_SDK</item>
<item>ANDROID_VR</item>
<string-array name="morphe_spoof_video_streams_client_type_entries">
<item>Android Reel</item>
<item>Android Music No SDK</item>
<item>Android VR</item>
<item>TV</item>
<item>visionOS</item>
</string-array>
<string-array name="morphe_spoof_video_streams_client_type_entry_values">
<item>ANDROID_REEL</item>
<item>ANDROID_MUSIC_NO_SDK</item>
<item>ANDROID_VR_1_47_48</item>
<item>TV</item>
<item>TV_LEGACY</item>
<item>TV_SIMPLY</item>
<item>VISIONOS</item>
</string-array>
<string-array name="revanced_watch_history_type_entries">

View file

@ -17,6 +17,81 @@ Tap the continue button and allow optimization changes."</string>
<string name="gms_core_settings_summary">To receive notifications in RVX Music, enable Cloud Messaging.</string>
<string name="gms_core_settings_title">Open GmsCore settings</string>
<string name="gms_core_toast_not_installed_message">GmsCore is not installed. Install it.</string>
<string name="morphe_disable_drc_audio_summary_off">DRC audio is enabled</string>
<string name="morphe_disable_drc_audio_summary_on">DRC audio is disabled</string>
<string name="morphe_disable_drc_audio_title">Disable DRC audio</string>
<string name="morphe_disable_quic_protocol_summary_off">QUIC protocol is enabled</string>
<string name="morphe_disable_quic_protocol_summary_on">QUIC protocol is disabled</string>
<string name="morphe_disable_quic_protocol_title">Disable QUIC protocol</string>
<string name="morphe_force_original_audio_not_available">To use this feature, change \'Spoof video streams\' to Android Reel or TV</string>
<string name="morphe_force_original_audio_summary_off">Using default audio</string>
<string name="morphe_force_original_audio_summary_on">Using original audio language</string>
<string name="morphe_force_original_audio_title">Force original audio language</string>
<string name="morphe_oauth2_connection_failure_auth_error">Cannot sign in to VR: %s</string>
<string name="morphe_oauth2_connection_failure_auth_not_approved">Cannot sign in to VR, website authentication was not approved</string>
<string name="morphe_oauth2_connection_failure_generic">OAuth2 is temporarily not available</string>
<string name="morphe_oauth2_connection_failure_status">OAuth2 is temporarily not available (status %d)</string>
<string name="morphe_oauth2_connection_failure_status_400">Android VR sign in expired, try signing in to VR again</string>
<string name="morphe_oauth2_connection_failure_timeout">OAuth2 is temporarily not available (API timed out)</string>
<string name="morphe_oauth2_toast_invalid">Invalid VR sign in token, try signing in to VR again</string>
<string name="morphe_oauth2_toast_reset">Logged out of Android VR</string>
<string name="morphe_replace_music_with_youtube_summary_off">Shared links use music.youtube.com</string>
<string name="morphe_replace_music_with_youtube_summary_on">Shared links use youtube.com</string>
<string name="morphe_replace_music_with_youtube_title">Change share links to youtube.com</string>
<string name="morphe_sanitize_sharing_links_summary_off">Tracking query parameter is not removed from shared links</string>
<string name="morphe_sanitize_sharing_links_summary_on">Tracking query parameter is removed from shared links</string>
<string name="morphe_sanitize_sharing_links_title">Sanitize sharing links</string>
<string name="morphe_spoof_video_streams_client_type_title">Default client</string>
<string name="morphe_spoof_video_streams_disable_player_js_update_summary">Disable player JavaScript update</string>
<string name="morphe_spoof_video_streams_disable_player_js_update_summary_off">Player JavaScript update is enabled</string>
<string name="morphe_spoof_video_streams_disable_player_js_update_summary_on">"Player JavaScript update is disabled
• A valid player JavaScript hash is required
• Playback fails if the Player JavaScript hash is revoked in GVS"</string>
<string name="morphe_spoof_video_streams_disable_player_js_update_title">Disable player JavaScript update</string>
<string name="morphe_spoof_video_streams_disable_player_js_update_user_dialog_message">Player JavaScript hashes are valid for approximately one month, and outdated hashes may be revoked by GVS.</string>
<string name="morphe_spoof_video_streams_no_clients_suggest_vr_toast">Try enabling: Spoof video streams &gt; Android VR sign in</string>
<string name="morphe_spoof_video_streams_no_clients_toast">Could not fetch any client streams</string>
<string name="morphe_spoof_video_streams_player_js_hash_summary">Enter a valid player JavaScript hash</string>
<string name="morphe_spoof_video_streams_player_js_hash_title">Player JavaScript hash</string>
<string name="morphe_spoof_video_streams_screen_summary">Spoof the client video streams to prevent playback issues</string>
<string name="morphe_spoof_video_streams_screen_title">Spoof video streams</string>
<string name="morphe_spoof_video_streams_sign_in_android_vr_about_summary">Not signed in to Android VR. Tap here to sign in</string>
<string name="morphe_spoof_video_streams_sign_in_android_vr_about_summary_disabled">To use this feature, change \'Default client\' to Android VR</string>
<string name="morphe_spoof_video_streams_sign_in_android_vr_about_summary_signed_in">Currently signed into Android VR</string>
<string name="morphe_spoof_video_streams_sign_in_android_vr_about_title">Android VR sign in</string>
<string name="morphe_spoof_video_streams_sign_in_android_vr_activation_code_dialog_message">"Activation code: %s
1. Tap 'Open website' below to open the 'Connect a device' website
2. Paste from the clipboard (or type out) the activation code shown above
3. Sign in to Google if prompted. If you have multiple accounts then choose this YouTube account
4. Tap 'Allow YouTube VR' access
5. Return to this app after reaching the 'Device connected' message"</string>
<string name="morphe_spoof_video_streams_sign_in_android_vr_activation_code_dialog_open_website">Open website</string>
<string name="morphe_spoof_video_streams_sign_in_android_vr_activation_code_dialog_title">Instructions</string>
<string name="morphe_spoof_video_streams_sign_in_android_vr_dialog_continue">Continue</string>
<string name="morphe_spoof_video_streams_sign_in_android_vr_dialog_not_signed_in_message">"Signing in to Android VR can fix playback issues for some users
To sign in, press continue below"</string>
<string name="morphe_spoof_video_streams_sign_in_android_vr_dialog_reset">Sign out</string>
<string name="morphe_spoof_video_streams_sign_in_android_vr_dialog_title">Sign in to Android VR</string>
<string name="morphe_spoof_video_streams_sign_in_android_vr_success_dialog_message">"Authentication saved
• Your Morphe settings will not import/export Android VR authentication
• If this app is unused for 6 months then Google will remove this authentication
• You will need to sign in again if you change your Google password or deregister this device from your Google settings"</string>
<string name="morphe_spoof_video_streams_sign_in_android_vr_success_dialog_title">Successfully signed in</string>
<string name="morphe_spoof_video_streams_sign_in_android_vr_toast_get_activation_code_failed">Failed to issue an activation code</string>
<string name="morphe_spoof_video_streams_sign_in_android_vr_toast_get_authorization_code_failed">Failed to issue an authentication token</string>
<string name="morphe_spoof_video_streams_summary">Spoof the client video streams to prevent playback issues.</string>
<string name="morphe_spoof_video_streams_summary_off">"Video streams are not spoofed
Playback may not work"</string>
<string name="morphe_spoof_video_streams_summary_on">"Video streams are spoofed
If you are a YouTube Premium user, this setting may not be required"</string>
<string name="morphe_spoof_video_streams_title">Spoof video streams</string>
<string name="morphe_spoof_video_streams_user_dialog_message">Turning off this setting may cause playback issues.</string>
<string name="revanced_add_miniplayer_next_button_summary">Adds a next track button to the miniplayer.</string>
<string name="revanced_add_miniplayer_next_button_title">Add miniplayer next button</string>
<string name="revanced_add_miniplayer_previous_button_summary">Adds a previous track button to the miniplayer.</string>
@ -552,18 +627,7 @@ Click to see how to issue an API key."</string>
Side effect (Block request):
• App may be forced to close when using a VPN or DNS."</string>
<string name="revanced_spoof_client_type_title">Default client</string>
<string name="revanced_spoof_streaming_data_client_entry_android_creator">Android Studio</string>
<string name="revanced_spoof_streaming_data_client_entry_android_music_no_sdk">Android Music No SDK</string>
<string name="revanced_spoof_streaming_data_client_entry_android_no_sdk">Android No SDK</string>
<string name="revanced_spoof_streaming_data_client_entry_android_vr">Android VR</string>
<string name="revanced_spoof_streaming_data_client_entry_tv">TV</string>
<string name="revanced_spoof_streaming_data_client_entry_tv_legacy">TV Legacy</string>
<string name="revanced_spoof_streaming_data_client_entry_tv_simply">TV Simply</string>
<string name="revanced_spoof_streaming_data_client_entry_visionos">visionOS</string>
<string name="revanced_spoof_streaming_data_default_client_summary">Defines a default client for spoofing.</string>
<string name="revanced_spoof_streaming_data_default_client_title">Default client</string>
<string name="revanced_spoof_streaming_data_failed_forbidden">Could not fetch any client streams.</string>
<string name="revanced_spoof_streaming_data_failed_forbidden_suggestion">Try changing the default client to TV Simply.</string>
<string name="revanced_spoof_streaming_data_no_sdk_auth_token_about_summary">Click to see how to get an authentication token.</string>
<string name="revanced_spoof_streaming_data_no_sdk_auth_token_about_title">About Android No SDK Auth</string>
<string name="revanced_spoof_streaming_data_sign_in_android_no_sdk_about_summary">Click here to see how to sign in to Android No SDK.</string>
@ -585,57 +649,33 @@ Side effect (Block request):
- If you change your password, or deregister a device in your account settings, or uninstall the app, you will need to reissue the authentication token."</string>
<string name="revanced_spoof_streaming_data_sign_in_android_no_sdk_success_dialog_title">Signed in</string>
<string name="revanced_spoof_streaming_data_sign_in_android_no_sdk_toast_reset">Authentication token reset.</string>
<string name="revanced_spoof_streaming_data_sign_in_android_vr_about_summary">Click here to see how to sign in to Android VR.</string>
<string name="revanced_spoof_streaming_data_sign_in_android_vr_about_title">Sign in to Android VR</string>
<string name="revanced_spoof_streaming_data_sign_in_android_vr_about_summary">Not signed in to Android VR. Tap here to sign in</string>
<string name="revanced_spoof_streaming_data_sign_in_android_vr_about_title">Android VR sign in</string>
<string name="revanced_spoof_streaming_data_sign_in_android_vr_activation_code_dialog_message">"Activation code:
%s
1. Click 'Open website' to go to 'https://yt.be/activate'.
2. Enter the activation code.
3. Sign in when prompted. If you have multiple Google Accounts, select the account you use with YouTube. If you're already signed in, skip to the next step.
4. Once signed in, return to RVX and click 'Sign in to Android VR' again.
5. Click 'Get authorization token' to get an authorization token."</string>
1. Tap 'Open website' below to open the 'Connect a device' website
2. Paste from the clipboard (or type out) the activation code shown above
3. Sign in to Google if prompted. If you have multiple accounts then choose this YouTube account
4. Tap 'Allow YouTube VR' access
5. Return to this app after reaching the 'Device connected' message"</string>
<string name="revanced_spoof_streaming_data_sign_in_android_vr_activation_code_dialog_open_website">Open website</string>
<string name="revanced_spoof_streaming_data_sign_in_android_vr_activation_code_dialog_open_website_text">Open website</string>
<string name="revanced_spoof_streaming_data_sign_in_android_vr_activation_code_dialog_title">Activation code</string>
<string name="revanced_spoof_streaming_data_sign_in_android_vr_activation_code_dialog_title">Instructions</string>
<string name="revanced_spoof_streaming_data_sign_in_android_vr_activation_code_toast_copy">Activation code copied to clipboard: %s.</string>
<string name="revanced_spoof_streaming_data_sign_in_android_vr_dialog_get_activation_code_text">Get activation code</string>
<string name="revanced_spoof_streaming_data_sign_in_android_vr_dialog_get_authorization_token_text">Get authorization token</string>
<string name="revanced_spoof_streaming_data_sign_in_android_vr_dialog_message">"Signing in to Android VR (YouTube VR) follows the guidelines provided by Google (Google account authorization - OAuth 2.0 for TV and Limited-Input Device Applications):
<string name="revanced_spoof_streaming_data_sign_in_android_vr_success_dialog_message">"Authentication saved
1. Click 'Get activation code' to generate an activation code.
2. Click 'Open website' to go to 'https://yt.be/activate'.
3. Enter the activation code.
4. Sign in when prompted. If you have multiple Google Accounts, select the account you use with YouTube. If you're already signed in, skip to the next step.
5. Once signed in, return to RVX and click 'Sign in to Android VR' again.
6. Click 'Get authorization token' to get an authorization token.
※ Click 'Reset' to remove an authentication token or activation code."</string>
<string name="revanced_spoof_streaming_data_sign_in_android_vr_dialog_reset_text">Reset</string>
<string name="revanced_spoof_streaming_data_sign_in_android_vr_dialog_title">Sign in to Android VR</string>
<string name="revanced_spoof_streaming_data_sign_in_android_vr_success_dialog_message">"Authentication token saved.
- Authentication token issued through this process can only be used in YouTube VR (In RVX, it is used to fetch streaming data from YouTube VR).
- Authentication token unused for more than 6 months will be revoked by Google servers.
- If you change your password, or deregister a device in your account settings, or uninstall the app, you will need to reissue the authentication token."</string>
<string name="revanced_spoof_streaming_data_sign_in_android_vr_success_dialog_title">Signed in</string>
• Your Morphe settings will not import/export Android VR authentication
• If this app is unused for 6 months then Google will remove this authentication
• You will need to sign in again if you change your Google password or deregister this device from your Google settings"</string>
<string name="revanced_spoof_streaming_data_sign_in_android_vr_success_dialog_title">Successfully signed in</string>
<string name="revanced_spoof_streaming_data_sign_in_android_vr_toast_failed">Failed to issue an authentication token.</string>
<string name="revanced_spoof_streaming_data_sign_in_android_vr_toast_failed_suggestion">Click Reset and try again.</string>
<string name="revanced_spoof_streaming_data_sign_in_android_vr_toast_reset">Authentication token and activation code reset.</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_stats_for_nerds_title">Show in Stats for nerds</string>
<string name="revanced_spoof_streaming_data_summary">"Spoof the client video streams to prevent playback issues.
If you are a YouTube Premium user, this setting may not be necessary."</string>
<string name="revanced_spoof_streaming_data_title">Spoof streaming data</string>
<string name="revanced_spoof_streaming_data_use_latest_player_js_summary">"When enabled, the latest player JavaScript will be used to deobfuscate streaming Url on 'TV' and 'Mobile Web'.
If you're having issues with your JavaScript client, try disabling this first."</string>
<string name="revanced_spoof_streaming_data_use_latest_player_js_title">Use latest player JavaScript</string>
<string name="revanced_spoof_streaming_data_use_yt_dlp_ejs_summary">"When enabled, the 'External JavaScript for yt-dlp (yt-dlp-ejs)' will be used to deobfuscate streaming Url on 'TV' and 'Mobile Web'.
• Player JavaScript for deobfuscation is kept up to date.
• It may take 5 to 15 seconds for the video to start."</string>
<string name="revanced_spoof_streaming_data_use_yt_dlp_ejs_title">Use yt-dlp ejs (WIP)</string>
<string name="revanced_spoof_streaming_data_summary">Spoof the client video streams to prevent playback issues</string>
<string name="revanced_spoof_streaming_data_title">Spoof video streams</string>
<string name="revanced_spoof_streaming_data_vr_auth_token_about_summary">Click to see how to get an authentication token.</string>
<string name="revanced_spoof_streaming_data_vr_auth_token_about_title">About Android VR Auth</string>
<string name="revanced_spoof_video_streams_default_client_summary">"Side effect:

View file

@ -18,6 +18,79 @@
<string name="gms_core_settings_summary">لتلقي الإشعارات في RVX Music، قم بتمكين المراسلة السحابية.</string>
<string name="gms_core_settings_title">GmsCore فتح اعدادات </string>
<string name="gms_core_toast_not_installed_message">لم يتم تثبيت GmsCore. قم بتثبيته.</string>
<string name="morphe_disable_drc_audio_summary_off">تم تمكين صوت DRC</string>
<string name="morphe_disable_drc_audio_summary_on">تم تعطيل صوت DRC</string>
<string name="morphe_disable_drc_audio_title">تعطيل صوت DRC</string>
<string name="morphe_disable_quic_protocol_summary_off">تم تمكين بروتوكول QUIC</string>
<string name="morphe_disable_quic_protocol_summary_on">تم تعطيل بروتوكول QUIC</string>
<string name="morphe_disable_quic_protocol_title">تعطيل بروتوكول QUIC</string>
<string name="morphe_force_original_audio_not_available">لاستخدام هذه الميزة، غيّر \'Spoof Video Streams\' إلى Android Reel أو TV</string>
<string name="morphe_force_original_audio_summary_off">استخدام الصوت الافتراضي</string>
<string name="morphe_force_original_audio_summary_on">استخدام لغة الصوت الأصلية</string>
<string name="morphe_force_original_audio_title">فرض لغة الصوت الأصلية</string>
<string name="morphe_oauth2_connection_failure_auth_error">فشل تسجيل الدخول إلى VR: %s</string>
<string name="morphe_oauth2_connection_failure_auth_not_approved">لا يمكن تسجيل الدخول إلى VR، لم تتم الموافقة على مصادقة الموقع الإلكتروني</string>
<string name="morphe_oauth2_connection_failure_generic">OAuth2 غير متاح مؤقتًا</string>
<string name="morphe_oauth2_connection_failure_status">OAuth2 غير متاح مؤقتًا (الحالة %d)</string>
<string name="morphe_oauth2_connection_failure_status_400">انتهت صلاحية تسجيل الدخول إلى Android VR، حاول تسجيل الدخول إلى VR مرة أخرى</string>
<string name="morphe_oauth2_connection_failure_timeout">OAuth2 غير متاح مؤقتًا (انتهت مهلة API)</string>
<string name="morphe_oauth2_toast_invalid">رمز تسجيل الدخول إلى VR غير صالح، حاول تسجيل الدخول مرة أخرى إلى VR</string>
<string name="morphe_oauth2_toast_reset">تم تسجيل خروج Android VR</string>
<string name="morphe_replace_music_with_youtube_summary_off">تستخدم روابط المشاركة music.youtube.com</string>
<string name="morphe_replace_music_with_youtube_summary_on">تستخدم روابط المشاركة youtube.com</string>
<string name="morphe_replace_music_with_youtube_title">تغيير روابط المشاركة إلى youtube.com</string>
<string name="morphe_sanitize_sharing_links_summary_off">لم تتم إزالة معلمة استعلام التتبع من الروابط عند المشاركة</string>
<string name="morphe_sanitize_sharing_links_summary_on">تمت إزالة معلمة استعلام التتبع من الروابط عند المشاركة</string>
<string name="morphe_sanitize_sharing_links_title">تطهير روابط المشاركة</string>
<string name="morphe_spoof_video_streams_client_type_title">العميل الافتراضي</string>
<string name="morphe_spoof_video_streams_disable_player_js_update_summary_off">تم تمكين تحديث مشغل JavaScript</string>
<string name="morphe_spoof_video_streams_disable_player_js_update_summary_on">"تم تعطيل تحديث مشغل JavaScript
• تتطلب تجزئة مشغل JavaScript صالحة.
• قد يفشل التشغيل إذا تم إلغاء تجزئة مشغل JavaScript في GVS"</string>
<string name="morphe_spoof_video_streams_disable_player_js_update_title">تعطيل تحديث مشغل JavaScript</string>
<string name="morphe_spoof_video_streams_disable_player_js_update_user_dialog_message">تكون تجزئات مشغل JavaScript صالحة لمدة شهر تقريبًا، وقد يتم إلغاء التجزئات القديمة بواسطة GVS.</string>
<string name="morphe_spoof_video_streams_no_clients_suggest_vr_toast">جرب تمكين: تضمين تدفقات الفيديو &gt; تسجيل الدخول إلى Android VR</string>
<string name="morphe_spoof_video_streams_no_clients_toast">تعذر جلب أي تدفقات عميل</string>
<string name="morphe_spoof_video_streams_player_js_hash_summary">أدخل تجزئة مشغل JavaScript صالحة</string>
<string name="morphe_spoof_video_streams_player_js_hash_title">تجزئة مشغل JavaScript</string>
<string name="morphe_spoof_video_streams_screen_summary">تزييف بثوث الفيديو الخاصة بالعميل لمنع مشكلات التشغيل</string>
<string name="morphe_spoof_video_streams_screen_title">Spoof Video Streams</string>
<string name="morphe_spoof_video_streams_sign_in_android_vr_about_summary">لم يتم تسجيل الدخول إلى Android VR. انقر هنا لتسجيل الدخول</string>
<string name="morphe_spoof_video_streams_sign_in_android_vr_about_summary_disabled">للحصول على هذه الميزة، غيّر \'العميل الافتراضي\' إلى Android VR</string>
<string name="morphe_spoof_video_streams_sign_in_android_vr_about_summary_signed_in">مسجل حالياً في Android VR</string>
<string name="morphe_spoof_video_streams_sign_in_android_vr_about_title">تسجيل الدخول إلى Android VR</string>
<string name="morphe_spoof_video_streams_sign_in_android_vr_activation_code_dialog_message">"رمز التفعيل: %s
1. انقر على 'فتح الموقع' أدناه لفتح موقع 'ربط جهاز'
2. الصق رمز التفعيل الموضح أعلاه من الحافظة (أو اكتبه)
3. تسجل الدخول إلى Google إذا طلب ذلك. إذا كان لديك أكثر من حساب، فاختر حساب YouTube هذا
4.انقر على 'السماح بالوصول إلى YouTube VR'
5. ارجع إلى هذا التطبيق بعد الوصول إلى رسالة 'تم توصيل الجهاز'"</string>
<string name="morphe_spoof_video_streams_sign_in_android_vr_activation_code_dialog_open_website">فتح الموقع</string>
<string name="morphe_spoof_video_streams_sign_in_android_vr_activation_code_dialog_title">تعليمات</string>
<string name="morphe_spoof_video_streams_sign_in_android_vr_dialog_continue">متابعة</string>
<string name="morphe_spoof_video_streams_sign_in_android_vr_dialog_not_signed_in_message">"تسجيل الدخول إلى Android VR يمكنه إصلاح مشكلات التشغيل لبعض المستخدمين
لتسجيل الدخول، اضغط على 'متابعة' بالأسفل"</string>
<string name="morphe_spoof_video_streams_sign_in_android_vr_dialog_reset">تسجيل خروج</string>
<string name="morphe_spoof_video_streams_sign_in_android_vr_dialog_title">تسجيل الدخول إلى Android VR</string>
<string name="morphe_spoof_video_streams_sign_in_android_vr_success_dialog_message">"تم حفظ بيانات المصادقة
• لن يتم استيراد/تصدير بيانات مصادقة Android VR من إعدادات Morphe الخاصة بك
• إذا لم يتم استخدام هذا التطبيق لمدة 6 أشهر، فستقوم Google بإزالة بيانات المصادقة هذه
• ستحتاج إلى تسجيل الدخول مرة أخرى إذا قمت بتغيير كلمة مرور Google أو إلغاء تسجيل هذا الجهاز من إعدادات Google الخاصة بك"</string>
<string name="morphe_spoof_video_streams_sign_in_android_vr_success_dialog_title">تم تسجيل الدخول بنجاح</string>
<string name="morphe_spoof_video_streams_sign_in_android_vr_toast_get_activation_code_failed">فشل في إصدار رمز التفعيل</string>
<string name="morphe_spoof_video_streams_sign_in_android_vr_toast_get_authorization_code_failed">فشل في إصدار رمز المصادقة</string>
<string name="morphe_spoof_video_streams_summary_off">"لا يتم تزييف تدفقات الفيديو
قد لا يعمل تشغيل الفيديو"</string>
<string name="morphe_spoof_video_streams_summary_on">"يتم تزييف تدفقات الفيديو
إذا كنت مستخدمًا لـ YouTube Premium، فقد لا يكون هذا الإعداد مطلوبًا"</string>
<string name="morphe_spoof_video_streams_title">Spoof Video Streams</string>
<string name="morphe_spoof_video_streams_user_dialog_message">قد يؤدي إيقاف تشغيل هذا الإعداد إلى مشكلات في التشغيل.</string>
<string name="revanced_bypass_image_region_restrictions_title">تجاوز القيود على منطقة الصورة</string>
<string name="revanced_change_share_sheet_title">تغيير لوح مشاركة</string>
<string name="revanced_change_start_page_entry_default">الافتراضي</string>
@ -237,17 +310,7 @@
<string name="revanced_spoof_app_version_target_title">الهدف من خِداع إصدار التطبيق</string>
<string name="revanced_spoof_app_version_title">إصدار تطبيق وهمي</string>
<string name="revanced_spoof_client_type_title">العميل الافتراضي</string>
<string name="revanced_spoof_streaming_data_client_entry_android_creator">Android Studio</string>
<string name="revanced_spoof_streaming_data_client_entry_android_music_no_sdk">Android Music No SDK</string>
<string name="revanced_spoof_streaming_data_client_entry_android_no_sdk">Android No SDK</string>
<string name="revanced_spoof_streaming_data_client_entry_android_vr">Android VR</string>
<string name="revanced_spoof_streaming_data_client_entry_tv">TV</string>
<string name="revanced_spoof_streaming_data_client_entry_tv_legacy">TV Legacy</string>
<string name="revanced_spoof_streaming_data_client_entry_tv_simply">TV Simply</string>
<string name="revanced_spoof_streaming_data_client_entry_visionos">visionOS</string>
<string name="revanced_spoof_streaming_data_default_client_title">العميل الافتراضي</string>
<string name="revanced_spoof_streaming_data_failed_forbidden">لم يتمكن من جلب أي تدفقات عميل.</string>
<string name="revanced_spoof_streaming_data_failed_forbidden_suggestion">جرّب تغيير العميل الافتراضي إلى TV Simply.</string>
<string name="revanced_spoof_streaming_data_no_sdk_auth_token_about_summary">انقر لمعرفة كيفية الحصول على رمز المصادقة.</string>
<string name="revanced_spoof_streaming_data_no_sdk_auth_token_about_title">لمحة عن مصادقة Android No SDK</string>
<string name="revanced_spoof_streaming_data_sign_in_android_no_sdk_about_summary">انقر هنا لمعرفة كيفية تسجيل الدخول إلى Android No SDK.</string>
@ -274,20 +337,6 @@
<string name="revanced_spoof_streaming_data_sign_in_android_vr_activation_code_dialog_open_website_text">فتح الموقع</string>
<string name="revanced_spoof_streaming_data_sign_in_android_vr_activation_code_dialog_title">رمز التفعيل</string>
<string name="revanced_spoof_streaming_data_sign_in_android_vr_activation_code_toast_copy">تم نسخ رمز التفعيل إلى الحافظة: %s.</string>
<string name="revanced_spoof_streaming_data_sign_in_android_vr_dialog_get_activation_code_text">الحصول على رمز التفعيل</string>
<string name="revanced_spoof_streaming_data_sign_in_android_vr_dialog_get_authorization_token_text">الحصول على رمز المصادقة</string>
<string name="revanced_spoof_streaming_data_sign_in_android_vr_dialog_message">"لتسجيل الدخول إلى Android VR (YouTube VR)، اتبع إرشادات Google (تفويض حساب جوجل - OAuth 2.0 لتطبيقات التلفزيون والأجهزة ذات الإدخال المحدود):
1. انقر على 'الحصول على رمز التفعيل' لإنشاء رمز التفعيل.
2. انقر على 'فتح الموقع' للانتقال إلى 'https://yt.be/activate'.
3. أدخل رمز التفعيل.
4. سجّل الدخول عند الطلب. إذا كان لديك حسابات Google متعددة، فحدد الحساب الذي تستخدمه مع YouTube. إذا كنت قد سجّلت الدخول بالفعل، فانتقل إلى الخطوة التالية.
5. بعد تسجيل الدخول، ارجع إلى RVX وانقر على 'تسجيل الدخول إلى Android VR' مرة أخرى.
6. انقر على 'الحصول على رمز المصادقة' للحصول على رمز التفويض.
※ انقر على 'إعادة تعيين' لإزالة رمز المصادقة أو رمز التفعيل."</string>
<string name="revanced_spoof_streaming_data_sign_in_android_vr_dialog_reset_text">إعادة تعيين</string>
<string name="revanced_spoof_streaming_data_sign_in_android_vr_dialog_title">تسجيل الدخول إلى Android VR</string>
<string name="revanced_spoof_streaming_data_sign_in_android_vr_success_dialog_message">"تم حفظ رمز المصادقة.
- رمز المصادقة الصادر من خلال هذه العملية لا يمكن استخدامه إلا في YouTube VR(في RVX, يستخدم لجلب بيانات البث من YouTube VR).
@ -298,8 +347,6 @@
<string name="revanced_spoof_streaming_data_sign_in_android_vr_toast_failed_suggestion">انقر فوق إعادة تعيين ثم حاول مرة أخرى.</string>
<string name="revanced_spoof_streaming_data_sign_in_android_vr_toast_reset">إعادة تعيين رمز المصادقة و رمز التفعيل.</string>
<string name="revanced_spoof_streaming_data_stats_for_nerds_title">عرض في إحصاءات تقنية</string>
<string name="revanced_spoof_streaming_data_use_latest_player_js_title">استخدام أحدث مشغل JavaScript</string>
<string name="revanced_spoof_streaming_data_use_yt_dlp_ejs_title">استخدام yt-dlp ejs (WIP)</string>
<string name="revanced_spoof_streaming_data_vr_auth_token_about_summary">انقر لمعرفة كيفية الحصول على رمز المصادقة.</string>
<string name="revanced_spoof_streaming_data_vr_auth_token_about_title">لمحة عن مصادقة Android VR</string>
<string name="revanced_spoof_video_streams_default_client_title">العميل الافتراضي</string>

View file

@ -2,6 +2,79 @@
<resources>
<string name="gms_core_dialog_continue_text">Продължи</string>
<string name="gms_core_dialog_open_website_text">Отвори уебсайт</string>
<string name="morphe_disable_drc_audio_summary_off">DRC аудио е включено</string>
<string name="morphe_disable_drc_audio_summary_on">DRC аудио е изключено</string>
<string name="morphe_disable_drc_audio_title">Изключете DRC аудио</string>
<string name="morphe_disable_quic_protocol_summary_off">Протокола QUIC е включен</string>
<string name="morphe_disable_quic_protocol_summary_on">Протокола QUIC е изключен</string>
<string name="morphe_disable_quic_protocol_title">Изключи протокола QUIC</string>
<string name="morphe_force_original_audio_not_available">За да използвате тази функция, променете \'Имитиране на видео потоци\' на Android Reel или TV</string>
<string name="morphe_force_original_audio_summary_off">Използване на аудио по подразбиране</string>
<string name="morphe_force_original_audio_summary_on">Използване на оригиналния език на аудиото</string>
<string name="morphe_force_original_audio_title">Принудително оригинален аудио език</string>
<string name="morphe_oauth2_connection_failure_auth_error">Не може да се впишете в VR: %s</string>
<string name="morphe_oauth2_connection_failure_auth_not_approved">Не може да се впишете в VR, защото идентификацията на уебсайта не е одобрена</string>
<string name="morphe_oauth2_connection_failure_generic">OAuth2 временно не е достъпен</string>
<string name="morphe_oauth2_connection_failure_status">OAuth2 временно не е достъпен (статус %d).</string>
<string name="morphe_oauth2_connection_failure_status_400">Входът за Android VR е изтекъл, опитайте се да влезете в VR отново</string>
<string name="morphe_oauth2_connection_failure_timeout">Временно не е възможно OAuth2 (изтекло време на API).</string>
<string name="morphe_oauth2_toast_invalid">Невалиден токен за VR, опитайте се да се влезете в VR отново</string>
<string name="morphe_oauth2_toast_reset">Излезли сте от Android VR</string>
<string name="morphe_replace_music_with_youtube_summary_off">Споделяните връзки използват music.youtube.com</string>
<string name="morphe_replace_music_with_youtube_summary_on">Споделяните връзки използват youtube.com</string>
<string name="morphe_replace_music_with_youtube_title">Сменете споделяните връзки на youtube.com</string>
<string name="morphe_sanitize_sharing_links_summary_off">Параметърът за отчитане не е премахнат от споделяни връзки</string>
<string name="morphe_sanitize_sharing_links_summary_on">Параметърът за следене е премахнат от споделяни връзки</string>
<string name="morphe_sanitize_sharing_links_title">Почистване на споделените връзки</string>
<string name="morphe_spoof_video_streams_client_type_title">Клиент по подразбиране</string>
<string name="morphe_spoof_video_streams_disable_player_js_update_summary_off">Актуализацията на JavaScript за плейъра е активирана</string>
<string name="morphe_spoof_video_streams_disable_player_js_update_summary_on">"Актуализацията на JavaScript за плейъра е деактивирана
• Необходим е валиден JavaScript хеш на плейъра.
• Възпроизвеждането се проваля, ако JavaScript хеш на плейъра е отменен в GVS"</string>
<string name="morphe_spoof_video_streams_disable_player_js_update_title">Деактивиране на актуализацията на JavaScript за плейъра</string>
<string name="morphe_spoof_video_streams_disable_player_js_update_user_dialog_message">JavaScript хешовете на плейъра са валидни приблизително един месец и остарелите хешове може да бъдат отменени от GVS.</string>
<string name="morphe_spoof_video_streams_no_clients_suggest_vr_toast">Пробвайте да вкл. Подправяне на видеопотокове &gt; Вход в Android VR</string>
<string name="morphe_spoof_video_streams_no_clients_toast">Не може да се изтеглят клиентски потоци</string>
<string name="morphe_spoof_video_streams_player_js_hash_summary">Въведете валиден JavaScript хеш на плейъра</string>
<string name="morphe_spoof_video_streams_player_js_hash_title">JavaScript хеш на играча</string>
<string name="morphe_spoof_video_streams_screen_summary">Подправете клиентските видео потоци, за да предотвратите проблеми с възпроизвеждането</string>
<string name="morphe_spoof_video_streams_screen_title">Подправяне на видео потоци</string>
<string name="morphe_spoof_video_streams_sign_in_android_vr_about_summary">Несте влезли в Android VR. Натиснете тук за да се влезете.</string>
<string name="morphe_spoof_video_streams_sign_in_android_vr_about_summary_disabled">За да използвате тази функционалност, променете \'Стандартен клиент\' на Android VR</string>
<string name="morphe_spoof_video_streams_sign_in_android_vr_about_summary_signed_in">Входът в Android VR е активен</string>
<string name="morphe_spoof_video_streams_sign_in_android_vr_about_title">Вход в Android VR</string>
<string name="morphe_spoof_video_streams_sign_in_android_vr_activation_code_dialog_message">"Код за активация: %s
1. Натиснете 'Отвори уебсайт' отдолу, за да отворите уебсайта 'Приклейте уебсайт'
2. Вмъкнете от клипборда (или напишете) кода за активация, показан отгоре
3. Влезте в Google, ако ви е нужно. Ако имате повече сметки, изберете тази сметка YouTube
4. Натиснете 'Разрешете YouTube VR' достъп
5. Върнете се в това приложение след като достигнете съобщението 'Уебсайт приклеен'"</string>
<string name="morphe_spoof_video_streams_sign_in_android_vr_activation_code_dialog_open_website">Отвори уебсайта</string>
<string name="morphe_spoof_video_streams_sign_in_android_vr_activation_code_dialog_title">Инструкции</string>
<string name="morphe_spoof_video_streams_sign_in_android_vr_dialog_continue">Продължи</string>
<string name="morphe_spoof_video_streams_sign_in_android_vr_dialog_not_signed_in_message">"Вход в Android VR може да реши проблеми с възпроизвеждане за някои потребители
За да се влезете, натиснете \"Продължи\" отдолу"</string>
<string name="morphe_spoof_video_streams_sign_in_android_vr_dialog_reset">Изход</string>
<string name="morphe_spoof_video_streams_sign_in_android_vr_dialog_title">Вход за Android VR</string>
<string name="morphe_spoof_video_streams_sign_in_android_vr_success_dialog_message">"Автентификацията е запазена
• Настройките за Morphe няма да бъдат импортирани/експортирани за Android VR автентификация
• Ако приложението не се използва в продължение на 6 месеца, Google ще премахне тази автентификация
• Ще трябва да се впишете отново, ако промените своя Google парола или изключите това устройство от Google настройки"</string>
<string name="morphe_spoof_video_streams_sign_in_android_vr_success_dialog_title">Успешно се влязохте</string>
<string name="morphe_spoof_video_streams_sign_in_android_vr_toast_get_activation_code_failed">Неуспешно издаване на код за активация</string>
<string name="morphe_spoof_video_streams_sign_in_android_vr_toast_get_authorization_code_failed">Неуспешно издаване на код за авторизация</string>
<string name="morphe_spoof_video_streams_summary_off">"Видеопотоците не са подправени
Възпроизвеждането може да не работи"</string>
<string name="morphe_spoof_video_streams_summary_on">"Видео потоците са подправени
Ако сте потребител на YouTube Premium, това настройка може да не е нужна"</string>
<string name="morphe_spoof_video_streams_title">Подправяне на видео потоци</string>
<string name="morphe_spoof_video_streams_user_dialog_message">Изключването на тази настройка може да причини проблеми при възпроизвеждане.</string>
<string name="revanced_change_player_background_color_title">Смени фоновия цвят на плейъра</string>
<string name="revanced_change_start_page_entry_chart">Хит-парад</string>
<string name="revanced_change_start_page_entry_explore">Преглед</string>

View file

@ -17,6 +17,79 @@
<string name="gms_core_settings_summary">Για να λαμβάνετε ειδοποιήσεις στο RVX Music, ενεργοποιήστε τις ρυθμίσεις Cloud Messaging.</string>
<string name="gms_core_settings_title">Άνοιγμα ρυθμίσεων του MicroG GmsCore</string>
<string name="gms_core_toast_not_installed_message">Το MicroG GmsCore δεν είναι εγκατεστημένο. Εγκαταστήστε το.</string>
<string name="morphe_disable_drc_audio_summary_off">Ο ήχος DRC είναι ενεργοποιημένος</string>
<string name="morphe_disable_drc_audio_summary_on">Ο ήχος DRC είναι απενεργοποιημένος</string>
<string name="morphe_disable_drc_audio_title">Απενεργοποίηση ήχου DRC</string>
<string name="morphe_disable_quic_protocol_summary_off">Το πρωτόκολλο QUIC είναι ενεργοποιημένο</string>
<string name="morphe_disable_quic_protocol_summary_on">Το πρωτόκολλο QUIC είναι απενεργοποιημένο</string>
<string name="morphe_disable_quic_protocol_title">Απενεργοποίηση πρωτοκόλλου QUIC</string>
<string name="morphe_force_original_audio_not_available">Για να χρησιμοποιήσετε αυτήν τη λειτουργία, αλλάξτε το \"Παραποίηση ροών βίντεο\" σε Android Reel ή TV</string>
<string name="morphe_force_original_audio_summary_off">Χρησιμοποιείται ο προεπιλεγμένος ήχος</string>
<string name="morphe_force_original_audio_summary_on">Χρησιμοποιείται η αρχική γλώσσα ήχου</string>
<string name="morphe_force_original_audio_title">Εξαναγκασμός αρχικής γλώσσας ήχου</string>
<string name="morphe_oauth2_connection_failure_auth_error">Δεν είναι δυνατή η σύνδεση στο VR: %s</string>
<string name="morphe_oauth2_connection_failure_auth_not_approved">Δεν είναι δυνατός ο έλεγχος εισόδου VR, η έγκριση διαδικασίας ελέγχου ιστοσελίδας δεν έχει επιτευχθεί</string>
<string name="morphe_oauth2_connection_failure_generic">OAuth2 είναι προσωρινά μη διαθέσιμο</string>
<string name="morphe_oauth2_connection_failure_status">OAuth2 προσωρινά μη διαθέσιμο (καθυστέρηση %d)</string>
<string name="morphe_oauth2_connection_failure_status_400">Η σύνδεση Android VR έληξε, προσπαθήστε να συνδεθείτε ξανά στο VR</string>
<string name="morphe_oauth2_connection_failure_timeout">OAuth2 προσωρινά μη διαθέσιμο (καθυστέρηση API)</string>
<string name="morphe_oauth2_toast_invalid">Δεν έγκειται το κωδικό εισόδου VR, προσπαθήστε να συνδεθείτε ξανά στο VR</string>
<string name="morphe_oauth2_toast_reset">Έξοδος από το Android VR</string>
<string name="morphe_replace_music_with_youtube_summary_off">Οι κοινοποιημένοι σύνδεσμοι χρησιμοποιούν το music.youtube.com</string>
<string name="morphe_replace_music_with_youtube_summary_on">Οι κοινοποιημένοι σύνδεσμοι χρησιμοποιούν το youtube.com</string>
<string name="morphe_replace_music_with_youtube_title">Αλλαγή συνδέσμων κοινοποίησης σε youtube.com</string>
<string name="morphe_sanitize_sharing_links_summary_off">Η παράμετρος παρακολούθησης δεν αφαιρείται από τους συνδέσμους κατά την κοινοποίηση</string>
<string name="morphe_sanitize_sharing_links_summary_on">Η παράμετρος παρακολούθησης αφαιρείται από τους συνδέσμους κατά την κοινοποίηση</string>
<string name="morphe_sanitize_sharing_links_title">Καθαρισμός συνδέσμων κοινοποίησης</string>
<string name="morphe_spoof_video_streams_client_type_title">Προεπιλεγμένο πρόγραμμα πελάτη</string>
<string name="morphe_spoof_video_streams_disable_player_js_update_summary_off">Η ενημέρωση JavaScript του προγράμματος αναπαραγωγής είναι ενεργοποιημένη</string>
<string name="morphe_spoof_video_streams_disable_player_js_update_summary_on">"Η ενημέρωση JavaScript του προγράμματος αναπαραγωγής είναι απενεργοποιημένη
• Απαιτείται έγκυρο hash JavaScript του προγράμματος αναπαραγωγής.
Η αναπαραγωγή αποτυγχάνει εάν το hash JavaScript του προγράμματος αναπαραγωγής ανακληθεί στο GVS"</string>
<string name="morphe_spoof_video_streams_disable_player_js_update_title">Απενεργοποίηση ενημέρωσης JavaScript του προγράμματος αναπαραγωγής</string>
<string name="morphe_spoof_video_streams_disable_player_js_update_user_dialog_message">Τα JavaScript hashes για τον παίκτη είναι έγκυρα για περίπου ένα μήνα, και τα устаревшая hashes ενδέχεται να ανακληθούν από το GVS.</string>
<string name="morphe_spoof_video_streams_no_clients_suggest_vr_toast">Δοκιμάστε να ενεργοποιήσετε: Μιμήση ροής βίντεο &gt; Είσοδος Android VR</string>
<string name="morphe_spoof_video_streams_no_clients_toast">Αδυναμία λήψης ροής του προγράμματος πελάτη</string>
<string name="morphe_spoof_video_streams_player_js_hash_summary">Εισάγετε ένα έγκυρο hash JavaScript παίκτη</string>
<string name="morphe_spoof_video_streams_player_js_hash_title">Hash JavaScript παίκτη</string>
<string name="morphe_spoof_video_streams_screen_summary">Παραποίηση ροών βίντεο του προγράμματος πελάτη για την αποφυγή προβλημάτων αναπαραγωγής</string>
<string name="morphe_spoof_video_streams_screen_title">Παραποίηση ροών βίντεο</string>
<string name="morphe_spoof_video_streams_sign_in_android_vr_about_summary">Δεν είστε συνδεδεμένοι στο Android VR. Πατήστε εδώ για να συνδεθείτε</string>
<string name="morphe_spoof_video_streams_sign_in_android_vr_about_summary_disabled">Για να χρησιμοποιήσετε αυτή τη δυνατότητα, αλλάξτε \'Κyendo κλέιντ\' σε Android VR</string>
<string name="morphe_spoof_video_streams_sign_in_android_vr_about_summary_signed_in">Τρέχουσα σύνδεση σε Android VR</string>
<string name="morphe_spoof_video_streams_sign_in_android_vr_about_title">Είσοδος Android VR</string>
<string name="morphe_spoof_video_streams_sign_in_android_vr_activation_code_dialog_message">"Κωδικός ενεργοποίησης: %s
1. Πατήστε 'Άνοιγμα ιστοσελίδας' παρακάτω για να ανοίξετε την ιστοσελίδα 'Σύνδεση συσκευής'
2. Εισάγετε από το πρόχειρο (ή πληκτρολογήστε) τον κωδικό ενεργοποίησης που εμφανίζεται παραπάνω
3. Είσοδος στο Google εάν ζητηθεί. Εάν έχετε πολλαπλές λογαριασμούς τότε επιλέξτε αυτόν τον λογαριασμό YouTube
4. Πατήστε 'Είσοδος YouTube VR'
5. Επιστρέψτε σε αυτήν την εφαρμογή αφού φτάσετε στο μήνυμα 'Συσκευή συνδεδεμένη'"</string>
<string name="morphe_spoof_video_streams_sign_in_android_vr_activation_code_dialog_open_website">Άνοιγμα ιστοσελίδας</string>
<string name="morphe_spoof_video_streams_sign_in_android_vr_activation_code_dialog_title">Οδηγίες</string>
<string name="morphe_spoof_video_streams_sign_in_android_vr_dialog_continue">Συνέχεια</string>
<string name="morphe_spoof_video_streams_sign_in_android_vr_dialog_not_signed_in_message">"Είσοδος στο Android VR μπορεί να επιλύσει προβλήματα αναπαραγωγής για ορισμένους χρήστες
Για να εισέλθετε, πατήστε \"Συνέχεια\" παρακάτω"</string>
<string name="morphe_spoof_video_streams_sign_in_android_vr_dialog_reset">Αποσύνδεση</string>
<string name="morphe_spoof_video_streams_sign_in_android_vr_dialog_title">Είσοδος στο Android VR</string>
<string name="morphe_spoof_video_streams_sign_in_android_vr_success_dialog_message">"Η εγκρίνιση αποθηκεύτηκε
Οι ρυθμίσεις Morphe δεν θα εισάγουν/εξάγουν εγκρίνιση Android VR
• Εάν η εφαρμογή παραμείνει αχρησιμοποίητη για 6 μήνες, η Google θα αφαιρέσει αυτή την εγκρίνιση
• Θα χρειαστεί να συνδεθείτε ξανά εάν αλλάξετε το κωδικό πρόσβασης της Google ή να αποσυνδεθείτε αυτή την συσκευή από τις ρυθμίσεις της Google"</string>
<string name="morphe_spoof_video_streams_sign_in_android_vr_success_dialog_title">Είσοδος ολοκληρώθηκε με επιτυχία</string>
<string name="morphe_spoof_video_streams_sign_in_android_vr_toast_get_activation_code_failed">Αποτυχία εξαγωγής κώδικα ενεργοποίησης</string>
<string name="morphe_spoof_video_streams_sign_in_android_vr_toast_get_authorization_code_failed">Αποτυχία έκδοσης διακριτικού εξουσιοδότησης</string>
<string name="morphe_spoof_video_streams_summary_off">"Οι ροές βίντεο δεν παραποιούνται
Η αναπαραγωγή ενδέχεται να μην λειτουργεί"</string>
<string name="morphe_spoof_video_streams_summary_on">"Οι ροές βίντεο παραποιούνται
Αν είστε χρήστης YouTube Premium, αυτή η ρύθμιση μπορεί να μην απαιτείται"</string>
<string name="morphe_spoof_video_streams_title">Παραποίηση ροών βίντεο</string>
<string name="morphe_spoof_video_streams_user_dialog_message">Η απενεργοποίηση αυτής της ρύθμισης ενδέχεται να προκαλέσει προβλήματα αναπαραγωγής.</string>
<string name="revanced_add_miniplayer_next_button_summary">Προσθήκη ενός κουμπιού επόμενου κομματιού στην ελαχιστοποιημένη οθόνη αναπαραγωγής.</string>
<string name="revanced_add_miniplayer_next_button_title">Προσθήκη κουμπιού επόμενου κομματιού στην ελαχιστοποιημένη οθόνη αναπαραγωγής</string>
<string name="revanced_add_miniplayer_previous_button_summary">Προσθήκη ενός κουμπιού προηγούμενου κομματιού στην ελαχιστοποιημένη οθόνη αναπαραγωγής.</string>
@ -553,18 +626,7 @@
Παρενέργεια αποκλεισμού αιτήματος:
Η εφαρμογή μπορεί να κλείνει απότομα όταν χρησιμοποιείτε VPN ή εναλλακτικό DNS."</string>
<string name="revanced_spoof_client_type_title">Προεπιλεγμένο πρόγραμμα πελάτη</string>
<string name="revanced_spoof_streaming_data_client_entry_android_creator">Android Studio</string>
<string name="revanced_spoof_streaming_data_client_entry_android_music_no_sdk">Android Music No SDK</string>
<string name="revanced_spoof_streaming_data_client_entry_android_no_sdk">Android (No SDK)</string>
<string name="revanced_spoof_streaming_data_client_entry_android_vr">Android VR</string>
<string name="revanced_spoof_streaming_data_client_entry_tv">TV</string>
<string name="revanced_spoof_streaming_data_client_entry_tv_legacy">TV Παλιό</string>
<string name="revanced_spoof_streaming_data_client_entry_tv_simply">TV Simply</string>
<string name="revanced_spoof_streaming_data_client_entry_visionos">visionOS</string>
<string name="revanced_spoof_streaming_data_default_client_summary">Καθορισμός ενός προεπιλεγμένου πρόγραμμα πελάτη για παραποίηση.</string>
<string name="revanced_spoof_streaming_data_default_client_title">Προεπιλεγμένο πρόγραμμα πελάτη</string>
<string name="revanced_spoof_streaming_data_failed_forbidden">Αδυναμία λήψης ροής του προγράμματος πελάτη.</string>
<string name="revanced_spoof_streaming_data_failed_forbidden_suggestion">Δοκιμάστε να αλλάξετε το προεπιλεγμένο πρόγραμμα πελάτη παραποίησης δεδομένων ροής σε TV Simply.</string>
<string name="revanced_spoof_streaming_data_no_sdk_auth_token_about_summary">Πατήστε για να δείτε πώς να αποκτήσετε ένα διακριτικό εξουσιοδότησης.</string>
<string name="revanced_spoof_streaming_data_no_sdk_auth_token_about_title">Σχετικά με την εξουσιοδότηση Android No SDK </string>
<string name="revanced_spoof_streaming_data_sign_in_android_no_sdk_about_summary">Πατήστε για να δείτε πώς να συνδεθείτε στο Android No SDK.</string>
@ -599,20 +661,6 @@
<string name="revanced_spoof_streaming_data_sign_in_android_vr_activation_code_dialog_open_website_text">Άνοιγμα ιστοσελίδας</string>
<string name="revanced_spoof_streaming_data_sign_in_android_vr_activation_code_dialog_title">Κωδικός ενεργοποίησης</string>
<string name="revanced_spoof_streaming_data_sign_in_android_vr_activation_code_toast_copy">Ο κωδικός αντιγράφηκε στο πρόχειρο: %s.</string>
<string name="revanced_spoof_streaming_data_sign_in_android_vr_dialog_get_activation_code_text">Λήψη κωδικού ενεργοποίησης</string>
<string name="revanced_spoof_streaming_data_sign_in_android_vr_dialog_get_authorization_token_text">Λήψη διακριτικού εξουσιοδότησης</string>
<string name="revanced_spoof_streaming_data_sign_in_android_vr_dialog_message">"Η σύνδεση στο Android VR (YouTube VR) γίνεται σύμφωνα με τις οδηγίες της Google (Google account authorization - OAuth 2.0 for TV and Limited-Input Device Applications):
1. Πατήστε «Λήψη κωδικού ενεργοποίησης» για να δημιουργήσετε έναν κωδικό ενεργοποίησης.
2. Πατήστε «Άνοιγμα ιστοσελίδας» για να μεταβείτε στο 'https://yt.be/activate'.
3. Εισάγετε τον κωδικό ενεργοποίησης.
4. Συνδεθείτε όταν σας ζητηθεί. Αν έχετε πολλαπλούς λογαριασμούς Google, επιλέξτε τον λογαριασμό που χρησιμοποιείτε με το YouTube. Αν έχετε συνδεθεί ήδη, συνεχίστε στο επόμενο βήμα.
5. Μόλις συνδεθείτε, επιστρέψτε στο RVX και πατήστε το «Σχετικά με το Android VR (με σύνδεση χρήστη)» ξανά.
6. Πατήστε «Λήψη διακριτικού εξουσιοδότησης» για να λάβετε διακριτικό εξουσιοδότησης.
※ Πατήστε «Επαναφορά» για να καταργήσετε το διακριτικό εξουσιοδότησης ή τον κωδικό ενεργοποίησης."</string>
<string name="revanced_spoof_streaming_data_sign_in_android_vr_dialog_reset_text">Επαναφορά</string>
<string name="revanced_spoof_streaming_data_sign_in_android_vr_dialog_title">Σύνδεση στο Android VR</string>
<string name="revanced_spoof_streaming_data_sign_in_android_vr_success_dialog_message">"Το διακριτικό εξουσιοδότησης αποθηκεύτηκε.
- Το διακριτικό εξουσιοδότησης που εκδίδεται μέσω αυτής της διαδικασίας μπορεί να χρησιμοποιηθεί μόνο στο YouTube VR (Στο RVX, χρησιμοποιείται για τη λήψη δεδομένων ροής από το YouTube VR).
@ -628,15 +676,6 @@
Αν είστε χρήστης του YouTube Premium, αυτή η ρύθμιση μπορεί να μην είναι απαραίτητη."</string>
<string name="revanced_spoof_streaming_data_title">Παραποίηση δεδομένων ροής</string>
<string name="revanced_spoof_streaming_data_use_latest_player_js_summary">"Όταν ενεργοποιείται, θα χρησιμοποιείται η Player JavaScript τελευταίας έκδοσης για την αποσυσκότιση των streaming Url στους πελάτες «TV» και «Web Κινητού».
Αν αντιμετωπίζετε προβλήματα με τον πελάτη JavaScript, δοκιμάστε πρώτα να απενεργοποιήσετε αυτή τη ρύθμιση."</string>
<string name="revanced_spoof_streaming_data_use_latest_player_js_title">Χρήση Player JavaScript τελευταίας έκδοσης</string>
<string name="revanced_spoof_streaming_data_use_yt_dlp_ejs_summary">"Όταν είναι ενεργοποιημένο, θα χρησιμοποιείται η εξωτερική JavaScript για το yt-dlp (yt-dlp-ejs) για την αποσυσκότιση Url ροής στους πελάτες «TV» και «Web Κινητού».
Η Player JavaScript για την αποσυσκότιση διατηρείται ενημερωμένη.
• Μπορεί να χρειαστούν από 5 έως 15 δευτερόλεπτα για να ξεκινήσει η αναπαραγωγή του βίντεο."</string>
<string name="revanced_spoof_streaming_data_use_yt_dlp_ejs_title">Χρήση του yt-dlp ejs (λειτουργία σε εξέλιξη)</string>
<string name="revanced_spoof_streaming_data_vr_auth_token_about_summary">Πατήστε για να δείτε πώς να αποκτήσετε ένα διακριτικό εξουσιοδότησης.</string>
<string name="revanced_spoof_streaming_data_vr_auth_token_about_title">Σχετικά με το Android VR (με σύνδεση χρήστη)</string>
<string name="revanced_spoof_video_streams_default_client_summary">"Παρενέργειες:
@ -665,4 +704,14 @@
<string name="revanced_webview_toast_no_network">Παρακαλούμε ελέγξτε τη σύνδεσή σας.</string>
<string name="revanced_webview_toast_unavailable">Το WebView δεν είναι διαθέσιμο.</string>
<string name="revanced_webview_visitor_data">Visitor Data</string>
<string name="revanced_spoof_streaming_data_sign_in_android_vr_dialog_message">"Η σύνδεση στο Android VR (YouTube VR) γίνεται σύμφωνα με τις οδηγίες της Google (Google account authorization - OAuth 2.0 for TV and Limited-Input Device Applications):
1. Πατήστε «Λήψη κωδικού ενεργοποίησης» για να δημιουργήσετε έναν κωδικό ενεργοποίησης.
2. Πατήστε «Άνοιγμα ιστοσελίδας» για να μεταβείτε στο 'https://yt.be/activate'.
3. Εισάγετε τον κωδικό ενεργοποίησης.
4. Συνδεθείτε όταν σας ζητηθεί. Αν έχετε πολλαπλούς λογαριασμούς Google, επιλέξτε τον λογαριασμό που χρησιμοποιείτε με το YouTube. Αν έχετε συνδεθεί ήδη, συνεχίστε στο επόμενο βήμα.
5. Μόλις συνδεθείτε, επιστρέψτε στο RVX και πατήστε το «Σχετικά με το Android VR (με σύνδεση χρήστη)» ξανά.
6. Πατήστε «Λήψη διακριτικού εξουσιοδότησης» για να λάβετε διακριτικό εξουσιοδότησης.
※ Πατήστε «Επαναφορά» για να καταργήσετε το διακριτικό εξουσιοδότησης ή τον κωδικό ενεργοποίησης."</string>
</resources>

View file

@ -1,6 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="gms_core_dialog_continue_text">Continuar</string>
<string name="gms_core_dialog_continue_text">Continuar.</string>
<string name="gms_core_dialog_dismiss_text">No volver a mostrar</string>
<string name="gms_core_dialog_not_whitelisted_not_allowed_in_background_message">"GmsCore no tiene permiso para ejecutarse en segundo plano.
@ -15,6 +15,79 @@ Pulsa el botón de continuar y desactiva las optimizaciones de la batería."</st
<string name="gms_core_settings_summary">Activa los ajustes de mensajería en la nube para recibir notificaciones.</string>
<string name="gms_core_settings_title">Abrir GmsCore</string>
<string name="gms_core_toast_not_installed_message">GmsCore no está instalado. Instálalo.</string>
<string name="morphe_disable_drc_audio_summary_off">El audio DRC está activado</string>
<string name="morphe_disable_drc_audio_summary_on">El audio DRC está desactivado</string>
<string name="morphe_disable_drc_audio_title">Desactivar audio DRC</string>
<string name="morphe_disable_quic_protocol_summary_off">El protocolo QUIC está activado</string>
<string name="morphe_disable_quic_protocol_summary_on">El protocolo QUIC está desactivado</string>
<string name="morphe_disable_quic_protocol_title">Desactivar protocolo QUIC</string>
<string name="morphe_force_original_audio_not_available">Para usar esta función, cambia \'Falsificar transmisiones de vídeo\' a Android Reel o TV</string>
<string name="morphe_force_original_audio_summary_off">Utilizando audio predeterminado</string>
<string name="morphe_force_original_audio_summary_on">Usar el idioma de audio original</string>
<string name="morphe_force_original_audio_title">Forzar idioma de audio original</string>
<string name="morphe_oauth2_connection_failure_auth_error">No se puede iniciar sesión en VR: %s</string>
<string name="morphe_oauth2_connection_failure_auth_not_approved">No se puede iniciar sesión en VR, la autenticación del sitio web no se ha aprobado</string>
<string name="morphe_oauth2_connection_failure_generic">OAuth2 no está disponible temporalmente</string>
<string name="morphe_oauth2_connection_failure_status">OAuth2 no está disponible temporalmente (estado %d)</string>
<string name="morphe_oauth2_connection_failure_status_400">El inicio de sesión de Android VR ha expirado, intenta iniciar sesión en VR de nuevo</string>
<string name="morphe_oauth2_connection_failure_timeout">OAuth2 no está disponible temporalmente (la API no responde)</string>
<string name="morphe_oauth2_toast_invalid">Token de inicio de sesión de VR no válido, intenta iniciar sesión en VR de nuevo</string>
<string name="morphe_oauth2_toast_reset">Desconectado de Android VR</string>
<string name="morphe_replace_music_with_youtube_summary_off">Los enlaces compartidos usan music.youtube.com</string>
<string name="morphe_replace_music_with_youtube_summary_on">Los enlaces compartidos usan youtube.com</string>
<string name="morphe_replace_music_with_youtube_title">Cambiar enlaces compartidos a youtube.com</string>
<string name="morphe_sanitize_sharing_links_summary_off">El parámetro de consulta de seguimiento no se elimina de los enlaces compartidos</string>
<string name="morphe_sanitize_sharing_links_summary_on">El parámetro de consulta de seguimiento se elimina de los enlaces compartidos</string>
<string name="morphe_sanitize_sharing_links_title">Limpiar enlaces compartidos</string>
<string name="morphe_spoof_video_streams_client_type_title">Cliente predeterminado</string>
<string name="morphe_spoof_video_streams_disable_player_js_update_summary_off">La actualización de JavaScript del reproductor está activada</string>
<string name="morphe_spoof_video_streams_disable_player_js_update_summary_on">"La actualización de JavaScript del reproductor está desactivada
• Se requiere un hash JavaScript del reproductor válido
• La reproducción fallará si el hash JavaScript del reproductor es revocado por GVS"</string>
<string name="morphe_spoof_video_streams_disable_player_js_update_title">Desactivar la actualización de JavaScript del reproductor</string>
<string name="morphe_spoof_video_streams_disable_player_js_update_user_dialog_message">Los hashes JavaScript del reproductor son válidos aproximadamente un mes, y los hashes obsoletos pueden ser revocados por GVS.</string>
<string name="morphe_spoof_video_streams_no_clients_suggest_vr_toast">Prueba a activar: Falsificar transmisiones de vídeo &gt; Inicio de sesión de Android VR</string>
<string name="morphe_spoof_video_streams_no_clients_toast">No se ha podido obtener ninguna transmisión del cliente</string>
<string name="morphe_spoof_video_streams_player_js_hash_summary">Introduzca un hash JavaScript del reproductor válido</string>
<string name="morphe_spoof_video_streams_player_js_hash_title">Hash JavaScript del reproductor</string>
<string name="morphe_spoof_video_streams_screen_summary">Falsifica las transmisiones de vídeo del cliente para evitar problemas de reproducción</string>
<string name="morphe_spoof_video_streams_screen_title">Falsificar transmisiones de vídeo</string>
<string name="morphe_spoof_video_streams_sign_in_android_vr_about_summary">No has iniciado sesión en Android VR. Pulsa aquí para iniciar sesión</string>
<string name="morphe_spoof_video_streams_sign_in_android_vr_about_summary_disabled">Para usar esta función, cambia \"Cliente predeterminado\" a Android VR</string>
<string name="morphe_spoof_video_streams_sign_in_android_vr_about_summary_signed_in">Actualmente conectado a Android VR</string>
<string name="morphe_spoof_video_streams_sign_in_android_vr_about_title">Inicio de sesión de Android VR</string>
<string name="morphe_spoof_video_streams_sign_in_android_vr_activation_code_dialog_message">"Código de activación: %s
1. Pulsa \"Abrir sitio web\" a continuación para abrir el sitio web \"Conectar un dispositivo\"
2. Pega desde el portapapeles (o escribe) el código de activación que se muestra arriba
3. Inicia sesión en Google si se te solicita. Si tienes varias cuentas, elige esta cuenta de YouTube
4. Pulsa \"Permitir acceso a YouTube VR\"
5. Vuelve a esta aplicación después de ver el mensaje \"Dispositivo conectado\""</string>
<string name="morphe_spoof_video_streams_sign_in_android_vr_activation_code_dialog_open_website">Abrir sitio web</string>
<string name="morphe_spoof_video_streams_sign_in_android_vr_activation_code_dialog_title">Instrucciones</string>
<string name="morphe_spoof_video_streams_sign_in_android_vr_dialog_continue">Continuar</string>
<string name="morphe_spoof_video_streams_sign_in_android_vr_dialog_not_signed_in_message">"Iniciar sesión en Android VR puede solucionar problemas de reproducción para algunos usuarios
Para iniciar sesión, pulsa en \"Continuar\" a continuación"</string>
<string name="morphe_spoof_video_streams_sign_in_android_vr_dialog_reset">Cerrar sesión</string>
<string name="morphe_spoof_video_streams_sign_in_android_vr_dialog_title">Iniciar sesión en Android VR</string>
<string name="morphe_spoof_video_streams_sign_in_android_vr_success_dialog_message">"Autenticación guardada
• Tus ajustes de Morphe no importarán/exportarán la autenticación de Android VR
• Si esta aplicación no se utiliza durante 6 meses, Google eliminará esta autenticación
• Tendrás que iniciar sesión de nuevo si cambias tu contraseña de Google o cancelas el registro de este dispositivo en tus ajustes de Google"</string>
<string name="morphe_spoof_video_streams_sign_in_android_vr_success_dialog_title">Sesión iniciada correctamente</string>
<string name="morphe_spoof_video_streams_sign_in_android_vr_toast_get_activation_code_failed">No se pudo emitir un código de activación</string>
<string name="morphe_spoof_video_streams_sign_in_android_vr_toast_get_authorization_code_failed">No se pudo emitir un token de autenticación</string>
<string name="morphe_spoof_video_streams_summary_off">"Las transmisiones de video no están suplantadas
La reproducción podría no funcionar"</string>
<string name="morphe_spoof_video_streams_summary_on">"Las transmisiones de vídeo están falsificadas
Si eres un usuario de YouTube Premium, esta configuración podría no ser necesaria"</string>
<string name="morphe_spoof_video_streams_title">Falsificar transmisiones de vídeo</string>
<string name="morphe_spoof_video_streams_user_dialog_message">Desactivar esta configuración puede causar problemas de reproducción.</string>
<string name="revanced_add_miniplayer_next_button_summary">Añade un botón de siguiente pista al minireproductor.</string>
<string name="revanced_add_miniplayer_next_button_title">Añade un botón de siguiente al minireproductor</string>
<string name="revanced_add_miniplayer_previous_button_summary">Añade un botón de pista anterior al minireproductor.</string>
@ -537,21 +610,7 @@ Toca para ver cómo crear una clave de API."</string>
<string name="revanced_spoof_client_title">Falsificar cliente</string>
<string name="revanced_spoof_client_type_summary">"Define un cliente de por defecto para el falsificado."</string>
<string name="revanced_spoof_client_type_title">Cliente por defecto</string>
<string name="revanced_spoof_streaming_data_client_entry_android_creator">Creador de android</string>
<string name="revanced_spoof_streaming_data_client_entry_android_music_no_sdk">Android Music No SDK</string>
<string name="revanced_spoof_streaming_data_client_entry_android_no_sdk">Android sin SDK</string>
<string name="revanced_spoof_streaming_data_client_entry_android_vr">Android RV</string>
<string name="revanced_spoof_streaming_data_client_entry_tv">TV</string>
<string name="revanced_spoof_streaming_data_client_entry_tv_legacy">TV Heredado</string>
<string name="revanced_spoof_streaming_data_client_entry_tv_simply">TV Simple</string>
<string name="revanced_spoof_streaming_data_client_entry_visionos">visionOS</string>
<string name="revanced_spoof_streaming_data_default_client_summary">Define un cliente predeterminado para falsificar.
Problemas conocidos:
• El audio se puede trabar durante la reproducción.</string>
<string name="revanced_spoof_streaming_data_default_client_title">Cliente predeterminado</string>
<string name="revanced_spoof_streaming_data_failed_forbidden">No se han podido obtener las transmisiones de ningún cliente.</string>
<string name="revanced_spoof_streaming_data_failed_forbidden_suggestion">Intenta cambiar el cliente predeterminado a TV Simply.</string>
<string name="revanced_spoof_streaming_data_no_sdk_auth_token_about_summary">Clic para ver como obtener un token de autentificación.</string>
<string name="revanced_spoof_streaming_data_no_sdk_auth_token_about_title">Acerca de la Autentificación de Android No Sdk</string>
<string name="revanced_spoof_streaming_data_sign_in_android_no_sdk_about_summary">Clic aquí para ver como iniciar sesión en Android No SDK</string>
@ -586,20 +645,6 @@ Problemas conocidos:
<string name="revanced_spoof_streaming_data_sign_in_android_vr_activation_code_dialog_open_website_text">Abrir Sitio Web</string>
<string name="revanced_spoof_streaming_data_sign_in_android_vr_activation_code_dialog_title">Código de activación</string>
<string name="revanced_spoof_streaming_data_sign_in_android_vr_activation_code_toast_copy">Código de activación copiado al portapapeles: %s.</string>
<string name="revanced_spoof_streaming_data_sign_in_android_vr_dialog_get_activation_code_text">Obtener codigo de Activación</string>
<string name="revanced_spoof_streaming_data_sign_in_android_vr_dialog_get_authorization_token_text">Obtener token de autorización</string>
<string name="revanced_spoof_streaming_data_sign_in_android_vr_dialog_message">"El inicio de sesión en Android VR (YouTube VR) sigue las directrices proporcionadas por Google: \"Autorización de cuenta de Google - OAuth 2.0 para aplicaciones de TV y dispositivos de entrada limitada\":
1. Pulsa en \"Obtener código de activación\" para generar un código de activación.
2. Pulsa en \"Abrir sitio web\" para ir a \"https://yt.be/activate\".
3. Introduce el código de activación.
4. Inicia sesión cuando se te solicite. Si tienes varias cuentas de Google, selecciona la cuenta que utilizas con YouTube. Si ya has iniciado sesión, ve al siguiente paso.
5. Una vez que hayas iniciado sesión, vuelve a RVX y pulsa en \"Iniciar sesión en Android VR\" de nuevo.
6. Pulsa en \"Obtener token de autorización\" para obtener un token de autorización.
※ Pulsa en \"Restablecer\" para eliminar un token de autenticación o un código de activación."</string>
<string name="revanced_spoof_streaming_data_sign_in_android_vr_dialog_reset_text">Restablecer</string>
<string name="revanced_spoof_streaming_data_sign_in_android_vr_dialog_title">Iniciar sesión en Android VR</string>
<string name="revanced_spoof_streaming_data_sign_in_android_vr_success_dialog_message">"Token de autenticación guardado.
- El token de autenticación emitido a través de este proceso solo se puede utilizar en YouTube VR (en RVX, se utiliza para obtener datos de transmisión de YouTube VR).
@ -615,15 +660,6 @@ Problemas conocidos:
Si eres un usuario de YouTube Premium, esta opción puede ser necesaria."</string>
<string name="revanced_spoof_streaming_data_title">Falsificar datos de transmisión</string>
<string name="revanced_spoof_streaming_data_use_latest_player_js_summary">"Cuando está activado, se utilizará el JavaScript del reproductor más reciente para desofuscar las URL de streaming en 'TV' y 'Web móvil'.
Si tienes problemas con tu cliente JavaScript, intenta desactivar esto primero."</string>
<string name="revanced_spoof_streaming_data_use_latest_player_js_title">Usar el JavaScript del reproductor más reciente</string>
<string name="revanced_spoof_streaming_data_use_yt_dlp_ejs_summary">"Cuando esté activado, el Script Externo de JavaScript para yt-dlp (yt-dlp-ejs) será utilizado para deofuscar la URL de streaming en 'TV' y 'Web Móvil'
• El reproductor de JavaScript para deofuscar se mantiene actualizado.
• El vídeo puede tardar entre 5 y 15 segundos en empezar."</string>
<string name="revanced_spoof_streaming_data_use_yt_dlp_ejs_title">Usar yt-dlp ejs (todavía en desarrollo)</string>
<string name="revanced_spoof_streaming_data_vr_auth_token_about_summary">Pulsa para ver cómo obtener un token de autenticación.</string>
<string name="revanced_spoof_streaming_data_vr_auth_token_about_title">Acerca de la autenticación de Android VR</string>
<string name="revanced_spoof_video_streams_default_client_summary">Efecto secundario:
@ -652,4 +688,5 @@ Si tienes problemas con tu cliente JavaScript, intenta desactivar esto primero."
<string name="revanced_webview_toast_no_network">Comprueba tu conexión a Internet.</string>
<string name="revanced_webview_toast_unavailable">WebView no está disponible.</string>
<string name="revanced_webview_visitor_data">Datos de visitante</string>
<string name="revanced_spoof_streaming_data_failed_forbidden_suggestion">Intenta cambiar el cliente predeterminado a TV Simply.</string>
</resources>

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