feat(YouTube & YT Music - Spoof video streams): Match Morphe
Add dynamic prefs (not used)
This commit is contained in:
parent
30119997b6
commit
43cab65290
155 changed files with 13406 additions and 3129 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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 + '\''
|
||||
+ '}';
|
||||
}
|
||||
}
|
||||
|
|
@ -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 + '\''
|
||||
+ '}';
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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 + "...>";
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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 + '\'' + '}';
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -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")
|
||||
|
|
@ -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"
|
||||
)
|
||||
},
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
)
|
||||
)
|
||||
)
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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"),
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
@ -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"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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())
|
||||
}
|
||||
}
|
||||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
package app.morphe.patches.shared.misc.settings.preference
|
||||
|
||||
enum class SummaryType(val type: String) {
|
||||
DEFAULT("summary"), ON("summaryOn"), OFF("summaryOff")
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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;"
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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;",
|
||||
),
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
@ -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"
|
||||
)
|
||||
}
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
*
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
*
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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 > 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:
|
||||
|
|
|
|||
|
|
@ -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">جرب تمكين: تضمين تدفقات الفيديو > تسجيل الدخول إلى 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>
|
||||
|
|
|
|||
|
|
@ -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">Пробвайте да вкл. Подправяне на видеопотокове > Вход в 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>
|
||||
|
|
|
|||
|
|
@ -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">Δοκιμάστε να ενεργοποιήσετε: Μιμήση ροής βίντεο > Είσοδος 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>
|
||||
|
|
|
|||
|
|
@ -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 > 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
Loading…
Reference in a new issue