feat(YouTube Music): add Spoof client patch

This commit is contained in:
inotia00 2024-12-08 15:49:46 +09:00
parent 96f2db9d3b
commit 481b1537e0
12 changed files with 557 additions and 17 deletions

View File

@ -0,0 +1,84 @@
package app.revanced.extension.music.patches.misc;
import app.revanced.extension.music.patches.misc.client.AppClient.ClientType;
import app.revanced.extension.music.settings.Settings;
@SuppressWarnings("unused")
public class SpoofClientPatch {
private static final boolean SPOOF_CLIENT_ENABLED = Settings.SPOOF_CLIENT.get();
private static final ClientType clientType = ClientType.IOS_MUSIC;
/**
* Injection point.
*/
public static int getClientTypeId(int originalClientTypeId) {
if (SPOOF_CLIENT_ENABLED) {
return clientType.id;
}
return originalClientTypeId;
}
/**
* Injection point.
*/
public static String getClientVersion(String originalClientVersion) {
if (SPOOF_CLIENT_ENABLED) {
return clientType.clientVersion;
}
return originalClientVersion;
}
/**
* Injection point.
*/
public static String getClientModel(String originalClientModel) {
if (SPOOF_CLIENT_ENABLED) {
return clientType.deviceModel;
}
return originalClientModel;
}
/**
* Injection point.
*/
public static String getOsVersion(String originalOsVersion) {
if (SPOOF_CLIENT_ENABLED) {
return clientType.osVersion;
}
return originalOsVersion;
}
/**
* Injection point.
*/
public static String getUserAgent(String originalUserAgent) {
if (SPOOF_CLIENT_ENABLED) {
return clientType.userAgent;
}
return originalUserAgent;
}
/**
* Injection point.
*/
public static boolean isClientSpoofingEnabled() {
return SPOOF_CLIENT_ENABLED;
}
/**
* Injection point.
* When spoofing the client to iOS, the playback speed menu is missing from the player response.
* Return true to force create the playback speed menu.
*/
public static boolean forceCreatePlaybackSpeedMenu(boolean original) {
if (SPOOF_CLIENT_ENABLED) {
return true;
}
return original;
}
}

View File

@ -0,0 +1,122 @@
package app.revanced.extension.music.patches.misc.client;
import android.os.Build;
import androidx.annotation.Nullable;
public class AppClient {
/**
* The hardcoded client version of the iOS app used for InnerTube requests with this client.
*
* <p>
* It can be extracted by getting the latest release version of the app on
* <a href="https://apps.apple.com/us/app/music-watch-listen-stream/id544007664/">the App
* Store page of the YouTube app</a>, in the {@code Whats New} section.
* </p>
*/
private static final String CLIENT_VERSION_IOS = "6.21";
private static final String DEVICE_MAKE_IOS = "Apple";
/**
* See <a href="https://gist.github.com/adamawolf/3048717">this GitHub Gist</a> for more
* information.
* </p>
*/
private static final String DEVICE_MODEL_IOS = "iPhone16,2";
private static final String OS_NAME_IOS = "iOS";
private static final String OS_VERSION_IOS = "17.7.2.21H221";
private static final String USER_AGENT_VERSION_IOS = "17_7_2";
private static final String USER_AGENT_IOS = "com.google.ios.youtubemusic/" +
CLIENT_VERSION_IOS +
"(" +
DEVICE_MODEL_IOS +
"; U; CPU iOS " +
USER_AGENT_VERSION_IOS +
" like Mac OS X)";
private AppClient() {
}
public enum ClientType {
IOS_MUSIC(26,
DEVICE_MAKE_IOS,
DEVICE_MODEL_IOS,
CLIENT_VERSION_IOS,
OS_NAME_IOS,
OS_VERSION_IOS,
null,
USER_AGENT_IOS,
true
);
/**
* YouTube
* <a href="https://github.com/zerodytrash/YouTube-Internal-Clients?tab=readme-ov-file#clients">client type</a>
*/
public final int id;
/**
* Device manufacturer.
*/
@Nullable
public final String deviceMake;
/**
* Device model, equivalent to {@link Build#MODEL} (System property: ro.product.model)
*/
public final String deviceModel;
/**
* Device OS name.
*/
@Nullable
public final String osName;
/**
* Device OS version.
*/
public final String osVersion;
/**
* Player user-agent.
*/
public final String userAgent;
/**
* Android SDK version, equivalent to {@link Build.VERSION#SDK} (System property: ro.build.version.sdk)
* Field is null if not applicable.
*/
public final Integer androidSdkVersion;
/**
* App version.
*/
public final String clientVersion;
/**
* If the client can access the API logged in.
*/
public final boolean canLogin;
ClientType(int id,
@Nullable String deviceMake,
String deviceModel,
String clientVersion,
@Nullable String osName,
String osVersion,
Integer androidSdkVersion,
String userAgent,
boolean canLogin
) {
this.id = id;
this.deviceMake = deviceMake;
this.deviceModel = deviceModel;
this.clientVersion = clientVersion;
this.osName = osName;
this.osVersion = osVersion;
this.androidSdkVersion = androidSdkVersion;
this.userAgent = userAgent;
this.canLogin = canLogin;
}
}
}

View File

@ -172,6 +172,7 @@ public class Settings extends BaseSettings {
public static final BooleanSetting CHANGE_SHARE_SHEET = new BooleanSetting("revanced_change_share_sheet", FALSE, true);
public static final BooleanSetting DISABLE_CAIRO_SPLASH_ANIMATION = new BooleanSetting("revanced_disable_cairo_splash_animation", FALSE, true);
public static final BooleanSetting ENABLE_OPUS_CODEC = new BooleanSetting("revanced_enable_opus_codec", FALSE, true);
public static final BooleanSetting SPOOF_CLIENT = new BooleanSetting("revanced_spoof_client", FALSE, true);
public static final BooleanSetting SETTINGS_IMPORT_EXPORT = new BooleanSetting("revanced_extended_settings_import_export", FALSE, false);

View File

@ -157,7 +157,7 @@ public class AppClient {
* Device manufacturer.
*/
@Nullable
public final String make;
public final String deviceMake;
/**
* Device model, equivalent to {@link Build#MODEL} (System property: ro.product.model)
@ -197,7 +197,7 @@ public class AppClient {
public final boolean canLogin;
ClientType(int id,
@Nullable String make,
@Nullable String deviceMake,
String deviceModel,
String clientVersion,
@Nullable String osName,
@ -208,7 +208,7 @@ public class AppClient {
) {
this.friendlyName = str("revanced_spoof_streaming_data_type_entry_" + name().toLowerCase());
this.id = id;
this.make = make;
this.deviceMake = deviceMake;
this.deviceModel = deviceModel;
this.clientVersion = clientVersion;
this.osName = osName;

View File

@ -56,8 +56,8 @@ public final class PlayerRoutes {
client.put("clientVersion", clientType.clientVersion);
client.put("deviceModel", clientType.deviceModel);
client.put("osVersion", clientType.osVersion);
if (clientType.make != null) {
client.put("deviceMake", clientType.make);
if (clientType.deviceMake != null) {
client.put("deviceMake", clientType.deviceMake);
}
if (clientType.osName != null) {
client.put("osName", clientType.osName);

View File

@ -14,6 +14,14 @@ internal val pendingIntentReceiverFingerprint = legacyFingerprint(
}
)
internal val playbackSpeedBottomSheetFingerprint = legacyFingerprint(
name = "playbackSpeedBottomSheetFingerprint",
returnType = "V",
accessFlags = AccessFlags.PUBLIC or AccessFlags.FINAL,
parameters = listOf("L"),
strings = listOf("PLAYBACK_RATE_MENU_BOTTOM_SHEET_FRAGMENT")
)
internal val playbackSpeedFingerprint = legacyFingerprint(
name = "playbackSpeedFingerprint",
returnType = "V",

View File

@ -0,0 +1,66 @@
package app.revanced.patches.music.utils.fix.client
import app.revanced.patches.shared.spoof.useragent.baseSpoofUserAgentPatch
import app.revanced.util.fingerprint.legacyFingerprint
import app.revanced.util.getReference
import app.revanced.util.indexOfFirstInstruction
import app.revanced.util.or
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 val createPlayerRequestBodyFingerprint = legacyFingerprint(
name = "createPlayerRequestBodyFingerprint",
returnType = "V",
parameters = listOf("L"),
opcodes = listOf(
Opcode.CHECK_CAST,
Opcode.IGET,
Opcode.AND_INT_LIT16,
),
strings = listOf("ms"),
)
internal val createPlayerRequestBodyWithVersionReleaseFingerprint = legacyFingerprint(
name = "createPlayerRequestBodyWithVersionReleaseFingerprint",
returnType = "V",
accessFlags = AccessFlags.PUBLIC or AccessFlags.FINAL,
parameters = listOf("L"),
strings = listOf("Google Inc."),
customFingerprint = { method, _ ->
indexOfBuildInstruction(method) >= 0
},
)
fun indexOfBuildInstruction(method: Method) =
method.indexOfFirstInstruction {
val reference = getReference<MethodReference>()
opcode == Opcode.INVOKE_VIRTUAL &&
reference?.name == "build" &&
reference.parameterTypes.isEmpty() &&
reference.returnType.startsWith("L")
}
internal val setPlayerRequestClientTypeFingerprint = legacyFingerprint(
name = "setPlayerRequestClientTypeFingerprint",
opcodes = listOf(
Opcode.IGET,
Opcode.IPUT, // Sets ClientInfo.clientId.
),
strings = listOf("10.29"),
)
/**
* This is the fingerprint used in the 'client-spoof' patch around 2022.
* (Integrated into [baseSpoofUserAgentPatch] now.)
*
* This method is modified by [baseSpoofUserAgentPatch], so the fingerprint does not check the [Opcode].
*/
internal val userAgentHeaderBuilderFingerprint = legacyFingerprint(
name = "userAgentHeaderBuilderFingerprint",
returnType = "Ljava/lang/String;",
accessFlags = AccessFlags.PUBLIC or AccessFlags.STATIC,
parameters = listOf("Landroid/content/Context;"),
strings = listOf("(Linux; U; Android "),
)

View File

@ -0,0 +1,255 @@
package app.revanced.patches.music.utils.fix.client
import app.revanced.patcher.extensions.InstructionExtensions.addInstruction
import app.revanced.patcher.extensions.InstructionExtensions.addInstructions
import app.revanced.patcher.extensions.InstructionExtensions.getInstruction
import app.revanced.patcher.extensions.InstructionExtensions.instructions
import app.revanced.patcher.patch.PatchException
import app.revanced.patcher.patch.bytecodePatch
import app.revanced.patcher.util.proxy.mutableTypes.MutableMethod.Companion.toMutable
import app.revanced.patches.music.utils.compatibility.Constants.COMPATIBLE_PACKAGE
import app.revanced.patches.music.utils.extension.Constants.MISC_PATH
import app.revanced.patches.music.utils.patch.PatchList.SPOOF_CLIENT
import app.revanced.patches.music.utils.playbackSpeedBottomSheetFingerprint
import app.revanced.patches.music.utils.settings.CategoryType
import app.revanced.patches.music.utils.settings.ResourceUtils.updatePatchStatus
import app.revanced.patches.music.utils.settings.addSwitchPreference
import app.revanced.patches.music.utils.settings.settingsPatch
import app.revanced.patches.shared.createPlayerRequestBodyWithModelFingerprint
import app.revanced.patches.shared.indexOfModelInstruction
import app.revanced.util.fingerprint.matchOrThrow
import app.revanced.util.fingerprint.methodOrThrow
import app.revanced.util.fingerprint.mutableClassOrThrow
import app.revanced.util.getReference
import app.revanced.util.getWalkerMethod
import app.revanced.util.indexOfFirstInstructionOrThrow
import app.revanced.util.indexOfFirstInstructionReversedOrThrow
import app.revanced.util.or
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.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.iface.reference.MethodReference
import com.android.tools.smali.dexlib2.iface.reference.TypeReference
import com.android.tools.smali.dexlib2.immutable.ImmutableMethod
import com.android.tools.smali.dexlib2.immutable.ImmutableMethodParameter
private const val EXTENSION_CLASS_DESCRIPTOR =
"$MISC_PATH/SpoofClientPatch;"
private const val CLIENT_INFO_CLASS_DESCRIPTOR =
"Lcom/google/protos/youtube/api/innertube/InnertubeContext\$ClientInfo;"
@Suppress("unused")
val spoofClientPatch = bytecodePatch(
SPOOF_CLIENT.title,
SPOOF_CLIENT.summary,
) {
dependsOn(settingsPatch)
compatibleWith(COMPATIBLE_PACKAGE)
execute {
// region Get field references to be used below.
val (clientInfoField, clientInfoClientTypeField, clientInfoClientVersionField) =
setPlayerRequestClientTypeFingerprint.matchOrThrow().let { result ->
with(result.method) {
// Field in the player request object that holds the client info object.
val clientInfoField = instructions.find { instruction ->
// requestMessage.clientInfo = clientInfoBuilder.build();
instruction.opcode == Opcode.IPUT_OBJECT &&
instruction.getReference<FieldReference>()?.type == CLIENT_INFO_CLASS_DESCRIPTOR
}?.getReference<FieldReference>()
?: throw PatchException("Could not find clientInfoField")
// Client info object's client type field.
val clientInfoClientTypeField =
getInstruction(result.patternMatch!!.endIndex)
.getReference<FieldReference>()
?: throw PatchException("Could not find clientInfoClientTypeField")
val clientInfoVersionIndex = result.stringMatches!!.first().index
val clientInfoVersionRegister =
getInstruction<OneRegisterInstruction>(clientInfoVersionIndex).registerA
val clientInfoClientVersionFieldIndex = indexOfFirstInstructionOrThrow(clientInfoVersionIndex) {
opcode == Opcode.IPUT_OBJECT &&
(this as TwoRegisterInstruction).registerA == clientInfoVersionRegister
}
// Client info object's client version field.
val clientInfoClientVersionField =
getInstruction(clientInfoClientVersionFieldIndex)
.getReference<FieldReference>()
?: throw PatchException("Could not find clientInfoClientVersionField")
Triple(clientInfoField, clientInfoClientTypeField, clientInfoClientVersionField)
}
}
val clientInfoClientModelField = with (createPlayerRequestBodyWithModelFingerprint.methodOrThrow()) {
// The next IPUT_OBJECT instruction after getting the client model is setting the client model field.
val clientInfoClientModelIndex = indexOfFirstInstructionOrThrow(indexOfModelInstruction(this)) {
val reference = getReference<FieldReference>()
opcode == Opcode.IPUT_OBJECT &&
reference?.definingClass == CLIENT_INFO_CLASS_DESCRIPTOR &&
reference.type == "Ljava/lang/String;"
}
getInstruction<ReferenceInstruction>(clientInfoClientModelIndex).reference
}
val clientInfoOsVersionField = with (createPlayerRequestBodyWithVersionReleaseFingerprint.methodOrThrow()) {
val buildIndex = indexOfBuildInstruction(this)
val clientInfoOsVersionIndex = indexOfFirstInstructionOrThrow(buildIndex - 5) {
val reference = getReference<FieldReference>()
opcode == Opcode.IPUT_OBJECT &&
reference?.definingClass == CLIENT_INFO_CLASS_DESCRIPTOR &&
reference.type == "Ljava/lang/String;"
}
getInstruction<ReferenceInstruction>(clientInfoOsVersionIndex).reference
}
// endregion
// region Spoof client type for /player requests.
createPlayerRequestBodyFingerprint.matchOrThrow().let {
it.method.apply {
val setClientInfoMethodName = "setClientInfo"
val checkCastIndex = it.patternMatch!!.startIndex
val checkCastInstruction = getInstruction<OneRegisterInstruction>(checkCastIndex)
val requestMessageInstanceRegister = checkCastInstruction.registerA
val clientInfoContainerClassName =
checkCastInstruction.getReference<TypeReference>()!!.type
addInstruction(
checkCastIndex + 1,
"invoke-static { v$requestMessageInstanceRegister }," +
" $definingClass->$setClientInfoMethodName($clientInfoContainerClassName)V",
)
// Change client info to use the spoofed values.
// Do this in a helper method, to remove the need of picking out multiple free registers from the hooked code.
it.classDef.methods.add(
ImmutableMethod(
definingClass,
setClientInfoMethodName,
listOf(
ImmutableMethodParameter(
clientInfoContainerClassName,
annotations,
"clientInfoContainer"
)
),
"V",
AccessFlags.PRIVATE or AccessFlags.STATIC,
annotations,
null,
MutableMethodImplementation(3),
).toMutable().apply {
addInstructions(
"""
invoke-static { }, $EXTENSION_CLASS_DESCRIPTOR->isClientSpoofingEnabled()Z
move-result v0
if-eqz v0, :disabled
iget-object v0, p0, $clientInfoField
# Set client type to the spoofed value.
iget v1, v0, $clientInfoClientTypeField
invoke-static { v1 }, $EXTENSION_CLASS_DESCRIPTOR->getClientTypeId(I)I
move-result v1
iput v1, v0, $clientInfoClientTypeField
# Set client model to the spoofed value.
iget-object v1, v0, $clientInfoClientModelField
invoke-static { v1 }, $EXTENSION_CLASS_DESCRIPTOR->getClientModel(Ljava/lang/String;)Ljava/lang/String;
move-result-object v1
iput-object v1, v0, $clientInfoClientModelField
# Set client version to the spoofed value.
iget-object v1, v0, $clientInfoClientVersionField
invoke-static { v1 }, $EXTENSION_CLASS_DESCRIPTOR->getClientVersion(Ljava/lang/String;)Ljava/lang/String;
move-result-object v1
iput-object v1, v0, $clientInfoClientVersionField
# Set client os version to the spoofed value.
iget-object v1, v0, $clientInfoOsVersionField
invoke-static { v1 }, $EXTENSION_CLASS_DESCRIPTOR->getOsVersion(Ljava/lang/String;)Ljava/lang/String;
move-result-object v1
iput-object v1, v0, $clientInfoOsVersionField
:disabled
return-void
""",
)
},
)
}
}
// endregion
// region Spoof user-agent
userAgentHeaderBuilderFingerprint.methodOrThrow().apply {
val insertIndex = implementation!!.instructions.lastIndex
val insertRegister = getInstruction<OneRegisterInstruction>(insertIndex).registerA
addInstructions(
insertIndex, """
invoke-static { v$insertRegister }, $EXTENSION_CLASS_DESCRIPTOR->getUserAgent(Ljava/lang/String;)Ljava/lang/String;
move-result-object v$insertRegister
"""
)
}
// endregion
playbackSpeedBottomSheetFingerprint.mutableClassOrThrow().let {
val onItemClickMethod =
it.methods.find { method -> method.name == "onItemClick" }
?: throw PatchException("Failed to find onItemClick method")
onItemClickMethod.apply {
val createPlaybackSpeedMenuItemIndex = indexOfFirstInstructionReversedOrThrow {
val reference = getReference<MethodReference>()
opcode == Opcode.INVOKE_VIRTUAL &&
reference?.returnType == "V" &&
reference.parameterTypes.firstOrNull()?.startsWith("[L") == true
}
val createPlaybackSpeedMenuItemMethod = getWalkerMethod(createPlaybackSpeedMenuItemIndex)
createPlaybackSpeedMenuItemMethod.apply {
val shouldCreateMenuIndex = indexOfFirstInstructionOrThrow {
val reference = getReference<MethodReference>()
opcode == Opcode.INVOKE_VIRTUAL &&
reference?.returnType == "Z" &&
reference.parameterTypes.isEmpty()
} + 2
val shouldCreateMenuRegister = getInstruction<OneRegisterInstruction>(shouldCreateMenuIndex - 1).registerA
addInstructions(
shouldCreateMenuIndex,
"""
invoke-static { v$shouldCreateMenuRegister }, $EXTENSION_CLASS_DESCRIPTOR->forceCreatePlaybackSpeedMenu(Z)Z
move-result v$shouldCreateMenuRegister
""",
)
}
}
}
addSwitchPreference(
CategoryType.MISC,
"revanced_spoof_client",
"false"
)
updatePatchStatus(SPOOF_CLIENT)
}
}

View File

@ -141,6 +141,10 @@ internal enum class PatchList(
"Spoof app version",
"Adds options to spoof the YouTube Music client version. This can remove the radio mode restriction in Canadian regions or disable real-time lyrics."
),
SPOOF_CLIENT(
"Spoof client",
"Adds options to spoof the client to allow track playback."
),
TRANSLATIONS_FOR_YOUTUBE_MUSIC(
"Translations for YouTube Music",
"Add translations or remove string resources."

View File

@ -1,18 +1,8 @@
package app.revanced.patches.music.video.playback
import app.revanced.util.fingerprint.legacyFingerprint
import app.revanced.util.or
import com.android.tools.smali.dexlib2.AccessFlags
import com.android.tools.smali.dexlib2.Opcode
internal val playbackSpeedBottomSheetFingerprint = legacyFingerprint(
name = "playbackSpeedBottomSheetFingerprint",
returnType = "V",
accessFlags = AccessFlags.PUBLIC or AccessFlags.FINAL,
parameters = listOf("L"),
strings = listOf("PLAYBACK_RATE_MENU_BOTTOM_SHEET_FRAGMENT")
)
internal val userQualityChangeFingerprint = legacyFingerprint(
name = "userQualityChangeFingerprint",
returnType = "V",

View File

@ -8,6 +8,7 @@ import app.revanced.patcher.patch.bytecodePatch
import app.revanced.patches.music.utils.compatibility.Constants.COMPATIBLE_PACKAGE
import app.revanced.patches.music.utils.extension.Constants.VIDEO_PATH
import app.revanced.patches.music.utils.patch.PatchList.VIDEO_PLAYBACK
import app.revanced.patches.music.utils.playbackSpeedBottomSheetFingerprint
import app.revanced.patches.music.utils.playbackSpeedFingerprint
import app.revanced.patches.music.utils.playbackSpeedParentFingerprint
import app.revanced.patches.music.utils.settings.CategoryType
@ -54,8 +55,9 @@ val videoPlaybackPatch = bytecodePatch(
playbackSpeedBottomSheetFingerprint.mutableClassOrThrow().let {
val onItemClickMethod =
it.methods.find { method -> method.name == "onItemClick" }
?: throw PatchException("Failed to find onItemClick method")
onItemClickMethod?.apply {
onItemClickMethod.apply {
val targetIndex = indexOfFirstInstructionOrThrow(Opcode.IGET)
val targetRegister =
getInstruction<TwoRegisterInstruction>(targetIndex).registerA
@ -64,7 +66,7 @@ val videoPlaybackPatch = bytecodePatch(
targetIndex + 1,
"invoke-static {v$targetRegister}, $EXTENSION_PLAYBACK_SPEED_CLASS_DESCRIPTOR->userSelectedPlaybackSpeed(F)V"
)
} ?: throw PatchException("Failed to find onItemClick method")
}
}
playbackSpeedFingerprint.matchOrThrow(playbackSpeedParentFingerprint).let {

View File

@ -442,6 +442,14 @@ This is required for the app to work."</string>
Tap on the continue button and disable battery optimizations."</string>
<string name="gms_core_dialog_continue_text">Continue</string>
<string name="revanced_spoof_client_title">Spoof client</string>
<string name="revanced_spoof_client_summary">"Spoof the client to prevent playback issues.
Limitations:
• OPUS audio codec may not be supported.
• Seekbar thumbnail may not be present.
• Watch history does not work with a brand account.</string>
<string name="revanced_sanitize_sharing_links_title">Sanitize sharing links</string>
<string name="revanced_sanitize_sharing_links_summary">Removes tracking query parameters from URLs when sharing links.</string>