diff --git a/extensions/shared/src/main/java/app/revanced/extension/shared/patches/spoof/requests/PlayerRoutes.kt b/extensions/shared/src/main/java/app/revanced/extension/shared/patches/spoof/requests/PlayerRoutes.kt index d16448c59..71947a4d9 100644 --- a/extensions/shared/src/main/java/app/revanced/extension/shared/patches/spoof/requests/PlayerRoutes.kt +++ b/extensions/shared/src/main/java/app/revanced/extension/shared/patches/spoof/requests/PlayerRoutes.kt @@ -44,6 +44,14 @@ object PlayerRoutes { "&alt=proto" ).compile() + @JvmField + val GET_VIDEO_DETAILS: CompiledRoute = Route( + Route.Method.POST, + "player" + + "?prettyPrint=false" + + "&fields=videoDetails.channelId" + ).compile() + private const val YT_API_URL = "https://youtubei.googleapis.com/youtubei/v1/" /** diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/general/OpenChannelOfLiveAvatarPatch.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/general/OpenChannelOfLiveAvatarPatch.java new file mode 100644 index 000000000..2334d738f --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/general/OpenChannelOfLiveAvatarPatch.java @@ -0,0 +1,61 @@ +package app.revanced.extension.youtube.patches.general; + +import app.revanced.extension.shared.utils.Logger; +import app.revanced.extension.youtube.patches.general.requests.VideoDetailsRequest; +import app.revanced.extension.youtube.settings.Settings; +import app.revanced.extension.youtube.utils.VideoUtils; + +@SuppressWarnings("unused") +public final class OpenChannelOfLiveAvatarPatch { + private static final boolean OPEN_CHANNEL_OF_LIVE_AVATAR = + Settings.OPEN_CHANNEL_OF_LIVE_AVATAR.get(); + + private static volatile String videoId = ""; + private static volatile boolean liveChannelAvatarClicked = false; + + public static void liveChannelAvatarClicked() { + liveChannelAvatarClicked = true; + } + + public static boolean openChannelOfLiveAvatar() { + try { + if (!OPEN_CHANNEL_OF_LIVE_AVATAR) { + return false; + } + if (!liveChannelAvatarClicked) { + return false; + } + VideoDetailsRequest request = VideoDetailsRequest.getRequestForVideoId(videoId); + if (request != null) { + String channelId = request.getInfo(); + if (channelId != null) { + liveChannelAvatarClicked = false; + VideoUtils.openChannel(channelId); + return true; + } + } + } catch (Exception ex) { + Logger.printException(() -> "openChannelOfLiveAvatar failure", ex); + } + return false; + } + + public static void openChannelOfLiveAvatar(String newlyLoadedVideoId) { + try { + if (!OPEN_CHANNEL_OF_LIVE_AVATAR) { + return; + } + if (newlyLoadedVideoId.isEmpty()) { + return; + } + if (!liveChannelAvatarClicked) { + return; + } + videoId = newlyLoadedVideoId; + VideoDetailsRequest.fetchRequestIfNeeded(newlyLoadedVideoId); + } catch (Exception ex) { + Logger.printException(() -> "openChannelOfLiveAvatar failure", ex); + } + } + +} diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/general/requests/VideoDetailsRequest.kt b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/general/requests/VideoDetailsRequest.kt new file mode 100644 index 000000000..a73f5a61b --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/general/requests/VideoDetailsRequest.kt @@ -0,0 +1,141 @@ +package app.revanced.extension.youtube.patches.general.requests + +import android.annotation.SuppressLint +import androidx.annotation.GuardedBy +import app.revanced.extension.shared.patches.client.WebClient +import app.revanced.extension.shared.patches.spoof.requests.PlayerRoutes +import app.revanced.extension.shared.requests.Requester +import app.revanced.extension.shared.utils.Logger +import app.revanced.extension.shared.utils.Utils +import org.json.JSONException +import org.json.JSONObject +import java.io.IOException +import java.net.SocketTimeoutException +import java.util.Collections +import java.util.concurrent.ExecutionException +import java.util.concurrent.Future +import java.util.concurrent.TimeUnit +import java.util.concurrent.TimeoutException + +class VideoDetailsRequest private constructor( + private val videoId: String +) { + private val future: Future = Utils.submitOnBackgroundThread { + fetch(videoId) + } + + val info: String? + get() { + try { + return future[MAX_MILLISECONDS_TO_WAIT_FOR_FETCH, TimeUnit.MILLISECONDS] + } catch (ex: TimeoutException) { + Logger.printInfo( + { "getInfo timed out" }, + ex + ) + } catch (ex: InterruptedException) { + Logger.printException( + { "getInfo interrupted" }, + ex + ) + Thread.currentThread().interrupt() // Restore interrupt status flag. + } catch (ex: ExecutionException) { + Logger.printException( + { "getInfo failure" }, + ex + ) + } + + return null + } + + companion object { + private const val MAX_MILLISECONDS_TO_WAIT_FOR_FETCH = 20 * 1000L // 20 seconds + + @GuardedBy("itself") + val cache: MutableMap = Collections.synchronizedMap( + object : LinkedHashMap(100) { + private val CACHE_LIMIT = 50 + + override fun removeEldestEntry(eldest: Map.Entry): Boolean { + return size > CACHE_LIMIT // Evict the oldest entry if over the cache limit. + } + }) + + @JvmStatic + @SuppressLint("ObsoleteSdkInt") + fun fetchRequestIfNeeded(videoId: String) { + cache[videoId] = VideoDetailsRequest(videoId) + } + + @JvmStatic + fun getRequestForVideoId(videoId: String): VideoDetailsRequest? { + synchronized(cache) { + return cache[videoId] + } + } + + private fun handleConnectionError(toastMessage: String, ex: Exception?) { + Logger.printInfo({ toastMessage }, ex) + } + + private fun sendRequest(videoId: String): JSONObject? { + val startTime = System.currentTimeMillis() + val clientType = WebClient.ClientType.MWEB + val clientTypeName = clientType.name + Logger.printDebug { "Fetching video details request for: $videoId, using client: $clientTypeName" } + + try { + val connection = PlayerRoutes.getPlayerResponseConnectionFromRoute( + PlayerRoutes.GET_VIDEO_DETAILS, + clientType + ) + val requestBody = + PlayerRoutes.createWebInnertubeBody(clientType, videoId) + + connection.setFixedLengthStreamingMode(requestBody.size) + connection.outputStream.write(requestBody) + + val responseCode = connection.responseCode + if (responseCode == 200) return Requester.parseJSONObject(connection) + + handleConnectionError( + (clientTypeName + " not available with response code: " + + responseCode + " message: " + connection.responseMessage), + null + ) + } catch (ex: SocketTimeoutException) { + handleConnectionError("Connection timeout", ex) + } catch (ex: IOException) { + handleConnectionError("Network error", ex) + } catch (ex: Exception) { + Logger.printException({ "sendRequest failed" }, ex) + } finally { + Logger.printDebug { "video: " + videoId + " took: " + (System.currentTimeMillis() - startTime) + "ms" } + } + + return null + } + + private fun parseResponse(videoDetailsJson: JSONObject): String? { + try { + return videoDetailsJson + .getJSONObject("videoDetails") + .getString("channelId") + } catch (e: JSONException) { + Logger.printException ({ "Fetch failed while processing response data for response: $videoDetailsJson" }, e) + } + + return null + } + + private fun fetch(videoId: String): String? { + val videoDetailsJson = sendRequest(videoId) + if (videoDetailsJson != null) { + return parseResponse(videoDetailsJson) + } + + return null + } + } +} diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/settings/Settings.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/settings/Settings.java index 395ffe2a2..c59c3bb6b 100644 --- a/extensions/shared/src/main/java/app/revanced/extension/youtube/settings/Settings.java +++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/settings/Settings.java @@ -156,6 +156,7 @@ public class Settings extends BaseSettings { public static final BooleanSetting REMOVE_VIEWER_DISCRETION_DIALOG = new BooleanSetting("revanced_remove_viewer_discretion_dialog", FALSE); public static final EnumSetting CHANGE_LAYOUT = new EnumSetting<>("revanced_change_layout", FormFactor.ORIGINAL, true); + public static final BooleanSetting OPEN_CHANNEL_OF_LIVE_AVATAR = new BooleanSetting("revanced_open_channel_of_live_avatar", FALSE, true); public static final BooleanSetting SPOOF_APP_VERSION = new BooleanSetting("revanced_spoof_app_version", false, true, "revanced_spoof_app_version_user_dialog_message"); public static final StringSetting SPOOF_APP_VERSION_TARGET = new StringSetting("revanced_spoof_app_version_target", PatchStatus.SpoofAppVersionDefaultString(), true, parent(SPOOF_APP_VERSION)); diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/utils/VideoUtils.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/utils/VideoUtils.java index 0a5eb34e6..cf55b9b11 100644 --- a/extensions/shared/src/main/java/app/revanced/extension/youtube/utils/VideoUtils.java +++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/utils/VideoUtils.java @@ -29,12 +29,17 @@ import app.revanced.extension.youtube.shared.VideoInformation; @SuppressWarnings("unused") public class VideoUtils extends IntentUtils { + private static final String CHANNEL_URL = "https://www.youtube.com/channel/"; private static final String PLAYLIST_URL = "https://www.youtube.com/playlist?list="; private static final String VIDEO_URL = "https://youtu.be/"; private static final String VIDEO_SCHEME_INTENT_FORMAT = "vnd.youtube://%s?start=%d"; private static final String VIDEO_SCHEME_LINK_FORMAT = "https://youtu.be/%s?t=%d"; private static final AtomicBoolean isExternalDownloaderLaunched = new AtomicBoolean(false); + private static String getChannelUrl(String channelId) { + return CHANNEL_URL + channelId; + } + private static String getPlaylistUrl(String playlistId) { return PLAYLIST_URL + playlistId; } @@ -119,6 +124,10 @@ public class VideoUtils extends IntentUtils { } } + public static void openChannel(@NonNull String channelId) { + launchView(getChannelUrl(channelId), getContext().getPackageName()); + } + public static void openVideo() { openVideo(VideoInformation.getVideoId()); } diff --git a/patches/src/main/kotlin/app/revanced/patches/youtube/general/channel/Fingerprints.kt b/patches/src/main/kotlin/app/revanced/patches/youtube/general/channel/Fingerprints.kt new file mode 100644 index 000000000..9a8dc13b4 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/youtube/general/channel/Fingerprints.kt @@ -0,0 +1,42 @@ +package app.revanced.patches.youtube.general.channel + +import app.revanced.patches.youtube.utils.resourceid.elementsImage +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 elementsImageFingerprint = legacyFingerprint( + name = "elementsImageFingerprint", + returnType = "Landroid/view/View;", + accessFlags = AccessFlags.PRIVATE or AccessFlags.STATIC, + parameters = listOf("Landroid/view/View;"), + literals = listOf(elementsImage), +) + +internal val clientSettingEndpointFingerprint = legacyFingerprint( + name = "clientSettingEndpointFingerprint", + returnType = "V", + accessFlags = AccessFlags.PUBLIC or AccessFlags.FINAL, + parameters = listOf("L", "Ljava/util/Map;"), + strings = listOf( + "force_fullscreen", + "PLAYBACK_START_DESCRIPTOR_MUTATOR", + "VideoPresenterConstants.VIDEO_THUMBNAIL_BITMAP_KEY" + ), + customFingerprint = { method, _ -> + indexOfPlaybackStartDescriptorInstruction(method) >= 0 + } +) + +internal fun indexOfPlaybackStartDescriptorInstruction(method: Method) = + method.indexOfFirstInstruction { + val reference = getReference() + opcode == Opcode.INVOKE_VIRTUAL && + reference?.returnType == "Lcom/google/android/libraries/youtube/player/model/PlaybackStartDescriptor;" && + reference.parameterTypes.isEmpty() + } diff --git a/patches/src/main/kotlin/app/revanced/patches/youtube/general/channel/OpenChannelOfLiveAvatarPatch.kt b/patches/src/main/kotlin/app/revanced/patches/youtube/general/channel/OpenChannelOfLiveAvatarPatch.kt new file mode 100644 index 000000000..27ed05a0a --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/youtube/general/channel/OpenChannelOfLiveAvatarPatch.kt @@ -0,0 +1,90 @@ +package app.revanced.patches.youtube.general.channel + +import app.revanced.patcher.extensions.InstructionExtensions.addInstruction +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.patch.bytecodePatch +import app.revanced.patches.youtube.utils.compatibility.Constants.COMPATIBLE_PACKAGE +import app.revanced.patches.youtube.utils.extension.Constants.GENERAL_PATH +import app.revanced.patches.youtube.utils.patch.PatchList.OPEN_CHANNEL_OF_LIVE_AVATAR +import app.revanced.patches.youtube.utils.resourceid.sharedResourceIdPatch +import app.revanced.patches.youtube.utils.settings.ResourceUtils.addPreference +import app.revanced.patches.youtube.utils.settings.settingsPatch +import app.revanced.patches.youtube.video.playbackstart.playbackStartDescriptorPatch +import app.revanced.patches.youtube.video.playbackstart.playbackStartVideoIdReference +import app.revanced.util.fingerprint.methodOrThrow +import app.revanced.util.indexOfFirstInstructionOrThrow +import app.revanced.util.indexOfFirstInstructionReversedOrThrow +import com.android.tools.smali.dexlib2.Opcode +import com.android.tools.smali.dexlib2.iface.instruction.OneRegisterInstruction + +private const val EXTENSION_CLASS_DESCRIPTOR = + "$GENERAL_PATH/OpenChannelOfLiveAvatarPatch;" + +@Suppress("unused") +val layoutSwitchPatch = bytecodePatch( + OPEN_CHANNEL_OF_LIVE_AVATAR.title, + OPEN_CHANNEL_OF_LIVE_AVATAR.summary, +) { + compatibleWith(COMPATIBLE_PACKAGE) + + dependsOn( + playbackStartDescriptorPatch, + sharedResourceIdPatch, + settingsPatch, + ) + + execute { + + elementsImageFingerprint.methodOrThrow().addInstruction( + 0, + "invoke-static { }, $EXTENSION_CLASS_DESCRIPTOR->liveChannelAvatarClicked()V" + ) + + clientSettingEndpointFingerprint.methodOrThrow().apply { + val eqzIndex = indexOfFirstInstructionReversedOrThrow(Opcode.IF_EQZ) + var freeIndex = indexOfFirstInstructionReversedOrThrow(eqzIndex, Opcode.NEW_INSTANCE) + var freeRegister = getInstruction(freeIndex).registerA + + addInstructionsWithLabels( + eqzIndex, """ + invoke-static { }, $EXTENSION_CLASS_DESCRIPTOR->openChannelOfLiveAvatar()Z + move-result v$freeRegister + if-eqz v$freeRegister, :ignore + return-void + :ignore + nop + """ + ) + + val playbackStartIndex = indexOfPlaybackStartDescriptorInstruction(this) + 1 + val playbackStartRegister = getInstruction(playbackStartIndex).registerA + + freeIndex = indexOfFirstInstructionOrThrow(playbackStartIndex, Opcode.CONST_STRING) + freeRegister = getInstruction(freeIndex).registerA + + addInstructions( + playbackStartIndex + 1, """ + invoke-virtual { v$playbackStartRegister }, $playbackStartVideoIdReference + move-result-object v$freeRegister + invoke-static { v$freeRegister }, $EXTENSION_CLASS_DESCRIPTOR->openChannelOfLiveAvatar(Ljava/lang/String;)V + """ + ) + } + + // region add settings + + addPreference( + arrayOf( + "PREFERENCE_SCREEN: GENERAL", + "PREFERENCE_CATEGORY: GENERAL_EXPERIMENTAL_FLAGS", + "SETTINGS: OPEN_CHANNEL_OF_LIVE_AVATAR" + ), + OPEN_CHANNEL_OF_LIVE_AVATAR + ) + + // endregion + + } +} diff --git a/patches/src/main/kotlin/app/revanced/patches/youtube/utils/patch/PatchList.kt b/patches/src/main/kotlin/app/revanced/patches/youtube/utils/patch/PatchList.kt index 1facf3032..7808865a4 100644 --- a/patches/src/main/kotlin/app/revanced/patches/youtube/utils/patch/PatchList.kt +++ b/patches/src/main/kotlin/app/revanced/patches/youtube/utils/patch/PatchList.kt @@ -169,6 +169,10 @@ internal enum class PatchList( "Navigation bar components", "Adds options to hide or change components related to the navigation bar." ), + OPEN_CHANNEL_OF_LIVE_AVATAR( + "Open channel of live avatar", + "Adds an option to open channel instead of video when clicking on live avatar." + ), OPEN_LINKS_EXTERNALLY( "Open links externally", "Adds an option to always open links in your browser instead of in the in-app-browser." diff --git a/patches/src/main/kotlin/app/revanced/patches/youtube/utils/resourceid/SharedResourceIdPatch.kt b/patches/src/main/kotlin/app/revanced/patches/youtube/utils/resourceid/SharedResourceIdPatch.kt index 64a23bfc0..f12b03dea 100644 --- a/patches/src/main/kotlin/app/revanced/patches/youtube/utils/resourceid/SharedResourceIdPatch.kt +++ b/patches/src/main/kotlin/app/revanced/patches/youtube/utils/resourceid/SharedResourceIdPatch.kt @@ -83,6 +83,8 @@ var easySeekEduContainer = -1L private set var editSettingsAction = -1L private set +var elementsImage = -1L + private set var endScreenElementLayoutCircle = -1L private set var endScreenElementLayoutIcon = -1L @@ -383,6 +385,10 @@ internal val sharedResourceIdPatch = resourcePatch( STRING, "edit_settings_action" ] + elementsImage = resourceMappings[ + ID, + "elements_image" + ] endScreenElementLayoutCircle = resourceMappings[ LAYOUT, "endscreen_element_layout_circle" diff --git a/patches/src/main/kotlin/app/revanced/patches/youtube/video/playbackstart/Fingerprints.kt b/patches/src/main/kotlin/app/revanced/patches/youtube/video/playbackstart/Fingerprints.kt new file mode 100644 index 000000000..c196c1f44 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/youtube/video/playbackstart/Fingerprints.kt @@ -0,0 +1,19 @@ +package app.revanced.patches.youtube.video.playbackstart + +import app.revanced.util.fingerprint.legacyFingerprint + +const val PLAYBACK_START_DESCRIPTOR_CLASS_DESCRIPTOR = + "Lcom/google/android/libraries/youtube/player/model/PlaybackStartDescriptor;" + +/** + * Purpose of this method is not clear, and it's only used to identify + * the obfuscated name of the videoId() method in PlaybackStartDescriptor. + */ +internal val playbackStartFeatureFlagFingerprint = legacyFingerprint( + name = "playbackStartFeatureFlagFingerprint", + returnType = "Z", + parameters = listOf(PLAYBACK_START_DESCRIPTOR_CLASS_DESCRIPTOR), + literals = listOf(45380134L) +) + + diff --git a/patches/src/main/kotlin/app/revanced/patches/youtube/video/playbackstart/PlaybackStartDescriptorPatch.kt b/patches/src/main/kotlin/app/revanced/patches/youtube/video/playbackstart/PlaybackStartDescriptorPatch.kt new file mode 100644 index 000000000..c2dbc260a --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/youtube/video/playbackstart/PlaybackStartDescriptorPatch.kt @@ -0,0 +1,33 @@ +package app.revanced.patches.youtube.video.playbackstart + +import app.revanced.patcher.extensions.InstructionExtensions.getInstruction +import app.revanced.patcher.patch.bytecodePatch +import app.revanced.patches.youtube.utils.resourceid.sharedResourceIdPatch +import app.revanced.util.fingerprint.methodOrThrow +import app.revanced.util.getReference +import app.revanced.util.indexOfFirstInstructionOrThrow +import com.android.tools.smali.dexlib2.iface.instruction.ReferenceInstruction +import com.android.tools.smali.dexlib2.iface.reference.MethodReference +import com.android.tools.smali.dexlib2.iface.reference.Reference + +internal lateinit var playbackStartVideoIdReference: Reference + +val playbackStartDescriptorPatch = bytecodePatch( + description = "playbackStartDescriptorPatch" +) { + dependsOn(sharedResourceIdPatch) + + execute { + // Find the obfuscated method name for PlaybackStartDescriptor.videoId() + playbackStartFeatureFlagFingerprint.methodOrThrow().apply { + val stringMethodIndex = indexOfFirstInstructionOrThrow { + val reference = getReference() + reference?.definingClass == PLAYBACK_START_DESCRIPTOR_CLASS_DESCRIPTOR + && reference.returnType == "Ljava/lang/String;" + } + + playbackStartVideoIdReference = getInstruction(stringMethodIndex).reference + } + } +} + diff --git a/patches/src/main/resources/youtube/settings/host/values/strings.xml b/patches/src/main/resources/youtube/settings/host/values/strings.xml index dfc9de3de..ad4abd47e 100644 --- a/patches/src/main/resources/youtube/settings/host/values/strings.xml +++ b/patches/src/main/resources/youtube/settings/host/values/strings.xml @@ -373,13 +373,6 @@ If the layout of the player screen changes due to server-side changes, unintende General - Change layout - Original - Phone - Phone (Max 480 dp) - Tablet - Tablet (Min 600 dp) - Change start page Default Browse channels @@ -438,6 +431,15 @@ Limitation: Back button on the toolbar may not work." "Removes the viewer discretion dialog. This does not bypass the age restriction. It just accepts it automatically." + Change layout + Original + Phone + Phone (Max 480 dp) + Tablet + Tablet (Min 600 dp) + Open channel of live avatar + Channel opens when the live avatar is clicked. + Live stream opens when the live avatar is clicked. Spoof app version Version spoofed Version not spoofed diff --git a/patches/src/main/resources/youtube/settings/xml/revanced_prefs.xml b/patches/src/main/resources/youtube/settings/xml/revanced_prefs.xml index bb1e5c6b0..b07c083ab 100644 --- a/patches/src/main/resources/youtube/settings/xml/revanced_prefs.xml +++ b/patches/src/main/resources/youtube/settings/xml/revanced_prefs.xml @@ -304,6 +304,9 @@ + +