diff --git a/extensions/shared/src/main/java/app/revanced/extension/shared/patches/spoof/SpoofStreamingDataPatch.java b/extensions/shared/src/main/java/app/revanced/extension/shared/patches/spoof/SpoofStreamingDataPatch.java index a5e25ba9a..2c5a7b71f 100644 --- a/extensions/shared/src/main/java/app/revanced/extension/shared/patches/spoof/SpoofStreamingDataPatch.java +++ b/extensions/shared/src/main/java/app/revanced/extension/shared/patches/spoof/SpoofStreamingDataPatch.java @@ -9,9 +9,10 @@ import com.google.protos.youtube.api.innertube.StreamingDataOuterClass$Streaming import java.lang.reflect.Field; import java.nio.ByteBuffer; +import java.util.Collections; +import java.util.LinkedHashMap; import java.util.List; import java.util.Map; -import java.util.concurrent.ConcurrentHashMap; import app.revanced.extension.shared.patches.BlockRequestPatch; import app.revanced.extension.shared.patches.spoof.requests.StreamingDataRequest; @@ -29,10 +30,18 @@ public class SpoofStreamingDataPatch extends BlockRequestPatch { SPOOF_STREAMING_DATA && BaseSettings.SPOOF_STREAMING_DATA_SYNC_VIDEO_LENGTH.get(); /** - * Key: videoId. - * Value: original [streamingData.formats]. + * Key: video id + * Value: original video length [streamingData.formats.approxDurationMs] */ - private static final ConcurrentHashMap> formatsMap = new ConcurrentHashMap<>(20, 0.8f); + private static final Map approxDurationMsMap = Collections.synchronizedMap( + new LinkedHashMap<>(100) { + private static final int CACHE_LIMIT = 50; + + @Override + protected boolean removeEldestEntry(Entry eldest) { + return size() > CACHE_LIMIT; // Evict the oldest entry if over the cache limit. + } + }); /** * Injection point. @@ -120,34 +129,26 @@ public class SpoofStreamingDataPatch extends BlockRequestPatch { * Injection point. *

* If spoofed [streamingData.formats] is empty, - * Put the original [streamingData.formats] into the HashMap. + * Put the original [streamingData.formats.approxDurationMs] into the HashMap. *

* Called after {@link #getStreamingData(String)}. */ - public static void setFormats(String videoId, StreamingDataOuterClass$StreamingData originalStreamingData, StreamingDataOuterClass$StreamingData spoofed) { - if (SPOOF_STREAMING_DATA_SYNC_VIDEO_LENGTH && formatsIsEmpty(spoofed)) { - formatsMap.put(videoId, getFormatsFromStreamingData(originalStreamingData)); - Logger.printDebug(() -> "New formats video id: " + videoId); - } - } - - private static boolean formatsIsEmpty(StreamingDataOuterClass$StreamingData streamingData) { - List formats = getFormatsFromStreamingData(streamingData); - return formats == null || formats.size() == 0; - } - - private static List getFormatsFromStreamingData(StreamingDataOuterClass$StreamingData streamingData) { - try { - // Field e: 'formats'. - Field field = streamingData.getClass().getDeclaredField("e"); - field.setAccessible(true); - if (field.get(streamingData) instanceof List list) { - return list; + public static void setApproxDurationMs(String videoId, String approxDurationMsFieldName, + StreamingDataOuterClass$StreamingData originalStreamingData, StreamingDataOuterClass$StreamingData spoofedStreamingData) { + if (SPOOF_STREAMING_DATA_SYNC_VIDEO_LENGTH) { + if (formatsIsEmpty(spoofedStreamingData)) { + List originalFormats = getFormatsFromStreamingData(originalStreamingData); + Long approxDurationMs = getApproxDurationMs(originalFormats, approxDurationMsFieldName); + if (approxDurationMs != null) { + approxDurationMsMap.put(videoId, approxDurationMs); + Logger.printDebug(() -> "New approxDurationMs loaded, video id: " + videoId + ", video length: " + approxDurationMs); + } else { + Logger.printDebug(() -> "Ignoring as original approxDurationMs is not found, video id: " + videoId); + } + } else { + Logger.printDebug(() -> "Ignoring as spoofed formats is not empty, video id: " + videoId); } - } catch (NoSuchFieldException | IllegalAccessException ex) { - Logger.printException(() -> "Reflection error accessing formats", ex); } - return null; } /** @@ -170,21 +171,21 @@ public class SpoofStreamingDataPatch extends BlockRequestPatch { *

* Called after {@link #getStreamingData(String)}. */ - public static List getOriginalFormats(String videoId, List spoofedFormats) { + public static long getApproxDurationMsFromOriginalResponse(String videoId, long lengthMilliseconds) { if (SPOOF_STREAMING_DATA_SYNC_VIDEO_LENGTH) { try { - if (videoId != null && !videoId.equals(MASKED_VIDEO_ID) && spoofedFormats.size() == 0) { - List androidFormats = formatsMap.get(videoId); - if (androidFormats != null) { - Logger.printDebug(() -> "Overriding iOS formats to original formats: " + videoId); - return androidFormats; + if (videoId != null && !videoId.equals(MASKED_VIDEO_ID)) { + Long approxDurationMs = approxDurationMsMap.get(videoId); + if (approxDurationMs != null) { + Logger.printDebug(() -> "Replacing video length from " + lengthMilliseconds + " to " + approxDurationMs + " , videoId: " + videoId); + return approxDurationMs; } } } catch (Exception ex) { Logger.printException(() -> "getOriginalFormats failure", ex); } } - return spoofedFormats; + return lengthMilliseconds; } /** @@ -226,4 +227,47 @@ public class SpoofStreamingDataPatch extends BlockRequestPatch { return videoFormat; } + + // Utils + + private static boolean formatsIsEmpty(StreamingDataOuterClass$StreamingData streamingData) { + List formats = getFormatsFromStreamingData(streamingData); + return formats == null || formats.size() == 0; + } + + private static List getFormatsFromStreamingData(StreamingDataOuterClass$StreamingData streamingData) { + try { + // Field e: 'formats'. + // Field name is always 'e', regardless of the client version. + Field field = streamingData.getClass().getDeclaredField("e"); + field.setAccessible(true); + if (field.get(streamingData) instanceof List list) { + return list; + } + } catch (NoSuchFieldException | IllegalAccessException ex) { + Logger.printException(() -> "Reflection error accessing formats", ex); + } + return null; + } + + private static Long getApproxDurationMs(List list, String approxDurationMsFieldName) { + try { + if (list != null) { + var iterator = list.listIterator(); + if (iterator.hasNext()) { + var formats = iterator.next(); + Field field = formats.getClass().getDeclaredField(approxDurationMsFieldName); + field.setAccessible(true); + if (field.get(formats) instanceof Long approxDurationMs) { + return approxDurationMs; + } else { + Logger.printDebug(() -> "Field type is null: " + approxDurationMsFieldName); + } + } + } + } catch (NoSuchFieldException | IllegalAccessException ex) { + Logger.printException(() -> "Reflection error accessing field: " + approxDurationMsFieldName, ex); + } + return null; + } } diff --git a/extensions/shared/src/main/java/app/revanced/extension/shared/patches/spoof/requests/StreamingDataRequest.java b/extensions/shared/src/main/java/app/revanced/extension/shared/patches/spoof/requests/StreamingDataRequest.java index 01a059940..5637f392b 100644 --- a/extensions/shared/src/main/java/app/revanced/extension/shared/patches/spoof/requests/StreamingDataRequest.java +++ b/extensions/shared/src/main/java/app/revanced/extension/shared/patches/spoof/requests/StreamingDataRequest.java @@ -2,8 +2,6 @@ package app.revanced.extension.shared.patches.spoof.requests; import static app.revanced.extension.shared.patches.spoof.requests.PlayerRoutes.GET_STREAMING_DATA; -import android.util.Pair; - import androidx.annotation.NonNull; import androidx.annotation.Nullable; diff --git a/patches/src/main/kotlin/app/revanced/patches/music/misc/drc/DrcAudioPatch.kt b/patches/src/main/kotlin/app/revanced/patches/music/misc/drc/DrcAudioPatch.kt index 695afc9b2..af9a47dd0 100644 --- a/patches/src/main/kotlin/app/revanced/patches/music/misc/drc/DrcAudioPatch.kt +++ b/patches/src/main/kotlin/app/revanced/patches/music/misc/drc/DrcAudioPatch.kt @@ -12,6 +12,7 @@ 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.formatStreamModelConstructorFingerprint import app.revanced.util.fingerprint.matchOrThrow import com.android.tools.smali.dexlib2.iface.instruction.TwoRegisterInstruction diff --git a/patches/src/main/kotlin/app/revanced/patches/music/misc/drc/Fingerprints.kt b/patches/src/main/kotlin/app/revanced/patches/music/misc/drc/Fingerprints.kt index 27aa6fc0b..15842faf9 100644 --- a/patches/src/main/kotlin/app/revanced/patches/music/misc/drc/Fingerprints.kt +++ b/patches/src/main/kotlin/app/revanced/patches/music/misc/drc/Fingerprints.kt @@ -5,17 +5,6 @@ import app.revanced.util.or import com.android.tools.smali.dexlib2.AccessFlags import com.android.tools.smali.dexlib2.Opcode -/** - * On YouTube, this class is 'Lcom/google/android/libraries/youtube/innertube/model/media/FormatStreamModel;' - * On YouTube Music, class names are obfuscated. - */ -internal val formatStreamModelConstructorFingerprint = legacyFingerprint( - name = "formatStreamModelConstructorFingerprint", - returnType = "V", - accessFlags = AccessFlags.PUBLIC or AccessFlags.CONSTRUCTOR, - literals = listOf(45374643L), -) - /** * YouTube Music 7.13.52 ~ */ diff --git a/patches/src/main/kotlin/app/revanced/patches/shared/Fingerprints.kt b/patches/src/main/kotlin/app/revanced/patches/shared/Fingerprints.kt index d3735393c..699e4fb0e 100644 --- a/patches/src/main/kotlin/app/revanced/patches/shared/Fingerprints.kt +++ b/patches/src/main/kotlin/app/revanced/patches/shared/Fingerprints.kt @@ -35,6 +35,21 @@ private fun Method.indexOfFieldReference(string: String) = indexOfFirstInstructi reference.toString() == string } +/** + * On YouTube, this class is 'Lcom/google/android/libraries/youtube/innertube/model/media/FormatStreamModel;' + * On YouTube Music, class names are obfuscated. + */ +internal val formatStreamModelConstructorFingerprint = legacyFingerprint( + name = "formatStreamModelConstructorFingerprint", + returnType = "V", + accessFlags = AccessFlags.PUBLIC or AccessFlags.CONSTRUCTOR, + opcodes = listOf( + Opcode.IGET_WIDE, + Opcode.IPUT_WIDE, + ), + literals = listOf(45374643L), +) + internal val mdxPlayerDirectorSetVideoStageFingerprint = legacyFingerprint( name = "mdxPlayerDirectorSetVideoStageFingerprint", strings = listOf("MdxDirector setVideoStage ad should be null when videoStage is not an Ad state ") diff --git a/patches/src/main/kotlin/app/revanced/patches/shared/spoof/streamingdata/BaseSpoofStreamingDataPatch.kt b/patches/src/main/kotlin/app/revanced/patches/shared/spoof/streamingdata/BaseSpoofStreamingDataPatch.kt index 4a4279854..35c06d749 100644 --- a/patches/src/main/kotlin/app/revanced/patches/shared/spoof/streamingdata/BaseSpoofStreamingDataPatch.kt +++ b/patches/src/main/kotlin/app/revanced/patches/shared/spoof/streamingdata/BaseSpoofStreamingDataPatch.kt @@ -12,13 +12,13 @@ import app.revanced.patcher.patch.bytecodePatch import app.revanced.patcher.util.proxy.mutableTypes.MutableMethod.Companion.toMutable import app.revanced.patches.shared.blockrequest.blockRequestPatch import app.revanced.patches.shared.extension.Constants.SPOOF_PATH +import app.revanced.patches.shared.formatStreamModelConstructorFingerprint import app.revanced.util.findInstructionIndicesReversedOrThrow import app.revanced.util.fingerprint.definingClassOrThrow import app.revanced.util.fingerprint.injectLiteralInstructionBooleanCall import app.revanced.util.fingerprint.matchOrThrow import app.revanced.util.fingerprint.methodOrThrow import app.revanced.util.getReference -import app.revanced.util.indexOfFirstInstructionOrThrow import app.revanced.util.indexOfFirstInstructionReversedOrThrow import com.android.tools.smali.dexlib2.AccessFlags import com.android.tools.smali.dexlib2.Opcode @@ -81,6 +81,13 @@ fun baseSpoofStreamingDataPatch( // region Replace the streaming data. + val approxDurationMsFieldName = formatStreamModelConstructorFingerprint.matchOrThrow().let { + with (it.method) { + val approxDurationMsFieldIndex = it.patternMatch!!.startIndex + (getInstruction(approxDurationMsFieldIndex).reference as FieldReference).name + } + } + createStreamingDataFingerprint.matchOrThrow(createStreamingDataParentFingerprint) .let { result -> result.method.apply { @@ -134,40 +141,42 @@ fun baseSpoofStreamingDataPatch( 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;)Ljava/nio/ByteBuffer; - move-result-object v3 - if-eqz v3, :disabled - - # Parse streaming data. - sget-object v4, $playerProtoClass->a:$playerProtoClass - invoke-static { v4, v3 }, $protobufClass->parseFrom(${protobufClass}Ljava/nio/ByteBuffer;)$protobufClass - move-result-object v5 - check-cast v5, $playerProtoClass - - iget-object v6, v5, $getStreamingDataField - if-eqz v6, :disabled - - # Get original streaming data. - iget-object v0, p0, $setStreamingDataField - - # Set spoofed streaming data. - iput-object v6, p0, $setStreamingDataField - - # Set original streaming data formats. - invoke-static { v2, v0, v6 }, $EXTENSION_CLASS_DESCRIPTOR->setFormats(Ljava/lang/String;$STREAMING_DATA_INTERFACE$STREAMING_DATA_INTERFACE)V - - :disabled - return-void - """, + 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;)Ljava/nio/ByteBuffer; + move-result-object v3 + + if-eqz v3, :disabled + + # Parse streaming data. + sget-object v4, $playerProtoClass->a:$playerProtoClass + invoke-static { v4, v3 }, $protobufClass->parseFrom(${protobufClass}Ljava/nio/ByteBuffer;)$protobufClass + move-result-object v5 + check-cast v5, $playerProtoClass + + iget-object v6, v5, $getStreamingDataField + if-eqz v6, :disabled + + # Get original streaming data. + iget-object v0, p0, $setStreamingDataField + + # Set spoofed streaming data. + iput-object v6, p0, $setStreamingDataField + + # Get video length from original streaming data and save to extension. + const-string v5, "$approxDurationMsFieldName" + invoke-static { v2, v5, v0, v6 }, $EXTENSION_CLASS_DESCRIPTOR->setApproxDurationMs(Ljava/lang/String;Ljava/lang/String;$STREAMING_DATA_INTERFACE$STREAMING_DATA_INTERFACE)V + + :disabled + return-void + """, ) }, ) @@ -188,24 +197,21 @@ fun baseSpoofStreamingDataPatch( getInstruction(videoIdIndex).registerB val videoIdReference = getInstruction(videoIdIndex).reference - val formatsIndex = indexOfFirstInstructionReversedOrThrow(videoIdIndex) { - opcode == Opcode.IGET_OBJECT && - getReference()?.definingClass == STREAMING_DATA_INTERFACE - } - val freeRegister = getInstruction( - indexOfFirstInstructionOrThrow(formatsIndex, Opcode.CONST_WIDE) - ).registerA - val audioCodecListRegister = getInstruction(formatsIndex).registerA + val toMillisIndex = indexOfToMillisInstruction(this) + val freeRegister = + getInstruction(toMillisIndex).registerC + val lengthMillisecondsRegister = + getInstruction(toMillisIndex + 1).registerA addInstructions( - formatsIndex + 1, """ + toMillisIndex + 2, """ # Get video id. iget-object v$freeRegister, v$definingClassRegister, $videoIdReference # Override streaming data formats. - invoke-static { v$freeRegister, v$audioCodecListRegister }, $EXTENSION_CLASS_DESCRIPTOR->getOriginalFormats(Ljava/lang/String;Ljava/util/List;)Ljava/util/List; - move-result-object v$audioCodecListRegister + invoke-static { v$freeRegister, v$lengthMillisecondsRegister, v${lengthMillisecondsRegister + 1} }, $EXTENSION_CLASS_DESCRIPTOR->getApproxDurationMsFromOriginalResponse(Ljava/lang/String;J)J + move-result-wide v$lengthMillisecondsRegister """ ) } diff --git a/patches/src/main/kotlin/app/revanced/patches/shared/spoof/streamingdata/Fingerprints.kt b/patches/src/main/kotlin/app/revanced/patches/shared/spoof/streamingdata/Fingerprints.kt index c685b9d35..6b8f1937e 100644 --- a/patches/src/main/kotlin/app/revanced/patches/shared/spoof/streamingdata/Fingerprints.kt +++ b/patches/src/main/kotlin/app/revanced/patches/shared/spoof/streamingdata/Fingerprints.kt @@ -108,7 +108,8 @@ internal val videoStreamingDataConstructorFingerprint = legacyFingerprint( accessFlags = AccessFlags.PUBLIC or AccessFlags.CONSTRUCTOR, returnType = "V", customFingerprint = { method, _ -> - indexOfFormatStreamModelInitInstruction(method) >= 0 + indexOfFormatStreamModelInitInstruction(method) >= 0 && + indexOfToMillisInstruction(method) >= 0 }, ) @@ -120,6 +121,17 @@ internal fun indexOfFormatStreamModelInitInstruction(method: Method) = reference.parameterTypes.size > 1 } +internal fun indexOfToMillisInstruction(method: Method) = + method.indexOfFirstInstruction { + val reference = getReference() + opcode == Opcode.INVOKE_VIRTUAL && + reference?.name == "toMillis" + } + +/** + * On YouTube, this class is 'Lcom/google/android/libraries/youtube/innertube/model/media/VideoStreamingData;' + * On YouTube Music, class names are obfuscated. + */ internal val videoStreamingDataToStringFingerprint = legacyFingerprint( name = "videoStreamingDataToStringFingerprint", returnType = "Ljava/lang/String;", @@ -135,7 +147,6 @@ internal const val HLS_CURRENT_TIME_FEATURE_FLAG = 45355374L internal val hlsCurrentTimeFingerprint = legacyFingerprint( name = "hlsCurrentTimeFingerprint", - accessFlags = AccessFlags.PUBLIC or AccessFlags.FINAL, parameters = listOf("Z", "L"), literals = listOf(HLS_CURRENT_TIME_FEATURE_FLAG), )