From dc5f484b7ecba59eb6a7c62893f1cbaec2adaf3b Mon Sep 17 00:00:00 2001 From: huajiqaq Date: Sat, 14 Feb 2026 21:01:30 +0800 Subject: [PATCH 1/7] Fixed script manager crash on click, added more script APIs, and added release testing methods --- .github/PULL_REQUEST_TEMPLATE.md | 1 + CONTRIBUTING.md | 23 + SCRIPT_API_DOCUMENT.md | 591 +++++++++++++- .../hooks/item/script/ScriptConfigHookItem.kt | 743 +----------------- .../hooks/item/script/WeDataBaseUtils.kt | 160 ++++ .../wekit/hooks/item/script/WeMessageUtils.kt | 109 +++ .../wekit/hooks/item/script/WeProtoUtils.kt | 35 + .../ui/creator/dialog/ScriptManagerDialog.kt | 611 ++++++++++++++ .../moe/ouom/wekit/util/script/JsExecutor.kt | 37 +- .../main/res/layout/dialog_script_edit.xml | 93 +++ .../main/res/layout/dialog_test_script.xml | 59 ++ app/src/main/res/layout/script_item.xml | 92 +++ 12 files changed, 1807 insertions(+), 747 deletions(-) create mode 100644 app/src/main/java/moe/ouom/wekit/hooks/item/script/WeDataBaseUtils.kt create mode 100644 app/src/main/java/moe/ouom/wekit/hooks/item/script/WeMessageUtils.kt create mode 100644 app/src/main/java/moe/ouom/wekit/hooks/item/script/WeProtoUtils.kt create mode 100644 app/src/main/java/moe/ouom/wekit/ui/creator/dialog/ScriptManagerDialog.kt create mode 100644 app/src/main/res/layout/dialog_script_edit.xml create mode 100644 app/src/main/res/layout/dialog_test_script.xml create mode 100644 app/src/main/res/layout/script_item.xml diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 9636d39..b659d29 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -18,6 +18,7 @@ - [ ] **我确认此更改不会破坏任何原有功能** / I confirm this change does not break any existing features - [ ] **我已进行多版本适配(如适用)** / I have used MMVersion for version compatibility (if applicable) - [ ] **我已在多个微信版本上测试此更改(如适用)** / I have tested this change on multiple WeChat versions (if applicable) +- [ ] **已在 Release 构建中完成测试**(含签名校验与 DEX 加密保护,未经测试请勿勾选;详见 `CONTRIBUTING.md` → 构建和发布 → 构建配置 → Release 构建) / Verified in Release build (with signature verification & DEX encryption protection; check only after testing per `CONTRIBUTING.md` → Build & Release → Build Configuration → Release Build) ## 其他信息 / Additional Information diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index be677dd..c2e1595 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -2969,6 +2969,7 @@ close #1 - [ ] **我确认此更改不会破坏任何原有功能** / I confirm this change does not break any existing features - [ ] **我已进行多版本适配(如适用)** / I have used MMVersion for version compatibility (if applicable) - [ ] **我已在多个微信版本上测试此更改(如适用)** / I have tested this change on multiple WeChat versions (if applicable) +- [ ] **已在 Release 构建中完成测试**(含签名校验与 DEX 加密保护,未经测试请勿勾选;详见 `CONTRIBUTING.md` → 构建和发布 → 构建配置 → Release 构建) / Verified in Release build (with signature verification & DEX encryption protection; check only after testing per `CONTRIBUTING.md` → Build & Release → Build Configuration → Release Build) ##### 其他信息 / Additional Information @@ -3026,6 +3027,28 @@ close #1 输出位置:`app/build/outputs/apk/debug/app-debug.apk` +#### Release 构建 +当构建 **Release 变体 APK** 时,软件启动阶段将执行双重签名校验。为进行本地测试,需**临时**修改以下两处配置: +<64位SHA256签名> 通过 SignatureVerifier.getSignatureHash() 生成 + +**Native 层(`secrets.h`)**: + +```bash +python generate_secrets_h.py <64位SHA256签名> +# 将输出内容临时覆盖 app/src/main/cpp/include/secrets.h +``` + +**Java 层(`SignatureVerifier.java`)**: + +```java +private static final String[] VALID_SIGNATURE_HASHES = { + "<64位SHA256签名>" // 仅限本地测试 +}; +``` + +> ⚠️ **关键要求**: +> 以上修改**仅用于本地 Release 构建测试**,测试完成后 **`secrets.h` 和 `SignatureVerifier.java` 必须立即还原**至仓库原始版本,**严禁提交至仓库**,包含测试签名的 PR 将被拒绝合并 + ### 自定义构建任务 **代码保护机制说明**: diff --git a/SCRIPT_API_DOCUMENT.md b/SCRIPT_API_DOCUMENT.md index 5ad4df1..ef5889b 100644 --- a/SCRIPT_API_DOCUMENT.md +++ b/SCRIPT_API_DOCUMENT.md @@ -3,14 +3,25 @@ ## Beta 状态警告 **重要提醒:此 API 目前处于 Beta 测试阶段,在后续更新中可能出现不兼容的更改,使用时请密切关注相关更新通知。** + +**目前暂无提供监听信息发送等的 API 的想法,请自行根据 cgiId 判断实现相关监听。** + ## 目录 1. [钩子函数](#钩子函数) - - [onRequest](#onrequest) - - [onResponse](#onresponse) + - [onRequest](#onrequest) + - [onResponse](#onresponse) + - [数据对象说明](#数据对象说明) 2. [WEKit 对象](#wekit-对象) - - [概述](#概述) - - [WEKit Log 函数](#wekit-log-函数) + - [概述](#概述) + - [WEKit Log 函数](#wekit-log-函数) + - [WEKit isMMAtLeast 函数](#wekit-ismmatleast-函数) + - [WEKit sendCgi 函数](#wekit-sendcgi-函数) + - [WEKit proto 对象](#wekit-proto-对象) + - [WEKit database 对象](#wekit-database-对象) + - [WEKit message 对象](#wekit-message-对象) +3. [注意事项](#注意事项) +4. [后续更新计划](#后续更新计划) ## 钩子函数 @@ -46,16 +57,22 @@ function onResponse(data) { } ``` -## 数据对象说明 +### 数据对象说明 每个钩子函数接收一个 `data` 参数,该参数是一个对象,包含以下字段: -| 字段名 | 类型 | 描述 | -|----------|--------|------------------------------| -| uri | string | 请求的目标 URI 地址 | +| 字段名 | 类型 | 描述 | +| -------- | ------ | ------------------------------- | +| uri | string | 请求的目标 URI 地址 | | cgiId | string | 请求的 CGI ID,用于识别请求类型 | | jsonData | object | 请求或响应的数据体(JSON 格式) | +**重要说明:** + +- 如果想要修改响应或者发送请求,**请返回修改后的 JSON 对象**,而不是直接修改 `data.jsonData` +- **不要直接修改传入的 data.jsonData 对象** +- **如果未做任何修改,直接 return null 或不 return**,返回的 `jsonData` 将保持原始状态 + ## WEKit 对象 ### 概述 @@ -84,11 +101,565 @@ function onRequest(data) { } ``` -### 注意事项 +### WEKit isMMAtLeast 函数 + +`wekit.isMMAtLeast` 是 `wekit` 对象提供的版本检查函数。 + +#### 使用方法 + +```javascript +wekit.isMMAtLeast(field); +``` + +#### 参数说明 + +| 参数名 | 类型 | 描述 | +| ------ | ------ | -------------------------- | +| field | string | `MMVersion` 提供的版本常量 | + +#### 返回值 + +| 类型 | 描述 | +| ------- | ----------------------------------------- | +| boolean | 如果当前版本号大于等于指定版本则返回 true | + +#### 示例 + +```javascript +function onRequest(data) { + if (wekit.isMMAtLeast("MM_8_0_67")) { + wekit.log("当前版本高于或等于指定版本"); + } else { + wekit.log("当前版本低于指定版本"); + } +} +``` + +### WEKit sendCgi 函数 + +`wekit.sendCgi` 是 `wekit` 对象提供的发送异步无返回值的 CGI 请求函数。 + +#### 使用方法 + +```javascript +wekit.sendCgi(uri, cgiId, funcId, routeId, jsonPayload); +``` + +#### 参数说明 + +| 参数名 | 类型 | 描述 | +| ----------- | ------ | --------------------------- | +| uri | string | 请求的目标 URI 地址 | +| cgiId | number | 请求的 CGI ID | +| funcId | number | 功能 ID | +| routeId | number | 路由 ID | +| jsonPayload | string | 请求的数据体(JSON 字符串) | + +#### 示例 + +```javascript +function onRequest(data) { + wekit.log('准备发送 CGI 请求'); + wekit.sendCgi('/cgi-bin/micromsg-bin/newgetcontact', 12345, 1, 1, JSON.stringify(data.jsonData)); +} +``` + +### WEKit proto 对象 + +`wekit.proto` 是 `wekit` 对象提供的处理 JSON 数据的工具对象。 + +#### wekit.proto.replaceUtf8ContainsInJson + +在 JSON 数据中替换包含指定字符串的内容。 + +```javascript +wekit.proto.replaceUtf8ContainsInJson(json, needle, replacement); +``` + +##### 参数说明 + +| 参数名 | 类型 | 描述 | +| ----------- | ------ | ------------------ | +| json | object | 要处理的 JSON 对象 | +| needle | string | 要查找的字符串 | +| replacement | string | 替换字符串 | + +##### 返回值 + +| 类型 | 描述 | +| ------ | ------------------ | +| object | 处理后的 JSON 对象 | + +##### 示例 + +```javascript +function onRequest(data) { + const modifiedJson = wekit.proto.replaceUtf8ContainsInJson( + data.jsonData, + "oldValue", + "newValue" + ); + return modifiedJson; +} +``` + +#### wekit.proto.replaceUtf8RegexInJson + +在 JSON 数据中使用正则表达式替换内容。 + +```javascript +wekit.proto.replaceUtf8RegexInJson(json, pattern, replacement); +``` + +##### 参数说明 + +| 参数名 | 类型 | 描述 | +| ----------- | ------ | ------------------ | +| json | object | 要处理的 JSON 对象 | +| pattern | string | 正则表达式字符串 | +| replacement | string | 替换字符串 | + +##### 返回值 + +| 类型 | 描述 | +| ------ | ------------------ | +| object | 处理后的 JSON 对象 | + +##### 示例 + +```javascript +function onResponse(data) { + const modifiedJson = wekit.proto.replaceUtf8RegexInJson( + data.jsonData, + "\\d+", + "REPLACED" + ); + return modifiedJson; +} +``` + +### WEKit database 对象 + +`wekit.database` 是 `wekit` 对象提供的数据库操作工具对象。 + +#### wekit.database.query + +执行 SQL 查询语句。 + +```javascript +wekit.database.query(sql); +``` + +##### 参数说明 + +| 参数名 | 类型 | 描述 | +| ------ | ------ | ------------ | +| sql | string | SQL 查询语句 | + +##### 返回值 + +| 类型 | 描述 | +| ----- | -------------------- | +| array | 查询结果的 JSON 数组 | + +##### 示例 + +```javascript +function onRequest(data) { + const result = wekit.database.query("SELECT * FROM contacts WHERE nickname LIKE '%张%'"); + wekit.log('查询结果:', result); +} +``` + +#### wekit.database.getAllContacts + +获取所有联系人列表。 + +```javascript +wekit.database.getAllContacts(); +``` + +##### 返回值 + +| 类型 | 描述 | +| ----- | ---------------------- | +| array | 所有联系人的 JSON 数组 | + +##### 示例 + +```javascript +function onRequest(data) { + const contacts = wekit.database.getAllContacts(); + wekit.log('所有联系人:', contacts); +} +``` + +#### wekit.database.getContactList + +获取好友列表。 + +```javascript +wekit.database.getContactList(); +``` + +##### 返回值 + +| 类型 | 描述 | +| ----- | -------------------- | +| array | 好友列表的 JSON 数组 | + +##### 示例 + +```javascript +function onRequest(data) { + const friends = wekit.database.getContactList(); + wekit.log('好友列表:', friends); +} +``` + +#### wekit.database.getChatrooms + +获取群聊列表。 + +```javascript +wekit.database.getChatrooms(); +``` + +##### 返回值 + +| 类型 | 描述 | +| ----- | -------------------- | +| array | 群聊列表的 JSON 数组 | + +##### 示例 + +```javascript +function onRequest(data) { + const chatrooms = wekit.database.getChatrooms(); + wekit.log('群聊列表:', chatrooms); +} +``` + +#### wekit.database.getOfficialAccounts + +获取公众号列表。 + +```javascript +wekit.database.getOfficialAccounts(); +``` + +##### 返回值 + +| 类型 | 描述 | +| ----- | ---------------------- | +| array | 公众号列表的 JSON 数组 | + +##### 示例 + +```javascript +function onRequest(data) { + const accounts = wekit.database.getOfficialAccounts(); + wekit.log('公众号列表:', accounts); +} +``` + +#### wekit.database.getMessages + +获取指定用户的聊天记录。 + +```javascript +wekit.database.getMessages(wxid, page, pageSize); +``` + +##### 参数说明 + +| 参数名 | 类型 | 描述 | +| -------- | ------ | --------------------------- | +| wxid | string | 用户的微信 ID | +| page | number | 页码(可选,默认为 1) | +| pageSize | number | 每页大小(可选,默认为 20) | + +##### 返回值 + +| 类型 | 描述 | +| ----- | -------------------- | +| array | 聊天记录的 JSON 数组 | + +##### 示例 + +```javascript +function onRequest(data) { + const messages = wekit.database.getMessages('wxid_123456', 1, 50); + wekit.log('聊天记录:', messages); +} +``` + +#### wekit.database.getAvatarUrl + +获取用户的头像 URL。 + +```javascript +wekit.database.getAvatarUrl(wxid); +``` + +##### 参数说明 + +| 参数名 | 类型 | 描述 | +| ------ | ------ | ------------- | +| wxid | string | 用户的微信 ID | + +##### 返回值 + +| 类型 | 描述 | +| ------ | -------------- | +| string | 用户的头像 URL | + +##### 示例 + +```javascript +function onRequest(data) { + const avatarUrl = wekit.database.getAvatarUrl('wxid_123456'); + wekit.log('用户头像:', avatarUrl); +} +``` + +#### wekit.database.getGroupMembers + +获取群成员列表。 + +```javascript +wekit.database.getGroupMembers(chatroomId); +``` + +##### 参数说明 + +| 参数名 | 类型 | 描述 | +| ---------- | ------ | ------------- | +| chatroomId | string | 群聊的微信 ID | + +##### 返回值 + +| 类型 | 描述 | +| ----- | ---------------------- | +| array | 群成员列表的 JSON 数组 | + +##### 示例 + +```javascript +function onRequest(data) { + const members = wekit.database.getGroupMembers('group_123456@chatroom'); + wekit.log('群成员列表:', members); +} +``` + +### WEKit message 对象 + +`wekit.message` 是 `wekit` 对象提供的微信消息发送工具对象。 + +#### wekit.message.sendText + +发送文本消息。 + +```javascript +wekit.message.sendText(toUser, text); +``` + +##### 参数说明 + +| 参数名 | 类型 | 描述 | +| ------ | ------ | ---------- | +| toUser | string | 目标用户ID | +| text | string | 消息内容 | + +##### 返回值 + +| 类型 | 描述 | +| ------- | --------------------------------- | +| boolean | 发送成功返回 true,否则返回 false | + +##### 示例 + +```javascript +function onRequest(data) { + const success = wekit.message.sendText('wxid_123456', 'Hello World!'); + if (success) { + wekit.log('文本消息发送成功'); + } else { + wekit.log('文本消息发送失败'); + } +} +``` + +#### wekit.message.sendImage + +发送图片消息。 + +```javascript +wekit.message.sendImage(toUser, imgPath); +``` + +##### 参数说明 + +| 参数名 | 类型 | 描述 | +| ------- | ------ | ---------- | +| toUser | string | 目标用户ID | +| imgPath | string | 图片路径 | + +##### 返回值 + +| 类型 | 描述 | +| ------- | --------------------------------- | +| boolean | 发送成功返回 true,否则返回 false | + +##### 示例 + +```javascript +function onRequest(data) { + const success = wekit.message.sendImage('wxid_123456', '/sdcard/DCIM/screenshot.png'); + if (success) { + wekit.log('图片消息发送成功'); + } else { + wekit.log('图片消息发送失败'); + } +} +``` + +#### wekit.message.sendFile + +发送文件消息。 + +```javascript +wekit.message.sendFile(talker, filePath, title, appid); +``` + +##### 参数说明 + +| 参数名 | 类型 | 描述 | +| -------- | ------ | -------------- | +| talker | string | 目标用户ID | +| filePath | string | 文件路径 | +| title | string | 文件标题 | +| appid | string | 应用ID(可选) | + +##### 返回值 + +| 类型 | 描述 | +| ------- | --------------------------------- | +| boolean | 发送成功返回 true,否则返回 false | + +##### 示例 + +```javascript +function onRequest(data) { + const success = wekit.message.sendFile('wxid_123456', '/sdcard/Documents/file.pdf', '文档文件', ''); + if (success) { + wekit.log('文件消息发送成功'); + } else { + wekit.log('文件消息发送失败'); + } +} +``` + +#### wekit.message.sendVoice + +发送语音消息。 + +```javascript +wekit.message.sendVoice(toUser, path, durationMs); +``` + +##### 参数说明 + +| 参数名 | 类型 | 描述 | +| ---------- | ------ | ---------------- | +| toUser | string | 目标用户ID | +| path | string | 语音文件路径 | +| durationMs | number | 语音时长(毫秒) | + +##### 返回值 + +| 类型 | 描述 | +| ------- | --------------------------------- | +| boolean | 发送成功返回 true,否则返回 false | + +##### 示例 + +```javascript +function onRequest(data) { + const success = wekit.message.sendVoice('wxid_123456', '/sdcard/Recordings/audio.amr', 5000); + if (success) { + wekit.log('语音消息发送成功'); + } else { + wekit.log('语音消息发送失败'); + } +} +``` + +#### wekit.message.sendXmlAppMsg + +发送XML应用消息。 + +```javascript +wekit.message.sendXmlAppMsg(toUser, xmlContent); +``` + +##### 参数说明 + +| 参数名 | 类型 | 描述 | +| ---------- | ------ | ---------- | +| toUser | string | 目标用户ID | +| xmlContent | string | XML内容 | + +##### 返回值 + +| 类型 | 描述 | +| ------- | --------------------------------- | +| boolean | 发送成功返回 true,否则返回 false | + +##### 示例 + +```javascript +function onRequest(data) { + const xmlContent = '分享链接'; + const success = wekit.message.sendXmlAppMsg('wxid_123456', xmlContent); + if (success) { + wekit.log('XML应用消息发送成功'); + } else { + wekit.log('XML应用消息发送失败'); + } +} +``` + +#### wekit.message.getSelfAlias + +获取当前用户的微信号。 + +```javascript +wekit.message.getSelfAlias(); +``` + +##### 返回值 + +| 类型 | 描述 | +| ------ | ---------------- | +| string | 当前用户的微信号 | + +##### 示例 + +```javascript +function onRequest(data) { + const alias = wekit.message.getSelfAlias(); + wekit.log('当前用户微信号:', alias); +} +``` + +## 注意事项 1. 日志输出将显示在脚本日志查看器中 2. 当 API 发生变更时,请及时更新相关脚本代码 +3. 所有工具方法都应在脚本环境中可用 +4. 钩子函数中修改数据时,请遵循返回值规则,不要直接修改传入参数 -### 后续更新计划 +## 后续更新计划 我们可能在稳定版本中提供更加完善和一致的日志功能接口,届时会提供更详细的文档和更稳定的 API 设计。 \ No newline at end of file diff --git a/app/src/main/java/moe/ouom/wekit/hooks/item/script/ScriptConfigHookItem.kt b/app/src/main/java/moe/ouom/wekit/hooks/item/script/ScriptConfigHookItem.kt index 88672cf..c4b7e2b 100644 --- a/app/src/main/java/moe/ouom/wekit/hooks/item/script/ScriptConfigHookItem.kt +++ b/app/src/main/java/moe/ouom/wekit/hooks/item/script/ScriptConfigHookItem.kt @@ -1,41 +1,37 @@ package moe.ouom.wekit.hooks.item.script import android.content.Context -import android.util.Base64 -import android.view.Gravity -import android.widget.LinearLayout -import android.widget.TextView -import androidx.appcompat.widget.AppCompatEditText -import androidx.core.content.ContextCompat -import com.afollestad.materialdialogs.MaterialDialog -import com.afollestad.materialdialogs.customview.customView -import com.afollestad.materialdialogs.list.listItems -import com.google.android.material.button.MaterialButton import moe.ouom.wekit.core.model.BaseClickableFunctionHookItem import moe.ouom.wekit.hooks.core.annotation.HookItem +import moe.ouom.wekit.hooks.sdk.protocol.WePkgHelper import moe.ouom.wekit.hooks.sdk.protocol.WePkgManager import moe.ouom.wekit.hooks.sdk.protocol.intf.IWePkgInterceptor -import moe.ouom.wekit.ui.CommonContextWrapper -import moe.ouom.wekit.ui.creator.dialog.BaseSettingsDialog +import moe.ouom.wekit.ui.creator.dialog.ScriptManagerDialog import moe.ouom.wekit.util.WeProtoData -import moe.ouom.wekit.util.common.Toasts.showToast import moe.ouom.wekit.util.log.WeLogger +import moe.ouom.wekit.util.script.JsExecutor import moe.ouom.wekit.util.script.ScriptEvalManager import moe.ouom.wekit.util.script.ScriptFileManager -import org.json.JSONObject -import java.text.SimpleDateFormat -import java.util.* /** - * 脚本配置管理器Hook项(包含对话框) + * 脚本配置管理器Hook项 */ @HookItem( path = "脚本管理/脚本开关", - desc = "管理JavaScript脚本配置" + desc = "点击管理JavaScript脚本配置" ) class ScriptConfigHookItem : BaseClickableFunctionHookItem(), IWePkgInterceptor { + private fun sendCgi(uri: String, cgiId: Int, funcId: Int, routeId: Int, jsonPayload: String) { + WePkgHelper.INSTANCE?.sendCgi(uri, cgiId, funcId, routeId, jsonPayload) { + onSuccess { json, bytes -> WeLogger.e("异步CGI请求成功:回包: $json") } + onFail { type, code, msg -> WeLogger.e("异步CGI请求失败: $type, $code, $msg") } + } + } + override fun entry(classLoader: ClassLoader) { + // 注入脚本接口 + JsExecutor.getInstance().injectScriptInterfaces(::sendCgi, WeProtoUtils, WeDataBaseUtils, WeMessageUtils) // 注册拦截器 WePkgManager.addInterceptor(this) } @@ -88,715 +84,4 @@ class ScriptConfigHookItem : BaseClickableFunctionHookItem(), IWePkgInterceptor ScriptManagerDialog(context, scriptManager, jsEvalManager).show() } - /** - * 脚本管理器对话框 - */ - inner class ScriptManagerDialog( - context: Context, - private val scriptManager: ScriptFileManager, - private val scriptEvalManager: ScriptEvalManager - ) : BaseSettingsDialog(context, "脚本管理器") { - - private val scripts = mutableListOf() - private val dateFormat = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.getDefault()) - - override fun initList() { - contentContainer.removeAllViews() - loadScripts() - renderScriptList() - } - - private fun loadScripts() { - scripts.clear() - scripts.addAll(scriptManager.getAllScripts()) - } - - private fun renderScriptList() { - if (scripts.isEmpty()) { - renderEmptyView() - renderActionButtons() - return - } - - scripts.sortBy { it.order } - scripts.forEachIndexed { index, script -> - renderScriptItem(index, script) - } - - renderActionButtons() - } - - private fun renderEmptyView() { - val emptyView = TextView(context).apply { - text = "暂无脚本,点击下方按钮添加" - gravity = Gravity.CENTER - textSize = 16f - setPadding(32, 64, 32, 64) - setTextColor(ContextCompat.getColor(context, android.R.color.darker_gray)) - } - contentContainer.addView(emptyView) - } - - private fun renderScriptItem(index: Int, script: ScriptFileManager.ScriptConfig) { - val itemLayout = LinearLayout(context).apply { - orientation = LinearLayout.VERTICAL - setPadding(32, 16, 32, 16) - setBackgroundResource(android.R.color.transparent) - } - - // 标题行 - val titleRow = LinearLayout(context).apply { - orientation = LinearLayout.HORIZONTAL - layoutParams = LinearLayout.LayoutParams( - LinearLayout.LayoutParams.MATCH_PARENT, - LinearLayout.LayoutParams.WRAP_CONTENT - ) - } - - val tvTitle = TextView(context).apply { - text = "${index + 1}. ${script.name}" - textSize = 16f - layoutParams = LinearLayout.LayoutParams( - 0, - LinearLayout.LayoutParams.WRAP_CONTENT, - 1f - ) - } - - val cbEnabled = androidx.appcompat.widget.AppCompatCheckBox(context).apply { - isChecked = script.enabled - setOnCheckedChangeListener { _, isChecked -> - script.enabled = isChecked - scriptManager.saveScript(script) - showToast(context, if (isChecked) "已启用" else "已禁用") - } - } - - titleRow.addView(tvTitle) - titleRow.addView(cbEnabled) - - // 脚本信息行 (UUID和创建时间) - val infoRow = LinearLayout(context).apply { - orientation = LinearLayout.VERTICAL - setPadding(0, 4, 0, 0) - } - - // UUID显示 - val tvUuid = TextView(context).apply { - text = "ID: ${script.id}..." - textSize = 10f - setTextColor(ContextCompat.getColor(context, android.R.color.darker_gray)) - } - - // 时间信息 - val tvTime = TextView(context).apply { - val createdTime = dateFormat.format(Date(script.createdTime)) - val modifiedTime = dateFormat.format(Date(script.modifiedTime)) - text = "创建: $createdTime | 修改: $modifiedTime" - textSize = 10f - setTextColor(ContextCompat.getColor(context, android.R.color.darker_gray)) - } - - infoRow.addView(tvUuid) - infoRow.addView(tvTime) - - // 描述和预览 - if (script.description.isNotEmpty()) { - val tvDesc = TextView(context).apply { - text = "描述: ${script.description}" - textSize = 12f - setTextColor(ContextCompat.getColor(context, android.R.color.darker_gray)) - setPadding(0, 4, 0, 0) - } - itemLayout.addView(tvDesc) - } - - // 操作按钮行 - val buttonRow = LinearLayout(context).apply { - orientation = LinearLayout.HORIZONTAL - layoutParams = LinearLayout.LayoutParams( - LinearLayout.LayoutParams.MATCH_PARENT, - LinearLayout.LayoutParams.WRAP_CONTENT - ).apply { - topMargin = 8 - } - } - - val btnCopy = MaterialButton(context).apply { - text = "复制" - layoutParams = LinearLayout.LayoutParams( - 0, - LinearLayout.LayoutParams.WRAP_CONTENT, - 1f - ).apply { - marginEnd = 8 - } - setOnClickListener { - copyFullScriptInfo(script) - } - } - - val btnActions = MaterialButton(context).apply { - text = "操作" - layoutParams = LinearLayout.LayoutParams( - 0, - LinearLayout.LayoutParams.WRAP_CONTENT, - 1f - ).apply { - marginStart = 8 - } - setOnClickListener { - showActionMenu(index, script) - } - } - - buttonRow.addView(btnCopy) - buttonRow.addView(btnActions) - - itemLayout.addView(titleRow) - itemLayout.addView(infoRow) - itemLayout.addView(buttonRow) - - contentContainer.addView(itemLayout) - - if (index < scripts.size - 1) { - val divider = TextView(context).apply { - setBackgroundColor(ContextCompat.getColor(context, android.R.color.darker_gray)) - layoutParams = LinearLayout.LayoutParams( - LinearLayout.LayoutParams.MATCH_PARENT, - 1 - ).apply { - setMargins(32, 16, 32, 16) - } - } - contentContainer.addView(divider) - } - } - - /** - * 显示操作菜单 - */ - private fun showActionMenu(index: Int, script: ScriptFileManager.ScriptConfig) { - val wrappedContext = CommonContextWrapper.createAppCompatContext(context) - val options = mutableListOf() - val isTop = index == 0 - val isBottom = index == scripts.size - 1 - - options.add("上移") - options.add("下移") - options.add("编辑") - options.add("删除") - options.add("测试") - - MaterialDialog(wrappedContext) - .title(text = "脚本操作") - .listItems(items = options) { dialog, optionIndex, _ -> - when (optionIndex) { - 0 -> { - // 上移 - if (isTop) { - showToast(context, "已在顶部,无法上移") - return@listItems - } - moveScriptUp(index) - } - - 1 -> { - // 下移 - if (isBottom) { - showToast(context, "已在底部,无法下移") - return@listItems - } - moveScriptDown(index) - } - - 2 -> showEditDialog(index) - 3 -> confirmDeleteScript(index) - 4 -> showTestDialog(index) - } - - dialog.dismiss() - } - .negativeButton(text = "取消") - .show() - } - - /** - * 复制完整的脚本信息 - */ - private fun copyFullScriptInfo(script: ScriptFileManager.ScriptConfig) { - val contentEncoded = Base64.encodeToString(script.content.toByteArray(Charsets.UTF_8), Base64.DEFAULT) - val jsonObject = JSONObject().apply { - put("name", script.name) - put("id", script.id) - put("description", script.description) - put("content", contentEncoded) - } - - copyToClipboard("脚本JSON", jsonObject.toString(2)) - showToast(context, "已复制脚本JSON格式") - } - - private fun renderActionButtons() { - val buttonLayout = LinearLayout(context).apply { - orientation = LinearLayout.HORIZONTAL - gravity = Gravity.CENTER - setPadding(32, 24, 32, 32) - } - - val btnAdd = MaterialButton(context).apply { - text = "添加脚本" - layoutParams = LinearLayout.LayoutParams( - LinearLayout.LayoutParams.WRAP_CONTENT, - LinearLayout.LayoutParams.WRAP_CONTENT - ).apply { - marginEnd = 16 - } - setOnClickListener { - showAddDialog() - } - } - - val btnImport = MaterialButton(context).apply { - text = "导入" - layoutParams = LinearLayout.LayoutParams( - LinearLayout.LayoutParams.WRAP_CONTENT, - LinearLayout.LayoutParams.WRAP_CONTENT - ) - setOnClickListener { - showImportOptions() - } - } - - buttonLayout.addView(btnAdd) - buttonLayout.addView(btnImport) - contentContainer.addView(buttonLayout) - } - - private fun showAddDialog() { - showEditDialog(-1) - } - - private fun showEditDialog(index: Int) { - val isNew = index == -1 - val script = if (isNew) { - val newId = UUID.randomUUID().toString() - ScriptFileManager.ScriptConfig( - id = newId, - name = "", - content = "", - order = scripts.size - ) - } else { - scripts.getOrNull(index) ?: return - } - - val wrappedContext = CommonContextWrapper.createAppCompatContext(context) - - val dialogView = LinearLayout(context).apply { - orientation = LinearLayout.VERTICAL - setPadding(32, 32, 32, 32) - } - - // UUID显示 - val tvUuidLabel = TextView(context).apply { - text = "脚本ID: ${script.id}" - textSize = 12f - setTextColor(ContextCompat.getColor(context, android.R.color.darker_gray)) - setPadding(0, 0, 0, 8) - } - - val tvNameLabel = TextView(context).apply { - text = "脚本名称:" - textSize = 14f - setPadding(0, 8, 0, 8) - } - - val etName = AppCompatEditText(context).apply { - hint = "输入脚本名称" - setText(script.name) - textSize = 14f - } - - val tvDescLabel = TextView(context).apply { - text = "描述(可选):" - textSize = 14f - setPadding(0, 16, 0, 8) - } - - val etDesc = AppCompatEditText(context).apply { - hint = "输入脚本描述" - setText(script.description) - textSize = 14f - } - - val tvContentLabel = TextView(context).apply { - text = "脚本内容 (JavaScript):" - textSize = 14f - setPadding(0, 16, 0, 8) - } - - val etContent = AppCompatEditText(context).apply { - hint = "输入JavaScript代码..." - setText(script.content) - minLines = 10 - maxLines = 20 - gravity = Gravity.START or Gravity.TOP - } - - dialogView.addView(tvUuidLabel) - dialogView.addView(tvNameLabel) - dialogView.addView(etName) - dialogView.addView(tvDescLabel) - dialogView.addView(etDesc) - dialogView.addView(tvContentLabel) - dialogView.addView(etContent) - - MaterialDialog(wrappedContext) - .customView(view = dialogView) - .title(text = if (isNew) "添加脚本" else "编辑脚本") - .positiveButton(text = "保存") { - val name = etName.text.toString().trim() - val description = etDesc.text.toString().trim() - val content = etContent.text.toString().trim() - - if (name.isEmpty()) { - showToast(context, "请输入脚本名称") - return@positiveButton - } - - if (content.isEmpty()) { - showToast(context, "请输入脚本内容") - return@positiveButton - } - - saveScript(script, name, description, content, isNew) - } - .neutralButton(text = "复制脚本内容") { - val content = etContent.text.toString().trim() - if (content.isNotEmpty()) { - copyToClipboard("脚本内容", content) - showToast(context, "已复制脚本内容") - } else { - showToast(context, "脚本内容为空") - } - } - .negativeButton(text = "取消") - .show() - } - - /** - * 显示测试对话框 - */ - private fun showTestDialog(index: Int) { - val script = scripts.getOrNull(index) ?: return - val wrappedContext = CommonContextWrapper.createAppCompatContext(context) - - val dialogView = LinearLayout(context).apply { - orientation = LinearLayout.VERTICAL - setPadding(32, 32, 32, 32) - } - - val tvScriptName = TextView(context).apply { - text = "测试脚本: ${script.name}" - textSize = 16f - setPadding(0, 0, 0, 16) - } - - val tvInputLabel = TextView(context).apply { - text = "输入要执行的JavaScript代码:" - textSize = 14f - setPadding(0, 0, 0, 8) - } - - val etInput = AppCompatEditText(context).apply { - hint = "例如: wekit.log(onRequest({uri: 'uri',cgiId: 0,jsonData: {}}));" - textSize = 14f - minLines = 5 - maxLines = 10 - gravity = Gravity.START or Gravity.TOP - } - - dialogView.addView(tvScriptName) - dialogView.addView(tvInputLabel) - dialogView.addView(etInput) - - MaterialDialog(wrappedContext) - .customView(view = dialogView) - .title(text = "测试脚本执行") - .positiveButton(text = "执行") { - val jsCode = etInput.text.toString().trim() - if (jsCode.isNotEmpty()) { - executeTestScript(script, jsCode) - } else { - showToast(context, "请输入要执行的JavaScript代码") - } - } - .negativeButton(text = "取消") - .show() - } - - /** - * 执行测试脚本 - */ - private fun executeTestScript(script: ScriptFileManager.ScriptConfig, jsCode: String) { - val wrappedContext = CommonContextWrapper.createAppCompatContext(context) - - val result = scriptEvalManager.testExecuteCode(script.content, jsCode, "${script.name}+测试脚本") - MaterialDialog(wrappedContext) - .title(text = "执行结果") - .message(text = result ?: "执行失败或无返回值") - .positiveButton(text = "复制结果") { - copyToClipboard("执行结果", result ?: "执行失败") - } - .negativeButton(text = "关闭") - .show() - } - - private fun saveScript( - script: ScriptFileManager.ScriptConfig, - name: String, - description: String, - content: String, - isNew: Boolean - ) { - // 检测脚本是否正常 - val testResult = scriptEvalManager.testScriptMethods(content) - if (!testResult.isPassed()) { - val errorMsg = testResult.getSummary() - showToast(context, "脚本验证失败,请修正后保存") - - // 显示详细错误信息 - val wrappedContext = CommonContextWrapper.createAppCompatContext(context) - MaterialDialog(wrappedContext) - .title(text = "脚本验证失败") - .message(text = errorMsg) - .positiveButton(text = "返回编辑") { - showEditDialog(if (isNew) -1 else scripts.indexOf(script)) - } - .negativeButton(text = "取消") - .show() - return - } - - script.name = name - script.description = description - script.content = content - script.modifiedTime = System.currentTimeMillis() - - if (isNew) { - script.createdTime = System.currentTimeMillis() - scripts.add(script) - scriptManager.saveScript(script) - showToast(context, "脚本已添加") - } else { - scriptManager.saveScript(script) - showToast(context, "脚本已更新") - } - - contentContainer.removeAllViews() - renderScriptList() - } - - private fun showImportOptions() { - val wrappedContext = CommonContextWrapper.createAppCompatContext(context) - val options = listOf("从剪贴板导入", "导入示例") - - MaterialDialog(wrappedContext) - .title(text = "导入脚本") - .listItems(items = options) { dialog, optionIndex, _ -> - when (optionIndex) { - 0 -> importFromClipboard() - 1 -> importExample() - } - dialog.dismiss() - } - .negativeButton(text = "取消") - .show() - } - - private fun importFromClipboard() { - try { - val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as? android.content.ClipboardManager - val clipData = clipboard?.primaryClip - - if (clipData != null && clipData.itemCount > 0) { - val clipItem = clipData.getItemAt(0) - val text = clipItem.text?.toString() - - if (!text.isNullOrBlank()) { - // 尝试解析为JSON格式 - val jsonObject = JSONObject(text) - - // 验证必需字段 - if (jsonObject.has("name") && jsonObject.has("id") && jsonObject.has("description") && jsonObject.has( - "content" - ) - ) { - val newName = jsonObject.optString("name", "从剪贴板导入") - val newId = jsonObject.optString("id", UUID.randomUUID().toString()) - val newDescription = jsonObject.optString("description", "") - val contentEncoded = jsonObject.optString("content", "") - - // 解码内容 - val contentBytes = Base64.decode(contentEncoded, Base64.NO_WRAP) - val newContent = String(contentBytes, Charsets.UTF_8) - - // 检查是否已存在相同ID的脚本 - val existingScript = scripts.find { it.id == newId } - if (existingScript != null) { - // 覆盖现有脚本 - existingScript.name = newName - existingScript.content = newContent - existingScript.description = newDescription - existingScript.modifiedTime = System.currentTimeMillis() - - scriptManager.saveScript(existingScript) - showToast(context, "已覆盖相同ID的脚本") - - contentContainer.removeAllViews() - renderScriptList() - return - } - - val newScript = ScriptFileManager.ScriptConfig( - id = newId, - name = newName, - content = newContent, - description = newDescription, - order = scripts.size - ) - - scriptManager.saveScript(newScript) - scripts.add(newScript) - - contentContainer.removeAllViews() - renderScriptList() - showToast(context, "已从剪贴板导入脚本") - } else { - showToast(context, "剪贴板内容格式不正确,缺少必要字段(name, id, description, content)") - } - } else { - showToast(context, "剪贴板内容为空") - } - } else { - showToast(context, "剪贴板无内容") - } - } catch (e: Exception) { - WeLogger.e("从剪贴板导入失败: ${e.message}") - showToast(context, "导入失败,请确保剪贴板内容为有效的JSON格式") - } - } - - private fun importExample() { - val exampleScripts = listOf( - ScriptFileManager.ScriptConfig( - id = UUID.randomUUID().toString(), - name = "打印示例", - content = """ - // 这是一个简单的示例脚本 将打印参数 - function onRequest(data) { - const {uri,cgiId,jsonData} = data; - wekit.log('拦截请求:', uri, cgiId, JSON.stringify(jsonData)); - return jsonData; - } - - function onResponse(data) { - const {uri,cgiId,jsonData} = data; - wekit.log('拦截响应:', uri, cgiId, JSON.stringify(jsonData)); - return jsonData; - } - """.trimIndent(), - description = "最简单的示例脚本,包含onRequest和onResponse方法", - order = scripts.size - ) - ) - - exampleScripts.forEach { script -> - scriptManager.saveScript(script) - scripts.add(script) - } - - contentContainer.removeAllViews() - renderScriptList() - showToast(context, "已导入示例脚本") - } - - private fun confirmDeleteScript(index: Int) { - val script = scripts.getOrNull(index) ?: return - val wrappedContext = CommonContextWrapper.createAppCompatContext(context) - - MaterialDialog(wrappedContext) - .title(text = "确认删除") - .message(text = "确定要删除脚本「${script.name}」吗?\nID: ${script.id}") - .positiveButton(text = "删除") { - deleteScript(index) - } - .negativeButton(text = "取消") - .show() - } - - private fun deleteScript(index: Int) { - val script = scripts.getOrNull(index) ?: return - - if (scriptManager.deleteScript(script.id)) { - scripts.removeAt(index) - - scripts.forEachIndexed { i, s -> - s.order = i - scriptManager.saveScript(s) - } - - contentContainer.removeAllViews() - renderScriptList() - showToast(context, "脚本已删除") - } else { - showToast(context, "删除失败") - } - } - - private fun moveScriptUp(index: Int) { - if (index > 0) { - val temp = scripts[index] - scripts[index] = scripts[index - 1] - scripts[index - 1] = temp - - scripts.forEachIndexed { i, s -> - s.order = i - scriptManager.saveScript(s) - } - - contentContainer.removeAllViews() - renderScriptList() - } - } - - private fun moveScriptDown(index: Int) { - if (index < scripts.size - 1) { - val temp = scripts[index] - scripts[index] = scripts[index + 1] - scripts[index + 1] = temp - - scripts.forEachIndexed { i, s -> - s.order = i - scriptManager.saveScript(s) - } - - contentContainer.removeAllViews() - renderScriptList() - } - } - - private fun copyToClipboard(label: String, text: String) { - try { - val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as? android.content.ClipboardManager - val clip = android.content.ClipData.newPlainText(label, text) - clipboard?.setPrimaryClip(clip) - } catch (e: Exception) { - WeLogger.e("复制失败: ${e.message}") - } - } - - } - } \ No newline at end of file diff --git a/app/src/main/java/moe/ouom/wekit/hooks/item/script/WeDataBaseUtils.kt b/app/src/main/java/moe/ouom/wekit/hooks/item/script/WeDataBaseUtils.kt new file mode 100644 index 0000000..50c5a9e --- /dev/null +++ b/app/src/main/java/moe/ouom/wekit/hooks/item/script/WeDataBaseUtils.kt @@ -0,0 +1,160 @@ +@file:Suppress("unused") + +package moe.ouom.wekit.hooks.item.script + +import moe.ouom.wekit.hooks.sdk.api.WeDatabaseApi +import moe.ouom.wekit.util.log.WeLogger +import org.json.JSONArray +import org.json.JSONObject +import kotlin.collections.iterator + +object WeDataBaseUtils { + private const val TAG = "WeDataBaseUtils" + private val instance: WeDatabaseApi? by lazy { + try { + WeDatabaseApi.INSTANCE + } catch (e: Exception) { + WeLogger.e(TAG, "初始化 WeDatabaseApi 失败: ${e.message}") + null + } + } + + fun query(sql: String): Any { + return try { + instance?.executeQuery(sql)?.map { row -> + val jsonObject = JSONObject() + for ((key, value) in row) { + jsonObject.put(key, value) + } + jsonObject + }?.let { JSONArray(it) } ?: JSONArray() + } catch (e: Exception) { + WeLogger.e("WeDatabaseApi", "SQL执行异常: ${e.message}") + JSONArray() + } + } + + fun getAllContacts(): Any { + return try { + instance?.getAllConnects()?.map { contact -> + val jsonObject = JSONObject() + jsonObject.put("username", contact.username) + jsonObject.put("nickname", contact.nickname) + jsonObject.put("alias", contact.alias) + jsonObject.put("conRemark", contact.conRemark) + jsonObject.put("pyInitial", contact.pyInitial) + jsonObject.put("quanPin", contact.quanPin) + jsonObject.put("avatarUrl", contact.avatarUrl) + jsonObject.put("encryptUserName", contact.encryptUserName) + jsonObject + }?.let { JSONArray(it) } ?: JSONArray() + } catch (e: Exception) { + WeLogger.e("WeDatabaseApi", "获取联系人异常: ${e.message}") + JSONArray() + } + } + + fun getContactList(): Any { + return try { + instance?.getContactList()?.map { contact -> + val jsonObject = JSONObject() + jsonObject.put("username", contact.username) + jsonObject.put("nickname", contact.nickname) + jsonObject.put("alias", contact.alias) + jsonObject.put("conRemark", contact.conRemark) + jsonObject.put("pyInitial", contact.pyInitial) + jsonObject.put("quanPin", contact.quanPin) + jsonObject.put("avatarUrl", contact.avatarUrl) + jsonObject.put("encryptUserName", contact.encryptUserName) + jsonObject + }?.let { JSONArray(it) } ?: JSONArray() + } catch (e: Exception) { + WeLogger.e("WeDatabaseApi", "获取好友异常: ${e.message}") + JSONArray() + } + } + + fun getChatrooms(): Any { + return try { + instance?.getChatroomList()?.map { group -> + val jsonObject = JSONObject() + jsonObject.put("username", group.username) + jsonObject.put("nickname", group.nickname) + jsonObject.put("pyInitial", group.pyInitial) + jsonObject.put("quanPin", group.quanPin) + jsonObject.put("avatarUrl", group.avatarUrl) + jsonObject + }?.let { JSONArray(it) } ?: JSONArray() + } catch (e: Exception) { + WeLogger.e("WeDatabaseApi", "获取群聊异常: ${e.message}") + JSONArray() + } + } + + fun getOfficialAccounts(): Any { + return try { + instance?.getOfficialAccountList()?.map { account -> + val jsonObject = JSONObject() + jsonObject.put("username", account.username) + jsonObject.put("nickname", account.nickname) + jsonObject.put("alias", account.alias) + jsonObject.put("signature", account.signature) + jsonObject.put("avatarUrl", account.avatarUrl) + jsonObject + }?.let { JSONArray(it) } ?: JSONArray() + } catch (e: Exception) { + WeLogger.e("WeDatabaseApi", "获取公众号异常: ${e.message}") + JSONArray() + } + } + + fun getMessages(wxid: String, page: Int = 1, pageSize: Int = 20): Any { + return try { + if (wxid.isEmpty()) return JSONArray() + instance?.getMessages(wxid, page, pageSize)?.map { message -> + val jsonObject = JSONObject() + jsonObject.put("msgId", message.msgId) + jsonObject.put("talker", message.talker) + jsonObject.put("content", message.content) + jsonObject.put("type", message.type) + jsonObject.put("createTime", message.createTime) + jsonObject.put("isSend", message.isSend) + jsonObject + }?.let { JSONArray(it) } ?: JSONArray() + } catch (e: Exception) { + WeLogger.e("WeDatabaseApi", "获取消息异常: ${e.message}") + JSONArray() + } + } + + fun getAvatarUrl(wxid: String): String { + return try { + if (wxid.isEmpty()) return "" + instance?.getAvatarUrl(wxid) ?: "" + } catch (e: Exception) { + WeLogger.e("WeDatabaseApi", "获取头像异常: ${e.message}") + "" + } + } + + fun getGroupMembers(chatroomId: String): Any { + return try { + if (!chatroomId.endsWith("@chatroom")) return JSONArray() + instance?.getGroupMembers(chatroomId)?.map { member -> + val jsonObject = JSONObject() + jsonObject.put("username", member.username) + jsonObject.put("nickname", member.nickname) + jsonObject.put("alias", member.alias) + jsonObject.put("conRemark", member.conRemark) + jsonObject.put("pyInitial", member.pyInitial) + jsonObject.put("quanPin", member.quanPin) + jsonObject.put("avatarUrl", member.avatarUrl) + jsonObject.put("encryptUserName", member.encryptUserName) + jsonObject + }?.let { JSONArray(it) } ?: JSONArray() + } catch (e: Exception) { + WeLogger.e("WeDatabaseApi", "获取群成员异常: ${e.message}") + JSONArray() + } + } +} \ No newline at end of file diff --git a/app/src/main/java/moe/ouom/wekit/hooks/item/script/WeMessageUtils.kt b/app/src/main/java/moe/ouom/wekit/hooks/item/script/WeMessageUtils.kt new file mode 100644 index 0000000..8904b2a --- /dev/null +++ b/app/src/main/java/moe/ouom/wekit/hooks/item/script/WeMessageUtils.kt @@ -0,0 +1,109 @@ +@file:Suppress("unused") + +package moe.ouom.wekit.hooks.item.script + +import moe.ouom.wekit.hooks.sdk.api.WeMessageApi +import moe.ouom.wekit.util.log.WeLogger + +object WeMessageUtils { + private const val TAG = "WeMessageUtils" + private val instance: WeMessageApi? by lazy { + try { + WeMessageApi.INSTANCE + } catch (e: Exception) { + WeLogger.e(TAG, "初始化 WeMessageApi 失败: ${e.message}") + null + } + } + + /** + * 发送文本消息 + * @param toUser 目标用户ID + * @param text 消息内容 + * @return 是否发送成功 + */ + fun sendText(toUser: String, text: String): Boolean { + return try { + instance?.sendText(toUser, text) ?: false + } catch (e: Exception) { + WeLogger.e(TAG, "发送文本消息失败: ${e.message}") + false + } + } + + /** + * 发送图片消息 + * @param toUser 目标用户ID + * @param imgPath 图片路径 + * @return 是否发送成功 + */ + fun sendImage(toUser: String, imgPath: String): Boolean { + return try { + instance?.sendImage(toUser, imgPath) ?: false + } catch (e: Exception) { + WeLogger.e(TAG, "发送图片消息失败: ${e.message}") + false + } + } + + /** + * 发送文件消息 + * @param talker 目标用户ID + * @param filePath 文件路径 + * @param title 文件标题 + * @param appid 应用ID(可选) + * @return 是否发送成功 + */ + fun sendFile(talker: String, filePath: String, title: String, appid: String? = null): Boolean { + return try { + instance?.sendFile(talker, filePath, title, appid) ?: false + } catch (e: Exception) { + WeLogger.e(TAG, "发送文件消息失败: ${e.message}") + false + } + } + + /** + * 发送语音消息 + * @param toUser 目标用户ID + * @param path 语音文件路径 + * @param durationMs 语音时长(毫秒) + * @return 是否发送成功 + */ + fun sendVoice(toUser: String, path: String, durationMs: Int): Boolean { + return try { + instance?.sendVoice(toUser, path, durationMs) ?: false + } catch (e: Exception) { + WeLogger.e(TAG, "发送语音消息失败: ${e.message}") + false + } + } + + /** + * 发送XML应用消息 + * @param toUser 目标用户ID + * @param xmlContent XML内容 + * @return 是否发送成功 + */ + fun sendXmlAppMsg(toUser: String, xmlContent: String): Boolean { + return try { + instance?.sendXmlAppMsg(toUser, xmlContent) ?: false + } catch (e: Exception) { + WeLogger.e(TAG, "发送XML应用消息失败: ${e.message}") + false + } + } + + /** + * 获取当前用户ID + * @return 当前用户ID + */ + fun getSelfAlias(): String { + return try { + instance?.getSelfAlias() ?: "" + } catch (e: Exception) { + WeLogger.e(TAG, "获取当前用户ID失败: ${e.message}") + "" + } + } +} \ No newline at end of file diff --git a/app/src/main/java/moe/ouom/wekit/hooks/item/script/WeProtoUtils.kt b/app/src/main/java/moe/ouom/wekit/hooks/item/script/WeProtoUtils.kt new file mode 100644 index 0000000..8e82d1f --- /dev/null +++ b/app/src/main/java/moe/ouom/wekit/hooks/item/script/WeProtoUtils.kt @@ -0,0 +1,35 @@ +@file:Suppress("unused") + +package moe.ouom.wekit.hooks.item.script + +import moe.ouom.wekit.util.WeProtoData +import moe.ouom.wekit.util.log.WeLogger +import org.json.JSONObject +import java.util.regex.Pattern + +object WeProtoUtils { + fun replaceUtf8ContainsInJson(json: JSONObject, needle: String, replacement: String): JSONObject { + return try { + val protoData = WeProtoData() + protoData.fromJSON(json) + protoData.replaceUtf8Contains(needle, replacement) + protoData.toJSON() + } catch (e: Exception) { + WeLogger.e("Failed to replace in JSON: ${e.message}") + json + } + } + + fun replaceUtf8RegexInJson(json: JSONObject, pattern: String, replacement: String): JSONObject { + return try { + val protoData = WeProtoData() + protoData.fromJSON(json) + val regex = Pattern.compile(pattern) + protoData.replaceUtf8Regex(regex, replacement) + protoData.toJSON() + } catch (e: Exception) { + WeLogger.e("Failed to regex replace in JSON: ${e.message}") + json + } + } +} \ No newline at end of file diff --git a/app/src/main/java/moe/ouom/wekit/ui/creator/dialog/ScriptManagerDialog.kt b/app/src/main/java/moe/ouom/wekit/ui/creator/dialog/ScriptManagerDialog.kt new file mode 100644 index 0000000..e17e5cf --- /dev/null +++ b/app/src/main/java/moe/ouom/wekit/ui/creator/dialog/ScriptManagerDialog.kt @@ -0,0 +1,611 @@ +package moe.ouom.wekit.ui.creator.dialog + +import android.content.Context +import android.util.Base64 +import android.view.Gravity +import android.view.View +import android.widget.LinearLayout +import android.widget.TextView +import androidx.core.content.ContextCompat +import com.afollestad.materialdialogs.MaterialDialog +import com.afollestad.materialdialogs.customview.customView +import com.afollestad.materialdialogs.list.listItems +import com.google.android.material.button.MaterialButton +import com.google.android.material.checkbox.MaterialCheckBox +import moe.ouom.wekit.ui.CommonContextWrapper +import moe.ouom.wekit.util.common.ModuleRes +import moe.ouom.wekit.util.common.Toasts.showToast +import moe.ouom.wekit.util.log.WeLogger +import moe.ouom.wekit.util.script.ScriptEvalManager +import moe.ouom.wekit.util.script.ScriptFileManager +import org.json.JSONObject +import java.text.SimpleDateFormat +import java.util.* + +/** + * 脚本管理器对话框 + */ +class ScriptManagerDialog( + context: Context, + private val scriptManager: ScriptFileManager, + private val scriptEvalManager: ScriptEvalManager +) : BaseSettingsDialog(context, "脚本管理器") { + + private val scripts = mutableListOf() + private val dateFormat = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.getDefault()) + + override fun initList() { + contentContainer.removeAllViews() + loadScripts() + renderScriptList() + } + + private fun loadScripts() { + scripts.clear() + scripts.addAll(scriptManager.getAllScripts()) + } + + private fun renderScriptList() { + if (scripts.isEmpty()) { + renderEmptyView() + renderActionButtons() + return + } + + scripts.sortBy { it.order } + scripts.forEachIndexed { index, script -> + renderScriptItem(index, script) + } + + renderActionButtons() + } + + private fun renderEmptyView() { + val emptyView = TextView(context).apply { + text = "暂无脚本,点击下方按钮添加" + gravity = Gravity.CENTER + textSize = 16f + setPadding(32, 64, 32, 64) + setTextColor(ContextCompat.getColor(context, android.R.color.darker_gray)) + } + contentContainer.addView(emptyView) + } + + private fun renderScriptItem(index: Int, script: ScriptFileManager.ScriptConfig) { + // 使用 inflateItem 方式创建视图 + val view = inflateItem("script_item", contentContainer) ?: return + + // 获取视图组件 + val tvTitle = view.findViewById(ModuleRes.getId("title", "id")) + val tvUuid = view.findViewById(ModuleRes.getId("uuid", "id")) + val tvTime = view.findViewById(ModuleRes.getId("time", "id")) + val tvDesc = view.findViewById(ModuleRes.getId("description", "id")) + val cbEnabled = view.findViewById(ModuleRes.getId("enabled_checkbox", "id")) + val btnCopy = view.findViewById(ModuleRes.getId("copy_button", "id")) + val btnActions = view.findViewById(ModuleRes.getId("actions_button", "id")) + + // 设置标题 + tvTitle.text = "${index + 1}. ${script.name}" + + // 设置UUID + tvUuid.text = "ID: ${script.id}..." + + // 设置时间信息 + val createdTime = dateFormat.format(Date(script.createdTime)) + val modifiedTime = dateFormat.format(Date(script.modifiedTime)) + tvTime.text = "创建: $createdTime | 修改: $modifiedTime" + + // 设置描述 + if (script.description.isNotEmpty()) { + tvDesc.text = "描述: ${script.description}" + tvDesc.visibility = View.VISIBLE + } else { + tvDesc.visibility = View.GONE + } + + // 设置启用状态 + cbEnabled.isChecked = script.enabled + + // 设置启用状态监听器 + cbEnabled.setOnCheckedChangeListener { _, isChecked -> + script.enabled = isChecked + scriptManager.saveScript(script) + showToast(context, if (isChecked) "已启用" else "已禁用") + } + + // 设置复制按钮监听器 + btnCopy.setOnClickListener { + copyFullScriptInfo(script) + } + + // 设置操作按钮监听器 + btnActions.setOnClickListener { + showActionMenu(index, script) + } + + // 设置整个视图的点击监听器,进入脚本编辑 + view.setOnClickListener { + showEditDialog(index) + } + + contentContainer.addView(view) + + // 添加分隔线(如果不是最后一个) + if (index < scripts.size - 1) { + val divider = TextView(context).apply { + setBackgroundColor(ContextCompat.getColor(context, android.R.color.darker_gray)) + layoutParams = LinearLayout.LayoutParams( + LinearLayout.LayoutParams.MATCH_PARENT, + 1 + ).apply { + setMargins(32, 16, 32, 16) + } + } + contentContainer.addView(divider) + } + } + + /** + * 显示操作菜单 + */ + private fun showActionMenu(index: Int, script: ScriptFileManager.ScriptConfig) { + val wrappedContext = CommonContextWrapper.createAppCompatContext(context) + val options = mutableListOf() + val isTop = index == 0 + val isBottom = index == scripts.size - 1 + + options.add("上移") + options.add("下移") + options.add("编辑") + options.add("删除") + options.add("测试") + + MaterialDialog(wrappedContext) + .title(text = "脚本操作") + .listItems(items = options) { dialog, optionIndex, _ -> + when (optionIndex) { + 0 -> { + // 上移 + if (isTop) { + showToast(context, "已在顶部,无法上移") + return@listItems + } + moveScriptUp(index) + } + + 1 -> { + // 下移 + if (isBottom) { + showToast(context, "已在底部,无法下移") + return@listItems + } + moveScriptDown(index) + } + + 2 -> showEditDialog(index) + 3 -> confirmDeleteScript(index) + 4 -> showTestDialog(index) + } + + dialog.dismiss() + } + .negativeButton(text = "取消") + .show() + } + + /** + * 复制完整的脚本信息 + */ + private fun copyFullScriptInfo(script: ScriptFileManager.ScriptConfig) { + val contentEncoded = Base64.encodeToString(script.content.toByteArray(Charsets.UTF_8), Base64.DEFAULT) + val jsonObject = JSONObject().apply { + put("name", script.name) + put("id", script.id) + put("description", script.description) + put("content", contentEncoded) + } + + copyToClipboard("脚本JSON", jsonObject.toString(2)) + showToast(context, "已复制脚本JSON格式") + } + + private fun renderActionButtons() { + val buttonLayout = LinearLayout(context).apply { + orientation = LinearLayout.HORIZONTAL + gravity = Gravity.CENTER + setPadding(32, 24, 32, 32) + } + + val btnAdd = MaterialButton(context).apply { + text = "添加脚本" + layoutParams = LinearLayout.LayoutParams( + LinearLayout.LayoutParams.WRAP_CONTENT, + LinearLayout.LayoutParams.WRAP_CONTENT + ).apply { + marginEnd = 16 + } + setOnClickListener { + showAddDialog() + } + } + + val btnImport = MaterialButton(context).apply { + text = "导入" + layoutParams = LinearLayout.LayoutParams( + LinearLayout.LayoutParams.WRAP_CONTENT, + LinearLayout.LayoutParams.WRAP_CONTENT + ) + setOnClickListener { + showImportOptions() + } + } + + buttonLayout.addView(btnAdd) + buttonLayout.addView(btnImport) + contentContainer.addView(buttonLayout) + } + + private fun showAddDialog() { + showEditDialog(-1) + } + + private fun showEditDialog(index: Int) { + val isNew = index == -1 + val script = if (isNew) { + val newId = UUID.randomUUID().toString() + ScriptFileManager.ScriptConfig( + id = newId, + name = "", + content = "", + order = scripts.size + ) + } else { + scripts.getOrNull(index) ?: return + } + + val wrappedContext = CommonContextWrapper.createAppCompatContext(context) + + // 使用 ModuleRes 加载布局 + val dialogView = ModuleRes.inflate("dialog_script_edit", null) + + // 获取视图组件 + val tvUuid = dialogView.findViewById(ModuleRes.getId("tv_uuid", "id")) + val etName = dialogView.findViewById(ModuleRes.getId("et_name", "id")) + val etDesc = dialogView.findViewById(ModuleRes.getId("et_desc", "id")) + val etContent = dialogView.findViewById(ModuleRes.getId("et_content", "id")) + + // 设置UUID + tvUuid.text = "脚本ID: ${script.id}" + + // 设置现有值 + etName.text = script.name + etDesc.text = script.description ?: "" + etContent.text = script.content + + MaterialDialog(wrappedContext) + .customView(view = dialogView) + .title(text = if (isNew) "添加脚本" else "编辑脚本") + .positiveButton(text = "保存") { + val name = etName.text.toString().trim() + val description = etDesc.text.toString().trim() + val content = etContent.text.toString().trim() + + if (name.isEmpty()) { + showToast(context, "请输入脚本名称") + return@positiveButton + } + + if (content.isEmpty()) { + showToast(context, "请输入脚本内容") + return@positiveButton + } + + saveScript(script, name, description, content, isNew) + } + .neutralButton(text = "复制脚本内容") { + val content = etContent.text.toString().trim() + if (content.isNotEmpty()) { + copyToClipboard("脚本内容", content) + showToast(context, "已复制脚本内容") + } else { + showToast(context, "脚本内容为空") + } + } + .negativeButton(text = "取消") + .show() + } + + /** + * 显示测试对话框 + */ + private fun showTestDialog(index: Int) { + val script = scripts.getOrNull(index) ?: return + val wrappedContext = CommonContextWrapper.createAppCompatContext(context) + + // 使用 ModuleRes 加载布局 + val dialogView = ModuleRes.inflate("dialog_test_script", null) ?: return + + // 获取视图组件 + val tvScriptName = dialogView.findViewById(ModuleRes.getId("tv_script_name", "id")) + val etInput = dialogView.findViewById(ModuleRes.getId("et_input", "id")) + + // 设置脚本名称 + tvScriptName.text = "测试脚本: ${script.name}" + + MaterialDialog(wrappedContext) + .customView(view = dialogView) + .title(text = "测试脚本执行") + .positiveButton(text = "执行") { + val jsCode = etInput.text.toString().trim() + if (jsCode.isNotEmpty()) { + executeTestScript(script, jsCode) + } else { + showToast(context, "请输入要执行的JavaScript代码") + } + } + .negativeButton(text = "取消") + .show() + } + + /** + * 执行测试脚本 + */ + private fun executeTestScript(script: ScriptFileManager.ScriptConfig, jsCode: String) { + val wrappedContext = CommonContextWrapper.createAppCompatContext(context) + + val result = scriptEvalManager.testExecuteCode(script.content, jsCode, "${script.name}+测试脚本") + MaterialDialog(wrappedContext) + .title(text = "执行结果") + .message(text = result ?: "执行失败或无返回值") + .positiveButton(text = "复制结果") { + copyToClipboard("执行结果", result ?: "执行失败") + } + .negativeButton(text = "关闭") + .show() + } + + private fun saveScript( + script: ScriptFileManager.ScriptConfig, + name: String, + description: String, + content: String, + isNew: Boolean + ) { + // 检测脚本是否正常 + val testResult = scriptEvalManager.testScriptMethods(content) + if (!testResult.isPassed()) { + val errorMsg = testResult.getSummary() + showToast(context, "脚本验证失败,请修正后保存") + + // 显示详细错误信息 + val wrappedContext = CommonContextWrapper.createAppCompatContext(context) + MaterialDialog(wrappedContext) + .title(text = "脚本验证失败") + .message(text = errorMsg) + .positiveButton(text = "返回编辑") { + showEditDialog(if (isNew) -1 else scripts.indexOf(script)) + } + .negativeButton(text = "取消") + .show() + return + } + + script.name = name + script.description = description + script.content = content + script.modifiedTime = System.currentTimeMillis() + + if (isNew) { + script.createdTime = System.currentTimeMillis() + scripts.add(script) + scriptManager.saveScript(script) + showToast(context, "脚本已添加") + } else { + scriptManager.saveScript(script) + showToast(context, "脚本已更新") + } + + contentContainer.removeAllViews() + renderScriptList() + } + + private fun showImportOptions() { + val wrappedContext = CommonContextWrapper.createAppCompatContext(context) + val options = listOf("从剪贴板导入", "导入示例") + + MaterialDialog(wrappedContext) + .title(text = "导入脚本") + .listItems(items = options) { dialog, optionIndex, _ -> + when (optionIndex) { + 0 -> importFromClipboard() + 1 -> importExample() + } + dialog.dismiss() + } + .negativeButton(text = "取消") + .show() + } + + private fun importFromClipboard() { + try { + val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as? android.content.ClipboardManager + val clipData = clipboard?.primaryClip + + if (clipData != null && clipData.itemCount > 0) { + val clipItem = clipData.getItemAt(0) + val text = clipItem.text?.toString() + + if (!text.isNullOrBlank()) { + // 尝试解析为JSON格式 + val jsonObject = JSONObject(text) + + // 验证必需字段 + if (jsonObject.has("name") && jsonObject.has("id") && jsonObject.has("description") && jsonObject.has( + "content" + ) + ) { + val newName = jsonObject.optString("name", "从剪贴板导入") + val newId = jsonObject.optString("id", UUID.randomUUID().toString()) + val newDescription = jsonObject.optString("description", "") + val contentEncoded = jsonObject.optString("content", "") + + // 解码内容 + val contentBytes = Base64.decode(contentEncoded, Base64.NO_WRAP) + val newContent = String(contentBytes, Charsets.UTF_8) + + // 检查是否已存在相同ID的脚本 + val existingScript = scripts.find { it.id == newId } + if (existingScript != null) { + // 覆盖现有脚本 + existingScript.name = newName + existingScript.content = newContent + existingScript.description = newDescription + existingScript.modifiedTime = System.currentTimeMillis() + + scriptManager.saveScript(existingScript) + showToast(context, "已覆盖相同ID的脚本") + + contentContainer.removeAllViews() + renderScriptList() + return + } + + val newScript = ScriptFileManager.ScriptConfig( + id = newId, + name = newName, + content = newContent, + description = newDescription, + order = scripts.size + ) + + scriptManager.saveScript(newScript) + scripts.add(newScript) + + contentContainer.removeAllViews() + renderScriptList() + showToast(context, "已从剪贴板导入脚本") + } else { + showToast(context, "剪贴板内容格式不正确,缺少必要字段(name, id, description, content)") + } + } else { + showToast(context, "剪贴板内容为空") + } + } else { + showToast(context, "剪贴板无内容") + } + } catch (e: Exception) { + WeLogger.e("从剪贴板导入失败: ${e.message}") + showToast(context, "导入失败,请确保剪贴板内容为有效的JSON格式") + } + } + + private fun importExample() { + val exampleScripts = listOf( + ScriptFileManager.ScriptConfig( + id = UUID.randomUUID().toString(), + name = "打印示例", + content = """ + // 这是一个简单的示例脚本 将打印参数 + function onRequest(data) { + const {uri,cgiId,jsonData} = data; + wekit.log('拦截请求:', uri, cgiId, JSON.stringify(jsonData)); + return jsonData; + } + + function onResponse(data) { + const {uri,cgiId,jsonData} = data; + wekit.log('拦截响应:', uri, cgiId, JSON.stringify(jsonData)); + return jsonData; + } + """.trimIndent(), + description = "最简单的示例脚本,包含onRequest和onResponse方法", + order = scripts.size + ) + ) + + exampleScripts.forEach { script -> + scriptManager.saveScript(script) + scripts.add(script) + } + + contentContainer.removeAllViews() + renderScriptList() + showToast(context, "已导入示例脚本") + } + + private fun confirmDeleteScript(index: Int) { + val script = scripts.getOrNull(index) ?: return + val wrappedContext = CommonContextWrapper.createAppCompatContext(context) + + MaterialDialog(wrappedContext) + .title(text = "确认删除") + .message(text = "确定要删除脚本「${script.name}」吗?\nID: ${script.id}") + .positiveButton(text = "删除") { + deleteScript(index) + } + .negativeButton(text = "取消") + .show() + } + + private fun deleteScript(index: Int) { + val script = scripts.getOrNull(index) ?: return + + if (scriptManager.deleteScript(script.id)) { + scripts.removeAt(index) + + scripts.forEachIndexed { i, s -> + s.order = i + scriptManager.saveScript(s) + } + + contentContainer.removeAllViews() + renderScriptList() + showToast(context, "脚本已删除") + } else { + showToast(context, "删除失败") + } + } + + private fun moveScriptUp(index: Int) { + if (index > 0) { + val temp = scripts[index] + scripts[index] = scripts[index - 1] + scripts[index - 1] = temp + + scripts.forEachIndexed { i, s -> + s.order = i + scriptManager.saveScript(s) + } + + contentContainer.removeAllViews() + renderScriptList() + } + } + + private fun moveScriptDown(index: Int) { + if (index < scripts.size - 1) { + val temp = scripts[index] + scripts[index] = scripts[index + 1] + scripts[index + 1] = temp + + scripts.forEachIndexed { i, s -> + s.order = i + scriptManager.saveScript(s) + } + + contentContainer.removeAllViews() + renderScriptList() + } + } + + private fun copyToClipboard(label: String, text: String) { + try { + val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as? android.content.ClipboardManager + val clip = android.content.ClipData.newPlainText(label, text) + clipboard?.setPrimaryClip(clip) + } catch (e: Exception) { + WeLogger.e("复制失败: ${e.message}") + } + } + +} \ No newline at end of file diff --git a/app/src/main/java/moe/ouom/wekit/util/script/JsExecutor.kt b/app/src/main/java/moe/ouom/wekit/util/script/JsExecutor.kt index 5086ee5..a247bc5 100644 --- a/app/src/main/java/moe/ouom/wekit/util/script/JsExecutor.kt +++ b/app/src/main/java/moe/ouom/wekit/util/script/JsExecutor.kt @@ -4,13 +4,13 @@ import android.annotation.SuppressLint import android.content.Context import android.os.Handler import android.os.Looper +import moe.ouom.wekit.constants.MMVersion +import moe.ouom.wekit.host.HostInfo import moe.ouom.wekit.util.log.WeLogger import org.mozilla.javascript.ScriptRuntime import org.mozilla.javascript.Scriptable import java.text.MessageFormat -import java.util.Locale -import java.util.ResourceBundle -import java.util.MissingResourceException +import java.util.* import javax.script.ScriptEngine import javax.script.ScriptEngineManager @@ -123,8 +123,6 @@ class JsExecutor private constructor() { val rhinoEngine = engineManager.getEngineByName("rhino") mScriptEngine = rhinoEngine - // 注入日志接口 - injectLoggingInterface() mInitialized = true WeLogger.i("JsExecutor initialized with Rhino") @@ -165,17 +163,40 @@ class JsExecutor private constructor() { } /** - * 注入日志接口 + * 注入脚本接口 + * @param sendCgi CGI发送函数 + * @param protoUtils 协议工具对象 + * @param dataBaseUtils 数据库工具对象 + * @param messageUtils 消息工具对象 */ @Suppress("unused") - private fun injectLoggingInterface() { + fun injectScriptInterfaces( + sendCgi: Any, + protoUtils: Any, + dataBaseUtils: Any, + messageUtils: Any + ) { try { - // 为 ScriptEngine 添加日志接口 mScriptEngine?.put("wekit", object { fun log(vararg args: Any?) { val message = args.joinToString(" ") { it?.toString() ?: "null" } ScriptLogger.getInstance().info(message) } + + fun isMMAtLeast(field: String) = runCatching { + HostInfo.getVersionCode() >= MMVersion::class.java.getField(field).getInt(null) + }.getOrDefault(false) + + fun sendCgi(uri: String, cgiId: Int, funcId: Int, routeId: Int, jsonPayload: String) { + sendCgi(uri, cgiId, funcId, routeId, jsonPayload) + } + + @JvmField + val proto: Any = protoUtils + @JvmField + val database: Any = dataBaseUtils + @JvmField + val message: Any = messageUtils }) WeLogger.i("JsExecutor: Injected Rhino logging interface") diff --git a/app/src/main/res/layout/dialog_script_edit.xml b/app/src/main/res/layout/dialog_script_edit.xml new file mode 100644 index 0000000..022ba8c --- /dev/null +++ b/app/src/main/res/layout/dialog_script_edit.xml @@ -0,0 +1,93 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/dialog_test_script.xml b/app/src/main/res/layout/dialog_test_script.xml new file mode 100644 index 0000000..7d82462 --- /dev/null +++ b/app/src/main/res/layout/dialog_test_script.xml @@ -0,0 +1,59 @@ + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/script_item.xml b/app/src/main/res/layout/script_item.xml new file mode 100644 index 0000000..2ee56b1 --- /dev/null +++ b/app/src/main/res/layout/script_item.xml @@ -0,0 +1,92 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file From 53a1280137288c1e5b32ef8e618d1af924fd0759 Mon Sep 17 00:00:00 2001 From: huajiqaq Date: Sat, 14 Feb 2026 21:06:15 +0800 Subject: [PATCH 2/7] Added Python script to generate signature header files --- generate_secrets_h.py | 76 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 76 insertions(+) create mode 100644 generate_secrets_h.py diff --git a/generate_secrets_h.py b/generate_secrets_h.py new file mode 100644 index 0000000..eaf6083 --- /dev/null +++ b/generate_secrets_h.py @@ -0,0 +1,76 @@ +import sys +import random + + +def generate_c_header(hex_string, keys=None): + """ + 生成 secrets.h C头文件 + + Args: + hex_string: APK签名的SHA256哈希值(64位十六进制字符串) + keys: 4个密钥的列表,如果不提供则随机生成 + + Returns: + C头文件内容的字符串 + """ + # 验证输入:必须是64个十六进制字符(0-9, A-F) + hex_string = hex_string.strip().upper() + if len(hex_string) != 64 or not all(c in "0123456789ABCDEF" for c in hex_string): + raise ValueError("输入必须是64个十六进制字符(0-9, A-F)") + + # 如果没有提供keys,随机生成4个密钥(0x00-0xFF) + if keys is None: + keys = [random.randint(0x00, 0xFF) for _ in range(4)] + elif len(keys) != 4: + raise ValueError("keys必须是包含4个密钥的列表") + + # 将字符串转换为ASCII字节数组(64字节) + plaintext = [ord(c) for c in hex_string] + + # 分段加密(每段16字节) + encrypted_segments = [] + for i in range(4): + start = i * 16 + segment = plaintext[start : start + 16] + key = keys[i] + encrypted = [b ^ key for b in segment] + encrypted_segments.append(encrypted) + + # 生成C头文件 + output = "#pragma once\n\n" + for i, (key, enc_data) in enumerate(zip(keys, encrypted_segments), 1): + output += f"static const unsigned char KEY{i} = 0x{key:02X};\n" + output += f"static const unsigned char ENC_PART{i}[] = {{\n" + # 格式化为每行8个字节 + for j in range(0, 16, 8): + line_bytes = enc_data[j : j + 8] + line = ", ".join(f"0x{b:02X}" for b in line_bytes) + output += f" {line}" + if j < 8: # 第一行需要逗号 + output += "," + output += "\n" + output += "};\n\n" + + return output.strip() + + +def main(): + if len(sys.argv) != 2: + print("用法: python generate_secrets_h.py <64位SHA256签名>", file=sys.stderr) + print( + "示例: python generate_secrets_h.py 156B65C9CBE827BF0BB22F9E00BEEC3258319CE8A15D2A3729275CAF71CEDA21", + file=sys.stderr, + ) + sys.exit(1) + + try: + # 默认使用随机密钥生成 + result = generate_c_header(sys.argv[1]) + print(result) + except ValueError as e: + print(f"错误: {e}", file=sys.stderr) + sys.exit(1) + + +if __name__ == "__main__": + main() From 0b31d7af36d0ad484920da2dfc68f359ee9d8780 Mon Sep 17 00:00:00 2001 From: huajiqaq Date: Sat, 14 Feb 2026 21:37:34 +0800 Subject: [PATCH 3/7] Add cache mechanism to prevent interceptor infinite recursion --- .../sdk/protocol/listener/WePkgDispatcher.kt | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/app/src/main/java/moe/ouom/wekit/hooks/sdk/protocol/listener/WePkgDispatcher.kt b/app/src/main/java/moe/ouom/wekit/hooks/sdk/protocol/listener/WePkgDispatcher.kt index fa26364..3df774a 100644 --- a/app/src/main/java/moe/ouom/wekit/hooks/sdk/protocol/listener/WePkgDispatcher.kt +++ b/app/src/main/java/moe/ouom/wekit/hooks/sdk/protocol/listener/WePkgDispatcher.kt @@ -11,10 +11,14 @@ import moe.ouom.wekit.util.common.SyncUtils import moe.ouom.wekit.util.log.WeLogger import org.luckypray.dexkit.DexKitBridge import java.lang.reflect.Proxy +import java.util.concurrent.ConcurrentHashMap +import kotlin.math.min @HookItem(path = "protocol/wepkg_dispatcher", desc = "WePkg 请求/响应数据包拦截与篡改") class WePkgDispatcher : ApiHookItem(), IDexFind { private val dexClsOnGYNetEnd by dexClass() + // 缓存最近10条记录,避免无限递归 + private val recentRequests = ConcurrentHashMap() override fun entry(classLoader: ClassLoader) { SyncUtils.postDelayed(3000) { @@ -40,6 +44,27 @@ class WePkgDispatcher : ApiHookItem(), IDexFind { val reqPbObj = XposedHelpers.getObjectField(reqWrapper, "a") // m.a val reqBytes = XposedHelpers.callMethod(reqPbObj, "toByteArray") as ByteArray + // 构造唯一标识符 + val key = "$cgiId|$uri|${reqWrapper?.javaClass?.name}|${reqPbObj?.javaClass?.name}|${reqBytes.contentToString()}" + // 检查是否在缓存中且时间间隔小于2秒 + val currentTime = System.currentTimeMillis() + val lastTime = recentRequests[key] + if (lastTime != null && currentTime - lastTime < 2000) { + // 直接返回,不执行任何请求处理 + WeLogger.i("PkgDispatcher", "Request skipped (duplicate): $uri") + return@hookBefore + } + // 更新缓存 + recentRequests[key] = currentTime + // 限制缓存大小为10条 + if (recentRequests.size > 10) { + // 移除最旧的条目 + val oldestEntry = recentRequests.entries.firstOrNull() + oldestEntry?.let { + recentRequests.remove(it.key) + } + } + WePkgManager.handleRequestTamper(uri, cgiId, reqBytes)?.let { tampered -> XposedHelpers.callMethod(reqPbObj, "parseFrom", tampered) WeLogger.i("PkgDispatcher", "Request Tampered: $uri") From 3e2e4f267f38d0a11e1d419ab42a297b6e375a88 Mon Sep 17 00:00:00 2001 From: huajiqaq Date: Fri, 20 Feb 2026 22:00:59 +0800 Subject: [PATCH 4/7] =?UTF-8?q?=E6=9B=B4=E6=96=B0RuntimeConfig=E6=94=B9?= =?UTF-8?q?=E4=B8=BA=E5=8A=A8=E6=80=81=E4=BB=8Eprefs=E8=AF=BB=E5=8F=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../moe/ouom/wekit/config/RuntimeConfig.java | 41 +++++-------------- .../moe/ouom/wekit/constants/MMVersion.kt | 9 +++- .../ouom/wekit/hooks/sdk/protocol/WeApi.kt | 8 ---- .../ouom/wekit/loader/core/WeLauncher.java | 6 +-- 4 files changed, 20 insertions(+), 44 deletions(-) diff --git a/app/src/main/java/moe/ouom/wekit/config/RuntimeConfig.java b/app/src/main/java/moe/ouom/wekit/config/RuntimeConfig.java index 74bc943..f1079b6 100644 --- a/app/src/main/java/moe/ouom/wekit/config/RuntimeConfig.java +++ b/app/src/main/java/moe/ouom/wekit/config/RuntimeConfig.java @@ -1,7 +1,7 @@ package moe.ouom.wekit.config; -import android.annotation.SuppressLint; import android.app.Activity; +import android.content.SharedPreferences; import android.content.pm.ApplicationInfo; import java.lang.ref.WeakReference; @@ -20,23 +20,16 @@ private RuntimeConfig() { // account info // - // 注意时效性,这里保存的登录信息是刚启动应用时的登录信息,而不是实时的登录信息 - // TODO: 需要一个机制来更新这些信息 - + private static SharedPreferences mmPrefs; // login_weixin_username: wxid_apfe8lfoeoad13 // last_login_nick_name: 帽子叔叔 // login_user_name: 15068586147 // last_login_uin: 1293948946 - public static String login_weixin_username; - public static String last_login_nick_name; - public static String login_user_name; - public static String last_login_uin; // ------- // - - // wechat app info // + // WeChat app info // @Getter private static String wechatVersionName; // "8.0.65" @@ -85,22 +78,6 @@ public static void setHostApplicationInfo(ApplicationInfo appInfo) { hostApplicationInfo = appInfo; } - public static void setLogin_weixin_username(String login_weixin_username) { - RuntimeConfig.login_weixin_username = login_weixin_username; - } - - public static void setLast_login_nick_name(String last_login_nick_name) { - RuntimeConfig.last_login_nick_name = last_login_nick_name; - } - - public static void setLogin_user_name(String login_user_name) { - RuntimeConfig.login_user_name = login_user_name; - } - - public static void setLast_login_uin(String last_login_uin) { - RuntimeConfig.last_login_uin = last_login_uin; - } - public static void setWechatVersionName(String wechatVersionName) { RuntimeConfig.wechatVersionName = wechatVersionName; } @@ -109,19 +86,23 @@ public static void setWechatVersionCode(long wechatVersionCode) { RuntimeConfig.wechatVersionCode = wechatVersionCode; } + public static void setmmPrefs(SharedPreferences sharedPreferences) { + RuntimeConfig.mmPrefs = sharedPreferences; + } + public static String getLogin_weixin_username() { - return login_weixin_username; + return mmPrefs.getString("login_weixin_username", ""); } public static String getLast_login_nick_name() { - return last_login_nick_name; + return mmPrefs.getString("last_login_nick_name", ""); } public static String getLogin_user_name() { - return login_user_name; + return mmPrefs.getString("login_user_name", ""); } public static String getLast_login_uin() { - return last_login_uin; + return mmPrefs.getString("last_login_uin", "0"); } } \ No newline at end of file diff --git a/app/src/main/java/moe/ouom/wekit/constants/MMVersion.kt b/app/src/main/java/moe/ouom/wekit/constants/MMVersion.kt index cf53873..adc748b 100644 --- a/app/src/main/java/moe/ouom/wekit/constants/MMVersion.kt +++ b/app/src/main/java/moe/ouom/wekit/constants/MMVersion.kt @@ -1,5 +1,7 @@ package moe.ouom.wekit.constants +import moe.ouom.wekit.host.HostInfo + object MMVersion { const val MM_8_0_67 = 3000 const val MM_8_0_66 = 2980 @@ -11,8 +13,9 @@ object MMVersion { const val MM_8_0_60 = 2860 const val MM_8_0_58 = 2840 const val MM_8_0_57 = 2820 - const val MM_8_0_56 = 2780 + const val MM_8_0_56 = 2800 const val MM_8_0_49 = 2600 + const val MM_8_0_43 = 2480 const val V_8_0_67 = "8.0.67" const val V_8_0_66 = "8.0.66" @@ -26,4 +29,8 @@ object MMVersion { const val V_8_0_57 = "8.0.57" const val V_8_0_56 = "8.0.56" const val V_8_0_49 = "8.0.49" + const val V_8_0_43 = "8.0.43" + + const val MM_8_0_48_Play = 2583 + const val V_8_0_48_Play = "8.0.48" } diff --git a/app/src/main/java/moe/ouom/wekit/hooks/sdk/protocol/WeApi.kt b/app/src/main/java/moe/ouom/wekit/hooks/sdk/protocol/WeApi.kt index 9d76d11..9ed4bbd 100644 --- a/app/src/main/java/moe/ouom/wekit/hooks/sdk/protocol/WeApi.kt +++ b/app/src/main/java/moe/ouom/wekit/hooks/sdk/protocol/WeApi.kt @@ -15,14 +15,6 @@ object WeApi { * 获取当前登录的微信ID */ fun getSelfWxId(): String { - val sharedPreferences: SharedPreferences = - HostInfo.getApplication().getSharedPreferences("com.tencent.mm_preferences", 0) - - RuntimeConfig.setLogin_weixin_username(sharedPreferences.getString("login_weixin_username", "")) - RuntimeConfig.setLast_login_nick_name(sharedPreferences.getString("last_login_nick_name", "")) - RuntimeConfig.setLogin_user_name(sharedPreferences.getString("login_user_name", "")) - RuntimeConfig.setLast_login_uin(sharedPreferences.getString("last_login_uin", "0")) - return RuntimeConfig.getLogin_weixin_username() } diff --git a/app/src/main/java/moe/ouom/wekit/loader/core/WeLauncher.java b/app/src/main/java/moe/ouom/wekit/loader/core/WeLauncher.java index 4a1c9a0..a7964f0 100644 --- a/app/src/main/java/moe/ouom/wekit/loader/core/WeLauncher.java +++ b/app/src/main/java/moe/ouom/wekit/loader/core/WeLauncher.java @@ -118,11 +118,7 @@ protected void afterHookedMethod(MethodHookParam param) throws Throwable { Activity activity = (Activity) param.thisObject; RuntimeConfig.setLauncherUIActivity(activity); SharedPreferences sharedPreferences = activity.getSharedPreferences("com.tencent.mm_preferences", 0); - - RuntimeConfig.setLogin_weixin_username(sharedPreferences.getString("login_weixin_username", "")); - RuntimeConfig.setLast_login_nick_name(sharedPreferences.getString("last_login_nick_name", "")); - RuntimeConfig.setLogin_user_name(sharedPreferences.getString("login_user_name", "")); - RuntimeConfig.setLast_login_uin(sharedPreferences.getString("last_login_uin", "0")); + RuntimeConfig.setmmPrefs(sharedPreferences); } }); From 1b709670bdcf05ff147b4dfdeb7876ccfdf191c9 Mon Sep 17 00:00:00 2001 From: huajiqaq Date: Fri, 20 Feb 2026 22:04:55 +0800 Subject: [PATCH 5/7] =?UTF-8?q?=E4=BC=98=E5=8C=96=E9=83=A8=E5=88=86?= =?UTF-8?q?=E4=BB=A3=E7=A0=81=20=E5=B0=86=E5=A8=B1=E4=B9=90=E5=8A=9F?= =?UTF-8?q?=E8=83=BD=E4=BB=8Efun=E7=A7=BB=E5=8A=A8=E5=88=B0func=20?= =?UTF-8?q?=E6=96=B0=E5=A2=9E=E5=88=A4=E6=96=AD=E8=B0=B7=E6=AD=8C=E7=89=88?= =?UTF-8?q?=E6=9C=AC=E7=9A=84=E6=96=B9=E6=B3=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CONTRIBUTING.md | 70 ++++- .../moe/ouom/wekit/constants/Constants.kt | 1 + .../moe/ouom/wekit/core/dsl/DexDelegate.kt | 2 +- .../item/chat/risk/HookQueryCashierPkg.kt | 2 +- .../hooks/item/chat/risk/WeRedPacketAuto.kt | 2 +- .../wekit/hooks/item/chat/risk/WeSendXml.kt | 18 -- .../wekit/hooks/item/dev/WePacketDebugger.kt | 2 - .../item/{dev => func}/WeProfileCleaner.kt | 3 +- .../item/{dev => func}/WeProfileNameSetter.kt | 3 +- .../ouom/wekit/hooks/sdk/api/WeDatabaseApi.kt | 196 ++++++++----- .../wekit/hooks/sdk/api/WeDatabaseListener.kt | 272 +++++++++++++++--- .../ouom/wekit/hooks/sdk/api/WeMessageApi.kt | 7 +- .../sdk/protocol/listener/WePkgDispatcher.kt | 6 +- .../java/moe/ouom/wekit/host/HostInfo.java | 8 +- .../java/moe/ouom/wekit/host/impl/HostInfo.kt | 6 + .../loader/core/hooks/ActivityProxyHooks.java | 62 ++-- .../ui/creator/center/DexFinderDialog.kt | 4 +- 17 files changed, 477 insertions(+), 187 deletions(-) rename app/src/main/java/moe/ouom/wekit/hooks/item/{dev => func}/WeProfileCleaner.kt (96%) rename app/src/main/java/moe/ouom/wekit/hooks/item/{dev => func}/WeProfileNameSetter.kt (97%) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index c2e1595..ecd8c37 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -266,7 +266,7 @@ moe.ouom.wekit/ │ │ ├── moment/ # 朋友圈 │ │ ├── fix/ # 优化与修复 │ │ ├── dev/ # 开发者选项 -│ │ ├── fun/ # 娱乐功能 +│ │ ├── func/ # 娱乐功能 │ │ ├── script/ # 脚本管理 │ │ └── example/ # 示例代码 │ └── sdk/ # SDK 封装 @@ -420,10 +420,14 @@ import moe.ouom.wekit.constants.MMVersion import moe.ouom.wekit.host.HostInfo override fun entry(classLoader: ClassLoader) { + val isGooglePlayVersion = HostInfo.isGooglePlayVersion val currentVersion = HostInfo.getVersionCode() // 根据版本选择不同的实现 when { + isGooglePlayVersion && currentVersion >= MMVersion.MM_8_0_48_Play -> + // Google Play 8.0.48+ + hookForNewVersion(classLoader) currentVersion >= MMVersion.MM_8_0_90 -> { // 8.0.90 及以上版本的实现 hookForNewVersion(classLoader) @@ -2239,6 +2243,70 @@ override fun unload(classLoader: ClassLoader) { } ``` +### 数据库监听器 (WeDatabaseListener) + +`WeDatabaseListener` 提供监听和篡改微信数据库操作的能力。 + +#### 适配器定义 + +```kotlin +// 重写需要的方法 +open class DatabaseListenerAdapter { + // 插入后执行 + open fun onInsert(table: String, values: ContentValues) {} + + // 更新前执行,返回true阻止更新 + open fun onUpdate(table: String, values: ContentValues): Boolean = false + + // 查询前执行,返回修改后的SQL,null表示不修改 + open fun onQuery(sql: String): String? = sql +} +``` + +#### 快速开始 + +```kotlin +// 1. 继承适配器 +class MyListener : DatabaseListenerAdapter() { + override fun onInsert(table: String, values: ContentValues) { + if (table == "message") { + values.put("time", System.currentTimeMillis()) + } + } + + override fun onUpdate(table: String, values: ContentValues): Boolean { + return table == "user_info" && values.containsKey("balance") + } + + override fun onQuery(sql: String): String? { + return if (sql.contains("password")) null else sql + } +} + +// 2. 注册/注销 +override fun entry(classLoader: ClassLoader) { + WeDatabaseListener.addListener(this) +} + +override fun unload(classLoader: ClassLoader) { + WeDatabaseListener.removeListener(this) + super.unload(classLoader) +} +``` + +#### 方法说明 + +| 方法 | 时机 | 返回值 | 作用 | +|------|------|--------|------| +| `onInsert` | 插入后 | - | 监听/修改插入数据 | +| `onUpdate` | 更新前 | `true`=阻止 | 监听/阻止更新 | +| `onQuery` | 查询前 | 新SQL/null | 篡改查询语句 | + +#### 特性 + +- ✅ **链式处理**:多个监听器按注册顺序执行 +- ✅ **直接修改**:`values` 对象可直接修改生效 + #### 核心工具类:WeProtoData `WeProtoData` 是处理 Protobuf 数据的核心工具类,提供以下关键方法: diff --git a/app/src/main/java/moe/ouom/wekit/constants/Constants.kt b/app/src/main/java/moe/ouom/wekit/constants/Constants.kt index c399858..25d1ce6 100644 --- a/app/src/main/java/moe/ouom/wekit/constants/Constants.kt +++ b/app/src/main/java/moe/ouom/wekit/constants/Constants.kt @@ -18,6 +18,7 @@ class Constants private constructor() { // 数据库类 const val CLAZZ_SQLITE_DATABASE = "com.tencent.wcdb.database.SQLiteDatabase" + const val CLAZZ_COMPAT_SQLITE_DATABASE = "com.tencent.wcdb.compat.SQLiteDatabase" // 红包消息类型 const val TYPE_LUCKY_MONEY = 436207665 // 红包 diff --git a/app/src/main/java/moe/ouom/wekit/core/dsl/DexDelegate.kt b/app/src/main/java/moe/ouom/wekit/core/dsl/DexDelegate.kt index db2fde1..5230b03 100644 --- a/app/src/main/java/moe/ouom/wekit/core/dsl/DexDelegate.kt +++ b/app/src/main/java/moe/ouom/wekit/core/dsl/DexDelegate.kt @@ -87,7 +87,7 @@ class DexClassDelegate internal constructor( } fun getSuperClass(dexKit: DexKitBridge): ClassData? { - return getClassData(dexKit)?.superClass + return getClassData(dexKit).superClass } override fun getValue(thisRef: Any?, property: KProperty<*>): DexClassDelegate = this diff --git a/app/src/main/java/moe/ouom/wekit/hooks/item/chat/risk/HookQueryCashierPkg.kt b/app/src/main/java/moe/ouom/wekit/hooks/item/chat/risk/HookQueryCashierPkg.kt index 2c36f90..00160a8 100644 --- a/app/src/main/java/moe/ouom/wekit/hooks/item/chat/risk/HookQueryCashierPkg.kt +++ b/app/src/main/java/moe/ouom/wekit/hooks/item/chat/risk/HookQueryCashierPkg.kt @@ -121,7 +121,7 @@ class HookQueryCashierPkg : BaseClickableFunctionHookItem(), IWePkgInterceptor { } } - private inner class ConfigDialog(context: Context) : BaseRikkaDialog(context, "收银台余额配置") { + private class ConfigDialog(context: Context) : BaseRikkaDialog(context, "收银台余额配置") { override fun initPreferences() { addCategory("金额设置") diff --git a/app/src/main/java/moe/ouom/wekit/hooks/item/chat/risk/WeRedPacketAuto.kt b/app/src/main/java/moe/ouom/wekit/hooks/item/chat/risk/WeRedPacketAuto.kt index 4104bff..b9cb651 100644 --- a/app/src/main/java/moe/ouom/wekit/hooks/item/chat/risk/WeRedPacketAuto.kt +++ b/app/src/main/java/moe/ouom/wekit/hooks/item/chat/risk/WeRedPacketAuto.kt @@ -25,7 +25,7 @@ import kotlin.random.Random @SuppressLint("DiscouragedApi") @HookItem(path = "聊天与消息/自动抢红包", desc = "监听消息并自动拆开红包") -class WeRedPacketAuto : BaseClickableFunctionHookItem(), WeDatabaseListener.DatabaseInsertListener, IDexFind { +class WeRedPacketAuto : BaseClickableFunctionHookItem(), IDexFind, WeDatabaseListener.InsertListener { private val dexClsReceiveLuckyMoney by dexClass() private val dexClsOpenLuckyMoney by dexClass() diff --git a/app/src/main/java/moe/ouom/wekit/hooks/item/chat/risk/WeSendXml.kt b/app/src/main/java/moe/ouom/wekit/hooks/item/chat/risk/WeSendXml.kt index 0b67938..c46a0c3 100644 --- a/app/src/main/java/moe/ouom/wekit/hooks/item/chat/risk/WeSendXml.kt +++ b/app/src/main/java/moe/ouom/wekit/hooks/item/chat/risk/WeSendXml.kt @@ -1,28 +1,10 @@ package moe.ouom.wekit.hooks.item.chat.risk import android.annotation.SuppressLint -import android.content.ContentValues import android.content.Context -import androidx.core.net.toUri import com.afollestad.materialdialogs.MaterialDialog -import de.robv.android.xposed.XposedHelpers -import moe.ouom.wekit.config.WeConfig -import moe.ouom.wekit.constants.Constants.Companion.TYPE_LUCKY_MONEY -import moe.ouom.wekit.constants.Constants.Companion.TYPE_LUCKY_MONEY_EXCLUSIVE -import moe.ouom.wekit.core.dsl.dexClass -import moe.ouom.wekit.core.dsl.dexMethod -import moe.ouom.wekit.core.model.BaseClickableFunctionHookItem import moe.ouom.wekit.core.model.BaseSwitchFunctionHookItem -import moe.ouom.wekit.dexkit.intf.IDexFind import moe.ouom.wekit.hooks.core.annotation.HookItem -import moe.ouom.wekit.hooks.sdk.api.WeDatabaseListener -import moe.ouom.wekit.hooks.sdk.api.WeNetworkApi -import moe.ouom.wekit.ui.creator.dialog.item.chat.risk.WeRedPacketConfigDialog -import moe.ouom.wekit.util.log.WeLogger -import org.json.JSONObject -import org.luckypray.dexkit.DexKitBridge -import java.util.concurrent.ConcurrentHashMap -import kotlin.random.Random @SuppressLint("DiscouragedApi") @HookItem(path = "聊天与消息/发送 AppMsg(XML)", desc = "长按'发送'按钮,自动发送卡片消息") diff --git a/app/src/main/java/moe/ouom/wekit/hooks/item/dev/WePacketDebugger.kt b/app/src/main/java/moe/ouom/wekit/hooks/item/dev/WePacketDebugger.kt index c40673b..f841526 100644 --- a/app/src/main/java/moe/ouom/wekit/hooks/item/dev/WePacketDebugger.kt +++ b/app/src/main/java/moe/ouom/wekit/hooks/item/dev/WePacketDebugger.kt @@ -5,8 +5,6 @@ import android.widget.EditText import android.widget.LinearLayout import com.afollestad.materialdialogs.MaterialDialog import com.afollestad.materialdialogs.customview.customView -import com.afollestad.materialdialogs.input.input -import moe.ouom.wekit.config.WeConfig import moe.ouom.wekit.core.model.BaseClickableFunctionHookItem import moe.ouom.wekit.hooks.core.annotation.HookItem import moe.ouom.wekit.hooks.sdk.protocol.WePkgHelper diff --git a/app/src/main/java/moe/ouom/wekit/hooks/item/dev/WeProfileCleaner.kt b/app/src/main/java/moe/ouom/wekit/hooks/item/func/WeProfileCleaner.kt similarity index 96% rename from app/src/main/java/moe/ouom/wekit/hooks/item/dev/WeProfileCleaner.kt rename to app/src/main/java/moe/ouom/wekit/hooks/item/func/WeProfileCleaner.kt index 17de24a..576e0d8 100644 --- a/app/src/main/java/moe/ouom/wekit/hooks/item/dev/WeProfileCleaner.kt +++ b/app/src/main/java/moe/ouom/wekit/hooks/item/func/WeProfileCleaner.kt @@ -1,9 +1,8 @@ -package moe.ouom.wekit.hooks.item.dev +package moe.ouom.wekit.hooks.item.func import android.content.Context import com.afollestad.materialdialogs.MaterialDialog import moe.ouom.wekit.core.model.BaseClickableFunctionHookItem -import moe.ouom.wekit.dexkit.cache.DexCacheManager import moe.ouom.wekit.hooks.core.annotation.HookItem import moe.ouom.wekit.hooks.sdk.protocol.WePkgHelper import moe.ouom.wekit.util.log.WeLogger diff --git a/app/src/main/java/moe/ouom/wekit/hooks/item/dev/WeProfileNameSetter.kt b/app/src/main/java/moe/ouom/wekit/hooks/item/func/WeProfileNameSetter.kt similarity index 97% rename from app/src/main/java/moe/ouom/wekit/hooks/item/dev/WeProfileNameSetter.kt rename to app/src/main/java/moe/ouom/wekit/hooks/item/func/WeProfileNameSetter.kt index 0de93da..26519b3 100644 --- a/app/src/main/java/moe/ouom/wekit/hooks/item/dev/WeProfileNameSetter.kt +++ b/app/src/main/java/moe/ouom/wekit/hooks/item/func/WeProfileNameSetter.kt @@ -1,9 +1,8 @@ -package moe.ouom.wekit.hooks.item.dev +package moe.ouom.wekit.hooks.item.func import android.content.Context import com.afollestad.materialdialogs.MaterialDialog import com.afollestad.materialdialogs.input.input -import moe.ouom.wekit.config.WeConfig import moe.ouom.wekit.core.model.BaseClickableFunctionHookItem import moe.ouom.wekit.hooks.core.annotation.HookItem import moe.ouom.wekit.hooks.sdk.protocol.WePkgHelper diff --git a/app/src/main/java/moe/ouom/wekit/hooks/sdk/api/WeDatabaseApi.kt b/app/src/main/java/moe/ouom/wekit/hooks/sdk/api/WeDatabaseApi.kt index 1e2d0f5..84c3d19 100644 --- a/app/src/main/java/moe/ouom/wekit/hooks/sdk/api/WeDatabaseApi.kt +++ b/app/src/main/java/moe/ouom/wekit/hooks/sdk/api/WeDatabaseApi.kt @@ -45,6 +45,119 @@ class WeDatabaseApi : ApiHookItem(), IDexFind { @SuppressLint("StaticFieldLeak") var INSTANCE: WeDatabaseApi? = null + + // ============================================================================= + // SQL 语句集中管理 + // ============================================================================= + private object SQL { + // 基础字段 - 联系人查询常用字段 + const val CONTACT_FIELDS = """ + r.username, r.alias, r.conRemark, r.nickname, + r.pyInitial, r.quanPin, r.encryptUsername, i.reserved2 AS avatarUrl + """ + + // 基础字段 - 群聊查询常用字段 + const val CHATROOM_FIELDS = "r.username, r.nickname, r.pyInitial, r.quanPin, i.reserved2 AS avatarUrl" + + // 基础字段 - 公众号查询常用字段 + const val OFFICIAL_FIELDS = "r.username, r.alias, r.nickname, i.reserved2 AS avatarUrl" + + // 基础 JOIN 语句 + const val LEFT_JOIN_IMG_FLAG = "LEFT JOIN img_flag i ON r.username = i.username" + + // ========================================= + // 联系人查询 + // ========================================= + + /** 所有人类账号(排除群聊和公众号和系统账号) */ + val ALL_CONNECTS = """ + SELECT $CONTACT_FIELDS, r.type + FROM rcontact r + $LEFT_JOIN_IMG_FLAG + WHERE + r.username != 'filehelper' + AND r.verifyFlag = 0 + AND (r.type & 1) != 0 + AND (r.type & 8) = 0 + AND (r.type & 32) = 0 + """.trimIndent() + + /** 好友列表(排除群聊和公众号和系统账号和自己和假好友) */ + val CONTACT_LIST = """ + SELECT $CONTACT_FIELDS, r.type + FROM rcontact r + $LEFT_JOIN_IMG_FLAG + WHERE + ( + r.encryptUsername != '' -- 是真好友 + OR + r.username = (SELECT value FROM userinfo WHERE id = 2) -- 是我自己 + ) + AND r.verifyFlag = 0 + AND (r.type & 1) != 0 + AND (r.type & 8) = 0 + AND (r.type & 32) = 0 + """.trimIndent() + + // ========================================= + // 群聊查询 + // ========================================= + + /** 所有群聊 */ + val CHATROOM_LIST = """ + SELECT $CHATROOM_FIELDS + FROM rcontact r + $LEFT_JOIN_IMG_FLAG + WHERE r.username LIKE '%@chatroom' + """.trimIndent() + + /** 获取群成员列表 */ + fun groupMembers(idsStr: String) = """ + SELECT $CONTACT_FIELDS + FROM rcontact r + $LEFT_JOIN_IMG_FLAG + WHERE r.username IN ($idsStr) + """.trimIndent() + + // ========================================= + // 公众号查询 + // ========================================= + + /** 所有公众号 */ + val OFFICIAL_LIST = """ + SELECT $OFFICIAL_FIELDS + FROM rcontact r + $LEFT_JOIN_IMG_FLAG + WHERE r.username LIKE 'gh_%' + """.trimIndent() + + // ========================================= + // 消息查询 + // ========================================= + + /** 分页获取消息 */ + fun messages(wxid: String, limit: Int, offset: Int) = """ + SELECT msgId, talker, content, type, createTime, isSend + FROM message + WHERE talker='$wxid' + ORDER BY createTime DESC + LIMIT $limit OFFSET $offset + """.trimIndent() + + // ========================================= + // 头像查询 + // ========================================= + + /** 获取头像URL */ + fun avatar(wxid: String) = """ + SELECT i.reserved2 AS avatarUrl + FROM img_flag i + WHERE i.username = '$wxid' + """.trimIndent() + + /** 获取群聊成员列表字符串 */ + val CHATROOM_MEMBERS = "SELECT memberlist FROM chatroom WHERE chatroomname = '%s'" + } } @SuppressLint("NonUniqueDexKitData") @@ -247,66 +360,21 @@ class WeDatabaseApi : ApiHookItem(), IDexFind { * 返回所有人类账号(包含好友、陌生人、自己),但排除群和公众号 */ fun getAllConnects(): List { - val sql = """ - SELECT - r.username, r.alias, r.conRemark, r.nickname, r.pyInitial, r.quanPin, - r.encryptUsername, i.reserved2 AS avatarUrl - FROM rcontact r - LEFT JOIN img_flag i ON r.username = i.username - WHERE - r.username NOT LIKE '%@chatroom' - AND r.username NOT LIKE 'gh_%' - AND r.username != 'filehelper' - AND r.verifyFlag = 0 - -- 移除了 type & 1 校验,允许返回非好友 - """.trimIndent() - return mapToContact(executeQuery(sql)) + return mapToContact(executeQuery(SQL.ALL_CONNECTS)) } /** * 获取【好友】 */ fun getContactList(): List { - val sql = """ - SELECT - r.username, r.alias, r.conRemark, r.nickname, r.pyInitial, r.quanPin, - r.encryptUsername, i.reserved2 AS avatarUrl - FROM rcontact r - LEFT JOIN img_flag i ON r.username = i.username - WHERE - r.username NOT LIKE '%@chatroom' - AND r.username NOT LIKE 'gh_%' - AND r.verifyFlag = 0 - AND (r.type & 1) != 0 - AND ( - r.encryptUsername != '' -- 是真好友 - OR - r.username = (SELECT value FROM userinfo WHERE id = 2) -- 是我自己 - ) - AND r.username NOT IN ( - 'filehelper', 'qqmail', 'fmessage', 'tmessage', 'qmessage', - 'floatbottle', 'lbsapp', 'shakeapp', 'medianote', 'qqfriend', - 'newsapp', 'blogapp', 'facebookapp', 'masssendapp', 'feedsapp', - 'voipapp', 'cardpackage', 'voicevoipapp', 'voiceinputapp', - 'officialaccounts', 'linkedinplugin', 'notifymessage', - 'appbrandcustomerservicemsg', 'appbrand_notify_message', - 'downloaderapp', 'opencustomerservicemsg', 'weixin', - 'weibo', 'pc_share', 'wxitil' - ) - """.trimIndent() - return mapToContact(executeQuery(sql)) + return mapToContact(executeQuery(SQL.CONTACT_LIST)) } /** * 获取【群聊】 */ fun getChatroomList(): List { - val sql = """ - SELECT r.username, r.nickname, r.pyInitial, r.quanPin, i.reserved2 AS avatarUrl - FROM rcontact r LEFT JOIN img_flag i ON r.username = i.username - WHERE r.username LIKE '%@chatroom' - """.trimIndent() - return executeQuery(sql).map { row -> + return executeQuery(SQL.CHATROOM_LIST).map { row -> WeGroup( username = row.str("username"), nickname = row.str("nickname"), @@ -324,7 +392,7 @@ class WeDatabaseApi : ApiHookItem(), IDexFind { fun getGroupMembers(chatroomId: String): List { if (!chatroomId.endsWith("@chatroom")) return emptyList() - val roomSql = "SELECT memberlist FROM chatroom WHERE chatroomname = '$chatroomId'" + val roomSql = SQL.CHATROOM_MEMBERS.format(chatroomId) val roomResult = executeQuery(roomSql) if (roomResult.isEmpty()) { @@ -340,32 +408,14 @@ class WeDatabaseApi : ApiHookItem(), IDexFind { val idsStr = members.joinToString(",") { "'$it'" } - val sql = """ - SELECT - r.username, r.alias, r.conRemark, r.nickname, r.pyInitial, r.quanPin, - r.encryptUsername, i.reserved2 AS avatarUrl - FROM rcontact r - LEFT JOIN img_flag i ON r.username = i.username - WHERE r.username IN ($idsStr) - """.trimIndent() - - return mapToContact(executeQuery(sql)) + return mapToContact(executeQuery(SQL.groupMembers(idsStr))) } /** * 获取【公众号】 */ fun getOfficialAccountList(): List { - val sql = """ - SELECT - r.username, r.alias, r.nickname, - i.reserved2 AS avatarUrl - FROM rcontact r - LEFT JOIN img_flag i ON r.username = i.username - WHERE r.username LIKE 'gh_%' - """.trimIndent() - - return executeQuery(sql).map { row -> + return executeQuery(SQL.OFFICIAL_LIST).map { row -> WeOfficial( username = row.str("username"), nickname = row.str("nickname"), @@ -382,9 +432,7 @@ class WeDatabaseApi : ApiHookItem(), IDexFind { fun getMessages(wxid: String, page: Int = 1, pageSize: Int = 20): List { if (wxid.isEmpty()) return emptyList() val offset = (page - 1) * pageSize - val sql = "SELECT msgId, talker, content, type, createTime, isSend FROM message WHERE talker='$wxid' ORDER BY createTime DESC LIMIT $pageSize OFFSET $offset" - - return executeQuery(sql).map { row -> + return executeQuery(SQL.messages(wxid, pageSize, offset)).map { row -> WeMessage( msgId = row.long("msgId"), talker = row.str("talker"), @@ -401,8 +449,7 @@ class WeDatabaseApi : ApiHookItem(), IDexFind { */ fun getAvatarUrl(wxid: String): String { if (wxid.isEmpty()) return "" - val sql = "SELECT i.reserved2 AS avatarUrl FROM img_flag i WHERE i.username = '$wxid'" - val result = executeQuery(sql) + val result = executeQuery(SQL.avatar(wxid)) return if (result.isNotEmpty()) { result[0]["avatarUrl"] as? String ?: "" } else { @@ -442,5 +489,4 @@ class WeDatabaseApi : ApiHookItem(), IDexFind { else -> 0 } } - } \ No newline at end of file diff --git a/app/src/main/java/moe/ouom/wekit/hooks/sdk/api/WeDatabaseListener.kt b/app/src/main/java/moe/ouom/wekit/hooks/sdk/api/WeDatabaseListener.kt index 4a6a609..f72ec60 100644 --- a/app/src/main/java/moe/ouom/wekit/hooks/sdk/api/WeDatabaseListener.kt +++ b/app/src/main/java/moe/ouom/wekit/hooks/sdk/api/WeDatabaseListener.kt @@ -2,12 +2,13 @@ package moe.ouom.wekit.hooks.sdk.api import android.annotation.SuppressLint import android.content.ContentValues -import android.util.Log import de.robv.android.xposed.XposedHelpers import moe.ouom.wekit.config.WeConfig import moe.ouom.wekit.constants.Constants +import moe.ouom.wekit.constants.MMVersion import moe.ouom.wekit.core.model.ApiHookItem import moe.ouom.wekit.hooks.core.annotation.HookItem +import moe.ouom.wekit.host.HostInfo import moe.ouom.wekit.util.Initiator.loadClass import moe.ouom.wekit.util.log.WeLogger import java.util.concurrent.CopyOnWriteArrayList @@ -15,40 +16,108 @@ import java.util.concurrent.CopyOnWriteArrayList @SuppressLint("DiscouragedApi") @HookItem(path = "API/数据库监听服务", desc = "为其他功能提供数据库写入监听能力") class WeDatabaseListener : ApiHookItem() { - - // 定义监听器接口 - interface DatabaseInsertListener { + // 定义独立的接口 + interface InsertListener { fun onInsert(table: String, values: ContentValues) } + interface UpdateListener { + fun onUpdate(table: String, values: ContentValues): Boolean + } + + interface QueryListener { + fun onQuery(sql: String): String? + } companion object { - private val listeners = CopyOnWriteArrayList() + private const val TAG = "WeDatabaseApi" - // 供其他模块注册监听 - fun addListener(listener: DatabaseInsertListener) { - if (!listeners.contains(listener)) { - listeners.add(listener) - WeLogger.i("WeDatabaseApi: 监听器已添加,当前监听器数量: ${listeners.size}") - } else { - WeLogger.w("WeDatabaseApi: 监听器已存在,跳过添加") + private val insertListeners = CopyOnWriteArrayList() + private val updateListeners = CopyOnWriteArrayList() + private val queryListeners = CopyOnWriteArrayList() + fun addListener(listener: Any) { + val addedTypes = mutableListOf() + + if (listener is InsertListener) { + insertListeners.add(listener) + addedTypes.add("INSERT") + } + if (listener is UpdateListener) { + updateListeners.add(listener) + addedTypes.add("UPDATE") + } + if (listener is QueryListener) { + queryListeners.add(listener) + addedTypes.add("QUERY") + } + + // 只有实现了至少一个接口才打印日志 + if (addedTypes.isNotEmpty()) { + WeLogger.i(TAG, "监听器已添加: ${listener.javaClass.simpleName} [${addedTypes.joinToString()}]") } } - fun removeListener(listener: DatabaseInsertListener) { - val removed = listeners.remove(listener) - WeLogger.i("WeDatabaseApi: 监听器移除${if (removed) "成功" else "失败"},当前监听器数量: ${listeners.size}") + fun removeListener(listener: Any) { + var removed = false + + if (listener is InsertListener) { + removed = insertListeners.remove(listener) || removed + } + if (listener is UpdateListener) { + removed = updateListeners.remove(listener) || removed + } + if (listener is QueryListener) { + removed = queryListeners.remove(listener) || removed + } + + if (removed) { + WeLogger.i(TAG, "监听器已移除: ${listener.javaClass.simpleName}") + } } + } override fun entry(classLoader: ClassLoader) { hookDatabaseInsert() + hookDatabaseUpdate() + hookDatabaseQuery() + } + + override fun unload(classLoader: ClassLoader) { + insertListeners.clear() + updateListeners.clear() + queryListeners.clear() + } + + // ==================== 私有辅助方法 ==================== + + private fun shouldLogDatabase(): Boolean { + val config = WeConfig.getDefaultConfig() + return config.getBooleanOrFalse(Constants.PrekVerboseLog) && + config.getBooleanOrFalse(Constants.PrekDatabaseVerboseLog) + } + + private fun formatArgs(args: Array): String { + return args.mapIndexed { index, arg -> + "arg[$index](${arg?.javaClass?.simpleName ?: "null"})=$arg" + }.joinToString(", ") } + private fun logWithStack(methodName: String, table: String, args: Array, result: Any? = null) { + if (!shouldLogDatabase()) return + + val argsInfo = formatArgs(args) + val resultStr = if (result != null) ", result=$result" else "" + val stackStr = ", stack=${WeLogger.getStackTraceString()}" + + WeLogger.logChunkedD(TAG, "[$methodName] table=$table$resultStr, args=[$argsInfo]$stackStr") + } + + // ==================== Insert Hook ==================== + private fun hookDatabaseInsert() { try { val clsSQLite = loadClass(Constants.CLAZZ_SQLITE_DATABASE) - - val mInsertWithOnConflict = XposedHelpers.findMethodExact( + val method = XposedHelpers.findMethodExact( clsSQLite, "insertWithOnConflict", String::class.java, @@ -57,40 +126,163 @@ class WeDatabaseListener : ApiHookItem() { Int::class.javaPrimitiveType ) - hookAfter(mInsertWithOnConflict) { param -> + hookAfter(method) { param -> try { + if (insertListeners.isEmpty()) return@hookAfter + val table = param.args[0] as String val values = param.args[2] as ContentValues + val result = param.result + + logWithStack("Insert", table, param.args, result) + insertListeners.forEach { it.onInsert(table, values) } + } catch (e: Throwable) { + WeLogger.e(TAG, "Insert dispatch failed", e) + } + } + WeLogger.i(TAG, "Insert hook success") + } catch (e: Throwable) { + WeLogger.e(TAG, "Hook insert failed", e) + } + } - val config = WeConfig.getDefaultConfig() - val verboseLog = config.getBooleanOrFalse(Constants.PrekVerboseLog) - val dbVerboseLog = config.getBooleanOrFalse(Constants.PrekDatabaseVerboseLog) - - // 分发事件给所有监听者 - if (listeners.isNotEmpty()) { - if (verboseLog) { - if (dbVerboseLog) { - val argsInfo = param.args.mapIndexed { index, arg -> - "arg[$index](${arg?.javaClass?.simpleName ?: "null"})=$arg" - }.joinToString(", ") - val result = param.result - - WeLogger.logChunkedD("WeDatabaseApi","[Insert] table=$table, result=$result, args=[$argsInfo], stack=${WeLogger.getStackTraceString()}") - } - } - listeners.forEach { it.onInsert(table, values) } + // ==================== Update Hook ==================== + + private fun hookDatabaseUpdate() { + try { + val isPlay = HostInfo.isGooglePlayVersion + val version = HostInfo.getVersionCode() + val isNewVersion = (!isPlay && version >= MMVersion.MM_8_0_43) || + (isPlay && version >= MMVersion.MM_8_0_48_Play) + + val clsName = if (isNewVersion) Constants.CLAZZ_COMPAT_SQLITE_DATABASE else Constants.CLAZZ_SQLITE_DATABASE + val clsSQLite = loadClass(clsName) + + val method = XposedHelpers.findMethodExact( + clsSQLite, + "updateWithOnConflict", + String::class.java, + ContentValues::class.java, + String::class.java, + Array::class.java, + Int::class.javaPrimitiveType + ) + + hookBefore(method) { param -> + try { + if (updateListeners.isEmpty()) return@hookBefore + + val table = param.args[0] as String + val values = param.args[1] as ContentValues + val whereClause = param.args[2] as? String + @Suppress("UNCHECKED_CAST") val whereArgs = param.args[3] as? Array + + logWithStack("Update", table, param.args) + + // 如果有任何一个监听器返回 true,则阻止更新 + val shouldBlock = updateListeners.any { it.onUpdate(table, values) } + + if (shouldBlock) { + param.result = 0 // 返回0表示没有行被更新 + WeLogger.d(TAG, "[Update] 被监听器阻止, table=$table, stack=${WeLogger.getStackTraceString()}") } } catch (e: Throwable) { - WeLogger.e("WeDatabaseApi: Dispatch failed", e) + WeLogger.e(TAG, "Update dispatch failed", e) } } - WeLogger.i("WeDatabaseApi: Hook success") + WeLogger.i(TAG, "Update hook success") } catch (e: Throwable) { - WeLogger.e("WeDatabaseApi: Hook database failed", e) + WeLogger.e(TAG, "Hook update failed", e) } } - override fun unload(classLoader: ClassLoader) { - listeners.clear() + // ==================== Query Hook ==================== + + private fun hookDatabaseQuery() { + try { + val isPlay = HostInfo.isGooglePlayVersion + val version = HostInfo.getVersionCode() + val isNewVersion = (!isPlay && version >= MMVersion.MM_8_0_43) || + (isPlay && version >= MMVersion.MM_8_0_48_Play) + + if (isNewVersion) { + hookNewVersionQuery() + } else { + hookOldVersionQuery() + } + WeLogger.i(TAG, "Query hook success") + } catch (e: Throwable) { + WeLogger.e(TAG, "Hook query failed", e) + } + } + + private fun hookNewVersionQuery() { + val clsSQLite = loadClass(Constants.CLAZZ_COMPAT_SQLITE_DATABASE) + val method = XposedHelpers.findMethodExact( + clsSQLite, + "rawQuery", + String::class.java, + Array::class.java, + ) + + hookBefore(method) { param -> + try { + if (queryListeners.isEmpty()) return@hookBefore + + val sql = param.args[0] as? String ?: return@hookBefore + var currentSql = sql + + logWithStack("rawQuery", "N/A", param.args) + + queryListeners.forEach { listener -> + listener.onQuery(currentSql)?.let { currentSql = it } + } + + if (currentSql != sql) { + param.args[0] = currentSql + WeLogger.d(TAG, "[rawQuery] SQL被修改: $sql -> $currentSql, stack=${WeLogger.getStackTraceString()}") + } + } catch (e: Throwable) { + WeLogger.e(TAG, "New version query dispatch failed", e) + } + } + } + + private fun hookOldVersionQuery() { + val clsSQLite = loadClass(Constants.CLAZZ_SQLITE_DATABASE) + val cursorFactoryClass = loadClass("com.tencent.wcdb.database.SQLiteDatabase\$CursorFactory") + val cancellationSignalClass = loadClass("com.tencent.wcdb.support.CancellationSignal") + + val method = XposedHelpers.findMethodExact( + clsSQLite, + "rawQueryWithFactory", + cursorFactoryClass, + String::class.java, + Array::class.java, + String::class.java, + cancellationSignalClass + ) + + hookBefore(method) { param -> + try { + if (queryListeners.isEmpty()) return@hookBefore + + val sql = param.args[1] as? String ?: return@hookBefore + var currentSql = sql + + logWithStack("rawQueryWithFactory", param.args[3] as? String ?: "N/A", param.args) + + queryListeners.forEach { listener -> + listener.onQuery(currentSql)?.let { currentSql = it } + } + + if (currentSql != sql) { + param.args[1] = currentSql + WeLogger.d(TAG, "[rawQueryWithFactory] SQL被修改: $sql -> $currentSql, stack=${WeLogger.getStackTraceString()}") + } + } catch (e: Throwable) { + WeLogger.e(TAG, "Old version query dispatch failed", e) + } + } } } \ No newline at end of file diff --git a/app/src/main/java/moe/ouom/wekit/hooks/sdk/api/WeMessageApi.kt b/app/src/main/java/moe/ouom/wekit/hooks/sdk/api/WeMessageApi.kt index 29f6be5..c5067cb 100644 --- a/app/src/main/java/moe/ouom/wekit/hooks/sdk/api/WeMessageApi.kt +++ b/app/src/main/java/moe/ouom/wekit/hooks/sdk/api/WeMessageApi.kt @@ -661,8 +661,8 @@ class WeMessageApi : ApiHookItem(), IDexFind { /** 发送私有路径下的语音文件 */ fun sendVoice(toUser: String, path: String, durationMs: Int): Boolean { return try { - val selfWxid = getSelfAlias() - if (selfWxid.isEmpty()) throw IllegalStateException("无法获取 Wxid") + val selfWxAlias = getSelfAlias() + if (selfWxAlias.isEmpty()) throw IllegalStateException("无法获取 WxAlias") // 获取 Service 实例 val serviceInterface = voiceServiceInterfaceClass ?: throw IllegalStateException("VoiceService interface not found") @@ -692,7 +692,7 @@ class WeMessageApi : ApiHookItem(), IDexFind { if (finalServiceObj == null) throw IllegalStateException("无法获取 VoiceService 实例") // 准备文件 - val fileName = voiceNameGenMethod?.invoke(null, selfWxid, "amr_") as? String ?: throw IllegalStateException("VoiceName Gen Failed") + val fileName = voiceNameGenMethod?.invoke(null, selfWxAlias, "amr_") as? String ?: throw IllegalStateException("VoiceName Gen Failed") val accPath = getAccPath() val voice2Root = if (accPath.endsWith("/")) "${accPath}voice2/" else "$accPath/voice2/" val destFullPath = pathGenMethod?.invoke(null, voice2Root, "msg_", fileName, ".amr", 2) as? String ?: throw IllegalStateException("Path Gen Failed") @@ -762,7 +762,6 @@ class WeMessageApi : ApiHookItem(), IDexFind { fun getSelfAlias(): String { return getSelfAliasMethod?.invoke(null) as? String ?: "" } - private fun bindServiceFramework() { val smClazz = dexClassServiceManager.clazz getServiceMethod = smClazz.declaredMethods.firstOrNull { diff --git a/app/src/main/java/moe/ouom/wekit/hooks/sdk/protocol/listener/WePkgDispatcher.kt b/app/src/main/java/moe/ouom/wekit/hooks/sdk/protocol/listener/WePkgDispatcher.kt index 3df774a..a3107c0 100644 --- a/app/src/main/java/moe/ouom/wekit/hooks/sdk/protocol/listener/WePkgDispatcher.kt +++ b/app/src/main/java/moe/ouom/wekit/hooks/sdk/protocol/listener/WePkgDispatcher.kt @@ -17,7 +17,7 @@ import kotlin.math.min @HookItem(path = "protocol/wepkg_dispatcher", desc = "WePkg 请求/响应数据包拦截与篡改") class WePkgDispatcher : ApiHookItem(), IDexFind { private val dexClsOnGYNetEnd by dexClass() - // 缓存最近10条记录,避免无限递归 + // 缓存最近10条记录,避免因脚本引起的无限递归 private val recentRequests = ConcurrentHashMap() override fun entry(classLoader: ClassLoader) { @@ -46,10 +46,10 @@ class WePkgDispatcher : ApiHookItem(), IDexFind { // 构造唯一标识符 val key = "$cgiId|$uri|${reqWrapper?.javaClass?.name}|${reqPbObj?.javaClass?.name}|${reqBytes.contentToString()}" - // 检查是否在缓存中且时间间隔小于2秒 + // 检查是否在缓存中且时间间隔小于500毫秒 val currentTime = System.currentTimeMillis() val lastTime = recentRequests[key] - if (lastTime != null && currentTime - lastTime < 2000) { + if (lastTime != null && currentTime - lastTime < 500) { // 直接返回,不执行任何请求处理 WeLogger.i("PkgDispatcher", "Request skipped (duplicate): $uri") return@hookBefore diff --git a/app/src/main/java/moe/ouom/wekit/host/HostInfo.java b/app/src/main/java/moe/ouom/wekit/host/HostInfo.java index d6f4693..3acef47 100644 --- a/app/src/main/java/moe/ouom/wekit/host/HostInfo.java +++ b/app/src/main/java/moe/ouom/wekit/host/HostInfo.java @@ -62,11 +62,11 @@ public static boolean isAndroidxFileProviderAvailable() { return moe.ouom.wekit.host.impl.HostInfo.isAndroidxFileProviderAvailable(); } - public static boolean isWeChat() { - return moe.ouom.wekit.host.impl.HostInfo.isWeChat(); - } + public static boolean isWeChat = moe.ouom.wekit.host.impl.HostInfo.isWeChat(); + + public static boolean isGooglePlayVersion = moe.ouom.wekit.host.impl.HostInfo.isGooglePlayVersion(); public static boolean requireMinWeChatVersion(int versionCode) { - return isWeChat() && getLongVersionCode() >= versionCode; + return isWeChat && getLongVersionCode() >= versionCode; } } \ No newline at end of file diff --git a/app/src/main/java/moe/ouom/wekit/host/impl/HostInfo.kt b/app/src/main/java/moe/ouom/wekit/host/impl/HostInfo.kt index f39c403..6332ff7 100644 --- a/app/src/main/java/moe/ouom/wekit/host/impl/HostInfo.kt +++ b/app/src/main/java/moe/ouom/wekit/host/impl/HostInfo.kt @@ -75,6 +75,12 @@ val isAndroidxFileProviderAvailable: Boolean by lazy { } } +val isGooglePlayVersion = runCatching { + Class.forName("com.tencent.mm.boot.BuildConfig") + .getField("BUILD_TAG") + .get(null) as? String +}.getOrNull()?.contains("GP", true) ?: false + data class HostInfoImpl( val application: Application, val packageName: String, diff --git a/app/src/main/java/moe/ouom/wekit/loader/core/hooks/ActivityProxyHooks.java b/app/src/main/java/moe/ouom/wekit/loader/core/hooks/ActivityProxyHooks.java index e778a2b..5c0da4c 100644 --- a/app/src/main/java/moe/ouom/wekit/loader/core/hooks/ActivityProxyHooks.java +++ b/app/src/main/java/moe/ouom/wekit/loader/core/hooks/ActivityProxyHooks.java @@ -12,42 +12,31 @@ import android.content.pm.ActivityInfo; import android.content.pm.ApplicationInfo; import android.content.pm.PackageManager; -import android.os.Bundle; -import android.os.Handler; -import android.os.IBinder; -import android.os.Looper; -import android.os.Message; -import android.os.PersistableBundle; -import android.os.TestLooperManager; +import android.os.*; import android.view.KeyEvent; import android.view.MotionEvent; - import androidx.annotation.NonNull; import androidx.annotation.Nullable; +import lombok.SneakyThrows; +import moe.ouom.wekit.config.RuntimeConfig; +import moe.ouom.wekit.constants.PackageConstants; +import moe.ouom.wekit.util.common.ModuleRes; +import moe.ouom.wekit.util.log.WeLogger; -import java.lang.reflect.Field; -import java.lang.reflect.InvocationHandler; -import java.lang.reflect.InvocationTargetException; -import java.lang.reflect.Method; -import java.lang.reflect.Proxy; +import java.lang.reflect.*; import java.util.List; import java.util.Map; import java.util.Objects; import java.util.UUID; import java.util.concurrent.ConcurrentHashMap; -import lombok.SneakyThrows; -import moe.ouom.wekit.config.RuntimeConfig; -import moe.ouom.wekit.constants.PackageConstants; -import moe.ouom.wekit.util.common.ModuleRes; -import moe.ouom.wekit.util.log.WeLogger; - /** * Activity 占位 Hook 实现 * 允许模块启动未在宿主 Manifest 中注册的 Activity */ public class ActivityProxyHooks { + private static final String TAG = "ActivityProxyHooks"; private static boolean __stub_hooked = false; public static class ActProxyMgr { @@ -82,7 +71,10 @@ public static void initForStubActivity(Context ctx) { mInstrumentation.setAccessible(true); Instrumentation instrumentation = (Instrumentation) mInstrumentation.get(sCurrentActivityThread); if (!(instrumentation instanceof ProxyInstrumentation)) { - mInstrumentation.set(sCurrentActivityThread, new ProxyInstrumentation(instrumentation)); + // 创建代理对象 + ProxyInstrumentation proxy = new ProxyInstrumentation(instrumentation); + // 替换掉系统的实例 + mInstrumentation.set(sCurrentActivityThread, proxy); } // Hook Handler (mH) @@ -119,6 +111,7 @@ private static void hookIActivityManager() throws Exception { gDefaultField = activityManagerClass.getDeclaredField("gDefault"); } catch (Exception err1) { activityManagerClass = Class.forName("android.app.ActivityManager"); + //noinspection JavaReflectionMemberAccess gDefaultField = activityManagerClass.getDeclaredField("IActivityManagerSingleton"); } gDefaultField.setAccessible(true); @@ -133,7 +126,8 @@ private static void hookIActivityManager() throws Exception { Method getMethod = singletonClass.getDeclaredMethod("get"); getMethod.setAccessible(true); getMethod.invoke(gDefault); - } catch (Exception ignored) {} + } catch (Exception ignored) { + } Object mInstance = mInstanceField.get(gDefault); if (mInstance == null) { @@ -171,6 +165,7 @@ private static void hookIActivityManager() throws Exception { } } + @SuppressLint("PrivateApi") private static void hookPackageManager(Context ctx, Object sCurrentActivityThread, Class clazz_ActivityThread) { try { Field sPackageManagerField = clazz_ActivityThread.getDeclaredField("sPackageManager"); @@ -217,8 +212,7 @@ public Object invoke(Object proxy, Method method, Object[] args) throws Throwabl if (shouldProxy(raw)) { args[i] = createTokenWrapper(raw); } - } - else if (args[i] instanceof Intent[] rawIntents) { + } else if (args[i] instanceof Intent[] rawIntents) { for (int j = 0; j < rawIntents.length; j++) { if (shouldProxy(rawIntents[j])) { rawIntents[j] = createTokenWrapper(rawIntents[j]); @@ -429,8 +423,7 @@ private Intent tryRecoverIntent(Intent intent) { */ @SneakyThrows @Override - public Activity newActivity(ClassLoader cl, String className, Intent intent) - throws InstantiationException, IllegalAccessException, ClassNotFoundException { + public Activity newActivity(ClassLoader cl, String className, Intent intent) { // 兜底:如果 intent 仍然是 stub 的 wrapper,尝试还原 Intent recovered = tryRecoverIntent(intent); @@ -445,7 +438,7 @@ public Activity newActivity(ClassLoader cl, String className, Intent intent) } catch (ClassNotFoundException e) { if (ActProxyMgr.isModuleProxyActivity(className)) { ClassLoader moduleCL = Objects.requireNonNull(getClass().getClassLoader()); - return (Activity) moduleCL.loadClass(className).newInstance(); + return (Activity) moduleCL.loadClass(className).getDeclaredConstructor().newInstance(); } throw e; } @@ -471,10 +464,11 @@ public void callActivityOnCreate(Activity activity, Bundle icicle) { ClassLoader hybridCL = ParcelableFixer.getHybridClassLoader(); if (hybridCL != null) { try { - Field f = Activity.class.getDeclaredField("mClassLoader"); + @SuppressWarnings("JavaReflectionMemberAccess") Field f = Activity.class.getDeclaredField("mClassLoader"); f.setAccessible(true); f.set(activity, hybridCL); - } catch (Throwable ignored) {} + } catch (Throwable ignored) { + } Intent intent = activity.getIntent(); if (intent != null) { @@ -762,7 +756,7 @@ public void callActivityOnSaveInstanceState(@NonNull Activity activity, @NonNull } @Override - public void callActivityOnSaveInstanceState(@NonNull Activity activity, @NonNull Bundle outState, PersistableBundle outPersistentState) { + public void callActivityOnSaveInstanceState(@NonNull Activity activity, @NonNull Bundle outState, @NonNull PersistableBundle outPersistentState) { mBase.callActivityOnSaveInstanceState(activity, outState, outPersistentState); } @@ -776,11 +770,15 @@ public void callActivityOnUserLeaving(Activity activity) { mBase.callActivityOnUserLeaving(activity); } + @SuppressWarnings("deprecation") + @Deprecated @Override public void startAllocCounting() { mBase.startAllocCounting(); } + @SuppressWarnings("deprecation") + @Deprecated @Override public void stopAllocCounting() { mBase.stopAllocCounting(); @@ -887,7 +885,11 @@ private static class IntentTokenCache { private static class Entry { Intent intent; long timestamp; - Entry(Intent i) { intent = i; timestamp = System.currentTimeMillis(); } + + Entry(Intent i) { + intent = i; + timestamp = System.currentTimeMillis(); + } } private static final Map sCache = new ConcurrentHashMap<>(); diff --git a/app/src/main/java/moe/ouom/wekit/ui/creator/center/DexFinderDialog.kt b/app/src/main/java/moe/ouom/wekit/ui/creator/center/DexFinderDialog.kt index 16cac78..de37de8 100644 --- a/app/src/main/java/moe/ouom/wekit/ui/creator/center/DexFinderDialog.kt +++ b/app/src/main/java/moe/ouom/wekit/ui/creator/center/DexFinderDialog.kt @@ -158,7 +158,7 @@ class DexFinderDialog( private suspend fun performParallelScanning() = withContext(Dispatchers.IO) { val dexKit = DexKitBridge.create(appInfo.sourceDir) - try { + dexKit.use { dexKit -> // 创建进度更新 Channel val progressChannel = Channel(Channel.UNLIMITED) @@ -187,8 +187,6 @@ class DexFinderDialog( withContext(Dispatchers.Main) { handleScanResults(results) } - } finally { - dexKit.close() } } From df8b924dff3291712ffb13d8527392da1e9d216c Mon Sep 17 00:00:00 2001 From: huajiqaq Date: Fri, 20 Feb 2026 22:06:40 +0800 Subject: [PATCH 6/7] =?UTF-8?q?=E6=96=B0=E5=A2=9E=E9=83=A8=E5=88=86ApiHook?= =?UTF-8?q?Item=20=E6=96=B0=E5=A2=9E=E9=83=A8=E5=88=86=E8=84=9A=E6=9C=AC?= =?UTF-8?q?=E6=8E=A5=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- SCRIPT_API_DOCUMENT.md | 120 ++++++++--- .../hooks/item/script/ScriptConfigHookItem.kt | 2 +- .../wekit/hooks/item/script/WeApiUtils.kt | 28 +++ .../wekit/hooks/item/script/WeMessageUtils.kt | 13 -- .../hooks/sdk/ui/WeChatChatContextMenuApi.kt | 170 ++++++++++++++++ .../ui/WeChatContactInfoAdapterItemHook.kt | 184 +++++++++++++++++ .../hooks/sdk/ui/WeChatSnsContextMenuApi.kt | 191 ++++++++++++++++++ .../moe/ouom/wekit/util/script/JsExecutor.kt | 14 ++ 8 files changed, 685 insertions(+), 37 deletions(-) create mode 100644 app/src/main/java/moe/ouom/wekit/hooks/item/script/WeApiUtils.kt create mode 100644 app/src/main/java/moe/ouom/wekit/hooks/sdk/ui/WeChatChatContextMenuApi.kt create mode 100644 app/src/main/java/moe/ouom/wekit/hooks/sdk/ui/WeChatContactInfoAdapterItemHook.kt create mode 100644 app/src/main/java/moe/ouom/wekit/hooks/sdk/ui/WeChatSnsContextMenuApi.kt diff --git a/SCRIPT_API_DOCUMENT.md b/SCRIPT_API_DOCUMENT.md index ef5889b..a158f1a 100644 --- a/SCRIPT_API_DOCUMENT.md +++ b/SCRIPT_API_DOCUMENT.md @@ -16,7 +16,10 @@ - [概述](#概述) - [WEKit Log 函数](#wekit-log-函数) - [WEKit isMMAtLeast 函数](#wekit-ismmatleast-函数) + - [WEKit isGooglePlayVersion 函数](#wekit-isgoogleplayversion-函数) - [WEKit sendCgi 函数](#wekit-sendcgi-函数) + - [WEKit getSelfWxId 函数](#wekit-getselfwxid-函数) + - [WEKit getSelfAlias 函数](#wekit-getselfalias-函数) - [WEKit proto 对象](#wekit-proto-对象) - [WEKit database 对象](#wekit-database-对象) - [WEKit message 对象](#wekit-message-对象) @@ -135,6 +138,54 @@ function onRequest(data) { } ``` +### WEKit isGooglePlayVersion 函数 + +`wekit.isGooglePlayVersion` 是 `wekit` 对象提供的 Google Play 版本判断函数。 + +#### 使用方法 + +```javascript +wekit.isGooglePlayVersion(); +``` + +#### 返回值 + +| 类型 | 描述 | +| ------- | ----------------------------------------- | +| boolean | 如果是 Google Play 版本则返回 true,否则返回 false | + +#### 示例 + +```javascript +function onRequest(data) { + if (wekit.isGooglePlayVersion()) { + wekit.log("当前是 Google Play 版本"); + // Google Play 版本的特定逻辑 + } else { + wekit.log("当前是非 Google Play 版本"); + // 普通版本的特定逻辑 + } +} +``` + +#### 结合版本判断使用 + +```javascript +function onRequest(data) { + if (wekit.isGooglePlayVersion()) { + // Google Play 版本逻辑 + if (wekit.isMMAtLeast("MM_8_0_48_Play")) { + wekit.log("Google Play 8.0.48 及以上版本"); + } + } else { + // 普通版本逻辑 + if (wekit.isMMAtLeast("MM_8_0_90")) { + wekit.log("普通版 8.0.90 及以上版本"); + } + } +} +``` + ### WEKit sendCgi 函数 `wekit.sendCgi` 是 `wekit` 对象提供的发送异步无返回值的 CGI 请求函数。 @@ -164,6 +215,52 @@ function onRequest(data) { } ``` +#### wekit.getSelfWxId + +获取当前用户的微信id。 + +```javascript +wekit.getSelfWxId(); +``` + +##### 返回值 + +| 类型 | 描述 | +| ------ | ---------------- | +| string | 当前用户的微信id | + +##### 示例 + +```javascript +function onRequest(data) { + const wxid = wekit.getSelfWxId(); + wekit.log('当前用户微信id:', wxid); +} +``` + +#### wekit.getSelfAlias + +获取当前用户的微信号。 + +```javascript +wekit.getSelfAlias(); +``` + +##### 返回值 + +| 类型 | 描述 | +| ------ | ---------------- | +| string | 当前用户的微信号 | + +##### 示例 + +```javascript +function onRequest(data) { + const alias = wekit.message.getSelfAlias(); + wekit.log('当前用户微信号:', alias); +} +``` + ### WEKit proto 对象 `wekit.proto` 是 `wekit` 对象提供的处理 JSON 数据的工具对象。 @@ -630,29 +727,6 @@ function onRequest(data) { } ``` -#### wekit.message.getSelfAlias - -获取当前用户的微信号。 - -```javascript -wekit.message.getSelfAlias(); -``` - -##### 返回值 - -| 类型 | 描述 | -| ------ | ---------------- | -| string | 当前用户的微信号 | - -##### 示例 - -```javascript -function onRequest(data) { - const alias = wekit.message.getSelfAlias(); - wekit.log('当前用户微信号:', alias); -} -``` - ## 注意事项 1. 日志输出将显示在脚本日志查看器中 diff --git a/app/src/main/java/moe/ouom/wekit/hooks/item/script/ScriptConfigHookItem.kt b/app/src/main/java/moe/ouom/wekit/hooks/item/script/ScriptConfigHookItem.kt index c4b7e2b..0e2e93c 100644 --- a/app/src/main/java/moe/ouom/wekit/hooks/item/script/ScriptConfigHookItem.kt +++ b/app/src/main/java/moe/ouom/wekit/hooks/item/script/ScriptConfigHookItem.kt @@ -31,7 +31,7 @@ class ScriptConfigHookItem : BaseClickableFunctionHookItem(), IWePkgInterceptor override fun entry(classLoader: ClassLoader) { // 注入脚本接口 - JsExecutor.getInstance().injectScriptInterfaces(::sendCgi, WeProtoUtils, WeDataBaseUtils, WeMessageUtils) + JsExecutor.getInstance().injectScriptInterfaces(::sendCgi, WeApiUtils, WeProtoUtils, WeDataBaseUtils, WeMessageUtils) // 注册拦截器 WePkgManager.addInterceptor(this) } diff --git a/app/src/main/java/moe/ouom/wekit/hooks/item/script/WeApiUtils.kt b/app/src/main/java/moe/ouom/wekit/hooks/item/script/WeApiUtils.kt new file mode 100644 index 0000000..f127ebf --- /dev/null +++ b/app/src/main/java/moe/ouom/wekit/hooks/item/script/WeApiUtils.kt @@ -0,0 +1,28 @@ +@file:Suppress("unused") + +package moe.ouom.wekit.hooks.item.script + +import moe.ouom.wekit.hooks.sdk.protocol.WeApi +import moe.ouom.wekit.util.log.WeLogger + +object WeApiUtils { + private const val TAG = "WeApiUtils" + + fun getSelfWxId(): String { + return try { + WeApi.getSelfWxId() + } catch (e: Exception) { + WeLogger.e(TAG, "获取当前微信id失败: ${e.message}") + "" + } + } + + fun getSelfAlias(): String { + return try { + WeApi.getSelfAlias() + } catch (e: Exception) { + WeLogger.e(TAG, "获取当前微信号失败: ${e.message}") + "" + } + } +} \ No newline at end of file diff --git a/app/src/main/java/moe/ouom/wekit/hooks/item/script/WeMessageUtils.kt b/app/src/main/java/moe/ouom/wekit/hooks/item/script/WeMessageUtils.kt index 8904b2a..a380558 100644 --- a/app/src/main/java/moe/ouom/wekit/hooks/item/script/WeMessageUtils.kt +++ b/app/src/main/java/moe/ouom/wekit/hooks/item/script/WeMessageUtils.kt @@ -93,17 +93,4 @@ object WeMessageUtils { false } } - - /** - * 获取当前用户ID - * @return 当前用户ID - */ - fun getSelfAlias(): String { - return try { - instance?.getSelfAlias() ?: "" - } catch (e: Exception) { - WeLogger.e(TAG, "获取当前用户ID失败: ${e.message}") - "" - } - } } \ No newline at end of file diff --git a/app/src/main/java/moe/ouom/wekit/hooks/sdk/ui/WeChatChatContextMenuApi.kt b/app/src/main/java/moe/ouom/wekit/hooks/sdk/ui/WeChatChatContextMenuApi.kt new file mode 100644 index 0000000..3f3b847 --- /dev/null +++ b/app/src/main/java/moe/ouom/wekit/hooks/sdk/ui/WeChatChatContextMenuApi.kt @@ -0,0 +1,170 @@ +package moe.ouom.wekit.hooks.sdk.ui + +import android.annotation.SuppressLint +import android.graphics.drawable.Drawable +import android.view.MenuItem +import android.view.View +import de.robv.android.xposed.XC_MethodHook +import moe.ouom.wekit.core.dsl.dexMethod +import moe.ouom.wekit.core.model.ApiHookItem +import moe.ouom.wekit.dexkit.intf.IDexFind +import moe.ouom.wekit.hooks.core.annotation.HookItem +import moe.ouom.wekit.util.log.WeLogger +import org.luckypray.dexkit.DexKitBridge +import org.luckypray.dexkit.query.enums.StringMatchType +import java.util.concurrent.CopyOnWriteArrayList + +@HookItem(path = "API/聊天右键菜单增强") +class WeChatChatContextMenuApi : ApiHookItem(), IDexFind { + + private val dexMethodOnCreateMenu by dexMethod() + private val dexMethodOnItemSelected by dexMethod() + + companion object { + private const val TAG = "ChatMenuApi" + private lateinit var thisView: View + private lateinit var rawMessage: Map + val onCreateCallbacks = CopyOnWriteArrayList() + val onSelectCallbacks = CopyOnWriteArrayList() + + fun addOnCreateListener(listener: OnCreateListener) { + onCreateCallbacks.add(listener) + } + + fun removeOnCreateListener(listener: OnCreateListener) { + onCreateCallbacks.remove(listener) + } + + fun addOnSelectListener(listener: OnSelectListener) { + onSelectCallbacks.add(listener) + } + + fun removeOnSelectListener(listener: OnSelectListener) { + onSelectCallbacks.remove(listener) + } + } + + /** + * 接口:创建菜单后触发 + */ + fun interface OnCreateListener { + fun onCreated(messageInfo: Map): MenuInfoItem? + } + + /** + * 接口:选中菜单时触发 + */ + fun interface OnSelectListener { + fun onSelected(id: Int, messageInfo: Map, view: View): Boolean + } + + data class MenuInfoItem(val id: Int, val title: String, val iconDrawable: Drawable) + + @SuppressLint("NonUniqueDexKitData") + override fun dexFind(dexKit: DexKitBridge): Map { + val descriptors = mutableMapOf() + + dexMethodOnCreateMenu.find(dexKit, allowMultiple = false, descriptors = descriptors) { + searchPackages("com.tencent.mm.ui.chatting.viewitems") + matcher { + usingStrings(listOf("MicroMsg.ChattingItem", "msg is null!"), StringMatchType.Equals, false) + } + } + + dexMethodOnItemSelected.find(dexKit, allowMultiple = false, descriptors = descriptors) { + searchPackages("com.tencent.mm.ui.chatting.viewitems") + matcher { + usingStrings( + "MicroMsg.ChattingItem", "context item select failed, null dataTag" + ) + } + } + + return descriptors + } + + override fun entry(classLoader: ClassLoader) { + // Hook OnCreate + hookAfter(dexMethodOnCreateMenu.method) { param -> + handleCreateMenu(param) + } + + // Hook OnSelected + hookAfter(dexMethodOnItemSelected.method) { param -> + handleSelectMenu(param) + } + } + + private fun getRawMessage(item: Any): Map = runCatching { + // 可能会变更 + val messageTagClass = item::class.java.superclass + val messageHolderClass = messageTagClass.superclass + val messageField = messageHolderClass.getField("a") + val messageObject = messageField.get(item) + val messageImplClass = messageObject::class.java + val messageWrapperClass = messageImplClass.superclass + val databaseMappingClass = messageWrapperClass.superclass + val databaseFields = databaseMappingClass.declaredFields.filter { field -> field.name.startsWith("field_") } + .onEach { field -> field.isAccessible = true }.associate { field -> + field.name to field.get(messageObject) + } + + databaseFields + }.onFailure { WeLogger.e(TAG, "获取rawMessage失败: ${it.message}") }.getOrDefault(emptyMap()) + + private fun handleCreateMenu(param: XC_MethodHook.MethodHookParam) { + try { + val chatContextMenu = param.args[0] + val addMethod = chatContextMenu::class.java.declaredMethods.first { + it.parameterTypes.contentEquals( + arrayOf( + Int::class.java, + CharSequence::class.java, + Drawable::class.java + ) + ) && it.returnType == MenuItem::class.java + } + + thisView = param.args[1] as View + val item = thisView.tag + /* + val messageTagClass = item::class.java.superclass + val positionMethod = messageTagClass.declaredMethods.first { + it.returnType === Int::class.java + } + val position = positionMethod.invoke(item) + */ + + rawMessage = getRawMessage(item) + for (listener in onCreateCallbacks) { + val item = listener.onCreated(rawMessage) + try { + item?.let { addMethod.invoke(chatContextMenu, it.id, it.title, it.iconDrawable) } + } catch (e: Exception) { + WeLogger.e(TAG, "添加条目失败: ${e.message}") + } + } + } catch (e: Throwable) { + WeLogger.e(TAG, "handleCreateMenu 失败", e) + } + } + + private fun handleSelectMenu(param: XC_MethodHook.MethodHookParam) { + try { + val menuItem = param.args[0] as MenuItem + val id = menuItem.itemId + for (listener in onSelectCallbacks) { + try { + val handled = listener.onSelected(id, rawMessage, thisView) + if (handled) { + WeLogger.d(TAG, "菜单项已被动态回调处理") + } + } catch (e: Throwable) { + WeLogger.e(TAG, "OnSelect 回调执行异常", e) + } + } + } catch (e: Throwable) { + WeLogger.e(TAG, "handleSelectMenu 失败", e) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/moe/ouom/wekit/hooks/sdk/ui/WeChatContactInfoAdapterItemHook.kt b/app/src/main/java/moe/ouom/wekit/hooks/sdk/ui/WeChatContactInfoAdapterItemHook.kt new file mode 100644 index 0000000..6c1980a --- /dev/null +++ b/app/src/main/java/moe/ouom/wekit/hooks/sdk/ui/WeChatContactInfoAdapterItemHook.kt @@ -0,0 +1,184 @@ +package moe.ouom.wekit.hooks.sdk.ui + +import android.app.Activity +import android.content.Context +import android.widget.BaseAdapter +import de.robv.android.xposed.XC_MethodHook +import de.robv.android.xposed.XposedBridge +import de.robv.android.xposed.XposedHelpers +import moe.ouom.wekit.core.model.ApiHookItem +import moe.ouom.wekit.hooks.core.annotation.HookItem +import moe.ouom.wekit.util.Initiator.loadClass +import moe.ouom.wekit.util.log.WeLogger +import java.lang.reflect.Constructor +import java.lang.reflect.Field +import java.lang.reflect.Method +import java.lang.reflect.Modifier +import java.util.concurrent.CopyOnWriteArrayList + +@HookItem(path = "API/用户联系页面扩展") +class WeChatContactInfoAdapterItemHook : ApiHookItem() { + + companion object { + private const val TAG = "ContactInfoAdapterItemHook" + private val initCallbacks = CopyOnWriteArrayList() + private val clickListeners = CopyOnWriteArrayList() + + @Volatile + private var isRefInitialized = false + private lateinit var prefConstructor: Constructor<*> + private lateinit var prefKeyField: Field + private lateinit var adapterField: Field + private lateinit var onPreferenceTreeClickMethod: Method + private lateinit var addPreferenceMethod: Method + private lateinit var setKeyMethod: Method + private lateinit var setSummaryMethod: Method + private lateinit var setTitleMethod: Method + + + fun addInitCallback(callback: InitContactInfoViewCallback) { + initCallbacks.add(callback) + } + + fun removeInitCallback(callback: InitContactInfoViewCallback) { + initCallbacks.remove(callback) + } + + fun addClickListener(listener: OnContactInfoItemClickListener) { + clickListeners.add(listener) + } + + fun removeClickListener(listener: OnContactInfoItemClickListener) { + clickListeners.remove(listener) + } + + } + + fun interface InitContactInfoViewCallback { + fun onInitContactInfoView(context: Activity): ContactInfoItem? + } + + fun interface OnContactInfoItemClickListener { + fun onItemClick(activity: Activity, key: String): Boolean + } + + + data class ContactInfoItem(val key: String, val title: String, val summary: String? = null, val position: Int = -1) + + override fun entry(classLoader: ClassLoader) { + initReflection() + hook(classLoader) + hookItemClick(classLoader) + } + + private fun initReflection() { + if (isRefInitialized) return + + synchronized(this) { + if (isRefInitialized) return + + val prefClass = loadClass("com.tencent.mm.ui.base.preference.Preference") + prefConstructor = prefClass.getConstructor(Context::class.java) + prefKeyField = prefClass.declaredFields.first { field -> + field.type == String::class.java && !Modifier.isFinal(field.modifiers) + } + + val contactInfoUIClass = loadClass("com.tencent.mm.plugin.profile.ui.ContactInfoUI") + adapterField = contactInfoUIClass.superclass.declaredFields.first { + BaseAdapter::class.java.isAssignableFrom(it.type) + }.apply { isAccessible = true } + onPreferenceTreeClickMethod = contactInfoUIClass.declaredMethods.first { + it.name == "onPreferenceTreeClick" + } + + val adapterClass = adapterField.type + addPreferenceMethod = adapterClass.declaredMethods.first { + !Modifier.isFinal(it.modifiers) + && it.parameterCount == 2 && + it.parameterTypes[0] == prefClass + && it.parameterTypes[1] == Int::class.java + } + + setKeyMethod = prefClass.declaredMethods.first { + it.parameterCount == 1 && it.parameterTypes[0] == String::class.java + } + + val charSeqMethods = prefClass.declaredMethods.filter { + it.parameterCount == 1 && it.parameterTypes[0] == CharSequence::class.java + } + + // 可能需要之后维护 不稳定的方法 + setSummaryMethod = charSeqMethods.getOrElse(0) { + throw RuntimeException("setTitle method not found") + } + setTitleMethod = charSeqMethods.getOrElse(1) { + throw RuntimeException("setSummary method not found") + } + + isRefInitialized = true + WeLogger.i( + TAG, """ + prefConstructor: $prefConstructor + prefKeyField: $prefKeyField + adapterField: $adapterField + onPreferenceTreeClickMethod: $onPreferenceTreeClickMethod + addPreferenceMethod: $addPreferenceMethod + setKeyMethod: $setKeyMethod + setSummaryMethod: $setSummaryMethod + setTitleMethod: $setTitleMethod + """.trimIndent() + ) + WeLogger.i(TAG, "反射初始化完成") + } + } + + fun hook(classLoader: ClassLoader) { + try { + XposedHelpers.findAndHookMethod( + "com.tencent.mm.plugin.profile.ui.ContactInfoUI", + classLoader, + "initView", + object : XC_MethodHook() { + override fun afterHookedMethod(param: MethodHookParam) { + val adapterInstance = adapterField.get(param.thisObject as Activity) + for (listener in initCallbacks) { + val item = listener.onInitContactInfoView(param.thisObject as Activity) + try { + val preference = prefConstructor.newInstance(param.thisObject as Context) + item?.let { + setKeyMethod.invoke(preference, it.key) + setTitleMethod.invoke(preference, it.title) + it.summary?.let { summary -> setSummaryMethod.invoke(preference, summary) } + addPreferenceMethod.invoke(adapterInstance, preference, it.position) + } + } catch (e: Exception) { + WeLogger.e(TAG, "添加条目失败: ${e.message}") + } + } + } + } + ) + + WeLogger.i(TAG, "Hook 注册成功") + } catch (e: Exception) { + WeLogger.e(TAG, "Hook 失败 - ${e.message}", e) + } + } + + private fun hookItemClick(classLoader: ClassLoader) { + XposedBridge.hookMethod(onPreferenceTreeClickMethod, object : XC_MethodHook() { + override fun beforeHookedMethod(param: MethodHookParam) { + val preference = param.args[1] ?: return + val key = prefKeyField.get(preference) as? String + if (key != null) { + for (listener in clickListeners) { + if (listener.onItemClick(param.thisObject as Activity, key)) { + param.result = true + break + } + } + } + } + }) + } +} \ No newline at end of file diff --git a/app/src/main/java/moe/ouom/wekit/hooks/sdk/ui/WeChatSnsContextMenuApi.kt b/app/src/main/java/moe/ouom/wekit/hooks/sdk/ui/WeChatSnsContextMenuApi.kt new file mode 100644 index 0000000..596f50b --- /dev/null +++ b/app/src/main/java/moe/ouom/wekit/hooks/sdk/ui/WeChatSnsContextMenuApi.kt @@ -0,0 +1,191 @@ +package moe.ouom.wekit.hooks.sdk.ui + +import android.annotation.SuppressLint +import android.app.Activity +import android.view.ContextMenu +import android.view.MenuItem +import de.robv.android.xposed.XC_MethodHook +import moe.ouom.wekit.core.dsl.dexMethod +import moe.ouom.wekit.core.model.ApiHookItem +import moe.ouom.wekit.dexkit.intf.IDexFind +import moe.ouom.wekit.hooks.core.annotation.HookItem +import moe.ouom.wekit.util.log.WeLogger +import org.luckypray.dexkit.DexKitBridge +import java.lang.reflect.Modifier +import java.util.concurrent.CopyOnWriteArrayList + +@HookItem(path = "API/朋友圈右键菜单增强") +class WeChatSnsContextMenuApi : ApiHookItem(), IDexFind { + + private val dexMethodOnCreateMenu by dexMethod() + private val dexMethodOnItemSelected by dexMethod() + private val dexMethodSnsInfoStorage by dexMethod() + private val dexMethodGetSnsInfoStorage by dexMethod() + + companion object { + private const val TAG = "SnsMenuApi" + + val onCreateCallbacks = CopyOnWriteArrayList() + val onSelectCallbacks = CopyOnWriteArrayList() + + fun addOnCreateListener(listener: OnCreateListener) { + onCreateCallbacks.add(listener) + } + fun removeOnCreateListener(listener: OnCreateListener) { + onCreateCallbacks.remove(listener) + } + + fun addOnSelectListener(listener: OnSelectListener) { + onSelectCallbacks.add(listener) + } + fun removeOnSelectListener(listener: OnSelectListener) { + onSelectCallbacks.remove(listener) + } + } + + /** + * 接口:创建菜单时触发 + */ + fun interface OnCreateListener { + fun onCreate(contextMenu: ContextMenu) + } + + /** + * 接口:选中菜单时触发 + */ + fun interface OnSelectListener { + fun onSelected(context: SnsContext, itemId: Int): Boolean + } + + + data class SnsContext( + val activity: Activity, + val snsInfo: Any?, + val timeLineObject: Any? + ) + + @SuppressLint("NonUniqueDexKitData") + override fun dexFind(dexKit: DexKitBridge): Map { + val descriptors = mutableMapOf() + + dexMethodOnCreateMenu.find(dexKit, allowMultiple = false, descriptors = descriptors) { + searchPackages("com.tencent.mm.plugin.sns.ui.listener") + matcher { + usingStrings("MicroMsg.TimelineOnCreateContextMenuListener", "onMMCreateContextMenu error") + } + } + + dexMethodOnItemSelected.find(dexKit, allowMultiple = false, descriptors = descriptors) { + searchPackages("com.tencent.mm.plugin.sns.ui.listener") + matcher { + usingStrings( + "delete comment fail!!! snsInfo is null", + "send photo fail, mediaObj is null", + "mediaObj is null, send failed!" + ) + } + } + + dexMethodSnsInfoStorage.find(dexKit, allowMultiple = false, descriptors = descriptors) { + matcher { + paramCount(1) + paramTypes("java.lang.String") + usingStrings( + "getByLocalId", + "com.tencent.mm.plugin.sns.storage.SnsInfoStorage" + ) + returnType("com.tencent.mm.plugin.sns.storage.SnsInfo") + } + } + + dexMethodGetSnsInfoStorage.find(dexKit, allowMultiple = false, descriptors = descriptors) { + searchPackages("com.tencent.mm.plugin.sns.model") + matcher { + // 必须是静态方法 + modifiers = Modifier.STATIC + returnType(dexMethodSnsInfoStorage.method.declaringClass) + // 无参数 + paramCount(0) + // 同时包含两个特征字符串 + usingStrings( + "com.tencent.mm.plugin.sns.model.SnsCore", + "getSnsInfoStorage" + ) + } + } + + return descriptors + } + + override fun entry(classLoader: ClassLoader) { + // Hook OnCreate + hookAfter(dexMethodOnCreateMenu.method) { param -> + handleCreateMenu(param) + } + + // Hook OnSelected + hookAfter(dexMethodOnItemSelected.method) { param -> + handleSelectMenu(param) + } + } + + + private fun handleCreateMenu(param: XC_MethodHook.MethodHookParam) { + try { + val contextMenu = param.args.getOrNull(0) as? ContextMenu ?: return + + for (listener in onCreateCallbacks) { + try { + listener.onCreate(contextMenu) + } catch (e: Throwable) { + WeLogger.e(TAG, "OnCreate 回调执行异常", e) + } + } + } catch (e: Throwable) { + WeLogger.e(TAG, "handleCreateMenu 失败", e) + } + } + + private fun handleSelectMenu(param: XC_MethodHook.MethodHookParam) { + try { + val menuItem = param.args.getOrNull(0) as? MenuItem ?: return + val hookedObject = param.thisObject + val fields = hookedObject.javaClass.declaredFields + fields.forEach { field -> + field.isAccessible = true + val value = field.get(hookedObject) + WeLogger.d(TAG, "字段: ${field.name} (${field.type.name}) = $value") + } + + val activity = fields.firstOrNull { it.type == Activity::class.java } + ?.apply { isAccessible = true }?.get(hookedObject) as Activity + + val timeLineObject = fields.firstOrNull { + it.type.name == "com.tencent.mm.protocal.protobuf.TimeLineObject" + }?.apply { isAccessible = true }?.get(hookedObject) + + val snsID = fields.firstOrNull { + it.type == String::class.java && !Modifier.isFinal(it.modifiers) + }?.apply { isAccessible = true }?.get(hookedObject) as String + val targetMethod = dexMethodSnsInfoStorage.method + val instance = dexMethodGetSnsInfoStorage.method.invoke(null) + val snsInfo = targetMethod.invoke(instance, snsID) + + val context = SnsContext(activity, snsInfo, timeLineObject) + val clickedId = menuItem.itemId + + for (listener in onSelectCallbacks) { + try { + val handled = listener.onSelected(context, clickedId) + if (handled) { + WeLogger.d(TAG, "菜单项 $clickedId 已被动态回调处理") + } + } catch (e: Throwable) { + WeLogger.e(TAG, "OnSelect 回调执行异常", e) + } + } + } catch (e: Throwable) { + WeLogger.e(TAG, "handleSelectMenu 失败", e) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/moe/ouom/wekit/util/script/JsExecutor.kt b/app/src/main/java/moe/ouom/wekit/util/script/JsExecutor.kt index a247bc5..ce0227f 100644 --- a/app/src/main/java/moe/ouom/wekit/util/script/JsExecutor.kt +++ b/app/src/main/java/moe/ouom/wekit/util/script/JsExecutor.kt @@ -5,6 +5,7 @@ import android.content.Context import android.os.Handler import android.os.Looper import moe.ouom.wekit.constants.MMVersion +import moe.ouom.wekit.hooks.item.script.WeApiUtils import moe.ouom.wekit.host.HostInfo import moe.ouom.wekit.util.log.WeLogger import org.mozilla.javascript.ScriptRuntime @@ -172,6 +173,7 @@ class JsExecutor private constructor() { @Suppress("unused") fun injectScriptInterfaces( sendCgi: Any, + weApiUtils: Any, protoUtils: Any, dataBaseUtils: Any, messageUtils: Any @@ -187,14 +189,26 @@ class JsExecutor private constructor() { HostInfo.getVersionCode() >= MMVersion::class.java.getField(field).getInt(null) }.getOrDefault(false) + fun isGooglePlayVersion() = HostInfo.isGooglePlayVersion + fun sendCgi(uri: String, cgiId: Int, funcId: Int, routeId: Int, jsonPayload: String) { sendCgi(uri, cgiId, funcId, routeId, jsonPayload) } + fun getSelfWxId(): String { + return WeApiUtils.getSelfWxId() + } + + fun getSelfAlias(): String { + return WeApiUtils.getSelfAlias() + } + @JvmField val proto: Any = protoUtils + @JvmField val database: Any = dataBaseUtils + @JvmField val message: Any = messageUtils }) From 89d44798af002a13432827698e4f659a07c7e143 Mon Sep 17 00:00:00 2001 From: huajiqaq Date: Fri, 20 Feb 2026 22:07:10 +0800 Subject: [PATCH 7/7] =?UTF-8?q?=E6=96=B0=E5=A2=9E=E9=83=A8=E5=88=86?= =?UTF-8?q?=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../wekit/hooks/item/chat/msg/AntiFoldMsg.kt | 48 ++++ .../item/chat/msg/ModifyMessageDisplayHook.kt | 80 +++++++ .../ouom/wekit/hooks/item/chat/msg/MsgType.kt | 69 ++++++ .../hooks/item/contact/ShowWeChatIdHook.kt | 87 ++++++++ .../wekit/hooks/item/fix/ForceCameraScan.kt | 61 ++++++ .../hooks/item/fix/WeChatArticleAdRemover.kt | 90 ++++++++ .../hooks/item/func/WeAvatarTransparent.kt | 50 +++++ .../ouom/wekit/hooks/item/moment/AntiSnsAd.kt | 35 +++ .../hooks/item/moment/AntiSnsDeleteHook.kt | 98 +++++++++ .../wekit/hooks/item/moment/SnsContentType.kt | 46 ++++ .../hooks/item/moment/SnsLikeModifyHook.kt | 205 ++++++++++++++++++ app/src/main/res/drawable/edit_24px.xml | 10 + 12 files changed, 879 insertions(+) create mode 100644 app/src/main/java/moe/ouom/wekit/hooks/item/chat/msg/AntiFoldMsg.kt create mode 100644 app/src/main/java/moe/ouom/wekit/hooks/item/chat/msg/ModifyMessageDisplayHook.kt create mode 100644 app/src/main/java/moe/ouom/wekit/hooks/item/chat/msg/MsgType.kt create mode 100644 app/src/main/java/moe/ouom/wekit/hooks/item/contact/ShowWeChatIdHook.kt create mode 100644 app/src/main/java/moe/ouom/wekit/hooks/item/fix/ForceCameraScan.kt create mode 100644 app/src/main/java/moe/ouom/wekit/hooks/item/fix/WeChatArticleAdRemover.kt create mode 100644 app/src/main/java/moe/ouom/wekit/hooks/item/func/WeAvatarTransparent.kt create mode 100644 app/src/main/java/moe/ouom/wekit/hooks/item/moment/AntiSnsAd.kt create mode 100644 app/src/main/java/moe/ouom/wekit/hooks/item/moment/AntiSnsDeleteHook.kt create mode 100644 app/src/main/java/moe/ouom/wekit/hooks/item/moment/SnsContentType.kt create mode 100644 app/src/main/java/moe/ouom/wekit/hooks/item/moment/SnsLikeModifyHook.kt create mode 100644 app/src/main/res/drawable/edit_24px.xml diff --git a/app/src/main/java/moe/ouom/wekit/hooks/item/chat/msg/AntiFoldMsg.kt b/app/src/main/java/moe/ouom/wekit/hooks/item/chat/msg/AntiFoldMsg.kt new file mode 100644 index 0000000..5319dcf --- /dev/null +++ b/app/src/main/java/moe/ouom/wekit/hooks/item/chat/msg/AntiFoldMsg.kt @@ -0,0 +1,48 @@ +package moe.ouom.wekit.hooks.item.chat.msg + +import moe.ouom.wekit.core.dsl.dexMethod +import moe.ouom.wekit.core.dsl.resultNull +import moe.ouom.wekit.core.model.BaseSwitchFunctionHookItem +import moe.ouom.wekit.dexkit.intf.IDexFind +import moe.ouom.wekit.hooks.core.annotation.HookItem +import moe.ouom.wekit.util.log.WeLogger +import org.luckypray.dexkit.DexKitBridge + +@HookItem(path = "聊天与消息/防止消息折叠", desc = "阻止聊天消息被折叠") +class AntiFoldMsg : BaseSwitchFunctionHookItem(), IDexFind { + + private val TAG = "AntiFoldMsg" + private val methodFoldMsg by dexMethod() + + override fun dexFind(dexKit: DexKitBridge): Map { + val descriptors = mutableMapOf() + + methodFoldMsg.find(dexKit, descriptors = descriptors) { + matcher { + usingStrings(".msgsource.sec_msg_node.clip-len") + paramTypes( + Int::class.java, + CharSequence::class.java, + null, + Boolean::class.javaPrimitiveType, + null, + null + ) + } + } + + return descriptors + } + + override fun entry(classLoader: ClassLoader) { + // Hook 折叠方法,使其无效 + methodFoldMsg.toDexMethod { + hook { + beforeIfEnabled { param -> + WeLogger.i(TAG, "拦截到消息折叠方法") + param.resultNull() + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/moe/ouom/wekit/hooks/item/chat/msg/ModifyMessageDisplayHook.kt b/app/src/main/java/moe/ouom/wekit/hooks/item/chat/msg/ModifyMessageDisplayHook.kt new file mode 100644 index 0000000..1f17f92 --- /dev/null +++ b/app/src/main/java/moe/ouom/wekit/hooks/item/chat/msg/ModifyMessageDisplayHook.kt @@ -0,0 +1,80 @@ +package moe.ouom.wekit.hooks.item.chat.msg + +import com.afollestad.materialdialogs.MaterialDialog +import com.afollestad.materialdialogs.input.getInputField +import com.afollestad.materialdialogs.input.input +import moe.ouom.wekit.core.model.BaseSwitchFunctionHookItem +import moe.ouom.wekit.hooks.core.annotation.HookItem +import moe.ouom.wekit.hooks.sdk.ui.WeChatChatContextMenuApi +import moe.ouom.wekit.ui.CommonContextWrapper +import moe.ouom.wekit.util.common.ModuleRes +import moe.ouom.wekit.util.log.WeLogger + +@HookItem( + path = "聊天与消息/修改消息显示", + desc = "修改本地消息显示内容" +) +class ModifyMessageDisplayHook : BaseSwitchFunctionHookItem() { + + companion object { + private const val TAG = "ModifyMessageDisplayHook" + private const val PREF_ID = 322424 + } + + private val onCreateMenuCallback = WeChatChatContextMenuApi.OnCreateListener { messageInfo -> + val type = messageInfo["field_type"] as? Int ?: 0 + if (!MsgType.isText(type)) { + return@OnCreateListener null + } + + WeChatChatContextMenuApi.MenuInfoItem( + id = PREF_ID, + title = "修改信息", + iconDrawable = ModuleRes.getDrawable("edit_24px") + ) + } + + private val onSelectMenuCallback = WeChatChatContextMenuApi.OnSelectListener { id, messageInfo, view -> + if (id != PREF_ID) return@OnSelectListener false + val context = view.context ?: return@OnSelectListener false + val wrappedContext = CommonContextWrapper.createAppCompatContext(context) + MaterialDialog(wrappedContext).show { + title(text = "修改消息") + input( + hint = "输入要修改的消息,仅限娱乐。", + waitForPositiveButton = false, + ) + positiveButton(text = "确定") { dialog -> + val inputText = dialog.getInputField().text?.toString() ?: "" + val setTextMethod = view.javaClass.declaredMethods.first { + it.parameterTypes.contentEquals( + arrayOf( + CharSequence::class.java, + ) + ) + } + setTextMethod.invoke(view, inputText) + dialog.dismiss() + } + + negativeButton(text = "取消") + } + return@OnSelectListener true + } + + override fun entry(classLoader: ClassLoader) { + try { + WeChatChatContextMenuApi.addOnCreateListener(onCreateMenuCallback) + WeChatChatContextMenuApi.addOnSelectListener(onSelectMenuCallback) + WeLogger.i(TAG, "修改消息显示 Hook 注册成功") + } catch (e: Exception) { + WeLogger.e(TAG, "注册失败: ${e.message}", e) + } + } + + override fun unload(classLoader: ClassLoader) { + WeChatChatContextMenuApi.removeOnCreateListener(onCreateMenuCallback) + WeChatChatContextMenuApi.removeOnSelectListener(onSelectMenuCallback) + super.unload(classLoader) + } +} \ No newline at end of file diff --git a/app/src/main/java/moe/ouom/wekit/hooks/item/chat/msg/MsgType.kt b/app/src/main/java/moe/ouom/wekit/hooks/item/chat/msg/MsgType.kt new file mode 100644 index 0000000..326789c --- /dev/null +++ b/app/src/main/java/moe/ouom/wekit/hooks/item/chat/msg/MsgType.kt @@ -0,0 +1,69 @@ +package moe.ouom.wekit.hooks.item.chat.msg + +enum class MsgType(val code: Int) { + MOMENTS(0), + TEXT(1), + IMAGE(3), + VOICE(34), + FRIEND_VERIFY(37), + CONTACT_RECOMMEND(40), + CARD(42), + VIDEO(43), + EMOJI(0x2F), + LOCATION(0x30), + APP(49), + VOIP(50), + STATUS(51), + VOIP_NOTIFY(52), + VOIP_INVITE(53), + MICRO_VIDEO(62), + SYSTEM_NOTICE(0x270F), + SYSTEM(10000), + SYSTEM_LOCATION(10002), + SO_GOU_EMOJI(0x100031), + LINK(0x1000031), + RECALL(0x10002710), + SERVICE(0x13000031), + TRANSFER(0x19000031), + RED_PACKET(0x1A000031), + YEAR_RED_PACKET(0x1C000031), + ACCOUNT_VIDEO(0x1D000031), + RED_PACKET_COVER(0x20010031), + VIDEO_ACCOUNT(0x2D000031), + VIDEO_ACCOUNT_CARD(0x2E000031), + GROUP_NOTE(0x30000031), + QUOTE(0x31000031), + PAT(0x37000031), + VIDEO_ACCOUNT_LIVE(0x3A000031), + PRODUCT(0x3A100031), + UNKNOWN(0x3A200031), + MUSIC(0x3E000031), + FILE(0x41000031); + + companion object { + fun fromCode(code: Int): MsgType? = entries.find { it.code == code } + + fun isType(code: Int, vararg types: MsgType): Boolean = + types.any { it.code == code } + + fun isText(code: Int) = code == TEXT.code + fun isImage(code: Int) = code == IMAGE.code + fun isVoice(code: Int) = code == VOICE.code + fun isVideo(code: Int) = code == VIDEO.code + fun isFile(code: Int) = code == FILE.code + fun isApp(code: Int) = code == APP.code + fun isLink(code: Int) = code == LINK.code || code == MUSIC.code || code == PRODUCT.code + fun isRedPacket(code: Int) = code == RED_PACKET.code || code == YEAR_RED_PACKET.code + fun isSystem(code: Int) = code == SYSTEM.code || code == SYSTEM_NOTICE.code + fun isEmoji(code: Int) = code == EMOJI.code || code == SO_GOU_EMOJI.code + fun isLocation(code: Int) = code == LOCATION.code || code == SYSTEM_LOCATION.code + fun isQuote(code: Int) = code == QUOTE.code + fun isPat(code: Int) = code == PAT.code + fun isTransfer(code: Int) = code == TRANSFER.code + fun isGroupNote(code: Int) = code == GROUP_NOTE.code + fun isVideoAccount(code: Int) = code == VIDEO_ACCOUNT.code || code == VIDEO_ACCOUNT_CARD.code || code == VIDEO_ACCOUNT_LIVE.code + fun isCard(code: Int) = code == CARD.code + fun isMoments(code: Int) = code == MOMENTS.code + fun isVoip(code: Int) = code == VOIP.code || code == VOIP_NOTIFY.code || code == VOIP_INVITE.code + } +} \ No newline at end of file diff --git a/app/src/main/java/moe/ouom/wekit/hooks/item/contact/ShowWeChatIdHook.kt b/app/src/main/java/moe/ouom/wekit/hooks/item/contact/ShowWeChatIdHook.kt new file mode 100644 index 0000000..fc33518 --- /dev/null +++ b/app/src/main/java/moe/ouom/wekit/hooks/item/contact/ShowWeChatIdHook.kt @@ -0,0 +1,87 @@ +package moe.ouom.wekit.hooks.item.contact + +import android.app.Activity +import android.content.ClipData +import android.content.Context +import android.widget.Toast +import moe.ouom.wekit.core.model.BaseSwitchFunctionHookItem +import moe.ouom.wekit.hooks.core.annotation.HookItem +import moe.ouom.wekit.hooks.sdk.ui.WeChatContactInfoAdapterItemHook +import moe.ouom.wekit.hooks.sdk.ui.WeChatContactInfoAdapterItemHook.ContactInfoItem +import moe.ouom.wekit.util.log.WeLogger + +@HookItem( + path = "联系人/显示微信ID", + desc = "在联系人页面显示微信ID" +) +class ShowWeChatIdHook : BaseSwitchFunctionHookItem() { + + companion object { + private const val TAG = "ShowWeChatIdHook" + private const val PREF_KEY = "wechat_id_display" + } + + + // 创建初始化回调 + private val initCallback = WeChatContactInfoAdapterItemHook.InitContactInfoViewCallback { activity -> + val wechatId = try { + "微信ID: ${activity.intent.getStringExtra("Contact_User") ?: "未知"}" + } catch (e: Exception) { + WeLogger.e(TAG, "获取微信ID失败", e) + "微信ID: 获取失败" + } + if (wechatId.contains("gh_")) { + WeLogger.d(TAG, "检测到公众号,不处理") + return@InitContactInfoViewCallback null + } + + ContactInfoItem( + key = PREF_KEY, + title = wechatId, + position = 1 + ) + } + + private val clickListener = WeChatContactInfoAdapterItemHook.OnContactInfoItemClickListener { activity, key -> + if (key == PREF_KEY) { + handleWeChatIdClick(activity) + true + } else { + false + } + } + + override fun entry(classLoader: ClassLoader) { + try { + // 添加初始化回调 + WeChatContactInfoAdapterItemHook.addInitCallback(initCallback) + // 添加点击监听器 + WeChatContactInfoAdapterItemHook.addClickListener(clickListener) + WeLogger.i(TAG, "显示微信ID Hook 注册成功") + } catch (e: Exception) { + WeLogger.e(TAG, "注册失败: ${e.message}", e) + } + } + + private fun handleWeChatIdClick(activity: Activity): Boolean { + try { + val contactUser = activity.intent.getStringExtra("Contact_User") + val clipboard = activity.getSystemService(Context.CLIPBOARD_SERVICE) as android.content.ClipboardManager + val clip = ClipData.newPlainText("微信ID", contactUser) + clipboard.setPrimaryClip(clip) + Toast.makeText(activity, "已复制", Toast.LENGTH_SHORT).show() + WeLogger.d(TAG, "Contact User: $contactUser") + return true + } catch (e: Exception) { + WeLogger.e(TAG, "处理点击失败: ${e.message}", e) + return false + } + } + + + override fun unload(classLoader: ClassLoader) { + WeChatContactInfoAdapterItemHook.removeInitCallback(initCallback) + WeChatContactInfoAdapterItemHook.removeClickListener(clickListener) + WeLogger.i(TAG, "已移除显示微信ID Hook") + } +} \ No newline at end of file diff --git a/app/src/main/java/moe/ouom/wekit/hooks/item/fix/ForceCameraScan.kt b/app/src/main/java/moe/ouom/wekit/hooks/item/fix/ForceCameraScan.kt new file mode 100644 index 0000000..8976d15 --- /dev/null +++ b/app/src/main/java/moe/ouom/wekit/hooks/item/fix/ForceCameraScan.kt @@ -0,0 +1,61 @@ +package moe.ouom.wekit.hooks.item.fix + +import moe.ouom.wekit.core.dsl.dexMethod +import moe.ouom.wekit.core.model.BaseSwitchFunctionHookItem +import moe.ouom.wekit.dexkit.intf.IDexFind +import moe.ouom.wekit.hooks.core.annotation.HookItem +import moe.ouom.wekit.util.log.WeLogger +import org.luckypray.dexkit.DexKitBridge + +@HookItem(path = "优化与修复/扫码增强", desc = "强制使用相机扫码方式处理所有扫码") +class ForceCameraScan : BaseSwitchFunctionHookItem(), IDexFind { + + private val methodHandleScan by dexMethod() + + override fun dexFind(dexKit: DexKitBridge): Map { + val descriptors = mutableMapOf() + + methodHandleScan.find(dexKit, descriptors = descriptors) { + matcher { + usingStrings( + "MicroMsg.QBarStringHandler", + "key_offline_scan_show_tips" + ) + } + } + return descriptors + } + + override fun entry(classLoader: ClassLoader) { + methodHandleScan.toDexMethod { + hook { + beforeIfEnabled { param -> + try { + // 确保参数足够 + if (param.args.size < 4) return@beforeIfEnabled + + val arg2 = param.args[2] as? Int ?: return@beforeIfEnabled + val arg3 = param.args[3] as? Int ?: return@beforeIfEnabled + + // 相机扫码的值 + val CAMERA_VALUE_1 = 0 + val CAMERA_VALUE_2 = 4 + + val BLOCKED_PAIR_1 = Pair(1, 34) // 相册扫码 + val BLOCKED_PAIR_2 = Pair(4, 37) // 长按扫码 + + val currentPair = Pair(arg2, arg3) + + // 如果是相册或长按扫码,强制改成相机扫码的值 + if (currentPair == BLOCKED_PAIR_1 || currentPair == BLOCKED_PAIR_2) { + param.args[2] = CAMERA_VALUE_1 + param.args[3] = CAMERA_VALUE_2 + } + } catch (_: Exception) { + // 忽略异常 + } + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/moe/ouom/wekit/hooks/item/fix/WeChatArticleAdRemover.kt b/app/src/main/java/moe/ouom/wekit/hooks/item/fix/WeChatArticleAdRemover.kt new file mode 100644 index 0000000..6c037ac --- /dev/null +++ b/app/src/main/java/moe/ouom/wekit/hooks/item/fix/WeChatArticleAdRemover.kt @@ -0,0 +1,90 @@ +package moe.ouom.wekit.hooks.item.fix + +import moe.ouom.wekit.core.model.BaseSwitchFunctionHookItem +import moe.ouom.wekit.hooks.core.annotation.HookItem +import moe.ouom.wekit.hooks.sdk.protocol.WePkgManager +import moe.ouom.wekit.hooks.sdk.protocol.intf.IWePkgInterceptor +import moe.ouom.wekit.util.WeProtoData +import moe.ouom.wekit.util.log.WeLogger +import org.json.JSONArray +import org.json.JSONObject + +@HookItem(path = "优化与修复/去除文章广告", desc = "清除文章中的广告数据") +class WeChatArticleAdRemover : BaseSwitchFunctionHookItem(), IWePkgInterceptor { + + override fun entry(classLoader: ClassLoader) { + WePkgManager.addInterceptor(this) + } + + override fun onResponse(uri: String, cgiId: Int, respBytes: ByteArray): ByteArray? { + if (cgiId != 21909) return null + + try { + val data = WeProtoData() + data.fromBytes(respBytes) + val json = data.toJSON() + + // 获取字段2 + val field2 = json.optJSONObject("2") ?: return null + // 获取字段3中的广告JSON字符串 + val adJsonStr = field2.optString("3") ?: return null + + // 解析广告JSON + val adJson = JSONObject(adJsonStr) + // 清空广告字段 + var modified = false + + // 清空广告数组 + if (adJson.has("ad_slot_data")) { + adJson.put("ad_slot_data", JSONArray()) + modified = true + } + + if (adJson.has("advertisement_info")) { + adJson.put("advertisement_info", JSONArray()) + modified = true + } + + // 广告数量设置为0 + if (adJson.has("advertisement_num")) { + adJson.put("advertisement_num", 0) + modified = true + } + + // 清空广告曝光相关 + if (adJson.has("no_ad_indicator_info")) { + adJson.put("no_ad_indicator_info", JSONArray()) + modified = true + } + + // 清空广告响应 + if (adJson.has("check_ad_resp")) { + adJson.put("check_ad_resp", JSONObject().apply { + put("aid", "0") + put("del_aid", JSONArray()) + put("offline_aid", JSONArray()) + put("online_aid", JSONArray()) + }) + modified = true + } + + if (modified) { + // 放回修改后的广告JSON + field2.put("3", adJson.toString()) + data.applyViewJSON(json, true) + WeLogger.d("WeChatArticleAdRemover", "已清空所有广告相关数据") + return data.toPacketBytes() + } + + } catch (e: Exception) { + WeLogger.e("WeChatArticleAdRemover", e) + } + + return null + } + + override fun unload(classLoader: ClassLoader) { + WePkgManager.removeInterceptor(this) + super.unload(classLoader) + } +} \ No newline at end of file diff --git a/app/src/main/java/moe/ouom/wekit/hooks/item/func/WeAvatarTransparent.kt b/app/src/main/java/moe/ouom/wekit/hooks/item/func/WeAvatarTransparent.kt new file mode 100644 index 0000000..8f10762 --- /dev/null +++ b/app/src/main/java/moe/ouom/wekit/hooks/item/func/WeAvatarTransparent.kt @@ -0,0 +1,50 @@ +package moe.ouom.wekit.hooks.item.func + +import android.graphics.Bitmap +import moe.ouom.wekit.core.dsl.dexMethod +import moe.ouom.wekit.core.model.BaseSwitchFunctionHookItem +import moe.ouom.wekit.dexkit.intf.IDexFind +import moe.ouom.wekit.hooks.core.annotation.HookItem +import moe.ouom.wekit.util.log.WeLogger +import org.luckypray.dexkit.DexKitBridge + +@HookItem(path = "娱乐功能/头像上传透明", desc = "头像上传时使用PNG格式保持透明") +class AvatarTransparent : BaseSwitchFunctionHookItem(), IDexFind { + + private val methodSaveBitmap by dexMethod() + + override fun dexFind(dexKit: DexKitBridge): Map { + val descriptors = mutableMapOf() + + methodSaveBitmap.find(dexKit, descriptors = descriptors) { + matcher { + usingStrings("saveBitmapToImage pathName null or nil", "MicroMsg.BitmapUtil") + } + } + + return descriptors + } + + override fun entry(classLoader: ClassLoader) { + methodSaveBitmap.toDexMethod { + hook { + beforeIfEnabled { param -> + try { + val args = param.args + + val pathName = args[3] as? String + if (pathName != null && + (pathName.contains("avatar") || pathName.contains("user_hd")) + ) { + WeLogger.i("检测到头像保存: $pathName") + args[2] = Bitmap.CompressFormat.PNG + WeLogger.i("已将头像格式修改为PNG,保留透明通道") + } + } catch (e: Exception) { + WeLogger.e("头像格式修改失败: ${e.message}") + } + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/moe/ouom/wekit/hooks/item/moment/AntiSnsAd.kt b/app/src/main/java/moe/ouom/wekit/hooks/item/moment/AntiSnsAd.kt new file mode 100644 index 0000000..b4934b9 --- /dev/null +++ b/app/src/main/java/moe/ouom/wekit/hooks/item/moment/AntiSnsAd.kt @@ -0,0 +1,35 @@ +package moe.ouom.wekit.hooks.item.moment + +import de.robv.android.xposed.XC_MethodHook +import de.robv.android.xposed.XposedHelpers +import de.robv.android.xposed.callbacks.IXUnhook +import moe.ouom.wekit.core.model.BaseSwitchFunctionHookItem +import moe.ouom.wekit.hooks.core.annotation.HookItem +import moe.ouom.wekit.util.Initiator.loadClass +import moe.ouom.wekit.util.log.WeLogger + +@HookItem(path = "朋友圈/拦截广告", desc = "拦截朋友圈广告") +class AntiSnsAd : BaseSwitchFunctionHookItem() { + + private var unhook: IXUnhook<*>? = null + override fun entry(classLoader: ClassLoader) { + val adInfoClass = loadClass("\"com.tencent.mm.plugin.sns.storage.ADInfo\"") + unhook = XposedHelpers.findAndHookConstructor( + adInfoClass, + String::class.java, + object : XC_MethodHook() { + override fun beforeHookedMethod(param: MethodHookParam) { + if (param.args.isNotEmpty() && param.args[0] is String) { + param.args[0] = "" + WeLogger.i("拦截到ADInfo广告") + } + } + } + ) + } + + override fun unload(classLoader: ClassLoader) { + unhook?.unhook() + super.unload(classLoader) + } +} \ No newline at end of file diff --git a/app/src/main/java/moe/ouom/wekit/hooks/item/moment/AntiSnsDeleteHook.kt b/app/src/main/java/moe/ouom/wekit/hooks/item/moment/AntiSnsDeleteHook.kt new file mode 100644 index 0000000..c60911c --- /dev/null +++ b/app/src/main/java/moe/ouom/wekit/hooks/item/moment/AntiSnsDeleteHook.kt @@ -0,0 +1,98 @@ +package moe.ouom.wekit.hooks.item.moment + +import android.content.ContentValues +import moe.ouom.wekit.core.model.BaseSwitchFunctionHookItem +import moe.ouom.wekit.hooks.core.annotation.HookItem +import moe.ouom.wekit.hooks.sdk.api.WeDatabaseListener +import moe.ouom.wekit.util.WeProtoData +import moe.ouom.wekit.util.log.WeLogger + +@HookItem( + path = "朋友圈/拦截朋友圈删除", + desc = "移除删除标志并注入 '[拦截删除]' 标记" +) +class AntiSnsDeleteHook : BaseSwitchFunctionHookItem(), WeDatabaseListener.UpdateListener { + + companion object { + private const val LOG_TAG = "MomentAntiDel" + private const val TBL_SNS_INFO = "SnsInfo" + private const val DEFAULT_WATERMARK = "[拦截删除]" + } + + override fun onUpdate(table: String, values: ContentValues): Boolean { + if (!isEnabled) return false + + try { + when (table) { + TBL_SNS_INFO -> handleSnsRecord(values) + } + } catch (ex: Throwable) { + WeLogger.e(LOG_TAG, "拦截处理异常", ex) + } + return false + } + + override fun entry(classLoader: ClassLoader) { + WeDatabaseListener.addListener(this) + WeLogger.i(LOG_TAG, "服务已启动 | 标记文本:'$DEFAULT_WATERMARK'") + } + + override fun unload(classLoader: ClassLoader) { + WeDatabaseListener.removeListener(this) + WeLogger.i(LOG_TAG, "服务已停止") + } + + private fun handleSnsRecord(values: ContentValues) { + val typeVal = (values.get("type") as? Int) ?: return + val sourceVal = (values.get("sourceType") as? Int) ?: return + + if (!SnsContentType.allTypeIds.contains(typeVal)) return + if (sourceVal != 0) return + + val kindName = SnsContentType.fromId(typeVal)?.displayName ?: "Unknown[$typeVal]" + WeLogger.d(LOG_TAG, "捕获删除信号 -> $kindName ($typeVal)") + + // 移除来源 + values.remove("sourceType") + + // 注入水印 + val contentBytes = values.getAsByteArray("content") + if (contentBytes != null) { + try { + val proto = WeProtoData() + proto.fromMessageBytes(contentBytes) + + if (appendWatermark(proto, 5)) { + values.put("content", proto.toMessageBytes()) + WeLogger.i(LOG_TAG, ">> 拦截成功:[$kindName] 已注入标记") + } + } catch (e: Exception) { + WeLogger.e(LOG_TAG, "朋友圈 Protobuf 处理失败", e) + } + } + } + + private fun appendWatermark(proto: WeProtoData, fieldNumber: Int): Boolean { + try { + val json = proto.toJSON() + val key = fieldNumber.toString() + WeLogger.d(LOG_TAG, json.toString()) + + if (!json.has(key)) return false + + val currentVal = json.get(key) + + if (currentVal is String) { + if (currentVal.contains(DEFAULT_WATERMARK)) { + return false + } + val newVal = "$DEFAULT_WATERMARK $currentVal " + proto.setLenUtf8(fieldNumber, 0, newVal) + return true + } + } catch (e: Exception) { + WeLogger.e(LOG_TAG, "注入标记失败", e) + } + return false + } +} \ No newline at end of file diff --git a/app/src/main/java/moe/ouom/wekit/hooks/item/moment/SnsContentType.kt b/app/src/main/java/moe/ouom/wekit/hooks/item/moment/SnsContentType.kt new file mode 100644 index 0000000..5a89572 --- /dev/null +++ b/app/src/main/java/moe/ouom/wekit/hooks/item/moment/SnsContentType.kt @@ -0,0 +1,46 @@ +package moe.ouom.wekit.hooks.item.moment + +enum class SnsContentType(val typeId: Int, val displayName: String) { + IMG(1, "图片"), + TEXT(2, "文本"), + LINK(3, "链接"), + MUSIC(4, "音乐"), + VIDEO(5, "视频"), + COMMODITY(9, "商品"), + STICKER(10, "表情"), + COMMODITY_OLD(12, "商品 (旧)"), + COUPON(13, "卡券"), + TV_SHOW(14, "视频号/电视"), + LITTLE_VIDEO(15, "微视/短视频"), + STREAM_VIDEO(18, "直播流"), + ARTICLE_VIDEO(19, "文章视频"), + NOTE(26, "笔记"), + FINDER_VIDEO(28, "视频号视频"), + WE_APP(30, "小程序单页"), + LIVE(34, "直播"), + FINDER_LONG_VIDEO(36, "视频号长视频"), + LITE_APP(41, "轻应用"), + RICH_MUSIC(42, "富媒体音乐"), + TING_AUDIO(47, "听歌"), + LIVE_PHOTO(54, "动态照片"); + + companion object { + // 缓存所有有效的 Type ID,避免每次重复计算 + private val validTypeSet by lazy { entries.map { it.typeId }.toHashSet() } + + /** + * 解析整型 ID 为对应的枚举实例 + * @param id 数据库中的 type 值 + * @return 匹配成功返回枚举,否则返回 null + */ + fun fromId(id: Int): SnsContentType? = + entries.firstOrNull { it.typeId == id } + + /** + * 获取全量类型 ID 集合 + * 用于快速判断某个 type 是否属于朋友圈已知内容范畴 + */ + val allTypeIds: Set + get() = validTypeSet + } +} \ No newline at end of file diff --git a/app/src/main/java/moe/ouom/wekit/hooks/item/moment/SnsLikeModifyHook.kt b/app/src/main/java/moe/ouom/wekit/hooks/item/moment/SnsLikeModifyHook.kt new file mode 100644 index 0000000..62677ad --- /dev/null +++ b/app/src/main/java/moe/ouom/wekit/hooks/item/moment/SnsLikeModifyHook.kt @@ -0,0 +1,205 @@ +package moe.ouom.wekit.hooks.item.moment + +import android.content.ContentValues +import android.view.ContextMenu +import com.afollestad.materialdialogs.MaterialDialog +import com.afollestad.materialdialogs.list.listItemsMultiChoice +import moe.ouom.wekit.core.model.BaseSwitchFunctionHookItem +import moe.ouom.wekit.hooks.core.annotation.HookItem +import moe.ouom.wekit.hooks.sdk.api.WeDatabaseApi +import moe.ouom.wekit.hooks.sdk.api.WeDatabaseListener +import moe.ouom.wekit.hooks.sdk.ui.WeChatSnsContextMenuApi +import moe.ouom.wekit.ui.CommonContextWrapper +import moe.ouom.wekit.util.Initiator.loadClass +import moe.ouom.wekit.util.log.WeLogger +import java.lang.reflect.Field +import java.lang.reflect.Method +import java.util.LinkedList + +@HookItem( + path = "朋友圈/伪点赞", + desc = "自定义修改朋友圈点赞用户列表(伪点赞)" +) +class FakeLikesHook : BaseSwitchFunctionHookItem(), WeDatabaseListener.UpdateListener { + + companion object { + private const val TAG = "FakeLikesHook" + private const val MENU_ID_FAKE_LIKES = 20001 + private const val TBL_SNS_INFO = "SnsInfo" + + // 存储每个朋友圈动态的伪点赞用户配置 (snsId -> Set<微信id>) + private val fakeLikeWxids = mutableMapOf>() + + private var snsObjectClass: Class<*>? = null + private var parseFromMethod: Method? = null + private var toByteArrayMethod: Method? = null + private var likeUserListField: Field? = null + private var likeUserListCountField: Field? = null + private var likeCountField: Field? = null + private var likeFlagField: Field? = null + private var snsUserProtobufClass: Class<*>? = null + } + + private val onCreateListener = WeChatSnsContextMenuApi.OnCreateListener { menu -> + menu.add(ContextMenu.NONE, MENU_ID_FAKE_LIKES, 0, "设置伪点赞") + ?.setIcon(android.R.drawable.star_on) + } + + private val onSelectListener = WeChatSnsContextMenuApi.OnSelectListener { context, itemId -> + if (itemId == MENU_ID_FAKE_LIKES) { + showFakeLikesDialog(context) + true + } else { + false + } + } + + override fun entry(classLoader: ClassLoader) { + initReflection(classLoader) + + WeChatSnsContextMenuApi.addOnCreateListener(onCreateListener) + WeChatSnsContextMenuApi.addOnSelectListener(onSelectListener) + + WeDatabaseListener.addListener(this) + WeLogger.i(TAG, "伪点赞功能已启动") + } + + override fun unload(classLoader: ClassLoader) { + WeDatabaseListener.removeListener(this) + + WeChatSnsContextMenuApi.removeOnCreateListener(onCreateListener) + WeChatSnsContextMenuApi.removeOnSelectListener(onSelectListener) + WeLogger.i(TAG, "伪点赞功能已停止") + } + + override fun onUpdate(table: String, values: ContentValues): Boolean { + try { + injectFakeLikes(table, values) + } catch (e: Throwable) { + WeLogger.e(TAG, "处理数据库更新异常", e) + } + + return false // 返回false表示继续原有流程 + } + + private fun initReflection(classLoader: ClassLoader) { + try { + snsObjectClass = loadClass("com.tencent.mm.protocal.protobuf.SnsObject") + + snsObjectClass?.let { clazz -> + parseFromMethod = clazz.getMethod("parseFrom", ByteArray::class.java) + toByteArrayMethod = clazz.getMethod("toByteArray") + + listOf("LikeUserList", "LikeUserListCount", "LikeCount", "LikeFlag").forEach { name -> + clazz.getDeclaredField(name).also { field -> + field.isAccessible = true + when (name) { + "LikeUserList" -> likeUserListField = field + "LikeUserListCount" -> likeUserListCountField = field + "LikeCount" -> likeCountField = field + "LikeFlag" -> likeFlagField = field + } + } + } + } + + snsUserProtobufClass = loadClass("com.tencent.mm.plugin.sns.ui.SnsCommentFooter") + .getMethod("getCommentInfo").returnType + + WeLogger.d(TAG, "反射初始化成功") + + } catch (e: Exception) { + WeLogger.e(TAG, "反射初始化失败", e) + } + } + + private fun injectFakeLikes(tableName: String, values: ContentValues) = runCatching { + if (tableName != TBL_SNS_INFO) return@runCatching + val snsId = values.get("snsId") as? Long ?: return@runCatching + val fakeWxids = fakeLikeWxids[snsId] ?: emptySet() + if (fakeWxids.isEmpty() || snsObjectClass == null || snsUserProtobufClass == null) return@runCatching + + val snsObj = snsObjectClass!!.getDeclaredConstructor().newInstance() + parseFromMethod?.invoke(snsObj, values.get("attrBuf") as? ByteArray ?: return@runCatching) + + val fakeList = LinkedList().apply { + fakeWxids.forEach { wxid -> + snsUserProtobufClass!!.getDeclaredConstructor().newInstance().apply { + javaClass.getDeclaredField("d").apply { isAccessible = true }.set(this, wxid) + add(this) + } + } + } + + likeUserListField?.set(snsObj, fakeList) + likeUserListCountField?.set(snsObj, fakeList.size) + likeCountField?.set(snsObj, fakeList.size) + likeFlagField?.set(snsObj, 1) + + values.put("attrBuf", toByteArrayMethod?.invoke(snsObj) as? ByteArray ?: return@runCatching) + WeLogger.i(TAG, "成功为朋友圈 $snsId 注入 ${fakeList.size} 个伪点赞") + }.onFailure { WeLogger.e(TAG, "注入伪点赞失败", it) } + + /** + * 显示伪点赞用户选择对话框 + */ + private fun showFakeLikesDialog(context: WeChatSnsContextMenuApi.SnsContext) { + try { + // 获取所有好友列表 + val allFriends = WeDatabaseApi.INSTANCE?.getAllConnects() ?: return + + val displayItems = allFriends.map { contact -> + buildString { + // 如果有备注,显示"备注(昵称)" + if (contact.conRemark.isNotBlank()) { + append(contact.conRemark) + if (contact.nickname.isNotBlank()) { + append(" (${contact.nickname})") + } + } + // 否则直接显示昵称 + else if (contact.nickname.isNotBlank()) { + append(contact.nickname) + } + // 最后备选用wxid + else { + append(contact.username) + } + } + } + + val snsInfo = context.snsInfo + val snsId = context.snsInfo!!.javaClass.superclass!!.getDeclaredField("field_snsId").apply { isAccessible = true }.get(snsInfo) as Long + val currentSelected = fakeLikeWxids[snsId] ?: emptySet() + + val currentIndices = allFriends.mapIndexedNotNull { index, contact -> + if (currentSelected.contains(contact.username)) index else null + }.toIntArray() + + val wrappedContext = CommonContextWrapper.createAppCompatContext(context.activity) + + // 显示多选对话框 + MaterialDialog(wrappedContext).show { + title(text = "选择伪点赞用户") + listItemsMultiChoice( + items = displayItems, + initialSelection = currentIndices + ) { dialog, indices, items -> + val selectedWxids = indices.map { allFriends[it].username }.toSet() + + if (selectedWxids.isEmpty()) { + fakeLikeWxids.remove(snsId) + WeLogger.d(TAG, "已清除朋友圈 $snsId 的伪点赞配置") + } else { + fakeLikeWxids[snsId] = selectedWxids + WeLogger.d(TAG, "已设置朋友圈 $snsId 的伪点赞: $selectedWxids") + } + } + positiveButton(text = "确定") + negativeButton(text = "取消") + } + } catch (e: Exception) { + WeLogger.e(TAG, "显示选择对话框失败", e) + } + } +} \ No newline at end of file diff --git a/app/src/main/res/drawable/edit_24px.xml b/app/src/main/res/drawable/edit_24px.xml new file mode 100644 index 0000000..b253108 --- /dev/null +++ b/app/src/main/res/drawable/edit_24px.xml @@ -0,0 +1,10 @@ + + +