From 125dfe583b4ddaa18ffd07427a6b8013bd6bd9ed Mon Sep 17 00:00:00 2001 From: Mars <3361409208a@gmail.com> Date: Thu, 2 Jul 2026 16:50:12 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20Telegram=20=E5=8F=8C=E9=80=9A=E9=81=93?= =?UTF-8?q?=E6=8A=93=E5=8F=96=E3=80=81PC=20=E8=B0=83=E8=AF=95=E5=8F=B0?= =?UTF-8?q?=E4=B8=8E=20Hook=20=E5=90=8E=E5=8F=B0=E8=BD=AC=E5=8F=91?= =?UTF-8?q?=E4=BF=AE=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit v2.2.1 更新说明: - 新增 xposed-module(Telegram/微信/SQLite Hook),双 APK + LSPosed 作用域 - HookMessageReceiver 后台直接 DebugForwarder + goAsync,修复 notiMessage 退后台丢消息 - MessageLogStore 日志持久化;AppConfig 调试/上传开关;PC 调试台 debug-server - 健康检查去掉联网限制;通知/Hook 通道增加诊断日志 - 安装脚本 install-full/configure-lsposed/start-debug-server;文档 CHANGELOG + HOOK_GUIDE --- AGENTS.md | 45 +- .../Activity/NotificationActivity.java | 61 ++- .../com/miraclegarden/smsmessage/App.java | 13 +- .../miraclegarden/smsmessage/AppConfig.java | 23 + .../smsmessage/MessageLogStore.java | 101 ++++ .../smsmessage/network/DebugForwarder.java | 113 +++++ .../service/HookMessageReceiver.java | 33 +- .../NotificationHealthCheckWorker.java | 6 + .../service/NotificationService.java | 96 ++-- .../smsmessage/service/RetryManager.java | 5 + .../main/res/layout/activity_notification.xml | 2 +- debug-server/server.py | 311 ++++++++++++ docs/CHANGELOG.md | 99 ++++ docs/HOOK_GUIDE.md | 290 +++++++++++ scripts/build-debug.ps1 | 23 +- scripts/configure-lsposed.py | 31 ++ scripts/install-debug.ps1 | 34 +- scripts/install-from-studio.ps1 | 5 + scripts/install-full.ps1 | 72 +++ scripts/start-debug-server.ps1 | 18 + settings.gradle | 3 + xposed-module/build.gradle | 37 ++ xposed-module/proguard-rules.pro | 1 + xposed-module/src/main/AndroidManifest.xml | 22 + xposed-module/src/main/assets/xposed_init | 1 + .../smsmessage/xposed/HookBridge.java | 18 + .../smsmessage/xposed/HookForwarder.java | 36 ++ .../smsmessage/xposed/MainHook.java | 39 ++ .../xposed/hook/SqliteMessageHook.java | 123 +++++ .../xposed/hook/TelegramMessageHook.java | 479 ++++++++++++++++++ .../xposed/hook/WeChatMessageHook.java | 98 ++++ xposed-module/src/main/res/values/arrays.xml | 9 + xposed-module/src/main/res/values/strings.xml | 5 + 33 files changed, 2164 insertions(+), 88 deletions(-) create mode 100644 app/src/main/java/com/miraclegarden/smsmessage/AppConfig.java create mode 100644 app/src/main/java/com/miraclegarden/smsmessage/MessageLogStore.java create mode 100644 app/src/main/java/com/miraclegarden/smsmessage/network/DebugForwarder.java create mode 100644 debug-server/server.py create mode 100644 docs/CHANGELOG.md create mode 100644 docs/HOOK_GUIDE.md create mode 100644 scripts/configure-lsposed.py create mode 100644 scripts/install-from-studio.ps1 create mode 100644 scripts/install-full.ps1 create mode 100644 scripts/start-debug-server.ps1 create mode 100644 xposed-module/build.gradle create mode 100644 xposed-module/proguard-rules.pro create mode 100644 xposed-module/src/main/AndroidManifest.xml create mode 100644 xposed-module/src/main/assets/xposed_init create mode 100644 xposed-module/src/main/java/com/miraclegarden/smsmessage/xposed/HookBridge.java create mode 100644 xposed-module/src/main/java/com/miraclegarden/smsmessage/xposed/HookForwarder.java create mode 100644 xposed-module/src/main/java/com/miraclegarden/smsmessage/xposed/MainHook.java create mode 100644 xposed-module/src/main/java/com/miraclegarden/smsmessage/xposed/hook/SqliteMessageHook.java create mode 100644 xposed-module/src/main/java/com/miraclegarden/smsmessage/xposed/hook/TelegramMessageHook.java create mode 100644 xposed-module/src/main/java/com/miraclegarden/smsmessage/xposed/hook/WeChatMessageHook.java create mode 100644 xposed-module/src/main/res/values/arrays.xml create mode 100644 xposed-module/src/main/res/values/strings.xml diff --git a/AGENTS.md b/AGENTS.md index aded2f6..a25d169 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,6 +1,8 @@ # AGENTS.md — SmsMessage (notiMessage) -Android 应用,监听通知栏短信/通知内容并上传至服务器。适配华为、MIUI 等手机。 +Android 应用,监听通知栏消息并通过 **双通道**(通知监听 + Xposed Hook)抓取内容,支持 PC 本地调试台转发。 + +> **Hook 架构与扩展指南**:详见 [docs/HOOK_GUIDE.md](docs/HOOK_GUIDE.md)(Telegram 实现、接入新 App 步骤) ## 项目结构 @@ -10,24 +12,43 @@ notiMessage/ │ ├── src/main/java/.../ │ │ ├── Activity/ # Activity 类 (MainActivity, NotificationActivity, SettingActivity, AppListActivity) │ │ ├── service/ # Service 类 (NotificationService — 通知监听核心) +│ │ ├── network/ # ApiService, DebugForwarder, TokenManager │ │ ├── comm/ # 通用组件 (CommonAdapter, ViewHolder) │ │ ├── App.java # Application 类,管理通知列表持久化 +│ │ ├── AppConfig.java # 调试转发 / 正式上传开关 +│ │ ├── MessageLogStore.java # 监听日志持久化 │ │ ├── MessageInfo.java, AppInfo.java # 数据模型 │ │ └── GsonUtils.java # JSON 工具类 │ └── src/main/res/ # 资源文件 +├── xposed-module/ # Xposed Hook 模块 (com.miraclegarden.smsmessage.xposed) +│ └── src/main/java/.../hook/ +│ ├── TelegramMessageHook.java # Telegram 专用 +│ ├── WeChatMessageHook.java # 微信专用 +│ └── SqliteMessageHook.java # 通用 SQLite 兜底 +├── debug-server/ # PC 本地调试台 (server.py, 端口 8765) +├── scripts/ # build-debug.ps1, install-full.ps1, start-debug-server.ps1 ├── library/ # 基础库模块 (com.miraclegarden.library) │ └── MiracleGardenActivity # ViewBinding 基类 -├── build.gradle # 根构建文件 (AGP 7.3.0) -├── settings.gradle # 模块声明 + 仓库配置 +├── docs/HOOK_GUIDE.md # Hook 架构与扩展文档 +├── build.gradle # 根构建文件 +├── settings.gradle # 模块: app, library, xposed-module └── gradle.properties # Gradle 配置 ``` ## 构建命令 ```bash -# 构建 -./gradlew assembleDebug # 构建 debug APK -./gradlew assembleRelease # 构建 release APK +# 构建(主 App + Xposed 双 APK) +./gradlew :app:assembleDebug :xposed-module:assembleDebug + +# Windows 一键完整安装(推荐) +powershell -ExecutionPolicy Bypass -File scripts/install-full.ps1 + +# PC 调试台 +powershell -ExecutionPolicy Bypass -File scripts/start-debug-server.ps1 + +# 构建 release(仅主 App) +./gradlew assembleRelease ./gradlew build # 完整构建(编译 + lint + 测试) # Lint @@ -51,12 +72,12 @@ notiMessage/ ## SDK 与依赖版本 -| 配置项 | app 模块 | library 模块 | -|--------|---------|-------------| -| compileSdk | 32 | 32 | -| minSdk | 24 | 21 | -| targetSdk | 32 | 32 | -| Java | 17 | 17 | +| 配置项 | app 模块 | library 模块 | xposed-module | +|--------|---------|-------------|---------------| +| compileSdk | 34 | 32 | 34 | +| minSdk | 24 | 21 | 24 | +| targetSdk | 34 | 32 | 34 | +| Java | 17 | 17 | 17 | **关键依赖**: OkHttp 5.0.0-alpha.10 (网络), Gson 2.9.0 (JSON), ViewBinding (UI绑定), Material 1.6.1 **测试框架**: JUnit 4.13.2, Espresso 3.4.0, AndroidX Test JUnit 1.1.3 diff --git a/app/src/main/java/com/miraclegarden/smsmessage/Activity/NotificationActivity.java b/app/src/main/java/com/miraclegarden/smsmessage/Activity/NotificationActivity.java index f1b47fc..ff8b5d7 100644 --- a/app/src/main/java/com/miraclegarden/smsmessage/Activity/NotificationActivity.java +++ b/app/src/main/java/com/miraclegarden/smsmessage/Activity/NotificationActivity.java @@ -1,5 +1,6 @@ package com.miraclegarden.smsmessage.Activity; +import android.content.Context; import android.content.Intent; import android.os.Bundle; import android.os.Handler; @@ -17,6 +18,8 @@ import androidx.annotation.Nullable; import com.miraclegarden.library.app.MiracleGardenActivity; import com.miraclegarden.smsmessage.App; +import com.miraclegarden.smsmessage.AppConfig; +import com.miraclegarden.smsmessage.MessageLogStore; import com.miraclegarden.smsmessage.databinding.ActivityNotificationBinding; import com.miraclegarden.smsmessage.network.TokenManager; import com.miraclegarden.smsmessage.service.NotificationService; @@ -36,19 +39,25 @@ public class NotificationActivity extends MiracleGardenActivity - binding.scrollView.fullScroll(ScrollView.FOCUS_DOWN)); - } + restoreLogFromStore(); } }; + private void restoreLogFromStore() { + if (binding == null || binding.tvLog == null) { + return; + } + binding.tvLog.setText(MessageLogStore.getDisplayText(this)); + binding.scrollView.post(() -> + binding.scrollView.fullScroll(ScrollView.FOCUS_DOWN)); + } + public static void sendMessage(String str) { + Context appContext = App.getAppContext(); + if (appContext != null) { + MessageLogStore.append(appContext, str); + } + NotificationActivity activity = instanceRef != null ? instanceRef.get() : null; if (activity != null && activity.handler != null) { Message message = Message.obtain(); @@ -74,11 +83,13 @@ public class NotificationActivity extends MiracleGardenActivity finish()); @@ -111,6 +131,7 @@ public class NotificationActivity extends MiracleGardenActivity { + MessageLogStore.clear(this); binding.tvLog.setText(""); }); @@ -173,14 +194,16 @@ public class NotificationActivity extends MiracleGardenActivity 0) { - int successRate = (uploadedCount * 100) / totalCount; - binding.tvSuccessRate.setText("成功率: " + successRate + "%"); - } else { - binding.tvSuccessRate.setText("成功率: --"); + if (AppConfig.ENABLE_SERVER_UPLOAD) { + binding.tvUploadedCount.setText("上传: " + uploadedCount); + if (totalCount > 0) { + int successRate = (uploadedCount * 100) / totalCount; + binding.tvSuccessRate.setText("成功率: " + successRate + "%"); + } else { + binding.tvSuccessRate.setText("成功率: --"); + } } } diff --git a/app/src/main/java/com/miraclegarden/smsmessage/App.java b/app/src/main/java/com/miraclegarden/smsmessage/App.java index b1bf765..11272ea 100644 --- a/app/src/main/java/com/miraclegarden/smsmessage/App.java +++ b/app/src/main/java/com/miraclegarden/smsmessage/App.java @@ -10,9 +10,7 @@ import android.text.TextUtils; import android.util.Base64; import android.util.Log; -import androidx.work.Constraints; import androidx.work.ExistingPeriodicWorkPolicy; -import androidx.work.NetworkType; import androidx.work.PeriodicWorkRequest; import androidx.work.WorkManager; @@ -29,10 +27,12 @@ import java.util.concurrent.TimeUnit; public class App extends Application { private static final String TAG = "App"; public static String Hash_value; + private static App instance; @Override public void onCreate() { super.onCreate(); + instance = this; String packageName = getApplicationContext().getPackageName(); MessageDigest messageDigest = getMessageDigest(); String signature = getSignature(this, packageName); @@ -40,14 +40,13 @@ public class App extends Application { scheduleHealthCheck(); } - private void scheduleHealthCheck() { - Constraints constraints = new Constraints.Builder() - .setRequiredNetworkType(NetworkType.CONNECTED) - .build(); + public static Context getAppContext() { + return instance; + } + private void scheduleHealthCheck() { PeriodicWorkRequest healthCheck = new PeriodicWorkRequest.Builder( NotificationHealthCheckWorker.class, 15, TimeUnit.MINUTES) - .setConstraints(constraints) .build(); WorkManager.getInstance(this).enqueueUniquePeriodicWork( "notification_health_check", diff --git a/app/src/main/java/com/miraclegarden/smsmessage/AppConfig.java b/app/src/main/java/com/miraclegarden/smsmessage/AppConfig.java new file mode 100644 index 0000000..4e089ee --- /dev/null +++ b/app/src/main/java/com/miraclegarden/smsmessage/AppConfig.java @@ -0,0 +1,23 @@ +package com.miraclegarden.smsmessage; + +/** + * 应用运行配置。 + */ +public final class AppConfig { + + /** 是否上传到正式后端(judy88.xin) */ + public static final boolean ENABLE_SERVER_UPLOAD = false; + + /** 是否转发到 PC 本地调试服务 */ + public static final boolean ENABLE_DEBUG_FORWARD = true; + + /** + * 调试服务地址。 + * USB + adb reverse: http://127.0.0.1:8765 + * Wi-Fi: http://<电脑局域网IP>:8765 + */ + public static final String DEBUG_SERVER_URL = "http://127.0.0.1:8765"; + + private AppConfig() { + } +} diff --git a/app/src/main/java/com/miraclegarden/smsmessage/MessageLogStore.java b/app/src/main/java/com/miraclegarden/smsmessage/MessageLogStore.java new file mode 100644 index 0000000..2c8e36f --- /dev/null +++ b/app/src/main/java/com/miraclegarden/smsmessage/MessageLogStore.java @@ -0,0 +1,101 @@ +package com.miraclegarden.smsmessage; + +import android.content.Context; +import android.content.SharedPreferences; +import android.text.TextUtils; + +import java.text.SimpleDateFormat; +import java.util.ArrayList; +import java.util.Date; +import java.util.List; +import java.util.Locale; + +/** + * 监听日志持久化,App 在后台时也能保留历史,回到监听页可恢复显示。 + */ +public final class MessageLogStore { + + private static final String SP_NAME = "message_log"; + private static final String KEY_LINES = "lines"; + private static final int MAX_LINES = 500; + private static final String SEP = "\u001E"; + + private MessageLogStore() { + } + + public static void append(Context context, String message) { + if (context == null || TextUtils.isEmpty(message)) { + return; + } + Context appContext = context.getApplicationContext(); + SimpleDateFormat format = new SimpleDateFormat("HH:mm:ss", Locale.getDefault()); + String line = format.format(new Date()) + " " + message; + + SharedPreferences sp = appContext.getSharedPreferences(SP_NAME, Context.MODE_PRIVATE); + String raw = sp.getString(KEY_LINES, ""); + List lines = decode(raw); + lines.add(line); + while (lines.size() > MAX_LINES) { + lines.remove(0); + } + sp.edit().putString(KEY_LINES, encode(lines)).apply(); + } + + public static String getDisplayText(Context context) { + if (context == null) { + return ""; + } + StringBuilder sb = new StringBuilder(); + for (String line : getLines(context)) { + sb.append(line).append('\n'); + } + return sb.toString(); + } + + public static List getLines(Context context) { + if (context == null) { + return new ArrayList<>(); + } + SharedPreferences sp = context.getApplicationContext() + .getSharedPreferences(SP_NAME, Context.MODE_PRIVATE); + return decode(sp.getString(KEY_LINES, "")); + } + + public static void clear(Context context) { + if (context == null) { + return; + } + context.getApplicationContext() + .getSharedPreferences(SP_NAME, Context.MODE_PRIVATE) + .edit() + .remove(KEY_LINES) + .apply(); + } + + private static List decode(String raw) { + List lines = new ArrayList<>(); + if (TextUtils.isEmpty(raw)) { + return lines; + } + for (String part : raw.split(SEP, -1)) { + if (!TextUtils.isEmpty(part)) { + lines.add(part); + } + } + return lines; + } + + private static String encode(List lines) { + if (lines == null || lines.isEmpty()) { + return ""; + } + StringBuilder sb = new StringBuilder(); + for (int i = 0; i < lines.size(); i++) { + if (i > 0) { + sb.append(SEP); + } + sb.append(lines.get(i)); + } + return sb.toString(); + } +} diff --git a/app/src/main/java/com/miraclegarden/smsmessage/network/DebugForwarder.java b/app/src/main/java/com/miraclegarden/smsmessage/network/DebugForwarder.java new file mode 100644 index 0000000..37af8a6 --- /dev/null +++ b/app/src/main/java/com/miraclegarden/smsmessage/network/DebugForwarder.java @@ -0,0 +1,113 @@ +package com.miraclegarden.smsmessage.network; + +import android.content.Context; +import android.text.TextUtils; +import android.util.Log; + +import com.miraclegarden.smsmessage.AppConfig; +import com.miraclegarden.smsmessage.MessageInfo; + +import org.json.JSONObject; + +import java.util.concurrent.TimeUnit; + +import okhttp3.Call; +import okhttp3.Callback; +import okhttp3.MediaType; +import okhttp3.OkHttpClient; +import okhttp3.Request; +import okhttp3.RequestBody; +import okhttp3.Response; + +/** + * 将抓取到的消息转发到 PC 本地调试服务,便于浏览器查看。 + */ +public final class DebugForwarder { + + private static final String TAG = "DebugForwarder"; + private static final MediaType JSON = MediaType.parse("application/json; charset=utf-8"); + + private static final OkHttpClient CLIENT = new OkHttpClient.Builder() + .connectTimeout(5, TimeUnit.SECONDS) + .writeTimeout(5, TimeUnit.SECONDS) + .readTimeout(5, TimeUnit.SECONDS) + .build(); + + private DebugForwarder() { + } + + public static void forward(Context context, MessageInfo messageInfo, + String title, String content, long timestamp, String source) { + forward(context, messageInfo, title, content, timestamp, source, null); + } + + public static void forward(Context context, MessageInfo messageInfo, + String title, String content, long timestamp, String source, + Runnable onComplete) { + if (!AppConfig.ENABLE_DEBUG_FORWARD || messageInfo == null) { + if (onComplete != null) { + onComplete.run(); + } + return; + } + + try { + JSONObject json = new JSONObject(); + json.put("source", source != null ? source : "unknown"); + json.put("packageName", messageInfo.getPackageName()); + json.put("appName", messageInfo.getAppName()); + json.put("title", title != null ? title : ""); + json.put("group", resolveGroup(title, messageInfo)); + json.put("content", content != null ? content : ""); + json.put("timestamp", timestamp); + + Request request = new Request.Builder() + .url(AppConfig.DEBUG_SERVER_URL + "/api/debug/push") + .header("Content-Type", "application/json") + .post(RequestBody.create(json.toString(), JSON)) + .build(); + + Log.d(TAG, "forwarding [" + source + "] " + messageInfo.getPackageName() + + " -> " + AppConfig.DEBUG_SERVER_URL); + + CLIENT.newCall(request).enqueue(new Callback() { + @Override + public void onFailure(Call call, java.io.IOException e) { + Log.w(TAG, "forward failed [" + source + "]: " + e.getMessage()); + if (onComplete != null) { + onComplete.run(); + } + } + + @Override + public void onResponse(Call call, Response response) { + int code = response.code(); + response.close(); + if (code >= 200 && code < 300) { + Log.d(TAG, "forward ok [" + source + "] " + title); + } else { + Log.w(TAG, "forward http " + code + " [" + source + "] " + title); + } + if (onComplete != null) { + onComplete.run(); + } + } + }); + } catch (Exception e) { + Log.w(TAG, "forward build failed: " + e.getMessage()); + if (onComplete != null) { + onComplete.run(); + } + } + } + + private static String resolveGroup(String title, MessageInfo messageInfo) { + if (!TextUtils.isEmpty(title)) { + return title.trim(); + } + if (messageInfo != null && !TextUtils.isEmpty(messageInfo.getAppName())) { + return messageInfo.getAppName(); + } + return "未分类"; + } +} diff --git a/app/src/main/java/com/miraclegarden/smsmessage/service/HookMessageReceiver.java b/app/src/main/java/com/miraclegarden/smsmessage/service/HookMessageReceiver.java index 9e606e7..29783ae 100644 --- a/app/src/main/java/com/miraclegarden/smsmessage/service/HookMessageReceiver.java +++ b/app/src/main/java/com/miraclegarden/smsmessage/service/HookMessageReceiver.java @@ -4,16 +4,22 @@ import android.content.BroadcastReceiver; import android.content.Context; import android.content.Intent; import android.text.TextUtils; +import android.util.Log; import com.miraclegarden.smsmessage.Activity.NotificationActivity; import com.miraclegarden.smsmessage.App; +import com.miraclegarden.smsmessage.AppConfig; import com.miraclegarden.smsmessage.MessageInfo; +import com.miraclegarden.smsmessage.network.DebugForwarder; /** - * 接收 Xposed 模块转发的消息(前台 Hook 通道)。 + * 接收 Xposed 模块转发的消息(Hook 通道)。 + * 在 Receiver 内直接转发到 PC,避免依赖 startService(后台易被系统拦截)。 */ public class HookMessageReceiver extends BroadcastReceiver { + private static final String TAG = "HookMessageReceiver"; + public static final String ACTION_HOOK_MESSAGE = "com.miraclegarden.smsmessage.action.HOOK_MESSAGE"; public static final String EXTRA_PACKAGE_NAME = "packageName"; public static final String EXTRA_TITLE = "title"; @@ -34,11 +40,13 @@ public class HookMessageReceiver extends BroadcastReceiver { String source = intent.getStringExtra(EXTRA_SOURCE); if (TextUtils.isEmpty(packageName) || TextUtils.isEmpty(content)) { + Log.w(TAG, "ignore hook: empty package or content"); return; } MessageInfo messageInfo = App.getMessageByNotiList(context, packageName); if (messageInfo == null) { + Log.w(TAG, "ignore hook: not in monitor list, pkg=" + packageName); NotificationActivity.sendMessage("[Hook] 未配置监听: " + packageName); return; } @@ -47,7 +55,26 @@ public class HookMessageReceiver extends BroadcastReceiver { title = messageInfo.getAppName(); } - NotificationActivity.sendMessage("[Hook/" + source + "] " + title + " " + content); - NotificationService.submitFromHook(context, messageInfo, title, content, timestamp); + Log.i(TAG, "hook received: pkg=" + packageName + " source=" + source + " title=" + title); + + final String finalTitle = title; + NotificationActivity.sendMessage("[Hook/" + source + "] " + finalTitle + " " + content); + + Context appContext = context.getApplicationContext(); + PendingResult pendingResult = goAsync(); + + Runnable finish = pendingResult::finish; + Runnable afterForward = () -> { + if (AppConfig.ENABLE_SERVER_UPLOAD) { + NotificationService.submitFromHook(appContext, messageInfo, finalTitle, content, timestamp, source); + } + finish.run(); + }; + + if (AppConfig.ENABLE_DEBUG_FORWARD) { + DebugForwarder.forward(appContext, messageInfo, finalTitle, content, timestamp, source, afterForward); + } else { + afterForward.run(); + } } } diff --git a/app/src/main/java/com/miraclegarden/smsmessage/service/NotificationHealthCheckWorker.java b/app/src/main/java/com/miraclegarden/smsmessage/service/NotificationHealthCheckWorker.java index d6e6947..bf3350e 100644 --- a/app/src/main/java/com/miraclegarden/smsmessage/service/NotificationHealthCheckWorker.java +++ b/app/src/main/java/com/miraclegarden/smsmessage/service/NotificationHealthCheckWorker.java @@ -3,6 +3,7 @@ package com.miraclegarden.smsmessage.service; import android.content.Context; import android.provider.Settings; import android.text.TextUtils; +import android.util.Log; import androidx.annotation.NonNull; import androidx.work.Worker; @@ -10,6 +11,8 @@ import androidx.work.WorkerParameters; public class NotificationHealthCheckWorker extends Worker { + private static final String TAG = "NotiHealthCheck"; + public NotificationHealthCheckWorker(@NonNull Context context, @NonNull WorkerParameters params) { super(context, params); } @@ -18,7 +21,10 @@ public class NotificationHealthCheckWorker extends Worker { @Override public Result doWork() { if (!isNotificationListenerEnabled()) { + Log.w(TAG, "notification listener disabled, attempting recovery"); NotificationService.toggleNotificationListenerService(getApplicationContext()); + } else { + Log.d(TAG, "notification listener ok"); } return Result.success(); } diff --git a/app/src/main/java/com/miraclegarden/smsmessage/service/NotificationService.java b/app/src/main/java/com/miraclegarden/smsmessage/service/NotificationService.java index 5bedb28..fb41431 100644 --- a/app/src/main/java/com/miraclegarden/smsmessage/service/NotificationService.java +++ b/app/src/main/java/com/miraclegarden/smsmessage/service/NotificationService.java @@ -26,11 +26,13 @@ import androidx.core.app.NotificationCompat; import com.miraclegarden.smsmessage.Activity.NotificationActivity; import com.miraclegarden.smsmessage.App; +import com.miraclegarden.smsmessage.AppConfig; import com.miraclegarden.smsmessage.MessageInfo; import com.miraclegarden.smsmessage.R; import com.miraclegarden.smsmessage.model.ApiError; import com.miraclegarden.smsmessage.model.UploadNotificationResponse; import com.miraclegarden.smsmessage.network.ApiService; +import com.miraclegarden.smsmessage.network.DebugForwarder; import com.miraclegarden.smsmessage.network.TokenManager; import org.json.JSONException; @@ -82,8 +84,10 @@ public class NotificationService extends NotificationListenerService { tokenManager = TokenManager.getInstance(this); retryManager = new RetryManager(this); - retryManager.setUploadCallback(this::performUpload); - retryManager.restoreFromPersistence(); + if (AppConfig.ENABLE_SERVER_UPLOAD) { + retryManager.setUploadCallback(this::performUpload); + retryManager.restoreFromPersistence(); + } } private static final String ACTION_HOOK_MESSAGE = "ACTION_HOOK_MESSAGE"; @@ -102,14 +106,25 @@ public class NotificationService extends NotificationListenerService { } public static void submitFromHook(Context context, MessageInfo messageInfo, - String title, String content, long timestamp) { + String title, String content, long timestamp, String source) { Intent intent = new Intent(context, NotificationService.class); intent.setAction(ACTION_HOOK_MESSAGE); intent.putExtra("hook_package", messageInfo.getPackageName()); intent.putExtra("hook_title", title); intent.putExtra("hook_content", content); intent.putExtra("hook_timestamp", timestamp); - context.startService(intent); + intent.putExtra("hook_source", source); + try { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + context.startForegroundService(intent); + } else { + context.startService(intent); + } + Log.d(TAG, "submitFromHook: service started for " + messageInfo.getPackageName()); + } catch (Exception e) { + Log.e(TAG, "submitFromHook failed: " + e.getMessage(), e); + NotificationActivity.sendMessage("[Hook] 服务启动失败: " + e.getMessage()); + } } private void handleHookMessageIntent(Intent intent) { @@ -118,12 +133,13 @@ public class NotificationService extends NotificationListenerService { if (messageInfo == null) { return; } + String source = intent.getStringExtra("hook_source"); NotificationExtractor.Result result = new NotificationExtractor.Result( intent.getStringExtra("hook_title"), intent.getStringExtra("hook_content"), intent.getLongExtra("hook_timestamp", System.currentTimeMillis()) ); - submitNotification(messageInfo, result); + submitNotification(messageInfo, result, source != null ? source : "hook"); } public static boolean isMonitoring() { @@ -135,7 +151,9 @@ public class NotificationService extends NotificationListenerService { isMonitoring = true; toggleNotificationListenerService(this); - updateForegroundNotification(retryManager != null ? retryManager.getUploadedCount() : 0); + updateForegroundNotification(retryManager != null + ? (AppConfig.ENABLE_SERVER_UPLOAD ? retryManager.getUploadedCount() : retryManager.getTotalCount()) + : 0); NotificationActivity.sendMessage("已开启持续监听模式"); NotificationActivity.updateUI(); } @@ -167,7 +185,11 @@ public class NotificationService extends NotificationListenerService { try { Intent intent = new Intent(context, NotificationService.class); intent.setAction("ACTION_START_MONITORING"); - context.startService(intent); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + context.startForegroundService(intent); + } else { + context.startService(intent); + } } catch (Exception e) { Log.e(TAG, "启动监听失败", e); } @@ -183,8 +205,11 @@ public class NotificationService extends NotificationListenerService { if (messageInfo == null) return; NotificationExtractor.Result result = NotificationExtractor.extract(sbn); + Log.d(TAG, "onNotificationPosted: pkg=" + sbn.getPackageName() + + " title=" + result.title + " content=" + result.content); if (TextUtils.isEmpty(result.title) && TextUtils.isEmpty(result.content)) { + Log.d(TAG, "skip empty notification: " + sbn.getPackageName()); NotificationActivity.sendMessage("[" + messageInfo.getAppName() + "] 通知内容为空,跳过"); return; } @@ -194,50 +219,60 @@ public class NotificationService extends NotificationListenerService { } NotificationActivity.sendMessage(result.title + " " + result.content); - submitNotification(messageInfo, result); + submitNotification(messageInfo, result, "notification"); } - private void submitNotification(MessageInfo messageInfo, NotificationExtractor.Result result) { - // 调试用:暂时跳过登录检查 -// if (!tokenManager.isLoggedIn()) { -// NotificationActivity.sendMessage("未登录,请先登录"); -// return; -// } + private void submitNotification(MessageInfo messageInfo, NotificationExtractor.Result result, + String debugSource) { + DebugForwarder.forward(this, messageInfo, result.title, result.content, + result.timestamp, debugSource); - // 调试用:本地模式可不绑定服务器银行账户 -// if (TextUtils.isEmpty(messageInfo.getBankInfoId())) { -// NotificationActivity.sendMessage("该应用未关联银行账户,请先配置"); -// return; -// } + if (!AppConfig.ENABLE_SERVER_UPLOAD) { + recordLocalCapture(); + return; + } + + if (!tokenManager.isLoggedIn()) { + NotificationActivity.sendMessage("未登录,请先登录"); + return; + } + + if (TextUtils.isEmpty(messageInfo.getBankInfoId())) { + NotificationActivity.sendMessage("该应用未关联银行账户,请先配置"); + return; + } try { JSONObject jsonObject = new JSONObject(); - // 根据文档,bankInfoId 是建议字段 jsonObject.put("bankInfoId", messageInfo.getBankInfoId()); - // data 字段包含通知内容 JSONObject dataObject = new JSONObject(); dataObject.put("title", result.title); dataObject.put("context", result.content); dataObject.put("timestamp", result.timestamp); jsonObject.put("data", dataObject); - // 保留原有字段用于兼容性 jsonObject.put("name", messageInfo.getName()); jsonObject.put("code", messageInfo.getCode()); jsonObject.put("remark", messageInfo.getRemark()); jsonObject.put("appName", messageInfo.getAppName()); jsonObject.put("packageName", messageInfo.getPackageName()); - String payload = jsonObject.toString(); - NotificationActivity.sendMessage("准备发送服务器:成功"); - retryManager.enqueue(payload); + retryManager.enqueue(jsonObject.toString()); } catch (JSONException e) { Log.e(TAG, "JSON构建失败", e); NotificationActivity.sendMessage("JSON构建失败: " + e.getMessage()); } } + private void recordLocalCapture() { + if (retryManager != null) { + retryManager.recordLocalCapture(); + updateForegroundNotification(retryManager.getTotalCount()); + } + NotificationActivity.updateUI(); + } + private void performUpload(String jsonPayload, RetryManager.UploadResultListener listener) { acquireWakeLockForUpload(); @@ -345,11 +380,12 @@ public class NotificationService extends NotificationListenerService { } } - private Notification buildForegroundNotification(int uploadedCount, boolean monitoring) { + private Notification buildForegroundNotification(int count, boolean monitoring) { String title = monitoring ? "持续监听中" : "通知监听服务"; + String countLabel = AppConfig.ENABLE_SERVER_UPLOAD ? "已上传" : "已抓取"; String text = monitoring - ? "已上传 " + uploadedCount + " 条 · 持续运行中" - : "已上传 " + uploadedCount + " 条"; + ? countLabel + " " + count + " 条 · 持续运行中" + : countLabel + " " + count + " 条"; return new NotificationCompat.Builder(this, CHANNEL_ID) .setContentTitle(title) @@ -361,10 +397,10 @@ public class NotificationService extends NotificationListenerService { .build(); } - private void updateForegroundNotification(int uploadedCount) { + private void updateForegroundNotification(int count) { NotificationManager nm = getSystemService(NotificationManager.class); if (nm != null) { - nm.notify(FOREGROUND_NOTIFICATION_ID, buildForegroundNotification(uploadedCount, isMonitoring)); + nm.notify(FOREGROUND_NOTIFICATION_ID, buildForegroundNotification(count, isMonitoring)); } } } diff --git a/app/src/main/java/com/miraclegarden/smsmessage/service/RetryManager.java b/app/src/main/java/com/miraclegarden/smsmessage/service/RetryManager.java index 9ad771b..1a2e741 100644 --- a/app/src/main/java/com/miraclegarden/smsmessage/service/RetryManager.java +++ b/app/src/main/java/com/miraclegarden/smsmessage/service/RetryManager.java @@ -178,6 +178,11 @@ public class RetryManager { return failedCount; } + /** 本地监听模式下仅累计抓取条数,不上传服务器 */ + public void recordLocalCapture() { + incrementTotalCount(); + } + private void incrementTotalCount() { totalCount++; sharedPreferences.edit() diff --git a/app/src/main/res/layout/activity_notification.xml b/app/src/main/res/layout/activity_notification.xml index 302ee85..9bf3bbc 100644 --- a/app/src/main/res/layout/activity_notification.xml +++ b/app/src/main/res/layout/activity_notification.xml @@ -107,7 +107,7 @@ android:layout_height="wrap_content" android:layout_weight="1" android:gravity="center" - android:text="监听: 0" + android:text="已抓取: 0" android:textColor="#333333" android:textSize="14sp" /> diff --git a/debug-server/server.py b/debug-server/server.py new file mode 100644 index 0000000..3b26b14 --- /dev/null +++ b/debug-server/server.py @@ -0,0 +1,311 @@ +#!/usr/bin/env python3 +"""notiMessage 本地调试转发服务 — 在 PC 浏览器查看手机抓取的消息。""" + +import json +import threading +from collections import deque +from datetime import datetime +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from urllib.parse import urlparse + +HOST = "0.0.0.0" +PORT = 8765 +MAX_MESSAGES = 500 + +_messages = deque(maxlen=MAX_MESSAGES) +_lock = threading.Lock() + + +def _now_iso(): + return datetime.now().strftime("%Y-%m-%d %H:%M:%S") + + +def _resolve_group(payload): + group = (payload.get("group") or payload.get("title") or "").strip() + if group and "TLRPC$" not in group and "org.telegram.tgnet." not in group: + return group + app = payload.get("appName") or payload.get("packageName") or "" + return app + " / 未分类" if app else "未分类" + + +def _add_message(payload): + item = dict(payload) + item["group"] = _resolve_group(payload) + item["receivedAt"] = _now_iso() + with _lock: + _messages.appendleft(item) + item["id"] = len(_messages) + print("[{0}] [{1}] [{2}] {3} | {4}".format( + _now_iso(), + item["group"], + payload.get("source", "?"), + payload.get("appName", payload.get("packageName", "")), + (payload.get("content", "") or "")[:80], + )) + return item + + +def _json_response(handler, status, data): + body = json.dumps(data, ensure_ascii=False).encode("utf-8") + handler.send_response(status) + handler.send_header("Content-Type", "application/json; charset=utf-8") + handler.send_header("Access-Control-Allow-Origin", "*") + handler.send_header("Content-Length", str(len(body))) + handler.end_headers() + handler.wfile.write(body) + + +def _group_messages(messages): + groups = {} + for msg in messages: + key = msg.get("group") or _resolve_group(msg) + if key not in groups: + groups[key] = { + "key": key, + "appName": msg.get("appName") or msg.get("packageName") or "", + "count": 0, + "latestAt": msg.get("receivedAt", ""), + "messages": [], + } + g = groups[key] + g["count"] += 1 + g["messages"].append(msg) + if (msg.get("receivedAt") or "") > (g.get("latestAt") or ""): + g["latestAt"] = msg.get("receivedAt", "") + result = sorted(groups.values(), key=lambda x: x.get("latestAt", ""), reverse=True) + for g in result: + g["messages"].sort(key=lambda m: m.get("receivedAt", ""), reverse=True) + return result + + +def _html_page(): + html = """ + + + + + notiMessage 调试台 + + + +
+

notiMessage 调试台

+ 0 条 + 0 群 + - + + +
+
+ +
+

请选择左侧群聊

+
+ +
+
+ + +""" + return html.encode("utf-8") + + +class Handler(BaseHTTPRequestHandler): + def log_message(self, fmt, *args): + if self.path.startswith("/api/messages") and self.command == "GET": + return + BaseHTTPRequestHandler.log_message(self, fmt, *args) + + def do_OPTIONS(self): + self.send_response(204) + self.send_header("Access-Control-Allow-Origin", "*") + self.send_header("Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS") + self.send_header("Access-Control-Allow-Headers", "Content-Type") + self.end_headers() + + def do_GET(self): + path = urlparse(self.path).path + if path == "/": + body = _html_page() + self.send_response(200) + self.send_header("Content-Type", "text/html; charset=utf-8") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + return + if path == "/api/messages": + with _lock: + data = list(_messages) + _json_response(self, 200, data) + return + if path == "/api/groups": + with _lock: + data = _group_messages(list(_messages)) + _json_response(self, 200, data) + return + if path == "/health": + _json_response(self, 200, {"ok": True}) + return + _json_response(self, 404, {"error": "not found"}) + + def do_POST(self): + path = urlparse(self.path).path + if path not in ("/api/messages", "/api/debug/push", "/api/bills/app-upload"): + _json_response(self, 404, {"error": "not found"}) + return + length = int(self.headers.get("Content-Length", "0")) + raw = self.rfile.read(length) if length else b"{}" + try: + payload = json.loads(raw.decode("utf-8") or "{}") + except ValueError: + _json_response(self, 400, {"error": "invalid json"}) + return + + if path == "/api/bills/app-upload": + data = payload.get("data") or {} + normalized = { + "source": "app-upload", + "packageName": payload.get("packageName", ""), + "appName": payload.get("appName", ""), + "title": data.get("title", ""), + "content": data.get("context", data.get("content", "")), + "timestamp": data.get("timestamp"), + "raw": payload, + } + else: + normalized = payload + + if not normalized.get("group"): + normalized["group"] = _resolve_group(normalized) + + item = _add_message(normalized) + _json_response(self, 200, {"ok": True, "id": item.get("id")}) + + def do_DELETE(self): + path = urlparse(self.path).path + if path != "/api/messages": + _json_response(self, 404, {"error": "not found"}) + return + with _lock: + _messages.clear() + _json_response(self, 200, {"ok": True}) + + +def main(): + server = ThreadingHTTPServer((HOST, PORT), Handler) + print("notiMessage debug server: http://127.0.0.1:{0}".format(PORT)) + print("浏览器打开上述地址即可查看消息") + print("手机经 USB 调试时先执行: adb reverse tcp:8765 tcp:8765") + print("Wi-Fi 调试时将 AppConfig.DEBUG_SERVER_URL 改为 http://:8765") + try: + server.serve_forever() + except KeyboardInterrupt: + print("\nstopped") + + +if __name__ == "__main__": + main() diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md new file mode 100644 index 0000000..457987b --- /dev/null +++ b/docs/CHANGELOG.md @@ -0,0 +1,99 @@ +# 更新说明 + +## v2.2.1(2026-07-02) + +### 修复:Hook 通道在 notiMessage 后台时丢失消息 + +**问题** +TG 在前台、notiMessage 切到后台时,Hook 虽能收到 Xposed 广播,但原先需经 `startService` 才转发到 PC。Android 8+ 会限制后台启动服务,导致 PC 调试台和日志经常收不到 `[Hook/...]` 消息,表现为「必须两个 App 都在当前页面才行」。 + +**改动** + +| 模块 | 说明 | +|------|------| +| `HookMessageReceiver` | 收到广播后**直接在 Receiver 内**调用 `DebugForwarder`;使用 `goAsync()` 等待 HTTP 完成,避免进程被提前回收 | +| `DebugForwarder` | 增加完成回调与详细日志(成功 / HTTP 错误 / 网络失败) | +| `NotificationService` | `submitFromHook` / `requestStartMonitoring` 改用 `startForegroundService`(正式上传模式);通知通道增加诊断日志 | +| `NotificationHealthCheckWorker` | 健康检查**不再要求联网**;每 15 分钟自动尝试恢复通知监听 | + +**诊断 logcat 标签** + +```text +HookMessageReceiver — Hook 广播到达 / 被跳过 +DebugForwarder — PC 转发结果 +NotificationService — 系统通知到达 / 空通知跳过 +NotiHealthCheck — 定时健康检查 +``` + +```powershell +adb logcat | findstr /i "HookMessageReceiver DebugForwarder NotificationService NotiHealthCheck" +``` + +**验证步骤** + +1. 安装新版主 App:`scripts\install-debug.ps1` +2. 启动 PC 调试台:`scripts\start-debug-server.ps1` +3. notiMessage 点「开始监听」后**切到桌面** +4. 打开 TG 发消息 → PC 应出现 `[Hook/xposed_telegram]` + +> **说明**:TG 在后台时仍走**通知通道**,需 TG 弹出系统通知。若 TG 被系统冻结或未弹通知,请给 TG / notiMessage 设「电池不受限制」,并确认群未静音。 + +--- + +## v2.2 + Xposed v1.1.0 + +### 新功能 + +- **Telegram 专用 Hook**(`TelegramMessageHook`):前台抓取已解密消息,支持文字 / 图片说明 / 群名 +- **双 APK 架构**:主 App + `xposed-module`,LSPosed 作用域需勾选 Telegram 与 notiMessage +- **PC 本地调试台**(`debug-server/server.py`,端口 8765):按群/会话分组展示 +- **`MessageLogStore`**:监听页日志持久化,App 后台再打开可恢复历史 +- **`DebugForwarder`**:消息 POST 到 PC(`AppConfig.ENABLE_DEBUG_FORWARD`) +- **安装脚本**:`build-debug.ps1`、`install-debug.ps1`、`install-full.ps1`、`configure-lsposed.py` + +### 配置(`AppConfig.java`) + +| 开关 | 当前默认值 | 含义 | +|------|------------|------| +| `ENABLE_SERVER_UPLOAD` | `false` | 正式后端上传 | +| `ENABLE_DEBUG_FORWARD` | `true` | 转发到 PC 调试台 | +| `DEBUG_SERVER_URL` | `http://127.0.0.1:8765` | 调试服务地址(USB 用 adb reverse) | + +### 双通道机制 + +| 场景 | 通道 | 日志标识 | +|------|------|----------| +| TG 后台 / 锁屏有系统通知 | 通知监听 | 无前缀 | +| TG 前台(通常无通知) | Xposed Hook | `[Hook/xposed_telegram]` | + +### 已知限制 + +- 通用 SQLite Hook 对 Telegram **无效**(消息在加密 `data` 字段) +- 监听包名须与实际安装一致,如 `org.telegram.messenger.web` +- 空内容通知(纯图片无文字)会被跳过 +- Android Studio 直接 Run 只装主 App;完整能力需 `install-full.ps1` + +### 文档 + +- 架构与扩展:`docs/HOOK_GUIDE.md` +- Agent 说明:`AGENTS.md` + +--- + +## 安装与升级 + +```powershell +# 构建 +powershell -ExecutionPolicy Bypass -File scripts\build-debug.ps1 + +# 安装双 APK +powershell -ExecutionPolicy Bypass -File scripts\install-debug.ps1 + +# 完整安装(含 LSPosed 作用域 + adb reverse + 电池白名单) +powershell -ExecutionPolicy Bypass -File scripts\install-full.ps1 + +# PC 调试台 +powershell -ExecutionPolicy Bypass -File scripts\start-debug-server.ps1 +``` + +升级 v2.2.1 后**至少需重装主 App**;若只改了主 App 代码,Xposed 模块无需重装。 diff --git a/docs/HOOK_GUIDE.md b/docs/HOOK_GUIDE.md new file mode 100644 index 0000000..dfc36ea --- /dev/null +++ b/docs/HOOK_GUIDE.md @@ -0,0 +1,290 @@ +# 消息抓取架构与 Hook 扩展指南 + +本文说明 notiMessage 的双通道抓取机制、Telegram 的实现方式,以及日后接入其他 App 的步骤。 + +--- + +## 1. 总体架构 + +notiMessage 使用 **两条独立通道** 抓取消息,互为补充: + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ 目标 App(如 Telegram) │ +└───────────────┬─────────────────────────────┬───────────────────┘ + │ │ + 后台/锁屏弹系统通知 前台打开 App、消息入库/解码 + │ │ + ▼ ▼ + NotificationListenerService Xposed Hook 模块 + (NotificationService) (xposed-module) + │ │ + │ │ Broadcast + │ ▼ + │ HookMessageReceiver + │ │ goAsync() + │ ├─ MessageLogStore(立即写入) + │ ├─ DebugForwarder(直接转发 PC) + │ └─ submitFromHook(仅正式上传模式) + │ │ + └──────────────┬──────────────┘ + ▼ + NotificationService.submitNotification()(通知通道) + │ + ┌────────────────┼────────────────┐ + ▼ ▼ ▼ + MessageLogStore DebugForwarder (可选)正式后端上传 + (App 本地日志) (PC 调试台) AppConfig.ENABLE_SERVER_UPLOAD +``` + +| 通道 | 触发条件 | 代码入口 | 日志前缀 | +|------|----------|----------|----------| +| **通知监听** | 目标 App 在后台且弹出系统通知 | `NotificationService.onNotificationPosted()` | 无前缀 | +| **Hook** | 目标 App 进程收到新消息(通常在前台) | `HookMessageReceiver` → 直接 `DebugForwarder` | `[Hook/xposed_telegram]` 等 | + +**重要**:仅开通知监听时,目标 App 在前台通常 **不会** 产生系统通知,因此抓不到消息。要覆盖前台场景,必须启用 Xposed Hook。 + +--- + +## 2. Telegram 实现方式 + +### 2.1 包名与作用域 + +Pixel 6 等设备上 Telegram 常见包名: + +| 安装来源 | 包名 | +|----------|------| +| Play / 官网 APK | `org.telegram.messenger` | +| 部分渠道 / Web 版 | `org.telegram.messenger.web` | + +LSPosed 作用域需勾选 **目标 Telegram 包名** + **notiMessage 主 App**。 + +默认作用域见 `xposed-module/src/main/res/values/arrays.xml`。 + +### 2.2 为何不 Hook SQLite? + +Telegram 消息存在 SQLite 的 **`data` 字段(加密二进制 TL 序列化)**,不是明文 `content`/`text`。 + +通用 `SqliteMessageHook` 只能读明文列,对 Telegram **无效**。因此单独实现 `TelegramMessageHook`。 + +### 2.3 Hook 点 + +**主路径**:Hook `NotificationCenter.postNotificationName(int, Object[])` + +- 读取静态字段 `NotificationCenter.didReceiveNewMessages` 作为事件 ID +- 当 `id == didReceiveNewMessages` 时,从参数里的 `List` 取新消息 +- 只处理 **非 outgoing**(`messageOwner.out == false`)的消息 + +**兜底路径**:Hook `MessageObject` 全部构造函数(主路径失败时启用) + +源码位置:`xposed-module/.../hook/TelegramMessageHook.java` + +### 2.4 字段提取逻辑 + +| 字段 | 提取方式 | 用途 | +|------|----------|------| +| **群名/会话名** | `MessagesController.getPeerTitle(dialogId)` → `MessageObject.getName()` → `getChat().title` | 分组标题、转发 `group` | +| **发送者** | `MessageObject.getFromName()` | 群内消息前缀 `发送者: 内容` | +| **正文** | `messageText` → `messageOwner.message` → `caption` | 文本内容 | +| **图片+说明** | 识别 `[图片]` 等媒体标签 + 合并 `caption` | 避免只显示「图片」丢失说明 | +| **dialogId** | `MessageObject.getDialogId()` | 去重键 `dialogId:messageId` | + +**注意**:不要把 `messageOwner.from_id`(`TLRPC$TL_peerUser` 对象)直接 `toString()` 当标题,会出现 `TLRPC$TL_peerUser@xxxx`。 + +### 2.5 转发到主 App + +Hook 模块通过 `HookForwarder` 发送广播: + +``` +Action: com.miraclegarden.smsmessage.action.HOOK_MESSAGE +Package: com.miraclegarden.smsmessage +Extras: packageName, title, content, timestamp, source=xposed_telegram +``` + +主 App 的 `HookMessageReceiver` 接收后: + +1. 检查该 `packageName` 是否在监听列表(`App.getMessageByNotiList`) +2. 写本地日志(`MessageLogStore`,立即持久化) +3. **直接**转发 PC(`DebugForwarder`,不依赖 `startService`,后台可用) +4. 若开启正式上传(`ENABLE_SERVER_UPLOAD`),再经 `submitFromHook` 交给 `NotificationService` + +--- + +## 3. 主 App 相关模块 + +| 文件 | 职责 | +|------|------| +| `NotificationService.java` | 通知监听 + 统一提交入口 | +| `HookMessageReceiver.java` | 接收 Xposed 广播 | +| `MessageLogStore.java` | 日志持久化(后台也能保留历史) | +| `DebugForwarder.java` | POST 到 PC 调试服务 | +| `AppConfig.java` | `ENABLE_DEBUG_FORWARD`、`ENABLE_SERVER_UPLOAD` 开关 | + +### PC 调试台 + +```powershell +# 启动本地服务(含 adb reverse) +powershell -ExecutionPolicy Bypass -File scripts\start-debug-server.ps1 +# 浏览器打开 http://127.0.0.1:8765 ,按群/会话分组展示 +``` + +--- + +## 4. 日后接入其他 App 的步骤 + +### 4.1 先判断用哪种 Hook 策略 + +``` +目标 App 前台消息是否需要抓取? +├─ 否 → 仅通知监听即可,在 App「监听设置」里添加包名 +└─ 是 → 需要 Xposed Hook + │ + ├─ 消息以明文写入 SQLite(content/text/body 等)? + │ └─ 是 → 可复用 SqliteMessageHook(默认兜底) + │ + ├─ 微信? + │ └─ 是 → 已有 WeChatMessageHook(Hook WCDB message 表) + │ + └─ 其他(Telegram、WhatsApp、银行 App 等) + └─ 需编写 **专用 Hook 类** +``` + +### 4.2 新增专用 Hook 的标准流程 + +以新 App `com.example.chat` 为例: + +#### 步骤 1:分析目标 App + +1. 用 `adb shell pm path <包名>` 拉出 APK +2. jadx / dexdump 找消息解码后的类(类似 Telegram 的 `MessageObject`) +3. 确认:消息明文出现在哪个字段、哪个时机(构造函数 / 事件总线 / 通知回调) + +#### 步骤 2:编写 Hook 类 + +在 `xposed-module/src/main/java/.../hook/` 新建 `ExampleChatMessageHook.java`: + +```java +public final class ExampleChatMessageHook { + public static void install(XC_LoadPackage.LoadPackageParam lpparam) { + // 1. findClass + findAndHookMethod / hookAllConstructors + // 2. 提取 title(群名)、content(正文)、过滤 outgoing/系统消息 + // 3. 去重(messageId + dialogId) + // 4. HookForwarder.forward(context, lpparam.packageName, title, content, "xposed_example"); + } +} +``` + +在 `HookBridge.java` 增加 source 常量,例如 `SOURCE_XPOSED_EXAMPLE = "xposed_example"`。 + +#### 步骤 3:注册到 MainHook + +编辑 `MainHook.java`: + +```java +if ("com.example.chat".equals(lpparam.packageName)) { + ExampleChatMessageHook.install(lpparam); + return; +} +``` + +#### 步骤 4:更新 LSPosed 作用域 + +编辑 `xposed-module/src/main/res/values/arrays.xml`: + +```xml +com.example.chat +``` + +#### 步骤 5:主 App 添加监听 + +在 notiMessage「监听设置」中添加该 App(或通过 `shared_prefs/server.xml` 的 `saveNotiList`)。 + +`bankInfoId` 本地调试可用 `local:<包名>`。 + +#### 步骤 6:构建、安装、验证 + +```powershell +# 完整安装(主 App + Xposed + LSPosed 作用域) +powershell -ExecutionPolicy Bypass -File scripts\install-full.ps1 +``` + +验证清单: + +- [ ] LSPosed 中模块已启用,作用域勾选 **目标 App + notiMessage** +- [ ] 强制停止并重启目标 App(让 Hook 重新加载) +- [ ] notiMessage 监听控制台「开始监听」 +- [ ] **后台场景**:收消息 → 通知通道有日志 +- [ ] **前台场景**:收消息 → `[Hook/xxx]` 日志 + PC 调试台有数据 +- [ ] `adb logcat | grep notiMessageHook` 可见 `installed for <包名>` + +### 4.3 复用通用 SqliteMessageHook 的条件 + +`SqliteMessageHook` 会在 `MainHook` 中作为 **默认兜底** 安装(非微信、非 Telegram 时)。 + +适用 App 特征: + +- 使用标准 `SQLiteDatabase` / `FrameworkSQLiteDatabase` +- 表名含 `message` / `messages` +- `ContentValues` 中有明文列:`content`、`text`、`body`、`msg` 等 + +**不适用**:Telegram、WhatsApp、Signal 等端到端加密或二进制 `data` 列的 App。 + +### 4.4 已有专用 Hook 参考 + +| App | 类 | Hook 目标 | +|-----|-----|-----------| +| 微信 | `WeChatMessageHook` | `com.tencent.wcdb.database.SQLiteDatabase` insert,`message` 表 | +| Telegram | `TelegramMessageHook` | `NotificationCenter.didReceiveNewMessages` + `MessageObject` | +| 其他 | `SqliteMessageHook` | 通用 SQLite insert 兜底 | + +--- + +## 5. 构建与安装 + +| 脚本 | 用途 | +|------|------| +| `scripts/build-debug.ps1` | 构建主 App + Xposed 双 APK | +| `scripts/install-debug.ps1` | 仅安装双 APK | +| `scripts/install-full.ps1` | **推荐**:构建 + 双 APK + LSPosed 作用域 + adb reverse + 电池白名单 | +| `scripts/start-debug-server.ps1` | 启动 PC 调试台 | + +**注意**:Android Studio 点 Run 只安装 `:app` 主模块,**不会**安装 Xposed 模块。完整功能必须用 `install-full.ps1` 或手动安装两个 APK。 + +--- + +## 6. 常见问题 + +| 现象 | 原因 | 处理 | +|------|------|------| +| 前台有消息,App 无日志 | 仅通知通道,Hook 未生效 | 检查 LSPosed 作用域、重启目标 App | +| 标题显示 `TLRPC$...` | 误把对象当字符串 | 用 `getPeerTitle` 等 API,见 TelegramMessageHook | +| 图片消息只有「图片」 | 说明在 caption 字段 | 合并 `caption` + 媒体标签 | +| PC 有数据,App 无历史 | 旧版日志未持久化 | 已用 `MessageLogStore` 修复,更新主 App | +| Hook 日志前缀 `[Hook/...]` 无 | Xposed 未注入 | 确认 Magisk + LSPosed + 模块启用 | + +--- + +## 7. 相关文件索引 + +``` +xposed-module/ +├── src/main/assets/xposed_init # 入口 MainHook +├── src/main/java/.../MainHook.java # 包名路由 +├── src/main/java/.../HookForwarder.java # 广播转发 +├── src/main/java/.../hook/ +│ ├── TelegramMessageHook.java +│ ├── WeChatMessageHook.java +│ └── SqliteMessageHook.java +└── src/main/res/values/arrays.xml # xposed_scope 默认作用域 + +app/ +├── src/main/java/.../service/ +│ ├── NotificationService.java +│ └── HookMessageReceiver.java +├── src/main/java/.../MessageLogStore.java +├── src/main/java/.../network/DebugForwarder.java +└── src/main/java/.../AppConfig.java + +debug-server/server.py # PC 调试台 +scripts/install-full.ps1 # 一键完整部署 +``` diff --git a/scripts/build-debug.ps1 b/scripts/build-debug.ps1 index 5156ead..5b6e896 100644 --- a/scripts/build-debug.ps1 +++ b/scripts/build-debug.ps1 @@ -1,4 +1,4 @@ -# Build debug APK for notiMessage +# Build debug APKs for notiMessage (app + xposed-module) $ErrorActionPreference = "Stop" $ProjectRoot = Split-Path -Parent $PSScriptRoot Set-Location $ProjectRoot @@ -6,14 +6,21 @@ Set-Location $ProjectRoot $env:JAVA_HOME = "C:\Program Files\Java\jdk-17" $javaHomeArg = "-Dorg.gradle.java.home=C:\Program Files\Java\jdk-17" Write-Host "JAVA_HOME=$env:JAVA_HOME" -Write-Host "Building debug APK..." -& "$ProjectRoot\gradlew.bat" $javaHomeArg assembleDebug +Write-Host "Building debug APKs..." +& "$ProjectRoot\gradlew.bat" $javaHomeArg :app:assembleDebug :xposed-module:assembleDebug if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } -$apk = Join-Path $ProjectRoot "app\build\outputs\apk\debug\app-debug.apk" -if (Test-Path $apk) { - Write-Host "" - Write-Host "Build OK: $apk" +$appApk = Join-Path $ProjectRoot "app\build\outputs\apk\debug\app-debug.apk" +$xposedApk = Join-Path $ProjectRoot "xposed-module\build\outputs\apk\debug\xposed-module-debug.apk" + +Write-Host "" +if (Test-Path $appApk) { + Write-Host "App OK: $appApk" } else { - Write-Host "Build finished but APK not found at expected path." -ForegroundColor Yellow + Write-Host "App APK not found." -ForegroundColor Yellow +} +if (Test-Path $xposedApk) { + Write-Host "Xposed OK: $xposedApk" +} else { + Write-Host "Xposed APK not found." -ForegroundColor Yellow } diff --git a/scripts/configure-lsposed.py b/scripts/configure-lsposed.py new file mode 100644 index 0000000..e4a7d68 --- /dev/null +++ b/scripts/configure-lsposed.py @@ -0,0 +1,31 @@ +import shutil +import sqlite3 +import sys + +db_path = sys.argv[1] +apk_path = sys.argv[2] +hook_pkg = "com.miraclegarden.smsmessage.xposed" +scopes = [ + "org.telegram.messenger.web", + "org.telegram.messenger", + "com.miraclegarden.smsmessage", +] + +shutil.copy2(db_path, db_path + ".bak") +conn = sqlite3.connect(db_path) +c = conn.cursor() + +c.execute( + "INSERT OR REPLACE INTO modules (mid, module_pkg_name, apk_path, enabled, auto_include) " + "VALUES (?, ?, ?, ?, ?)", + (2, hook_pkg, apk_path, 1, 0), +) +c.execute("DELETE FROM scope WHERE mid = 2") +for pkg in scopes: + c.execute( + "INSERT OR REPLACE INTO scope (mid, app_pkg_name, user_id) VALUES (?, ?, ?)", + (2, pkg, 0), + ) +conn.commit() +conn.close() +print("configured", hook_pkg, "scopes=", scopes) diff --git a/scripts/install-debug.ps1 b/scripts/install-debug.ps1 index 8287b6b..8897157 100644 --- a/scripts/install-debug.ps1 +++ b/scripts/install-debug.ps1 @@ -1,10 +1,11 @@ -# Install debug APK to connected Android device via adb +# Install debug APKs to connected Android device via adb $ErrorActionPreference = "Stop" $ProjectRoot = Split-Path -Parent $PSScriptRoot -$apk = Join-Path $ProjectRoot "app\build\outputs\apk\debug\app-debug.apk" +$appApk = Join-Path $ProjectRoot "app\build\outputs\apk\debug\app-debug.apk" +$xposedApk = Join-Path $ProjectRoot "xposed-module\build\outputs\apk\debug\xposed-module-debug.apk" -if (-not (Test-Path $apk)) { - Write-Host "APK not found. Run scripts\build-debug.ps1 first." -ForegroundColor Red +if (-not (Test-Path $appApk)) { + Write-Host "App APK not found. Run scripts\build-debug.ps1 first." -ForegroundColor Red exit 1 } @@ -16,8 +17,25 @@ if (-not (Test-Path $adb)) { } & $adb devices -Write-Host "Installing $apk ..." -& $adb install -r $apk -if ($LASTEXITCODE -eq 0) { - Write-Host "Install OK." -ForegroundColor Green +Write-Host "Installing app: $appApk ..." +& $adb shell settings put global verifier_verify_adb_installs 0 2>$null +& $adb install -r -t -g $appApk +if ($LASTEXITCODE -ne 0) { + Write-Host "Retry install with --bypass-low-target-sdk-block ..." + & $adb install -r -t -g --bypass-low-target-sdk-block $appApk } +if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + +if (Test-Path $xposedApk) { + Write-Host "Installing xposed module: $xposedApk ..." + & $adb install -r -t -g $xposedApk + if ($LASTEXITCODE -ne 0) { + & $adb install -r -t -g --bypass-low-target-sdk-block $xposedApk + } + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } +} else { + Write-Host "Xposed APK not found, skipped." -ForegroundColor Yellow +} + +Write-Host "Install OK." -ForegroundColor Green +Write-Host "Remember to enable the module in LSPosed and scope Telegram + notiMessage." diff --git a/scripts/install-from-studio.ps1 b/scripts/install-from-studio.ps1 new file mode 100644 index 0000000..aba8b3b --- /dev/null +++ b/scripts/install-from-studio.ps1 @@ -0,0 +1,5 @@ +# 在 Android Studio 中安装完整版(主 App + Xposed 模块) +# 用法: Run 选 app 只会装主包;完整版请运行此脚本或 install-full.ps1 + +$ErrorActionPreference = "Stop" +& "$PSScriptRoot\install-full.ps1" diff --git a/scripts/install-full.ps1 b/scripts/install-full.ps1 new file mode 100644 index 0000000..89f9c80 --- /dev/null +++ b/scripts/install-full.ps1 @@ -0,0 +1,72 @@ +$ErrorActionPreference = "Stop" +$ProjectRoot = Split-Path -Parent $PSScriptRoot +Set-Location $ProjectRoot + +$sdk = "C:\Users\Administrator\AppData\Local\Android\Sdk" +$adb = Join-Path $sdk "platform-tools\adb.exe" +if (-not (Test-Path $adb)) { + Write-Host "adb not found: $adb" -ForegroundColor Red + exit 1 +} + +Write-Host "=== Step 1/5 Build ===" -ForegroundColor Cyan +& "$ProjectRoot\scripts\build-debug.ps1" +if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + +$appApk = Join-Path $ProjectRoot "app\build\outputs\apk\debug\app-debug.apk" +$xposedApk = Join-Path $ProjectRoot "xposed-module\build\outputs\apk\debug\xposed-module-debug.apk" + +Write-Host "`n=== Step 2/5 Device ===" -ForegroundColor Cyan +& $adb devices +$device = (& $adb devices | Select-String "device$" | Select-Object -First 1) +if (-not $device) { + Write-Host "No authorized adb device" -ForegroundColor Red + exit 1 +} + +Write-Host "`n=== Step 3/5 Install APKs ===" -ForegroundColor Cyan +& $adb shell settings put global verifier_verify_adb_installs 0 2>$null +& $adb install -r -t -g $appApk +if ($LASTEXITCODE -ne 0) { + & $adb install -r -t -g --bypass-low-target-sdk-block $appApk +} +if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } +& $adb install -r -t -g $xposedApk +if ($LASTEXITCODE -ne 0) { + & $adb install -r -t -g --bypass-low-target-sdk-block $xposedApk +} +if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + +Write-Host "`n=== Step 4/5 LSPosed scope ===" -ForegroundColor Cyan +$apkPath = (& $adb shell pm path com.miraclegarden.smsmessage.xposed 2>$null) -replace '^package:', '' +$apkPath = $apkPath.Trim() +if (-not $apkPath) { + Write-Host "Failed to get xposed module path" -ForegroundColor Red + exit 1 +} +Write-Host "xposed apk: $apkPath" + +& $adb shell "su -c 'cp /data/adb/lspd/config/modules_config.db /sdcard/Download/modules_config.db; cp /data/adb/lspd/config/modules_config.db-wal /sdcard/Download/modules_config.db-wal 2>/dev/null; cp /data/adb/lspd/config/modules_config.db-shm /sdcard/Download/modules_config.db-shm 2>/dev/null; chmod 644 /sdcard/Download/modules_config.db*'" +$db = Join-Path $env:TEMP "modules_config_full.db" +$prevEap = $ErrorActionPreference +$ErrorActionPreference = "Continue" +& $adb pull /sdcard/Download/modules_config.db $db 2>&1 | Out-Null +& $adb pull /sdcard/Download/modules_config.db-wal "$db-wal" 2>&1 | Out-Null +& $adb pull /sdcard/Download/modules_config.db-shm "$db-shm" 2>&1 | Out-Null +$ErrorActionPreference = $prevEap +python "$ProjectRoot\scripts\configure-lsposed.py" $db $apkPath +& $adb push $db /sdcard/Download/modules_config.db | Out-Null +& $adb shell "su -c 'cp /sdcard/Download/modules_config.db /data/adb/lspd/config/modules_config.db; rm -f /data/adb/lspd/config/modules_config.db-wal /data/adb/lspd/config/modules_config.db-shm; chmod 660 /data/adb/lspd/config/modules_config.db'" + +Write-Host "`n=== Step 5/5 System tweaks ===" -ForegroundColor Cyan +& $adb reverse tcp:8765 tcp:8765 2>$null +& $adb shell "su -c 'dumpsys deviceidle whitelist +com.miraclegarden.smsmessage'" 2>$null +& $adb shell "cmd notification allow_listener com.miraclegarden.smsmessage/com.miraclegarden.smsmessage.service.NotificationService" 2>$null +& $adb shell "am force-stop org.telegram.messenger.web" +& $adb shell "am force-stop com.miraclegarden.smsmessage" + +Write-Host "" +Write-Host "Full install done." -ForegroundColor Green +Write-Host "Installed: app + xposed module" +Write-Host "Configured: LSPosed scope, adb reverse, battery whitelist, notification listener" +Write-Host "Next: open notiMessage -> start monitoring -> reopen Telegram" diff --git a/scripts/start-debug-server.ps1 b/scripts/start-debug-server.ps1 new file mode 100644 index 0000000..85285ce --- /dev/null +++ b/scripts/start-debug-server.ps1 @@ -0,0 +1,18 @@ +# 启动 PC 调试转发服务 +$ErrorActionPreference = "Stop" +$ProjectRoot = Split-Path -Parent $PSScriptRoot +$Server = Join-Path $ProjectRoot "debug-server\server.py" + +$sdk = "C:\Users\Administrator\AppData\Local\Android\Sdk" +$adb = Join-Path $sdk "platform-tools\adb.exe" +if (Test-Path $adb) { + Write-Host "Setting adb reverse tcp:8765 ..." + & $adb reverse tcp:8765 tcp:8765 2>$null +} + +Write-Host "Starting debug server on http://127.0.0.1:8765" +if (Get-Command py -ErrorAction SilentlyContinue) { + py -3 $Server +} else { + python $Server +} diff --git a/settings.gradle b/settings.gradle index d266937..3af9882 100644 --- a/settings.gradle +++ b/settings.gradle @@ -10,6 +10,7 @@ pluginManagement { maven { url 'https://maven.aliyun.com/nexus/content/repositories/jcenter' } maven { url 'https://maven.aliyun.com/nexus/content/repositories/google' } maven { url 'https://maven.aliyun.com/nexus/content/repositories/gradle-plugin' } + maven { url 'https://api.xposed.info/' } } } dependencyResolutionManagement { @@ -24,8 +25,10 @@ dependencyResolutionManagement { maven { url 'https://maven.aliyun.com/nexus/content/repositories/jcenter' } maven { url 'https://maven.aliyun.com/nexus/content/repositories/google' } maven { url 'https://maven.aliyun.com/nexus/content/repositories/gradle-plugin' } + maven { url 'https://api.xposed.info/' } } } rootProject.name = "SmsMessage" include ':app' include ':library' +include ':xposed-module' diff --git a/xposed-module/build.gradle b/xposed-module/build.gradle new file mode 100644 index 0000000..93104ed --- /dev/null +++ b/xposed-module/build.gradle @@ -0,0 +1,37 @@ +plugins { + id 'com.android.application' +} + +android { + namespace 'com.miraclegarden.smsmessage.xposed' + compileSdk 34 + + defaultConfig { + applicationId "com.miraclegarden.smsmessage.xposed" + minSdk 24 + targetSdk 34 + versionCode 2 + versionName "1.1.0" + } + + buildTypes { + release { + minifyEnabled false + proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' + } + } + + compileOptions { + sourceCompatibility JavaVersion.VERSION_17 + targetCompatibility JavaVersion.VERSION_17 + } + + lint { + abortOnError false + } +} + +dependencies { + compileOnly 'de.robv.android.xposed:api:82' + compileOnly 'de.robv.android.xposed:api:82:sources' +} diff --git a/xposed-module/proguard-rules.pro b/xposed-module/proguard-rules.pro new file mode 100644 index 0000000..f606a08 --- /dev/null +++ b/xposed-module/proguard-rules.pro @@ -0,0 +1 @@ +-keep class com.miraclegarden.smsmessage.xposed.** { *; } diff --git a/xposed-module/src/main/AndroidManifest.xml b/xposed-module/src/main/AndroidManifest.xml new file mode 100644 index 0000000..a3f2162 --- /dev/null +++ b/xposed-module/src/main/AndroidManifest.xml @@ -0,0 +1,22 @@ + + + + + + + + + + + + diff --git a/xposed-module/src/main/assets/xposed_init b/xposed-module/src/main/assets/xposed_init new file mode 100644 index 0000000..960e800 --- /dev/null +++ b/xposed-module/src/main/assets/xposed_init @@ -0,0 +1 @@ +com.miraclegarden.smsmessage.xposed.MainHook diff --git a/xposed-module/src/main/java/com/miraclegarden/smsmessage/xposed/HookBridge.java b/xposed-module/src/main/java/com/miraclegarden/smsmessage/xposed/HookBridge.java new file mode 100644 index 0000000..ce98e69 --- /dev/null +++ b/xposed-module/src/main/java/com/miraclegarden/smsmessage/xposed/HookBridge.java @@ -0,0 +1,18 @@ +package com.miraclegarden.smsmessage.xposed; + +public final class HookBridge { + + public static final String TARGET_APP_PACKAGE = "com.miraclegarden.smsmessage"; + public static final String ACTION_HOOK_MESSAGE = "com.miraclegarden.smsmessage.action.HOOK_MESSAGE"; + public static final String EXTRA_PACKAGE_NAME = "packageName"; + public static final String EXTRA_TITLE = "title"; + public static final String EXTRA_CONTENT = "content"; + public static final String EXTRA_TIMESTAMP = "timestamp"; + public static final String EXTRA_SOURCE = "source"; + public static final String SOURCE_XPOSED_SQLITE = "xposed_sqlite"; + public static final String SOURCE_XPOSED_WECHAT = "xposed_wechat"; + public static final String SOURCE_XPOSED_TELEGRAM = "xposed_telegram"; + + private HookBridge() { + } +} diff --git a/xposed-module/src/main/java/com/miraclegarden/smsmessage/xposed/HookForwarder.java b/xposed-module/src/main/java/com/miraclegarden/smsmessage/xposed/HookForwarder.java new file mode 100644 index 0000000..577812b --- /dev/null +++ b/xposed-module/src/main/java/com/miraclegarden/smsmessage/xposed/HookForwarder.java @@ -0,0 +1,36 @@ +package com.miraclegarden.smsmessage.xposed; + +import android.content.Context; +import android.content.Intent; +import android.text.TextUtils; + +import de.robv.android.xposed.XposedBridge; + +public final class HookForwarder { + + private static final String TAG = "notiMessageHook"; + + private HookForwarder() { + } + + public static void forward(Context context, String packageName, String title, + String content, String source) { + if (context == null || TextUtils.isEmpty(packageName) || TextUtils.isEmpty(content)) { + return; + } + + try { + Intent intent = new Intent(HookBridge.ACTION_HOOK_MESSAGE); + intent.setPackage(HookBridge.TARGET_APP_PACKAGE); + intent.putExtra(HookBridge.EXTRA_PACKAGE_NAME, packageName); + intent.putExtra(HookBridge.EXTRA_TITLE, + TextUtils.isEmpty(title) ? packageName : title); + intent.putExtra(HookBridge.EXTRA_CONTENT, content); + intent.putExtra(HookBridge.EXTRA_TIMESTAMP, System.currentTimeMillis()); + intent.putExtra(HookBridge.EXTRA_SOURCE, source); + context.sendBroadcast(intent); + } catch (Throwable t) { + XposedBridge.log(TAG + " sendBroadcast failed: " + t.getMessage()); + } + } +} diff --git a/xposed-module/src/main/java/com/miraclegarden/smsmessage/xposed/MainHook.java b/xposed-module/src/main/java/com/miraclegarden/smsmessage/xposed/MainHook.java new file mode 100644 index 0000000..30f663d --- /dev/null +++ b/xposed-module/src/main/java/com/miraclegarden/smsmessage/xposed/MainHook.java @@ -0,0 +1,39 @@ +package com.miraclegarden.smsmessage.xposed; + +import com.miraclegarden.smsmessage.xposed.hook.SqliteMessageHook; +import com.miraclegarden.smsmessage.xposed.hook.TelegramMessageHook; +import com.miraclegarden.smsmessage.xposed.hook.WeChatMessageHook; + +import de.robv.android.xposed.IXposedHookLoadPackage; +import de.robv.android.xposed.callbacks.XC_LoadPackage; + +public class MainHook implements IXposedHookLoadPackage { + + private static final String WECHAT_PACKAGE = "com.tencent.mm"; + private static final String TELEGRAM_PACKAGE = "org.telegram.messenger"; + private static final String TELEGRAM_WEB_PACKAGE = "org.telegram.messenger.web"; + private static final String MAIN_APP_PACKAGE = "com.miraclegarden.smsmessage"; + + @Override + public void handleLoadPackage(XC_LoadPackage.LoadPackageParam lpparam) throws Throwable { + if (lpparam == null || lpparam.packageName == null) { + return; + } + if (MAIN_APP_PACKAGE.equals(lpparam.packageName)) { + return; + } + + if (WECHAT_PACKAGE.equals(lpparam.packageName)) { + WeChatMessageHook.install(lpparam); + return; + } + + if (TELEGRAM_PACKAGE.equals(lpparam.packageName) + || TELEGRAM_WEB_PACKAGE.equals(lpparam.packageName)) { + TelegramMessageHook.install(lpparam); + return; + } + + SqliteMessageHook.install(lpparam); + } +} diff --git a/xposed-module/src/main/java/com/miraclegarden/smsmessage/xposed/hook/SqliteMessageHook.java b/xposed-module/src/main/java/com/miraclegarden/smsmessage/xposed/hook/SqliteMessageHook.java new file mode 100644 index 0000000..8dc6067 --- /dev/null +++ b/xposed-module/src/main/java/com/miraclegarden/smsmessage/xposed/hook/SqliteMessageHook.java @@ -0,0 +1,123 @@ +package com.miraclegarden.smsmessage.xposed.hook; + +import android.content.ContentValues; +import android.content.Context; +import android.text.TextUtils; + +import com.miraclegarden.smsmessage.xposed.HookBridge; +import com.miraclegarden.smsmessage.xposed.HookForwarder; + +import de.robv.android.xposed.XC_MethodHook; +import de.robv.android.xposed.XposedBridge; +import de.robv.android.xposed.XposedHelpers; +import de.robv.android.xposed.callbacks.XC_LoadPackage; + +public final class SqliteMessageHook { + + private static final String TAG = "notiMessageHook/Sqlite"; + private static final String[] SQLITE_CLASSES = { + "android.database.sqlite.SQLiteDatabase", + "androidx.sqlite.db.framework.FrameworkSQLiteDatabase" + }; + + private SqliteMessageHook() { + } + + public static void install(XC_LoadPackage.LoadPackageParam lpparam) { + for (String sqliteClass : SQLITE_CLASSES) { + try { + hookInsert(lpparam, sqliteClass, "insert"); + hookInsert(lpparam, sqliteClass, "insertWithOnConflict"); + } catch (Throwable t) { + XposedBridge.log(TAG + " skip " + sqliteClass + ": " + t.getMessage()); + } + } + } + + private static void hookInsert(XC_LoadPackage.LoadPackageParam lpparam, + String className, String methodName) { + XposedHelpers.findAndHookMethod( + className, + lpparam.classLoader, + methodName, + String.class, + String.class, + ContentValues.class, + new XC_MethodHook() { + @Override + protected void afterHookedMethod(MethodHookParam param) { + handleInsert(lpparam.packageName, param); + } + } + ); + } + + private static void handleInsert(String packageName, XC_MethodHook.MethodHookParam param) { + if (param.args == null || param.args.length < 3) { + return; + } + + Object tableArg = param.args[0]; + Object valuesArg = param.args[2]; + if (!(tableArg instanceof String) || !(valuesArg instanceof ContentValues)) { + return; + } + + String table = ((String) tableArg).toLowerCase(); + if (!isMessageTable(table)) { + return; + } + + ContentValues values = (ContentValues) valuesArg; + String title = firstNonEmpty(values, "talker", "sender", "from", "title", "name"); + String content = firstNonEmpty(values, "content", "text", "body", "msg", "message"); + if (TextUtils.isEmpty(content)) { + return; + } + + Context context = getContext(param.thisObject); + if (context == null) { + return; + } + + HookForwarder.forward(context, packageName, title, content, + HookBridge.SOURCE_XPOSED_SQLITE); + } + + private static boolean isMessageTable(String table) { + return "message".equals(table) + || "messages".equals(table) + || table.endsWith("_message") + || table.contains("message"); + } + + private static String firstNonEmpty(ContentValues values, String... keys) { + for (String key : keys) { + String value = values.getAsString(key); + if (!TextUtils.isEmpty(value)) { + return value; + } + } + return ""; + } + + private static Context getContext(Object database) { + try { + Object ctx = XposedHelpers.callMethod(database, "getContext"); + if (ctx instanceof Context) { + return (Context) ctx; + } + } catch (Throwable ignored) { + } + + try { + Class activityThread = XposedHelpers.findClass("android.app.ActivityThread", null); + Object app = XposedHelpers.callStaticMethod(activityThread, "currentApplication"); + if (app instanceof Context) { + return (Context) app; + } + } catch (Throwable ignored) { + } + return null; + } +} diff --git a/xposed-module/src/main/java/com/miraclegarden/smsmessage/xposed/hook/TelegramMessageHook.java b/xposed-module/src/main/java/com/miraclegarden/smsmessage/xposed/hook/TelegramMessageHook.java new file mode 100644 index 0000000..bae400d --- /dev/null +++ b/xposed-module/src/main/java/com/miraclegarden/smsmessage/xposed/hook/TelegramMessageHook.java @@ -0,0 +1,479 @@ +package com.miraclegarden.smsmessage.xposed.hook; + +import android.content.Context; +import android.text.TextUtils; + +import com.miraclegarden.smsmessage.xposed.HookBridge; +import com.miraclegarden.smsmessage.xposed.HookForwarder; + +import java.util.ArrayDeque; +import java.util.HashSet; +import java.util.List; + +import de.robv.android.xposed.XC_MethodHook; +import de.robv.android.xposed.XposedBridge; +import de.robv.android.xposed.XposedHelpers; +import de.robv.android.xposed.callbacks.XC_LoadPackage; + +/** + * Telegram 专用 Hook:监听 NotificationCenter.didReceiveNewMessages, + * 从 MessageObject 提取已解密的 messageText。 + */ +public final class TelegramMessageHook { + + private static final String TAG = "notiMessageHook/Telegram"; + private static final String NOTIFICATION_CENTER = "org.telegram.messenger.NotificationCenter"; + private static final String MESSAGE_OBJECT = "org.telegram.messenger.MessageObject"; + private static final int DEDUP_SIZE = 512; + + private static final ArrayDeque RECENT_KEYS = new ArrayDeque<>(); + private static final HashSet RECENT_SET = new HashSet<>(); + + private TelegramMessageHook() { + } + + public static void install(XC_LoadPackage.LoadPackageParam lpparam) { + try { + Class notificationCenter = XposedHelpers.findClass( + NOTIFICATION_CENTER, lpparam.classLoader); + final int didReceiveNewMessages = XposedHelpers.getStaticIntField( + notificationCenter, "didReceiveNewMessages"); + + XposedHelpers.findAndHookMethod( + notificationCenter, + "postNotificationName", + int.class, + Object[].class, + new XC_MethodHook() { + @Override + protected void afterHookedMethod(MethodHookParam param) { + int id = (int) param.args[0]; + if (id != didReceiveNewMessages) { + return; + } + Object[] args = (Object[]) param.args[1]; + if (args == null || args.length == 0) { + return; + } + + Context context = getContext(); + if (context == null) { + return; + } + + for (Object arg : args) { + if (arg instanceof List) { + processMessageList(context, lpparam.packageName, (List) arg); + } + } + } + } + ); + + XposedBridge.log(TAG + " installed for " + lpparam.packageName + + ", didReceiveNewMessages=" + didReceiveNewMessages); + } catch (Throwable t) { + XposedBridge.log(TAG + " install failed: " + t.getMessage()); + installMessageObjectFallback(lpparam); + } + } + + /** NotificationCenter Hook 失败时的兜底:Hook MessageObject 构造完成后的消息。 */ + private static void installMessageObjectFallback(XC_LoadPackage.LoadPackageParam lpparam) { + try { + Class messageObjectClass = XposedHelpers.findClass( + MESSAGE_OBJECT, lpparam.classLoader); + XposedBridge.hookAllConstructors(messageObjectClass, new XC_MethodHook() { + @Override + protected void afterHookedMethod(MethodHookParam param) { + Context context = getContext(); + if (context == null) { + return; + } + forwardMessageObject(context, lpparam.packageName, param.thisObject); + } + }); + XposedBridge.log(TAG + " fallback MessageObject hook installed for " + + lpparam.packageName); + } catch (Throwable t) { + XposedBridge.log(TAG + " fallback install failed: " + t.getMessage()); + } + } + + private static void processMessageList(Context context, String packageName, List messages) { + for (Object item : messages) { + if (item == null) { + continue; + } + if (!MESSAGE_OBJECT.equals(item.getClass().getName())) { + continue; + } + forwardMessageObject(context, packageName, item); + } + } + + private static void forwardMessageObject(Context context, String packageName, Object messageObject) { + try { + Object messageOwner = XposedHelpers.getObjectField(messageObject, "messageOwner"); + if (messageOwner == null) { + return; + } + + if (XposedHelpers.getBooleanField(messageOwner, "out")) { + return; + } + + int messageId = XposedHelpers.getIntField(messageOwner, "id"); + long dialogId = extractDialogId(messageObject, messageOwner); + if (!rememberMessage(dialogId, messageId)) { + return; + } + + String content = extractContent(messageObject, messageOwner); + if (TextUtils.isEmpty(content)) { + return; + } + + String title = extractTitle(messageObject, messageOwner, dialogId); + long timestamp = XposedHelpers.getIntField(messageOwner, "date") * 1000L; + if (timestamp <= 0) { + timestamp = System.currentTimeMillis(); + } + + String sender = extractSenderName(messageObject); + String body = content; + if (!TextUtils.isEmpty(sender) && !sender.equals(title)) { + body = sender + ": " + content; + } + + HookForwarder.forward(context, packageName, title, body, + HookBridge.SOURCE_XPOSED_TELEGRAM); + } catch (Throwable t) { + XposedBridge.log(TAG + " forward failed: " + t.getMessage()); + } + } + + private static long extractDialogId(Object messageObject, Object messageOwner) { + try { + return ((Number) XposedHelpers.callMethod(messageObject, "getDialogId")).longValue(); + } catch (Throwable ignored) { + } + try { + return XposedHelpers.getLongField(messageOwner, "dialog_id"); + } catch (Throwable ignored) { + } + return 0L; + } + + private static String extractContent(Object messageObject, Object messageOwner) { + String caption = firstNonEmpty( + safeText(XposedHelpers.getObjectField(messageObject, "caption")), + safeText(XposedHelpers.getObjectField(messageOwner, "message")), + safeText(callMethodSafe(messageObject, "getCaption")) + ); + + String messageText = firstNonEmpty( + safeText(XposedHelpers.getObjectField(messageObject, "messageText")), + safeText(callMethodSafe(messageObject, "getMessageText")) + ); + + String mediaLabel = detectMediaLabel(messageObject, messageOwner); + + if (!TextUtils.isEmpty(caption)) { + if (!TextUtils.isEmpty(mediaLabel)) { + return mediaLabel + "\n" + caption; + } + if (!TextUtils.isEmpty(messageText) && !isMediaPlaceholder(messageText) + && !messageText.equals(caption)) { + return messageText + "\n" + caption; + } + return caption; + } + + if (!TextUtils.isEmpty(messageText) && !isMediaPlaceholder(messageText)) { + return messageText; + } + + if (!TextUtils.isEmpty(mediaLabel)) { + return mediaLabel; + } + + if (!TextUtils.isEmpty(messageText)) { + return messageText; + } + + return ""; + } + + private static String detectMediaLabel(Object messageObject, Object messageOwner) { + try { + if (Boolean.TRUE.equals(XposedHelpers.callMethod(messageObject, "isSticker"))) { + return "[贴纸]"; + } + } catch (Throwable ignored) { + } + try { + if (Boolean.TRUE.equals(XposedHelpers.callMethod(messageObject, "isPhoto"))) { + return "[图片]"; + } + } catch (Throwable ignored) { + } + try { + if (Boolean.TRUE.equals(XposedHelpers.callMethod(messageObject, "isVideo"))) { + return "[视频]"; + } + } catch (Throwable ignored) { + } + try { + if (Boolean.TRUE.equals(XposedHelpers.callMethod(messageObject, "isGif"))) { + return "[GIF]"; + } + } catch (Throwable ignored) { + } + try { + if (Boolean.TRUE.equals(XposedHelpers.callMethod(messageObject, "isVoice"))) { + return "[语音]"; + } + } catch (Throwable ignored) { + } + try { + if (Boolean.TRUE.equals(XposedHelpers.callMethod(messageObject, "isMusic"))) { + return "[音频]"; + } + } catch (Throwable ignored) { + } + + Object media = XposedHelpers.getObjectField(messageOwner, "media"); + if (media == null) { + return ""; + } + String mediaClass = media.getClass().getName(); + if (mediaClass.contains("Photo")) { + return "[图片]"; + } + if (mediaClass.contains("Document")) { + return "[文件]"; + } + if (mediaClass.contains("Video")) { + return "[视频]"; + } + if (mediaClass.contains("Geo")) { + return "[位置]"; + } + if (mediaClass.contains("Contact")) { + return "[联系人]"; + } + return "[媒体消息]"; + } + + private static boolean isMediaPlaceholder(String text) { + if (TextUtils.isEmpty(text)) { + return true; + } + String value = text.trim(); + return value.equals("图片") + || value.equals("Photo") + || value.equals("照片") + || value.equals("贴纸") + || value.equals("Sticker") + || value.equals("Video") + || value.equals("视频") + || value.equals("GIF") + || value.equals("动画") + || value.equals("文件") + || value.equals("Document") + || value.equals("语音") + || value.equals("Voice") + || value.equals("音频") + || value.equals("Audio") + || value.equals("位置") + || value.equals("Location") + || value.startsWith("[媒体") + || value.startsWith("[图片") + || value.startsWith("[贴纸") + || value.startsWith("[视频"); + } + + private static String firstNonEmpty(String... values) { + for (String value : values) { + if (!TextUtils.isEmpty(value)) { + return value; + } + } + return ""; + } + + private static String callMethodSafe(Object target, String methodName) { + try { + return safeText(XposedHelpers.callMethod(target, methodName)); + } catch (Throwable ignored) { + return ""; + } + } + + private static String extractTitle(Object messageObject, Object messageOwner, long dialogId) { + String viaController = resolveTitleViaController(messageObject, dialogId); + if (!TextUtils.isEmpty(viaController)) { + return viaController; + } + + try { + Object dialogName = XposedHelpers.callMethod(messageObject, "getName"); + String name = safeText(dialogName); + if (!TextUtils.isEmpty(name)) { + return name; + } + } catch (Throwable ignored) { + } + + try { + Object chat = XposedHelpers.callMethod(messageObject, "getChat"); + if (chat != null) { + String chatTitle = safeText(XposedHelpers.getObjectField(chat, "title")); + if (!TextUtils.isEmpty(chatTitle)) { + return chatTitle; + } + } + } catch (Throwable ignored) { + } + + String localName = safeText(XposedHelpers.getObjectField(messageObject, "localName")); + if (!TextUtils.isEmpty(localName)) { + return localName; + } + + return dialogId != 0 ? ("对话 " + dialogId) : "Telegram"; + } + + private static String resolveTitleViaController(Object messageObject, long dialogId) { + if (dialogId == 0) { + return null; + } + try { + int account = XposedHelpers.getIntField(messageObject, "currentAccount"); + ClassLoader cl = messageObject.getClass().getClassLoader(); + Class mcClass = XposedHelpers.findClass( + "org.telegram.messenger.MessagesController", cl); + Object mc = XposedHelpers.callStaticMethod(mcClass, "getInstance", account); + + Object title = null; + try { + title = XposedHelpers.callMethod(mc, "getPeerTitle", dialogId, false); + } catch (Throwable ignored) { + title = XposedHelpers.callMethod(mc, "getPeerTitle", dialogId); + } + + String text = safeText(title); + if (!TextUtils.isEmpty(text)) { + return text; + } + + if (dialogId < 0) { + Object chat = XposedHelpers.callMethod(mc, "getChat", -dialogId); + if (chat == null) { + chat = XposedHelpers.callMethod(mc, "getChat", dialogId); + } + if (chat != null) { + text = safeText(XposedHelpers.getObjectField(chat, "title")); + if (!TextUtils.isEmpty(text)) { + return text; + } + } + } else { + Object user = XposedHelpers.callMethod(mc, "getUser", dialogId); + text = formatUserName(user); + if (!TextUtils.isEmpty(text)) { + return text; + } + } + } catch (Throwable t) { + XposedBridge.log(TAG + " resolveTitleViaController: " + t.getMessage()); + } + return null; + } + + private static String formatUserName(Object user) { + if (user == null) { + return ""; + } + String first = safeText(XposedHelpers.getObjectField(user, "first_name")); + String last = safeText(XposedHelpers.getObjectField(user, "last_name")); + String username = safeText(XposedHelpers.getObjectField(user, "username")); + StringBuilder sb = new StringBuilder(); + if (!TextUtils.isEmpty(first)) { + sb.append(first); + } + if (!TextUtils.isEmpty(last)) { + if (sb.length() > 0) { + sb.append(' '); + } + sb.append(last); + } + if (sb.length() > 0) { + return sb.toString(); + } + if (!TextUtils.isEmpty(username)) { + return "@" + username; + } + return ""; + } + + private static String safeText(Object value) { + if (value == null) { + return ""; + } + if (!(value instanceof CharSequence) && !(value instanceof Number) && !(value instanceof Boolean)) { + String className = value.getClass().getName(); + if (className.startsWith("org.telegram.tgnet.") + || className.contains("TLRPC$") + || value.getClass().getName().contains("Peer")) { + return ""; + } + } + String text = String.valueOf(value).trim(); + if (text.contains("TLRPC$") || text.contains("org.telegram.tgnet.")) { + return ""; + } + return text; + } + + private static String extractSenderName(Object messageObject) { + try { + String fromName = safeText(XposedHelpers.callMethod(messageObject, "getFromName")); + if (!TextUtils.isEmpty(fromName)) { + return fromName; + } + } catch (Throwable ignored) { + } + return ""; + } + + private static synchronized boolean rememberMessage(long dialogId, int messageId) { + if (messageId <= 0) { + return true; + } + String key = dialogId + ":" + messageId; + if (RECENT_SET.contains(key)) { + return false; + } + RECENT_SET.add(key); + RECENT_KEYS.addLast(key); + while (RECENT_KEYS.size() > DEDUP_SIZE) { + String oldest = RECENT_KEYS.removeFirst(); + RECENT_SET.remove(oldest); + } + return true; + } + + private static Context getContext() { + try { + Class activityThread = XposedHelpers.findClass("android.app.ActivityThread", null); + Object app = XposedHelpers.callStaticMethod(activityThread, "currentApplication"); + if (app instanceof Context) { + return (Context) app; + } + } catch (Throwable ignored) { + } + return null; + } +} diff --git a/xposed-module/src/main/java/com/miraclegarden/smsmessage/xposed/hook/WeChatMessageHook.java b/xposed-module/src/main/java/com/miraclegarden/smsmessage/xposed/hook/WeChatMessageHook.java new file mode 100644 index 0000000..70871fd --- /dev/null +++ b/xposed-module/src/main/java/com/miraclegarden/smsmessage/xposed/hook/WeChatMessageHook.java @@ -0,0 +1,98 @@ +package com.miraclegarden.smsmessage.xposed.hook; + +import android.content.ContentValues; +import android.content.Context; +import android.text.TextUtils; + +import com.miraclegarden.smsmessage.xposed.HookBridge; +import com.miraclegarden.smsmessage.xposed.HookForwarder; + +import de.robv.android.xposed.XC_MethodHook; +import de.robv.android.xposed.XposedBridge; +import de.robv.android.xposed.XposedHelpers; +import de.robv.android.xposed.callbacks.XC_LoadPackage; + +public final class WeChatMessageHook { + + private static final String TAG = "notiMessageHook/WeChat"; + private static final String WCDB_CLASS = "com.tencent.wcdb.database.SQLiteDatabase"; + + private WeChatMessageHook() { + } + + public static void install(XC_LoadPackage.LoadPackageParam lpparam) { + try { + hookWcdbInsert(lpparam, "insert"); + hookWcdbInsert(lpparam, "insertWithOnConflict"); + XposedBridge.log(TAG + " installed for " + lpparam.packageName); + } catch (Throwable t) { + XposedBridge.log(TAG + " install failed: " + t.getMessage()); + } + } + + private static void hookWcdbInsert(XC_LoadPackage.LoadPackageParam lpparam, String methodName) { + XposedHelpers.findAndHookMethod( + WCDB_CLASS, + lpparam.classLoader, + methodName, + String.class, + String.class, + ContentValues.class, + new XC_MethodHook() { + @Override + protected void afterHookedMethod(MethodHookParam param) { + handleWeChatInsert(param); + } + } + ); + } + + private static void handleWeChatInsert(XC_MethodHook.MethodHookParam param) { + if (param.args == null || param.args.length < 3) { + return; + } + + Object tableArg = param.args[0]; + Object valuesArg = param.args[2]; + if (!(tableArg instanceof String) || !(valuesArg instanceof ContentValues)) { + return; + } + + String table = (String) tableArg; + if (!"message".equalsIgnoreCase(table)) { + return; + } + + ContentValues values = (ContentValues) valuesArg; + String talker = values.getAsString("talker"); + String content = values.getAsString("content"); + Integer type = values.getAsInteger("type"); + + if (TextUtils.isEmpty(content)) { + return; + } + if (type != null && (type == 10000 || type == 10002)) { + return; + } + + Context context = getContext(); + if (context == null) { + return; + } + + HookForwarder.forward(context, "com.tencent.mm", talker, content, + HookBridge.SOURCE_XPOSED_WECHAT); + } + + private static Context getContext() { + try { + Class activityThread = XposedHelpers.findClass("android.app.ActivityThread", null); + Object app = XposedHelpers.callStaticMethod(activityThread, "currentApplication"); + if (app instanceof Context) { + return (Context) app; + } + } catch (Throwable ignored) { + } + return null; + } +} diff --git a/xposed-module/src/main/res/values/arrays.xml b/xposed-module/src/main/res/values/arrays.xml new file mode 100644 index 0000000..da36e0b --- /dev/null +++ b/xposed-module/src/main/res/values/arrays.xml @@ -0,0 +1,9 @@ + + + + org.telegram.messenger + org.telegram.messenger.web + com.tencent.mm + com.google.android.gm + + diff --git a/xposed-module/src/main/res/values/strings.xml b/xposed-module/src/main/res/values/strings.xml new file mode 100644 index 0000000..8b87d8e --- /dev/null +++ b/xposed-module/src/main/res/values/strings.xml @@ -0,0 +1,5 @@ + + + notiMessage Hook + Hook 目标 App 消息,前台也能同步到 notiMessage。需在 LSPosed 中勾选目标 App 并启用本模块。 +