Skip to content
Merged
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
2 changes: 1 addition & 1 deletion patches-list.json
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@
],
"compatiblePackages": {
"com.google.android.apps.magazines": [
"5.158.0.908428942"
"5.161.0.931240252"
]
},
"options": []
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import app.morphe.patcher.patch.AppTarget
import app.morphe.patcher.patch.Compatibility
import app.morphe.patcher.patch.bytecodePatch
import com.android.tools.smali.dexlib2.Opcode
import com.android.tools.smali.dexlib2.builder.BuilderOffsetInstruction
import com.android.tools.smali.dexlib2.builder.instruction.BuilderInstruction10t
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.reference.FieldReference
Expand All @@ -15,7 +17,7 @@ private val COMPAT = Compatibility(
packageName = "com.google.android.apps.magazines",
appIconColor = 0x4285F4,
targets = listOf(
AppTarget(version = "5.158.0.908428942"),
AppTarget(version = "5.161.0.931240252"),
),
)

Expand All @@ -30,52 +32,48 @@ val enableCustomTabsPatch = bytecodePatch(

execute {
with(InstructionExtensions) {
// Step 1: Patch Ladqf.b() — replace IF_EQZ experiment flag check with NOP.
// Ladqf.b() normally returns the disabled Ladqi picker when the experiment is OFF,
// causing Ladpx.c=null and binding failure. With NOP, it always returns the
// enabled Ladql picker, which uses PackageManager to find a CT-supporting browser.
val laecrMethod = LaecrFingerprint.method
// Step 1: Patch Ladvb.a() — replace IF_EQZ experiment flag check with NOP.
// Ladvb.a() reads Laqrp experiment flag and returns the disabled Ladvf picker
// when the flag is OFF. With NOP, it always returns the enabled Ladvi picker.
val advbMethod = LadvbFingerprint.method
var ifEqzIndex = -1
for ((i, instr) in laecrMethod.implementation!!.instructions.withIndex()) {
for ((i, instr) in advbMethod.implementation!!.instructions.withIndex()) {
if (instr.opcode == Opcode.IF_EQZ) {
ifEqzIndex = i
break
}
}
check(ifEqzIndex != -1) { "IF_EQZ not found in Ladqf.b()" }
laecrMethod.replaceInstruction(ifEqzIndex, "nop")
check(ifEqzIndex != -1) { "IF_EQZ not found in Ladvb.a()" }
advbMethod.replaceInstruction(ifEqzIndex, "nop")

// Step 2: Bypass experiment allowlist in Ladql.a() (enabled Ladqg picker).
// Ladql.a() picks the best CT-supporting browser via resolveActivity, then checks
// if it is in the experiment allowlist. Non-Chrome browsers are not in that list.
// NOP-ing the IF_EQZ makes it always return whatever resolveActivity found.
LaecxFingerprint.methodOrNull?.let { method ->
// Step 2: Bypass experiment allowlist in Ladvi.a() (enabled browser picker).
// Ladvi.a() resolves the OS default browser, then checks if it's in the experiment
// allowlist. NOP-ing the IF_EQZ makes it always return whatever the OS resolved.
LadviFingerprint.methodOrNull?.let { method ->
val idx = method.implementation!!.instructions.indexOfFirst {
it.opcode == Opcode.IF_EQZ
}
if (idx != -1) method.replaceInstruction(idx, "nop")
}

// Step 3: Bypass allowlist in Ladqi.a() (disabled Ladqg picker, belt+suspenders).
// Ladqi.a() filters installed browsers against the allowlist first.
// NOP-ing the IF_EQZ always attempts resolveActivity so the default browser is tried.
LaecuFingerprint.methodOrNull?.let { method ->
// Step 3: Bypass allowlist-only filtering in Ladvf.a() (disabled picker, belt+suspenders).
// Ladvf.a() returns null when no installed browser matches the allowlist. NOP-ing the
// IF_EQZ always proceeds to compute a candidate instead of bailing out early.
LadvfFingerprint.methodOrNull?.let { method ->
val idx = method.implementation!!.instructions.indexOfFirst {
it.opcode == Opcode.IF_EQZ
}
if (idx != -1) method.replaceInstruction(idx, "nop")
}

// Step 4: In every method that reads Ladpx.i, replace iget-boolean Ladpx;->i:Z
// Step 4: In every method that reads Ladut.i, replace iget-boolean Ladut;->i:Z
// with const/4 vX, 0x1 so the CustomTabs branch is always taken.
// Covers click handlers, navigation, ReadNow, and CustomTabsTrampolineActivity.
val methods = listOf(
LajdkFingerprint.method,
LaedzFingerprint.methodOrNull,
LajdrFingerprint.methodOrNull,
LajgzFingerprint.methodOrNull,
LajhkFingerprint.methodOrNull,
CustomTabsTrampolineFingerprint.methodOrNull,
LajdqFingerprint.method,
LadwcFingerprint.methodOrNull,
LajdxFingerprint.methodOrNull,
LajheFingerprint.methodOrNull,
LajhlFingerprint.methodOrNull,
).filterNotNull()

for (method in methods) {
Expand All @@ -84,7 +82,7 @@ val enableCustomTabsPatch = bytecodePatch(
for (instr in method.implementation!!.instructions) {
if (instr.opcode == Opcode.IGET_BOOLEAN) {
val ref = (instr as ReferenceInstruction).reference
if (ref is FieldReference && ref.definingClass == "Ladpx;" && ref.name == "i") {
if (ref is FieldReference && ref.definingClass == "Ladut;" && ref.name == "i") {
targets.add(index)
}
}
Expand All @@ -95,6 +93,28 @@ val enableCustomTabsPatch = bytecodePatch(
method.replaceInstruction(i, "const/4 v$reg, 0x1")
}
}

// Step 5: CustomTabsTrampolineActivity.onCreate() reads field a (Ladvd), calls
// Ladvd.a(), and does an IF_NEZ on the (possibly null) result; if null, it logs
// "Unexpected intent; activity is not enabled" and finishes. Convert the IF_NEZ into
// an unconditional GOTO to its existing target so the enabled path is always taken.
val trampolineMethod = CustomTabsTrampolineFingerprint.method
val fieldReadIndex = trampolineMethod.implementation!!.instructions.indexOfFirst {
if (it.opcode != Opcode.IGET_OBJECT) return@indexOfFirst false
val ref = (it as ReferenceInstruction).reference
ref is FieldReference && ref.definingClass ==
"Lcom/google/apps/dots/android/modules/reading/customtabs/CustomTabsTrampolineActivity;" &&
ref.name == "a" && ref.type == "Ladvd;"
}
check(fieldReadIndex != -1) { "Field read of CustomTabsTrampolineActivity.a not found" }

val ifNezIndex = trampolineMethod.implementation!!.instructions
.drop(fieldReadIndex)
.indexOfFirst { it.opcode == Opcode.IF_NEZ } + fieldReadIndex
check(ifNezIndex >= fieldReadIndex) { "IF_NEZ not found after CustomTabsTrampolineActivity.a read" }

val target = trampolineMethod.getInstruction<BuilderOffsetInstruction>(ifNezIndex).target
trampolineMethod.replaceInstruction(ifNezIndex, BuilderInstruction10t(Opcode.GOTO, target))
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,73 +4,82 @@ import app.morphe.patcher.Fingerprint
import app.morphe.patcher.fieldAccess
import com.android.tools.smali.dexlib2.Opcode

// All methods in Google News v5.158.0 that read Ladpx;->i:Z (iget-boolean).
// Ladpx = CustomTabsArticleLauncher; field i controls CustomTabs (true) vs WebView (false).
// All methods in Google News v5.161.0 that read Ladut;->i:Z (iget-boolean).
// Ladut = ArticleLauncher; field i controls CustomTabs (true) vs WebView (false).
//
// Verified read sites (DEX bytecode scan):
// classes.dex: Laiud (StartActivity handler)
// classes3.dex: Ladrm (handler)
// Laiuk (handler)
// Laixq (handler)
// Laixz (handler)
// CustomTabsTrampolineActivity (onCreate)
// classes.dex: Lajdq (handler, method D, two read sites)
// classes3.dex: Ladwc (handler)
// Lajdx (handler)
// Lajhe (handler)
// Lajhl (handler)

internal object LajdkFingerprint : Fingerprint(
filters = listOf(fieldAccess("Ladpx;", "i", "Z", Opcode.IGET_BOOLEAN)),
custom = { _, classDef -> classDef.type == "Laiud;" },
internal object LajdqFingerprint : Fingerprint(
filters = listOf(fieldAccess("Ladut;", "i", "Z", Opcode.IGET_BOOLEAN)),
custom = { _, classDef -> classDef.type == "Lajdq;" },
)

internal object LaedzFingerprint : Fingerprint(
filters = listOf(fieldAccess("Ladpx;", "i", "Z", Opcode.IGET_BOOLEAN)),
custom = { _, classDef -> classDef.type == "Ladrm;" },
internal object LadwcFingerprint : Fingerprint(
filters = listOf(fieldAccess("Ladut;", "i", "Z", Opcode.IGET_BOOLEAN)),
custom = { _, classDef -> classDef.type == "Ladwc;" },
)

internal object LajdrFingerprint : Fingerprint(
filters = listOf(fieldAccess("Ladpx;", "i", "Z", Opcode.IGET_BOOLEAN)),
custom = { _, classDef -> classDef.type == "Laiuk;" },
internal object LajdxFingerprint : Fingerprint(
filters = listOf(fieldAccess("Ladut;", "i", "Z", Opcode.IGET_BOOLEAN)),
custom = { _, classDef -> classDef.type == "Lajdx;" },
)

internal object LajgzFingerprint : Fingerprint(
filters = listOf(fieldAccess("Ladpx;", "i", "Z", Opcode.IGET_BOOLEAN)),
custom = { _, classDef -> classDef.type == "Laixq;" },
internal object LajheFingerprint : Fingerprint(
filters = listOf(fieldAccess("Ladut;", "i", "Z", Opcode.IGET_BOOLEAN)),
custom = { _, classDef -> classDef.type == "Lajhe;" },
)

internal object LajhkFingerprint : Fingerprint(
filters = listOf(fieldAccess("Ladpx;", "i", "Z", Opcode.IGET_BOOLEAN)),
custom = { _, classDef -> classDef.type == "Laixz;" },
internal object LajhlFingerprint : Fingerprint(
filters = listOf(fieldAccess("Ladut;", "i", "Z", Opcode.IGET_BOOLEAN)),
custom = { _, classDef -> classDef.type == "Lajhl;" },
)

// CustomTabsTrampolineActivity.onCreate() reads Ladpx.i; if i==0 it logs
// "Unexpected intent; activity is not enabled" and finishes. Patch i-read to always true.
internal object CustomTabsTrampolineFingerprint : Fingerprint(
filters = listOf(fieldAccess("Ladpx;", "i", "Z", Opcode.IGET_BOOLEAN)),
custom = { _, classDef ->
classDef.type == "Lcom/google/apps/dots/android/modules/reading/customtabs/CustomTabsTrampolineActivity;"
},
// Ladvb.a(Laqxf, Laqxf) — experiment flag switch.
// Reads Laqrp;->a:Laqrp; (experiment flag), calls .a().f() → boolean.
// IF_EQZ branches to the disabled Ladvf picker when the flag is OFF.
// NOP-ing the IF_EQZ forces the enabled Ladvi path (resolveActivity-based browser picker).
internal object LadvbFingerprint : Fingerprint(
filters = listOf(fieldAccess("Laqrp;", "a", "Laqrp;", Opcode.SGET_OBJECT)),
custom = { _, classDef -> classDef.type == "Ladvb;" },
)

// Ladqf.b() returns a Ladqg implementation based on experiment flag Laqhh.a.get().
// When the experiment is OFF, IF_EQZ branches to the disabled Ladqi (filters all browsers
// against an allowlist, returns null if none match), causing Ladpx.c=null → binding failure.
// Patching the IF_EQZ to NOP forces the enabled Ladql path (picks browser via resolveActivity).
internal object LaecrFingerprint : Fingerprint(
filters = listOf(fieldAccess("Laqhh;", "a", "Laqhh;", Opcode.SGET_OBJECT)),
custom = { _, classDef -> classDef.type == "Ladqf;" },
// Ladvi.a() — "enabled" Ladvd impl: resolves the OS default browser via Leu.a(...), then checks
// if it's in the experiment allowlist (Laqrp). IF_EQZ skips the immediate return when the default
// browser isn't allowlisted (e.g. Firefox/Brave), falling back to an allowlist-only candidate list.
// NOP-ing it always returns the OS default browser, regardless of the allowlist.
internal object LadviFingerprint : Fingerprint(
filters = listOf(fieldAccess("Ladvi;", "a", "Landroid/content/Context;", Opcode.IGET_OBJECT)),
custom = { _, classDef -> classDef.type == "Ladvi;" },
)

// Ladqi.a() — "disabled" Ladqg: filters installed browsers against an experiment allowlist,
// returns null if none match. IF_EQZ returns null when the filtered list is empty.
// NOP-ing it forces the fallback resolveActivity call which picks the default browser.
internal object LaecuFingerprint : Fingerprint(
filters = listOf(fieldAccess("Ladqi;", "a", "Landroid/content/Context;", Opcode.IGET_OBJECT)),
custom = { _, classDef -> classDef.type == "Ladqi;" },
// Ladvf.a() — "disabled" Ladvd impl: filters installed browsers against the experiment allowlist
// only (no default-browser fallback), returns null if the filtered list is empty. IF_EQZ returns
// null in that case. NOP-ing it always proceeds to compute a candidate from the (possibly empty)
// filtered list instead of bailing out early.
internal object LadvfFingerprint : Fingerprint(
filters = listOf(fieldAccess("Ladvf;", "a", "Landroid/content/Context;", Opcode.IGET_OBJECT)),
custom = { _, classDef -> classDef.type == "Ladvf;" },
)

// Ladql.a() — "enabled" Ladqg: picks the best CT-supporting browser via resolveActivity,
// then checks if it's in the experiment allowlist. IF_EQZ redirects to allowlist-only path
// when the default browser isn't allowlisted (e.g. Firefox/Brave). NOP-ing it always returns
// whatever resolveActivity found (default browser has priority).
internal object LaecxFingerprint : Fingerprint(
filters = listOf(fieldAccess("Ladql;", "a", "Landroid/content/Context;", Opcode.IGET_OBJECT)),
custom = { _, classDef -> classDef.type == "Ladql;" },
// CustomTabsTrampolineActivity.onCreate() reads field a (Ladvd) and calls a()Ljava/lang/String;
// then does if-nez on the result; if null, it logs "Unexpected intent; activity is not enabled"
// and finishes. Patch the if-nez into an unconditional goto to its existing target so the
// enabled path is always taken regardless of what Ladvd.a() returns.
internal object CustomTabsTrampolineFingerprint : Fingerprint(
filters = listOf(
fieldAccess(
"Lcom/google/apps/dots/android/modules/reading/customtabs/CustomTabsTrampolineActivity;",
"a",
"Ladvd;",
Opcode.IGET_OBJECT,
),
),
custom = { _, classDef ->
classDef.type == "Lcom/google/apps/dots/android/modules/reading/customtabs/CustomTabsTrampolineActivity;"
},
)
Loading