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:
45
AGENTS.md
45
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<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
|
||||
|
||||
@@ -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("成功率: --");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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() {
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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 "未分类";
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -178,6 +178,11 @@ public class RetryManager {
|
||||
return failedCount;
|
||||
}
|
||||
|
||||
/** 本地监听模式下仅累计抓取条数,不上传服务器 */
|
||||
public void recordLocalCapture() {
|
||||
incrementTotalCount();
|
||||
}
|
||||
|
||||
private void incrementTotalCount() {
|
||||
totalCount++;
|
||||
sharedPreferences.edit()
|
||||
|
||||
@@ -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
311
debug-server/server.py
Normal 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,'&').replace(/</g,'<').replace(/>/g,'>');
|
||||
}
|
||||
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
99
docs/CHANGELOG.md
Normal file
@@ -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 模块无需重装。
|
||||
290
docs/HOOK_GUIDE.md
Normal file
290
docs/HOOK_GUIDE.md
Normal 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
|
||||
│
|
||||
├─ 消息以明文写入 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
|
||||
<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 # 一键完整部署
|
||||
```
|
||||
@@ -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
|
||||
}
|
||||
|
||||
31
scripts/configure-lsposed.py
Normal file
31
scripts/configure-lsposed.py
Normal 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)
|
||||
@@ -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."
|
||||
|
||||
5
scripts/install-from-studio.ps1
Normal file
5
scripts/install-from-studio.ps1
Normal 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
72
scripts/install-full.ps1
Normal 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"
|
||||
18
scripts/start-debug-server.ps1
Normal file
18
scripts/start-debug-server.ps1
Normal 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
|
||||
}
|
||||
@@ -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'
|
||||
|
||||
37
xposed-module/build.gradle
Normal file
37
xposed-module/build.gradle
Normal 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
1
xposed-module/proguard-rules.pro
vendored
Normal file
@@ -0,0 +1 @@
|
||||
-keep class com.miraclegarden.smsmessage.xposed.** { *; }
|
||||
22
xposed-module/src/main/AndroidManifest.xml
Normal file
22
xposed-module/src/main/AndroidManifest.xml
Normal 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>
|
||||
1
xposed-module/src/main/assets/xposed_init
Normal file
1
xposed-module/src/main/assets/xposed_init
Normal file
@@ -0,0 +1 @@
|
||||
com.miraclegarden.smsmessage.xposed.MainHook
|
||||
@@ -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() {
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
9
xposed-module/src/main/res/values/arrays.xml
Normal file
9
xposed-module/src/main/res/values/arrays.xml
Normal 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>
|
||||
5
xposed-module/src/main/res/values/strings.xml
Normal file
5
xposed-module/src/main/res/values/strings.xml
Normal 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>
|
||||
Reference in New Issue
Block a user