diff --git a/app/src/main/java/org/matrix/chromext/Chrome.kt b/app/src/main/java/org/matrix/chromext/Chrome.kt index 249bf0a3..5553cc08 100644 --- a/app/src/main/java/org/matrix/chromext/Chrome.kt +++ b/app/src/main/java/org/matrix/chromext/Chrome.kt @@ -66,7 +66,8 @@ object Chrome { isMi = packageName == "com.mi.globalbrowser" || packageName == "com.android.browser" isQihoo = packageName == "com.qihoo.contents" isSamsung = packageName.startsWith("com.sec.android.app.sbrowser") - isVivaldi = packageName == "com.vivaldi.browser" + // com.vivaldi.browser.snapshot is supported too, and it needs the same special cases as stable + isVivaldi = packageName.startsWith("com.vivaldi.browser") @Suppress("DEPRECATION") val packageInfo = ctx.packageManager?.getPackageInfo(packageName, 0) version = packageInfo?.versionName version = (if (version?.startsWith("v") == true) "" else "v") + version diff --git a/app/src/main/java/org/matrix/chromext/Listener.kt b/app/src/main/java/org/matrix/chromext/Listener.kt index 0ba6fca8..885c73ba 100644 --- a/app/src/main/java/org/matrix/chromext/Listener.kt +++ b/app/src/main/java/org/matrix/chromext/Listener.kt @@ -17,6 +17,8 @@ import android.os.Handler import android.webkit.WebView import java.io.File import java.io.FileReader +import java.lang.reflect.Method +import java.lang.reflect.Modifier import java.net.HttpURLConnection import java.net.URL import org.json.JSONArray @@ -37,6 +39,7 @@ import org.matrix.chromext.utils.ERUD_URL import org.matrix.chromext.utils.Log import org.matrix.chromext.utils.XMLHttpRequest import org.matrix.chromext.utils.findMethod +import org.matrix.chromext.utils.findMethodOrNull import org.matrix.chromext.utils.invalidUserScriptUrls import org.matrix.chromext.utils.invokeMethod import org.matrix.chromext.utils.isChromeXtFrontEnd @@ -88,6 +91,61 @@ object Listener { return true } + // TabModel#closeTab(Tab tab, Tab recommendedNextTab, boolean...[, int]), dropped by Chrome 131 + // but still shipped by older forks. + private fun findLegacyCloseTab(model: Class<*>, tab: Class<*>): Method? { + fun Method.closesTab() = + parameterTypes.firstOrNull() == tab && + parameterTypes.drop(1).all { + it == tab || it == Boolean::class.java || it == Int::class.java + } + return findMethodOrNull(model, true) { name == "closeTab" && closesTab() } + ?: findMethodOrNull(model, true) { + returnType == Boolean::class.java && parameterTypes.size >= 5 && closesTab() + } + } + + // TabClosureParams is the only class offering a static Tab factory whose result builds one back, + // a shape that survives the renaming of all three classes involved. + private fun isTabClosureParams(clazz: Class<*>, tab: Class<*>): Boolean = + clazz.declaredMethods.any { + Modifier.isStatic(it.modifiers) && + it.parameterTypes contentDeepEquals arrayOf(tab) && + it.returnType.declaredMethods.any { build -> + build.parameterTypes.size == 0 && build.returnType == clazz + } + } + + // Since Chrome 131: TabModel#getTabRemover()#forceCloseTabs(TabClosureParams#closeTab(tab)) + private fun forceCloseTab(model: Any, tabModel: Class<*>, tab: Class<*>, currentTab: Any) { + val getTabRemover = + findMethod(tabModel) { + parameterTypes.size == 0 && + returnType.declaredMethods.any { + it.returnType == Void.TYPE && + it.parameterTypes.size == 1 && + isTabClosureParams(it.parameterTypes[0], tab) + } + } + val remover = getTabRemover.invoke(model)!! + val forceCloseTabs = + findMethod(getTabRemover.returnType) { + returnType == Void.TYPE && + parameterTypes.size == 1 && + isTabClosureParams(parameterTypes[0], tab) + } + val closureParams = forceCloseTabs.parameterTypes[0] + val builder = + findMethod(closureParams) { + Modifier.isStatic(modifiers) && parameterTypes contentDeepEquals arrayOf(tab) + } + .invoke(null, currentTab)!! + val params = + findMethod(builder::class.java) { parameterTypes.size == 0 && returnType == closureParams } + .invoke(builder) + forceCloseTabs.invoke(remover, params) + } + private fun checkErudaVerison(ctx: Context, callback: (String?) -> Unit) { val url = URL(ERUD_URL + "@latest/eruda.js") val connection = url.openConnection() as HttpURLConnection @@ -177,19 +235,21 @@ object Listener { parameterTypes.size == 0 && returnType == tabModel } val model = getCurrentTabModel.invoke(activity)!! - val closeTab = - findMethod(model::class.java) { - returnType == Boolean::class.java && - parameterTypes contentDeepEquals - arrayOf( - tab, - tab, - Boolean::class.java, - Boolean::class.java, - Boolean::class.java, - Int::class.java) - } - closeTab.invoke(model, currentTab, null, false, false, false, 0) + val legacyClose = findLegacyCloseTab(model::class.java, tab) + if (legacyClose != null) { + val args = + legacyClose.parameterTypes.mapIndexed { i, type -> + when { + i == 0 -> currentTab + type == tab -> null + type == Boolean::class.java -> false + else -> 0 + } + } + legacyClose.invoke(model, *args.toTypedArray()) + } else { + forceCloseTab(model, tabModel, tab, currentTab) + } } else { val msg = "Closing tab ${currentTab} with context ${activity}" callback = "console.error(new TypeError('ChromeXt Action failure', {cause: '${msg}'}));" diff --git a/app/src/main/java/org/matrix/chromext/MainHook.kt b/app/src/main/java/org/matrix/chromext/MainHook.kt index f33296b6..b6f46881 100644 --- a/app/src/main/java/org/matrix/chromext/MainHook.kt +++ b/app/src/main/java/org/matrix/chromext/MainHook.kt @@ -56,22 +56,42 @@ class MainHook : IXposedHookLoadPackage, IXposedHookZygoteInit { Log.d(lpparam.processName + " started") if (lpparam.packageName == "org.matrix.chromext") return if (supportedPackages.contains(lpparam.packageName)) { - lpparam.classLoader - .loadClass("org.chromium.ui.base.WindowAndroid") - .declaredConstructors[1] - .hookAfter { - Chrome.init(it.args[0] as Context, lpparam.packageName) - initHooks(UserScriptHook) - if (ContextMenuHook.isInit) return@hookAfter - runCatching { - if (!Chrome.isVivaldi) initHooks(PreferenceHook) - initHooks(if (Chrome.isEdge || Chrome.isCocCoc) PageInfoHook else PageMenuHook) - } - .onFailure { - initHooks(ContextMenuHook) - if (BuildConfig.DEBUG && !(Chrome.isSamsung || Chrome.isEdge)) Log.ex(it) - } + // Every constructor taking a Context, rather than a fixed index into an order the JLS leaves + // unspecified: browsers do not agree on how many constructors WindowAndroid has, and Whale in + // particular has no one-argument overload, so index 1 there is a test-only constructor the + // browser never calls and the whole module stays inert. The overloads delegate into one + // another, so hooking all of them just means the callback can run twice, which Chrome.init + // and initHooks both already absorb. + val windowAndroid = lpparam.classLoader.loadClass("org.chromium.ui.base.WindowAndroid") + val constructors = + windowAndroid.declaredConstructors.filter { + val first = it.parameterTypes.firstOrNull() + first != null && Context::class.java.isAssignableFrom(first) } + if (constructors.isEmpty()) Log.e("No WindowAndroid constructor takes a Context") + constructors.forEach { constructor -> + constructor.hookAfter { + Chrome.init(it.args[0] as Context, lpparam.packageName) + // Keep the menu hooks reachable even when the UserScript hook cannot be installed + runCatching { initHooks(UserScriptHook) }.onFailure { Log.ex(it) } + if (ContextMenuHook.isInit) return@hookAfter + // The settings screen and the browser menu are independent features, so a browser that + // cannot host our preferences must still get its menu entries. Edge is exactly that case: + // it builds its settings programmatically, so PreferenceFragmentCompat carries no + // addPreferencesFromResource for PreferenceHook to hook. + if (!Chrome.isVivaldi) { + runCatching { initHooks(PreferenceHook) } + .onFailure { if (BuildConfig.DEBUG && !Chrome.isSamsung) Log.ex(it) } + } + runCatching { + initHooks(if (Chrome.isEdge || Chrome.isCocCoc) PageInfoHook else PageMenuHook) + } + .onFailure { + initHooks(ContextMenuHook) + if (BuildConfig.DEBUG && !(Chrome.isSamsung || Chrome.isEdge)) Log.ex(it) + } + } + } } else { val ctx = AndroidAppHelper.currentApplication() diff --git a/app/src/main/java/org/matrix/chromext/hook/PageInfo.kt b/app/src/main/java/org/matrix/chromext/hook/PageInfo.kt index 3c147fb9..3d3921d0 100644 --- a/app/src/main/java/org/matrix/chromext/hook/PageInfo.kt +++ b/app/src/main/java/org/matrix/chromext/hook/PageInfo.kt @@ -3,7 +3,6 @@ package org.matrix.chromext.hook import android.view.View import android.view.ViewGroup import android.widget.ImageView -import android.widget.LinearLayout import android.widget.TextView import org.matrix.chromext.Chrome import org.matrix.chromext.Listener @@ -18,52 +17,84 @@ object PageInfoHook : BaseHook() { override fun init() { if (ContextMenuHook.isInit) return - var controller: Any? = null val proxy = PageInfoProxy + val newRow = proxy.rowConstructor ?: throw NoSuchMethodException("PageInfoRowView(Context)") + val title = proxy.mTitle ?: throw NoSuchFieldException("PageInfoRowView.mTitle") + val show = proxy.showPageInfo ?: throw NoSuchMethodException("PageInfoController.show") + val destroy = proxy.destroy ?: throw NoSuchMethodException("PageInfoController.destroy") + if (proxy.nativeCallbacks.isEmpty()) throw NoSuchMethodException("PageInfoController callbacks") + + // The row container is looked up by resource id and never kept in a field, and Edge can swap + // the whole PageInfoView for a bottom sheet behind a feature flag, so rather than chase it + // through the controller we keep the first row built during show() and take its parent. + var firstRow: ViewGroup? = null + var controller: Any? = null + var showing = false + var inserted = false - fun addErudaRow(url: String): ViewGroup { - val infoRow = - proxy.pageInfoRowView.declaredConstructors[0].newInstance(Chrome.getContext(), null) - as ViewGroup - infoRow.setVisibility(View.VISIBLE) - val icon = proxy.mIcon.get(infoRow) as ImageView - icon.setImageResource(R.drawable.ic_devtools) - val subTitle = proxy.mSubtitle.get(infoRow) as TextView - (subTitle.getParent() as? ViewGroup)?.removeView(subTitle) - val title = proxy.mTitle.get(infoRow) as TextView + fun erudaRow(url: String, host: Any, parent: ViewGroup): View { + val row = newRow.newInstance(parent.getContext(), null) as ViewGroup + row.setVisibility(View.VISIBLE) + (proxy.mIcon?.get(row) as? ImageView)?.setImageResource(R.drawable.ic_devtools) + val subTitle = proxy.mSubtitle?.get(row) as? TextView + (subTitle?.getParent() as? ViewGroup)?.removeView(subTitle) + val label = title.get(row) as TextView + val onClick: () -> Unit if (isChromeXtFrontEnd(url)) { - title.setText(R.string.main_menu_developer_tools) - infoRow.setOnClickListener { - Listener.on("inspectPages") - controller!!.invokeMethod() { name == "destroy" } - } + label.setText(R.string.main_menu_developer_tools) + onClick = { Listener.on("inspectPages") } } else if (isUserScript(url)) { - title.setText(R.string.main_menu_install_script) - infoRow.setOnClickListener { + label.setText(R.string.main_menu_install_script) + onClick = { val sandBoxed = shouldBypassSandbox(url) Chrome.evaluateJavascript(listOf("Symbol.installScript(true);"), null, null, sandBoxed) - controller!!.invokeMethod() { name == "destroy" } } } else { - title.setText(R.string.main_menu_eruda_console) - infoRow.setOnClickListener { - UserScriptProxy.evaluateJavascript(Local.openEruda) - controller!!.invokeMethod() { name == "destroy" } - } + label.setText(R.string.main_menu_eruda_console) + onClick = { UserScriptProxy.evaluateJavascript(Local.openEruda) } + } + row.setOnClickListener { + onClick() + runCatching { destroy.invoke(host) }.onFailure { Log.ex(it) } } - return infoRow + return row } - proxy.pageInfoControllerRef.declaredConstructors[0].hookAfter { controller = it.thisObject } + fun insertRow(host: Any) { + if (inserted) return + val parent = firstRow?.getParent() as? ViewGroup ?: return + val url = Chrome.getUrl() ?: return + if (isChromeScheme(url)) return + inserted = true + parent.addView(erudaRow(url, host, parent)) + } + + show.hookBefore { + showing = true + inserted = false + firstRow = null + controller = null + } + + newRow.hookAfter { if (showing && firstRow == null) firstRow = it.thisObject as ViewGroup } + + // Native runs these while show() is still on the stack, which is both where the controller + // instance becomes reachable and the earliest point at which the rows are already laid out. + proxy.nativeCallbacks.forEach { callback -> + callback.hookAfter { + if (!showing) return@hookAfter + controller = it.thisObject + insertRow(it.thisObject) + } + } - proxy.pageInfoController.declaredConstructors[0].hookAfter { - val url = Chrome.getUrl()!! - if (isChromeScheme(url) || controller == null) return@hookAfter - (proxy.mRowWrapper.get(proxy.mView.get(it.thisObject)) as LinearLayout).addView( - addErudaRow(url)) + show.hookAfter { + showing = false + controller?.let { host -> insertRow(host) } + firstRow = null + controller = null } - // readerMode.init(Chrome.load("org.chromium.chrome.browser.dom_distiller.ReaderModeManager")) isInit = true } } diff --git a/app/src/main/java/org/matrix/chromext/hook/PageMenu.kt b/app/src/main/java/org/matrix/chromext/hook/PageMenu.kt index e436f419..89c5c801 100644 --- a/app/src/main/java/org/matrix/chromext/hook/PageMenu.kt +++ b/app/src/main/java/org/matrix/chromext/hook/PageMenu.kt @@ -1,6 +1,7 @@ package org.matrix.chromext.hook import android.content.Context +import android.graphics.drawable.Drawable import android.os.Bundle import android.util.DisplayMetrics import android.view.Menu @@ -8,7 +9,6 @@ import android.view.MenuInflater import android.view.MenuItem import android.view.View import android.widget.ImageButton -import android.widget.LinearLayout import de.robv.android.xposed.XC_MethodHook.Unhook import java.lang.reflect.Modifier import java.util.ArrayList @@ -44,7 +44,10 @@ enum class AppMenuItemType(val value: Int) { /** The header for submenus when submenus are displayed in drilldown. */ SUBMENU_HEADER(4), - /** A divider item to distinguish between menu item groupings. */ + /** + * A divider item to distinguish between menu item groupings. Chrome renumbered this to 7 in M151, + * so the value below only serves the forks that kept the original numbering. + */ DIVIDER(5), /** @@ -71,15 +74,18 @@ object readerMode { val ID = 31415926 fun activate() { - @Suppress("UNCHECKED_CAST") - val observers = (PageMenuProxy.mObservers.get(Chrome.getTab()) as Iterable).toList() + val observers = PageMenuProxy.mObservers?.get(Chrome.getTab()) as? Iterable<*> val readerModeManager = - observers.find { + observers?.filterNotNull()?.find { findFieldOrNull(it::class.java) { type == LinkedHashSet::class.java && Modifier.isStatic(modifiers) } != null && findFieldOrNull(it::class.java) { type == PageMenuProxy.propertyModel } != null - }!! + } + if (readerModeManager == null) { + Log.e("No ReaderModeManager is observing the current tab") + return + } readerModeManager::class .java @@ -122,16 +128,19 @@ object PageMenuHook : BaseHook() { "org.matrix.chromext:id/eruda_console_id" -> UserScriptProxy.evaluateJavascript(Local.openEruda) "${ctx.packageName}:id/reload_menu_id" -> { - val isLoading = proxy.mIsLoading.get(Chrome.getTab()) as Boolean - if (!isLoading) return Listener.on("userAgentSpoof", getUrl()) != null + val tab = Chrome.getTab() + if (tab != null && !UserScriptProxy.isLoading(tab)) + return Listener.on("userAgentSpoof", getUrl()) != null } } return false } findMethod(proxy.chromeTabbedActivity) { - // public boolean onMenuOrKeyboardAction(int id, boolean fromMenu, ? triggeringMotion) - (parameterCount == 2 || parameterCount == 3) && + // public boolean onMenuOrKeyboardAction(int id, boolean fromMenu, ...) + // Chrome keeps appending optional trailing arguments, a Bundle and a MotionEventInfo as + // of M151, so only the leading two are worth matching on. + parameterCount >= 2 && parameterTypes[0] == Int::class.java && parameterTypes[1] == Boolean::class.java && returnType == Boolean::class.java @@ -143,8 +152,8 @@ object PageMenuHook : BaseHook() { } findMethod(proxy.customTabActivity) { - // public boolean onMenuOrKeyboardAction(int id, boolean fromMenu, ? triggeringMotion) - (parameterCount == 2 || parameterCount == 3) && + // public boolean onMenuOrKeyboardAction(int id, boolean fromMenu, ...) + parameterCount >= 2 && parameterTypes[0] == Int::class.java && parameterTypes[1] == Boolean::class.java && returnType == Boolean::class.java @@ -191,24 +200,62 @@ object PageMenuHook : BaseHook() { findField(appMenuPropertiesDelegateImpl, true) { type == parameters[1] } if (Chrome.isBrave) { - // Brave browser replaces the first row menu with class AppMenuIconRowFooter, - // and it customize the menu by onFooterViewInflated() function in + // Brave replaces the first row of the menu with AppMenuIconRowFooter, see // https://github.com/brave/brave-core/blob/master/android/java/ // org/chromium/chrome/browser/appmenu/BraveTabbedAppMenuPropertiesDelegate.java - findMethod(tabbedAppMenuPropertiesDelegate, true) { - parameterTypes.size == 2 && getParameterTypes()[1] == View::class.java - } - // public void onFooterViewInflated(AppMenuHandler appMenuHandler, View view) - .hookAfter { - val appMenuIconRowFooter = it.args[1] as LinearLayout - val bookmarkButton = - (appMenuIconRowFooter.getChildAt(1) as LinearLayout).getChildAt(1) as ImageButton - bookmarkButton.setVisibility(View.VISIBLE) - val ctx = mContext.get(it.thisObject) as Context - Resource.enrich(ctx) - bookmarkButton.setImageResource(R.drawable.ic_book) - bookmarkButton.setId(readerMode.ID) + // It used to hand that view to the delegate as onFooterViewInflated(handler, view); since + // 1.93 the delegate returns it from a one argument factory instead, and the row holds + // MaterialButtons looked up by id rather than nested ImageButtons. Everything here is + // best-effort: this only restyles one button, and it must never abort the caller, which is + // what actually adds the ChromeXt entries to the menu. + fun brandBookmarkButton(delegate: Any, footer: View?) { + val ctx = mContext.get(delegate) as Context + Resource.enrich(ctx) + val id = ctx.resources.getIdentifier("bookmark_this_page_id", "id", ctx.packageName) + val button = if (id == 0) null else footer?.findViewById(id) + if (button == null) { + Log.e("No bookmark button in the Brave app menu footer") + return + } + button.setVisibility(View.VISIBLE) + // Only the icon is cosmetic; the id is what makes the button reach readerMode, so a browser + // whose icon setter we cannot name still gets a working button. + runCatching { + if (button is ImageButton) { + button.setImageResource(R.drawable.ic_book) + } else { + // MaterialButton takes a Drawable, and its setIcon does not survive obfuscation, so + // match the signature and keep the inherited background setters out of the way. + button.invokeMethod(ctx.getDrawable(R.drawable.ic_book)) { + name == "setIcon" || + (parameterTypes contentDeepEquals arrayOf(Drawable::class.java) && + returnType == Void.TYPE && + !name.startsWith("setBackground")) + } + } + } + .onFailure { Log.e("Cannot set the reader mode icon: ${it}") } + button.setId(readerMode.ID) + } + + runCatching { + val onFooterViewInflated = + findMethodOrNull(tabbedAppMenuPropertiesDelegate, true) { + parameterTypes.size == 2 && parameterTypes[1] == View::class.java + } + if (onFooterViewInflated != null) { + onFooterViewInflated.hookAfter { + brandBookmarkButton(it.thisObject, it.args[1] as? View) + } + } else { + val footerFactory = + findMethod(tabbedAppMenuPropertiesDelegate, true) { + parameterTypes.size == 1 && returnType == View::class.java + } + footerFactory.hookAfter { brandBookmarkButton(it.thisObject, it.result as? View) } + } } + .onFailure { Log.ex(it, "Cannot reach the Brave app menu footer") } } val prepareMenu = @@ -233,13 +280,28 @@ object PageMenuHook : BaseHook() { val iconRowMenu = menu.getItem(0) if (iconRowMenu.hasSubMenu() && !Chrome.isBrave) { - val infoMenu = iconRowMenu.getSubMenu()!!.getItem(3) - infoMenu.setIcon(R.drawable.ic_book) - infoMenu.setEnabled(true) - val mId = infoMenu::class.java.getDeclaredField("mId") - mId.setAccessible(true) - mId.set(infoMenu, readerMode.ID) - mId.setAccessible(false) + // Anchor on the page-info entry by id. Taking the fourth icon on faith is what made + // ChromeXt overwrite a user-configurable quick command on some forks (issue #290). + val iconRow = iconRowMenu.getSubMenu()!! + val infoMenu = + (0 until iconRow.size()) + .map { iconRow.getItem(it) } + .firstOrNull { + runCatching { ctx.resources.getResourceName(it.getItemId()) } + .getOrNull() + ?.endsWith("id/info_menu_id") == true + } + if (infoMenu == null) { + // Only the reader mode button is lost; the ChromeXt entries below still go in. + Log.e("No page info entry in the icon row, skipping the reader mode button") + } else { + infoMenu.setIcon(R.drawable.ic_book) + infoMenu.setEnabled(true) + val mId = infoMenu::class.java.getDeclaredField("mId") + mId.setAccessible(true) + mId.set(infoMenu, readerMode.ID) + mId.setAccessible(false) + } } val mItems = menu::class.java.getDeclaredField("mItems").also { it.setAccessible(true) } @@ -300,39 +362,69 @@ object PageMenuHook : BaseHook() { } // Inflate for MVC UI model - val maybeAddDividerLine = + val namesModelList = findMethodOrNull(tabbedAppMenuPropertiesDelegate) { + // void maybeAddDividerLine(MVCListAdapter.ModelList modelList, @IdRes int id), static + // since M150 and moved off the delegate entirely in M153 parameterTypes.size == 2 && parameterTypes[1] == Int::class.java && returnType == Void.TYPE && - !Modifier.isAbstract(modifiers) + !Modifier.isAbstract(modifiers) && + !parameterTypes[0].isPrimitive && + findFieldOrNull(parameterTypes[0], true) { type == ArrayList::class.java } != null } - // private void maybeAddDividerLine(MVCListAdapter.ModelList modelList, @IdRes int id) + ?: findMethod(appMenuPropertiesDelegateImpl) { + // public abstract MVCListAdapter.ModelList buildMenuModelList(), the one member the + // delegate cannot delegate away + parameterTypes.size == 0 && + Modifier.isAbstract(modifiers) && + !returnType.isPrimitive && + findFieldOrNull(returnType, true) { type == ArrayList::class.java } != null + } + // Either way the ModelList is the only non primitive type the signature mentions. + val MVCListAdapter_ModelList = + namesModelList.parameterTypes.firstOrNull() ?: namesModelList.returnType + val mItems = findField(MVCListAdapter_ModelList, true) { type == ArrayList::class.java } val buildModelForStandardMenuItem = - findMethod(appMenuPropertiesDelegateImpl) { + findMethodOrNull(appMenuPropertiesDelegateImpl) { parameterTypes contentDeepEquals arrayOf(Int::class.java, Int::class.java, Int::class.java) && returnType == proxy.propertyModel } // public PropertyModel buildModelForStandardMenuItem( // @IdRes int id, @StringRes int titleId, @DrawableRes int iconResId) + // M153 hoisted every model factory into a static helper class that nothing we can name refers + // to, so when the method is gone we assemble the same model out of PropertyModel itself. + + val modelOfKeys = + proxy.propertyModel.declaredConstructors + .firstOrNull { + it.parameterTypes.size == 1 && + it.parameterTypes[0].isAssignableFrom(ArrayList::class.java) + } + ?.also { it.isAccessible = true } + // public PropertyModel(List keys), the only constructor that registers the keys + val propertySetters = + proxy.propertyModel.declaredMethods + .filter { + it.parameterTypes.size == 2 && + it.returnType == Void.TYPE && + !Modifier.isStatic(it.modifiers) + } + .onEach { it.isAccessible = true } + // set(WritableIntPropertyKey, int) and its siblings, one overload per value type - val MVCListAdapter_ModelList = maybeAddDividerLine!!.parameterTypes.first() - val mItems = findField(MVCListAdapter_ModelList, true) { type == ArrayList::class.java } - - val excludedReturnValuesForModelItem = - arrayOf( - MVCListAdapter_ModelList, - Int::class.java, - Boolean::class.java, - Void.TYPE, - View::class.java) val buildNewIncognitoTabItem = findMethod(tabbedAppMenuPropertiesDelegate) { + // Anchor on the shape of MVCListAdapter.ListItem, a PropertyModel plus an int type, + // otherwise zero argument getters such as getProfile() match just as well. parameterTypes.size == 0 && !Modifier.isStatic(modifiers) && - !excludedReturnValuesForModelItem.contains(returnType) + !returnType.isPrimitive && + returnType != MVCListAdapter_ModelList && + findFieldOrNull(returnType) { type == proxy.propertyModel } != null && + findFieldOrNull(returnType) { type == Int::class.java } != null } // private MVCListAdapter.ListItem buildNewIncognitoTabItem() val MVCListAdapter_ListItem = buildNewIncognitoTabItem.returnType @@ -340,94 +432,194 @@ object PageMenuHook : BaseHook() { val mType = findField(MVCListAdapter_ListItem) { type == Int::class.java } // the original field name was "type" - val mData = findField(proxy.propertyModel) { type == Map::class.java } + val mData = findField(proxy.propertyModel) { Map::class.java.isAssignableFrom(type) } + // declared as a raw HashMap since M150 + + val itemConstructor = + MVCListAdapter_ListItem.declaredConstructors + .first { + it.parameterTypes.size == 2 && + it.parameterTypes.contains(proxy.propertyModel) && + it.parameterTypes.contains(Int::class.java) + } + .also { it.isAccessible = true } + // R8 is free to swap the (int type, PropertyModel model) parameters around, and it did in M150 + val typeComesFirst = itemConstructor.parameterTypes[0] == Int::class.java + fun newListItem(type: Int, menuModel: Any?): Any = + if (typeComesFirst) itemConstructor.newInstance(type, menuModel) + else itemConstructor.newInstance(menuModel, type) + + // Brave hangs its own zero-argument ModelList getters off the delegate, and matching on the + // signature alone lands on one of those, so our entries end up in a list nobody ever shows. The + // superclass declares buildMenuModelList abstract and an override keeps the name, so use it. + val buildMenuModelList = + findMethodOrNull(appMenuPropertiesDelegateImpl, true) { + parameterTypes.size == 0 && + Modifier.isAbstract(modifiers) && + returnType == MVCListAdapter_ModelList + } + ?.name return findMethod(tabbedAppMenuPropertiesDelegate) { - parameterTypes.size == 0 && returnType == MVCListAdapter_ModelList + parameterTypes.size == 0 && + returnType == MVCListAdapter_ModelList && + (buildMenuModelList == null || name == buildMenuModelList) } // public MVCListAdapter.ModelList buildMenuModelList() .hookAfter { - val tabProvider = mActivityTabProvider.get(it.thisObject)!! + val delegate = it.thisObject + val tabProvider = mActivityTabProvider.get(delegate)!! Chrome.updateTab(tabProvider.invokeMethod { name == "get" }) - val ctx = mContext.get(it.thisObject) as Context + val ctx = mContext.get(delegate) as Context Resource.enrich(ctx) val url = getUrl() @Suppress("UNCHECKED_CAST") val menuModels = mItems.get(it.result) as MutableList - @Suppress("UNCHECKED_CAST") - val iconModels = mData.get(model.get(menuModels[0])) as Map - val additionalIcons = - iconModels.entries - .find { it.key.toString() == "ADDITIONAL_ICONS" } - ?.let { - val _value = it.value!!::class.java.declaredFields[0] - _value.get(it.value) - } - if (additionalIcons != null && !Chrome.isBrave) { - @Suppress("UNCHECKED_CAST") val icons = mItems.get(additionalIcons) as ArrayList + // Every PropertyModel value is boxed in a one field holder, null until the key is written + fun propertyOf(item: Any, key: String): Any? { @Suppress("UNCHECKED_CAST") - val pageInfoModel = mData.get(model.get(icons[3])) as Map - pageInfoModel.forEach { - if (it.value == null) { - return@forEach - } - val _value = it.value!!::class.java.declaredFields[0].also { it.setAccessible(true) } - if (it.key.toString() == "MENU_ITEM_ID") { - _value.set(it.value, readerMode.ID) - } else if (it.key.toString() == "ICON") { - _value.set(it.value, ctx.resources.getDrawable(R.drawable.ic_book, null)) - } - } + val properties = mData.get(model.get(item)) as Map + val holder = properties.entries.find { it.key.toString() == key }?.value ?: return null + val boxed = holder::class.java.declaredFields.firstOrNull() ?: return null + return boxed.also { it.setAccessible(true) }.get(holder) + } + + fun menuIdNameOf(item: Any): String? { + val id = propertyOf(item, "MENU_ITEM_ID") as? Int ?: return null + return runCatching { ctx.resources.getResourceName(id) }.getOrNull() } + // Rebranding the page info entry is cosmetic, so it must never cost us the menu entries + // that follow: a throw here would be swallowed by the hook and look like a missing menu. + runCatching { + val additionalIcons = + menuModels.firstOrNull()?.let { row -> propertyOf(row, "ADDITIONAL_ICONS") } + if (additionalIcons != null && !Chrome.isBrave) { + @Suppress("UNCHECKED_CAST") + val icons = mItems.get(additionalIcons) as ArrayList + // Vivaldi fills this row with five user configurable quick commands picked from + // kzd.c/kzd.d/kzd.a, and the page info entry is in none of those lists, so a + // positional guess just steals whichever command sits fourth (issue #290). + val pageInfo = + icons.find { menuIdNameOf(it)?.endsWith(":id/info_menu_id") == true } + if (pageInfo == null) { + Log.d("No page info entry to turn into the reader mode one") + } else { + @Suppress("UNCHECKED_CAST") + val pageInfoModel = mData.get(model.get(pageInfo)) as Map + pageInfoModel.forEach { + if (it.value == null) { + return@forEach + } + val _value = + it.value!!::class.java.declaredFields[0].also { it.setAccessible(true) } + if (it.key.toString() == "MENU_ITEM_ID") { + _value.set(it.value, readerMode.ID) + } else if (it.key.toString() == "ICON") { + _value.set(it.value, ctx.resources.getDrawable(R.drawable.ic_book, null)) + } + } + } + } + } + .onFailure { Log.ex(it, "Cannot reach the page info entry of the app menu") } + val skip = menuModels.size <= 10 || isChromeScheme(url) if (skip && !isUserScript(url)) return@hookAfter - val localMenus = - listOf( - buildModelForStandardMenuItem.invoke( - it.thisObject, - R.id.developer_tools_id, - R.string.main_menu_developer_tools, - R.drawable.ic_devtools), - buildModelForStandardMenuItem.invoke( - it.thisObject, - R.id.extension_id, - R.string.main_menu_extension, - R.drawable.ic_extension), - buildModelForStandardMenuItem.invoke( - it.thisObject, - R.id.install_script_id, - R.string.main_menu_install_script, - R.drawable.ic_install_script), - buildModelForStandardMenuItem.invoke( - it.thisObject, - R.id.eruda_console_id, - R.string.main_menu_eruda_console, - R.drawable.ic_devtools)) + fun writeProperty(menuModel: Any, key: Any, value: Any) { + val valueType = + when (value) { + is Int -> Int::class.java + is Boolean -> Boolean::class.java + else -> Any::class.java + } + propertySetters + .find { it.parameterTypes[0].isInstance(key) && it.parameterTypes[1] == valueType } + ?.invoke(menuModel, key, value) + } - val menusToAdd = mutableListOf() + fun standardMenuItem(id: Int, titleId: Int, iconResId: Int): Any? { + if (buildModelForStandardMenuItem != null) + return buildModelForStandardMenuItem.invoke(delegate, id, titleId, iconResId) + // Any menu item already in the list knows the full key set of a menu item, which is all + // PropertyModel needs to build an empty one of the same shape. Only the properties + // buildModelForStandardMenuItem itself writes get filled in: CLICK_HANDLER and the + // other listeners have to stay unset, or our entry would run the template's action. + val candidates = + menuModels.filter { + propertyOf(it, "TITLE") is String && propertyOf(it, "ICON") != null + } + // Only a STANDARD row will do: the key set is copied wholesale, and a TITLE_BUTTON or + // BUTTON_ROW template carries keys for buttons we never fill in, which the adapter then + // dereferences. Better no entry than a malformed one. + val template = + candidates.firstOrNull { mType.get(it) == AppMenuItemType.STANDARD.value } + if (template == null || modelOfKeys == null) return null + @Suppress("UNCHECKED_CAST") + val keys = (mData.get(model.get(template)) as Map).keys + val menuModel = modelOfKeys.newInstance(keys.toList()) + keys.forEach { key -> + val value = + when (key.toString()) { + "MENU_ITEM_ID" -> id + "TITLE" -> ctx.getString(titleId) + "ICON" -> ctx.resources.getDrawable(iconResId, null) + "ENABLED" -> true + "ICON_COLOR_RES", + "ICON_NO_TINT", + "ICON_SHOW_BADGE", + "MENU_ICON_AT_START" -> propertyOf(template, key.toString()) + else -> null + } + if (value != null) writeProperty(menuModel, key, value) + } + return menuModel + } - val itemConstuctor = MVCListAdapter_ListItem.declaredConstructors[0] - if (isChromeXtFrontEnd(url)) { - menusToAdd.add( - itemConstuctor.newInstance(AppMenuItemType.STANDARD.value, localMenus[0])) - menusToAdd.add( - itemConstuctor.newInstance(AppMenuItemType.STANDARD.value, localMenus[1])) - } else if (isUserScript(url)) { - menusToAdd.add( - itemConstuctor.newInstance(AppMenuItemType.STANDARD.value, localMenus[2])) - } else { - menusToAdd.add( - itemConstuctor.newInstance(AppMenuItemType.STANDARD.value, localMenus[3])) + val entries = + if (isChromeXtFrontEnd(url)) { + listOf( + Triple( + R.id.developer_tools_id, + R.string.main_menu_developer_tools, + R.drawable.ic_devtools), + Triple( + R.id.extension_id, R.string.main_menu_extension, R.drawable.ic_extension)) + } else if (isUserScript(url)) { + listOf( + Triple( + R.id.install_script_id, + R.string.main_menu_install_script, + R.drawable.ic_install_script)) + } else { + listOf( + Triple( + R.id.eruda_console_id, + R.string.main_menu_eruda_console, + R.drawable.ic_devtools)) + } + + val menusToAdd = mutableListOf() + entries.forEach { (id, titleId, iconResId) -> + val menuModel = standardMenuItem(id, titleId, iconResId) + if (menuModel == null) { + Log.e("Cannot build a standard app menu item for the ChromeXt entries") + return@hookAfter + } + menusToAdd.add(newListItem(AppMenuItemType.STANDARD.value, menuModel)) } - val injectPosition = + // Chrome renumbered AppMenuItemType, DIVIDER moved from 5 to 7 in M151, so anchor on the + // divider resource ids and keep the ordinal only as a fallback for forks + val dividers = menuModels - .filter { mType.get(it) == AppMenuItemType.DIVIDER.value }[2] - .let { menuModels.indexOf(it) } + .filter { menuIdNameOf(it)?.endsWith("divider_line_id") == true } + .ifEmpty { menuModels.filter { mType.get(it) == AppMenuItemType.DIVIDER.value } } + val anchor = dividers.getOrNull(2) ?: dividers.lastOrNull() + val injectPosition = anchor?.let { menuModels.indexOf(it) } ?: (menuModels.size - 1) menuModels.addAll(injectPosition + 1, menusToAdd) } } diff --git a/app/src/main/java/org/matrix/chromext/hook/UserScript.kt b/app/src/main/java/org/matrix/chromext/hook/UserScript.kt index f558f677..17e5b1a3 100644 --- a/app/src/main/java/org/matrix/chromext/hook/UserScript.kt +++ b/app/src/main/java/org/matrix/chromext/hook/UserScript.kt @@ -102,6 +102,19 @@ object UserScriptHook : BaseHook() { .onFailure { if (BuildConfig.DEBUG) Log.ex(it) } } + if (proxy.mIsLoading == null) { + // The field could not be located, so track the loading state ourselves. The name of + // loadingStateChanged survives the obfuscation because it is called from the native side, and + // it is the only writer of that field. + runCatching { + findMethod(proxy.tabWebContentsDelegateAndroidImpl) { name == "loadingStateChanged" } + // public void loadingStateChanged(boolean toDifferentDocument) + .hookAfter { proxy.setLoading(proxy.getTab(it.thisObject), it.args[0] as Boolean) } + proxy.startTrackingLoadingState() + } + .onFailure { Log.ex(it, "Fail to track the loading state of tabs") } + } + findMethod(if (Chrome.isSamsung) proxy.tabImpl else proxy.tabWebContentsDelegateAndroidImpl) { name == "onUpdateUrl" || name == "onUpdateTargetUrl" } @@ -113,8 +126,7 @@ object UserScriptHook : BaseHook() { if (url.isEmpty() && proxy.getUrl != null) { url = proxy.parseUrl(proxy.getUrl(tab))!! } - val isLoading = proxy.mIsLoading.get(tab) as Boolean - if (!url.startsWith("chrome") && isLoading) { + if (!url.startsWith("chrome") && proxy.isLoading(tab)) { ScriptDbManager.invokeScript(url) } } @@ -143,10 +155,15 @@ object UserScriptHook : BaseHook() { } } - findMethod(proxy.navigationControllerImpl) { - name == "loadUrl" || parameterTypes contentDeepEquals arrayOf(proxy.loadUrlParams) - } - // public void loadUrl(LoadUrlParams params) + (findMethodOrNull(proxy.navigationControllerImpl) { name == "loadUrl" } + ?: findMethodOrNull(proxy.navigationControllerImpl) { + parameterTypes contentDeepEquals arrayOf(proxy.loadUrlParams) && + returnType.simpleName == "NavigationHandle" + } + ?: findMethod(proxy.navigationControllerImpl) { + parameterTypes contentDeepEquals arrayOf(proxy.loadUrlParams) + }) + // public NavigationHandle loadUrl(LoadUrlParams params) .hookBefore { val url = proxy.parseUrl(it.args[0])!! proxy.userAgentHook(url, it.args[0]) @@ -155,7 +172,8 @@ object UserScriptHook : BaseHook() { findMethod(proxy.chromeTabbedActivity, true) { name == "onResume" } .hookBefore { Chrome.init(it.thisObject as Context) } - findMethod(proxy.chromeTabbedActivity) { name == "onStop" } + // Edge declares neither onResume nor onStop on its own activity, both come from the superclass + findMethod(proxy.chromeTabbedActivity, true) { name == "onStop" } .hookBefore { ScriptDbManager.updateScriptStorage() val cache = HttpResponseCache.getInstalled() diff --git a/app/src/main/java/org/matrix/chromext/proxy/PageInfo.kt b/app/src/main/java/org/matrix/chromext/proxy/PageInfo.kt index 5557e290..ec474dbb 100644 --- a/app/src/main/java/org/matrix/chromext/proxy/PageInfo.kt +++ b/app/src/main/java/org/matrix/chromext/proxy/PageInfo.kt @@ -1,40 +1,71 @@ package org.matrix.chromext.proxy -import android.view.View.OnClickListener -import android.widget.FrameLayout -import android.widget.LinearLayout +import android.app.Activity +import android.content.Context +import android.widget.ImageView import android.widget.TextView -import java.lang.ref.WeakReference +import java.lang.reflect.Constructor +import java.lang.reflect.Field +import java.lang.reflect.Method +import java.lang.reflect.Modifier import org.matrix.chromext.Chrome -import org.matrix.chromext.utils.findField +import org.matrix.chromext.utils.Log +import org.matrix.chromext.utils.firstDeclared +// Nothing here may throw: an exception in this initializer surfaces as ExceptionInInitializerError +// and poisons the object for good, while PageInfoHook.init needs to report a plain +// NoSuchMethod/NoSuchField so that MainHook can fall back to ContextMenuHook. object PageInfoProxy { - val pageInfoRowView = Chrome.load("org.chromium.components.page_info.PageInfoRowView") - val mIcon = pageInfoRowView.declaredFields.find { it.type.name.contains("ChromeImageView") }!! - val mTitle = pageInfoRowView.declaredFields.find { it.type == TextView::class.java }!! - val mSubtitle = - pageInfoRowView.declaredFields.find { it != mTitle && it.type == TextView::class.java }!! - - val pageInfoController = Chrome.load("org.chromium.components.page_info.PageInfoController") - val mView = - findField(pageInfoController) { - (Chrome.isEdge && type == FrameLayout::class.java) || - (type.superclass == FrameLayout::class.java && - type.interfaces.contains(OnClickListener::class.java)) + private fun load(name: String): Class<*>? = + runCatching { Chrome.load("org.chromium.components.page_info." + name) } + .onFailure { Log.e("PageInfoProxy: cannot load " + name) } + .getOrNull() + + private val pageInfoRowView = load("PageInfoRowView") + + // PageInfoRowView is an XML widget, so (Context, AttributeSet) is its only constructor + val rowConstructor: Constructor<*>? = + pageInfoRowView?.declaredConstructors?.firstOrNull { + it.parameterTypes.size == 2 && Context::class.java.isAssignableFrom(it.parameterTypes[0]) } - private val pageInfoView = - if (Chrome.isEdge) Chrome.load("org.chromium.components.page_info.PageInfoView") - else mView.type - val mRowWrapper = findField(pageInfoView) { type == LinearLayout::class.java } - - val pageInfoControllerRef = - // A particular WebContentsObserver designed for PageInfoController - findField(pageInfoController) { - type.declaredFields.size == 1 && - (type.declaredFields[0].type == pageInfoController || - type.declaredFields[0].type == WeakReference::class.java) + private val rowFields = pageInfoRowView?.declaredFields?.onEach { it.isAccessible = true } + val mIcon: Field? = rowFields?.firstOrNull { ImageView::class.java.isAssignableFrom(it.type) } + private val rowTexts = + rowFields?.filter { TextView::class.java.isAssignableFrom(it.type) } ?: emptyList() + val mTitle: Field? = rowTexts.firstDeclared() + val mSubtitle: Field? = rowTexts.filter { it != mTitle }.firstDeclared() + + private val pageInfoController = load("PageInfoController") + private val controllerMethods = + pageInfoController + ?.declaredMethods + ?.filterNot { it.isSynthetic } + ?.onEach { it.isAccessible = true } ?: emptyList() + + // The single static entry point, show(Activity, WebContents, ...), which builds the whole dialog + val showPageInfo: Method? = + controllerMethods.firstOrNull { + Modifier.isStatic(it.modifiers) && + it.returnType == Void.TYPE && + it.parameterTypes.firstOrNull() == Activity::class.java + } + + // Native calls these back from within show(), once the dialog view tree has been inflated + private val callbackNames = + setOf("setSecurityDescription", "updatePermissionDisplay", "addPermissionSection") + val nativeCallbacks = controllerMethods.filter { it.name in callbackNames } + + // destroy() unregisters the observer and dismisses the dialog. It takes no argument and is the + // first method declared in PageInfoController on every build we checked, hence the lowest R8 rank + // among the obfuscated no-argument ones: a() in Chrome 151 and Edge 150, b() in CocCoc 155. + val destroy: Method? = + controllerMethods + .filter { + !Modifier.isStatic(it.modifiers) && + it.returnType == Void.TYPE && + it.parameterTypes.isEmpty() } - .type + .let { noArgs -> noArgs.firstOrNull { it.name == "destroy" } ?: noArgs.firstDeclared() } } diff --git a/app/src/main/java/org/matrix/chromext/proxy/PageMenu.kt b/app/src/main/java/org/matrix/chromext/proxy/PageMenu.kt index d44ef93f..1f8db2ec 100644 --- a/app/src/main/java/org/matrix/chromext/proxy/PageMenu.kt +++ b/app/src/main/java/org/matrix/chromext/proxy/PageMenu.kt @@ -1,7 +1,7 @@ package org.matrix.chromext.proxy import org.matrix.chromext.Chrome -import org.matrix.chromext.utils.findField +import org.matrix.chromext.utils.findFieldOrNull object PageMenuProxy { @@ -12,6 +12,7 @@ object PageMenuProxy { val emptyTabObserver = Chrome.load("org.chromium.chrome.browser.login.ChromeHttpAuthHandler").superclass as Class<*> val tabImpl = UserScriptProxy.tabImpl - val mIsLoading = UserScriptProxy.mIsLoading - val mObservers = findField(tabImpl) { type.interfaces.contains(Iterable::class.java) } + // TabImpl.mObservers is the only ObserverList it holds; only reader mode needs it, so a miss must + // not blow up this initializer and take PageMenuHook down with it. + val mObservers = findFieldOrNull(tabImpl) { type.interfaces.contains(Iterable::class.java) } } diff --git a/app/src/main/java/org/matrix/chromext/proxy/Preference.kt b/app/src/main/java/org/matrix/chromext/proxy/Preference.kt index 93adfe7f..348b7dd0 100644 --- a/app/src/main/java/org/matrix/chromext/proxy/Preference.kt +++ b/app/src/main/java/org/matrix/chromext/proxy/Preference.kt @@ -16,6 +16,8 @@ import org.matrix.chromext.script.ScriptDbManager import org.matrix.chromext.utils.Log import org.matrix.chromext.utils.findField import org.matrix.chromext.utils.findMethod +import org.matrix.chromext.utils.findMethodOrNull +import org.matrix.chromext.utils.firstDeclared import org.matrix.chromext.utils.hookBefore object PreferenceProxy { @@ -27,16 +29,31 @@ object PreferenceProxy { val emptyTabObserver = Chrome.load("org.chromium.chrome.browser.login.ChromeHttpAuthHandler").superclass as Class<*> - private val preference = Chrome.load("androidx.preference.Preference") - private val mClickListener = - findField(preference) { - type == OnClickListener::class.java || type.interfaces.contains(OnClickListener::class.java) - } - private val setSummary = - findMethod(preference) { - parameterTypes contentDeepEquals arrayOf(CharSequence::class.java) && - returnType == Void.TYPE - } + private val preference by lazy { Chrome.load("androidx.preference.Preference") } + // Both members below are only ever touched once the settings screen is on screen, and they must + // stay lazy: enumerating the members of Preference resolves every parameter type it mentions, and + // Edge puts some of those in a split that is not loaded yet when the hooks are installed, so + // doing + // this eagerly throws NoClassDefFoundError and takes the whole settings integration down with it. + private val mClickListener by lazy { + findField(preference) { + type == OnClickListener::class.java || type.interfaces.contains(OnClickListener::class.java) + } + } + // setSummary shares its (CharSequence)void signature with setTitle, and nothing else tells them + // apart once renamed: androidx declares setSummary first, so fall back to declaration order. + private val setSummary by lazy { + findMethodOrNull(preference) { + name == "setSummary" && parameterTypes contentDeepEquals arrayOf(CharSequence::class.java) + } + ?: preference.declaredMethods + .filter { + it.parameterTypes contentDeepEquals arrayOf(CharSequence::class.java) && + it.returnType == Void.TYPE + } + .run { firstDeclared() ?: first() } + .also { it.isAccessible = true } + } private val twoStatePreference = Chrome.load("org.chromium.components.browser_ui.settings.ChromeSwitchPreference").superclass as Class<*> diff --git a/app/src/main/java/org/matrix/chromext/proxy/UserScript.kt b/app/src/main/java/org/matrix/chromext/proxy/UserScript.kt index 742fb122..fea69976 100644 --- a/app/src/main/java/org/matrix/chromext/proxy/UserScript.kt +++ b/app/src/main/java/org/matrix/chromext/proxy/UserScript.kt @@ -1,16 +1,20 @@ package org.matrix.chromext.proxy import android.net.Uri -import android.view.ContextThemeWrapper +import java.lang.reflect.Field import java.lang.reflect.Modifier +import java.util.Collections +import java.util.WeakHashMap import org.matrix.chromext.Chrome import org.matrix.chromext.script.ScriptDbManager import org.matrix.chromext.utils.Log import org.matrix.chromext.utils.findField import org.matrix.chromext.utils.findMethod import org.matrix.chromext.utils.findMethodOrNull +import org.matrix.chromext.utils.firstDeclared import org.matrix.chromext.utils.invokeMethod import org.matrix.chromext.utils.parseOrigin +import org.matrix.chromext.utils.r8Rank object UserScriptProxy { // It is possible to do a HTTP POST with LoadUrlParams Class @@ -46,51 +50,71 @@ object UserScriptProxy { Chrome.load("org.chromium.chrome.browser.tab.TabImpl") } private val getId = findMethodOrNull(tabImpl) { name == "getId" } - private val mId = - (if (Chrome.isSamsung) tabWebContentsDelegateAndroidImpl else tabImpl) - .declaredFields - .run { - val target = find { it.name == "mId" } - if (target == null) { - val profile = Chrome.load("org.chromium.chrome.browser.profiles.Profile") - val windowAndroid = Chrome.load("org.chromium.ui.base.WindowAndroid") - var startIndex = indexOfFirst { it.type == gURL } - val endIndex = indexOfFirst { - it.type == profile || - it.type == ContextThemeWrapper::class.java || - it.type == windowAndroid - } - if (startIndex == -1 || startIndex > endIndex) startIndex = 0 - slice(startIndex..endIndex - 1).findLast { it.type == Int::class.java }!! - } else target - } - .also { it.isAccessible = true } + // Only consulted when getId is missing, which is never the case on Chrome itself: mId is the + // first int declared by TabImpl, right after the native pointer. Lazy, so that a browser we + // cannot make sense of costs us getTabId rather than the whole module. + private val mId: Field? by lazy { + (if (Chrome.isSamsung) tabWebContentsDelegateAndroidImpl else tabImpl) + .declaredFields + .filter { !Modifier.isStatic(it.modifiers) } + .run { find { it.name == "mId" } ?: filter { it.type == Int::class.java }.firstDeclared() } + ?.also { it.isAccessible = true } + } val mTab = findField(tabWebContentsDelegateAndroidImpl) { type == tabImpl } - val mIsLoading = - tabImpl.declaredFields - .run { - // mIsLoading is used in method stopLoading, before calling - // Lorg/chromium/content_public/browser/WebContents;->stop()V - val target = find { it.name == "mIsLoading" } - if (target == null) { - val webContents = Chrome.load("org.chromium.content_public.browser.WebContents") - val startIndex = - maxOf( - indexOfFirst { it.type == webContents }, - indexOfFirst { it.type == loadUrlParams }) - slice(startIndex..size - 1).find { - it.type == Boolean::class.java && !Modifier.isStatic(it.modifiers) - }!! - } else target - } - .also { it.isAccessible = true } - val getUrl = findMethodOrNull(tabImpl) { returnType == gURL } + val mIsLoading: Field? by lazy { + // mIsLoading is used in method stopLoading, before calling + // Lorg/chromium/content_public/browser/WebContents;->stop()V, and TabImpl declares it right + // after the WebContents and the pending LoadUrlParams, hence the two anchors below. + tabImpl.declaredFields + .filter { !Modifier.isStatic(it.modifiers) } + .run { + find { it.name == "mIsLoading" } + ?: run { + val webContents = Chrome.load("org.chromium.content_public.browser.WebContents") + val anchor = + maxOf( + filter { it.type == webContents }.firstDeclared()?.let { r8Rank(it.name) } + ?: -1, + filter { it.type == loadUrlParams }.firstDeclared()?.let { r8Rank(it.name) } + ?: -1) + // Without an anchor any boolean is as good as any other, so do not guess. + if (anchor < 0) null + else + filter { it.type == Boolean::class.java && r8Rank(it.name) > anchor } + .firstDeclared() + } + } + ?.also { it.isAccessible = true } + } + // Match the name first: picking by return type alone lands on getOriginalUrl, which hands back + // the distilled URL rather than the one the tab is actually showing. + val getUrl = + findMethodOrNull(tabImpl) { name == "getUrl" && returnType == gURL } + ?: findMethodOrNull(tabImpl) { returnType == gURL && parameterTypes.isEmpty() } val loadUrl = findMethod(if (Chrome.isSamsung) tabWebContentsDelegateAndroidImpl else tabImpl) { parameterTypes contentDeepEquals arrayOf(loadUrlParams) && (Chrome.isSamsung || returnType != Void.TYPE) } + // Chrome keeps none of these names, so fall back to the declaration order recovered from the + // obfuscated ones: LoadUrlParams declares mUrl first and mVerbatimHeaders second among its + // strings, and GURL declares mSpec as its only one. + private fun stringField(clz: Class<*>, name: String, skip: Int = 0): Field? = + clz.declaredFields + .filter { !Modifier.isStatic(it.modifiers) && it.type == String::class.java } + .run { + find { it.name == name } + ?: filter { r8Rank(it.name) != Int.MAX_VALUE } + .sortedBy { r8Rank(it.name) } + .getOrNull(skip) + } + ?.also { it.isAccessible = true } + + private val mUrl by lazy { stringField(loadUrlParams, "mUrl") } + private val mVerbatimHeaders by lazy { stringField(loadUrlParams, "mVerbatimHeaders", 1) } + private val mSpec by lazy { stringField(gURL, "mSpec") } + val kMaxURLChars = 2097152 private fun loadUrl(url: String, tab: Any? = Chrome.getTab()) { @@ -98,8 +122,33 @@ object UserScriptProxy { loadUrl.invoke(tab, newLoadUrlParams(url)) } + // Fallback for when mIsLoading cannot be located: UserScriptHook then hooks loadingStateChanged, + // whose name the obfuscation preserves because it is called from the native side, and feeds the + // state here instead. + private val loadingTabs = Collections.newSetFromMap(WeakHashMap()) + private var trackLoadingState = false + + fun startTrackingLoadingState() { + trackLoadingState = true + } + + fun setLoading(tab: Any?, loading: Boolean) { + if (tab == null) return + if (loading) loadingTabs.add(tab) else loadingTabs.remove(tab) + } + + fun isLoading(tab: Any): Boolean { + mIsLoading?.let { + return it.get(tab) as Boolean + } + // With no way to tell, keep injecting: both the init script and GM.bootstrap are idempotent + // for a given document. + return if (trackLoadingState) loadingTabs.contains(tab) else true + } + fun getTabId(tab: Any): String { - val id = if (getId != null) getId.invoke(tab)!! else mId.get(tab)!! + val id = getId?.invoke(tab) ?: mId?.get(tab) + if (id == null) Log.e("Failed to read the tab id of ${tab::class.java}") return id.toString() } @@ -144,11 +193,9 @@ object UserScriptProxy { } else if (packed::class.java == String::class.java) { return packed as String } else if (packed::class.java == loadUrlParams) { - val mUrl = loadUrlParams.getDeclaredField("a") - return mUrl.get(packed) as String + return mUrl?.get(packed) as String? } else if (packed::class.java == gURL) { - val mSpec = gURL.getDeclaredField("a") - return mSpec.get(packed) as String + return mSpec?.get(packed) as String? } Log.e("parseUrl: ${packed::class.java} is not ${loadUrlParams.name} nor ${gURL.name}") return null @@ -163,9 +210,7 @@ object UserScriptProxy { if (Chrome.isSamsung) { urlParams.invokeMethod(header) { name == "setVerbatimHeaders" } } else { - val mVerbatimHeaders = - loadUrlParams.declaredFields.filter { it.type == String::class.java }[1] - mVerbatimHeaders.set(urlParams, header) + mVerbatimHeaders?.set(urlParams, header) ?: return false } return true } diff --git a/app/src/main/java/org/matrix/chromext/utils/Reflect.kt b/app/src/main/java/org/matrix/chromext/utils/Reflect.kt index b1b18189..1050dfd3 100644 --- a/app/src/main/java/org/matrix/chromext/utils/Reflect.kt +++ b/app/src/main/java/org/matrix/chromext/utils/Reflect.kt @@ -7,6 +7,35 @@ typealias MethodCondition = Method.() -> Boolean typealias FieldCondition = Field.() -> Boolean +private const val R8_HEAD = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ" +private const val R8_TAIL = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ" + +// R8 hands out obfuscated member names in declaration order, whereas getDeclaredFields returns them +// sorted by name, so mapping a name back to its index in that sequence recovers the declaration +// order our heuristics are really after. The first character comes from a 52 letter pool and every +// further character from a 62 character pool, least significant first, which is why the names run +// 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 silently mis-rank half of Whale's members. +// Names that R8 did not generate, i.e. everything on a non obfuscated build, rank last so that they +// are never picked by a lowest rank search. +fun r8Rank(name: String): Int { + val head = if (name.isEmpty()) -1 else R8_HEAD.indexOf(name[0]) + if (head < 0) return Int.MAX_VALUE + if (name.length == 1) return head + var value = 0L + for (i in name.length - 1 downTo 1) { + val digit = R8_TAIL.indexOf(name[i]) + if (digit < 0) return Int.MAX_VALUE + value = value * R8_TAIL.length + digit + if (value > Int.MAX_VALUE / R8_HEAD.length) return Int.MAX_VALUE + } + return ((value + 1) * R8_HEAD.length + head).toInt() +} + +// The member declared first in the original source, among the obfuscated ones. +fun Iterable.firstDeclared(): T? = + filter { r8Rank(it.name) != Int.MAX_VALUE }.minByOrNull { r8Rank(it.name) } + fun findMethod(clz: Class<*>, findSuper: Boolean = false, condition: MethodCondition): Method { return findMethodOrNull(clz, findSuper, condition) ?: throw NoSuchMethodException() }