diff --git a/extensions/youtube/src/main/java/app/morphe/extension/youtube/patches/components/CustomFilter.java b/extensions/youtube/src/main/java/app/morphe/extension/youtube/patches/components/CustomFilter.java index 9fc7d96f9..b11cb8445 100644 --- a/extensions/youtube/src/main/java/app/morphe/extension/youtube/patches/components/CustomFilter.java +++ b/extensions/youtube/src/main/java/app/morphe/extension/youtube/patches/components/CustomFilter.java @@ -200,10 +200,6 @@ boolean isFiltered(ContextInterface contextInterface, } // Check buffer if specified. - if (custom.bufferSearch != null && !custom.bufferSearch.matches(buffer)) { - return false; - } - - return true; // All custom filter conditions passed. + return custom.bufferSearch == null || custom.bufferSearch.matches(buffer); // All custom filter conditions passed. } } \ No newline at end of file diff --git a/extensions/youtube/src/main/java/app/morphe/extension/youtube/patches/components/LithoFilterPatch.java b/extensions/youtube/src/main/java/app/morphe/extension/youtube/patches/components/LithoFilterPatch.java index 45065d368..86fa6dc7c 100644 --- a/extensions/youtube/src/main/java/app/morphe/extension/youtube/patches/components/LithoFilterPatch.java +++ b/extensions/youtube/src/main/java/app/morphe/extension/youtube/patches/components/LithoFilterPatch.java @@ -26,30 +26,17 @@ @SuppressWarnings("unused") public final class LithoFilterPatch { /** - * Simple wrapper to pass the litho parameters through the prefix search. - */ - private static final class LithoFilterParameters { - final ContextInterface contextInterface; - final String identifier; - final String path; - final String accessibility; - final byte[] buffer; - - LithoFilterParameters(ContextInterface contextInterface, String identifier, - String path, String accessibility, byte[] buffer) { - this.contextInterface = contextInterface; - this.identifier = identifier; - this.path = path; - this.accessibility = accessibility; - this.buffer = buffer; - } + * Simple wrapper to pass the litho parameters through the prefix search. + */ + private record LithoFilterParameters(ContextInterface contextInterface, String identifier, + String path, String accessibility, byte[] buffer) { @NonNull @Override public String toString() { // Estimate the percentage of the buffer that are Strings. StringBuilder builder = new StringBuilder(Math.max(100, buffer.length / 2)); - builder.append( "ID: "); + builder.append("ID: "); builder.append(identifier); if (!accessibility.isEmpty()) { // AccessibilityId and AccessibilityText are pieces of BufferStrings. @@ -233,6 +220,7 @@ public static boolean isFiltered(ContextInterface contextInterface, @Nullable by try { String identifier = contextInterface.patch_getIdentifier(); StringBuilder pathBuilder = contextInterface.patch_getPathBuilder(); + //noinspection SizeReplaceableByIsEmpty if (identifier.isEmpty() || pathBuilder.length() == 0) { return false; } diff --git a/extensions/youtube/src/main/java/app/morphe/extension/youtube/patches/spans/InclusiveSpanPatch.java b/extensions/youtube/src/main/java/app/morphe/extension/youtube/patches/spans/InclusiveSpanPatch.java new file mode 100644 index 000000000..c8200e99d --- /dev/null +++ b/extensions/youtube/src/main/java/app/morphe/extension/youtube/patches/spans/InclusiveSpanPatch.java @@ -0,0 +1,223 @@ +/* + * Copyright 2026 Morphe. + * https://github.com/MorpheApp/morphe-patches + * + * Original hard forked code: + * https://github.com/ReVanced/revanced-patches/commit/724e6d61b2ecd868c1a9a37d465a688e83a74799 + * + * See the included NOTICE file for GPLv3 §7(b) and §7(c) terms that apply to Morphe contributions. + */ + +package app.morphe.extension.youtube.patches.spans; + +import android.text.Spannable; +import android.text.SpannableString; +import android.text.style.AbsoluteSizeSpan; +import android.text.style.CharacterStyle; +import android.text.style.ClickableSpan; +import android.text.style.ForegroundColorSpan; +import android.text.style.ImageSpan; +import android.text.style.LineHeightSpan; +import android.text.style.TypefaceSpan; + +import androidx.annotation.NonNull; + +import java.util.List; + +import app.morphe.extension.shared.Logger; +import app.morphe.extension.shared.StringTrieSearch; +import app.morphe.extension.youtube.settings.Settings; + +/** + * Placeholder for actual filters. + */ +final class DummySpanFilter extends SpanFilter { +} + +@SuppressWarnings("unused") +public final class InclusiveSpanPatch { + + /** + * Simple wrapper to pass the litho parameters through the prefix search. + */ + private static final class LithoFilterParameters { + final String conversionContext; + final SpannableString spannableString; + final Object span; + final int start; + final int end; + final int flags; + final String originalString; + final int originalLength; + final SpanType spanType; + final boolean isWord; + + public LithoFilterParameters(String conversionContext, SpannableString spannableString, + Object span, int start, int end, int flags) { + this.conversionContext = conversionContext; + this.spannableString = spannableString; + this.span = span; + this.start = start; + this.end = end; + this.flags = flags; + this.originalString = spannableString.toString(); + this.originalLength = spannableString.length(); + this.spanType = getSpanType(span); + this.isWord = !(start == 0 && end == originalLength); + } + + @NonNull + @Override + public String toString() { + StringBuilder builder = new StringBuilder(); + builder.append("CharSequence:'") + .append(originalString) + .append("'\nSpanType:'") + .append(getSpanType(spanType, span)) + .append("'\nLength:'") + .append(originalLength) + .append("'\nStart:'") + .append(start) + .append("'\nEnd:'") + .append(end) + .append("'\nisWord:'") + .append(isWord) + .append("'"); + if (isWord) { + builder.append("\nWord:'") + .append(originalString.substring(start, end)) + .append("'"); + } + return builder.toString(); + } + } + + private static SpanType getSpanType(Object span) { + if (span instanceof ClickableSpan) { + return SpanType.CLICKABLE; + } else if (span instanceof ForegroundColorSpan) { + return SpanType.FOREGROUND_COLOR; + } else if (span instanceof AbsoluteSizeSpan) { + return SpanType.ABSOLUTE_SIZE; + } else if (span instanceof TypefaceSpan) { + return SpanType.TYPEFACE; + } else if (span instanceof ImageSpan) { + return SpanType.IMAGE; + } else if (span instanceof LineHeightSpan) { + return SpanType.LINE_HEIGHT; + } else if (span instanceof CharacterStyle) { // Replaced by patch. + return SpanType.CUSTOM_CHARACTER_STYLE; + } else { + return SpanType.UNKNOWN; + } + } + + private static String getSpanType(SpanType spanType, Object span) { + return spanType == SpanType.UNKNOWN + ? span.getClass().getSimpleName() + : spanType.type; + } + + private static final SpanFilter[] filters = new SpanFilter[]{ + new DummySpanFilter() // Replaced by patch. + }; + + private static final StringTrieSearch searchTree = new StringTrieSearch(); + + /** + * Because litho filtering is multithreaded and the buffer is passed in from a different injection point, + * the buffer is saved to a ThreadLocal so each calling thread does not interfere with other threads. + */ + private static final ThreadLocal conversionContextThreadLocal = new ThreadLocal<>(); + + static { + for (SpanFilter filter : filters) { + filterUsingCallbacks(filter, filter.callbacks); + } + + if (Settings.DEBUG_SPANNABLE.get()) { + Logger.printDebug(() -> "Using: " + + searchTree.numberOfPatterns() + " conversion context filters" + + " (" + searchTree.getEstimatedMemorySize() + " KB)"); + } + } + + private static void filterUsingCallbacks(SpanFilter filter, List groups) { + String filterSimpleName = filter.getClass().getSimpleName(); + + for (StringSpanFilterGroup group : groups) { + if (!group.includeInSearch()) { + continue; + } + + for (String pattern : group.filters) { + InclusiveSpanPatch.searchTree.addPattern(pattern, (textSearched, matchedStartIndex, + matchedLength, callbackParameter) -> { + if (!group.isEnabled()) return false; + + LithoFilterParameters parameters = (LithoFilterParameters) callbackParameter; + final boolean isFiltered = filter.skip(parameters.conversionContext, parameters.spannableString, + parameters.span, parameters.start, parameters.end, parameters.flags, parameters.isWord, + parameters.spanType, group); + + if (isFiltered && Settings.DEBUG_SPANNABLE.get()) { + Logger.printDebug(() -> "Removed " + filterSimpleName + + " setSpan: " + parameters.spanType); + } + + return isFiltered; + } + ); + } + } + } + + /** + * Injection point. + * + * @param conversionContext ConversionContext is used to identify whether it is a comment thread or not. + */ + public static CharSequence setConversionContext(Object conversionContext, CharSequence original) { + conversionContextThreadLocal.set(conversionContext.toString()); + return original; + } + + private static boolean returnEarly(SpannableString spannableString, Object span, int start, int end, int flags) { + try { + String conversionContext = conversionContextThreadLocal.get(); + if (conversionContext == null || conversionContext.isEmpty()) { + return false; + } + + LithoFilterParameters parameter = new LithoFilterParameters(conversionContext, + spannableString, span, start, end, flags); + + if (Settings.DEBUG_SPANNABLE.get()) { + Logger.printDebug(() -> "Searching...\n\u200B\n" + parameter); + } + + return searchTree.matches(parameter.conversionContext, parameter); + } catch (Exception ex) { + Logger.printException(() -> "Spans filter failure", ex); + } + + return false; + } + + /** + * Injection point. + * + * @param spannableString Original SpannableString. + * @param span Span such as {@link ClickableSpan}, {@link ForegroundColorSpan}, + * {@link AbsoluteSizeSpan}, {@link TypefaceSpan}, {@link ImageSpan}. + * @param start Start index of {@link Spannable#setSpan(Object, int, int, int)}. + * @param end End index of {@link Spannable#setSpan(Object, int, int, int)}. + * @param flags Flags of {@link Spannable#setSpan(Object, int, int, int)}. + */ + public static void setSpan(SpannableString spannableString, Object span, int start, int end, int flags) { + if (returnEarly(spannableString, span, start, end, flags)) { + return; + } + spannableString.setSpan(span, start, end, flags); + } +} \ No newline at end of file diff --git a/extensions/youtube/src/main/java/app/morphe/extension/youtube/patches/spans/SpanFilter.java b/extensions/youtube/src/main/java/app/morphe/extension/youtube/patches/spans/SpanFilter.java new file mode 100644 index 000000000..5c66ff7f8 --- /dev/null +++ b/extensions/youtube/src/main/java/app/morphe/extension/youtube/patches/spans/SpanFilter.java @@ -0,0 +1,69 @@ +/* + * Copyright 2026 Morphe. + * https://github.com/MorpheApp/morphe-patches + * + * Original hard forked code: + * https://github.com/ReVanced/revanced-patches/commit/724e6d61b2ecd868c1a9a37d465a688e83a74799 + * + * See the included NOTICE file for GPLv3 §7(b) and §7(c) terms that apply to Morphe contributions. + */ + +package app.morphe.extension.youtube.patches.spans; + +import android.graphics.Color; +import android.graphics.drawable.ColorDrawable; +import android.graphics.drawable.Drawable; +import android.text.SpannableString; +import android.text.style.ImageSpan; +import android.text.style.RelativeSizeSpan; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +/** + * Filters litho based components. + *

+ * All callbacks must be registered before the constructor completes. + */ +abstract class SpanFilter { + private static final RelativeSizeSpan relativeSizeSpanDummy = new RelativeSizeSpan(0f); + private static final Drawable transparentDrawable = new ColorDrawable(Color.TRANSPARENT); + private static final ImageSpan imageSpanDummy = new ImageSpan(transparentDrawable); + + /** + * Path callbacks. Do not add to this instance, + * and instead use {@link #addCallbacks(StringSpanFilterGroup...)}. + */ + protected final List callbacks = new ArrayList<>(); + + /** + * Adds callbacks to {@link #skip(String, SpannableString, Object, int, int, int, boolean, SpanType, StringSpanFilterGroup)} + * if any of the groups are found. + */ + protected final void addCallbacks(StringSpanFilterGroup... groups) { + callbacks.addAll(Arrays.asList(groups)); + } + + protected final void hideSpan(SpannableString spannableString, int start, int end, int flags) { + spannableString.setSpan(relativeSizeSpanDummy, start, end, flags); + } + + protected final void hideImageSpan(SpannableString spannableString, int start, int end, int flags) { + spannableString.setSpan(imageSpanDummy, start, end, flags); + } + + /** + * Called after an enabled filter has been matched. + * Default implementation is to always filter the matched component and log the action. + * Subclasses can perform additional or different checks if needed. + *

+ * Method is called off the main thread. + * + * @param matchedGroup The actual filter that matched. + */ + boolean skip(String conversionContext, SpannableString spannableString, Object span, int start, int end, + int flags, boolean isWord, SpanType spanType, StringSpanFilterGroup matchedGroup) { + return true; + } +} \ No newline at end of file diff --git a/extensions/youtube/src/main/java/app/morphe/extension/youtube/patches/spans/SpanFilterGroup.java b/extensions/youtube/src/main/java/app/morphe/extension/youtube/patches/spans/SpanFilterGroup.java new file mode 100644 index 000000000..0ed0eb871 --- /dev/null +++ b/extensions/youtube/src/main/java/app/morphe/extension/youtube/patches/spans/SpanFilterGroup.java @@ -0,0 +1,96 @@ +/* + * Copyright 2026 Morphe. + * https://github.com/MorpheApp/morphe-patches + * + * Original hard forked code: + * https://github.com/ReVanced/revanced-patches/commit/724e6d61b2ecd868c1a9a37d465a688e83a74799 + * + * See the included NOTICE file for GPLv3 §7(b) and §7(c) terms that apply to Morphe contributions. + */ + +package app.morphe.extension.youtube.patches.spans; + +import androidx.annotation.NonNull; + +import app.morphe.extension.shared.settings.BooleanSetting; + +abstract class SpanFilterGroup { + final static class FilterGroupResult { + private BooleanSetting setting; + private int matchedIndex; + + FilterGroupResult() { + this(null, -1); + } + + FilterGroupResult(BooleanSetting setting, int matchedIndex) { + setValues(setting, matchedIndex); + } + + public void setValues(BooleanSetting setting, int matchedIndex) { + this.setting = setting; + this.matchedIndex = matchedIndex; + } + + public BooleanSetting getSetting() { + return setting; + } + + public boolean isFiltered() { + return matchedIndex >= 0; + } + } + + protected final BooleanSetting setting; + protected final T[] filters; + + @SafeVarargs + public SpanFilterGroup(final BooleanSetting setting, final T... filters) { + this.setting = setting; + this.filters = filters; + if (filters.length == 0) { + throw new IllegalArgumentException("Must use one or more filter patterns (zero specified)"); + } + } + + public boolean isEnabled() { + return setting == null || setting.get(); + } + + @SuppressWarnings("BooleanMethodIsAlwaysInverted") + public boolean includeInSearch() { + return isEnabled() || !setting.rebootApp; + } + + @NonNull + @Override + public String toString() { + return getClass().getSimpleName() + ": " + (setting == null ? "(null setting)" : setting); + } + + public abstract FilterGroupResult check(final T stack); +} + +class StringSpanFilterGroup extends SpanFilterGroup { + + public StringSpanFilterGroup(final BooleanSetting setting, final String... filters) { + super(setting, filters); + } + + @Override + public FilterGroupResult check(final String string) { + int matchedIndex = -1; + if (isEnabled()) { + for (String pattern : filters) { + if (!string.isEmpty()) { + final int indexOf = string.indexOf(pattern); + if (indexOf >= 0) { + matchedIndex = indexOf; + break; + } + } + } + } + return new FilterGroupResult(setting, matchedIndex); + } +} \ No newline at end of file diff --git a/extensions/youtube/src/main/java/app/morphe/extension/youtube/patches/spans/SpanFilterGroupList.java b/extensions/youtube/src/main/java/app/morphe/extension/youtube/patches/spans/SpanFilterGroupList.java new file mode 100644 index 000000000..da8aef5f1 --- /dev/null +++ b/extensions/youtube/src/main/java/app/morphe/extension/youtube/patches/spans/SpanFilterGroupList.java @@ -0,0 +1,84 @@ +/* + * Copyright 2026 Morphe. + * https://github.com/MorpheApp/morphe-patches + * + * Original hard forked code: + * https://github.com/ReVanced/revanced-patches/commit/724e6d61b2ecd868c1a9a37d465a688e83a74799 + * + * See the included NOTICE file for GPLv3 §7(b) and §7(c) terms that apply to Morphe contributions. + */ + +package app.morphe.extension.youtube.patches.spans; + +import androidx.annotation.NonNull; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Iterator; +import java.util.List; +import java.util.Spliterator; +import java.util.function.Consumer; + +import app.morphe.extension.shared.StringTrieSearch; +import app.morphe.extension.shared.TrieSearch; + +abstract class SpanFilterGroupList> implements Iterable { + + private final List filterGroups = new ArrayList<>(); + private final TrieSearch search = createSearchGraph(); + + @SafeVarargs + protected final void addAll(final T... groups) { + filterGroups.addAll(Arrays.asList(groups)); + + for (T group : groups) { + if (!group.includeInSearch()) { + continue; + } + for (V pattern : group.filters) { + search.addPattern(pattern, (textSearched, matchedStartIndex, + matchedLength, callbackParameter) -> { + if (group.isEnabled()) { + SpanFilterGroup.FilterGroupResult result = + (SpanFilterGroup.FilterGroupResult) callbackParameter; + result.setValues(group.setting, matchedStartIndex); + return true; + } + return false; + }); + } + } + } + + @NonNull + @Override + public Iterator iterator() { + return filterGroups.iterator(); + } + + @Override + public void forEach(@NonNull Consumer action) { + filterGroups.forEach(action); + } + + @NonNull + @Override + public Spliterator spliterator() { + return filterGroups.spliterator(); + } + + protected SpanFilterGroup.FilterGroupResult check(V stack) { + SpanFilterGroup.FilterGroupResult result = new SpanFilterGroup.FilterGroupResult(); + search.matches(stack, result); + return result; + } + + protected abstract TrieSearch createSearchGraph(); +} + +final class StringSpanFilterGroupList extends SpanFilterGroupList { + @Override + protected StringTrieSearch createSearchGraph() { + return new StringTrieSearch(); + } +} \ No newline at end of file diff --git a/extensions/youtube/src/main/java/app/morphe/extension/youtube/patches/spans/SpanType.java b/extensions/youtube/src/main/java/app/morphe/extension/youtube/patches/spans/SpanType.java new file mode 100644 index 000000000..b63e07822 --- /dev/null +++ b/extensions/youtube/src/main/java/app/morphe/extension/youtube/patches/spans/SpanType.java @@ -0,0 +1,28 @@ +/* + * Copyright 2026 Morphe. + * https://github.com/MorpheApp/morphe-patches + * + * See the included NOTICE file for GPLv3 §7(b) and §7(c) terms that apply to Morphe contributions. + */ + +package app.morphe.extension.youtube.patches.spans; + +import androidx.annotation.NonNull; + +enum SpanType { + ABSOLUTE_SIZE("AbsoluteSizeSpan"), + CLICKABLE("ClickableSpan"), + CUSTOM_CHARACTER_STYLE("CustomCharacterStyle"), + FOREGROUND_COLOR("ForegroundColorSpan"), + IMAGE("ImageSpan"), + LINE_HEIGHT("LineHeightSpan"), + TYPEFACE("TypefaceSpan"), + UNKNOWN("Unknown"); + + @NonNull + public final String type; + + SpanType(@NonNull String type) { + this.type = type; + } +} \ No newline at end of file diff --git a/extensions/youtube/src/main/java/app/morphe/extension/youtube/settings/Settings.java b/extensions/youtube/src/main/java/app/morphe/extension/youtube/settings/Settings.java index 5d51ceed8..4e09bfbe1 100644 --- a/extensions/youtube/src/main/java/app/morphe/extension/youtube/settings/Settings.java +++ b/extensions/youtube/src/main/java/app/morphe/extension/youtube/settings/Settings.java @@ -463,6 +463,7 @@ public class Settings extends SharedYouTubeSettings { public static final EnumSetting SPOOF_VIDEO_STREAMS_CLIENT_TYPE = new EnumSetting<>("morphe_spoof_video_streams_client_type", ClientType.ANDROID_REEL, true, parent(SPOOF_VIDEO_STREAMS)); 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 SpoofClientAv1Availability()); + public static final BooleanSetting DEBUG_SPANNABLE = new BooleanSetting("morphe_debug_spannable", FALSE, parent(BaseSettings.DEBUG)); public static final BooleanSetting DEBUG_PROTOBUFFER = new BooleanSetting("morphe_debug_protobuffer", FALSE, false, "morphe_debug_protobuffer_user_dialog_message", parent(BaseSettings.DEBUG)); diff --git a/patches/src/main/kotlin/app/morphe/patches/youtube/misc/spans/Fingerprints.kt b/patches/src/main/kotlin/app/morphe/patches/youtube/misc/spans/Fingerprints.kt new file mode 100644 index 000000000..b0fb571f5 --- /dev/null +++ b/patches/src/main/kotlin/app/morphe/patches/youtube/misc/spans/Fingerprints.kt @@ -0,0 +1,38 @@ +/* + * Copyright 2026 Morphe. + * https://github.com/MorpheApp/morphe-patches + * + * See the included NOTICE file for GPLv3 §7(b) and §7(c) terms that apply to Morphe contributions. + */ + +package app.morphe.patches.youtube.misc.spans + +import app.morphe.patcher.Fingerprint +import app.morphe.patcher.fieldAccess +import com.android.tools.smali.dexlib2.AccessFlags +import com.android.tools.smali.dexlib2.Opcode + +internal object CustomCharacterStyleFingerprint : Fingerprint( + returnType = "Landroid/graphics/Path;", + parameters = listOf("Landroid/text/Layout;") +) + +internal object InclusiveSpanFilterFingerprint : Fingerprint( + definingClass = EXTENSION_SPANS_CLASS, + accessFlags = listOf(AccessFlags.STATIC, AccessFlags.CONSTRUCTOR), + filters = listOf( + fieldAccess( + opcode = Opcode.SPUT_OBJECT, + definingClass = "this", + type = EXTENSION_FILTER_ARRAY + ) + ) +) + +internal object GetSpanTypeFingerprint : Fingerprint( + definingClass = EXTENSION_SPANS_CLASS, + name = "getSpanType", + custom = { method, _ -> + method.returnType != "Ljava/lang/String;" + } +) \ No newline at end of file diff --git a/patches/src/main/kotlin/app/morphe/patches/youtube/misc/spans/InclusiveSpanPatch.kt b/patches/src/main/kotlin/app/morphe/patches/youtube/misc/spans/InclusiveSpanPatch.kt new file mode 100644 index 000000000..b5d2175c7 --- /dev/null +++ b/patches/src/main/kotlin/app/morphe/patches/youtube/misc/spans/InclusiveSpanPatch.kt @@ -0,0 +1,166 @@ +/* + * Copyright 2026 Morphe. + * https://github.com/MorpheApp/morphe-patches + * + * Original hard forked code: + * https://github.com/ReVanced/revanced-patches/commit/724e6d61b2ecd868c1a9a37d465a688e83a74799 + * + * See the included NOTICE file for GPLv3 §7(b) and §7(c) terms that apply to Morphe contributions. + */ + +@file:Suppress("SpellCheckingInspection") + +package app.morphe.patches.youtube.misc.spans + +import app.morphe.patcher.extensions.InstructionExtensions.addInstruction +import app.morphe.patcher.extensions.InstructionExtensions.addInstructions +import app.morphe.patcher.extensions.InstructionExtensions.getInstruction +import app.morphe.patcher.extensions.InstructionExtensions.replaceInstruction +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.patches.youtube.misc.textcomponent.hookSpannableString +import app.morphe.patches.youtube.misc.textcomponent.textComponentPatch +import app.morphe.patches.youtube.shared.SpannableStringBuilderFingerprint +import app.morphe.patches.youtube.shared.indexOfSpannableStringInstruction +import app.morphe.util.fiveRegisters +import app.morphe.util.getMutableMethod +import app.morphe.util.getReference +import app.morphe.util.indexOfFirstInstructionOrThrow +import app.morphe.util.indexOfFirstInstructionReversedOrThrow +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.MethodReference +import com.android.tools.smali.dexlib2.immutable.ImmutableMethod +import java.lang.ref.WeakReference + +internal const val EXTENSION_SPANS_CLASS = "Lapp/morphe/extension/youtube/patches/spans/InclusiveSpanPatch;" + +internal const val EXTENSION_FILTER_ARRAY = "[Lapp/morphe/extension/youtube/patches/spans/SpanFilter;" + +// Registers used in extension helperMethod. +private const val REGISTER_FILTER_CLASS = 0 +private const val REGISTER_FILTER_COUNT = 1 +private const val REGISTER_FILTER_ARRAY = 2 + +private lateinit var helperMethodRef: WeakReference +private var addSpanFilterCount = 0 + +fun addSpanFilter(classDescriptor: String) { + helperMethodRef.get()!!.addInstructions( + 0, + """ + new-instance v$REGISTER_FILTER_CLASS, $classDescriptor + invoke-direct { v$REGISTER_FILTER_CLASS }, $classDescriptor->()V + const/16 v$REGISTER_FILTER_COUNT, ${addSpanFilterCount++} + aput-object v$REGISTER_FILTER_CLASS, v$REGISTER_FILTER_ARRAY, v$REGISTER_FILTER_COUNT + """ + ) +} + +val inclusiveSpanPatch = bytecodePatch( + description = "Hooks SpannableString setting and filters specific span components.", +) { + dependsOn(textComponentPatch) + + execute { + hookSpannableString( + EXTENSION_SPANS_CLASS, + "setConversionContext" + ) + + SpannableStringBuilderFingerprint.method.apply { + val spannedIndex = indexOfSpannableStringInstruction(this) + val setInclusiveSpanIndex = indexOfFirstInstructionOrThrow(spannedIndex) { + val reference = getReference() + opcode == Opcode.INVOKE_STATIC && + reference?.returnType == "V" && + reference.parameterTypes.size > 3 && + reference.parameterTypes.firstOrNull() == "Landroid/text/SpannableString;" + } + val setInclusiveSpanMethod = getInstruction(setInclusiveSpanIndex) + .getReference()!! + .getMutableMethod() + + setInclusiveSpanMethod.apply { + val insertIndex = indexOfFirstInstructionReversedOrThrow { + opcode == Opcode.INVOKE_VIRTUAL && + getReference().toString() == "Landroid/text/SpannableString;->setSpan(Ljava/lang/Object;III)V" + } + replaceInstruction( + insertIndex, + "invoke-static { ${fiveRegisters(insertIndex)} }, $EXTENSION_SPANS_CLASS->setSpan(Landroid/text/SpannableString;Ljava/lang/Object;III)V" + ) + } + } + + val customCharacterStyle = CustomCharacterStyleFingerprint.classDef.type + + GetSpanTypeFingerprint.method.apply { + val index = indexOfFirstInstructionOrThrow { + opcode == Opcode.INSTANCE_OF && + (this as? ReferenceInstruction)?.reference?.toString() == "Landroid/text/style/CharacterStyle;" + } + val instruction = getInstruction(index) + replaceInstruction( + index, + "instance-of v${instruction.registerA}, v${instruction.registerB}, $customCharacterStyle" + ) + } + + // Remove dummy filter from extension static field and add the filters included during patching. + InclusiveSpanFilterFingerprint.let { + it.method.apply { + // Add a helper method to avoid finding multiple free registers. + // This fixes an issue with extension compiled with Android Gradle Plugin 8.3.0+. + val helperClass = definingClass + val helperName = "patch_getFilterArray" + val helperReturnType = EXTENSION_FILTER_ARRAY + val helperMethod = ImmutableMethod( + helperClass, + helperName, + listOf(), + helperReturnType, + AccessFlags.PRIVATE.value or AccessFlags.STATIC.value, + null, + null, + MutableMethodImplementation(3), + ).toMutable() + it.classDef.methods.add(helperMethod) + helperMethodRef = WeakReference(helperMethod) + + val insertIndex = it.instructionMatches.first().index + val insertRegister = getInstruction(insertIndex).registerA + + addInstructions( + insertIndex, + """ + invoke-static {}, $EXTENSION_SPANS_CLASS->$helperName()$EXTENSION_FILTER_ARRAY + move-result-object v$insertRegister + """ + ) + } + } + } + + finalize { + helperMethodRef.get()!!.apply { + addInstruction( + implementation!!.instructions.size, + "return-object v$REGISTER_FILTER_ARRAY" + ) + + addInstructions( + 0, + """ + const/16 v$REGISTER_FILTER_COUNT, $addSpanFilterCount + new-array v$REGISTER_FILTER_ARRAY, v$REGISTER_FILTER_COUNT, $EXTENSION_FILTER_ARRAY + """ + ) + } + } +} \ No newline at end of file diff --git a/patches/src/main/kotlin/app/morphe/patches/youtube/misc/textcomponent/Fingerprints.kt b/patches/src/main/kotlin/app/morphe/patches/youtube/misc/textcomponent/Fingerprints.kt new file mode 100644 index 000000000..7adba6a84 --- /dev/null +++ b/patches/src/main/kotlin/app/morphe/patches/youtube/misc/textcomponent/Fingerprints.kt @@ -0,0 +1,33 @@ +/* + * Copyright 2026 Morphe. + * https://github.com/MorpheApp/morphe-patches + * + * See the included NOTICE file for GPLv3 §7(b) and §7(c) terms that apply to Morphe contributions. + */ + +package app.morphe.patches.youtube.misc.textcomponent + +import app.morphe.patcher.Fingerprint +import app.morphe.patcher.opcode +import app.morphe.patcher.string +import com.android.tools.smali.dexlib2.AccessFlags +import com.android.tools.smali.dexlib2.Opcode + +internal object TextComponentConstructorFingerprint : Fingerprint( + returnType = "V", + accessFlags = listOf(AccessFlags.PRIVATE, AccessFlags.CONSTRUCTOR), + filters = listOf(string("TextComponent")) +) + +internal object TextComponentContextFingerprint : Fingerprint( + classFingerprint = TextComponentConstructorFingerprint, + returnType = "L", + accessFlags = listOf(AccessFlags.PROTECTED, AccessFlags.FINAL), + parameters = listOf("L"), + filters = listOf( + opcode(Opcode.IGET_OBJECT), + opcode(Opcode.IGET_OBJECT), + opcode(Opcode.IGET_OBJECT), + opcode(Opcode.IGET_BOOLEAN) + ) +) \ No newline at end of file diff --git a/patches/src/main/kotlin/app/morphe/patches/youtube/misc/textcomponent/TextComponentPatch.kt b/patches/src/main/kotlin/app/morphe/patches/youtube/misc/textcomponent/TextComponentPatch.kt new file mode 100644 index 000000000..46337c32c --- /dev/null +++ b/patches/src/main/kotlin/app/morphe/patches/youtube/misc/textcomponent/TextComponentPatch.kt @@ -0,0 +1,134 @@ +/* + * Copyright 2026 Morphe. + * https://github.com/MorpheApp/morphe-patches + * + * See the included NOTICE file for GPLv3 §7(b) and §7(c) terms that apply to Morphe contributions. + */ + +package app.morphe.patches.youtube.misc.textcomponent + +import app.morphe.patcher.extensions.InstructionExtensions.addInstruction +import app.morphe.patcher.extensions.InstructionExtensions.addInstructions +import app.morphe.patcher.extensions.InstructionExtensions.getInstruction +import app.morphe.patcher.extensions.InstructionExtensions.replaceInstruction +import app.morphe.patcher.patch.PatchException +import app.morphe.patcher.patch.bytecodePatch +import app.morphe.patcher.util.proxy.mutableTypes.MutableMethod +import app.morphe.patches.youtube.shared.SPANNABLE_STRING_REFERENCE +import app.morphe.patches.youtube.shared.SpannableStringBuilderFingerprint +import app.morphe.patches.youtube.shared.indexOfSpannableStringInstruction +import app.morphe.util.getReference +import app.morphe.util.indexOfFirstInstruction +import app.morphe.util.indexOfFirstInstructionOrThrow +import com.android.tools.smali.dexlib2.Opcode +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.iface.reference.MethodReference + +private lateinit var spannedMethod: MutableMethod +private var spannedIndex = 0 +private var spannedRegister = 0 +private var spannedContextRegister = 0 + +private lateinit var textComponentMethod: MutableMethod +private var textComponentIndex = 0 +private var textComponentRegister = 0 +private var textComponentContextRegister = 0 + +val textComponentPatch = bytecodePatch( + description = "Provides hooks into text components for extension filtering." +) { + execute { + SpannableStringBuilderFingerprint.method.apply { + spannedMethod = this + spannedIndex = indexOfSpannableStringInstruction(this) + spannedRegister = getInstruction(spannedIndex).registerC + spannedContextRegister = + getInstruction(spannedIndex + 1).registerA + + replaceInstruction( + spannedIndex, + "move-object/from16 v$spannedContextRegister, p0" + ) + addInstruction( + ++spannedIndex, + "invoke-static {v$spannedRegister}, $SPANNABLE_STRING_REFERENCE" + ) + } + + TextComponentContextFingerprint.method.apply { + textComponentMethod = this + val conversionContextFieldIndex = indexOfFirstInstructionOrThrow { + getReference()?.type == "Ljava/util/Map;" + } - 1 + val conversionContextFieldReference = + getInstruction(conversionContextFieldIndex).reference + + // ~ YouTube 19.32.xx + val legacyCharSequenceIndex = indexOfFirstInstruction { + getReference()?.type == "Ljava/util/BitSet;" + } - 1 + val charSequenceIndex = indexOfFirstInstruction { + val reference = getReference() + opcode == Opcode.INVOKE_VIRTUAL && + reference?.returnType == "V" && + reference.parameterTypes.firstOrNull() == "Ljava/lang/CharSequence;" + } + + val insertIndex: Int + + if (legacyCharSequenceIndex > -2) { + textComponentRegister = + getInstruction(legacyCharSequenceIndex).registerA + insertIndex = legacyCharSequenceIndex - 1 + } else if (charSequenceIndex > -1) { + textComponentRegister = + getInstruction(charSequenceIndex).registerD + insertIndex = charSequenceIndex + } else { + throw PatchException("Could not find insert index") + } + + textComponentContextRegister = getInstruction( + indexOfFirstInstructionOrThrow(insertIndex, Opcode.IGET_OBJECT) + ).registerA + + addInstructions( + insertIndex, + """ + move-object/from16 v$textComponentContextRegister, p0 + iget-object v$textComponentContextRegister, v$textComponentContextRegister, $conversionContextFieldReference + """ + ) + textComponentIndex = insertIndex + 2 + } + } +} + +internal fun hookSpannableString( + classDescriptor: String, + methodName: String +) = spannedMethod.addInstructions( + spannedIndex, + """ + invoke-static { v$spannedContextRegister, v$spannedRegister }, $classDescriptor->$methodName(Ljava/lang/Object;Ljava/lang/CharSequence;)Ljava/lang/CharSequence; + move-result-object v$spannedRegister + """ +) + +internal fun hookTextComponent( + classDescriptor: String, + methodName: String = "onLithoTextLoaded" +) = textComponentMethod.apply { + addInstructions( + textComponentIndex, + """ + invoke-static { v$textComponentContextRegister, v$textComponentRegister }, $classDescriptor->$methodName(Ljava/lang/Object;Ljava/lang/CharSequence;)Ljava/lang/CharSequence; + move-result-object v$textComponentRegister + """ + ) + textComponentIndex += 2 +} \ No newline at end of file diff --git a/patches/src/main/kotlin/app/morphe/patches/youtube/shared/Fingerprints.kt b/patches/src/main/kotlin/app/morphe/patches/youtube/shared/Fingerprints.kt index fdd15aaa8..ea8d7138e 100644 --- a/patches/src/main/kotlin/app/morphe/patches/youtube/shared/Fingerprints.kt +++ b/patches/src/main/kotlin/app/morphe/patches/youtube/shared/Fingerprints.kt @@ -23,8 +23,13 @@ import app.morphe.patcher.opcode import app.morphe.patcher.string import app.morphe.patches.all.misc.resources.ResourceType import app.morphe.patches.all.misc.resources.resourceLiteral +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 +import com.android.tools.smali.dexlib2.iface.reference.StringReference internal const val YOUTUBE_MAIN_ACTIVITY_CLASS_TYPE = "Lcom/google/android/apps/youtube/app/watchwhile/MainActivity;" @@ -254,3 +259,24 @@ internal object WatchNextResponseParserFingerprint : Fingerprint( literal(46659098L), ) ) + +internal object SpannableStringBuilderFingerprint : Fingerprint( + returnType = "Ljava/lang/CharSequence;", + custom = { method, _ -> + method.indexOfFirstInstruction { + opcode == Opcode.CONST_STRING && + getReference() + ?.string.toString() + .startsWith("Failed to set PB Style Run Extension in TextComponentSpec.") + } >= 0 && + indexOfSpannableStringInstruction(method) >= 0 + } +) + +const val SPANNABLE_STRING_REFERENCE = + "Landroid/text/SpannableString;->valueOf(Ljava/lang/CharSequence;)Landroid/text/SpannableString;" + +fun indexOfSpannableStringInstruction(method: Method) = method.indexOfFirstInstruction { + opcode == Opcode.INVOKE_STATIC && + getReference()?.toString() == SPANNABLE_STRING_REFERENCE +}