feat: Telegram 双通道抓取、PC 调试台与 Hook 后台转发修复

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
This commit is contained in:
2026-07-02 16:50:12 +08:00
parent 7c80b7073a
commit 125dfe583b
33 changed files with 2164 additions and 88 deletions

View File

@@ -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<T> # 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

View File

@@ -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<ActivityNotifica
@Override
public void handleMessage(@NonNull Message msg) {
super.handleMessage(msg);
String str = (String) msg.obj;
if (str != null && binding != null && binding.tvLog != null) {
SimpleDateFormat format = new SimpleDateFormat("HH:mm:ss");
String t = format.format(new Date());
binding.tvLog.append(t + " " + str + "\n");
trimLogIfNeeded();
binding.scrollView.post(() ->
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<ActivityNotifica
getWindow().addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
initView();
restoreLogFromStore();
}
@Override
protected void onResume() {
super.onResume();
restoreLogFromStore();
updateUI();
handler.post(statsUpdateRunnable);
}
@@ -99,9 +110,18 @@ public class NotificationActivity extends MiracleGardenActivity<ActivityNotifica
}
private void initView() {
String username = tokenManager.getUsername();
if (username != null) {
binding.tvUsername.setText(username);
if (AppConfig.ENABLE_SERVER_UPLOAD) {
String username = tokenManager.getUsername();
if (username != null) {
binding.tvUsername.setText(username);
}
} else {
binding.tvUsername.setVisibility(android.view.View.GONE);
}
if (!AppConfig.ENABLE_SERVER_UPLOAD) {
binding.tvUploadedCount.setVisibility(android.view.View.GONE);
binding.tvSuccessRate.setVisibility(android.view.View.GONE);
}
binding.ivBack.setOnClickListener(v -> finish());
@@ -111,6 +131,7 @@ public class NotificationActivity extends MiracleGardenActivity<ActivityNotifica
});
binding.btnClearLog.setOnClickListener(v -> {
MessageLogStore.clear(this);
binding.tvLog.setText("");
});
@@ -173,14 +194,16 @@ public class NotificationActivity extends MiracleGardenActivity<ActivityNotifica
int configuredCount = App.getNotiList(this).size();
binding.tvConfiguredApps.setText("已配置: " + configuredCount + "");
binding.tvMonitoredCount.setText("监听: " + totalCount);
binding.tvUploadedCount.setText("上传: " + uploadedCount);
binding.tvMonitoredCount.setText("已抓取: " + totalCount);
if (totalCount > 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("成功率: --");
}
}
}

View File

@@ -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",

View File

@@ -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://&lt;电脑局域网IP&gt;:8765
*/
public static final String DEBUG_SERVER_URL = "http://127.0.0.1:8765";
private AppConfig() {
}
}

View File

@@ -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<String> 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<String> 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<String> decode(String raw) {
List<String> 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<String> 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();
}
}

View File

@@ -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 "未分类";
}
}

View File

@@ -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();
}
}
}

View File

@@ -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();
}

View File

@@ -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));
}
}
}

View File

@@ -178,6 +178,11 @@ public class RetryManager {
return failedCount;
}
/** 本地监听模式下仅累计抓取条数,不上传服务器 */
public void recordLocalCapture() {
incrementTotalCount();
}
private void incrementTotalCount() {
totalCount++;
sharedPreferences.edit()

View File

@@ -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" />

311
debug-server/server.py Normal file
View File

@@ -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 = """<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>notiMessage 调试台</title>
<style>
* { box-sizing: border-box; }
body { font-family: ui-monospace, Consolas, monospace; margin: 0; background: #0f1115; color: #e6edf3; height: 100vh; display: flex; flex-direction: column; }
header { padding: 14px 20px; background: #161b22; border-bottom: 1px solid #30363d; display: flex; gap: 14px; align-items: center; flex-wrap: wrap; flex-shrink: 0; }
h1 { margin: 0; font-size: 18px; }
.stat { color: #8b949e; font-size: 13px; }
button { background: #238636; color: #fff; border: 0; padding: 8px 14px; border-radius: 6px; cursor: pointer; font-family: inherit; font-size: 13px; }
button.secondary { background: #21262d; border: 1px solid #30363d; }
.layout { display: flex; flex: 1; min-height: 0; }
.sidebar { width: 280px; border-right: 1px solid #30363d; background: #161b22; overflow-y: auto; flex-shrink: 0; }
.sidebar h2 { margin: 0; padding: 14px 16px 8px; font-size: 13px; color: #8b949e; font-weight: 600; }
.group-item { padding: 12px 16px; border-bottom: 1px solid #21262d; cursor: pointer; }
.group-item:hover { background: #1c2128; }
.group-item.active { background: #1f2937; border-left: 3px solid #58a6ff; padding-left: 13px; }
.group-name { color: #e6edf3; font-size: 13px; word-break: break-word; }
.group-meta { color: #8b949e; font-size: 11px; margin-top: 4px; }
.content-panel { flex: 1; overflow-y: auto; padding: 16px 20px; }
.panel-title { font-size: 16px; color: #ffa657; margin: 0 0 12px; word-break: break-word; }
.msg { padding: 12px 14px; margin-bottom: 10px; background: #161b22; border: 1px solid #21262d; border-radius: 8px; }
.msg-head { display: flex; gap: 10px; flex-wrap: wrap; margin-bottom: 6px; font-size: 11px; color: #8b949e; }
.msg-source { color: #58a6ff; }
.msg-sender { color: #d2a8ff; }
.msg-body { color: #e6edf3; font-size: 13px; line-height: 1.5; word-break: break-word; white-space: pre-wrap; }
.empty { padding: 40px; text-align: center; color: #8b949e; }
</style>
</head>
<body>
<header>
<h1>notiMessage 调试台</h1>
<span class="stat" id="count">0 条</span>
<span class="stat" id="groupCount">0 群</span>
<span class="stat" id="updated">-</span>
<button onclick="loadMessages()">刷新</button>
<button class="secondary" onclick="clearMessages()">清空</button>
</header>
<div class="layout">
<aside class="sidebar">
<h2>群 / 会话</h2>
<div id="groups"></div>
</aside>
<main class="content-panel">
<h2 class="panel-title" id="panelTitle">请选择左侧群聊</h2>
<div id="messages"></div>
<div class="empty" id="empty" style="display:none">暂无消息,请在手机上开启监听并收一条 Telegram 消息</div>
</main>
</div>
<script>
let groups = [];
let activeGroup = null;
function groupKey(g) { return g.key; }
async function loadMessages() {
const res = await fetch('/api/groups');
groups = await res.json();
document.getElementById('count').textContent =
groups.reduce((n, g) => n + g.count, 0) + '';
document.getElementById('groupCount').textContent = groups.length + '';
document.getElementById('updated').textContent = '更新: ' + new Date().toLocaleTimeString();
const empty = document.getElementById('empty');
const groupsEl = document.getElementById('groups');
groupsEl.innerHTML = '';
if (!groups.length) {
empty.style.display = 'block';
document.getElementById('messages').innerHTML = '';
document.getElementById('panelTitle').textContent = '请选择左侧群聊';
activeGroup = null;
return;
}
empty.style.display = 'none';
if (!activeGroup || !groups.find(g => groupKey(g) === activeGroup)) {
activeGroup = groupKey(groups[0]);
}
for (const g of groups) {
const div = document.createElement('div');
div.className = 'group-item' + (groupKey(g) === activeGroup ? ' active' : '');
div.onclick = () => { activeGroup = groupKey(g); loadMessages(); };
div.innerHTML = `
<div class="group-name">${esc(g.key)}</div>
<div class="group-meta">${esc(g.appName || '')} · ${g.count} 条 · ${esc(g.latestAt || '')}</div>`;
groupsEl.appendChild(div);
}
renderActiveGroup();
}
function renderActiveGroup() {
const g = groups.find(x => groupKey(x) === activeGroup);
const panel = document.getElementById('messages');
if (!g) {
panel.innerHTML = '';
return;
}
document.getElementById('panelTitle').textContent = g.key + '' + g.count + ' 条)';
panel.innerHTML = '';
for (const m of g.messages) {
const div = document.createElement('div');
div.className = 'msg';
const sender = (m.title && m.title !== g.key) ? m.title : '';
div.innerHTML = `
<div class="msg-head">
<span>${esc(m.receivedAt || '')}</span>
<span class="msg-source">${esc(m.source || '')}</span>
${sender ? `<span class="msg-sender">${esc(sender)}</span>` : ''}
</div>
<div class="msg-body">${esc(m.content || '')}</div>`;
panel.appendChild(div);
}
}
async function clearMessages() {
await fetch('/api/messages', { method: 'DELETE' });
activeGroup = null;
loadMessages();
}
function esc(s) {
return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
}
loadMessages();
setInterval(loadMessages, 2000);
</script>
</body>
</html>"""
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://<PC局域网IP>:8765")
try:
server.serve_forever()
except KeyboardInterrupt:
print("\nstopped")
if __name__ == "__main__":
main()

99
docs/CHANGELOG.md Normal file
View File

@@ -0,0 +1,99 @@
# 更新说明
## v2.2.12026-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 模块无需重装。

290
docs/HOOK_GUIDE.md Normal file
View File

@@ -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<MessageObject>` 取新消息
- 只处理 **非 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
├─ 消息以明文写入 SQLitecontent/text/body 等)?
│ └─ 是 → 可复用 SqliteMessageHook默认兜底
├─ 微信?
│ └─ 是 → 已有 WeChatMessageHookHook 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
<item>com.example.chat</item>
```
#### 步骤 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 # 一键完整部署
```

View File

@@ -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
}

View File

@@ -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)

View File

@@ -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."

View File

@@ -0,0 +1,5 @@
# 在 Android Studio 中安装完整版(主 App + Xposed 模块)
# 用法: Run 选 app 只会装主包;完整版请运行此脚本或 install-full.ps1
$ErrorActionPreference = "Stop"
& "$PSScriptRoot\install-full.ps1"

72
scripts/install-full.ps1 Normal file
View File

@@ -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"

View File

@@ -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
}

View File

@@ -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'

View File

@@ -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'
}

1
xposed-module/proguard-rules.pro vendored Normal file
View File

@@ -0,0 +1 @@
-keep class com.miraclegarden.smsmessage.xposed.** { *; }

View File

@@ -0,0 +1,22 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application
android:allowBackup="false"
android:label="@string/module_name">
<meta-data
android:name="xposedmodule"
android:value="true" />
<meta-data
android:name="xposeddescription"
android:value="@string/xposed_description" />
<meta-data
android:name="xposedminversion"
android:value="82" />
<meta-data
android:name="xposedscope"
android:resource="@array/xposed_scope" />
</application>
</manifest>

View File

@@ -0,0 +1 @@
com.miraclegarden.smsmessage.xposed.MainHook

View File

@@ -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() {
}
}

View File

@@ -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());
}
}
}

View File

@@ -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);
}
}

View File

@@ -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;
}
}

View File

@@ -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<String> RECENT_KEYS = new ArrayDeque<>();
private static final HashSet<String> 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;
}
}

View File

@@ -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;
}
}

View File

@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string-array name="xposed_scope">
<item>org.telegram.messenger</item>
<item>org.telegram.messenger.web</item>
<item>com.tencent.mm</item>
<item>com.google.android.gm</item>
</string-array>
</resources>

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="module_name">notiMessage Hook</string>
<string name="xposed_description">Hook 目标 App 消息,前台也能同步到 notiMessage。需在 LSPosed 中勾选目标 App 并启用本模块。</string>
</resources>