Restore compatibility with current Chromium browsers - #306
Open
JingMatrix wants to merge 2 commits into
Open
Conversation
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.
This was
linked to
issues
Aug 7, 2026
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
ChromeXt had stopped working on every browser it supports. The module still loaded, but
UserScriptProxy's class initializer threw aNullPointerExceptionthat surfaced asExceptionInInitializerErrorout ofMainHook, and since Kotlin evaluates anobject'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()andgetDeclaredMethods()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 slicingdeclaredFieldspositionally looked correct, and recent Chromium releases finally moved enough fields around for the illusion to collapse.utils/Reflect.ktnow carriesr8Rank(), 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. WithfirstDeclared()built on top,mIdandmIsLoadingare found again asTabImpl.bandTabImpl.A.The rest of the work follows from two habits that had crept in. The first is throwing from an
objectinitializer, 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.mIdis dead weight whenevergetId()exists, which is every current Chrome, andPreferenceProxy's members are only read once the settings screen is open. That second change also fixes Edge, where enumeratingandroidx.preference.Preferenceforces 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.
MainHooknow hooks everyWindowAndroidconstructor that takes aContextrather thandeclaredConstructors[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,onMenuOrKeyboardActionis matched structurally on(int, boolean, …) -> booleanbecause Chromium keeps appending trailing arguments,MVCListAdapter.ListItemis built by picking the constructor on parameter types since R8 swapped them, dividers are located by a menu id ending individer_line_idafter M151 renumberedAppMenuItemType.DIVIDERfrom 5 to 7, and the page-info entry is found byinfo_menu_idon 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.PageInfoProxyno longer reaches for obfuscated fields at all, and where Chrome M153 hoistedbuildModelForStandardMenuItemout 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
buildMenuModelListon 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'sMaterialButtonalso losessetIconto obfuscation, leaving only the inheritedsetBackgroundoverloads named, so the reader mode icon is matched on its(Drawable)voidsignature 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
PreferenceFragmentCompatcarries noaddPreferencesFromResourcefor us to hook. Letting that failure cascade was costing Edge its menu as well. ThoseinitHookscalls are now guarded individually, withContextMenuHookleft as the fallback for the menu alone.There is also a small, long-lived bug that had nothing to do with Chromium:
isVivaldiused==where every other browser flag usesstartsWith, socom.vivaldi.browser.snapshotwas never recognised as Vivaldi despite being listed insupportedPackages. And when themIsLoadingfield cannot be located at all, loading state is now tracked by hookingloadingStateChanged(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.