Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions patches/api/patches.api
Original file line number Diff line number Diff line change
Expand Up @@ -717,6 +717,27 @@ public final class app/revanced/patches/reddit/customclients/boostforreddit/misc
public static final fun getSharedExtensionPatch ()Lapp/revanced/patcher/patch/BytecodePatch;
}

public final class app/revanced/patches/reddit/customclients/continuum/api/Constants {
public static final field INSTANCE Lapp/revanced/patches/reddit/customclients/continuum/api/Constants;
public static final field NEW_CLIENT_ID Ljava/lang/String;
public static final field NEW_REDIRECT_URI Ljava/lang/String;
public static final field NEW_USER_AGENT Ljava/lang/String;
public static final field OLD_CLIENT_ID Ljava/lang/String;
public static final field OLD_REDIRECT_URI Ljava/lang/String;
}

public final class app/revanced/patches/reddit/customclients/continuum/api/RedditApiBytecodePatchKt {
public static final fun getRedditApiBytecodePatch ()Lapp/revanced/patcher/patch/BytecodePatch;
}

public final class app/revanced/patches/reddit/customclients/continuum/api/RedditApiResourcePatchKt {
public static final fun getRedditApiResourcePatch ()Lapp/revanced/patcher/patch/ResourcePatch;
}

public final class app/revanced/patches/reddit/customclients/continuum/misc/RemoveClientIdCheckPatchKt {
public static final fun getRemoveClientIdCheckPatch ()Lapp/revanced/patcher/patch/BytecodePatch;
}

public final class app/revanced/patches/reddit/customclients/infinityforreddit/api/SpoofClientPatchKt {
public static final fun getSpoofClientPatch ()Lapp/revanced/patcher/patch/BytecodePatch;
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package app.revanced.patches.reddit.customclients.continuum.api

object Constants {
const val NEW_USER_AGENT = "org.quantumbadger.redreader/1.25.1"
const val OLD_REDIRECT_URI = "continuum://localhost"
const val NEW_REDIRECT_URI = "redreader://rr_oauth_redir"
const val OLD_CLIENT_ID = "Ro2J-y8bN412oS6BeaIj0A"
const val NEW_CLIENT_ID = "QnM1dlkC_2UfSlACOTXGRw"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
package app.revanced.patches.reddit.customclients.continuum.api

import app.revanced.patcher.extensions.InstructionExtensions.replaceInstruction
import app.revanced.patcher.patch.bytecodePatch
import app.revanced.patches.all.misc.transformation.transformInstructionsPatch
import app.revanced.patches.reddit.customclients.continuum.misc.removeClientIdCheckPatch
import app.revanced.patches.shared.misc.string.replaceStringPatch
import com.android.tools.smali.dexlib2.Opcode
import com.android.tools.smali.dexlib2.iface.instruction.OneRegisterInstruction
import com.android.tools.smali.dexlib2.iface.reference.StringReference

@Suppress("unused")
val redditApiBytecodePatch = bytecodePatch(
name = "Reddit API override",
description = "Overrides Reddit User-Agent, Redirect URI, and Client ID",
) {
compatibleWith("org.cygnusx1.continuum", "org.cygnusx1.continuum.debug")

dependsOn(
redditApiResourcePatch,
// Use regex-based replacement for user agent to work across all versions
transformInstructionsPatch(
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  1. Do we really need this? Instead, just fingerprint the method where the UA is built, isnt that enough?

filterMap = filterMap@{ _, _, instruction, instructionIndex ->
if (instruction.opcode != Opcode.CONST_STRING && instruction.opcode != Opcode.CONST_STRING_JUMBO) {
return@filterMap null
}

val stringReference = (instruction as? com.android.tools.smali.dexlib2.iface.instruction.ReferenceInstruction)
?.reference as? StringReference ?: return@filterMap null

val pattern = "android:org\\.cygnusx1\\.continuum:\\d+\\.\\d+\\.\\d+\\.\\d+ \\(by /u/edgan\\)".toRegex()
if (!pattern.matches(stringReference.string)) return@filterMap null

Triple(instructionIndex, instruction as OneRegisterInstruction, stringReference.string)
},
transform = { mutableMethod, entry ->
val (instructionIndex, instruction, _) = entry
mutableMethod.replaceInstruction(
instructionIndex,
"${instruction.opcode.name} v${instruction.registerA}, \"${Constants.NEW_USER_AGENT}\"",
)
},
),
replaceStringPatch(Constants.OLD_REDIRECT_URI, Constants.NEW_REDIRECT_URI),
removeClientIdCheckPatch
)

execute {
// The actual replacements are handled by the dependencies:
// - redditApiResourcePatch: modifies default_client_id in res/values/strings.xml
// - transformInstructionsPatch: replaces user agent with regex pattern matching
// - replaceStringPatch: replaces hardcoded redirect URI strings
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
package app.revanced.patches.reddit.customclients.continuum.api

import app.revanced.patcher.patch.resourcePatch
import app.revanced.util.findElementByAttributeValueOrThrow

val redditApiResourcePatch = resourcePatch(
name = "Reddit API override (resource)",
description = "Overrides Reddit client ID in strings.xml",
Copy link
Member

@oSumAtrIX oSumAtrIX Jan 15, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why not just patch the bytecode that obtains the default client id string

) {
compatibleWith("org.cygnusx1.continuum", "org.cygnusx1.continuum.debug")

execute {
document("res/values/strings.xml").use { document ->
document.documentElement.childNodes.findElementByAttributeValueOrThrow(
"name",
"default_client_id"
).textContent = Constants.NEW_CLIENT_ID
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
package app.revanced.patches.reddit.customclients.continuum.misc

import app.revanced.patcher.extensions.InstructionExtensions.getInstruction
import app.revanced.patcher.extensions.InstructionExtensions.removeInstructions
import app.revanced.patcher.fingerprint
import app.revanced.patcher.patch.bytecodePatch
import app.revanced.util.getReference
import app.revanced.util.indexOfFirstInstructionOrThrow
import app.revanced.util.indexOfFirstInstructionReversedOrThrow
import com.android.tools.smali.dexlib2.Opcode
import com.android.tools.smali.dexlib2.iface.reference.MethodReference
import com.android.tools.smali.dexlib2.iface.reference.StringReference
import com.android.tools.smali.dexlib2.iface.reference.TypeReference

internal val clientIdCheckFingerprint = fingerprint {
strings("client_id_pref_key")
}

@Suppress("unused")
val removeClientIdCheckPatch = bytecodePatch(
name = "Remove client ID check",
description = "Removes the dialog that prevents login with default client ID",
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The entire PR can be simplified a little. Instead of this, simply patch the code that actually makes the api calls or the place where the client id etc is saved. This way whatever the user enters, it would be overwritten by the patch.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The spoof client base patch can be adjusted to not just only spoof the client id, but also the redirect uri and user agent. Parametrize/adjust that patch accordingly. Then, implement it for continuum, done.

) {
compatibleWith("org.cygnusx1.continuum", "org.cygnusx1.continuum.debug")

execute {
// Find all classes and methods containing "client_id_pref_key"
var targetMethod: com.android.tools.smali.dexlib2.iface.Method? = null
var targetClassDef: com.android.tools.smali.dexlib2.iface.ClassDef? = null

var methodsWithClientIdPrefKey = 0
var methodsWithDialog = 0

classes.forEach { classDef ->
classDef.methods.forEach { method ->
// Check if method contains "client_id_pref_key" string
val hasClientIdPrefKey = method.implementation?.instructions?.any { instruction ->
instruction.getReference<StringReference>()?.string == "client_id_pref_key"
} == true

if (hasClientIdPrefKey) {
methodsWithClientIdPrefKey++

// Check if this method also has MaterialAlertDialogBuilder
val hasDialog = method.implementation?.instructions?.any {
it.opcode == Opcode.NEW_INSTANCE &&
it.getReference<TypeReference>()?.toString()?.contains("MaterialAlertDialogBuilder") == true
} == true

if (hasDialog) {
methodsWithDialog++
targetMethod = method
targetClassDef = classDef
return@forEach
}
}
}
if (targetMethod != null) return@forEach
}

if (targetMethod == null || targetClassDef == null) {
throw Exception("Could not find MainActivity method with client ID check")
}

// Now patch the method
val mutableMethod = proxy(targetClassDef).mutableClass
.methods.first { it.name == targetMethod!!.name && it.parameterTypes == targetMethod!!.parameterTypes }


mutableMethod.apply {
// First find where "client_id_pref_key" is loaded
val clientIdPrefKeyIndex = indexOfFirstInstructionOrThrow {
opcode == Opcode.CONST_STRING &&
getReference<StringReference>()?.string == "client_id_pref_key"
}

// Find the String.equals call that comes AFTER the client_id_pref_key reference
val equalsIndex = indexOfFirstInstructionOrThrow(clientIdPrefKeyIndex) {
opcode == Opcode.INVOKE_VIRTUAL &&
getReference<MethodReference>()?.toString()?.contains("Ljava/lang/String;->equals") == true
}

// The next instruction is move-result
val moveResultIndex = equalsIndex + 1

// After that is if-eqz that branches on the equals result
val ifEqzIndex = moveResultIndex + 1

// Verify that this if-eqz is followed by MaterialAlertDialogBuilder
// to ensure we're removing the right code
val dialogCheck = indexOfFirstInstructionOrThrow(ifEqzIndex) {
opcode == Opcode.NEW_INSTANCE &&
getReference<TypeReference>()?.toString()?.contains("MaterialAlertDialogBuilder") == true
}

// Find the iget-object that loads this$0 - work backwards from equals
// Try to find it by looking for iget-object that references MainActivity
var igetObjectIndex = -1
try {
igetObjectIndex = indexOfFirstInstructionReversedOrThrow(equalsIndex - 1) {
opcode == Opcode.IGET_OBJECT &&
(toString().contains("this\$0") || toString().contains("MainActivity"))
}
} catch (e: Exception) {
// If we can't find MainActivity reference, just find the first iget-object
igetObjectIndex = indexOfFirstInstructionReversedOrThrow(equalsIndex - 1) {
opcode == Opcode.IGET_OBJECT
}
}

// Find where the normal flow resumes (new Intent creation for LoginActivity)
// We need to find the Intent that comes AFTER the dialog code
// Look for the MaterialAlertDialogBuilder first, then find Intent after it
val dialogBuilderIndex = indexOfFirstInstructionOrThrow(ifEqzIndex) {
opcode == Opcode.NEW_INSTANCE &&
getReference<TypeReference>()?.toString()?.contains("MaterialAlertDialogBuilder") == true
}

val intentIndex = indexOfFirstInstructionOrThrow(dialogBuilderIndex) {
opcode == Opcode.NEW_INSTANCE &&
getReference<TypeReference>()?.toString()?.contains("Landroid/content/Intent;") == true
}

// Double-check: look at a few instructions after to see if it's LoginActivity
val nextInstruction = getInstruction(intentIndex + 1)

// So remove from igetObjectIndex to intentIndex (exclusive)
val count = intentIndex - igetObjectIndex
removeInstructions(igetObjectIndex, count)
}
}
}