From 7b91a9724af09a14625e19292345c48f53534f45 Mon Sep 17 00:00:00 2001 From: loliconRoot <70987589+loliconRoot@users.noreply.github.com> Date: Fri, 12 Jun 2026 12:00:20 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D8.97.0=E5=8F=8A=E4=BB=A5?= =?UTF-8?q?=E4=B8=8A=E7=89=88=E6=9C=AC=E5=B4=A9=E6=BA=83=E9=97=AE=E9=A2=98?= =?UTF-8?q?=EF=BC=8C=E5=90=8C=E6=97=B6=E4=BF=AE=E5=A4=8D=E9=83=A8=E5=88=86?= =?UTF-8?q?=E5=A4=B1=E6=95=88=E5=8A=9F=E8=83=BD=20(#1713)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 注意:采用claude code进行辅助分析和构建,所有代码和设计经过人工审核 API版本:100 测试版本:8.46.0 / 8.97.0 / 8.98.0 注意:使用较新的B站版本可能因为部分功能未适配导致部分功能失效 - 修复了多个导致8.97.0及以上版本崩溃的问题 - 修复了8.97.0版本设置按钮未能正确显的问题 - 进一步修复了开屏广告屏蔽功能 - 修复了部分情况下自定义开屏功能失效 - 修复了av号显示功能在8.97.0版本失效 API版本101的功能更新将在之后进行 --- .../me/iacn/biliroaming/BaseWidgetDialog.kt | 2 +- .../me/iacn/biliroaming/BiliBiliPackage.kt | 71 +++++++-- .../iacn/biliroaming/CommentFilterDialog.kt | 4 +- .../iacn/biliroaming/CustomSubtitleDialog.kt | 4 +- .../iacn/biliroaming/DynamicFilterDialog.kt | 4 +- .../java/me/iacn/biliroaming/SettingDialog.kt | 82 +++++------ .../me/iacn/biliroaming/SpeedTestDialog.kt | 10 +- .../java/me/iacn/biliroaming/XposedInit.kt | 2 + .../java/me/iacn/biliroaming/hook/CopyHook.kt | 81 +++++++---- .../java/me/iacn/biliroaming/hook/EnvHook.kt | 78 +++++----- .../iacn/biliroaming/hook/KotlinxJsonHook.kt | 51 +++++++ .../me/iacn/biliroaming/hook/SettingHook.kt | 135 ++++++++++++++++-- .../me/iacn/biliroaming/hook/SplashHook.kt | 131 +++++++++++------ .../hook/kotlinx/KotlinxProcessor.kt | 12 ++ .../kotlinx/KotlinxSplashListProcessor.kt | 22 +++ .../kotlinx/KotlinxSplashShowProcessor.kt | 24 ++++ .../java/me/iacn/biliroaming/utils/Log.kt | 7 +- .../proto/me/iacn/biliroaming/configs.proto | 2 + 18 files changed, 544 insertions(+), 178 deletions(-) create mode 100644 app/src/main/java/me/iacn/biliroaming/hook/KotlinxJsonHook.kt create mode 100644 app/src/main/java/me/iacn/biliroaming/hook/kotlinx/KotlinxProcessor.kt create mode 100644 app/src/main/java/me/iacn/biliroaming/hook/kotlinx/KotlinxSplashListProcessor.kt create mode 100644 app/src/main/java/me/iacn/biliroaming/hook/kotlinx/KotlinxSplashShowProcessor.kt diff --git a/app/src/main/java/me/iacn/biliroaming/BaseWidgetDialog.kt b/app/src/main/java/me/iacn/biliroaming/BaseWidgetDialog.kt index 26f8b265ba..c1758d2654 100644 --- a/app/src/main/java/me/iacn/biliroaming/BaseWidgetDialog.kt +++ b/app/src/main/java/me/iacn/biliroaming/BaseWidgetDialog.kt @@ -16,7 +16,7 @@ import me.iacn.biliroaming.utils.dp import me.iacn.biliroaming.utils.setRippleBackground open class BaseWidgetDialog(context: Context) : AlertDialog.Builder(context) { - protected fun string(resId: Int) = context.getString(resId) + protected fun string(resId: Int) = XposedInit.moduleRes.getString(resId) protected fun categoryTitle(title: String) = TextView(context).apply { text = title diff --git a/app/src/main/java/me/iacn/biliroaming/BiliBiliPackage.kt b/app/src/main/java/me/iacn/biliroaming/BiliBiliPackage.kt index b00f028898..f83bfe3b81 100644 --- a/app/src/main/java/me/iacn/biliroaming/BiliBiliPackage.kt +++ b/app/src/main/java/me/iacn/biliroaming/BiliBiliPackage.kt @@ -2,6 +2,7 @@ package me.iacn.biliroaming +import android.app.Activity import android.app.AndroidAppHelper import android.content.Context import android.content.SharedPreferences @@ -32,6 +33,7 @@ import kotlin.time.measureTimedValue infix fun Configs.Class.from(cl: ClassLoader) = if (hasName()) name.findClassOrNull(cl) else null val Configs.Method.orNull get() = if (hasName()) name else null val Configs.Field.orNull get() = if (hasName()) name else null +val Configs.Class.orNull get() = if (hasName()) name else null class BiliBiliPackage constructor(private val mClassLoader: ClassLoader, mContext: Context) { init { @@ -81,6 +83,7 @@ class BiliBiliPackage constructor(private val mClassLoader: ClassLoader, mContex val menuGroupItemClass by Weak { mHookInfo.settings.menuGroupItem from mClassLoader } val drawerLayoutClass by Weak { mHookInfo.drawer.layout from mClassLoader } val drawerLayoutParamsClass by Weak { mHookInfo.drawer.layoutParams from mClassLoader } + val mineAdapterClass by Weak { mHookInfo.settings.mineAdapter from mClassLoader } val splashInfoClass by Weak { "tv.danmaku.bili.ui.splash.brand.BrandShowInfo" from mClassLoader ?: "tv.danmaku.bili.ui.splash.brand.model.BrandShowInfo" from mClassLoader @@ -324,6 +327,8 @@ class BiliBiliPackage constructor(private val mClassLoader: ClassLoader, mContex fun onOperateClick() = mHookInfo.onOperateClick.orNull + fun operateClickHostClass() = mHookInfo.operateClickHostClass.orNull + fun getContentString() = mHookInfo.getContentString.orNull fun check() = mHookInfo.updateInfoSupplier.check.orNull @@ -952,7 +957,7 @@ class BiliBiliPackage constructor(private val mClassLoader: ClassLoader, mContex } settings = settings { val menuGroupItemClass = - "com.bilibili.lib.homepage.mine.MenuGroup\$Item" from classloader + ("com.bilibili.lib.homepage.mine.MenuGroup\$Item" from classloader) ?: return@settings menuGroupItem = class_ { name = menuGroupItemClass.name } settingRouter = class_ { @@ -979,7 +984,8 @@ class BiliBiliPackage constructor(private val mClassLoader: ClassLoader, mContex } val contextIndex = dexHelper.encodeClassIndex(Context::class.java) val listIndex = dexHelper.encodeClassIndex(List::class.java) - dexHelper.findMethodUsingString( + // 旧版 DEX 扫描链: 先通过字符串定位 HomeUserCenter 类 + val homeUserCenterClasses = dexHelper.findMethodUsingString( "main.my-information.noportrait.0.show", false, -1, @@ -990,8 +996,8 @@ class BiliBiliPackage constructor(private val mClassLoader: ClassLoader, mContex null, null, false - ).asSequence().mapNotNull { dexHelper.decodeMethodIndex(it)?.declaringClass } - .forEach { homeUserCenterClass -> + ).asSequence().mapNotNull { dexHelper.decodeMethodIndex(it)?.declaringClass }.toList() + val legacyResult = homeUserCenterClasses.mapNotNull { homeUserCenterClass -> val homeUserCenterIndex = dexHelper.encodeClassIndex(homeUserCenterClass) val addSettingMethod = dexHelper.findMethodUsingString( "bilibili://main/scan", @@ -1035,12 +1041,30 @@ class BiliBiliPackage constructor(private val mClassLoader: ClassLoader, mContex true ).asSequence().firstNotNullOfOrNull { dexHelper.decodeMethodIndex(it) - } ?: return@settings + } + addSettingMethod?.let { homeUserCenterClass to it } + }.toList() + if (legacyResult.isNotEmpty()) { + legacyResult.forEach { (cls, method) -> homeUserCenter += homeUserCenter { - class_ = class_ { name = homeUserCenterClass.name } - addSetting = method { name = addSettingMethod.name } + class_ = class_ { name = cls.name } + addSetting = method { name = method.name } } } + } else { + // 8.97.0: addSetting 方法未找到,通过字符串扫描拿到的类找菜单适配器 + val adapterType = + ("androidx.recyclerview.widget.RecyclerView\$Adapter" from classloader) + ?: ("android.support.v7.widget.RecyclerView\$Adapter" from classloader) + mineAdapter = class_ { + adapterType ?: return@class_ + name = homeUserCenterClasses.firstNotNullOfOrNull { cls -> + cls.declaredFields.firstOrNull { + adapterType.isAssignableFrom(it.type) + }?.type?.name + } ?: return@class_ + } + } } drawer = drawer { val navigationViewClass = @@ -1841,13 +1865,33 @@ class BiliBiliPackage constructor(private val mClassLoader: ClassLoader, mContex null, null, true - ).asSequence().firstNotNullOfOrNull { + ).asSequence().mapNotNull { dexHelper.decodeMethodIndex(it) as? Method - }?.let { - val getContentStringMethod = it.parameterTypes[1].declaredMethods.find { m -> + }.find { method -> + // 跨版本匹配: 方法在 ConversationActivity 中,或在捕获了 + // ConversationActivity 的 lambda 类中。用 Activity 类型做混淆无关匹配 + val dc = method.declaringClass + Activity::class.java.isAssignableFrom(dc) || + dc.declaredFields.any { + Activity::class.java.isAssignableFrom(it.type) + } + }?.let { method -> + // 跨版本获取 BaseTypedMessage: 旧版为方法参数,新版为 captured field + val btmClass = method.parameterTypes.getOrNull(1) + ?: method.declaringClass.declaredFields.firstNotNullOfOrNull { f -> + f.type.takeIf { t -> + t != String::class.java && !t.isPrimitive && + t.declaredMethods.any { m -> + m.returnType == String::class.java && m.parameterTypes.isEmpty() + } + } + } + ?: return@let + val getContentStringMethod = btmClass.declaredMethods.find { m -> m.returnType == String::class.java && m.parameterTypes.isEmpty() } ?: return@let - onOperateClick = method { name = it.name } + onOperateClick = method { name = method.name } + operateClickHostClass = class_ { name = method.declaringClass.name } getContentString = method { name = getContentStringMethod.name } } livePagerRecyclerView = class_ { @@ -2296,8 +2340,9 @@ class BiliBiliPackage constructor(private val mClassLoader: ClassLoader, mContex null, null, null, - true - ).asSequence().mapNotNull { dexHelper.decodeMethodIndex(it) }.firstOrNull() + false + ).asSequence().mapNotNull { dexHelper.decodeMethodIndex(it) } + .firstOrNull { it.declaringClass.name.contains("blconfig") } ?: return@preBuiltConfig class_ = class_ { name = getMap.declaringClass.name } get = method { name = getMap.name } diff --git a/app/src/main/java/me/iacn/biliroaming/CommentFilterDialog.kt b/app/src/main/java/me/iacn/biliroaming/CommentFilterDialog.kt index c2215493cd..89a77bbb2a 100644 --- a/app/src/main/java/me/iacn/biliroaming/CommentFilterDialog.kt +++ b/app/src/main/java/me/iacn/biliroaming/CommentFilterDialog.kt @@ -56,7 +56,7 @@ class CommentFilterDialog(activity: Activity, prefs: SharedPreferences) : val currentTargetCommentAuthorLevel = prefs.getLong("target_comment_author_level", 0L).toInt() val tvHint = seekBarView.findViewById(R.id.tvHint).apply { - text = if (currentTargetCommentAuthorLevel == 0) "关闭" else context.getString( + text = if (currentTargetCommentAuthorLevel == 0) "关闭" else XposedInit.moduleRes.getString( R.string.danmaku_filter_weight_hint, currentTargetCommentAuthorLevel ) @@ -68,7 +68,7 @@ class CommentFilterDialog(activity: Activity, prefs: SharedPreferences) : seekBar: SeekBar?, progress: Int, fromUser: Boolean ) { tvHint.text = - if (progress == 0) "关闭" else context.getString( + if (progress == 0) "关闭" else XposedInit.moduleRes.getString( R.string.danmaku_filter_weight_hint, progress ) diff --git a/app/src/main/java/me/iacn/biliroaming/CustomSubtitleDialog.kt b/app/src/main/java/me/iacn/biliroaming/CustomSubtitleDialog.kt index 2c559f9afe..060e192bae 100644 --- a/app/src/main/java/me/iacn/biliroaming/CustomSubtitleDialog.kt +++ b/app/src/main/java/me/iacn/biliroaming/CustomSubtitleDialog.kt @@ -175,9 +175,9 @@ class CustomSubtitleDialog(activity: Activity, fragment: Fragment, prefs: Shared private fun refreshFontStatus() { fontStatus?.text = if (SubtitleHook.fontFile.isFile) - context.getString(R.string.custom_subtitle_status_custom) + XposedInit.moduleRes.getString(R.string.custom_subtitle_status_custom) else - context.getString(R.string.custom_subtitle_status_default) + XposedInit.moduleRes.getString(R.string.custom_subtitle_status_default) } fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { diff --git a/app/src/main/java/me/iacn/biliroaming/DynamicFilterDialog.kt b/app/src/main/java/me/iacn/biliroaming/DynamicFilterDialog.kt index 53486feaaf..612bd7872c 100644 --- a/app/src/main/java/me/iacn/biliroaming/DynamicFilterDialog.kt +++ b/app/src/main/java/me/iacn/biliroaming/DynamicFilterDialog.kt @@ -81,8 +81,8 @@ class DynamicFilterDialog(activity: Activity, prefs: SharedPreferences) : ) } root.addView(gridLayout) - val dynamicTypes = context.resources.getStringArray(R.array.dynamic_entries).zip( - context.resources.getStringArray(R.array.dynamic_values) + val dynamicTypes = XposedInit.moduleRes.getStringArray(R.array.dynamic_entries).zip( + XposedInit.moduleRes.getStringArray(R.array.dynamic_values) ) val colSpec = fun(colWeight: Float) = GridLayout.spec(GridLayout.UNDEFINED, colWeight) val rowSpec = { GridLayout.spec(GridLayout.UNDEFINED) } diff --git a/app/src/main/java/me/iacn/biliroaming/SettingDialog.kt b/app/src/main/java/me/iacn/biliroaming/SettingDialog.kt index bf42301728..b83c882cf0 100644 --- a/app/src/main/java/me/iacn/biliroaming/SettingDialog.kt +++ b/app/src/main/java/me/iacn/biliroaming/SettingDialog.kt @@ -89,7 +89,7 @@ class SettingDialog(context: Context) : AlertDialog.Builder(context) { findPreference("custom_splash")?.onPreferenceChangeListener = this findPreference("custom_splash_logo")?.onPreferenceChangeListener = this findPreference("save_log")?.summary = - context.getString(R.string.save_log_summary).format(logFile.absolutePath) + XposedInit.moduleRes.getString(R.string.save_log_summary).format(logFile.absolutePath) findPreference("custom_server")?.onPreferenceClickListener = this findPreference("test_upos")?.onPreferenceClickListener = this findPreference("customize_bottom_bar")?.onPreferenceClickListener = this @@ -155,22 +155,22 @@ class SettingDialog(context: Context) : AlertDialog.Builder(context) { private fun SearchItem.appendExtraKeywords() = when (key) { "custom_subtitle" -> { - extra.add(context.getString(R.string.custom_subtitle_remove_bg)) - extra.add(context.getString(R.string.custom_subtitle_bold)) - extra.add(context.getString(R.string.custom_subtitle_font_size)) - extra.add(context.getString(R.string.custom_subtitle_stroke_color)) - extra.add(context.getString(R.string.custom_subtitle_stroke_width)) - extra.add(context.getString(R.string.custom_subtitle_offset)) + extra.add(XposedInit.moduleRes.getString(R.string.custom_subtitle_remove_bg)) + extra.add(XposedInit.moduleRes.getString(R.string.custom_subtitle_bold)) + extra.add(XposedInit.moduleRes.getString(R.string.custom_subtitle_font_size)) + extra.add(XposedInit.moduleRes.getString(R.string.custom_subtitle_stroke_color)) + extra.add(XposedInit.moduleRes.getString(R.string.custom_subtitle_stroke_width)) + extra.add(XposedInit.moduleRes.getString(R.string.custom_subtitle_offset)) } "home_filter" -> { - extra.add(context.getString(R.string.apply_to_relate_title)) - extra.add(context.getString(R.string.hide_low_play_count_recommend_title)) - extra.add(context.getString(R.string.hide_low_play_count_recommend_summary)) - extra.add(context.getString(R.string.hide_short_duration_recommend_title)) - extra.add(context.getString(R.string.hide_long_duration_recommend_title)) - extra.add(context.getString(R.string.hide_duration_recommend_summary)) - extra.add(context.getString(R.string.keywords_filter_recommend_summary)) + extra.add(XposedInit.moduleRes.getString(R.string.apply_to_relate_title)) + extra.add(XposedInit.moduleRes.getString(R.string.hide_low_play_count_recommend_title)) + extra.add(XposedInit.moduleRes.getString(R.string.hide_low_play_count_recommend_summary)) + extra.add(XposedInit.moduleRes.getString(R.string.hide_short_duration_recommend_title)) + extra.add(XposedInit.moduleRes.getString(R.string.hide_long_duration_recommend_title)) + extra.add(XposedInit.moduleRes.getString(R.string.hide_duration_recommend_summary)) + extra.add(XposedInit.moduleRes.getString(R.string.keywords_filter_recommend_summary)) } "customize_bottom_bar" -> { @@ -182,19 +182,19 @@ class SettingDialog(context: Context) : AlertDialog.Builder(context) { } "customize_dynamic" -> { - extra.add(context.getString(R.string.customize_dynamic_prefer_video_tab)) - extra.add(context.getString(R.string.purify_city_title)) - extra.add(context.getString(R.string.purify_campus_title)) - extra.add(context.getString(R.string.customize_dynamic_all_rm_topic_title)) - extra.add(context.getString(R.string.customize_dynamic_all_rm_up_title)) - extra.add(context.getString(R.string.customize_dynamic_video_rm_up_title)) - extra.add(context.getString(R.string.customize_dynamic_filter_apply_to_video)) - extra.add(context.getString(R.string.customize_dynamic_rm_blocked_title)) - extra.addAll(context.resources.getStringArray(R.array.dynamic_entries)) + extra.add(XposedInit.moduleRes.getString(R.string.customize_dynamic_prefer_video_tab)) + extra.add(XposedInit.moduleRes.getString(R.string.purify_city_title)) + extra.add(XposedInit.moduleRes.getString(R.string.purify_campus_title)) + extra.add(XposedInit.moduleRes.getString(R.string.customize_dynamic_all_rm_topic_title)) + extra.add(XposedInit.moduleRes.getString(R.string.customize_dynamic_all_rm_up_title)) + extra.add(XposedInit.moduleRes.getString(R.string.customize_dynamic_video_rm_up_title)) + extra.add(XposedInit.moduleRes.getString(R.string.customize_dynamic_filter_apply_to_video)) + extra.add(XposedInit.moduleRes.getString(R.string.customize_dynamic_rm_blocked_title)) + extra.addAll(XposedInit.moduleRes.getStringArray(R.array.dynamic_entries)) } "pref_import", "pref_export" -> { - extra.add(context.getString(R.string.pref_backup)) + extra.add(XposedInit.moduleRes.getString(R.string.pref_backup)) } else -> false @@ -214,7 +214,7 @@ class SettingDialog(context: Context) : AlertDialog.Builder(context) { private fun checkUposServer() { val currentServer = prefs.getString("upos_host", null).orEmpty() - val serverList = context.resources.getStringArray(R.array.upos_values) + val serverList = XposedInit.moduleRes.getStringArray(R.array.upos_values) if (currentServer !in serverList) { scope.launch(Dispatchers.IO) { val defaultServer = @@ -225,7 +225,7 @@ class SettingDialog(context: Context) : AlertDialog.Builder(context) { } private fun checkUpdate() { - val url = URL(context.getString(R.string.version_url)) + val url = URL(XposedInit.moduleRes.getString(R.string.version_url)) scope.launch { val result = fetchJson(url) ?: return@launch val newestVer = result.optString("name") @@ -236,9 +236,9 @@ class SettingDialog(context: Context) : AlertDialog.Builder(context) { (findPreference("about") as PreferenceCategory).addPreference( Preference(activity).apply { key = "update" - title = context.getString(R.string.update_title) + title = XposedInit.moduleRes.getString(R.string.update_title) summary = result.optString("body").substringAfterLast("更新日志\r\n") - .ifEmpty { context.getString(R.string.update_summary) } + .ifEmpty { XposedInit.moduleRes.getString(R.string.update_summary) } onPreferenceClickListener = this@PrefsFragment order = 1 }) @@ -291,7 +291,7 @@ class SettingDialog(context: Context) : AlertDialog.Builder(context) { if (versionCode >= 7500300) { disablePreference( "music_notification", - context.getString(R.string.os_not_support)) + XposedInit.moduleRes.getString(R.string.os_not_support)) } else { disablePreference("music_notification") } @@ -335,7 +335,7 @@ class SettingDialog(context: Context) : AlertDialog.Builder(context) { private fun disablePreference( name: String, - message: String = context.getString(R.string.not_support) + message: String = XposedInit.moduleRes.getString(R.string.not_support) ) { findPreference(name)?.run { isEnabled = false @@ -484,7 +484,7 @@ class SettingDialog(context: Context) : AlertDialog.Builder(context) { } private fun onUpdateClick(): Boolean { - val uri = Uri.parse(context.getString(R.string.update_url)) + val uri = Uri.parse(XposedInit.moduleRes.getString(R.string.update_url)) val intent = Intent(Intent.ACTION_VIEW, uri) startActivity(intent) return true @@ -515,7 +515,7 @@ class SettingDialog(context: Context) : AlertDialog.Builder(context) { } } setNegativeButton("获取公共解析服务器") { _, _ -> - val uri = Uri.parse(context.getString(R.string.server_url)) + val uri = Uri.parse(XposedInit.moduleRes.getString(R.string.server_url)) val intent = Intent(Intent.ACTION_VIEW, uri) startActivity(intent) } @@ -535,14 +535,14 @@ class SettingDialog(context: Context) : AlertDialog.Builder(context) { seekBar: SeekBar?, progress: Int, fromUser: Boolean ) { tvHint.text = - context.getString(R.string.danmaku_filter_weight_hint, progress) + XposedInit.moduleRes.getString(R.string.danmaku_filter_weight_hint, progress) } override fun onStartTrackingTouch(seekBar: SeekBar?) {} override fun onStopTrackingTouch(seekBar: SeekBar?) {} }) val current = prefs.getInt("danmaku_filter_weight", 0) - tvHint.text = context.getString(R.string.danmaku_filter_weight_hint, current) + tvHint.text = XposedInit.moduleRes.getString(R.string.danmaku_filter_weight_hint, current) seekBar.progress = current setTitle(R.string.danmaku_filter_title) setNegativeButton(android.R.string.cancel, null) @@ -601,7 +601,7 @@ class SettingDialog(context: Context) : AlertDialog.Builder(context) { if (it.isEmpty() || ids.contains(it)) return@forEach bottomItems.add(JsonHook.BottomItem("未知", null, it, false)) } - setTitle(context.getString(R.string.customize_bottom_bar_title)) + setTitle(XposedInit.moduleRes.getString(R.string.customize_bottom_bar_title)) setPositiveButton(android.R.string.ok) { _, _ -> val hideItems = mutableSetOf() bottomItems.forEach { @@ -706,7 +706,7 @@ class SettingDialog(context: Context) : AlertDialog.Builder(context) { return true } AlertDialog.Builder(activity) - .setTitle(context.getString(R.string.share_log_title)) + .setTitle(XposedInit.moduleRes.getString(R.string.share_log_title)) .setItems(arrayOf("log.txt", "old_log.txt (崩溃相关发这个)")) { _, which -> val toShareLog = if (which == 0) logFile else oldLogFile if (toShareLog.exists()) { @@ -721,7 +721,7 @@ class SettingDialog(context: Context) : AlertDialog.Builder(context) { putExtra(Intent.EXTRA_STREAM, uri) addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) setDataAndType(uri, "text/log") - }, context.getString(R.string.share_log_title))) + }, XposedInit.moduleRes.getString(R.string.share_log_title))) } else { Log.toast("日志文件不存在", force = true) } @@ -738,7 +738,7 @@ class SettingDialog(context: Context) : AlertDialog.Builder(context) { if (it.isEmpty() || ids.contains(it)) return@forEach JsonHook.drawerItems.add(JsonHook.BottomItem("未知", null, it, false)) } - setTitle(context.getString(R.string.customize_drawer_title)) + setTitle(XposedInit.moduleRes.getString(R.string.customize_drawer_title)) setPositiveButton(android.R.string.ok) { _, _ -> val hideItems = mutableSetOf() JsonHook.drawerItems.forEach { @@ -770,7 +770,7 @@ class SettingDialog(context: Context) : AlertDialog.Builder(context) { tv.setText(sPrefs.getString("custom_link", "")) tv.hint = "bilibili://user_center/vip" AlertDialog.Builder(activity).run { - setTitle(context.getString(R.string.custom_link_summary)) + setTitle(XposedInit.moduleRes.getString(R.string.custom_link_summary)) setView(tv) setPositiveButton(android.R.string.ok) { _, _ -> if (tv.text.toString().startsWith("bilibili://")) { @@ -894,7 +894,7 @@ class SettingDialog(context: Context) : AlertDialog.Builder(context) { val blockedCount = sPrefs.getInt("purify_story_video_ad_blocked_count", 0) - setTitle(context.getString(R.string.purify_story_video_ad_title) + "(累计拦截 $blockedCount 条)") + setTitle(XposedInit.moduleRes.getString(R.string.purify_story_video_ad_title) + "(累计拦截 $blockedCount 条)") setPositiveButton(context.getString(android.R.string.ok)) { _, _ -> val selected = mutableSetOf() for (i in keys.indices) { @@ -1049,7 +1049,7 @@ class SettingDialog(context: Context) : AlertDialog.Builder(context) { val endIdx = startIdx + hint.hint.length if (endIdx > length) return this val flags = Spanned.SPAN_EXCLUSIVE_EXCLUSIVE - val hintColor = preference.context.getColor(R.color.text_search_hint) + val hintColor = XposedInit.moduleRes.getColor(R.color.text_search_hint, null) val colorSpan = ForegroundColorSpan(hintColor) val boldSpan = StyleSpan(Typeface.BOLD) return SpannableStringBuilder(this).apply { diff --git a/app/src/main/java/me/iacn/biliroaming/SpeedTestDialog.kt b/app/src/main/java/me/iacn/biliroaming/SpeedTestDialog.kt index e181142fc2..9336bc1bc1 100644 --- a/app/src/main/java/me/iacn/biliroaming/SpeedTestDialog.kt +++ b/app/src/main/java/me/iacn/biliroaming/SpeedTestDialog.kt @@ -34,7 +34,7 @@ class SpeedTestAdapter(context: Context) : ArrayAdapter(context getItem(position).let { findViewById(R.id.upos_name).text = it?.name findViewById(R.id.upos_speed).text = - context.getString(R.string.speed_formatter, it?.speed) + XposedInit.moduleRes.getString(R.string.speed_formatter, it?.speed) } } } @@ -65,8 +65,8 @@ class SpeedTestDialog(activity: Activity, prefs: SharedPreferences) : view.adapter = adapter view.addHeaderView(context.inflateLayout(R.layout.cdn_speedtest_item).apply { - findViewById(R.id.upos_name).text = context.getString(R.string.upos) - findViewById(R.id.upos_speed).text = context.getString(R.string.speed) + findViewById(R.id.upos_name).text = XposedInit.moduleRes.getString(R.string.upos) + findViewById(R.id.upos_speed).text = XposedInit.moduleRes.getString(R.string.speed) }, null, false) view.setPadding(16.dp, 10.dp, 16.dp, 10.dp) @@ -98,8 +98,8 @@ class SpeedTestDialog(activity: Activity, prefs: SharedPreferences) : dialog.setTitle("测速失败") return@launch } - context.resources.getStringArray(R.array.upos_entries) - .zip(context.resources.getStringArray(R.array.upos_values)).asFlow().map { + XposedInit.moduleRes.getStringArray(R.array.upos_entries) + .zip(XposedInit.moduleRes.getStringArray(R.array.upos_values)).asFlow().map { scope.launch { val item = SpeedTestResult(it.first, it.second, "...") adapter.add(item) diff --git a/app/src/main/java/me/iacn/biliroaming/XposedInit.kt b/app/src/main/java/me/iacn/biliroaming/XposedInit.kt index a4e96979f2..b8ec6f45db 100644 --- a/app/src/main/java/me/iacn/biliroaming/XposedInit.kt +++ b/app/src/main/java/me/iacn/biliroaming/XposedInit.kt @@ -15,6 +15,7 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.MainScope import kotlinx.coroutines.future.future import me.iacn.biliroaming.hook.* +import me.iacn.biliroaming.hook.KotlinxJsonHook import me.iacn.biliroaming.utils.* import java.util.concurrent.CompletableFuture @@ -89,6 +90,7 @@ class XposedInit : IXposedHookLoadPackage, IXposedHookZygoteInit { startHook { TeenagersModeHook(lpparam.classLoader) } startHook { JsonHook(lpparam.classLoader) } startHook { GsonHook(lpparam.classLoader) } + startHook { KotlinxJsonHook(lpparam.classLoader) } startHook { ShareHook(lpparam.classLoader) } startHook { AutoLikeHook(lpparam.classLoader) } startHook { SettingHook(lpparam.classLoader) } diff --git a/app/src/main/java/me/iacn/biliroaming/hook/CopyHook.kt b/app/src/main/java/me/iacn/biliroaming/hook/CopyHook.kt index 7ca1808aa6..42879a5c84 100644 --- a/app/src/main/java/me/iacn/biliroaming/hook/CopyHook.kt +++ b/app/src/main/java/me/iacn/biliroaming/hook/CopyHook.kt @@ -8,6 +8,7 @@ import android.text.SpannableStringBuilder import android.text.style.ClickableSpan import android.view.View import android.widget.FrameLayout +import android.widget.PopupWindow import android.widget.TextView import me.iacn.biliroaming.BiliBiliPackage.Companion.instance import me.iacn.biliroaming.utils.* @@ -102,33 +103,59 @@ class CopyHook(classLoader: ClassLoader) : BaseHook(classLoader) { } if (!enhanceLongClickCopy) return - "com.bilibili.bplus.im.conversation.ConversationActivity".from(mClassLoader) - ?.declaredMethods?.find { - it.name == instance.onOperateClick() && it.parameterTypes.size == 8 - }?.hookBeforeMethod { param -> - if (param.args.last() == param.args.first()) { - val activity = param.thisObject as Activity - val json = param.args[1].callMethodOrNullAs(instance.getContentString()) ?: "" - val text = runCatchingOrNull { json.toJSONObject() }?.run { - optString("content").ifEmpty { - buildString { - appendLine(optString("title").trim()) - appendLine(optString("text").trim()) - optJSONArray("modules")?.run { - asSequence().map { - it.optString("title") + ":" + it.optString("detail") - }.joinToString("\n").run { - append(this) - } - } - }.run { removeSuffix("\n") } - } - } ?: return@hookBeforeMethod - showCopyDialog(activity, text, param) - param.args[6].callMethodOrNull("dismiss") - param.result = null - } - } + val onClickName = instance.onOperateClick() ?: return + val contentStringName = instance.getContentString() ?: return + val hostClassName = instance.operateClickHostClass() ?: return + val hostClass = hostClassName.from(mClassLoader) ?: return + val hookMethod = hostClass.declaredMethods.find { it.name == onClickName } ?: return + + fun parseContentText(json: String): String? = runCatchingOrNull { json.toJSONObject() }?.run { + optString("content").ifEmpty { + buildString { + appendLine(optString("title").trim()) + appendLine(optString("text").trim()) + optJSONArray("modules")?.run { + asSequence().map { + it.optString("title") + ":" + it.optString("detail") + }.joinToString("\n").run { append(this) } + } + }.run { removeSuffix("\n") } + }.ifEmpty { null } + } + + hookMethod.hookBeforeMethod { param -> + // Repost guard: last arg == first arg + if (param.args.size >= 2 && param.args.last() != param.args.first()) return@hookBeforeMethod + + val hostClass = param.thisObject.javaClass + + // Activity: try this first, then search for captured field + val activity = (param.thisObject as? Activity) + ?: hostClass.declaredFields.find { + Activity::class.java.isAssignableFrom(it.type) + }?.apply { isAccessible = true }?.get(param.thisObject) as? Activity + ?: return@hookBeforeMethod + + // Typed message: try args[1] first, then search for field with getContentString + val typedMsg = param.args.getOrNull(1) + ?: hostClass.declaredFields.find { + runCatching { it.type.getMethod(contentStringName) }.isSuccess + }?.apply { isAccessible = true }?.get(param.thisObject) + ?: return@hookBeforeMethod + + val json = typedMsg.callMethodOrNullAs(contentStringName) ?: return@hookBeforeMethod + val text = parseContentText(json) ?: return@hookBeforeMethod + showCopyDialog(activity, text, param) + + // Dismiss popup: try args[6] first, then search for PopupWindow field + (param.args.getOrNull(6) + ?: hostClass.declaredFields.find { + PopupWindow::class.java.isAssignableFrom(it.type) + }?.apply { isAccessible = true }?.get(param.thisObject)) + ?.callMethodOrNull("dismiss") + + param.result = null + } } private fun showCopyDialog(context: Context, text: CharSequence, param: MethodHookParam) { diff --git a/app/src/main/java/me/iacn/biliroaming/hook/EnvHook.kt b/app/src/main/java/me/iacn/biliroaming/hook/EnvHook.kt index aafefb6626..e0bc5b8ca6 100644 --- a/app/src/main/java/me/iacn/biliroaming/hook/EnvHook.kt +++ b/app/src/main/java/me/iacn/biliroaming/hook/EnvHook.kt @@ -14,48 +14,39 @@ class EnvHook(classLoader: ClassLoader) : BaseHook(classLoader) { instance.preBuiltConfigClass?.let { val hooker: Hooker = hooker@ { param -> @Suppress("UNCHECKED_CAST") - val result = param.result as MutableMap + val result = param.result as? MutableMap ?: return@hooker for (config in configSet) { - (if (sPrefs.getBoolean( - config.config, - false - ) - ) config.trueValue else config.falseValue) - ?.let { result[config.key] = it } ?: result.remove(config.key) + config.getEncryptedValue()?.let { result[config.key] = it } + ?: result.remove(config.key) } } // v8.28.0 - ? - it.hookAfterMethod(instance.getPreBuiltConfigMethod(), hooker = hooker) + runCatching { it.hookAfterMethod(instance.getPreBuiltConfigMethod(), hooker = hooker) } // ? - v8.48.0 .. - it.hookAfterMethod(instance.getPreBuiltConfigMethod(), it, hooker = hooker) + runCatching { it.hookAfterMethod(instance.getPreBuiltConfigMethod(), it, hooker = hooker) } } // TypedContext instance.dataSPClass?.let { val hooker: Hooker = hooker@ { param -> - val result = param.result as SharedPreferences - // this indicates the proper instance + val result = param.result as? SharedPreferences ?: return@hooker if (!result.contains("bv.enable_bv")) return@hooker for (config in configSet) { - (if (sPrefs.getBoolean( - config.config, - false - ) - ) config.trueValue else config.falseValue) - ?.let { result.edit().putString(config.key, it).apply() } - ?: result.edit().remove(config.key).apply() + config.getEncryptedValue()?.let { + result.edit().putString(config.key, it).apply() + } ?: result.edit().remove(config.key).apply() } } // v8.28.0 - ? - it.hookAfterMethod(instance.getDataSPMethod(), hooker = hooker) + runCatching { it.hookAfterMethod(instance.getDataSPMethod(), hooker = hooker) } // ? - v8.48.0 .. - it.hookAfterMethod(instance.getDataSPMethod(), it, hooker = hooker) + runCatching { it.hookAfterMethod(instance.getDataSPMethod(), it, hooker = hooker) } } "com.bilibili.lib.blconfig.internal.OverrideConfig".findClassOrNull(mClassLoader) ?.hookBeforeAllConstructors { param -> val delegate = param.args.getOrNull(0) ?: return@hookBeforeAllConstructors - val realConfig = param.args.getOrNull(1) ?: return@hookBeforeAllConstructors + val realConfig = param.args.getOrNull(1) // may be null on 8.97.0+ (z12=true) val delegateClass = delegate.javaClass param.args[0] = Proxy.newProxyInstance( delegateClass.classLoader, @@ -66,8 +57,10 @@ class EnvHook(classLoader: ClassLoader) : BaseHook(classLoader) { var result: Any? = null val key = args[0] for (config in configSet) { - if (sPrefs.getBoolean(config.config, false) && config.key == key) { - result = realConfig.callMethodOrNull("get", *args) + if (config.key == key) { + result = if (realConfig != null) { + realConfig.callMethodOrNull("get", *args) + } else config.getPlainValue() } } result ?: m(delegate, *args) @@ -87,13 +80,22 @@ class EnvHook(classLoader: ClassLoader) : BaseHook(classLoader) { Log.d("lateHook: Env") if (sPrefs.getBoolean("enable_av", false)) { val compatClass = "com.bilibili.droid.BVCompat".findClassOrNull(mClassLoader) - compatClass?.declaredFields?.forEach { - val field = compatClass.getStaticObjectField(it.name) - if (field is Pattern && field.pattern() == "av[1-9]\\d*") - compatClass.setStaticObjectField( - it.name, - Pattern.compile("(av[1-9]\\d*)|(BV1[1-9A-NP-Za-km-z]{9})", field.flags()) - ) + compatClass?.declaredFields?.forEach { f -> + runCatchingOrNull { + val field = compatClass.getStaticObjectField(f.name) + if (field is Pattern && field.pattern() == "av[1-9]\\d*") { + compatClass.setStaticObjectField( + f.name, + Pattern.compile("(av[1-9]\\d*)|(BV1[1-9A-NP-Za-km-z]{9})", field.flags()) + ) + } + } + if (f.type == Boolean::class.javaPrimitiveType) { + runCatching { + f.isAccessible = true + f.setBoolean(null, false) + }.onFailure { Log.e(it) } + } } } } @@ -109,15 +111,25 @@ class EnvHook(classLoader: ClassLoader) : BaseHook(classLoader) { val key: String, val config: String, val trueValue: String?, - val falseValue: String? - ) + val falseValue: String?, + val plainTrueValue: String? = null, + val plainFalseValue: String? = null + ) { + fun getEncryptedValue(): String? = + if (sPrefs.getBoolean(config, false)) trueValue else falseValue + + fun getPlainValue(): String? = + if (sPrefs.getBoolean(config, false)) plainTrueValue else plainFalseValue + } val configSet = listOf( ConfigTuple( "bv.enable_bv", "enable_av", encryptedValueMap["0"], - encryptedValueMap["1"] + encryptedValueMap["1"], + "0", + "1" ), ) } diff --git a/app/src/main/java/me/iacn/biliroaming/hook/KotlinxJsonHook.kt b/app/src/main/java/me/iacn/biliroaming/hook/KotlinxJsonHook.kt new file mode 100644 index 0000000000..7d257f3b4e --- /dev/null +++ b/app/src/main/java/me/iacn/biliroaming/hook/KotlinxJsonHook.kt @@ -0,0 +1,51 @@ +package me.iacn.biliroaming.hook + +import me.iacn.biliroaming.BiliBiliPackage.Companion.instance +import me.iacn.biliroaming.hook.kotlinx.KotlinxProcessor +import me.iacn.biliroaming.hook.kotlinx.KotlinxSplashListProcessor +import me.iacn.biliroaming.hook.kotlinx.KotlinxSplashShowProcessor +import me.iacn.biliroaming.utils.Log +import me.iacn.biliroaming.utils.getObjectField +import me.iacn.biliroaming.utils.getObjectFieldAs +import me.iacn.biliroaming.utils.hookAfterAllMethods + +class KotlinxJsonHook(classLoader: ClassLoader) : BaseHook(classLoader) { + private val allProcessors = listOf( + KotlinxSplashListProcessor(), + KotlinxSplashShowProcessor(), + ) + + private val enabledProcessors: Map> by lazy { + allProcessors + .filter { it.shouldEnable() } + .groupBy { it.targetSerialName } + } + + override fun startHook() { + if (enabledProcessors.isEmpty()) return + Log.d("startHook: KotlinxJson") + + val jsonClass = instance.kotlinJsonClass ?: return + + jsonClass.hookAfterAllMethods("decodeFromString") { param -> + dispatchResult(param.args.getOrNull(0), param.result) + } + } + + private fun dispatchResult(deserializer: Any?, result: Any?) { + if (deserializer == null || result == null) return + + val serialName = deserializer + .getObjectField("descriptor") + ?.getObjectFieldAs("serialName") + ?: return + + enabledProcessors[serialName]?.forEach { processor -> + try { + processor.process(result) + } catch (e: Throwable) { + Log.e("KotlinxJsonHook processor ${processor.targetSerialName} error: $e") + } + } + } +} diff --git a/app/src/main/java/me/iacn/biliroaming/hook/SettingHook.kt b/app/src/main/java/me/iacn/biliroaming/hook/SettingHook.kt index a3e2bf669a..5b017caae4 100644 --- a/app/src/main/java/me/iacn/biliroaming/hook/SettingHook.kt +++ b/app/src/main/java/me/iacn/biliroaming/hook/SettingHook.kt @@ -68,15 +68,7 @@ class SettingHook(classLoader: ClassLoader) : BaseHook(classLoader) { ) else list } ?: list - val item = instance.menuGroupItemClass?.new() ?: return@hookBeforeAllMethods - item.setIntField("id", SETTING_ID) - .setObjectField("title", "哔哩漫游设置") - .setObjectField( - "icon", - "https://i0.hdslb.com/bfs/album/276769577d2a5db1d9f914364abad7c5253086f6.png" - ) - .setObjectField("uri", SETTING_URI) - .setIntField("visible", 1) + val item = makeSettingItem() ?: return@hookBeforeAllMethods itemList.forEach { if (try { it.getIntField("id") == SETTING_ID @@ -116,11 +108,136 @@ class SettingHook(classLoader: ClassLoader) : BaseHook(classLoader) { } } } + + // 8.97.0+: hook 菜单适配器 notify* 方法注入设置项 + instance.mineAdapterClass?.let { hookAdapterFallback(it) } + } + + private fun hookAdapterFallback(adapterClass: Class<*>) { + val menuGroupItemClass = instance.menuGroupItemClass ?: return + val dataField = adapterClass.declaredFields.firstOrNull { + List::class.java.isAssignableFrom(it.type) + } ?: return + dataField.isAccessible = true + + fun injectSettingItem(data: MutableList<*>) { + if (data.isEmpty()) return + + // 去重:检查 data 中所有 MenuGroup 的 itemList 是否已含设置项 + for (group in data) { + group?.getObjectFieldOrNullAs>("itemList")?.forEach { item -> + if (item.javaClass == menuGroupItemClass && + (try { + item.getIntField("id") == SETTING_ID + } catch (_: Throwable) { + item.getLongField("id") == SETTING_ID.toLong() + }) + ) return + } + } + + val targetList = data.lastOrNull() + ?.getObjectFieldOrNullAs>("itemList") ?: return + + val item = makeSettingItem() ?: return + targetList.add(if(targetList.isEmpty()) 0 else targetList.lastIndex, item) + } + + // hook RecyclerView.Adapter 多个通知方法代替单一 notifyDataSetChanged + // 8.97.0 的适配器使用 notifyItemRangeChanged 刷新数据 + listOf( + "notifyDataSetChanged", "notifyItemRangeChanged", + "notifyItemRangeInserted", "notifyItemRangeRemoved", "notifyItemChanged" + ).forEach { methodName -> + runCatching { + adapterClass.getMethod(methodName).hookBeforeMethod { param -> + if (!adapterClass.isInstance(param.thisObject)) return@hookBeforeMethod + val data = dataField.get(param.thisObject) as? MutableList<*> + ?: return@hookBeforeMethod + injectSettingItem(data) + } + } + } + + // 为注入的设置项绑定点击监听 + // 8.97.0+ 为双层 RecyclerView:外层分组适配器 → 内层条目适配器 + // 从外层 data 定位包含注入项的 MenuGroup,post 后遍历 View 树找到内层 RecyclerView + fun bindSettingClick(holder: Any, position: Int, adapter: Any) { + val data = dataField.get(adapter) as? List<*> ?: return + val group = data.getOrNull(position) ?: return + val itemList = group.getObjectFieldOrNullAs>("itemList") ?: return + itemList.find { + it?.javaClass == menuGroupItemClass && it.getObjectField("uri") == SETTING_URI + } ?: return + val itemView = holder.getObjectFieldOrNullAs("itemView") ?: return + itemView.post { + val rv = findFirstRecyclerView(itemView) ?: return@post + val innerAdapter = rv.callMethodAs("getAdapter") ?: return@post + + @Suppress("UNCHECKED_CAST") + val innerData = innerAdapter.javaClass.declaredFields + .firstOrNull { List::class.java.isAssignableFrom(it.type) } + ?.also { it.isAccessible = true } + ?.get(innerAdapter) as? List ?: return@post + val idx = innerData.indexOfFirst { + it.javaClass == menuGroupItemClass && + it.getObjectField("uri") == SETTING_URI + } + if (idx < 0) return@post + val lm = rv.callMethodAs("getLayoutManager") ?: return@post + val child = lm.callMethodAs("findViewByPosition", idx) ?: return@post + child.setOnClickListener { + val ctx = it.context + if (ctx is Activity) SettingDialog.show(ctx) + } + } + } + + // 2-param onBindViewHolder (首次 bind、全量刷新) + adapterClass.methods.firstOrNull { m -> + m.name == "onBindViewHolder" && m.parameterTypes.size == 2 + }?.hookAfterMethod { param -> + bindSettingClick(param.args[0], param.args[1] as Int, param.thisObject) + } + } + + /** + * 递归遍历 View 树,查找第一个 RecyclerView 实例。 + */ + private fun findFirstRecyclerView(root: View): View? { + if (rvClass?.isInstance(root) == true) return root + if (root !is ViewGroup) return null + for (i in 0 until root.childCount) { + findFirstRecyclerView(root.getChildAt(i))?.let { return it } + } + return null + } + + private val rvClass: Class<*>? by lazy { + "androidx.recyclerview.widget.RecyclerView".findClassOrNull(mClassLoader) + ?: "android.support.v7.widget.RecyclerView".findClassOrNull(mClassLoader) } companion object { const val START_SETTING_KEY = "biliroaming_start_setting" const val SETTING_URI = "bilibili://biliroaming" const val SETTING_ID = 114514 + + fun makeSettingItem(): Any? { + val item = instance.menuGroupItemClass?.new() ?: return null + try { + item.setIntField("id", SETTING_ID) + } catch (_: Throwable) { + item.setLongField("id", SETTING_ID.toLong()) + } + item.setObjectField("title", "哔哩漫游设置") + .setObjectField( + "icon", + "https://i0.hdslb.com/bfs/album/276769577d2a5db1d9f914364abad7c5253086f6.png" + ) + .setObjectField("uri", SETTING_URI) + item.setIntField("visible", 1) + return item + } } } diff --git a/app/src/main/java/me/iacn/biliroaming/hook/SplashHook.kt b/app/src/main/java/me/iacn/biliroaming/hook/SplashHook.kt index de6ab2ac31..bab1b03437 100644 --- a/app/src/main/java/me/iacn/biliroaming/hook/SplashHook.kt +++ b/app/src/main/java/me/iacn/biliroaming/hook/SplashHook.kt @@ -5,6 +5,7 @@ import android.graphics.Color import android.net.Uri import android.os.Bundle import android.view.View +import android.view.ViewGroup import android.widget.ImageView import me.iacn.biliroaming.BiliBiliPackage.Companion.instance import me.iacn.biliroaming.utils.* @@ -12,23 +13,21 @@ import java.io.File class SplashHook(classLoader: ClassLoader) : BaseHook(classLoader) { override fun startHook() { - if (!sPrefs.getBoolean("custom_splash", false) && !sPrefs.getBoolean( - "custom_splash_logo", - false - ) - && !sPrefs.getBoolean("full_splash", false) && !sPrefs.getBoolean("auto_dark_splash", false) + val customSplash = sPrefs.getBoolean("custom_splash", false) + val customSplashLogo = sPrefs.getBoolean("custom_splash_logo", false) + val fullSplash = sPrefs.getBoolean("full_splash", false) + val autoDarkSplash = sPrefs.getBoolean("auto_dark_splash", false) + if ( + !customSplash && + !customSplashLogo && + !fullSplash && + !autoDarkSplash ) return Log.d("startHook: Splash") instance.splashInfoClass?.hookAfterMethod( "getMode" - ) { param -> - param.result = if (sPrefs.getBoolean("full_splash", false)) { - "full" - } else { - param.result - } - } + ) { param -> if (fullSplash) param.result = "full" } instance.brandSplashClass?.hookAfterMethod( "onViewCreated", @@ -36,38 +35,91 @@ class SplashHook(classLoader: ClassLoader) : BaseHook(classLoader) { Bundle::class.java ) { param -> val view = param.args[0] as View - val containerId = getId("splash_container") - if (sPrefs.getBoolean("auto_dark_splash", false)) - view.findViewById(containerId) - .setBackgroundColor( - if (view.resources.configuration.uiMode.and(Configuration.UI_MODE_NIGHT_MASK) == Configuration.UI_MODE_NIGHT_YES) - Color.BLACK - else Color.WHITE - ) - if (sPrefs.getBoolean("custom_splash", false)) { - val brandId = getId("brand_splash") - val fullId = getId("full_brand_splash") - val brandSplash = view.findViewById(brandId) - val full = if (fullId != 0) view.findViewById(fullId) else null - val splashImage = File(currentContext.filesDir, SPLASH_IMAGE) + + // auto_dark_splash: 优先设置 splash_container 背景,回退到 root view + if (autoDarkSplash) { + val bgColor = if (view.resources.configuration.uiMode.and(Configuration.UI_MODE_NIGHT_MASK) == Configuration.UI_MODE_NIGHT_YES) + Color.BLACK else Color.WHITE + val containerId = getId("splash_container") + val container = if (containerId != 0) view.findViewById(containerId) else null + (container ?: view).setBackgroundColor(bgColor) + } + + // 检测渲染路径:brand_splash 存在于 XML 布局,不存在于 ComposeView + val brandId = getId("brand_splash") + val isXmlPath = brandId != 0 && view.findViewById(brandId) != null + + if (isXmlPath) { + applyXmlPath(view, brandId, customSplash, customSplashLogo) + } else if (view is ViewGroup) { + view.post { applyComposeOverlay(view, customSplash, customSplashLogo) } + } + } + } + + private fun applyXmlPath(view: View, brandId: Int, customSplash: Boolean, customSplashLogo: Boolean) { + if (customSplash) { + val brandSplash = view.findViewById(brandId)!! + val fullId = getId("full_brand_splash") + val full = if (fullId != 0) view.findViewById(fullId) else null + val splashImage = File(currentContext.filesDir, SPLASH_IMAGE) + if (splashImage.exists()) { + val uri = Uri.fromFile(splashImage) + brandSplash.setImageURI(uri) + full?.setImageURI(uri) + } else { + brandSplash.alpha = .0f + full?.alpha = .0f + } + } + if (customSplashLogo) { + val logoId = getId("brand_logo") + val brandLogo = view.findViewById(logoId) ?: return + val logoImage = File(currentContext.filesDir, LOGO_IMAGE) + if (logoImage.exists()) + brandLogo.setImageURI(Uri.fromFile(logoImage)) + else + brandLogo.alpha = .0f + } + } + + /** + * Compose 渲染路径:在 ComposeView 上叠加 ImageView。 + * ComposeView 是 ViewGroup 子类,addView 可添加覆盖层于 Compose 内容之上。 + */ + private fun applyComposeOverlay(container: ViewGroup, customSplash: Boolean, customSplashLogo: Boolean) { + if (sPrefs.getBoolean("custom_splash", false)) { + val splashImage = File(currentContext.filesDir, SPLASH_IMAGE) + val iv = ImageView(container.context).apply { + scaleType = ImageView.ScaleType.CENTER_CROP + layoutParams = ViewGroup.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.MATCH_PARENT + ) if (splashImage.exists()) { - val uri = Uri.fromFile(splashImage) - brandSplash.setImageURI(uri) - full?.setImageURI(uri) + setImageURI(Uri.fromFile(splashImage)) } else { - brandSplash.alpha = .0f - full?.alpha = .0f + visibility = View.GONE } } - if (sPrefs.getBoolean("custom_splash_logo", false)) { - val logoId = getId("brand_logo") - val brandLogo = view.findViewById(logoId) - val logoImage = File(currentContext.filesDir, LOGO_IMAGE) - if (logoImage.exists()) - brandLogo.setImageURI(Uri.fromFile(logoImage)) - else - brandLogo.alpha = .0f + container.addView(iv) + } + if (sPrefs.getBoolean("custom_splash_logo", false)) { + val logoH = container.resources.displayMetrics.heightPixels / 8 + val logoImage = File(currentContext.filesDir, LOGO_IMAGE) + val iv = ImageView(container.context).apply { + scaleType = ImageView.ScaleType.FIT_CENTER + layoutParams = ViewGroup.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, logoH + ) + y = (container.height - logoH).toFloat() + if (logoImage.exists()) { + setImageURI(Uri.fromFile(logoImage)) + } else { + visibility = View.GONE + } } + container.addView(iv) } } @@ -75,5 +127,4 @@ class SplashHook(classLoader: ClassLoader) : BaseHook(classLoader) { const val SPLASH_IMAGE = "biliroaming_splash" const val LOGO_IMAGE = "biliroaming_logo" } - } diff --git a/app/src/main/java/me/iacn/biliroaming/hook/kotlinx/KotlinxProcessor.kt b/app/src/main/java/me/iacn/biliroaming/hook/kotlinx/KotlinxProcessor.kt new file mode 100644 index 0000000000..0cc231c446 --- /dev/null +++ b/app/src/main/java/me/iacn/biliroaming/hook/kotlinx/KotlinxProcessor.kt @@ -0,0 +1,12 @@ +package me.iacn.biliroaming.hook.kotlinx + +interface KotlinxProcessor { + /** Serial descriptor name matching `serializer.descriptor.serialName` */ + val targetSerialName: String + + /** Return true if this processor's settings are enabled */ + fun shouldEnable(): Boolean + + /** Process the deserialized result object */ + fun process(result: Any) +} diff --git a/app/src/main/java/me/iacn/biliroaming/hook/kotlinx/KotlinxSplashListProcessor.kt b/app/src/main/java/me/iacn/biliroaming/hook/kotlinx/KotlinxSplashListProcessor.kt new file mode 100644 index 0000000000..e0524f2393 --- /dev/null +++ b/app/src/main/java/me/iacn/biliroaming/hook/kotlinx/KotlinxSplashListProcessor.kt @@ -0,0 +1,22 @@ +package me.iacn.biliroaming.hook.kotlinx + +import me.iacn.biliroaming.utils.sPrefs + +class KotlinxSplashListProcessor : KotlinxProcessor { + override val targetSerialName = + "kntr.srcs.app.splash.model.SplashListResponse" + + override fun shouldEnable() = + sPrefs.getBoolean("hidden", false) && sPrefs.getBoolean("purify_splash", false) + + override fun process(result: Any) { + try { + result.javaClass.declaredFields + .filter { MutableList::class.java.isAssignableFrom(it.type) } + .forEach { field -> + field.isAccessible = true + (field.get(result) as? MutableList<*>)?.clear() + } + } catch (_: Throwable) {} + } +} diff --git a/app/src/main/java/me/iacn/biliroaming/hook/kotlinx/KotlinxSplashShowProcessor.kt b/app/src/main/java/me/iacn/biliroaming/hook/kotlinx/KotlinxSplashShowProcessor.kt new file mode 100644 index 0000000000..ec53195a4d --- /dev/null +++ b/app/src/main/java/me/iacn/biliroaming/hook/kotlinx/KotlinxSplashShowProcessor.kt @@ -0,0 +1,24 @@ +package me.iacn.biliroaming.hook.kotlinx + +import me.iacn.biliroaming.utils.sPrefs + +class KotlinxSplashShowProcessor : KotlinxProcessor { + override val targetSerialName = + "kntr.srcs.app.splash.model.SplashShowStrategy" + + override fun shouldEnable() = + sPrefs.getBoolean("hidden", false) && sPrefs.getBoolean("purify_splash", false) + + override fun process(result: Any) { + try { + result.javaClass.declaredFields + .filter { !it.type.isPrimitive } + .filter { it.type != String::class.java + && it.type.name != "kotlinx.serialization.json.JsonObject" } + .forEach { field -> + field.isAccessible = true + field.set(result, null) + } + } catch (_: Throwable) {} + } +} diff --git a/app/src/main/java/me/iacn/biliroaming/utils/Log.kt b/app/src/main/java/me/iacn/biliroaming/utils/Log.kt index e8d64d8027..0a7d5524ef 100644 --- a/app/src/main/java/me/iacn/biliroaming/utils/Log.kt +++ b/app/src/main/java/me/iacn/biliroaming/utils/Log.kt @@ -18,10 +18,11 @@ object Log { fun toast(msg: String, force: Boolean = false, duration: Int = Toast.LENGTH_SHORT, alsoLog: Boolean = true) { if (!force && !sPrefs.getBoolean("show_info", true)) return handler.post { - BiliBiliPackage.instance.toastHelperClass?.runCatchingOrNull { - callStaticMethod(BiliBiliPackage.instance.cancelShowToast()) + val inst = runCatchingOrNull { BiliBiliPackage.instance } ?: return@post + inst.toastHelperClass?.runCatchingOrNull { + callStaticMethod(inst.cancelShowToast()) callStaticMethod( - BiliBiliPackage.instance.showToast(), + inst.showToast(), currentContext, "哔哩漫游:$msg", duration diff --git a/app/src/main/proto/me/iacn/biliroaming/configs.proto b/app/src/main/proto/me/iacn/biliroaming/configs.proto index e62a9501b3..cc82375280 100644 --- a/app/src/main/proto/me/iacn/biliroaming/configs.proto +++ b/app/src/main/proto/me/iacn/biliroaming/configs.proto @@ -200,6 +200,7 @@ message Settings { optional Class menu_group_item = 2; optional Class setting_router = 3; repeated HomeUserCenter home_user_center = 5; + optional Class mine_adapter = 6; } message BiliAccounts { @@ -386,6 +387,7 @@ message HookInfo { optional Class comment_long_click_new = 73; optional Method on_operate_click = 74; optional Method get_content_string = 75; + optional Class operate_click_host_class = 79; optional Class live_pager_recycler_view = 76; optional BiliConfig bili_config = 77; optional UpdateInfoSupplier update_info_supplier = 78;