├── app ├── .gitignore ├── src │ └── main │ │ ├── assets │ │ ├── xposed_init │ │ ├── editor.css │ │ ├── devtools.js │ │ ├── extension.js │ │ ├── eruda.css │ │ ├── editor.js │ │ ├── encoding.js │ │ └── scripts.js │ │ ├── java │ │ └── org │ │ │ └── matrix │ │ │ └── chromext │ │ │ ├── hook │ │ │ ├── Base.kt │ │ │ ├── PageInfo.kt │ │ │ ├── WebView.kt │ │ │ ├── Preference.kt │ │ │ ├── UserScript.kt │ │ │ └── ContextMenu.kt │ │ │ ├── proxy │ │ │ ├── PageMenu.kt │ │ │ ├── PageInfo.kt │ │ │ ├── UserScript.kt │ │ │ └── Preference.kt │ │ │ ├── utils │ │ │ ├── Log.kt │ │ │ ├── Reflect.kt │ │ │ ├── Hook.kt │ │ │ ├── Url.kt │ │ │ └── XMLHttpRequest.kt │ │ │ ├── script │ │ │ ├── SQLite.kt │ │ │ ├── Parser.kt │ │ │ ├── Local.kt │ │ │ └── Manager.kt │ │ │ ├── devtools │ │ │ ├── Inspect.kt │ │ │ └── WebSocketClient.kt │ │ │ ├── OpenInChrome.kt │ │ │ ├── extension │ │ │ └── LocalFiles.kt │ │ │ ├── MainHook.kt │ │ │ └── Chrome.kt │ │ ├── res │ │ ├── mipmap │ │ │ └── ic_launcher.xml │ │ ├── mipmap-v26 │ │ │ └── ic_launcher.xml │ │ ├── drawable │ │ │ ├── ic_devtools.xml │ │ │ ├── ic_extension.xml │ │ │ ├── ic_install_script.xml │ │ │ ├── ic_book.xml │ │ │ └── ic_chrome.xml │ │ ├── values │ │ │ ├── strings.xml │ │ │ └── arrays.xml │ │ ├── menu │ │ │ └── main_menu.xml │ │ └── xml │ │ │ └── developer_preferences.xml │ │ └── AndroidManifest.xml ├── proguard-rules.pro └── build.gradle.kts ├── .gitattributes ├── .gitignore ├── gradle └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── .github ├── dependabot.yml └── workflows │ └── android.yml ├── settings.gradle.kts ├── gradle.properties ├── gradlew.bat ├── docs └── presentation.tex ├── gradlew └── README.md /app/.gitignore: -------------------------------------------------------------------------------- 1 | /build -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | app/src/main/assets/*.js text=auto eol=lf 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /build 2 | .gradle 3 | .kotlin 4 | apktool 5 | *.apk 6 | -------------------------------------------------------------------------------- /app/src/main/assets/xposed_init: -------------------------------------------------------------------------------- 1 | org.matrix.chromext.MainHook 2 | -------------------------------------------------------------------------------- /app/proguard-rules.pro: -------------------------------------------------------------------------------- 1 | -keep class org.matrix.chromext.MainHook 2 | -keepattributes SourceFile,LineNumberTable 3 | -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JingMatrix/ChromeXt/HEAD/gradle/wrapper/gradle-wrapper.jar -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "gradle" 4 | directory: "/" 5 | schedule: 6 | interval: "daily" 7 | -------------------------------------------------------------------------------- /app/src/main/java/org/matrix/chromext/hook/Base.kt: -------------------------------------------------------------------------------- 1 | package org.matrix.chromext.hook 2 | 3 | abstract class BaseHook { 4 | var isInit: Boolean = false 5 | 6 | abstract fun init() 7 | } 8 | -------------------------------------------------------------------------------- /app/src/main/res/mipmap/ic_launcher.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | distributionBase=GRADLE_USER_HOME 2 | distributionPath=wrapper/dists 3 | distributionUrl=https\://services.gradle.org/distributions/gradle-8.12-bin.zip 4 | networkTimeout=10000 5 | validateDistributionUrl=true 6 | zipStoreBase=GRADLE_USER_HOME 7 | zipStorePath=wrapper/dists 8 | -------------------------------------------------------------------------------- /settings.gradle.kts: -------------------------------------------------------------------------------- 1 | pluginManagement { 2 | repositories { 3 | gradlePluginPortal() 4 | google() 5 | mavenCentral() 6 | } 7 | } 8 | 9 | dependencyResolutionManagement { 10 | repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) 11 | repositories { 12 | google() 13 | mavenCentral() 14 | maven("https://api.xposed.info/") 15 | // maven("https://jitpack.io") 16 | } 17 | } 18 | 19 | include(":app") 20 | 21 | rootProject.name = "ChromeXt" 22 | -------------------------------------------------------------------------------- /app/src/main/res/mipmap-v26/ic_launcher.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_devtools.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 7 | 11 | 12 | -------------------------------------------------------------------------------- /app/src/main/res/values/strings.xml: -------------------------------------------------------------------------------- 1 | 2 | ChromeXt 3 | Open in Chrome 4 | UserScript and DevTools supports for Chromium based and WebView based browsers 5 | Extensions 6 | Install UserScript 7 | Developer tools 8 | Eruda console 9 | 10 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_extension.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 6 | 7 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_install_script.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_book.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 7 | 8 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_chrome.xml: -------------------------------------------------------------------------------- 1 | 2 | 7 | 10 | 11 | -------------------------------------------------------------------------------- /app/src/main/java/org/matrix/chromext/proxy/PageMenu.kt: -------------------------------------------------------------------------------- 1 | package org.matrix.chromext.proxy 2 | 3 | import org.matrix.chromext.Chrome 4 | import org.matrix.chromext.utils.findField 5 | 6 | object PageMenuProxy { 7 | 8 | val chromeTabbedActivity = UserScriptProxy.chromeTabbedActivity 9 | val customTabActivity = Chrome.load("org.chromium.chrome.browser.customtabs.CustomTabActivity") 10 | val propertyModel = Chrome.load("org.chromium.ui.modelutil.PropertyModel") 11 | val tab = Chrome.load("org.chromium.chrome.browser.tab.Tab") 12 | val emptyTabObserver = 13 | Chrome.load("org.chromium.chrome.browser.login.ChromeHttpAuthHandler").superclass as Class<*> 14 | val tabImpl = UserScriptProxy.tabImpl 15 | val mIsLoading = UserScriptProxy.mIsLoading 16 | val mObservers = findField(tabImpl) { type.interfaces.contains(Iterable::class.java) } 17 | } 18 | -------------------------------------------------------------------------------- /app/src/main/res/menu/main_menu.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 9 | 14 | 19 | 24 | 25 | -------------------------------------------------------------------------------- /gradle.properties: -------------------------------------------------------------------------------- 1 | # Project-wide Gradle settings. 2 | # IDE (e.g. Android Studio) users: 3 | # Gradle settings configured through the IDE *will override* 4 | # any settings specified in this file. 5 | # For more details on how to configure your build environment visit 6 | # http://www.gradle.org/docs/current/userguide/build_environment.html 7 | # Specifies the JVM arguments used for the daemon process. 8 | # The setting is particularly useful for tweaking memory settings. 9 | # org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 10 | # When configured, Gradle will run in incubating parallel mode. 11 | # This option should only be used with decoupled projects. More details, visit 12 | # http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects 13 | # org.gradle.parallel=true 14 | # AndroidX package structure to make it clearer which packages are bundled with the 15 | # Android operating system, and which are packaged with your app"s APK 16 | # https://developer.android.com/topic/libraries/support-library/androidx-rn 17 | # android.useAndroidX=true 18 | # Automatically convert third-party libraries to use AndroidX 19 | # android.enableJetifier=true 20 | # Kotlin code style for this project: "official" or "obsolete": 21 | kotlin.code.style=official 22 | -------------------------------------------------------------------------------- /app/build.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | id("com.android.application") 3 | id("org.jetbrains.kotlin.android") 4 | id("com.ncorti.ktfmt.gradle") 5 | } 6 | 7 | android { 8 | compileSdk = 34 9 | namespace = "org.matrix.chromext" 10 | 11 | defaultConfig { 12 | applicationId = "org.matrix.chromext" 13 | minSdk = 21 14 | targetSdk = 35 15 | versionCode = 16 16 | versionName = "3.8.2" 17 | } 18 | 19 | buildFeatures { buildConfig = true } 20 | 21 | buildTypes { 22 | release { 23 | isShrinkResources = true 24 | isMinifyEnabled = true 25 | proguardFiles("proguard-rules.pro") 26 | } 27 | } 28 | 29 | androidResources { 30 | additionalParameters += listOf("--allow-reserved-package-id", "--package-id", "0x42") 31 | } 32 | 33 | compileOptions { 34 | sourceCompatibility = JavaVersion.VERSION_21 35 | targetCompatibility = JavaVersion.VERSION_21 36 | } 37 | 38 | lint { 39 | disable += 40 | listOf( 41 | "Internationalization", 42 | "UnsafeIntentLaunch", 43 | "SetJavaScriptEnabled", 44 | "UnspecifiedRegisterReceiverFlag", 45 | "Usability:Icons") 46 | } 47 | 48 | kotlinOptions { jvmTarget = JavaVersion.VERSION_21.toString() } 49 | } 50 | 51 | dependencies { compileOnly("de.robv.android.xposed:api:82") } 52 | -------------------------------------------------------------------------------- /app/src/main/res/values/arrays.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | app.vanadium.browser 5 | com.android.browser 6 | com.android.chrome 7 | com.brave.browser 8 | com.brave.browser_beta 9 | com.brave.browser_nightly 10 | com.chrome.beta 11 | com.chrome.canary 12 | com.chrome.dev 13 | com.coccoc.trinhduyet 14 | com.coccoc.trinhduyet_beta 15 | com.herond.android.browser 16 | com.kiwibrowser.browser 17 | com.mi.globalbrowser 18 | com.microsoft.emmx 19 | com.microsoft.emmx.beta 20 | com.microsoft.emmx.canary 21 | com.microsoft.emmx.dev 22 | com.naver.whale 23 | com.sec.android.app.sbrowser 24 | com.sec.android.app.sbrowser.beta 25 | com.vivaldi.browser 26 | com.vivaldi.browser.snapshot 27 | org.axpos.aosmium 28 | org.bromite.bromite 29 | org.chromium.chrome 30 | org.chromium.thorium 31 | org.cromite.cromite 32 | org.greatfire.freebrowser 33 | org.triple.banana 34 | us.spotco.mulch 35 | 36 | 37 | -------------------------------------------------------------------------------- /app/src/main/java/org/matrix/chromext/utils/Log.kt: -------------------------------------------------------------------------------- 1 | package org.matrix.chromext.utils 2 | 3 | import android.content.Context 4 | import android.util.Log 5 | import android.widget.Toast 6 | import de.robv.android.xposed.XposedBridge 7 | import java.lang.ref.WeakReference 8 | import org.matrix.chromext.BuildConfig 9 | import org.matrix.chromext.TAG 10 | 11 | object Log { 12 | private var lastToast: WeakReference? = null 13 | 14 | fun i(msg: String) { 15 | Log.i(TAG, msg) 16 | XposedBridge.log("ChromeXt logging: " + msg) 17 | } 18 | 19 | fun d(msg: String, full: Boolean = false) { 20 | if (BuildConfig.DEBUG) { 21 | if (!full && msg.length > 300) { 22 | Log.d(TAG, msg.take(300) + " ...") 23 | } else { 24 | Log.d(TAG, msg) 25 | } 26 | } 27 | } 28 | 29 | fun w(msg: String) { 30 | Log.w(TAG, msg) 31 | } 32 | 33 | fun e(msg: String) { 34 | Log.e(TAG, msg) 35 | XposedBridge.log("ChromeXt error: " + msg) 36 | } 37 | 38 | fun ex(thr: Throwable, msg: String = "") { 39 | Log.e(TAG, msg, thr) 40 | XposedBridge.log("ChromeXt exception caught: [${msg}] " + thr.toString()) 41 | } 42 | 43 | fun toast(context: Context, msg: String) { 44 | this.lastToast?.get()?.cancel() 45 | val duration = Toast.LENGTH_SHORT 46 | val toast = Toast.makeText(context, msg, duration) 47 | toast.show() 48 | this.lastToast = WeakReference(toast) 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /app/src/main/assets/editor.css: -------------------------------------------------------------------------------- 1 | @import url("https://unpkg.com/@speed-highlight/core/dist/themes/default.css"); 2 | html { 3 | overflow-x: hidden; 4 | } 5 | pre { 6 | overflow-x: scroll; 7 | } 8 | @media (prefers-color-scheme: dark) { 9 | body > pre > em { 10 | color: yellow; 11 | } 12 | } 13 | pre > em { 14 | font-weight: bold; 15 | color: blue; 16 | } 17 | pre > span { 18 | font-weight: bold; 19 | color: red; 20 | } 21 | body { 22 | margin: 0; 23 | } 24 | dialog#confirm { 25 | white-space: pre-wrap; 26 | text-wrap: balance; 27 | top: 40%; 28 | position: fixed; 29 | max-width: calc((100% - 6px) - 2em); 30 | } 31 | dialog#confirm:modal { 32 | top: 0; 33 | } 34 | dialog#confirm > p#alert { 35 | text-align: center; 36 | font-weight: bold; 37 | } 38 | dialog#confirm > div#interaction { 39 | display: flex; 40 | justify-content: space-around; 41 | margin-top: 2em; 42 | } 43 | [class*="shj-lang-"] { 44 | border-radius: 0; 45 | color: #abb2bf; 46 | background: #161b22; 47 | font: 1em monospace; 48 | padding: 8px 5px; 49 | margin: 0; 50 | width: 100vw; 51 | word-break: break-word; 52 | } 53 | [class*="shj-lang-"]:before { 54 | color: #6f9aff; 55 | } 56 | .shj-syn-deleted, 57 | .shj-syn-err, 58 | .shj-syn-var { 59 | color: #e06c75; 60 | } 61 | .shj-syn-section, 62 | .shj-syn-oper, 63 | .shj-syn-kwd { 64 | color: #c678dd; 65 | } 66 | .shj-syn-class { 67 | color: #e5c07b; 68 | } 69 | .shj-numbers, 70 | .shj-syn-cmnt { 71 | color: #76839a; 72 | } 73 | .shj-syn-insert { 74 | color: #98c379; 75 | } 76 | .shj-syn-type { 77 | color: #56b6c2; 78 | } 79 | .shj-syn-num, 80 | .shj-syn-bool { 81 | color: #d19a66; 82 | } 83 | .shj-syn-str, 84 | .shj-syn-func { 85 | color: #61afef; 86 | } 87 | -------------------------------------------------------------------------------- /app/src/main/java/org/matrix/chromext/proxy/PageInfo.kt: -------------------------------------------------------------------------------- 1 | package org.matrix.chromext.proxy 2 | 3 | import android.view.View.OnClickListener 4 | import android.widget.FrameLayout 5 | import android.widget.LinearLayout 6 | import android.widget.TextView 7 | import java.lang.ref.WeakReference 8 | import org.matrix.chromext.Chrome 9 | import org.matrix.chromext.utils.findField 10 | 11 | object PageInfoProxy { 12 | 13 | val pageInfoRowView = Chrome.load("org.chromium.components.page_info.PageInfoRowView") 14 | val mIcon = pageInfoRowView.declaredFields.find { it.type.name.contains("ChromeImageView") }!! 15 | val mTitle = pageInfoRowView.declaredFields.find { it.type == TextView::class.java }!! 16 | val mSubtitle = 17 | pageInfoRowView.declaredFields.find { it != mTitle && it.type == TextView::class.java }!! 18 | 19 | val pageInfoController = Chrome.load("org.chromium.components.page_info.PageInfoController") 20 | val mView = 21 | findField(pageInfoController) { 22 | (Chrome.isEdge && type == FrameLayout::class.java) || 23 | (type.superclass == FrameLayout::class.java && 24 | type.interfaces.contains(OnClickListener::class.java)) 25 | } 26 | 27 | private val pageInfoView = 28 | if (Chrome.isEdge) Chrome.load("org.chromium.components.page_info.PageInfoView") 29 | else mView.type 30 | val mRowWrapper = findField(pageInfoView) { type == LinearLayout::class.java } 31 | 32 | val pageInfoControllerRef = 33 | // A particular WebContentsObserver designed for PageInfoController 34 | findField(pageInfoController) { 35 | type.declaredFields.size == 1 && 36 | (type.declaredFields[0].type == pageInfoController || 37 | type.declaredFields[0].type == WeakReference::class.java) 38 | } 39 | .type 40 | } 41 | -------------------------------------------------------------------------------- /app/src/main/java/org/matrix/chromext/utils/Reflect.kt: -------------------------------------------------------------------------------- 1 | package org.matrix.chromext.utils 2 | 3 | import java.lang.reflect.Field 4 | import java.lang.reflect.Method 5 | 6 | typealias MethodCondition = Method.() -> Boolean 7 | 8 | typealias FieldCondition = Field.() -> Boolean 9 | 10 | fun findMethod(clz: Class<*>, findSuper: Boolean = false, condition: MethodCondition): Method { 11 | return findMethodOrNull(clz, findSuper, condition) ?: throw NoSuchMethodException() 12 | } 13 | 14 | fun findMethodOrNull( 15 | clz: Class<*>, 16 | findSuper: Boolean = false, 17 | condition: MethodCondition 18 | ): Method? { 19 | var c = clz 20 | c.declaredMethods 21 | .firstOrNull { it.condition() } 22 | ?.let { 23 | it.isAccessible = true 24 | return it 25 | } 26 | 27 | if (findSuper) { 28 | while (c.superclass?.also { c = it } != null) { 29 | c.declaredMethods 30 | .firstOrNull { it.condition() } 31 | ?.let { 32 | it.isAccessible = true 33 | return it 34 | } 35 | } 36 | } 37 | return null 38 | } 39 | 40 | fun Any.invokeMethod(vararg args: Any?, condition: MethodCondition): Any? { 41 | findMethodOrNull(this::class.java, true, condition)?.let { 42 | return it(this, *args) 43 | } 44 | throw NoSuchMethodException() 45 | } 46 | 47 | fun findField(clz: Class<*>, findSuper: Boolean = false, condition: FieldCondition): Field { 48 | return findFieldOrNull(clz, findSuper, condition) ?: throw NoSuchFieldException() 49 | } 50 | 51 | fun findFieldOrNull(clz: Class<*>, findSuper: Boolean = false, condition: FieldCondition): Field? { 52 | var c = clz 53 | c.declaredFields 54 | .firstOrNull { it.condition() } 55 | ?.let { 56 | it.isAccessible = true 57 | return it 58 | } 59 | 60 | if (findSuper) { 61 | while (c.superclass?.also { c = it } != null) { 62 | c.declaredFields 63 | .firstOrNull { it.condition() } 64 | ?.let { 65 | it.isAccessible = true 66 | return it 67 | } 68 | } 69 | } 70 | return null 71 | } 72 | -------------------------------------------------------------------------------- /app/src/main/res/xml/developer_preferences.xml: -------------------------------------------------------------------------------- 1 | 2 | 7 | 8 | 9 | 13 | 16 | 21 | 26 | 30 | 34 | 38 | 44 | 45 | -------------------------------------------------------------------------------- /app/src/main/java/org/matrix/chromext/script/SQLite.kt: -------------------------------------------------------------------------------- 1 | package org.matrix.chromext.script 2 | 3 | import android.content.Context 4 | import android.database.sqlite.SQLiteDatabase 5 | import android.database.sqlite.SQLiteOpenHelper 6 | import org.json.JSONObject 7 | 8 | data class Script( 9 | val id: String, 10 | val match: Array, 11 | val grant: Array, 12 | val exclude: Array, 13 | var meta: String, 14 | val code: String, 15 | var storage: JSONObject?, 16 | val lib: List = mutableListOf(), 17 | val noframes: Boolean, 18 | ) 19 | 20 | private const val SQL_CREATE_ENTRIES = 21 | "CREATE TABLE script (id TEXT PRIMARY KEY NOT NULL, meta TEXT NOT NULL, code TEXT NOT NULL, storage TEXT);" 22 | 23 | class ScriptDbHelper(context: Context) : 24 | SQLiteOpenHelper(context, DATABASE_NAME, null, DATABASE_VERSION) { 25 | 26 | override fun onCreate(db: SQLiteDatabase) { 27 | db.execSQL(SQL_CREATE_ENTRIES) 28 | } 29 | 30 | override fun onUpgrade(db: SQLiteDatabase, oldVersion: Int, newVersion: Int) { 31 | if (oldVersion < 7) { 32 | db.execSQL("DROP TABLE script;") 33 | onCreate(db) 34 | return 35 | } 36 | 37 | if (oldVersion == 7 && newVersion == 8) { 38 | db.execSQL("CREATE TABLE tmp AS SELECT id,meta,code,storage FROM script;") 39 | db.execSQL("DROP TABLE script;") 40 | db.execSQL("ALTER TABLE tmp RENAME TO script;") 41 | } 42 | 43 | if (oldVersion == 8 && newVersion == 9) { 44 | db.execSQL("CREATE TABLE tmp AS SELECT id,meta,code,storage FROM script;") 45 | db.execSQL("DROP TABLE script;") 46 | db.execSQL(SQL_CREATE_ENTRIES) 47 | db.execSQL( 48 | "INSERT INTO script (id,meta,code) SELECT id,meta,code FROM tmp WHERE storage = '';") 49 | db.execSQL("INSERT INTO script SELECT * FROM tmp WHERE storage != '';") 50 | db.execSQL("DROP TABLE tmp;") 51 | } 52 | 53 | if (newVersion - oldVersion > 1) { 54 | onUpgrade(db, oldVersion, oldVersion + 1) 55 | onUpgrade(db, oldVersion + 1, newVersion) 56 | } 57 | } 58 | 59 | override fun onDowngrade(db: SQLiteDatabase, oldVersion: Int, newVersion: Int) {} 60 | 61 | companion object { 62 | const val DATABASE_VERSION = 9 63 | const val DATABASE_NAME = "userscript" 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /app/src/main/java/org/matrix/chromext/devtools/Inspect.kt: -------------------------------------------------------------------------------- 1 | package org.matrix.chromext.devtools 2 | 3 | import android.net.LocalSocket 4 | import org.json.JSONArray 5 | import org.matrix.chromext.utils.Log 6 | 7 | fun getInspectPages(): JSONArray? { 8 | var response = "" 9 | runCatching { 10 | hitDevTools().inputStream.bufferedReader().use { 11 | while (true) { 12 | val line = it.readLine() 13 | if (line.length == 0) { 14 | val bufferSize = 15 | response 16 | .split("\n") 17 | .find { it.startsWith("Content-Length") }!! 18 | .substring(15) 19 | .toInt() 20 | val buffer = CharArray(bufferSize) 21 | it.read(buffer) 22 | response = buffer.joinToString("") 23 | it.close() 24 | break 25 | } 26 | response += line + "\n" 27 | } 28 | } 29 | } 30 | .onFailure { 31 | Log.ex(it) 32 | return null 33 | } 34 | return JSONArray(response) 35 | } 36 | 37 | fun hitDevTools(): LocalSocket { 38 | val client = LocalSocket() 39 | connectDevTools(client) 40 | client.outputStream.write("GET /json HTTP/1.1\r\n\r\n".toByteArray()) 41 | return client 42 | } 43 | 44 | object DevSessions { 45 | private val clients = mutableSetOf() 46 | 47 | fun get(condition: (DevToolClient) -> Boolean): DevToolClient? { 48 | var cached = clients.find { condition(it) } 49 | if (cached?.isClosed() == true) { 50 | clients.remove(cached) 51 | cached = null 52 | } 53 | return cached 54 | } 55 | 56 | fun new( 57 | tabId: String, 58 | tag: String?, 59 | condition: (DevToolClient) -> Boolean = { true } 60 | ): DevToolClient { 61 | var client = 62 | get { it.tabId == tabId && it.tag == tag && condition(it) } ?: DevToolClient(tabId, tag) 63 | if (client.isClosed()) { 64 | hitDevTools().close() 65 | client = DevToolClient(tabId) 66 | } 67 | return client 68 | } 69 | 70 | fun add(client: DevToolClient?) { 71 | if (client == null) return 72 | val cached = clients.find { it.tabId == client.tabId } 73 | if (cached != null) clients.remove(cached) 74 | clients.add(client) 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /.github/workflows/android.yml: -------------------------------------------------------------------------------- 1 | name: Android CI 2 | 3 | on: 4 | push: 5 | branches: [ "master" ] 6 | pull_request: 7 | branches: [ "master" ] 8 | 9 | jobs: 10 | build: 11 | 12 | runs-on: ubuntu-latest 13 | 14 | steps: 15 | - uses: actions/checkout@main 16 | - name: set up JDK 21 17 | uses: actions/setup-java@main 18 | with: 19 | java-version: '21' 20 | distribution: 'temurin' 21 | 22 | - name: Build debug 23 | run: ./gradlew app:assembleDebug 24 | 25 | - name: Build release 26 | if: ${{ ( github.event_name != 'pull_request' && github.ref == 'refs/heads/master' ) || github.ref_type == 'tag' }} 27 | run: ./gradlew app:assembleRelease 28 | 29 | - name: Rename debug build 30 | run: mv app/build/outputs/apk/debug/app-debug.apk app/build/ChromeXt_debug.apk 31 | 32 | - name: Rename signed apk 33 | if: ${{ ( github.event_name != 'pull_request' && github.ref == 'refs/heads/master' ) || github.ref_type == 'tag' }} 34 | run: mv app/build/outputs/apk/release/app-release-unsigned.apk app/build/outputs/apk/release/ChromeXt.apk 35 | 36 | 37 | - uses: noriban/sign-android-release@master 38 | if: ${{ ( github.event_name != 'pull_request' && github.ref == 'refs/heads/master' ) || github.ref_type == 'tag' }} 39 | name: Sign app APK 40 | id: sign_app 41 | with: 42 | releaseDirectory: app/build/outputs/apk/release 43 | signingKeyBase64: ${{ secrets.SIGNING_KEY }} 44 | alias: ${{ secrets.ALIAS }} 45 | keyStorePassword: ${{ secrets.KEY_STORE_PASSWORD }} 46 | keyPassword: ${{ secrets.KEY_PASSWORD }} 47 | 48 | - name: Upload debug build 49 | uses: actions/upload-artifact@main 50 | with: 51 | name: ChromeXt_debug.apk 52 | path: app/build/ChromeXt_debug.apk 53 | 54 | - name: Upload signed APK 55 | if: ${{ ( github.event_name != 'pull_request' && github.ref == 'refs/heads/master' ) || github.ref_type == 'tag' }} 56 | uses: actions/upload-artifact@main 57 | with: 58 | name: ChromeXt_signed.apk 59 | path: ${{steps.sign_app.outputs.signedReleaseFile}} 60 | 61 | - name: Collect debug build 62 | uses: actions/download-artifact@main 63 | with: 64 | name: ChromeXt_debug.apk 65 | 66 | - name: Collect signed APK 67 | if: ${{ ( github.event_name != 'pull_request' && github.ref == 'refs/heads/master' ) || github.ref_type == 'tag' }} 68 | uses: actions/download-artifact@main 69 | with: 70 | name: ChromeXt_signed.apk 71 | -------------------------------------------------------------------------------- /app/src/main/java/org/matrix/chromext/hook/PageInfo.kt: -------------------------------------------------------------------------------- 1 | package org.matrix.chromext.hook 2 | 3 | import android.view.View 4 | import android.view.ViewGroup 5 | import android.widget.ImageView 6 | import android.widget.LinearLayout 7 | import android.widget.TextView 8 | import org.matrix.chromext.Chrome 9 | import org.matrix.chromext.Listener 10 | import org.matrix.chromext.R 11 | import org.matrix.chromext.proxy.PageInfoProxy 12 | import org.matrix.chromext.proxy.UserScriptProxy 13 | import org.matrix.chromext.script.Local 14 | import org.matrix.chromext.utils.* 15 | 16 | object PageInfoHook : BaseHook() { 17 | 18 | override fun init() { 19 | 20 | if (ContextMenuHook.isInit) return 21 | var controller: Any? = null 22 | val proxy = PageInfoProxy 23 | 24 | fun addErudaRow(url: String): ViewGroup { 25 | val infoRow = 26 | proxy.pageInfoRowView.declaredConstructors[0].newInstance(Chrome.getContext(), null) 27 | as ViewGroup 28 | infoRow.setVisibility(View.VISIBLE) 29 | val icon = proxy.mIcon.get(infoRow) as ImageView 30 | icon.setImageResource(R.drawable.ic_devtools) 31 | val subTitle = proxy.mSubtitle.get(infoRow) as TextView 32 | (subTitle.getParent() as? ViewGroup)?.removeView(subTitle) 33 | val title = proxy.mTitle.get(infoRow) as TextView 34 | if (isChromeXtFrontEnd(url)) { 35 | title.setText(R.string.main_menu_developer_tools) 36 | infoRow.setOnClickListener { 37 | Listener.on("inspectPages") 38 | controller!!.invokeMethod() { name == "destroy" } 39 | } 40 | } else if (isUserScript(url)) { 41 | title.setText(R.string.main_menu_install_script) 42 | infoRow.setOnClickListener { 43 | val sandBoxed = shouldBypassSandbox(url) 44 | Chrome.evaluateJavascript(listOf("Symbol.installScript(true);"), null, null, sandBoxed) 45 | controller!!.invokeMethod() { name == "destroy" } 46 | } 47 | } else { 48 | title.setText(R.string.main_menu_eruda_console) 49 | infoRow.setOnClickListener { 50 | UserScriptProxy.evaluateJavascript(Local.openEruda) 51 | controller!!.invokeMethod() { name == "destroy" } 52 | } 53 | } 54 | return infoRow 55 | } 56 | 57 | proxy.pageInfoControllerRef.declaredConstructors[0].hookAfter { controller = it.thisObject } 58 | 59 | proxy.pageInfoController.declaredConstructors[0].hookAfter { 60 | val url = Chrome.getUrl()!! 61 | if (isChromeScheme(url) || controller == null) return@hookAfter 62 | (proxy.mRowWrapper.get(proxy.mView.get(it.thisObject)) as LinearLayout).addView( 63 | addErudaRow(url)) 64 | } 65 | 66 | // readerMode.init(Chrome.load("org.chromium.chrome.browser.dom_distiller.ReaderModeManager")) 67 | isInit = true 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /app/src/main/assets/devtools.js: -------------------------------------------------------------------------------- 1 | window.addEventListener("load", () => { 2 | const meta = document.createElement("meta"); 3 | meta.setAttribute("name", "viewport"); 4 | meta.setAttribute("content", "width=device-width, initial-scale=1"); 5 | document.head.prepend(meta); 6 | const style = document.createElement("style"); 7 | style.innerText = ".filter-bitset-filter { overflow-x: scroll !important; }"; 8 | document.body.prepend(style); 9 | }); 10 | 11 | class WebSocket extends EventTarget { 12 | binaryType = "blob"; // Not used for DevTools protectol 13 | #state; 14 | #url; 15 | #payload; 16 | #pending = []; 17 | 18 | get bufferedAmount() { 19 | return new Blob(this.#pending).size; 20 | } 21 | get extensions() { 22 | return ""; 23 | } 24 | get protocol() { 25 | return ""; 26 | } 27 | get readyState() { 28 | return this.#state; 29 | } 30 | get url() { 31 | return this.#url; 32 | } 33 | 34 | constructor() { 35 | super(); 36 | this.#state = 0; 37 | this.#url = arguments[0]; 38 | this.#payload = { targetTabId: this.#url.split("/").pop() }; 39 | ChromeXt.dispatch("websocket", this.#payload); 40 | ChromeXt.addEventListener("websocket", this.#handler.bind(this)); 41 | } 42 | 43 | #handler(e) { 44 | const type = Object.keys(e.detail)[0]; 45 | const data = e.detail[type]; 46 | if (type == "message" && !("id" in data) && "params" in data) { 47 | const targetInfo = data.params.targetInfo; 48 | if (typeof targetInfo != "undefined" && targetInfo.type != "page") { 49 | console.info("Ignore inspecting", targetInfo.type, targetInfo.url); 50 | // To inspect them, we may need to change the targetTabId 51 | return; 52 | } 53 | } else if (type == "close") { 54 | this.close(); 55 | } else if (type == "open") { 56 | this.#state = 1; 57 | // It would be better if the target is attached, 58 | // but there is no way to do so, neither to replay the pending message later. 59 | } 60 | const event = new MessageEvent(type, { data }); 61 | try { 62 | this["on" + type](event); 63 | } catch { 64 | this.dispatchEvent(event); 65 | } 66 | } 67 | 68 | send(msg) { 69 | if (typeof msg == "string") { 70 | this.#pending.push(msg); 71 | } else { 72 | throw Error("Invalid message", msg); 73 | } 74 | if (this.#state == 1) { 75 | this.#pending.forEach((msg) => { 76 | this.#payload.message = msg; 77 | ChromeXt.dispatch("websocket", this.#payload); 78 | }); 79 | this.#pending.length = 0; 80 | } 81 | } 82 | 83 | close() { 84 | this.#state = 2; 85 | const event = new MessageEvent("close"); 86 | if (typeof this.onclose == "function") { 87 | this.onclose(event); 88 | } else { 89 | this.dispatchEvent(event); 90 | } 91 | this.#state = 3; 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /app/src/main/java/org/matrix/chromext/utils/Hook.kt: -------------------------------------------------------------------------------- 1 | package org.matrix.chromext.utils 2 | 3 | import de.robv.android.xposed.XC_MethodHook 4 | import de.robv.android.xposed.XC_MethodHook.Unhook 5 | import de.robv.android.xposed.XposedBridge 6 | import de.robv.android.xposed.callbacks.XCallback 7 | import java.lang.reflect.Constructor 8 | import java.lang.reflect.Method 9 | 10 | typealias Hooker = (param: XC_MethodHook.MethodHookParam) -> Unit 11 | 12 | fun Method.hookMethod(hookCallback: XC_MethodHook): XC_MethodHook.Unhook { 13 | return XposedBridge.hookMethod(this, hookCallback) 14 | } 15 | 16 | fun Method.hookBefore( 17 | priority: Int = XCallback.PRIORITY_DEFAULT, 18 | hook: Hooker 19 | ): XC_MethodHook.Unhook { 20 | return this.hookMethod( 21 | object : XC_MethodHook(priority) { 22 | override fun beforeHookedMethod(param: MethodHookParam) = 23 | try { 24 | hook(param) 25 | } catch (thr: Throwable) { 26 | Log.ex(thr) 27 | } 28 | }) 29 | } 30 | 31 | fun Method.hookAfter( 32 | priority: Int = XCallback.PRIORITY_DEFAULT, 33 | hooker: Hooker 34 | ): XC_MethodHook.Unhook { 35 | return this.hookMethod( 36 | object : XC_MethodHook(priority) { 37 | override fun afterHookedMethod(param: MethodHookParam) = 38 | try { 39 | hooker(param) 40 | } catch (thr: Throwable) { 41 | Log.ex(thr) 42 | } 43 | }) 44 | } 45 | 46 | fun Constructor<*>.hookMethod(hookCallback: XC_MethodHook): XC_MethodHook.Unhook { 47 | return XposedBridge.hookMethod(this, hookCallback) 48 | } 49 | 50 | fun Constructor<*>.hookAfter( 51 | priority: Int = XCallback.PRIORITY_DEFAULT, 52 | hooker: Hooker 53 | ): XC_MethodHook.Unhook { 54 | return this.hookMethod( 55 | object : XC_MethodHook(priority) { 56 | override fun afterHookedMethod(param: MethodHookParam) = 57 | try { 58 | hooker(param) 59 | } catch (thr: Throwable) { 60 | Log.ex(thr) 61 | } 62 | }) 63 | } 64 | 65 | class XposedHookFactory(priority: Int = XCallback.PRIORITY_DEFAULT) : XC_MethodHook(priority) { 66 | private var beforeMethod: Hooker? = null 67 | private var afterMethod: Hooker? = null 68 | 69 | override fun beforeHookedMethod(param: MethodHookParam) { 70 | beforeMethod?.invoke(param) 71 | } 72 | 73 | override fun afterHookedMethod(param: MethodHookParam) { 74 | afterMethod?.invoke(param) 75 | } 76 | 77 | fun before(before: Hooker) { 78 | this.beforeMethod = before 79 | } 80 | 81 | fun after(after: Hooker) { 82 | this.afterMethod = after 83 | } 84 | } 85 | 86 | fun Method.hookMethod( 87 | priority: Int = XCallback.PRIORITY_DEFAULT, 88 | hook: XposedHookFactory.() -> Unit 89 | ): XC_MethodHook.Unhook { 90 | val factory = XposedHookFactory(priority) 91 | hook(factory) 92 | return this.hookMethod(factory) 93 | } 94 | -------------------------------------------------------------------------------- /app/src/main/java/org/matrix/chromext/OpenInChrome.kt: -------------------------------------------------------------------------------- 1 | package org.matrix.chromext 2 | 3 | import android.app.Activity 4 | import android.content.ComponentName 5 | import android.content.Intent 6 | import android.net.Uri 7 | import android.os.Bundle 8 | import org.matrix.chromext.utils.Log 9 | 10 | const val TAG = "ChromeXt" 11 | 12 | class OpenInChrome : Activity() { 13 | var defaultPackage = "com.android.chrome" 14 | 15 | fun invokeChromeTabbed(url: String) { 16 | val activity = "com.google.android.apps.chrome.Main" 17 | val chromeMain = 18 | Intent(Intent.ACTION_MAIN).setComponent(ComponentName(defaultPackage, activity)) 19 | startActivity(chromeMain.putExtra("ChromeXt", url)) 20 | } 21 | 22 | @Suppress("QueryPermissionsNeeded") 23 | override fun onCreate(savedInstanceState: Bundle?) { 24 | super.onCreate(savedInstanceState) 25 | 26 | @Suppress("DEPRECATION") val installedApplications = packageManager.getInstalledApplications(0) 27 | val avaiblePackages = supportedPackages.intersect(installedApplications.map { it.packageName }) 28 | if (avaiblePackages.size == 0) { 29 | Log.toast(this, "No supported Chrome installed") 30 | finish() 31 | return 32 | } else { 33 | defaultPackage = avaiblePackages.last() 34 | } 35 | 36 | val isSamsung = defaultPackage.startsWith("com.sec.android.app.sbrowser") 37 | val intent: Intent = getIntent() 38 | val destination: ComponentName = 39 | ComponentName( 40 | defaultPackage, 41 | if (isSamsung) { 42 | "com.sec.android.app.sbrowser.SBrowserMainActivity" 43 | } else { 44 | "com.google.android.apps.chrome.IntentDispatcher" 45 | }) 46 | 47 | if (intent.action == Intent.ACTION_VIEW) { 48 | intent.setComponent(destination) 49 | intent.setDataAndType(intent.data, "text/html") 50 | startActivity(intent) 51 | } else if (intent.action == Intent.ACTION_SEND && !isSamsung) { 52 | var text = intent.getStringExtra(Intent.EXTRA_TEXT) 53 | if (text == null || intent.type != "text/plain") { 54 | finish() 55 | return 56 | } 57 | 58 | Log.d("Get share text: ${text}") 59 | if (text.startsWith("file://") || text.startsWith("data:")) { 60 | invokeChromeTabbed(text) 61 | } else { 62 | if (!text.contains("://")) { 63 | text = "https://google.com/search?q=${text.replace("#", "%23")}" 64 | } else if (text.contains("\n ")) { 65 | text = text.split("\n ")[1] 66 | } 67 | 68 | if (!text.startsWith("http")) { 69 | // Unable to open custom url 70 | Log.toast(this, "Unable to open ${text.split("://").first()} scheme") 71 | finish() 72 | return 73 | } 74 | 75 | startActivity( 76 | Intent().apply { 77 | action = Intent.ACTION_VIEW 78 | data = Uri.parse(text) 79 | component = destination 80 | }) 81 | } 82 | } 83 | finish() 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /gradlew.bat: -------------------------------------------------------------------------------- 1 | @rem 2 | @rem Copyright 2015 the original author or authors. 3 | @rem 4 | @rem Licensed under the Apache License, Version 2.0 (the "License"); 5 | @rem you may not use this file except in compliance with the License. 6 | @rem You may obtain a copy of the License at 7 | @rem 8 | @rem https://www.apache.org/licenses/LICENSE-2.0 9 | @rem 10 | @rem Unless required by applicable law or agreed to in writing, software 11 | @rem distributed under the License is distributed on an "AS IS" BASIS, 12 | @rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | @rem See the License for the specific language governing permissions and 14 | @rem limitations under the License. 15 | @rem 16 | @rem SPDX-License-Identifier: Apache-2.0 17 | @rem 18 | 19 | @if "%DEBUG%"=="" @echo off 20 | @rem ########################################################################## 21 | @rem 22 | @rem Gradle startup script for Windows 23 | @rem 24 | @rem ########################################################################## 25 | 26 | @rem Set local scope for the variables with windows NT shell 27 | if "%OS%"=="Windows_NT" setlocal 28 | 29 | set DIRNAME=%~dp0 30 | if "%DIRNAME%"=="" set DIRNAME=. 31 | @rem This is normally unused 32 | set APP_BASE_NAME=%~n0 33 | set APP_HOME=%DIRNAME% 34 | 35 | @rem Resolve any "." and ".." in APP_HOME to make it shorter. 36 | for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi 37 | 38 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 39 | set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" 40 | 41 | @rem Find java.exe 42 | if defined JAVA_HOME goto findJavaFromJavaHome 43 | 44 | set JAVA_EXE=java.exe 45 | %JAVA_EXE% -version >NUL 2>&1 46 | if %ERRORLEVEL% equ 0 goto execute 47 | 48 | echo. 1>&2 49 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 50 | echo. 1>&2 51 | echo Please set the JAVA_HOME variable in your environment to match the 1>&2 52 | echo location of your Java installation. 1>&2 53 | 54 | goto fail 55 | 56 | :findJavaFromJavaHome 57 | set JAVA_HOME=%JAVA_HOME:"=% 58 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe 59 | 60 | if exist "%JAVA_EXE%" goto execute 61 | 62 | echo. 1>&2 63 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 64 | echo. 1>&2 65 | echo Please set the JAVA_HOME variable in your environment to match the 1>&2 66 | echo location of your Java installation. 1>&2 67 | 68 | goto fail 69 | 70 | :execute 71 | @rem Setup the command line 72 | 73 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar 74 | 75 | 76 | @rem Execute Gradle 77 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* 78 | 79 | :end 80 | @rem End local scope for the variables with windows NT shell 81 | if %ERRORLEVEL% equ 0 goto mainEnd 82 | 83 | :fail 84 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of 85 | rem the _cmd.exe /c_ return code! 86 | set EXIT_CODE=%ERRORLEVEL% 87 | if %EXIT_CODE% equ 0 set EXIT_CODE=1 88 | if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% 89 | exit /b %EXIT_CODE% 90 | 91 | :mainEnd 92 | if "%OS%"=="Windows_NT" endlocal 93 | 94 | :omega 95 | -------------------------------------------------------------------------------- /app/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 35 | 38 | 41 | 44 | 45 | 48 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | -------------------------------------------------------------------------------- /app/src/main/assets/extension.js: -------------------------------------------------------------------------------- 1 | extension.tabUrl == extension.tabUrl || 2 | "https://jingmatrix.github.io/ChromeXt/"; 3 | const StubChrome = extension.tabUrl + "StubChrome.js"; 4 | 5 | const imports = await import(StubChrome); 6 | globalThis.chrome = imports.chrome; 7 | 8 | class ChromeEvent extends imports.StubEvent { 9 | #listeners = []; 10 | constructor(target, event) { 11 | super(); 12 | this.target = target; 13 | this.event = event; 14 | } 15 | addListener(f) { 16 | const l = (e) => f(e.detail); 17 | this.#listeners.push([f, l]); 18 | this.target.addEventListener(this.event, l); 19 | } 20 | removeListener(f) { 21 | const index = this.#listeners.findIndex((i) => i[0] == f); 22 | if (index != -1) { 23 | const l = this.#listeners[index][1]; 24 | this.#listeners.splice(index, 1); 25 | this.target.removeEventListener(this.event, l); 26 | } 27 | } 28 | hasListener(f) { 29 | const index = this.#listeners.findIndex((i) => i[0] == f); 30 | return index != -1; 31 | } 32 | dispatch(detail) { 33 | this.target.dispatchEvent(new CustomEvent(this.event, { detail })); 34 | } 35 | } 36 | 37 | Object.keys(chrome).forEach((key) => { 38 | const domain = chrome[key]; 39 | const keys = Object.keys(domain); 40 | const events = keys 41 | .filter((k) => domain[k].__proto__.constructor == imports.StubEvent) 42 | .map((k) => k.substring(2)); 43 | const properties = keys 44 | .filter((k) => k.startsWith("set")) 45 | .map((k) => k.substring(3)); 46 | if (events.length > 0) { 47 | chrome[key] = new EventTarget(); 48 | const t = chrome[key]; 49 | Object.assign(t, domain); 50 | events.forEach((e) => { 51 | t["on" + e] = new ChromeEvent(t, e); 52 | }); 53 | } 54 | if (properties.length > 0) { 55 | chrome[key]._props = new Map(); 56 | const props = chrome[key]._props; 57 | properties.forEach((k) => { 58 | chrome[key]["set" + k] = (v) => props.set(k, v); 59 | chrome[key]["get" + k] = () => props.get(k); 60 | }); 61 | } 62 | }); 63 | 64 | chrome.runtime.getManifest = () => extension; 65 | chrome.runtime.id = extension.id; 66 | chrome.runtime.getURL = (path) => location.origin + "/" + path; 67 | 68 | async function fetch_locale(locales) { 69 | while (locales.length > 0) { 70 | const locale = locales.pop(); 71 | try { 72 | const res = await fetch("/_locales/" + locale + "/messages.json"); 73 | chrome.i18n.locale = locale; 74 | chrome.i18n.messages = await res.json(); 75 | break; 76 | } catch {} 77 | } 78 | } 79 | await fetch_locale([ 80 | extension.default_locale, 81 | navigator.language.substring(0, 2), 82 | navigator.language, 83 | ]); 84 | 85 | chrome.i18n.getMessage = (name) => chrome.i18n.messages[name] || ""; 86 | 87 | // Restore the original HTML elements 88 | const parser = new DOMParser(); 89 | const doc = parser.parseFromString(extension.html, "text/html"); 90 | const inFrame = typeof Symbol.ChromeXt == "undefined"; 91 | document.head.remove(); 92 | document.documentElement.prepend(doc.head); 93 | doc.querySelectorAll("script").forEach((node) => { 94 | const script = document.createElement("script"); 95 | script.src = node.src; 96 | if (typeof node.type == "string") { 97 | script.type = node.type; 98 | } 99 | document.body.append(script); 100 | }); 101 | -------------------------------------------------------------------------------- /app/src/main/java/org/matrix/chromext/script/Parser.kt: -------------------------------------------------------------------------------- 1 | package org.matrix.chromext.script 2 | 3 | import android.net.Uri 4 | import android.util.Base64 5 | import java.net.HttpURLConnection 6 | import java.net.URL 7 | import kotlin.text.Regex 8 | import org.json.JSONObject 9 | import org.matrix.chromext.Chrome 10 | 11 | private val blocksReg = 12 | Regex( 13 | """(?[\S\s]*?// ==UserScript==\r?\n[\S\s]*?\r?\n// ==/UserScript==\s+)(?[\S\s]*)""") 14 | private val metaReg = Regex("""^//\s+@(?[\w-]+)(\s+(?.+))?""") 15 | 16 | fun parseScript(input: String, storage: String? = null): Script? { 17 | val blockMatchGroup = blocksReg.matchEntire(input)?.groups 18 | if (blockMatchGroup == null) { 19 | return null 20 | } 21 | 22 | val script = 23 | object { 24 | var name = "sample" 25 | var namespace = "ChromeXt" 26 | var match = mutableListOf() 27 | var grant = mutableListOf() 28 | var exclude = mutableListOf() 29 | var require = mutableListOf() 30 | val meta = (blockMatchGroup[1]?.value as String) 31 | val code = blockMatchGroup[2]?.value as String 32 | var storage: JSONObject? = null 33 | var noframes = false 34 | } 35 | script.meta.split("\n").forEach { 36 | val metaMatchGroup = metaReg.matchEntire(it)?.groups 37 | if (metaMatchGroup != null) { 38 | val key = metaMatchGroup[1]?.value as String 39 | if (metaMatchGroup[2] != null) { 40 | val value = metaMatchGroup[3]?.value as String 41 | when (key) { 42 | "name" -> script.name = value.replace(":", "") 43 | "namespace" -> script.namespace = value 44 | "match" -> script.match.add(value) 45 | "include" -> script.match.add(value) 46 | "grant" -> script.grant.add(value) 47 | "exclude" -> script.exclude.add(value) 48 | "require" -> script.require.add(value) 49 | "noframes" -> script.noframes = true 50 | } 51 | } else { 52 | when (key) { 53 | "noframes" -> script.noframes = true 54 | } 55 | } 56 | } 57 | } 58 | 59 | if (!script.grant.contains("GM_xmlhttpRequest") && 60 | (script.grant.contains("GM_download") || 61 | script.grant.contains("GM.xmlHttpRequest") || 62 | script.grant.contains("GM_getResourceText"))) { 63 | script.grant.add("GM_xmlhttpRequest") 64 | } 65 | 66 | if (script.grant.contains("GM.getValue") || 67 | script.grant.contains("GM_getValue") || 68 | script.grant.contains("GM_cookie")) { 69 | runCatching { script.storage = JSONObject(storage!!) } 70 | .onFailure { script.storage = JSONObject() } 71 | } 72 | 73 | if (script.match.size == 0) { 74 | return null 75 | } else { 76 | val lib = mutableListOf() 77 | Chrome.IO.submit { script.require.forEach { runCatching { lib.add(downloadLib(it)) } } } 78 | val parsed = 79 | Script( 80 | script.namespace + ":" + script.name, 81 | script.match.toTypedArray(), 82 | script.grant.toTypedArray(), 83 | script.exclude.toTypedArray(), 84 | script.meta, 85 | script.code, 86 | script.storage, 87 | lib, 88 | script.noframes) 89 | return parsed 90 | } 91 | } 92 | 93 | private fun downloadLib(libUrl: String): String { 94 | if (libUrl.startsWith("data:")) { 95 | val chunks = libUrl.split(",").toMutableList() 96 | val type = chunks.removeFirst() 97 | val data = Uri.decode(chunks.joinToString("")) 98 | if (type.endsWith("base64")) { 99 | return Base64.decode(data, Base64.DEFAULT).toString() 100 | } else { 101 | return data 102 | } 103 | } 104 | val url = URL(libUrl) 105 | val connection = url.openConnection() as HttpURLConnection 106 | return connection.inputStream.bufferedReader().use { it.readText() } 107 | } 108 | -------------------------------------------------------------------------------- /app/src/main/assets/eruda.css: -------------------------------------------------------------------------------- 1 | /* font_fix */ 2 | [class^="eruda-icon"]:before { 3 | font-size: 10px; 4 | display: block; 5 | } 6 | .eruda-icon-arrow-left:before { 7 | content: "←"; 8 | } 9 | .eruda-icon-arrow-right:before { 10 | content: "→"; 11 | } 12 | .eruda-icon-clear:before { 13 | content: "⦸"; 14 | font-size: 16px; 15 | margin-left: 1px; 16 | } 17 | .eruda-icon-compress:before { 18 | content: "🗎"; 19 | } 20 | .eruda-icon-copy:before, 21 | .luna-text-viewer-icon-copy:before { 22 | content: "⎘ "; 23 | font-size: 14px; 24 | font-weight: bold; 25 | } 26 | .eruda-icon-delete:before { 27 | content: "X"; 28 | font-size: 15px; 29 | font-weight: bold; 30 | margin-left: 2px; 31 | transform: scale(1.3, 1); 32 | } 33 | .eruda-icon-expand:before { 34 | content: "⌄"; 35 | } 36 | .eruda-icon-eye:before { 37 | content: "⊕"; 38 | font-size: 16px; 39 | transform: translate(-2px, 0.5px); 40 | } 41 | #eruda-info .eruda-userscripts > h2 > span.eruda-icon-eye { 42 | transform: translate(0px, -6px); 43 | } 44 | div.eruda-btn.eruda-search { 45 | margin-top: 4px; 46 | } 47 | .eruda-icon-filter:before { 48 | content: "Ȳ"; 49 | font-size: 13px; 50 | margin-left: 6px; 51 | transform: scale(1.5, 1); 52 | } 53 | .eruda-icon-play:before { 54 | content: "▷"; 55 | } 56 | .eruda-icon-record:before { 57 | content: "●"; 58 | } 59 | .eruda-icon-refresh:before { 60 | content: "↻"; 61 | font-size: 17px; 62 | font-weight: normal; 63 | transform: translate(-5px, -1.5px); 64 | } 65 | .eruda-icon-reset:before { 66 | content: "↺"; 67 | font-size: 18px; 68 | font-weight: bold; 69 | transform: rotate(270deg) translate(7px, 0); 70 | } 71 | .eruda-icon-search:before { 72 | content: "🔍"; 73 | } 74 | .eruda-icon-select:before { 75 | content: "➤"; 76 | font-size: 14px; 77 | transform: rotate(232deg); 78 | } 79 | .eruda-icon-tool:before { 80 | content: "⚙"; 81 | font-size: 30px; 82 | } 83 | .luna-console-icon-error:before { 84 | content: "✗"; 85 | } 86 | .luna-console-icon-warn:before { 87 | content: "⚠"; 88 | } 89 | [class$="icon-caret-right"]:before, 90 | [class$="icon-arrow-right"]:before { 91 | content: "▼"; 92 | font-size: 9px; 93 | display: block; 94 | transform: rotate(-0.25turn); 95 | } 96 | [class$="icon-caret-down"]:before, 97 | [class$="icon-arrow-down"]:before { 98 | content: "▼"; 99 | font-size: 9px; 100 | } 101 | 102 | /* new_icons */ 103 | .eruda-icon-add:before { 104 | content: "➕"; 105 | font-size: 10px; 106 | vertical-align: 3px; 107 | } 108 | .eruda-icon-save:before { 109 | content: "💾"; 110 | font-size: 10px; 111 | vertical-align: 3px; 112 | } 113 | 114 | /* dom_fix */ 115 | #eruda-elements div.eruda-dom-viewer-container { 116 | overflow-x: hidden; 117 | } 118 | #eruda-elements div.eruda-dom-viewer-container > div.eruda-dom-viewer { 119 | overflow-x: scroll; 120 | } 121 | .luna-dom-viewer { 122 | min-width: 80vw; 123 | } 124 | 125 | /* plugin */ 126 | .eruda-filters > ul > li { 127 | display: flex; 128 | } 129 | h2.eruda-title { 130 | width: 100%; 131 | } 132 | #eruda-info li .eruda-title span { 133 | padding-left: 8px; 134 | float: right; 135 | } 136 | #eruda-info .eruda-csp-rules > div.eruda-content { 137 | white-space: pre-wrap; 138 | margin-right: 3em; 139 | } 140 | #eruda-info .eruda-user-agent > h2 > span.eruda-reset { 141 | position: relative; 142 | top: 35px; 143 | left: 20.5px; 144 | } 145 | #eruda-info .eruda-userscripts > h2 > span.eruda-icon-eye { 146 | position: relative; 147 | right: 10px; 148 | } 149 | #eruda-info .eruda-user-agent > div.eruda-content { 150 | text-wrap: balance; 151 | text-align: center; 152 | margin-right: 3em; 153 | } 154 | #eruda-info .eruda-user-agent h2, 155 | #eruda-info .eruda-csp-rules h2 { 156 | padding-bottom: 12px; 157 | } 158 | #eruda-info .eruda-check-update { 159 | text-align: center; 160 | } 161 | #eruda-info .eruda-userscripts div.eruda-content, 162 | #eruda-resources div.eruda-commands { 163 | display: flex; 164 | flex-wrap: wrap; 165 | justify-content: space-around; 166 | > span { 167 | padding: 0.3em; 168 | margin: 0.3em; 169 | border: 0.5px solid violet; 170 | } 171 | } 172 | #eruda-resources .eruda-filter-item { 173 | width: 90%; 174 | padding: 0.3em; 175 | } 176 | #eruda-resources .eruda-delete-filter { 177 | width: 10%; 178 | margin: auto; 179 | text-align: center; 180 | } 181 | -------------------------------------------------------------------------------- /app/src/main/java/org/matrix/chromext/hook/WebView.kt: -------------------------------------------------------------------------------- 1 | package org.matrix.chromext.hook 2 | 3 | import android.app.Activity 4 | import android.os.Build 5 | import android.os.Handler 6 | import java.lang.ref.WeakReference 7 | import org.matrix.chromext.Chrome 8 | import org.matrix.chromext.Listener 9 | import org.matrix.chromext.script.Local 10 | import org.matrix.chromext.script.ScriptDbManager 11 | import org.matrix.chromext.utils.Log 12 | import org.matrix.chromext.utils.findField 13 | import org.matrix.chromext.utils.findMethod 14 | import org.matrix.chromext.utils.hookAfter 15 | import org.matrix.chromext.utils.hookBefore 16 | import org.matrix.chromext.utils.invokeMethod 17 | 18 | object WebViewHook : BaseHook() { 19 | 20 | var ViewClient: Class<*>? = null 21 | var ChromeClient: Class<*>? = null 22 | var WebView: Class<*>? = null 23 | val records = mutableListOf>() 24 | 25 | fun evaluateJavascript(code: String?, view: Any?) { 26 | val webView = (view ?: Chrome.getTab()) 27 | if (code != null && code.length > 0 && webView != null) { 28 | val webSettings = webView.invokeMethod { name == "getSettings" } 29 | if (webSettings?.invokeMethod { name == "getJavaScriptEnabled" } == true) 30 | Handler(Chrome.getContext().mainLooper).post { 31 | webView.invokeMethod(code, null) { name == "evaluateJavascript" } 32 | } 33 | } 34 | } 35 | 36 | override fun init() { 37 | 38 | findMethod(ChromeClient!!, true) { name == "onConsoleMessage" && parameterCount == 1 } 39 | // public boolean onConsoleMessage (ConsoleMessage consoleMessage) 40 | .hookAfter { 41 | // Don't use ConsoleMessage to specify this method since Mi Browser uses its own 42 | // implementation 43 | // This should be the way to communicate with the front-end of ChromeXt 44 | val chromeClient = it.thisObject 45 | val consoleMessage = it.args[0] 46 | val messageLevel = consoleMessage.invokeMethod { name == "messageLevel" } 47 | val sourceId = consoleMessage.invokeMethod { name == "sourceId" } as String 48 | val lineNumber = consoleMessage.invokeMethod { name == "lineNumber" } 49 | val message = consoleMessage.invokeMethod { name == "message" } as String 50 | if (messageLevel.toString() == "TIP" && 51 | sourceId.startsWith("local://ChromeXt/init") && 52 | lineNumber == Local.anchorInChromeXt) { 53 | val webView = 54 | if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { 55 | records 56 | .find { 57 | if (Chrome.isQihoo) { 58 | val mProvider = findField(WebView!!) { name == "mProvider" } 59 | mProvider.get(it.get()) 60 | } else { 61 | it.get() 62 | } 63 | ?.invokeMethod { name == "getWebChromeClient" } == chromeClient 64 | } 65 | ?.get() 66 | } else Chrome.getTab() 67 | Listener.startAction(message, webView, chromeClient, sourceId) 68 | } else { 69 | Log.d(messageLevel.toString() + ": [${sourceId}@${lineNumber}] ${message}") 70 | } 71 | } 72 | 73 | fun onUpdateUrl(url: String, view: Any?) { 74 | if (url.startsWith("javascript") || view == null) return 75 | Chrome.updateTab(view) 76 | ScriptDbManager.invokeScript(url, view) 77 | } 78 | 79 | findMethod(WebView!!) { name == "setWebChromeClient" } 80 | .hookAfter { 81 | val webView = it.thisObject 82 | records.removeAll(records.filter { it.get() == null || it.get() == webView }) 83 | if (it.args[0] != null) records.add(WeakReference(webView)) 84 | } 85 | 86 | findMethod(WebView!!) { name == "onAttachedToWindow" } 87 | .hookAfter { Chrome.updateTab(it.thisObject) } 88 | 89 | findMethod(ViewClient!!, true) { name == "onPageStarted" } 90 | // public void onPageStarted (WebView view, String url, Bitmap favicon) 91 | .hookAfter { 92 | if (Chrome.isQihoo && it.thisObject::class.java.declaredMethods.size > 1) return@hookAfter 93 | onUpdateUrl(it.args[1] as String, it.args[0]) 94 | } 95 | 96 | findMethod(Activity::class.java) { name == "onStop" } 97 | .hookBefore { ScriptDbManager.updateScriptStorage() } 98 | isInit = true 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /app/src/main/java/org/matrix/chromext/utils/Url.kt: -------------------------------------------------------------------------------- 1 | package org.matrix.chromext.utils 2 | 3 | import android.net.Uri 4 | import android.provider.OpenableColumns 5 | import kotlin.text.Regex 6 | import org.matrix.chromext.Chrome 7 | import org.matrix.chromext.script.Script 8 | 9 | const val ERUD_URL = "https://cdn.jsdelivr.net/npm/eruda" 10 | private const val DEV_FRONT_END = "https://chrome-devtools-frontend.appspot.com" 11 | 12 | fun randomString(length: Int): String { 13 | val alphabet: List = ('a'..'z') + ('A'..'Z') 14 | return List(length) { alphabet.random() }.joinToString("") 15 | } 16 | 17 | private fun urlMatch(match: String, url: String, strict: Boolean): Boolean { 18 | var pattern = match 19 | val regexPattern = pattern.startsWith("/") && pattern.endsWith("/") 20 | 21 | if (regexPattern) { 22 | pattern = pattern.removeSurrounding("/", "/") 23 | pattern = pattern.replace("\\/", "/") 24 | pattern = pattern.replace("\\://", "://") 25 | } else if ("*" !in pattern) { 26 | if (strict) { 27 | return pattern == url 28 | } else { 29 | return pattern in url 30 | } 31 | } else if ("://" in pattern || strict) { 32 | pattern = pattern.replace("?", "\\?") 33 | pattern = pattern.replace(".", "\\.") 34 | pattern = pattern.replace("*", "[^:]*") 35 | pattern = pattern.replace("[^:]*\\.", "([^:]*\\.)?") 36 | } else { 37 | return false 38 | } 39 | 40 | runCatching { 41 | val result = 42 | if (regexPattern) { 43 | Regex(pattern).containsMatchIn(url) 44 | } else { 45 | Regex(pattern).matches(url) 46 | } 47 | return result 48 | } 49 | .onFailure { Log.i("Invaid matching rule: ${match}, error: " + it.message) } 50 | return false 51 | } 52 | 53 | fun matching(script: Script, url: String): Boolean { 54 | script.exclude.forEach { 55 | if (urlMatch(it, url, true)) { 56 | return false 57 | } 58 | } 59 | script.match.forEach { 60 | if (urlMatch(it, url, false)) { 61 | // Log.d("${script.id} injected") 62 | return true 63 | } 64 | } 65 | return false 66 | } 67 | 68 | fun isDevToolsFrontEnd(url: String?): Boolean { 69 | if (url == null) return false 70 | return url.startsWith(DEV_FRONT_END) 71 | } 72 | 73 | private val invalidUserScriptDomains = listOf("github.com") 74 | val invalidUserScriptUrls = mutableListOf() 75 | 76 | fun isUserScript(url: String?, path: String? = null): Boolean { 77 | if (url == null) return false 78 | if (url.endsWith(".user.js") || 79 | (Chrome.isEdge && 80 | url.endsWith(".js") && 81 | (url.startsWith("file://") || url.startsWith("content://")))) { 82 | if (invalidUserScriptUrls.contains(url)) return false 83 | invalidUserScriptDomains.forEach { if (url.startsWith("https://" + it) == true) return false } 84 | return true 85 | } else { 86 | return (path ?: resolveContentUrl(url)).endsWith(".js") 87 | } 88 | } 89 | 90 | fun resolveContentUrl(url: String): String { 91 | if (!url.startsWith("content://")) return "" 92 | Chrome.getContext().contentResolver.query(Uri.parse(url), null, null, null, null)?.use { cursor -> 93 | cursor.moveToFirst() 94 | val nameIndex = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME) 95 | val dataIndex = cursor.getColumnIndex("_data") 96 | if (dataIndex != -1) { 97 | return cursor.getString(dataIndex) ?: cursor.getString(nameIndex) 98 | } else { 99 | return cursor.getString(nameIndex) 100 | } 101 | } 102 | return "" 103 | } 104 | 105 | private val trustedHosts = 106 | listOf("jingmatrix.github.io", "jianyu-ma.onrender.com", "jianyu-ma.netlify.app") 107 | 108 | fun isChromeXtFrontEnd(url: String?): Boolean { 109 | if (url == null || !url.endsWith("/ChromeXt/")) return false 110 | trustedHosts.forEach { if (url == "https://" + it + "/ChromeXt/") return true } 111 | return false 112 | } 113 | 114 | private val sandboxHosts = listOf("raw.githubusercontent.com", "gist.githubusercontent.com") 115 | 116 | fun shouldBypassSandbox(url: String?): Boolean { 117 | sandboxHosts.forEach { if (url?.startsWith("https://" + it) == true) return true } 118 | return false 119 | } 120 | 121 | fun parseOrigin(url: String): String? { 122 | val protocol = url.split("://") 123 | if (protocol.size > 1 && arrayOf("https", "http", "file").contains(protocol.first())) { 124 | return protocol.first() + "://" + protocol[1].split("/").first() 125 | } else { 126 | return null 127 | } 128 | } 129 | 130 | fun isChromeScheme(url: String): Boolean { 131 | val protocol = url.split("://") 132 | return (protocol.size > 1 && 133 | arrayOf("chrome", "chrome-native", "edge").contains(protocol.first())) 134 | } 135 | -------------------------------------------------------------------------------- /app/src/main/java/org/matrix/chromext/extension/LocalFiles.kt: -------------------------------------------------------------------------------- 1 | package org.matrix.chromext.extension 2 | 3 | import java.io.File 4 | import java.io.FileReader 5 | import java.net.ServerSocket 6 | import java.net.Socket 7 | import java.net.URLConnection 8 | import org.json.JSONArray 9 | import org.json.JSONObject 10 | import org.matrix.chromext.Chrome 11 | import org.matrix.chromext.utils.Log 12 | 13 | object LocalFiles { 14 | 15 | private val directory: File 16 | private val extensions = mutableMapOf() 17 | val script: String 18 | 19 | init { 20 | val ctx = Chrome.getContext() 21 | directory = File(ctx.getExternalFilesDir(null), "Extension") 22 | script = ctx.assets.open("extension.js").bufferedReader().use { it.readText() } 23 | if (!directory.exists()) directory.mkdirs() 24 | directory.listFiles()?.forEach { 25 | val path = it.name 26 | val manifest = File(it, "manifest.json") 27 | if (manifest.exists()) { 28 | val json = FileReader(manifest).use { it.readText() } 29 | runCatching { extensions.put(path, JSONObject(json)) } 30 | } 31 | } 32 | } 33 | 34 | private fun serveFiles(id: String, connection: Socket) { 35 | val path = directory.toString() + "/" + id 36 | val background = extensions.get(id)?.optJSONObject("background")?.optString("page") 37 | runCatching { 38 | connection.inputStream.bufferedReader().use { 39 | val requestLine = it.readLine() 40 | if (requestLine == null) { 41 | connection.close() 42 | return 43 | } 44 | val request = requestLine.split(" ") 45 | if (request[0] == "GET" && request[2] == "HTTP/1.1") { 46 | val name = request[1] 47 | val file = File(path + name) 48 | if (!file.exists() && name != "/ChromeXt.js") { 49 | connection.outputStream.write("HTTP/1.1 404 Not Found\r\n\r\n".toByteArray()) 50 | } else if (file.isDirectory() || name.contains("..")) { 51 | connection.outputStream.write("HTTP/1.1 403 Forbidden\r\n\r\n".toByteArray()) 52 | } else { 53 | val data = 54 | if (name == "/" + background) { 55 | val html = FileReader(file).use { it.readText() } 56 | "" 57 | .toByteArray() 58 | } else if (name == "/ChromeXt.js") { 59 | script.toByteArray() 60 | } else { 61 | file.readBytes() 62 | } 63 | val type = URLConnection.guessContentTypeFromName(name) ?: "text/plain" 64 | val response = 65 | arrayOf( 66 | "HTTP/1.1 200", 67 | "Content-Length: ${data.size}", 68 | "Content-Type: ${type}", 69 | "Access-Control-Allow-Origin: *") 70 | connection.outputStream.write( 71 | (response.joinToString("\r\n") + "\r\n\r\n").toByteArray()) 72 | connection.outputStream.write(data) 73 | } 74 | connection.close() 75 | } 76 | } 77 | } 78 | .onFailure { Log.ex(it) } 79 | } 80 | 81 | private fun startServer(id: String) { 82 | if (extensions.containsKey(id) && !extensions.get(id)!!.has("port")) { 83 | val server = ServerSocket() 84 | server.bind(null) 85 | val port = server.getLocalPort() 86 | Log.d("Listening at port ${port} for ${id}") 87 | Chrome.IO.submit { 88 | runCatching { 89 | while (true) { 90 | val socket = server.accept() 91 | Chrome.IO.submit { serveFiles(id, socket) } 92 | } 93 | } 94 | .onFailure { 95 | Log.ex(it) 96 | server.close() 97 | if (extensions.get(id)?.optInt("port") == port) { 98 | extensions.get(id)!!.remove("port") 99 | } 100 | } 101 | } 102 | extensions.get(id)!!.put("port", server.getLocalPort()) 103 | extensions.get(id)!!.put("tabUrl", Chrome.getUrl()) 104 | } 105 | } 106 | 107 | fun start(): JSONObject { 108 | extensions.keys.forEach { startServer(it) } 109 | val info = 110 | if (extensions.keys.size == 0) { 111 | Log.d("No extensions found") 112 | JSONArray() 113 | } else { 114 | JSONArray(extensions.map { it.value.put("id", it.key) }) 115 | } 116 | return JSONObject(mapOf("type" to "init", "manifests" to info)) 117 | } 118 | } 119 | -------------------------------------------------------------------------------- /app/src/main/assets/editor.js: -------------------------------------------------------------------------------- 1 | const isSandboxed = [ 2 | "raw.githubusercontent.com", 3 | "gist.githubusercontent.com", 4 | ].includes(location.hostname); 5 | 6 | async function installScript(force = false) { 7 | const dialog = document.querySelector("dialog#confirm"); 8 | if (!force) { 9 | dialog.showModal(); 10 | } else { 11 | dialog.close(); 12 | const script = document.body.innerText; 13 | Symbol.ChromeXt.dispatch("installScript", script); 14 | } 15 | } 16 | 17 | function renderEditor(code, alertEncoding) { 18 | let scriptMeta = document.querySelector("#meta"); 19 | if (scriptMeta) return; 20 | const separator = "==/UserScript==\n"; 21 | const script = code.innerHTML.split(separator); 22 | if (separator.length == 1) return; 23 | let html = (script.shift() + separator).replace( 24 | "GM.ChromeXt", 25 | "GM.ChromeXt" 26 | ); 27 | for (const api of ["GM_notification", "GM_setClipboard", "GM_cookie"]) { 28 | html = html.replace(api, `${api}`); 29 | } 30 | scriptMeta = document.createElement("pre"); 31 | scriptMeta.innerHTML = html; 32 | code.innerHTML = script.join(separator); 33 | code.id = "code"; 34 | code.removeAttribute("style"); 35 | scriptMeta.id = "meta"; 36 | document.body.prepend(scriptMeta); 37 | 38 | if (alertEncoding) { 39 | const msg = 40 | "Current script may contain badly encoded text.\n\nTo fix possible issues, you can download this script and open it locally."; 41 | createDialog(msg, false); 42 | } else { 43 | const msg = 44 | "Code editor is blocked on this page.\n\nPlease use the menu to install this UserScript, or reload the page to solve this problem."; 45 | createDialog(msg); 46 | setTimeout(fixDialog); 47 | // setTimeout is not working in sandboxed pages, and thus can be used for detecting sandboxed pages 48 | } 49 | 50 | scriptMeta.setAttribute("contenteditable", true); 51 | code.setAttribute("contenteditable", true); 52 | scriptMeta.setAttribute("spellcheck", false); 53 | code.setAttribute("spellcheck", false); 54 | // Too many nodes heavily slow down the event-loop, should be improved 55 | import("https://unpkg.com/@speed-highlight/core/dist/index.js").then( 56 | (imports) => { 57 | imports.highlightElement(code, "js", "multiline", { 58 | hideLineNumbers: true, 59 | }); 60 | } 61 | ); 62 | } 63 | 64 | function createDialog(msg) { 65 | const dialog = document.createElement("dialog"); 66 | dialog.id = "confirm"; 67 | dialog.textContent = msg; 68 | document.body.prepend(dialog); 69 | dialog.show(); 70 | } 71 | 72 | function fixDialog() { 73 | const dialog = document.querySelector("dialog#confirm"); 74 | if (dialog.textContent == "") return; 75 | dialog.close(); 76 | dialog.textContent = ""; 77 | const text = document.createElement("p"); 78 | text.textContent = "Confirm ChromeXt to install this UserScript?"; 79 | const div = document.createElement("div"); 80 | div.id = "interaction"; 81 | const yes = document.createElement("button"); 82 | yes.textContent = "Confirm"; 83 | yes.addEventListener("click", () => installScript(true)); 84 | const no = document.createElement("button"); 85 | no.addEventListener("click", () => { 86 | dialog.close(); 87 | setTimeout(() => dialog.show(), 30000); 88 | }); 89 | no.textContent = "Ask 30s later"; 90 | div.append(yes); 91 | div.append(no); 92 | dialog.append(text); 93 | const askChromeXt = document.querySelector("#meta > em") != undefined; 94 | if (askChromeXt) { 95 | const alert = document.createElement("p"); 96 | alert.id = "alert"; 97 | alert.textContent = "ATTENTION: GM.ChromeXt is declared"; 98 | dialog.append(alert); 99 | } 100 | dialog.append(div); 101 | installScript(); 102 | } 103 | 104 | async function prepareDOM() { 105 | if (Symbol.ChromeXt == undefined) return; 106 | if (document.querySelector("script,div,p") != null) return; 107 | const meta = document.createElement("meta"); 108 | const style = document.createElement("style"); 109 | 110 | style.setAttribute("type", "text/css"); 111 | meta.setAttribute("name", "viewport"); 112 | meta.setAttribute( 113 | "content", 114 | "width=device-width,initial-scale=1,maximum-scale=1,user-scalable=no" 115 | ); 116 | style.textContent = _editor_style; 117 | 118 | const code = document.querySelector("body > pre"); 119 | if (document.readyState == "loading") { 120 | if (isSandboxed) { 121 | return prepareDOM(); 122 | // EventListeners are unavailable in sandboxed pages 123 | } else { 124 | return document.addEventListener("DOMContentLoaded", prepareDOM); 125 | } 126 | } 127 | Symbol.installScript = installScript; 128 | document.head.appendChild(meta); 129 | document.head.appendChild(style); 130 | 131 | const alertEncoding = !(await fixEncoding(true, true, code)); 132 | renderEditor(code, alertEncoding); 133 | } 134 | 135 | prepareDOM(); 136 | -------------------------------------------------------------------------------- /app/src/main/java/org/matrix/chromext/hook/Preference.kt: -------------------------------------------------------------------------------- 1 | package org.matrix.chromext.hook 2 | 3 | import android.app.Activity 4 | import android.content.Context 5 | import android.content.Intent 6 | import android.graphics.Insets 7 | import android.graphics.Rect 8 | import android.net.Uri 9 | import android.os.Build 10 | import android.os.Bundle 11 | import android.view.WindowInsets 12 | import java.lang.reflect.Modifier 13 | import kotlin.math.roundToInt 14 | import org.matrix.chromext.Chrome 15 | import org.matrix.chromext.R 16 | import org.matrix.chromext.Resource 17 | import org.matrix.chromext.proxy.PreferenceProxy 18 | import org.matrix.chromext.utils.* 19 | 20 | object PreferenceHook : BaseHook() { 21 | 22 | private fun getUrl(): String { 23 | return Chrome.getUrl()!! 24 | } 25 | 26 | override fun init() { 27 | 28 | val proxy = PreferenceProxy 29 | 30 | proxy.addPreferencesFromResource 31 | // public void addPreferencesFromResource(Int preferencesResId) 32 | .hookMethod { 33 | before { 34 | if (it.thisObject::class.java == proxy.developerSettings) { 35 | it.args[0] = R.xml.developer_preferences 36 | } 37 | } 38 | 39 | after { 40 | if (it.thisObject::class.java == proxy.developerSettings) { 41 | val refThis = it 42 | val preferences = mutableMapOf() 43 | arrayOf("eruda", "gesture_mod", "keep_storage", "bookmark", "reset", "exit").forEach { 44 | preferences[it] = proxy.findPreference.invoke(refThis.thisObject, it)!! 45 | } 46 | proxy.setClickListener(preferences.toMap()) 47 | } 48 | } 49 | } 50 | 51 | findMethod(proxy.developerSettings, true) { 52 | Modifier.isStatic(modifiers) && 53 | parameterTypes contentDeepEquals 54 | arrayOf(Context::class.java, String::class.java, Bundle::class.java) 55 | // public static Fragment instantiate(Context context, 56 | // String fname, @Nullable Bundle args) 57 | } 58 | .hookAfter { 59 | if (it.result::class.java == proxy.developerSettings) { 60 | Resource.enrich(it.args[0] as Context) 61 | } 62 | } 63 | 64 | findMethod(proxy.chromeTabbedActivity) { name == "onNewIntent" || name == "onMAMNewIntent" } 65 | .hookBefore { 66 | val intent = it.args[0] as Intent 67 | if (intent.hasExtra("ChromeXt")) { 68 | intent.setAction(Intent.ACTION_VIEW) 69 | var url = intent.getStringExtra("ChromeXt") as String 70 | intent.setData(Uri.parse(url)) 71 | } 72 | } 73 | 74 | if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { 75 | findMethodOrNull(WindowInsets::class.java) { name == "getSystemGestureInsets" } 76 | ?.hookBefore { 77 | val ctx = Chrome.getContext() 78 | val sharedPref = ctx.getSharedPreferences("ChromeXt", Context.MODE_PRIVATE) 79 | if (sharedPref.getBoolean("gesture_mod", true)) { 80 | it.result = Insets.of(0, 0, 0, 0) 81 | toggleGestureConflict(true) 82 | } else { 83 | toggleGestureConflict(false) 84 | } 85 | } 86 | } 87 | 88 | if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { 89 | findMethod(WindowInsets::class.java) { 90 | name == "getInsets" && 91 | parameterTypes.size == 1 && 92 | parameterTypes.first() == Int::class.java 93 | } 94 | .hookBefore { 95 | val typeMask = it.args[0] as Int 96 | if (typeMask == WindowInsets.Type.systemGestures()) { 97 | val ctx = Chrome.getContext() 98 | val sharedPref = ctx.getSharedPreferences("ChromeXt", Context.MODE_PRIVATE) 99 | if (sharedPref.getBoolean("gesture_mod", true)) { 100 | it.result = Insets.of(0, 0, 0, 0) 101 | toggleGestureConflict(true) 102 | } else { 103 | toggleGestureConflict(false) 104 | } 105 | } 106 | } 107 | } 108 | 109 | isInit = true 110 | } 111 | 112 | private fun toggleGestureConflict(excludeSystemGesture: Boolean) { 113 | val activity = Chrome.getContext() 114 | if (activity is Activity && Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { 115 | val decoView = activity.window.decorView 116 | if (excludeSystemGesture) { 117 | val width = decoView.width 118 | val height = decoView.height 119 | val excludeHeight: Int = (activity.resources.displayMetrics.density * 100).roundToInt() 120 | decoView.setSystemGestureExclusionRects( 121 | // public Rect (int left, int top, int right, int bottom) 122 | listOf(Rect(width / 2, height / 2 - excludeHeight, width, height / 2 + excludeHeight))) 123 | } else { 124 | decoView.setSystemGestureExclusionRects(listOf(Rect(0, 0, 0, 0))) 125 | } 126 | } 127 | } 128 | } 129 | -------------------------------------------------------------------------------- /docs/presentation.tex: -------------------------------------------------------------------------------- 1 | % ! TEX program = lualatex 2 | \documentclass[aspectratio=169]{beamer} 3 | 4 | \usepackage{emoji} 5 | 6 | \usefonttheme[onlymath]{serif} 7 | \beamertemplatenavigationsymbolsempty 8 | \setbeamertemplate{footline}[frame number]{} 9 | \setbeamertemplate{caption}{\raggedright\insertcaption\par} 10 | \newcommand{\diff}{\operatorname{d}} 11 | \newcommand{\info}[1]{\texttt{{\color{teal}(#1)}}} 12 | 13 | \title{ 14 | Introduction to ChromeXt\\ 15 | {\color{teal}\normalsize UserScript and DevTools supports for mobile browsers }\\ 16 | } 17 | \author{\texttt{JingMatrix}} 18 | \date{\small \color{gray} \today} 19 | 20 | \begin{document} 21 | { 22 | \setbeamertemplate{footline}{} 23 | \begin{frame}[noframenumbering] 24 | \titlepage 25 | \end{frame} 26 | } 27 | 28 | \section{Background} 29 | 30 | \setbeamercovered{transparent} 31 | \begin{frame} 32 | \frametitle{What is ChromeXt?} 33 | \begin{definition} 34 | ChromeXt is {\color<2-5>{brown} a tiny Xposed module} 35 | \info{Android} that adds {\color<6-9>{brown} UserScript}, {\color<10-12>{brown} DevTools} and 36 | some other functions to {\color<13-15>{brown} Chromium based and WebView based mobile browsers}. 37 | \end{definition} 38 | 39 | \vspace{0.5cm} 40 | \uncover<2->{ 41 | \begin{block}{{\only<2-5>{What is Xposed}\only<6-9>{What are UserScripts}\only<10-12>{What are DevTools}\only<13-15>{Which browsers are supported}}? } 42 | \only<2-15>{ 43 | \begin{itemize} 44 | \only<2-5>{ 45 | \item<2-> Xposed is a framework helping developers to modify Android applications. 46 | \item<3-> The most popular Xposed framework for Android $8.1 \sim 14$ is LSPosed. 47 | \item<4-> ChromeXt is an Xposed module with size less than $0.1$\,MB. 48 | \item<5-> Both ChromeXt and LSPosed are open source projects powered 49 | by \only<5>{\emoji{heart}}. 50 | } 51 | 52 | \only<6-9>{ 53 | \item<6-> UserScripts enable users to modify websites in their browsers. 54 | \item<7-> UserScripts are supported by most desktop browsers but only few mobile browsers. 55 | \item<8-> Popular UserScript managers include Tampermonkey and Violentmonkey; 56 | and people usually share their UserScripts on GreasyFork or GitHub. 57 | \item<9-> ChromeXt works as a UserScript manager for mobile browsers. 58 | } 59 | \only<10-12>{ 60 | \item<10-> DevTools help developers to inspect, modify and debug websites in browsers. 61 | \item<11-> They are the primary tools for hacking or finding vulnerabilities of websites. 62 | \item<12-> Usually one needs desktop browsers to inspect websites in mobile phones. 63 | } 64 | \only<13-15>{ 65 | \item<13-> Chromium based browsers include Chrome, Edge, Bromite, Brave, 66 | Vivalid $\ldots$ 67 | \item<14-> WebView based browsers include Via, Soul, FOSS Browser $\ldots$ 68 | \item<15-> Not supported: Firefox, Samsung Internet Browser, Opera. 69 | } 70 | \end{itemize} 71 | } 72 | \end{block} 73 | } 74 | \end{frame} 75 | 76 | \section{Installation and Usage} 77 | \begin{frame} 78 | \frametitle{How to install ChromeXt} 79 | \begin{block}{Root users} 80 | Install LSPosed \info{Magisk module} 81 | and then install ChromeXt. 82 | \end{block} 83 | \vspace{1cm} 84 | \uncover<2->{ 85 | \begin{block}{Non-root users} 86 | \begin{enumerate} 87 | \item<2-> Download LSPatch \info{modified by JingMatrix} and ChromeXt. 88 | \item<3-> If Java is available, then use lspatch.jar, otherwise install manager.apk. 89 | \item<4-> Patch the target browser to embed ChromeXt.apk. 90 | \end{enumerate} 91 | \end{block} 92 | } 93 | \end{frame} 94 | 95 | \begin{frame} 96 | \frametitle{How to use ChromeXt?} 97 | ChromeXt is fully integrated into the target browser, almost all interactions are 98 | done within the browser. 99 | 100 | \uncover<2->{ 101 | \begin{block}{Different ways to install UserScripts} 102 | Open .user.js URLs, open local UserScripts with ChromeXt, import via 103 | Eruda console. 104 | \end{block} 105 | } 106 | 107 | \uncover<3->{ 108 | \begin{block}{Functions offered by ChromeXt} 109 | \begin{itemize} 110 | \item<3-> Via front end: manage and modify installed UserScripts 111 | \item<4-> Via page menu: reader mode, Eruda console and Developer Tools 112 | \item<5-> Via Developer options setting: set gesture navigation, export bookmarks 113 | \item<6-> Via Eruda console: cosmetic filters, user-agent spoofing, UserScript commands 114 | \end{itemize} 115 | \end{block} 116 | } 117 | \end{frame} 118 | 119 | \section{Tutorial to hack YouTube services} 120 | 121 | \begin{frame} 122 | \frametitle{Tutorial: write a UserScript to remove YouTube advertisements} 123 | \begin{block}{Analysis} 124 | \begin{enumerate} 125 | \item<1-> Different videos contains different advertisements to be played at 126 | different time, but the YouTube page does not reload when we switch videos. 127 | \item<2-> Therefore, advertisement data are fetched from remote for each new video. 128 | \item<3-> Find the code that fetching remote advertisement data. 129 | \item<4-> Change the fetched data to clear all advertisement data. 130 | \end{enumerate} 131 | \end{block} 132 | 133 | \uncover<5->{ 134 | \begin{block}{Start writing a UserScript} 135 | \begin{enumerate} 136 | \item Only partial code will be shown for instructive purpose. 137 | \item The previously described method is novel, no source code available online. 138 | \item YouTube Music has the same vulnerability. 139 | \end{enumerate} 140 | \end{block} 141 | } 142 | \end{frame} 143 | 144 | \end{document} 145 | -------------------------------------------------------------------------------- /app/src/main/assets/encoding.js: -------------------------------------------------------------------------------- 1 | const invalidChar = "�"; 2 | 3 | class Encoding { 4 | #name; 5 | decoder = new TextDecoder(); 6 | get encoding() { 7 | return this.#name; 8 | } 9 | map = () => []; 10 | constructor(name = "utf-8") { 11 | this.#name = name.toLowerCase(); 12 | } 13 | defaultOnError(_input, result) { 14 | result.push(0xff); 15 | } 16 | defaultOnAlloc = (data) => new Uint8Array(data); 17 | static generateTable() { 18 | return new Map(); 19 | } 20 | encode(input, opt = {}) { 21 | if (!(this.table instanceof Map)) 22 | Object.defineProperty(this, "table", { 23 | value: new Map([ 24 | ...this.constructor.generateTable(this.decode.bind(this)), 25 | ...this.map(), 26 | ]), 27 | }); 28 | if (this.encoding == "utf-8") return new TextEncoder().encode(input); 29 | const onError = opt.onError || this.defaultOnError.bind(this); 30 | const onAlloc = opt.onAlloc || this.defaultOnAlloc.bind(this); 31 | const result = []; 32 | [...input].forEach((str) => { 33 | let codePoint = str.codePointAt(0); 34 | if (0x00 <= codePoint && codePoint < 0x80) { 35 | result.push[codePoint]; 36 | return; 37 | } 38 | if (this.table.has(codePoint)) { 39 | result.push(this.table.get(codePoint)); 40 | } else if (str == invalidChar) { 41 | const ret = onError(input, result); 42 | if (ret === -1) { 43 | throw Error("Stop decoding", input); 44 | } 45 | } 46 | }); 47 | return new Uint8Array(onAlloc(result).buffer).filter((c) => c != 0x00); 48 | } 49 | decode(uint8) { 50 | return new TextDecoder(this.#name).decode(uint8); 51 | } 52 | convert(text) { 53 | return this.decoder.decode(this.encode(text)); 54 | } 55 | } 56 | 57 | class SingleByte extends Encoding { 58 | static generateTable(decode, start = 0x80, end = 0xff) { 59 | const range = [...Array(end - start + 1).keys()]; 60 | const charCodes = new Uint8Array(range.map((x) => x + start)); 61 | const str = decode(charCodes); 62 | console.assert(str.length == charCodes.length); 63 | return new Map(range.map((i) => [str.codePointAt(i), charCodes[i]])); 64 | } 65 | } 66 | 67 | class TwoBytes extends Encoding { 68 | static intervals = [[0x81, 0xfe, 0x40, 0xfe]]; 69 | static generateTable(decode) { 70 | const map = []; 71 | this.intervals.forEach(([b1Begin, b1End, b2Begin, b2End]) => { 72 | for (let b1 = b1Begin; b1 <= b1End; b1++) { 73 | for (let b2 = b2Begin; b2 <= b2End; b2++) { 74 | const charCode = (b2 << 8) | b1; 75 | const str = decode(new Uint16Array([charCode])); 76 | if (!str.includes(invalidChar)) 77 | map.push([str.codePointAt(0), charCode]); 78 | } 79 | } 80 | }); 81 | return map; 82 | } 83 | defaultOnAlloc = (data) => new Uint16Array(data); 84 | } 85 | 86 | class GBK extends TwoBytes { 87 | // https://en.wikipedia.org/wiki/GBK_(character_encoding) 88 | map = () => [["€".codePointAt(0), 0x80]]; 89 | } 90 | 91 | class SJIS extends TwoBytes { 92 | // https://en.wikipedia.org/wiki/Shift_JIS 93 | map = () => SingleByte.generateTable(this.decode.bind(this), 0xa1, 0xdf); 94 | } 95 | 96 | function preferUTF8( 97 | text, 98 | utf8, 99 | encoding = document.characterSet.toLowerCase() 100 | ) { 101 | // Check if text with given encoding is properly encodes; 102 | // The argmuent utf8 is the same data encoded with UTF-8; 103 | // Return true if we should discard given encoding and use UTF-8 encoding instead 104 | if (encoding == "utf-8") return false; 105 | const encoded = new TextDecoder(encoding).decode( 106 | new TextEncoder().encode(utf8) 107 | ); 108 | const length = Math.min(text.length, encoded.length); 109 | const result = text.slice(0, length) == encoded.slice(0, length); 110 | const msg = "The declared encoding is " + (result ? "incorrect" : "correct"); 111 | console.debug(msg); 112 | return result; 113 | } 114 | 115 | function fixEncoding(tryPart = false, tryFetch = true, node) { 116 | // return false if failed 117 | node = node || document.querySelector("body > pre"); 118 | const url = window.location.href; 119 | if (!node) return false; 120 | const text = node.textContent; 121 | const encoding = document.characterSet.toLowerCase(); 122 | if ( 123 | url.startsWith("file://") || 124 | document.characterSet == "UTF-8" || 125 | /^[\p{ASCII}]*$/u.test(text) 126 | ) 127 | return true; 128 | if (window.content) { 129 | if (!window.content.fixed) { 130 | const utf8 = window.content["utf-8"]; 131 | if (preferUTF8(text, utf8, encoding)) node.textContent = utf8; 132 | } 133 | window.content.fixed = true; 134 | return true; 135 | } 136 | let converter = () => invalidChar; 137 | let encoder = null; 138 | if ( 139 | encoding.startsWith("windows") || 140 | encoding.startsWith("iso-8859") || 141 | encoding.startsWith("koi") || 142 | encoding.startsWith("ibm") || 143 | encoding.includes("mac") 144 | ) { 145 | encoder = new SingleByte(encoding); 146 | } else if (encoding.startsWith("gb")) { 147 | encoder = new GBK(encoding); 148 | } else if (encoding == "shift_jis") { 149 | encoder = new SJIS(encoding); 150 | } else { 151 | encoder = new TwoBytes(encoding); 152 | } 153 | if (encoder !== null) converter = encoder.convert.bind(encoder); 154 | let failed, converted; 155 | if (!tryPart && text.includes(invalidChar)) { 156 | failed = true; 157 | } else { 158 | converted = text.replace(/[^\p{ASCII}]+/gu, converter); 159 | failed = converted.includes(invalidChar); 160 | } 161 | if (!failed || tryPart) node.textContent = converted; 162 | if (tryFetch && failed) { 163 | return new Promise((resolve, _reject) => { 164 | fetch(url, { cache: "force-cache", mode: "same-origin" }) 165 | .then((res) => res.text()) 166 | .then((utf8) => { 167 | node.textContent = preferUTF8(text, utf8, encoding) ? utf8 : text; 168 | resolve(true); 169 | }) 170 | .catch((_e) => resolve(false)); 171 | }); 172 | } else { 173 | return !failed; 174 | } 175 | } 176 | -------------------------------------------------------------------------------- /app/src/main/java/org/matrix/chromext/utils/XMLHttpRequest.kt: -------------------------------------------------------------------------------- 1 | package org.matrix.chromext.utils 2 | 3 | import android.os.Handler 4 | import android.util.Base64 5 | import java.io.IOException 6 | import java.net.HttpCookie 7 | import java.net.HttpURLConnection 8 | import java.net.SocketTimeoutException 9 | import java.net.URL 10 | import org.json.JSONArray 11 | import org.json.JSONObject 12 | import org.matrix.chromext.Chrome 13 | import org.matrix.chromext.Listener 14 | import org.matrix.chromext.script.Local 15 | 16 | class XMLHttpRequest( 17 | id: String, 18 | request: JSONObject, 19 | uuid: Double, 20 | currentTab: Any?, 21 | frameId: String? 22 | ) { 23 | val currentTab = currentTab 24 | val frameId = frameId 25 | val request = request 26 | val response = JSONObject(mapOf("id" to id, "uuid" to uuid)) 27 | 28 | var connection: HttpURLConnection? = null 29 | var cookies: List 30 | 31 | val anonymous = request.optBoolean("anonymous") 32 | val binary = request.optBoolean("binary") 33 | val buffersize = request.optInt("buffersize", 8) 34 | val cookie = request.optJSONArray("cookie") 35 | val headers = request.optJSONObject("headers") 36 | val method = request.optString("method") 37 | val nocache = request.optBoolean("nocache") 38 | val timeout = request.optInt("timeout") 39 | val responseType = request.optString("responseType") 40 | val url = URL(request.optString("url")) 41 | val uri = url.toURI() 42 | 43 | init { 44 | if (cookie != null && !anonymous) { 45 | for (i in 0 until cookie.length()) { 46 | runCatching { 47 | HttpCookie.parse(cookie!!.getString(i)).forEach { Chrome.cookieStore.add(uri, it) } 48 | } 49 | } 50 | } 51 | cookies = Chrome.cookieStore.get(uri) 52 | } 53 | 54 | fun abort() { 55 | response("abort", JSONObject().put("abort", "Abort on request")) 56 | } 57 | 58 | fun send() { 59 | connection = url.openConnection() as HttpURLConnection 60 | with(connection!!) { 61 | setRequestMethod(method) 62 | setInstanceFollowRedirects(request.optString("redirect") != "manual") 63 | headers?.keys()?.forEach { setRequestProperty(it, headers.optString(it)) } 64 | setUseCaches(!nocache) 65 | setConnectTimeout(timeout) 66 | 67 | if (!anonymous && cookies.size > 0) 68 | setRequestProperty("Cookie", cookies.map { it.toString() }.joinToString("; ")) 69 | 70 | if (request.has("user")) { 71 | val user = request.optString("user") 72 | val password = request.optString("password") 73 | val encoded = Base64.encodeToString(("${user}:${password}").toByteArray(), Base64.DEFAULT) 74 | setRequestProperty("Authorization", "Basic " + encoded) 75 | } 76 | 77 | var data = JSONObject() 78 | runCatching { 79 | if (method != "GET" && request.has("data")) { 80 | val input = request.optString("data") 81 | val bytes = 82 | if (binary) { 83 | Base64.decode(input, Base64.DEFAULT) 84 | } else { 85 | input.toByteArray() 86 | } 87 | setFixedLengthStreamingMode(bytes.size) 88 | outputStream.write(bytes) 89 | } 90 | 91 | data.put("status", responseCode) 92 | data.put("statusText", responseMessage) 93 | val headers = headerFields.filter { it.key != null }.mapValues { JSONArray(it.value) } 94 | data.put("headers", JSONObject(headers)) 95 | val binary = 96 | responseType !in listOf("", "text", "document", "json") || 97 | contentEncoding != null || 98 | (contentType != null && 99 | contentType.contains("charset") && 100 | !contentType.contains("utf-8")) 101 | data.put("binary", binary) 102 | 103 | val buffer = ByteArray(buffersize * DEFAULT_BUFFER_SIZE) 104 | while (true) { 105 | var bytes = 0 106 | while (buffer.size > bytes) { 107 | val b = inputStream.read(buffer, bytes, buffer.size - bytes) 108 | if (b == 0 || b == -1) break 109 | bytes += b 110 | } 111 | if (bytes == 0) break 112 | val chunk = 113 | if (binary) { 114 | Base64.encodeToString(buffer, 0, bytes, Base64.DEFAULT) 115 | } else { 116 | String(buffer, 0, bytes) 117 | } 118 | data.put("chunk", chunk) 119 | data.put("bytes", bytes) 120 | response("progress", data, false) 121 | data.remove("headers") 122 | } 123 | response("load", data) 124 | } 125 | .onFailure { 126 | if (it is IOException) { 127 | data.put("type", it::class.java.name) 128 | data.put("message", it.message) 129 | data.put("stack", it.stackTraceToString()) 130 | errorStream?.bufferedReader()?.use { it.readText() }?.let { data.put("error", it) } 131 | if (it is SocketTimeoutException) { 132 | response("timeout", data.put("bytesTransferred", it.bytesTransferred)) 133 | } else { 134 | response("error", data) 135 | } 136 | } 137 | } 138 | } 139 | if (!anonymous && connection != null) { 140 | Chrome.storeCoookies(this, connection!!.headerFields) 141 | } 142 | } 143 | 144 | fun response( 145 | type: String, 146 | data: JSONObject = JSONObject(), 147 | disconnect: Boolean = true, 148 | ) { 149 | response.put("type", type) 150 | response.put("data", data) 151 | val code = "Symbol.${Local.name}.unlock(${Local.key}).post('xmlhttpRequest', ${response});" 152 | Handler(Chrome.getContext().mainLooper).post { 153 | Chrome.evaluateJavascript(listOf(code), currentTab, frameId) 154 | } 155 | if (disconnect) { 156 | Listener.xmlhttpRequests.remove(response.getDouble("uuid")) 157 | connection?.disconnect() 158 | } 159 | } 160 | } 161 | -------------------------------------------------------------------------------- /app/src/main/java/org/matrix/chromext/script/Local.kt: -------------------------------------------------------------------------------- 1 | package org.matrix.chromext.script 2 | 3 | import android.content.Context 4 | import android.net.Uri 5 | import java.io.File 6 | import java.io.FileReader 7 | import kotlin.random.Random 8 | import org.json.JSONArray 9 | import org.json.JSONObject 10 | import org.matrix.chromext.Chrome 11 | import org.matrix.chromext.Resource 12 | import org.matrix.chromext.utils.Log 13 | import org.matrix.chromext.utils.randomString 14 | 15 | object GM { 16 | private val localScript: Map 17 | 18 | init { 19 | val ctx = Chrome.getContext() 20 | Resource.enrich(ctx) 21 | localScript = 22 | ctx.assets 23 | .open("GM.js") 24 | .bufferedReader() 25 | .use { it.readText() } 26 | .split("// Kotlin separator\n\n") 27 | .associateBy( 28 | { 29 | val decalre = it.lines()[0] 30 | val sep = if (decalre.startsWith("function")) "(" else " =" 31 | decalre.split(sep)[0].split(" ").last() 32 | }, 33 | { it }) 34 | } 35 | 36 | fun bootstrap( 37 | script: Script, 38 | codes: MutableList = mutableListOf(), 39 | ): MutableList { 40 | var code = script.code 41 | var grants = "" 42 | 43 | if (!script.meta.startsWith("// ==UserScript==")) { 44 | code = script.meta + code 45 | } 46 | 47 | script.grant.forEach { 48 | when (it) { 49 | "none" -> return@forEach 50 | "frames" -> return@forEach 51 | "GM_info" -> return@forEach 52 | "GM.ChromeXt" -> return@forEach 53 | "window.close" -> return@forEach 54 | else -> 55 | if (localScript.containsKey(it)) { 56 | grants += localScript.get(it) 57 | } else if (it.startsWith("GM_")) { 58 | grants += 59 | "function ${it}(){ console.error('${it} is not implemented in ChromeXt yet, called with', arguments) }\n" 60 | } else if (it.startsWith("GM.")) { 61 | val func = it.substring(3) 62 | val name = 63 | "GM_" + 64 | if (func == "xmlHttpRequest") { 65 | "xmlhttpRequest" 66 | } else if (func == "getResourceUrl") { 67 | "getResourceURL" 68 | } else { 69 | func 70 | } 71 | if (localScript.containsKey(name) && !script.grant.contains(name)) 72 | grants += localScript.get(name) 73 | grants += "${it}={sync: ${name}};\n" 74 | } 75 | } 76 | } 77 | 78 | grants += localScript.get("GM.bootstrap")!! 79 | val GM_info = JSONObject(mapOf("scriptMetaStr" to script.meta)) 80 | GM_info.put("script", JSONObject().put("id", script.id)) 81 | if (script.storage != null) GM_info.put("storage", script.storage) 82 | code = "\ndelete window.__loading__;\n${code};" 83 | code = localScript.get("globalThis")!! + script.lib.joinToString("\n") + code 84 | codes.add( 85 | "(()=>{ const GM = {key:${Local.key}, name:'${Local.name}'}; const GM_info = ${GM_info}; GM_info.script.code = (key=null) => {${code}};\n${grants}GM.bootstrap();})();\n//# sourceURL=local://ChromeXt/${Uri.encode(script.id)}") 86 | return codes 87 | } 88 | } 89 | 90 | object Local { 91 | 92 | val promptInstallUserScript: String 93 | val customizeDevTool: String 94 | val eruda: String 95 | val encoding: String 96 | val initChromeXt: String 97 | val openEruda: String 98 | val cspRule: String 99 | val cosmeticFilter: String 100 | val key = Random.nextDouble() 101 | val name = randomString(25) 102 | 103 | var eruda_version: String? 104 | 105 | val anchorInChromeXt: Int 106 | 107 | // lineNumber of the anchor in GM.js, used to verify ChromeXt.dispatch 108 | 109 | init { 110 | val ctx = Chrome.getContext() 111 | Resource.enrich(ctx) 112 | var css = 113 | JSONArray( 114 | ctx.assets.open("editor.css").bufferedReader().use { it.readText() }.split("\n\n")) 115 | promptInstallUserScript = 116 | "const _editor_style = ${css}[0];\n" + 117 | ctx.assets.open("editor.js").bufferedReader().use { it.readText() } 118 | customizeDevTool = ctx.assets.open("devtools.js").bufferedReader().use { it.readText() } 119 | css = 120 | JSONArray(ctx.assets.open("eruda.css").bufferedReader().use { it.readText() }.split("\n\n")) 121 | eruda = 122 | "eruda._styles = ${css};\n" + 123 | ctx.assets 124 | .open("eruda.js") 125 | .bufferedReader() 126 | .use { it.readText() } 127 | .replaceFirst("Symbol.ChromeXt", "Symbol." + name) 128 | .replaceFirst("ChromeXtUnlockKeyForEruda", key.toString()) 129 | encoding = ctx.assets.open("encoding.js").bufferedReader().use { it.readText() } 130 | eruda_version = getErudaVersion() 131 | val localScript = 132 | ctx.assets 133 | .open("scripts.js") 134 | .bufferedReader() 135 | .use { it.readText() } 136 | .split("// Kotlin separator\n\n") 137 | 138 | val seed = Random.nextDouble() 139 | // Use empty lines to randomize anchorInChromeXt 140 | val parts = 141 | localScript[0] 142 | .replaceFirst("Symbol.ChromeXt", "Symbol." + name) 143 | .replaceFirst("ChromeXtUnlockKeyForInit", key.toString()) 144 | .split("\n") 145 | .filter { if (it.length != 0) true else Random.nextDouble() > seed } 146 | anchorInChromeXt = parts.indexOfFirst { it.endsWith("// Kotlin anchor") } + 2 147 | initChromeXt = parts.joinToString("\n") 148 | openEruda = 149 | localScript[1] 150 | .replaceFirst("Symbol.ChromeXt", "Symbol." + name) 151 | .replaceFirst("ChromeXtUnlockKeyForEruda", key.toString()) 152 | cspRule = localScript[2] 153 | cosmeticFilter = localScript[3] 154 | } 155 | 156 | fun getErudaVersion(ctx: Context = Chrome.getContext(), versionText: String? = null): String? { 157 | val eruda = File(ctx.filesDir, "Eruda.js") 158 | if (eruda.exists() || versionText != null) { 159 | val verisonReg = Regex(" eruda v(?[\\d\\.]+) https://") 160 | val firstLine = (versionText ?: FileReader(eruda).use { it.readText() }).lines()[0] 161 | val vMatchGroup = verisonReg.find(firstLine)?.groups 162 | if (vMatchGroup != null) { 163 | return vMatchGroup[1]?.value as String 164 | } else if (eruda.exists()) { 165 | eruda.delete() 166 | Log.toast(ctx, "Eruda.js is corrupted") 167 | } 168 | } 169 | return null 170 | } 171 | } 172 | -------------------------------------------------------------------------------- /app/src/main/java/org/matrix/chromext/MainHook.kt: -------------------------------------------------------------------------------- 1 | package org.matrix.chromext 2 | 3 | import android.app.AndroidAppHelper 4 | import android.content.Context 5 | import android.webkit.WebChromeClient 6 | import android.webkit.WebView 7 | import android.webkit.WebViewClient 8 | import de.robv.android.xposed.IXposedHookLoadPackage 9 | import de.robv.android.xposed.IXposedHookZygoteInit 10 | import de.robv.android.xposed.callbacks.XC_LoadPackage 11 | import org.matrix.chromext.hook.BaseHook 12 | import org.matrix.chromext.hook.ContextMenuHook 13 | import org.matrix.chromext.hook.PageInfoHook 14 | import org.matrix.chromext.hook.PageMenuHook 15 | import org.matrix.chromext.hook.PreferenceHook 16 | import org.matrix.chromext.hook.UserScriptHook 17 | import org.matrix.chromext.hook.WebViewHook 18 | import org.matrix.chromext.utils.Log 19 | import org.matrix.chromext.utils.findMethodOrNull 20 | import org.matrix.chromext.utils.hookAfter 21 | 22 | val supportedPackages = 23 | arrayOf( 24 | "app.vanadium.browser", 25 | "com.android.chrome", 26 | "com.brave.browser", 27 | "com.brave.browser_beta", 28 | "com.brave.browser_nightly", 29 | "com.chrome.beta", 30 | "com.chrome.canary", 31 | "com.chrome.dev", 32 | "com.coccoc.trinhduyet", 33 | "com.coccoc.trinhduyet_beta", 34 | "com.herond.android.browser", 35 | "com.kiwibrowser.browser", 36 | "com.microsoft.emmx", 37 | "com.microsoft.emmx.beta", 38 | "com.microsoft.emmx.canary", 39 | "com.microsoft.emmx.dev", 40 | "com.naver.whale", 41 | "com.sec.android.app.sbrowser", 42 | "com.sec.android.app.sbrowser.beta", 43 | "com.vivaldi.browser", 44 | "com.vivaldi.browser.snapshot", 45 | "org.axpos.aosmium", 46 | "org.bromite.bromite", 47 | "org.chromium.chrome", 48 | "org.chromium.thorium", 49 | "org.cromite.cromite", 50 | "org.greatfire.freebrowser", 51 | "org.triple.banana", 52 | "us.spotco.mulch") 53 | 54 | class MainHook : IXposedHookLoadPackage, IXposedHookZygoteInit { 55 | override fun handleLoadPackage(lpparam: XC_LoadPackage.LoadPackageParam) { 56 | Log.d(lpparam.processName + " started") 57 | if (lpparam.packageName == "org.matrix.chromext") return 58 | if (supportedPackages.contains(lpparam.packageName)) { 59 | lpparam.classLoader 60 | .loadClass("org.chromium.ui.base.WindowAndroid") 61 | .declaredConstructors[1] 62 | .hookAfter { 63 | Chrome.init(it.args[0] as Context, lpparam.packageName) 64 | initHooks(UserScriptHook) 65 | if (ContextMenuHook.isInit) return@hookAfter 66 | runCatching { 67 | if (!Chrome.isVivaldi) initHooks(PreferenceHook) 68 | initHooks(if (Chrome.isEdge || Chrome.isCocCoc) PageInfoHook else PageMenuHook) 69 | } 70 | .onFailure { 71 | initHooks(ContextMenuHook) 72 | if (BuildConfig.DEBUG && !(Chrome.isSamsung || Chrome.isEdge)) Log.ex(it) 73 | } 74 | } 75 | } else { 76 | val ctx = AndroidAppHelper.currentApplication() 77 | 78 | Chrome.isMi = 79 | Chrome.isMi || 80 | lpparam.packageName == "com.mi.globalbrowser" || 81 | lpparam.packageName == "com.android.browser" 82 | Chrome.isQihoo = lpparam.packageName == "com.qihoo.contents" 83 | 84 | if (ctx == null && Chrome.isMi) return 85 | // Wait to get the browser context of Mi Browser 86 | 87 | if (ctx != null && lpparam.packageName != "android") Chrome.init(ctx, ctx.packageName) 88 | 89 | if (Chrome.isMi) { 90 | WebViewHook.WebView = Chrome.load("com.miui.webkit.WebView") 91 | runCatching { 92 | WebViewHook.ViewClient = Chrome.load("com.android.browser.tab.TabWebViewClient") 93 | WebViewHook.ChromeClient = Chrome.load("com.android.browser.tab.TabWebChromeClient") 94 | } 95 | .onFailure { 96 | val miuiAutologinBar = Chrome.load("com.android.browser.MiuiAutologinBar") 97 | // Use MiuiAutologinBar to find `com.android.browser.tab.Tab`, which can located by 98 | // searching the string "X-MiOrigin" 99 | val fields = miuiAutologinBar.declaredFields.map { it.type } 100 | val tab = 101 | miuiAutologinBar.declaredMethods 102 | .find { 103 | it.parameterCount == 2 && 104 | it.parameterTypes[1] == Boolean::class.java && 105 | !fields.contains(it.parameterTypes[0]) 106 | }!! 107 | .run { parameterTypes[0] } 108 | tab.declaredFields.forEach { 109 | if (findMethodOrNull(it.type) { 110 | // Found by searching the string "Console: " 111 | it.name == "onGeolocationPermissionsHidePrompt" 112 | } != null) 113 | WebViewHook.ChromeClient = it.type 114 | if (findMethodOrNull(it.type) { 115 | // Found by searching the string "Tab.MainWebViewClient" 116 | it.name == "onReceivedHttpAuthRequest" 117 | } != null) 118 | WebViewHook.ViewClient = it.type 119 | } 120 | } 121 | 122 | hookWebView() 123 | return 124 | } 125 | 126 | if (Chrome.isQihoo) { 127 | WebViewHook.WebView = Chrome.load("com.qihoo.webkit.WebView") 128 | WebViewHook.ViewClient = Chrome.load("com.qihoo.webkit.WebViewClient") 129 | WebViewHook.ChromeClient = Chrome.load("com.qihoo.webkit.WebChromeClient") 130 | hookWebView() 131 | return 132 | } 133 | 134 | WebViewClient::class.java.declaredConstructors[0].hookAfter { 135 | if (it.thisObject::class != WebViewClient::class) { 136 | WebViewHook.ViewClient = it.thisObject::class.java 137 | hookWebView() 138 | } 139 | } 140 | 141 | WebChromeClient::class.java.declaredConstructors[0].hookAfter { 142 | if (it.thisObject::class != WebChromeClient::class) { 143 | WebViewHook.ChromeClient = it.thisObject::class.java 144 | hookWebView() 145 | } 146 | } 147 | } 148 | } 149 | 150 | private fun hookWebView() { 151 | if (WebViewHook.ChromeClient == null || WebViewHook.ViewClient == null) return 152 | if (WebViewHook.WebView == null) { 153 | runCatching { 154 | WebViewHook.WebView = WebView::class.java 155 | WebView.setWebContentsDebuggingEnabled(true) 156 | } 157 | .onFailure { if (BuildConfig.DEBUG) Log.ex(it) } 158 | } 159 | initHooks(WebViewHook, ContextMenuHook) 160 | } 161 | 162 | override fun initZygote(startupParam: IXposedHookZygoteInit.StartupParam) { 163 | Resource.init(startupParam.modulePath) 164 | } 165 | 166 | private fun initHooks(vararg hook: BaseHook) { 167 | hook.forEach { 168 | if (it.isInit) return@forEach 169 | it.init() 170 | if (it.isInit) Log.d("${it.javaClass.simpleName} hooked") 171 | } 172 | } 173 | } 174 | -------------------------------------------------------------------------------- /app/src/main/java/org/matrix/chromext/proxy/UserScript.kt: -------------------------------------------------------------------------------- 1 | package org.matrix.chromext.proxy 2 | 3 | import android.net.Uri 4 | import android.view.ContextThemeWrapper 5 | import java.lang.reflect.Modifier 6 | import org.matrix.chromext.Chrome 7 | import org.matrix.chromext.script.ScriptDbManager 8 | import org.matrix.chromext.utils.Log 9 | import org.matrix.chromext.utils.findField 10 | import org.matrix.chromext.utils.findMethod 11 | import org.matrix.chromext.utils.findMethodOrNull 12 | import org.matrix.chromext.utils.invokeMethod 13 | import org.matrix.chromext.utils.parseOrigin 14 | 15 | object UserScriptProxy { 16 | // It is possible to do a HTTP POST with LoadUrlParams Class 17 | // grep org/chromium/content_public/common/ResourceRequestBody to get setPostData in 18 | // org/chromium/content_public/browser/LoadUrlParams 19 | 20 | val gURL = Chrome.load("org.chromium.url.GURL") 21 | val loadUrlParams = 22 | if (Chrome.isSamsung) { 23 | Chrome.load("com.sec.android.app.sbrowser.tab.LoadUrlParams") 24 | } else { 25 | Chrome.load("org.chromium.content_public.browser.LoadUrlParams") 26 | } 27 | // val tabModelJniBridge = Chrome.load("org.chromium.chrome.browser.tabmodel.TabModelJniBridge") 28 | val tabWebContentsDelegateAndroidImpl = 29 | if (Chrome.isSamsung) { 30 | Chrome.load("com.sec.android.app.sbrowser.tab.Tab") 31 | } else { 32 | Chrome.load("org.chromium.chrome.browser.tab.TabWebContentsDelegateAndroidImpl") 33 | } 34 | val navigationControllerImpl = 35 | Chrome.load("org.chromium.content.browser.framehost.NavigationControllerImpl") 36 | val chromeTabbedActivity = 37 | if (Chrome.isSamsung) { 38 | Chrome.load("com.sec.terrace.TerraceActivity") 39 | } else { 40 | Chrome.load("org.chromium.chrome.browser.ChromeTabbedActivity") 41 | } 42 | val tabImpl = 43 | if (Chrome.isSamsung) { 44 | Chrome.load("com.sec.terrace.Terrace") 45 | } else { 46 | Chrome.load("org.chromium.chrome.browser.tab.TabImpl") 47 | } 48 | private val getId = findMethodOrNull(tabImpl) { name == "getId" } 49 | private val mId = 50 | (if (Chrome.isSamsung) tabWebContentsDelegateAndroidImpl else tabImpl) 51 | .declaredFields 52 | .run { 53 | val target = find { it.name == "mId" } 54 | if (target == null) { 55 | val profile = Chrome.load("org.chromium.chrome.browser.profiles.Profile") 56 | val windowAndroid = Chrome.load("org.chromium.ui.base.WindowAndroid") 57 | var startIndex = indexOfFirst { it.type == gURL } 58 | val endIndex = indexOfFirst { 59 | it.type == profile || 60 | it.type == ContextThemeWrapper::class.java || 61 | it.type == windowAndroid 62 | } 63 | if (startIndex == -1 || startIndex > endIndex) startIndex = 0 64 | slice(startIndex..endIndex - 1).findLast { it.type == Int::class.java }!! 65 | } else target 66 | } 67 | .also { it.isAccessible = true } 68 | val mTab = findField(tabWebContentsDelegateAndroidImpl) { type == tabImpl } 69 | val mIsLoading = 70 | tabImpl.declaredFields 71 | .run { 72 | // mIsLoading is used in method stopLoading, before calling 73 | // Lorg/chromium/content_public/browser/WebContents;->stop()V 74 | val target = find { it.name == "mIsLoading" } 75 | if (target == null) { 76 | val webContents = Chrome.load("org.chromium.content_public.browser.WebContents") 77 | val startIndex = 78 | maxOf( 79 | indexOfFirst { it.type == webContents }, 80 | indexOfFirst { it.type == loadUrlParams }) 81 | slice(startIndex..size - 1).find { 82 | it.type == Boolean::class.java && !Modifier.isStatic(it.modifiers) 83 | }!! 84 | } else target 85 | } 86 | .also { it.isAccessible = true } 87 | val loadUrl = 88 | findMethod(if (Chrome.isSamsung) tabWebContentsDelegateAndroidImpl else tabImpl) { 89 | parameterTypes contentDeepEquals arrayOf(loadUrlParams) && 90 | (Chrome.isSamsung || returnType != Void.TYPE) 91 | } 92 | 93 | val kMaxURLChars = 2097152 94 | 95 | private fun loadUrl(url: String, tab: Any? = Chrome.getTab()) { 96 | if (!Chrome.isSamsung && !Chrome.checkTab(tab)) return 97 | loadUrl.invoke(tab, newLoadUrlParams(url)) 98 | } 99 | 100 | fun getTabId(tab: Any): String { 101 | val id = if (getId != null) getId.invoke(tab)!! else mId.get(tab)!! 102 | return id.toString() 103 | } 104 | 105 | fun newLoadUrlParams(url: String): Any { 106 | val constructor = 107 | loadUrlParams.declaredConstructors.find { it.parameterTypes.contains(String::class.java) }!! 108 | val types = constructor.parameterTypes 109 | if (types contentDeepEquals arrayOf(Int::class.java, String::class.java)) { 110 | return constructor.newInstance(0, url) 111 | } else if (types contentDeepEquals arrayOf(String::class.java, Int::class.java)) { 112 | return constructor.newInstance(url, 0) 113 | } else { 114 | return constructor.newInstance(url) 115 | } 116 | } 117 | 118 | fun evaluateJavascript(script: String, tab: Any? = Chrome.getTab()): Boolean { 119 | if (script == "") return true 120 | if (Chrome.isSamsung) { 121 | mTab.get(tab ?: Chrome.getTab())?.invokeMethod(script, null) { 122 | name == "evaluateJavaScriptForTests" 123 | } 124 | return true 125 | } 126 | if (script.length > kMaxURLChars - 20000) return false 127 | val code = Uri.encode(script) 128 | if (code.length < kMaxURLChars - 200) { 129 | loadUrl("javascript:${code}", tab ?: Chrome.getTab()) 130 | return true 131 | } else { 132 | return false 133 | } 134 | } 135 | 136 | fun getTab(delegate: Any): Any? { 137 | return if (Chrome.isSamsung) delegate else mTab.get(delegate) 138 | } 139 | 140 | fun parseUrl(packed: Any?): String? { 141 | if (packed == null) { 142 | return null 143 | } else if (packed::class.java == String::class.java) { 144 | return packed as String 145 | } else if (packed::class.java == loadUrlParams) { 146 | val mUrl = loadUrlParams.getDeclaredField("a") 147 | return mUrl.get(packed) as String 148 | } else if (packed::class.java == gURL) { 149 | val mSpec = gURL.getDeclaredField("a") 150 | return mSpec.get(packed) as String 151 | } 152 | Log.e("parseUrl: ${packed::class.java} is not ${loadUrlParams.name} nor ${gURL.name}") 153 | return null 154 | } 155 | 156 | fun userAgentHook(url: String, urlParams: Any): Boolean { 157 | val origin = parseOrigin(url) 158 | if (origin != null) { 159 | // Log.d("Change User-Agent header: ${origin}") 160 | if (ScriptDbManager.userAgents.contains(origin)) { 161 | val header = "user-agent: ${ScriptDbManager.userAgents.get(origin)}\r\n" 162 | if (Chrome.isSamsung) { 163 | urlParams.invokeMethod(header) { name == "setVerbatimHeaders" } 164 | } else { 165 | val mVerbatimHeaders = 166 | loadUrlParams.declaredFields.filter { it.type == String::class.java }[1] 167 | mVerbatimHeaders.set(urlParams, header) 168 | } 169 | return true 170 | } 171 | } 172 | return false 173 | } 174 | } 175 | -------------------------------------------------------------------------------- /app/src/main/java/org/matrix/chromext/hook/UserScript.kt: -------------------------------------------------------------------------------- 1 | package org.matrix.chromext.hook 2 | 3 | import android.content.Context 4 | import android.content.Intent 5 | import android.content.ServiceConnection 6 | import android.net.http.HttpResponseCache 7 | import org.matrix.chromext.BuildConfig 8 | import org.matrix.chromext.Chrome 9 | import org.matrix.chromext.Listener 10 | import org.matrix.chromext.proxy.UserScriptProxy 11 | import org.matrix.chromext.script.Local 12 | import org.matrix.chromext.script.ScriptDbManager 13 | import org.matrix.chromext.utils.Log 14 | import org.matrix.chromext.utils.findField 15 | import org.matrix.chromext.utils.findFieldOrNull 16 | import org.matrix.chromext.utils.findMethod 17 | import org.matrix.chromext.utils.findMethodOrNull 18 | import org.matrix.chromext.utils.hookAfter 19 | import org.matrix.chromext.utils.hookBefore 20 | 21 | object UserScriptHook : BaseHook() { 22 | 23 | override fun init() { 24 | 25 | val proxy = UserScriptProxy 26 | 27 | // proxy.tabModelJniBridge.declaredConstructors[0].hookAfter { 28 | // Chrome.addTabModel(it.thisObject) 29 | // } 30 | 31 | // findMethod(proxy.tabModelJniBridge) { name == "destroy" } 32 | // .hookBefore { Chrome.dropTabModel(it.thisObject) } 33 | 34 | if (Chrome.isSamsung) { 35 | findMethodOrNull(proxy.tabWebContentsDelegateAndroidImpl) { name == "onDidFinishNavigation" } 36 | .let { 37 | if (it == null) 38 | findMethod(proxy.tabWebContentsDelegateAndroidImpl) { 39 | name == "updateBrowserControlsState" 40 | } 41 | else it 42 | } 43 | .hookAfter { Chrome.updateTab(it.thisObject) } 44 | 45 | runCatching { 46 | // Avoid exceptions thrown due to signature conficts while binding services 47 | val ConnectionManager = 48 | Chrome.load("com.samsung.android.sdk.scs.base.connection.ConnectionManager") 49 | val mServiceConnection = 50 | findField(ConnectionManager) { name == "mServiceConnection" } 51 | .also { it.isAccessible = true } 52 | 53 | findMethod(ConnectionManager) { name == "connectToService" } 54 | // (Landroid/content/Context;Landroid/content/Intent;)Z 55 | .hookBefore { 56 | val hook = it 57 | val ctx = hook.args[0] as Context 58 | val intent = hook.args[1] as Intent 59 | val connection = mServiceConnection.get(hook.thisObject) as ServiceConnection 60 | runCatching { 61 | if (BuildConfig.DEBUG) Log.d("Binding service ${intent} with ${ctx}") 62 | val bound = ctx.bindService(intent, connection, Context.BIND_AUTO_CREATE) 63 | hook.result = bound 64 | } 65 | .onFailure { 66 | if (BuildConfig.DEBUG) Log.ex(it) 67 | hook.result = false 68 | } 69 | } 70 | } 71 | .onFailure { if (BuildConfig.DEBUG) Log.ex(it) } 72 | 73 | runCatching { 74 | // Avoid version codes mismatch when isolated child services are connected 75 | val childProcessConnection = 76 | Chrome.load("org.chromium.base.process_launcher.ChildProcessConnection") 77 | val packageUtils = Chrome.load("org.chromium.base.PackageUtils") 78 | val buildInfo = Chrome.load("org.chromium.base.BuildInfo") 79 | val buildConifg = Chrome.load("org.chromium.build.BuildConfig") 80 | 81 | val mServiceName = findField(childProcessConnection) { name == "mServiceName" } 82 | val getApplicationPackageInfo = 83 | findMethod(packageUtils) { name == "getApplicationPackageInfo" } 84 | val packageVersionCode = findMethod(buildInfo) { name == "packageVersionCode" } 85 | val version_code = findFieldOrNull(buildConifg) { name == "VERSION_CODE" } 86 | 87 | if (version_code != null) { 88 | findMethod(childProcessConnection) { name == "onServiceConnectedOnLauncherThread" } 89 | // (Landroid/os/IBinder;)V 90 | .hookBefore { 91 | val latestPackage = getApplicationPackageInfo.invoke(null, 0) 92 | val latestVersionCode = packageVersionCode.invoke(null, latestPackage) 93 | val loadedVersionCode = version_code.get(null) 94 | if (latestVersionCode != loadedVersionCode) { 95 | Log.d( 96 | "Version codes mismatched for child services ${mServiceName.get(it.thisObject)}") 97 | version_code.set(null, latestVersionCode) 98 | } 99 | } 100 | } 101 | } 102 | .onFailure { if (BuildConfig.DEBUG) Log.ex(it) } 103 | } 104 | 105 | findMethod(if (Chrome.isSamsung) proxy.tabImpl else proxy.tabWebContentsDelegateAndroidImpl) { 106 | name == "onUpdateUrl" 107 | } 108 | // public void onUpdateUrl(GURL url) 109 | .hookAfter { 110 | val tab = proxy.getTab(it.thisObject)!! 111 | if (!Chrome.isSamsung) Chrome.updateTab(tab) 112 | val url = proxy.parseUrl(it.args[0])!! 113 | val isLoading = proxy.mIsLoading.get(tab) as Boolean 114 | if (!url.startsWith("chrome") && isLoading) { 115 | ScriptDbManager.invokeScript(url) 116 | } 117 | } 118 | 119 | findMethod(proxy.tabWebContentsDelegateAndroidImpl) { 120 | name == if (Chrome.isSamsung) "onAddMessageToConsole" else "addMessageToConsole" 121 | } 122 | // public boolean addMessageToConsole(int level, String message, int lineNumber, 123 | // String sourceId) 124 | .hookAfter { 125 | // This should be the way to communicate with the front-end of ChromeXt 126 | val lineNumber = it.args[2] as Int 127 | val sourceId = it.args[3] as String 128 | if (it.args[0] as Int == 0 && 129 | sourceId.startsWith("local://ChromeXt/init") && 130 | lineNumber == Local.anchorInChromeXt) { 131 | Listener.startAction(it.args[1] as String, proxy.getTab(it.thisObject), null, sourceId) 132 | } else { 133 | Log.d( 134 | when (it.args[0] as Int) { 135 | 0 -> "D" 136 | 2 -> "W" 137 | 3 -> "E" 138 | else -> "V" 139 | } + ": [${sourceId}@${lineNumber}] ${it.args[1]}") 140 | } 141 | } 142 | 143 | findMethod(proxy.navigationControllerImpl) { 144 | name == "loadUrl" || parameterTypes contentDeepEquals arrayOf(proxy.loadUrlParams) 145 | } 146 | // public void loadUrl(LoadUrlParams params) 147 | .hookBefore { 148 | val url = proxy.parseUrl(it.args[0])!! 149 | proxy.userAgentHook(url, it.args[0]) 150 | } 151 | 152 | findMethod(proxy.chromeTabbedActivity, true) { name == "onResume" } 153 | .hookBefore { Chrome.init(it.thisObject as Context) } 154 | 155 | findMethod(proxy.chromeTabbedActivity) { name == "onStop" } 156 | .hookBefore { 157 | ScriptDbManager.updateScriptStorage() 158 | val cache = HttpResponseCache.getInstalled() 159 | Log.d("HttpResponseCache: Hit ${cache.hitCount} / NetWork ${cache.networkCount}") 160 | cache.flush() 161 | } 162 | isInit = true 163 | } 164 | } 165 | -------------------------------------------------------------------------------- /app/src/main/java/org/matrix/chromext/devtools/WebSocketClient.kt: -------------------------------------------------------------------------------- 1 | package org.matrix.chromext.devtools 2 | 3 | import android.net.LocalSocket 4 | import android.net.LocalSocketAddress 5 | import android.os.Process 6 | import android.util.Base64 7 | import java.io.OutputStream 8 | import java.security.SecureRandom 9 | import kotlin.experimental.xor 10 | import org.json.JSONObject 11 | import org.matrix.chromext.Chrome 12 | import org.matrix.chromext.hook.UserScriptHook 13 | import org.matrix.chromext.hook.WebViewHook 14 | import org.matrix.chromext.utils.Log 15 | import org.matrix.chromext.utils.randomString 16 | 17 | class DevToolClient(tabId: String, tag: String? = null) : LocalSocket() { 18 | 19 | val tabId = tabId 20 | val tag = tag 21 | private var cspBypassed = false 22 | private var id = 1 23 | private var listening = false 24 | private var mClosed = false 25 | private var pageEnabled = false 26 | 27 | init { 28 | connectDevTools(this) 29 | val request = 30 | arrayOf( 31 | "GET /devtools/page/${tabId} HTTP/1.1", 32 | "Connection: Upgrade", 33 | "Upgrade: websocket", 34 | "Sec-WebSocket-Version: 13", 35 | "Sec-WebSocket-Key: ${Base64.encodeToString(randomString(16).toByteArray(), Base64.DEFAULT).trim()}") 36 | Log.d("Start inspecting tab ${tabId}") 37 | outputStream.write((request.joinToString("\r\n") + "\r\n\r\n").toByteArray()) 38 | val buffer = ByteArray(DEFAULT_BUFFER_SIZE / 8) 39 | inputStream.read(buffer) 40 | if (String(buffer).split("\r\n")[0] != "HTTP/1.1 101 WebSocket Protocol Handshake") { 41 | close() 42 | Log.d("Fail to inspect tab ${tabId} with response\n" + String(buffer), true) 43 | } 44 | } 45 | 46 | override fun close() { 47 | super.close() 48 | mClosed = true 49 | } 50 | 51 | override fun isClosed(): Boolean { 52 | return mClosed 53 | } 54 | 55 | fun isPageEnabled(forceActivate: Boolean = false): Boolean { 56 | val status = pageEnabled 57 | if (forceActivate && !pageEnabled) { 58 | command(null, "Page.enable", JSONObject()) 59 | pageEnabled = true 60 | } 61 | return status 62 | } 63 | 64 | fun command(id: Int?, method: String, params: JSONObject?) { 65 | if (isClosed()) { 66 | return 67 | } 68 | val msg = JSONObject(mapOf("method" to method)) 69 | if (params != null) msg.put("params", params) 70 | 71 | if (id == null) { 72 | msg.put("id", this.id) 73 | this.id += 1 74 | } else { 75 | msg.put("id", id) 76 | } 77 | 78 | WebSocketFrame(msg.toString()).write(outputStream) 79 | } 80 | 81 | fun bypassCSP(bypass: Boolean) { 82 | if (cspBypassed == bypass) return 83 | isPageEnabled(true) 84 | command(null, "Page.setBypassCSP", JSONObject().put("enabled", bypass)) 85 | cspBypassed = bypass 86 | if (bypass) DevSessions.add(this) 87 | } 88 | 89 | fun evaluateJavascript(script: String) { 90 | command(this.id, "Runtime.evaluate", JSONObject(mapOf("expression" to script))) 91 | this.id += 1 92 | } 93 | 94 | fun ping(msg: String = "heartbeat") { 95 | WebSocketFrame(msg, 0x9).write(outputStream) 96 | } 97 | 98 | fun listen(callback: (JSONObject) -> Unit = { msg -> Log.d(msg.toString()) }) { 99 | if (listening) Log.w("client was being listened on") 100 | listening = true 101 | runCatching { 102 | while (!isClosed()) { 103 | val type = inputStream.read() 104 | if (type == -1) { 105 | break 106 | } else if (type == (0x80 or 0x1) || type == (0x80 or 0xA)) { 107 | var len = inputStream.read() 108 | if (len == 0x7e) { 109 | len = inputStream.read() shl 8 110 | len += inputStream.read() 111 | } else if (len == 0x7f) { 112 | len = 0 113 | for (i in 0 until 8) { 114 | len = len or (inputStream.read() shl (8 * (7 - i))) 115 | } 116 | } else if (len > 0x7d) { 117 | throw Exception("Invalid frame length ${len}") 118 | } 119 | var offset = 0 120 | val buffer = ByteArray(len) 121 | while (offset != len) offset += inputStream.read(buffer, offset, len - offset) 122 | val frame = String(buffer) 123 | 124 | if (type == (0x80 or 0xA)) { 125 | callback(JSONObject(mapOf("pong" to frame))) 126 | } else { 127 | callback(JSONObject(frame)) 128 | } 129 | } else { 130 | throw Exception("Invalid frame type ${type}") 131 | } 132 | } 133 | } 134 | .onFailure { 135 | if (!isClosed()) { 136 | Log.ex(it, "Listening at tab ${tabId}") 137 | } 138 | } 139 | close() 140 | } 141 | } 142 | 143 | class WebSocketFrame(msg: String?, opcode: Int = 0x1) { 144 | private val mFin: Int 145 | private val mRsv1: Int 146 | private val mRsv2: Int 147 | private val mRsv3: Int 148 | private val mOpcode: Int 149 | private val mPayload: ByteArray 150 | 151 | var mMask: Boolean = false 152 | 153 | init { 154 | mFin = 0x80 155 | mRsv1 = 0x00 156 | mRsv2 = 0x00 157 | mRsv3 = 0x00 158 | mOpcode = opcode 159 | mPayload = 160 | if (msg == null) { 161 | ByteArray(0) 162 | } else { 163 | msg.toByteArray() 164 | } 165 | } 166 | 167 | fun write(os: OutputStream) { 168 | writeFrame0(os) 169 | writeFrame1(os) 170 | writeFrameExtendedPayloadLength(os) 171 | val maskingKey = ByteArray(4) 172 | SecureRandom().nextBytes(maskingKey) 173 | os.write(maskingKey) 174 | writeFramePayload(os, maskingKey) 175 | } 176 | 177 | private fun writeFrame0(os: OutputStream) { 178 | val b = mFin or mRsv1 or mRsv2 or mRsv1 or (mOpcode and 0x0F) 179 | os.write(b) 180 | } 181 | 182 | private fun writeFrame1(os: OutputStream) { 183 | var b = 0x80 184 | val len = mPayload.size 185 | if (len <= 0x7d) { 186 | b = b or len 187 | } else if (len <= 0xffff) { 188 | b = b or 0x7e 189 | } else { 190 | b = b or 0x7f 191 | } 192 | os.write(b) 193 | } 194 | 195 | private fun writeFrameExtendedPayloadLength(os: OutputStream) { 196 | var len = mPayload.size 197 | val buf: ByteArray 198 | if (len <= 0x7d) { 199 | return 200 | } else if (len <= 0xffff) { 201 | buf = ByteArray(2) 202 | buf[1] = (len and 0xff).toByte() 203 | buf[0] = ((len shr 8) and 0xff).toByte() 204 | } else { 205 | buf = ByteArray(8) 206 | for (i in 0 until 8) { 207 | buf[7 - i] = (len and 0xff).toByte() 208 | len = len shr 8 209 | } 210 | } 211 | os.write(buf) 212 | } 213 | 214 | private fun writeFramePayload(os: OutputStream, mask: ByteArray) { 215 | os.write(mPayload.mapIndexed { index, byte -> byte xor mask[index.rem(4)] }.toByteArray()) 216 | } 217 | } 218 | 219 | fun connectDevTools(client: LocalSocket) { 220 | val address = 221 | if (UserScriptHook.isInit) { 222 | if (Chrome.isSamsung) { 223 | "Terrace_devtools_remote" 224 | } else { 225 | "chrome_devtools_remote" 226 | } 227 | } else if (Chrome.isMi) { 228 | "miui_webview_devtools_remote" 229 | } else if (WebViewHook.isInit) { 230 | "webview_devtools_remote" 231 | } else { 232 | throw Exception("DevTools started unexpectedly") 233 | } 234 | 235 | runCatching { client.connect(LocalSocketAddress(address)) } 236 | .onFailure { client.connect(LocalSocketAddress(address + "_" + Process.myPid())) } 237 | } 238 | -------------------------------------------------------------------------------- /app/src/main/java/org/matrix/chromext/script/Manager.kt: -------------------------------------------------------------------------------- 1 | package org.matrix.chromext.script 2 | 3 | import android.content.ContentValues 4 | import android.content.Context 5 | import android.database.AbstractWindowedCursor 6 | import android.database.CursorWindow 7 | import android.net.Uri 8 | import android.os.Build 9 | import org.json.JSONArray 10 | import org.json.JSONObject 11 | import org.matrix.chromext.Chrome 12 | import org.matrix.chromext.utils.Log 13 | import org.matrix.chromext.utils.invokeMethod 14 | import org.matrix.chromext.utils.isChromeXtFrontEnd 15 | import org.matrix.chromext.utils.isDevToolsFrontEnd 16 | import org.matrix.chromext.utils.isUserScript 17 | import org.matrix.chromext.utils.matching 18 | import org.matrix.chromext.utils.parseOrigin 19 | import org.matrix.chromext.utils.resolveContentUrl 20 | import org.matrix.chromext.utils.shouldBypassSandbox 21 | 22 | object ScriptDbManager { 23 | 24 | val scripts = query() 25 | val cosmeticFilters: MutableMap 26 | val userAgents: MutableMap 27 | val cspRules: MutableMap 28 | var keepStorage: Boolean 29 | 30 | init { 31 | val ctx = Chrome.getContext() 32 | @Suppress("UNCHECKED_CAST") 33 | cosmeticFilters = 34 | ctx.getSharedPreferences("CosmeticFilter", Context.MODE_PRIVATE).getAll() 35 | as MutableMap 36 | @Suppress("UNCHECKED_CAST") 37 | userAgents = 38 | ctx.getSharedPreferences("UserAgent", Context.MODE_PRIVATE).getAll() 39 | as MutableMap 40 | @Suppress("UNCHECKED_CAST") 41 | cspRules = 42 | ctx.getSharedPreferences("CSPRule", Context.MODE_PRIVATE).getAll() 43 | as MutableMap 44 | 45 | keepStorage = 46 | ctx.getSharedPreferences("ChromeXt", Context.MODE_PRIVATE).getBoolean("keep_storage", true) 47 | } 48 | 49 | fun insert(vararg script: Script) { 50 | val dbHelper = ScriptDbHelper(Chrome.getContext()) 51 | val db = dbHelper.writableDatabase 52 | script.forEach { 53 | val lines = db.delete("script", "id = ?", arrayOf(it.id)) 54 | if (lines > 0) { 55 | val id = it.id 56 | Log.d("Update ${lines} rows with id ${id}") 57 | if (keepStorage) it.storage = scripts.find { it.id == id }?.storage 58 | } 59 | val values = 60 | ContentValues().apply { 61 | put("id", it.id) 62 | put("code", it.code) 63 | put("meta", it.meta) 64 | if (it.storage != null) { 65 | put("storage", it.storage.toString()) 66 | } 67 | } 68 | runCatching { db.insertOrThrow("script", null, values) } 69 | .onFailure { 70 | Log.e("Fail to store script ${values.getAsString("id")} into SQL database.") 71 | Log.ex(it) 72 | } 73 | } 74 | dbHelper.close() 75 | } 76 | 77 | private fun fixEncoding(url: String, path: String, codes: MutableList) { 78 | if (path.endsWith(".js") || path.endsWith(".txt")) { 79 | // Fix encoding for local text files 80 | val inputStream = Chrome.getContext().contentResolver.openInputStream(Uri.parse(url)) 81 | val text = inputStream?.bufferedReader()?.readText() 82 | if (text != null) { 83 | val data = JSONObject(mapOf("utf-8" to text)) 84 | codes.add("window.content=${data};") 85 | codes.add(Local.encoding) 86 | } 87 | inputStream?.close() 88 | } else if (url.endsWith(".js") || url.endsWith(".txt")) { 89 | // Fix encoding for remote text files 90 | codes.add(Local.encoding) 91 | } 92 | 93 | if (codes.size > 1 && (url.endsWith(".txt") || path.endsWith(".txt"))) 94 | codes.add("fixEncoding();") 95 | } 96 | 97 | fun invokeScript(url: String, webView: Any? = null, frameId: String? = null) { 98 | val codes = mutableListOf(Local.initChromeXt) 99 | val path = resolveContentUrl(url) 100 | val webSettings = webView?.invokeMethod { name == "getSettings" } 101 | 102 | var trustedPage = true 103 | // Whether ChromeXt is accessible in the global context 104 | var runScripts = false 105 | // Whether UserScripts are invoked 106 | var bypassSandbox = false 107 | 108 | fixEncoding(url, path, codes) 109 | 110 | if (isUserScript(url, path)) { 111 | trustedPage = false 112 | codes.add(Local.promptInstallUserScript) 113 | bypassSandbox = shouldBypassSandbox(url) 114 | } else if (isDevToolsFrontEnd(url)) { 115 | codes.add(Local.customizeDevTool) 116 | webSettings?.invokeMethod(null) { name == "setUserAgentString" } 117 | } else if (!isChromeXtFrontEnd(url)) { 118 | val origin = parseOrigin(url) 119 | if (origin != null) { 120 | if (cspRules.contains(origin)) { 121 | runCatching { 122 | val rule = JSONArray(cspRules.get(origin)) 123 | codes.add("Symbol.ChromeXt.cspRules.push(...${rule});${Local.cspRule}") 124 | } 125 | } 126 | if (cosmeticFilters.contains(origin)) { 127 | runCatching { 128 | val filter = JSONArray(cosmeticFilters.get(origin)) 129 | codes.add("Symbol.ChromeXt.filters.push(...${filter});${Local.cosmeticFilter}") 130 | } 131 | } 132 | if (userAgents.contains(origin)) { 133 | val agent = userAgents.get(origin) 134 | codes.add("Object.defineProperties(window.navigator,{userAgent:{value:'${agent}'}});") 135 | webSettings?.invokeMethod(agent) { name == "setUserAgentString" } 136 | } 137 | trustedPage = false 138 | runScripts = true 139 | } 140 | } 141 | 142 | if (trustedPage) { 143 | codes.add("globalThis.ChromeXt = Symbol.ChromeXt;") 144 | } else if (runScripts) { 145 | codes.add("Symbol.ChromeXt.lock(${Local.key}, '${Local.name}');") 146 | } 147 | codes.add("//# sourceURL=local://ChromeXt/init" + if (frameId == null) "" else "/" + frameId) 148 | webSettings?.invokeMethod(true) { name == "setJavaScriptEnabled" } 149 | val initScript = codes.joinToString("\n") 150 | 151 | val asyncEvaluation = bypassSandbox || frameId != null 152 | var framesGranted = false 153 | 154 | if (!asyncEvaluation) 155 | Chrome.evaluateJavascript( 156 | listOf(initScript), webView, frameId, bypassSandbox, bypassSandbox) 157 | codes.clear() 158 | if (asyncEvaluation) codes.add(initScript) 159 | if (runScripts) { 160 | scripts 161 | .filter { matching(it, url) && !(frameId != null && it.noframes) } 162 | .forEach { 163 | if (it.grant.contains("frames")) framesGranted = true 164 | GM.bootstrap(it, codes) 165 | } 166 | if (!asyncEvaluation) Chrome.evaluateJavascript(codes, webView, frameId) 167 | } 168 | if (asyncEvaluation) 169 | Chrome.evaluateJavascript(codes, webView, frameId, bypassSandbox, bypassSandbox) 170 | 171 | if (framesGranted && frameId == null) Chrome.injectFrames(webView) 172 | } 173 | 174 | fun updateScriptStorage() { 175 | val dbHelper = ScriptDbHelper(Chrome.getContext()) 176 | val db = dbHelper.writableDatabase 177 | scripts.forEach { 178 | if (it.storage != null) { 179 | val values = ContentValues().apply { put("storage", it.storage.toString()) } 180 | if (db.update("script", values, "id = ?", arrayOf(it.id)).toString() == "-1") { 181 | Log.e("Updating scriptStorage failed for: " + it.id) 182 | } else { 183 | Log.d("ScriptStorage updated for " + it.id) 184 | } 185 | } 186 | } 187 | dbHelper.close() 188 | } 189 | 190 | private fun query( 191 | selection: String? = null, 192 | selectionArgs: Array? = null, 193 | ): MutableList