refactor(Spoof streaming data): Improve hooking performance

This commit is contained in:
inotia00 2024-12-18 20:21:18 +09:00
parent 5ffbb4714a
commit 9ed8754bb7
3 changed files with 99 additions and 89 deletions

View File

@ -1,22 +1,19 @@
package app.revanced.extension.shared.patches.spoof;
import static app.revanced.extension.shared.utils.Utils.isSDKAbove;
import android.net.Uri;
import android.text.TextUtils;
import androidx.annotation.Nullable;
import com.google.android.libraries.youtube.innertube.model.media.FormatStreamModel;
import com.google.protos.youtube.api.innertube.StreamingDataOuterClass$StreamingData;
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.client.AppClient.ClientType;
import app.revanced.extension.shared.patches.spoof.requests.StreamingDataRequest;
import app.revanced.extension.shared.settings.BaseSettings;
import app.revanced.extension.shared.utils.Logger;
@ -27,17 +24,9 @@ public class SpoofStreamingDataPatch extends BlockRequestPatch {
/**
* Key: videoId.
* Value: Original StreamingData of Android client.
* Value: original [streamingData.formats].
*/
private static final Map<String, StreamingDataOuterClass$StreamingData> streamingDataMap = Collections.synchronizedMap(
new LinkedHashMap<>(10) {
private static final int CACHE_LIMIT = 5;
@Override
protected boolean removeEldestEntry(Entry eldest) {
return size() > CACHE_LIMIT; // Evict the oldest entry if over the cache limit.
}
});
private static final ConcurrentHashMap<String, List<?>> formatsMap = new ConcurrentHashMap<>(20, 0.8f);
/**
* Injection point.
@ -48,6 +37,7 @@ public class SpoofStreamingDataPatch extends BlockRequestPatch {
/**
* Injection point.
* This method is only invoked when playing a livestream on an iOS client.
*/
public static boolean fixHLSCurrentTime(boolean original) {
if (!SPOOF_STREAMING_DATA) {
@ -88,11 +78,9 @@ public class SpoofStreamingDataPatch extends BlockRequestPatch {
* Injection point.
* Fix playback by replace the streaming data.
* Called after {@link #fetchStreams(String, Map)}.
*
* @param originalStreamingData Original StreamingData.
*/
@Nullable
public static ByteBuffer getStreamingData(String videoId, StreamingDataOuterClass$StreamingData originalStreamingData) {
public static ByteBuffer getStreamingData(String videoId) {
if (SPOOF_STREAMING_DATA) {
try {
StreamingDataRequest request = StreamingDataRequest.getRequestForVideoId(videoId);
@ -108,25 +96,8 @@ public class SpoofStreamingDataPatch extends BlockRequestPatch {
var stream = request.getStream();
if (stream != null) {
ByteBuffer spoofedStreamingData = stream.first;
ClientType spoofedClientType = stream.second;
Logger.printDebug(() -> "Overriding video stream: " + videoId);
// Put the videoId and originalStreamingData into a HashMap.
if (spoofedClientType == ClientType.IOS) {
// For YT Music 6.20.51, which is supported by RVX, it can run on Android 5.0 (SDK 21).
// The IDE does not make any suggestions since the project's minSDK is 24, but you should check the SDK version for compatibility with SDK 21.
if (isSDKAbove(24)) {
streamingDataMap.putIfAbsent(videoId, originalStreamingData);
} else {
if (!streamingDataMap.containsKey(videoId)) {
streamingDataMap.put(videoId, originalStreamingData);
}
}
}
return spoofedStreamingData;
return stream;
}
}
@ -142,39 +113,77 @@ public class SpoofStreamingDataPatch extends BlockRequestPatch {
/**
* Injection point.
* <p>
* In iOS Clients, Progressive Streaming are not available, so 'formats' field have been removed
* completely from the initial response of streaming data.
* Therefore, {@link FormatStreamModel} class is never be initialized, and the video length field
* is set with an estimated value from `adaptiveFormats` instead.
* If spoofed [streamingData.formats] is empty,
* Put the original [streamingData.formats] into the HashMap.
* <p>
* To get workaround with this, replace streamingData (spoofedStreamingData) with originalStreamingData,
* which is only used to initialize the {@link FormatStreamModel} class to calculate the video length.
* The playback issues shouldn't occur since the integrity check is not applied for Progressive Stream.
* <p>
* Called after {@link #getStreamingData(String, StreamingDataOuterClass$StreamingData)}.
*
* @param spoofedStreamingData Spoofed StreamingData.
* Called after {@link #getStreamingData(String)}.
*/
public static StreamingDataOuterClass$StreamingData getOriginalStreamingData(String videoId, StreamingDataOuterClass$StreamingData spoofedStreamingData) {
public static void setFormats(String videoId, StreamingDataOuterClass$StreamingData originalStreamingData, StreamingDataOuterClass$StreamingData spoofed) {
if (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;
}
} catch (NoSuchFieldException | IllegalAccessException ex) {
Logger.printException(() -> "Reflection error accessing formats", ex);
}
return null;
}
/**
* Looks like the initial value for the videoId field.
*/
private static final String MASKED_VIDEO_ID = "zzzzzzzzzzz";
/**
* Injection point.
* <p>
* When measuring the length of a video in an Android YouTube client,
* the client first checks if the streaming data contains [streamingData.formats.approxDurationMs].
* <p>
* If the streaming data response contains [approxDurationMs] (Long type, actual value), this value will be the video length.
* <p>
* If [streamingData.formats] (List type) is empty, the [approxDurationMs] value cannot be accessed,
* So it falls back to the value of [videoDetails.lengthSeconds] (Integer type, approximate value) multiplied by 1000.
* <p>
* For iOS clients, [streamingData.formats] (List type) is always empty, so it always falls back to the approximate value.
* <p>
* Called after {@link #getStreamingData(String)}.
*/
public static List<?> getOriginalFormats(String videoId, List<?> spoofedFormats) {
if (SPOOF_STREAMING_DATA) {
try {
StreamingDataOuterClass$StreamingData androidStreamingData = streamingDataMap.get(videoId);
if (androidStreamingData != null) {
Logger.printDebug(() -> "Overriding iOS streaming data to original streaming data: " + videoId);
return androidStreamingData;
} else {
Logger.printDebug(() -> "Not overriding original streaming data as spoofed client is not iOS: " + videoId);
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;
}
}
} catch (Exception ex) {
Logger.printException(() -> "getOriginalStreamingData failure", ex);
Logger.printException(() -> "getOriginalFormats failure", ex);
}
}
return spoofedStreamingData;
return spoofedFormats;
}
/**
* Injection point.
* Called after {@link #getStreamingData(String, StreamingDataOuterClass$StreamingData)}.
* Called after {@link #getStreamingData(String)}.
*/
@Nullable
public static byte[] removeVideoPlaybackPostBody(Uri uri, int method, byte[] postData) {

View File

@ -97,7 +97,7 @@ public class StreamingDataRequest {
}
private final String videoId;
private final Future<Pair<ByteBuffer, ClientType>> future;
private final Future<ByteBuffer> future;
private StreamingDataRequest(String videoId, Map<String, String> playerHeaders) {
Objects.requireNonNull(playerHeaders);
@ -174,7 +174,7 @@ public class StreamingDataRequest {
return null;
}
private static Pair<ByteBuffer, ClientType> fetch(String videoId, Map<String, String> playerHeaders) {
private static ByteBuffer fetch(String videoId, Map<String, String> playerHeaders) {
lastSpoofedClientType = null;
// Retry with different client if empty response body is received.
@ -196,7 +196,7 @@ public class StreamingDataRequest {
}
lastSpoofedClientType = clientType;
return new Pair<>(ByteBuffer.wrap(baos.toByteArray()), clientType);
return ByteBuffer.wrap(baos.toByteArray());
}
}
} catch (IOException ex) {
@ -214,7 +214,7 @@ public class StreamingDataRequest {
}
@Nullable
public Pair<ByteBuffer, ClientType> getStream() {
public ByteBuffer getStream() {
try {
return future.get(MAX_MILLISECONDS_TO_WAIT_FOR_FETCH, TimeUnit.MILLISECONDS);
} catch (TimeoutException ex) {

View File

@ -5,7 +5,6 @@ import app.revanced.patcher.extensions.InstructionExtensions.addInstructions
import app.revanced.patcher.extensions.InstructionExtensions.addInstructionsWithLabels
import app.revanced.patcher.extensions.InstructionExtensions.getInstruction
import app.revanced.patcher.extensions.InstructionExtensions.instructions
import app.revanced.patcher.extensions.InstructionExtensions.replaceInstruction
import app.revanced.patcher.patch.BytecodePatchBuilder
import app.revanced.patcher.patch.BytecodePatchContext
import app.revanced.patcher.patch.PatchException
@ -19,6 +18,7 @@ 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
@ -143,8 +143,7 @@ fun baseSpoofStreamingDataPatch(
if-eqz v2, :disabled
# Get streaming data.
iget-object v6, p0, $setStreamingDataField
invoke-static { v2, v6 }, $EXTENSION_CLASS_DESCRIPTOR->getStreamingData(Ljava/lang/String;$STREAMING_DATA_INTERFACE)Ljava/nio/ByteBuffer;
invoke-static { v2 }, $EXTENSION_CLASS_DESCRIPTOR->getStreamingData(Ljava/lang/String;)Ljava/nio/ByteBuffer;
move-result-object v3
if-eqz v3, :disabled
@ -154,10 +153,17 @@ fun baseSpoofStreamingDataPatch(
move-result-object v5
check-cast v5, $playerProtoClass
# Set streaming data.
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
@ -171,41 +177,36 @@ fun baseSpoofStreamingDataPatch(
videoStreamingDataConstructorFingerprint.methodOrThrow(videoStreamingDataToStringFingerprint)
.apply {
val formatStreamModelInitIndex = indexOfFormatStreamModelInitInstruction(this)
val getVideoIdIndex =
val videoIdIndex =
indexOfFirstInstructionReversedOrThrow(formatStreamModelInitIndex) {
val reference = getReference<FieldReference>()
opcode == Opcode.IGET_OBJECT &&
reference?.type == "Ljava/lang/String;" &&
reference.definingClass == definingClass
}
val getVideoIdReference =
getInstruction<ReferenceInstruction>(getVideoIdIndex).reference
val insertIndex = indexOfFirstInstructionReversedOrThrow(getVideoIdIndex) {
val definingClassRegister =
getInstruction<TwoRegisterInstruction>(videoIdIndex).registerB
val videoIdReference =
getInstruction<ReferenceInstruction>(videoIdIndex).reference
val formatsIndex = indexOfFirstInstructionReversedOrThrow(videoIdIndex) {
opcode == Opcode.IGET_OBJECT &&
getReference<FieldReference>()?.definingClass == STREAMING_DATA_INTERFACE
}
val freeRegister = getInstruction<OneRegisterInstruction>(
indexOfFirstInstructionOrThrow(formatsIndex, Opcode.CONST_WIDE)
).registerA
val (freeRegister, streamingDataRegister) = with(
getInstruction<TwoRegisterInstruction>(
insertIndex
)
) {
Pair(registerA, registerB)
}
val definingClassRegister =
getInstruction<TwoRegisterInstruction>(getVideoIdIndex).registerB
val insertReference = getInstruction<ReferenceInstruction>(insertIndex).reference
val audioCodecListRegister = getInstruction<TwoRegisterInstruction>(formatsIndex).registerA
replaceInstruction(
insertIndex,
"iget-object v$freeRegister, v$freeRegister, $insertReference"
)
addInstructions(
insertIndex, """
iget-object v$freeRegister, v$definingClassRegister, $getVideoIdReference
invoke-static { v$freeRegister, v$streamingDataRegister }, $EXTENSION_CLASS_DESCRIPTOR->getOriginalStreamingData(Ljava/lang/String;$STREAMING_DATA_INTERFACE)$STREAMING_DATA_INTERFACE
move-result-object v$freeRegister
"""
formatsIndex + 1, """
# 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
"""
)
}