Skip to content

Restore compatibility with current Chromium browsers - #306

Open
JingMatrix wants to merge 2 commits into
masterfrom
chromium-compat
Open

Restore compatibility with current Chromium browsers#306
JingMatrix wants to merge 2 commits into
masterfrom
chromium-compat

Conversation

@JingMatrix

@JingMatrix JingMatrix commented Aug 7, 2026

Copy link
Copy Markdown
Owner

ChromeXt had stopped working on every browser it supports. The module still loaded, but UserScriptProxy's class initializer threw a NullPointerException that surfaced as ExceptionInInitializerError out of MainHook, and since Kotlin evaluates an object's properties eagerly, that one failure took every hook down with it. Repairing the boot path uncovered a second layer of breakage in the menu, settings and page-info integrations, so this branch works through all of it.

The underlying cause is worth spelling out, because it explains most of what follows. getDeclaredFields() and getDeclaredMethods() hand back members in DEX order, which is sorted by name string, while R8 assigns those names in declaration order. For years the two happened to agree closely enough that slicing declaredFields positionally looked correct, and recent Chromium releases finally moved enough fields around for the illusion to collapse. utils/Reflect.kt now carries r8Rank(), which maps an obfuscated name back to its position in R8's naming sequence: the first character comes from a 52 letter alphabet and every further character from a 62 character one, least significant first, so names run a, b, … z, A, … Z, a0, b0, … Z0, a1, … Z9, aa, ba. Large classes really do reach the letter suffixed part, so stopping at digit suffixes would have mis-ranked half of Whale's members. With firstDeclared() built on top, mId and mIsLoading are found again as TabImpl.b and TabImpl.A.

The rest of the work follows from two habits that had crept in. The first is throwing from an object initializer, which turns a single missing member into a dead feature. Reflective members that aren't needed when the hooks are installed are now lazy and nullable, and their callers degrade instead of failing. UserScriptProxy.mId is dead weight whenever getId() exists, which is every current Chrome, and PreferenceProxy's members are only read once the settings screen is open. That second change also fixes Edge, where enumerating androidx.preference.Preference forces resolution of every parameter type its methods mention, and one of those lives in a split that hasn't been loaded yet at hook time.

The other habit is reaching for positional indexes, or for names, where neither is guaranteed. MainHook now hooks every WindowAndroid constructor that takes a Context rather than declaredConstructors[1]; browsers disagree on how many constructors that class has, and Whale has no one argument overload at all, so index 1 there was a test only constructor the browser never calls, which is why Whale was completely inert. In the same spirit, onMenuOrKeyboardAction is matched structurally on (int, boolean, …) -> boolean because Chromium keeps appending trailing arguments, MVCListAdapter.ListItem is built by picking the constructor on parameter types since R8 swapped them, dividers are located by a menu id ending in divider_line_id after M151 renumbered AppMenuItemType.DIVIDER from 5 to 7, and the page-info entry is found by info_menu_id on both the MVC and legacy paths instead of being assumed to be the fourth icon. That last assumption is what made ChromeXt paint its reader-mode book over a user configurable quick command on Vivaldi. PageInfoProxy no longer reaches for obfuscated fields at all, and where Chrome M153 hoisted buildModelForStandardMenuItem out of the delegate entirely, the model is rebuilt from the key set of an existing standard row.

Brave needed two more of the same kind. It declares several zero argument ModelList getters on its menu delegate, so matching buildMenuModelList on the signature alone picked whichever name sorted first, which on 1.93.130 is a small Brave specific list rather than the real menu, and the entries went somewhere nothing renders. The superclass declares that method abstract and an override keeps the name, so the name of the abstract declaration is the anchor. Brave's MaterialButton also loses setIcon to obfuscation, leaving only the inherited setBackground overloads named, so the reader mode icon is matched on its (Drawable)void signature instead, and setting it is best effort since the id is what actually wires the button up.

One structural change is worth calling out on its own: installing the settings hook and installing the menu hook are separate concerns, and a browser that cannot host our preferences should still get its menu entries. Edge is exactly that case, since it builds its settings programmatically and its PreferenceFragmentCompat carries no addPreferencesFromResource for us to hook. Letting that failure cascade was costing Edge its menu as well. Those initHooks calls are now guarded individually, with ContextMenuHook left as the fallback for the menu alone.

There is also a small, long-lived bug that had nothing to do with Chromium: isVivaldi used == where every other browser flag uses startsWith, so com.vivaldi.browser.snapshot was never recognised as Vivaldi despite being listed in supportedPackages. And when the mIsLoading field cannot be located at all, loading state is now tracked by hooking loadingStateChanged(boolean), a name obfuscation preserves because it is called from native code.

Everything was checked on a Pixel 6 by installing each browser and reading the module's log, and where a menu is involved by opening it and scrolling to the bottom before looking, since the ChromeXt entry sits below the fold on a long menu and a dump taken without scrolling cannot tell a missing entry from an off screen one. The Eruda console entry is present in the app menu on Chrome 151, Canary 153, Brave 1.93.130 and Nightly 1.95.46, Vivaldi 8.1, Cromite 148 and Kiwi 137, and in the page info dialog on Edge 150 and Cốc Cốc 155. Userscripts were seen executing with the console bridge live on Chrome, Brave and Whale, and all three hooks install cleanly on Chrome Beta 152 and Dev 153, Brave Beta 1.94.104, Herond 2.6.8, Whale 3.9.14.9, Vivaldi snapshot 8.2.4110.4 and Edge Canary 153.

Two caveats on the menu entry specifically. Whale installs every hook and runs userscripts, but it has replaced Chromium's app menu with a bespoke Naver one, so there is nothing for the menu integration to attach to and no entry appears there. On Herond the hooks install and the menu inflation runs, but its bottom toolbar is drawn in Compose and exposes no ids to uiautomator, so I could not open the menu to confirm the entry renders; that one is untested rather than known good.

Closes #263, #236, #288, #271, #290, #277 and #220.

Every supported browser had gone inert. UserScriptProxy's class initializer
threw a NullPointerException, that surfaced as ExceptionInInitializerError out
of MainHook, and it took every hook down with it. Repairing the boot path then
exposed a further set of breakages in the menu, settings and page-info
integrations. Verified on a Pixel 6 against 16 installed browser builds.

Four rules now underpin the reflection code. Each replaces a pattern that had
quietly rotted as Chromium moved on, so they are worth stating explicitly.

1. Recover declaration order, never trust member order.

getDeclaredFields() and getDeclaredMethods() return DEX order, which is sorted
by name string, whereas R8 hands out names in declaration order. The two
coincided often enough that slicing declaredFields positionally looked correct
for years, and stopped being correct the moment a class grew past the point
where "a" < "a0" < "b" stops tracking the source. utils/Reflect.kt now offers
r8Rank(), mapping an obfuscated name back to its index in R8's pool: the first
character comes from a 52 letter alphabet and every further character from a 62
character one, least significant first, so the sequence runs a, b, ... z, A, ...
Z, a0, b0, ... Z0, a1, ... Z9, aa, ba. Big classes really do reach the letter
suffixed part, so stopping at digits would mis-rank half of Whale's members.
firstDeclared() builds on it. This is how mId and mIsLoading are located again,
as TabImpl.b and TabImpl.A respectively.

2. Never throw from an `object` initializer.

Kotlin evaluates every property of an object eagerly, so one missing member used
to kill an entire feature. Reflective members that are not needed at hook
installation time are now `by lazy` and nullable, and their callers degrade
instead of failing. UserScriptProxy.mId is dead weight whenever getId() exists,
which is every current Chrome. PreferenceProxy's members are only read once the
settings screen is open, which additionally fixes Edge: enumerating
androidx.preference.Preference forces resolution of every parameter type its
methods mention, and one of those lives in a split that is not loaded yet when
the hooks are installed.

3. Anchor on structure and on names that survive obfuscation, never on indexes.

  - MainHook hooks every WindowAndroid constructor that takes a Context rather
    than declaredConstructors[1]. Browsers disagree on how many constructors
    that class has, and Whale has no one argument overload at all, so index 1
    there was a test only constructor the browser never calls. The overloads
    delegate into one another, so hooking all of them merely means the callback
    can run twice, which Chrome.init and initHooks already absorb.
  - onMenuOrKeyboardAction matches on (int, boolean, ...) -> boolean with
    parameterCount >= 2, because Chromium keeps appending trailing arguments.
  - MVCListAdapter.ListItem is constructed by selecting the constructor on
    parameter types and passing arguments in whatever order it declares; R8
    swapped them.
  - Menu dividers are found by a menu id whose resource name ends in
    divider_line_id, since M151 renumbered AppMenuItemType.DIVIDER from 5 to 7.
  - The page info entry in the icon row is found by info_menu_id on both the MVC
    and the legacy prepareMenu path. Taking the fourth icon on faith is what made
    ChromeXt overwrite a user configurable quick command on Vivaldi (#290).
  - PageInfoProxy no longer reaches for obfuscated fields at all. It brackets one
    dialog build around PageInfoController's single static show method, keeps the
    first PageInfoRowView constructed inside that bracket and uses its parent as
    the row container, and captures the controller from the @CalledByNative
    callbacks whose names R8 preserves. The old field lookups cannot work: CocCoc
    inflates the container by resource id without storing it, and R8 inlined
    PageInfoController's constructor into the static factory on both Edge and
    CocCoc.
  - Brave's icon row footer moved from onFooterViewInflated(handler, view) to a
    one argument factory returning the view, and its buttons became
    MaterialButtons resolved by id, so the bookmark button is looked up by
    bookmark_this_page_id instead of by child position and type.
  - Chrome M153 hoisted buildModelForStandardMenuItem out of the delegate; when
    it is absent the model is rebuilt from the key set of an existing STANDARD
    row. Only a STANDARD template will do, because the key set is copied
    wholesale and a button row template carries keys we never fill in.

4. Keep independent features independent.

Installing the settings hook and installing the menu hook are separate concerns,
and a browser that cannot host our preferences must still get its menu entries.
Edge is exactly that case: it builds its settings programmatically, so its
PreferenceFragmentCompat carries no addPreferencesFromResource for us to hook,
and letting that failure cascade cost Edge its menu as well. initHooks calls are
now guarded individually, with ContextMenuHook remaining the fallback for the
menu alone.

Also: isVivaldi used == where every other browser flag uses startsWith, so
com.vivaldi.browser.snapshot was never recognised as Vivaldi despite being in
supportedPackages (#277). The loading state is tracked by hooking
loadingStateChanged(boolean), whose name survives obfuscation because it is
called from native code, whenever the mIsLoading field cannot be located.

Fixes #263, #236 (Edge), #288, #271 (Brave), #290, #277 (Vivaldi), #220 (CocCoc).

Working and device verified: Chrome 151, Beta 152, Dev and Canary 153, Brave
1.93/1.94/1.95, Herond 2.6.8, Whale 3.9.14.9, Vivaldi 8.1 and snapshot 8.2,
Cromite 148, Kiwi 137, CocCoc 155, Edge 150 and Canary 153.

Still open: Samsung Internet 30 ships no org/chromium/chrome/** at all and only
reaches ContextMenuHook; #304 (blank GitHub pages on Cromite) did not reproduce
on a clean Cromite 148 profile.
Brave declares several zero-argument ModelList getters on its app menu
delegate, so matching buildMenuModelList on the signature alone picked
whichever sorted first. On 1.93.130 that is sn2.b0(), a small Brave-specific
list of 892 instructions, rather than sn2.f() at 7532, and since the entries
went into a list nothing ever renders they simply never appeared. The
superclass declares buildMenuModelList abstract and an override keeps the
name, so match on that name instead: jd0 declares `abstract f()Ljta;` and
qx0 declares `abstract f()Lsaf;` on Chrome 151, so the anchor holds for both.

Brave's MaterialButton also loses setIcon to obfuscation, leaving only the
inherited setBackground overloads named, so the reader mode icon is matched
on its (Drawable)void signature with the background setters excluded. That
lookup threw NoSuchMethodException every time the menu opened. Setting the
icon is now best effort, because the id is what actually wires the button to
readerMode and it should survive a browser whose icon setter we cannot name.

Verified by opening and scrolling the app menu on device: the Eruda console
entry is present on Brave 1.93.130 and Nightly 1.95.46, and still present on
Chrome 151, Canary 153, Vivaldi 8.1, Cromite 148 and Kiwi 137.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment